1.变量的声明
1.1 c++ 代码
c++ 的设计理念是,任何数据类型,在编程中的地位是一样的。例如:
int foo(){
int result = int(); //默认构造
return result; //默认构造
}
string bar(){
string result = string();
return result;
}
尽管 int 是内置类型,数据分配栈上,而 string 是外部定义的数据类型,数据动态分配在堆上,但两种类型变量构造过程的 c++ 语法规则完全一样。因此,上述 c++ 代码可以统一写成如下模板,而泛型变量类型 T 可以是任何数据类型:
template <typename T>
T wokao(){
T result = T();
return result;
}
1.2 rust 代码
rust 中,int 和 string 属于两类完全不同的变量,在使用过程中的方法也不相同。代码如下:
fn foo() -> i32 {
let result = 123;
return result;
}
fn bar() -> String {
let result = String::from("hello");
return result;
}
i32、String 二者的构造方法完全不同。rust 没有提供构造函数和析构函数机制,如果需要,用普通的函数实现即可。当然,我研究了一下,利用该机制,rust 应该能模拟出 c++ 的构造和析构机制。
1.3 对比
c++ 从语法机制保证所有变量地位平等、语法规则一致。 rust 提供了更自由的语法机制,完全可以模拟 c++ 的机制。就变量的声明而言,我觉得两种语言算是打平手。
2. 变量的构造、析构过程
c++、rust 构造变量的策略完全一样 —— 把局部变量分配在栈上。当然,一些复杂对象,例如struct、class等,它的基本数据结构是在栈上申请的,但是其包含的动态数据可能另外在堆上申请。
这样做的好处是,在变量离开作用域时,可自动调用析构函数。 rust 没有构造函数,但必须提供析构函数,对于在堆上申请了动态空间的对象来讲,需要提供一个名字为 drop() 的析构函数在离开作用域时供系统自动调用。
rust 和 c++ 不同之处在于,变量有可能因所有权转移,在作用域结束前提前析构。
事实上,c++、rust 的这个机制还是很棒的,如果你不用 new 语句动态申请内存,就不会出现内存泄露。相比之下,java、c# 等对于 class 类型只能用 new 语句动态创建,必须提供垃圾回收机制才能安心写程序。如果这类语言不提供垃圾回收机制,那就是一个悲剧 —— 曾经深受程序员喜爱的 delphi 正是这样一种语言!
在简单的代码块内,变量的构造、析构机制在两种语言中的实现是完全一样的,二者在这方面算是平手。
3. 数据所有权转移
3.1 函数参数:传值、传地址?
rust 立志要解决变量共享冲突问题,该问题 c++ 一直无法搞定。我们看个例子:
fn foobar(x: String) {}
fn main() {
let v = String::from("hello");
foobar(v); //- value moved here
println!("{}", v); // value used here after move
}
编译出现错误,错误信息我写在代码注释里面了。
- 在 c++ 中,
foobar(x: String)
这样的函数声明参数是传值的,因此他们对 main 函数中的变量 v 绝对不会产生任何副作用。 - 在 rust 中,
foobar(x: String)
这样的函数声明参数是浅复制,也就是说 String 知是个胖指针,指针给了 foobar,相当于堆上的数据也移交给了它们。foobar 函数结束时析构 x 的时候数据就销毁了,所以后面代码执行的时候,所需要的数据 v 已经不存在了。
当然,i32 这样的变量大小在编译阶段是已知的,其全部信息可以保存在栈上,调用 foobar 的时候,浅复制和深复制效果没区别,所以这段代码中的 String 换成 i32 类型,就不会有编译错误。
函数 fn foobar(x: T) {}
是传值,还是传地址?c++ 和 rust 给出了不同的处理办法:
- c++ 认为是传值,采用 copy 方式把数据传递过去。优点是 int 和 string 两种不同的变量的语法、语义都是一致的。缺点是存在效率问题,c++为了保持一致性的用法,引入了很多辅助机制补救,例如只读引用、右值引用等一大坨语法。
- rust 则不区分什么传值、传地址,它只负责把栈空间的数据传递给函数。i32 变量的栈空间数据就是它的全部,等于说是传值(copy 传值)。但是,String 变量的栈空间只是个 struct {addr, size} 这样的胖指针,于是上面的调用就等于是传地址(move 传值)。优点是,执行效率高。缺点是,不同类型的变量语义解释不一致,为判断一个类型是不是传值的,编译器需要判断它是否实现 trait Copy。rust 中一旦采用 move 移动传值,变量的所有权就转移了。
相比之下,fn (x: T) 这样的参数,c++ 和 rust 各自的特点如下:
- c++在维护函数参数传递的语法、语义一致性方面做得不错,使得各种类型变量在传参时对应的语法形式和语义解释是完全一致的。但是针对复杂数据类型,需要程序员做许多额外的工作。比如:复制构造、移动构造、左值引用、右值引用、常量引用等,足以干倒一大批程序员。
- rust 解决方法更接近机器实现的底层。利用 trait Copy 把变量分成了两类,可以复制的和不可复制的。i32 这样的就是可复制的,String 这样的就是不可复制的。可复制变量数据全在栈空间,通过 copy 传参(数据);不可复制的变量栈上保存了数据在堆上的地址和大小信息,通过 move 传参(地址和大小),同时实现所有权转移。
可以看出,trait Copy 类型的变量是不存在所有权问题的,因为他们的值只能在不同的变量之间 copy。只有那些没有实现 trait Copy 的变量,才需要处理所有权问题,因为他们在堆空间保存的数据通过指针在不同变量之间 move。
3.2 计算式中的所有权转移
计算式和函数差不多,也存在类似的问题。下面的代码编译不通过,错误信息我写在注释了,大家自行研究原因。
fn main() {
let x = String::from("hello");
//- move occurs because `x` has type `String`, which does not implement the `Copy` trait
let y = x + "a";
// ------- `x` moved due to usage in operator
println!("{}", x);
// ^ value borrowed here after move
}
真他妈的神奇,x 的数据让别人看了一眼,所有权就 move 了!
3.3 所有权从函数返回
下面程序中的函数 foobar 的 return 语句把变量 result 的所有权返给了 main 函数的变量 x。
fn foobar() -> String {
let result = String::from("hello");
return result;
}
fn main() {
let x = foobar();
println!("{}", x);
}
rust 的浅复制传递变量的值,效率很高。如果 c++ 采用传值 copy 方式返回结果,折腾死了:在 foobar 函数内部构造局部变量 result,给 result 赋值,在 main 函数构造 x,把 result 的值复制过去,再析构 result。
但这并没结束,c++ 的设计者也不是吃素的。更多精彩的设计,我们继续往下看。
4. c++ 引用 vs rust 借用
4.1 c++ 引用机制
c++ 设计了一个引用机制,我们先看下面的代码。
#include <string>
using namespace std;
void foobar_r(const string &x){}
void foobar_rw(string &x){}
void foobar_c(string *x){}
void main(){
string x = "hello";
foobar_r(x);
foobar_rw(x);
foobar_c(&x);
}
- main 函数中的 foobar_r(x) 调用,和 rust 的实现完全一样,实际上指传递了 x 的地址。这是一个常量(只读)引用机制。
- foobar_rw(x) 调用,和上面一样,但是允许修改变量的值。
- foobar_c(&x) 调用,和下面要介绍的 rust 的借用机制一样。可以说 rust 更多地借鉴了 c 语言中的语法,算是一个高级版的 c 语言。
4.2 rust 借用机制
下面的函数 foobar 的参数 x 采用了借用机制实现,foobar(&v) 调用只是借用的 v 的数据,并没有获得所有权,foobar 结束时不负责销毁所借用的数据。因此,v 的生命周期可以一致延续到后面的语句。
fn foobar(x: &String) {}
fn main() {
let v = String::from("hello");
foobar(&v);
println!("{}", v);
}
5. rust 中变量所有权的小结
rust 中未实现 trait Copy 的数据类型,在函数传参、计算式中,无法复制其数据,只能传递其指针。所谓的所有权转移,就是数据指针的转移。所有权在谁那里,谁就负责销毁。
为了验证我的想法,写了下面的测试:
struct T {
a: String,
b: String,
}r
fn foo(x: String){}
fn bar(x: String){}
fn kao(y: T){}
fn main() {
let v: T = T{a: String::from("a"), b: String::from("b")};
foo(v.a); // ok
bar(v.b); // ok
foo(v.a); // error, 已经被 move
kao(v); // error, 数据已经被部分 move
bar(v.b); // error, 已经被 move
kao(v); // error, 分量已经被 move
}
6. 结论
rust 把变量分成两种类型,一种类型的数据大小在编译阶段就可以确定,比如 i32、u32 等等。还有一种数据类型,其数据分成两部分,第一部分的数据大小在编译阶段是确定的,而一部分数据大小编译阶段不确定,数据只能保存在堆上。
为便于理解,我把 rust 的数据存储格式形象地表示为 data = key + more
。也就是说,完整的数据包括你银行保险柜的钥匙 key 和在银行保险柜了保存的数据 more,有了钥匙 key 就可以打开保险柜读取和修改信息 more。
- i32 这种类型只有 key,没有 more,实现了 trait Copy。也就是说,i32 的全部信息都在 key 上,拿到 key 就拿到了全部数据。优点是,你的 key 信息复制给了别人,别人也改不了你的 key,你自己的 key 永远不会让别人偷走。
- String 同时包括 key + more,key 的信息复制给别人了,等于说 more 的控制权你也丢了,因为别人可以拿着复制的钥匙去银行打开保险柜,然后随意处理你的数据。因此,如果有 more 这部分数据,key 的信息让别人复制了,银行就立即把你的帐户移交给新人了,你对数据的所有权就丢失了。因为,rust 只允许数据被一个作用域拥有。
对于普通的非引用传递数据,rust 总是简单地把 key 传递给别人,如果存在数据 more,就等于把数据的所有权移交给别人了,如果不存在数据 more,就等于做了一个简单的复制,不存在所有权转移问题。
仔细分析 rust 的实现机制,会发现,rust 是一个更优秀的 c 语言,而不是一个变种的 c++。
原创不易,如有帮助,敬请点暂、关注、收藏,谢谢支持!