[Programming Language] Rust vs C++
Table of Contents
这篇文章通过一些C++和Rust代码片段做对比,对Rust核心特点进行快速介绍。适合熟悉C/C++并且不熟悉Rust的读者,并且帮助他们快速熟悉Rust的特点。注意,这篇文章不能让你学会写Rust,只是帮助你快速理解Rust的一些特性。
0x00⌗
关于Rust这门语言,大家或许都听说过。它是一门 旨在取代C++ 的系统及编程语言。相比于C++,在零代价抽象和无GC的基础上,增加了编译期借用检查,及时阻止了C++中的一系列内存不安全的写法的,同时具有更加现代
编程语言特性的一门年轻的语言(2015 1.0版本)。
三门语言的对比,星越多越利于程序员使用。
问题 | Rust | C++ | C |
---|---|---|---|
阻止悬垂指针的使用 | ⭐⭐⭐ | ⭐ | |
缓冲区溢出 | ⭐⭐ | ⭐ | |
内存泄漏 | ⭐⭐ | ⭐ | |
多线程数据竞争 | ⭐⭐ | ||
抽象性 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
学习曲线 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
通用性 | ⭐⭐ | ⭐⭐⭐ | |
生态 | ⭐⭐⭐ | ⭐⭐⭐ | |
包管理 | ⭐⭐⭐ | ⭐ | ⭐ |
阻止悬垂指针是Rust的主打特性,只要写Safe Rust代码,基本上可以杜绝。所以这里是三颗星。 C++只能靠智能指针解决一部分问题,因为只要有原生指针的存在,就有可能导致问题。并且C++在语法上不显式阻止原生指针的使用。
在内存泄漏方面Rust和C++面对的问题是一样的,由于没有GC,基于引用计数的内存管理无法处理这种设计上的缺陷,但是由于Safe Rust的特点以及借用检查机制,相比于C++,能够避免大多数情况的内存泄漏。比如Safe Rust内不允许出现显式的内存分配。另外,Rust当中写出循环引用的代码是比较困难的。
Rust的所有权系统在一定程度上可以做一些线程安全方面的检查工作。但无法解决死锁的问题。
0x01 可变性和移动语义⌗
移动语义⌗
Rust中,除了简单变量(内置基础类型和POD)默认是按位复制的之外,其余变量默认都是移动语义,对于这种变量,想要深复制,必须显式的操作clone()
。
let foo = 1;
let var = foo; // Copy
println!("{}", var); //Ok
let s = String::from("hello world");
let s2 = s;
println!("{}", s); // Error: the ownership of s has already been moved to s2
let s = String::from("hello world");
let cp = s.clone();
println!("{}", s); // Ok
从上面这个例子可以看出,Rust默认的移动语义是语言特性保证的。C++中对右值的移动操作,可以理解为一种特殊的资源交换,并没有在语言级别上保证“所有权被移动”,因为就算移动了,还是可以访问这个变量的,只不过这个变量可能是不完整状态,但是状态的完整性还是要靠写代码保证的。但Rust的值一旦被移动,原来的这个值就变得不合法,再次访问编译会报错。 Rust这种移动是保证内存安全的基础。
可变性⌗
Rust中的可变性,只是针对变量绑定来说的。也就是说,可变性并不是变量的先天性质,而是后天的。这与C++不太一样。C++声明const类型的成员变量就是类型的一部分,就是我们通常说的“const类型” 和 “非const类型”。而rust中没有const类型和非const类型。代码如下
let s = String::from("hello");
s += " world"; // error
const std::string s = "hello";
s += " world"; // error
对于C++的const类型和rust的不可变绑定,都不可以被修改,我们似乎看不出const类型和不可变绑定的区别。 我们再来对比一下struct就可以看出区别了。
struct Person{
name:String,
age:i32
}
fn main(){
let mut tom = Person{name:"tom".to_owned(), age:18};
tom.name = "jerry"; // error: name field has been bound as immutable
let mut jerry = Person{name:"tom".to_owned(), age:18};
jerry.name = "tom";
}
struct Person{
const std::string name;
int age;
};
int main(){
Person tom = Person();
tom.age = 18;
tom.name = "Tom"; //Error: name is always constant
}
无论这个struct是否为const,相应的const成员变量总是const,不可以被修改。Rust中struct成员变量类型没有const与非const区别,成员的可变性在于是否用let mut
还是let
绑定的。let mut
绑定意味着可以被&mut T
类型借用,这种借用可以用来修改成员变量。
从以上可以看出,Rust的mut如果作为类型,只出现在&后面,也就是是有mut借用类型(&mut T)和非mut借用类型(&T)的。并且mut借用类型只能借用mut绑定的变量。 Rust变量本身没有mut类型和非mut类型。变量的mut绑定可以随时更改。代码如下
let s = String::from("hello");
let mut s = s;
s += " world";
assert_eq!(s, "hello world");
变量s一开始是不可变的,之后可以重新绑定为可变绑定,我们能继续修改内容"hello"。而且,这里与C++不同的是,可以在同级作用域内隐藏同名变量。方便随时更改可变性。
0x02 借用(Borrow)⌗
借用(Borrow)是Rust核心概念。先介绍一下借用记号&
,和我们熟悉的*
操作符。
Rust中的借用(Borrow) 相当于C/C++中的指针。更准确的说,Rust中没有C++中的引用(Reference) 概念的,C++的引用只是相当于别名,实际上只是指针的语法糖,关于C++引用的引入,可以参考《C++语言的设计与演化》。
虽然Rust的&形式的借用看起来和C++中的引用差不多,其实它还是a指针,只不过多了一些默认行为。我们可以看到Rust当中也有*
这个记号。
*
这个记号和C/C++中的语义几乎是一样的,在指针变量前(包括&形式和*形式)作为解引用操作符,用作类型时表示指针,这个指针是Unsafe形式的。可以认为& 和 *类型的变量都看作指针,语义上是一样的。只不过&形式的指针(借用)为安全指针,安全指针多了如下几个特点:
- 不能为空
- 参与借用和生命周期检查
- 不能进行指针运算
- 在某些场合下自动解引用,以方便使用。
因为有第四个特点,所以看起来才像C++引用。而*
形式的指针为非安全指针(原生指针),无以上特点,并且在safe代码中不可以解引用,只能在unsafe中进行解引用。
静态借用检查 —— &形式的安全指针⌗
&安全指针是rust的核心。核心原则是:每个变量最多只能有一个可变借用,或者多个不可变借用。 这个原则很好理解,如果有多个不可变借用的话,通过一个可变引用修改,会造成其它引用的数据不一致的问题。 很容易的发现,这个条件非常苛刻。在一定程度上会造成写代码的不方便。
多个不可变借用
fn main(){
let x = 5;
let rx1 = &x;
let rx2 = &x;
println!("{} {}", *rx1, *rx2);
}
Rust 2018引入了NLL,所以借用检查比之前版本智能了许多。之前变量的生命周期是作用域范围,现在成了最后一次使用变量的位置。
{
let mut x = 5;
let mr = &mut x;
let ir = &x;
// *mr = 1; // Error when uncomment this line
}
比如以上代码,Rust 2018之前是不能编译通过的。2018之后如果不使用mr,则可以编译通过。借用检查器认为mr
对x
的借用就截止到当前行。ir
hemr
的借用时机没有重叠。
还有一个有意思的代码片段
let mut v = vec![1];
v.resize(v.len() + 10, 0);
2018之前这段代码是不能通过编译的,因为v.reisize
是对v
的可变引用(可查看resize
的第一个上下文参数类型),v.len
是对v
的不可变引用。在v.len
求值的过程当中相当于有一个对v的不可变借用,根据借用检查规则,此时v.resize
是不能再次进行可变借用的。但实际上,在对resize
求值的时候,函数参数作用域已经结束了,此时在对v
的借用时机上是没有重合的,事实上是安全的,理应编译通过。只能通过以下写法妥协,但是这非常的不方便。
let mut v= vec![];
let new_len = v.len() + 10;
v.resize(new_len, 0);
好在这种局限性在2018时得到了改进。这个问题和上面的那个片段其实都是NLL的问题。我们可以再深入追究一下,即便在2018之前,按照没有NLL的规则来说,为什么len new_len = v.len() + 10;
和v.resize
没有借用重叠呢?因为这里的v.len()是值表达式,借用作用域在得到new_len的时候就已经结束了。这里的值表达式就是我们熟悉的右值,相应的左值对应位置表达式 。
这就是Rust最基本的借用检查规则,这几个示例非常简单,只是用来说明最基本的规则。不过这种规则影响了整个Rust代码的构建,尤其是涉及到结构体之后,情况变得更加复杂。
动态借用检查 —— 内部可变性⌗
Rust的可变性绑定在结构体方面其实是有局限性的,如果不用特殊的手法,Rust结构体字段没有办法单独实现某些可变,某些不可变。比如以下C++代码,在Rust当中没有比较好的对应。
struct Person{
const std::string name;
const std::string id_number;
int age;
Person(std::string name, std::string id):name(std::move(name)),id_number(id),age(0){}
void year(){
age++;
}
void get_name()const{
return name;
}
};
对于C++里面这种大多数字段不可变,只有少数字段可变的情景,在rust里面的办法还是用let
绑定,而不是let mut
绑定。因为let mut
会让所有字段可变(继承可变性,inherited mutability),破坏了不可变的一般情况。对于一小部分字段,我们可以开一个后门。
这就是Rust的**内部可变性
所谓的内部可变性,就是对内可变(某些字段可变),对外不可变(这个结构体是let
绑定)。
其实成员函数能不能修改自己的字段取决于上下文参数&mut self
还是&self
。而能不能调用这种成员函数取决于绑定时候的可变性。&self
和C++成员函数的const限定有些相似,但其实差别还是比较大的,const限定只是不能修改成员变量,和结构体本身的可变性无关。但是C++的成员函数const限定是函数签名的一部分,可以用来区分重载。不过幸运的是Rust当中没有重载,重载引入的坑还是挺多的,虽然没有重载确实不太方便,但是这种不方便忍忍就过去了,但是重载带来的坑比较难以察觉,有时候代价比较大,而且也不太好实现API统一,在编译器实现层面就有不少困难。
Rust通过RefCell<T>
实现内部可变性。因此,上面的C++代码对应到Rust是这样的
struct Person{
pub name:String,
pub id_number:String,
pub age:i32
}
impl Person{
fn new(name:String, id:String, age:i32){
Self{
name:name,
id_number:id,
age:age
}
}
fn year(&mut self)[
self.age += 1;
]
fn get_name(&self){
self.name
}
}
let p = Person::new();
p.year(); // Error, not a mutable binding
struct Person{
pub name:String,
pub id_number:String,
pub age:RefCell<i32>
}
impl Person{
fn new(name:String, id:String, age:i32){
Self{
name:name,
id_number:id,
age:RefCell::new(age)
}
}
fn year(&self)[
*self.age.borrow_mut() += 1;
]
fn get_name(&self){
self.name
}
}
let p = Person::new();
p.year(); // age++
- 这里更适合用Cell,因为Cell是针对可复制的内置类型的。
- Cell实上对T进行了Copy,所以没有违反静态借用检查规则,。(因为没有借用)
- RefCell其实违反了静态借用检查规则,因为
self.age.borrow_mut
中的self是个不可变借用,我们最终通过引用修改了age这个值,这个是静态借用检查所不允许的。这里用的就是动态借用检查。
不过这不是RefCell的常规用法,RefCell的常规用法是和Rc组合成Rc<RefCell<T>>
,用来模拟在单线程情况下接近C++意义上的指针的功能。
动态借用检查⌗
上面的内部可变性可以实现动态动态借用检查。 所谓动态借用检查就是借用检查不依赖词法作用域,不是根据引用的先后顺序,而是动态的。动态借用检查的规则仍然是多个不可变借用或一个可变借用。
平常使用的& T指针,对应到动态借用检查的形式就是Rc<RefCell<T>>
。
let ptr = Rc::new(RefCell::new(1)); // 这里之所以可以不用let mut绑定就可以修改后面的,就是因为内部可变性的原因。因为Rc内部的字段就是一个RefCell字段。即内部可变字段,不用let mut绑定就可以改变内部值。
let mut r = ptr.borrow_mut(); // 进行可变借用,返回的是一个BorrowMut对象。
*r = 2; // 可变借用
let ir = ptr.borrow(); // 进行可变借用, 返回一个Borrow对象,运行时会panic
上面的代码能编译成功,但会运行时崩溃。究其原因就是违背了动态借用检查的原则。虽然看似还不如带有NLL的静态借用检查灵活,但借用检查是动态的,发生在运行期。关键就在于borrow_mut和borrow,分别返回BorrowMut和Borrow对象,这个对象是一个借用计数器,BorrowMut会记录可变引用的个数mut_ref_cnt,Borrow会记录不可变引用的个数ref_cnt,当在代码运行中发现计数 (mut_ref_cnt > 1 and ref_cnt == 0) or (mut_ref_cnt > 0 and ref_cnt > 0)
就会触发panic,程序终止。
如果改成下面这种写法,就可以运行。
let ptr = Rc::new(RefCell::new(1)); // 这里之所以可以不用let mut绑定就可以修改后面的,就是因为内部可变性的原因。因为Rc内部的字段就是一个RefCell字段。即内部可变字段,不用let mut绑定就可以改变内部值。
{
let mut r = ptr.borrow_mut(); // 进行可变借用,返回的是一个BorrowMut对象。
*r = 2; // 可变借用
}
let ir = ptr.borrow();
Option<Rc<RefCell<T>>>
表达的意思是可空的
、带有共享所有权的指针,相当于C++中的,T* 或者 shared_ptr
Option<Rc<T>>
相当于 const T * 或 shared_ptr
0x03 生命周期及其标记⌗
我们非常熟悉C++中的作用域和生命周期的概念,以及相应的RAII机制。Rust在继承这些的基础之上,添加了生命周期的检查。比如以下返回栈上或者已经析构的对象的指针或引用这种典型的错误写法,在Safe Rust中会被编译器叫停。
{
let r;
{
let mut v = 5;
r = &mut v;
}
*r = 10; // Error:
}
fn danger()->&i32{
let v = 5;
&v // Error:
}
{
int * r;
{
int v = 5;
r = &v;
}
*r = 10;
}
int * danger(){
int v = 5;
return &v;
}
如果我们不违反生命周期写代码会出现什么情况?
fn max(a: &i32, b: &i32)->&i32{
if *a > *b{
a
}else{
b
}
}
给出的编译信息为:
missing lifetime specifier
this function’s return type contains a borrowed value, but the signature does not say whether it is borrowed from
a
orb
fn max<'a>(a: &'a i32, b: &'a i32)->&'a i32{
if *a > *b{
a
}else{
b
}
}
改成如上形式就可以了。
上面的`a
就是生命周期标记。接下来我们详细的介绍一下生命周期标记的意义。以及为什么有了这个标记就可以编译通过了。
首先我们需要注意的是,Rust和C++里面的生命周期是一样的:无GC,超出作用域之后析构。通过生命周期标记并不能延长变量的生命周期。因此,只要代码给定了,所有变量的引用关系以及生命周期就已经确定。因此,生命周期标记的存在并不是程序的正确性(无悬垂引用)的充分条件。但通过前面的代码,我们发现生命周期标记是在某些情况下编译通过的必要条件。它不能帮我们纠正错误生命周期的引用,那为什么还需要它呢?
理论上,只要编译器分析所有的代码,都可以在编译期检查引用的生命周期是否正确,进而当代码出现悬垂引用时报错,达到编译期检查的目的。然而在实现上是不太可能的。比如对于如下的代码片段:
fn foo<'a,'b>(x:&'a i32, y:&'b i32)->&'a i32{
x
}
{
let x = 6;
let &z = {
let y = 5;
foo(&x, &y)
}
}
这段代码没有违反引用的生命周期规则,并且可以编译通过。即我们返回的引用的生命周期为’a,就是引用参数x,返回的引用也标明’a, 最终赋值给z这个引用,有相同生命周期’a, 即最外层的x的引用。z这个和x在相同的生命周期内。
然而,你可能有疑问,当我们去掉’a, ‘b这些标记时,我们通过观察代码,也是能够判断出生命周期的引用是否正确。
比如
{
let x = 6;
let &z = {
let y = 5;
foo(&y, &x)
}
}
是错误的
而
{
let x = 6;
let &z = {
let y = 5;
foo(&x, &y)
}
}
是正确的。那标注的意义何在?而且增加了写代码的难度。实际上,编译器能够判断出这两段代码引用关系是否正确的前提是知道foo的实现,知道它返回的引用的变量生命周期和第一个变量相同。那么这就有一个问题,这个函数只套了一层,如果这个函数有十层,如果要靠编译器自动推导生命周期是否正确,编译器只有看到最后一层才能确定是否正确。不说效率问题, 即使编译器发现了错误,到底是哪里的错误呢?是最后一个函数写错了,还是最外层函数用错了?。可以看到,如果不标记生命周期,全靠编译器检查错误的生命周期引用完全可以做到,但是得到任何的有效信息。因此,手动标明生命周期相当于用户的一个说明书,同时也充当了注释的作用,让我们明白如果我们的引用的生命周期一旦搞错, 可以快速排查问题。同时,编译器只需要看函数的声明部分,就能马上给出判断,也简化了实现。这其实是一个妥协。Rust1.0版本之前只要有引用,都必须标注生命周期,当前版本的Rust在一些比较常见的情况下是不需要标注的,因为这些比较容易推导以及这些写法太常见了。未来不排除Rust会继续放松生命周期标注的原则。
总之,生命周期不会让我们本来写错的代码变得正确,因此,标注声明周期的最好办法是除了那几种常见的情况外,如果引用比较多,更加复杂,那我们就不标注,当编译器提醒我们的缺了的时候我们再根据我们的意图标注。对于初学者,大可不必在如何标记生命周期方面深究,随着写Rust代码的熟练程度增加,自然就会标记了。
0x04 trait和面向对象⌗
trait⌗
面向对象⌗
通过上面的示例基本上可以看出,Rust弱化了面向对象,那种传统的面向对象设施在Rust当中是没有的,比如类,构造函数,析构函数,虚函数,数据继承,友元等。不过多态(动态分发)是保留的,是通过trait对象实现的。trait对象是trait的自己,也就是满足一定条件的trait才是trait对象
0x05 并发⌗
Rust的并发安全保证也是通过特殊的trait标记来完成的。简单来说,Rust通过给创建线程的入口添加某些trait约束,只有满足这些trait约束的对象,才能够被传入创建的线程,或者在线程之间共享变量。