C++C++ の 乐园

一文说尽C++智能指针的点点滴滴

2018-07-17  本文已影响13人  CCCCCCode

0、摘要

本文先讲了智能指针存在之前C++面临的窘境,并顺理成章地引出利用RAII技术封装普通指针从而诞生了智能指针,然后以示例代码的形式讲解了三种智能指针的基本用法。为了更好地理解引用计数形式实现的智能指针,本文提供了实现一个简单版本的智能指针的方法,并讨论了引用计数形式的缺点。最后,本文讨论了使用智能指针应当注意的事项,包括shared_ptr 的循环引用问题等三个事项。

1、智能指针的前世今生

在智能指针出现以前,我们通常使用 newdelete 来管理动态分配的内存,但这种方式存在几个常见的问题:

制造出这些错误很容易,但查找和修正这些错误就困难的多。于是,我们就要考虑如何从根本上克服这种弊端,不制造出这些错误。动态分配的内存是 C++ 中最常使用的资源,所谓资源就是,一旦用了它,将来必须还给系统,否则就会发生糟糕的事情。所以,我们就要考虑如何更好地进行资源管理,来保证资源的有借必有还。

不难想到,资源管理技术的关键在于:要保证资源的释放顺序与获取顺序严格相反。这自然使我们联想到局部对象的创建和销毁过程。在C++中,定义在栈空间上的局部对象称为自动存储对象。管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。我们只需在某个作用域中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关善后工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,从而销毁该对象。

如果系统中的资源也具有如同局部对象一样的特性,自动获取,自动释放,那该多么美妙啊!既然类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。把资源放进对象内,用资源来管理对象,便是 C++ 编程中最重要的编程技法之一,即 RAII ,它是 "Resource Acquisition Is Initialization" 的首字母缩写。智能指针便是利用 RAII 的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针

综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。难怪微软的MSDN杂志在最近的一篇文章中承认:“若论资源管理,谁也比不过标准C++”。

说到这里,顺便 diss 一下 Java 的 GC 机制,表面来看,Java 似乎更优秀,因为从一开始你就不用考虑什么特殊的机制,大胆地往前 new ,自有 GC 替你收拾残局。 Java 的 GC 实际上是 JVM 中的一个独立线程,采用不同的算法策略来收集堆中那些不再有引用指向的垃圾对象所占用的内存。但是,通常情况下,GC 线程的优先级比较低,只有在当前程序空闲的时候才会被调度,收集垃圾。当然,如果 JVM 感到内存紧张了,JVM 会主动调用 GC 来收集垃圾,获取更多的内存。请注意,Java 的 GC 工作的时机是:1. 当前程序不忙,有空闲时间。2. 空闲内存不足。现在我们考虑一种常见的情况,程序在紧张运行之中,没有空闲时间给 GC 来运行,同时机器内存很大,JVM 也没有感到内存不足,结果是什么?对了 ,GC 形同虚设,得不到调用。于是,内存被不断吞噬,而那些早已经用不着的垃圾对象仍在在宝贵的内存里睡大觉。

反过来看看 C++ 利用智能指针达成的效果,一旦某对象不再被引用,系统刻不容缓,立刻回收内存。这通常发生在关键任务完成后的清理时期,不会影响关键任务的实时性,同时,内存里所有的对象都是有用的,绝对没有垃圾空占内存。

既然智能指针有如此多的好处,那我们还等什么,赶紧来学学它的用法吧!

2、智能指针的基本语法

C++11 中提供了三种智能指针,分别是 shared_ptr , unique_ptr 和 weak_ptr 。shared_ptr 允许多个指针指向同一个对象,unique_ptr 则“独占”所指向的对象,weak_ptr 则是和share_ptr 相辅相成的伴随类,具体用法后文细说。

这三种类型都定义在头文件memory中。类似vector,智能指针也是模板,需要在尖括号内给出类型信息。shared_ptr 和 unique_ptr 的使用方式和普通指针类似,都可以使用*->等运算符。

关于基本语法,读完下面这段代码,你一定会了然于胸的。

#include <iostream>
#include <memory>

class Person
{
public:
    Person(int v) 
    {
        value = v;
        std::cout << "Person " << value << " is constructed."<< std::endl;
    }
    ~Person() 
    {
        std::cout << "Person " << value << " is destructed." << std::endl;
    }

    int value;
};

int main()
{
    // 初始化方式 1,默认初始化的智能指针保存着一个空指针
    std::shared_ptr<Person> p1;

    // 初始化方式 2,使用标准库函数 make_shared
    // make_shared 传递的构造参数必须与 Person 的某个构造函数相匹配
    // 我们通常用 auto 来定义一个对象保存 make_shared 的结果,简洁易懂
    // Person 2 的引用计数为 1,p2 指向它
    auto p2 = std::make_shared<Person>(2);

    // 初始化方式 3,使用 new 返回的指针来初始化智能指针
    // 此种方式必须使用直接初始化
    // Person 3 的引用计数为 1,p3 指向它
    std::shared_ptr<Person> p3(new Person(3));

    // 将 shared_ptr 作为一个条件判断,若指向一个对象,则为 true
    // p1 没有指向一个对象,故 !p1 为 true
    if (!p1)
    {
        // 赋值操作会递增右侧操作数的引用计数,递减左侧操作数的引用计数
        // Person 2的引用计数为 2,p1、p2 指向它
        p1 = p2;
        // Person 2的引用计数为 1,p1 指向它
        // Person 3的引用计数为 2,p2、p3 指向它
        p2 = p3;

        // 解引用智能指针,获得它指向的对象
        // 输出结果为 Person 2
        std::cout << "This is Person " << (*p1).value << std::endl;
        // 输出结果为 Person 3
        std::cout << "This is Person " << p2->value << std::endl;

        // 交换 p1 和 p2 的指针
        // Person 2的引用计数为 1,p2 指向它
        // Person 3的引用计数为 2,p1、p3 指向它
        p1.swap(p2);

        // 当智能指针中有值的时候,调用 reset() 会使引用计数减 1
        // Person 3的引用计数为 1,p3 指向它
        p1.reset();
        // Person 3的引用计数为 0,被销毁
        p3.reset();
    }

    // unique_ptr 的初始化方式同 shared_ptr,不一一列出
    // make_unique 函数是 C++14 中才加入的
    // Person 4的引用计数为 1,p4 指向它
    auto p4 = std::make_unique<Person>(4);
    // Person 5的引用计数为 1,p5 指向它
    std::unique_ptr<Person> p5(new Person(5));

    // 某个时刻只能有一个 unique_ptr 指向一个对象
    // 所以,unique_ptr 不支持拷贝,也不支持赋值
    // std::unique_ptr<Person> p5(p4); // 错误,不支持拷贝
    // p5 = p4; // 错误,不支持赋值

    // release函数使得 p4 放弃对指针的控制权,返回指针并置 p4 为空
    // Person 4 的引用计数为 1,p6 指向它
    std::unique_ptr<Person> p6(p4.release()); 

    // weak_ptr 指向一个由 shared_ptr 管理的对象
    // 但不会改变 shared_ptr 的引用计数
    // weak_ptr 不控制所指对象的生存期,所以,即使有
    // weak_ptr指向对象,对象也还是会被释放
    std::weak_ptr<Person> p7(p2);

    // 由于 weak_ptr 所指对象可能不存在,所以我们不能用 weak_ptr
    // 直接访问对象,而必须调用 lock(),若不存在,则返回一个空 shared_ptr
    // 若存在,则返回weak_ptr所指对象的 shared_ptr
    // Person 2的引用计数为 2,p2、p8 指向它
    if (auto p8 = p7.lock())
    {
        // use_count() 函数返回共享 weak_ptr 所指对象的 shared_ptr 数量
        // 这里的输出结果为 2
        std::cout << p7.use_count() << std::endl;
    }

}

以上代码的输出结果为:

Person 2 is constructed.
Person 3 is constructed.
This is Person 2
This is Person 3
Person 3 is destructed.
Person 4 is constructed.
Person 5 is constructed.
2
Person 4 is destructed.
Person 5 is destructed.
Person 2 is destructed.
请按任意键继续. . .

为了更进一步透彻地理解智能指针的基本原理,我们有必要实现一个简单版本的智能指针(shared_ptr)来辅助理解。

3、自己实现一个简单的智能指针

智能指针(shared_ptr)能够自动释放所指向的对象,其实现原理却并不复杂。简单一说:

下面是一个基于引用计数的智能指针的实现,需要实现构造,析构,拷贝构造,=操作符重载,重载*->操作符。

#include <iostream>
#include <memory>

template<typename T>
class SmartPointer
{
private:
    T * _ptr;
    size_t* _count;
public:
    SmartPointer(T* ptr = nullptr) :
        _ptr(ptr) 
    {
        if (_ptr) 
        {
            _count = new size_t(1);
        }
        else 
        {
            _count = new size_t(0);
        }
    }

    SmartPointer(const SmartPointer &ptr)
    {
        if (this != &ptr)
        {
            this->_ptr = ptr._ptr;
            this->_count = ptr._count;
            ++(*this->_count);
        }
    }

    SmartPointer& operator=(const SmartPointer &ptr) 
    {
        if (this->_ptr == ptr._ptr)
        {
            return *this;
        }

        if (this->_ptr) 
        {
            --(*this->_count);
            if (this->_count == 0) 
            {
                delete this->_ptr;
                delete this->_count;
            }
        }

        this->_ptr = ptr._ptr;
        this->_count = ptr._count;
        ++(*this->_count);
        return *this;
    }

    T& operator*() 
    {
        assert(this->_ptr == nullptr);
        return *(this->_ptr);

    }

    T* operator->() 
    {
        assert(this->_ptr == nullptr);
        return this->_ptr;
    }

    ~SmartPointer() 
    {
        --(*this->_count);
        if (0 == *this->_count )
        {
            delete this->_ptr;
            delete this->_count;
        }
    }

    size_t use_count() 
    {
        return *this->_count;
    }
};

int main() 
{
        SmartPointer<int> sp(new int(10));
        SmartPointer<int> sp2(sp);
        SmartPointer<int> sp3(new int(20));
        sp2 = sp3;
        std::cout << sp.use_count() << std::endl;
        std::cout << sp3.use_count() << std::endl;
}

这个智能指针的简单实现模仿的是 share_ptr 的行为,不难发现,引用计数的存在会带来一些性能影响:

4、使用智能指针的一些注意事项

4.1 shared_ptr 的循环引用问题

shared_ptr 意味着你的引用和原对象是一个强联系。你的引用不解开,原对象就不能销毁。滥用强联系,这在一个运行时间长、规模比较大,或者是资源较为紧缺的系统中,极易造成隐性的内存泄漏,这会成为一个灾难性的问题。

更糟的是,滥用强联系可能造成循环引用的灾难。即:B持有指向A内成员的一个shared_ptr,A也持有指向B内成员的一个 shared_ptr,此时A和B的生命周期互相由对方决定,事实上都无法从内存中销毁。 更进一步,循环引用不只是两方的情况,只要引用链成环都会出现问题。

举个循环引用的简单例子。

#include <memory>

class B;
class A
{
public:
    std::shared_ptr<B> m_b;
};

class B
{
public:
    std::shared_ptr<A> m_a;
};

int main()
{
    while (true)
    {
        std::shared_ptr<A> a(new A); // new出来的A的引用计数此时为1
        std::shared_ptr<B> b(new B); // new出来的B的引用计数此时为1
        a->m_b = b; // B的引用计数增加为2
        b->m_a = a; // A的引用计数增加为2
    }

    // b先出作用域,B的引用计数减少为1,不为0,所以堆上的B空间没有被释放,
    // 且B持有的A也没有机会被析构,A的引用计数也完全没减少
    // a后出作用域,同理A的引用计数减少为1,不为0,所以堆上A的空间也没有被释放
}

如此一来,A和B都互相指着对方吼,“放开我的引用!“,“你先发我的我就放你的!”,于是悲剧发生了,内存泄漏了。当然循环引用本身就说明设计上可能存在一些问题,如果特殊原因不得不使用循环引用,那可以让引用链上的一方持用普通指针(或弱智能指针weak_ptr)即可。

这就是 weak_ptr 的用处。weak_ptr 提供一个(1)能够确定对方生存与否(2)互相之间生命周期无干扰(3)可以临时借用一个强引用(在你需要引用对方的短时间内保证对方存活)的智能指针。而 weak_ptr 要求程序员在运行时确定生存并加锁,这也是逻辑上必须的本征复杂度——如果别人活的比你短,你当然要:(1)先确定别人的死活(2)如果还活着,就给他续个命续到你用完了为止。

4.2 切记:让所有的智能指针都有名字

智能指针为解决资源泄漏、编写异常安全代码提供了一种解决方案,那么他是万能的良药吗?使用智能指针,就不会再有资源泄漏了吗?请看下面的代码:

//header file
void func(shared_ptr<T1> ptr1, shared ptr<T2> ptr2);

//call func like this
func(shared_ptr<T1>(new T1()), shared_ptr<T2>(new T2()));

上面的函数调用,看起来是安全的,但在现实世界中,其实不然:由于C++并未定义一个表达式的求值顺序,因此上述函数调用除了func在最后得到调用之外是可以确定,其他的执行序列则很可能被拆分成如下步骤:

  1. 分配内存给T1
  2. 分配内存给T2
  3. 构造T1对象
  4. 构造T2对象
  5. 构造T1的智能指针对象
  6. 构造T2的智能指针对象
  7. 调用func

此时,如果程序在第3步失败,那T1和T2对象所分配内存必然泄漏。而解决这个问题的方案也很简单,就是不要在函数实参中创建shared_ptr,抛弃临时对象,让所有的智能指针都有名字,就可以避免此类问题的发生。比如以下代码:

//header file
void func( shared_ptr<T1> ptr1, shared_ptr<T2> ptr2);

//call func like this
shared_ptr<T1> ptr1( new T1() );
shared_ptr<T2> ptr2( new T2() );

func(ptr1, ptr2);

4.3 优先选用make_unique(shared)而非直接使用new

简单说来,相比于直接使用new表达式,make系列函数有三个优点:

5、参考文献

  1. 书籍:C++ Primer(第五版)
  2. 书籍:Effective C++ (第三版)
  3. 书籍:Effective Modern C++
  4. 文章:垃圾收集机制(Garbage Collection)批判 . 孟岩
  5. 文章:RAII惯用法:C++资源管理的利器
  6. 文章:C++11中智能指针的原理、使用、实现
  7. 文章:weak_ptr这个智能指针有什么用?
  8. 文章:智能指针的死穴 -- 循环引用
  9. 文章:shared_ptr的一些尴尬

注:因Markdown没有方便的交叉引用,故没有在原文中具体指出何处引用了参考文献,但这并不影响我对教给我知识的作者们的感激之情!

上一篇 下一篇

猜你喜欢

热点阅读