某汉语 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!("{}", ®ion);
}
}
fn main() {
greet_world();
}
对于
println
来说,我们没有使用其它语言惯用的%s
、%d
来做输出占位符,而是使用{}
,因为 Rust 在底层帮我们做了大量工作,会自动识别输出数据的类型,例如当前例子,会识别为。(原文)String
类型
Rust 一系列用于格式化的宏非常类似,{}
占位符只要求输出数据的类型实现了 fmt::Display
特征即可。而这里输出的的 ®ion
是 &&&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
处理成了关联类型。
也就是说,泛型特征是一个类型在实现这个特征时,针对不同的类型可以有不同的实现(比如同样是吃东西,吃炒菜用筷子,但吃蛋糕用叉子),而关联类型则是不同的类型实现这个特征时可能关联到不同的类型,但特定类型实现这个特征时,关联到的类型是唯一的(比如餐厅都是可以吃饭的,不同餐厅可能提供不同的菜式,但在中餐厅只能吃到中餐,在西餐厅只能吃到西餐)。