[原创]C++基础系列—智能指针

如果你熟悉 Java 或者 C# ,那么以下代码你肯定不陌生

class Students { public int value = 0; }

void main()
{
 Students a = new Students();
 Students b = a;
 a.value = 10;
//此时,变量 a 和变量 b 的 value 值都是 10
}

很简单,生成了一个Students对象,变量 a 和变量 b 都是对该对象的引用;但是在C++ 中,结果并不是这样

class Students
{
 public:
    int value = 0;
};

void main()
{
    Students a;
    Students b = a;
    a.value = 10;
    //此时,变量 a 的 value 是10,变量 b 的 value 仍是 0
}

编译器会为 class 默认生成 copy constructor 和 assignment operator,C++ 对象默认是可以拷贝的 ,因此 b = a,相当于拷贝了一份全新的对象。那要怎么实现类似Java 或 C# 的效果呢?C++ 就发明了指针这么个东西,它就是指向对象实际存储在内存中的地址,用法如下:

int main()
{
    Students* a = new Students();
    Students* b = a;
    a->value = 5;
    //此时,变量 a 和 b 的value 都是 5
    //delete a;
}

new做的事情就是,调用底层 malloc 获取 Students 类型大小的内存地址(一般会对字节补齐),然后调用 Students 的构造函数(如果没有,编译器会自动补全空构造),返回该对象的内存地址。从此,内存上的这一块地址就永远被占用,除非调用 delete 函数释放。但如果 delete 过早,其他函数还在用这个指针,就可能导致程序崩溃

void foo(Students* ptr)
{
    ptr->value = 10; //crash
}

void main()
{
    Students* ptr = new Students();
    delete ptr;
    foo(ptr);
}

使用裸指针(raw pointer)最大的问题就是,你不知道它当前的所有权(ownership)属于谁,因此也就很难判断应该在哪个位置 delete 它。C++ 编委会的巨佬们本着为了让程序员少掉头发的原则,设计了一个封装指针的类,同时赋予它一个响亮的名字: 智能指针( Smart Pointers )

既然也叫 “指针”,那么就要求它用起来跟普通指针没区别。因此这个封装裸指针的类重载了很多操作符,比如:* ,& ,bool ,== 等。针对各种使用场景,一共设计了三种智能指针类 :

  • std::unique_ptr(single ownership)
  • std::shared_ptr (shared ownership)
  • std::weak_ptr ( no ownership ,与 shared_ptr 伴生关系)

针对上面会崩溃的问题,我们代码改下如下:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //no crash
}

void main()
{
    auto ptr =std::make_unique<Students>();
    foo(std::move(ptr)); 
}
// no memory leak

崩溃解决,同时也不会造成内存泄漏。构造 unique_ptr 的官方推荐的写法是: auto ptr = std::make_unique<class>(); 转移 ownership 的写法是 std::move(ptr); 为了能更详细的理解,我们来写个小实例,并打印相关讯息:

class Students
{
public:
    Students(){ std::cout << "Students初始化创建" << std::endl; }
    ~Students() { std::cout << "Students析构销毁" << std::endl; }
};

int foo(std::unique_ptr<Students> ptr2)
{
    std::cout << "进入foo函数" << std::endl;
    std::cout << "退出foo函数" << std::endl;
}

int main()
{
    std::cout << "进入main函数" << std::endl;
    //注释段1
    //{
        //std::cout << "进入main函数scope1" << std::endl;
        //auto ptr1 = std::make_unique<Students>();
        //std::cout << "退出main函数scope1" << std::endl;
    //}

    //注释段2
    //auto ptr2 = std::make_unique<Students>();
    //foo(std::move(ptr2));
    std::cout << "退出main函数" << std::endl;
}

打开注释段1,打印如下左边;打开注释段2,打印如下图右边

可以看出 ptr1 的 ownership 属于 block scope,scope代码执行完以后, ptr1 自动执行析构函数;ptr2 的 ownership 一开始属于 main 函数, 通过 std::move 转移给 foo 函数,因此一旦 foo 函数生命周期结束,ptr2 也会自动执行析构函数

看似很完美,然而 unique 的意思是唯一,也就是说这种智能指针的 ownership 只能唯一,因此如下代码,就会崩溃:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10;
}

void bar(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //ptr is empty, crash
}

int main()
{
    auto ptr = std::make_unique<Students>();
    foo(std::move(ptr));
    bar(std::move(ptr));
}

原因是什么呢?std::move(ptr) 的意思就是把 ptr 的 ownership 从 main 函数转移给 foo 函数,转移后 ptr 也就没有 ownership了,自动析构了; 针对这种情况,我们就需要用到 shared_ptr , 使用规范如下:

跟 unique_ptr 相比,多了一个use_count。因为它就是用来记录,当前有多少个 ownership,为什么要记录个数呢?因为只有确定它最后一个 ownership 的生命周期结束以后,它才能自动析构。针对上面那个问题,我们用shared_ptr改写:

void foo(const std::shared_ptr<Students> ptr)
{
    puts("enter foo");
    std::cout << ptr.use_count() << std::endl;
    puts("exit foo");
}

int main()
{
    puts("enter main");
    const auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() <<std::endl;
	
    {
	puts("enter scope");
	auto ptr_scope1 = ptr;
	std::cout << ptr.use_count() << std::endl;
	auto ptr_scope2 = std::move(ptr_scope1);//transfer ownership
	std::cout << ptr.use_count() << std::endl;
	puts("exit scope");
     }
	
    std::cout << ptr.use_count() << std::endl;
    foo(ptr);
    std::cout << ptr.use_count() << std::endl;
    puts("exit main");
}

打印结果如下:

也许下面这张图,你能理解的更透彻

shared_ptr 的 use_count 的增减做了防 data_race ,因此它默认支持多线程。目前来看,unique_ptr 和 shared_ptr 好像可以解决所有问题,为什么还需要一个 weak_ptr 呢?那么来看下面的情况:

class Students
{
public:
    Students() { puts("Students create"); }
    ~Students() { puts("Students destroy"); }
    std::shared_ptr<Students> next;
    //std::weak_ptr<Students> next;
};

int main()
{
    auto ptr_1 = std::make_shared<Students>();
    auto ptr_2 = std::make_shared<Students>();
    ptr_1->next = ptr_2;
    ptr_2->next = ptr_1;
    std::cout << ptr_1.use_count() << std::endl;
    std::cout << ptr_2.use_count() << std::endl;
}

//print:
//Student create
//Student create
//2
//2

哦豁,并没有打印 Student destroy;也即是说出现了 memory leak;智能指针失灵了?问题就出在赋值的那两句代码导致 use_count = 2,main函数退出后,use_count 仍然为1,因此不会执行析构。因此如果有一种指针,我想用shared_ptr,但又不想它 use_count 有变化,只是在用的时候,检查一下它还在不在,那不就完美了吗?因此,上面的注释打开,就能正确析构。详细用法如下:

    auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() << std::endl;
    {
	//必须从 shared_ptr 构造(不会增加 shared_ptr 的 use_count)
	std::weak_ptr<Students> ptr_w = ptr;
		
	//必须从weak_ptr 重新转换成 shared_ptr 再使用
	//ptr的 use_cunt + 1
	std::shared_ptr<Students> ptr_w2s = ptr_w.lock();
	if(ptr_w2s)
	{
	    std::cout << ptr.use_count() << std::endl;
	}
    }	
    std::cout << ptr.use_count() << std::endl;
    //print: 1 2 1

打印结果与预期的结果一致。因此,很多时候,为了避免循环引用,一般shared_ptr 和 weak_ptr 配合使用。官方给出的使用建议是:

  • 尽量使用智能指针替代裸指针
  • 优先使用std::unique_ptr 而不是 shared_ptr (可以想想为什么?)
  • 尽可能 std::move(share_ptr)而不是直接 foo(share_ptr) (理由同上)

内容参考:

  1. B站 花花酱的表世界 Smart Pointers 智能指针 – C++ Weekly EP3
  2. 知乎用户 sin1080 关于 c++ 11 的shared_ptr多线程安全?的优秀回答
  3. C++ ownership semantics