聪明的开发者使用智能指针(1/7) - 智能指针 基础

原文链接: https://www.fluentcpp.com/2017/08/22/smart-developers-use-smart-pointers-smart-pointers-basics/

内存管理是一件可以迅速的让你的 C++ 代码混乱并且难以阅读的事情。如果在这上面做不好,就会使代码中简单的逻辑变得非常难以表达,并且失去对内存安全的控制。

确保安全的删除所有对象在编程里面是处于非常低级别的抽象上的,但是好的代码本质上应该遵循一定的抽象级别,所以需要尽可能将这些对象管理的事情从程序逻辑中剔除出去。

智能指针可以高效的将你的代码从这些底层苦活中解放出来。 这一系列文章将会展示怎么利用智能指针把你的代码变得更容易阅读并且正确。

我们将会深入的探讨这个话题。并且我们将从智能指针最基本的地方开始,这样所有的人可以跟随这些文章阅读下去。

这里是这一系列文章的内容:

栈 和 堆

如同其他不同的编程语言,C++拥有很多不同类型的内存抽象,对应着物理内存的不同部分。他们是:静态内存。静态内存是一个内容较多的话题,值得另外一篇文章来讨论它,现在我们只讨论 栈和堆。

C++默认在栈上存储对象:

int f(int a)
{
    if (a > 0)
    {
        std::string s = "a positive number";
        std::cout << s << '\n';
    }
    return a;
}

这里 as 是存储在 栈 上的。 技术上来讲,这意味着 as 在内存上是互相紧挨着的,它们是由编译器负责维护并且压入栈中的。然而这些事情跟日常工作是不太相关的。

然而有一件关于 栈 的事情是至关重要并且非常基础的。 这是这个系列文章中最基本的事情。 好消息是它非常得简单:

分配在栈上的对象在离开它们的作用域时会被自动销毁

你可以重新阅读这句话,也可以根据你的需要纹到你的前臂上,或者给你的配偶印刷一个带有这句话的T恤,这样你就可以把它记住了 :)

在 C++ 里面,作用域由一对大括号定义(除非它们初始化一个对象):

std::vector<int> v = {1, 2, 3}; // 这个不是一个作用域
 
if (v.size() > 0)
{ // 这是一个作用域的开始
    ...
} // 这是一个作用域的结束

并且有 3 种方式离开作用域:

  • 遇到下一个右大括号 (})
  • 遇到一个 return 语句
  • 抛出一个有一个没有在当前作用域捕获的异常

所以在第一个例子中, sif 语句的右大括号处被销毁,a 在 return 语句处被销毁。

动态分配的对象存储在 堆 上, 这是说 用new分配的对象,它们一般返回一个指针:

int * pi = new int(42);

上面语句中, pi 指向一个分配在 堆 上的int对象。

严格来讲,用 new 来分配的这块内存被称作自由存储(free store?)。使用 malloccallocrealloc 分配的堆内存是 C 时代的遗留,我们在这里就不讨论了 (但是我们将在这系列文章的后面讨论)。 但是 堆 这个词在开发者术语中普遍代表着所有动态申请的内存,在这里我们也使用这个想法。

总之我们需要使用 delete 去删除用 new 分配的对象:

delete pi;

和 栈 相反,分配在 堆 上的对象不会自动销毁。 这给了它们比作用域更长的存活时间,除了指向 堆 的指针,不需要拷贝整个内存,这样操作代价是很低的。 并且,指针给了对象多态性: 指向基类的指针事实上可以指向任何衍生子类的对象。

但是这样的便利性需要开发者要付出管理删除这些对象的代价。

并且从 堆 上删除一个对象并不是很简单的: 删除一个对象只能 调用 delete 一次。 如果没有调用,那么这个对象将不会被收回,它的空间不能再被使用 - 这叫做 内存泄漏(memory leak)。 但是从另外一个方面,如果对同一个内存地址调用多次 delete 将会是未定义行为。

这就是代码如何变得混乱并且失去了表达性 (甚至有时候会有错误)。 事实上,确保所有对象被正确销毁的这项清洁工作跨度非常大:可以从一个简单的 delete 到复杂的标记删除系统(比如过早return的时候)。

并且,有些接口在内存管理上是具有歧义的。 考虑下面的例子:

House* buildAHouse();

如果我们调用这个方法,那么在最后我们需要删除它返回的指针吗? 如果没有删除的话就会有内存泄漏,如果我删除了并且别人也删除了的话就会有未定义行为。 这是一个进退两难的选择。

我认为复杂的内存管理让 C++ 的名声并不是很好。

幸运的是,智能指针将会为我们解决这些问题。

RAII: 神奇的4个字母

RAII 在 C++ 中是一个非常符合习惯的用法,它采用类似于 栈 内存管理的优点来管理 堆 上的对象。 事实上,RAII 可以被简单并安全用到管理其他资源上,并不仅仅是用在管理内存上。我并不想写下这4个字母是什么意思(因为它们并不重要)。 你可以把它们认为是一个人的名字, 就像 C++ 的超级英雄 这样。

RAII 的本质是非常简单的: 将一个资源(指向一个实体的指针)封装成对象,并且在它的析构方法中处理这个资源。 这就是智能指针做的事情:

template <typename T>
class SmartPointer
{
public:
    explicit SmartPointer(T* p) : p_(p) {}
    ~SmartPointer() { delete p_; }
 
private:
    T* p_;
};

这里的关键是你可以像操作 栈 上对象一样操作智能指针。因为: 分配在栈上的对象在离开它们的作用域时会被自动销毁,编译器将会自动调用智能指针的析构方法。因此封装过的指针可以调用 delete 方法 有且只有一次。 简单来说,智能指针的行为像是一个普通指针,但是在它们销毁的时候将会自动删除它们指向的对象。

上面的示例代码仅仅作为 RAII 的初探。不意味着它展示了智能指针完整的接口。

首先,一个智能指针在语法上从很多方面表现的像普通指针: 它可以被 operator* 或者 operator-> 析构,这意味着你可以对它使用 *sp 或者 sp->member。并且它们可以转换到 bool 值,你可以在分支语句 (if里)中像普通指针一样使用它:

if (sp)
{
    ...

上面代码测试了 sp 底层指针是否是 NULL。 并且,可以使用 a.get() 方法获取底层指针。

其次,或许更重要的是上面的接口没有处理拷贝!实际上,拷贝一个 智能指针 将会拷贝其底层的指针,下面的代码有一个 bug:

{
    SmartPointer<int> sp1(new int(42));
    SmartPointer<int> sp2 = sp1; // now both sp1 and sp2 point to the same object
} // sp1 和 sp2 都被销毁了, 底层指针被删除了两次!

确实,上面的代码中底层的指针被删除了两次,这是一个未定义行为。

那么怎么处理拷贝呢?这是不同类型的智能指针的不同之处了。并且这使你更精确得表达你的代码中的想法。保持关注,下一节中你将会看到更多内容。