unique_ptr实现的是专属所有权语义,用于独占它所指向的资源对象的场合。某个时刻只能有一个unique_ptr指向一个动态分配的资源对象,也就是这个资源不会被多个unique_ptr对象同时占有,它所管理的资源只能在unique_ptr对象之间进行移动,不能拷贝,所以它只提供了移动语义。资源对象的生命周期被唯一的一个unique_ptr对象托管着,一旦这个unique_ptr对象被销毁或者变成空对象,或者拥有了另一个资源对象,它所管理的资源对象同时一并销毁。资源对象要么随同unique_ptr对象一起销毁,要么在离开unique_ptr对象的管理范围时被销毁,从而保证了内存不会泄露。
unique_ptr以模板形式提供的,它有两种版本:一个是普通版本,即标量版本,用于管理一个动态分配的资源对象;另一个是数组版本,是一个偏特化版本,用于管理一个动态分配的数组。
下面看一下它的用法。
构造对象
构造一个unique_ptr相当简单,以int资源类型为例:
unique_ptr<int> up(new int(8)); // 创建一个指向int型对象的指针,删除器缺省为delete
unique_ptr<int[]> ar_up(new int[10]); // 指向一个10个元素的int型数组,删除器缺省为delete[]
定义数组版本的unique_ptr对象时,模板参数需要声明为数组形式,注意在模板参数中不要指定数组的大小,写成下面那样的定义是无法编译的。
unique_ptr<int[10]> ar_up(new int[10]); // 无法编译
也不要使用普通版本来分配数组,下面代码编译时没有问题,但是运行时会可能出错,因为在销毁资源时,使用的是delete操作符,不是delete[]操作符。
unique_ptr<int> ar_up(new int[10]); // 编译没有问题,但是运行时可能会出错
unique_ptr可以不拥有资源对象,使用缺省构造函数创建的是不拥有任何资源的空对象:
unique_ptr<int> up_empty;
unique_ptr<int[]> ar_empty;
空对象可以在需要的时候使用reset()为它分配指针。
up_empty.reset(new int(42));
ar_empty.reset(new int[10]);
或者移动一个unique_ptr对象给它.。
up_empty= move(up);
ar_empty = move(ar_up);
构造unique_ptr对象时,不能直接把裸指针赋值给unique_ptr对象,它没有提供这样的隐式转换,但是可以把nullptr赋值给unique_ptr对象。
//! std::unique_ptr<int> up_0 = new int(4); // error
std::unique_ptr<int> up_1(nullptr); // 可以使用nullptr直接构造
std::unique_ptr<int> up_2 = nullptr; // 可以使用nullptr直接赋值
up_2.reset(nullptr);
定制删除器
构造unique_ptr对象时除了传递指针外,还可以提供一个删除器。如果没有指定删除器,unique_ptr会使用缺省删除器,即缺省使用delete和delete[]来销毁对象,分别用于普通版本和数组版本。如果动态分配的资源有专门的释放函数,必须在构造时同时提供一个删除器。比如:
unique_ptr<int, void(*)(int*)> up2(new int(4), [](int *ptr){delete ptr;});
unique_ptr<int[], function<void(int*)>> ar_up2(new int[10], [](int *ptr){delete[] ptr;});
删除器要求是可调用对象,它的类型可以是函数指针、函数对象、lambda表达式、function对象等。
当使用提供删除器时,删除器的类型也要作为模板的参数类型声明在unique_ptr中,也就是说删除器类型也是unique_ptr模板实例化后的类型的一部分。因此,如果一个unique_ptr对象在move时,源和目的unique_ptr对象的删除器类型也必须一样,这点与shared_ptr不一样。
一般情况下,unique_ptr释放的资源是在内存中动态分配的,但通过定制删除器,也可以使用unique_ptr来管理一些通过其他方式分配的资源,比如文件句柄、socket等。C++中有一种资源管理的惯例-RAII,可以通过它来实现对资源的自动释放,比如,对于使用fopen打开的文件,返回一个文件指针FILE,在程序结束时,需要调用fclose关闭,为了防止忘记关闭,可以手动编写一个RAII类来负责管理这个FILE的生命周期,不是很方便。现在可以使用unique_ptr来实现,只需定义一个删除器就可以了,非常方便。下面是一个使用unique_ptr来自动关闭打开文件的例子。
FILE *file = fopen("/tmp/tmp.txt", "r"); // 分配FILE资源
unique_ptr<FILE, void(*)(FILE *)> up(file, [](FILE *file) {
fclose(file);
});
...//其它操作file的代码逻辑
移动语义
unique_ptr是一个独占型的智能指针,它是资源对象的唯一拥有者,不允许其它智能指针共享其内部的资源。unique_ptr没有拷贝语义,它的拷贝构造和拷贝赋值函数都是delete的,它和其它智能指针只能通过move操作来转移内部资源,比如可以转移给另一个unique_ptr对象,或者shared_ptr对象。
extern void foo(unique_ptr<int> up);
unique_ptr<int> up(new int (5));
//!foo(up) ; // 无法编译
foo(move(up));
// 或者
foo(unique_ptr<int>(new int (5)));
移动操作要求两个对象的类型必须完全一样,即删除器的类型也必须一样。下面的代码是无法编译的,原因是unique_ptr<int, function<void(int*)>>和unique_ptr 是不同的类型,它们的内存布局不一样。这点不同于shared_ptr,shared_ptr<int, function<void(int*)>>和shared_ptr之间是可以进行移动或者拷贝操作的。
unique_ptr<int, function<void(int*)>> up1(new int(4), [](int *ptr){delete ptr;});
unique_ptr<int> up(move(up1));
不过,对于返回值为unique_ptr类型的函数,比如函数unique_ptr foo() ,调用时,可以直接赋值:
extern unique_ptr<int> foo();
unique_ptr<int> up = foo();
当然并不是说在此应用场景中支持拷贝语义了,而是因为调用foo()函数时返回的是临时对象,是一个纯右值,编译器使用了移动构造操作(当然,在编译器优化时,如果满足条件,可能使用命名返回值优化(NRVO)技术,直接在foo()中构造了up对象)。
使用
unique_ptr可以通过成员函数get()来获取原始的裸指针,如果unique_ptr对象是个空对象,它里面没有资源时,则返回nullptr。
int *q=up.get();
既然是智能指针,unique_ptr具有指针的特点和操作方式,编程时可以象操作普通裸指针那样进行使用。
1、unique_ptr重载了操作符*,->可以象使用指针那样使用它们:
int x = *up1;
printf("%d\n", *up2);
2、对于数组版本的unique_ptr,还重载了数组操作符[],可以通过它访问指定位置的数组元素:
int x = ar_up[0]; // unique_ptr重载了[]操作符
// int x = ar_up[10]; // 同裸指针访问数组一样,不进行越界检查
操作符[]返回的是引用,可以作为左值使用:
ar_up[1] = 150;
注意unique_ptr<int[]> up2(new int[10])与unique_ptr up2(new int[10])的区别,后者不是一个指向数组的智能指针,而是普通版本的智能指针,并没有提供[]操作符重载,下面的语句编译失败:
int x = up2[0];
up2[1] = 150;
但可以:*(up2) = 150;,不过,这样使用,访问的是位置0处的元素。
3、unique_ptr并没有重载+,-,++,–,+=,-=等算术运算操作符,如果访问其它位置的元素,只能通过裸指针,如:
int *q = up2.get();
std::cout << *q << std::endl;
std::cout << *(q+1) << std::endl;
std::cout << q[4] << std::endl;
4、unique_ptr也可以直接判断是否为nullptr,有两种方式,和使用裸指针的形式完全一样。
if(!up)
std::cout<<"up is nullptr"<<std::endl;
if(up== nullptr)
std::cout<<"up is nullptr"<<std::endl;
5、unique_ptr也可象普通指针那样,当指向一个类继承体系的基类对象时,也具有多态性质,如同使用裸指针管理基类对象和派生类对象那样。如:
// 基类
class base {
public:
void foo(); // 非virtual成员函数,没有多态性质
virtual void bar(); // virtual成员函数
virtual ~base();
};
// 派生类
class derive : public base {
public:
void foo(); // 覆盖基类的foo
void bar(); // 重写基类的bar
~derive(); // 重写基类的析构
};
// 测试一个管理派生类对象的智能指针能否赋值给一个管理基类对象的智能指针
// 即,智能指针是否有派生类到基类的隐式转换
void test1() {
unique_ptr<base> up_base(new base); // up_base指向基类
up_base->foo(); // 调用基类的成员函数
up_base->bar(); // 调用基类的成员函数
unique_ptr<derive> up_derive(new derive);
up_base = move(up_derive); // unique_ptr可以进行隐式转换,基类指针指向派生类对象
up_base->foo(); // 不是多态,调用基类对象的成员函数
up_base->bar(); // 由多态机制调用派生类对象的成员函数
up_base.reset(); // 调用派生类的析构函数
}
管理资源对象
当使用裸指针构造unique_ptr对象成功之后,unique_ptr对象就和所指资源的生命周期绑定在一起了。如果中间没有脱离开的话,unique_ptr对象和资源生命周期完全一致,当unique_ptr对象销毁时,资源同时一块销毁。如果在中间过程中对unique_ptr对象调用了移动构造、移动赋值、reset()等函数,涉及到的资源也会被销毁。
up = move(up0); // 释放up对象指向的旧资源对象,隐式释放,up0置空
up.reset(); // 释放up对象指向的资源对象,显式释放
up.reset(new int); // 释放up指向的资源对象,同时指向新的对象,隐式释放
up.reset(nullptr); // 释放up对象指向的资源对象,显式释放
int *p = up.release(); // 返回指针,放弃控制权,并将up置空
up.release(); // 内存泄露
获取裸指针有两种形式: get()和release(),二者对待返回的裸指针不一样。当使用get()返回裸指针时,程序不要自己释放内存,而使用release()返回裸指针时,要由程序负责释放这个裸指针。
值得注意的是,在程序中不能以“up.release();”的形式调用release(),它并不会释放内存,相反会造成内存泄露,一定要把返回值赋值给一个指针变量,并在合适的时机进行释放。
空间和时间开销成本
先看空间开销,下面是一段测试代码,分别为unique_ptr指定了不同的删除器,测试环境是x86-64,一个指针变量的长度是8字节,分别输出不同删除器情况下的unique_ptr对象的大小:
// 下面是不同类型的删除器
class deleter0 { // 空基类
public:
deleter0() = default;
void operator()(int *p) {
delete p;
}
};
class deleter1 : public deleter0 { // 非空类
int dummy;
public:
deleter1() = default;
};
void delete_ptr(int *p) { // 回调函数
delete p;
}
void unique_size() {
const char *msg = "deleter in lambda!";
auto func = [&msg](int *p){ // 捕捉变量的lambda表达式
puts(msg);
delete p;
};
auto lambda = [](int*p){delete p;}; // 没有捕捉变量的lambda表达式
unique_ptr<int> ptr1(new int);
std::cout << sizeof(ptr1) << std::endl; // 8
unique_ptr<int, deleter0> ptr2(new int, deleter0());
std::cout << sizeof(ptr2) << std::endl; // 8
unique_ptr<int, decltype(lambda)> ptr3(new int, lambda);
std::cout << sizeof(ptr3) << std::endl; // 8
unique_ptr<int, void(*)(int *)> ptr4(new int, [](int *p){delete p;});
std::cout << sizeof(ptr4) << std::endl; // 16
unique_ptr<int, decltype(func)> ptr5(new int, func);
std::cout << sizeof(ptr5) << std::endl; // 16
unique_ptr<int, void(*)(int *)> ptr6(new int, delete_ptr);
std::cout << sizeof(ptr6) << std::endl; // 16
unique_ptr<int, deleter1> ptr7(new int, deleter1());
std::cout << sizeof(ptr7) << std::endl; // 16
unique_ptr<int, std::function<void(int *)>> ptr8(new int, deleter0());
std::cout << sizeof(ptr8) << std::endl; // 40
unique_ptr<int, std::function<void(int *)>> ptr9(new int, deleter1());
std::cout << sizeof(ptr9) << std::endl; // 40
unique_ptr<int, std::function<void(int*)>> ptr11(new int(10), [](int *ptr){delete ptr;}); // function类型
std::cout << sizeof(ptr11) << std::endl; // 40
unique_ptr<int, std::function<void(int*)>> ptr12(new int(10), lambda); // function类型
std::cout << sizeof(ptr12) << std::endl; // 40
unique_ptr<int, std::function<void(int*)>> ptr13(new int(10), func); // function类型
std::cout << sizeof(ptr13) << std::endl; // 40
}
从输出的log来看:
1、当使用缺省删除器、没有数据成员的函数对象(即用空类创建的对象)和decltype声明的没有捕获变量的lambda表达式时,unique_ptr占用的内存空间仅为8字节,是一个指针所占用的空间大小,也就是unique_ptr对象除了保存裸指针之外,没有额外的内存消耗了;
2、如果删除器是函数指针,需要额外使用8字节的内存空间,unique_ptr要使用额外空间保存这个函数指针;
3、如果删除器是捕捉了变量的lambda表达式或者有数据成员的函数对象,还要额外使用内存空间,空间大小和所捕捉的变量和数据成员数量及类型有关。
4、当使用std::function声明删除器的类型时,所占用的空间最多,unique_ptr对象要占用40字节的空间。除了这显式的40个字节之外,std::function对象自己还要额外地进行动态分配内存,所分配的内存空间和所包装的可调用对象的大小有关,也就是最终整个unique_ptr对象占用的空间要大于40字节。
因此,一般而言,如果删除器不是缺省的或者空函数对象,unique_ptr对象与裸指针相比要占用额外的内存空间,即unique_ptr要分配空间来存放删除器对象,可见,为了管理动态分配的对象资源,在内存消耗上是要花费代价的。此外,在定义unique_ptr对象时,不要轻易使用std::function来声明删除器的类型,它会使unique_ptr的空间开销变得更大。
从这里我们就理解了,在定义unique_ptr对象时,如果要提供删除器,为什么必须也要提供删除器的模板类型了。是因为编译器在实例化模板类时,可以根据删除器的类型进行优化,可以生成占用内存最小的unique_ptr类。比如,如果删除器没有数据成员,是一个空类,那么编译器在使用模板参数生成具体unique_ptr类时,可以采用继承的方式,这样在unique_ptr中只继承了删除器的成员函数,成员函数是不占用对象内存空间的,数据成员仍然只是一个裸指针,不会增加对象的存储空间;如果是其它形式的删除器,可以把它作为unique_ptr类的一个值类型的数据成员,此情况下unique_ptr要增加额外的存储空间。如果把空类形式的删除器也作为unique_ptr的数据成员的话,也会额外占用一个字节,增加了不必要的内存开销。这种对于空类采用继承而不是组合的方式,是一种内存空间优化方式,称为EBO。
使用没有捕捉外部变量的lambda表达式来定义unique_ptr的删除器,是比较常见的形式,它的使用场景有如下几种:
1、就地声明,模板参数类型使用函数指针类型,创建的unique_ptr对象大小为16字节。因为此时编译器把lambda表达式类型转换为函数指针,前面例子已经说明过,如果删除器是函数指针,需要额外使用8字节的内存空间,因此unique_ptr对象大小为16字节。
unique_ptr<int, void(*)(int *)> up(new int(10), [](int *p){delete p;}); // 函数指针类型
2、先定义lambda表达式,赋值给一个变量,然后在unique_ptr时,传入变量,模板参数类型使用decltype来自动推导,创建的unique_ptr对象大小为8字节。因为此时编译器把lambda表达式编译为一个没有数据成员的函数对象,使用了EBO优化,所以它没有额外占用unique_ptr对象空间。
auto deleter = [](int *ptr){delete ptr;};
unique_ptr<int, decltype(deleter)> up(new int(10), deleter); //使用decltype声明类型
可见,对于同一个lambda表达式生成的删除器,使用不同的类型声明方式,所创建的unique_ptr对象的大小是不一样的。使用decltype来声明类型比使用函数指针声明类型的空间要小,而且lambda表达式生成的删除器还可以被不同的unique_ptr对象复用,不像“就地声明”的使用方式,每次都要创建一个新对象。不过应当指出,如果编译器在编译过程中进行了优化,有可能会识别出函数指针形式的回调函数,在优化时会把它内联展开(inline),此时也不会额外分配内存空间。
下面看一下运行开销:
1、在对unique_ptr对象使用*操作符进行解引用时,编译器在编译优化过程中会进行内联展开,它包含的指针成员会使用一个寄存器来存放,不额外占用内存空间,与一个裸指针编译后的代码几乎完全一样。解引用一个unique_ptr和解引用一个裸指针的开销完全一样,没有额外的执行时间消耗,而且删除销毁时也没有额外的开销。
extern int *get(int n); // 为了防止编译器把指针优化掉,使用一个函数返回指针
void test0(int arg) {
unique_ptr<int> up(get(arg));
printf("%d\n", *up);
}
void test1(int arg) {
int *p = get(arg);
printf("%d\n", *p);
delete p;
}
下面是编译器优化后得汇编指令,可以看出,unique_ptr作为局部变量解引用操作时被优化为和普通指针一样的代码。
test1(int): // 解引用普通指针类型的汇编代码
push rbp
call get()
mov edi, OFFSET FLAT:.LC0
mov esi, DWORD PTR [rax] // rax寄存器为指针p,它所指向的数据赋值给寄存器esi
mov rbp, rax
xor eax, eax
call printf
mov rdi, rbp
pop rbp
jmp operator delete(void*)
test0(int): // 解引用unique_ptr指针类型的汇编代码
push r12
push rbp
sub rsp, 8 // 为unique_ptr分配了空间,但没有使用
call get()
mov edi, OFFSET FLAT:.LC0
mov esi, DWORD PTR [rax] // rax寄存器为up所管理的指针,它指向的数据赋值给寄存器esi
mov rbp, rax
xor eax, eax
call printf
add rsp, 8
mov rdi, rbp
pop rbp
pop r12
jmp operator delete(void*)
2、如果作为参数传递时,同裸指针相比,会多了一次间接访问。因为会有一次从this指针获取指针变量这个数据成员的操作,这是一个寄存器间接寻址。当然如果函数比较简单,编译器优化时会进行内联优化,此时这个unique_ptr对象优化后可能就是一个局部变量,可以直接使用,避免了从this中获取的过程,开销和直接使用裸指针完全一样:
void test0(unique_ptr<int> up)
//下面是获取裸指针的相关汇编代码
mov rax, QWORD PTR [rdi] // rdi存放up的this指针,间接访问得到裸指针的值,存放在rax中
mov esi, DWORD PTR [rax] // 间接访问rax得到裸指针指向的值,存放的rsi中
void test1(int *p)
//下面是获取指针的相关汇编代码
mov esi, DWORD PTR [rdi] // rdi存放是裸指针地址,间接访问得到指向的值,存放在rsi中
总之,如果unique_ptr使用了缺省删除器,无论是空间开销还是时间开销,几乎等同于使用裸指针的开销。使用其它形式的删除器,尤其是使用std::fucntion包装后的可调用对象作为删除器时,unique_ptr对象占用的内存空间要多一些,而且在调用删除器时,使用了动态绑定的函数调用形式,也会增加一定的运行时开销,我们在程序开发时要注意这点。
应用场合
1、作为函数参数
当unique_ptr作为函数参数时,有如下几种形式:
第一种,作为值语义的参数形式:
void foo(unique_ptr<T> up);
因为unique_ptr仅支持移动语义,在调用函数时,只能使用移动语义,要么传入一个纯右值对象,要么对左值使用move()转为将亡值。调用完成后意味着资源对象被转移了,从调用者的实参转移到函数内部的unique_ptr参数中。函数调用完成之后资源对象被释放,原参数对象就成为了一个空对象,它接下来的命运要么在离开作用域后被销毁,要么接收一个新的资源对象焕发新生,总之它和所托管的资源对象没有任何关联了,无法继续使用了。
第二种,使用引用语义的参数形式,重载了两个函数,它们的参数类型分别为常量左值引用和右值引用。
void foo(const unique_ptr<T> &up);
void foo(unique_ptr<T> &&up);
调用函数时,实参是以引用形式传递的,是传址操作,没有涉及到资源移动的操作。如果unique_ptr实参对象是左值,就选择使用常量左值引用形式的函数类型;如果是个纯右值或者move后的将亡值,就使用右值引用形式的函数类型。但无论使用哪一种形式,实参都是引用形式传入的,即传入的是实参对象的别名,这样实参对象和函数内的对象都是指向同一个unique_ptr对象。
常量左值引用参数形式的函数,在函数内部只能以只读的形式访问up参数,不能进行移动操作,也不能调用reset()、release()等函数。因此,在函数内部,不会对实参对象的生命周期造成影响,当函数调用完成之后,原参数对象仍然占有资源对象。
而右值引用参数形式的函数,在函数内部可以使用move操作,把资源转移到别的地方去,或者调用reset()、release()等函数释放资源,造成函数外部的unique_ptr对象是一个空对象。因此在调用完成之后,实参的资源有可能被释放了,这点应当注意。此外,因为调用者传递的和foo函数使用的是同一个unique_ptr对象,假设在foo()函数中启动了一个异步线程,并以引用形式把unique_ptr对象传递给线程,如果在线程函数中对unique_ptr对象进行了修改操作,如移动操作,可能会造成线程不安全。
第三种,使用非常量左值引用的参数形式
void foo(unique_ptr<T> &up);
这种是按照一般引用方式作为参数传递的,它和右值引用形式的参数类似,也有类似的不安全问题,使用时要注意资源是否被转移了。
总之,如果调用函数之后,调用者不再使用所管理的内部资源了,可以把它转移到函数内部,就使用第一种方式;如果调用者自己还要占有资源,那就使用参数为常量左值引用的函数形式;右值引用和非常量左值引用的参数形式尽量不要使用,稍有不慎,就会造成错误。
2、函数返回值
使用智能指针最常见的场景是在一个函数中动态分配资源,并且在别的函数中释放,比如使用下面的形式来定义一个通过unique_ptr返回资源的函数。
unique_ptr<T> get_resource();
get_resource函数内部动态分配一个资源,使用unique_ptr进行包装并返回,当调用auto up = get_resource()之后,资源就从函数内部转移到up对象中了,释放资源也就完全交给up来负责了。前面说过,此处赋值是采用了移动语义,因为调用函数返回的临时对象是个纯右值。
3、数据成员
一个类中,如果数据成员是指针类型的,而这个成员是这个类的对象独有的,也可以考虑使用unique_ptr。如果对象中只有一个指针成员,也可以不使用unique,因为类自己的构造和析构函数,完全可以用它们实现一个RAII语义的资源管理模式:在构造函数中初始化指针,在析构函数中销毁指针。如果有一个以上的指针成员时,这样实现时有可能不安全,最好考虑使用unique_ptr来管理它们。下面有一个例子。
使用裸指针类型定义数据成员:
class C {
A *a;
B *b;
public:
C(): a(new A), b(new B) { // 构造函数分配资源
}
~C() { // 需要提供析构函数来释放a和b
delete a;
delete b;
}
};
使用unique_ptr类型定义指针成员:
class D {
unique_ptr<A> a;
unique_ptr<B> b;
public:
D() : a(new A), b(new B) {
}
~D() = default; // 析构函数使用缺省方式就可以了,会自动析构a和b对象
};
当使用C来定义一个对象时,如果在构造函数时,a已经初始化成功了,当初始化b时抛出了异常,此时不会调用析构函数(C++语义规定,没有构造成功的对象不会调用它的析构函数),a所分配的内存就不会释放,造成内存泄漏。但是如果使用D来定义对象,发生同样的问题时,a所分配的内存会被释放。可见,如果类中有指针类型的数据成员,要在构造过程中,想使部分成员在发生异常时进行析构,最好使用智能指针。
其次,如果一个类要提供移动语义,当它有指针类型的数据成员时,使用unique_ptr来管理这个指针成员的好处是,不用手动编写移动构造函数和移动赋值函数了,可以使用=default让编译器在需要时自动生成相关的函数就行了。当然,因为unique_ptr没有拷贝语义,如果类同时也要提供复制语义的话,就不能使用=default让编译器自动实现了,可以考虑使用shared_ptr。
4、局部变量
在一个函数中,如果有动态资源的分配和释放,如果不要求资源共享的话,最好也使用unique_ptr,因为如果使用裸指针的话,需要在函数退出的所有路径处,包括发生异常时,都要加上释放内存的语句,否则就会产生内存泄露。而且,unique_ptr对象作为局部变量,是一个在栈上创建的对象,前面说过,在此场景下,编译器在编译时完全可以把unique_ptr对象优化成普通指针进行使用,无论是内存空间占用还是执行时间的开销,同裸指针完全一样,但使用shared_ptr时没有这样高的效率,无论是内存占用还是执行时间。这是unique_ptr最为常见的应用场景,不举例多说了。