三、内存所有权
内存的错误管理是C++程序中最常见的问题之一。此类问题发生的原因多数是源于对内存所有权概念的模糊。于是,我们的程序中就出现了诸如:内存泄漏,访问未经初始化的内存,内存超限使用,以及其他难以排错的问题。现代C++中存在一套内存所有权管理的惯用法,这些方法允许程序员清除地表达他们对于内存所有权方面的设计意图。这样一来,我们就能更容易地写出正确分配、访问以及释放内存的优良代码。
以下为本章中将要讨论的主题:
- 什么是内存所有权?
- 一个良好的资源所有权设计应该是什么样的?
- 什么时候需要质疑自己关于资源的所有权设计?
- 如何在C++中表达对内存的独占所有权?
- 如何在C++中表达对内存的共享所有权?
- 不同的内存所有权分别需要付出多少额外的开销?
技术指南
1 什么是内存所有权
在C++中,“内存所有权”这则术语的含义是指某个实体需要对其分配的特定内存空间及其生命周期的控制负责。在现实中,我们很少提及裸内存的所有权问题。通常意义上的内存所有权,即我们对驻留在指定内存中的对象生命周期及所有权的管理,本质上就是对象所有权管理的另一种表述方式。内存所有权的概念与资源所有权又是紧密联系在一起的。首先,内存就是一种资源。它不仅仅是程序所能管理的资源,也是到目前位置最广泛使用的资源。其次,C++中管理资源的方式就是管理这个资源所属的对象,也就是我们刚刚提到的,所谓内存所有权的本质。从这个层面上看,内存所有权的概念不仅仅局限于拥有内存,因此一旦对内存所有权管理不善,会导致程序资源的泄露、谬误或丢失等,包括但不限于内存、互斥量、文件句柄、数据库句柄、猫片、机票预定、甚至核弹头等。
1.1 精心设计的内存所有权
一个精心设计的内存所有权应该长什么样?青铜的答案:是在程序中的每一处都清晰标注对象的所有者。而这,会引发过度约束,因为大部分程序不与资源或者内存的所有权打交道。有些程序的几乎不用使用额外资源,当我们编写这类代码时,知道这个类或函数不拥有内存就已经足够了。它丝毫不关心资源的所有者是谁:
struct MyValues { long a, b, c, d; }
void Reset(MyValues* v) { // 我们不关心v的所有者是谁
v->a = v->b = v->c = v->d = 0;
}
白银的答案:在程序中的每一处,都清晰表示所有权是否发生改变。这样就好多了。因为大部分的代码都会落入我们现在描述的情况。然而,这样的答案仍然限制太多,因为当我们获得一个对象的所有权时,通常不需要了解是从谁那里取得的:
class A {
public:
A(std::vector<int>&& v) :
v_(std::move(v)) {} // 从随便谁那里取得控制权
private:
std::vector<int> v_; // 控制权在我手里了
};
同样地,共享所有权的理念(也即是std::shared_ptr
通过引用计数表达的思想)也不要求我们知道具体是谁和我们分享了这个对象的所有权:
class A {
public:
A(std::shared_ptr<std::vector<int>> v) : v_(v) {}
// 不知道也不关心具体是谁分享了v
private:
std::shared_ptr<std::vector<int>> v_;
// 通过shared_ptr和其他人分享v的所有权
};
由此可得,对于一个精心设计的内存所有权,不能简单地一句话讲清楚。总的来说,一个精心设计的内存所有权实践,应当遵顼以下的特征和属性:
- 如果一个函数或者类不以任何方式改变内存所有权,那么客户端和实现者应当明确知情;
- 如果一个函数或者类对传递给他的某些对象获取独占所有权,那么客户端应当明确知情(实现者必然知道,因为代码是他写的);
- 如果一个函数和类会共享传递给他的对象的所有权,那么客户端应(或任何读取客户端代码的人)当明确知情;
- 对于创建的每一个对象,每当被使用时,应当明确调用者是否有义务释放它。
1.2 设计糟糕的内存所有权
精心设计的内存所有权无法用简单的语言描述和定义,因此我们用了一系列特征来描述它。同理,糟糕的内存所有权设计也有显著的共性特征。一般来说,良好的设计可以清晰地表明一段代码是否拥有资源,而糟糕的设计需要额外的信息去理解,并无法从上下文中推导出结论。例如,这个MakeWidget()
函数返回的对象会被谁拥有?
Widget* w = MakeWidget();
当这个对象不再被需要的时候,是否期望客户端删除它?如果是,那么将要如何删除它?如果我们选择了错误的方式去删除这个对象,那么就有可能造成内存崩溃——例如,这个对象并非由new
构造,而我们错误地使用了delete
去删除它。这种情况下最好的结果就是程序崩溃:
WidgetFactory WF;
Widget* w = WF.MakeAnother();
对于这个工厂对象,它对其创造的对象是否具有所有权?当工厂对象销毁时,它所生产的对象是否也会随之销毁?又或者说,应当由客户端来做这个事?如果我们决定工厂也许知道它所创建的对象,并且会在自己销毁时删除这些对象,但这仍然有发生内存泄露的风险(尤其是当这些被生产出来的对象同时也对其他对象具有所有权):
Widget* w = MakeWidget();
Widget* w1 = Transmogrify(w);
函数Transmogrify(w)
是否会获得对象的所有权呢?在Transmogrify()
处理后,w
对象是否仍然存在呢?如果w1
对象是通过删除w
对象而构造的,这种情况下我们会得到一个空悬指针w
;如果对象不被删除,但是我们假设他会被删除,那么就发生了内存泄露。
需要注意的是,并非只要出现裸指针,就是糟糕的内存管理方式,请不要产生这种定势思维。下面是一个糟糕的内存管理例子,属于治标不治本的典型:
void Double(std::shared_ptr<std::vector<int>> v) {
for (auto& x : *v) {
x *= 2;
}
};
...
std::shared_ptr<std::vector<int>> v(...);
Double(v);
...
这个Double()
函数在其接口中声明了对vector
的共享所有权。然而这个所有权的分享过于慷慨,对于Double()
的而言,它没有理由去拥有它参数的所有权,因为它的目标不是为了延长生命周期,并且他也不会讲所有权转移给其他人,它仅仅是为了修改调用者传给他的vector
中数据的值。逻辑上说,调用者应当对vector
具有所有权(或者油调用栈更高层次中的某个对象所拥有),并且vector
对象在Double()
调用返回之前应当存活,毕竟是调用者希望我们去处理容器中的元素,假设它在我们处理完之后仍需要用容器做其他的事情。
至此,对于糟糕的所有权设计,读者应该已经有一个比较笼统的概念了。
2 在C++中如何表达内存所有权
在C++发展的历史进程中,这门语言进化出了许多表达内存所有权的方式。有的时候,相同的语法结构甚至也会有不同的语义。这种进化在某种程度上是由添加进语言中的新特性驱动的,脱离智能指针谈内存所有权管理就是耍流氓。在另一方面,在C++11以后加入进来的大多数内存管理工具都不是什么新观点或者新概念。共享指针的理念距提出之际已经存在很长的时间了。C++11新特性的支持使得语言层面上对共享指针的实现更加方便,但是我们要知道,在被引入C++11标准之前,共享指针已经被使用很久了。相比之下,更为重要的改变是是C++社区中共识的进化,以及许多常用实践和惯用法的涌现。在这种意义下, 作为一组与不同语法相关联的约定和语义,我们可以将这些内存管理方法的集合统称为C++语言的设计模式之一。我们现在就来了解一下几种不同的内存所有权的表达方式。
2.1 表达非所有权
首先,我们从最常见的内存所有权开始。大多数的代码并不进行资源的分配、释放或是对象的构造,析构。这些代码仅仅在别人创建的对象上做一些工作,最后对象会被其他人释放。这种情况下我们如何表达呢?其实很简单,大多数C++程序员都这样做过:
void Transmogrify(Widged* w) { // 表示我不会删除对象
...
}
void MustTransmogrify(Widget& w) { // 表示我不会删除对象
...
}
class WidgetProcessor {
public:
WidgetProcessor(Widget* w) : w_(w) {}
...
Widget* w_; // I do not own w
};
非所有权的访问应通过授予裸指针或者引用进行。是的,即便在C++17的代码中,即使有智能指针的加持,裸指针仍有一席之地。不仅如此,在整个代码中,大部分的指针都是裸指针,因为它们表达的含义是不拥有所指涉的对象。
你可能会感到困惑,这个例子中的代码和之前的糟糕实践的代码几乎如出一辙。其实,最主要的区别在于上下文:在一个优秀的设计中,只有非所有权的访问能够被授予裸指针或引用。实际的所有权往往通过其他的方式表述。因此,当我们遇到一个裸指针的时候,它所表达的含义就非常清晰,即函数或者类不会乱搞指针所指对象的所有权。当然,这样一来,当我们面对远古代码的时候,可能会感到困惑,因为那里到处都是裸指针。为了清楚起见,建议将这些代码分成几部分进行转换,并在各部分之间明确指出遵循现代准则的转换代码和不遵循现代准则的转换代码。
另一个值得讨论的议题就是对于使用指针或是引用的抉择。从语法角度看,引用就是一个无法为NULL
(或者说nullptr
)的指针,并且必须被初始化。一个良好的习惯是,当一个函数接可能接受空指针时,必须对指针判空;如果一个函数不能接受空指针,那么参数中的指针就必须被替换为引用。这是被广泛使用的一个套路,但是并不能认为其是设计模式。也许是认识到了这一点,因此GSL库中提供了一种新式的指针判空方式:not_null<T*>
。注意这并不是语言的一部分,但是可以不借助任何语言扩展由标准C++实现。
2.2 表达独占所有权
第二种最常见的所有权类型就是独占所有权:代码创造了一个对象,并且负责在后续删除它。删除的任务不会被委派给其他对象,因为独占所有权不允许延长对象的生命周期。这种类型的内存所有权相当常见,以至于我们几乎每时每刻都在使用它:
void Work() {
Widget w;
Transmogrify(w);
Draw(w);
}
所有的局部(栈)变量都表达了独占的内存所有权。注意,这个上下文中的所有权并不意味着其他人不会修改对象。它仅仅意味着,w
对象的创造者,即Work()
函数,将会把它删除;删除行为将会成功执行(因为没有其他人删除它);对象将被事实上删除(在作用域之内没有其他东西试图延长对象的生命周期)。
这是C++中最古老的创建对象的方法,但他依然是最优秀的。如果栈上对象能够满足你的需求,那么就使用它。C++11标准中,提出了另外一种表达独占所有权的方式,它主要被使用在对象无法被构造在栈上而必须构造柱堆中的场景下。当所有权需要被共享或者转移的情况下,往往我们会在堆上分配内存空间来存放这些对象,毕竟栈上构造的对象一旦离开其作用域就会被销毁,分配在堆上是唯一的出路。另一个需要在堆上分配空间构造对象的原因可能是:对象的大小在编译时不可知,需要在运行时决定。例如,当一个基类指针指向派生类对象的多态场景下。我们可以通过std::unique_ptr
来表示这样的独占所有权:
class FancyWidget : public Widget { ... };
std::unique_ptr<Widget> w(new FancyWidget);
如果对象的构造方式不能仅仅使用简单的new
,如果需要用到工厂函数的话,那应该怎么办呢?接下来我们就会讨论这个问题。
2.3 表达独占所有权的转移
在前面的例子中,当一个新对象被创建时立即就绑定到了一个std::unique
指针上,这就保证了独占的所有权。客户代码看起来就好像这个对象是被一个工厂函数制造的一样:
std::unique_ptr<Widget> w(WidgetFactory());
但是这样的话,工厂函数的返回值应该是什么类型的呢?它当然可以仅返回一个裸指针,如Widget*
。毕竟,这就是new
运算符的返回类型。但是这样就开启了一条错误使用工厂函数的道路。例如,我们有可能不用std::unique_ptr
去捕获这个返回的裸指针,而是将其传入一个类似Transmogrify
的接受裸指针为参数的函数中,它虽然接受裸指针,但是并不进行任何与所有权相关的操作。那么这样一来,没有人“拥有”这个Widget
对象,最终造成了内存泄漏。理想状态下,WidgetFactory
应当以一种强制调用者接受对象所有权的形式编写。
这里我们需要做的就是所有权的转移。显而易见,WidgetFactory
对其所创造对象拥有独占所有权,但是在某个时刻,他需要将所有权拱手相让与一个新的主人。下面是一个简单的例子:
std::unique_ptr<Widget> WidgetFactory() {
Widget* new_w = new Widget;
...
return std::unique_ptr<Widget>(new_w);
}
std::unique_ptr<Widget> w(WidgetFactory());
这样就能达到我们的目的了,但是等等,这是如何办到的?难道std::unique_ptr
不是提供了独占所有权吗,怎么就转移了?这个问题的答案是,std::unique_ptr
对象是一个可移动的对象。从一个移动智能指针对象中移动内容到另一个智能指针对象中会转移内容对象的所有权,同时,原有智能指针会变为“移出”的状态(即析构时不会销毁任何内容对象的状态)。这样的用法有什么好处?它清晰地表达了,并且在编译时作为强制手段,一个工厂期望它的调用者获取它所创造对象的独占(或是共享)所有权。例如,下面的代码无法通过编译,因为它没有指定对象的所有者:
void Transmogrify(Widget* w);
Transmogrify(WidgetFactory());
那么,在假定已经获得正确所有权的情况下,我们如何对一个Widget
对象调用Transmogrify()
呢?依然是通过裸指针进行:
std::unique_ptr<Widget> w(WidgetFactory());
Transmogrify(&*w); // 也可通过 w.get() 调用,它表示非独占的访问
那么对于栈对象又如何呢?栈对象的所有权可以在其被析构前转移至其他人手中吗?这个问题就会有点复杂了,因为对象被分配的内存在栈上,并且将会被释放,所以这种行为或者操作会导致一定程度的复制。究竟多少内容被复制取决于对象是否可移动。这种行为可以被用于返回值,但更常用于向函数传参并允许其获取独占所有权。类似的函数必须声明其参数为右值或者万能引用:
void Consume(Widget&& w) { auto my_w = std::move(w); ... }
Widget w, w1;
Consume(std::move(w)); // w将不复存在,因为它进入了“移出”态
Consume(w1); // 无法通过编译,必须满足移动语义
注意,调用者必须显式地通过调用std::move
来放弃对象的所有权。这是这种惯用法的优势之一,如果没有这种操作,调用时发生的所有权的转移看起来就像是普通的调用一样。
2.4 表达共享所有权
最后一种还没讲到的所有权表达是共享所有权,即多个实体平等地共享对象。首先,我们高度注意,共享所有权的概念经常被误用,或者滥用。考虑之前的例子,当一个函数被传入了一个共享指针,但是其实它并不需要拥有对象。这种通过引用计数来防止对象被销毁的做法非常诱人。然而,这通常是糟糕设计的表现。在大多数系统中,某种程度上说,资源的所有权必须被清晰地反映在资源管理的设计上。这种懒人式设计虽然合法,但是我们要知道,尽管显式销毁对象的行为鲜少发生,但这种行为并不要求共享所有权来实现,通常是通过清晰的表达所有权来实现(专有指针,数据成员,容器都可以做得足够好)。
话虽如此,有些确定的场景下依然需要使用到共享所有权。最常见的合法应用场景是在底层数据结构中,如链表,树型结构等。一个数据元可能被同样结构中的其他节点拥有,同时被任意数量的迭代器同时指向,或者在对数据结构调用的成员方法中的临时变量中存在(通常例如树形结构的平衡调整)。通常来说,整个数据结构的所有权在良好设计中是十分清晰的。但是每一个节点,或者每个数据元的所有权,可能就真的在某种意义上说,拥有平等共享的多个所有者。
在C++中,共享所有权的表达式通过共享指针std::shared_ptr
进行的:
struct ListNode {
T data;
std::shared_ptr<ListNode> next, prev;
};
class ListIterator {
...
std::shared_ptr<ListNode> node_p;
};
这种设计的优势在于,每个链表单元在与其他部分断开后,依然可以通过迭代器访问。这并不是std::list
的实现方式,并且STL中的链表也不提供类似保证。然而,它可以是某些场景下的合法设计,例如,一个线程安全的链表。注意,在特殊场景下可能会需要使用原子的共享指针,它仅在C++20标准中提供(当然也可以自行造一个C++11版本的轮子)。
那么,哪些函数应该使用共享指针作为参数呢?在一个遵循良好内存所有权设计的程序中,这类函数表达了调用者意图获取部分所有权,以使得对象的生存期略长于函数调用,因为共享指针的拷贝会被创建,在函数调用结束时析构。在并发的场景下,这也意味着函数需要保证对象在函数调用的过程中不会被其他线程析构。
共享所有权有几个缺点我们需要铭记在心。最为人所熟知的就是共享指针的灾难,即循环引用。如果两个对象具有互相指向的共享指针,那么整个体系会永远“被占用”。C++对此的通常解决方案是使用std::weak_ptr
。它是一种可以安全指向可能被删除对象的指针。假如刚才的例子采用一个共享指针,一个弱指针的话,那么这种循环依赖关系就会被打破。
循环引用的问题是很现实的,当某些设计中,意图通过共享所有权来隐藏资源所有权不确定的问题时,这种循环引用的隐患往往更严重。然而,共享所有权还有其他的缺点。一个共享指针的性能通常要比裸指针慢。而另一方面,专有指针的性能可以和裸指针相当(事实上,std::unique_ptr
的性能与裸指针一模一样)。因为当一个共享指针对象被创建时,需要额外分配内存空间来存放引用计数。
在C++11中,std::make_shared
可以被用来同时分配对象和引用计数的内存空间,但这样就隐含了对象创建出来就是为了被共享的意图(通常,对象工厂函数返回专有指针,有些再后来被转换为共享指针)。对于共享指针的拷贝或者删除都会增加或减少引用计数。共享指针通常在并发数据结构中更常用,因为在更底层的结构中,所有权确实可能更加模糊。然而,设计一个线程安全的共享指针并不简单,并且也会带来额外的运行时开销。
总结
在C++中,“内存所有权”仅仅只是对“对象所有权”的另一种说法,换言之,就是管理任意资源及其所有权的一种手段。我们已经回顾了C++社区中,对于不同类型内存所有权的表达方式,以及它们的现代惯用法。同样重要的是在程序中表达非所有权,即对资源所有权的默认。我们也学习了关于资源所有权在良好的程序设计中的实践和特性。
现在我们已经掌握了在程序中如何清晰地表达一个实体是否对某个对象或资源拥有所有权的语言用法。下面的章节我们将讨论一个对于资源操作的最简单的惯用法:交换或swap
。