第 34 章 / 共 58 章 卷七 系统与互操作

native-backed class

第 32、33 章打通了函数与变量的双向通道,本章解决互操作里最后一块硬骨头:C 的 opaque handle 资源——malloc 出来的结构体、fopen 的文件、SSL 会话、数据库连接。它们有明确的"打开-使用-关闭"生命周期,手工管理迟早泄漏或 double free。native-backed class 用一根 native point handle 加几行 extern 声明,把 C 函数组映射成 xlang 的 class:构造即打开,作用域结束自动关闭,用起来和普通 class 一模一样。

本章导读

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 声明形态

最小完整声明只有四行核心——这是本章的主角,对照语法导览页的口径逐行拆解:

Counter 声明(tour 同款,本章实测)
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 无差别:

使用端(实测输出 42 42)
auto c = Counter(40)
print(c.add(2))      // 42
print(c.value())     // 42;作用域结束自动 counter_close

34.3 生命周期映射

"自动析构"不是宣传语,本章用一个全局计数器把每条控制流路径都数了一遍。C 侧 counter_close 每次被调用就 gCounterClosed++,xlang 侧逐个场景验证:

life.xl(实测:输出 1 2 3)
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 库最清楚:

counter.c(C opaque handle 库,本章实战)
#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__ 协议:

mixed.xl(实测通过)
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(...) == nullvar 丢失静态类型,连类型名都不剩
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 手工绑定:

borrow.xl(实测输出 7)
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 生命周期照常工作:

inh2.xl(实测输出 ticker 42)
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,类型判定也齐全:

cont.xl(实测输出 6 true true 9)
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 不是替代关系,是两种资源所有权的选择:

维度普通 classnative-backed class
数据在哪xlang 受管理堆(payload struct)C/XRT 侧(opaque handle)
字段对 C 可见是——生成头文件公开布局(33.5)否——handle 不透明,只有 void* 一位
方法实现xlangC(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。

深入细节

深入细节 1:cgen 产物解剖——handle 存在哪,wrapper 长什么样

对 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 有值——"不可复制"不是运行时的善意提醒,是类型操作表层面的不存在。

深入细节 2:为什么 copy 给 null 而不是报错

copy(c) 返回一个"静态类型为 Counter 的 null",而不是编译错误——初看像纵容,细想是分层:编译期无法知道一个泛型函数手里的 T 是不是 noncopyable(第 14 章的泛型实例化在类型检查之后),所以防线设在类型操作表:copy 走 XRT 的 xvoCopy,类型没注册 copy 回调就返回 null 对象。这也是"共享所有权用 retain,不伪装成 copy"的实现形态。工程建议:把 copy 的 null 结果当作"该类型不可复制"的运行时信号,判空后再用——官方生命周期用例里就有 expect(type(rejectedCopy) == null, 1) 这样的守门断言。

深入细节 3:你已经依赖的 native class 们

标准库是 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 成员绑定规则的现成展品。学完本章,标准库源码对你不再有秘密。

⚠️ 常见错误

⚠️ 坑 1:extern 成员有了,native point handle 忘了写
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 这是必选项。

⚠️ 坑 2:手工绑定句柄时传了非 point 值
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 返回)获取,再绑进对象。

⚠️ 坑 3:同一 class 里两个成员绑同一个 C 符号
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 侧拆两个函数(哪怕其中一个是另一个的转发),各绑各的。

⚠️ 坑 4:extern 成员返回容器类型
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 直通类型;批量数据走"缓冲区+长度"两段式,或拆成按索引取值的多次调用。

⚠️ 坑 5:extern 成员写可变参数
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 侧收数组指针+计数。

⚠️ 坑 6:把 const point 交给 owning class
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。

⚠️ 坑 7:native class extends 普通 class 后调用 extern 成员
$ 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 destroyhandle 是 payload 首字段;create 返回值成为 handle
生命周期对象最后释放时自动调 destroy 一次作用域/提前 return/break/continue 全覆盖(计数器实测)
extern 成员extern "sym" func m(...) as TC 侧首参收 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,销毁自动释放

复习题

  1. extern create 与 extern destroy 各自绑定的 C 函数签名有什么要求?handle 在哪一步进入对象?
  2. xlang 声明 func add(int value) as int,对应的 C 函数为什么是 counter_add(void* self, long long value) 两个参数?
  3. copy(c) 和 clone(c) 对 owning native class 各自得到什么?为什么两条路都不会造成 double free?
  4. 什么时候该写"没有 destroy 的 native class"?const point 为什么只能绑给这种 class?
  5. 普通 class extends native class 与 native class extends 普通 class 的实测结果分别是什么?需要扩展 native 类型时推荐怎么做?

编程练习

练习 1:C 栈的 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 侧如实透传。

练习 2:borrower 视图

给练习 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"的所有权注释写成团队规范。

练习 3:混合成员的装饰器

给本章 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 成员名与包装方法名错开即可。