C++的 “=” 实际有很多陷阱。对于内置类型来说,“=” 并没有什么特别。C背景的C++开发者,因为先入为主的印象,在类对象的使用上,仍然遵循了内置类型的方法,因此导致非常普遍的误用。
0 引子
“=” 名称
赋值运算符
“=” 功能
在不同的场景下被赋予不同的功能。包含初始化、赋值、复制、构造、移动
“=”可以操作的类型
-
内置类型
-
类类型
1 语义
对于上述2中类型的使用场景进行语义分析,可以形成如下表格
初始化 | 复制初始化 | 复制 | 移动初始化 | 移动 | |
内置类型 | 初始化 | 初始化 | 赋值 | 初始化 | 赋值 |
类类型 | 构造函数 | 拷贝构造 | 拷贝重载 | 移动构造 | 移动重载 |
-
内置类型
对于内置类型来说没有什么特殊,仅有两种语义初始化和赋值。
-
类类型
#include <iostream>
class A{
public:
explicit A(int a) {
std::cout << "ctor" << std::endl;
};
A(const A& a)
{
std::cout << "copy ctor" << std::endl;
}
A& operator=(const A& a) {
std::cout << "copy operator =" << std::endl;
return *this;
}
A(A&& a)noexcept
{
std::cout << "move ctor" << std::endl;
}
A& operator=(A&& a) noexcept {
std::cout << "move operator =" << std::endl;
return *this;
}
};
int main()
{
auto a0 = A(0); // 构造
auto a1 = a0; // 拷贝构造
a0 = a1; // 拷贝
auto a3 = std::move(a0); // 移动构造
a3 = std::move(a1); // 移动
return 0;
}
2 效率
移动语义是在C++11标准中引入的,它的出现是为了解决传统的复制操作在性能上可能存在的问题。在C++之前,通过复制构造函数和拷贝赋值运算符来进行复制操作,这对于大型对象或者资源密集型对象来说可能会导致性能损失。
让我们用一个简单的类来进行说明,这个类内部包含一个动态分配的资源,比如一个指向动态分配数组的指针。假设有一个类 Resource:
class Resource {
private:
int* data;
public:
Resource() : data(nullptr) {}
Resource(int size) : data(new int[size]) {}
~Resource() { delete[] data; }
// 移动构造函数
Resource(Resource&& other) noexcept : data(other.data) {
other.data = nullptr; // 避免资源被释放两次
}
};
现在我们来比较传统的值语义和移动语义的操作:
-
传统的值语义
对象的复制意味着需要分配新的内存,并将数据复制到新的内存空间中。在传统的值语义中,每次复制都会导致动态分配资源的重新分配和复制,这可能会导致性能损失。
-
移动语义
对象的移动意味着将资源的所有权从一个对象转移到另一个对象,而不是复制资源。移动语义通过将指针从一个对象转移到另一个对象,而不是对数据进行复制,避免了不必要的资源分配和复制操作,从而提高了性能。
在移动语义中,资源的指针被简单地转移给目标对象,而源对象不再拥有资源,这样就避免了资源的复制和额外的内存分配。这种转移是通过移动构造函数和移动赋值运算符来实现的。
3 = default 表达式
在C++中,当我们声明一个类的构造函数、析构函数、拷贝构造函数、拷贝赋值运算符或移动构造函数时,可以使用= default语法来指示编译器生成默认的实现。
对于拷贝构造函数和拷贝赋值运算符:
当我们声明它们为= default时,编译器会自动生成逐成员复制的拷贝构造函数和拷贝赋值运算符。这意味着它们的行为与默认情况下编译器生成的行为完全相同。
对于移动构造函数:
当我们声明移动构造函数为= default时,编译器会生成默认的移动构造函数,这个默认移动构造函数会将成员变量逐个地移动(使用std::move)。
使用= default的主要好处是可以让编译器为我们自动生成对应的特殊成员函数,避免手动编写这些函数。这在很多情况下能够简化代码,并且保证了正确性,因为编译器生成的函数通常都是符合预期的。
4 自定义与空实现
如果你提供了一个空的移动构造函数,即使成员类提供了自定义的移动构造函数,它们也不会被调用。空的移动构造函数不会触发自动成员移动,而是会进行成员的逐位拷贝。这就是为什么在实践中,如果你的类需要移动语义,但不需要对成员进行特殊处理时,你可以使用= default来生成默认的移动构造函数,这将确保成员按照它们的移动构造函数进行移动。
#include <iostream>
class MemberClass {
public:
MemberClass() {}
MemberClass(MemberClass&& other) {
std::cout << "MemberClass move constructor called" << std::endl;
}
};
class MyClass {
public:
MemberClass member;
// 空实现移动构造函数
MyClass(MyClass&& other) noexcept {
std::cout << "MyClass move constructor called" << std::endl;
// 这里虽然空,但不会调用成员的移动构造函数
}
};
int main() {
MyClass obj1;
MyClass obj2 = std::move(obj1);
return 0;
}
输出结果将会是:
MyClass move constructor called
所以,即使成员类提供了自定义的移动构造函数,空的移动构造函数也不会调用它们。如果你想使用默认移动,那么请不要进行空实现。