C++ 三种智能指针的使用场景
C++98中引入,但是实现有缺陷(使用copy语义转移资源),现已弃用,在实际项目中不应该使用。本文提到的三种智能指针主要指的得是、和。
TL,DL
智能指针的基本哲学是RAII(Resource Acquisition Is Initialization). 将内存申请放在对象构造的时候,而在对象析构的时候自动释放。
RAII的思想还应用到例如 之类的用于对锁、线程、socket、handle等资源的管理
把握unique含义,资源的所有权不是共享的,而是被某个对象独占的时候。当资源所有权发生转移,可以通过move或者release进行转移
把握share的含义,对象的所有权是共享的,可以被拷贝
把握weak的含义,是一种“弱指针”,就是指向的资源可能是不可用的(可能是个垂悬指针),需要通过 或者 方法检查,利用这个特性,在可能会失效的场合可以使用(例如缓存、订阅者等)。需要搭配使用,并且可以防止循环引用。
一、RAII
RAII(Resource Acquisition Is Initialization) 资源获得即初始化。首先解释资源的概念:
资源: 程序中需要获取后才能用,然后需要隐式(implictly)或者显式(explicitly)释放的东西。例如内存、文件句柄、socket和锁等。如果没有释放,可能会造成资源泄漏或者程序出错。
资源泄漏在简单调试中可能无法发现,但是在长期运行的时候可能会吃尽系统的可获得的资源,导致程序崩溃
void send(X* x, string_view destination)
{
auto port = open_port(destination);
my_mutex.lock();
// ... 1
send(port, x);
// ... 2
my_mutex.unlock();
close_port(port);
delete x;
}
在这个函数中需要程序手动释放锁、关闭端口、并且delete x的内存。这样依靠程序员的自觉自律很容易发生遗漏,更糟糕的是,如果在 1 或者 2 处抛出了异常; 2 后面的代码将不会被执行,也就是说释放资源的代码会被跳过,将导致资源泄漏!因此这样无法保证异常安全!
所以RAII的思想被提出来了,RAII 的理念是将资源的获取放在类的构造函数里,资源的释放放在类的析构函数里。在类的生存期结束的时候,析构函数会被自动调用,对应的资源将会释放。
例如刚刚的例子可以改成:
void send(unique_ptr x, string_view destination) // x owns the X
{
Port port{destination}; // port owns the PortHandle
lock_guard guard{my_mutex}; // guard owns the lock
// ...
send(port, x);
// ...
} // automatically unlocks my_mutex and deletes the pointer in x
在类的构造函数中获得资源,并且在析构的时候释放:
class Port {
PortHandle port;
public:
Port(string_view destination) : port{open_port(destination)} { }
~Port() { close_port(port); }
operator PortHandle() { return port; }
// port handles can't usually be cloned, so disable copying and assignment if necessary
Port(const Port&) = delete;
Port& operator=(const Port&) = delete;
};
RAII 是C++ best practice 最重要的思想之一, 在实际的开发中我们应该尽可能使用。这样才能保证资源安全和异常安全。
智能指针 和 就是RAII在内存管理上的实践。
二、 vs vs
智能指针的语义是拥有(own)一个对象的所有权,并且控制其生存期。
2.1
unique_ptr 没有拷贝语义(unique_ptr),不可以通过拷贝赋值和构造,但是可以通过移动语义进行资源所有权的转移。
unique_ptr的使用场景,最常见的就是拥有一个一个对象的所有权,就像传统C指针的最基础用法,保存一个申请于堆上的对象
auto q = std::make_unique(1);//better
2.2
shared_ptr 内部有引用计数,在对象所有权需要共享的时候(share)用,shared_ptr具有赋值拷贝的语义。
用法:
作为需要保存在容器里的对象,同时避免频繁创建引起性能上的开销
如果一个类的创建需要比较多的资源(例如比较大的的内存和拷贝),如果我们直接保存在容器里可能会在拷贝时产生比较大的性能损失,这个时候可以考虑使用,然后将保存于容器。
vector> foos;
// ...
for(auto &foo : foos){
process_func(*foo);
}
// tranditionally
FILE *fp = fopen("./1.txt","r");
// ...
// ...
fclose(fp);
//-------
// 通过使用定制删除器, 将删除器作为回调函数传入
shared_ptr fp1(fopen("./1.txt","r"),fclose);
例如上面这个例子,在fp1生命周期结束的时候,将会调用而不是。 这个例子参考了
2.2
weak_ptr 的语义是并不真正own一个对象的所有权,而是需要在使用的时候检查一下指针的有效性,可以应用于可能失效的场景,例如缓存、观察者模式的订阅者等等。也应用于打破shared_ptr代理的循环引用无法析构的问题。
例1
#include
#include // for std::shared_ptr
#include
class Person
{
std::string m_name;
std::shared_ptr m_partner; // initially created empty
public:
Person(const std::string &name): m_name(name)
{
std::cout << m_name << " created\n";
}
~Person()
{
std::cout << m_name << " destroyed\n";
}
friend bool partnerUp(std::shared_ptr &p1, std::shared_ptr &p2)
{
if (!p1 || !p2)
return false;
p1->m_partner = p2;
p2->m_partner = p1;
std::cout << p1->m_name << " is now partnered with " << p2->m_name << "\n";
return true;
}
};
int main()
{
auto lucy = std::make_shared("Lucy") ; // create a Person named "Lucy"
auto ricky = std::make_shared("Ricky"); // create a Person named "Ricky"
partnerUp(lucy, ricky); // Make "Lucy" point to "Ricky" and vice-versa
return 0;
}
在这个例子中产生了循环引用 ,在析构的时候将会尬住,lucky和ricky都无法正确析构。跑一下这个程序会发现析构函数没有被调用。解决办法就是使用 来替代 。
例子2, 使用 保存二叉树的parent节点,作用于1相似,也是用于打破循环引用带来的资源泄漏。
#include
struct node
{
std::shared_ptr left_child;
std::shared_ptr right_child;
std::weak_ptr parent;
foo data;
};
例子3. 带缓存的工厂函数
在一些代价高昂的场景,例如操作了文件或者数据库I/O, 并且ID会被频繁重复使用,加上缓存可以优化性能,但是缓存需要在一定期限过期。
因为要让调用者决定对象生存期,所以不能用
因为工厂内部需要保存一个对象的缓存,所以不能用
因为缓存管理器需要检查指针是否空悬,所以不能用裸指针
class Base{
//...
};
class Foo:public Base{
// ...
};
class Bar:public Base{
// ...
}
enum Type{
k_FOO,
k_Bar
}
std::shared_ptr MakeInstance(Type t){
switch(t){
case k_FOO:
return make_shared();
case k_BAR:
return make_shared();
default:
return nullptr;
}
}
std::shared_ptr FooFactory(Type t){
static std::unordered_map> cache;
auto p = cache[t].lock();
if(!p){
p = MakeInstance(t);
cache[t] = p;
}
return p;
}
例子4. 观察者模式的订阅者
观察者模式中,每个topic可以用容器保存一个观察者的指针,在有消息更新的时候,推送给订阅者。
这个时候使用 的好处是,当观察者结束其生存期的时候,topic可以检查其是否expired,如果已经失效,则不去访问和推送。
Best Practices
下面关于 Best Practices 的一些条款援引自 C++ Core Guidelines,建议细细阅读,可以转为实际的coding经验。
其他Tips
对于 Qt 的QObject及子类对象,Qt框架使用 parent-children 的方式进行内存回收,此时不应该使用智能指针, 遵循 Qt 的设计即可