Kotlin 新特性抢先看:伴生扩展与伴生块

大家吼哇!不知道大家是否了解 Kotlin 即将推出的一个新的实验性特性:「Companion extensions and blocks(伴生扩展与伴生块)」呢?

哦我的上帝!对于这个特性,我可是相当期待,就像我无时无刻不在期待乔治叔叔的姐姐那新鲜出炉的苹果派一样。

单看名字也能看得出来,这个特性似乎是 companion object 的亲戚。而失去了 object,它又能玩出什么花样?今天我打算挑出这个特性聊聊。

想写个扩展,怎么还得看类的脸色?

首先我们来假设这里有一个表示坐标的 Point,我作为第三方使用者,想给它加个工厂方法 Point.origin(),用来创建一个原点。

老办法大家应该不陌生,给伴生对象写扩展就行:

// 原类型
class Point(val x: Int, val y: Int) {
    companion object
}
// 第三方补充
fun Point.Companion.origin(): Point = Point(0, 0)
// 调用处
val point = Point.origin()

看着似乎一切都那么美好:Point 负责表示坐标,扩展负责补一个创建原点的方法,互不耽误。

但很明显这里有个前提:原本的 Point 类里得先有那句 companion object。

如果它不存在呢?Point.Companion 都没了,扩展自然也就无从谈起。你可能就只能写一个顶层的 createPointOrigin 之类的函数来作为代餐了。
自己写的类还好办,添一句就是了。可如果是别人库里的类,总不能为了加个扩展,先去求人家留一个伴生对象吧?甚至如果是面对一个 Java 中定义的类,就更是无从下手了。

这正是原有的伴生对象扩展的限制。它也被列在 KEEP 0449 的第一个问题里,对应的 KT-11968,倒也算是个老生常谈的问题了。

伴生扩展能做到什么?

而现在,伴生扩展(companion extensions) 带来了新的写法:

class Point(val x: Int, val y: Int)

companion fun Point.origin(): Point = Point(0, 0)

val point = Point.origin()

在顶层扩展函数前面加上 companion,接收者写成 Point,就这么多。

类里不用预留 companion object,也不用为了这个方法改动原来的类。

终于可以随心所欲地直接写 Point.origin() 了,芜湖!

一个值得注意的点:这里扩展的是通过类型名调用的那一部分。这也是为什么我上面提到接受者时用了斜体,因为它不同于以前我们所熟知的那个 receiver:
这里没有一个 Point 对象被传进 origin(),因此它所代表的是一个“类型标记”,而不是一个参数、一个对象实例,调用时也不能换成 point.origin(),函数体内也无法使用 this。

具体规则可以看 KEEP 0449 的伴生扩展章节和 KEEP 0449 §1.3 的声明规则。

伴生扩展也同样支持属性:

companion val Point.UnitX: Point
    get() = Point(1, 0)

用的时候通过 Point.UnitX 拿到一个 x 方向的单位坐标,point.UnitX 则不行。

当然了,扩展还是扩展,私有成员的访问限制依旧生效,很好理解也很合理的限制。

Java 的类,也能安排上

我们前面提到了 Java 的类,而伴生扩展也完美解决了对 Java 类型添加扩展的痛点。

比如给 LocalDate 加一个方法,把 20261009 这样的字符串解析成日期:

package example.dates

import java.time.LocalDate

companion fun LocalDate.fromCompact(text: String): LocalDate {
    require(text.length == 8)
    return LocalDate.of(
        text.substring(0, 4).toInt(),
        text.substring(4, 6).toInt(),
        text.substring(6, 8).toInt(),
    )
}

换个包,导入后就能用了:

import example.dates.fromCompact
import java.time.LocalDate

val date = LocalDate.fromCompact("20261009")

LocalDate 根本没有 Kotlin 的 Companion,现在也可以进行扩展了。
KEEP 0449在开头就特意提到了 Java 类型,也算是这次改动的重头戏之一。

顺便留意那个 import example.dates.fromCompact。它跟普通顶层扩展的规则是类似的。
不过 KEEP 确实也有提到过探索自动导入的想法,不过并未落地,或者至少现在没有落地。

那 Java 代码是不是也能直接调用 LocalDate.fromCompact() 了?

那当然…是不行的。了解 Kotlin 语言特性以及这些顶层函数的底层编译结果的小伙伴应该很容易理解,这类扩展并没有改写目标类型的字节码,
因此在 Java 那边肯定是不能直接调用的。至于该怎么调用,等到下文看编译结果就明白了。

扩展属性,居然还能存东西?

普通扩展属性有个规矩:不能有 backing field,也就是不能有一个真实储存内容的字段。所以通常它们只能写 getter/setter,想直接 val/var xxx = ... 是不行的。

但这次的伴生扩展属性不同:它允许有初始化器,也允许有 backing field。

companion val Point.UnitY: Point = Point(0, 1)

跟前面示例代码中的 UnitX 对比一下就能看出来:

  • UnitX 每次进 getter,都会新建一个 Point。
  • UnitY 在初始化时创建,之后访问拿到的是同一个对象。

看上去是 Point 的属性,但没有给每个 Point 实例塞一个字段。
参照它的 初始化规则,在 JVM 中它类似于一个放在扩展所在文件里的顶层属性。

泛型也能用,但不能这么用

再来一个 List 的例子:

companion fun <T> List.singleton(value: T): List<T> = listOf(value)

val names: List<String> = List.singleton("Kotlin")

注意这里是 List.singleton,不是 List<T>.singleton。泛型 T 是这个函数自己的,而不能是类型的。

KEEP 0449 §1.3.2对接收者的要求中提到:得是已经声明的类、接口,或者符合条件的类型别名,不能带指定的类型实参,也不能拿类型参数或 object 来充数。

这其实也很好理解。将伴生扩展相关的函数想象成 Java 中的静态函数(当然它实际上也是这么做的),你在Java中是不是也不能用 List<T>.of() 这种语法?

伴生块:把 object 删掉,会怎样?

前面讲的是在类外面补扩展。如果类本来就是自己写的,想把工厂方法、常量之类的直接放在里面呢?

当然,继续写 companion object 完全可以。不过现在多了一个选择,少写一个 object:

package example

data class Point(val x: Int, val y: Int) {
    companion {
        val Origin: Point = Point(0, 0)

        fun parse(text: String): Point {
            val (x, y) = text.split(',')
            return Point(x.trim().toInt(), y.trim().toInt())
        }
    }
}

companion val Point.UnitX: Point
    get() = Point(1, 0)

上述示例中,类里有伴生块,类外也有伴生扩展,这两种写法可以搭配使用。

调用起来也是一如既往:

val origin = Point.Origin
val parsed = Point.parse("3, 4")
val unitX = Point.UnitX

乍一看可能觉得只不过是少写一个 object 而已,但 object 这个词,原本就不是白写的。伴生对象如字面意思一般是一个对象,它可以实现抽象类或接口,也可以作为值传给别的函数。它所有的行为都与其他 object 别无二致(毕竟它的确就是个 object)。

删掉它以后的伴生块没有自己的对象实例,也没有它自己定义的 Point.Companion 类型。
可以参考 KEEP 的伴生块章节。

那么少了这个对象,又会发生什么?

JVM:这次真的是静态成员了

先回忆一下原本普通的 companion object。伴生对象里的函数本来是 Companion object 的实例方法,要让 Java 通过类名调用,我们需要手动给函数添加注解 @JvmStatic。
而且加了注解以后,实际上也只是多生成一个外层类的‘桥接式’的静态方法,伴生对象里的实例方法仍然在。
官方的Java 互操作文档中对这个行为有明确说明。

所以,@JvmStatic 并没有让伴生对象凭空消失。

而伴生块不同。按照 KEEP 的 JVM 编译策略,上面的代码示例会实际生成下面这些成员:

// Point 类中的成员
private static final Point Origin;
public static final Point getOrigin();
public static final Point parse(String text);

// Point.kt 对应的 PointKt 类中的成员
public static final Point getUnitX();

Java 调用时就是:

Point origin = Point.getOrigin();
Point parsed = Point.parse("3, 4");
Point unitX = PointKt.getUnitX();

不用 @JvmStatic,直接就是静态方法。好用,爱用。

这里也同时展示了前面那个 Java 调用伴生扩展的问题:伴生块的成员生成在原类里,顶层伴生扩展则生成在文件对应的类里。
就像这里的 UnitX,Kotlin 写 Point.UnitX,Java 则使用 PointKt.getUnitX(),扩展函数同理。

而其他平台的设计也在 KEEP 的编译策略章节里有所说明,感兴趣的小伙伴可以看看。

新旧伴生混在一起,谁说了算?

到这里,让我们把三个写法放一起对比一下:

写法 声明的什么 拿来做什么
companion object { ... } 一个真正的伴生对象 实现接口、作为值使用
companion { ... } 通过类型名访问的一组成员 把工厂、常量之类的写在自己的类里
顶层 companion fun Type.foo() 通过类型名调用的扩展 给现有类型补功能

不过它们倒也不是非此即彼。根据 KEEP §1.2的描述,一个类里可以既有伴生对象,又有伴生块,甚至还能有多个伴生块。

不过这么一来,要是名字撞上了怎么办?
对于比较熟悉普通扩展的小伙伴来说可能会觉得:那肯定是类里原本有的成员优先呗!

那么事实如何?

class Factory {
    companion object {
        fun label(): String = "object"
    }
}

companion fun Factory.label(): String = "extension"

fun main() {
    println(Factory.label())           // extension
    println(Factory.Companion.label()) // object
}

Factory.label() 优先找到的其实是新扩展!
KEEP 的解析示例 2.1对这个顺序进行了说明:通过类型名找成员时,先找伴生块与伴生扩展,再找伴生对象。
如果想明确找对象里的那个,需要显式写出 .Companion 也就是伴生对象那个类型。

具体顺序可以前往参考 KEEP §2.3。

我们各司其职

既然没有对象产生,那伴生块自然也不能把伴生对象的所有能力全部代替。

KEEP 的声明规则里列了不少限制:块里只能写函数和属性,不能放构造器、init 或成员扩展,也不能给成员标 open、override 之类的修饰符。
伴生扩展得写在顶层,运算符也存在一些范围与限制。

其中有两处也许可以拿出来简单讲讲。

都快成 static 了,为什么还叫 companion?

在我的印象中,给 Kotlin 加一套静态语法很早就有人提过。而 companion 这套设计也不是一步到位的,它的前面还有 Static members and type extensions 之类的东西。

作者在这条回复里提到,static 这个词有点身兼数职:可以说的是“属于类型的成员”,也可以说的是“编译以后生成了静态方法”。
比如 Kotlin 的顶层函数,在 JVM 上也是静态方法,但写 Kotlin 的时候,我们通常不会把它和伴生对象混为一谈。

所以沿用 companion,也就少了点歧义。源码里讨论的是成员怎么跟类型关联,到了 JVM 上,再讨论它怎么变成静态方法。
对于我个人来说,我很认可这种选择,这也挺符合 Kotlin 一直以来的设计美学。

属性能初始化,怎么 init 反而不行?

这个限制乍一看确实有点奇怪,但是仔细想想其实完全可以理解。

作者在这里解释过,一个主要的原因还是多平台的问题。不是所有平台都有 JVM 这套初始化方式,有的更适合按属性惰性初始化。
属性自己的初始化器还好处理,但是一套完整的 init 块就可能会过于复杂、甚至难以统一了。

他在后面的补充回复里还提到,一个类可以有多个伴生块,没有 init,调整这些块的位置时,也能少操心一点初始化顺序。

想在属性初始化时做点计算,run { ... } 仍然可以用。
不过循环依赖、提前读了还没初始化好的属性,这些麻烦可不会跟着 object 一起消失。
KEEP 的初始化章节也专门提了这一点,别依赖这种写法的结果。

想试试?

聊了这么多,如果你想自己写两行试试的话,在 Gradle Kotlin DSL 里按照实验性特性的一贯风格加上配置即可:

plugins {
    kotlin("jvm") version "2.5.0-Beta1"
}

repositories {
    mavenCentral()
}

kotlin {
    compilerOptions {
        freeCompilerArgs.add("-Xcompanion-blocks-and-extensions")
    }
}

你直接写一个 companion {} IDEA 也会提醒你加的。呃… 只要你的 IDEA 版本不会太低。

顺便一提:虽然这个特性限制可以在 What’s new in Kotlin 2.5.0-Beta1 中看到了,不过其实在 2.4.20 就已经能用了。当然这里面可能有更多的 Bug 就是了。

在 JetBrains 的官方博客 The Companions to Come 中也有介绍这个特性,甚至还给了两个开关:

  • -Xcompanion-blocks:只用伴生块。
  • -Xcompanion-blocks-and-extensions:两个都开。

文章中还特意提醒,启用伴生扩展会生成 pre-release binaries,也就是预发布的编译产物。
这样的库不能当成普通稳定依赖直接交给下游项目用。只用伴生块不受这一条限制,只不过调用它的项目还是得启用伴生块支持。

因此如果你是一个比较激进的库作者,又不希望为难下游,那么可以考虑先使用第一个开关,然后…静候佳音。

那我旧代码里的 object,还删不删?

看完新写法,你可能像我一样已经开始手痒了(骗你的,我其实已经爽完了),想把那一堆 companion object 全部精简一遍。

倒也不用急。

companion object 依然有自己的应用场景。如果伴生对象实现了工厂接口,或者会被当成值传来传去,那这个对象就不是多余的。
官方对象文档的工厂例子 和 KEEP 的迁移章节 都有提到这类限制。

还有就是作为一个库作者老生常谈的兼容问题。一个已经发布出去的库,直接删除 object 势必会带来二进制的不兼容(毕竟因为 object 们都没了)。

如果想慢慢迁移的话,KEEP 里给的建议是先加伴生块里的实现,再保留旧的伴生对象入口,让旧方法转调新方法,之后再按兼容要求处理弃用、警告,在循序渐进地提升等级、隐藏实现,再最终删除。就像对其他 API 的变更方案一样。
如果原来还有 @JvmStatic,也得留意别复制出两个相同签名的静态方法。
具体可以看 KEEP 里的迁移建议和JVM 签名冲突的例子。

如果你的目的只是想让 Java 调用方便点,其实继续保持加 @JvmStatic 的方案设计也没什么。KEEP 中也说了:一个成熟的项目全量迁移,收益可能并没那么大。
当然,收益的大小与否,取决于你的实际项目、与你自己的评判标准。

结尾

撰此文仅为分享,感谢你的阅读!我们下次再见,ヾ( ̄▽ ̄)Bye~Bye~

2 个赞