ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

如何理解 Kotlin inline 函数在 JVM 与 klib 后端内联语义的差异?

2026/9/10 7:38:44 拓冰建站 浏览量
如何理解 Kotlin inline 函数在 JVM 与 klib 后端内联语义的差异? 如何理解 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 转成 FIRFir2Ir转成 IR外部依赖的 FIR 转成 LazyIrIrActualizer合并模块再经过 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 doesnt happen over IR, it happens over bytecode.也就是说JVM 上的内联发生在所有 lowering 之后、针对字节码进行。文档同时提到存在把内联迁移到 IR 上的计划但具体实现不在该文档范围内。klib 后端两次编译内联传统上推迟到第二次运行klib 系后端的编译分两次运行第一次运行把源码编译成 Klib。源码与依赖 klib 的 metadata 经 Frontend 转成 FIRFir2Ir把源模块的 FIR 转成 IR、把外部引用的 FIR 转成LazyIrIrActualizer合并模块Plugins 处理后由 Klib Serializer 序列化成 klib。第二次运行把所有 klib含第一次运行的产物反序列化后交给irLinker反序列化 Linker负责把声明引用匹配到具体声明随后依次经过 Pre-inline lowerings、Ir Inliner、后续 Lowerings 和 Code Gen产出最终二进制。文档对三个中间产物的定义值得留意FIR前端表示每个声明可序列化/反序列化为 metadataLazyIr基于 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 为例列出需要在内联前完成的 loweringtypeOfintrinsic 的处理见 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说明实现时不能丢失这些情况并建议第一遍阅读可以跳过typeOfintrinsic 与内联的交互与匿名对象的交互访问私有声明内联会突破模块边界访问调用方看不到的声明从 Java 调用inline override使用调用点不可见的声明例如libA提供fooAlibB的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),仅供参考