Kotlin 19:Java 返回值怎样越过空安全边界
Kotlin 的非空变量在本地代码里不允许直接赋 null,但 Java 方法返回一个没有空注解的 String,实际却可以返回 null。如果 Kotlin 直接对这个返回值取 .length,编译器会放行,运行时仍可能抛 NullPointerException。已有的 05 空安全实验处理的是 Kotlin 自己声明的可空字段;本篇把同一任务系统接上一小段 Java 输入适配器,记录 Java 返回值越界后的实际行为,以及在哪里将它变回明确的 Kotlin 类型。
本章沿用 Kotlin Gradle Plugin/stdlib 2.2.21、Gradle Wrapper 8.14 和 JDK 21;未新增三方依赖。规则出处是 Kotlin 官方 Java 空安全与 platform type、SAM 转换。具体值、异常和调用时序以本仓库 19 的 Kotlin/Java 混合编译及测试日志为准。Java 类的空注解、线程调度或真实网络在本篇都不存在。
Java 前置:类、接口、擦除和 checked exception
Java 的 class 承载字段、构造器、实例方法和 static 成员。interface 定义调用方可依赖的方法形状,类用 implements 承诺实现它;接口还能有 default 方法,但默认方法也只是类文件中的一个可继承入口,不会替实现类保存状态或启动线程。最小形状如下:
1 | |
这段代码在本机 JDK 21 下用 javac --release 21 编译通过,运行输出 task:42;同一个样本还调用 List.of("queued") 并读取首项长度,输出 6。不过 Java 泛型在运行时主要保留原始类型,List<String> 的元素约束不能靠运行时类对象完整恢复;Kotlin 从 Java 接收 List<String> 时仍要把元素当边界值检查。Java 的 checked exception 是源码规则:调用 render 这样声明 throws IOException 的方法时,Java 调用方必须 catch,或在自己的方法上继续 throws。本次负例省略 catch/throws,JDK 21 javac 直接拒绝,诊断为“必须对其进行捕获或声明以便抛出”。原始命令与输出保存在 writing-plans/kotlin/completion-evidence/java-bridge/javac-jdk21.txt。
Java 的签名为何没有挡住 null
新增的 JavaInputPort 只有三个同步静态方法:无空标注的任务文本、标签列表和一个调用 Consumer<String> 的回调。
1 | |
在 Java 里 String 泛型签名本身没有声明 null 的约束,List<String> 也没有排除当前实参中的 null。javap 对这次编译得到 java.lang.String taskText(boolean) 和 java.util.List<java.lang.String> labels(boolean),没有空值注解。Kotlin 官方用 String! 这样的文档记号表示平台类型的弹性;这不是能在 Kotlin 源码里直接声明的 String! 类型。将这种返回值用作非空或可空是调用方作出的选择,编译成功不能证明 Java 实现永不返回 null。
实际负例没有靠推测:JavaInputPort.taskText(true).length 编译通过,在 JUnit 中抛 NullPointerException。测试又将 JavaInputPort.labels(true) 接收成 List<String>,访问第二项的 .length 也抛 NullPointerException;Java 方法的第二项实际就是 null。异常在读值时出现,不意味着 Kotlin 为所有从 Java 流入的元素补齐了静态空安全。官方平台类型章节提醒的是这个边界弱化,并非让程序不经检查地到处使用 !!。
在进入领域模型前收紧输入
正向路径以 Kotlin 声明保留不确定性:val text: String? = JavaInputPort.taskText(absent);标签接收为 List<String?>?,不仅考虑元素可能为空,也不把未来 Java 实现的整个列表永远非空当成隐含保证。当前 Java 方法的列表本身不会返回 null;本次只实际注入了null 元素,整表为 null 的分支是边界防御,不记为已经测试通过。
1 | |
缺文本时返回 Result.success(Rejected("Java task text is null")),含 null 标签时返回另一条有原因的 Rejected;两次都不写入 Store。正常 taskText(false) 和不含 null 的列表形成 TaskDraft,按 java-task 与正文 index 调用 13 的提交决策,测试得到 Accepted,内存 Store 能查到文本 index。之后才能说本次输入通过了校验,而不是说“所有 Java 返回都安全”。这条路径先验证标签,但当前 Store 只存 (id, text),草稿的标签没有被保存;不得把合法草稿误报成标签已经持久化。
runCatching 把同步调用的异常包装成 Result,不是重试或事务。Rejected 是可预期的输入结果;如果 Store 将来抛异常,会落在另一层失败路径,是否落库未知。这里还没有协程,13 对取消宽捕获的提醒在以后挂起调用里仍然成立;不能把这个同步包装原样当取消处理规范。
SAM 转换有没有启动后台任务
Java 方法参数是 Consumer<String>,Kotlin 调用 JavaInputPort.dispatch("index") { text -> events.add("called:$text") }。dispatch 方法体立刻执行 consumer.accept(text);测试在方法返回后读到唯一事件 [called:index]。这是 JVM 上可验证的一次 SAM 转换和同步调用,没有 Dispatcher、Job、队列、线程池或网络应答。回调收到的字符串若换成来自 Java 的不可信 nullable 值,还需在调用边界重新说明空值契约;本章只传了非空字面值 index。
复跑与练习答案
bash examples/kotlin-language/verification/19.sh 在项目内执行离线 clean test(日志含 compileKotlin、compileJava)、00–19 二十组 JUnit XML 校验、Java 类 javap 和旧 CLI 正反回归;旧 CLI 不调用 JavaInputPort,因此正反输出只证明此前入口未受影响。原始环境/命令/退出码在 writing-plans/kotlin/verification/19/。下载含完整累计工程/Wrapper与证据的快照(SHA-256校验)可解包离线复跑。
练习:把 Java 的 List<String> 直接赋给 Kotlin List<String> 后再放进只读字段,能否排除 null 元素?不能,本次赋值能通过编译而第二项在运行时确实是 null;字段的只读接口不改变 Java 值。若把 JavaInputPort.dispatch 改成接受一次 Kotlin lambda,能否据此声明任务已经并发运行?不能,当前 Java 方法只直接调用一次 Consumer.accept,且测试没有创建或等待任何后台工作者。
[PATTERN] 看到未标注的 Java 值,先按边界不确定处理:接收为 Kotlin 可空类型,检查外层与必要的每个元素,再交给领域构造器;对回调则另外检查调用时机,不以 SAM 语法推断执行模型。空安全、领域校验和任务完成是三张不同的验收单。
| 边界 | 本篇已验证 | 本篇未证明 |
|---|---|---|
Java String / List<String> |
未标注返回中含 null,危险直接读取抛异常 | 任意 Java API 都安全或所有列表元素非空 |
submitJavaInput |
显式拒绝缺文本/含 null 标签,正常任务写入内存文本 | 标签已保存、数据库提交或事务恢复 |
Consumer<String> |
Kotlin lambda 经 Java 同步回调 | 已创建 Job、非阻塞或后台完成 |
下一篇从 Java 调用 Kotlin 的方向回看默认参数、异常和生成方法名称,不沿用这一篇的无注解契约去猜对外二进制 API。






