在 C++ 中,拷贝的能力分为 初始化拷贝 和 赋值拷贝 两种。默认情况下,就算你不声明对象是否存在这两种能力,编译器也会帮你自动生成一个默认版本。
class T {};
此时的 T 已经具有默认拷贝的功能, 但没有移动能力。
T a;
T b(a); // 初始化拷贝
b = a; // 赋值拷贝
当你需要定义拷贝里的一些操作是,就可以显示声明并定义内容。
class T {
public:
/* 初始化拷贝 */
T(const T &) ;
/* 赋值拷贝 */
T & operator= (const T &);
};
注意,上面的声明不一定要全写完。但最好是成对出现,这样使用语义比较完整。
当你的类不希望有 copy 的情况时,可以这样声明。
class T {
public:
T(const T&) = delete;
T & operator=(const T &) = delete;
};
=================================================================
拷贝的语义比较好理解,但移动的可能在理解上会有一定的误区。
我开始看移动时,一直以为,C++ 的移动和 Rust 里的一样,也是移动内存变量的使用权。后来,我才明白,其只是语义上的移动,内存所有权基本不变。
不过,这样理解也不对,因为要视使用场景而言。
C++ 的移动和右值有关,而右值又引申出右值引用的概念 T &&
。请注意,右值和右值引用是两个不同的概念,右值通常是指临时变量、字面量等,而右值引用是能直接引用右值的变量,其本身是左值变量。
例如,函数 int func();
在被调用后的返回值就是一个临时变量,其不能被左值引用直接引用,但可以被右值引用引用(但要注意,这可能会出现很多问题)。
int func();
int &r1 = func(); // 错误
int v = func(); // 先把临时返回值放至本函数栈内
int &r1 = v; // 再使用左值引用去引用
int &&r2 = func(); // 正确,右值引用可以直接引用临时变量
注意,上述的右值引用虽然能通过编译,但这是存在很大隐患的。
int && func() { int i = 13; return move(i); } int &&r = func();
明显,右值引用
r
指向的内存是func
函数栈里的i
。当r
完成初始化后,func
> > 的栈空间就会被销毁,故此时r
指向的是一块无效内存,并引发 运行时的错误。
编译可以通过,但一般会报警告。
C++ 的移动语义是与右值引用有关。其 std::move()
原理就是把一个你要移动的值,转变为右值。move()
的简化版本如下,
template <typename T>
T && move(T &obj) {
return static_case<T &&>(obj); // static_case 强转类型
}
接下来,说怎样定义一个类型具有移动的能力。
首先,类型在默认情况下,是不具备移动能力的。这和拷贝不一样。
同理,移动也分为初始化移动能力和赋值移动能力两种。
class T {
public:
/* 初始化移动 */
T(T &&);
/* 赋值移动 */
T & operator=(T &&);
};
使用场景是,
T a;
T b = move(a);
a.xxx(); // 正常使用
b.xxx();
此时,若你以为把 a
在 move
后,就不能使用,那你就大错特错了。因为 move
的本质只是把 a
从左值变成右值而已。
所以,一定要注意的是,C++ 的移动,是语义上的移动,而不是内存所有权的移动(后面会说一个例外)。
但,你可能说,如果是这样,那移动是有什么用呢?
其实移动的一个很有用的场景是在智能指针上,
int main() {
SmartPoint p1, p2(13);
rt_kprintf("...before move...\n");
rt_kprintf("p1: ");
p1.print();
rt_kprintf("p2: ");
p2.print();
p1 = move(p2);
rt_kprintf("...move...\n");
rt_kprintf("p1: ");
p1.print();
rt_kprintf("p2: ");
p2.print();
return 0;
}
输出结果为,
==================================================================
当一个类型,既定义了 拷贝,又定义了 移动。那到底是调用谁呢?
接下来,我们做几个实验。实验 1
class A {
public:
A() = default;
A(const A &) {rt_kprintf("A copy...\n");}
A(A&&) {rt_kprintf("A move...\n");}
A & operator=(const A &) {rt_kprintf("A copy=...\n"); return *this;}
A & operator=(const A &&) {rt_kprintf("A move=...\n"); return *this;}
};
上述的 A
既具能 copy 又能 move。
A a;
A b1(a);
A b2(move(a));
A b3, b4;
b3 = a;
b4 = move(a);
结果是,
实验 2 ,是类型 A
只能 copy 不能 move
class A {
public:
A() = default;
A(const A &) {rt_kprintf("A copy...\n");}
// A(A&&) {rt_kprintf("A move...\n");}
A & operator=(const A &) {rt_kprintf("A copy=...\n"); return *this;}
// A & operator=(const A &&) {rt_kprintf("A move=...\n"); return *this;}
};
结果是,移动的被转移到 copy 中了。
实验 3 ,是只能 move 不能 copy 时,结果发现,编译不通过。
==================================================================
最后,说一种,在 C++ 上真正是内存转移的情况。不对,应该说是逻辑上的。
开始时说过,如果一个函数返回的是引用时,一定要注意其指向内存的生命周期是否大于引用变量的,不然该引用变量就很容易变成悬垂指针,引发运行异常。
所以,在函数体内,想直接移动体内的局部变量是不行的。即,
T && func() {
T a;
return move(a);
}
那只能是通过拷贝,即
T func() {
T a;
return a;
}
T t;
t = func();
其调用情况是,调用 func()
时,编译器会为其生成专用栈空间,而 a
就是存在这个栈空间内。当调用 return
后,就会调用 t.operator= (a);
。最后,系统回收 func
的栈空间,而 a
原本的内存无效。
此时,你可能会疑惑,如果 t
既有移动又有拷贝,那它到底是调用哪个呢?这里,就要从 t = func();
这个语句分析起了。对于 t
而言,我不管 func()
里做了什么,创建了多少变量,我只关心你返回了什么。显然,对于函数返回值而言,其属于临时变量,也就是右值。故 其调用的是 move 语义的函数 。
回到之前的问题,我想知道 C++ 里在哪存在内存所有权的转移。答案就在初始化时。
class A {
public:
A() = default;
A(const A &obj) {rt_kprintf("copy...\n");}
void print() {rt_kprintf("self addr is %d\n", this);}
};
A func() {
A a;
rt_kprintf("func a addr is %d\n", &a);
return a;
}
int main() {
A a = func();
a.print();
return 0;
}
结果是
此时,func()
的函数形式,是不是很熟悉,和调用初始化函数很像。这就是编译器优化后的结果。逻辑上,是把局部变量从内部转移到外部了。
其实,是编译器对此形式进行了优化。其分析内部的局部变量最后一定会流向外部的值时,就没必要为他再开辟内存,直接使用就行了。
但这不一定时时能实现的,因为只是优化产生的一个特例。
class A {
public:
A() = default;
A(const A &obj) {rt_kprintf("copy...\n");}
void print() {rt_kprintf("self addr is %d\n", this);}
};
A func(int n) {
A a, b;
rt_kprintf("func a addr is %d\n", &a);
rt_kprintf("func b addr is %d\n", &b);
if (n > 3)
return a;
return b;
}
int main() {
A a = func(1);
a.print();
return 0;
}
此时就不行了,因为编译器没法判断你是想把哪个返回。