Skip to content

15.1 智能指针和移动语义的介绍

By Alex on February 17th, 2017 | last modified by nascardriver on April 25th, 2020

翻译by dashjay 2020.7.14

思考下列函数,在这个函数中我们动态分配了一个值

void someFunction()
{
    Resource *ptr = new Resource; // Resource is a struct or class 【资源是一个结构或者类】

    // do stuff with ptr here 【使用指针在这里做一些事】

    delete ptr;
}

经管以上代码看起来很直接简单,但是也相当容易忘记去释放指针。即便你确实记得在函数末尾释放指针,也有无数种情况导致指针没有被删除,如果函数提前退出的话。这很可能发生通过一个 early return:

# include <iostream>

void someFunction()
{
    Resource *ptr = new Resource;

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

    if (x == 0)
        return; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

或者通过一个异常的抛出

# include <iostream>

void someFunction()
{
    Resource *ptr = new Resource;

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

    if (x == 0)
        throw 0; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

在以上的两个程序中,提前退出或者抛出语句执行都会造成函数终止,并且未释放 ptr 指针。因此,为变量 ptr 分配的内存就会释放(并且将每次调用该函数时,泄露一次,如果提前退出的话)。

本指上,这些种类的问题之所以会发生,是因为指针变量没有固有的机制来清理他们自己。

智能指针能拯救一切么

写代码最棒的事就是使用的类包含一个解构函数会自动执行,当这个类对象脱离作用域后。因此如果你分配(或得到)内存在你的构造函数中,你可以释放他们在你的析构函数,并且保证内存将会被释放当这个对象被销毁(可以是离开作用于或者显式的删除,等等……)。这是 RAII 编程的核心,我们在 8.7 课讨论过的 —— 结构函数

这样说来,我们是否能用一个类来帮助我们管理或者清理我们的指针?当然可以!

思考如果有这样一个类,它所有的工作就是持有和”拥有“一个传给他的指针,并且当该类的对象离开作用域后就会释放那个持有的指针。只要那个类的对象仅仅被创建作为局部变量,我们可以保证该类将会以恰当的方式(properly)的离开作用域(可以视为我们的函数何时或者如何终止),然后持有该指针的对象将会被销毁。

这是这个想法的第一个草稿

# include <iostream>

template<class T>
class Auto_ptr1
{
 T* m_ptr;
public:
 // Pass in a pointer to "own" via the constructor
 Auto_ptr1(T* ptr=nullptr)
  :m_ptr(ptr)
 {
 }

 // The destructor will make sure it gets deallocated
 ~Auto_ptr1()
 {
  delete m_ptr;
 }

 // Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
 T& operator*() const { return*m_ptr; }
 T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
 Auto_ptr1<Resource> res(new Resource); // Note the allocation of memory here

        // ... but no explicit delete needed

 // Also note that the Resource in angled braces doesn't need a * symbol, since that's supplied by the template

 return 0;
} // res goes out of scope here, and destroys the allocated Resource for us

这个程序会打印:

Resource acquired
Resource destroyed

思考这个程序和类是如何工作的。首先,我们动态的创建一个 Resource,并且作为一个参数传给我们的模板类 Auto_ptr1。从那个点开始往后走,我们的 Auto_ptr 变量 res 持有这个 Resource 对象(Auto_ptr1m_ptr 有组成的关系)。因为 res 被声明为一个局部变量,有作用域。当前语句块结束后,它将会离开作用域,并且被销毁(不用再但因忘了释放它)。并且因为它是一个类,当它被销毁时,Auto_ptr1 的析构函数将会被调用。(Auto_ptr1)的析构函数将会确保它持有的 Resource 指针被删除!

只要 Auto_ptr1 被定义为一个局部变量(自动的生命周期,因此 Auto 才作为类名的一部分),Resouce 将在被定义的语句末尾会被删除这件事得到了保证,不管函数何时结束(即便它提前结束)。

这样的类被叫做智能指针(Smart Pointer)。一个智能指针是一个复合类(composition class),专门被设计出来管理的动态内存的分配,并且保证当智能指针离开作用域后内存被释放。(于此相关的,内置的指针有时候会被叫做”笨指针(dumb pointers)“,因为他们不能清理他们自己。)

现在,让我们回到我们上方的的 someFunction() 例子,并且展示一个智能指针如何我们遇到的困难:

# include <iostream>

template<class T>
class Auto_ptr1
{
 T* m_ptr;
public:
 // Pass in a pointer to "own" via the constructor
 // 通过构造函数传入一个指针让它“所有“
 Auto_ptr1(T* ptr=nullptr)
  :m_ptr(ptr)
 {
 }

 // The destructor will make sure it gets deallocated
 // 析构函数将会保证它被销毁
 ~Auto_ptr1()
 {
  delete m_ptr;
 }

 // Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
 // 重载引用和 -> 操作符,以便于我们可以像 m_ptr 那样使用 Auto_ptr1
 T& operator*() const { return*m_ptr; }
 T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
// 一个简单的类来证明上面的代码工作
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
    void sayHi() { std::cout << "Hi!\n"; }
};

void someFunction()
{
    Auto_ptr1<Resource> ptr(new Resource); // ptr now owns the Resource
                                           // 指针现在拥有了 Resource

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

    if (x == 0)
        return; // the function returns early
                // 函数提前返回

    // do stuff with ptr here
    // 使用指针
    ptr->sayHi();
}

int main()
{
    someFunction();

    return 0;
}

如果用户传入了一个非零整数,上面的程序将会打印:

Resource acquired
Hi!
Resource destroyed

如果用户输入0,上面的的程序将会提前终止,退出,并且打印:

Resource acquired
Resource destroyed

注意在这个例子中,即便用户输入0导致程序提前退出,Resource 也会得到合理的释放。

因为指针变量是一个局部变量,指针将会被释放当函数终止(不管它如何停止)。并且因为 Auto_ptr1 析构函数将会清理 Resouce,我们保证 Rresouce 将会被合理的清理。

一个很关键的缺陷

Auto_ptr1 类有一个致命的缺陷隐藏在一些 自动生成 的代码里。在进一步阅读之前,看看你是否能找到答案,快想想吧……

(提示:思考类中的哪个部分会被自动生成,如果你不提供)

(紧张的音乐)

Okay, time’s up. 好了,时间到了。

相比讲给你听,我们将直接向你展示,思考下列程序:

# include <iostream>

// Same as above
template<class T>
class Auto_ptr1
{
 T* m_ptr;
public:
 Auto_ptr1(T* ptr=nullptr)
  :m_ptr(ptr)
 {
 }

 ~Auto_ptr1()
 {
  delete m_ptr;
 }

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

class Resource
{
public:
 Resource() { std::cout << "Resource acquired\n"; }
 ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
 Auto_ptr1<Resource> res1(new Resource);
 Auto_ptr1<Resource> res2(res1); // Alternatively, don't initialize res2 and then assign res2 = res1;

 return 0;
}

这个程序将会打印

Resource acquired
Resource destroyed
Resource destroyed

可能(但不是一定)你的程序将会在这时退出。看到问题了么?因为我们没有提供拷贝构造函数或者赋值操作符,C++ 给我们提供了一个。并且该函数进行了浅拷贝,因此当我们用 res1 初始化 res2 时, 两个 Auto_ptr1 变量通知指向了同样的 Resource 。当 res2 离开作用域,他就会删除 resouce,让 res1 成为一个悬空指针,当 res1 删除他的(早就被删除)的 Resouce 时,程序崩溃!

你实现另一个简单的问题,当像这样调用函数时:

void passByValue(Auto_ptr1<Resource> res)
{
}

int main()
{
 Auto_ptr1<Resource> res1(new Resource);
 passByValue(res1)

 return 0;
}

在这个程序中,res1 将会被值拷贝进 passByValue() 的参数 res,导致复制了一份 Resouce 指针,最后因为同样的问题崩溃。

好的,我们清楚这个问题了。我们如何解决它?

能够解决的方法之一就是显式的定义删除拷贝构造和赋值操作符,从而阻止了任何拷贝从原始对象复制出来,那会阻止值传值(那时很棒的,无论如何我们都不应该在这个情况下使用值传值)。

但是紧接着返回一个 Auto_ptr1 从一个函数返回将会发生什么?

??? generateResource()
{
     Resource *r = new Resource;
     return Auto_ptr1(r);
}

我们不能通过引用返回 Auto_ptr1 ,因为局部变量 Auto_ptr1 将会在函数的末尾被删除,并且调用者将会得到一个悬空的引用。通过地址返回有同样的问题。

我们可以通过地址返回指针 r,但是我们也许会忘了之后删除 r,这也是我们之所以使用智能指针的原因。因此那毫无疑问,通过值返回 Auto_ptr1 是唯一有意义的选项 —— 但是紧接着我们就会以浅拷贝,复制指针,最后崩溃。

另一个选项就是重写拷贝构造函数和赋值操作符来保证深拷贝。以这个方式,我们至少能保证避免复制指向共一个对象的指针。但是深拷贝是昂贵的(并且也许是不可取的或者甚至是不可能的),并且我们不想为了从函数中返回 Auto-ptr1 从而对对象进行不必要的复制。另外,分配或初始化一个笨指针并不会复制所指向的对象,那么为什么我们希望智能指针的行为有所不同呢?

我们该怎么办?

移动语义

如果不是让复制构造函数和赋值运算符复制指针(“复制语义”),而是将指针的所有权从源对象转移/移动到目标对象呢?这是move语义背后的核心思想。移动语义意味着类将转移对象的所有权,而不是进行复制。

让我们你更新我们的 Auto_ptr1 类来展示这如何完成:

# include <iostream>

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

 ~Auto_ptr2()
 {
  delete m_ptr;
 }

 // A copy constructor that implements move semantics
 Auto_ptr2(Auto_ptr2& a) // note: not const
 {
  m_ptr = a.m_ptr; // transfer our dumb pointer from the source to our local object
  a.m_ptr = nullptr; // make sure the source no longer owns the pointer
 }

 // An assignment operator that implements move semantics
 Auto_ptr2& operator=(Auto_ptr2& a) // note: not const
 {
  if (&a == this)
   return *this;

  delete m_ptr; // make sure we deallocate any pointer the destination is already holding first
  m_ptr = a.m_ptr; // then transfer our dumb pointer from the source to the local object
  a.m_ptr = nullptr; // make sure the source no longer owns the pointer
  return *this;
 }

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

class Resource
{
public:
 Resource() { std::cout << "Resource acquired\n"; }
 ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
 Auto_ptr2<Resource> res1(new Resource);
 Auto_ptr2<Resource> res2; // Start as nullptr

 std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
 std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

 res2 = res1; // res2 assumes ownership, res1 is set to null

 std::cout << "Ownership transferred\n";

 std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
 std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

 return 0;
}

这个打印出:

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

注意我们重载了 operator=m_ptr 的所有权从 res1 递交给 res2!因此我们不会复制指针的拷贝,并且所有事都被整齐的清理干净。

std::auto_ptr 和为何避免使用

现在是合适的实机来讲 std::auto_ptr 了,std::auto_ptr,在 C++98中引进,是 C++ 的第一次尝试实现一个标准的只能指针。std::auto_ptr 选择了实现移动语义就像 Auto_ptr2 类做的那样。

然而,std::auto_ptr(和我们的 Auto_ptr2 类一样)有一大堆问题,使得用起来非常的危险。

首先: 由于 std::auto_ptr 通过copy构造函数和赋值运算符实现移动语义(move semantics),因此按值向函数传递 std::auto_ptr 将导致资源移动到函数参数(当函数参数离开作用域后,参数在函数末尾销毁)。然后当你从调用者那里访问你的 auto_ptr 参数时(没有意识到它已经被转移和删除),你突然对空指针的取值。导致崩溃

第二: std::auto_ptr 总是用非数组删除它的内容。这意味这 auto_ptr 在动态分配数组内存的情况下,不能正确的工作,因为它使用了错误的释放符号。更糟糕的是,它不允许你传一个动态的数组,因为这样做它就会失去管理,导致内存泄露。

最后: std::auto_ptr 不能和其他标准库中的类搭配使用,包括大量的容器和算法。这会发生就是因为那些标准库假设当他们拷贝一个对象的时候,实际上是拷贝而不是移动。

因为以上提到的缺点,std::auto_ptr 在 C++11 中已经被移除,并且不应该被使用。事实上 std::auto_ptr 在 C++17 中才被从标准库中完全移除。

Rule: std::auto_ptr is deprecated and should not be used. (Use std::unique_ptr or std::shared_ptr instead).. 规则:std::auto_ptr是被抛弃的,并且不应该被使用。(使用 std::unique_ptr or std::shared_ptr 替换他)……

向前看

在 C++11 之前 std::auto_ptr 的核心设计问题,是 C++ 语言没有一个简单的机制来分辨拷贝语义和移动语义。重写拷贝语义来实现移动语义,无意中引起了问题。例如,你可以写 res1 = res2 并且没办法知道 res2 是否会被改变!

因为这个,在 C++11中,”移动”这个概念被正式的定义,并且”移动语义“被添加到语言中来合适的分辨拷贝(copying)和移动(moving)。既然我们已经为"为何移动语义是有用的"做好了准备,我们将在本章的其余部分中探讨"移动语义"的主题。我们还将使用move语义修复Auto ptr2类。

在 C++11中,std::auto_ptr 已经被一堆其他类型的“move-aware”只能指正所替换:std::unique_ptrstd::weak_ptr,和 std::shared_ptr。我们将也探索这些中最著名的两个:unique_ptr (直接用来替换std::auto_ptr的) 和 shared_ptr.