第4章:Kotlin IR中间表示:IR概念、IR树结构、IR元素类型、IR构建器
好,咱们进入编译器插件最核心的部分——IR。说实话,我第一次接触Kotlin IR的时候,脑子里也是一团浆糊。什么中间表示、树结构、构建器……听着就头大。但后来我慢慢发现,IR其实就是编译器在「看懂」你的代码之后,准备「动手改造」之前,那个中间状态的数据结构。
你想想看,编译器要处理你的Kotlin源码,它得先解析成语法树(AST),然后做语义分析,最后生成字节码。IR就夹在中间,它比AST更接近底层,但又比字节码更保留高级语言的信息。说白了,IR就是编译器内部用来「思考」的语言。
核心观点:Kotlin编译器插件之所以强大,就是因为它能直接操作IR。你可以在IR层面插入、修改、删除代码节点,从而实现各种黑科技——比如自动生成序列化代码、注入日志、实现AOP等。
4.1 IR是什么?为什么需要它?
IR全称是Intermediate Representation,中间表示。Kotlin编译器从源码到字节码,大致走这三步:
- 解析阶段:把源码变成AST(抽象语法树)
- 分析阶段:做类型检查、符号解析,生成带类型的AST
- 生成阶段:把AST翻译成JVM字节码或JS代码
IR就插在第二步和第三步之间。为什么非要加这一层?我个人的理解是:为了解耦。Kotlin要支持JVM、JS、Native多个后端,如果每个后端都直接解析AST,那维护成本就太高了。IR作为统一中间表示,前端只生成IR,后端各自把IR翻译成目标代码。
我在项目中遇到过这样一个场景:我们团队想给一个大型Android项目加一个自动埋点插件。如果直接操作AST,你会发现AST的节点类型特别多,而且不同Kotlin版本之间AST结构还不一样。后来改用IR插件,接口稳定多了,代码也简洁不少。
我的建议:如果你刚开始写编译器插件,优先选择IR方式。虽然学习曲线陡一点,但长期来看更靠谱。
4.2 IR树结构:从源码到IR的映射
IR是以树的形式组织的。这棵树里每个节点都代表一个程序元素。比如一个函数、一个变量、一个表达式,在IR里都有对应的节点。
咱们来看一个简单的例子:
// Kotlin源码
fun hello(name: String): String {
return "Hello, $name"
}
这段代码在IR里会变成一棵树,大致结构如下:
IrFunction (name: hello)
├── IrValueParameter (name: name, type: String)
├── IrReturnableBlock (body)
│ └── IrReturn
│ └── IrStringConcatenation
│ ├── IrConstString ("Hello, ")
│ └── IrGetValue (name: name)
你看,每个节点都有明确的类型。IrFunction代表函数,IrReturn代表return语句,IrStringConcatenation代表字符串拼接。这种结构的好处是:你可以像操作DOM树一样操作IR树。
嗯,这里要注意:IR树和AST树长得有点像,但IR树更「扁平」。AST里很多语法糖(比如中缀表达式、解构声明)在IR里都被展开了。说白了,IR是「去语法糖」之后的代码表示。
4.3 IR元素类型:认识核心节点
IR里的节点类型很多,但常用的其实就那么几类。我按功能把它们分了个组:
| 类别 | 核心类型 | 说明 |
|---|---|---|
| 声明 | IrFunction, IrClass, IrProperty, IrValueParameter | 代表函数、类、属性、参数等声明 |
| 表达式 | IrCall, IrGetValue, IrSetValue, IrConst, IrStringConcatenation | 代表函数调用、变量读写、常量、字符串拼接等 |
| 语句 | IrReturn, IrBlock, IrIf, IrWhen, IrWhile | 代表控制流语句 |
| 类型 | IrType, IrSimpleType, IrDynamicType | 代表类型信息 |
| 其他 | IrAnnotation, IrComment, IrError | 注解、注释、错误节点 |
我刚开始写IR插件时,最常打交道的就是IrCall和IrFunction。因为大部分代码注入工作,要么是插入新的函数调用,要么是修改现有函数的逻辑。
避坑指南:我曾经在遍历IR树时,直接修改了正在遍历的节点,结果导致编译器崩溃。后来才意识到,IR树在遍历期间是「冻结」的,你不能边遍历边修改。正确的做法是:先用一个列表收集要修改的节点,遍历结束后再统一处理。
4.4 IR构建器:如何用代码生成代码
好了,理论讲完了,咱们来点实际的。IR构建器(IrBuilder)就是用来「造」IR节点的工具。你可以把它想象成一个乐高积木盒,里面各种形状的积木(IR节点),你用它们拼出新的代码。
来看一个实际例子。假设我们要在函数开头插入一行日志打印:
// 目标效果:在每个函数开头插入 println("Entering function: xxx")
fun hello() {
println("Entering function: hello") // 这是我们要插入的
// 原有代码...
}
用IR构建器实现如下:
// 伪代码,展示核心逻辑
fun IrBuilder.generateLogCall(functionName: String): IrExpression {
// 1. 获取 println 函数的引用
val printlnFunc = irBuiltIns.printLn
// 2. 构建字符串参数
val message = irString("Entering function: $functionName")
// 3. 构建函数调用
return irCall(printlnFunc).also {
it.putValueArgument(0, message)
}
}
// 在插件中使用
override fun visitFunctionNew(declaration: IrFunction) {
// 在函数体最前面插入日志调用
declaration.body?.statements?.add(0,
irBuilder.generateLogCall(declaration.name.asString())
)
}
你看,核心就是三个步骤:获取引用、构建参数、构建调用。irCall、irString、irBlock这些都是IR构建器提供的方法。
我个人习惯把常用的构建操作封装成工具函数。比如上面那个generateLogCall,我后来把它做成了一个通用的「方法注入器」,支持注入任意代码片段。
小技巧:IR构建器的方法名大多以「ir」开头,比如irCall、irString、irBlock、irReturn。你在IDE里输入「ir」就能看到所有可用的构建方法,非常方便。
4.5 实战:用IR构建器生成一个完整的函数
光说不练假把式。咱们来生成一个完整的函数:
// 目标:生成如下函数
fun greet(name: String): String {
return "Hello, $name!"
}
用IR构建器实现:
fun IrBuilder.generateGreetFunction(): IrFunction {
// 1. 创建函数声明
val function = irFunction(
name = "greet",
returnType = irBuiltIns.stringType,
visibility = DescriptorVisibilities.PUBLIC
)
// 2. 添加参数
val nameParam = irValueParameter(
name = "name",
type = irBuiltIns.stringType
)
function.addValueParameter(nameParam)
// 3. 构建函数体
val body = irBlock {
// 构建 "Hello, " + name + "!"
val greeting = irString("Hello, ")
val exclamation = irString("!")
val concat = irCall(irBuiltIns.stringPlus).also {
it.putValueArgument(0, greeting)
it.putValueArgument(1, irGetValue(nameParam))
}
val result = irCall(irBuiltIns.stringPlus).also {
it.putValueArgument(0, concat)
it.putValueArgument(1, exclamation)
}
// 构建 return 语句
+irReturn(result)
}
function.body = body
return function
}
这段代码看起来有点啰嗦,但每一步都很清晰。你想想看,如果不用IR构建器,你要手动创建每个节点、设置类型、建立父子关系……那才叫痛苦。
核心要点:IR构建器本质上是一个DSL(领域特定语言),它让你用「声明式」的方式描述要生成的代码。你只需要关注「要生成什么」,而不需要关心「怎么生成」。
4.6 常见陷阱与最佳实践
最后,分享几个我踩过的坑:
- 类型系统要小心:IR里的类型和Kotlin源码里的类型不完全一样。比如可空类型、泛型参数,在IR里都有专门的表示。我刚开始经常搞混,导致生成的代码编译不过。
- 符号引用别搞错:irCall需要传入一个函数符号(IrSimpleFunction),而不是函数名。如果你传错了符号,生成的代码会调用到错误的方法。
- 作用域问题:在IR里插入代码时,要注意变量的作用域。你不能在一个函数里引用另一个函数的局部变量。
- 调试技巧:IR树可以用dump()方法打印出来。我写插件时经常在关键位置加一句irElement.dump(),看看生成的IR长什么样。
好了,这一章的内容就到这儿。IR是编译器插件的核心,理解了它,你就掌握了Kotlin元编程的钥匙。下一章咱们会深入IR的遍历和变换,到时候你会看到更多实战案例。