第 24 章 / 共 58 章 卷五 OOP

协议方法全集

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、构造表达式的两条限制。

24.1 协议总览

协议方法就定义在 class 里,语法与普通方法完全相同,只是名字被语言"预定"了:

overview.xl
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)。

24.2 算术六协议

六个二元算术运算符各有对应协议,全部实测可用:

arith.xl
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)。

24.3 一元三协议

负号、正号、逻辑非各对应一个零参协议:

unary.xl
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__ 通常返回同类新实例(值语义:原对象不变)。

24.4 比较六协议

==、!=、<、<=、>、>= 对应 __eq__/__ne__/__lt__/__le__/__gt__/__ge__,逐个定义、逐个生效:

cmp.xl
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__ 推导矛盾,可判定回退为引用序。三条推论:

  • 第 21 章"实例 == 默认是引用同一性"只是这套规则的冰山一角——六个比较运算符各自独立判定,定义谁谁生效,缺谁谁回退引用语义。
  • 要让 != 与 == 自洽,__eq__ 与 __ne__ 必须成对定义;排序四兄弟同理,最省心的做法是六个全写(或只使用已定义的那个运算符)。
  • 地址序对业务毫无意义,缺省比较的输出不可依赖——别拿它当特性用。

24.5 索引协议

__item__ 管读、__setItem__ 管写,参数就是方括号里的下标——可以是多个:

index.xl
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,看到它先检查下标类型。

24.6 包含协议 in

x in bag 翻译为 bag.__contains__(x),返回 bool:

contains.xl
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 连字段都没有,照样能回答"在不在里面"。

24.7 调用协议 __call__

定义 __call__ 后,对象本身可以像函数一样被调用:

call.xl
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 章)多携带一份数据。

24.8 迭代两段式

让 for 认识你写的类需要两个类、两个协议:可迭代类提供 __iter__() 返回迭代器;迭代器提供 __next__() 返回元组 (bool, T)——bool 表示"还有没有",T 是本次值:

iter.xl
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),先赋变量再迭代。

24.9 显示协议 __toString__

__toString__ 决定对象被 string() 转换时的文本形态:

tostring.xl
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() 的输出不变。

24.10 协议的静态性

协议绑定发生在编译期、按静态类型解析——这让 var(动态类型)与类实例的组合格外危险:

vartrap.xl
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() 一样省略返回类型即可。

实战:复数 Complex

把四族协议装进一个类——复数的加、乘、相等、显示:

complex.xl
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 的表达式允许换行续写。

深入细节

拾遗 1:协议编译成了什么——普通方法的铁证

对 24.2 的 Vec 跑 xl cgen,生成的 C 声明是:

xvalue xl_class_Vec______add____(XL_Class_Vec* self, xvalue o); xvalue xl_class_Vec______sub____(XL_Class_Vec* self, xvalue o); xvalue xl_class_Vec______mul____(XL_Class_Vec* self, double k); xvalue xl_class_Vec______pow____(XL_Class_Vec* self, long long k);

命名规则 xl_class_类名__方法名 与普通方法一字不差——__add__ 里的双下划线原样进了符号名。这解释了为什么协议方法可以按参数类型重载(__mul__(float) 与 __mul__(Vec) 是两个不同函数)、为什么 virtual 机制对它同样适用:协议从来不是独立特性,只是编译器在翻译运算符时去查的一张约定名字表。第 23 章属性的 ______get______/______let______ 换名方法对,是同一张桌子上的另一套餐具。

拾遗 2:缺省回退语义——为什么不做逻辑推导

"定义 __eq__ 后 != 自动取反"是许多语言(如 C++20 的 != 重写、Python 的 __ne__ 缺省)都会做的便利。xlang RC0 不做:六个比较协议各自独立,缺谁谁就回退到引用语义(== 与 != 回退引用同一性,四个序比较回退地址序——后者实测与分配先后一致)。这个设计把"值等价"与"同一性"的决定权完全留给作者,代价是写一半比完全不写更危险:完全不写时 ==/!= 至少自洽(都看引用),只写 __eq__ 反而让 a == b 与 a != b 同时为 true 成为可能。工程建议:要么六个比较协议全写(体力和正确性都可控,大多是一行委托),要么在代码评审里把"孤立的 __eq__"列为坏味道。第 25 章的 derive 派生会为这个痛点给出编译器级解法——derive equals 一族能让编译器替你生成自洽的比较族。

拾遗 3:~= 近似比较与不可重载的边界

除了六比较,还有一个独立的近似运算符 ~=,对应 __approx__ 协议:

func __approx__(Approx o) as bool ...return d < 0.001 # 容差自己定 println(a ~= b) # true:1.0 与 1.0005 println(a ~= c) # false:1.0 与 2.0

浮点"差不多相等"有了正式语言位置,不必在 __eq__ 里纠结容差。另一侧的边界也要知道:and/or 短路逻辑不可重载(设计文档明确,短路求值时序不允许被用户代码接管),位运算与赋值运算符同样不在协议表内。协议覆盖的是"算术、一元、比较(含 ~=)、索引、包含、调用、迭代、显示"这八族,超出这个范围的运算符行为固定。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:对浮点数用 ** 幂运算
# bad.xl 内容: # println(2.0 ** 0.5)(第 2 行) > xl run bad.xl bad.xl:2:13: [sem] power operator operands must be integer

原因:** 只接受整数操作数——底和指数都得是 int,2.0 ** 0.5(求平方根)这种数学写法直接被拒。正确做法:整数幂用 **;开方、任意实数幂改用数学函数或循环自建,自定义类的 __pow__ 参数同样按 int 设计。

坑 2:括号表达式直接取字段
# bad.xl 内容: # println((a + b).x)(第 31 行) > xl run bad.xl bad.xl:31:17: [checked] class field receiver must be name, member, index or call

原因:字段访问的接收者只接受名字、成员、索引、调用四种形态,括号包住的表达式不在其列。正确做法:先赋变量再取字段——auto s = a + b 然后 s.x;本书全部示例按此写法。

坑 3:for 里直接写构造表达式
# bad.xl 内容: # for v in Seq(4)(第 27 行) > xl run bad.xl bad.xl:27:13: [checked] class constructor expression requires statement-level lowering

原因:类构造表达式不能嵌在 for 的迭代位(函数内把它当二元运算操作数也报同族错误)。正确做法:auto s = Seq(4) 先落地,再 for v in s。

坑 4:全局作用域的类构造写法越界
# bad.xl 内容(模块顶层): # Vec v = Vec(1.5) 或 auto w = Vec(1.5) + Vec(2.5)(第 11 行) > xl run bad.xl bad.xl:11:12: [checked] module global class constructor boxing failed

原因:模块全局作用域里,类构造只支持 auto x = T(...) 这一种简单形态;显式类型声明、构造表达式当操作数都会触发装箱失败。正确做法:全局一律 auto a = Vec(1.5);需要显式类型或组合表达式时移进函数体内。

坑 5:指望 println 自动调用 __toString__
# bad.xl 内容:Approx 定义了 __approx__/__typeName__ 但未定义 __toString__ # println(string(a))(第 22 行) > xl run bad.xl bad.xl:22:16: [sem] 该表达式不能转换为目标类型: target string, source __class_handle

原因: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 形态

复习题

✍️ 先遮住答案想一想
  1. 为什么说"协议不是运算符重载这个独立特性"?cgen 证据是什么?
  2. 只定义 __eq__ 的类,a == b 为 true 时 a != b 是什么结果?为什么?
  3. __setItem__(int x, int y, float v) 里哪个参数是写入值?m[1, 0] = 7.5 调用时实参如何对应?
  4. for i, v in seq 双变量形态里 i 是什么?迭代协议的两个方法各自返回什么?
  5. println(q) 与 println(string(q)) 对定义了 __toString__ 的对象各输出什么?
  6. var v = Vec(1.5) 之后 v + Vec(2.5) 会发生什么?正确写法是什么?
参考答案
  1. 协议方法是普通方法,运算符只是编译期翻译的语法糖。cgen 证据:__add__ 编译为 xl_class_Vec______add____,与普通方法同一命名规则,因此天然支持按参数类型重载。
  2. a != b 也是 true——__ne__ 缺省时不取反 __eq__,回退为引用同一性判定;a、b 是两个实例,引用不同。__eq__/__ne__ 必须成对定义。
  3. 末参 v 是写入值,前面都是下标。m[1, 0] = 7.5 调用 __setItem__(1, 0, 7.5)。
  4. i 是从 0 起的 int 循环序号,与值无关。__iter__() 返回迭代器对象;__next__() 返回元组 (bool, T),bool 为 false 时循环结束。
  5. println(q) 输出 [class:地址]——它不走协议;println(string(q)) 输出 __toString__ 的返回文本——string() 才查协议,未定义则编译报错。
  6. 编译通过但运行时 w.x 得 null——var 是动态类型,协议绑定没有生效。用 auto 或(函数内)显式类型让实例带静态类型,协议才能正确解析。

编程练习

练习 1:向量下标

编写 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] 两种访问形态。

练习 2:等差数列迭代器

编写 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)。

练习 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__ 保持严格相等,互不干扰。