自动特征auto trait
的扩散规则
公式化地概括,auto trait = marker trait + derived trait
。其中,等号右侧的marker
与derived
是在Rustonomicon书中的引入的概念,鲜见于Rust References。所以,若略感生僻,不奇怪。
marker trait
与derived trait
精准概括了auto trait
功能的两面性
前者指明
auto trait
实现类具备了由rustc
编译器和std
标准库对其约定的“天赋异能 intrinsic properties”。后者描述了这些“天赋异能”沿
auto trait
实现类数据结构【自内及外】的继承性与扩散性。
接下来逐一解释。
marker trait
标识“天赋”特征是什么
既然是“天赋”,那么auto trait
就没有任何抽象成员方法待被“后天实现”或关联项待被“后天赋值” — 这也是marker trait
别名的由来。rustc
甚至未配备专项检查器以静态分析与推断 @Rustacean 对auto trait
的实现是否合理。类似于unsafe
块,@Rustacean 需向rustc
承诺:知道自已正在干什么,和提交可供其它程序模块信任的“天赋异能“代码实现。否则,运行时程序就会执行出未定义行为 U.B.。相比于传统的std
程序接口和rustc
内存安全承诺,这是一项“反向契约” — 即,由rustc
充当“甲方”和规划功能要求,而由 @Rustacean 充当“乙方”完成功能代码和提供正确性保证。在硕大的Rust
标准库中,这类“天赋异能“的”反向契约”并不多见,但包括
derived trait
明确“天赋”特征如何扩散
概括起来,auto trait
的扩散规则就四个字“由内及外”。其遇到不同的场景,伴有不同解释的扩散链条
场景一:变量 ➜ 指针
以变量的数据类型为内,和以指向该变量值的指针/引用为外 — 变量值(数据类型T
)实现的auto trait
会自动扩散至它的各类指针与引用:
&T
&mut T
*const T
*mut T
所以,该扩散链条也被记作【类型 ➜ 指针】。
场景二:字段 ➜ 结构体
以字段的数据类型为内,和以父数据结构为外 — 所有字段(数据类型)都实现的auto trait
会自动扩散至它们的紧上一层数据结构:
structs
enums
unions
tuples
所以,该扩散链条也被记作【字段 ➜ 结构】。
场景三:元素 ➜ 集合
以集合元素的数据类型为内,和以集合容器为外 — 由元素(数据类型T
)实现的auto trait
会自动扩散至该元素的紧上一层集合容器:
[T; n]
[T]
Vec<T>
场景四:捕获变量 ➜ 闭包
以捕获变量的数据类型为内,和以闭包为外 — 所有捕获变量(数据类型)都实现的auto trait
会自动扩散至引用(或所有权占用)这些捕获变量的闭包。
场景五:函数 ➜ 函数指针
函数项fn
与函数指针fn ptr
总是会被rustc
编译时自动实现全部auto trait
。
扩散链条的“串连”
前四个场景的扩散链是可以多重嵌套衔接的。举个例子,Vec<Wrapping<u8>>
一定满足trait Send
限定条件,因为这条扩散链条的存在:
再举个更复杂的例子,假设有如下枚举类
enum Test<'a> {
Str(&'a String),
Num(u8)
}
那么Vec<Test>
也一定满足trait Send
限定条件,因为此扩散链条的存在:
auto trait
扩散链条的“阻断”
安装
nightly
版rustc
编译器。然后,在代码中,开启
#![feature(negative_impls)]
的feature-gate
编译开关,否定实现
auto trait
。比如,impl !Unpin for Test {}
于是,rustc
就不会再对遇到的【类型定义】自动添加曾被否定实现过的auto trait
了。在众多auto trait
中,仅trait Unpin
可绕过对nightly
编译工具链的依赖和仅凭stable
标准库内符号类型std::marker::PhantomPinned
定义的幻影字段阻止rustc
悄悄地实现自动特征。
【幻影字段】是仅作用于编译时的零成本抽象项。它被用来帮助编译器理解 @Rustacean 提交的代码和推断 @Rustacean 的程序设计意图。其语义功能很像
typescript
中的【@ 装饰器】。即,
辅助代码静态分析
辅助编译器生成垫片程序
编译后立即抹除
对【运行时】不可见 — 这也是【零成本】的由来。但,世间任何事物都有两面性和是双刃剑。“零成本”是省
CPU
,但更费脑细胞呀!Rust
编程的心智成本高已是行业共识了。另值一提的是,【
Rust
幻影字段】与【typescript
装饰器】皆都不同于【Java
的 @ 注释】,因为
前者是给编译器看和解读的 — 充其量是代码正文的旁白注脚。
后者是给运行时
VM
用和执行的 — 这已算是正文指令的一部分了。它们就是两个不同“位面”的东西。
我日常仅用过
std::marker::PhantomPinned
与std::marker::PhantomData
两类幻影字段
前者解决
stable
编译工具链对trait Unpin
的否定实现 — 就是这里正在讲的事后者被用于“类型状态
Type State Pattern
”设计模式中,将【运行时】对象状态的(动态)信息编码入【编译时】对象类型的(静态)定义里,以扩展Rust
类型系统的应用场景至对状态集的“状态管理”。若您对“类型状态”设计模式有兴趣,推荐移步至我的另一篇主题文章对照 OOP 浅谈【类型状态】设计模式保证有收获。这闲篇扯远了,让咱们重新回到文章的主题上来。
请细读下面自引用结构体的类型定义(特别含注释内容)和体会std::marker::PhantomPinned
如何被用来声明结构体内的幻影字段:
use ::std::marker::PhantomPinned;
struct SelfReferential {
// 整个结构体内唯一包含了有效信息的字段。
a: String,
// 自引用前一个字段`a`的值。
ref_a: *const String,
// 1. 这是对【幻影字段】的定义
// 2. 因为该字段并不会真的被后续功能代码用到,
// 所以为了压制来自编译器的`useless field`警告,
// 字段名以`_`为前缀
_marker: PhantomPinned
} // 于是,自引用结构体`Test`就是 !Unpin 的了
即便不太理解,也请不要质疑【自引用结构体】的存在必要性。至少在异步程序块中,跨.await
轮询点的变量引用都依赖这套机制。仅因为async {}
语法糖把每次构造trait Future
实现类的细节都隐藏了起来,所以 @Rustacean 对自引用结构体的直观感受会比较弱。
auto trait
扩散不至的自定义数据结构
若数据结构定义内含有未实现auto trait
的字段(比如,
use ::std::env::Vars;
struct Dumb(Vars);
其中Dumb.0
字段Vars
明确是!Send
的),那么 @Rustacean 就有必要考虑
要么,重新规划程序设计,以规避要求
struct Dumb
满足trait Send
限定条件要么,给
struct Dumb
添加unsafe
的auto trait
实现块。unsafe impl Send for Dumb {};
后者的unsafe impl
语法前缀就是rustc
对程序作者最后的警告:“你真的明白,你正在做什么事吗?”。
【快排序】 综合例程
先贴源码,再做详解。敲黑板强调:代码内的注释同样重要呀!推荐同正文一样重视和仔细阅读。
fn quick_sort<T: Ord + Send>(v: &mut [T]) {
if v.len() <= 1 {
return;
}
let mid = {
let pivot = v.len() - 1;
let mut i = 0;
for j in 0..pivot {
if v[j] <= v[pivot] {
v.swap(i, j);
i += 1;
}
}
v.swap(i, pivot);
i
};
// 1. 此处,虽然 lo 与 hi 是两个崭新的【切片】胖指针实例,
// 但由【切片】胖指针引用的底层 Vec<i32> 数据值却只有
// 一份呀!
let (lo, hi) = v.split_at_mut(mid);
// 2. 所以,后续的【多线程+递归】是修改的同一个 Vec<i32>
// 实例。
rayon::join(|| quick_sort(lo),
|| quick_sort(hi));
// 3. 至此,虽然此函数没有返回值,但仍可沿函数的【输入输出】
// 实参 v: &mut [T] 向调用端传递排序结果。
}
fn main() {
use ::rand::prelude::*;
// 1. 生成一个大数字集合
let mut numbers: Vec<i32> = (1000..10000).collect();
// 2. 乱序数组内容
numbers.shuffle(&mut rand::thread_rng());
// 3. 多线程快排序
quick_sort(&mut numbers);
// 4. 打印排序结果
println!("Sorted: {:?}", &numbers[..10]);
// 5. 一个单例 Vec<i32> 对象贯穿整个多线程快排序 demo 始终。
}
上述例程的难点并不是rayon::join()
如何在后台悄悄唤起多个线程加速大数据集【快排序】 — 这不需要 @Rustacean 操心,咱们只要多读读 API 文档和了解Work Stealing工作原理就足够了。相反,曾经困惑过我一段时间的痛点是:
为什么虽然泛型类型参数<T: Ord + Send>
的书面语义是“跨线程·数据复制”但程序的实际执行结果却是对Vec<i32>
单例的“跨线程·内存共享”?即,多个线程透过【切片】胖指针,修改同一个Vec<T>
变长数组实例。
推导明白这条逻辑链(元素Send
➜ 集合Sync
)先后耗费了我不少心神。但总结起来也无非如下几步:
依据前文介绍的
auto trait
扩散规则,对特征trait Send
和泛型类型参数T
,构造初始扩散链条:输入图片说明 依据
trait Send
至trait Sync
的(单向)转换关系,有<&S: Send> → <S: Sync>
。将 #2 代入 #1,进一步完善
trait Send
扩散链条,有输入图片说明 依据
rustc
赋予trait Sync
的语义,集合[T]
被允许跨线程引用与多线程共享又因为
fn quick_sort()
的形参是对Vec<i32>
实例的可修改引用&mut
,所以多个线程被允许并行修改同一个变长数组实例。
你不会以为故事就此结束了吧?难道你没有发觉例程中多线程代码有缺了点儿什么的异样吗?没错!细心的读者可能早就想问:
对单实例变长数组的并行修改,为什么未采用【读写锁RwLock
】或【互斥锁Mutex
】加以同步保护呢?甚至rustc
在编译时连警告提示都没有输出?What's wrong?
好问题!您有心了。快速回答是:虽然Vec<i32>
实例同时被多个线程并行修改不假,但每个线程并行修改的切片却只是同一变长数组内彼此衔接却并不相交的“子段”。所以,在快排序过程中,事实上没有任何数据竞争发生 — 这是彻头彻尾的算法胜利。果真,编程的尽头是数学与算法啊!此外,rustc
能顺利地接受与成功地编译这样的代码也足已破除人们以往对它保守且不变通的刻板印象。
结束语
这次就先分享这一个小知识点。文章里埋的有关Unpin
的坑以后再填。2024搞了一年的“鸿蒙Next ArkTs
”真不容易。哎!天大地大,饭辙最大。我是一颗螺丝钉,甲方爸爸需要什么,我就研究什么。但,年底写篇Rust
知识分享文章压压惊。