第 23 章 / 共 58 章 卷五 OOP

属性 get/let

属性让"读写字段"的语法执行"调用方法"的逻辑:c.area 背后是一次计算,p.hp = -5 背后可以是一道校验。get 管读、let 管写,还能带维度参数把属性变成索引形态——本章逐条实测。

本章导读

字段是"存什么",方法是"做什么",属性站在中间:语法上像字段(c.area、p.hp = 42),执行上像方法(读取跑 get、写入跑 let)。它的两个经典用途:计算属性——面积、模长这类"从其他字段推出、不值得存"的值;受控写入——血量不能为负、年龄不能倒退这类"写之前先校验"的约束。xlang 的属性还支持维度参数(m.value[1]、g.at[2, 4])和 virtual/override 多态,本章把声明形态、读写组合、索引回退规则逐一实测,并给出字段/属性/方法的选型准则。

本章学习目标

会写 get 计算属性与 let 受控写入;掌握只读/只写/读写三种组合及两条越权报错;会用维度参数把属性做成索引形态(含多维);理解 virtual get/let 的多态派发;掌握属性上复合赋值的语义与求值次数;理解索引歧义的回退规则与维度优先;能按"存数据/要逻辑/带参数"三问做选型。

23.1 计算属性 get

get 名字() as 类型 声明一个只读计算属性,访问时不加括号:

get.xl
class Circle
  float r = 2.0
  get area() as float
    return 3.14159 * self.r * self.r
  end
end
auto c = Circle()
println(c.area)   // 12.56636:像读字段,实际跑了一遍计算
c.r = 3.0
println(c.area)   // 28.27431:每次访问重新求值

每次访问都执行 get 体——没有缓存。用副作用探针实测:get 里给计数字段 +1,读两次 c.tick,计数变成 2。这意味着 get 里写重逻辑要掂量成本;也意味着 get 永远返回最新值,不存在"存下来的面积忘了更新"的问题。

23.2 可写属性 let

let 名字(类型 v) 给属性加写入通道,校验逻辑就放在这里:

let.xl
class Player
  private int hpValue = 100
  get hp() as int
    return self.hpValue
  end
  let hp(int v)
    if v < 0 then
      self.hpValue = 0      // 写入校验:血量不为负
    else
      self.hpValue = v
    end
  end
end
auto p = Player()
println(p.hp)   // 100
p.hp = 42
println(p.hp)   // 42
p.hp = -5
println(p.hp)   // 0:被 let 拦下修正

三种组合与对应诊断(实测):

  • 只读(只有 get):写入报 [sem] class property assignment requires matching let。
  • 只写(只有 let):读取报 [sem] unknown class/union field——与 private 方法同款"查无此名"文案。
  • 读写(get + let):同签名下 let 的末参类型须与 get 返回类型兼容。

private 字段 + public 属性是封装的进阶版:第 21 章用 reveal()/check() 方法做的事,属性用字段语法就做到了,还多了一层写入校验。

23.3 带维度参数的属性

get/let 可以带维度参数,访问形态从点号变成方括号——属性就此变成"索引外观":

dim.xl
class Matrix
  array<int> cells = [10, 20, 30]
  get value(int idx) as int
    return self.cells[idx]
  end
  let value(int idx, int v)   // let 末参固定是写入值
    self.cells[idx] = v
  end
end
auto m = Matrix()
println(m.value[1])   // 20:方括号形态
m.value[1] = 99
println(m.value[1])   // 99

注意访问语法是 m.value[1] 而非 m.value(1)——维度属性不是方法,圆括号调用报 [sem] unknown class/union field(坑 3)。同一名字的零维属性与维度属性可以共存(23.7 的回退规则由此展开)。

23.4 多维属性

维度可以不止一个,let 的最后一个参数固定是写入值,前面都是维度:

multi.xl
class Grid
  int base = 10
  get at(int x, int y) as int
    return self.base + x * 10 + y
  end
  let at(int x, int y, int v)   // x、y 是维度,v 是写入值
    self.base = v + x * 10 + y
  end
end
auto g = Grid()
println(g.at[2, 4])   // 34:10 + 20 + 4
g.at[2, 4] = 5
println(g.base)      // 29:5 + 20 + 4

约束(设计文档明确、实测印证):维度参数只支持位置参数;.../*** 可变参数报 [sem] property parameters cannot be vararg;同名不同维度数的属性算重载(get at(x) 与 get at(x, y) 可共存)。

23.5 virtual 属性与多态

属性和方法一样参与 virtual/override 体系,读写两侧独立派发:

vprop.xl
class Entity
  int baseValue = 1
  virtual get prop() as int
    return 10
  end
  virtual let prop(int value)
    self.baseValue = value
  end
end
class Player extends Entity
  int bonus = 5
  override get prop() as int
    return 70
  end
  override let prop(int value)
    self.bonus = value
  end
end
Entity e = Player()
println(e.prop)   // 70:读派发到 Player 的 get
e.prop = 9          // 写派发到 Player 的 let
Player p = e as Player
println(p.bonus)  // 9

第 22 章的规则原样适用:覆盖必须 override、目标必须 virtual、签名必须精确。属性因此可以进入类的"契约"——父类定义"每个实体都有 prop",子类各自实现存储方式。

23.6 复合赋值与求值次数

读写双全的属性支持 +=、-= 等复合赋值,语义是"get 一次、let 一次":

compound.xl
auto p = Player()
p.hp = 10
p.hp += 5     // 等价 p.hp = p.hp + 5:先 get 后 let
println(p.hp)  // 15
p.hp -= 3
println(p.hp)  // 12

维度属性的复合赋值有一个关键保证(官方测试用例锁定):索引表达式只求值一次——p.hp[propertyIndex(2)] += 5 里 propertyIndex 只被调用一次,不会像朴素展开那样求值两遍。用计数函数验证过:一次复合赋值,调用计数恰好 +1。

23.7 索引歧义与回退

obj.prop[i] 有两种可能的含义,解析顺序实测如下:

  • 维度属性优先——存在 get prop(int) 时,obj.prop[1] 走维度属性。
  • 否则回退——只有零维属性且它返回 array/list/dict/class 时,obj.prop[1] 等价 (obj.prop)[1],索引的是属性的返回值。
fallback.xl
class Team
  array<string> data = ["a", "b"]
  get names() as array<string>
    return self.data
  end
end
auto t = Team()
println(t.names[1])   // b:无维度属性,回退为 (t.names)[1]

设计文档还给了 (obj.prop)[i] 强制索引返回值的写法,但实测在维度属性存在时它仍走维度属性(应索引返回值却返回了维度结果)——这条强制通道 RC0 尚未生效,记为待修空白;需要索引返回值时,先把属性赋给局部变量再取下标即可。

23.8 字段/属性/方法选型

三者的边界由两个问题决定:要不要逻辑?要不要参数?

选择场景代价/收益
字段纯数据,读写无需干预零开销直接访问;日后加校验要改调用点
属性读取要计算 / 写入要校验 / 要索引外观每次访问一次调用;调用点无感,可随时从字段升级
方法有副作用的动作、参数与"值"无关语义最直白;调用点必须写括号

经验法则:名词用属性,动词用方法——area、length、hp 是名词(对象的某种"值"),draw、save、attack 是动词(对象做的"事")。计算昂贵又无参数时,宁可在 get 里缓存到字段(自己管失效),也别让每次访问跑重活。

深入细节

拾遗 1:属性编译成了什么——方法对证据

用 xl cgen 观察 23.2 的 hp 属性,生成的核心代码是:

long long xl_class_Player______get______hp(XL_Class_Player* self); void xl_class_Player______let______hp(XL_Class_Player* self, long long v); // p.hp = 7 → xl_class_Player______let______hp(p, 7LL) // println(p.hp) → xl_class_Player______get______hp(p)

属性没有任何特殊运行时:get/let 就是一对换名方法,访问语法在编译期翻译成对应调用,分发方式与普通方法相同(virtual 属性走虚表,普通属性直接调用)。这解释了两件事:属性开销 = 一次方法调用;"属性是字段语法的方法"不是比喻,是实现。

拾遗 2:get/let 的命名来源

为什么写入侧叫 let 而不是 set?设计文档给出的理由是避免与 set 容器类型撞名——xlang 已有 set<T>(第 17 章),如果属性写入侧也叫 set,set 一词就同时承担"集合类型""赋值动作"两个角色,解析与阅读都会纠缠。get/let 取自 BASIC 家族的传统(LET x = 1),读作"让它成为"。文档还规定 get/let/set 不得作为变量名与成员名,但 RC0 对变量名尚未强制执行(实测 int let = 1 可用)——作为字段名则会让编译直接失败(坑 5),写代码时照旧避开这三个词。

拾遗 3:属性与性能——求值时机备忘录

23.1 的副作用探针值得再强调:属性访问没有缓存,for 循环里写 c.area 一千次就跑一千次计算。三种应对:热路径先把属性读进局部变量;get 体内自建缓存(字段存结果 + 脏标记);干脆改方法命名(calcArea())让成本显形。反过来,这个"每次求值"也是正确性保证——属性永远不会返回过期值,这是它与"字段存派生值"方案的本质差异。

⚠️ 常见错误

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

坑 1:给只读属性赋值
# bad.xl 内容:只有 get hp(),没有 let # p.hp = 1 > xl run bad.xl bad.xl:8:3: [sem] class property assignment requires matching let

原因:写入通道由 let 单独声明,缺 let 即只读。正确做法:补 let hp(int v);只读是刻意的就改从方法写入底层字段。

坑 2:读只写属性
# bad.xl 内容:只有 let secret(int v) # println(v.secret) > xl run bad.xl bad.xl:9:11: [sem] unknown class/union field

原因:没有 get 的名字在读取位置查无此名——报错文案与"不存在"相同,容易误判为拼写错误。正确做法:补 get;只写属性本就少见,先确认是不是把"设值方法"误写成了属性。

坑 3:用圆括号调维度属性
# bad.xl 内容:get value(int idx) 已声明 # println(m.value(3)) > xl run bad.xl bad.xl:8:11: [sem] unknown class/union field

原因:维度属性不是方法,调用语法是方括号。正确做法:m.value[3];想要圆括号语义就老老实实写方法。

坑 4:维度参数用可变参数
# bad.xl 内容: # get value(...) as int / return 0 > xl run bad.xl bad.xl:2:13: [sem] property parameters cannot be vararg

原因:属性的维度签名必须是固定位置参数(... 与 *** 都被禁)。正确做法:声明确定维度数;不定长索引用 array 参数或方法承载。

坑 5:用 get 作字段名
# bad.xl 内容: # class C / int get = 1 > xl run bad.xl script entry xlang_main not found

原因:get/let/set 是属性声明的上下文关键字,用作 class 字段名会让编译产物丢失入口(RC0 的下游怪异报错,非优雅诊断)。正确做法:字段改名(如 getCount → count);普通局部变量用这三个词 RC0 暂不拦,但文档已禁,别依赖。

本章小结

主题规则要点
getget 名() as 类型;访问不加括号每次访问都求值,无缓存
letlet 名(类型 v);写入入口校验逻辑的家;只读写报 requires matching let
组合只读/只写/读写只写读报 unknown class/union field(文案误导)
维度属性get 名(维度...);访问 obj.名[i]let 末参是值;圆括号调用无效;禁 vararg
virtual 属性virtual get/let + override读写两侧独立派发,规则同方法
复合赋值+= 等 = get 一次 + let 一次维度索引表达式只求值一次
索引回退维度优先;否则索引属性返回值(obj.prop)[i] 强制写法 RC0 未生效
选型名词属性、动词方法、纯数据字段属性 = 字段语法的方法对(cgen 证据)

复习题

✍️ 先遮住答案想一想
  1. get 属性和"把计算结果存进字段"相比,正确性与性能各有什么差异?
  2. 只有 get 的属性被赋值、只有 let 的属性被读取,各报什么错?
  3. let value(int idx, int v) 里哪个参数是写入值?m.value[1] = 9 调用时实参怎么对应?
  4. virtual 属性的读写两侧分别怎么派发?
  5. obj.prop[1] 在什么条件下索引的是属性的返回值?
  6. 为什么写入侧叫 let 不叫 set?
参考答案
  1. 属性每次访问重新求值——永远最新但每次都花成本;字段存结果——读取零成本但可能过期。热路径把属性读进局部变量,或在 get 内自建缓存。
  2. 只读属性被赋值:[sem] class property assignment requires matching let;只写属性被读取:[sem] unknown class/union field(像"不存在",实为无 get)。
  3. 末参 v 是写入值,前面都是维度。m.value[1] = 9 调用 let value(1, 9)。
  4. 读取按动态类型派发到子类的 get,写入派发到子类的 let——与方法的 vtable 机制相同,两侧独立。
  5. 当该类没有匹配的维度属性、且零维属性返回 array/list/dict/class 时,回退为 (obj.prop)[1];存在维度属性时维度优先。
  6. 避免与 set<T> 容器类型撞名(一词不兼两职);let 取自 BASIC 家族"让它成为"的传统。

编程练习

练习 1:温度转换

编写 temp.xl:class Temperature 私有存摄氏度字段,提供 celsius 读写属性与 fahrenheit 读写属性(写 fahrenheit 时换算回摄氏度存储)。演示双向读写。

参考思路:f = c × 1.8 + 32,反向 c = (f - 32) / 1.8;存储只留一份(摄氏度),另一个单位纯计算——属性的"单一事实源"经典案例。

练习 2:受限计数器

编写 counter.xl:class Counter 的 count 属性读写,let 里把值钳制在 0~100(小于 0 记 0,大于 100 记 100)。再用 += 演示复合赋值也受钳制。

参考思路:复合赋值 = get + let,钳制只在 let 里写一次就两种入口都管住;对照 23.6 的语义验证输出。

练习 3:虚拟棋盘行

编写 board.xl:class Board 内部用 array<int> 存 9 格棋盘,提供维度属性 cell(int i) 读写(带越界保护:越界读返回 -1、越界写忽略),再提供 get row(int r) 返回某行三格之和。打印验证。

参考思路:cell 用方括号访问 b.cell[4];row 带维度参数,访问 b.row[1];越界保护逻辑全部收进 get/let,调用点永远安全——这就是维度属性相对裸数组的价值。