最近因为面试要学习 Rust,突击了几天,感觉 Rust 是真的麻烦。
但确实 Rust 很多地方是 copy 了 C++ 的,然后做出了一些改进。
本文章是建立在我比较懂 C++ 但是不懂 Rust 的情况下写出来的,不保正内容完全正确。仅作为个人记录,为更深入的理解 Rust 做铺垫。
堆内存、栈内存
首先要说的是我不知道 C++ 的标准里有没有规定堆内存和栈内存,但大概的实现应该和大部分网上的资料相同。至少目前这么认为应该没问题?
以下为了方便叙述,就假设目前已知的资料是正确的。
Rust 跟 C++ 一样,在堆上使用内存都需要分配(栈内存那当然不用分配……因为已经预先分配好了,不然就不用 stack overflow 了),然后使用指针访问。
那么所有权存在的原因是什么呢?在 Rust 中,管理 heap 数据,就是所有权存在的原因。
Rust 怎么回收内存呢?和 C++ 一样,反正不是通过 GC 回收,而是通过所有权。
Rust 在编译时会进行一系列的检查,如果违反了所有权的规则,那么就不能过编译(存在潜在的内存泄漏可能)。
所有权
Rust 中关于所有权有三条规则:
- 每一个值都有一个所有者。
- 每个值在任何时刻 有且只有一个所有者。
- 所有者离开作用域,值会被 Drop
我思考了一下,Rust 应该是把对象的生命周期和存储周期绑定在一起了?当然在 C++ 中如果你遵循 RAII 的话那大部分时刻也是这样的。
String
在 Rust 中是存储在堆上的,以下都用 String
来作为示例(当然其他用堆存储数据的结构也同理)。
普通的字符串使用:
1
2
3
4
5
6
7
| fn main() {
let mut str = String::from("Hello");
str.push_str(" World");
println!("{str}");
}
|
根据上面的规则,你别管堆内存栈内存,离开作用域就会被释放,Rust 在 }
自动调用 drop
函数(C++ 中的 RAII)
Rust vs. C++ -> 移动
Rust 中对于存在于堆上的对象,默认语义是 移动。
以下代码无法编译,因为 str
的所有权已经移动给了 str2
。
1
2
3
4
5
6
7
8
| fn main() {
let str = String::from("Hello World");
let str2 = str;
println!("{str}");
println!("{str2}");
}
|
而在 C++ 中,首先,C++ 默认语义全是拷贝,如果要移动需要调用 std::move
,此外,C++ 标准保证标准库组件被移动后状态为 valid (但未指定)。
以下代码可以编译,访问 str.length()
是合法的
1
2
3
4
5
6
7
8
| int main() {
std::string str{ "Hello World" };
auto str2 = std::move(str);
std::cout << str.length() << '\n';
std::cout << str2 << '\n';
}
|
对于存在栈上的对象大家都一样。
Rust 这么干明显可以避免 double free 这种问题。
当然了,不管是 Rust 还是 C++,移动都不代表浅拷贝。
Rust vs. C++ -> 克隆
如果真的要复制堆上的数据,在 Rust 中,需要调用 .clone()
方法,这个就创建了一个副本。
而在 C++ 中当然直接 =
赋值默认就会选择复制构造。
但需要注意,Rust 不允许实现 Drop trait 的同时实现 Copy trait。
真是严格呢~
Rust vs. C++ -> 所有权 + 函数
根据之前的介绍,Rust
默认写值就是移动语义,那么在函数参数中自然也如此。
以下代码并不能通过编译,str
的所有权移动给了函数 takes_ownership
的参数上,在出函数作用域时已经被释放了。
1
2
3
4
5
6
7
8
9
10
11
| fn main() {
let str = String::from("Hello World");
takes_ownership(str);
println!("{str}");
}
fn takes_ownership(str: String) {
println!("{str}");
}
|
在 C++ 中想实现类似的效果需要:
1
2
3
4
5
6
7
8
9
10
11
| void takes_ownership(std::string&& str) {
std::cout << str << '\n';
}
int main() {
std::string str{ "Hello World" };
takes_ownership(std::move(str));
std::cout << str.length() << '\n';
}
|
注意,函数参数写的是 &&
,这样才是直接调用移动构造,然后在函数结尾销毁参数,然后 str
是 valid 但是未指明状态。
而如果写普通的值类型,那么 main
中的 str
还是要析构的。
这里我也没太明白,我感觉应该是跟解分配和释放有关。总之,&& 才是和 Rust 的语义相同。
Rust 中还能返回所有权,这样参数就不会被销毁了,即:
1
2
3
4
5
6
7
8
9
10
11
12
| fn main() {
let str = String::from("Hello World");
let str2 = takes_ownership(str);
println!("{str2}");
}
fn takes_ownership(str: String) -> String {
println!("{str}");
str
}
|
这里可以利用一下 rust 的 shadowing,直接让返回的变量也叫 str。当然这里 str
是不可变引用,所以得 shadowing,不能直接用 str 接返回值。
在 C++ 里一般应该不会这么干,如果想干的话:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| std::string takes_ownership(std::string&& str) {
std::cout << str << '\n';
return str;
}
int main() {
std::string str{ "Hello World" };
str = takes_ownership(std::move(str));
std::cout << str << '\n';
return 0;
}
|
返回的时候值类型就可以,会帮你选择移动的。
引用
Rust 中,如果不需要所有权,那么可以给函数传入一个引用。
也就是说:引用不持有所有权,创建一个引用称为 借用。
Rust vs. C++ -> 引用
Rust 给函数传参也需要加上 &
符号,代表传递引用。
(弹幕说其实是类似于指针,Rust 在函数参数这个地方自动帮你解引用了)
1
2
3
4
5
6
7
8
9
10
11
| fn main() {
let str = String::from("Hello World");
let str_len = calculate_length(&str);
println!("{str_len}");
}
fn calculate_length(str: &String) -> usize {
str.len()
}
|
那这个在 C++ 里就不用说了吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
| size_t calculate_length(const std::string& str) {
return str.length();
}
int main() {
std::string str{ "Hello World" };
auto str_len = calculate_length(str);
std::cout << str_len << '\n';
return 0;
}
|
如果想要在函数里修改字符串,那么在 Rust 中你需要:
1
2
3
4
5
6
7
8
9
10
11
12
| fn main() {
let mut str = String::from("Hello");
let str_len = change(&mut str);
println!("str: {str}, len: {str_len}");
}
fn change(str: &mut String) -> usize {
str.push_str(" World!");
str.len()
}
|
如果在 C++ 中,反过来(这俩一个默认可变一个默认不可变真的是):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| size_t change(std::string& str) {
str += " World!";
return str.length();
}
int main() {
std::string str{ "Hello" };
auto str_len = change(str);
std::cout << str << '\n' << str_len << '\n';
return 0;
}
|
Rust vs. C++ -> 可变引用
在 Rust 中,同时创建并使用两个可变引用是错误的。
1
2
3
4
5
6
7
8
9
| fn main() {
let mut str = String::from("Hello");
let str2 = &mut str; // str2 虽然没写 mut,但也是可变引用,s3 同理。
let str3 = &mut str;
println!("{str2}, {str3}");
}
|
C++ 不用说了肯定可以。
引用借用出去后,原来的引用是不能修改的(可能是防止 Rust 所说的“数据竞争”),例如:
这个代码可以通过编译:
1
2
3
4
5
6
7
8
9
10
11
| fn main() {
let mut str = String::from("Hello");
let r = &mut str;
r.push_str("123");
str.push_str("456");
println!("{str}");
}
|
而这段代码不能通过编译:
1
2
3
4
5
6
7
8
9
10
11
| fn main() {
let mut str = String::from("Hello");
let r = &mut str;
str.push_str("456");
r.push_str("123");
println!("{str}");
}
|
Rust 中,在不同作用域可以拥有多个可变引用,但不能同时拥有(类似于 lock_guard ? 正常,不然就冲突了,而在同一个大括号作用域内是不会冲突的)
此外,在同时使用可变引用和不可变引用时也有这种规则:
以下代码无法通过编译,其实类似于读写锁,就像写锁只能有一个,读锁可以很多,而读写又互斥。
1
2
3
4
5
6
7
8
9
10
| fn main() {
let mut str = String::from("Hello");
let r1 = &str;
let r2 = &str;
let r3 = &mut str;
println!("{r1} {r2} {r3}");
}
|
注意,这里如果你使用了 r1
r2
那么就不能编译,如果你没有使用,那么可以编译。
引用的作用域是:引用声明的地方开始,到最后一次使用为止。
编译器判断这个的能力称之为:非词法作用域生命周期,NLL。
Rust vs. C++ -> 悬垂引用
在 Rust 中,会阻止你使用悬垂引用,编译器确保引用永远有效。
1
2
3
4
5
6
7
8
| fn main() {
let mut str = dangling();
}
fn dangling() -> &String {
let str = String::from("dangling ref");
&str
}
|
这里会报错,需要生命周期标记。
如果是 C++ 中,那必然是可以返回的,你得自己避免。
Slice
Slice 也是 Rust 中的一种引用,他引用一段连续的集合,既然 Slice 也是引用,那么自然也没有所有权。
Rust vs. C++ -> 字符串 Slice
使用一个左闭右开的 range,当然,也可以写 &s[0..=4]
来给一个闭区间。
此外,0可以省略,最后一个 idx
也可以省略,比如 &s[..]
。
字符串的 slice 类型叫做 &str
,字符串常量的类型也是 &str
1
2
3
4
5
| fn main() {
let s = String::from("Hello");
let hello = &s[0..5];
}
|
在 C++ 中 字符串 Slice 明显是 C++17 的 std::string_view,然后字符串字面量的类型明显都是 const char[]
1
2
3
4
5
| int main() {
std::string str{ "Hello" };
auto view = std::string_view{ str.begin(), str.begin() + 5 };
}
|
其他的结构可以用 C++20 的 std::span
以及 C++23 的 std::mdspan
来进行切片。
而不管是 Rust 还是 C++,都可以对 Slice 字符串直接传递 String
/std::string
。