文章 12
评论 4
浏览 32306
关于C++智能指针的一些思考和总结

关于C++智能指针的一些思考和总结

简介

  在C++语言的编程中,内存是一个非常重要的概念,其原因在于C++不像其他的高级语言(如java, python, go等)一样提供了内存回收机制,编程过程中在堆内存区域上申请的所有内存都必须手动管理和释放,管理不当的话极容易造成内存泄漏。在C++中,我们一般使用new操作符在堆上申请内存,如

std::string* p = new std::string("hello world");

这个语句使用new操作符在堆内存上构建了一个字符串对象,new操作符会返回一个指针供程序访问这个对象。需要注意的是,指针p是我们访问这个对象所在内存区域的唯一途径,当不需要使用这个对象时,应当使用delete操作符释放内存,即

delete p;

在后面的程序中,大致有三种情况会造成内存泄漏:1)某个线程修改了指针p的值而没有释放内存;2)程序在delete之前抛出异常,导致没有成功执行delete;3)p指针存在于栈内存上,离开指针所处的作用域时没有返回p的值,这时指针p被释放导致我们丢失了该字符串对象的内存地址。

  内存泄漏在C++程序中是一种危害性较大但又经常出现的问题,因为我们不能保证在任何时刻都记得去释放内存。正是为了减少内存泄漏以及降低管理内存的难度,c++标准库提供了智能指针为我们自动托管堆内存上的对象,这样我们就不需要手动去释放内存,这在一定程度上提高了程序的安全性和可读性。智能指针是C++ RAII机制非常具有代表性的一种应用,为了更好地理解智能指针的原理,在下一节先对c++ RAII机制做一个简要的介绍。

C++ RAII机制

  RAII的全称是“Resource Acquisition is Initialization”,也就是所谓的“资源获取即初始化”,是C++之父Bjarne Stroustrup提出的概念。RAII一直被认为是C++中管理资源的最佳方法,合理地使用RAII机制可以写出简洁且安全的c++代码。RAII利用了C++语言的一个非常重要的特性,即创建一个对象时会自动调用其构造函数,当程序离开这个对象的作用域时,会自动调用该对象的析构函数来释放资源。基于这个思想,RAII认为我们应当使用类来管理资源,将资源和对象的生命周期绑定。简单来说就是,用类将系统资源(如内存、文件句柄、套接字、互斥锁等)的申请和使用封装起来,当这个类的对象生命周期结束时,程序会自动调用其析构函数释放这个对象,如果将对象生命周期内申请的所有资源的释放操作都定义在析构函数中,那么随着对象被析构资源也会被释放,这样就无需手动去释放资源。

  事实上,除了智能指针以外,C++标准库中还有很多其他组件也使用了RAII来帮助资源管理。例如,在多线程并发的编程中,常常涉及到加锁和解锁的操作。当一个线程访问临界资源时,首先需要对这个资源对应的互斥锁进行加锁操作,访问完毕后需要解锁以供其他线程访问。然而手动进行加锁和解锁容易造成死锁现象,例如

std::mutex m;
try {
    m.lock();
    // 临界区代码
    m.unlock();
} catch (std::exception& e) {
    // 异常处理
}

设想这样一种情况,当前线程成功加锁,但在临界区突然抛出了一个异常,导致锁无法被释放,这样就极容易使得程序陷入死锁。为了方便进行安全的加锁解锁操作,标准库提供了锁管理器类 std::lock_guard,它的基本原理就是在构造函数中加锁,在析构函数中进行解锁,看下面的代码

{
    std::mutex m;
    std::lock_guard<std::mutex> lock(m);
    // 临界区代码
}

在c++中,一个大括号就是一个单独的作用域,在上面的代码中我们使用lock对象管理锁,而这个对象是声明在栈内存上的,当程序离开大括号的作用域时会自动调用其析构函数,对象的析构函数中则定义了解锁的操作。这样整个过程就被自动完成了,即使临界区抛出异常,对象lock也能够被析构,这在一定程度上减少了死锁的概率。

C++智能指针

  铺垫了这么多,终于可以进入本文的正题——C++智能指针了。C++11之后的标准库提供了 std::shared_ptrstd::unique_ptrstd::weak_ptr三种类型的智能指针类,使用时需要包含头文件<memory>,这些类都以模板的形式定义,下面简要介绍一下三种指针的使用和注意事项。

std::shared_ptr

使用

  shared_ptr允许多个指针指向同一个对象,它重载了->和*运算符,这意味着我们可以像使用一般的指针一样使用它。shared_ptr对象内部维护了一个引用计数器,对shared_ptr对象的每一次拷贝或赋值都会使得引用计数+1,指向同一个对象的shared_ptr被析构则会使得引用计数-1,当引用计数减为0时托管的对象就会被释放,shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。shared_ptr定义了拷贝和赋值的操作,还提供了reset()方法转移对象的所有权,看下面一个例子

#include <iostream>
#include <memory>
#include <string>

class Base {
    public:
        Base(std::string str): name(str) {} 
        ~Base() { std::cout << name << " destructed"<< std::endl; }
        std::string name;
};

int main()
{
    std::shared_ptr<Base> ptr1(new Base("object_0")); //test的对象由ptr1托管
    std::cout << ptr1.use_count() << std::endl;  //打印引用计数值
    std::shared_ptr<Base> ptr2(ptr1); //ptr2是ptr1的拷贝
    std::cout << ptr1.use_count() << std::endl;
    std::shared_ptr<Base> ptr3;
    ptr3 = ptr2; //赋值操作
    std::cout << ptr1.use_count() << std::endl;  
    std::cout << ptr3 -> name << std::endl; //使用常规指针一样使用智能指针
    Base* p = ptr1.get(); //返回ptr1中的对象指针
    std::cout << ptr3 -> name << std::endl;
    ptr1.reset(new Base("object_1"));
    ptr2.reset(new Base("object_2"));
    ptr3.reset(new Base("object_3"));
    // ptr1原本托管的对象引用计数减为0,被析构
    std::cout << "end of function scope" << std::endl;

}

使用gcc编译代码,程序运行结果为

1
2
3
object_0
object_0
object_0 destructed
end of function scope
object_3 destructed
object_2 destructed
object_1 destructed

  上面的代码行为很简单,首先我们用new操作符构建了一个Base对象,并用shared_ptr对象ptr1托管,此时引用计数为1。在这之后,分别使用拷贝和赋值的方式使得ptr2和ptr3对象也指向同一个对象,这两次操作分别都使引用计数增加1,这些智能指针对象都可以像常规的指针一样使用。如果使用reset()方法转移了三枚指针的指向,则他们原本指向的对象的引用计数会变成0,object_0对象会被析构(为了更好地展示这个析构过程,我们在Base类的析构函数中打印了一句话表示该对象被析构)。最后,程序离开main函数作用域,三个指针对象被析构,与此同时它们指向的对象也分别被析构。

注意事项

  • 除了向shared_ptr构造函数传入对象指针来初始化以外,还可以通过赋值和拷贝的方式初始化,但注意不能直接将指针赋值给对象,如 std::shared_ptr<int> p = new int(4);就是错误的用法。

  • 不应使用同一个对象的指针初始化多个shared_ptr对象,否则会造成对象被析构多次。

  • 避免循环引用,当两个对象分别使用shared_ptr成员变量指向对方会造成循环引用,看下面的代码

    #include <iostream>
    #include <memory>
    class A;
    class B;
    
    class A{
        public:
            std::shared_ptr<B> ptr;
            A(){}
            ~A(){ std::cout << "object A destructed"<< std::endl; }  
            //简单起见,成员变量定义为公有的
    }
    
    class B{
        public:
            std::shared_ptr<A> ptr;
            B(){}
            ~B(){ std::cout << "object B destructed"<< std::endl; }  
    }
    
    int main()
    {
        std::shared_ptr<A> pa(new A);
        std::shared_ptr<B> pb(new B);
        pa -> ptr = pb;
        pb -> ptr = pa;
        std::cout << pa.use_count() << std::endl;
        std::cout << pb.use_count() << std::endl;
    }
    

    这段代码就是一个典型的循环引用的例子,编译运行上面的程序会发现两个引用计数都是2,而两个对象都没有被析构,导致了内存泄漏。造成这种结果的主要原因是,对象A和B都分别有一个指向对方的shared_ptr指针,要析构对象A则必须先将对象B析构,要析构对象B则必须先将对象A析构,否则两者的引用计数都不会是0,这就造成了一个死锁的循环。一般情况下,可以使用后面将要介绍的weak_ptr来避免这种循环引用。

  • 在返回对象自身的指针时,不应当直接使用shared_ptr返回。由于C++中智能指针的引用计数器不是和托管对象放在一起的,因此在使用shared_ptr时应谨慎考虑对象的指针是否已经用于初始化过某个shared_ptr对象。假设类A提供了某个成员函数用于返回对象本身的指针,并且以下面的方式返回

    return std::shared_ptr<A> ptr(this);
    

    这种行为会造成一些意想不到的严重后果,使用this指针构造的ptr对象和原本指向这个对象的shared_ptr指针的引用计数器是相互独立的,这就可能造成对象被析构多次或者在使用时提前被析构。当然,通常情况下我们也不应当直接返回this指针,其原因在于异步IO调用中,我们无法确定回调函数线程的执行时间,如果该对象在主线程中就已经被释放,那么其他线程所持有的this指针就会指向一个已经被回收的内存区域,这时最好结果恐怕就是core dump了(这也告诉我们,大多数情况下我们不应混合使用智能指针和常规指针,否则会破坏智能指针的语义,造成一些难以预计的后果)。为了避免上面提到的问题,C++11之后标准库提供了 enable_shared_from_this类来延长对象生命周期,这是一个模板类,一般通过继承来使用,如

    class A: public std::enable_shared_from_this<A>
    {
        //类的代码
    };
    

    可以通过下面的方式来获得指向该对象的智能指针

    std::shared_ptr<A> new_ptr = p -> shared_from_this();
    

    这样就不会造成引用计数错误,其具体实现原理可参考网上的资料,这里不再赘述。

std::unique_ptr

  unique_ptr独占所指向的对象,同一时刻只允许一个unique_ptr指向托管对象。unique_ptr禁止了拷贝和赋值操作,这意味着我们无法通过拷贝和赋值的操作给unique_ptr指定托管对象,但可以使用c++11之后的移动语义来转移对象所有权

std::unique_ptr<int> ptr1(new int(0)); //正确,调用构造函数
std::unique_ptr<int> ptr2 = ptr1; //错误,没有赋值运算
std::unique_ptr<int> ptr3(ptr1); //错误,没有拷贝构造函数
std::unique_ptr<int> ptr3 = std::move(ptr1); //正确,使用移动语义
std::unique_ptr<int> ptr3(std::move(ptr1)); //正确,使用移动构造函数

unique_ptr也重载了->和*运算符,同时也提供了reset方法重置托管对象。根据unique_ptr的特性,当unique_ptr被析构或reset时,其托管的对象也会立即被析构。一旦unique_ptr成功创建,那么即使后面的程序抛出了异常,unique_ptr所指向的对象也一定会被析构,这保证了资源的安全申请和释放。

std::weak_ptr

  weak_ptr是为了配合shared_ptr而引入的一种智能指针,它没有重载->和*运算符,因而也不能像普通指针一样访问对象。weak_ptr是一种弱引用,只是以一个旁观者观察资源而不能共享资源,也不会增加引用计数。我们可以使用shared_ptr对象或另一个weak_ptr对象初始化

std::unique_ptr<int> wptr1(ptr); //ptr是一个shared_ptr对象,指向一个字符串对象
std::unique_ptr<int> wptr2 = ptr; //通过赋值操作初始化
std::unique_ptr<int> wptr3(wptr1); //使用另一个unique_ptr对象初始化
std::unique_ptr<int> wptr2 = wptr1; //赋值操作

weak_ptr提供了一个非常重要的方法lock()来使weak_ptr对象提升为shared_ptr来获得对资源的管理权,同时增加引用计数,通常可以以下面的方式使用

std::shared_ptr<A> ptr = wptr.lock(); //wptr是一个指向A对象的weak_ptr指针
if (ptr) {
    //若提升成功,则采取相应操作
} else {
    //提升失败,说明对象已被析构
}

使用weak_ptr可以解决悬垂指针和循环引用的问题:

  • 悬垂指针:如果线程A和B分别持有指针p1和p2,它们指向同一个对象,某个时刻线程A释放了p1指向的资源,然而线程A的行为线程B不得而知,故而线程B仍以p2访问资源,实际情况是p2指向的资源已被释放,现在它所指向的是一片未知的内存区域,p2就是所谓的悬垂指针。如果我们使线程A持有shared_ptr指针,线程B持有weak_ptr指针就可以解决这个问题,当线程B准备访问资源时,可以先调用lock方法尝试提升,若提升成功则正常访问,若提升失败则说明源对象已被析构。
  • 循环引用:前文提到的循环引用现象可以通过weak_ptr解决,只需将前文代码中的类A或B中指向对方的指针类型改为weak_ptr即可。

总结

  现代C++语言的编程中,为了保证代码的可读性、安全性和高效性,我们不应当使代码中出现显式的delete操作,取而代之的是使用标准库提供的智能指针来管理内存。同理,对其他计算机系统资源,我们也应当采用类似的RAII机制来进行管理。因c++语言复杂的语法特性和内存管理机制,编写出完全没有问题的c++程序是非常困难的一件事,但只要遵循相应的标准,养成良好的编程习惯,就可以有效减少错误的产生。

  C++11标准引入了非常多重要的特性,以C++11为分水岭,前后的语言特性和编程范式更是大相径庭,C++也绝对不是简单的“带类的c语言”。多练习和使用C++11的新特性,有助于改善程序的性能和可读性,更有利于我们写出简洁、高效且安全的代码。


标题:关于C++智能指针的一些思考和总结
作者:coollwd
地址:http://coollwd.top/articles/2019/12/30/1577689601487.html

Everything that kills me makes me feel alive

取消