C++学习笔记-智能指针

  1. 如果想用堆内存指针,则最好首选std::unique_ptr,它的大小与内置类型指针相同,且性能也几乎与内置类型指针差不多。如果内置类型指针对你来说足够小或者足够快,那么std::unique_ptr几乎可以肯定也能满足你的要求。不过,如果不使用默认的析构器,而是自定义析构器,则大小可能会变化。当自定义析构器是普通函数时,则大小至少加上函数指针的尺寸,如果自定义析构器是函数对象,则带来的尺寸变化取决于该函数对象中存储了多少状态。无状态的函数对象(如无捕获的lambda表达式)不会浪费任何存储空间。因此,如果需要自定义析构器,且无需传入状态,则最好使用无捕获的lambda表达式。同理,如果自定义析构器可能需要大量的状态,则可能使用函数比较好;

  2. 使用lambda表达式可能会比使用常规函数效率更高;

  3. C++14中,可以把智能指针的自定义析构器(一个lambda表达式)放在函数的内部封装起来,而不必暴露给外部;

  4. std::unique_ptr有两种形式,一种是单个对象(std::unique_ptr),一种是数组(std::unique_ptr)。单个对象的形式不提供索引运算符(operator[]),数组形式不提供解引用运算符(operator*operator->)。数组形式的不支持子类到基类的指针类型转换;

  5. std::unique_ptr经常用于工厂函数,用于返回对创建的对象。因为std::unique_ptr可以直接转换为std::shared_ptr(直接用std::unique_ptr对象初始化std::shared_ptr对象即可,原对象就会解除对象所有权),因为工厂函数并不知道调用者是对其返回的对象采用专属所有权语义好还是共享所有权语义更合适,通过返回一个std::unique_ptr,外部调用者可以根据自己需要来获取自己需要的智能指针类型;

  6. 引用计数的存在导致std::shared_ptr在内存占用和效率方面没有std::unique_ptr好:

    • std::shared_ptr的尺寸是裸指针的两倍;
    • 引用计数的内存必须动态分配;
    • 引用计数的递增和递减必须是原子操作。

    此外,由于移动操作不涉及引用计数的递增和递减,因此std::shared_ptr的移动操作要比其复制操作快。并且由于其内部有一个控制块(存储引用计数,弱计数,自定义的删除器,分配器等),因此其总的存储空间(不是仅仅指std::shared_ptr对象的存储空间)通常更大;

  7. std::unique_ptr中自定义的析构器是智能指针类型的一部分,而对于std::shared_ptr,则不是其类型的一部分:

    auto del1 = [](T* t){...};
    auto del2 = [](T* t){...};
    
    std::unique_ptr<T, decltype(del1)> up(nullptr, del1); // 既要设置模板实参,又要传入构函实参
    std::shared_ptr<T> sp(nullptr, del2); // 只需要设置构函实参即可
    
    
  8. 虽然std::shared_ptr增加自定义析构器并不会增加std::shared_ptr对象的尺寸(其尺寸始终是裸指针的两倍),但是增加的额外的析构器的内存占用的确是存在的,这些内存会在堆内存中,与引用计数所在内存相邻;

  9. std::shared_ptr在处理this指针时候特别要注意,假如有这样的需求:一个自定义类中有一个函数用来处理对象本身,然后将处理好的对象存储到一个智能指针容器中:

    std::vector<std::shared_ptr<MyClass>> Porcessed;
    class MyClass
    {
    	......
    	void process()
    	{
    		...... // 处理
    		Processed.emplace_back(this); // 将已经处理完的对象指针存储起来
    	}
    	......
    };
    
    std::shared_ptr<MyClass> test;
    ......
    test->process();
    	
    

    如代码所示,在process()函数中将this指针传给了Porcessed,然后编译器会根据这个裸指针创建一个对应的智能指针对象。然而,这里会有问题,因为这个this裸指针所指向的对象已经被test这个智能指针包装过了,此时在process()中相当于通过同一个裸指针又创建了一个智能指针对象,那么当析构的时候,就会删除两次,导致出错(一个裸指针只能被一个智能指针对象包装,否则会出错)。这种情况下需要使用C++11标准中的std::enable_shared_frome_this来解决:

    std::vector<std::shared_ptr<MyClass>> Porcessed;
    class MyClass : public std::enable_shared_from_this<MyClass> // 1
    {
    	......
    	void process()
    	{
    		...... // 处理
    		Processed.emplace_back(shared_from_this()); // 将已经处理完的对象指针存储起来 // 2
    	}
    	......
    };
    
    std::shared_ptr<MyClass> test; // 3
    ......
    test->process();
    	
    

    也就是将自定义类型作为子类(代码1处),继承于上述类型(这是一种设计模式:递归模板模式,CRTP),基类中有一个函数叫做shared_from_this(),每当你需要一个和this指针指向相同对象的shared_ptr对象时,都可以在成员函数中使用它(代码2处),这个函数本身会返回基类的智能指针对象。这样,就不会出现一个裸指针初始化两个智能指针的情况了。不过,这样的话,就需要在调用成员函数process()前,已经外部创建了一个智能指针来包装此对象了(比如代码3处),否则,如果此MyClass对象没有被包装在std::shared_ptr中,遇到调用shared_from_this()就会抛出异常。因此,这种情况下一般会把此MyClass类的构造函数声明为private的,不允许用户创建此对象,而是开放一个工厂函数接口,使工厂函数返回一个MyClass的std::unique_ptr或者std::shared_ptr对象。这样,外部得到的MyClass对象一定是被智能指针包装的;

  10. std::shared_ptr不支持数组类型,虽然可以通过巧妙的方式实现存储数组类型,但是这通常意味着拙劣的设计(有那么多容器你不用,非要用这个?!!!);

  11. 使用std::weak_ptr时(即将std::weak_ptr转成std::shared_ptr时),有两种方式,第一种是调用std::weak_ptrlock()函数,如果std::weak_ptr已经失效,则lock()返回nullptr,否则返回一个std::shared_ptr。第二种方式是直接用std::weak_ptr来构造初始化一个std::shared_ptr,此时如果std::weak_ptr失效,则直接抛出异常;

  12. std::weak_ptr可用于缓存版本的工厂函数(当工厂函数创建一个新的对象非常耗时的时候,比如可能涉及到IO操作或者数据库操作,则可以将某次创建的对象缓存起来,然后等到没用的时候自动消除),此时工厂函数返回值只能是std::shared_ptr

    std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
    {
    	static std::unordered_map<WidgetID, std::weak_ptr<const Widget> cache;
    	
    	auto objPtr = cache[id].lock(); // 得到智能指针
    	if (!objPtr) // 此对象已经被析构了
    	{
    		objPtr = loadWidget(id); // 就重新加载
    		cache[id] = objPtr; // 并缓存
    	}
    	
    	return objPtr;
    }
    
    

    虽然上述实现有个问题,就是cache可能会存积非常多的无效std::weak_ptr,这里是用来说明std::weak_ptr的可能用处。另外,在观察着设计模式中,很适合使用std::weak_ptr,可以在每个“主题”中创建一个指向“观察着”std::weak_ptr的容器。最后,当std::shared_ptr发生环路引用时候(即A对象中数据成员引用了B对象的std::shared_ptr,且B对象中数据成员引用了A对象的std::shared_ptr,相互引用),此时可以使其中一个引用使用std::weak_ptr,这样就不会发生内存泄漏了;

  13. 在类似于树这种严格继承体系的数据结构中,一般使用std::unique_ptr来保存子节点的指针,而子节点使用裸指针来保存父节点的指针。因为子节点通常只被其父节点拥有,当父节点被析构后,子节点也应被析构。由此,子节点的生存期不会比父节点的更长,所以使用裸指针不会出现异常(此处虽然也属于环路引用,但是一般不使用std::weak_ptr)。当然,并非所有的基于指针的数据结构都是严格的继承体系,在非严格的继承体系的数据结构中,以及缓存和观察着的列表实现等情况下,std::weak_ptr就非常适用了;

  14. 再总结一下,std::weak_ptr可能的用武之地包括缓存、观察者列表、以及避免std::shared_ptr指针环路;

  15. 尽量使用make系列函数来初始化智能指针(std::make_sharedstd::make_uniquestd::allocate_shared)。使用make系列函数,可以提高异常安全:

    void ProcessWidget(std::shared_ptr<Widget> spw, int priority) {...}
    int computePriority() {...}
    
    ProcessWidget(std::shared_ptr<Widget>(new Widget), computePriority());
    

    在上述代码的最后,实参的求值顺序可能是先new一个Widget,然后执行computePriority(),最后再将new出来的对象初始化成一个智能指针。如果执行computePriority()过程中出现异常,则上一步new出来的对象就无法被释放,导致内存泄漏。如果用的是std::make_shared()作为第一个实参,则要么先执行std::make_shared()创建出一个智能指针对象,要么先执行computePriority()。此时即使先执行computePriority()并抛出异常,智能指针中的指针所有权已经有智能指针对象来管理了,会在必要的时候析构,不会发生内存泄漏。这个对于std::unique_ptr及其make函数也是一样的;

  16. std::unique_ptr唯一两个情况下不能使用make函数,一个是需要指定自定义析构器的情况,另一个是希望直接传递大括号初始化列表来初始化对象的情况(第二种情况可以先用单独的表达式创建一个初始化列表变量,然后再将此变量传递给make函数就可以了);

  17. std::shared_ptr不能使用make函数的情况,除了有两个情况与上述std::unique_ptr一样之外,还有额外两种情况。其一是当自定义类定义了自身版本的operator newoperator delete的时候,不应该使用make系列函数。第二个是自定义类的体积非常大,而内存非常紧张,且存在生存期很久的std::weak_ptr对象的时候;

  18. 使用make函数初始化智能指针(这里指的是std::shared_ptr类型,不是std::unique_ptr类型)时,内存分配只分配一次,存储的值与控制块(存储了引用计数,弱计数,删除器信息等等)会在一个内存块中。而如果使用new来初始化智能指针,则存储的对象需要在堆上单独分配内存,然后控制块又需要单独分配内存,因此就分配了两次内存,效率变低,所以尽量使用make系列函数;

  19. 当使用new来初始化std::shared_ptr时,由于存储的值与控制块不在同一内存块上,因此当有一个std::weak_ptr引用它时,且此std::shared_ptr被销毁时,此时控制块的内存并没有被释放,只是存储值的内存块被释放了。但是,如果使用的是make函数创建的,则由于存储的值与控制块的内存是同一块内存,一起分配的,因此如果此时std::weak_ptr存在但是无效了,此时内存并没有被释放,整个内存还在,直到std::weak_ptr也析构时才释放;

  20. 再强调一下,使用make函数创建std::shared_ptr会比使用new来初始化一个std::shared_ptr占用总内存更小。然而,当有很多std::shared_ptr都被std::weak_ptr引用时,且std::weak_ptr对象的生存周期比对应的std::shared_ptr常的时候,此时使用new来初始化可能会好点;

  21. 如果使用PIMPL方式来实现类(即在类中使用一个指针来指向一个结构体,结构体中包含了此类所需要的所有的数据成员,而此结构体的定义在类的实现文件中,不在头文件中,这种用法是一种可以在类的实现与类的使用者之间减少编译依赖性的方法),并且类中的指针使用的是智能指针(应该选用std::unique_ptr,因为需要专属所有权),则即使此类不需要显式定义析构函数,也一定要在上述结构体的定义的代码的下方加上析构函数的定义(比如可以~MyClass() = default;),不然代码编译不通过。此外,如果要类支持移动操作(使用PIMPL的类其实理应自动支持移动操作的,因为用的智能指针天然支持,但是由于自定义了析构函数,所以移动操作不再支持了,需要主动定义),则也必须将移动操作定义到上述结构体代码后面的位置(不是头文件中哟!)。如果还想让类有复制操作,则也需要将这些复制操作放到结构体的定义下方。不过,如果类中使用的是std::shared_ptr,则就不需要这些注意事项了;

  22. 注意,当容器中保存智能指针对象时,绝不应该将new T表达式直接传给容器的push_back()emplace_back()中,而是应该先创建一个临时智能指针对象,再传入进去。因为如果直接传递裸指针的话,则可能由于完美转发的原因,导致还未创建出智能指针的时候,就出现的异常,导致最后裸指针对应的内存无法释放。如果是先创建临时的智能指针对象,因为传递的是智能指针对象,所以即使容器的操作出现异常,智能指针对象在析构的时候也会自动释放内存。

你可能感兴趣的:(C/C++,编程技巧,智能指针,c++,c++11,c++14)