概念

rust 中的字符串相较于其他语言要复杂一点。
首先先明确一个概念,rust 语言层面上的字符串只有一种类型,即 &str 字符串切面。比较常见的 String 是标准库提供的封装(本质为向量)。
而常说的 rust 中的字符串,一般也不仅仅指 &str 或者 String,一般泛指这两者。
当然,标准库中所提供的字符串也不仅仅只有 String,还包括:

  1. std::ffi::OsStr;
  2. std::ffi::OsString;
  3. std::ffi::CStr;
  4. std::ffi::CString;

这些字符串可以看到后缀有 Str 和 String 两种,而这和字符串切面 &str 与 String 的区别有关。

类型不同

  1. fn main() {
  2. let name = "Bill";
  3. greet(name);
  4. }
  5. fn greet(name: String) {
  6. println!("hello {}!", name);
  7. }

这段代码看起来好像没有问题,但是运行 cargo check 后可以看到异常:

  1. error[E0308]: mismatched types
  2. --> src/main.rs:17:11
  3. |
  4. 17 | greet(name);
  5. | ^^^^- help: try using a conversion method: `.to_string()`
  6. | |
  7. | expected struct `String`, found `&str`
  8. For more information about this error, try `rustc --explain E0308`.
  9. error: could not compile `demo12` due to previous error

异常原因很明显,类型异常,greet 函数的参数接收 String 类型,而变量 name 为字符串切面 &str。这其实就是上面说的 String 为标准库提供而不是语言的类型,所以 main -> name 变量,会被默认推导为字符串切面。
既然如此,我们现在有两个修改方向:

  1. 把 name 改为 String 类型;
  2. 把 greet 的参数改为字符串切面; ```rust fn main() { let name = String::from(“Bill”); greet(name); }

fn greet(name: String) { println!(“hello {}!”, name); }

  1. ```rust
  2. fn main() {
  3. let name = "Bill";
  4. greet(name);
  5. }
  6. fn greet(name: &str) {
  7. println!("hello {}!", name);
  8. }

上面两段代码运行 cargo check 都不会报错,并且 run 都能通过

  1. hello Bill!

新的问题

好的,上面只是开始,如果我们在 String 版本的 main 中的 greet 函数调用后,加一句有关 name 的调用,就像这样:

  1. fn main() {
  2. let name = String::from("Bill");
  3. greet(name);
  4. println!("greet again: {}", name);
  5. }
  6. fn greet(name: String) {
  7. println!("hello {}!", name);
  8. }

运行 cargo check,很快就能看到异常:

  1. error[E0382]: borrow of moved value: `name`
  2. --> src/main.rs:18:33
  3. |
  4. 16 | let name = String::from("Bill");
  5. | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
  6. 17 | greet(name);
  7. | ---- value moved here
  8. 18 | println!("greet again: {}", name);
  9. | ^^^^ value borrowed here after move
  10. For more information about this error, try `rustc --explain E0382`.

但是换成字符串切面的版本则不同:

  1. fn main() {
  2. let name = "Bill";
  3. greet(name);
  4. println!("greet again: {}", name);
  5. }
  6. fn greet(name: &str) {
  7. println!("hello {}!", name);
  8. }

运行 cargo check 顺利通过 check,cargo run 也能顺利运行;
那么,为什么会这样呢?

所有权和借用

这是 rust 的一个很重要的特性,也和 rust 所提到的其安全的原因之一。首先程序的运行是需要内存管理的,而对于内存管理,一般有两种管理方式:

  • GC :也就是在运行时不断跟踪并且清理不再使用的内存,对其进行释放,比如 js,这种方式会存在一些问题:
    • GC 本身是消耗内存的;
    • 不同的 GC 算法效率也不一样,都有其优缺点;
  • 无 GC:也就是内存释放由开发者调用 api 进行手动操作,比如 c 和 c++,这种方式也会有一些问题:
    • 如果忘记释放就会造成内存溢出;
    • 如果提前释放就会操成变量非法;
    • 如果多次释放就会造成 bug,比如 double free;

而 rust 选择了另一种方式:无 GC 且不需要开发者手动释放内存;
听起来好像很奇怪,内存当然是需要管理的,不可能一直占用而不释放,无 GC 也不需要开发者手动释放,rust 是如何做到的呢?我们一步一步来了解。

内存中的堆栈

完全可以和 js 做对照学习,因为这些和语言本身没有很大相关性,同时也非常重要(这个堆栈和数据结构中的堆栈可以说是 java 和 Javascript 的关系 — 也就是说没啥关系)。

栈内存

  • 后进先出;
  • 内存连续且大小固定,也就是说申请后不能变化;
  • 存取快;

    堆内存

  • 存取自由;

  • 内存不固定且不一定连续;
  • 存取比栈慢;

基于以上的特性,从语言层面来说,一般情况下比较简单的数据类型会放到栈内存中进行存储,而复杂的数据类型会放到堆内存中。以 js 为例:

  • 基本数据类型都是在栈内存中:String,Boolean,Number,undefined,null,Symbol;
  • 复杂数据类型都放在堆内存中:Object(包括 Function,Array 等)(js 中的复杂数据类型也被称为引用类型);

    区别

    这两种存储类型的区别在 js 中有一个比较经典的例子:
    1. let a = 1;
    2. let b = a;
    3. a = 2;
    4. console.log(b); // log 1
    即使在把 a 拷贝给 b 后,修改 a,b 的值也不会变,因为栈中的数据只会做复制。而如果是堆中的数据:
    1. let a = { x: 1 };
    2. let b = a;
    3. a.x = 2;
    4. console.log(b.x); // log 2
    这时候因为 a 是 Object,存在堆中,b = a 会把 a 在栈中存的指向堆内存的指针复制给了 b,此时 b 和 a 指向同一块堆内存,修改的也是同一份数据,同时这种现象在 js 中被称为浅拷贝(当然浅拷贝这个概念也是 js 无关的,也不是 js 首先提出的,其他语言中也有这么称呼),对应的深拷贝就是指不拷贝指针,而是根据被拷贝的指针所指向的内存的大小、容量、数据重新申请一份同等大小、容量、数据的内存。

    回到 rust

    回到 rust,那么 rust 中也存在这种现象吗?当然也存在。字符串切面和 i32 都是存储在栈中的数据:
    1. fn main() {
    2. let a = 1;
    3. let b = a;
    4. println!("a {}, b {}", a, b); // log a 1, b 2
    5. }
    他的赋值行为和 js 是相同的。但是堆内存的数据则和 js 完全不同,以 String 为例(没错,如果从js 的视角来说 rust 的 String 就属于复杂类型数据,因为 rust 语言层面是没有 String 类型的,这个 String 上面说了是由标准库提供,用向量实现的):
    1. fn main() {
    2. let a = String::from("world");
    3. let b = a;
    4. println!("a {}, b {}", a, b);
    5. }
    这种在 js 中看起来没有问题的代码在 rust 中是存在语法错误的: ``rust error[E0382]: borrow of moved value:a--> src/main.rs:41:28 | 39 | let a = String::from("world"); | - move occurs becauseahas typeString, which does not implement theCopy` trait 40 | let b = a; | - value moved here 41 | println!(“a {}, b {}”, a, b); | ^ value borrowed here after move

For more information about this error, try rustc --explain E0382.

  1. 因为 rust 这涉及到 rust 的**所有权原则**:
  2. 1. Rsut 中每个值都有且只有一个所有者持有(所有者可以理解为变量);
  3. 1. 当所有者离开作用域后这个值将会被释放;
  4. 上面的错误则触犯了所有权原则的第一条。<br />也就是说,当程序运行到 let b = a 这一行后,a 指向的堆内存的指针,会 **move** b(因为 String 类型默认没有实现 Copy 接口),所以 a 就已经失效了,无法再对其进行访问。这也是 rust 所说的 **安全** 的一部分;<br />这里提到的 move,在 rust 中被称为 **所有权转移**。而上面 [新的问题](#FVWtO) 中所遇到的 String 类型报错,也正是因为这个原因。<br />同时因为这种**语言层面的规则**,所以可以做到运行时无 GC,在编译时把内存释放的代码都编译进去了。这种方式其实不难理解,可以类比为 webpack 的一些编译时的 babel 比如 vue loader,在编译时把 vue 语法改为标签,react jsx loader 可以理解为 GC -- 运行时操作。
  5. <a name="trWXI"></a>
  6. # 引用和借用
  7. 如果真的完全依据这种所有权规则去编码,代码会变得非常复杂(丑),同时可能一块完整且独立的逻辑中,同一种意思的变量会出现没有必要的重复声明:
  8. ```rust
  9. fn main() {
  10. let a = String::from("world");
  11. let b = a;
  12. let a = String::from("world");
  13. println!("a {}, b {}", a, b); // 再次声明一遍 a
  14. }

那 rust 有没有办法做到不转移所有权而是借用一下他的所有权呢?
答案是有的。rust 支持通过 & 符号来完成 borrowing 并且获取变量的引用(一般为指针类型)。这个 borrowing 的意思其实就和 js 的浅拷贝的意思很像,把当前变量的引用做一次借出。

  1. fn main() {
  2. let a = String::from("hello");
  3. let b = &a;
  4. println!("a {}, b {}", a, b); // log a hello, b hello
  5. }

对照一下 上面的问题代码,此时只需要在 b 声明的同时,给复赋值语句加上引用符号,则可以表示 b 是借了 a 的引用而不是让 a 做所有权的转移。

解引用

上面的例子中 let b = &a,a 为 String 类型,b 可能大家一开始会认为也是 String 类型,其实不是的,b 是 &String — 也就是引用类型,让我们看下断言:

  1. fn main() {
  2. let a = String::from("hello");
  3. let b = &a;
  4. assert_eq!(a, b);
  5. }

运行 cargo check 则会报错:

  1. error[E0277]: can't compare `String` with `&String`
  2. --> src/main.rs:41:5
  3. |
  4. 41 | assert_eq!(a, b);
  5. | ^^^^^^^^^^^^^^^^ no implementation for `String == &String`
  6. |
  7. = help: the trait `PartialEq<&String>` is not implemented for `String`
  8. = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
  9. For more information about this error, try `rustc --explain E0277`.

a 和 b 两者并不是一个类型(这也是为什么说和 js 的浅拷贝有些像但并不是浅拷贝)。而解引用符号 * 能帮我们正确获取其引用的指针;

  1. fn main() {
  2. let a = String::from("hello");
  3. let b = &a;
  4. assert_eq!(a, *b);
  5. }

这时候就可以顺利通过编译和运行;

可变引用

仅仅如此依然不能满足实际开发的需求,因为很多情况下,在引用借出后我们会对引用的内容做变更,就像上面那个 js 经典问题。在 rust 中如何做到在获取引用的同时对其原始值做变更呢?很简单,为原始变量和引用变量加上 mut,此时引用变量就成为了一个可变引用:

  1. fn main() {
  2. let mut a = String::from("hello");
  3. let b = &mut a;
  4. b.push_str(" world");
  5. println!("a {}", a); // 此时 log hello world
  6. }

可能这样看很简单,那我们用函数来举例会更有意思一点:

  1. fn main() {
  2. let mut a = String::from("hello");
  3. concat_str(&mut a, "hello world");
  4. println!("a {}", a);
  5. }
  6. fn concat_str(origin: &mut String, str: &str) {
  7. origin.push_str(str);
  8. }

concat_str 函数用来给 String 做一次 concat 操作,这是一个很常见的操作,但是在 rust 里面需要理解引用和借用,因为 concat_str 第一个参数 origin,需要是一个 可变引用。

同一个变量的可变引用在同一作用域内只能同时存在一个

这句话很不好理解,看一下代码:

  1. fn main() {
  2. let mut a = String::from("hello");
  3. // 同一个变量的可变引用这里同时出现了两次
  4. concat_str(&mut a, &mut a, "hello world");
  5. println!("a {}", a);
  6. }
  7. fn concat_str(origin: &mut String, _origin_2: &mut String, str: &str) {
  8. origin.push_str(str);
  9. }

上面这种情况很明显的出现了两次。

  1. fn main() {
  2. let mut a = String::from("hello");
  3. // 同一个变量的可变引用这里同时出现了两次
  4. let a1 = &mut a;
  5. let a2 = &mut a;
  6. println!("a1 {}, a2 {}", a1, a2);
  7. }

这种情况看似好像并非同时出现,但是实际上还是在同一个作用域内,也就是 println a1 和 声明 a1 这一段 a1 的作用域内,出现了第二次可变引用的声明也就是 a2,如果分开的话就可以通过编译:

  1. fn main() {
  2. let mut a = String::from("hello");
  3. let _a1 = &mut a;
  4. println!("a1 {}", _a1);
  5. let _a2 = &mut a;
  6. println!("a2 {}", _a2);
  7. }

a 的可变引用是分两次出现,这样是没有问题的。
这样做的好处就是避免出现数据竞争,相当于是避免出现需要“加锁”。

作用域的区别

这里说一下 rust 的 1.31 后的作用域和 js 的作用域是不一样的:

  1. fn main() {
  2. let mut s = String::from("hello");
  3. let r1 = &s;
  4. let r2 = &s;
  5. println!("{} and {}", r1, r2);
  6. // 新编译器中,r1, r2作用域在这里结束
  7. let r3 = &mut s;
  8. println!("{}", r3);
  9. } // 老编译器中,r1、r2、r3作用域在这里结束
  10. // 新编译器中,r3 作用域在这里结束

可以看到新编译器中的作用域判定更加精准了,定位到行,而老版编译器中的作用域是定位到花括号(和 js 现阶段的作用域判定是一致的)。

回到开端

知道了这些概念,再回到开端中提到的 新的问题,在这里再贴一下问题代码吧:

  1. fn main() {
  2. let name = String::from("Bill");
  3. greet(name);
  4. println!("greet again: {}", name);
  5. }
  6. fn greet(name: String) {
  7. println!("hello {}!", name);
  8. }

此时问题就很明确了,这是由于函数 greet 的传参直接把 name 给传过去了,name 的指针也被 move 了,然后在 greet 函数调用结束后,这个指针的在当前作用域内没有调用,就被 drop 了,所以 greet 调用后 println name,当然会报错。
知道原因解法就非常简单了,greet 函数的参数不需要 move 只需要 borrow,同时在 greet 中并没有对其进行变更,所以也不需要可变引用,那么通过简单的更改:

  1. fn main() {
  2. let name = String::from("Bill");
  3. greet(&name);
  4. println!("greet again: {}", name);
  5. }
  6. fn greet(name: &String) {
  7. println!("hello {}!", name);
  8. }

就可以通过编译正常运行。