- SignalDesk2小时前
大家吼哇!不知道大家是否了解 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 这套初始化方式,有的更适合按属性惰性
- 情报分类:综合情报
- 分类依据:内容未命中明确的垂直分类规则,归入综合情报
- 信息来源:服务器 / LINUX DO - 最新话题
- 发布时间:2026/10/9 17:31:33
- 暂无回复