不改源码,给 String 偷偷加成员函数
华为仓颉编程语言里的扩展,有一种很让人眼前一亮的能力:不去改 `String` 的源码,也能让字符串实例像是多了一个成员函数。
这不是修改标准库,也不是运行期把成员函数塞进去,而是在当前包的编译上下文里,用 `extend` 为一个可见类型补充成员。写完以后,调用体验就像这个成员函数原本属于 `String` 一样。
extend String { public func printSize() { println("the size is ${this.size}") } } main() { let text = "123" text.printSize() }
这段代码的重点是 `extend String`。它告诉编译器:在当前可见范围内,我要给 `String` 增加一组额外能力。于是 `text.printSize()` 可以像普通成员函数一样调用。
扩展不是改类,而是补能力
理解扩展时,最容易误会的一点是:它不是把原类型定义改掉。`String` 的源码没有变化,标准库也没有被重新编译。扩展只是让当前包里的类型检查和成员查找,知道这个类型还可以使用一批新成员。
这就像给一本书夹了一张便签。书本身没改,但你在阅读这本书时,可以顺手看到额外说明。扩展的价值就在这里:它不破坏原类型封装,却能让代码更贴近业务表达。
例如我们可以为字符串补一个“带边框打印”的函数:
extend String { public func printBox() { let border = "==========" println(border) println(this) println(border) } } main() { "hello".printBox() }
这样的函数如果写成普通工具函数,调用形式可能是 `printBox(text)`;写成扩展后,调用形式变成 `text.printBox()`。读起来更像“这个字符串自己执行了一个动作”。

String 可以加函数,也可以加计算属性
扩展不仅能加成员函数,也能加成员属性。不过这里的属性必须有实现,本质上是计算属性,不能新增存储字段。
比如我们可以给 `String` 补一个 `isLong` 属性:
extend String { public prop isLong: Bool { get() { this.size > 10 } } } main() { let title = "Cangjie extension" println(title.isLong) }
这里没有给 `String` 增加一个真实存储的变量。`isLong` 每次访问时都会根据 `this.size` 算出结果。
如果扩展允许随便加成员变量,原类型的内存布局和封装边界都会被打乱。仓颉明确禁止扩展增加成员变量,正是为了保持类型模型清晰稳定。

this 可以用,private 不能碰
在扩展的实例成员里,可以使用 `this` 访问当前对象,也可以省略 `this`。前面的 `this.size` 就是典型用法。
但是扩展不能访问被扩展类型里的 `private` 成员。原因很直接:扩展不是类型定义的一部分,它不能突破原类型作者设置的封装边界。
class UserName { private let raw: String public init(raw: String) { this.raw = raw } public prop size: Int64 { get() { raw.size } } } extend UserName { public func printSize() { println(this.size) // println(raw) } }
在这个例子里,扩展可以访问 `public prop size`,但不能直接访问 `private let raw`。这正是扩展和继承,扩展和源码修改之间的边界。

不能遮蔽已有成员
扩展还有一个重要规则:不能重新定义类型上已经有的成员,也不能和同一类型的其他扩展成员重名。
如果一个类型已经有 `f`,扩展里不能再写一个同签名的 `f` 来覆盖它。
class A { public func f() {} } extend A { // public func f() {} }
同样,两个扩展之间也不能互相遮蔽:
class B {} extend B { public func g() {} } extend B { // public func g() {} }
这个规则让成员查找更确定。看到 `x.g()` 时,编译器和读代码的人都不需要猜:到底是哪一个扩展抢到了这个名字。
直接扩展和接口扩展,思路不一样
给 `String` 加 `printSize` 属于直接扩展。它只是补一个成员函数,没有改变类型和接口之间的抽象关系。
接口扩展则更进一步:它能让一个已有类型实现某个接口。比如我们定义一个 `PrintableSize`,然后让 `String` 通过扩展实现它。
interface PrintableSize { func printSize(): Unit } extend String <: PrintableSize { public func printSize(): Unit { println("the size is ${this.size}") } } func show(item: PrintableSize) { item.printSize() } main() { show("123") }
这时 `String` 不只是“多了一个成员函数”,还可以被当作 `PrintableSize` 使用。接口扩展适合把已有类型纳入新的抽象体系,让业务代码获得更灵活的多态表达。
导入导出规则决定它能走多远
扩展的导入导出规则很值得注意,尤其是扩展标准库类型时。
在当前包里直接扩展 `String`,当前包可以直接使用新成员。但直接扩展只要和被扩展类型不在同一个包,就不会作为公共能力导出。也就是说,你在自己的包里给 `String` 写了一个直接扩展,别的包导入你的包以后,并不能像本地成员一样使用它。
这条规则能避免一种混乱:一个包导入之后,全世界的 `String` 都突然多出各种成员函数。仓颉让扩展能力遵守包边界,代码关系会更可控。
如果你是在做应用内部的语法糖,直接扩展很顺手。如果你是在做公共库,就要认真设计 API:是提供普通函数,还是定义接口并用接口扩展,或者干脆封装一个新的业务类型。

什么时候适合给 String 加扩展
适合扩展的场景,通常有三个共同点。
第一,这个能力确实像目标类型自己的行为。比如 `text.printSize()`,`text.printBox()`,读起来都自然。
第二,它不需要额外存储状态。因为扩展不能加成员变量,所以它更适合纯计算,格式化,转换,校验这类能力。
第三,它应该服务于局部清晰,而不是制造全局惊喜。扩展让代码更短,但也要让团队成员容易找到它在哪里定义,为什么存在。
一个稳妥的命名方式,是让扩展函数表达出明确动作:
extend String { public func printDebugLabel(label: String) { println("${label}: ${this}") } } main() { "ready".printDebugLabel("status") }
扩展的美感在于,它让已有类型长出更贴近业务的表达,却仍然尊重源码,封装和包边界。学会它以后,你会发现很多工具函数都可以变成更顺手的成员调用。
学习仓颉编程,就找九丘教育!
夜雨聆风