内存管理那些事(一):智能指针

聊聊 C++ 内存管理的问题,第一篇是关于智能指针。

内存管理的那些事

最近接触了不少智能指针、内存池的编程,于是想写篇博客聊聊相关话题。预计会写三篇博客,分别是智能指针、malloc 分配细节、动态数组和手动内存池。

C++ 里,申请内存的方式不外乎三种:定义(函数体内的)局部变量、调用 new 动态创建变量、全局或静态变量。它们分别对应了现代操作系统内存管理中的全局数据区、堆区和栈区。局部变量从其进入作用域起占用栈上内存,退出作用域时解除占用;全局和静态变量在整个程序的生命周期中都占用全局数据区的一块内存,这两种内存分配都比较简单粗暴。

相比之下,动态变量的分配就灵活很多。使用关键字 new 和 delete 可以控制自由内存的占用和释放,实现对内存的细粒度控制。在 C++11 中又引入了智能指针,方便实现对内存的自动释放。本篇博客就从智能指针的由来说起。

new/delete

new 和 delete 是 c++ 的两个关键字。其作用有二:申请/释放空闲空间;调用构造/析构函数并返回对象。顺便提一句,在 C++ 标准中, new 对象的存放位置是空闲空间,不一定是堆区。之所以有时说 new 在堆区分配内存,是因为现代编译器对 new 的实现中往往最终调用在堆上分配空间的函数,如 Windows 下的 HeapAlloc 和 Linux 下的 malloc。

对于实用主义而言,其实 malloc/free、new/delete 和 new[]/delete[] 这三对的效果都差不多,往往都能解决问题,只要注意不要混用就行。但本质上它们当然是完全不同的,以下简单回顾 new 和 malloc 的几点区别:

  1. new 是关键字, 而 malloc 是标准库函数,更加等价的比较应当是 operator new() 和 malloc 函数,他们(在实现上)都是从堆区分配一块内存,因为 operator new() 的内部调用了 malloc。当然,你完全可以自己实现一个内存分配器,然后调用 set_new_handler 来替换 malloc,详见 文档 - set_new_handler
  2. new 操作返回的一定是有类型的指针,而 malloc 返回的是 void*。这意味着 new 会调用对象的构造函数,而 malloc 不会。
  3. 当内存不足时,new 会抛出异常,而 malloc 会返回 NULL。想让 new 不抛出异常,可以传入参数 nothrow。没错,new 也是可以传参的,详见 文档 - std::nothrow

工程中常说要慎用传统指针。有人说,“使用 new 会导致内存泄漏”,但事实上,只要足够小心,在每一处必要的位置及时释放指针,是不会发生内存泄漏的。隔壁 C 语言就是这么操作的。那么为什么 C++ 工程慎用传统指针呢?

从代码的角度说,在大型项目中,由于一些指针的生命周期可能很长,在传递过程中往往会经历许多不同的分支(尤其是涉及异步时),此时在每个分支的结尾处都需要释放指针,往往就容易遗漏。从软件工程的角度说,new/delete 对应的结构降低了代码的可维护性和重构的成本。如果多人协作编程时涉及到传统指针的互传,则增加了团队的沟通成本和引入缺陷的风险。又由于内存泄漏检测和定位的复杂性,大型项目中,常常推荐使用自动释放智能指针来替代传统指针。

智能指针

智能指针是为了解决上述问题而诞生的。比如考虑这样一段代码:

int* get_ptr(){
	auto new_ptr = new int();
	return new_ptr;
}// ptr is destroyed, but return value holds the address.
int main(){
    int* ptr_outer;
    do {
        int* ptr = get_ptr();
        ptr_outer = ptr;
    	// ptr is destroyed, but ptr_outer holds the address.
    }while(0);
    // ptr_outer is destroyed. Memory leak happens here.
}

在这段代码的 get_ptr 函数中动态创建了一个新指针,且最终没有释放,造成了内存泄漏。那么内存是在什么时候泄漏的呢?

  • 对于 get_ptr 函数来说,虽然在代码块内没有释放 new_ptr 变量,但它以返回值的形式传出了,并在 do-while 循环中被局部变量 ptr 获取,因此没有构成内存泄漏。
  • 在循环中,ptr 变量也没有释放就销毁了,但它的值被存在了 ptr_outer变量中,因此这里也没有构成内存泄漏。
  • 在 main 函数返回时,ptr_outer 变量被销毁,且没有释放内存。此时这段内存对应的所有变量都被销毁了,因此构成了内存泄漏。

由此我们可以发现,防止内存泄漏的关键就是对“有多少个指针指向这个内存”这个数字进行跟踪,也就是所谓的“引用计数”。当引用计数为 0 后,没有任何指针指向这块内存,如果此时再不销毁,以后就没有办法找到这块内存了。这时就需要调用 delete 来清理这段内存。

假如我们实现一个类 SmartPtr ,在其构造、拷贝函数中增加其引用计数,在析构函数中减少引用计数,并在计数为 0 时自动地调用 delete。那么以上代码可以改写为:

SmartPtr get_ptr(){
	auto new_ptr = new SmartPtr(0);
	return new_ptr;
}
int main(){
    SmartPtr ptr_outer;
    do {
        SmartPtr ptr = get_ptr();
        ptr_outer = ptr;
    	// ptr is destroyed, but ptr is not <delete>'ed.
    }while(0);
    // ptr_outer is destroyed, called <delete> automatically.
}

这样,我们就利用 C++ 的构造、析构函数的性质实现了自动 new/delete 的一个辅助类,我们把这样的类称为智能指针。

熟悉 Java 垃圾回收算法的同学肯定知道 “引用计数”的诸多缺陷,其一就是“循环引用”问题,也就是如果两个指针互相应用,那么它们的引用计数就永远不会降为 0,依然造成了内存泄漏。因此在 C++ 中,提供了 shared_ptr、unique_ptr、weak_ptr 三个不同作用的智能指针,用来对指针进行灵活而又智能的管理。

顺便提一句,在 C++11 之前,标准库就有一个 auto_ptr 类用于管理智能指针,但由于其功能上的缺陷,使用的场景比较有限,后来被更为完善的 shared_ptr 和 unique_ptr 取代。现在还能在比较旧的一些项目中看到 auto_ptr 的身影,但这个类已经 deprecated,不推荐使用了。

下面就简单介绍 C++11 中引入的这些智能指针的用法。

shared_ptr

shared_ptr 是最常用的智能指针。它是一个模板类,里面存储其所实例化的类型的一个指针,以及引用计数等信息。这个指针可以像普通的指针那样任意地复制,就好像几个指针共享一块空间那样,因此叫 shared_ptr。

其声明和调用的操作如下:

shared_ptr<string> p = make_shared<string>(3, '9');
cout << *p << ' ' << p->length() << endl; // 999 3

可以看到,我们可以调用 make_shared 模板函数来生成一个 shared_ptr。然后由于重载了 operator* 和 operator-> 运算符,我们可以像使用普通指针一样使用它。

涉及赋值和复制时的操作如下:

auto p = make_shared<int>(3);
auto q(p); // or auto q = p;
(*p)++;
cout << *q << endl; // 4

可以看到,我们可以对 p 调用复制构造函数或者一般赋值函数,复制一个指针 q,它们指向同一个内存。这和普通指针的表现也是一样的。

涉及释放相关的操作如下:

shared_ptr<string> factory(int x){
	return make_shared<string>(to_string(x));
}
void use_factory(){
	auto p = factory(33);
	cout << *p << endl; // 33
} // 自动释放内存
shared_ptr<string> use_factory_2(){
	auto p = factory(33);
	cout << *p << endl;
	return p; // 增加引用计数
} // 不自动释放

可以看到,在 use_factory 函数中,我们创建了一个智能指针 p,它在其生命周期结束时就自动释放了。在 use_factory_2 函数中,由于将 p 作为返回值传出了函数,在 return 操作时将其拷贝到了函数的返回值这个临时变量中,增加了引用计数,因此不会被自动释放。因此,我们可以放心地将智能指针像普通指针那样在函数间传递,不用担心其被提前释放。

以下是一些可能用到的成员函数与危险操作:

auto p = shared_ptr<int>(new int(3)); // 另一种初始化方式,不推荐

int* p_old = p.get(); // 获得p的内部指针 // delete p_old; // 危险!释放了p中的指针,会使p失效,当p析构时再次调用delete会触发未定义操作 // auto q = shared_ptr<int>(p.get()); // 危险!两个智能指针使用一个底层指针,释放时同样会引发未定义操作
cout << p.unique() // 返回智能指针是否唯一 << p.use_count() // 返回智能指针引用数 << endl;
auto q = make_shared<string>(4); p.swap(q); // 交换指针

我们几乎总是不推荐把智能指针和 new/delete 混用。即使你对智能指针很了解,确定不会引发重复 delete 等 UB,也应当尽可能只使用智能指针提供的api,把 new/delete 忘掉。

假如我们要获取 p 内部的传统指针,可以用 get() 方法。假如要交换两个指针,可以用 swap() 方法。假如要查看 p 当前的引用计数,可以用 use_count() 方法。

最后提一下自定义删除器,它可以接受一个函数,在 delete 的同时调用。这个功能用得不多,因为在 C++ 中大多数情况下可以把删除时的逻辑写在析构函数里。自定义删除器一般和 C 风格的指针配合使用,达到析构的效果,如:

void read_file(){
	FILE* p = fopen("test.dat", "r");
	auto ptr = shared_ptr<FILE> (p, [](auto p){
		if(p) fclose(p);
	});
	read_with_file_pointer(p);
	return;	// close file automatically
}

unique_ptr

上面我们看到,shared_ptr 为了实现动态指针的自动释放,使用了引用计数的技术。但有些场景下,某个对象从头到尾只有一个指针指向它,此时引用计数导致了资源的一定浪费,因此引入了更为轻量的 unique_ptr。

unique_ptr 是一个唯一“拥有”对象的指针。与 shared_ptr 不同,每时每刻只能有一个 unique_ptr 指向其分配的内存。也就是说,unique_ptr 的拷贝操作是被禁止的,此外的其他操作与 shared_ptr 相似。

实际开发中,一般能用 unique_ptr 的地方尽量用 unique_ptr,真的需要多个指针时才使用 shared_ptr,是一个好的开发习惯。

其声明和调用操作如下:

auto p = unique_ptr<int>(new int(3));
//auto p = make_unique<int>(3);
cout << *p << endl;   // 3

其中第一行是 C++11 的写法,make_unique 是 C++14 后提出的函数。虽说两种写法本质上差不多,但出于代码风格的考虑,如果生产环境允许开 C++14 以上的编译,还是推荐使用 make_unique。

这里顺便对比一下 make_unique/make_shared 和手动 new 后再调用构造函数的区别。虽然标准没有规定智能指针的实现,但一般的实现中 shared_ptr 中其实有两个指针,一个指向内部数据,一个指向存储引用计数等信息的结构体。如果使用手动 new 的方式,就会导致智能指针创建过程中再调用一次 new 来创建结构体;而 make_shared 可以一次 new 解决问题,不仅节省了内存分配的时间,也对 cache 更友好。

unique_ptr 转移或放弃指针控制权的操作如下:

// auto q = p;        // 错误:unique_ptr 不可拷贝
// p = unique_ptr<int>(new int(4)); // 错误:unique_ptr 不可重新赋值

int* t = p.release(); // 释放控制权,返回指针 p.reset(); // 删除对象,释放内存 p.reset(t); // 删除对象(如果有),然后令 p 指向 t auto q = unique_ptr<int>(move(p)); // 另一种转移控制权的方式
p.swap(q); // 交换控制权

从上面代码中的第二种转移控制权方式可以看出,虽然 unique_ptr 禁止拷贝构造函数,但它没有禁止移动构造函数(即右值拷贝)。这也是为什么其可以作为函数参数或函数返回值。

unique_ptr 的其他操作和 shared_ptr 类似;它也可以自定义删除器,虽然写法与 shared_ptr 略有不同,但原理也是类似的。这些部分就不再赘述了。

weak_ptr

前面提到过引用计数的缺陷:循环引用。weak_ptr 的出现就是为了缓解这个问题,它是一个不会增加引用计数的 shared_ptr,。weak_ptr 并不能完全解决循环引用。

比如 OOAD 中常见的以下设计模式:

struct Parent{
	shared_ptr<Child> son; 
}
struct Child{
	shared_ptr<Parent> par;
}
int main(){
	auto p = make_shared<Parent>();
	auto c = make_shared<Child>();
	p->son = c;
	c->par = p;
}

Parent 类和 Child 类分别持有对方的一个指针。这时双方的引用计数都增加了 1。这样,在其退出作用域时 p 和 c 的引用计数都不会减为 0,从而导致了内存泄漏。

weak_ptr 就是一个不增加引用计数的智能指针,可以理解为“弱引用”。在上面的例子中,如果将 Child 的定义修改为这样既可:

struct Child{
	weak_ptr<Parent> par;
}

参考资料

A beginner's look at smart pointers in modern C++

C++ Primer(5th Edition): Lippman, Stanley B., Lajoie, Josée, Moo, Barbara

intro/smart pointers - cppreference.com - C++ Reference

make_unique的使用_CSDN博客

C++智能指针的正确使用方式