第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编译器从源码到字节码,大致走这三步:

  1. 解析阶段:把源码变成AST(抽象语法树)
  2. 分析阶段:做类型检查、符号解析,生成带类型的AST
  3. 生成阶段:把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的遍历和变换,到时候你会看到更多实战案例。