C++ 程序员30分钟速通Rust指南(待补充)

劝退提示

本文信息量较大,要求有一定C++开发基础的开发人员才建议阅读。必备知识:

  1. 熟练掌握C++基本语法;
  2. 理解智能指针,移动语义;
  3. 理解模板、基本的泛型编程概念;
  4. 理解函数式编程范式,理解STL容器特性(迭代器、仿函数等);
  5. 有熟悉的C++工具链使用经验、基本的项目组织能力;

0x0 写在前面

  1. Rust融合了多种语言优点,并且为了安全和性能舍弃了很多不必要东西。对于熟悉很多语言的人来说,Rust是一锅大杂烩,可以看到C/C++,Python,Go,perl,Haskell等语言的身影。总体来看,它的数据结构是经典的C风格,数据(struct)与操作(impl)完全分离;在类型实现和方法定义上,又用到了很多来自C++的模板的概念;在语法上,有些类似于Go语言的变量声明,perl的模式匹配,Python的字符串处理;在程序设计上又可以使用Haskell的函数式编程范式。
  2. Rust开发中有一个很明显的特点,就是套娃。一旦某种操作无法满足规范,或者设计的要求,就会往这些相关的操作上套一些辅助的工具,例如:当你发现一个变量需要被共享,就会想到Rc,然后你发现要多线程,就会把Rc替换成Arc>;完了你发现这个东西在析构函数里面需要移出所有权,又得套一层Option变成Option>>……要实现的东西越复杂,套的娃就越来越多。
  3. Rust的面向对象思想和C++/Java这些完全不一样,至少是差别很大。熟悉了C++/Java的面向对象之后,要熟悉Rust的面向对象需要绕很大一个弯。因为Rust的面向对象主要靠组合,因为Rust不支持继承,但是Rust有一个特点,就是它可以在多个trait之间横向动态变换,这也是一种多态的方式。通过给一个数据结构“插入”trait,就可以让数据结构通过dyn关键字支持多种方法,很是奇妙。
  4. Rust本身提供的工具链效率很高,包括环境搭建、项目组织、编译、测试、文档生成、包依赖、版本库等等,都非常成熟且完全自动化,可以极大程度减少程序员的心智负担。由于Rust的强制规则,和严格的编译期检查、静态分析,只要熟悉了Rust语法,程序员就可以专注于业务、算法或者框架设计,而不需要被上述这些“杂事”所困扰。另一方面,得益于Rust的“严格”,不同程序员产出的代码不会有明显的水平差异,不像是C/C++中的代码千人千面,风格迥异,性能也天差地别。

1. 基本语法

老生常谈的基本类型:

  • 整型: u8, i8, u16, i16,i32, i64, u32, u64, i128, u128, usize,其中usize类似C++的std::size_t,与平台相关;
  • 浮点型:f32, f64
  • 布尔型:true, false
  • 字符型:char,注意,Rust中的char不是C/C++中的char,Rust的char是完整的Unicode标量,例如 ‘a’ 或者 ‘’。
  • 复合类型:()元组,[]切片;元组类似于std::tuple,切片类似于std::string_view

除了基本变量,和实现了Clone“特征”以外的类型,他们的“赋值”(=号)都是“移动语义”,在后面的所有权章节会详细讨论这个问题。

声明变量的方式:

struct A;  // 声明一个占位符类 就是一个不包含任何成员的类
struct B {
    field: i32,
} // 声明一个包含了1个有名称的字段的类
struct C(i32, i32); // 声明一个包含了2个i32类型 匿名字段的类 实际上是一个元组
enum Status {
    Ok,
    Fail,
    Other(B)
} // 声明一个枚举类型
const ERR_CODE_OPERATE_FAIL : u32 = 888u32; // 声明一个常量 规范要求常量变量名为 全大写+下划线
// ---- snip ----
let a: i32 = 3; // 显式指定类型
let b = "hello world"; // 自动推导类型 (推导为 &str 切片类型,后面我们再谈)
let c = a; // c获得了a的拷贝 因为a是基本类型 i32

Rust中,注释只有//开头的单行注释。
注意,struct的声明体,只有占位符和元组类末尾需要加;分号,其他的包括enum都不需要分号。
Rust中的enum类型类似于C++的enum class,但是比C++更进一步,枚举值可以是类型,也就是说,可以包含更多信息。
另外,let声明变量只能在函数体内使用,而const声明常量可以在全局范围(main函数体外)以及函数体内使用。

返回值、代码块和表达式
  • 任何Rust程序都是由包含fn main()函数的.rs文件开始的。
  • fn main()函数可以没有返回值,默认返回值为(),是一个空元组(tuple);fn main函数的返回值也可以是类型;
  • 表达式之间通过;分号来分隔(和C++一致);
  • 函数的返回值可以写return,或者省略return和最后的;分号,那么最后一个表达式将作为函数的值;
  • 语句块是以{}大括号包裹的多个表达式(这个概念和C++一致);语句块也可以有值,它的值就是最后一个省略掉;分号的表达式的值;
  • 每个语句块都是一个作用域(和C++一致);

流程控制

注意,在Rust中,所有流程控制都是不需要使用()将表达式括起来的,这点和C++有所不同。并且,由于Rust不存在隐式转换,因此,表达式必须是通过逻辑运算符计算得到的布尔类型,或者单纯的true或者false。也就是说,不存在“0为假,非0为真”这种说法。

for循环

和C++不一样的是,Rust只提供了ranged for循环,如:

for i in (0..10) {
    println!("{i}");
}

注意这里的..运算符,生成了一个可供迭代的数值序列,包含了从[0,10)的左闭右开区间。

while循环
let mut i = 0;
while i < 3 {
    println!("hello world");
    i += 1;
}

注意,Rust中不存在类似C/C++的前/后++运算符,只能通过+=-=等方式对变量自增(这也是由于Rust的所有权规则决定的)。

loop循环
let mut i = 3;
loop {
    if i < 0 { break; }
    i -= 1;
}

loop循环等价于while true {},是一个无限循环的循环体。

if, else if, else
let mut rng = rand::thread_rng();
let random_number = rng.gen_range(-100, 101); 
if random_number > 0 {
    println!("random number is less than 0");
} else if random_number == 0 {
    println!("random number is equal to 0");
} else {
    println!("random number is greater than 0");
}
break, continue, 循环标记
let mut i = 0;
let mut j = 0;
'outer : loop {
    i += 1;
    if i > 5 {
        'inner : loop {
            j += 1;
            if j > 5 {
                break 'inner
            }
            i = 0;
            continue 'outer;
        }
    } else {
        continue 'outer;
    }
    break 'outer;
}
println!("i = {i}, j = {j}");

我们可以在循环体前打上标记,如'outer'inner。在breakcontinue时,可以追加标记,代表从何处继续。这个特性略类似于C/C++中的goto语句,但是必须在循环体中使用。

match
enum Status {
    Ok,
    Fail,
}

fn main() {
    let ret = Status::Ok;
    match a {
        Status::Ok => println!("is ok"),
        Status::Fail => println!("is fail")
    }
}

match可以对枚举类型进行穷尽匹配,如果变量符合枚举值,则执行并返回=>后的一段表达式。match必须穷尽枚举类的所有枚举值,否则会报编译错误。对于枚举类型,match的各项表达式返回值必须统一;

注解

Rust中,我们可以通过添加注解来为sturct或者enum类型添加可打印的熟悉,如:

#[derive(Debug)]
struct A {
    x: i32,
    y: i32,
}
#[derive(Debug)]
enum Status {
    Ok,
    Fail,
}
let a = A{x: 1, y: 2};
let b = Status::Ok;
println!("{a:?}, {b:?}");

这里我们不需要主动为struct Aenum Status实现fmt::Display特征,就可以通过println!宏打印对象的内容。这种自省式的类似反射的功能是C++程序员梦寐以求的,但是C++目前仍然没有实现和支持静态反射,十分遗憾。

字符串

Rust中的字符串String比较特殊,它是UTF-8编码的字符串,是真的用来存储文本的一个对象。C++中的std::string或者const char*都只是保存或指向了一段以'\0'结尾的内存空间(我倾向于称之为字节块)。
虽然Rust中的字符串String底层也是一段字节块,但是它并不支持下标操作,因为如果随意选取一个下标,可能正好落在某个UTF-8字符的中间,如果强行这么做,Rust程序会直接崩溃。但是String提供了.chars()方法,可以将底层字节块的内容按照UTF-8的方式解析出来,并支持.iter()方式迭代遍历。

2. 项目组织

cargo的使用

C++目前主流的项目组织方式是CMake,但是大部分内容还是需要手动编写CMakeLists.txt文件才能生成所需的二进制文件。
Rust使用cargo作为项目组织工具,只需要一个配置文件Cargo.toml即可全自动解决编译、依赖、版本管理(git)等功能,用过之后直呼太爽。
Rust要求可执行文件需要一个main.rs文件,库文件要求一个lib.rs文件。cargo会自动扫描目录下相关名称的文件,自动编译。

主要命令:

  • cargo new xxx创建一个xxx目录,包含了基本可执行文件模板的项目结构目录,包含一个main.rs和一个Cargo.toml配置文件,;如果是cargo new xxx --lib,则是按照库模板生成项目结构目录,包含一个lib.rs和一个Cargo.toml配置文件;
  • cargo build 编译
  • cargo run -- 执行项目的可执行文件,--两个横线后可以接命令行参数,传递给可执行文件;
  • cargo doc --open 生成并打开项目文档;是的,你没有看错,只要在代码中按照规范写注释,就可以自动生成html文档。不像在C++ 中,还得手动搞doxygen,sphinx等等一大堆东西。
  • cargo test [testname] 执行自动化(单元)测试,不需要额外搞gtest或者boost.Test,内置测试框架,直接一键测试。

引入模块

在Rust中,一个完整的项目称作crate,官方的包管理库也叫crate.io;在项目中,从逻辑上组织成一个统一功能的集合叫做package,例如通过main.rs生成的可执行文件属于一个package,通过lib.rs生成的库也是一个package;每个rs文件可以作为Rust项目的最小单位module,在Rust源文件中,可以通过pub mod mymod导出模块,通过use mymode导入模块。
相比于C/C++的include模式,Rust的模块引入路径和项目组织有很大关系:

  • 所有项目的模块根路径是crate::,通常这个根路径在项目内部可以省略;
  • 当前项目的根路径是myproject::,例如:lib.rs导出的符号myfunc,都可以通过路径use myproject::myfunc或者use crate::myproject::myfunc导入;
  • 子模块如果在lib.rs里面导出的,通常放在和lib.rs同一级的目录中,例如在lib.rs中通过pub mod mymod导出的子模块mymod,它的源文件放在lib.rs同一级目录的mymod.rs中;
  • 子模块的子模块需要放在上级子模块同名的目录中,例如:mymod.rs中导出的pub mod mysub,它的源文件应当放在mymod/mysub.rs中,如下所示:
    .
    ├── Cargo.toml
    └── src
     ├── lib.rs  // 使用了子模块 mymod
     ├── mymod
     │   └── mysub.rs  // mysub 的实现必须放在上级模块同名的目录下
     └── mymod.rs  // 子模块使用了子模块mysub
    

3. 所有权

所有权铁律

Rust对于引用有如下“铁律”,任何情况不得违反(智能指针除外,后面会讨论):

  1. 在任何给定时间,您可以拥有(但不能同时拥有)一个可变借用(&mut)或任意数量的不可变借用(&)。
  2. 引用必须始终有效。

一切皆移动

和C/C++默认拷贝的语义不同的是,Rust中除了基本类型(类似于C/C++的POD),以及实现了Copy特征的类型之外,一切赋值、或者函数参数形实结合的时候,都是移动语义,都会产生所有权的转移。

我们先看一段代码来感受一下:

fn main() {
    let mut str = String::from("hello");
    let ptr = &mut str;
    let ptr2 = ptr;
    *ptr2 = String::from("world");
    *ptr = String::from("!"); // 编译错误!!!
}

在上面的代码中,我们首先定义了一个可变的字符串“hello”,然后通过引用ptr获取了字符串str可变借用(指针);
然后,我们通过赋值定义了另一个引用ptr2,注意,此时已经发生了所有权的转移:ptr的内容(指针)转移给了ptr2ptr2拥有了原本ptr所“拥有”的指针。用C++的(近似)等价代码表示如下:

int main() {
    std::unique_ptr<std::string> str(new std::string("hello");
    std::unique_ptr<std::string> ptr(str.release()); 
    std::unique_ptr<std::string> ptr2 = std::move(ptr);
    *ptr2 = "world";
    *ptr = "!";
    // 最后,在离开作用域时,str会再次获得std::string的所有权,最后由str释放资源,即存在一段代码
    // str.reset(ptr2.release()); 但是C++不存在这种等价行为,std::unique_ptr的所有权转移是唯一的
}

注意,此处只是尽可能模拟,并非完全还原真实情况。需要注意的是:

  • 当我们声明并定义了变量str的时候,它拥有了这个字符串“hello”的所有权,等价于C++中通过std::unique_ptr获取的指针所有权,str拥有所拥有的对象的读、写以及所有权;
  • 当我们定义了一个ptr引用指向&mut str的时候,ptr暂时获取了str的读、写权限;注意,此时如果使用str去修改字符串内容,是不允许的,根据“所有权铁律”,同一时间只能有一个可变借用,str相当于对指向对象的可变借用,这个借用被转移给ptr了;
  • 当我们又定义了一个ptr2,使其“等于”ptr的时候,ptr丧失了对str的读写权限,转移到了ptr2上面,此时通过ptr2解引用修改或者读取str的字符串是合法的;
  • 最后,当我们对ptr解引用时,由于ptr丧失了str的所有权,因此如果此时允许解引用,则会产生未定义行为;对应于C++中,当我们对已经使用过std::move移除所有权的智能指针(将亡值)解引用时,也是未定义行为,但是C++编译期不会拒绝这样的代码,而Rust严格的标准会拒绝这样的代码。

我们再回顾一下,Rust中的“引用”、“指针”、“借用”的概念,细细体会一下。在我的理解中,Rust中的变量在某种程度上都属于是“引用”,而“引用”是包含了一个“指针”的对象。当我们需要临时借用这个“引用”的“指针”所指向的对象时,我们就要发生“可变借用”或者“不可变借用”,此时,所有权仍然归第一个“引用”对象所有,但是我们可以获得这个对象的读或者写的权限,与此同时,第一个“引用”对于其所指向对象的读、写、所有权等权限可能会被暂时屏蔽。

生命周期(lifetime)

(// TODO 待补充……)

4. 错误处理

OptionResult

在Rust开发的错误处理规范中,我们会了解到OptionResult属于2类常见的枚举类,用于帮助确认函数的执行情况。
(// TODO 待补充……)

5. 泛型与特征

泛型:

函数模板
类似C++的函数模板,但是需要注意的是,必须要为模板参数提供“特征”,否则只能原样返回入参。一个经典的练习题:

fn mystery<T>(x: T) -> T {
    // ????
}

fn main() {
    let y = mystery(3);
}

请问该函数的返回值y是多少?答案是

如果泛型类型的“模板参数”没有为其定义“特征”,那么这个泛型类型或者函数是没有任何意义的。

特征

特征就是类似于Java中接口以及C++中抽象类的概念,它是一种特殊的类型,不能实体化,只提供一组方法的原型(也可以提供默认实现);
只要声明了对某个类型实现某个“特征”,那么这个类型就自动“继承”了这个特称下的一系列方法;在C++中,就相当于一个继承了抽象类的派生类实现。

特征实现的规则如下:

  1. 您可以为本地类型实现外部特征;(实现外部接口,C++中就是继承抽象类)
  2. 您可以为外部类型实现本地特征;(Rust特有,相当于让外部类组合了本地的接口)
  3. 您可以为本地类型实现本地特征;(扩展接口,C++中可以是继承抽象类,也可以是组合)

6. 函数式语言特性

闭包

等价于C++中的lambda函数。基本语法形式:

fn main() {
    let lambda = | | println!("hello world");
    // 本质上完整的声明是
    // | | -> () { println!("hello world") };
    lambda();
}

和Rust函数声明对比:

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  ;

注意,add_one_v3add_one_v4要求必须在程序中调用,才能推导出参数类型,才能编译通过。否则会产生编译错误。另外,Rust的闭包不支持泛型,也就是说,调用的参数只能推导出一种类型,一旦推导出类型之后,闭包不可以作用于其他类型的对象上。

对比C++的形式[&,=](auto arg) -> T { expr; }可以看出,Rust的闭包缺少了捕获列表,实际上Rust会默认捕获其声明时所在作用域的所有对象,但是捕获的方式会根据闭包内表达式由编译器自动推导出三种情况:不可变借用,可变借用以及所有权转移。(后者对应C++14引入的移动捕获)

不可变借用捕获
fn main() {
    let list = vec![1, 2, 3];
    println!("Before define closure, {:?}", list);
    let borrow_immut = || prinltln!("Inside closure, {:?}", list);
    println!("Before calling closure, {:?}", list);
    borrow_immut();
    println!("After calling closure, {:?}", list);
}

编译器根据闭包内的表达式println!("Inside closure, {:?}", list);识别出list只需要满足不可变借用即可。

可变借用捕获:
// ---- snip ----
    let mut list = vec![1, 2, 3];
    println!("Before calling closure: {list:?}");
    let mut borrow_mut = || list.push(7);
    borrow_mut();
    println!("After calling  closure: {list:?}");

编译器根据闭包内的表达式list.push(7);识别出list应当满足可变借用才能正常工作。注意,此处的闭包类型也必须加上mut,因为其中的list是可变借用。

所有权转移捕获
// ---- snip ----
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);
    std::thread::spawn(move || println!("From thread: {:?}", list))
        .join()
        .unwrap();

注意,此处在std::thread::spawn中,有一个move关键字,显式声明了后面的闭包要求转移list变量的所有权。

“厕所闭包”(toilet closure)

let f = |_|();
等价于std::mem::drop,夺取入参的所有权并立刻丢弃入参。

迭代器

Rust中的迭代器概念和C++中的差别很大。C++中的迭代器可以理解为一种特殊的指针,而Rust中的迭代器更加类似于Python中的迭代器,是一种可以遍历的、指向某些元素的一个集合。
基本用法示例:

// ---- snip ----
    let v1 = vec![1, 2, 3];
    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
    println!("{:?}", v2);
    let v3: Vec<_> = v2.into_iter().filter(|x| x > &2 ).collect();
    println!("{:?}", v3);
    let v3 = 

代码中的mapfilter都是函数式编程范式中的常用方法,需要注意的是,在Rust中,迭代器都是惰性的,因此产生之后必须消耗掉,否则会引发编译错误。通常使用collect方法来消耗这些迭代器,保存到Vec中。
注意,mapfilter都是通过消耗前一个迭代器并生产新的迭代器,因此最后都要使用collect来消耗这些迭代器。
迭代器在Rust中是一个实现了Iterator特征的类型,其提供的next方法返回了一个Option类型。在遍历迭代器时,本质上我们做得是:

// ---- snip ----
let v = vec![1, 2, 3, 4];
let iter = v.iter();
while let Some(x) = iter.next() {
    // do something with x
}

当遍历结束时,我们的Option中只包含了一个None,因此退出了while循环。但是,在Rust中,遍历迭代器的方式建议是通过for循环:

// ---- snip ----
for x in v.iter() {
    // do something with x
}

Rust中的迭代器是 一种零开销抽象(zero-overhead),使用迭代器实现的代码性能不会比手动实现循环的代码慢。

7. 智能指针

在Rust中,通常我们说的指针实际上都是引用。或者换句话说,Rust中的引用取代了C++中指针的地位。需要注意的是,Rust中很多宏都会默认解引用,并且.运算符也会解引用,因此通常情况下不需要主动解引用。手动解引用的方式和C/C++类似,都是*号,例如:

let x = 1;
let y = &x;
*y += 1; // 手动解引用
println!("{}", y); // 默认解引用

在Rust中,智能指针有很多种,三方库也会提供自己的智能指针,我们只讨论标准库提供的最常见的3种智能指针:

  1. Box;(类似于std::unique_ptr
  2. Rc;(类似于std::shared_ptr
  3. 通过RefCell访问的RefRefMut(Rust特有,用于运行时借用检查);

使用Box

当我们需要从堆上申请内存时,最常见的方式就是使用Box::new方法,他的地位就如同C语言的malloc和C++的new运算符一样,但是它本质上更类似于C++中的std::make_unique()

例如,当我们需要为自定义类实现构造函数时,Rust中的惯用方法是这样的:

sturct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn new(x: i32, y: i32) -> Box<Self> {
        Box::new( Point {x, y} )
    }
}

fn main() {
    let p = Point::new(1, 2);
    drop(p);  // 不可使用 p.drop();
}

这段代码有几个细节需要注意:

  1. 正规的构造方式应当是Point{x: x, y: y}但是这里省略了冒号,因为这是Rust的构造省略特性,因为构造函数中的参数和成员的变量名重复了,就可以直接替代;
  2. 这里的new函数第一个参数不是self,它从功能上看类似于C++中的静态成员函数,因此也就不支持在对象上调用这个函数;如果要在对象上调用成员函数,那么其函数的第一个参数必须是self或者&self&mut self
  3. Rust禁止提前析构对象,默认情况下,在main函数结尾会对对象一一进行drop操作,如果提前调用对象自己的drop方法,则会造成double free;如果确实要提前释放对象,需要使用std::mem::drop方法。

Box类型p本身是在栈上的一个对象,其中包含了一个指针(引用),指向了分配在堆上的Point类型实例。其中堆上分配了Point实例大小的内存用于存放这个Point实例。

那么如何才能得知一个实例的大小呢?Rust中也提供了类似C++的sizeof运算符的功能:

// ---- snip ----
println!("sizeof p is {}", std::mem::size_of_val(&p)); // 8 = 1个指针的长度
println!("sizeof Point is {}", std::mem::size_of::<Point>()); // 8 = 2个i32的长度

注意,在Rust中,有一个注解#[repr(C)]用来表示以C语言ABI(二进制接口)的方式分配类型的内存布局,也是C/C++中的默认内存布局,通常是以4字节对齐的。如果需要实现C++中类似#pragma pack(push, 1)这样的紧凑布局,则需要使用注解#[repr(packed)],一般在网络通信中常用。

Rust中定义析构函数的方式:
为类型实现Drop特征,实现drop方法

sturct A {
    x: i32,
}

impl Drop for A {
    fn drop(&nmut self) {
        println!("Deconstruct A with data `{}`!", self.x);
    }
}

fn main() {
    let a = A{x: 1};
    drop(a);
    println!("bye");
}

使用Rc

在Rust中,Rc是类似于std::shared_ptr的东西,但是Rc指涉的对象是不可修改的,因为Rc没有实现DerefMut特征,如果要修改,编译器会报错如下:

trait DerefMut is required to modify through a dereference, but it is not implemented for Rc

注意,Rc不是线程安全的,只能用于单线程。使用示例:

enum MyList {
    Cons(i32, Rc<MyList>),
    Nil,
}
use std::rc::Rc;
fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

注意上面的MyList,其中的Cons成员是一个包含了i32Rc的元组。如果直接写为Cons(i32, MyList)则会造成编译错误,因为产生了递归定义。这就类似于C++中定义一个二叉树节点,需要写成这个样子:

struct Node {
    Node* left;
    Node* right;
    int data;
};

因为在定义Node的时候,其大小还未确定,因此不能在其中声明Node类型的成员,只能声明为指针;在这种情况下,Rust也是同理,Rc也可以替换成Box实现MyList的定义。

需要说明的是,在Rust中,对于实现了Clone特征的对象,通常是使用a.clone()放法进行拷贝。由于clone()方法在约定俗成上是深拷贝,而Rc虽然是一个对象,但是作为引用计数智能指针,并不真正拥有其所指涉对象的所有权,所以不需要进行深拷贝;因此,Rust对于Rc采用了另一种约定俗成的拷贝方式,即Rc::clone。这个方式拷贝出来的Rc对象,仅仅克隆了其内部的指针,然后增加了所指涉对象的引用计数。

使用RefCell

从基本作用上看,RefCellBox是一样的,都是只能用于指涉一个自己拥有所有权的对象。但是,RefCell却有着运行时控制引用方式的特性。一般来说,根据Rust的所有权“铁律”,编译器在编译期会通过借用检查来确保“铁律”通过。

Rust的编译器会执行借用检查,静态分析等机制对代码进行检查和优化。它的标准是非常严格的,因此宁愿错杀也不会放过任何可能导致出错的代码。当你认为你的代码是正确的,但是编译器无法理解时,就需要一些手段让编译器允许你的代码可以通过——RefCell就是为了这个功能而生的。
注意,RefCellRc一样,不保证线程安全,只能用于单线程。

总结一下各个智能指针的特性:
智能指针特性对比
由于RefCell的借用检查是发生在运行时的,因此即便RefCell对象本身是不可变的,但是其所指涉的对象在运行期也是可以修改的
这种特殊的现象有点类似C++中,一个const对象,调用const成员函数,但是修改了标记为mutable的成员的情况。这种情况在C++中不算常见,因为大部分引用都是可变的,而且可以同时拥有多种引用。但是在Rust中,由于严格的借用检查,这种场景就比较常见了。例如

use std::cell::RefCell;

struct Api {
    count: RefCell<usize>
}
impl Api {
    fn send(&self) {
        *self.count.borrow_mut() += 1;
        // 做一些其他事情
    }
}

fn main() {
    let a = Api{count: RefCell::new(0usize)};
    a.send();
    a.send();
    a.send();
    a.send();
    println!("Api called {} times", a.count.borrow());
    // 打印 Api called 4 times
}

注意,some_method第一个参数是&self是不可变的引用,但是我又需要统计对Api调用的次数,因此将count成员通过RefCell封装,我们可以通过不可变引用修改对象内部的值。在C++中,我们可以写出以下等价代码片段:

struct Api {
    mutable size_t count{0};
    void send() const {
        count++;
        // do something else
    }
};

int main() {
    Api inst;
    const Api& a = inst;
    a.send();
    a.send();
    a.send();
    a.send();
    return 0;
}

由于Rc内部的对象必须是不可变引用,因此通常在Rust中可以使用Rc>封装的方式来创建可以修改的共享指针。

循环引用的隐患

思考下列代码,可以帮助我们理解循环引用问题是如何产生的:

use crate::List::{Cons, Nil};
use std::{cell::RefCell, rc::Rc};

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
    // 注意此处 a 是 List::Cons(5, RefCell> -> Nil)

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
    // 此处 b 是 List::Cons(10, RefCell> -> a )
    // 从链表角度来看 就是  b -> a -> Nil

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }
    // 由于 a.tail() 返回了 Some 就是 其中的 RefCell> -> Nil 这个指针
    // if 中 a的这个指针修改了指向 -> b
    // 此时从链表角度看 就是 b -> a -> b -> a.....

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // 下面这行注释取消后 程序将会爆栈
    // println!("a next item = {:?}", a.tail());
}

main函数返回后,a和b中RefCell>的引用计数都为1,导致了内存泄漏。
这种情况和C++中std::shared_ptr互相指涉导致的循环引用问题如出一辙,通常是程序逻辑有问题,Rust编译器并不会检查出这种问题,要求程序员能够通过单元测试保证逻辑正确(C++中也是如此)。

8. 并发编程

Rust号称可以安全地处理并发编程,那么它是如何做到的呢?
(待续……)

你可能感兴趣的:(rust,c++,rust,开发语言)