< 返回我的博客

爱国的张浩予 发表于 2023-02-12 23:06

Tags:union,tagged-union,enum,variant,ffi,abi

为什么Rust英文文档普遍将【枚举值】记作variant而不是enum value

在阅读各类Rust英文技术资料时,你是否也曾经困惑过:为何每逢【枚举值】的概念出现时,作者都会以variant一词指代之?就字面含义而言,enum value岂不是更贴切与易理解。简单地讲,这馁馁地是Rust技术优越性·宣传软文的广告梗,而且是很高端的内行梗。Rustacean们看了往往报以会心一笑 — 似乎优秀尽在不言中。至于梗在何处,请耐心听我娓娓道来!

C++语境下,variant意味着什么

首先,当variant被记作variant member时,根据C++ 11标准,它指的就是C union数据结构中的字段。C union允许在同一段内存上,每次保存数据类型不同的值。但在程序运行期间,C union却并不支持内省·窥知它正保存哪种类型的值。程序员需要自己记忆这些代码细节与保持读写类型的一致。variant的字面含义“变异体”也贴切地暗示了该语法项的“飘忽不定,与琢磨不透”。

C++ 11

其次,当variant被记作std::variant时,根据C++ 17标准,它便是Tagged Union的语法糖。比如,std::variant就支持运行时“穷举”找出正被用来保存数据的“活跃”类型。例程1核心代码如下:

// 声明一个`int`, `float`与`std::string`类型的【共用体】。
std::variant<int, float, std::string> intFloatString;
// 初始化为`float`值。
intFloatString = 100.0f;
// 开始穷举该【共用体】,以找出它正保存什么类型的值
if (std::holds_alternative<int>(intFloatString))
    std::cout << "正在保存整数!\n";
else if (std::holds_alternative<float>(intFloatString))
    std::cout << "正在保存浮点数\n"; // 程序执行后,这一条日志将被输出
else if (std::holds_alternative<std::string>(intFloatString))
    std::cout << "正在保存字符串\n";

个人观点,std::variant最多算是对飙Rust enum语言核心特性的C++标准库实现版本。请注意被加粗的两个关键词“语言核心”与“标准库”。所以,即便Rust enumstd::variant功能相同,前者也是发自语言内核的“天赋技能”,而后者仅是来自标准库的“后天补丁”。和人一样,“先天就聪明”与“后天人为训练”是两码事!他们的成长上限不同,文章后续会再有提到。

Rust Union并没有带来新改善

为了优化互操作性,Rust也有与C union概念对等的数据结构Union。但程序对Rust Union实例任何读操作都是unsafe的,因为rustc不能编译时保证对相同Union实例的任何一次读写操作都采用了正确的数据类型。例如,对同一个Rust Union实例,先用f32写,再以String读就会导致程序panic,因为f32字节序列不符合UTF-8编码规则例程2。真是“十步一Crash,五步一UB (i.e. Undefined Behavior)”呀!难!

Rust Union

此外,对称于C union实例在切换至活跃“字段”时会自动释放“活跃”字段的内存占用,rustc直接禁止Drop trait实现类(比如,String)作为Rust Union数据结构的成员字段(,除非该字段被显示地标定为内存自理例程3。这简单粗暴的作法也真的没谁了!

Rust enum带来的创新

enum valueC/Cpp语法规则中只能是intunsigned int类型。即便是在语法限制更为宽松的计算机语言中,enum value至少也得是数据类型相同的值。但,在Rust中,一个枚举类enum不同枚举值enum value被允许存储类型不同的数据。于是,

  • 相比于C unionRust enum包含分辨因子discriminant和支持(穷举)匹配。经由match表达式(穷举)匹配全部枚举值,程序必定能找到正确的数据类型读取enum枚举项内的值。
  • 相较于C enum
    • Rust enum能够在每个枚举值enum value内保存不同类型的值。
    • 更重要的是,当Rust enum实例切换到枚举值时,枚举值内保存对象的析构函数Drop::drop(&mut self)会被自动调用和释放内存例程4。这兑现了rustc的内存安全承诺。

因此,Rust enumC enumC union的概念集合体(即,Rust enum = C enum+ C union)。更准确地讲,Rust enum = C struct + C enum+ C union。其中,

  • C struct作为容器,起到了收拢命名空间的作用。
  • C enum记忆正保存哪个枚举值
  • C union存储不同类型具体的值

Rust enum

虽然Rust enum在功能上无限接近于C++ 17标准库中的std::variant数据结构,但Rust enum更高级,因为Rust enum语言内核特性,而不是来自标准库的后天补丁(数据结构)。于是,即便为了适配硬件条件简陋的嵌入式设备,我们不得不开启#![no_std]模式和弃掉整个【标准库】

  • Rust程序依旧安全、精简和高性能。
  • C++程序员必定要为重构代码而哭晕在工位上。

综上所述,将枚举值记作variantRust团队向全世界潜移默化地输出Rust enum技术优越性观念的手段。即,

  • 兼容多类型 — 同C union,和引入variant别名。
  • 可穷举匹配 — 同C enum
  • 自动析构旧值 — Rust内存安全保证
  • 语言内核支持 — Rust对要求弃掉【标准库】的嵌入式编程友好

然后,再追问一句:“都这么好了,你还不来上手试试吗?”。这是多有技术格的广告梗呀!

当然,任何事物都有正反两面,既然Rust enum如此地特立独行,那么其它计算机语言应该如何布局内存来描述被FFI导入的Rust enum实例呢,又或许Rust enum就从此无缘FFI了?这注定不是轻松的工作。于是,才有了文章的最后一节:不怕,以C ABI为中间格式。

FFI导入Rust enum

【枚举类】从RustCABI映射关系决定了其它计算机语言(比如,nodejs)如何FFI导入Rust枚举值,因为Rust是以C ABI为中间协议实现跨语言互操作的。

ABI话题本身就是一个Esoteric Topic。在这里,我仅点到为止地聊两句:若Rust程序

  • 在编译时·经由rustc链接*.rlib链接库文件,那就采用Rust ABI实现互操作。
  • 在运行时·链接由【rustcdylib包类型】编译出的*.dll / *.dylib / *.so链接库文件,那也采用Rust ABI实现互操作。
    • Windows*.dll
    • Mac*.dylib
    • -nix*.so
  • 在运行时·链接非以上两种类型的任何链接库文件,那都采用C ABI实现互操作。所以,Rust与其它任何计算机语言都是经由C ABI协议联通的。

似乎文字还是缺乏描述力,那就承接上图的例子,观察nodejs如何FFI导入由Rust端输出的Result枚举值。就java / python / ruby而言,其底层原理也是一样。

FFI

简单地讲,就FFI的调用端来说,其只见structunion,而不见enum,因为Rust enum的穷举匹配能力被转变成了tag索引字段的整数比较操作。tag索引字段的

  • 字段名tag是硬编码的C ABI约定,改不了。
  • 字段值是始于0的整数。
  • 字段值大小反映的是Rust枚举值在声明时的词法次序。

最后,js调用端的完整代码如下

const [ffi, ref, Union, Struct] = ['ffi', 'ref', 'ref-union', 'ref-struct'].map(require);
const Result = Struct({ // 虽然完全看不出数据源是`enum`,但`Tagged Union`风却直扑面门。
    tag: ref.types.uint8,
    union: new Union({
        ciphertext: Struct({
            password: 'string',
            nonce: 'string'
        }),
        errCode: Struct({
            err_code: ref.types.uint8
        })
    })
});
const core = ffi.Library(dllPath, {
    calcNonce: ['string', []],
    encryptPassword: [Result, ['string', 'string']]
});
const result = core.encryptPassword('12222', core.calcNonce());
switch (result.tag) { // 枚举值匹配·转变成了·索引值比较
case 0: // 加密成功,和输出密文密码
    console.log('password=', result.union.ciphertext.password, 'nonce=', result.union.ciphertext.nonce);
    break;
case 1: // 加密失败,和输出错误码
default:
    console.log('err_code=', result.union.errCode.err_code);
    break;
}

结束语

前不久有网友私信我和热烈讨论了这个技术点。太有意义了!事后,我汇总·提炼聊天内容,和进一步做了概念延伸(于是,才有FFI一节)。最终,写下这篇文章与大家分享。希望那位网友看到这篇文章也能帮我点赞与发评论暖场。更请路过的神仙哥哥与仙女妹妹们指导与纠错,共同进步。谢谢!

哎,Rust是真难学,我好像又进入“瓶颈”状态了。头疼!

评论区

写评论
TinusgragLin 2023-05-13 19:27

其实可能只是因为想要借鉴 OCaml 的 Variants 概念吧!另外,大家可以试一试 OCaml 哦,我在入坑 Rust 之前试了一下,感觉超酷的!

c5soft 2023-03-17 08:27

Rust enum >= C struct + C enum+ C union 说到点子上了

作者 爱国的张浩予 2023-02-14 12:15
  1. 主要是因为,无论 wasm 还是 nodejs addon 都撇不开 ffi。在我接触到的场景里 rust 都是做“火力支援”的。
  2. 当然可以 --
    👇
    ManonLoki: 每一篇文章其实都是在围绕这ffi来写,从这里看出来作者对于ffi这一块是深耕了很久,这次到了枚举,下次有机会是否方便讲讲数组的ffi映射?
wajjforever1314 2023-02-14 11:02

棒棒的

ManonLoki 2023-02-14 10:59

每一篇文章其实都是在围绕这ffi来写,从这里看出来作者对于ffi这一块是深耕了很久,这次到了枚举,下次有机会是否方便讲讲数组的ffi映射?

xianbing 2023-02-13 09:31

写得越来越棒了

是也乎 2023-02-13 08:56

#是也乎,( ̄▽ ̄) 先赞再读, 这种日常发现问题/解决问题/分享问题的姿势太正义了 ;-)

1 共 7 条评论, 1 页