Rust 语言伪经

comp
@pl
@rust
#rust
#tutorial

某汉语 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(), matchloop 组成的复杂语法糖。

但数组的情况很特殊。在返回值的 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 matchif 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 2005match 的 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 处理成了关联类型。

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