有一种感觉,我不太了解(或根本不了解)移动(通过右值引用)在 C++ 中是如何工作的,以及如何在类中正确组织/使用移动复制构造函数/运算符。
一点关于目前的情况
我决定编写一个“数据缓冲区”类,以便能够更方便地操作这个缓冲区。实际上,它由指向数据数组的指针、数组中元素的数量和几个方法组成。我这里展示了一个稍微简化的版本,它的本质没有太大变化:
class A
{
private:
unsigned int size;
char * data;
public:
// Конструктор по умолчанию (инициализирует пустой объект без данных)
A() :size(0), data(nullptr){}
// Еще конструктор. Инициализирует буфер размера size, заполняет его значением clearValue
A(unsigned int size, char clearValue = 'a') :size(size),data(new char[this->size]){
std::fill_n(this->data, this->size, clearValue);
}
// Удаляет динамически выделенные данные data
~A(){
delete[] data;
}
};
然后我尝试做这样的事情:
A a;
a = A(100);
data最后发现对象中的指针a不再有效(当程序/函数结束时,一切都中断了)。根据我的假设,这是因为我通过调用构造函数得到的对象,A(100)然后我分配给a它的对象是临时的,并且在分配之后,它的析构函数立即触发,清除了谎言在指针处data。原来是指针的值被复制了,数据已经被析构函数杀死了(还是我错了?)。
想到的第一个想法是“没有足够的复制构造函数”。我决定写它,它看起来像这样:
A(const A& other) :size(other.size), data(other.data ? new char[other.size] : nullptr)
{
if(other.data) memcpy(this->data, other.data, other.size);
}
但这并没有帮助。a = A(100);看来复制构造函数根本不参与操作。谷歌搜索了一下,我遇到了各种“移动/分配/复制”的习语,没有真正理解本质,我决定缺少移动构造函数。添加它,不知何故它看起来像这样:
A(A&& other):A()
{
std::swap(this->size, other.size);
std::swap(this->data, other.data);
}
然后神秘的事情发生了。=在我尝试做的地方,环境开始强调操作符是一个错误a = A(100),并且代码完全停止编译。环境给出了这样的解释——“函数 A::oprator=(A const a&) 不能被引用,因为这个函数已经被删除了。” 它在哪里被移除?为什么去掉?怎么回事,我不知道。结果,我决定重新定义这个 operator =,结果是这样的:
A& operator=(A other)
{
std::swap(this->size, other.size);
std::swap(this->data, other.data);
return *this;
}
你瞧,现在一切正常。
剩下的问题就这么多:
我什至做了什么?究竟何时调用移动构造函数,何时调用复制构造函数?为什么重新定义移动构造函数时A(A&& other):A()操作符停止工作=并且必须显式定义它?运算符=中做与移动构造函数中几乎相同的事情有什么意义,毕竟,我可以只使用移动运算符?处理所有这些的正确方法是什么?
我完全感到困惑,我很高兴能逐点给出一致的解释。
我们找到了正确的解决方案 - 编写所有三大(现在是五个 :))复制构造函数、析构函数和赋值运算符。现在(如果需要)还添加了一个移动构造函数和一个移动赋值。
创建实例时。例如,
新标准有其自身的微妙之处——例如,在
不调用复制/移动构造函数。
赋值运算符不会停止工作。但是默认运算符执行浅赋值,本质上只是指针......但您需要的是深赋值 - 数组内容的赋值。
在某种情况下
a = b;,您不太可能使用移动赋值运算符来解决问题——那么b在赋值后您将有空。但是有一个常见的通过复制和交换赋值的习惯用法 - 如果有一个函数
swap可以交换两个对象的内部表示(这里 - 只是改变指针),那么赋值可以完成为阅读相关文献。例如,Meyers 的 Effective and Modern C++。这里有一个很大的清单。
在这里,您可以立即注意到 C ++ 中的“移动”与您自己实现它的方式一样。语言本身(语言的核心)没有“运动”。在重载解析的过程中,只有一个右值引用类型有自己的行为规则。您可以将这种类型的右值引用及其随附的重载解析规则用于“移动”目的。
但是,没有人强迫你这样做。在许多情况下,移动只是一种优化的可能性。同时,从概念上看,通常的“复制”是“移动”的特例。“复制”是“移动”的最不理想的选择。也就是说,没有人会强迫你在你已经实施了复制的地方实施一项举措。复制等本身将应付一切,尽管不是最理想的。
另一件事是,对于某些类型的实体,根本不可能进行复制,而其他形式的移动是完全可能的。这就是搬家必不可少的地方。但这是一个完全不同的故事。
一切都绝对正确。
没错-您的类中的复制构造函数确实丢失了。但是,您之前的代码的问题不是由于缺少复制构造函数,而是由于缺少正确实现的复制赋值运算符。(到目前为止,我不是在谈论一般的运动,因为它们不是强制性的,与问题没有直接关系)。
这就是为什么您的复制构造函数的实现(对于我们的目的来说非常正确)并没有挽救这种情况。正如您正确指出的那样,您的代码中根本没有使用复制构造函数。
请记住,您的类中有一个复制赋值运算符 - 它是由编译器为您隐式生成的。但是这个复制赋值运算符的行为不正确:正如您正确猜到的那样,它只是将指针复制到数组中,在这种情况下,这无论如何都不适合您。
这不是一个正确的结论。缺少的是正确实现的赋值运算符。换句话说,您的代码可以在完全不涉及“重定位”主题的情况下变得可行。您可以通过经典复制来解决问题。但要做到这一点,您必须正确实现复制构造函数(您已经这样做了),并正确实现复制赋值运算符。
这就是所谓的经典三法则。
在现代 C++ 中,有理由认为该语言应该更严格地要求用户首先遵循三法则。即:在用户在他的类中声明了至少一个三规则函数(复制构造函数、复制赋值运算符、析构函数)的情况下,自动抑制所有其他三规则函数的隐式生成。也就是说,语言应该强制用户按照“我用手实现一个 - 然后我用手实现所有其他”的原则行事。然而,这不是在经典 C++ 中完成的。您刚刚成为这种情况的受害者:您实现了复制构造函数和析构函数,但“忘记”了实现相应的赋值运算符。
在现代 C++ 中,随着移动构造函数和移动赋值运算符的新概念的出现(以及相应地,随着五法则的出现),这个烦人的遗漏已被尽可能地修复,而不会破坏与经典的向后兼容性C++:只要你的类有一个显式的移动构造函数或一个显式的移动赋值运算符,所有其他五规则函数的隐式生成就会立即自动被抑制。
这正是您所看到的:一旦您实现了移动构造函数,编译器生成的复制赋值运算符立即消失了——它被删除了。你的代码停止编译。
此外,三法则下的经典编译器行为现已正式弃用,即暂时保留它以向后兼容,但将在未来的语言版本中通过意志力消除。也就是说,您现在成功编译的原始代码也将在将来的某个时间停止编译,并显示赋值运算符已删除的消息。
精彩的。话虽如此,您并不真正“需要”移动构造函数来使您的代码正式工作。但是对于优化它非常有用。
您实现赋值运算符的方式(“按值”获取参数 and
swap)只是基于移动语义优化代码的最简单方法。默认情况下,定义了没有参数和复制(偷偷地)的构造函数。定义移动运算符或移动构造函数时,默认构造函数和默认赋值运算符无效。您需要单独编写它们。