最近在修改用老C++写的代码,为了优化性能在追加一些和移动语义有关的东西。本来是想要验证在C++ 11中右值在往const引用上绑定的效果,无意间注意到了一个关于析构的问题。
先上代码:
#include <iostream>
using namespace std;
auto g_nCounter = 0u;
class TestObj {
private:
unsigned int m_nCounter = ++g_nCounter;
public:
TestObj() { cout << "Constructor." << m_nCounter << endl; }
TestObj(const TestObj& toTarget) { cout << "Copy Constructor." << m_nCounter << endl; }
TestObj(TestObj&& toTarget) { cout << "Move Constructor." << m_nCounter << endl; }
~TestObj() { cout << "Destructor." << m_nCounter << endl; }
};
TestObj GetRef() {
auto toTemp = TestObj {};
return toTemp;
}
void TakeRef(const TestObj& obj) {
}
int main(int argc, char** argv) {
TakeRef(GetRef());
return 0;
}
验证的内容很简单:GetRef()返回了一个临时的TestObj对象作为右值,并且作为参数绑定到了TakeRef()的形参const TestObj& obj上,此时想要观察一下整体构造的情况。因为如果在老的C++标准之中,GetRef() 因为返回了局部变量,因此将会创建一个临时变量返回,然后绑定给const TestObj& obj,因此这里会多一次复制构造析构的开销。而在我想法中,在C++ 11之中这里将会采用新的标准规定即移动构造来进行绑定,因此运行了程序做了验证:
Constructor.1
Move Constructor.2
Destructor.1
Destructor.2
生成toTemp执行一次构造,没问题;返回临时变量(为方便后面的讨论,这里我们命名为_tmp)然后执行一次移动构造,然后绑定到TakeRef的形参,没问题。
其实更让我感到好奇的是析构函数的调用次序。一般而言,析构函数和构造函数的调用顺序应该是颠倒的,比如如果栈上的对象析构函数调用顺序是c1,c2,c3,那么相应地析构函数的调用顺序就是d3,d2,d1。而从实际的例子上来看,右值引用的引入把这个规则稍微改变了。
那么为什么第一个对象的析构函数会事先调用呢?原因在于在_tmp在执行拷贝构造函数完毕的时候,编译器就让被拷贝的toTemp析构了。
这里不妨回忆一下右值引用(或者说move语义)引入的最大用处:事先语义层面的资源转移,避免拷贝。
在很多介绍右值引用的文章中,都强调了转移这个概念,并且告诉我们如果对象b通过move语义将资源转移给了a,那么b就是个死对象,但是这种描述并没有具体到编译器的实现层面上:怎么个死法?
于是这里告诉我们怎么个死法了:被转移的那个对象将会在移动构造结束的时候自动被释放掉。
于是有了上面的那个结果。
因此进一步地,我觉得在这里可能有个容易被误解的地方需要被澄清:移动语义移动的是资源而不是对象本身。这里首先要弄清楚
资源到底是什么?
对于我们编写的代码而言,资源就是一个对象内部的那些成员变量,比如一个简单实现的vector的资源可能就是内部的一个数组,而当我们想要转移资源的时候,转移的其实就是这个数组,而不是对象本身。
因此实际上在进行资源移动的时候,仍然是新建了对象的,只是在新建对象之后我们并没有拷贝资源,而是直接转移了资源。换言之,整个过程只是把过去的拷贝构造函数的调用改成了移动构造函数的调用,最后加上一个释放掉被转移资源的原始对象本身的操作。
因此,回到上面那个vector的例子,我们可能写出如下代码:
MyVector(MyVector&& orgVector) {
if(this != &orgVector) {
this->m_pArray = orgVector.m_pArray; // 转移资源
orgVector.m_pArray = nullptr;
}
}
~MyVector() {
if(!m_pArray) {
delete[] m_pArray;
}
}
而在这个转移构造函数结束之后,orgVector将会被自动析构,这样子就能够保证资源转移的安全性。但同时,这个安全仍然需要我们来保证,具体反映在两点:1、构造函数中我们转移了m_pArray之后,我们将其设置为nullptr;2、在析构函数中,首先判断m_pArray是否为nullptr,如果是,就不能进行释放。为什么呢?假设我们在构造函数中转移了m_pArray之后不去把它设置为nullptr,那么当移动构造函数执行完毕之后, orgVector将会被自动析构,然后调用析构函数,这个时候就出问题了:此时析构函数会把m_pArray指向的堆内存直接释放掉,接盘侠——新的那个MyVector对象还活不活了?
那么能不能使用拷贝构造来完成呢?
答案是可以,同时也不可以。
可以强行用以下方法来完成:
MyVector(const MyVector& orgVector) {
if(this != &orgVector) {
this->m_pArray = orgVector.m_pArray; // 转移资源
const_cast<MyVector&>(orgVector).m_pArray = nullptr;
}
}
但是这样做造成了两个问题:
- 拷贝构造函数原本只是为了“拷贝”,本不应该去修改被拷贝对象的任何成员内容的,然而我们这里使用了const_cast强行让它能够修改,无疑是破坏了这条规则;
- 拷贝构造函数完成后不会自动释放原来的那个对象,这个和转移资源的语义就不符合了。
因此,不能也不应该这样子做。
因此通过上面的讨论,我们得出了这样子的结论:
- 移动语义下,仍然创建了新的对象;
- 之所以说移动构造的开销没有复制构造的开销大,是因为在移动构造之中完成的是资源转移而不是拷贝——当然,这个也需要编写代码的人员来保证;
- 移动构造函数结束后,被移动的那个对象会被自动释放。
此外补充一个关于C++ 11中各个构造函数的default/delete的讨论:
why does deleting move constructor cause vector to stop working
其中我觉得最重要的两点在于:
- C++ 11中,如果手动设置移动构造函数为delete,那么编译器将会自动地把复制构造函数与operator=都给delete了;
- C++ 11中,如果手动编写了移动构造函数的话,那么编译器将会默认将复制构造函数设置为delete;
关于右值引用的更多话题,比如完美转发将会放在后面来介绍。
网友评论
Constructor.1
Destructor.1
这个是编译器做了优化么?