opoojkk

Kotlin 编译器插件(译)

lxx
目次

原文:https://kt.academy/article/ak-compiler-plugin

这是《Advanced Kotlin》一书的一个章节。您可以在 LeanPubAmazon 上找到它。

Kotlin 编译器是一个用于编译 Kotlin 代码的程序,同时也被 IDE 用于提供代码补全、警告等分析功能。与许多程序一样,Kotlin 编译器可以使用插件来改变其行为。我们通过扩展一个名为扩展(extension)的特殊类来定义 Kotlin 编译器插件,然后使用注册器(registrar)来注册它。每个扩展在编译器工作的特定阶段被调用,从而可能改变该阶段的结果。例如,你可以注册一个插件,当编译器为类生成父类型时被调用,从而向结果添加额外的父类型。当我们编写编译器插件时,我们受限于所支持的扩展允许我们做的事情。我们很快会讨论当前可用的扩展,但让我们先从一些关于编译器如何工作的基础知识开始。

编译器前端和后端 #

Kotlin 是一种多平台语言,这意味着相同的代码可以用于为不同平台生成底层代码。合理的是,Kotlin 编译器被分为两大部分:

编译器前端与目标平台无关,当我们编译多平台模块时,其结果可以被重用。然而,目前正在发生一场革命,因为新的 K2 前端正在取代旧的 K1 前端。

编译器后端是特定于编译目标的,所以 JVM、JS、Native 和 WASM 都有各自独立的后端。它们有一些共享的部分,但本质上是不同的。

编译器前端负责解析和分析 Kotlin 代码,并将其转换为发送给后端的表示形式,后端基于此生成平台特定的文件。前端与目标平台无关,但有两个前端:旧的 K1 和新的 K2。后端是特定于目标平台的。

当你在像 IntelliJ 这样的 IDE 中使用 Kotlin 时,IDE 会向你显示警告、错误、组件使用情况、代码补全等,但 IntelliJ 本身并不分析 Kotlin:所有这些功能都基于与 Kotlin 编译器的通信,编译器有一个专门的 IDE API,而前端负责这种通信。

每个后端变体都共享一部分,该部分从前端提供的表示形式生成 Kotlin 中间表示(在 K2 的情况下是 FIR,即前端中间表示)。平台特定的文件是基于这个表示形式生成的。

每个后端都共享一部分,该部分将前端提供的表示形式转换为 Kotlin 中间表示,该表示用于生成特定目标的文件。

你可以在许多演示文稿和文章中找到关于编译器前端和编译器后端如何工作的详细描述,例如 Amanda Hinchman-Dominguez 或 Mikhail Glukhikh 的文章。我不会在这里详细讨论,因为我们已经涵盖了讨论编译器插件所需的一切。

编译器扩展 #

Kotlin 编译器扩展也分为前端扩展和后端扩展。所有前端扩展都以 Fir 前缀开头,以 Extension 后缀结尾。以下是当前支持的 K2 扩展的完整列表1

注意! 在本章中,我们只讨论 K2 前端扩展,因为 K1 前端已被弃用,将来会被移除。然而,K2 编译器前端目前默认不被使用。要使用它,你需要至少有 Kotlin 版本 1.9.0-Beta,并添加 -Pkotlin.experimental.tryK2=true 编译器选项。

如你所见,这些插件允许我们对编译和分析应用更改。它们可以用于显示警告或中断编译并报错。它们还可以用于更改特定元素的可见性,从而影响生成代码的行为和 IDE 中的建议。

关于后端,只有一个扩展:IrGenerationExtension。它在从 FIR(前端中间表示)生成 IR(Kotlin 中间表示)之后但在用于生成平台特定文件之前使用。IrGenerationExtension 用于修改 IR 树。这意味着 IrGenerationExtension 可以更改生成代码中的任何内容,但使用它很困难,因为我们很容易引入破坏性更改,所以必须非常小心地使用它。此外,IrGenerationExtension 无法影响代码分析,因此无法影响 IDE 建议、警告等。

后端插件扩展在从 FIR(前端中间表示)生成 IR(Kotlin 中间表示)之后但在用于生成平台特定文件之前使用。

我想明确一点,后端无法影响 IDE 分析。如果你使用 IrGenerationExtension 向类添加方法,你将无法在 IntelliJ 中直接调用它,因为它无法识别这样的方法,所以你只能使用反射来调用它。相反,使用前端 FirDeclarationGenerationExtension 添加到类的方法可以直接使用,因为 IDE 知道它的存在。

大多数流行的 Kotlin 插件需要多个扩展,包括前端和后端。例如,Kotlin Serialization 使用后端扩展来生成所有序列化和反序列化的函数;另一方面,它使用前端扩展来添加隐式父类型、检查和声明。

这是关于 Kotlin 编译器插件的基本知识。为了使其更实用一些,让我们看几个例子。

流行的编译器插件 #

许多编译器插件和使用编译器插件的库已经可用。最流行的包括:

大多数插件使用多个扩展,所以让我们考虑简单的 Parcelize 插件,它只使用以下扩展:

Kotlin 编译器插件在 build.gradle(.kts) 的 plugins 部分中定义:

1
2
3
plugins {
    id("kotlin-parcelize")
}

一些插件作为单独的 Gradle 插件的一部分分发。

使所有类为 open #

我们将从一个简单的任务开始:使所有类为 open。这个行为受到 AllOpen 插件的启发,该插件打开所有带有指定注解之一的类。然而,我们的示例会更简单,因为我们只是打开所有类。

作为依赖项,我们只需要 kotlin-compiler-embeddable,它为我们提供了可用于定义插件的类。

就像在 KSP 或注解处理中一样,我们需要在 resources/META-INF/services 中添加一个带有注册器名称的文件。这个文件的名称应该是 org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar,这是 CompilerPluginRegistrar 类的完全限定名。在其中,你应该放置注册器类的完全限定名。在我们的例子中,这将是 com.marcinmoskala.AllOpenComponentRegistrar

1
2
3
// org.jetbrains.kotlin.compiler.plugin.
// CompilerPluginRegistrar
com.marcinmoskala.AllOpenComponentRegistrar

我们的 AllOpenComponentRegistrar 注册器需要注册一个扩展注册器(我们称之为 FirAllOpenExtensionRegistrar),该注册器注册我们的扩展。请注意,注册器可以访问配置,以便我们可以将参数传递给我们的插件,但我们现在不需要这个配置。我们的扩展只是一个扩展 FirStatusTransformerExtension 的类;它有两个方法:needTransformStatustransformStatus。前者确定是否应该应用转换;后者应用它。在我们的例子中,我们将扩展应用于所有类,并将它们的状态更改为 open,无论之前的状态是什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@file:OptIn(ExperimentalCompilerApi::class)

class AllOpenComponentRegistrar : CompilerPluginRegistrar() {
   override fun ExtensionStorage.registerExtensions(
       configuration: CompilerConfiguration
   ) {
       FirExtensionRegistrarAdapter
           .registerExtension(FirAllOpenExtensionRegistrar())
   }

   override val supportsK2: Boolean
       get() = true
}

class FirAllOpenExtensionRegistrar : FirExtensionRegistrar(){
   override fun ExtensionRegistrarContext.configurePlugin() {
       +::FirAllOpenStatusTransformer
   }
}

class FirAllOpenStatusTransformer(
    session: FirSession
) : FirStatusTransformerExtension(session) {
   override fun needTransformStatus(
       declaration: FirDeclaration
   ): Boolean = declaration is FirRegularClass

   override fun transformStatus(
       status: FirDeclarationStatus,
       declaration: FirDeclaration
   ): FirDeclarationStatus =
       status.transform(modality = Modality.OPEN)
}

这只是一个简化的版本,但实际的 AllOpen 插件稍微复杂一些,因为它只打开那些带有指定注解之一的类。为此,FirAllOpenExtensionRegistrar 注册一个插件,该插件由 FirAllOpenStatusTransformer 使用来确定是否应该打开特定的类。如果你对细节感兴趣,请查看 Kotlin 仓库中 plugins 文件夹中的 AllOpen 插件。

更改类型 #

我们的下一个示例将是 SAM-with-receiver 编译器插件,它将从带有适当注解的 SAM 接口生成的函数类型更改为带有接收者的函数类型。它使用 FirSamConversionTransformerExtension,这对于这个插件来说非常特定,因为它仅在 SAM 接口转换为函数类型时被调用,并且允许更改将要生成的类型。这个示例很有趣,因为它添加了一个类型,该类型将被 IDE 识别并可以直接在代码中使用。完整的实现可以在 Kotlin 仓库的 plugins/sam-with-receiver 文件夹中找到,但这里我只想展示这个扩展的简化实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class FirScriptSamWithReceiverConventionTransformer(
    session: FirSession
) : FirSamConversionTransformerExtension(session) {
    override fun getCustomFunctionTypeForSamConversion(
        function: FirSimpleFunction
    ): ConeLookupTagBasedType? {
        val containingClassSymbol = function
            .containingClassLookupTag()
            ?.toFirRegularClassSymbol(session)
            ?: return null

        return if (shouldTransform(it)) {
            val parameterTypes = function.valueParameters
                .map { it.returnTypeRef.coneType }
            if (parameterTypes.isEmpty()) return null
            createFunctionType(
                getFunctionType(it),
                parameters = parameterTypes.drop(1),
                receiverType = parameterTypes[0],
                rawReturnType = function.returnTypeRef
                    .coneType
            )
        } else null
    }

    // ...
}

如果 getCustomFunctionTypeForSamConversion 函数不返回 null,它将覆盖为 SAM 接口生成的类型。在我们的例子中,我们确定函数是否应该被转换;如果是,我们通过使用 createFunctionType 函数创建一个带有接收者的函数类型。有构建器函数可以帮助我们创建在 FIR 中表示的许多元素。示例包括 buildSimpleFunctionbuildRegularClass,它们中的大多数都提供了简单的 DSL。这里,createFunctionType 函数创建一个类型为 ConeLookupTagBasedType 的带有接收者表示的函数类型,它将替换从 SAM 接口自动生成的类型。本质上,这就是这个插件的工作原理。

生成函数包装器 #

让我们考虑以下问题:Kotlin 挂起函数只能在 Kotlin 代码中调用。这意味着如果你想从 Java 调用挂起函数,你可以使用例如 runBlocking 将其包装在一个常规函数中,该函数在协程中调用挂起函数。

1
2
3
suspend fun suspendFunction() = ...

fun blockingFunction() = runBlocking { suspendFunction() }

我们可以使用插件来自动生成这样的挂起函数包装器,使用后端或前端插件。

后端插件需要一个 IrGenerationExtension 扩展,该扩展在 IR 中为适当的函数生成额外的包装函数。这些包装函数将存在于生成的平台特定代码中,因此可用于 Java、Groovy 和其他语言。问题是这些包装类在 Kotlin 代码中不可见。如果我们的包装函数本来就是要从其他语言使用的,这很好,但我们需要知道这个严重的限制。有一个名为 kotlin-jvm-blocking-bridge 的开源插件,它使用后端插件为挂起函数生成阻塞包装器;你可以在链接 github.com/Him188/kotlin-jvm-blocking-bridge 下找到其源代码。

前端插件需要一个 FirDeclarationGenerationExtension 类的扩展,为 FIR 中的适当挂起函数生成包装函数。然后,这些额外的函数将用于生成 IR,最后生成平台特定的代码。这些函数在 IntelliJ 中也可见,因此我们可以在 Kotlin 和 Java 中使用它们。然而,这样的插件只能与 K2 编译器一起工作,即从 Kotlin 2.0 开始。要支持以前的语言版本,我们需要定义一个支持 K1 的额外扩展。

示例插件实现 #

Kotlin 编译器插件目前没有文档,生成的元素必须遵守许多限制才能使我们的代码不会崩溃,所以定义自定义插件相当困难。如果你想定义自己的插件,我的建议是首先获取 Kotlin 编译器源代码,然后分析 plugins 文件夹中的现有插件。

这个文件夹不仅包括 K2 插件,还包括 K1 和基于 KSP 的插件。我们只对 K2 插件感兴趣,所以你可以忽略其余的。

所有支持的扩展的列表可以在 FirExtensionRegistrar 类中找到。要分析编译器如何使用扩展,你可以搜索其公开方法的使用情况。要做到这一点,按住 command/Ctrl 并点击方法名跳转到其使用位置。这应该向你显示 Kotlin 编译器在哪里使用这个扩展。但请注意,所有未记录的知识在未来更有可能改变。

总结 #

如你所见,Kotlin 编译器插件的能力由 Kotlin 编译器支持的扩展决定。在编译器的前端,这些扩展能力是有限的,所以目前只有一组特定的事情可以在前端使用 Kotlin 编译器插件完成。在编译器后端,你可以以任何方式更改生成的 IR 表示;这提供了许多可能性,但也很容易在你的代码中引起破坏性更改。

Kotlin 编译器插件技术仍然年轻、缺乏文档且在变化中。应该非常小心地使用它,因为它很容易破坏你的代码,但它也极其强大,提供了难以想象的可能性。Jetpack Compose 就是一个很好的例子。我只能与你分享 Kotlin 编译器插件如何工作以及它们能做什么的大致概念,但我希望这足以让你理解关键概念和可能性。

在下一章中,我们将讨论另一个有助于代码开发的工具:静态代码分析器。一方面,它比 KSP 或编译器插件更有限,因为它无法生成任何代码;另一方面,静态代码分析器也极其强大,因为它们可以严重影响我们的开发过程并帮助我们改进实际代码。


1: K1 扩展已被弃用,所以我将跳过它们。

作者 #

Marcin Moskała

Marcin Moskala 是一位经验丰富的开发者和 Kotlin 教师,同时也是 Kt. Academy 的创始人,该机构是 JetBrains 的官方合作伙伴,专注于 Kotlin 培训。他还是 Google Developers Expert,以其对 Kotlin 社区的重大贡献而闻名。Moskala 著有多本广受认可的书籍,包括《Effective Kotlin》、《Kotlin Coroutines》、《Functional Kotlin》、《Advanced Kotlin》、《Kotlin Essentials》和《Android Development with Kotlin》。

除了在出版领域的成就外,Moskala 还是 Medium 上最大的 Kotlin 专栏的作者。作为一名备受尊敬的演讲者,他曾受邀在众多编程大会上分享见解,包括 Droidcon 以及 Kotlin 编程语言顶级大会 Kotlin Conf 等知名活动。

标签:
Categories: