a + b 为什么能加两个自定义向量?for v in seq 为什么能遍历你自己写的类?答案是协议:一组约定俗成的双下划线方法名。定义了 __add__,+ 就认识你;定义了 __iter__/__next__,for 就认识你。本章把八大族协议逐一实测,连缺省时的回退行为也不放过。
前面三章把类的"数据"(字段)、"逻辑"(方法)、"外观"(属性)讲完了,本章解决最后一个问题:类如何接入语言的运算符与语句。xlang 的答案不是"运算符重载"这个独立特性,而是协议——一组双下划线包裹的普通方法名:a + b 在编译期翻译成 a.__add__(b),x in bag 翻译成 bag.__contains__(x),for v in seq 翻译成 __iter__ 加反复 __next__。协议方法就是普通方法:同样走重载解析,同样可以按参数类型写多个版本(深入细节 1 有 cgen 证据)。本章按"算术、一元、比较、索引、包含、调用、迭代、显示"八族逐一实测,并给出三条容易踩空的边界:比较协议缺省时不回退到逻辑推导而是引用语义(24.4);协议绑定按静态类型解析,var 会失效(24.10);构造表达式不能直接当运算/迭代操作数(坑 3、坑 4)。
理解"协议 = 换名普通方法,运算符 = 语法糖"的模型;会写算术六协议(__add__/__sub__/__mul__/__div__/__mod__/__pow__)与一元三协议(__neg__/__pos__/__not__);掌握比较六协议的缺省回退规则(实测:引用语义,不做逻辑推导);会用 __item__/__setItem__ 做多参数索引、__contains__ 支持 in、__call__ 做可调用对象;会按 __iter__/__next__ 两段式写可迭代类;掌握 __toString__ 与 string() 的关系及 println 的盲区;理解协议的静态性与 var、构造表达式的两条限制。
协议方法就定义在 class 里,语法与普通方法完全相同,只是名字被语言"预定"了:
class Vec float x = 0.0 create(float x) self.x = x end func __add__(Vec o) as Vec return Vec(self.x + o.x) end end auto a = Vec(7.0) auto b = Vec(2.0) auto s = a + b // 编译期翻译成 a.__add__(b) println(s.x) // 9.0
三个要点先记住:
func __add__(Vec o) as Vec 就是一个普通方法声明,参数类型、返回类型由你定,甚至 __mul__(float k)(标量)与 __mul__(Vec o)(向量)可以共存,按实参类型重载。a + b 只是 a.__add__(b) 的好看写法,左操作数是接收者(self),右操作数是参数。(a + b).x 这种"括号表达式直接取字段"的写法 RC0 不接受(坑 2),本书所有示例都先赋给局部变量。内置类型的 ** 幂运算也在这里一并说明:2 ** 10 得 1024、2 ** 0 得 1、2 ** -1 得 0(负指数截断)。但 ** 只收整数操作数——2.0 ** 0.5 直接编译报错(坑 1)。
六个二元算术运算符各有对应协议,全部实测可用:
class Vec float x = 0.0 create(float x) self.x = x end func __add__(Vec o) as Vec return Vec(self.x + o.x) end func __sub__(Vec o) as Vec return Vec(self.x - o.x) end func __mul__(float k) as Vec // 标量乘法:参数类型自由 return Vec(self.x * k) end func __div__(float k) as Vec return Vec(self.x / k) end func __mod__(int k) as Vec return Vec(float(int(self.x) % k)) end func __pow__(int k) as Vec float r = 1.0 for i in range(k) r = r * self.x end return Vec(r) end end auto a = Vec(7.0) auto b = Vec(2.0) auto s = a + b println(s.x) // 9.0 auto d = a - b println(d.x) // 5.0 auto m = a * 3.0 println(m.x) // 21.0:走 __mul__(float) auto q = a / 2.0 println(q.x) // 3.5 auto r = a % 4 println(r.x) // 3.0 auto pw = b ** 3 println(pw.x) // 8.0
注意 __mul__/__div__ 的参数写的是 float k——协议方法没有"参数必须是同类"的规定,签名完全按业务需要设计;同名协议再写一个 __add__(float k) 版本,a + 10.0 与 a + b 会各自落到正确的重载上(实测输出 11.0 与 3.0)。
负号、正号、逻辑非各对应一个零参协议:
class Box int v = 5 create(int v) self.v = v end func __neg__() as Box return Box(0 - self.v) end func __pos__() as Box return Box(self.v) end func __not__() as bool return self.v == 0 end end auto b = Box(5) auto n = -b println(n.v) // -5 auto p = +b println(p.v) // 5 auto z = Box(0) println(not z) // true:关键字形态 println(!z) // true:符号形态,同样走 __not__
not z 与 !z 是同一个运算符的两种写法,都翻译为 z.__not__()。返回类型同样自由——__not__ 按惯例返回 bool,__neg__/__pos__ 通常返回同类新实例(值语义:原对象不变)。
==、!=、<、<=、>、>= 对应 __eq__/__ne__/__lt__/__le__/__gt__/__ge__,逐个定义、逐个生效:
class Box int v = 0 create(int v) self.v = v end func __eq__(Box o) as bool return self.v == o.v end func __lt__(Box o) as bool return self.v < o.v end end auto a = Box(1) auto b = Box(1) println(a == b) // true:走 __eq__,同值即等 println(a != b) // true:意外!见下文
上面第二行输出是全章最重要的实测发现:比较协议缺省时不做逻辑推导,回退到引用语义。只定义 __eq__ 时,a != b 并不会自动取反 __eq__——它回退为"两个对象是不是同一个引用",a、b 是两个实例,引用不同,得 true。用区分性用例验证过缺省的 <=、>=、> 同理:两个同值实例 a = Box(1)、b = Box(1),a <= b 得 true、a >= b 得 false——若从 __lt__ 推导(a<=b 等价 !(b<a))两者都该为 true;实测结果与对象地址先后一致,与 __lt__ 推导矛盾,可判定回退为引用序。三条推论:
__item__ 管读、__setItem__ 管写,参数就是方括号里的下标——可以是多个:
class Matrix array<float> data = [0.0, 0.0, 0.0, 0.0] create() end func __item__(int x, int y) as float return self.data[x * 2 + y] end func __setItem__(int x, int y, float v) self.data[x * 2 + y] = v end end auto m = Matrix() m[1, 0] = 7.5 // m.__setItem__(1, 0, 7.5):末参是写入值 println(m[1, 0]) // 7.5:m.__item__(1, 0) println(m[0, 0]) // 0.0
与第 23 章的维度属性对照着记:维度属性是 m.value[1, 0](名字 + 方括号,可以有很多个不同名字的索引入口),索引协议是 m[1, 0](直接挂在对象上,全类只有一个)。__setItem__ 的最后一个参数固定是写入值,其余是下标——与 let 维度属性同一约定。下标类型不符时编译器会拒绝,但 RC0 的诊断文案比较内部化:[hir] typed HIR value expression is missing its static type,看到它先检查下标类型。
x in bag 翻译为 bag.__contains__(x),返回 bool:
class Bag create() end func __contains__(int x) as bool return x > 0 and x < 10 end end auto b = Bag() println(5 in b) // true println(50 in b) // false if 5 in b then println("5 in bag") end
in 是普通二元运算符,可以直接放进 println、if 条件、布尔表达式。"容器"的语义完全由 __contains__ 的实现决定——上面的 Bag 连字段都没有,照样能回答"在不在里面"。
定义 __call__ 后,对象本身可以像函数一样被调用:
class Adder int base = 0 create(int b) self.base = b end func __call__(int x, int y) as int return self.base + x * 10 + y end end auto c = Adder(2) println(c(2, 3)) // 25:c.__call__(2, 3) = 2 + 20 + 3
可调用对象是"带状态的函数":base 存在实例里,每次调用都看得见。策略对象、累加器、带缓存的计算器都是它的典型用途——比传函数指针(第 14 章)多携带一份数据。
让 for 认识你写的类需要两个类、两个协议:可迭代类提供 __iter__() 返回迭代器;迭代器提供 __next__() 返回元组 (bool, T)——bool 表示"还有没有",T 是本次值:
class Seq int total = 0 create(int n) self.total = n end func __iter__() as SeqIter return SeqIter(self.total) end end class SeqIter int stop = 0 int i = 0 create(int n) self.stop = n self.i = 0 end func __next__() as (bool, int) if self.i < self.stop then self.i = self.i + 1 return true, self.i end return false, 0 end end auto s = Seq(4) for v in s println(v) // 1 2 3 4 end auto s2 = Seq(3) for i, v in s2 println(i * 10 + v) // 1 12 23:i 是 0 起的序号 end
要点:for v in s 每次循环调一次 __next__,bool 为 false 时停;for i, v in s 双变量形态里 i 是 int 序号,从 0 起,与值内容无关。两段式的好处是迭代状态(i)与数据本体(total)分离——同一个 Seq 可以同时开两个循环互不干扰。另注意 for v in Seq(4) 把构造表达式直接写进 for 会被拒(坑 3),先赋变量再迭代。
__toString__ 决定对象被 string() 转换时的文本形态:
class Pt int x = 0 int y = 0 create(int x, int y) self.x = x self.y = y end func __toString__() as string return "Pt(" + string(self.x) + ", " + string(self.y) + ")" end end auto q = Pt(3, 4) println(string(q)) // Pt(3, 4):string() 走 __toString__ println(q) // [class:0000000000a8aa58]:println 不走协议! println(type(q)) // Pt:type() 返回类名
两个反直觉的实测结论:println(obj) 不调用 __toString__——它直接打印对象句柄形态 [class:地址],想看到自定义文本必须显式 println(string(q));反过来,string() 依赖 __toString__——类没定义它时 string(q) 直接报 [sem] 该表达式不能转换为目标类型: target string, source __class_handle(坑 5)。另外 type(q) 返回的是类名 "Pt",与文档中列出的 __typeName__ 协议无关——实测定义 __typeName__ 返回别的字符串,type() 的输出不变。
协议绑定发生在编译期、按静态类型解析——这让 var(动态类型)与类实例的组合格外危险:
func test() var v = Vec(1.5) auto w = v + Vec(2.5) println(w.x) // null:协议没有生效,结果退化了 end test()
同一个文件里把 var v 换成 auto v(静态类型 Vec),输出就是正常的 4.0。var 形态下没有编译错误、没有运行时异常,只悄悄给出 null——与第 20 章 var 装 time 再 as time 得 time(0) 的缺陷同族。全局作用域更直接:var v = Vec(1.5) 或 Vec v = Vec(1.5) 写在模块顶层,部分形态会报 [checked] module global class constructor boxing failed(坑 4)。结论:持有类实例请用 auto 或显式类型(函数内),让协议在静态类型上解析;var 只留给真正的动态场景。顺带一条语法备注:无返回值函数不要写 as void——会报 [sem] 未知类型: void,像上面的 test() 一样省略返回类型即可。
把四族协议装进一个类——复数的加、乘、相等、显示:
class Complex float re = 0.0 float im = 0.0 create(float re, float im) self.re = re self.im = im end func __add__(Complex o) as Complex return Complex(self.re + o.re, self.im + o.im) end func __mul__(Complex o) as Complex return Complex(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) end func __eq__(Complex o) as bool return self.re == o.re and self.im == o.im end func __toString__() as string return string(self.re) + " + " + string(self.im) + "i" end end auto a = Complex(1.0, 2.0) auto b = Complex(3.0, 4.0) auto s = a + b println(string(s)) // 4.0 + 6.0i auto p = a * b println(string(p)) // -5.0 + 10.0i auto a2 = Complex(1.0, 2.0) println(a == a2) // true:同值即等 println(a == b) // false
四个协议各司其职:__add__/__mul__ 让数学式子写得像数学,__eq__ 让断言可读,__toString__ 让调试输出能看。注意乘法结果 (1+2i)(3+4i) = 3+4i+6i+8i² = -5+10i,输出分毫不差——协议方法里的逻辑就是普通逻辑,语言不加任何魔法。乘法那行跨行书写也顺手验证了:return 的表达式允许换行续写。
对 24.2 的 Vec 跑 xl cgen,生成的 C 声明是:
命名规则 xl_class_类名__方法名 与普通方法一字不差——__add__ 里的双下划线原样进了符号名。这解释了为什么协议方法可以按参数类型重载(__mul__(float) 与 __mul__(Vec) 是两个不同函数)、为什么 virtual 机制对它同样适用:协议从来不是独立特性,只是编译器在翻译运算符时去查的一张约定名字表。第 23 章属性的 ______get______/______let______ 换名方法对,是同一张桌子上的另一套餐具。
"定义 __eq__ 后 != 自动取反"是许多语言(如 C++20 的 != 重写、Python 的 __ne__ 缺省)都会做的便利。xlang RC0 不做:六个比较协议各自独立,缺谁谁就回退到引用语义(== 与 != 回退引用同一性,四个序比较回退地址序——后者实测与分配先后一致)。这个设计把"值等价"与"同一性"的决定权完全留给作者,代价是写一半比完全不写更危险:完全不写时 ==/!= 至少自洽(都看引用),只写 __eq__ 反而让 a == b 与 a != b 同时为 true 成为可能。工程建议:要么六个比较协议全写(体力和正确性都可控,大多是一行委托),要么在代码评审里把"孤立的 __eq__"列为坏味道。第 25 章的 derive 派生会为这个痛点给出编译器级解法——derive equals 一族能让编译器替你生成自洽的比较族。
除了六比较,还有一个独立的近似运算符 ~=,对应 __approx__ 协议:
浮点"差不多相等"有了正式语言位置,不必在 __eq__ 里纠结容差。另一侧的边界也要知道:and/or 短路逻辑不可重载(设计文档明确,短路求值时序不允许被用户代码接管),位运算与赋值运算符同样不在协议表内。协议覆盖的是"算术、一元、比较(含 ~=)、索引、包含、调用、迭代、显示"这八族,超出这个范围的运算符行为固定。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:** 只接受整数操作数——底和指数都得是 int,2.0 ** 0.5(求平方根)这种数学写法直接被拒。正确做法:整数幂用 **;开方、任意实数幂改用数学函数或循环自建,自定义类的 __pow__ 参数同样按 int 设计。
原因:字段访问的接收者只接受名字、成员、索引、调用四种形态,括号包住的表达式不在其列。正确做法:先赋变量再取字段——auto s = a + b 然后 s.x;本书全部示例按此写法。
原因:类构造表达式不能嵌在 for 的迭代位(函数内把它当二元运算操作数也报同族错误)。正确做法:auto s = Seq(4) 先落地,再 for v in s。
原因:模块全局作用域里,类构造只支持 auto x = T(...) 这一种简单形态;显式类型声明、构造表达式当操作数都会触发装箱失败。正确做法:全局一律 auto a = Vec(1.5);需要显式类型或组合表达式时移进函数体内。
原因:println(obj) 永远打印 [class:地址],与 __toString__ 无关;string(obj) 才走协议,但协议缺失时就是这条类型转换错误。正确做法:给类补 __toString__,输出时统一写 println(string(obj))。
| 协议族 | 方法名 → 语法 | 要点 |
|---|---|---|
| 算术 | __add__/__sub__/__mul__/__div__/__mod__/__pow__ → + - * / % ** | 参数类型自由,可重载;内置 ** 只收整数 |
| 一元 | __neg__/__pos__/__not__ → - + not/! | not 与 ! 同协议;通常返回新实例 |
| 比较 | __eq__/__ne__/__lt__/__le__/__gt__/__ge__ | 缺省回退引用语义,不做逻辑推导;要写就写全 |
| 索引 | __item__/__setItem__ → obj[下标...] | 多下标可行;__setItem__ 末参是写入值 |
| 包含 | __contains__ → x in obj | 返回 bool,容器语义自定 |
| 调用 | __call__ → obj(实参...) | 可调用对象 = 带状态的函数 |
| 迭代 | __iter__ + __next__ → for v in obj | 两段式;__next__ 返回 (bool, T);i 从 0 起 |
| 显示 | __toString__ → string(obj) | println 不走协议;缺失时 string() 报错 |
| 近似 | __approx__ → a ~= b | 容差自定义;and/or 短路不可重载 |
| 静态性 | 协议按静态类型绑定 | var 装实例协议失效得 null;全局构造只许 auto 形态 |
编写 vec2.xl:class Vec2 存 x、y 两个 float,实现 __item__(int i) 与 __setItem__(int i, float v)——i 为 0 读写 x,其余读写 y。演示 v[0]、v[1] 的读与写,最后打印 v[0] + v[1]。
参考思路:__item__ 里 if i == 0 分流,__setItem__ 里 if/else 分流写入;实测输出 3.0、4.0、改写后 70.0。下标协议让 Vec2 同时拥有 v.x 与 v[0] 两种访问形态。
编写 arith.xl:class Arith 存首项、公差、项数,按两段式实现迭代(__iter__ 返回 ArithIter,__next__ 返回 (bool, int))。用 for v in 打印 Arith(1, 2, 5)(应得 1 3 5 7 9),再用 for i, v in 验证序号(i * 100 + v 应得 10、120、230)。
参考思路:迭代器字段 value/step/left,__next__ 先判 left <= 0 返回 false,再取当前值、步进、left 减一后返回 true——先取后推进去,首项才不会丢。注意 for 里不能直接写 Arith(...),先赋变量(坑 3)。
编写 approx.xl:class Approx 包装一个 float,实现 __approx__(容差 0.001)与 __toString__。验证 1.0 ~= 1.0005 得 true、1.0 ~= 2.0 得 false,并用 println(string(a)) 打印实例。
参考思路:差值取绝对值用"if d < 0.0 then d = 0.0 - d"的手工写法(内置 abs 留给读者查 stdlib);__toString__ 里用 string(self.v) 拼接。对照深入细节 3 检查 ~= 与 == 的分工:近似比较走 __approx__,__eq__ 保持严格相等,互不干扰。