C++中堆上的动态资源管理是一个容易出错的地方,借助RAII(Resource Acquisition Is Initialization,资源获取即初始化),能够简化资源管理,依此思想产生的智能指针极大的简化了编程。 本文先简要介绍C++中资源管理,然后介绍RAII,最后在这些概念上实现一个简单的不带引用计数的智能指针和带引用计数的智能指针,以加深对这些知识的理解。
C++中的栈、堆及资源管理
堆和栈与C#中的概念相似,C#中的值类型的对象大多数情况下是在栈上分配,而引用类型的对象则默认在堆上分配,这些都是默认行为,不需要手动指定。栈上的内存在出作用域后会被回收,而堆上的内存,则由垃圾回收器负责回收。
在C++中则复杂的多,它有三块内存区域:
- 静态内存,用来保存局部static对象、类的static数据成员,以及任何定义在函数之外的变量,分配在静态内存中的对象由编译器自动创建和销毁。static对象在程序使用之前分配,程序结束时销毁。
- 栈内存,保存定义在函数内的非static对象,由编译器自动创建和销毁,栈对象仅在程序块运行时才存在。
- 自由空间(free store)或堆(heap),程序在堆上动态分配对象,即在程序原型时才分配的对象,因此对象的生命周期也由程序来控制,当动态对象不再使用时,代码必须显示销毁他们。
在C++中,对象是分配在栈上还是在堆上是由我们自行决定的。简单来说 new 出来的对象分配在堆上,需要我们手动调用 delete 来进行释放。
堆(heap)在内存管理的语境下,指的是动态分配内存的区域,一个程序的堆大小是有限制的,一般情况下是1MB。使用 new 可以在堆上分配一块内存,比如:
int *p = new int;
*p = 3;
delete p;
上述代码,在堆上分配了一个int大小的空间,返回了指向该内存区域指针p,当不用后,需要使用delete p删除这部分内存空间。
在堆上分配内存也称为动态内存分配,很多语言都是用new关键字来表示在堆上分配内存。关于堆上的内存分配,有三个可能涉及到内存管理的操作:
- 让内存管理器分配指定大小的内存块。
- 让内存管理器释放之前分配的某个内存块。
- 让内存管理器进行垃圾回收操作,寻找不再使用的内存块并释放,对不连续的内存空间进行移动或压缩合并操作。
一些语言,比如C#、Java等具备垃圾回收机制,它能够自动执行2和3。在C++中,内存的分配和释放的管理,是内存分配器的任务,一般不需要我们介入,只需要正确使用new和delete,每个new出来的对象记得delete释放,就没有问题。然而漏掉delete是一种常见的情况,这就是“内存泄漏”,比如:
void foo()
{
bar* ptr = new bar();
// do something
delete ptr;
}
上面的代码存在两个问题:
- do something的时候,可能会抛出异常,从而导致delete ptr得不到执行。
- 代码逻辑可能有问题,可能更早就提前return了。这个例子正确的做法是不应该是在堆上分配内存,而是直接在栈上分配。
更常见也更合理的情况,就是分配和释放不在一个函数里,比如:
bar* make_bar(…)
{
…
try {
bar* ptr = new bar();
…
}
catch (...) {
delete ptr;
throw;
}
return ptr;
}
void foo()
{
…
bar* ptr = make_bar(…)
…
delete ptr;
}
这样,出现漏delete的情况就会变得很多。
栈(stack),指的是函数调用过程中产生的本地变量和调用数据的区域。与数据结构里面的“栈”相似,满足“后进先出"(last-in-first-out,LIFO)。本地变量所需的内存就在栈上,跟函数执行所需的其它数据在一起,当函数执行完成之后,这些内存就自然而然的释放了。
对于有构造函数和析构函数的类型,当在栈上分配内存时,编译器会在生成代码的合适位置,插入对构造函数和析构函数的调用。另外,更重要的是:编译器会自动调用析构函数,包括在函数执行中产生异常的时候。在发生异常时对析构函数的调用,也叫栈展开(stack unwinding)。即不管是否产生异常,函数方法里栈上对象的析构函数都会被调用。
RAII
RAII,完整的英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++ 是唯一一个依赖 RAII 来做资源管理的。RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。对 RAII 的使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法,也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。
C++支持将对象存储在栈上,但很多情况下,对象不能或不应该存储在栈上,比如:
- 对象很大,因为栈的空间大小有限,对象过大则会引起stackoverflow。
- 对象的大小不能在编译时确定,比如标准库里面的那些容器,它们会动态扩容。
- 对象是函数的返回值,但由于一些原因,不能以值类型返回。比如工厂方法,它应该返回指向具体类的指针,而不能是具体类型,否则会引起对象切片(object slicing)。
那么如何保证,当函数返回指针时,指针指向的对象一定会被释放,而不会产生内存泄漏呢?答案就是使用析构函数和它的栈展开行为。我们只需要把这个返回值放到一个本地变量里面(亦即引入另外一个间接层 another layer of indirection ,亦即 handle/body 惯用法 idiom), 并且保它的析构函数会删除该对象即可。
All problems in computer science can be solved by another level of indirection," is a famous quote attributed to Butler Lampson, the scientist who in 1972 envisioned the modern personal computer.
--- from 《beautiful code》
比如下面的这个smartptr类。
#include <iostream>
using namespace std;
template <typename T>
class smartptr
{
public:
smartptr(T *t = nullptr) : ptr(t)
{
}
~smartptr()
{
delete ptr;
ptr = nullptr;
}
private:
T *ptr;
};
这样,我们就可以在栈上直接定义一个smartptr<object>(new obj)对象了,在出作用域的时候,编译器生成的代码会直接调用smartptr的析构函数。
不带引用计数的智能指针
智能指针本质上并不神秘,它只是借助RAII资源管理功能的自然展现而已。实际上,下面的smartptr已经是一个unique_ptr的雏形了。
#include <iostream>
using namespace std;
template <typename T>
class smartptr
{
public:
smartptr(T *t = nullptr) : ptr(t)
{
}
~smartptr()
{
delete ptr;
ptr = nullptr;
}
private:
T *ptr;
};
智能指针本质上是一个指针,但是上面的实现离实际的指针还是有些差异,这些很容易,我们添加几个成员函数就可以。
T &operator*() const { return *ptr; }
T *operator->() const { return ptr; }
operator bool() const { return ptr; }
现在可以简单使用了:
int main()
{
smartptr<int> p1(new int);
*p1 = 3;
smartptr<int> p2(p1);
std::cout << *p2 << endl;
return 1;
}
上述代码运行后会输出:3,随后会报错。
报错的问题出在从p1拷贝构造到p2的时候,C++默认的拷贝构造函数执行的是简单的内存拷贝,也就是浅拷贝,这就使得p1和p2里面的指针都指向了同一块内存区域。现在当离开main函数的时候,首先析构p2,会删除p2内部指针指向的内存空间,紧接着会调用p1的析构函数,此时,因为底层的内存空间已经被释放,所以再次释放会报错。但是如果实现深拷贝,那么p2和p1分别指向的是不同的对象,这显然不是使用者想得到的效果,理想的效果是将p2所指的对象,转移到p1。
拷贝构造和赋值
上面的问题出现在拷贝构造和赋值操作上。事实上,当我们把智能指针从A赋值给B时,应该是把A所指向的指针赋值给B,同时删除A指针的指向。这样,就不会出现两个指针指向同一块内存,从而导致在析构时析构两次的问题。当然,最简单的办法就是禁止拷贝和赋值。
smartptr(const smartptr &) = delete;
smartptr &operator=(const smartptr &) = delete;
这很简单,现在就不允许从一个现存的智能指针通过拷贝,或者赋值来产生新的智能指针,这就避免了多个智能指针指向同一个对象,从而析构两次的错误。
但我们是不是还有另外的一种做法,那就是在拷贝或赋值时转移指针的所有权,这就是开头所说的,当把A指针赋值给B的时候,把指针拷贝给B后,解除A指针所指的内容。比如拷贝构造函数,实现如下:
#include <iostream>
using namespace std;
template <typename T>
class smartptr
{
public:
smartptr(T *t = nullptr) : ptr(t)
{
}
// 带左值引用参数的拷贝构造函数
smartptr(smartptr &other) : ptr(other.release())
{
}
T &operator*() const { return *ptr; }
T *operator->() const { return ptr; }
operator bool() const { return ptr; }
T *release()
{
T *temp = ptr;
ptr = nullptr;
return temp;
}
~smartptr()
{
delete ptr;
ptr = nullptr;
}
private:
T *ptr;
};
可以看到,release方法,首先将当前的指针拷贝到一个临时对象中,然后把当前指针置为nullptr,最后返回临时对象指针。这样就在拷贝构造的时候就完成了指针所有权的转移。
现在我们要解决赋值操作,标准的赋值操作逻辑是:
- 先释放当前对象的资源
- 将目标对象的资源拷贝到当前对象
标准的赋值操作函数的参数是需要用const标注的,表示不能修改,但这里我们需要将源对象的资源”移动“到目标对象,需要修改源对象。因为要在赋值的时候实现控制权转移,所以可以实现如下:
smartptr &operator=(smartptr &other)
{
ptr = other.release();
return *this;
}
以上的实现和拷贝构造函数基本相似,这是因为这个smartptr里面只有一个指针成员,所以比较简单。
参考上一篇文章介绍,更标准的做法则是实用 copy-and-swap 惯用法,
class smartptr
{
public:
.........
smartptr &operator=(smartptr &other)
{
smartptr tmp(other);
swap(*this,tmp);
return *this;
}
.........
friend void swap(smartptr &lhs, smartptr &rhs)
{
using std::swap;
swap(lhs.ptr, rhs.ptr);
}
};
首先,根据实参拷贝构造一个临时变量tmp,指针控制权随后转移到了tmp, other 内的指针被设置为了nullptr,紧接着将当前对象与临时对象进行替换。原来的对象this对象则被替换到临时对象tmp中,等离开作用域后,临时对象tmp就被析构了。
在C++11中引入了移动语义,而智能指针的含义在这里则非常契合。在智能指针的场景下,当拷贝或者赋值产生新对象时,是要将原始对象析构的。所以我们可以只添加移动构造函数和移动赋值操作符:
using namespace std;
template <typename T>
class smartptr
{
public:
smartptr(T *t = nullptr) : ptr(t)
{
}
// 带右值引用参数的拷贝构造函数
smartptr(smartptr &&other) :ptr(other.release())
{
}
smartptr &operator=(smartptr other)
{
swap(*this, other);
return *this;
}
T &operator*() const { return *ptr; }
T *operator->() const { return ptr; }
operator bool() const { return ptr; }
friend void swap(smartptr &lhs, smartptr &rhs)
{
using std::swap;
swap(lhs.ptr, rhs.ptr);
}
T *release()
{
T *temp = ptr;
ptr = nullptr;
return temp;
}
~smartptr()
{
cout << "~smartptr()" << endl;
delete ptr;
ptr = nullptr;
}
private:
T *ptr;
};
以上修改有两个地方:
- 把拷贝构造函数中的参数类型 smart_ptr& 改成了 smart_ptr&&;现在它成了移动构造函数。根据 C++ 的规则,如果提供了移动构造函数而没有手动提供拷贝构造函数,那后者自动被禁用。
- 把赋值函数中的参数类型 smart_ptr& 改成了 smart_ptr,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象。现在赋值函数的行为是移动还是拷贝,完全依赖于构造参数时走的是移动构造还是拷贝构造。等于是编译器来帮我们决定从实参是”拷贝“还是”移动“来产生形参,这一技巧参考前篇文章。
现在拷贝构造和拷贝赋值是被禁用了,如果要使用,必须添加std::move。这就是我们要达到的效果。
int main()
{
smartptr<int> p1{new int};
smartptr<int> p2{p1}; //编译出错,不存在拷贝构造函数
smartptr<int> p3;
p3 = p1; //编译出错,不存在赋值操作符
p3 = std::move(p1);
smartptr<int> p4{std::move(p3)};
return 1;
}
子类向基类指针转换
现在还存在的一个问题是指针类型的转换问题,比如在工厂方法中,需要允许子类的指针向基类的指针进行隐式转换。比如circle*可以隐式转换为shape*,这就要求smartptr<circle>也可以隐式转换为smartptr<shape>,解决方法很简单,那就是利用模板。
template <typename U>
smartptr(smartptr<U> &&src) : ptr(src.release())
{
cout << "smartptr(smartptr &&src)" << endl;
}
这样,我们自然而然利用了指针的转换特性:现在 smart_ptr<circle> 可以移动给 smart_ptr<shape>,但不能移动给 smart_ptr<triangle>。不正确的转换会在代码编译时直接报错。
带引用计数的智能指针
带引用计数的智能指针是指当允许多个智能指针指向同一个资源的时候,每一个智能指针都会给资源的引用计数加1,当一个智能指针析构时,同样会使资源的引用计数减1,这样最后一个智能指针把资源的引用计数从1减到0时,就说明该资源可以释放了,由最后一个智能指针的析构函数来处理资源的释放问题,这就是引用计数的概念。
要实现引用计数,首先要创建一个计数器类:
class sharecounter
{
public:
sharecounter() : counter(1) {}
void addcount()
{
++counter;
}
int reducecount()
{
return --counter;
}
int getcount() const { return counter; }
private:
int counter;
};
除了构造函数之外有三个方法,分别是增加计数,减少计数和获取计数。需要注意的是,上面的增加计数接口不需要返回计数值,而减少计数时需要返回减少后的计数值。以供调用者判断是否它是最后一个指向共享计数的指针。
现在,可以实现引用计数的智能指针了,首先是构造函数、析构函数和私有成员变量:
template <typename T>
class smartptrwithcounter
{
public:
smartptrwithcounter(T *t = nullptr) : ptr(t)
{
if (t)
{
counter = new sharecounter();
}
}
~smartptrwithcounter()
{
if (ptr && counter->reducecount() == 0)
{
delete ptr;
delete counter;
ptr = nullptr;
counter = nullptr;
// cout << "~smartptrwithcounter(real)" << endl;
}
}
private:
T *ptr;
sharecounter *counter;
};
在构造函数中,当指针不为空时,会构造一个sharecounter对象,此时引用计数默认为1。在析构函数中,当指针不为空时,对引用计数进行减一,如果减一之后的值降到零时,则彻底删除指针指向对象和引用计数。
接下来是拷贝构造和移动构造函数。
// 带左值引用参数的拷贝构造函数
smartptrwithcounter(const smartptrwithcounter &other) noexcept
{
ptr = other.ptr;
if (ptr)
{
other.counter->addcount();
counter = other.counter;
}
}
// 带右值引用参数的移动构造函数
smartptrwithcounter(smartptrwithcounter &&other) noexcept
{
ptr = other.ptr;
if (ptr)
{
counter = other.counter;
other.ptr = nullptr;
}
}
可以看到,拷贝构造和移动构造的唯一区别是,移动构造的other是一个临时值,不需要增加引用计数,只需要把other的指针和counter对象分别赋值给当前对象,并且将other的指针置为空。
接下来就是拷贝赋值,遵循之前的copy-and-swap,实现如下:
smartptrwithcounter &operator=(smartptrwithcounter other) noexcept
{
swap(*this, other);
return *this;
}
friend void swap(smartptrwithcounter &lhs, smartptrwithcounter &rhs) noexcept
{
using std::swap;
swap(lhs.ptr, rhs.ptr);
swap(lhs.counter, rhs.counter);
}
这里面拷贝赋值的参数是值类型,这样编译器可以根据=右侧的值来决定是通过调用拷贝构造还是移动构造来从实参生成形参。最后添加一个获取当前引用计数的方法,以方便调试:
long usecount() const noexcept
{
if (ptr)
{
return counter->getcount();
}
else
{
return 0;
}
}
现在来几个测试用例看下:
void basic_usage()
{
smartptrwithcounter<int> p1{new int(42)};
cout << "p1 use count:" << p1.usecount() << endl; // 1
{
smartptrwithcounter<int> p2 = p1;
cout << "p1 use count:" << p1.usecount() << endl; // 2
cout << "p2 use count:" << p2.usecount() << endl; // 2
}
cout << "p1 use count:" << p1.usecount() << endl; // 1
}
void move_semantic()
{
smartptrwithcounter<int> p3{new int(100)};
cout << "original p3 use count:" << p3.usecount() << endl; // 1
smartptrwithcounter<int> p4 = std::move(p3);
cout << "after move p3 is:" << (p3 ? "valid" : "null") << endl; // 1
cout << "p4 use count:" << p4.usecount() << endl; // 1
}
struct Testobj
{
static int count;
Testobj() { ++count; }
~Testobj() { --count; }
};
int Testobj::count = 0;
void auto_cleanup()
{
{
smartptrwithcounter<Testobj> obj1{new Testobj};
{
smartptrwithcounter<Testobj> obj2 = obj1;
cout << "objects lives:" << Testobj::count << endl; // 1
}
cout << "objects lives:" << Testobj::count << endl; // 1
}
cout << "objects lives:" << Testobj::count << endl; // 0
}
int main()
{
cout << "=====basic usage ======" << endl;
basic_usage();
cout << "=====move semantic =====" << endl;
move_semantic();
cout << "=====auto cleanup ======" << endl;
auto_cleanup();
return 1;
}
输出结果如下:
=====basic usage ======
p1 use count:1
p1 use count:2
p2 use count:2
p1 use count:1
=====move semantic =====
original p3 use count:1
after move p3 is:null
p4 use count:1
=====auto cleanup ======
objects lives:1
objects lives:1
objects lives:0
参考: