Java 调用 Kotlin 时默认参数去哪了 已经验证,默认参数并不会自动让 Java 省略实参;`@JvmOverloads` 才会另生成 Java 能直接调用的重载。库作者一旦放出这个重载,问题就从“现在能否编译”变成“旧调用方带着已有 class 文件升级库之后,还能否链接并保持结果不变”。同样的函数声明,在源码、JVM 符号和业务返回值三个层面有不同的兼容结果。

固定消费者,只替换库

隔离工程 examples/kotlin-language/abi-compat/ 使用 Kotlin/JVM 2.2.21、JDK 21 和 Gradle 8.14,通过 maven-publish 将三个示例版本发布到专用 /tmp Maven 仓库。1.0.0 的库对外提供:

1
2
3
4
5
object TaskLabels {
@JvmStatic
@JvmOverloads
fun render(id: String, suffix: String = "!"): String = id + suffix
}

Java 旧消费者直接写 TaskLabels.render("id"),Kotlin 旧消费者也省略第二个参数;两个消费者都只在依赖 1.0.0 的条件下编译。消费侧的 compileOnly 指向 1.0.0,而 runtimeOnly 逐次指向 1.0.0、1.1.0、2.0.0;编译输出没有因为切换运行库而重建。这样才能问旧二进制的实际行为,而不是每次先重编译再把“能编译”冒充“旧包能运行”。

三个产物的差别刻意缩小。1.1.0 仍保留同一个两参数方法、@JvmOverloads 生成的单参数方法和 Kotlin 默认参数入口,但把默认 "!" 改为 "?";2.0.0 则移除默认值及 @JvmOverloads,仅保留 render(String,String)。这个版本号只是隔离实验标签,不替任何真实库定义语义版本策略。

链接成功不等于结果未变

JUnit 以外的实际 JavaExec 输出如下,每个版本都让旧 Java 和旧 Kotlin 消费者分别调用一次:

运行库 旧 Java 消费者 旧 Kotlin 消费者 本次说明
1.0.0 id! id! 基线
1.1.0 id? id? 旧二进制可运行,但默认行为变了
2.0.0 NoSuchMethodError NoSuchMethodError 旧二进制无法正常调用

abi-signatures.txt 的 javap -p 表明:1.0.0 与 1.1.0 都有单参数 render(String) 以及 render$default(...);2.0.0 只剩双参数版本。因此 Java 旧调用端需要的单参数方法消失,Kotlin 编译过的默认参数调用入口也消失。不要建议 Java 用户手写 $default:那是这版 Kotlin 编译器生成的实现细节,不是良好的公开 API。错误是测试主动捕获并比对的 NoSuchMethodError,不是“2.0.0 跑通业务”。

换个角度,在 1.1.0 下重新编译原 Java/Kotlin 源码也成功;换到 2.0.0,Kotlin 编译器报告 No value passed for parameter 'suffix',独立 javac --release 21 报 render 需要两个 String 却只收到一个。两条负例各有原始非零退出码。1.1.0 对本实验是源码与二进制兼容、行为不兼容;2.0.0 的这两个旧调用同时不满足源码和二进制兼容。实验只覆盖这一个公共方法,不能由它推出整个模块的兼容程度。

发布物和工程边界

发布的是三个实际 JAR/POM 而非替换目录中的 .class 文件。每个版本的生成 JAR 都保存 SHA-256、javap 符号与本地 Maven 路径;版本切换由运行 classpath 控制。21 所说的 Gradle 内嵌 Kotlin 与应用 Kotlin 分属不同位置:实验固定用于编译库/消费者的 Kotlin 插件为 2.2.21,Gradle 内嵌 Kotlin 2.0.21 不会代替前者参与生成业务 ABI。

复现时进入下载包的 kotlin-language/abi-compat/,传入日志目录:

1
bash verify.sh /tmp/kotlin-e07-result

脚本使用主工程 Wrapper 的离线 Gradle 与全新 /tmp 私有 Maven 仓库,发布三个版本、运行旧消费者、重编译 1.1.0 源码、验证 2.0.0 两种语言的编译拒绝。需要 JDK 21 和已缓存 Kotlin 插件/stdlib;不要把本机绝对仓库地址当成公共依赖源。这次没有上传 Maven Central,没有用新消费者测试全部库 API、反射符号、序列化格式或下游编译器跨版本行为;这些范围均 NOT_RUN。

要让 2.0.0 的 Java 编译拒绝在不同系统语言下稳定验收,脚本应固定 LC_ALL=C 后再匹配英文诊断,或以 javac 非零退出码、目标源码文件和期望缺参调用作为判断条件。这样检查的是“旧 Java 源码不能再用单参数 render 编译”,而不是某一种本地化错误文本。日志目录也应在写入前创建,避免诊断文件路径本身造成额外失败。

练习与答案

练习一:1.1.0 让旧二进制继续运行但输出从 id! 变成 id?,它算兼容吗?答案:源码和二进制调用形状兼容,行为不兼容;三种兼容要分开记录。

练习二:2.0.0 重新编译旧源码失败,旧 class 运行也 NoSuchMethodError,问题是否相同?答案:不是。前者是源码兼容失败,后者是二进制链接失败。

练习三:能否建议 Java 消费者调用 render$default 避免重载删除?答案:不能。$default 是当前 Kotlin 编译器实现细节,不应成为公共 API。

参考资料