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

博客: www.cyhone.com

公众号:编程沉思录


C++11中推出了三种智能指针,unique_ptr、shared_ptr和weak_ptr,同时也将auto_ptr置为废弃(deprecated)。

但是在实际的使用过程中,很多人都会有这样的问题:

  1. 不知道三种智能指针的具体使用场景
  2. 无脑只使用shared_ptr
  3. 认为应该禁用raw pointer(裸指针,即Widget*这种形式),全部使用智能指针

本文试图理清楚三种智能指针的具体使用场景,并讲解三种智能指针背后的性能消耗。同时也解释了为什么要用shared_from_this以及智能指针的函数传参问题。

对象所有权

首先需要理清楚的概念就是对象所有权的概念。所有权在rust语言中非常严格,写rust的时候必须要清楚自己创建的每个对象的所有权。

但是C++比较自由,似乎我们不需要明白对象的所有权,写的代码也能正常运行。但是明白了对象所有权,我们才可以正确管理好对象生命周期和内存问题。

C++引入了智能指针,也是为了更好的描述对象所有权,简化内存管理,从而大大减少我们C++内存管理方面的犯错机会。

unique_ptr:专属所有权

我们大多数场景下用到的应该都是unique_ptr

unique_ptr代表的是专属所有权,即由unique_ptr管理的内存,只能被一个对象持有。

所以,unique_ptr不支持复制和赋值,如下:

auto w = std::make_unique<Widget>();
auto w2 = w; // 编译错误

如果想要把w复制给w2, 是不可以的。因为复制从语义上来说,两个对象将共享同一块内存。

因此,unique_ptr只支持移动, 即如下:

auto w = std::make_unique<Widget>();
auto w2 = std::move(w); // w2获得内存所有权,w此时等于nullptr

unique_ptr代表的是专属所有权,如果想要把一个unique_ptr的内存交给另外一个unique_ptr对象管理。只能使用std::move转移当前对象的所有权。转移之后,当前对象不再持有此内存,新的对象将获得专属所有权。

如上代码中,将w对象的所有权转移给w2后,w此时等于nullptr,而w2获得了专属所有权。

性能

因为C++的zero cost abstraction的特点,unique_ptr在默认情况下和裸指针的大小是一样的。

所以内存上没有任何的额外消耗,性能是最优的。

使用场景1:忘记delete

unique_ptr一个最简单的使用场景是用于类属性。代码如下:

class Box{
public:
    Box() : w(new Widget())
    {}
    ~Box()
    {
        // 忘记delete w
    }
private:
    Widget* w;
};

如果因为一些原因,w必须建立在堆上。如果用裸指针管理w,那么需要在析构函数中delete w;

这种写法虽然没什么问题,但是容易漏写delete语句,造成内存泄漏。

如果按照unique_ptr的写法,不用在析构函数手动delete属性,当对象析构时,属性w将会自动释放内存。

使用场景2:异常安全

假如我们在一段代码中,需要创建一个对象,处理一些事情后返回,返回之前将对象销毁,如下所示:

void process()
{
    Widget* w = new Widget();
    w->do_something(); // 可能会发生异常
    delete w;
}

在正常流程下,我们会在函数末尾delete创建的对象w,正常调用析构函数,释放内存。

但是如果w->do_something()发生了异常,那么delete w将不会被执行。此时就会发生内存泄漏

我们当然可以使用try...catch捕捉异常,在catch里面执行delete,但是这样代码上并不美观,也容易漏写。

如果我们用std::unique_ptr,那么这个问题就迎刃而解了。无论代码怎么抛异常,在unique_ptr离开函数作用域的时候,内存就将会自动释放。

shared_ptr:共享所有权

在使用shared_ptr之前应该考虑,是否真的需要使用shared_ptr, 而非unique_ptr。

shared_ptr代表的是共享所有权,即多个shared_ptr可以共享同一块内存。

因此,从语义上来看,shared_ptr是支持复制的。如下:

auto w = std::make_shared<Widget>();
{
    auto w2 = w;
    cout << w.use_count() << endl;  // 2
}  
cout << w.use_count() << endl;  // 1

shared_ptr内部是利用引用计数来实现内存的自动管理,每当复制一个shared_ptr,引用计数会+1。当一个shared_ptr离开作用域时,引用计数会-1。当引用计数为0的时候,则delete内存。

同时,shared_ptr也支持移动。从语义上来看,移动指的是所有权的传递。如下:

auto w = std::make_shared<Widget>();
auto w2 = std::move(w); // 此时w等于nullptr,w2.use_count()等于1

我们将w对象move给w2,意味着w放弃了对内存的所有权和管理,此时w对象等于nullptr。

而w2获得了对象所有权,但因为此时w已不再持有对象,因此w2的引用计数为1。

性能

  1. 内存占用高 shared_ptr的内存占用是裸指针的两倍。因为除了要管理一个裸指针外,还要维护一个引用计数。 因此相比于unique_ptr, shared_ptr的内存占用更高
  2. 原子操作性能低 考虑到线程安全问题,引用计数的增减必须是原子操作。而原子操作一般情况下都比非原子操作慢。
  3. 使用移动优化性能 shared_ptr在性能上固然是低于unique_ptr。而通常情况,我们也可以尽量避免shared_ptr复制。 如果,一个shared_ptr需要将所有权共享给另外一个新的shared_ptr,而我们确定在之后的代码中都不再使用这个shared_ptr,那么这是一个非常鲜明的移动语义。 对于此种场景,我们尽量使用std::move,将shared_ptr转移给新的对象。因为移动不用增加引用计数,因此性能比复制更好。

使用场景

  1. shared_ptr通常使用在共享权不明的场景。有可能多个对象同时管理同一个内存时。
  2. 对象的延迟销毁。陈硕在《Linux多线程服务器端编程》中提到,当一个对象的析构非常耗时,甚至影响到了关键线程的速度。可以使用BlockingQueue<std::shared_ptr<void>>将对象转移到另外一个线程中释放,从而解放关键线程。

为什么要用shared_from_this?

我们往往会需要在类内部使用自身的shared_ptr,例如:

class Widget
{
public:
    void do_something(A& a)
    {
        a.widget = 该对象的shared_ptr;
    }
}

我们需要把当前shared_ptr对象同时交由对象a进行管理。意味着,当前对象的生命周期的结束不能早于对象a。因为对象a在析构之前还是有可能会使用到a.widget

如果我们直接a.widget = this;, 那肯定不行, 因为这样并没有增加当前shared_ptr的引用计数。shared_ptr还是有可能早于对象a释放。

如果我们使用a.widget = std::make_shared<Widget>(this);,肯定也不行,因为这个新创建的shared_ptr,跟当前对象的shared_ptr毫无关系。当前对象的shared_ptr生命周期结束后,依然会释放掉当前内存,那么之后a.widget依然是不合法的。

对于这种,需要在对象内部获取该对象自身的shared_ptr, 那么该类必须继承std::enable_shared_from_this<T>。代码如下:

class Widget : public std::enable_shared_from_this<Widget>
{
public:
    void do_something(A& a)
    {
        a.widget = shared_from_this();
    }    
}

这样才是合法的做法。

weak_ptr

weak_ptr是为了解决shared_ptr双向引用的问题。即:

class B;
struct A{
    shared_ptr<B> b;
};
struct B{
    shared_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

pa和pb存在着循环引用,根据shared_ptr引用计数的原理,pa和pb都无法被正常的释放。

对于这种情况, 我们可以使用weak_ptr:

class B;
struct A{
    shared_ptr<B> b;
};
struct B{
    weak_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

weak_ptr不会增加引用计数,因此可以打破shared_ptr的循环引用。

通常做法是parent类持有child的shared_ptr, child持有指向parent的weak_ptr。这样也更符合语义。

如何指针作为函数传参

很多时候,函数的参数是个指针。这个时候就会面临选择困难症,这个参数应该怎么传,应该是shared_ptr<T>,还是const shared_ptr<T>&,还是直接raw pointer更合适。

1. 只在函数使用指针,但并不保存

假如我们只需要在函数中,用这个对象处理一些事情,但不打算涉及其生命周期的管理,不打算通过函数传参延长shared_ptr的生命周期。

对于这种情况,可以使用raw pointer或者const shared_ptr<T>&。

即:

void func(Widget*);
void func(const shared_ptr<Widget>&)

实际上第一种裸指针的方式可能更好,从语义上更加清楚,函数也不用关心智能指针的类型。

  1. 在函数中保存智能指针 假如我们需要在函数中把这个智能指针保存起来,这个时候建议直接传值。void func(std::shared_ptr<Widget> ptr);这样的话,外部传过来值的时候,可以选择move或者赋值。函数内部直接把这个对象通过move的方式保存起来。 这样性能更好,而且外部调用也有多种选择。

总结

对于智能指针的使用,实际上是对所有权和生命周期的思考,一旦想明白了这两点,那对智能指针的使用也就得心应手了。

同时理解了每种智能指针背后的性能消耗、使用场景,那智能指针也不再是黑盒子和洪水猛兽。

参考

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Jerry的SAP技术分享

css position relative and absolute布局

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

4920
来自专栏Rust语言学习交流

【Rust日报】2019-09-13 - C/C++ vs Rust:开发人员的观点

C++是一种非常快速和高效的编程语言。它的多功能性无任何限制,其成熟度确保支持和可靠性是首屈一指的。用C ++开发的代码也非常便携,所有主流操作系统都支持它。许...

13020
来自专栏算法工程师之路

动态规划问题-LeetCode 64、135(58同城笔试题)

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

10120
来自专栏.NET技术与企业级解决方案

C#6.0 新增功能

C# 6.0 版本包含许多可提高开发人员工作效率的功能。 这些功能的总体效果是让你编写的代码更简洁、更具可读性。 该语法不像许多常见做法那样繁琐。 可以更轻松地...

8220
来自专栏FreeBuf

远程进程Shellcode注入调试技巧

在病毒分析的过程中,时常会遇到很多病毒为了躲避杀软的检测,使用进程注入的方式,将shellcode注入到系统进程中执行,本文将介绍一些在遇到shellcode注...

10820
来自专栏算法工程师之路

动态规划问题-LeetCode 5 (经典DP问题、LCS问题)

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

34410
来自专栏.NET技术与企业级解决方案

Visual Studio 调试系列9 调试器提示和技巧

如果你在调试时,经常将鼠标悬停在数据提示上,就可能想固定变量的数据提示,方便自己随时查看。 即使在重新启动后,固定的变量也能保持不动。 要固定数据提示,请在鼠标...

9110
来自专栏FreeBuf

谷歌peering.google.com中的本地文件包含(LFI)漏洞

众所周知,本地文件包含漏洞(LFI)可以造成信息泄露甚至入侵系统,即使其中的包含代码不具备执行权限,但攻击者也可以从中获取一些深入渗透目标系统的有价值信息。该篇...

6120
来自专栏C语言入门到精通

基础知识 | 每日一练(116)

小林:确保在写操作之前先调用 fseek, 回到你准备覆盖的字串的开始, 况且在读写“+” 模式下的读和写操作之间总是需要 fseek 或 fflush。同时,...

5610
来自专栏明年我18

Linux red hat enterprise5下安装oracle

我对linux是绝对的菜鸟,但今天有份工作是给一台装在linux上的oracle服务器设置自动数据备份。这很可能是只做一次的工作,只做一次的东西,时间久了肯定会...

10320

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励