1
头图
版本日期备注
1.02024.3.18文章首发
1.12024.8.15修复格式问题

笔者的主力语言是Java,近三年Kotlin、Groovy、Go、TypeScript写得比较多。早年间还写过一些Python和JavaScript。总得来说落地在生产中的语言都是应用级语言,对于系统编程级语言接触不多。但这不妨碍我写下这么一篇笔记,说不定也有一些常年在应用层的同学想领略一下Rust的风采呢。

1.核心解决的问题

Rust和C、C++一个级别。更多是在解决C语言自由带来的问题:

  • 多线程并发问题。
  • 基本类型大小晦涩的问题:C语言标准中对许多类型的大小并没有做强制规定,比如int、long、double等类型,在不同平台上都可能是不同的大小,这给许多程序员带来了不必要的麻烦。相反,Rust在语言标准中规定好各个类型的大小,让编译器针对不同平台做适配,生成不同的代码,是更合理的选择。
  • 尽量避免内存安全问题。

    • 空指针:C++在访问内存地址时不会先做校验,如果尝试访问一个内存地址,但是这块内存已经被释放了。就会出错。
    • 野指针:指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)指针变量在定义时如果未初始化,其值是随机的,访问就会出错。
    • 悬空指针:内存空间在被释放了之后,继续使用。它跟野指针类似,同样会读写已经不属于这个指针的内容。
    • 使用未初始化内存:不只是指针类型,任何一种类型不初始化就直接使用都是危险的,造成的后果我们完全无法预测。
    • 非法释放:内存分配和释放要配对。如果对同一个指针释放两次,会制造出内存错误。如果指针并不是内存分配器返回的值,对其执行释放操作,也是危险的。
    • 缓冲区溢出:指针访问越界了,结果也是类似于野指针,会读取或者修改临近内存空间的值,造成危险。
    • 执行非法函数指针:如果一个函数指针不是准确地指向一个函数地址,那么调用这个函数指针会导致一段随机数据被当成指令来执行,是非常危险的。
  • 对象读写问题。

    • C++里面允许全局变量,指针爱咋传咋传,如果对全局变量不小心修改了,整个代码逻辑都会出问题。
    • 对同一个对象的不安全读写操作:比如边遍历一个vector边对这个vector做着一些插入删除操作。
C语言的思想是:尽量不对程序员做限制,尽量接近机器底层,类型安全、可变性、共享性都由程序员自由掌控,语言本身不提供太多的限制和规定。安全与否,也完全取决于程序员。所以要写好C代码肯定不会比写好Java简单的。

2.那么代价是什么?

  • 默认的所有权机制。使很多语言过来的程序员无法适应。
  • 基于所有权而引入的一系列机制:

<!---->

    • 借用
    • Copy
    • 内部可变性
    • 生命周期标记
    • 特殊的错误处理机制

2.1 每个值同时只有一个Owner(所有权机制)

  • 每个值在Rust中都有一个变量来管理它,这个变量就是这个值、这块内存的所有者;
  • 每个值在一个时间点上只有一个管理者;
  • 当变量所在的作用域结束的时候,变量以及它代表的值将会被销毁。
    fn main() {
        let s = String::from("hello");
        let s1 = s;
        println! ("{}", s);
    }
// 这样的代码编译会直接报错
//     error[E0382]: use of moved value: `s`
//      --> test.rs:5:20
//       |
//     4 |      let s1 = s;
//       |          -- value moved here
//     5 |      println! ("{}", s);
//       |                    ^ value used here after move
//       |
//       = note: move occurs because `s` has type `std::string::String`, which does not
// implement the `Copy` trait

这种直接赋值的方式在大多数语言中非常常见,但是在Rust中不行。因为它需要保证全程只有一个变量引用这块内存。

所有权还有一个Move的操作:一个变量可以把它拥有的值转移给另外一个变量,称为“所有权转移”。赋值语句、函数调用、函数返回等,都有可能导致所有权转移。

    fn create() -> String {
        let s = String::from("hello");
        return s; // 所有权转移,从函数内部移动到外部
    }
    fn consume(s: String) { // 所有权转移,从函数外部移动到内部
        println! ("{}", s);
    }
    fn main() {
        let s = create();
        consume(s);
    }

2.2 借用

从前面的例子看起来,Rust中一个变量永远只能有唯一一个入口可以访问,那可就太难使用了。因此,所有权还可以借用。

借用指针的语法有两种:

  • &:只读借用
  • &mut:可读写借用

借用指针(borrow pointer)也可以称作“引用”(reference)。借用指针与普通指针的内部数据是一模一样的,唯一的区别是语义层面上的。它的作用是告诉编译器,它对指向的这块内存区域没有所有权。

    fn foo(v: &mut Vec<i32>) {
        v.push(5);
    }
    fn main() {
        // 我们需要这个动态数组本身是“可变的”,才能获得它的“可变借用指针”
        let mut v = vec! [];
        // 在函数调用的时候,同时也要显示获取它的“可变借用指针”
        foo(&mut v);
        // 打印结果,可以看到v已经被改变
        println! ("{:? }", v);
    }

对于&mut型指针,可能大家会混淆它与变量绑定之间的语法。如果mut修饰的是变量名,那么它代表这个变量可以被重新绑定;如果mut修饰的是“借用指针&”,那么它代表的是被指向的对象可以被修改。

关于借用指针,有以下几个规则:

  • 借用指针不能比它指向的变量存在的时间更长。
  • &mut型借用只能指向本身具有mut修饰的变量,对于只读变量,不可以有&mut型借用。
  • &mut型借用指针存在的时候,被借用的变量本身会处于“冻结”状态。
  • 如果只有&型借用指针,那么能同时存在多个;如果存在&mut型借用指针,那么只能存在一个;如果同时有其他的&或者&mut型借用指针存在,那么会出现编译错误。

借用指针只能临时地拥有对这个变量读或写的权限,没有义务管理这个变量的生命周期。因此,借用指针的生命周期绝对不能大于它所引用的原来变量的生命周期,否则就是悬空指针,会导致内存不安全。

2.3 生命周期标记

前面我们提到了生命周期的概念,现在让我们展开来讲讲。

Rust的每个引用都有自己的生命周期(lifetime),它对应着引用保持有效性的作用域。在大多数时候,生命周期都是隐式且可以被推导出来的,就如同大部分时候类型也是可以被推导的一样。当出现了多个可能的类型时,我们就必须手动声明类型。类似地,当引用的生命周期可能以不同的方式相互关联时,我们就必须手动标注生命周期。Rust需要我们注明泛型生命周期参数之间的关系,来确保运行时实际使用的引用一定是有效的。

所以,生命周期最主要的目标在于避免悬垂引用,进而避免程序引用到非预期的数据。

而具体实现主要是在Rust的编译器中,名为借用检查器(borrow checker),它被用于比较不同的作用域并确定所有借用的合法性。

我们用两段简单的代码来解释这个机制。

在这里,我们将r的生命周期标注为了'a,并将x的生命周期标注为了'b。如你所见,内部的'b代码块要小于外部的'a生命周期代码块。在编译过程中,Rust会比较两段生命周期的大小,并发现r拥有生命周期'a,但却指向了拥有生命周期'b的内存。这段程序会由于'b比'a短而被拒绝通过编译:被引用对象的存在范围短于引用者。

下面这段代码修复了这个问题。

这里的x拥有长于'a的生命周期'b。这也意味着r可以引用x了,因为Rust知道r中的引用在x有效时会始终有效。

接下来我们看一段需要手动标记生命周期的场景。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

// error[E0106]: missing lifetime specifier
//  --> src/main.rs:1:33
//   |
// 1 | fn longest(x: &str, y: &str) -> &str {
//   |                                 ^ expected lifetime parameter
//   |
//   = help: this function's return type contains a borrowed value, but the
// signature does not say whether it is borrowed from `x` or `y`

我们需要给返回类型标注一个泛型生命周期参数,因为Rust并不能确定返回的引用会指向x还是指向y。实际上,即便是编写代码的我们也无法做出这个判断。因为函数体中的if代码块返回了x的引用,而else代码块则返回了y的引用。

在这种情况下,我们需要显示的标记生命周期标记。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这段代码的函数签名向Rust表明,函数所获取的两个字符串切片参数的存活时间,必须不短于给定的生命周期'a。这个函数签名同时也意味着,从这个函数返回的字符串切片也可以获得不短于'a的生命周期。而这些正是我们需要Rust所保障的约束。记住,当我们在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期。我们只是向借用检查器指出了一些可以用于检查非法调用的约束。注意,longest函数本身并不需要知道x与y的具体存活时长,只要某些作用域可以被用来替换'a并满足约束就可以了。

当我们在函数中标注生命周期时,这些标注会出现在函数签名而不是函数体中。Rust可以独立地完成对函数内代码的分析。但是,当函数开始引用或被函数外部的代码所引用时,想要单靠Rust自身来确定参数或返回值的生命周期,就几乎是不可能的了。函数所使用的生命周期可能在每次调用中都会发生变化。这也正是我们需要手动对生命周期进行标注的原因。

当我们将具体的引用传入longest时,被用于替代'a的具体生命周期就是作用域x与作用域y重叠的那一部分。换句话说,泛型生命周期'a会被具体化为x与y两者中生命周期较短的那一个。因为我们将返回的引用也标记为了生命周期参数'a,所以返回的引用在具化后的生命周期范围内都是有效的。

生命周期的标注并不会改变任何引用的生命周期长度。如同使用了泛型参数的函数可以接收任何类型一样,使用了泛型生命周期的函数也可以接收带有任何生命周期的引用。在不影响生命周期的前提下,标注本身会被用于描述多个引用生命周期之间的关系。

生命周期的标注使用了一种明显不同的语法:它们的参数名称必须以撇号(')开头,且通常使用全小写字符。与泛型一样,它们的名称通常也会非常简短。'a被大部分开发者选择作为默认使用的名称。我们会将生命周期参数的标注填写在&引用运算符之后,并通过一个空格符来将标注与引用类型区分开来。

  • 拥有显示生命周期的引用例子:&'a i32
  • 拥有显示生命周期的可变引用:&'a mut i32

单个生命周期的标注本身并没有太多意义,标注之所以存在是为了向Rust描述多个泛型生命周期参数之间的关系。例如,假设我们编写了一个函数,这个函数的参数first是一个指向i32的引用,并且拥有生命周期'a。它的另一个参数second同样也是指向i32且拥有生命周期'a的引用。这样的标注就意味着:first和second的引用必须与这里的泛型生命周期存活一样长的时间。

2.4 特殊的错误处理机制

Rust的错误处理机制和Go特别像。可恢复的错误应该尽量使用Result类型。

#[derive(Copy, PartialEq, PartialOrd, Eq, Ord, Debug, Hash)]
#[must_use = "this `Result` may be an `Err` variant, which should be handled"]
#[rustc_diagnostic_item = "Result"]
#[stable(feature = "rust1", since = "1.0.0")]
pub enum Result<T, E> {
    /// Contains the success value
    #[lang = "Ok"]
    #[stable(feature = "rust1", since = "1.0.0")]
    Ok(#[stable(feature = "rust1", since = "1.0.0")] T),

    /// Contains the error value
    #[lang = "Err"]
    #[stable(feature = "rust1", since = "1.0.0")]
    Err(#[stable(feature = "rust1", since = "1.0.0")] E),
}

对于Result而言,它拥有Ok和Err两个变体。其中的Ok变体表明当前的操作执行成功,并附带代码产生的结果值。相应地,Err变体则表明当前的操作执行失败,并附带引发失败的具体原因。

这可能会让人联想到go里的if err != nil之类的啰嗦代码。好消息是Rust有很多语法糖,可以让代码写得很优雅。

不可恢复的错误则是使用Panic。其作为一种“fail fast”机制,处理那种万不得已的情况(比如FFI场景下和C交互,避免underfined behavior;线程池里一个线程panic,及时关闭,不要把整个线程拉下水)。实现机制有两种方式:unwind和abort。

  • unwind方式在发生panic的时候,会一层一层地退出函数调用栈,在此过程中,当前栈内的局部变量还可以正常析构。
  • abort方式在发生panic的时候,会直接退出整个程序。

在常见的操作系统上,默认情况下,编译器使用的是unwind方式。所以在发生panic的时候,我们可以通过一层层地调用栈找到发生panic的第一现场,就像前面例子展示的那样。

但是,unwind并不是在所有平台上都能获得良好支持的。在某些嵌入式系统上,unwind根本无法实现,或者占用的资源太多。在这种时候,我们可以选择使用abort方式实现panic。

3.Compile与Runtime

  • Rust支持静态、动态链接。
  • Runtime时程序结构封闭。但由于标准库的元编程功能强大,即便是对比Java这种Runtime灵活的语言也不会落多少下风。

4.命名规范

C语言风格,类似Go,越简单越好。我认为语言上偏简单的设计,则对工程师的能力要求更强。

5.标准库

Rust官方模块管理、工具链(Cargo)的能力都是不错的。新语言没有包袱,很舒服。类似Go。

6.Composite litera(复合字面值)

类似Go中的使用field:value的复合字面值形式对struct类型变量进行值构造:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}

这种值构造方式可以降低结构体类型使用者与结构体类型设计者之间的耦合。这个是真的很香,Groovy和Kotlin也有类似的支持,很方便使用。

7.对于编程范式的支持

Rust中还是以面向对象为主,以Trait(有点像Java的抽象类,可以包含函数、常量、类型)做组合。

7.1 面向对象编程

结构体的声明以及如何new一个对象已经在第6节演示过了。演示下一个结构体如何实现trait。

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

一个结构体可以实现多个trait的方法,trait也可以有自己的默认方法。

7.2 函数式编程

7.2.1 Itertor(迭代器)

    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {}", val);
    }

这块倒没有什么神秘的地方,只要实现了Iterator这个trait就可以获取迭代器。

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // methods with default implementations elided
}

不仅如此,我们也可以写出纯函数式风格:

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

如果在Java8里,这里会很老实一个个去计算。但在Rust里,编译器会做unrolls——因为它知道每次都有12值,可以成批的去计算。所以用迭代器相关的接口也不用担心性能的问题,这就是Rust的好处——零代价抽象。

这里其实我们把Functor(函子)的demo也写出来了。

7.2.2 Closure(闭包)

以一个函数为例,转换为等价逻辑的闭包:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

let result_v1 = add_one_v1(1);
let result_v2 = add_one_v2(1);
let result_v3 = add_one_v3(1);
let result_v4 = add_one_v4(1);

看起来就是个语法糖,但其实没有这么简单。我们随便来抛出几个问题——当编译器把闭包语法糖转换为普通的类型和函数调用的时候:

  1. 结构体内部的成员应该用什么类型,如何初始化?应该用u32或是&u32还是&mut u32?
  2. 函数调用的时候self应该用什么类型?应该写self或是&self还是&mut self?

7.2.3 Currying(柯里化)

在计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下的参数和返回结果的新函数的技术。这个技术以逻辑学家Haskell Curry命名。

在Rust中实现Currying需要了解其内部的一些实现机制(见https://stackoverflow.com/questions/64005006/how-to-implement-a-multi-level-currying-function-in-rust):

fn add(x: i32) -> impl Fn(i32) -> Box<dyn Fn(i32) -> i32> {
    move |y: i32| {
        Box::new(move |z: i32| {
            x + y + z
        })
    }
}

fn main() {
    let add5 = add(5);
    let add5_10 = add5(10);
    println!("Call closure: {}", add5_10(6)); // prints "Call closure: 21"
}

第一个问题是,Fn是什么?看起来是个关键字。是的,它其实是一个Trait,用于实现编译后的匿名函数。诸如此类的还有FnOnce、FnMut。

    pub trait FnOnce<Args> {
        type Output;
        extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
    }

    pub trait FnMut<Args> : FnOnce<Args> {
        extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
    }

    pub trait Fn<Args> : FnMut<Args> {
        extern "rust-call" fn call(&self, args: Args) -> Self::Output;
    }

这几个trait的主要区别在于,被调用的时候self参数的类型。FnOnce被调用的时候,self是通过move的方式传递的,因此它被调用之后,这个闭包的生命周期就已经结束了,它只能被调用一次;FnMut被调用的时候,self是&mut Self类型,有能力修改当前闭包本身的成员,甚至可能通过成员中的引用,修改外部的环境变量;Fn被调用的时候,self是&Self类型,只有读取环境变量的能力。

第二个问题是,Box是什么?dyn又是什么?在Rust的编译器规则中,它需要知道每个函数返回类型需要多少空间,这就意味着类型需要被确定。那么该如何解决呢?就需用到这两个关键字了:

struct Sheep {}
struct Cow {}

trait Animal {
    // Instance method signature
    fn noise(&self) -> &'static str;
}

// Implement the `Animal` trait for `Sheep`.
impl Animal for Sheep {
    fn noise(&self) -> &'static str {
        "baaaaah!"
    }
}

// Implement the `Animal` trait for `Cow`.
impl Animal for Cow {
    fn noise(&self) -> &'static str {
        "moooooo!"
    }
}

// Returns some struct that implements Animal, but we don't know which one at compile time.
fn random_animal(random_number: f64) -> Box<dyn Animal> {
    if random_number < 0.5 {
        Box::new(Sheep {})
    } else {
        Box::new(Cow {})
    }
}

fn main() {
    let random_number = 0.234;
    let animal = random_animal(random_number);
    println!("You've randomly chosen an animal, and it says {}", animal.noise());
}

8.异常流:Result与Panic

可恢复错误一般使用Rusult类型,不可恢复错误一般使用Panic,具体在2.4部分提到过。不再赘述。

9.并发

Rust通过一系列基础设施和编译器检测保证了线程安全。核心是两个特殊的trait。

  • std::marker::Sync:如果类型T实现了Sync类型,那说明在不同的线程中使用&T访问同一个变量是安全的。
  • std::marker::Send:如果类型T实现了Send类型,那说明这个类型的变量在不同的线程中传递所有权是安全的。

这个抽象是比较有意思的。Rust语言本身并不知晓“线程”“并发”具体是什么,而是抽象出了一些更高级的概念Send/Sync,用来描述类型在并发环境下的特性。

比如我们最常见的创建线程的函数spawn,它的完整函数签名是这样的:

    pub fn spawn<F, T>(f: F) -> JoinHandle<T>
        where F: FnOnce() -> T, F: Send + 'static, T: Send + 'static

我们需要注意的是,参数类型F有重要的约束条件F: Send + 'static, T: Send+'static。但凡在线程之间传递所有权会发生安全问题的类型,都无法在这个参数中出现,否则就是编译错误。

我们可以看到,上述函数就是一个普通函数,编译器没有对它做任何特殊处理。它能保证线程安全的关键是,它对参数有合理的约束条件。

其他基础设施:

  • Arc:Arc是Rc的线程安全版本。它的全称是“Atomic reference counter”。注意第一个单词代表的是atomic而不是automatic。它强调的是“原子性”。它跟Rc最大的区别在于,引用计数用的是原子整数类型。
  • Mutex:系统编程经典工具,锁。
  • RwLock:RwLock就是“读写锁”。它跟Mutex很像,主要区别是对外暴露的API不一样。对Mutex内部的数据读写,RwLock都是调用同样的lock方法;而对RwLock内部的数据读写,它分别提供了一个成员方法read / write来做这个事情。其他方面基本和Mutex一致。
  • Atomic:原子类型数据。
  • Barrier:使用一个整数做初始化,可以使得多个线程在某个点上一起等待,然后再继续执行。
  • Condvar:ondvar的一个常见使用模式是和一个Mutex<bool>类型结合使用。我们可以用Mutex中的bool变量存储一个旧的状态,在条件发生改变的时候修改它的状态。通过这个状态值,我们可以决定是否需要执行等待事件的操作。
  • ThreadLocal:线程局部变量。

注意:死锁问题无法通过编译检测发现。

实际上我们可以看到,Rust保证内存安全的思路和线程安全的思路是一致的。在多线程中,我们要保证没有数据竞争,一般是通过下面的方式:

  1. 多个线程可以同时读共享变量;
  2. 只要存在一个线程在写共享变量,则不允许其他线程读/写共享变量。

Rust如果没有“默认内存安全”打下的良好基础,其实就没办法做到“线程安全”;正因为在“内存安全”问题上的一系列基础性设计,才导致了“线程安全”基本就是水到渠成的结果。我们甚至可以观察到一些“线程安全类型”和“非线程安全类型”之间有趣的对应关系,比如:

  1. Rc是非线程安全的,Arc则是与它对应的线程安全版本。当然还有弱指针Weak也是一一对应的。Rc无须考虑多线程场景下的问题,因此它内部只需普通整数做引用计数即可。Arc要用在多线程场景,因此它内部必须使用“原子整数”来做引用计数。
  2. RefCell是非线程安全的,它不能在跨线程场景使用。Mutex/RwLock则是与它相对应的线程安全版本。它们都提供了“内部可变性”, RefCell无须考虑多线程问题,所以它内部只需一个普通整数做借用计数即可。Mutex/RwLock可以用在多线程环境,所以它们内部需要使用操作系统提供的原语来完成“锁”功能。它们有相似之处,也有不同之处。Mutex/RwLock在加锁的时候返回的是Result类型,是因为它们需要考虑“异常安全”问题——在多线程环境下,很可能出现一个线程发生了panic,导致Mutex内部的数据已经被破坏,而在另外一个线程中依然有可能观察到这个被破坏的数据结构。RefCell则相对简单,只需考虑AssertUnwindSafe即可。
  3. Cell是非线程安全的,Atomic*系列类型则是与它对应的线程安全版本。它们之间的相似之处在于,都提供了“内部可变性”,而且都不提供指向内部数据的方法。它们对内部数据的读写,都是整体读出、整体写入,不可能制造出直接指向内部数据的指针。它们的不同之处在于,Cell的条件更宽松。而标准库提供的Atomic*系列类型则受限于CPU提供的原子指令,内部存储的数据类型是有限的,无法推广到所有类型。其实我们完全可以仿造Cell类型,设计一个可以应用于所有类型的通用型Atomic<T>类型——内部用Mutex实现,提供get / set方法作为对外API。这个尝试已经在第三方开源库中实现,如需了解,上GitHub搜索“atomic-rs”即可。

Rust的这套线程安全设计有以下好处:

  1. 免疫一切数据竞争;
  2. 无额外性能损耗;
  3. 无须与编译器紧耦合。

我们可以观察到一个有趣的现象:Rust语言实际上并不知晓“线程”这个概念,相关类型都是写在标准库中的,与其他类型并无二致。Rust语言提供的仅仅只是Sync、Send这样的一般性概念,以及生命周期分析、“borrow check”分析这样的机制。Rust编译器本身并未与“线程安全”“数据竞争”等概念深度绑定,也不需要一个runtime来辅助完成功能。然而,通过这些基本概念和机制,它却实现了完全通过编译阶段静态检查实现“免除数据竞争”这样的目标。这样的设计正是Rust的魅力所在。

正因为解耦合如此彻底,Rust语言才会如此精简,它只提供了非常基本的并行开发相关的基本抽象。而且标准库中实现的这些基本功能,其实都可以完全由第三方来实现。理论上来讲,其他语言中出现了的更高级的并行程序开发的抽象机制,一般都可以通过第三方库的方式来提供,没必要与Rust编译器深度绑定。

10.元编程

Rust的元编程基于宏(macro)实现。实现在编译器端来做扩展。它的调用方式为some_macro! (...)。宏调用与普通函数调用的区别可以一眼区分开来,凡是宏调用后面都跟着一个感叹号。宏也可以通过some_macro! [...]和some_macro! {...}两种语法调用,只要括号能正确匹配即可。

与C/C++中的宏不一样的是,Rust中的宏是一种比较安全的“卫生宏”(hygiene)。首先,Rust的宏在调用的时候跟函数有明显的语法区别;其次,宏的内部实现和外部调用者处于不同名字空间,它的访问范围严格受限,是通过参数传递进去的,我们不能随意在宏内访问和改变外部的代码。C/C++中的宏只在预处理阶段起作用,因此只能实现类似文本替换的功能。而Rust中的宏在语法解析之后起作用,因此可以获取更多的上下文信息,而且更加安全。

11.范型

Rust对应范型编程支持良好,甚至还可以配合where关键字做一系列的条件约束。

fn first_or_default<I>(mut i: I) -> I::Item
where
    I: Iterator,
    I::Item: Default,
{
    i.next().unwrap_or_else(I::Item::default)
}

assert_eq!(first_or_default([1, 2, 3].into_iter()), 1);
assert_eq!(first_or_default(Vec::<i32>::new().into_iter()), 0);

在这个代码例子中,这个I必须是Iterator且Item类型。

12.方法重载

Rust并不支持方法重载,也不支持参数默认值。Go那边也是这么考虑的,些惯Java的人表示难受。

13.语法糖

Rust应该是我见过语法糖最多的语言了。总得来说还是挺有用的,虽然面向系统底层,但是通过一些良好的API设计和语法糖,可以在编写系统级程序时没有那种很明显面向底层的感觉。


泊浮目
4.9k 声望1.3k 粉丝