第 27 章 / 共 58 章 卷五 OOP · 收官

拷贝语义

"b = a 之后改 b,a 会变吗?"——答案取决于 a 是 int、class 还是容器。xlang 的复制体系分三层:值类型按位拷贝、class 永远共享引用、容器共享 backing 直到你显式 copy/clone。本章把第 12 章参数传递、第 25 章 derive、第 26 章 union 三条线收拢成一张完整地图,卷五收官。

本章导读

前面六章埋下了三条关于"复制"的线索:第 12 章讲参数传递时说"byval 对 class 复制的是句柄不是对象";第 25 章讲 derive 时区分了 copy 浅复制与 clone 深复制;第 26 章讲 union 时看到值语义的实现就是 memcpy。本章把它们拼成完整图景,回答四个问题:默认什么时候复制?(几乎从不——赋值、传参、返回、闭包、进容器都只搬引用);想复制时怎么复制?(显式 copy()/clone(),且类型必须事先用 derive 或手写协议开放能力);浅与深差在哪?(只差在 class 引用字段/元素——标量、string、容器字段在浅复制里也按值走);哪些坑会无声致命?(无 derive 调 copy 静默 fatal、循环引用 clone 直接崩溃)。读完本章,"b = a 之后改 b,a 会变吗"这类问题你将有确定性的答案。

本章学习目标

掌握三层复制模型(值类型按位/class 引用共享/容器共享 backing);理解"默认不复制"原则及其在赋值、传参、byval 中的一致性;分清 copy() 浅复制与 clone() 深复制对 class 字段的不同处理;会手写 __copy__/__clone__ 做定制复制(脱敏、重置);掌握容器复制的动静不对称(var 容器 clone 克隆 class 元素、typed 容器不克隆);记住两条危险边界(无 derive 静默 fatal、循环引用崩溃)及防御写法;能按决策表在赋值/copy/clone/byref 间正确选型。

27.1 三层复制模型

层成员b = a 的语义独立副本怎么来
值类型int/float/bool/string/time/point/union按位复制,天然独立赋值即是;copy() 原样返回
class普通/object 派生/native-backed共享同一受管理引用copy()/clone()(须 derive 或手写协议)
容器array/list/set/dict(typed 与 var)共享同一 backingcopy()/clone() 建独立容器

三层的共同点:复制从不隐式发生两次——值类型一次按位复制就够了,class 和容器则永远先共享、想要独立再显式开口。这个"默认共享、显式复制"的原则是本章所有规则的源头。

27.2 默认值:不复制

把第 12 章的结论放进复制框架再看一遍:赋值、默认参数、byval、返回、闭包捕获、容器存储,全都不调用任何复制协议——它们只让两个名字指向同一个对象:

default.xl
class Item
    derive copy
    int v = 0
    create(int v)
        self.v = v
    end
end
func touch(byval Item x)
    x.v = 99          // byval 也不是复制!改的是同一个对象
end
auto a = Item(1)
auto b = a           // 共享
touch(a)
println(b.v)          // 99:赋值共享 + byval 共享,两处都看得见

byval 复制的只是"句柄"(参数变量本身),被调函数里给参数重新赋值不影响调用方,但通过句柄改成员双方都可见。要真正的独立对象,只有一个入口:copy()/clone()——而且类型得先开放这个能力。

27.3 copy() 浅复制

copy(obj) 要求类声明 derive copy 或手写 __copy__,返回一个"一层新"的对象:

copy.xl
class Config
    derive copy
    derive clone
    int level = 0
    create(int level)
        self.level = level
    end
end
class App
    derive copy
    derive clone
    string name = ""
    Config cfg = Config(0)
    create(string name, int level)
        self.name = name
        self.cfg = Config(level)
    end
end
auto app = App("demo", 1)
auto twin = copy(app)
twin.cfg.level = 9      // cfg 是共享引用
twin.name = "twin"       // name 是按值带走的
println(app.cfg.level)  // 9:改副本的 class 字段,原件遭殃
println(app.name)       // demo:string 字段早已各走各路

"浅"的精确含义:标量、string、array 字段按值复制,class 类型字段只复制引用。副本与原对象因此"半独立"——顶层字段互不干扰,深层对象藕断丝连。第 25 章已见过这个模型,本章的增量是:它同样适用于容器(27.6),且有手写定制的入口(27.5)。

27.4 clone() 深复制

clone(obj) 递归到底——class 字段也各自生成独立副本:

clone.xl
auto ghost = clone(app)
ghost.cfg.level = 5
println(app.cfg.level)  // 9:clone 的 cfg 是独立副本,互不波及
println(ghost.name)     // demo:顶层字段照旧按值

三条规则:嵌套的类自己也必须开放 clone 能力(derive clone 或手写 __clone__),否则编译报"字段类型必须可复制";深复制不会中途退化为浅复制(XRT 为 copy/clone 提供独立操作槽);clone 的对象图必须无环——循环引用会让进程崩溃(27.9)。

27.5 手写 __copy__/__clone__

复制协议可以完全自己写——签名固定,返回新实例;与 derive 共存时手写优先:

custom.xl
class Session
    derive copy          // derive 在场,但下面的手写版胜出
    int token = 0
    create(int token)
        self.token = token
    end
    func __copy__() as Session
        auto fresh = Session(0)   // 复制即脱敏:token 不带过去
        return fresh
    end
end
auto s = Session(99)
auto guest = copy(s)
println(s.token)      // 99
println(guest.token)  // 0:手写 __copy__ 的定制逻辑

手写协议的价值场景:复制时脱敏(凭证、缓存不带走)、复制时重置(统计清零、时间戳刷新)、复制时换算(官方测试用例里 __copy__ 给 value +10、__clone__ +20,用来区分两条路径)。与第 24 章协议家族同一规则:协议就是普通方法,只是名字被预定。手写版与 derive 同在一个类时,实测手写生效(derive 产物被覆盖)——与第 25 章 __eq__/__toString__ 的优先级结论一致。

27.6 容器的复制

容器赋值共享 backing——没有写时复制(见深入细节 2 的考证);copy()/clone() 才建独立容器。差异在"容器里装 class"时显现,而且 typed 与 var 两个世界不对称:

container.xl
var items = [Item(1), Item(2)]
var shared = items
shared[0].v = 99
println(items[0].v)    // 99:赋值共享 backing
var shallow = copy(items)
shallow[0].v = 55
println(items[0].v)    // 55:copy 复制容器壳,class 元素仍共享
var deep = clone(items)
deep[0].v = 11
println(items[0].v)    // 55:clone 连元素一起克隆,彻底独立
println(deep[0].v)     // 11

实测汇总四条:var 容器——copy 递归复制嵌套容器(三层实测均独立)但 class 元素保持共享引用;clone 连 class 元素也调 __clone__,完全独立。typed 容器——copy/clone 都只复制容器壳,class 元素在 clone 里也不克隆(array<Item> 实测 clone 后改元素仍波及原数组),要深复制 typed 容器得手动遍历逐个 clone。嵌套 typed 容器读出即复制——array<array<int>> 的 m[0] 取出的是内层副本,改它影响不到 m;链式 m[0][0] 读写直接编译报错(坑 5)。标量元素容器(array<int> 等)——壳独立即全独立,copy/clone 效果相同。

27.7 标量与 union 的复制

值类型不需要复制协议——copy()/clone() 对它们就是原样返回:

scalar.xl
println(copy(42))       // 42
println(clone("hello"))  // hello
var v = 7
println(copy(v))         // 7
union Bits
    int i
    float64 f
end
Bits b
b.i = 99
Bits b2 = copy(b)
b.i = 1
println(b2.i)            // 99:union 复制=memcpy,天然独立

union 的复制就是第 26 章那块 memcpy——不需要 derive、不需要协议,和 int 一样是"复制免费户"。string 看似引用类型,但不可变,按值传递从不出事。

27.8 move 与 moveOnly

第 25 章见过 derive moveOnly,本章给它的语义画上句号:xlang 没有用户级 move() 函数——写 move(a) 报 [sem] undeclared identifier 'move'。"移动"发生在两个地方:

  • 返回值优化——函数 return 一个局部 class/native 对象时,所有权直接转交调用方,不经过复制协议,析构恰好一次(官方测试用例锁定:native 资源 return 后只关闭一次)。这是"移动"唯一真实发生的场景,而且是编译器自动做的。
  • derive moveOnly 的声明意义——它不为移动提供新语法,而是明确"此类型禁止复制"(与 derive copy/clone 互斥,第 25 章坑 5),把资源型类的身份写进类型系统,让误用 copy() 的人至少在阅读代码时得到提示。

所以"xlang 有移动语义吗"的准确回答是:有,但只在返回值的边界上自动发生;你能控制的只有"禁止复制"(moveOnly)与"显式复制"(copy/clone)两个开关。

27.9 两条危险边界

边界一:无能力复制 = 静默 fatal。对没写 derive copy/clone(或手写协议)的类调 copy()/clone(),编译期放行,运行期进程直接退出(退出码 4,零输出)。防御写法有两层:写库时——class 一定义就决定复制能力(可复制就 derive,资源型就 moveOnly),把决定留在类型旁边;用时——复制别人的类之前先翻它的声明,"这个类 derive 了什么"应该成为肌肉记忆。这也是 review 清单的好条目:搜索 copy( 与 clone( 调用点,逐一核对目标类型。

边界二:循环引用 clone = 崩溃。a.next = b、b.next = a 之后 clone(a),进程崩溃(退出码 127,与第 25 章 json 循环序列化同病)。规避:clone 前把对象图树形化(断开回边);必须用图结构时放弃 clone,手写"按 id 重建"的复制逻辑。这两条边界的共同教训:复制能力是把对象图当树来处理的——环与资源都不在它的合同里。

27.10 复制决策表

你的处境选择理由
只想多个名字指同一对象直接赋值 / byval零成本,默认即共享
要让函数改我的变量本体byref / out绑定变量槽,调用点显式标记
顶层独立、深层共享就行copy()配置副本、轻量分叉
完全独立的对象树clone()快照、存档;前提是无环
复制时要脱敏/重置字段手写 __copy__/__clone__定制逻辑,优先于 derive
typed 容器装 class 要深复制遍历逐个 clone 重建clone 对 typed 容器元素不递归
资源型对象(native 句柄)derive moveOnly,别复制复制即双 Owner,fatal 等着你

口诀:共享是默认,复制要开口,深浅看字段,环资源莫入。

实战:文档快照

把本章的决策串起来:一个文档系统,页面是 var dict(文档对象 + 历史数组),存档要完全独立的快照,外发要脱敏副本:

snapshot.xl
class Doc
    derive copy
    derive clone
    string title = ""
    create(string title)
        self.title = title
    end
end
var page = {"doc": Doc("v1"), "history": [Doc("h1")]}
var snap = clone(page)          // 存档:连 class 元素一起克隆
snap["doc"].title = "v2"
snap["history"][0].title = "h2"
println(page["doc"].title)          // v1:原件不动
println(page["history"][0].title)   // h1:嵌套元素也不动
println(snap["doc"].title)          // v2:快照自有一方天地

注意这个程序依赖的是 var 容器的 clone 行为(27.6:元素递归克隆)——若把 page 换成 typed 容器,快照就不会这么彻底,得手动遍历重建。选容器类型时,"未来要不要整体深复制"是一个真实的考量因素。

深入细节

拾遗 1:copy() 编译成了什么——cgen 证据

对 27.5 的 Session 跑 xl cgen,copy(s) 的编译产物分两步:

xvalue xl_class_Session______copy____(XL_Class_Session* self); // copy(s) → protocolValue = xl_class_Session______copy____(src) // 再把 protocolValue 的 payload 写入新 record // 标量/容器路径 → xvoCopy(value)(XRT 内置复制)

设计文档补完另一半:协议返回的是受管理引用,XRT 把结果的 payload 誊进新 record——所以 __copy__ 里 return self 也不会制造悬空句柄(xvoGetRecordOwner 找回外壳)。这套"协议生产 + 运行时落盘"的两段式,解释了为什么复制协议签名被锁死为"零参返回同类"(27.5):编译器要拿返回值做二次加工,形态不能自由。

拾遗 2:写时复制考——一份名存实亡的文档承诺

功能清单写着"动态 var 容器采用 XRT 写时复制:赋值时共享 backing,首次写入时分离"。本章用 run 与 build 两种模式实测:var b = a 之后 b[0] = 99,a[0] 也是 99;dict 同;b.append(3) 之后 a.length 也是 3——索引写与 append 都不分离,COW 在 RC0 并未生效。XRT 底层确实保留了 xvoEnsureUnique 设施,但 xlang 侧没有接线。写作本书以实测为准:今天的正确心智模型是"var 容器赋值即共享,与 typed 容器无异"。若未来版本补上 COW,27.6 的第一条规则需要修订——这也提醒读者:对"共享还是复制"这类基础语义,拿不准时写三行代码验证,比查文档可靠。

拾遗 3:卷五地图——class 家族七章回顾

第 21~27 章把 xlang 的对象体系讲完了,回望这张地图:21 章立起骨架(字段、方法、生命周期、引用语义);22 章加血缘(单继承、virtual 多态);23 章换外观(属性=换名方法对);24 章接神经(协议=运算符与语句的挂钩);25 章上自动档(derive=协议生产线);26 章开两翼(object 加弹性、union 减抽象);27 章收复制(默认共享、显式 copy/clone)。一条主线贯穿始终:class 是唯一对象模型,其余一切都是它的协议、派生或布局变体。卷六将进入并发世界(线程、channel、async),对象们要开始跨线程旅行了——"共享是默认"这条原则,在那时会有新的分量。

⚠️ 常见错误

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

坑 1:对无复制能力的类调 copy()
# bad.xl 内容:class Plain 无 derive 无协议;auto c = copy(p) > xl run bad.xl (无任何输出,进程退出码 4)

原因:复制是"许可制"但 RC0 在编译期不查,运行期静默 fatal。正确做法:给类补 derive copy/clone 或手写协议;排查"程序无声消失"优先怀疑此类 fatal(第 25 章坑 2 同族)。

坑 2:以为 byval 会复制对象
# func touch(byval Item x) / x.v = 99 # 调用后 println(a.v) 输出 99——调用方的对象被改了

原因:byval 复制的是参数句柄(重新绑定不可见),不是对象本体(成员修改可见)。正确做法:函数不该碰原对象时,调用方先 copy()/clone() 再传,或函数内先复制再改。

坑 3:以为容器赋值得到独立副本
# var b = a; b[0] = 99; println(a[0]) → 99 # array<int> y = x; y[0] = 9; x[0] 同样变

原因:var 与 typed 容器赋值都共享 backing——文档里的写时复制 RC0 未生效(run/build 双模式实测)。正确做法:要独立容器显式 copy()/clone();标量元素两者等效,装 class 时注意 27.6 的动静差异。

坑 4:指望 clone 深复制 typed 容器的 class 元素
# array<Item> deep = clone(items) # deep[0].v = 11; println(items[0].v) → 11:元素仍共享

原因:typed 容器的 copy/clone 只复制容器壳,不递归调元素的 __clone__(var 容器的 clone 才会)。正确做法:手动遍历建新容器逐个 clone;或整体改用 var 容器。

坑 5:对嵌套 typed 容器用链式索引
# bad.xl 内容:array<array<int>> m # m[0][0] = 99 或 println(m[0][0]) > xl run bad.xl bad.xl:3:10: [checked] dynamic index assignment receiver must be array, list or dict bad.xl:4:13: [checked] index receiver must be array, list, dict or var

原因:typed 嵌套容器不支持 m[0][0] 链式索引;且 m[0] 读出的是内层副本,赋中间变量再改也影响不到 m。正确做法:需要真二维结构用 class 封装行(第 24 章 Matrix 的 __item__(x, y) 双参数形态),或改用 var 嵌套容器。

本章小结

主题规则要点
三层模型值按位/class 共享/容器共享 backing默认不复制;复制从不隐式发生
默认路径赋值/传参/byval/返回/闭包/存储只搬句柄;成员修改双方可见
copy()浅复制:顶层按值、class 字段共享须 derive copy 或手写 __copy__
clone()深复制:class 字段递归克隆嵌套类须有 clone 能力;对象图须无环
手写协议__copy__/__clone__ 零参返回同类手写优先于 derive;脱敏/重置场景
var 容器copy 复制嵌套容器;clone 连 class 元素也克隆赋值共享,COW 未生效(实测)
typed 容器copy/clone 只复制壳class 元素 clone 也不克隆;嵌套读出即副本
标量/unioncopy() 原样返回union 复制=memcpy,复制免费户
move无 move() 函数;返回值自动移动moveOnly=禁止复制的声明
危险边界无能力复制静默 fatal;循环 clone 崩溃防御:类型旁决定能力 + review 调用点

复习题

✍️ 先遮住答案想一想
  1. b = a 之后改 b.v,a.v 变不变?分 a 是 int、class、var 数组三种情况回答。
  2. copy(app) 之后改副本的 name 不影响原件、改副本的 cfg.level 却影响原件——为什么两种字段命运不同?
  3. 手写 __copy__ 与 derive copy 同在一个类里,copy() 走哪个?手写协议的典型用途是什么?
  4. var 容器与 typed 容器在 clone 上的行为差异是什么?实战中选择容器类型时这意味着什么?
  5. 为什么说"xlang 有移动语义但没有 move()"?moveOnly 的实际作用是什么?
  6. 对无 derive 的类调 copy()、对循环引用的对象调 clone(),各有什么后果?怎么防御?
参考答案
  1. int:不变(按位复制);class:变(赋值共享引用);var 数组:变(共享 backing,COW 未生效)。后两者要独立须显式 copy()/clone()。
  2. 浅复制里标量/string/array 字段按值带走(name 独立),class 类型字段只复制引用(cfg 共享)——"浅"只差在 class 引用字段。
  3. 走手写版(derive 产物被覆盖,与 __eq__/__toString__ 优先级一致)。典型用途:复制时脱敏(凭证不带走)、重置(统计清零)、定制换算。
  4. var 容器 clone 会递归克隆 class 元素,typed 容器只复制壳、元素保持共享。意味着需要"整体深快照"的数据结构优先选 var 容器,或接受手动遍历重建。
  5. 移动只在函数返回局部对象时由编译器自动完成(所有权转交、析构一次),语言没有 move() 函数(报 undeclared identifier)。moveOnly 是"禁止复制"的类型级声明,与 copy/clone 互斥。
  6. 无 derive 调 copy():编译放行、运行静默 fatal(退出码 4)——防御:类定义时即决定复制能力、review 时核对 copy/clone 调用点。循环引用 clone():进程崩溃(退出码 127)——防御:clone 前断环树形化,图结构改用按 id 重建。

编程练习

练习 1:复制即脱敏

编写 session.xl:class Session 带 token 字段,derive copy 同时手写 __copy__——返回一个 token 归零的新实例。验证 copy 出的"访客副本"拿不到原 token,而原对象不受影响。

参考思路:预期输出 99 与 0——手写协议优先于 derive 是解题钥匙(27.5);这也是"复制时该带走什么"的安全设计范式。

练习 2:深浅对照实验

编写 apps.xl:Config(level) 与 App(name, cfg) 都 derive copy/clone。对同一 App 分别 copy 和 clone,改两个副本的 cfg.level 与 name,打印原对象验证:copy 副本改 cfg 波及原件、改 name 不波及;clone 副本什么都不波及。

参考思路:预期输出 9 / demo / 9——与 27.3、27.4 的示例同构;亲手跑一遍,"浅差在 class 字段"就长在记忆里了。

练习 3:完全独立的页面快照

编写 page.xl:Doc(title) derive copy/clone;var page = {"doc": Doc("v1"), "history": [Doc("h1")]}。用 clone 做快照,改快照的 doc.title 和 history[0].title,验证原件纹丝不动;再用 copy 做一次,指出哪一层漏了。

参考思路:clone 版输出 v1 / h1 / v2(27.6 var 容器规则);copy 版改 doc.title 会波及原件(元素共享)——两层答案写进注释,就是现成的决策表应用。