草帽团三大战力-童年版
这里遵循了社区的习惯译法"移动",学过 C++ 的读者可能比较熟悉了;对使用其他语言的读者来说,要特别注意这里的"移动"在语义上并非像真实生活中那样简单地挪动物品的位置,而是涉及一个非常重要的概念------所有权。在这个语义下,你可以把它理解为将值从一个所有者移交给另一个所有者,这里的重点是对所有权的转移,而所有权是 Rust 的核心概念。------译者注
4.2 移动
在 Rust 中,对大多数类型来说,像为变量赋值、将其传给函数或从函数返回这样的操作都不会复制值,而是会移动值。源会把值的所有权转移给目标并变回未初始化状态,改由目标变量来控制值的生命周期。Rust 程序会以每次只移动一个值的方式建立和拆除复杂的结构。
你可能惊讶于 Rust 会改变这些基本操作的含义,确实如此,历史发展到今天,赋值应该已经是含义最明确的操作了。但是,如果仔细观察不同的语言处理赋值操作的方式,你会发现不同的编程流派之间实际上存在着相当明显的差异。对比这些差异也能很容易看出 Rust 做出这种选择的意义极其重要性。
考虑以下 Python 代码:
ini
s = ['udon', 'ramen', 'soba']
t = s
u = s
每个 Python 对象都有一个引用计数,以用于跟踪当前正引用着此值的数量。因此,在对 s
赋值之后,程序的状态如图 4-5 所示。(请注意,这里忽略了一些字段。)
图 4-5:Python 如何在内存中表示字符串列表
由于只有 s
指向列表,因此该列表的引用计数为 1。由于列表是唯一指向这些字符串的对象,因此它们各自的引用计数也是 1
。
当程序执行对 t
和 u
的赋值时会发生什么?Python 会直接让目标指向与源相同的对象,并增加对象的引用计数来实现赋值。所以程序的最终状态如图 4-6 所示。
图 4-6:在 Python 中将 s
赋值给 t
和 u
的结果
Python 已经将指针从 s
复制到 t
和 u
,并将此列表的引用计数更新为 3
。Python 中的赋值开销极低,但因为它创建了对对象的新引用,所以必须维护引用计数才能知道何时可以释放该值。
现在考虑类似的 C++ 代码:
ini
using namespace std;
vector<string> s = { "udon", "ramen", "soba" };
vector<string> t = s;
vector<string> u = s;
s
的原始值在内存中如图 4-7 所示。
图 4-7:C++ 如何表示内存中的字符串向量
当程序将 s
赋值给 t
和 u
时会发生什么?在 C++ 中,把 std::vector
赋值给其他元素会生成一个向量的副本,std::string
的行为也类似。所以当程序执行到这段代码的末尾时,它实际上已经分配了 3 个向量和 9 个字符串,如图 4-8 所示。
图 4-8:在 C++ 中将 s
赋值给 t
和 u
的结果
理论上,如果涉及某些特定的值,那么 C++ 中的赋值可能会消耗超乎想象的内存和处理器时间。然而,其优点是程序很容易决定何时释放这些内存:当变量超出作用域时,此处分配的所有内容都会自动清除。
从某种意义上说,C++ 和 Python 选择了相反的权衡:Python 以需要引用计数(以及更广泛意义上的垃圾回收)为代价,让赋值的开销变得非常低。C++ 则选择让全部内存的所有权保持清晰,而代价是在赋值时要执行对象的深拷贝。一般来说,C++ 程序员不太热衷这种选择:深拷贝的开销可能很昂贵,而且通常有更实用的替代方案。
那么类似的程序在 Rust 中会怎么做呢?请看如下代码:
ini
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;
let u = s;
与 C 和 C++ 一样,Rust 会将纯字符串字面量(如 "udon"
)放在只读内存中,因此为了与 C++ 示例和 Python 示例进行更清晰的比较,此处调用了 to_string
以获取堆上分配的 String
值。
在执行了 s
的初始化之后,由于 Rust 和 C++ 对向量和字符串使用了类似的表示形式,因此情况看起来就和 C++ 中一样,如图 4-9 所示。
图 4-9:Rust 如何表示内存中的字符串向量
但要记住,在 Rust 中,大多数类型的赋值会将值从源转移 给目标,而源会回到未初始化状态。因此在初始化 t
之后,程序的内存如图 4-10 所示。
图 4-10:Rust 中将 s
赋值给 t
的结果
这里发生了什么?初始化语句 let t = s;
将向量的 3 个标头字段从 s
转移给了 t
,现在 t
拥有此向量。向量的元素保持原样,字符串也没有任何变化。每个值依然只有一个拥有者,尽管其中一个已然易手。整个过程中没有需要调整的引用计数,不过编译器现在会认为 s
是未初始化状态。
那么当我们执行初始化语句 let u = s;
时会发生什么呢?这会将尚未初始化的值 s
赋给 u
。Rust 明智地禁止使用未初始化的值,因此编译器会拒绝此代码并报告如下错误:
rust
error: use of moved value: `s`
|
7 | let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
| - move occurs because `s` has type `Vec<String>`,
| which does not implement the `Copy` trait
8 | let t = s;
| - value moved here
9 | let u = s;
| ^ value used here after move
思考一下 Rust 在这里使用移动语义的影响。与 Python 一样,赋值操作开销极低:程序只需将向量的三字标头从一个位置移到另一个位置即可。但与 C++ 一样,所有权始终是明确的:程序不需要引用计数或垃圾回收就能知道何时释放向量元素和字符串内容。
代价是如果需要同时访问它们,就必须显式地要求复制。如果想达到与 C++ 程序相同的状态(每个变量都保存一个独立的结构副本),就必须调用向量的 clone
方法,该方法会执行向量及其元素的深拷贝:
ini
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s.clone();
let u = s.clone();
还可以使用 Rust 的引用计数来实现与 Python 类似的操作,4.4 节会简要讨论这些内容。
笔记
如果不理解Rust底层实现逻辑,只是从JavaScript语言角度来看就很难理解
4.2.1 更多移动类操作
在先前的例子中,我们已经展示了如何初始化工作------在变量进入 let
语句的作用域时为它们提供值。给变量赋值则与此略有不同,如果你将一个值转移给已初始化的变量,那么 Rust 就会丢弃该变量的先前值。例如:
ini
let mut s = "Govinda".to_string();
s = "Siddhartha".to_string(); // 在这里丢弃了值"Govinda"
在上述代码中,当程序将字符串 "Siddhartha"
赋值给 s
时,它的先前值 "Govinda"
会首先被丢弃。但请考虑以下代码:
ini
let mut s = "Govinda".to_string();
let t = s;
s = "Siddhartha".to_string(); // 这里什么也没有丢弃
笔记
这里如果是JavaScript的思路是没问题的,但是,这是Rust
这一次,t
从 s
接手了原始字符串的所有权,所以当给 s
赋值时,它是未初始化状态。这种情况下不会丢弃任何字符串。
我们在这个例子中使用了初始化和赋值,因为它们很简单,但 Rust 还将"移动"的语义应用到了几乎所有对值的使用上。例如,将参数传给函数会将所有权转移给函数的参数、从函数返回一个值会将所有权转移给调用者、构建元组会将值转移给元组。
你现在可以更好地理解 4.1 节的示例中到底发生过什么了。例如,我们在构建 composers
向量时,是这样写的:
rust
struct Person { name: String, birth: i32 }
let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(),
birth: 1525 });
这段代码展示了除初始化和赋值之外发生移动的几个地方。
从函数返回值
调用 Vec::new()
构造一个新向量并返回,返回的不是指向此向量的指针,而是向量本身:它的所有权从 Vec::new
转移给了变量 composers
。同样,to_string
调用返回的是一个新的 String
实例。
构造出新值
新 Person
结构体的 name
字段是用 to_string
的返回值初始化的。该结构体拥有这个字符串的所有权。
将值传给函数
整个 Person
结构体(不是指向它的指针)被传给了向量的 push
方法,此方法会将该结构体移动到向量的末尾。向量接管了 Person
的所有权,因此也间接接手了 name
这个 String
的所有权。
像这样移动值乍一看可能效率低下,但有两点需要牢记。首先,移动的永远是值本身,而不是这些值拥有的堆存储。对于向量和字符串,值本身就是指单独的"三字标头",幕后的大型元素数组和文本缓冲区仍然位于它们在堆中的位置。其次,Rust 编译器在生成代码时擅长"看穿"这一切动作。在实践中,机器码通常会将值直接存储在它应该在的位置。2
2这意味着甚至可以没有运行期开销。------译者注
笔记
哇哦,这也太酷了
4.2.2 移动与控制流
前面的例子中都有非常简单的控制流,那么该如何在更复杂的代码中移动呢?一般性原则是,如果一个变量的值有可能已经移走,并且从那以后尚未明确赋予其新值,那么它就可以被看作是未初始化状态。如果一个变量在执行了 if
表达式中的条件后仍然有值,那么就可以在这两个分支中使用它:
scss
let x = vec![10, 20, 30];
if c {
f(x); // ......可以在这里移动x
} else {
g(x); // ......也可以在这里移动x
}
h(x); // 错误:只要任何一条路径用过它,x在这里就是未初始化状态
出于类似的原因,禁止在循环中进行变量移动:
scss
let x = vec![10, 20, 30];
while f() {
g(x); // 错误:x已经在第一次迭代中移动出去了,在第二次迭代中,它成了未初始化状态
}
也就是说,除非在下一次迭代中明确赋予 x
一个新值,否则就会出错。
scss
let mut x = vec![10, 20, 30];
while f() {
g(x); // 从x移动出去了
x = h(); // 赋予x一个新值
}
e(x);
4.2.3 移动与索引内容
前面提到过,移动会令其来源变成未初始化状态,因为目标将获得该值的所有权。但并非值的每种拥有者都能变成未初始化状态。例如,考虑以下代码:
ini
// 构建一个由字符串"101"、"102"......"105"组成的向量
let mut v = Vec::new();
for i in 101 .. 106 {
v.push(i.to_string());
}
// 从向量中随机抽取元素
let third = v[2]; // 错误:不能移动到Vec索引结构之外3
let fifth = v[4]; // 这里也一样
3 注意这里写的是
v[2]
而非&v[2]
。------译者注
为了解决这个问题,Rust 需要以某种方式记住向量的第三个元素和第五个元素是未初始化状态,并要跟踪该信息直到向量被丢弃。通常的解决方案是,让每个向量都携带额外的信息来指示哪些元素是活动的,哪些元素是未初始化的。这显然不是系统编程语言应该做的。向量应该只是向量,不应该携带额外的信息或状态。事实上,Rust 会拒绝前面的代码并报告如下错误:
rust
error: cannot move out of index of `Vec<String>`
|
14 | let third = v[2];
| ^^^^
| |
| move occurs because value has type `String`,
| which does not implement the `Copy` trait
| help: consider borrowing here: `&v[2]`
移动第五个元素时 Rust 也会报告类似的错误。在这条错误消息中,Rust 还建议使用引用,因为你可能只是想访问该元素而不是移动它,这通常确实是你想要做的。但是,如果真想将一个元素移出向量该怎么办呢?需要找到一种在遵循类型限制的情况下执行此操作的方法。以下是 3 种可能的方法:
rust
// 构建一个由字符串"101"、"102"......"105"组成的向量
let mut v = Vec::new();
for i in 101 .. 106 {
v.push(i.to_string());
}
// 方法一:从向量的末尾弹出一个值:
let fifth = v.pop().expect("vector empty!");
assert_eq!(fifth, "105");
// 方法二:将向量中指定索引处的值与最后一个值互换,并把前者移动出来:
let second = v.swap_remove(1);
assert_eq!(second, "102");
// 方法三:把要取出的值和另一个值互换:
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");
// 看看向量中还剩下什么
assert_eq!(v, vec!["101", "104", "substitute"]);
每种方法都能将一个元素移出向量,但仍会让向量处于完全填充状态,只是向量可能会变小。
像 Vec
这样的集合类型通常也会提供在循环中消耗所有元素的方法:
rust
let v = vec!["liberté".to_string(),
"égalité".to_string(),
"fraternité".to_string()];
for mut s in v {
s.push('!');
println!("{}", s);
}
当我们将向量直接传给循环(如 for ... in v
)时,会将向量从 v
中移动 出去,让 v
变成未初始化状态。for
循环的内部机制会获取向量的所有权并将其分解为元素。在每次迭代中,循环都会将另一个元素转移给变量 s
。由于 s
现在拥有字符串,因此可以在打印之前在循环体中修改它。在循环的过程中,向量本身对代码不再可见,因此也就无法观察到它正处在某种部分清空的状态。4
4因此不用担心中途修改向量本身之类的问题。------译者注
如果需要从拥有者中移出一个编译器无法跟踪的值,那么可以考虑将拥有者的类型更改为能动态跟踪自己是否有值的类型。例如,下面是前面例子的一个变体:
rust
struct Person { name: Option<String>, birth: i32 }
let mut composers = Vec::new();
composers.push(Person { name: Some("Palestrina".to_string()),
birth: 1525 });
但不能像下面这样做:
ini
let first_name = composers[0].name;
这只会引发与前面一样的"无法移动到索引结构之外"错误。但是因为已将 name
字段的类型从 String
改成了 Option<String>
,所以这意味着 None
也是该字段要保存的合法值。因此,可以像下面这样做:
rust
let first_name = std::mem::replace(&mut composers[0].name, None);
assert_eq!(first_name, Some("Palestrina".to_string()));
assert_eq!(composers[0].name, None);
replace
调用会移出 composers[0].name
的值,将 None
留在原处,并将原始值的所有权转移给其调用者。事实上,这种使用 Option
的方式非常普遍,所以该类型专门为此提供了一个 take
方法,以便更清晰地写出上述操作,如下所示:
ini
let first_name = composers[0].name.take();
这个 take
调用与之前的 replace
调用具有相同的效果。
笔记
相对 replace , 使用 take 可使代码更简洁
4.3 Copy
类型:关于移动的例外情况
迄今为止,本章所展示的值移动示例都涉及向量、字符串和其他可能占用大量内存且复制成本高昂的类型。移动能让这些类型的所有权清晰且赋值开销极低。但对于像整数或字符这样的简单类型,如此谨小慎微的处理方式确实没什么必要。
下面来比较一下用 String
进行赋值和用 i32
进行赋值时内存中有什么不同:
ini
let string1 = "somnambulance".to_string();
let string2 = string1;
let num1: i32 = 36;
let num2 = num1;
运行这段代码后,内存如图 4-11 所示。
图 4-11:用 String
赋值会移动值,而用 i32
赋值会复制值
与前面的向量一样,赋值会将 string1
转移给 string2
,这样就不会出现两个字符串负责释放同一个缓冲区的情况。但是,num1
和 num2
的情况有所不同。i32
只是内存中的几字节,它不拥有任何堆资源,也不会实际依赖除本身的字节之外的任何内存。当我们将它的每一位转移给 num2
时,其实已经为 num1
制作了一个完全独立的副本。
移动一个值会使移动的源变成未初始化状态。不过,尽管将 string1
视为未初始化变量确实符合其基本意图,但以这种方式对待 num1
毫无意义,继续使用 num1
也不会造成任何问题。移动在这里并无好处,反而会造成不便。
之前我们谨慎地说过,大多数 类型会被移动,现在该谈谈例外情况了,即那些被 Rust 指定成 Copy
类型 的类型。对 Copy
类型的值进行赋值会复制这个值,而不会移动它。赋值的源仍会保持已初始化和可用状态,并且具有与之前相同的值。把 Copy
类型传给函数和构造器的行为也是如此。
标准的 Copy
类型包括所有机器整数类型和浮点数类型、char
类型和 bool
类型,以及某些其他类型。Copy
类型的元组或固定大小的数组本身也是 Copy
类型。
只有那些可以通过简单地复制位来复制其值的类型才能作为 Copy
类型。前面解释过,String
不是 Copy
类型,因为它拥有从堆中分配的缓冲区。出于类似的原因,Box<T>
也不是 Copy
类型,因为它拥有从堆中分配的引用目标。代表操作系统文件句柄的 File
类型不是 Copy
类型,因为复制这样的值需要向操作系统申请另一个文件句柄。类似地,MutexGuard
类型表示一个互斥锁,它也不是 Copy
类型:复制这种类型毫无意义,因为每次只能有一个线程持有互斥锁。
根据经验,任何在丢弃值时需要做一些特殊操作的类型都不能是 Copy
类型:Vec
需要释放自身元素、File
需要关闭自身文件句柄、MutexGuard
需要解锁自身互斥锁,等等。对这些类型进行逐位复制会让我们无法弄清哪个值该对原始资源负责。
那么自定义类型呢?默认情况下,struct
类型和 enum
类型不是 Copy
类型:
css
struct Label { number: u32 }
fn print(l: Label) { println!("STAMP: {}", l.number); }
let l = Label { number: 3 };
print(l);
println!("My label number is: {}", l.number);
这无法编译。Rust 会报错:
go
error: borrow of moved value: `l`
|
10 | let l = Label { number: 3 };
| - move occurs because `l` has type `main::Label`,
| which does not implement the `Copy` trait
11 | print(l);
| - value moved here
12 | println!("My label number is: {}", l.number);
| ^^^^^^^^
| value borrowed here after move
由于 Label
不是 Copy
类型,因此将它传给 print
会将值的所有权转移给 print
函数,然后在返回之前将其丢弃。这样做显然是愚蠢的,Label
中只有一个 u32
,因此没有理由在将 l
传给 print
时移动这个值。
但是用户定义的类型不是 Copy
类型这一点只是默认情况而已。如果此结构体的所有字段本身都是 Copy
类型,那么也可以通过将属性 #[derive(Copy, Clone)]
放置在此定义之上来创建 Copy
类型,如下所示:
css
#[derive(Copy, Clone)]
struct Label { number: u32 }
笔记
添加 #[derive(Copy, Clone)] 属性后,对于数据的操作就和JavaScript类似了
经过此项更改,前面的代码可以顺利编译了。但是,如果试图在一个其字段不全是 Copy
类型的结构体上这样做,则仍然行不通。假设要编译如下代码:
rust
#[derive(Copy, Clone)]
struct StringLabel { name: String }
那么就会引发如下错误:
rust
error: the trait `Copy` may not be implemented for this type
|
7 | #[derive(Copy, Clone)]
| ^^^^
8 | struct StringLabel { name: String }
| ------------ this field does not implement `Copy`
为什么符合条件的用户定义类型不能自动成为 Copy
类型呢?这是因为类型是否为 Copy
对于在代码中使用它的方式有着重大影响:Copy
类型更灵活,因为赋值和相关操作不会把原始值变成未初始化状态。但对类型的实现者而言,情况恰恰相反:Copy
类型可以包含的类型非常有限,而非 Copy
类型可以在堆上分配内存并拥有其他种类的资源。因此,创建一个 Copy
类型代表着实现者的郑重承诺:如果以后确有必要将其改为非 Copy
类型,则使用它的大部分代码可能需要进行调整。
虽然 C++ 允许重载赋值运算符以及定义专门的复制构造函数和移动构造函数,但 Rust 并不允许这种自定义行为。在 Rust 中,每次移动都是字节级的一对一浅拷贝,并让源变成未初始化状态。复制也是如此,但会保留源的初始化状态。这确实意味着 C++ 类可以提供 Rust 类型所无法提供的便捷接口,比如可以在看似普通的代码中隐式调整引用计数、把昂贵的复制操作留待以后进行,或使用另一些复杂的实现技巧。
但这种灵活性的代价是,作为一门语言,C++ 的基本操作(比如赋值、传参和从函数返回值)变得更难预测。例如,本章的前半部分展示过在 C++ 中将一个变量赋值给另一个变量时可能需要任意数量的内存和处理器时间。Rust 的一个原则是:各种开销对程序员来说应该是显而易见的。基本操作必须保持简单,而潜在的昂贵操作应该是显式的,比如前面例子中对 clone
的调用就是在对向量及其包含的字符串进行深拷贝。
本节用复制(Copy
)和克隆(Clone
)这两个模糊的术语描述了某个类型可能具备的特征。它们实际上是特型 的示例。特型是 Rust 语言中的开放式工具,用于根据你对类型可以执行的操作来对类型进行分类。第 11 章会对特型做一般性讲解,第 13 章会专门讲解 Copy
和 Clone
这两个特型。
4.4 Rc
与 Arc
:共享所有权
尽管在典型的 Rust 代码中大多数值会有唯一的拥有者,但在某些情况下,很难为每个值都找到具有所需生命周期的单个拥有者,你会希望某个值只要存续到每个人都用完它就好。对于这些情况,Rust 提供了引用计数指针类型 Rc
和 Arc
[Arc
是原子引用计数(atomic reference count) 的缩写 ]。正如你对 Rust 的期待一样,这些类型用起来完全安全:你不会忘记调整引用计数,不会创建 Rust 无法注意到的指向引用目标的其他指针,也不会偶遇那些常与 C++ 中的引用计数指针如影随形的各种问题。
Rc
类型和 Arc
类型非常相似,它们之间唯一的区别是 Arc
可以安全地在线程之间直接共享,而普通 Rc
会使用更快的非线程安全代码来更新其引用计数。如果不需要在线程之间共享指针,则没有理由为 Arc
的性能损失"埋单",因此应该使用 Rc
,Rust 能防止你无意间跨越线程边界传递它。这两种类型在其他方面都是等效的,所以本节的其余部分只会讨论 Rc
。
之前我们展示过 Python 如何使用引用计数来管理值的生命周期。你可以使用 Rc
在 Rust 中获得类似的效果。考虑以下代码:
rust
use std::rc::Rc;
// Rust能推断出所有这些类型,这里写出它们只是为了讲解时清晰
let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();
对于任意类型 T
,Rc<T>
值是指向附带引用计数的在堆上分配的 T
型指针。克隆一个 Rc<T>
值并不会复制 T
,相反,它只会创建另一个指向它的指针并递增引用计数。所以前面的代码在内存中会生成图 4-12 所示的结果。
图 4-12:具有 3 个引用的引用计数字符串
这 3 个 Rc<String>
指针指向了同一个内存块,其中包含引用计数和 String
本身的空间。通常的所有权规则适用于 Rc
指针本身,当丢弃最后一个现有 Rc
时,Rust 也会丢弃 String
。
可以直接在 Rc<String>
上使用 String
的任何常用方法:
less
assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u);
Rc
指针拥有的值是不可变的。如果你试图将一些文本添加到字符串的末尾:
arduino
s.push_str(" noodles");
那么 Rust 会拒绝:
javascript
error: cannot borrow data in an `Rc` as mutable
|
13 | s.push_str(" noodles");
| ^ cannot borrow as mutable
|
Rust 的内存和线程安全保证的基石是:确保不会有任何值是既共享又可变的。Rust 假定 Rc
指针的引用目标通常都可以共享,因此就不能是可变的。第 5 章会解释为什么这个限制很重要。
使用引用计数管理内存的一个众所周知的问题是,如果有两个引用计数的值是相互指向的,那么其中一个值就会让另一个值的引用计数保持在 0 以上,因此这些值将永远没机会释放,如图 4-13 所示。
图 4-13:循环引用计数------这些对象都没机会释放
以这种方式在 Rust 中造成值的泄漏也是有可能的,但这种情况非常少见。只要不在某个时刻让旧值指向新值,就无法建立循环。这显然要求旧值是可变的。由于 Rc
指针会保证其引用目标不可变,因此通常不可能建立这种循环引用。但是,Rust 确实提供了创建其他不可变值中的可变部分的方法,这称为内部可变性 ,9.11 节会详细介绍。如果将这些技术与 Rc
指针结合使用,则确实可以建立循环并造成内存泄漏。
有时可以通过对某些链接使用弱引用指针 std::rc::Weak
来避免建立 Rc
指针循环。但是,本节不会介绍这些内容,有关详细信息,请参阅标准库的文档。
移动和引用计数指针是缓解所有权树严格性问题的两种途径。在第 5 章中,我们将研究第三种途径:借用对值的引用。一旦你熟悉了所有权和借用这两个概念,就已经翻越了 Rust 学习曲线中最陡峭的部分,并为发挥 Rust 的独特优势做好了准备。
笔记
记住 Rust 独特的默认行为,底层思路和其他语言还是有很多相似之处的,看完这章对Rust似乎越来越越有感觉了
欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗^_^
微信公众号:草帽Lufei