第 26 章 / 共 58 章 卷五 OOP

object 与 union

class 是规则严明的公民,本章的两位是"非常规户":object 给对象装上弹性——运行期随意增删字段;union 把抽象剥到见底——几个字段共享同一块内存,连"当前是哪个字段"都不记。一个最松,一个最硬,却都是 class 家族的正式成员。

本章导读

前五章把 class 的标准形态讲完了:字段固定、方法齐全、协议可扩、derive 可省。本章讲 class 家族的两翼。object 是标准库提供的普通基类,class X extends object 之后实例获得动态字段能力——运行期随手写 p.rank = 9,不需要事先声明,配置袋、 duck typing、快速原型都靠它;第 25 章留下的伏笔(动态字段是否参与序列化)也在本章揭晓。union 走相反方向:它是 C 风格的共享内存布局声明,所有字段占同一块存储,写 d 读 i 看到的是同一批字节的另一种解读——它不是"安全的代数数据类型",而是 C ABI 的翻译官,为第 32 章的 C 互操作铺路。两者共性值得记住:都不是新的对象系统——object 是普通 class、union 是值类型,语言刻意不造第二套机制。

本章学习目标

会用 extends object 读写动态字段(点号与方括号两形态);掌握动态字段三条边界(缺失读得 null、与声明字段双存储、object() 可直接实例化);知道动态字段参与 derive json 序列化且排在声明字段前;能按"要方法继承还是要万用装箱"在 object 与 var 间选型;会声明顶层具名 union 与类内匿名 union,理解共享内存的位级证据;掌握 union 的值语义(传参/赋值/容器全复制);逐条记住 union 的九条限制与 typedef/== 两处 RC0 失效;理解"DEMO6 不提供 struct、union 只服务 C ABI"的设计口径。

26.1 两个极端总览

object(extends object)union
本质标准库普通基类,携带字段协议值类型,C 风格共享内存布局
字段声明字段 + 运行期任意新增编译期固定,共享同一块存储
语义引用语义(class 家族)值语义(传参/赋值即复制)
方法/继承/协议全部支持(就是 class)全部不支持
典型场景配置袋、动态属性、序列化载体C ABI、二进制布局、协议头

一句话定位:object 给 class 加弹性,union 给类型减抽象。接下来先松后硬。

26.2 extends object 动态字段

继承 object 之后,实例可以在运行期随意增写字段——点号和方括号两种写法等价:

dyn.xl
class Actor extends object
    int id = 0
    create(int id)
        self.id = id
    end
end
auto a = Actor(7)
a.hp = 120          // 未声明的字段,直接写
a.name = "hero"
a["score"] = 30     // 方括号形态,等价
println(a.id)       // 7:声明字段照常
println(a.hp)       // 120
println(a["score"])  // 30
a.hp = a.hp + 5
println(a.hp)       // 125:动态字段参与运算
println(type(a))    // Actor

三个要点:动态字段不需要声明,写即创建;点号形态(a.hp)与方括号形态(a["score"])读写在同一个动态字典上;声明字段、方法、继承、协议一切照常——Actor 仍然是第 21~25 章那个 class,只是基类换成了 object。object 不是保留字,是标准库注入的普通类名;auto o = object() 直接实例化一个"纯动态袋"也合法(o.tag = 1 即用)。

26.3 动态字段的边界

三条实测边界,条条有坑相:

  • 读不存在的字段得 null——不报错、不异常,println(a.missing) 输出 null,type 也是 null。写错字段名不会有人提醒你,拼写检查靠自觉(对照:普通 class 写未声明字段是编译错误 [sem] unknown class/union field,见坑 1)。
  • 声明字段与动态字典是两套存储——a.id = 42 写声明字段,a["id"] = 43 写动态字典里的同名条目;之后 a.id 读 42、a["id"] 读 43,互不覆盖。点号永远优先走声明字段,方括号永远走动态字典。同名共存是合法状态,也是阅读噩梦——别让两种写法指同一个名字。
  • 动态字段写 null 后读回 null——与"从未写过"无法区分;语言不提供动态字段的删除与遍历 API(RC0),把它当"只增不减的袋子"用。

26.4 动态字段与 derive json

第 25 章的伏笔揭晓:动态字段参与序列化,且排在声明字段之前,roundtrip 完整还原:

dynjson.xl
class Player extends object
    derive json
    int id = 0
    string name = ""
    create()
    end
end
auto p = Player()
p.id = 7
p.name = "hero"
p.rank = 9             // 动态字段
p.guild = "红宝石"     // 动态字段
string text = json.stringify(p)
println(text)   // {"rank":9,"guild":"红宝石","id":7,"name":"hero"}
auto q = json.parse::<Player>(text)
println(q.rank)  // 9:动态字段被还原
println(q.guild) // 红宝石

这使 extends object + derive json 成为弱结构数据的最佳载体:声明字段放稳定核心(id、name),动态字段收零散附加(rank、guild),序列化一键带走。注意 25.10 的边界依旧有效——动态字段值若是 null 的 class 引用同样危险;袋里只放标量、string、容器最安全。

26.5 object 与 var 选型

两者都能"装任意东西",但抽象层级不同:

维度object 动态字段var
定位形状可变的对象类型擦除的值箱
访问点号/方括号,直读直写取出须 as 显式转换
家族能力方法、继承、协议、derive 全有无(只是值)
典型风险读缺失字段静默 nullas 错类型 fatal(第 15 章)

经验法则:要"像个对象"用 object,要"装个值"用 var。配置、记录、 duck typing 选 object(还能挂方法);跨函数搬运算术值、异构容器元素选 var。var dict 也能当动态袋(bag["hp"]),但没有点号语法、不能挂方法——那是数据,不是对象。

26.6 union 与共享内存

union 在顶层必须具名,字段用 类型 名 声明(不带默认值),裸声明即零初始化:

union.xl
union Payload
    int i
    float64 d
end
Payload p          // 零初始化,无 create
println(p.i)        // 0
p.i = 42
println(p.i)        // 42
println(type(p))    // Payload
p.d = 3.5
println(p.d)        // 3.5
println(p.i)        // 4615063718147915776:3.5 的位模式!

最后一行是 union 的灵魂:所有字段共享同一块内存。写入 d 后,i 读到的不是旧值 42,而是 3.5 的 IEEE 754 位模式按整数解读的结果。xlang 不记录"当前激活字段"、不生成 tag、不做任何保护——读哪个字段是你的责任。这是故意的:union 的全部意义就是描述 C 的内存布局,多一分抽象就偏离 ABI 一分。

26.7 union 的值语义

union 与 int、float 一样是值类型——传参复制、赋值复制、进容器复制:

value.xl
union Bits
    int i
    float64 f
end
func mutate(Bits b)
    b.i = 999
end
Bits a
a.i = 10
mutate(a)
println(a.i)         // 10:传参是复制,函数改不到原件
Bits c
c = a
c.i = 20
println(a.i)         // 10:赋值也是复制
array<Bits> arr = [a]
arr[0].i = 30
println(a.i)         // 10:进容器还是复制

与 class 的引用语义(第 21 章)恰好相反——这份"怎么折腾都不影响原件"正是 C 结构体的行为。容器配合也到位:set<Payload> 按位内容去重(set{42, 42, 43} 的 length 是 2),底层是 memcmp。但注意一个 RC0 裂缝:a == b 直接比较两个 union 会报下游 tcc 错误(坑 5),值比较目前只存在于容器内部路径。

26.8 匿名 union 与嵌套

class 体内可以写匿名 union,字段直接暴露为 class 的字段——用来模拟 C 结构体里的匿名 union:

nested.xl
class Packet
    int tag = 0
    union
        int code
        float64 ratio
    end
    create()
    end
end
auto pkt = Packet()
pkt.tag = 7
pkt.code = 200
println(pkt.code)     // 200:像普通字段一样访问
pkt.ratio = 1.5
println(pkt.code)     // 4609434218613702656:共享内存依旧

union Payload2
    int i
    float64 d
end
class Envelope
    Payload2 payload    // 具名 union 作 class 字段
    create()
    end
end
auto e = Envelope()
e.payload.i = 9
println(e.payload.i)  // 9:嵌套访问

两种嵌套形态各司其职:匿名 union 让 Packet 拥有"tag 加一段共享内存"的 C 结构体布局;具名 union 作字段则保持独立类型身份(Envelope 嵌套 Payload2)。匿名 union 的字段名不能与 class 其他成员冲突(报 [sem] duplicate union field name 同款检查)。

26.9 union 限制清单

九条限制全部实测,诊断原文照录:

限制诊断/现象
无方法写 func 报 [parse] 语法错误
无 create/destroy[parse] 语法错误
无继承extends 报 [parse] 语法错误
字段无默认值[sem] union fields do not support default values
不能为空[sem] union must contain at least one field
字段名不重复[sem] duplicate union field name
顶层必须具名[sem] top-level union must have a name
class 内必须匿名[sem] class inline union must be anonymous in this phase
字段须平凡 C 兼容类型var/class 字段报 [sem] union field type must be a plain C-compatible value type

另有两处实测出入要留意:string 字段可以声明使用(文档口径禁止非平凡类型,实测漏网——写入后读其它字段看到的是字符串指针的位值,生命周期风险自担,别依赖);typedef 别名 union 的字段访问失效——typedef Bits = Payload 后 type(b) 正确显示 Payload,但 b.i 读写都报 [checked] class field receiver is not a class value,别名目前只能用于类型标注场景。

26.10 口径:union 不是 struct

设计文档把话挑明了:DEMO6 不提供 struct 语句,union 也不是"未来安全联合类型"的雏形——它就是 C ABI 的声明工具。三个推论:

  • 想要"带 tag 的可辨联合"请绕道——语言不记录激活字段;要"几种形状之一且运行期可知",用 class 继承(第 22 章)或 var 装箱(第 15 章)表达,别在 union 上等待 enum union。
  • 想要普通值聚合也请绕道——没有 struct 意味着"几个字段各占各的内存"的值类型目前只能由 union 单字段形态或 class 承担;多字段 union 天然是共享布局。
  • union 的高光场景在边界上——extern/export 函数的参数与返回值(第 32 章 C 互操作详解)、二进制协议头的编解码(本章实战)。具名 union 生成的 C typedef 与 C 侧布局一字节不差,这正是它存在的理由。

实战:二进制头解析

经典场景:收到一个 32 位的"打包字",既要整体比较,又要拆出其中的字节字段——union 让"两种解读"零成本共存:

parse.xl
union IpAddr
    int u32       // 整体:网络字节序的 32 位地址
    int8 first    // 首字节:共享同一存储的低 8 位
end
IpAddr ip
ip.u32 = 2130706433          // 0x7F000001,即 127.0.0.1
println(ip.u32)              // 2130706433
println(ip.first)            // 1:低 8 位(小端机的首字节)
println(ip.u32 / 16777216)   // 127:高 8 位,A 段

class Header
    int kind = 0
    union
        int code      // 文本协议:状态码
        float64 ratio   // 二进制协议:压缩率
    end
    create()
    end
end
auto h = Header()
h.kind = 1
h.code = 404
println(h.kind * 10000 + h.code)   // 10404
auto h2 = Header()
h2.kind = 2
h2.ratio = 0.75
println(h2.ratio * 100.0)         // 75.0

Header 演示了 C 协议解析的标准手法:kind 当"人工 tag",匿名 union 承载随 kind 变化的载荷——语言不帮你记激活字段,你自己用 kind 记。这与 C 代码里 struct { int type; union { ... } } 的布局一模一样,也正是它生成的 C 代码的样子(深入细节 2)。读哪支字段之前先看 kind,是这类代码铁律。

深入细节

拾遗 1:object 动态字段的代价——cgen 证据

对 26.2 的 Actor 跑 xl cgen,动态字段读写编译为:

a.hp = 120 → xl_class_object______setField____(&a->base, "hp", 值) println(a.hp) → xl_class_object______field____(&a->base, "hp") a["score"]=30 → xl_class_object______setItem____(&a->base, "score", 值) 底层:xvoTableSetValue(obj, key, 0, value, false)

"object 是普通基类"在这里落地:动态字段靠 object 携带的 __field__/__setField__/__item__/__setItem__ 四个字段协议实现(第 24 章协议家族成员),底层是一张 xvalue 哈希表。代价随之清晰:声明字段访问是编译期偏移(零成本),动态字段访问是一次方法调用加一次字符串键哈希查找——热路径请用声明字段,动态字段留给冷数据。这也解释了 26.3 的双存储:点号先查声明布局、落空才进协议;方括号直接进协议。

拾遗 2:union 的 cgen 布局——与 C 一字节不差

26.6 的 Payload 生成的 C 代码是教科书级直白:

typedef union XL_Union_Payload2 { long long i; double d; } XL_Union_Payload2; // 复制函数:memcpy(dst, src, sizeof(XL_Union_Payload2))

值语义的实现就是 memcpy——这就是为什么传参、赋值、进容器"总是复制"。类内匿名 union 生成 C 匿名 union 字段,布局与手写 C 结构体完全一致。第 32 章讲 extern/export 时会回到这里:具名 union 可以直接做 C ABI 的参数与返回值类型,导出头文件里的 typedef 就是上面这段——xlang 侧和 C 侧看到的是同一块内存的同一副图纸。

拾遗 3:双存储评注——弹性的价格

26.3 的"同名双存储"(a.id=42 与 a["id"]=43 共存)初看像 bug,其实是分层设计的必然:声明字段走编译期固定布局(快、可校验),动态字典走运行期哈希表(慢、免声明),两套各有存在理由,强行合并反而两头不靠。但语言可以做得更好——至少 a["id"] 与 a.id 同名时给一次警告。在那之前,团队规范补上这条:extends object 的类里,方括号只用于真正动态的键(运行期才知道名字的),声明过的字段永远用点号。弹性是好东西,前提是每个人都知道哪块地是弹性的。

⚠️ 常见错误

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

坑 1:给普通 class 写未声明字段
# bad.xl 内容:class Plain 没有 extends object # p.rank = 9(第 7 行) > xl run bad.xl bad.xl:7:3: [sem] unknown class/union field

原因:动态字段是 extends object 的专属能力,普通 class 的字段集在编译期封闭。正确做法:要动态字段就继承 object;只要一两个额外属性,老老实实声明字段。

坑 2:union 字段带默认值
# bad.xl 内容: # union Bad / int a = 1 / end > xl run bad.xl bad.xl:2:9: [sem] union fields do not support default values

原因:union 字段共享内存,"默认值"无从谈起——整个 union 只有一份零初始化。正确做法:去掉默认值;需要初始值就在声明后显式赋值。

坑 3:union 里放 var 或 class 字段
# bad.xl 内容:union Bad / int i / var v / end > xl run bad.xl bad.xl:3:5: [sem] union field type must be a plain C-compatible value type

原因:var、class、容器、资源句柄都需要生命周期管理,而 union 不记激活字段、无法析构——只能放平凡 C 兼容值类型。正确做法:字段换成 int/float 家族、point、time 等值类型;string 实测漏网可用但有生命周期风险,别依赖。

坑 4:class 内写具名 union
# bad.xl 内容: # class Packet / union payload / int code / end / end > xl run bad.xl bad.xl:3:11: [sem] class inline union must be anonymous in this phase

原因:class 内嵌 union 当前只支持匿名形态(字段直接暴露);具名内联 union 需要嵌套访问规则,RC0 直接拒绝。正确做法:要么匿名内联,要么把具名 union 提到顶层、作为 class 字段类型引用(26.8 的 Envelope 写法)。

坑 5:直接用 == 比较两个 union
# bad.xl 内容:Payload a, b 各赋值后 # println(a == b) > xl run bad.xl tcc: __xlvfs__/module.c:867: error: invalid operand types for binary operation

原因:union 的值比较只实现在容器内部路径(set 去重按 memcmp 正常),== 运算符没有为 union 生成比较调用,下游 C 编译器报出不优雅的错误。正确做法:逐字段比较(a.i == b.i),或借助 set<T> 的成员判定。

本章小结

主题规则要点
动态字段extends object 后写即创建点号/方括号等价;读缺失得 null;object() 可裸实例化
双存储声明字段与动态字典同名共存点号走声明、方括号走字典,互不覆盖
动态字段 × json参与序列化,排在声明字段前roundtrip 完整;弱结构数据首选载体
object vs var像对象用 object,装值用 varobject 有方法/继承/derive;var 是类型擦除值箱
union 基础顶层具名;字段共享内存;裸声明零值写 d 读 i 得位模式;无 tag 无保护
union 值语义传参/赋值/容器全复制(memcpy)与 class 引用语义相反;set 按位去重
嵌套形态类内匿名 union / 具名 union 作字段匿名字段直达 pkt.code;具名保持类型身份
union 限制九条禁令(方法/继承/默认值/空/重名等)string 字段漏网勿依赖;typedef 别名字段访问失效;== 不可用
设计口径union = C ABI 工具,非 struct 非安全联合激活字段自己记(人工 tag);第 32 章回链

复习题

✍️ 先遮住答案想一想
  1. extends object 的实例读一个从未写过的字段会怎样?普通 class 写未声明字段又会怎样?
  2. a.id = 42 与 a["id"] = 43 之后,a.id 和 a["id"] 各读到什么?为什么?
  3. 动态字段参与 derive json 序列化吗?输出里字段顺序有什么规律?
  4. union 的 p.d = 3.5 之后读 p.i 得到什么?这个行为是缺陷吗?
  5. union 传参后函数内修改字段,调用方看得到吗?背后的实现机制是什么?
  6. 为什么说 union 不是"安全联合类型"?想要可辨联合该怎么办?
参考答案
  1. extends object:静默得 null(type 也是 null),无报错。普通 class:编译期 [sem] unknown class/union field——动态字段是 extends object 专属。
  2. a.id 读 42、a["id"] 读 43。声明字段与动态字典是两套存储:点号优先查编译期布局,方括号直进字段协议字典,同名互不覆盖。
  3. 参与。动态字段排在声明字段之前输出({"rank":9,...,"id":7,...}),roundtrip 完整还原。
  4. 得到 3.5 的 IEEE 754 位模式按 int 解读的值(4615063718147915776)。不是缺陷——union 的全部语义就是共享内存布局,不记激活字段是刻意的 C 兼容设计。
  5. 看不到——union 是值类型,传参 memcpy 复制;赋值、进容器同理。set<Payload> 按位去重(memcmp),但 == 运算符对 union 不可用(RC0 裂缝)。
  6. union 不生成 tag、不记录当前是哪个字段、读错字段无人拦。要"几种形状之一且运行期可知"用 class 继承或 var 装箱;或像本章实战那样用一个人工 tag 字段(kind)自己记。

编程练习

练习 1:动态配置袋

编写 profile.xl:class Profile extends object,声明 version 字段并 derive json。运行期写入 theme/fontSize/sidebar 三个动态字段,json.stringify 打印,再 parse 回来验证动态字段还原;最后读一个从未设置的字段观察输出。

参考思路:输出形如 {"theme":"dark","fontSize":14,"sidebar":true,"version":1}——动态字段在前;读缺失字段得 null(这就是 26.3 的静默规则,练习里亲眼见一次印象最深)。

练习 2:寄存器分解

编写 reg.xl:union Reg 含 int whole 与 int8 lowByte。写入 whole = 305419896(0x12345678),读 lowByte 验证低 8 位;再把 lowByte 改成 1,读 whole 观察高 24 位不动、低 8 位被换。最后裸声明一个 Reg 验证零初始化。

参考思路:预期输出 120(0x78)、305419777(0x12345601)、0——低字节在小端机上与 whole 的低 8 位共享存储,改哪头另一头都看得见。

练习 3:颜色打包器

编写 color.xl:class Rgb extends object,声明 r/g/b 字段与 pack() 方法(r×65536+g×256+b);union Packed 含 int argb 与 int8 blue。把 Rgb(18, 52, 86) 打包进 Packed,读 argb 整体值与 blue 通道;再给实例写动态字段 note 并打印——一个类同时用上声明字段、方法、动态字段,旁边站着共享内存的 union。

参考思路:预期 1193046、86、note 原文。打包用算术、解包靠 union——两种思路在同一程序里对照,正是本章"加弹性"与"减抽象"的合影。