C/C++程序员视角下的Rust所有权机制
前言
题图:Kaiserreich 壁纸
一师因一僧问曰:“如何是道?”
师曰:“正眼前是道。”
“如何我不自见?”
“汝自虑故。”
“师知之否?”
师曰:“汝但见二分:言‘我不自见’、‘师见之’,汝目障矣。”
“无我无你,可得见否?”
“无我无你,谁欲见之?”
在控制变量/对象生命周期防止泄漏上,C++ 基于 C 延伸出了移动语义和智能指针/内存池 GC 这一套方案。然而 Rust 却给出了另一套令人耳目一新的方案,借助编译期静态分析创造了一个虽然有很多限制但是完全“安全”的世界。为我们揭开了一面放弃部分编程自由换来更好的编译期优化的全新 tradeoff。本文旨在为C/C++ 程序员介绍Rust的所有权机制。不论读者了解Rust与否都可以愉快的阅读这篇文章,一起思考安全和自由的边界。
从一个 C++函数开始
假设我们有一个交换两个变量的函数:
1 | void swap(int& a,int& b){ |
不考虑变量溢出的情况下,表面上看这个用来交换两个变量的函数实现正确了,但是遇到下面这种情况就会交换失败:
1 | int a=2; |
这暗示了我们在写 C/C++程序时要随时考虑两个指针/引用是否指向一个变量,否则就会出现一些奇怪的问题。
同样,这也限制了编译器的优化:
1 | void foo(int* a,int *b){ |
因为刚才提到的和可能指向一个变量的问题,编译器无法将两次*a
优化成一次访存,即编译成这样:
1 | void foo(int* a,int *b){ |
这时候编译器要么只能笨拙的每次解引用都访一次存,要么还得加个判断,无论怎么样都必须引入一些额外的复杂度。
正应如此,C 有一个修饰指针类型的 restrict 类型限定关键词来解决这个问题。C++因为本身的复杂性并没有继承 C 的 restrict,这超出了本文的范围,感兴趣的朋友可以去查找其他资料了解。
Rust 来救场
我们来重新审视刚才遇到的问题,编译器和我们之所以怕两个指针指向同一个值,本质上是我们害怕修改一个指针指向的值会改变另一个指针指向的值。因此一个指针是“安全”的当且仅当
- 和所有指向的指针都不会修改——所有指针和只读
- 只有这一个指针指向,且修改只有从指针修改——指针单独读写
这两条规则本文称为指针的安全规则。
这也是 restrict 的语义:
“在每个声明声明了 restrict 指针 P 的块(典型例子是函数体的执行,其中 P 为参数)中,若某个对象可由 P (直接或间接)访问的对象会被任何手段修改,则该块中所有对该对象(读或写)的访问,都必须经由 P 出现,否则行为未定义”
Rust 的所有权机制也正是基于指针的安全规则延伸而来,但在我们介绍 Rust 如何遵守指针的安全规则前,我们先得进行一些铺垫。
move-only 和默认 const 的世界
不同于 C/C++,默认情况下 Rust 的类型都是 move-only 的,而且这个move
语义是在赋值时自动完成的
1 | let a=String::from("Hello"); |
跟 C++的以下代码类似
1 | const auto a=std::string("Hello"); |
不同的是在 C++中,这段代码后 a 还是一个有效(valid)的状态,你仍然可以调用a.size()
并期望返回 0。正因如此,虽然表面上 a 已经移动给 b,a(貌似)已经不占据任何堆空间,编译器还是在作用域结束时添加了 a 的析构函数;而在 Rust 中,移动后 a 已经是一个无效状态了,此时如果继续访问a.len()
会直接报编译错误。因此只需要给 b 添加析构函数即可。
当然,之后 a 可以被再次赋值变成有效状态。
你或许注意到了 C++代码中的 const,在 Rust 中默认所有变量都是 const 的,如果要取消 const 限定得加mut关键字:
1 | let mut a=String::from("Hello"); |
跟 C++的以下代码类似
1 | auto a=std::string("Hello"); |
跟 C++ 不同的是,Rust 中类型为T
的变量和类型为mut T
的变量可以互相移动——即取消mut
限定。而 C++ 你不可以将类型为const T
移动的变量到类型为T
的变量。这也体现了两者的不同——C++是代码保证移动语义,而 Rust 是语言保证移动语义。
正是因为语言保证了移动语义,静态分析变量的生命周期就变得可行,变量的生命周期也可以变得尽可能小了:
1 | { |
而 C++只能在作用域结束时添加析构函数
1 | { |
引用
在“安全”的Rust中,引用跟其他非C/C++语言类似——引用无法参与计算,你不能取一个引用添加一个偏移量算出地址。这也是为了编译器能够进行静态分析。
借用和归还
在 Rust 中,引用更像 C 的指针而不是 C++的引用。考虑上面的指针的安全规则,我们必须为引用分成两类
- 不可变引用
&T
- 可变引用
&mut T
而从变量到引用,Rust 中需要显式取地址,Rust 为这个对于 C/C++程序员来说看起来非常自然的过程定义了名字“借用”:
1 | { |
之所以要定义借用,是因为借用后被借用变量会失效。避免出现以下情况破坏指针的安全规则。
1 | let a=String::from("Hello"); |
既然有借用,那就有归还。在最后一个引用的生命周期结束时,被借用变量恢复有效。
1 | let a=String::from("Hello"); |
移动不了的引用变量
为了确保引用能够成功归还,无法根据一个引用移动变量。因此交换两个变量在不调用unsafe过程的情况下只能折中一下:
1 | fn swap(a : &mut i32,b : &mut i32){ |
引用的相互转换
考虑指针的安全规则,引用的相互转换也就变得自然了
FROM\TO | &mut T | &T |
---|---|---|
&mut T | 移动语义 | 移动语义 |
&T | 无法转换 | 复制语义 |
这里的移动和复制是相对于引用本身而言的,不是指向的变量本身的移动和复制。
引用的生命周期
对于变量和引用,不同生命周期意味着不同行为:
分配 | 回收 | |
---|---|---|
T/mut T | 构造 | 析构 |
&T/&mut T | 借用 | (最后一个引用时)归还 |
考虑到上述的借用和归还,那么引用的生命周期必须被引用变量的生命周期包含。
1 | let b:&mut String; |
多重引用和可变引用
这就是为什么说Rust的引用更像C的指针而不是C++的引用
1 | let mut a1=String::from("a1"); |
等价于以下C++代码:
1 | string a1 = string("a1"); |
函数?
讲到这里一切都很好,但是遇到函数调用就失效了。因为函数能轻松返回一个引用,然而我们对它指向的变量分文不知。上述的各种分析也就落了空。C++曾经也倒在这里:
1 | //...in namespace std |
我们该对这个函数进行怎样的修改才能继续上面的分析?
1 | fn min(a: &i32,b: &i32)->&i32{ |
我们发现返回的引用是动态的,只能在运行期确定其所有者。所以编译期间干脆这么处理——调用函数时持续借用a,b所指向的变量,在由返回值延伸的最后一个引用生命周期结束时同时归还a,b。
Rust利用泛型语法,添加了一个特殊的泛型参数用来解决这个问题。这种语法叫生命周期标注
1 | fn min<'a>(a : &'a String,b : &'a String)->&'a String{ |
这么标注的意思是编译器认为返回的引用是同时借用了a和b,也因此其生命周期一定同时在a,b生命周期内。再加上Rust没有C++ 的rvalue赋给const&
时生命周期延长的特性。我们就解决了C++ 遇到的问题。
1 | let mut a1=String::from("a1"); |
遇到明确返回引用的来源时,那也可以直接只在来源参数上标注
1 | fn foo<'a>(a : &'a mut String,b : &mut String,c : String)->&'a mut String{ |
只能Unsafe访问的可变全局变量
可变全局变量天生和上述机制冲突——引用的借用和返还完全失效,任何地方都可以不看函数参数可变借用可变全局变量,生命周期的静态分析根本不能进行。
所以可变全局变量只能Unsafe访问。
那么,古尔丹,代价是什么呢?
学习到这,我们发现了为了“安全”得舍弃很多东西:
- 无法使用可变全局变量
- 无法移动一个引用指向的变量
- 无法创建互相引用的数据结构
- 等等等…
这直接导致了一些在C/C++里常见的操作必须得“不安全”实现:
- 可变静态变量
- std::exchange和std::swap
- 双向链表等各自互相引用的数据结构
- 等等等等…
为了避免这些问题,Rust会告诉你使用类似shared_ptr<T>
的Rc<T>
等等…那我为什么不直接用C++的智能指针呢…
Next Dream…
或许,编译期分析能走的更远…
或许,我们可以创建一个“safe”的,语法更现代的C++…
就叫它cppfront吧!