Rust,逮着你了

发布: 2021-12-25   上次更新: 2022-07-04   分类: 编程语言   标签: rust

文章目录

https://img.alicdn.com/imgextra/i1/581166664/O1CN0150SqYG1z6A7CHolHN_!!581166664.gif
Lovely ferris

使用 Rust 已经两年多了,尽管与编译器做了无数次斗争,但还是会不时遇到些一时不能理解的问题,更不用说新手了。之前几个小时就能写出来的程序,用 Rust 可能要一天,因此非常有必要把使用 Rust 的一些疑问(Gotchas,一般直译“逮着你了”、“明白你的意思了”)记录下来,一方面加深对问题的理解,另一个方面是与社区内的用户交流,说不定会多逮几只🦀呢。

RAII

一个变量在 Rust 中不仅仅用来保存数据,还用来实现 RAII(资源获取即初始化),RAII 最早是在设计 C++ 的异常时,为了解决发生异常时,资源能够安全回收而提出的概念。RAII 要求:

资源的有效性与变量的生命周期严格绑定,在构造时(constructor)获得资源的所有权,在析构时(destructor)进行资源的释放。

一个资源正常只能被释放一次,否则有可能发生悬挂指针(dangling pointer)的类似问题,因此这要求一个资源只能有一个拥有者(owner),一个具备所有权的变量可以进行下面两个操作:

  1. 通过 borrow 机制衍生出它的引用,这里又可细分为可变引用与不可变引用
  2. 通过 move 直接来转移它的所有权,一般发生在赋值或函数调用时

Move 与 Copy

变量赋值时,默认是 move 语义,即会把之前资源的所有权转移到新变量中,被 move 的变量无法再使用,drop 函数在新变量失效时执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#[derive(Debug)]
struct MyString(String);
impl Drop for MyString {
    fn drop(&mut self) {
        println!("drop MyString {}", self.0);
    }
}

fn main() {
    let str1 = MyString("hello".to_string());
    let str2 = str1;
    // println!("str1 is {:?}", str1);
    //                          ^^^^^^^^^ value borrowed here after move
    println!("str2 is {:?}", str2);
}
// 依次输出

// str2 is MyString("hello")
// drop MyString hello

与 move 语义相对应的是 copy 语义,copy 语义就是直接拷贝值的二进制位(bitwise copy),原始变量的所有权不会转移,新产生的变量具有单独的所有权。

1
2
3
4
5
6
7
8
#[derive(Debug, Copy, Clone)]
struct Foo;

let x = Foo;
let y = x;

// `y` is a copy of `x`
println!("{:?}", x); // OK!

一种数据类型如果允许浅拷贝(shallow copy),那么就是 copy 类型。比如 i32、所有的共享引用类型 &T 。 一般来说,如果一个类型可以是 copy 类型,那尽量将其定义为 copy 类型。很明显,copy 类型会比 move 类型好用,这在后文会有应证。

值得说明的是,copy 类型与 move 类型在实现层面是类似的,都是基于 memcpy 之类的操作,copy 类型需要进行拷贝还好理解,毕竟创建了一个新元素,但对 move 来说,拷贝是不是有些重了?毕竟 move 在 Rust 中比较常见,对性能会不会有影响?(zero-overhead 是不是只是喊口号?)社区内有不少这方面的讨论:

结论就是 it depends(视情况而定)。在多数情况下,Rust 在进行 release 编译时,会优化掉那些不需要的拷贝,上面链接中提到的一些技巧包括:

  • function inline
  • big struct might not be constructed at all due to constant propagation and folding
  • returning something by-value might be converted into something more efficient automatically(such as LLVM passing in an out-pointer to the returning function to construct the value in the caller’s frame)

Copy 与 Clone

在 Rust 的实现中,copy 类型的数据都实现了 Copy trait,由于 copy 类型在复制是只是进行简单的 memcpy,因此 Copy trait 中没有任何方法。

1
2
3
4
5
6
pub trait Copy: Clone { }

pub trait Clone: Sized {
    fn clone(&self) -> Self;
    fn clone_from(&mut self, source: &Self) { ... }
}

从上面定义可以看到,Copy 继承了 Clone,Clone trait 里面主要有个 clone 方法,clone 方法里既可能是类似 memcpy 的简单操作,也可以包含任意赋值操作,因此 Clone 相比 Copy,适用范围更广,所以是它的父 trait。

两者的差别主要在使用时,Copy 是隐式调用的,而 Clone 则需要显式调用。

Borrow checker

对于 Rust 初学者来说,基本上时时都会遇到 borrow checker 这个错误。一种典型的错误示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct Container {
    items: Vec<i32>,
}
impl Container {
    fn loop_items(&mut self) {
        for i in self.items.iter_mut() {
            self.do_something(i)
        }
    }

    fn do_something(&self, i: &mut i32) {
        *i += 1;
    }
}

上面这段程序在编译时会报下面的错:

1
2
3
4
5
6
7
 |         for i in self.items.iter_mut() {
 |                  ---------------------
 |                  |
 |                  mutable borrow occurs here
 |                  mutable borrow later used here
 |             self.do_something(i)
 |             ^^^^^^^^^^^^^^^^^^^^ immutable borrow occurs here

尽管在 do_something 中,参数是不可变的 &self ,而且函数里面也没有访问 items 这个成员属性,但是目前的 Rust 版本还没那么智能的识别出来。解决这个问题的办法有如下两个:

  1. 手动 inline,直接把 do_something 里面的代码拷贝到 for 循环里
  2. 借助具备 interior mutability 的智能指针(比如 RefCell),绕过 borrow checker 在编译期的检查
1
2
3
4
5
6
7
8
9
struct Container {
    items: RefCell<Vec<i32>>,
}

fn loop_items(&self) {
    for i in self.items.borrow_mut().iter_mut() {
        self.do_something(i)
    }
}

Rust 核心开发者 Niko 在 View types for Rust 这篇文章里构思了一种解决方法,类似于数据库中的 view 表,可以定义多个 type alias 来明确指定需要访问的字段,这样就能避免上述问题的发生。但目前还没有 rfc 来跟进,社区内的一些其他讨论:

需要注意的是,Rust 目前的 borrow check 比较保守,有些理论上没有问题,但是实际上还是有可能报错,但这大多数情况下都有 workaround,读者不必担心,只需要静下心来根据编译器提示进行修改即可,或者也可以试试另一个更先进的 borrow checker: Polonius

闭包

闭包(closure)是 Rust 中提供基本类型,用于提供函数式编程的能力,但由于有生命周期的存在,闭包会发生一些诡异的行为,比如(来源):

1
2
3
4
5
6
7
8
9
fn fn_elision(x: &i32) -> &i32 { x }
let closure_elision = |x: &i32| -> &i32 { x };

// fn_elision 可以正常编译,closure_elision 则报下面的错误:
|     let closure = |x: &i32| -> &i32 { x }; // fails
|                       -        -      ^ returning this value requires that `'1` must outlive `'2`
|                       |        |
|                       |        let's call the lifetime of this reference `'2`
|                       let's call the lifetime of this reference `'1`

在 Rust 的生命周期消除规则中,有一条是

如果入参与返回值只有一个,那么他们的生命周期一致

这就是为什么函数 fn_elision 能编译通过的原因,但这条规则不适用于闭包。究其原因,是因为闭包的声明周期判断比函数要复杂些,不像函数,只需要考虑入参就可以了,闭包还要考虑被它绑定的变量生命周期,这不是个简单的工作(试想一下,闭包再调用了其他闭包。。。),因此编译器就没做,采用了最简单的规则,返回值生命周期大于入参。

'static 生命周期

'static 是 Rust 中预定义生命周期,一般有如下两种用法

1
2
3
4
5
// A reference with 'static lifetime:
let s: &'static str = "hello world";

// 'static as part of a trait bound:
fn generic<T>(x: T) where T: 'static {}

这两种用法有些细微的区别:

  • 作为引用的生命周期时,意味着该引用在程序的整个运行期间内都有效
  • 作为类型约束(trait bound 或泛型)时,含义是类型不能包含非 &'static 的引用。这包括两部分: &'static T 引用与 owned 类型。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    use std::fmt::Debug;
    
    fn print_it( input: impl Debug + 'static ) {
      println!( "'static value passed in is: {:?}", input );
    }
    
    fn main() {
      // i is owned and contains no references, thus it's 'static:
      let i = 5;
      print_it(i);
    }

另一点容易让人误解的是 'static 的引用只能在编译时创建,在运行时也是可以的:

1
2
3
4
5
6
7
use rand;

// generate random 'static str refs at run-time
fn rand_str_generator() -> &'static str {
    let rand_string = rand::random::<u64>().to_string();
    Box::leak(rand_string.into_boxed_str())
}

关于生命周期更多的误解,可以参考:

Pin

由于 move 机制的存在,导致在 Rust 很难去正确表达『自引用』的结构,比如链表、树等。主要问题:

move 只会进行值本身的拷贝,指针的指向则不变

如果被 move 的结构有指向其他字段的指针,那么这个指向被 move 后就是非法的,因为原始指向已经换地址了。

https://img.alicdn.com/imgextra/i1/581166664/O1CN01dpUo2k1z6A78xMyly_!!581166664.png
对象从左移动到右后的指针指向

Cloudflare 的 Pin, Unpin, and why Rust needs them 这篇文章详细解释了这个问题的解法,这里不再赘述。

Cow

1
2
3
4
5
6
7
pub enum Cow<'a, B: ?Sized + 'a>
where
    B: ToOwned,
{
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

Cow 可能是最容易被初学者忽略的 trait,官方文档中的 abs_all 示例也没能很好解释 copy-on-write 的实际价值。其实可以把 Cow 的语义看成『potentially owned』,即可能拥有所有权,可以用来避免一些不必须的拷贝:

1
2
3
4
5
6
7
fn foo(s: &str, some_condition: bool) -> &str {
    if some_condition {
        &s.replace("foo", "bar")
    } else {
        s
    }
}

上面的示例看起来没问题,但是会有编译错误:

1
2
3
4
5
   |         &s.replace("foo", "bar")
   |         ^-----------------------
   |         ||
   |         |temporary value created here
   |         returns a reference to data owned by the current function

如果把返回值改成 String,那么在 else 分支会有一次额外的拷贝,这时,Cow 就可以派上用场了:

1
2
3
4
5
6
7
fn foo(s: &str, some_condition: bool) -> Cow<str> {
    if some_condition {
        Cow::from(s.replace("foo", "bar"))
    } else {
        Cow::from(s)
    }
}

另一个类似的例子(playground):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct MyString<'a, F>(&'a str, F);

impl<'a, F> MyString<'a, F>
where
    F: Fn(&'a str),
{
    fn foo(&self, some_condition: bool) {
        if some_condition {
            (self.1)(self.0)
        } else {
            (self.1)(&self.0.replace("foo", "bar"))
        }
    }
}
fn main() {
    let ss = MyString("foo", |s| println!("Results: {}", s));

    ss.foo(true);
    ss.foo(false)
}

在上面这个例子有,结构体的第一个属性的生命周期是 'a ,第二个属性是个闭包,参数的生命周期也是 'a ,直接编译会报下面的错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
error[E0716]: temporary value dropped while borrowed
  --> src/main.rs:11:23
   |
3  | impl<'a, F> MyString<'a, F>
   |      -- lifetime `'a` defined here
...
11 |             (self.1)(&self.0.replace("foo", "bar"))
   |             ----------^^^^^^^^^^^^^^^^^^^^^^^^^^^^-
   |             |         |
   |             |         creates a temporary which is freed while still in use
   |             argument requires that borrow lasts for `'a`
12 |         }
   |         - temporary value is freed at the end of this statement

和第一个例子的报错类似,改用 Cow 同样可以在尽量不拷贝的前提下解决这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
impl<'a, F> MyString<'a, F>
where
    F: Fn(Cow<'a, str>),
{
    fn foo(&self, some_condition: bool) {
        if some_condition {
            (self.1)(Cow::from(self.0))
        } else {
            (self.1)(Cow::from(self.0.replace("foo", "bar")))
        }
    }
}

index 表达式

在 Rust 中,为了让代码书写简洁(ergonomics),会自动做一些事情,比如类型推导,运算符重载等,但笔者对其中的一些做法并不认同,这里就通过 index 表达式来阐述理由。

对于数组与 slice 类型来说,可以使用 [index] 来进行元素访问,其他类型可以通过实现 Index trait 来支持这种语法。

1
2
3
4
pub trait Index<Idx: ?Sized> {
    type Output: ?Sized;
    fn index(&self, index: Idx) -> &Self::Output;
}

index 方法返回一个引用类型,但在通过 container[idx] 这种语法访问时,返回的不是引用类型,Rust 会自动把上述形式转为 *container.index(idx) ,美其名曰,这样的话就可以直接通过 let value = v[idx] 这样的方式进行 copy 类型的赋值。

但笔者觉得这样的做法有些画蛇添足,index 参数明明需要的是个 &self 引用,但是 index 表达式返回的却不是引用,必须让用户用 &v[idx] 这样的方式返回引用,这样也许看起来语法统一,但会让一些细心的用户觉得别扭。这种编译器的隐式操作很容易导致编译器报出的错误与用户的代码无法一一对应。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#[derive(Debug)]
struct NonCopy;
fn main() {
    let v = vec![NonCopy, NonCopy];
    let v_ref = &v;
    let first = v_ref[0];  // v_ref 本身已经是引用了,通过引用取第一个元素默认不应该也是引用嘛??
  //                 |
  //                 move occurs because value has type `NonCopy`, which does not implement the `Copy` trait
  //                 help: consider borrowing here: `&v_ref[0]`

    println!("Results: {:?}", first)
}

从这一点也可以看出,Rust 中是比较推崇 copy 类型。社区内一些讨论:

Deref trait

解引用表达式

*expression 是 Rust 中的解引用表达式,当它作用于指针类型(主要包括:引用 &, &mut 、原生指针 *const, *mut )时,表示指向的内容,这点与 C/C++ 中一致,但当它作用于非指针类型时,它表示 *std::ops::Deref::deref(&x) 。比如 String 的 Deref 实现如下:

1
2
3
4
5
6
7
8
impl ops::Deref for String {
    type Target = str;

    #[inline]
    fn deref(&self) -> &str {
        unsafe { str::from_utf8_unchecked(&self.vec) }
    }
}

那么可以通过如下方式得到 &str 类型:

1
2
let addr_string = "192.168.0.1:3000".to_string();
let addr_ref: &str = &*addr_string; // *addr_string = str,再加上外面的 & 即为 &str

类型自动转化

类型自动转化是 Rust 为了追求语言简洁,提供的另一种隐式操作,比如下面的赋值都是正确的:

1
2
3
4
5
6
let s: String = "hello".to_string();
let s1: &String = &s;
let s2: &str = s1;
let s3: &str = &&s;
let s4: &str = &&&s;
let s5: &str = &&&&s;

可以看到,无论有多少个 & ,Rust 都能正确的将其转为 &str 类型,究其原因,是因为 deref coercions,它允许在 T: Deref<U> 时, &T 可以自动转为 &U

因为 s2 的赋值能成功就是利用了 deref coercion 的原理,那么 s3/s4/s5 呢?如果稍微有些 Rust 经验的话,当一个类型可以调用一个不知道哪里定义的方法时,大概率是 trait 的通用实现(blanket implementation) 起作用的,这里就是这种情况:

1
2
3
4
5
6
7
impl<T: ?Sized> const Deref for &T {
    type Target = T;

    fn deref(&self) -> &T {
        *self
    }
}

上面代码是 Deref 的一个通用实现,有了这个通用实现再来看看后面的赋值是怎么进行的:

  • 对于 s3,T 为 &&str ,U 为 &str ,所以 s3 能正确赋值
  • 对于 s4,T 为 &&&str U 为 &&str ,然后再根据 s3 转化,所以 s4 赋值也是正确的
  • 对于 s5,相当于进行 deref coercions 三次

此外,Deref 在方法调用时也会自动进行类型转化,比如:

1
2
3
fn takes_str(s: &str) { ... }
let s = String::from("Hello");
takes_str(&s); // &String 自动转为了 &str

这在熟悉相关 API 后是挺方便的,但对初学者来说会有些困惑,比如在看到一个变量调用了一个不属于该类型的的方法时,很有可能就是 deref 在起作用。

在 Rust 1.58 中改进了文档展示,可以展示所有 Deref 后的方法。比如一个类型 Foo,它有 Deref<Target = String> ,那么文档上会同时展示 String 与 str 的方法(截图来源)。

https://img.alicdn.com/imgextra/i1/581166664/O1CN011xRcal1z6A7E45CLo_!!581166664.png
1.58 文档展示效果

Unsafe

Unsafe 是 Rust 给自己留得一个后门,因为套用它的种种约束后,很有可能一些代码无法通过编译。但 unsafe 就像其名预示的那样,稍有不慎,就会踩到自己的脚。鉴于本文篇幅, unsafe 的使用注意事项可参考下面的社区实践:

总结

Rust 特有的所有权机制,给程序的编写带来了不少复杂度,而且 Rust 追求语言的简洁,会隐式做不少操作,这些东西加起来,很容易导致初学者在分析程序时,感到困惑。但这些问题在一些人来看是缺陷,但在另一些人来看却是机会。现在国内外大公司都在重度参与 Rust 的社区建设,比如 Google 新一代的 Fuchisa 操作系统就大量使用 Rust,而且 Google 也在推进 Rust 也成为 Linux 开发的第二语言,这是 C++ 都没做到的事情。所以,Let's Go Rust!

参考

评论

欢迎读者通过邮件与我交流,也可以在 MastodonTwitter 上关注我。