C、C++、Rust、Go的赋值和生命周期管理
柏舟 新冠4年 07-11
在编写程序时,赋值操作一般会涉及到3种情况:
- 通过值拷贝传递,例如:整数、结构体赋值和传参。这时改变值的内容不会影响上下文。
- 引用传递,此时改变内部的字段会影响上下文。
- 深拷贝和浅拷贝。
C语言赋值:值拷贝
当我学习了其他语言再回过来思考C语言时,我发现C语言真的比想象中要强大。C语言的“=”或者函数参数传递时赋值只有一种,无论是结构体还是指针都是值拷贝。表面上看指针和其它标量、结构体不同,但实际上都是值拷贝。编写程序的时候,头脑里要有堆栈,哪些值是在栈分配的,哪些是在堆上分配的,你很容易根据需求实现引用传递和拷贝,因为值拷贝的过程是简单而又透明的。
C++赋值
C++的赋值操作非常复杂,首先一个class创建时就会默认生成copy构造函数、copy assignment操作符和析构函数。(C++11还会创建默认的右值引用的copy构造函数)
class A{
private:
std::string name;
public:
A() { std::cout << "default" << std::endl; } // default
A(const A& a) { std::cout << "copy" << std::endl; } // copy
A& operator=(const A& a) {
std::cout << "assign" << std::endl;
return *this;
} // 赋值
~A() { std::cout << "bye" << std::endl; }
}
A process1(A a){
std::cout << "process1" << std::endl;
return a;
}
int main() {
A a; // default
A a1 = process1(a); // assign and copy
std::cout << "main finish" << std::endl;
return 0;
}
// 打印调用顺序
// default
// copy -- process1参数传递时调用
// process1
// copy -- 返回结果传递时调用
// bye -- process1的a的析构
// main finish
// bye
// bye
自动生成的copy构造函数会递归的调用字段的copy构造函数,比如标准库的string是深拷贝的。C++允许结构体中使用引用,此时需要手动生成copy构造函数。当项目足够大时,class层层嵌套,可能有的字段是深拷贝,有的字段是浅拷贝,所以,编写C++很容易出现困惑,经常需要看源码。
A process2(const A& a){
return a; // 隐式转换,调用copy
}
A process3(A&& a){
return a;
}
A a2 = process2(a);
A a3 = process3(A());
使用引用可以减少不必要的拷贝。process1传递a时会先调用copy构造函数,将对象分配在栈上,当函数退出返回时,栈释放,又会重新调用一次copy构造函数将结果赋给外围的作用域,最后调用析构函数销毁process1内部的a。而process2,调用时省去了第一次copy。process3与process2类似,使用右值引用只会转移内部字段的所有权,不会重新复制一份,但是仍是值传递,传递使用右值引用的构造函数,转移后,原来的变量所有权失效。
注:我已经很久没写C++了,很多细节可能不对,比如process2返回结果是编译器可能会自动优化,只调用一次copy,不调用assign。而且C++有很多隐式转换,比如copy构造函数,它可能会默认重载=。C++在函数重载和类型转换上的隐式转换非常复杂,没有任何错误提示,有的时候会有稀奇古怪的问题,再加上错误信息非常有限,很难debug。
Rust
Rust的赋值操作很有特色,总体上它类似于C++,但是它抽象出了几种资源管理方式,分为不变引用,可变引用和值。Rust的编译器帮助实现了引用的生命周期管理:
- 不变引用:在作用域范围内可以有多个不变引用;
- 可变引用:在生命周期标注内,只能有一个可变引用;
- 值:与C++不同,当值作为参数时默认使用右值传递的方法,移交所有权。
表面上看是解决了内存泄露的问题,实际上是强制用一种严格的办法管理资源对象。我认为Rust写出比C++还快的代码很正常:
- 虽然仍然需要区分对象到底是实体还是值对象,但是引用的传递过程是透明的,整个过程更加简单,实现深浅拷贝的心智负担很小。C++得非常清楚内部的赋值方式,比如copy函数实现方式,编译器默认行为,copy assignment行为,Rust可以通过宏的方式指定是否自动生成Clone函数。
- 默认行为开销是最小的,比如值传递默认右值引用传递。
- 它最厉害的地方是编译器自动分析,能保证多线程资源共享的安全。像Go等GC语言的共享变量仍需要程序员自己加锁管理,但是Rust能够静态分析出必须加锁的地方。C++必须使用人脑模拟变量的生命周期,很多时候分析不清楚,不得不拷贝以保证线程安全。
Go赋值和切片魔法
Go语言的赋值也很简单,只有值拷贝。但是Go拷贝时内部有黑魔法,比如Go的数组:
var array []int
// 它不是一个指针,而大致是这个结构
type Array struct{
pointer unsafe.Pointer
len int
cup int
}
当你值传递数组时就会出现潜在的问题,比如:
func AppendArray(array []int) {
array1 := append(array, 3)
}
func TestArray(t *testing.T) {
var array = []int{1, 2}
array1 := AppendArray(array)
array[0] = 3
fmt.Println(array) // [3 2]
fmt.Println(array1) // [1 2 3]
var array2 = make([]int, 1, 2) // len: 1, cap: 2
array2[0] = 1
array3 := AppendArray(array2)
array2[0] = 3
fmt.Println(array2) // [3]
fmt.Println(array3) // [3 3]
}
这是因为在调用append的时候会判断容量(cap)够不够,如果容量不够就会分配新的内存替代原有的指针;如果容量足够就会直接在后面追加。由于函数传递的是值,所以一旦在内部进行了append和修改操作,既可能影响外部,也可能不影响。
这是因为Golang内部实现的问题:
func AppendByte(slice []byte, data ...byte) []byte {
m := len(slice)
n := m + len(data)
if n > cap(slice) { // if necessary, reallocate
// allocate double what's needed, for future growth.
newSlice := make([]byte, (n+1)*2)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:n]
copy(slice[m:n], data)
return slice
}
Golang数组传递既不是值拷贝,也不是引用,因为值拷贝会重新拷贝内部pointer的值,而引用在修改时会改变pointer,len和cap。我只能说Golang的这个实现极其愚蠢,因为它的副作用是不确定的。所以每次传递数组的时候就要好好想想函数也没有副作用,是值传递数组还是传递指针*[]T。
虽然Golang有GC,但是你会发现在使用slice,append和值传递的时候,你仍然需要关心到底是怎么拷贝的。除此之外,如果分配大数组,取一个很小的slice,大数组的pointer会一直不释放,导致内存泄露。所以不是有了GC就可以乱写,尤其是Golang这种不优雅的语言更要小心。
生命周期
Golang的GC虽然不需要手动管理内存,但是例如文件、网络句柄等资源管理仍然需要使用Close,这与手动管理内存又有什么区别吗?
从这个角度看C++的RAII真的十分强大,使用RAII定义资源释放的析构函数,把资源挂载在对应生命周期的对象上,当对象析构时资源自动释放了。不像Golang,只能在函数内部使用defer,一旦需要在多个过程中管理生命周期就非常麻烦。
以一个Rust编写操作系统rCore管理内存页为例子:
/// manage a frame which has the same lifecycle as the tracker
pub struct FrameTracker {
pub ppn: PhysPageNum,
}
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}
每次页表PageTable分配内存页时将内存页append进frames,当释放PageTable时,自动递归调用Vec<FrameTracker>的析构函数释放内存页。是不是突然发现内存、锁、句柄等等资源的管理并没有本质的不同,GC只解决了变量内存的分配,但是其它资源对象仍然要手动分配:
- C每次都要手动free最难用;
- Go可以用defer,但是生命周期跨越多个函数就跟C一样;
- 有RAII机制的C++和Rust很好用。
理解生命周期和作用域范围这些概念需要学习成本,而GC会自动引用计数和逃逸分析。你不需要关心作用域和堆栈,但是GC分析不了锁、句柄这些的作用域。
智能指针
资源管理的复杂度体现在:
- 生命周期、作用域;
- 实体和值对象的区别;
- 当对象占有大内存时如何管理。
以智能指针为例,智能指针使用RAII自动管理生命周期,但是提供了不同的管理策略:
指针 | 适用对象 | 功能 |
---|---|---|
unique_ptr | 实体 | 独占的所有权,可以安全更改内部 |
shared_ptr和weak_ptr | 共享的资源对象,或大内存值 | 提供共享的所有权,引用计数,资源对象还需要加锁 |
Copy on Write | 值 | 如果只读则内部存储为引用,如果修改则会自动copy一份 |
小结:赋值本身是资源管理
我觉得新手学这些手动管理内存的语言是很困难的,因为赋值操作看起来很简单,但是实际上牵扯到标量、结构体,堆栈,生命周期。函数的参数又有值和引用类型,以值类型为例表面上看class是在栈上面,但是内部字段的指针又指向堆上面,函数退出的时候,哪些在栈上,不需要管,哪些在堆上,需要析构,我觉得对于新手不是特别友好。
而且不同语言的赋值有区别,比如Go诡异的数组、Rust值传递类似于右值引用、swift数组是copy on write等等。
就算是GC语言,仍然需要理解引用,理解深拷贝和浅拷贝在内存中是怎么分配的,此外GC不能代替资源管理,仍然需要人脑模拟生命周期。
一个比较好的实践是尽量保持不变性,在需要修改时创建新的。这样就不需要纠结变量和变量内部的字段到底是值还是引用,因为都不会变。但是有些资源如队列、数据库、网络、文件句柄等等就是没法复制,最终还是要理解生命周期和资源管理。
简单来说,赋值操作本身就是你如何管理计算资源,你不仅需要理解编程语言赋值的复杂性,你也需要理解资源管理的复杂性。
在这里考考大家:
- 在全局变量分配大数组,分配在堆、栈还是其他地方?与全局的静态变量有什么区别?.text .data .bss是什么东西?
- copy on write在多线程和promise future上如何工作的?
主要参考文献
- Effective C++
- C++ Primer Plus