Scala 30:Java互操作的类型与运行边界
能读取Java签名还不等于程序能运行
Scala 与 Java 都可以生成 JVM 类文件,但互操作不能只用“能导入一个 Java 类”验收。编译器可能读到源文件中的签名,运行时却找不到对应 class;集合转换可能改变可见接口,却仍共享底层可变对象;Java 返回 null 时,默认 Scala 引用类型也不自动得到非空保证。
本章把这些问题分开实验。独立工程先用 javac 编译 Java 库,再编译调用该库的 Scala 对象,然后用 javac 编译真正的 Java 客户端,最后直接执行 java。普通章节入口另外验证集合视图、快照、单抽象方法接口和空值行为。
冻结环境为 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。带显式空值检查的样例单独使用 -Yexplicit-nulls,没有把这个选项注入整套默认构建。这样才能比较默认模式与显式空值模式各自接受什么,而不是把两套规则混成一个描述。
双向调用需要真正的两种编译产物
Java 库提供一个静态乘三方法,Scala 对象提供乘二与调用 Java 的方法,Java 客户端再调用这两个 Scala 入口:
1 | |
实际运行断言 ScalaApi.doubled(4) == 8、ScalaApi.callJava(4) == 12。第二个结果经过 Java 客户端、Scala 方法、Java 库三个位置,能验证编译和运行类路径中都存在必要产物。
脚本检查了 JavaBridge.class、JavaClient.class、ScalaApi.class 的存在,并保存 SHA-256 清单。检查文件存在只能说明产物生成;最后的 Java 客户端执行才能证明当前组合确实能够加载和调用。两种检查联合起来,避免把只通过类型检查的样例写成运行成功。
本次使用 Scala CLI 的无服务器模式。实验中没有依赖它替混合输入生成所有 Java class,而是显式调用 javac。这条路线使编译责任明确,也方便排查缺类问题。它不是说所有 Scala 构建工具都必须手工分成四条命令;sbt 等工具有自己的混合编译支持,需要按各自配置验证。
编译顺序也与依赖方向有关。本例 Java 库不依赖 Scala,Scala 库依赖 Java,Java 客户端依赖两者,所以可以形成顺序。若源文件之间有相互引用环,就需要支持该输入形状的混合编译流程,不能机械照抄这个单向示例。
源级object与Java看到的静态接口
Scala 的 object 表示单例定义。Java 调用者看到什么,要以生成的类文件为准。本次 javap -p -s 输出显示 ScalaApi 的 doubled 和 callJava 是可直接从 Java 使用的静态转发入口,描述符均为 (I)I。
这证明当前顶层对象与当前编译配置产生了预期接口,但不意味着所有嵌套对象、重载、扩展方法或复杂 Scala 签名都同样适合作为 Java API。公共边界设计最好先写一个真实 Java 客户端,让它表达所需调用,再检查生成产物。
如果接口大量暴露 Scala 特有集合、上下文参数、路径依赖类型或高阶类型,Java 端可能需要更多适配。类型在 JVM 上有表示,不代表 Java 源语言能方便地表达全部关系。实际项目可以把丰富的 Scala 内部模型留在实现层,在互操作边界提供简单稳定的方法。
静态转发也不等于实现没有对象初始化。Java 可调用的外观与 Scala 单例的实现关系需要分别理解。若单例初始化包含副作用,调用入口仍可能触发相关行为。本文实验的对象只含纯数值方法,没有研究初始化失败或并发初始化过程,因此不扩展到那些结论。
前一篇的字节码工具在这里有具体用途:确认方法是否静态、参数描述符是什么、异常声明和桥接成员有哪些。比起依赖 IDE 自动补全是否显示某个名字,真实 javac 与 javap 能给出更直接的接口证据。
泛型签名能传递多少类型约束
Java 的泛型集合可以被 Scala 静态类型系统读取。java.util.ArrayList[String] 的元素类型会影响 Scala 调用的类型检查,但 JVM 擦除仍然存在;这并不新增对所有历史元素来源的运行时验证。
如果 Java 代码使用 raw type 或未经检查的转换插入错误对象,Scala 端并不会因为声明写了 String 就自动清洗容器。读取时可能在转换或方法调用处失败。需要信任边界校验的输入,应根据来源检查内容,而不是只依赖一个已经被上游绕过的类型声明。
反过来,泛型不变性、通配符和 Scala 型变之间也不能直接按符号替换。某个 Java 参数接收 ? extends T,表达的是受限使用关系;Scala 的 List[+A] 则是声明处型变。设计适配函数时应写出它读什么、写什么,再决定允许的类型关系。
本章没有逐项实现通配符转换算法,因此把这部分作为接口设计的检查方法,而不声称所有复杂签名都已覆盖。实际双向测试只使用整数参数和返回值,使运行类路径问题与复杂泛型问题不互相遮蔽。
集合转换可能只建立共享视图
普通入口使用 scala.jdk.CollectionConverters.*:
1 | |
两边的修改都能被另一边观察到,说明这次转换没有复制出互相独立的元素容器。它提供 Scala 风格的可变集合接口,操作仍作用于共享底层数据。变量名里写成 copy 并不会改变这种关系。
之后程序执行 val snapshot = scalaView.toList,再清空 Java 列表。视图为空,但快照仍包含三个字符串。这组对照把“转换接口”与“物化当前内容”区分开来。若需要隔离后续结构修改,应明确执行相应复制,而不是默认 asScala 已经完成。
快照也不自动深拷贝元素。如果列表装的是可变订单对象,toList 复制集合结构之后仍可能保存相同对象引用。元素字段的后续变化可以同时被两侧观察到。业务需要深度隔离时,应明确复制哪些字段及嵌套对象,不能仅凭不可变外层容器判断。
这与第 06 篇的型变边界相通:类型参数允许协变,不代表对象图深不可变;转换后的集合拥有 Scala API,也不代表底层所有权转移。互操作函数最好说明返回的是共享视图、结构快照还是深度值副本。
视图共享还会影响遍历与线程安全
在 Java 集合上建立 Scala 视图,并没有让原本非线程安全的集合获得同步能力。两种语言中的代码若同时修改同一个底层对象,仍需要遵守该对象的并发契约。包装层改变调用形式,不会自动提供锁或原子事务。
遍历期间修改集合也取决于底层实现。本章没有测试所有 Java 集合的迭代器行为,因此不把某个 ArrayList 观察推广到并发集合。采用转换前应先确认原始类型的约束,再确定 Scala 侧的调用方式是否符合它。
所有权文档可以很具体。例如“调用期间不允许修改输入”“方法复制元素序列,但不复制元素对象”“返回值与传入列表共享修改”都能让调用者判断后续操作。笼统写成“返回 Scala 集合”不足以表达这些条件。
若方法只需要遍历一次,也不必为了使用 Scala 语法而无条件复制全部元素。共享只读访问有时足够,但必须明确调用期间上游是否可能改变数据。复制成本与一致性需求应一起考虑;本文没有测量性能,选择依据首先是语义正确。
SAM转换把函数体放进Java接口契约
Java 的 IntUnaryOperator 有一个需要实现的抽象操作。Scala 可以用函数字面量提供实现:
1 | |
静态目标类型说明这里需要 Java 接口实例。调用通过 applyAsInt 进行,正例实际得到四十二。它不是先随意创建某种 Scala 函数对象,再保证对任意 Java 接口都能隐式转换;目标接口的形状与编译器支持共同决定适配是否成立。
这类边界仍要考虑异常与副作用。函数体可以抛异常、捕获可变状态或执行阻塞操作,SAM 转换不会改变这些行为。Java 调用者何时、在哪个线程调用它,取决于接收接口的 API。传入执行器的任务和同步调用的运算器不能因为都写成箭头函数就被视为同一种执行时机。
选择原始整数专用接口也具有表示意义。IntUnaryOperator 的方法使用整数参数与结果,和通用对象接口不同。但这只说明接口形状,不能直接得出整段业务没有装箱或分配的性能结论。上一章要求检查具体调用点,这里同样适用。
设计 Java 可调用的 Scala API 时,显式采用合适的 Java 函数接口常能减少调用端适配。是否这样做取决于主要使用者和兼容要求,不必把所有内部高阶函数都换成 Java 接口。边界适配保持集中,通常更容易测试。
默认空值模式保留Java的风险
Java HashMap.get 在缺失键时返回 null。本章默认构建允许把这个返回值赋给 String,随后直接调用 length,实际产生 NullPointerException。静态名字看起来是字符串,并没有消除来自 Java 的空值可能性。
同一个值传给 Option(result) 得到 None。这是一种明确的边界翻译:把 Java 的缺失表示转换成 Scala 的可选值,再让内部代码通过模式或组合处理缺失。它并不把所有异常转换成 None;如果调用 Java 方法本身抛出了异常,需要另外决定处理方式。
Some(result) 与 Option(result) 的含义也不同。若要把可空返回规范化成可选值,应使用具有该语义的构造,而不是仅因为 Some 看起来更具体就替换。缺失、失败与合法空字符串同样不能混成一种状态。
API 设计还应区分“值未找到”与“调用失败”。查询无结果可以对应 None,连接中断通常更适合显式错误通道。把所有问题都吞成空值,会让调用者无法选择重试、报告或继续处理。互操作层的作用之一就是把外部约定翻译为内部能够区分的模型。
显式空值检查改变静态规则
隔离反例在 -Yexplicit-nulls 下定义一个 String | Null 参数并直接访问 length,编译器拒绝。正例先判断 value == null,空值返回零,非空分支再计算长度,并断言 null 与三个字符输入的结果。
这里的机制是把空值可能性纳入类型,再要求使用前提供足够的排除信息。它能让许多遗漏在编译期暴露,但不能简单等同于“启用之后绝不可能发生空指针”。不安全转换、初始化顺序、Java 边界以及其他逃逸方式仍需按冻结版本规则审查。
Scala 3.3.7 的显式空值文档还单独讨论初始化与 Java 签名处理。安全初始化检查与空值类型检查有交集,但不是同一个开关。第 05 篇使用 -Ysafe-init 观察初始化问题;不能因为本章启用了显式空值就省略初始化分析。
默认模式与显式模式的库接口、源兼容和迁移成本也不同。本章把选项限制在独立文件和反例目录,避免读者复制一个命令就改变所有前文样例的规则。项目迁移时应先选择边界模块,检查 Java API 适配,再扩大范围。
.nn 一类断言式操作应被视为需要前提的边界,而不是消除诊断的通用修复。若前提错误,运行时仍可能失败。相比在每个调用点强行排除 null,把可空输入集中翻译为 Option 或领域错误更容易审查,也更容易写出有意义的测试。
编译失败与运行失败要分别定位
如果 javac 报找不到 Scala 方法,先检查生成接口和编译类路径;如果 java 报缺类,检查运行类路径与实际 class 是否存在;如果得到空指针,检查可空返回和初始化;如果集合结果变化,检查共享别名与修改时机。错误位置不同,修复对象也不同。
本章脚本把每个阶段分开保存,能直接定位是 Java 库编译、Scala 库编译、Java 客户端编译还是最终执行失败。把所有步骤塞进一个未保留中间结果的命令,往往只能得到“互操作不工作”,而无法知道缺少哪一种证据。
双向调用通过也只覆盖测试过的接口。增加重载、默认参数、可变参数或特定异常约定时,应扩充 Java 客户端。特别是面向 Java 发布的库,Scala 测试全绿不能代替 Java 源码编译测试,因为两种源语言可表达的调用形式不同。
迁移已有接口时可以保留旧 Java 客户端源码做回归,再保留旧二进制客户端做兼容检查。前者验证重新编译可用,后者验证替换库后的链接行为。本文只完成新客户端编译与执行,没有声称对历史二进制做了兼容承诺。
把互操作边界写成可审查的契约
一个跨语言方法至少有四类条件:调用形状、值域、所有权和失败方式。调用形状由参数、返回值与静态成员等决定;值域说明空值和非法数据是否可能出现;所有权说明集合或资源由谁修改、关闭;失败方式则说明缺失、异常与错误结果如何传递。只记录方法签名,通常覆盖不了后面三类条件。
例如一个返回订单列表的 Java 方法,Scala 可以读懂 List<Order>,但签名未必告诉调用者列表是否共享、元素是否可变、null 是否表示查询失败,以及列表是否在后台继续填充。适配层需要从原 API 契约取得这些信息,再决定提供共享视图、物化快照还是明确错误。
本章的 ArrayList 对照可以作为这类测试的起点。先在转换后修改原集合,再修改视图,最后物化并再次修改原集合,每一步都观察两个引用。若需要验证元素隔离,再对元素字段做一次修改。测试动作应分别触及结构和元素,否则很容易把只验证了前者的结果写成深复制。
资源型 Java 对象还多出生命周期问题。把它包装成 Scala 类或传入函数,并不会转移关闭责任;返回迭代器时,实际读取也可能晚于方法返回。第 33 篇会通过真实文件与异步门闩重现这些时序。本章先明确:类型转换本身不管理资源,所有权应有单独约定。
从现有Java接口逐步增加Scala实现
一个较容易验收的迁移方式是保留简单 Java 边界,先替换内部实现,再用原 Java 客户端回归。这样可以在引入 Scala 表达方式的同时,保持调用者看到的契约可观察。内部是否采用模式匹配或不可变数据,不必同时改变每个外部入口。
迁移之前应记录正常、缺失和非法输入的现有结果。如果旧接口对缺失返回 null,新实现直接改抛异常,即使编译完全兼容,也改变了调用语义。改善错误模型可以有价值,但应作为明确的接口变更处理,而不是把它混在语言转换里。
本章选择纯整数接口,是为了让期望结果不依赖时钟、网络或配置。实际服务接口可注入假依赖,在 Java 客户端中验证相同输出与失败路径。随后再增加真实集成测试,区分纯逻辑正确与外部服务接入正确。一次最小客户端成功不证明整个应用已经完成迁移,但它能建立一个清楚、可重复的二进制边界检查。
实验记录与练习
本章为 LAB_VERIFIED。普通场景与反例:
1 | |
普通证据在 examples/scala-lab/evidence/20261002-ch30/;独立模块记录在 20261002-ch30-module/,包含 javac-library、scalac-library、javac-client、java-client、javap-api、classes.json 与 explicit-nulls。所有成功场景退出零,隔离空值反例非零并匹配诊断。
手算题:asScala 后添加元素,Java 原列表是否变化?当前 ArrayList 转换会变化,因为共享底层对象。执行 toList 后清空原列表,快照结构是否清空?不会。若元素本身可变,其字段是否隔离?没有这个保证。
第二题:Java 客户端编译成功,但运行时找不到 JavaBridge,能否用添加 Scala 类型注解修复?不能,问题是运行产物或类路径缺失,应检查 class 文件与运行命令。
执行练习在 Java 库中增加返回可空字符串的方法,先用默认模式重现风险,再在独立显式空值入口把它翻译为领域结果。然后把 Java 列表元素换成可变对象,分别观察结构快照与元素字段变化。答案应报告具体输出、异常或编译拒绝,不能仅写“转换成功”。
参考
Scala 官方 Java 互操作章节说明语言边界;Scala 3.3.7 显式空值文档限定冻结版本规则;Scala 2.13.16 CollectionConverters API说明集合适配。前篇是编译管线与TASTy,后续异步章节继续检查接口背后的实际执行时间与线程。


