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_ptr1
和 m_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_ptr
,std::weak_ptr
,和 std::shared_ptr
。我们将也探索这些中最著名的两个:unique_ptr
(直接用来替换std::auto_ptr
的) 和 shared_ptr
.