Fork me on GitHub

c++之智能指针

前言

内存管理是C++中的一个常见的错误和bug来源。在大部分情形中,这些bug来自动态分配内存指针的使用

  • 当多次释放动态分配的内存时,可能会导致内存损坏或者致命的运行时错误;
  • 当忘记释放动态分配的内存时,会导致内存泄露。

所以,我们需要智能指针来帮助我们管理动态分配的内存。其来源于一个事实:栈比堆要安全的多,因为栈上的变量离开作用域后,会自动销毁并清理。智能指针结合了栈上变量的安全性和堆上变量的灵活性。

考虑下面一个函数:

void someFunction()
{
  Resource* ptr = new Resource; // Resource是一个类或者结构
  // 使用ptr处理
  // ...
  delete ptr;
}

上面代码中申请了一份动态内存,并在函数退出前释放该内存。在实际中可能出现一些情况,使得在内存释放前退出了函数,从而造成内存泄露。例如:

void someFunction()
{
  Resource* ptr = new Resource; // Resource是一个类或者结构

  int x;
  std::cout << "Enter an integer: ";
  std::cin >> x;

  if (x == 0)
      return;  // 函数终止,无法释放ptr
  
  if (x < 0)
      throw;   // 出现异常,函数终止,无法释放ptr
  // 使用ptr处理
  // ...
  delete ptr;
}

由于过早的return语句以及异常的抛出,ptr将得不到正确释放,从而出现内存泄露。归根到底,指针并没有一个内在机制来自动管理与释放。

AutoPtr1类

于是我们想到了类:在构造函数中得到指针,在析构函数中释放该指针,从而实现资源的自动管理。只要该资源管理类是局部变量,一旦该变量超出了其作用域,该类的析构函数就能够被调用,从而释放资源。

#include <iostream>
#include <string>

template <typename T>
class AutoPtr1
{
public:
  AutoPtr1(T* ptr = nullptr) : m_ptr(ptr)
  {
  }

  virtual ~AutoPtr1()
  {
    delete m_ptr;
  }

  T& operator*()
  {
    return *m_ptr;
  }

  T* operator->()
  {
    return m_ptr;
  }

private:
  T* m_ptr;
};

class Resource
{
public:
  Resource()
  {
    std::cout << "Resource acquired!" << std::endl;
  }
  virtual ~Resource()
  {
    std::cout << "Resource destoryed!" << std::endl;
  }
};

int main()
{
  {
    AutoPtr1<Resource> res(new Resource);
  }

  return 0;
}

结果

Resource acquired!
Resource destoryed!

我们将动态申请的资源交给一个类变量来保存,由于类变量在局部作用域,其离开后将会自动调用析构函数,然后释放内存。同时,不论其是如何离开作用域的,即使出现异常,析构函数一定会被执行,内存也一定得到释放,因为该类变量是保存在栈上的。

但是上面的实现却有致命的隐患,考虑下面的代码:

void passByValue(AutoPtr1<Resource> res)
{
}

int main()
{
  {
    AutoPtr1<Resource> res(new Resource);
    AutoPtr1<Resource> res2(res); // 情况1
    passByValue(res);  // 情况2
  }

  return 0;
}

结果

Resource acquired!
Resource destoryed!
[1]    4897 segmentation fault (core dumped)

对于上述的两种情况,调用了默认的复制构造函数。复制的是指针而不是指针指向的内容,也就是浅拷贝,因此存在多个智能指针指向同一块内存。当变量离开作用域时,同一块内存会被多次释放,从而造成程序的奔溃。

AutoPtr2类

解决以上问题需要自己实现复制构造函数和复制运算符函数。使得指针的所有权从一个对象转移到另外一个对象,那么上面的问题将迎刃而解。修改的智能指针类如下:

#include <iostream>
#include <string>

template <typename T>
class AutoPtr2
{
public:
  AutoPtr2(T* ptr = nullptr) : m_ptr(ptr)
  {
  }

  virtual ~AutoPtr2()
  {
    delete m_ptr;
  }

  AutoPtr2(AutoPtr2& rhs)
  {
    m_ptr = rhs.m_ptr;
    rhs.m_ptr = nullptr;
  }

  AutoPtr2& operator=(AutoPtr2& rhs)
  {
    if (&rhs == this)
      return *this;

    delete m_ptr;
    m_ptr = rhs.m_ptr;
    rhs.m_ptr = nullptr;
    return *this;
  }

  T& operator*()
  {
    return *m_ptr;
  }
  T* operator->()
  {
    return m_ptr;
  }
  bool isNull() const
  {
    return m_ptr == nullptr;
  }

private:
  T* m_ptr;
};

class Resource
{
public:
  Resource()
  {
    std::cout << "Resource acquired!" << std::endl;
  }
  virtual ~Resource()
  {
    std::cout << "Resource destoryed!" << std::endl;
  }
};

int main()
{
  AutoPtr2<Resource> res1(new Resource);
  AutoPtr2<Resource> res2;  // 初始化为nullptr
  std::cout << "res1 is " << (res1.isNull() ? "null" : "not null") << std::endl;
  std::cout << "res2 is " << (res2.isNull() ? "null" : "not null") << std::endl;

  res2 = res1;  // 转移指针所有权
  std::cout << "Ownership transferred" << std::endl;
  std::cout << "res1 is " << (res1.isNull() ? "null" : "not null") << std::endl;
  std::cout << "res2 is " << (res2.isNull() ? "null" : "not null") << std::endl;

  return 0;
}

结果

Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed

从上面代码可以看到,在复制构造函数和复制运算符函数中实现的是移动语义,其将转移对象所有权,而不是进行赋值。由于在C++11之前,并没有右值引用,所以没有机制实现移动语义,也就是移动构造函数和移动运算符函数,所以C++11之前的智能指针是std::auto_ptr,其实现就类似于AutoPtr2类。但是其存在很多问题。

  • 首先如果函数中存在std::AutoPtr2类型的参数,你使用一个变量进行传值时,资源所有权将会被转移,那么函数结束后资源将被销毁,然后你可能解引用这个变量,但实际上它已经是空指针了,因此程序可能崩溃。
  • 其次,std::auto_ptr内部调用的是非数组delete,那么对于动态分配的数组,std::auto_ptr无法正常工作,可能会出现内存泄露。
  • 最后,std::auto_ptr对STL不兼容,因为STL的对象在进行复制时,就是进行复制,而不是移动语义。

所以实际上,在std::auto_ptrC++11中已经被弃用了,并且在C++17中被移除标准库。

AutoPtr3类

基于C++11中的右值引用与移动语义,我们可以解决上面出现的大部分问题:

#include <iostream>
#include <string>

template <typename T>
class AutoPtr3
{
public:
  AutoPtr3(T* ptr = nullptr) : m_ptr{ ptr }
  {
  }

  AutoPtr3(const AutoPtr3& rhs) = delete;

  AutoPtr3(AutoPtr3&& rhs) : m_ptr{ rhs.m_ptr }
  {
    rhs.m_ptr = nullptr;
  }

  AutoPtr3& operator=(const AutoPtr3& rhs) = delete;

  AutoPtr3& operator=(AutoPtr3&& rhs)
  {
    if (this == &rhs)
    {
      return *this;
    }
    std::swap(m_ptr, rhs.m_ptr);
    return *this;
  }

  virtual ~AutoPtr3()
  {
    delete m_ptr;
  }

  T& operator*()
  {
    return *m_ptr;
  }
  T* operator->()
  {
    return m_ptr;
  }

  bool isNull() const
  {
    return m_ptr == nullptr;
  }

private:
  T* m_ptr;
};

可以看到AutoPtr3实现了移动构造函数与移动赋值操作符的重载,进而实现了移动语义,但是同时禁用了复制构造函数与复制赋值运算符,因此这个类的变量仅可以通过仅可以传递右值,但是不能传递左值。当你传递右值时,那么明显地你已经知道要转移指针所有权了,那么当前变量将不再有效。在C++11中有类似的实现,那就是std::unique_ptr,当然更智能了。

C++11标准库中含有四种智能指针:std::auto_ptr(不要使用), std::unique_ptr, std::shared_ptrstd::weak_ptr。下面我们逐个介绍后面三个智能指针。

std::unique_ptr

std::unique_ptrstd::auto_ptr的替代品,其用于不能被多个实例共享的内存管理。这就是说,仅有一个实例拥有内存所有权。它的使用很简单:

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

class Fraction
{
private:
  int m_numerator = 0;
  int m_denominator = 1;

public:
  Fraction(int numerator = 0, int denominator = 1) : m_numerator(numerator), m_denominator(denominator)
  {
  }

  friend std::ostream& operator<<(std::ostream& out, const Fraction& f1)
  {
    out << f1.m_numerator << "/" << f1.m_denominator;
    return out;
  }
};

int main()
{
  std::unique_ptr<Fraction> f1(new Fraction{ 3, 5 });
  std::cout << *f1 << std::endl;  // output: 3/5

  std::unique_ptr<Fraction> f2;  // 初始化为nullptr

  // f2 = f1 // 非法,不允许左值赋值
  f2 = std::move(f1);  // 此时f1转移到f2,f1变为nullptr

  // // C++14 可以使用 make_unique函数
  // auto f3 = std::make_unique<Fraction>(2, 7);
  // std::cout << *f3 << std::endl;  // output: 2/7

  // // 处理数组,但是尽量不用这样做,因为你可以用std::array或者std::vector
  // auto f4 = std::make_unique<Fraction[]>(4);
  // std::std::cout << f4[0] << std::endl;  // output: 0/1

  return 0;
}

可以将std::unique_ptr对象传递给函数,一种方法是通过常量左值引用,另一种方法是通过右值引用的方法。

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

class Resource
{
public:
  Resource()
  {
    std::cout << "Resource acquired!" << std::endl;
  }
  virtual ~Resource()
  {
    std::cout << "Resource destoryed!" << std::endl;
  }

  friend std::ostream& operator<<(std::ostream& out, const Resource& res)
  {
    out << "I am a resource" << std::endl;
    return out;
  }
};

// 使用const左值引用
void useResource(const std::unique_ptr<Resource>& res)
{
  if (res)
  {
    std::cout << *res;
  }
}

// 使用右值引用
void takeOwnerShip(std::unique_ptr<Resource>&& res)  // 也可以用 std::unique_ptr<Resource> res
{
  if (res)
  {
    std::cout << *res;
  }
}

int main()
{
  {
    std::unique_ptr<Resource> ptr(new Resource());
    useResource(ptr);
  }

  {
    std::unique_ptr<Resource> ptr(new Resource());
    takeOwnerShip(std::move(ptr));
  }
  return 0;
}

结果

Resource acquired!
I am a resource
Resource destoryed!
Resource acquired!
I am a resource
Resource destoryed!

使用std::unique_ptr可能犯的两个错误是:

// 千万不要用同一个资源来初始化多个std::unique_ptr对象
Resource *res = new Resource;
std::unique_ptr<Resource> res1(res);
std::unique_ptr<Resource> res2(res);

// 不要混用普通指针与智能指针
Resource *res = new Resource;
std::unique_ptr<Resource> res1(res);
delete res;

std::unique_ptr还有几个常用的方法:

  • release():返回该对象所管理的指针,同时释放其所有权;
  • reset():析构其管理的内存,同时也可以传递进来一个新的指针对象;
  • swap():交换所管理的对象;
  • get():返回对象所管理的指针;
  • get_deleter():返回析构其管理指针的调用函数。

std::shared_ptr

std::shared_ptrstd::unique_ptr类似。要创建std::shared_ptr对象,可以使用make_shared()函数(C++11是支持的,貌似制定这个标准的人忘了make_unique(),所以在C++14追加了)。std::shared_ptrstd::unique_ptr的主要区别在于前者是使用引用计数的智能指针。引用计数的智能指针可以跟踪引用同一个真实指针对象的智能指针实例的数目。这意味着,可以有多个std::shared_ptr实例可以指向同一块动态分配的内存,当最后一个引用对象离开其作用域时,才会释放这块内存。还有一个区别是std::shared_ptr不能用于管理C语言风格的动态数组,这点要注意。下面看例子:

int main()
{
  auto ptr1 = std::make_shared<Resource>();
  std::cout << ptr1.use_count() << std::endl;  // output: 1
  {
    auto ptr2 = ptr1;                  // 通过复制构造函数使两个对象管理同一块内存
    std::shared_ptr<Resource> ptr3;    // 初始化为空
    ptr3 = ptr1;                       // 通过赋值,共享内存
    std::cout << ptr1.use_count() << std::endl;  // output: 3
    std::cout << ptr2.use_count() << std::endl;  // output: 3
    std::cout << ptr3.use_count() << std::endl;  // output: 3
  }
  // 此时ptr2与ptr3对象析构了
  std::cout << ptr1.use_count() << std::endl;  // output: 1

  return 0;
}

可以看到,通过复制构造函数或者赋值操作符函数来共享内存,知道这一点很重要,看下面的例子:

int main()
{
  Resource* res = new Resource;
  std::shared_ptr<Resource> ptr1{ res };
  std::cout << ptr1.use_count() << std::endl;  // output: 1
  {
    std::shared_ptr<Resource> ptr2{ res };  // 用同一块内存初始化

    std::cout << ptr1.use_count() << std::endl;  // output: 1
    std::cout << ptr2.use_count() << std::endl;  // output: 1
  }
  // 此时ptr2ptr3对象析构了, output:Resource destroyed
  std::cout << ptr1.use_count() << std::endl;  // output: 1

  return 0;
}

很奇怪,ptr1与ptr2虽然是用同一块内存初始化,但是这个共享却并不被两个对象所知道。这是由于两个对象是独立初始化的,它们互相之间没有通信。当然上面的程序会最终崩溃,因为同一块内存会被析构两次。所以,还是使用复制构造函数还有赋值运算来使不同对象管理同一块内存。如果深挖的话,std::shared_ptrstd::unique_ptr内部实现机理有区别,前者内部使用两个指针,一个指针用于管理实际的指针,另外一个指针指向一个”控制块“,其中记录了哪些对象共同管理同一个指针。这是在初始化完成的,所以如果单独初始化两个对象,尽管管理的是同一块内存,它们各自的”控制块“没有互相记录的。所以,上面的问题就出现了。但是如果是使用复制构造函数还有赋值运算时,“控制块”会同步更新的,这样就达到了引用计数的目的。

注:有关std::shared_ptr更多的信息在这里

std::weak_ptr

std::shared_ptr可以实现多个对象共享同一块内存,当最后一个对象离开其作用域时,这块内存被释放。但是仍然有可能出现内存无法被释放的情况,联想一下“死锁”现象,对于std::shared_ptr会出现类似的“循环引用”现象:

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

class Person
{
public:
  Person(const std::string& name) : m_name{ name }
  {
    std::cout << m_name << " created" << std::endl;
  }

  virtual ~Person()
  {
    std::cout << m_name << " destoryed" << std::endl;
  }

  friend bool partnerUp(std::shared_ptr<Person>& p1, std::shared_ptr<Person>& p2)
  {
    if (!p1 || !p2)
    {
      return false;
    }

    p1->m_partner = p2;
    p2->m_partner = p1;

    std::cout << p1->m_name << " is now partenered with " << p2->m_name << std::endl;
    return true;
  }

private:
  std::string m_name;
  std::shared_ptr<Person> m_partner;
};

int main()
{
  {
    auto p1 = std::make_shared<Person>("Lucy");
    auto p2 = std::make_shared<Person>("Ricky");
    partnerUp(p1, p2);  // 互相设为伙伴
  }

  return 0;
}

结果

Lucy created
Ricky created
Lucy is now partnered with Ricky

在程序中创建两个Person动态对象,交由智能指针管理,并且通过partnerUp()函数互相引用为自己的伙伴。从结果可以看出对象没有被析构!出现内存泄露!仔细想想std::shared_ptr对象是什么时候才能被析构,就是引用计数变为0时,但是当你想析构p1时,p2内部却引用了p1,无法析构;反过来也无法析构。互相引用造成了“死锁”,最终内存泄露!这样的情形也会出现在“自锁”中:

int main()
{
  {
    auto p1 = std::make_shared<Person>("Lucy");
    partnerUp(p1, p1);  // 自己作为自己的伙伴
  }
  return 0;
}

这时候std::weak_ptr应运而生。std::weak_ptr可以包含由std::shared_ptr所管理的内存的引用。但是它仅仅是旁观者,并不是所有者。那就是std::weak_ptr不拥有这块内存,当然不会计数,也不会阻止std::shared_ptr释放其内存。但是它可以通过lock()方法返回一个std::shared_ptr对象,从而访问这块内存。这样我们可以用std::weak_ptr来解决上面的“循环引用”问题:


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

class Person
{
public:
  Person(const std::string& name) : m_name{ name }
  {
    std::cout << m_name << " created" << std::endl;
  }

  virtual ~Person()
  {
    std::cout << m_name << " destoryed" << std::endl;
  }

  friend bool partnerUp(std::shared_ptr<Person>& p1, std::shared_ptr<Person>& p2)
  {
    if (!p1 || !p2)
    {
      return false;
    }

    p1->m_partner = p2;
    p2->m_partner = p1;

    std::cout << p1->m_name << " is now partenered with " << p2->m_name << std::endl;
    return true;
  }

private:
  std::string m_name;
  std::weak_ptr<Person> m_partner;
};

int main()
{
  {
    auto p1 = std::make_shared<Person>("Lucy");
    auto p2 = std::make_shared<Person>("Ricky");
    partnerUp(p1, p2);  // 互相设为伙伴
  }

  return 0;
}

结果

Lucy created
Ricky created
Lucy is now partenered with Ricky
Ricky destoryed
Lucy destoryed

注:有关std::weak_ptr更多的信息在这里

参考

posted @ 2021-06-28 18:13  chrislzy  阅读(131)  评论(0编辑  收藏  举报