C++ 的五个普遍误解(2):垃圾回收
程序员文章站
2022-06-11 09:52:26
...
每一个误解,都需要一大篇文章,甚至一本书来澄清,但是这里我的目标很简单,就是抛出问题,并简明地陈述我的原因。
前两个误解在我的第一篇文中呈现。
4. 误解3:“对可靠的软件,你需要垃圾回收”
在回收不再使用的内存上,垃圾回收做的很好,但是并不完美。它并非灵丹妙药。因为内存可以被间接地引用,并且很多资源并不是普通内存。考虑:
Filter的构造函数打开了两个文件。之后,Filter从它的输入文件读取数据,执行一些任务,然后输出到输出文件。任务与Filter直接有关,可能通过一个lambda提供,或者通过一个函数返回重载了虚方法的继承类来提供;这些细节在资源管理的讨论中并不重要。我们可以这样创建Filter:
从资源管理的观点来看,这里的问题在于如何保证关闭被打开的文件,以及回收这两个流对象的相关资源,以供后续重复使用。
对于依赖垃圾回收的语言和系统,常规的解决方法是消除delete(它很容易被遗忘,导致泄漏)和析构函数(因为支持垃圾回收的语言很少有析构函数,而最好避免使用“finalizers”,因为它在逻辑上容易被取巧,并经常损坏性能)。内存回收器能够回收所有内存,但是我们需要用户手动(代码)关闭文件,以及释放与流相关的非内存资源(如锁)。因此,内存是自动(此例中很完美)回收的,但是需要手动管理其他资源,从而存在错误和泄露的可能性。
C++中常用和推荐的方法是使用析构函数,来保证资源被回收。典型的,在此例和通用技术中,这类资源在构造器中申请,并遵循有着笨拙名字的“资源申请即初始化”(RAII)原则。在user()中,flt的析构函数隐式地调用了流is和os的析构函数。这些析构函数依次关闭文件并释放流相关的资源。delete对*p做同样的操作。
有经验的现代C++11用户会注意到,user()相当笨拙并容易出错。这样会更好一些:
现在当user()退出时,*p将被隐式地释放。程序员不会忘记这么做。unique_ptr是标准库类,它被设计用来在没有运行时(RTTI)或者空间开销的前提下,增强内置“裸“指针的资源释放。
然而,我们仍然可以看到new,这个解决方案有点啰嗦(Filter类型重复了两次),并且将普通指针构造(通过new)和智能指针(这里是unique_ptr)分离开阻止了一些有效的优化。我们可以使用C++14中的辅助函数make_unique来改进,它构造一个指定类型的对象,并返回一个unique_ptr:
Unless we really needed the second Filter to have pointer semantics (which is unlikely) this would be better still:
除非我们在语法上真正地需要第二个Filter指针(这不太可能),否则这样会更好:
最后一个版本比最初的代码更简短,更简单,更清晰,更快。
但是Filter的析构函数做什么?它释放Filter拥有的资源;即,它关闭文件(通过触发它们的析构函数)。实际上,这是隐式完成的,因此除非有其他需要,我们可以忽略Filter析构函数的显式声明,让编译器来处理它。因此,我需要编写的只有:
这比你在多数垃圾回收语言(如Java或C#)中写的代码更简单;并且对那些健忘的程序员,它不会导致泄漏。它也比其他方案(不需要使用free/dynamic,也不需要运行垃圾回收器)更快。典型的,相对与手动方式,RAII也缩短了资源的生命周期。
这是我理想的资源管理方式。它不单单处理内存,同时也处理通用(非内存)资源,例如文件句柄,线程句柄和锁。但是这就够了吗?怎么处理需要从一个函数传递到另一个函数的对象?那些没有明显单独拥有者的对象呢?
4.1传递拥有关系:move
让我们先来看一看把对象从一个代码块传递到另一个代码块的问题。关键问题是,在不复制或者错误使用指针导致严重性能问题的前提下,如何从一个代码块中得到大量信息。使用指针的传统方式是:
现在,谁有责任来释放对象呢?在这个简单的例子里,明显是make_X()的调用者,但是通常情况下答案并不是显而易见的。假如make_X()为了最小化申请负荷而保存了对象的缓存呢?假如user()把指针传递给了其他如other_user()函数呢?潜在的可能性很多,在这类程序中的泄露并非罕见。
我可能会使用一个shared_ptr或者unique_ptr,来明确表明对创建对象的拥有关系。例如:
但是为什么要使用一个指针(不管是否智能)呢?通常,我不想使用指针;并且,指针会导致从对象的常规使用中分心。例如,一个矩阵求和函数,根据两个参数创建了一个新的对象(求和结果),但是返回一个指针会导致非常奇怪的代码:
这里需要使用*操作符来得到求和结果,否则得到的是指向结果的指针。在很多情况下,我真正需要的是一个对象,而不是指向对象的指针。很多时候,我可以容易地做到。尤其是,复制一个小的对象很快,我不想使用指针:
从另一方面来说,一个包含了很多数据的对象,一般会处理这么多的数据。考虑istream,string,vector,list和thread。它们都只包含了少数几个字节的数据,来保证潜在的大量数据访问。再次考虑矩阵求和。我们需要的是
我们可以轻松的做到。
默认情况下,它将res的元素复制给r,但是因为res即将被销毁,保存元素的内存即将被释放,因此这里没有必要复制:我们可以“窃取”元素。自从C++诞生以来,任何人都可能这么做,并且很多人确实这么做了。但是这是代码实现的技巧,而且这项技术并不好理解。C++11直接支持“窃取表示法(stealing the representation)”,通过move操作传递一个句柄的拥有关系。考虑一个简单的2维double类型的矩阵:
通过引用参数(&),可以识别一个复制操作。类似地,通过右值引用(&&)参数,可以识别一个move操作。move操作的目的是“窃取”对象表现,并留下一个“空对象”。对Matrix,意味着这样的情形:
就是这样!当编译器看到返回值res,它意识到res即将被销毁。即,在函数返回后res将不再被使用。因此它使用了一个move构造函数来传递返回值,而不是复制构造函数。特殊的,对于
在operator+()内部的res变成了空——析构函数将空执行一次——然后r拥有了res的元素。我们成功地从函数的结果中取得了元素——可能是数M字节的内存——并存入调用函数的变量中。我们用最小的代价实现了(可能是4个字的赋值)。
老练的C++用户指出,一个好的编译器能够完全消除返回值复制操作(这个例子中是,消除掉4个字的赋值和析构函数调用)。然而,这是依赖于实现的,我不喜欢我的基本编程技术的性能依赖于独立编译器的聪明程度。更进一步,一个能够消除复制的编译器,也能够轻易的消除move。这里我们所拥有的,是一个简单、可靠和通用的方式,能够消除从一个代码块移动大量信息到另一个块的复杂度和代价。
通常,我们甚至不需要定义所有这些赋值和移动操作。如果一个类由拥有特定表现的成员组成,我们可以简单地依赖编译器自动生成的默认操作。考虑:
这个版本的Matrix和之前版本的表现相同,除了它处理错误稍好一些,以及稍大一些(一个vector通常是3个字)。
不是句柄的对象怎么处理呢?如果它们很小,像int,或者complex,不用担心。否则,把它们改成句柄,或者使用“智能”指针返回,如unique_ptr和shared_ptr。不要和“裸”操作new和delete混用。
不幸的是,类似我上面例子中的Matrix类不是ISO C++标准库的一部分,但是还是可以找到的(开源或者商业)。例如,在网上搜索“Origin Matrix Sutton”,阅读我The C++ Programming Language (Fourth Edition)的第29章,里面有如何设计类似矩阵类的讨论。
4.2 共享拥有关系:shared_ptr
在关于垃圾回收的讨论中,通常会注意到一个现象,即不是每一个对象都有唯一的拥有者。这意味着,我们必须确保当最后一个引用消除后,才能销毁/释放这个对象。在这个模型中,我们必须有一个机制,来保证当对象的最后一个拥有者销毁时,销毁这个对象。即,我们需要一种共享的拥有关系形式。假设我们有一个同步的队列,sync_queue,用作任务之间的通信。生产者和消费者都拥有一个指向sync_queue的指针:
我假定task1,task2,iqueue和oqueue已经在其他地方定义好了;很抱歉让线程的生存周期比创建线程的域更长(使用detatch())。你可能会想到多任务处理中的管道和同步队列。然而,这里我只对一个问题感兴趣:“谁来释放startup()中创建的sync_queue?”。如前面所写,只有一个正确答案:“最后使用sync_queue的那个线程”。这是一个刺激产生垃圾回收的经典情形。垃圾回收的最初形式是引用计数:保持对象被使用的计数,当计数降为0时,释放对象。今天很多语言都依赖于这种想法,而C++11通过shared_ptr的形式支持它。例子变成这样:
这样当task1和task2析构时,会销毁它们的shared_ptr(在良好的设计中会隐式地调用),并且最后一个析构的任务会销毁sync_queue。
它很简单并高效。它并不包含需要复杂运行时系统的垃圾回收器。更重要的是,它不仅仅回收sync_queue关联的内存资源,它同时回收内置在sync_queue中管理两个任务线程同步的对象(互斥,锁,或其他)。我们这里做到的,仍然不仅仅是内存管理,而是通用资源管理。“隐藏的”同步对象也被处理了,和前面例子中处理文件句柄和流缓冲区一样。
在围绕任务的某些范围内,我们可以尝试引入一个唯一的拥有者,从而不使用shared_ptr;但是这样做通常不简单。因此C++11同时提供了unique_ptr(对唯一拥有关系)和shared_ptr(对共享拥有关系)。
4.3 类型安全
我刚刚只提到了和资源管理有关联的垃圾回收。它还在类型安全中扮演一个角色。只要我们有显式的delete操作,它就可能被错误使用。例如:
不要这样做。裸露的delete非常危险——而且在常用的代码中是不必要的。把delete放到资源管理类的内部,例如string,ostream,thread,unique_ptr和shared_ptr。这样,delete就会和new正确对应,不会出错。
4.4 总结:资源管理理念
对于资源管理,我认为垃圾回收是最后的选择,而不是“解决方案”或者理念:
1. 运用适当的抽象,递归和显式地处理自己拥有的资源。限定对象的作用域会更好。
2. 当你需要使用指针/引用语义时,使用诸如unique_ptr和shared_ptr的“智能指针”,来表明拥有关系。
3. 如果其他都行不通(如,你的代码是一个程序的一部分,而程序中使用了大量不满足语言资源管理和错误处理策略的指针),尝试“手动”处理非内存资源,并内嵌一个保守的垃圾回收器,用它来处理那些几乎不可避免的内存泄露。
这个策略完美吗?不,但它是通用的,并且简单。传统的基于垃圾回收的策略也不完美,并且它们不能直接处理非内存资源。
附言
前两个误解在我的第一篇文中呈现。
4. 误解3:“对可靠的软件,你需要垃圾回收”
在回收不再使用的内存上,垃圾回收做的很好,但是并不完美。它并非灵丹妙药。因为内存可以被间接地引用,并且很多资源并不是普通内存。考虑:
class Filter { // take input from file iname and produce output on file oname public: Filter(const string& iname, const string& oname); // constructor ~Filter(); // destructor // ... private: ifstream is; ofstream os; // ... };
Filter的构造函数打开了两个文件。之后,Filter从它的输入文件读取数据,执行一些任务,然后输出到输出文件。任务与Filter直接有关,可能通过一个lambda提供,或者通过一个函数返回重载了虚方法的继承类来提供;这些细节在资源管理的讨论中并不重要。我们可以这样创建Filter:
void user() { Filter flt {“books”,”authors”}; Filter* p = new Filter{“novels”,”favorites”}; // use flt and *p delete p; }
从资源管理的观点来看,这里的问题在于如何保证关闭被打开的文件,以及回收这两个流对象的相关资源,以供后续重复使用。
对于依赖垃圾回收的语言和系统,常规的解决方法是消除delete(它很容易被遗忘,导致泄漏)和析构函数(因为支持垃圾回收的语言很少有析构函数,而最好避免使用“finalizers”,因为它在逻辑上容易被取巧,并经常损坏性能)。内存回收器能够回收所有内存,但是我们需要用户手动(代码)关闭文件,以及释放与流相关的非内存资源(如锁)。因此,内存是自动(此例中很完美)回收的,但是需要手动管理其他资源,从而存在错误和泄露的可能性。
C++中常用和推荐的方法是使用析构函数,来保证资源被回收。典型的,在此例和通用技术中,这类资源在构造器中申请,并遵循有着笨拙名字的“资源申请即初始化”(RAII)原则。在user()中,flt的析构函数隐式地调用了流is和os的析构函数。这些析构函数依次关闭文件并释放流相关的资源。delete对*p做同样的操作。
有经验的现代C++11用户会注意到,user()相当笨拙并容易出错。这样会更好一些:
void user2() { Filter flt {“books”,”authors”}; unique_ptr<Filter> p {new Filter{“novels”,”favorites”}}; // use flt and *p }
现在当user()退出时,*p将被隐式地释放。程序员不会忘记这么做。unique_ptr是标准库类,它被设计用来在没有运行时(RTTI)或者空间开销的前提下,增强内置“裸“指针的资源释放。
然而,我们仍然可以看到new,这个解决方案有点啰嗦(Filter类型重复了两次),并且将普通指针构造(通过new)和智能指针(这里是unique_ptr)分离开阻止了一些有效的优化。我们可以使用C++14中的辅助函数make_unique来改进,它构造一个指定类型的对象,并返回一个unique_ptr:
void user3() { Filter flt {“books”,”authors”}; auto p = make_unique<Filter>(“novels”,”favorites”); // use flt and *p }
Unless we really needed the second Filter to have pointer semantics (which is unlikely) this would be better still:
除非我们在语法上真正地需要第二个Filter指针(这不太可能),否则这样会更好:
void user3() { Filter flt {“books”,”authors”}; Filter flt2 {“novels”,”favorites”}; // use flt and flt2 }
最后一个版本比最初的代码更简短,更简单,更清晰,更快。
但是Filter的析构函数做什么?它释放Filter拥有的资源;即,它关闭文件(通过触发它们的析构函数)。实际上,这是隐式完成的,因此除非有其他需要,我们可以忽略Filter析构函数的显式声明,让编译器来处理它。因此,我需要编写的只有:
class Filter { // take input from file iname and produce output on file oname public: Filter(const string& iname, const string& oname); // ... private: ifstream is; ofstream os; // ... }; void user3() { Filter flt {“books”,”authors”}; Filter flt2 {“novels”,”favorites”}; // use flt and flt2 }
这比你在多数垃圾回收语言(如Java或C#)中写的代码更简单;并且对那些健忘的程序员,它不会导致泄漏。它也比其他方案(不需要使用free/dynamic,也不需要运行垃圾回收器)更快。典型的,相对与手动方式,RAII也缩短了资源的生命周期。
这是我理想的资源管理方式。它不单单处理内存,同时也处理通用(非内存)资源,例如文件句柄,线程句柄和锁。但是这就够了吗?怎么处理需要从一个函数传递到另一个函数的对象?那些没有明显单独拥有者的对象呢?
4.1传递拥有关系:move
让我们先来看一看把对象从一个代码块传递到另一个代码块的问题。关键问题是,在不复制或者错误使用指针导致严重性能问题的前提下,如何从一个代码块中得到大量信息。使用指针的传统方式是:
X* make_X() { X* p = new X: // ... fill X .. return p; } void user() { X* q = make_X(); // ... use *q ... delete q; }
现在,谁有责任来释放对象呢?在这个简单的例子里,明显是make_X()的调用者,但是通常情况下答案并不是显而易见的。假如make_X()为了最小化申请负荷而保存了对象的缓存呢?假如user()把指针传递给了其他如other_user()函数呢?潜在的可能性很多,在这类程序中的泄露并非罕见。
我可能会使用一个shared_ptr或者unique_ptr,来明确表明对创建对象的拥有关系。例如:
unique_ptr<X> make_X();
但是为什么要使用一个指针(不管是否智能)呢?通常,我不想使用指针;并且,指针会导致从对象的常规使用中分心。例如,一个矩阵求和函数,根据两个参数创建了一个新的对象(求和结果),但是返回一个指针会导致非常奇怪的代码:
unique_prt<Matrix> operator+(const Matrix& a, const Matrix& b); Matrix res = *(a+b);
这里需要使用*操作符来得到求和结果,否则得到的是指向结果的指针。在很多情况下,我真正需要的是一个对象,而不是指向对象的指针。很多时候,我可以容易地做到。尤其是,复制一个小的对象很快,我不想使用指针:
double sqrt(double); // a square root function double s2 = sqrt(2); // get the square root of 2
从另一方面来说,一个包含了很多数据的对象,一般会处理这么多的数据。考虑istream,string,vector,list和thread。它们都只包含了少数几个字节的数据,来保证潜在的大量数据访问。再次考虑矩阵求和。我们需要的是
Matrix operator+(const Matrix& a, const Matrix& b); // return the sum of a and b Matrix r = x+y;
我们可以轻松的做到。
Matrix operator+(const Matrix& a, const Matrix& b) { Matrix res; // ... fill res with element sums ... return res; }
默认情况下,它将res的元素复制给r,但是因为res即将被销毁,保存元素的内存即将被释放,因此这里没有必要复制:我们可以“窃取”元素。自从C++诞生以来,任何人都可能这么做,并且很多人确实这么做了。但是这是代码实现的技巧,而且这项技术并不好理解。C++11直接支持“窃取表示法(stealing the representation)”,通过move操作传递一个句柄的拥有关系。考虑一个简单的2维double类型的矩阵:
class Matrix { double* elem; // pointer to elements int nrow; // number of rows int ncol; // number of columns public: Matrix(int nr, int nc) // constructor: allocate elements :elem{double[nr*nc]}, nrow{nr}, ncol{nc} { for(int i=0; i<nr*nc; ++i) elem[i]=0; // initialize elements } Matrix(const Matrix&); // copy constructor Matrix operator=(const Matrix&); // copy assignment Matrix(Matrix&&); // move constructor Matrix operator=(Matrix&&); // move assignment ~Matrix() { delete[] elem; } // destructor: free the elements // … };
通过引用参数(&),可以识别一个复制操作。类似地,通过右值引用(&&)参数,可以识别一个move操作。move操作的目的是“窃取”对象表现,并留下一个“空对象”。对Matrix,意味着这样的情形:
Matrix::Matrix(Matrix&& a) // move constructor :nrow{a.nrow}, ncol{a.ncol}, elem{a.elem} // “steal” the representation { a.elem = nullptr; // leave “nothing” behind }
就是这样!当编译器看到返回值res,它意识到res即将被销毁。即,在函数返回后res将不再被使用。因此它使用了一个move构造函数来传递返回值,而不是复制构造函数。特殊的,对于
Matrix r = a+b;
在operator+()内部的res变成了空——析构函数将空执行一次——然后r拥有了res的元素。我们成功地从函数的结果中取得了元素——可能是数M字节的内存——并存入调用函数的变量中。我们用最小的代价实现了(可能是4个字的赋值)。
老练的C++用户指出,一个好的编译器能够完全消除返回值复制操作(这个例子中是,消除掉4个字的赋值和析构函数调用)。然而,这是依赖于实现的,我不喜欢我的基本编程技术的性能依赖于独立编译器的聪明程度。更进一步,一个能够消除复制的编译器,也能够轻易的消除move。这里我们所拥有的,是一个简单、可靠和通用的方式,能够消除从一个代码块移动大量信息到另一个块的复杂度和代价。
通常,我们甚至不需要定义所有这些赋值和移动操作。如果一个类由拥有特定表现的成员组成,我们可以简单地依赖编译器自动生成的默认操作。考虑:
class Matrix { vector<double> elem; // elements int nrow; // number of rows int ncol; // number of columns public: Matrix(int nr, int nc) // constructor: allocate elements :elem(nr*nc), nrow{nr}, ncol{nc} { } // ... };
这个版本的Matrix和之前版本的表现相同,除了它处理错误稍好一些,以及稍大一些(一个vector通常是3个字)。
不是句柄的对象怎么处理呢?如果它们很小,像int,或者complex,不用担心。否则,把它们改成句柄,或者使用“智能”指针返回,如unique_ptr和shared_ptr。不要和“裸”操作new和delete混用。
不幸的是,类似我上面例子中的Matrix类不是ISO C++标准库的一部分,但是还是可以找到的(开源或者商业)。例如,在网上搜索“Origin Matrix Sutton”,阅读我The C++ Programming Language (Fourth Edition)的第29章,里面有如何设计类似矩阵类的讨论。
4.2 共享拥有关系:shared_ptr
在关于垃圾回收的讨论中,通常会注意到一个现象,即不是每一个对象都有唯一的拥有者。这意味着,我们必须确保当最后一个引用消除后,才能销毁/释放这个对象。在这个模型中,我们必须有一个机制,来保证当对象的最后一个拥有者销毁时,销毁这个对象。即,我们需要一种共享的拥有关系形式。假设我们有一个同步的队列,sync_queue,用作任务之间的通信。生产者和消费者都拥有一个指向sync_queue的指针:
void startup() { sync_queue* p = new sync_queue{200}; // trouble ahead! thread t1 {task1,iqueue,p}; // task1 reads from *iqueue and writes to *p thread t2 {task2,p,oqueue}; // task2 reads from *p and writes to *oqueue t1.detach(); t2.detach(); }
我假定task1,task2,iqueue和oqueue已经在其他地方定义好了;很抱歉让线程的生存周期比创建线程的域更长(使用detatch())。你可能会想到多任务处理中的管道和同步队列。然而,这里我只对一个问题感兴趣:“谁来释放startup()中创建的sync_queue?”。如前面所写,只有一个正确答案:“最后使用sync_queue的那个线程”。这是一个刺激产生垃圾回收的经典情形。垃圾回收的最初形式是引用计数:保持对象被使用的计数,当计数降为0时,释放对象。今天很多语言都依赖于这种想法,而C++11通过shared_ptr的形式支持它。例子变成这样:
void startup() { auto p = make_shared<sync_queue>(200); // make a sync_queue and return a stared_ptr to it thread t1 {task1,iqueue,p}; // task1 reads from *iqueue and writes to *p thread t2 {task2,p,oqueue}; // task2 reads from *p and writes to *oqueue t1.detach(); t2.detach(); }
这样当task1和task2析构时,会销毁它们的shared_ptr(在良好的设计中会隐式地调用),并且最后一个析构的任务会销毁sync_queue。
它很简单并高效。它并不包含需要复杂运行时系统的垃圾回收器。更重要的是,它不仅仅回收sync_queue关联的内存资源,它同时回收内置在sync_queue中管理两个任务线程同步的对象(互斥,锁,或其他)。我们这里做到的,仍然不仅仅是内存管理,而是通用资源管理。“隐藏的”同步对象也被处理了,和前面例子中处理文件句柄和流缓冲区一样。
在围绕任务的某些范围内,我们可以尝试引入一个唯一的拥有者,从而不使用shared_ptr;但是这样做通常不简单。因此C++11同时提供了unique_ptr(对唯一拥有关系)和shared_ptr(对共享拥有关系)。
4.3 类型安全
我刚刚只提到了和资源管理有关联的垃圾回收。它还在类型安全中扮演一个角色。只要我们有显式的delete操作,它就可能被错误使用。例如:
X* p = new X; X* q = p; delete p; // ... q->do_something(); // the memory that held *p may have been re-used
不要这样做。裸露的delete非常危险——而且在常用的代码中是不必要的。把delete放到资源管理类的内部,例如string,ostream,thread,unique_ptr和shared_ptr。这样,delete就会和new正确对应,不会出错。
4.4 总结:资源管理理念
对于资源管理,我认为垃圾回收是最后的选择,而不是“解决方案”或者理念:
1. 运用适当的抽象,递归和显式地处理自己拥有的资源。限定对象的作用域会更好。
2. 当你需要使用指针/引用语义时,使用诸如unique_ptr和shared_ptr的“智能指针”,来表明拥有关系。
3. 如果其他都行不通(如,你的代码是一个程序的一部分,而程序中使用了大量不满足语言资源管理和错误处理策略的指针),尝试“手动”处理非内存资源,并内嵌一个保守的垃圾回收器,用它来处理那些几乎不可避免的内存泄露。
这个策略完美吗?不,但它是通用的,并且简单。传统的基于垃圾回收的策略也不完美,并且它们不能直接处理非内存资源。
附言
- 误解1:“要理解C++,你必须先学习C”
- 误解2:“C++是一门面向对象的语言”
- 误解4:“为了效率,你必须编写底层代码”
- 误解5:“C++只适用于大型、复杂的程序”
上一篇: PC端更新网页版新浪微博至最新版本 可实现图片评论
下一篇: OA员工管理模块
推荐阅读