Rust 语言伪经

某汉语 Rust 教程没能正确告诉你的内容。

从个人喜好来说,我不喜欢某 Rust 中文教程。不可否认的是,它诸如索引的一些设计还是较为科学的,但我不太喜欢它的行文风格,过于泛滥的幽默之笔对于我来说似乎更容易发散掉注意力。我个人偏好官方出品的原版教材,The Book,它的行文比较紧凑且知识密集,当然可能略显枯燥。

客观来说,官方出品的教程在内容的准确性上更值得信赖。笔者本身对 Rust 的了解极其浅显,但对该中文教程中的一部分内容存有一些疑问。本篇的目标是,尽可能客观地考证并指出该中文教程中的偏误。

§1.4 仅仅是 Hello world

在 §1.4 不仅是 Hello world 一节,该教程给出了如下代码:

fn greet_world() {
    let southern_germany = "Grüß Gott!";
    let chinese = "世界,你好";
    let english = "World, hello";
    let regions = [southern_germany, chinese, english];
    for region in regions.iter() {
            println!("{}", &region);
    }
}
fn main() {
    greet_world();
}

对于 println 来说,我们没有使用其它语言惯用的 %s、%d 来做输出占位符,而是使用 {},因为 Rust 在底层帮我们做了大量工作,会自动识别输出数据的类型,例如当前例子,会识别为 String 类型。(原文)

Rust 一系列用于格式化的宏非常类似,{} 占位符只要求输出数据的类型实现了 fmt::Display 特征即可。而这里输出的的 &region 是 &&&str 类型,由于 Display 对于一切实现了它的类型的引用类型自动实现,且 str 实现了它,因此这个 println 可以正常工作。

考察 println 的底层实现,最终追溯到一个私有的模块 io::stdio 中的 print_to 泛型函数,它最终会向一个实现了 io::Write 的类型中写入,而 println 使用的是 io::Stdout。

无论是这里占位符处变量的类型,还是 println 的实现,都用不到堆上分配的 String 类型。

最后,和其它语言不同,Rust 的集合类型不能直接进行循环,需要变成迭代器(这里是通过 .iter() 方法),才能用于迭代循环。[…]

实际上这段代码可以简写,在 2021 edition 及以后,支持直接写 for region in regions,原因会在迭代器章节的开头提到,是因为 for 隐式地将 regions 转换成迭代器。(原文)

Rust 的集合类型确实不能直接进行循环,但数组(array)不算是 Rust 的集合类型,而是基本类型(primitive),好比 C++ 的 [] 不是 std::array。但在语法上,集合类型当然可以出现在 for 循环的表达式位置,只要这个类型实现了 iter::IntoIterator 即可。标准库 collections 实现的所有集合类型都实现了 IntoIterator,因此从语法上来说 Rust 的集合类型是可以直接用 for 循环遍历的。当然,for 实际上是由 IntoIterator::into_iter(), match 和 loop 组成的复杂语法糖。

但数组的情况很特殊。在返回值的 impl Trait 以及常量泛型(const generics)稳定之前,这个 impl IntoIterator 并不方便实现。数组本身可以隐式类型转换为切片,而切片的引用上实现 IntoIterator 不需要常量泛型,因此数组的引用之前可以使用 for 循环之。因此在数组的 IntoIterator 稳定后,由于 Rust 方法调用时会考虑隐式转换,在数组上显式调用IntoIterator::into_iter 在之前的版次中会导致隐式转换到切片,因此存在非向下兼容的冲突,最终在 2021 版次才解决了这一问题。而示例代码中的 .iter() 方法是切片的关联方法,实际上也引发了一次隐式转换。因此,所谓的“简写”实际上有细微的区别,甚至循环变量的类型也不相同。参考:

for i in   [1, 2, 3] .iter() { /* i: &i32; */ } // 1 
for i in   [1, 2, 3]         { /* i:  i32; */ } // 2
for i in (&[1, 2, 3]).iter() { /* i: &i32; */ } // 3
for i in  &[1, 2, 3]         { /* i: &i32; */ } // 4

// ~>  coercion
// =>  call
// ->  type
//
// 1:  [i32; 3] ~> [i32] => .iter() -> slice::Iter[i32] => .into_iter() -> slice::Iter[i32]
// 2:  [i32; 3]                                         => .into_iter() -> array::IntoIter<i32, 3>
// 3: &[i32; 3] ~> [i32] => .iter() -> slice::Iter[i32] => .into_iter() -> slice::Iter[i32]
// 4: &[i32; 3]                                         => .into_iter() -> slice::Iter[i32]

§2.6.1 Not matching when match-ing

参看 §2.6.1 match 和 if let 一节。

enum MyEnum {
    Foo,
    Bar
}
fn main() {
    let v = vec![MyEnum::Foo,MyEnum::Bar,MyEnum::Foo];
}

现在如果想对 v 进行过滤,只保留类型是 MyEnum::Foo 的元素,你可能想这么写:

v.iter().filter(|x| x == MyEnum::Foo);

但是,实际上这行代码会报错,因为你无法将 x 直接跟一个枚举成员进行比较。(原文)

一个变量不能和一个值比较?不管你可不可以,反正我可以:

let none: Option<()> = None;
assert!(none == None); // 故意没用 assert_eq! 宏

把一个看起来是变量的东西和看起来是枚举分量的东西比较,完全符合文法。出现编译错误,一定是语义错误。

语法分析

从文法层面来看这个问题,一个枚举分量(variant,或者所谓“枚举成员”)可以是一个光杆标识符(identifier),一个光杆标识符当然也是一个合法的路径表达式(path expression)。变量是一个标识符,当然也是一个路径表达式。而一个合法的路径表达式也可以是一个合法的结构体表达式,即单元结构体(unit struct)表达式。

== 构成的运算符表达式 只要求两侧是表达式。显然,路径表达式和结构体表达式都是表达式。

但以下的例子去掉注释显然编译不过:

let some: Option<()> = Some(());
// assert!(some == Some); 

报的什么错?E0308。

mismatched types
expected enum `Option<()>`
found fn item `fn(_) -> Option<_> {Option::<_>::Some}`

类型不匹配,这当然是一个语义错误。Some 这个路径表达式指向了这个枚举分量的构造函数。当然不能把一个类型的构造函数和这个类型的一个值比较。这个时候你就需要借助 matches! 宏了。

顺着这个思路,让我们来人肉类型检查。

v           // Vec<MyEnum>
  .iter()   // std::slice::Iter<MyEnum>: Iterator<Item=&MyEnum>
  .filter(  
    // P: FnMut(&Iterator::Item) -> bool 
    // P: FnMut(&&MyEnum       ) -> bool
    |x /*: &&MyEnum*/ | {
         x          == MyEnum::Foo
      /* &&MyEnum      MyEnum  */
    }
  )/* blah blah blah */;

其一,MyEnum 没有实现 PartialEq,怎么比较?其二,&&MyEnum 类型和 MyEnum 之间都不匹配(match 😜)怎么比较?matches! 宏本身基于模式匹配,不依赖 PartialEq,而且根据 RFC 2005,match 的 scrutinee 自动解引用,自然用 matches! 宏就正常工作了。实现 PartialEq 加上解引用,编译顺利通过:

#[derive(PartialEq)]
enum MyEnum {
    Foo,
    Bar,
}

let v = vec![MyEnum::Foo, MyEnum::Bar, MyEnum::Foo];
v.iter().filter(|x| **x == MyEnum::Foo);

此外,还有一处前后表述不太 match(😜) 的地方:所谓覆盖实际上就是遮蔽(shadowing)。The Book 讲解标识符遮蔽的一节 把内层作用域和外层出现相同标识符的情况也称作遮蔽。

无论是 match 还是 if let,他们都可以在模式匹配时覆盖掉老的值,绑定新的值(原文)

把内外两层作用域之间重名标识符的情况称作“覆盖”的坏处是,“值”和“标识符”都可以“覆盖”,但遮蔽机制只牵涉到标识符。match 的分支和 if let 绑定了新的变量时,绝对不会把外层作用域的值覆盖掉(也就是重新赋值)。可以尝试用以下 Python 代码理解这个区别:

ident = 1

def shadowing():
    ident = 2
    return ident

print(shadowing()) # 2
print(ident) # 1

def covering_value():
    global ident
    ident = 3
    return ident

print(covering_value()) # 3
print(ident) # 3

基于以上讨论,评论区里说的:

至于为什么用=, 而不是==, 就是if let的固定语法, 不需要理解, 记住就行.(原文)

当然不是什么固定语法。从上面关于 matches! 的讨论来看,模式匹配和 PartialEq::eq 也就是 ==(部分等价(partial equivalence)可以不满足自反性,不是等价(equivalence)!)完全不同。let 的功能是模式绑定加声明,当然是用绑定运算符 = 而不是部分等价运算符 == 了!

§2.8.1 Not-so-generic generics

参看《§2.8.1 泛型》一节。

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

(原文)

这段代码的错误一眼没看出来……不止需要按下文所说增加 PartialOrd 的限制,评论区已经指出,它根本编译不过,因为把 list[0] 绑定到 largest 需要移动,但从一个非 Copy 的切片(的引用)里是不可能把所有权移走的。那么,加上 Copy 限制……?

且慢,比较两个变量只需要读取它们的值,并不会修改它。回想最喜欢的 C++ 运算符重载:

bool operator< (const X& lhs, const X& rhs)

还有 Rust 的 PartialOrd 特征:

pub trait PartialOrd<Rhs = Self>: PartialEq<Rhs> 
where
    Rhs: ?Sized, 
{
    fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;
    ...
}

分别用的常量引用和只读引用。既然用引用就好了,那么为什么还要 Copy?个人理解,既然写了泛型,约束应该尽可能地少才好,比较大小的函数没必要限制 Copy。这个函数的签名其实就不太正常——输入一个引用类型,返回该类型的值?这其中大概率有复制发生。所以,其实这个函数本来可以写成:

fn largest<T: PartialOrd>(list: &[T]) -> &T { // 生命周期擦除
    let mut largest = &list[0];

    for item in list.iter() { // 这里不需要写 &,item 本身是 &T
        if item > largest {
            largest = item;
        }
    }

    largest
}

且慢,根据你的离散数学知识,你要在一些具有 PartialOrd 关系的值中间找最大值?这样最终找到的有可能只是极大值!看看 PartialOrd::partial_comp 的签名:fn(&self, other: &Rhs) -> Option<Ordering>,这确实是偏序。所以,这个找极大值的泛型函数的约束本来应该是全序 Ord😲。

当然,更 Rust 的写法不应该这么过程式:

#[inline]
fn largest<T: Ord>(list: &[T]) -> &T {
    list.iter().max().unwrap()
}

为什么需要 Option::unwrap——如果你的 list 是空的的话,就不存在什么最大值了……

更新:原来在《§2.8.2 特征》一节讲了这个 Copy 的错误,那没事了(可是 PartialOrd 的缺陷还是存在的)。

§2.8.2 Coping with copy and Copy's

Rust 默认采用移动语义(move semantics)。通常情况下,移动的代价比复制(copy semantics)要小,但如果在栈上分配了巨大的空间,移动的代价也变得不可忽略了。比如在栈上开了巨大的数组,移动整个数组时,仍然有很大的开销。在 C++ 里,栈上分配的(裸)数组通常是以裸指针形态出现的,移动整个数组很多时候是通过 C 函数 memcpy 针对一个指针进行操作。但在 Rust 中数组这个基本类型并不会隐式转换到指针,在 = 操作时就可能发生值的移动,因为 Rust 对所有实现了 Copy 的类型的数组都自动实现了 Copy。

在 Rust 中,Copy 是一个标记特征(marker trait),直接影响语义(采用复制语义),因此《§2.8.2 特征》一节的表述是有问题的:

再如 Copy 特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy 特征,进而可以调用 copy 方法,进行自我复制。(原文)

derive 是一种过程宏,而不是一个特征的“默认代码”,你可以在 Rust 标准库源代码中找到分开的定义。同样地,标记特征没有关联的方法,根本不存在什么 copy 方法。Copy 特征和 Copy 宏实际上内置在编译器中。

有两个术语:深拷贝(deep copy)和浅拷贝(shallow copy),它们的区别是是否跟随指针和引用从而递归地复制数据。Clone 特征的语义是显式的、代价不定的复制操作,使用 Clone 宏推导出的实现一般是递归的深拷贝,但不必是。而 Copy 特征的语义(采用复制语义)并不指发生浅拷贝(反而移动语义才更贴近浅拷贝),而是声明复制操作非常平凡,因而可采用复制语义,不考虑所有权问题。

所以,在《附录 D:派生特征 trait》中的表述并不准确。

Copy 特征允许你通过只拷贝存储在栈上的数据来复制值(浅拷贝),而无需复制存储在堆上的底层数据。查阅 通过 Copy 复制栈数据 的部分来获取有关 Copy 的更多信息。

实际上 Copy 特征并不阻止你在实现时使用了深拷贝,只是,我们不应该这么做,毕竟遵循一个语言的惯例是很重要的。当用户看到 Copy 时,潜意识就应该知道这是浅拷贝,复制一个值会非常快。

当一个类型的内部字段全部实现了 Copy 时,你就可以在该类型上派上 Copy 特征。 一个类型如果要实现 Copy 它必须先实现 Clone ,因为一个类型实现 Clone 后,就等于顺便实现了 Copy 。

总之, Copy 拥有更好的性能,当浅拷贝足够的时候,就不要使用 Clone ,不然会导致你的代码运行更慢。(原文)

Copy 的性能并不会比 Clone 更好,因为 Copy 只是声明处处默认使用 Clone 的标记。若要使用浅拷贝,实际上应该尽可能使用一个类型的引用(引用是 Copy 的),而非把这个类型声明为 Copy。

Rust 标准库中有一句话:当一个类型可以是 Copy 的时候,它就应该实现 Copy。

§2.8.4 You only implement once

《§2.8.4 深入了解特征》中提到了关联类型:

为何不用泛型,例如如下代码:

pub trait Iterator<Item> {
       fn next(&mut self) -> Option<Item>;
}

答案其实很简单,为了代码的可读性,当你使用了泛型后,你需要在所有地方都写 Iterator<Item>,而使用了关联类型,你只需要写 Iterator,当类型定义复杂时,这种写法可以极大的增加可读性。

参考 The Book,关联类型(associated types)最重要的作用实际上是避免多态。如果使用 Iterator<Item> 这样的泛型特征描述迭代器,则对一个类型可以有多个特化的实现(多态),即,一个迭代器可能产出不同类型的元素。比如说,一个泛型的 MyIterator<String> 是存储了 String 类型的用于迭代器的结构体,但如果 Iterator 特征是多态的,那么我们就可以为 MyIterator 添加产生 String, i32, u32……等多种类型的迭代器实现:

impl Iterator<String> for MyIterator<String> {} // OK
impl Iterator<i32>    for MyIterator<String> {} // ???

允许产生除了 T 以外的类型的元素似乎没有意义。同时,由于泛型有传染的特点,Iterator 的调用也会变成多态的,每次调用 .next() 方法都需要把泛型单态化(monomorphize),确定一个实际类型。对于一个 Iterator 特征的实现者来说,这个类型本应被唯一确定,而不需要任何多态。为了阻止这种多态,我们把 Item 处理成了关联类型。

也就是说,泛型特征是一个类型在实现这个特征时,针对不同的类型可以有不同的实现(比如同样是吃东西,吃炒菜用筷子,但吃蛋糕用叉子),而关联类型则是不同的类型实现这个特征时可能关联到不同的类型,但特定类型实现这个特征时,关联到的类型是唯一的(比如餐厅都是可以吃饭的,不同餐厅可能提供不同的菜式,但在中餐厅只能吃到中餐,在西餐厅只能吃到西餐)。