Rust才是最好的语言

  1. 前言
  2. 所有权系统(Ownership)
  3. 结尾

前言

大约是大一下学期,我开始接触到了 C++ 这门语言, 本着大家都学 Java, 我学 C++ 才比较酷的心态开始了后来的 C++ 入门之旅. 在经历了 《C++ Primer 5th》, 《Thinking in C++》,《STL 源码剖析》, “Effective 全家桶” 的摧残后, 我才意识到到这个时候我才算是正式开始了 C++ 的入门.

第一次听说 Rust 这门语言是在大一下一次与浩然巨佬的交流中, 当时我已经确立了要踩 C++ 这个深坑, 浩然巨佬就在那个时候给我推荐了这门语言. 我在网上搜索过一番后对所谓「Ownership」,「Zero cost abstraction」 等特性并不感冒, 也正如上面所说的, 我当时对 C++ 的认识仅仅是「速度快」,「多范式」,「难」,而日常的 toy code 也没有办法认识到 C++ 真正的痛点——资源管理, 而在学习 Modern C++ 的时候, 我发现C++标准中更是付出了很大的努力来改善这一点.

当我在读 STL 源码的时候,我对 萃取器(Traits) 的实现产生了浓厚的兴趣, 然后写了一篇博客, 第二天浩然巨佬找到了我, 给我补充了一些关于 C++ 中 Traits 技术的不足,同时再次给我安利了 Rust 中的 Traits,恰逢前些日子我又偶然间发现了一个 THU 大佬的 「从零开始写 OS」其中采用的语言正是 Rust, 就这样, 我开始了 Rust 的学习之旅.

近期看了太多侯捷老师的作品, 对于他的翻译风格甚是喜欢, 所以本文采取类似的方式写作,具体观点见技術引導乎 文化傳承乎.

可能大量存在的英文术语:
trivial: 没用的
memberwise: 对每一个 member 施以
bitwise: 对每一个 bit 施以
sematics: 语义

所有权系统(Ownership)

所有权(系统)是 Rust 最核心的功能,其令 Rust 无需GC(Garbage Collector)即可保障内存安全. 在看到这里的讲解的时候, 我不由自主的拿 C++ 的 RAII 思想来做比较, 很明显 C++ 中的存在的例如 to_string().c_str() 导致的问题,在 Rust 中直接被编译器给 ban 掉了~

这里首先需要讨论资源转移的问题(方式为深浅拷贝). 这个我认为最好的区别实践是在 C++ 的 default copy constructor 的编译器生成策略上: 当用户没有显示的写出 copy constructor 时, C++ 编译器会在必要的时候自动生成一个. 而生成的 copy constructor 需要针对一些问题(例如:「class 内的某个成员对象的class 声明中存在一个 copy constructor」,「class 继承自一个 base class, 而后者存在一个 copy constructor」, 「class 声明了 virtual functions」,「class 的派生自一个继承链, 其中存在 virtual functions」)来决定 生成的 copy constructor 是通过 bitwise copy semantics 还是 memberwise copy semantics. 从条件中也可以看出, 如果存在不能简单进行浅拷贝的情况, 就需要特殊处理.

上述说到深浅拷贝的问题主要是为了接下来说一下 Rust 中字符串的操作.

例如我写出下面的代码:

1
2
let str1 = String::from("Hello");
let str2 = str1;

其中第一条发生的事是从栈上开辟一段空间,然后假设这段空间里有三个成员, 一个是指向堆中一块内存的指针ptr, 一个用来表示这个字符串的长度len, 另一个用来表示字符串的容量capacity.

第二条的语义是将 str1 赋给 str2, str1 的数据被赋值了, 但是这里进行的是浅拷贝, 并不会拷贝 *str1.ptr 的内容, 仅仅是将 ptr 的值, len 的值, capacity 的值进行了拷贝. 这里可能是考虑到性能的问题, 因为如果 堆上的数据很大,那么复制的代价将会很高.

这样就会出现这样的问题, 两个 String 对象的 ptr 指向了同一块堆内存, 如果其中一个 进行了 release, 那么剩下的可能会导致访问这片已经被释放的内存.

或者还有一个问题,Rust每个指针和引用都有一个 lifetime (对比C++ RAII思想),两个对象在离开了他们的作用域(假设在同一个), 双双调用 destructor 会导致二次释放内存造成内存污染.

但是上述的问题在 Rust 中并不会出现, 因为在第一条语句进行了赋值的同时, str1 中的 ptr 就已经失效了, 也就是资源控制权被转交给了 str2, 这时候再尝试访问 str1 的话 编译时会出现如下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
error[E0382]: borrow of moved value: `str1`
--> src/main.rs:6:19
|
3 | let str1 = String::from("Hello");
| ---- move occurs because `str1` has type `std::string::String`, which does not implement the `Copy` trait
4 | let str2 = str1;
| ---- value moved here
5 |
6 | println!("{}",str1);
| ^^^^ value borrowed here after move

```

因为 Rust 禁止访问无效的引用.

但并不是所有的资源都会进行所有权的转移, 也就像 str2 的成员被赋值时说的, 已知编译时已知大小的类型的变量会被存储在栈上, 这里就是简单的 copy 行为.


而在 C++ 的中对于所有权唯一持有者的使用方式是 scoped-ptr 和 auto-ptr. 两者的区别是前者的所有权是从一而终的, 后者可以自由的转交资源,同时保证所有权的唯一,但是由于 auto-ptr 使用的 copy semantics 会导致将原指针置为 NULL,在某些情境下这是不希望看到的, 所以在 C++11 中已经由使用 move semantics 并禁用了 copy 的 unique-ptr 所替代.


[作者实现的unique_ptr](https://github.com/maochongxin/smart_ptr/blob/master/src/unique_ptr.h)


生命周期(Lifetime)——跟Rust编译器学C++内存管理
---
```C++
#include <cstring>
#include <cstdio>

class String {
public:
String(const char* s):
str(new char[strlen(s) + 1]) {
strcpy(str, s);
}
~String() {
delete[] str;
str = nullptr;
}
const char* c_str() const { return str; }
private:
char* str;
};

const char* longer(const String& lhs, const String& rhs) {
return strlen(lhs.c_str()) > strlen(rhs.c_str()) ? lhs.c_str() : rhs.c_str();
}


int main() {
String s1("aaaa");
const char* s;

{
String s2("aaaaaaaaaa");
s = longer(s1, s2);
}

printf("%s\n", s);

return 0;
}

在C++中, 很不经意间就会写出类似于上面的代码, 返回了局部对象的引用, 离开局部对象的作用域后, 就造成了典型的垂悬引用, 因为析构后内存不会及时回收的缘故, 通常短期内使用该引用可能会输出正确的结果, 但说不定什么时候这颗炸弹就会炸掉了, 类似的还有std::vector 等容器的迭代器失效问题.

Rust 中生命周期的概念便很好的处理了这种情况, 上述代码如果在 Rust 中是根本无法通过编译的. 在Rust中, 上面C++的获取某对象的指针的行为称为借用(borrow), Rust编译器中的借用检查器(borrow checker), 通过比较对象的作用域来防止垂悬引用的产生, 像上述的代码, 如果加上 Rust 的生命周期作用域标识应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn longer<'a>(lhs: &'a str, rhs: &'a str) -> &'a str {
if lhs.len() > rhs.len() {
lhs
} else {
rhs
}
}

fn main() {
let s1 = String::from("aaaaaaaa");
let s: &str;
{
let s2 = String::from("aaaa");
s = longer(s1.as_str(), s2.as_str());
}
println!("{}", s);
}

报错:

1
2
3
4
5
6
7
8
9
10
11
12
error: `s2` does not live long enough
--> prog.rs:15:5
|
14 | s = longer(s1.as_str(), s2.as_str());
| -- borrow occurs here
15 | }
| ^ `s2` dropped here while still borrowed
16 | println!("{}", s);
17 | }
| - borrowed value needs to live until here

error: aborting due to previous error

综上来看, 关于悬垂引用的问题, 在C++中是只能靠经验来避免写出这样的代码, 相对来说, Rust 这种直接CE有效避免了新手犯错.

结尾

必要的声明:

  1. 本文仅作为Rust学习笔记, 仅供自娱自乐.
  2. 作者水平有限, 部分观点可能并不全面或者存在错误,欢迎指正.

教材:

参考:

script>