本章导读
C 库的经典风格是 opaque handle:counter_open() 返回一个不透明的 void*,之后所有操作(counter_add/counter_value)都把它作为第一个参数传回去,用完 counter_close() 释放。这个模式安全、封装好,但生命周期管理是纯手工的——早退、异常路径、循环 break,每一条路都得记得 close。native-backed class 把这套函数组"认领"为 xlang class:extern "counter_open" create(...) 的返回值成为对象的 handle,extern "counter_close" destroy() 注册为析构,对象最后一次释放时自动调用一次。剩下的 extern 成员自动把 handle 作为第一个 C 参数注入。你从第 5 章起就在用它们——标准库的 File、Buffer、Random、TcpStream 全是 native-backed class,底层是 XRT 的 xfile/xbuffer/xrtRandObj。本章从声明形态写到完整 C 计数器实战,拷贝语义、借用模式、继承交互全部实测,包括一个继承方向导致的段错误(如实汇报)。
会写 native point handle + extern create/destroy/func 的完整声明;理解 create 返回值成为 handle、destroy 由对象最后释放时自动调用一次的生命周期映射;掌握 extern 成员"首参自动注入 handle"的绑定规则与类型限制;会混合 xlang 字段、普通方法、__toString__ 协议方法;理解拷贝语义(copy 得 null、clone 共享、别名共享、destroy 只跑一次)与 double free 防御设计;会写无 destroy 的借用模式(手工 point 绑定,owning class 拒收 const point);知道继承两个方向的实测结果(普通 extends native 正常,反向段错误);会在普通 class 与 native-backed class 之间做选型。
34.1 为什么需要它
先看没有 native class 时的写法:全部用第 32 章的 extern 手工管理——
extern "counter_open" func counterOpen(int seed) as point extern "counter_add" func counterAdd(point self, int v) as int extern "counter_close" func counterClose(point self) point c = counterOpen(40) counterAdd(c, 2) counterClose(c) // 忘了就是泄漏;调两次就是 double free
三个痛点:每次调用都要手动传 handle(噪音);任何一条控制流路径漏掉 close 就泄漏(第 28 章讲过的资源管理老问题);point 是不透明值,类型系统分不出"这是 Counter 的句柄还是 FILE 的句柄"。native-backed class 一次解决三个:handle 收进对象,方法调用自动注入,生命周期挂进 xlang 的受管理引用语义——对象没人用了,destroy 自动触发。设计文档的定位原话:"高性能或系统级数据结构可由 C/XRT 管理,再通过 native-backed class 零额外封装映射到 xlang"。
34.2 声明形态
最小完整声明只有四行核心——这是本章的主角,对照语法导览页的口径逐行拆解:
class Counter native point handle // ① C 资源指针的存放位 extern "counter_open" create(int seed) // ② 构造:返回值成为 handle extern "counter_close" destroy() // ③ 析构:最后释放时自动调一次 extern "counter_add" func add(int value) as int extern "counter_value" func value() as int // ④ 普通成员 end
四个要点。一,native point handle 是固定写法的字段声明,告诉编译器"这个 class 的 payload 里有一个 C 指针位";有 extern 成员的 native class 少了它直接报错(坑 1)。二,extern create 绑定的 C 函数返回值成为 handle——构造 Counter(40) 时实际调 counter_open(40),返回的 void* 存进 payload。三,extern destroy 绑定的函数在对象生命周期结束时自动调用,xlang 代码里永远不需要、也不应该手动调 close。四,普通 extern 成员看起来和普通方法声明一样,但底层 C 函数会收到 handle 作为第一个参数(34.4 详解)。使用端与任何普通 class 无差别:
auto c = Counter(40) print(c.add(2)) // 42 print(c.value()) // 42;作用域结束自动 counter_close
34.3 生命周期映射
"自动析构"不是宣传语,本章用一个全局计数器把每条控制流路径都数了一遍。C 侧 counter_close 每次被调用就 gCounterClosed++,xlang 侧逐个场景验证:
func scoped() auto c = Counter(1) c.add(1) end // 函数返回,c 释放 func earlyReturn() as int auto c = Counter(1) return c.add(1) // 提前 return 也释放 end func loopBreak() for i in range(0, 3) auto c = Counter(i) break // break 出循环也释放 end end scoped() print(counterClosed()) // 1 earlyReturn() print(counterClosed()) // 2 loopBreak() print(counterClosed()) // 3
官方回归用例 cgen_native_class_lifecycle.xl 覆盖得更密:continue、循环每次迭代、var 重绑定,destroy 计数全部精确。这套可靠性的根基是第 27 章讲过的受管理引用语义——native class 与普通 class 共用同一对象基座和同一 retain/unref 路径,最后一个引用消失时 type ops 里的 drop 回调触发 destroy。构造失败策略由 C 函数组自己定:counter_open 返回 NULL 时 handle 就是空指针,后续成员调用的行为由 C 侧决定(我们的 counter.c 选择 exit(2),标准库 File 的选择是返回 false 类结果)。
34.4 extern 成员绑定规则
C 侧函数组与 xlang 声明的对应关系,看本章实战的完整 C 库最清楚:
#include <stdlib.h> typedef struct Counter { long long value; } Counter; static long long gCounterClosed = 0; void* counter_open(long long seed) // ← create:返回 handle { Counter* p = (Counter*)malloc(sizeof(Counter)); if ( p == 0 ) { exit(2); } p->value = seed; return p; } long long counter_close(void* self) // ← destroy:首参 handle { free(self); gCounterClosed += 1; return 0; } long long counter_add(void* self, long long value) // ← 成员:首参 handle { Counter* p = (Counter*)self; p->value += value; return p->value; } long long counter_value(void* self) // ← 成员:首参 handle { return ((Counter*)self)->value; }
绑定规则四条,全部实测验证。一,首参自动注入:xlang 声明 func add(int value) 只有一个参数,C 函数 counter_add 有两个——编译器生成的 wrapper 会把 self->handle 作为第一个实参补上(深入细节 1 有 cgen 原文)。二,destroy 的 C 侧也收 handle,xlang 声明不写参数。三,C 符号在 class 内唯一:同一个 class 里两个成员绑同一个 C 符号会被拒(坑 3),这与第 33 章导出符号唯一性同理。四,类型限制继承 extern 规则:成员参数/返回值必须是 C ABI 直通类型(返回 array<int> 报错,坑 4),且不支持可变参数(坑 5)。混编命令就是第 32 章的老朋友:
$ xl run main.xl counter.c 42 42 $ xl build main.xl counter.c -o app.exe
34.5 混合 xlang 成员
native class 不是"只能装 extern 的空壳"——设计文档明确允许混合 xlang 字段、普通方法、属性、协议和静态成员。给 Counter 加一个 xlang 字符串字段和一个普通方法,再实现 __toString__ 协议:
class Counter native point handle string label = "counter" // 普通 xlang 字段 extern "counter_open" create(int seed) extern "counter_close" destroy() extern "counter_value" func value() as int func describe() as string // 普通方法可调 extern 成员 return self.label + "=" + string(self.value()) end func __toString__() as string // 协议 dunder 同样有效 return "Counter(" + string(self.value()) + ")" end end auto c = Counter(42) c.label = "ticks" print(c.describe()) // ticks=42 print(string(c)) // Counter(42)
两个细节值得说。一,xlang 字段与 handle 在 payload 里共存(cgen 产物是 struct { void* handle; const char* label; }),析构时先 counter_close(handle) 再释放 label——两类资源各归各的清理路径,全部自动。二,print(c) 直接打印对象得到的是 [custom:000000...] 地址形式,__toString__ 要被 string(c) 显式转换时才调用——这是 print 与 string 转换的分工,与普通 class 一致,不是 native 特例。derive 能力开关(copy/clone/value/moveOnly)在声明上都能写,但对拷贝行为的实际影响见下一节。
34.6 拷贝语义与防御
native handle 的所有权是独占的——两个对象共享同一个 handle 又各自析构,就是教科书级 double free。xlang 的防御分三层,全部实测:
| 操作 | 实测结果 | 解读 |
|---|---|---|
alias = c(赋值/参数/返回值) | 共享同一对象,add 互相可见 | 受管理引用,retain/unref 计数,安全 |
copy(c) | 得到 null(静态类型仍是 Counter) | 类型未注册复制回调,拒绝伪复制 |
copy(var 装的 c) | null,且 type(...) == null | var 丢失静态类型,连类型名都不剩 |
clone(c) | 新包装对象共享同一 handle;c == d 为 false;destroy 只跑一次 | 共享所有权,不是深拷贝 |
关键实测片段:
auto c = Counter(40) auto alias = c alias.add(2) print(c.value()) // 42 —— 别名共享,天然安全 auto rejected = copy(c) print(rejected == null) // true —— copy 直接给 null print(c.value()) // 40 —— 原对象不受影响 auto d = clone(c) d.add(2) print(c.value()) // 42 —— clone 共享 handle // 作用域结束:counter_close 只被调用 1 次(计数器实测)
设计文档的原文锚点:"有 destroy 的句柄不允许隐式复制;复制必须映射真实的 C clone/copy 能力"、"编译器和 XRT 都不得对 native pointer payload 使用 memcpy 模拟复制"。cgen 产物印证了实现:Counter 的 XRT type ops 表里 init/copy/move 三个回调位置全是 0——类型系统层面就没有复制能力。需要真正可复制的 native 资源时,正路是让 C 库提供真实的 counter_clone(),再写一个新 create 路径绑定它。注意一个使用纪律:copy 得到 null 后调方法会运行时崩溃(实测 exit 4),拿到 copy 结果先判空。
34.7 借用模式
不是所有句柄都该由 xlang 拥有——C 侧自己管理生命周期的池化对象、全局单例、返回借用指针的查找函数,都不该被 xlang 析构。设计文档的口径:"没有 destroy 映射时,xlang 只借用或传递句柄,不推断资源所有权"。声明里不写 extern destroy 就是借用模式,handle 还可以由已有 point 手工绑定:
class CounterView native point handle extern "counter_value" func value() as int // 无 create/destroy end extern "counter_open_raw" func openRaw(int seed) as point extern "counter_close_raw" func closeRaw(point p) point raw = openRaw(7) auto view = CounterView(raw) // 手工绑定已有句柄 print(view.value()) // 7 closeRaw(raw) // 生命周期归 C 侧手工管
所有权边界由编译器看守两个方向。一,手工绑定的实参必须是 point——Native(1) 传 int 报 native-backed class handle binding argument must be point(坑 2)。二,owning class(声明了 destroy 的)拒收 const point:const point 是"只读借用"标记,把它交给一个将来要 close 它的所有者,等于预定了一次非法释放,报错原文 owning native-backed class cannot consume a const point handle(坑 6)。一句话记法:有 destroy 是 owner,没 destroy 是 borrower,const point 只能遇见 borrower。
34.8 继承交互实测
设计文档说 native class 可以混合"继承",但继承有两个方向,实测结果一好一坏,必须如实分开讲。
方向一:普通 class extends native class——完全正常。子类加 xlang 字段、显式 base(...) 调用父类 create,handle 生命周期照常工作:
class Counter native point handle extern "counter_open" create(int seed) extern "counter_close" destroy() extern "counter_value" func value() as int end class NamedCounter extends Counter string name = "unnamed" create(int seed, string n) base(seed) self.name = n end end auto c = NamedCounter(42, "ticker") print(c.name) // ticker print(c.value()) // 42;析构照常自动 close
方向二:native class extends 普通 class——构造成功,调用 extern 成员即段错误。这是本章发现的编译器 bug:
# class Counter extends Named(父类有没有字段都一样) $ xl run bad.xl counter.c constructed Segmentation fault # exit 139,c.value() 调用处
父类为空 class 时同样崩溃,说明问题不在字段布局冲突,而是派生后 extern 成员取 handle 的地址算错。在编译器修复前,需要继承扩展 native 类型时一律用方向一(普通子类 extends native 父类),或者干脆用组合:普通 class 里放一个 native class 字段。
34.9 容器、is/as 与 var
不可复制不等于处处受限——native class 对象按引用自由进出参数、返回值、容器和 var,类型判定也齐全:
array<Counter> cs = [Counter(1), Counter(2), Counter(3)] int total = 0 for c in cs total += c.value() end print(total) // 6 auto c = Counter(9) print(c is Counter) // true var v = c print(v is Counter) // true —— var 保留精确类型 auto back = v as Counter print(back.value()) // 9
设计文档的原话:"不可复制 class 仍可按引用进入普通变量、参数、返回值和容器;只有显式 copy/clone 要求复制能力"。容器里的三个 Counter 离开作用域时各自触发一次 destroy,计数精确——受管理语义对 native 资源同样滴水不漏。
34.10 选型对照
普通 class 与 native-backed class 不是替代关系,是两种资源所有权的选择:
| 维度 | 普通 class | native-backed class |
|---|---|---|
| 数据在哪 | xlang 受管理堆(payload struct) | C/XRT 侧(opaque handle) |
| 字段对 C 可见 | 是——生成头文件公开布局(33.5) | 否——handle 不透明,只有 void* 一位 |
| 方法实现 | xlang | C(extern 绑定),可混 xlang 方法 |
| 析构 | 默认字段清理,可写 destroy() | extern destroy 映射 C close 函数 |
| 拷贝 | 默认值拷贝/clone 可用 | copy 得 null,clone 共享,需 C clone 函数才能真复制 |
| 典型场景 | 业务数据结构、纯 xlang 逻辑 | 文件/套接字/加密会话/C 库句柄/性能关键结构 |
与第 33.5 节形成有趣的对照:普通 class 的字段布局是"透明公开"的(C 可直读 data->x),native-backed class 则把数据彻底藏进 C 侧,xlang payload 里只有一个 void*——同一个 cgen,两种透明哲学。选型口诀:数据属于 xlang 世界就用普通 class;数据属于 C 世界(或必须属于)就用 native-backed class 把句柄接进来。拿不准时看所有权:谁负责 free,就决定谁该是 owner。
深入细节
对 mixed.xl 跑 cgen,三个关键位置看清全部机制。payload 布局——handle 永远是第一个字段:
struct XL_Class_Counter { void* handle; const char* label; };
extern 成员 wrapper——首参是 payload 指针,函数体转发 handle:
long long xl_class_Counter__value(XL_Class_Counter* self) { return counter_value(self->handle); }
析构 wrapper——判空、关闭、置零,再清理 xlang 字段,双保险防二次释放:
void xl_class_Counter__destroy(XL_Class_Counter* self) { if (self->handle != 0) { counter_close(self->handle); self->handle = 0; } xrtFree((void*)self->label); self->label = 0; }
最后是 XRT type ops 表:init/copy/move 三个位置全是 0,只有 drop 有值——"不可复制"不是运行时的善意提醒,是类型操作表层面的不存在。
copy(c) 返回一个"静态类型为 Counter 的 null",而不是编译错误——初看像纵容,细想是分层:编译期无法知道一个泛型函数手里的 T 是不是 noncopyable(第 14 章的泛型实例化在类型检查之后),所以防线设在类型操作表:copy 走 XRT 的 xvoCopy,类型没注册 copy 回调就返回 null 对象。这也是"共享所有权用 retain,不伪装成 copy"的实现形态。工程建议:把 copy 的 null 结果当作"该类型不可复制"的运行时信号,判空后再用——官方生命周期用例里就有 expect(type(rejectedCopy) == null, 1) 这样的守门断言。
标准库是 native-backed class 最大的用户:File(底层 XRT xfile,对象销毁自动关文件)、Buffer(xbuffer,销毁自动释放)、Random(xrtRandObj)、TcpStream/TcpServer/UdpSocket(网络句柄)、TlsConfig/TlsSession(加密会话)。回头看第 5 章起的所有文件读写——auto f = File.open(path) 之后从不 close——靠的就是本章这套机制。官方示例 class_native_backed.xl 里 Buffer 的 append/len/下标、Random 的种子重现(同种子同序列),都是 extern 成员绑定规则的现成展品。学完本章,标准库源码对你不再有秘密。
⚠️ 常见错误
class BadNative extern "counter_value" func value() as int // ← 报错(第 2 行) end
bad.xl:2:12: [sem] native-backed class extern member requires native point handle field
原因:extern 成员的首参注入依赖 payload 里的 handle 位,没有声明就没有存放位。正确写法:class 第一行写 native point handle——有 extern 成员的 class 这是必选项。
class Native native point handle end int handle = 1 auto native = Native(handle) // ← 报错(第 6 行)
bad.xl:6:22: [sem] native-backed class handle binding argument must be point
原因:手工绑定只接受 point(或 point<T>),整数地址不能冒充指针——这正是 point 不透明三规则(32.7)在构造口的延伸。正确写法:句柄从 extern 函数(as point 返回)获取,再绑进对象。
class Bad native point handle extern "bag_value" func count() as int extern "bag_value" func size() as int // ← 报错(第 4 行) end
bad.xl:4:12: [sem] same native-backed class cannot bind the same C symbol twice
原因:与第 33 章导出符号唯一性同理——C 符号是绑定关系的锚点,一符多绑会让调用路径歧义。正确写法:C 侧拆两个函数(哪怕其中一个是另一个的转发),各绑各的。
class Bad native point handle extern "bad_values" func values() as array<int> // ← 报错(第 3 行) end
bad.xl:3:42: [sem] return type cannot enter first-stage C ABI extern directly
原因:extern 成员的本质是 C 函数声明,类型面与第 32 章 extern 完全一致——array<int> 是 xlang 容器,C 没有对应物。正确写法:返回 C ABI 直通类型;批量数据走"缓冲区+长度"两段式,或拆成按索引取值的多次调用。
class Bad native point handle extern "bad_add" func add(...) as int // ← 报错(第 3 行) end
bad.xl:3:31: [sem] native-backed class extern member does not support vararg parameters
原因:可变参数与"首参自动注入 handle"的调用约定组合没有定义——编译器选择直接拒绝。正确写法:固定参数列表;需要变参语义时在 C 侧收数组指针+计数。
class OwningPoint native point handle extern "borrowed_box_close" destroy() end extern "make_borrowed_box" func makeBox(int value) as point func invalidTransfer() const point handle = makeBox(1) OwningPoint value = OwningPoint(handle) // ← 报错(第 10 行) end
bad.xl:10:37: [sem] owning native-backed class cannot consume a const point handle
原因:const point 语义是"只读借用、不归你释放",owning class 的 destroy 却预定要释放它——所有权冲突在绑定口拦下。正确写法:借用资源用无 destroy 的 borrower class(34.7);确实要转移所有权时,让 C API 提供明确的 take/steal 函数返回普通 point。
$ xl run bad.xl counter.c constructed Segmentation fault # exit 139
原因:本章实测确认的编译器缺陷——native 子类继承普通父类后,extern 成员取 handle 的地址计算错误,构造能过、方法一调就崩。正确写法:反转继承方向(普通子类 extends native 父类,34.8 方向一实测完全正常),或改用组合(普通 class 持有 native class 字段)。
本章小结
| 知识点 | 写法 / 规则 | 要点 |
|---|---|---|
| 声明三件套 | native point handle + extern create + extern destroy | handle 是 payload 首字段;create 返回值成为 handle |
| 生命周期 | 对象最后释放时自动调 destroy 一次 | 作用域/提前 return/break/continue 全覆盖(计数器实测) |
| extern 成员 | extern "sym" func m(...) as T | C 侧首参收 handle 自动注入;符号 class 内唯一;禁 vararg;类型须 C ABI 直通 |
| 混合成员 | xlang 字段/普通方法/__toString__ 等 dunder | 共存于同一 payload;析构先 close handle 再清 xlang 字段 |
| 拷贝语义 | copy → null;clone → 共享 handle 新包装;赋值 → 共享 | type ops 无 copy 回调;destroy 永远只跑一次;copy 结果须判空 |
| 借用模式 | 不写 extern destroy;已有 point 手工绑定 | borrower 不拥有;owning class 拒收 const point |
| 继承 | 普通 extends native ✅;native extends normal | 方向二段错误(编译器 bug,已如实记录);可用组合替代 |
| 容器与判定 | array<T>/参数/返回值/is/as/var 全通 | 不可复制≠不可传递;引用语义畅通 |
| 选型 | 数据属 xlang→普通 class;属 C→native-backed | 与 33.5 字段公开互为对照:两种透明哲学 |
| 标准库实例 | File/Buffer/Random/TcpStream/TlsSession | 底层 xfile/xbuffer/xrtRandObj,销毁自动释放 |
复习题
- extern create 与 extern destroy 各自绑定的 C 函数签名有什么要求?handle 在哪一步进入对象?
- xlang 声明
func add(int value) as int,对应的 C 函数为什么是counter_add(void* self, long long value)两个参数? - copy(c) 和 clone(c) 对 owning native class 各自得到什么?为什么两条路都不会造成 double free?
- 什么时候该写"没有 destroy 的 native class"?const point 为什么只能绑给这种 class?
- 普通 class extends native class 与 native class extends 普通 class 的实测结果分别是什么?需要扩展 native 类型时推荐怎么做?
编程练习
用 C 写一个固定容量的 int 栈:stack_new(capacity) 返回 handle、stack_push/stack_pop/stack_size/stack_free。xlang 侧写 native-backed class IntStack 完整封装(create(int cap)、destroy、push/pop/size 方法),写一个函数压入 1..10 再弹出求和(应为 55),并用一个 gFreed 计数器验证函数返回后栈被自动释放。
参考思路:完全仿照本章 counter.c + Counter 的组合;pop 空栈的返回约定(如返回 -1)由 C 侧定,xlang 侧如实透传。
给练习 1 的 C 库加两个函数:stack_peek_handle(s) 返回借用指针(point),stack_top(void*) 读栈顶。xlang 侧写无 destroy 的 StackView,演示"拥有者 IntStack 存活期间, borrower 视图读栈顶",并用注释标出谁负责 free。再故意写一行 owning class 绑定 const point,把编译器报错原文贴进注释。
参考思路:34.7 的 CounterView 是模板;核心是把"owning/borrowing"的所有权注释写成团队规范。
给本章 Counter 加 xlang 侧增强:int 字段 opCount(每次 add/value 调用自增,用普通方法包一层实现)、__toString__ 输出 "Counter(value, ops)"。然后用 array<Counter> 装三个实例,循环操作后打印每个的描述。验证 opCount 是 xlang 字段、value 来自 C——两种数据在同一对象里和平共处。
参考思路:包装方法 func add(int v) as int 里先 self.opCount += 1 再 return self.nativeAdd(v)——extern 成员名与包装方法名错开即可。