想念 C++,讨厌 Rust

rust 中的这套cargo 工具栈 确实很好用, 但是除此之外, 全是我学不会的 高级特性, 我很好奇到底什么样的人谁能在不借助文档,google的情况下, 使用 rust 流畅的编写程序.

借用检查器

在 C++ 里写一个指针类型的结构,例如一个图,只要搞清楚节点持有指向相邻节点的指针,图持有所有节点。指针就是地址,地址就是内存位置。你理解了内存布局,就理解了这段代码。

在 Rust 里写同样的东西?

借用检查器真正的问题在于它逼你用绕路的方式表达本来很直接的东西。 Rc<RefCell<T>> 这种写法,本质上就是在运行时重新实现了一个借用检查器——那我当初为什么要用 Rust?

Rust 不会帮你理解计算机

C++ 的指针是什么?一个内存地址。你 new 了一块内存,拿到地址,用完 delete。中间出了问题——段错误、内存泄漏、野指针——每一个 bug 都在教你计算机是怎么管理内存的。你追踪一个 use-after-free,你就理解了堆的生命周期。你调试一个数据竞争,你就理解了缓存一致性协议。所以你再去学习 智能指针/RAII 这些都是自然而然的,

Rust 的借用检查器把这一切藏起来了。它告诉你”这里不行”,然后教你 Rust语言的规则,不是机器的规则。你花了一下午搞定了一个生命周期标注问题,但你对内存布局的理解没有增加一个比特。

你学到的唯一一件事是:怎么让 rust编译器 闭嘴。

类型体操

可以看这里的这个文章

作者的文章写得极好。实现了一套数据库执行器里需要的零拷贝、静态类型关联、运行时分发、逻辑类型映射,全都堆到类型系统上… 反正我是学不会这些 “高级特性”。

从数组开始

为数据库执行器设计一套类型化的 Array 接口。Int32Array 返回 i32StringArray 返回 &str。如果只看“一个按类型取值的数组”,在 C++ 里这通常就是模板加特化。

这个数组同时满足下面几件事的抽象:

这些要求叠在一起之后,Rust 的类型系统就…

1,你先发现 &str 这种返回值写进关联类型时需要带生命周期,不然编译器直接报错。

2,你照着提示写 type RefItem<'a> = &'a str; 这一步会碰到 GAT,当时还是 nightly feature。

3,有了 GAT,Array trait 终于能写得像样一些。然后你又发现,这套设计很难直接走 dyn Array 这条路。原因也不只是“它用了 GAT”,而是整套 trait 里充满了和 Self 绑定得很紧的类型关系,dyn compatibility 很差。

4,于是只能退回 enum 做 dispatch。ArrayImplScalarImplScalarRefImpl 三套 enum 一起上。每加一种数据类型,三个 enum 都要多一个 variant,再配若干个 match 分支。

5,接着你需要在泛型函数里把 ArrayImpl 转回具体类型。于是给 Array trait 加上 TryFrom<ArrayImpl> + Into<ArrayImpl> 之类的 bound。对 PrimitiveArray<T> 这种 blanket implementation,还得继续往上叠约束。最后 trait impl 长这样:

impl<T> Array for PrimitiveArray<T>
where
    T: PrimitiveType,
    T: Scalar<ArrayType = Self>,
    for<'a> T: ScalarRef<'a, ScalarType = T, ArrayType = Self>,
    for<'a> T: Scalar<RefType<'a> = T>,
    Self: Into<ArrayImpl>,
    Self: TryFrom<ArrayImpl>,
{

看看这段签名。 六个 where clause,两个 Higher-Ranked Trait Bound(for<'a>),四个关联类型约束。

6,你还会发现,光表达 “ArrayTryFrom<ArrayImpl>” 不够,连 “&Array 也能从 &ArrayImpl 转出来” 都得单独写出来。于是 HRTB 又来了:for<'a> &'a Self: TryFrom<&'a ArrayImpl>

7,更糟的是,这种 bound 一旦放进 trait 约束,就会像传染病一样扩散到调用点。于是你只能认输,把它从 trait 上拆下来,按函数一个个补。

8,最后代码膨胀到人类已经不想手写的程度,只能上宏。用 macro_rules! 定义逻辑类型到物理类型的映射,用回调宏提取信息,再用 for_all_cmp_combinations! 这种东西把成批的类型组合一口气展开。

到这一步,你还记得你要写什么吗? 能明显感觉到自己不是在表达业务逻辑,而是在安抚 trait system、生命周期和 dyn compatibility。

Move / Borrow / Lifetime

’a

fn get_setting<'a>(
    entries: &'a [(String, String)],
    key: &str,
    fallback: &'a str,
) -> &'a str {
    entries
        .iter()
        .find(|(name, _)| name == key)
        .map(|(_, value)| value.as_str())
        .unwrap_or(fallback)
}

从配置列表里按 key 取值,取不到就返回默认值。 生命周期标注像传染病一样扩散到整个调用链——你给一个函数加了 'a,它的调用者也得加 'a,调用者的调用者也得加。直到每个函数签名都变成尖括号的海洋。

你以为在控制内存,其实在被控制

Rust 的宣传语是”零成本抽象”和”精确的内存控制”。实践中,为了让借用检查器通过,你往往被迫选择次优的内存策略:

** 难道这叫 “零成本”? ** C++ 程序员可以根据 profiling 结果来选择用不用智能指针、用不用 move、用不用 placement new。Rust 程序员的选择空间被编译器大幅压缩——不是因为你的选择不安全,而是因为编译器没法证明你的选择是安全的。

开发机资源占用

使用 Rust 的开发机磁盘占用极大, 比 Node.js 还过分, 1T 根本不够用; 编译极其慢, 真的是极其慢…

C++ 比 Rust 更简单

只使用 std::unique_ptr 就能覆盖 80% 的内存管理场景; 偶尔使用一下 std::shared_ptr; 然后你开启 AddressSanitizer、ThreadSanitizer、MemorySanitizer 在运行时捕捉借用检查器能在编译期捕捉的绝大部分问题; Clang-Tidy 提供静态分析——悬垂指针、缓冲区溢出、未初始化变量,在代码提交前就能发现; Valgrind 做内存分析和泄漏检测; 然后按照 C++ Core Guidelines 中的最佳实践。

io_uring 学习总结
RocksDB BlobDB 分析