C、C++、Rust、Go的赋值和生命周期管理

柏舟   新冠4年 07-11

在编写程序时,赋值操作一般会涉及到3种情况:

  1. 通过值拷贝传递,例如:整数、结构体赋值和传参。这时改变值的内容不会影响上下文。
  2. 引用传递,此时改变内部的字段会影响上下文。
  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的编译器帮助实现了引用的生命周期管理:

表面上看是解决了内存泄露的问题,实际上是强制用一种严格的办法管理资源对象。我认为Rust写出比C++还快的代码很正常:

  1. 虽然仍然需要区分对象到底是实体还是值对象,但是引用的传递过程是透明的,整个过程更加简单,实现深浅拷贝的心智负担很小。C++得非常清楚内部的赋值方式,比如copy函数实现方式,编译器默认行为,copy assignment行为,Rust可以通过宏的方式指定是否自动生成Clone函数。
  2. 默认行为开销是最小的,比如值传递默认右值引用传递。
  3. 它最厉害的地方是编译器自动分析,能保证多线程资源共享的安全。像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只解决了变量内存的分配,但是其它资源对象仍然要手动分配:

理解生命周期和作用域范围这些概念需要学习成本,而GC会自动引用计数和逃逸分析。你不需要关心作用域和堆栈,但是GC分析不了锁、句柄这些的作用域。

智能指针

资源管理的复杂度体现在:

以智能指针为例,智能指针使用RAII自动管理生命周期,但是提供了不同的管理策略:

指针 适用对象 功能
unique_ptr 实体 独占的所有权,可以安全更改内部
shared_ptr和weak_ptr 共享的资源对象,或大内存值 提供共享的所有权,引用计数,资源对象还需要加锁
Copy on Write 如果只读则内部存储为引用,如果修改则会自动copy一份

小结:赋值本身是资源管理

我觉得新手学这些手动管理内存的语言是很困难的,因为赋值操作看起来很简单,但是实际上牵扯到标量、结构体,堆栈,生命周期。函数的参数又有值和引用类型,以值类型为例表面上看class是在栈上面,但是内部字段的指针又指向堆上面,函数退出的时候,哪些在栈上,不需要管,哪些在堆上,需要析构,我觉得对于新手不是特别友好。

而且不同语言的赋值有区别,比如Go诡异的数组、Rust值传递类似于右值引用、swift数组是copy on write等等。

就算是GC语言,仍然需要理解引用,理解深拷贝和浅拷贝在内存中是怎么分配的,此外GC不能代替资源管理,仍然需要人脑模拟生命周期。

一个比较好的实践是尽量保持不变性,在需要修改时创建新的。这样就不需要纠结变量和变量内部的字段到底是值还是引用,因为都不会变。但是有些资源如队列、数据库、网络、文件句柄等等就是没法复制,最终还是要理解生命周期和资源管理。

简单来说,赋值操作本身就是你如何管理计算资源,你不仅需要理解编程语言赋值的复杂性,你也需要理解资源管理的复杂性。

在这里考考大家:

主要参考文献

  1. Effective C++
  2. C++ Primer Plus