如何理解 Kotlin inline 函数在 JVM 与 klib 后端内联语义的差异?
【免费下载链接】kotlinThe Kotlin Programming Language.项目地址: https://gitcode.com/GitHub_Trending/ko/kotlin
当你维护一个包含inline函数的库,并且依赖库升级后该函数被改动、删除或不兼容地变更时,同一份下游代码在 JVM 和 klib 系后端(Native、JS 等)上的行为可能完全不同。kotlin 仓库的设计文档 Klib Inlining 正是针对这个问题:它给出了一个最小示例说明两个后端的行为差异,解释了差异来自编译流水线的哪个环节,并描述了 klib 后端计划向 JVM 语义靠拢的方案。理解这些内容能帮你在设计跨平台库的 API 升级时,提前判断inline函数改动会如何影响已发布的依赖方。
最小示例:依赖升级后两个后端各输出什么
文档用下面这个四模块示例说明分歧点:
// dependency-v1: inline fun depFun() = "lib.v1" // dependency-v2 inline fun depFun() = "lib.v2" // lib: depends on dependency-v1 fun libFun() = depFun() // main: depends on lib and dependency-v2 fun main() { println(libFun()) }模块关系按注释划分:lib编译时只依赖dependency-v1,而main同时依赖lib和dependency-v2。文档明确给出这个示例在两个后端上的预期输出:
- JVM 后端输出
lib.v1:内联函数的代码在lib编译时就已经内联进libFun的字节码,之后的结果不会随链接时实际可见的函数版本而改变。 - klib 系后端输出
lib.v2:内联函数调用在 klib 中按普通调用保存,内联发生在依赖解析(linking)之后,因此取到的是链接时可见的v2版本。
文档特别指出,当函数只是换实现时还能得到“错误的版本”,如果函数被删除或不兼容地变更,问题会更严重。这正是库开发者需要同时面对两套兼容模型的原因。
差异根源:内联发生在流水线的哪一步
JVM 后端:内联在代码生成阶段作用于字节码
JVM 流水线在 Plugins 阶段之前与 klib 后端完全一致:源码经 Frontend 转成 FIR,Fir2Ir转成 IR(外部依赖的 FIR 转成 LazyIr),IrActualizer合并模块,再经过 Plugins。差别在于之后不走 klib 序列化:IR 直接进入 Lowerings 和代码生成。文档描述 JVM 代码生成阶段包含 Class File generation、Metadata generation、Bytecode Inliner 和 Coroutines processing 几部分,并且:
As opposed to non-jvm backends, inlining here happens after all other lowerings as a part of code generation. Moreover, it doesn't happen over IR, it happens over bytecode.
也就是说,JVM 上的内联发生在所有 lowering 之后、针对字节码进行。文档同时提到存在把内联迁移到 IR 上的计划,但具体实现不在该文档范围内。
klib 后端:两次编译,内联传统上推迟到第二次运行
klib 系后端的编译分两次运行:
- 第一次运行:把源码编译成 Klib。源码与依赖 klib 的 metadata 经 Frontend 转成 FIR,
Fir2Ir把源模块的 FIR 转成 IR、把外部引用的 FIR 转成LazyIr,IrActualizer合并模块,Plugins 处理后由 Klib Serializer 序列化成 klib。 - 第二次运行:把所有 klib(含第一次运行的产物)反序列化后交给irLinker(反序列化 + Linker,负责把声明引用匹配到具体声明),随后依次经过 Pre-inline lowerings、Ir Inliner、后续 Lowerings 和 Code Gen,产出最终二进制。
文档对三个中间产物的定义值得留意:
- FIR:前端表示,每个声明可序列化/反序列化为 metadata;
- LazyIr:基于 FIR 的 IR 替身,只含声明、不含函数体,用于避免反序列化依赖中用不到的部分;
- IR:内联机制实际处理的中间表示。
关键在于:旧模型中内联发生在第二次运行、链接完成之后。这就是为什么 klib 系后端能“看到”dependency-v2的新实现,而 JVM 后端看到的仍是lib字节码里固化的v1。
为什么官方更倾向 JVM 模型
文档列出了四条理由:
- JVM 行为存在时间更长,更难改变;
- klib 模型在 JVM 上无法合理实现,而 JVM 模型可以在 klib 上实现;
- JVM 模型更符合直觉——人们把
inline函数当作高层宏来理解; - JVM 模型更简单,见 inline-to-crossinline 示例。
第四条背后的问题在于,内联函数比普通函数多出几种声明变更维度:类型参数可以在 reified 与非 reified 之间转换、lambda 参数可以在 inline/noinline/crossinline 之间变化、inline关键字本身可以增删。klib 兼容模型需要为这些组合逐一回答“链接时怎么办”;而 JVM 上链接时实际上已不存在内联函数(少数 corner case 除外),它们此时表现得和普通过程函数一样,无法再被改变,心智模型因此简单很多。例如该示例提出的问题:一个在 v1 中用l()直接调用、调用方依赖非局部返回的代码,遇到库在 v2 中把参数改成noinline,还能链接成功吗?这类问题在 JVM 模型下不会出现。
目标状态:klib 后端把内联提前到序列化之前
文档声明 “We are aiming to change the pipeline in the near future”,目标流水线相对旧模型的四个核心变化:
- 内联在 Klib 序列化之前发生——这是主要目标,让 klib 产物中的调用固化为已内联的代码,与 JVM 行为一致;
- Pre-inline lowerings 必须移到序列化之前,这给它们带来额外限制(见下节);
- Klib 序列化必须能处理部分未链接的 IR(部分引用仍是 unbound 的
IdSignature,链接被推迟到第二次运行); - IR 内联必须能处理内联函数体内部未链接的引用。前提是调用点处的 IR 总是已链接的——因此内联一个函数前,必须先把它内部所有内联函数的调用点内联掉。
文档用 step-by-step-basic 示例 演示了新模型下main模块编译时run { 42 }的处理过程:前端解析出调用、依赖lib的函数以 LazyIr(无函数体)形式加载,反序列化出run的函数体(此时体内引用是 unbound 符号,不能先跑 Linker),随后在调用点展开内联,最终foo的函数体里出现内联展开后的process调用,并用IrInlinedFunctionBlock节点承载调试信息。
Pre-inline lowerings:必须在序列化前完成、且结果会被固化
文档以 Native 为例列出需要在内联前完成的 lowering:
typeOfintrinsic 的处理(见 typeOf 示例);Array(size: Int, init: (Int) -> Int)构造器处理,这是语言中唯一允许的内联构造器;- lateinit 字段处理——
isInitializedintrinsic 需要访问私有字段,必须在类作用域内完成; - outer this 处理(见 outer-this 示例);
- Shared variable lowerings,它是 local declarations lowering 的前置条件;
- 局部声明的处理(涉及匿名对象,见 anonymouse-objects 示例);
- 指向带 reified 类型实参的内联函数的函数引用的特殊处理。
这些 lowering 移到序列化前有两个新限制:一是它们存在上下文里的数据在第二次运行时不可用(如 LateInit 和 OuterThis 这类 lowering 存了状态),需要去掉这些状态或把 lowering 移回内联之后;二是语义层面的——这些 lowering 的结果会被序列化进 klib,意味着它们不能被回溯修改,新增或变更 lowering 时必须同时兼容新旧两个版本的 klib,且不能改变公开可见的签名(否则把模块当依赖用时会在 LazyIr 上重新执行它们)。因此文档的结论是:pre-inline lowerings 应尽量避免。另外,type erasure 目前发生在内联过程中,因为内联函数反序列化之后上下文不足,文档认为它本应是一个独立的 lowering。
迁移安排与需要留意的 corner case
关于存量 klib,文档给出的迁移策略(并明确标注This is draft decision, more investigation is required)是:已存在的、未按新方案准备的内联函数 klib 保持原样,仍然在 klib 链接之后内联。文档把这归为需要接受的技术债,理由是链接前内联的约束更严格,能处理它的代码应当也能处理已链接的 IR。
文档同时列出一组 corner case,说明实现时不能丢失这些情况(并建议第一遍阅读可以跳过):
- typeOf:intrinsic 与内联的交互;
- 与匿名对象的交互;
- 访问私有声明:内联会突破模块边界访问调用方看不到的声明;
- 从 Java 调用;
- inline override;
- 使用调用点不可见的声明:例如
libA提供fooA,libB的inline fun fooB() = fooA()实现依赖libA,而libC的fun fooC() = fooB()只依赖libB。在 JVM 上内联invokestatic libAKt.foo时不关心目标是否存在,不是问题;现行非 JVM 后端内联时能看到全部传递依赖,也不是问题;但改成编译期对 IR 内联后,fooA对libC的编译不可见,就会成为问题——而且 JVM 上无法靠 IrLinker 事后补救。
如何核对两种后端的行为
该设计文档本身不提供构建命令,验证方式就是复现开头的四模块示例:按注释拆分模块,保证lib只链接dependency-v1、main同时链接lib与dependency-v2,分别在 JVM 目标与 klib 系目标上编译并运行main。文档给出的预期结果是 JVM 输出lib.v1、klib 系后端输出lib.v2——注意这是文档陈述的预期输出,用于判断你观察到的行为属于哪套语义模型,而不是一个必须逐字匹配的日志模板。
判断你手上的库走哪套模型时,需要结合迁移现状:新方案在文档中属于“近期目标”+草稿级决策,而旧 klib 会明确保留链接后内联。也就是说,一个已发布 klib 的inline函数调用,其固化的版本取决于该 klib 按哪套流水线编译,这正是库 API 升级前要先想清楚兼容模型的原因。
【免费下载链接】kotlinThe Kotlin Programming Language.项目地址: https://gitcode.com/GitHub_Trending/ko/kotlin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考