[引擎开发] 深入C++拷贝控制
Posted ZJU_fish1996
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[引擎开发] 深入C++拷贝控制相关的知识,希望对你有一定的参考价值。
C++作为引擎开发的常用语言,是因为它的设计更加面向底层,我们有更多的控制内存的手段,以获得高性能的程序。这种控制内存的手段不仅体现在对内存分配管理上,也体现在内存的拷贝控制上。
这意味着,为了编写高性能的代码,我们不仅需要关注逻辑的正确性,还需要对每条逻辑下发生了哪种情况的拷贝有着比较明确的认知。
[本文大纲] 基础概念 内置类型 字面值常量 常量表达式 完整表达式 复合类型 聚合类 左值和右值 左值引用和右值引用 常量引用 临时对象 临时对象的生命周期 顶层和底层const cv限定符 变量的初始化 未初始化 默认初始化 零初始化 值初始化 常量初始化 类成员的初始化 聚合初始化 直接初始化 拷贝初始化 列表初始化 初始化与内存模型 拷贝控制函数 拷贝构造函数 拷贝赋值运算符 移动构造函数 移动赋值运算符 拷贝和移动 转换构造函数 默认生成的函数 返回值优化 返回值优化(RVO) 命名返回值优化(NRVO) 实现原理 作用条件 使用方式 复制消除 总结 |
(*注:为了书写简便,本文示例代码在大部分类的定义中省略了public和其它必要函数)
基础概念
我们在后续内容中会提及到一些和类型、变量相关的基础概念,在这里我们先对这些概念做一些简单的介绍。
内置类型
C++的数据分为基本内置类型,复合类型和类(含标准库类型)。
其中,内置类型也称为标量变量,它是语言中的基础类型。它包括了算术类型(int, float, double, char, bool, long, short等)和空类型(void)。
字面值常量
字面值常量直接描述了特定数据类型的值:
42; // 整型字面常量
1.0f; // 浮点数字面常量
'c'; // 字符字面常量
"test"; // 字符串字面常量,常量字符的数组
true; // 布尔字面值
nullptr; // 指针字面值
常量表达式
常量表达式是在编译阶段能够得到结果的表达式:
constexpr int geti1() return 1;
const int geti2() return 1;
const int i1 = 1; // 常量表达式
const int i2 = i1 + 1; // 常量表达式
constexpr int i3 = 1; // 常量表达式
constexpr int i4 = geti1(); // 常量表达式
int i4 = 1; // 非常量表达式
const int i5 = geti2(); // 非常量表达式
完整表达式
完整表达式是指不是其他表达式的子表达式的表达式。
反过来说,如果一个表达式可以被看作是其它表达式的子表达式,它就不是一个完整表达式。比如:
x > 1 ? x + y : y;
这里的x + y就是一个子表达式。
复合类型
复合类型(compound type)是基于其它类型定义的类型。包括引用、指针。之所以说复合类型是基于其它类型定义的对象,是因为它们本身提供了对其它对象的间接访问。
其中,引用包括左值引用和右值引用,默认指代左值引用。
指针和引用的区别在于,引用并非一个对象,而是一个对象的别名,它必须被初始化,且是不可修改的,也不可以定义引用的指针或引用;而指针是一个对象,可对其引用,也可对其进行修改。
指针或引用对应的*或&修饰的是变量而非数据类型,如:
int* p1, p2; // p1 : int*, p2: int
聚合类
聚合体包含数组和聚合类。
聚合类是指满足以下所有条件的类:
① 不包含自定义构造函数
② 不包含private,protected的非静态成员变量
③ 没有虚成员函数,不包含虚基类或private,protected的基类
可以使用is_aggregate<>判断一个类是否是聚合类。
左值和右值
C++中左值(lvalue) 和右值(rvalue) 描述了对象的属性。通常来说,当我们在说左值的时候,我们一般在描述对象的地址属性,而当我们在说右值的时候,我们一般在讨论对象的值属性。
左值和右值用于描述变量,也可以用于描述表达式以及引用。当我们修饰表达式的时候,我们是指这个表达式的结果是一个左值或右值,而当我们修饰引用的时候,我们是指这个引用绑定的是一个左值或右值。
左值代表可寻址的对象,它是持久的,如果它可以出现在等号左边进行赋值,那么它是一个可修改的左值(modifiable lvalues)。
右值对应了不可寻址的临时对象或字面值常量,它是短暂的,通常没有名字,且不可被修改,它只能出现在等号右侧。
我们把以下变量/表达式称为左值/左值表达式:
class A
public:
int i = 0;
int& Get() return i;
int main()
int i;
const int ci;
int* p = &i;
int& r = i;
A arr[2];
A a;
*p; // 指针的解引用是左值
arr[0]; // 数组对象是左值
i; // 变量i是左值
++i; // 前自增是左值
r; // 引用r为左值
A.i; // A.i是左值
(i); // 括号内的为左值表达式
a.Get(); // 返回引用的表达式为左值表达式
"hello"; // 字符串字面量是左值
ci; // const对象ci是不可修改的左值
// 变量和函数本身是左值,和类型无关
我们把以下变量/表达式称为右值/右值表达式:
int func() return 1;
int main()
int i = 1;
2; // 字面值常量(除字符串字面量)都是右值
func(); // 返回非引用的函数为右值
(i + 1); // 内置逻辑、算法、比较表达式为右值
i++; // 后自增表达式为右值
&i; // 取地址后是右值,包括this指针
左值和右值属性和当前作用的位置也有关系:
int i = 1;
int i2 = i; // 初始化语句中,i本身是一个左值,在这里充当了右值
左值变量/表达式通常可以转换为右值使用,但右值变量/表达式只能作为右值存在。
现代C++中,由于移动语义的引入,对左右值的概念做了进一步扩展。
这里的消亡值的含义是,这些对象完成移动构造或移动赋值的操作后,它也就结束了自己的使命。
左值引用和右值引用
此外,引用也分为左值引用和右值引用:
int i = 1;
int& r1 = i; // 左值引用
int&& r2 = 1; // 右值引用
我们不可以将左值绑定到右值引用上,也不可以将右值绑定到左值引用上:
int& r1 = 1; // 不能将右值绑定到左值引用上
int&& r2 = r1; // 不能将左值绑定到右值引用上
但我们可以通过调用std::move来获得一个绑定到左值的右值引用:
int i = 1;
int&& r = std::move(i); // ok
该标准库能够实现是基于C++定义了例外的规则,支持将左值static_cast到一个右值引用。
上述代码中,移动后的变量i,属于消亡值(xvalue)。
常量引用
A func() return A(1);
int i = 1;
const int& r1 = i * 2; // ok
int& r2 = i * 2; // error
const A& a1 = func(); // ok
A& a2 = func(); // error
和普通引用不一样,常量引用可以绑定字面值、非常量对象/表达式。在绑定非常量的时候,它实际上绑定了一个临时对象。
常量引用有一个特性,它会延长指向的临时对象的生命周期,直到引用被销毁(右值引用同样具有这个特性)
除了直接绑定的常量引用,我们也可以将一个临时对象传入参数为常量引用的函数:
void func(const A& a)
int main()
func(A(2)); // ok
这种延长并不体现在返回值中,因为函数退出后引用对应数据就被销毁了。
const A& func()
A a(1);
return a;
// error !
临时对象
临时对象是编译器在计算表达式的时候,临时创建一个暂存求值结果的未命名对象,该临时对象会一直存在,直到包含该表达式的最大表达式计算完成为止。
从一个角度来说,临时对象在纯右值(prvalue)被具体化的时候创建,以便它可以作为广义的左值(glvalue)。
简单来说,我们认为如下情况中存在临时对象,或者创建了临时对象:
(1)绑定对纯右值的引用(包括常量引用,右值引用)
void func(const int& i)
func(1);
int&& r = 1;
// 绑定的是一个临时对象
(2)类型隐式转换
void func(string s)
char buffer[10];
func(buffer); // 生成一个临时对象来存储转换为string后的结果
(3)纯右值的表达式
A func() return A(); // A()是临时对象
int main()
A a = A(); // A()是临时对象
int x = 1;
int y = 2;
int i = x + y; //x + y是临时对象
(4)返回纯右值的函数
A func() A a; return a; // 产生临时对象存储返回值
int main()
func().Run(); // 临时对象用于执行一些操作
(6)列表初始化的结果
int i = 1 ; // 1是临时对象
(5)lambda表达式
[]()->int return 1; ;
临时对象的生命周期
临时对象是在表达式计算过程中产生的,它会在求值过程中的最后一步被销毁。
需要注意的几个例子:
① 如果临时对象用于初始化或者赋值,它会在初始化或赋值结束后再销毁
int x = a + b;
② 如果引用绑定了一个临时对象,那么临时对象的生命周期延长到和引用一致,也就是说,引用变量销毁后临时对象才被销毁。
③ 临时对象作为函数实参时,生命周期会持续到函数退出,因为此时完整表达式尚未结束。
以下是临时对象生命周期的一个示例:
void Test(const char *pc)
cout << pc << endl;
int main()
string a = "111";
string b = "222";
string c = a + b;
const char *pc = (a + b).c_str(); // error
cout << pc; // 我们尝试输出一个临时对象的指针,但是它在完成函数调用后已经被销毁了
Test((a + b).c_str()); // ok
// 此时临时对象还在生命周期内
顶层和底层const
当我们使用const来修饰变量的时候,这表示特定的对象是常量。这种const我们称为顶层const。
另外,对于指针或引用这样的复合类型而言,它们将与其它的对象绑定,此时const除了可以修饰复合类型本身,也可以用于修饰复合类型所绑定的对象。如果const修饰的是复合类型绑定的对象,我们称之为底层const。
const int* p1 = &ci; // 底层const, 指向一个const int对象
const int& r = ci; // 底层const (常量引用总是底层的)
int* const p2 = &i; // 顶层const, p2本身是const的,指向一个int对象
特别的,使用constexpr修饰指针的时候,得到的是顶层const:
const int* p1 = nullptr; // 底层const
constexpr int* p2 = nullptr; // 顶层const
顶层const对象的拷贝没有过多限制,这是因为拷贝一个对象,不会修改对象本身的值:
const int ci = 1;
int i = ci; // ok
const int ci2 = i; // ok
但是底层const的拷贝需要保证底层const一致,且只支持非常量到常量的转换:
int i = 1;
const int ci = 1;
const int* p1 = &i; // ok, 可以将非常量绑定到常量指针
int* p2 = p1; // error, 不能将常量指针拷贝给普通指针
const int& r1 = i; // ok, 可以将非常量绑定到常量引用
int& r2 = ci; // error, 不能将常量绑定到普通引用
cv限定符
cv限定符是指出现在类型说明符中,用以指定对象的常量性(const,mutable)或易变性(volatile)的限定符。
变量的初始化
什么是变量的初始化?它是指对象在创建时,被赋予了一个特定的值。
C++中有两组绝对不应混淆的概念,一组是声明和定义,另一组就是初始化和赋值。与初始化相对的,赋值是指对象创建之后,使用新的值去覆盖变量原本的值。这一点是至关重要的。
先概览一下C++内多种初始化的含义。这些概念有些是从不同角度出发描述的,有些则是互斥的或者是包含的关系,它们的大致关系如上图所示。
我们先从第一个角度着手考虑变量的初始化,也就是考虑当变量创建的时候,会使用什么数据来初始化变量的内存。一种是比较明确的,我们明确的指出了这个变量的初始值;另一种情况就是比较隐秘的,我们并没有直接给出变量的初始值,此时的变量将被如何初始化将是一个很有意思的话题。
默认初始化
如果我们没有指定初始值,编译器会尝试为我们完成初始化的操作,称为默认初始化:
class A
A()
// ...
;
class B
A a;
B()
// ...
;
int main()
A a1; // a1 : 默认初始化
A* a2 = new A; // a2 : 默认初始化
A a3[2]; // a3[0],a3[1] : 默认初始化
B b; // b.a : 默认初始化
对于数组的元素或者类,当我们没有明确指定初始值时,如果该类的默认构造函数是合法的,那么C++会调用默认构造函数,进行默认初始化。如上面的a1,a2以及a3变量。
这意味着,有些类在试图进行默认初始化时会发生错误:
class A
public:
A(int i)
;
int main()
A a; // error,默认构造函数被隐藏了
特别的,引用和const对象是不能被默认初始化的。
未初始化
根据变量初始化的定义,我们知道,初始化发生在变量创建的时候。但是,这并不意味着变量创建的时候必然伴随着初始化,比如如下的情况:
class A
float f;
;
int main()
A a;
int i;
此时,a中的变量f,以及变量i,它们是定义在函数体内部的内置变量,因此将不被初始化。
未初始化意味着C++对于分配的内存没有做任何处理,该变量是未定义的,它仍然保留着原有的脏数据。
如果试图访问或拷贝未初始化的内置类型的值,将会导致错误:
int main()
int x;
int y = x; // ub : 未定义的行为
unsigned char c;
unsigned char d = c; // ok
值初始化
如果初始化时,我们使用空初始化来构造对象,那么该变量是值初始化的。
class B
A a;
D() : a() // 值初始化
int main()
A a1 = new A; // 默认初始化
A a2[2]; // 默认初始化
// 以下为值初始化:
A a3 = new A();
A a4 = new A;
A a5;
A a6[2];
概括来说,使用值初始化的情况包括:
使用() 或 来显式地初始化匿名或命名对象、new表达式创建的对象以及初始化列表中的对象。
特别的,引用不能被值初始化。
需要注意的是,以下写法是有歧义的,编译器会将其识别为函数的声明,该函数具有一个返回值和空的参数:
int main()
// wrong :
A a();
// right :
A a;
零初始化
我们已经知道定义在函数体内的内置变量是未初始化的,与之相对的,定义在函数体外的内置变量将被初始化为0。静态变量属于静态存储方式,而不是定义在函数体内,因此,它也会被默认初始化为0。
void f()
static int i; // 初始化为0
float f; // 初始化为0
int main()
另外一种情况,如果我们对变量进行值初始化,如果变量是内置类型,会将其初始化为0:
class A
public:
int x,y;
int main()
int i1[2] = ;
int i2[2] ;
int i3;
int();
A a = A();
此外,如果一个类具有隐式默认构造函数,那么对其进行值初始化,将把所有成员初始化为0;如果数组提供的默认值数量小于数组的大小,其余的成员也会被初始化为0。
class A
int i;
;
int main()
char ch[5] = "h"; // ch[1]~ch[4]为0
A a; // a.i为0
常量初始化
在所有初始化开始之前,会进行常量初始化。这个过程在编译期间完成:
static int i = 1;
static A a = A(1);
constexpr int i2 = 2;
类成员的初始化
对于类的成员,同样适用于以上规则。
也就是说,如果我们为类的成员指定了初始值,那么它将执行常规初始化,如果指定了空初始列表,则执行值初始化,否则将执行默认初始化。
我们有多种方式为类的成员设置初始值。一种方式是提供类内初始值(in-class initializer):
class A
const int i = 1; // 类内初始化
float f 1.0f // 类内初始化(只能用, 使用()会被识别为函数声明)
;
另一种方式是使用初始化列表(initialize list):
class A
int i;
A() : i(1)
;
变量的初始化顺序由变量声明顺序决定,而不取决于初始化列表中的顺序。我们尽量确保初始化列表的顺序和变量声明的顺序一致,部分编译器对此会有较为严格的检查。
如果初始化列表和类内初始化同时设置了初值,编译器将选择初始化列表的值作为初始化的值。此外,如果类是派生的,它会优先初始化虚基类、直接基类,然后再按类中声明顺序初始化非静态成员,最后再执行构造函数。
最后,需要注意的是,在构造函数内的代码属于赋值操作,而非初始化:
class A
int i;
A() i = 1;
;
以上做法语法上是正确的,但如果能够通过初始化完成,更推荐初始化的方式。
类的const或引用成员变量必须进行初始化而不是赋值。
聚合初始化
聚合体可以应用和常规类不一样的列表初始化方式。
class A
public:
int i;
float f;
;
int main()
A a1 = 1, 2.0f ;
A a2 1, 2.0f ;
int a3[3] = 1,2,3;
聚合初始化要求初始值按照成员声明的顺序进行传递。
如果我们没有完全指定聚合体的所有初始值,其余值将被设为0:
int main()
A a = 1 ; // a.f为0
int arr[3] = 1 ; // arr[1],arr[2]为0
如果我们给变量指定特定的初始值,也有多种不同形式的初始方式。一种是直接初始化,另一种是拷贝初始化(复制初始化)。简单来说,如果使用等号来初始化变量,那么执行的是拷贝初始化;反之,如果不使用等号,则执行直接初始化。
直接初始化
以下是几个直接初始化的例子:
string s1("str"); // 直接初始化
string s2(10, 's'); // 直接初始化
当使用直接初始化的时候,编译器会调用匹配的构造函数对变量进行初始化。
特别的,C++的容器提供了emplace方法,便于在往容器添加变量时执行直接初始化,而不是执行拷贝操作。传入的参数需要和构造函数的参数相匹配:
class A
A(int i, float f) // ..
// ...
;
int main()
vector<A> vec;
vec.emplace_back(1, 2.0f);
;
拷贝初始化
以下是几个拷贝初始化的例子:
string s1 = "str"; // 拷贝初始化
string s2 = string(10, 's'); // 拷贝初始化
string s3 = s1; // 拷贝初始化
string s4(s2); // 拷贝初始化
string s5 = f(); // 拷贝初始化
当使用拷贝初始化的时候,编译器会调用拷贝构造函数或移动构造函数来对变量进行初始化。调用拷贝构造或移动构造这个过程在特殊情况下,会被编译器优化掉。
对于C++的容器,如果我们调用push或insert,我们会把对应的元素拷贝到容器中:
class A
A(int i, float f) // ..
// ...
;
int main()
vector<A> vec;
vec.push_back(A(1, 2.0f));
// error : vec.push_back(1,2.0f);
;
除了以上我们手动使用=指定的拷贝初始化,以下情况下,也会发生拷贝初始化:
① 函数调用的时候,将变量作为传递给非引用的形参:
void func(A a)
int main()
A a1;
func(a1); // 当按值传递参数到函数时
② 函数调用的时候,返回一个非引用的对象:
A func()
A a;
return a;
int main()
func();
函数返回值时,会将返回值拷贝给一个临时对象,对临时对象进行拷贝初始化。
③ 值捕获异常对象
列表初始化
列表初始化是指使用花括号 来初始化的方式。这是一种通用的初始化表达方式。这意味着绝大部分初始化都可以使用列表初始化的形式来表达。
以下是列表初始化的例子:
// 直接列表初始化
class B
A a11;
A a2;
B() : a21
;
int main()
A a1;
A1;
new A1;
// 复制列表初始化
A func(A a)
return 1 ;
int main()
A a1 = 1;
func(1);
A a2;
a2 = 1;
// 嵌套的列表初始化
map<int, string> m =
1, "a",
2, 'a', 'b', 'c'
;
我们可以通过std::initializer_list作为构造函数的参数来支持容器类的列表初始化:
template<T>
class A
public:
A(std::initializer_list<T> l)
;
列表初始化有一些特殊的属性:
① 列表初始化的花括号结果并非表达式,因此也不具备类型。
② 列表初始化应用于内置类型时,如果初始值存在精度丢失,会导致报错,如从浮点类型转换到整数类型,或者从double转换到float。
③ 列表初始化中,执行的顺序是确定的,也就是位于前面的语句会先被执行(函数调用表达式的语句则没有明确的顺序)
④ 存在重载的时候,非聚合类会优先匹配initializer_list的构造函数。
初始化与内存模型
最后,我们用简短的语言总结一下以上提到的几种初始化情况:
如果我们没有指定初始化的值,那么该情况属于默认初始化。
在不指定特定值的情况下,对于定义在函数体内的内置变量,它将是未初始化的;对于定义在函数体外的内置变量,它将被初始化为0;对于默认构造函数可调用的类,它将调用默认构造函数进行默认初始化。
如果我们使用 () 或 这样的空列表来指定初始化的值,那么该情况属于值初始化。
指定了空列表的情况下,如果是内置类型,那么它将被初始化为0。
如果我们明确的指出了初始值,当这个语句是常量表达式的时候,这是一个常量初始化,此外,该情况可能是直接初始化,也有可能是拷贝初始化,通常拷贝初始化会伴随着等号一起出现,或者出现在函数值参数和值返回中。拷贝初始化可能执行的是拷贝构造,也可能是移动构造。
由此可见,C++对于变量初始化的设计思路是比较清晰的,它将尽可能确保变量被初始化。如果我们并没有指定初始值,对于类对象,C++也会尽可能调用默认构造函数,或者将其初始化为0。
比较特殊的是内置变量,它并没有构造函数的概念,因此需要单独处理。这里分为函数体内外来讨论,最主要的区别在于,函数体内的变量定义在栈空间,而函数体外的变量定义在全局区。
对于全局区的数据,编译器会在程序载入的时候,一次性将内存初始化为0,这是一次比较廉价的操作。此外,定义在函数栈内的变量,在函数调用时被定义,而函数调用是一个运行时的高频操作,如果同样也执行同样的初始化为0的操作,意味着每次函数调用的开销会增加。考虑到对效率的追求,编译器舍弃了这样的保护操作,交由程序员来确保内置变量能够被正确的初始化。
拷贝控制函数
拷贝构造函数
// 拷贝构造函数:
A(const A&) //...
A(const A&) = default // 强制编译器生成拷贝
A(const A&) = delete // 阻止隐式生成拷贝构造函数
拷贝构造函数满足以下条件:
① 第一个参数是自身类型的引用
② 如果有其它额外的参数,其它额外的参数都有默认值。
在定义类的拷贝构造函数的时候,需要注意两个要点:
① 参数通常是一个const的引用,而不应该是值参数
函数值参数会触发拷贝构造,发生定义上的递归。
② 拷贝构造函数通常不应该是explict的
正如我们在拷贝初始化中的介绍,很多情况下会发生隐式的拷贝构造的调用,为了确保这些调用的合法性,我们不把拷贝构造函数定义为explict的。
隐式拷贝构造函数
如果我们没有定义自己的拷贝构造函数,那么编辑器总会为我们生成一个拷贝构造函数。
但是,以下情况下,这个拷贝构造函数将被视为删除的。
类的非静态成员,或者类的基类,具有 :
① 不可访问或删除的析构函数
② 不可访问或删除的拷贝构造函数
③ 不可访问或删除的拷贝赋值运算符
④ 右值引用类型的数据成员,或有用户定义的移动构造函数或移动赋值函数
⑤ 无法默认构造的const或引用成员
总结来说,当编译器无法执行拷贝或者销毁操作时,它就不会去生成可用的拷贝构造函数。
这个生成的拷贝构造函数是非explict的。通常情况下,这个隐式的拷贝构造函数形式为:
A(A&)
仅当类的拥有形式const&的拷贝构造函数,或者类的每个非静态成员都有const&的拷贝构造函数时,生成的拷贝构造函数形式才为:
A(const A&)
拷贝赋值运算符
// 拷贝赋值运算符:
A& operator=(A); // 拷贝交换法
A& operator=(const A&); // 不采用拷贝交换法
A& operator=(const A&) = default; // 强制编译器生成拷贝赋值运算符
A& operator=(const A&) = delete; // 避免隐式赋值
在定义类的赋值运算符时,我们确保:
自赋值时不进行任何操作,并且按引用返回自身。
隐式拷贝赋值运算符
如果我们没有提供自己的拷贝赋值运算符,那么编辑器总会为我们生成一个拷贝赋值运算符。
通常情况下,这个隐式的拷贝赋值运算符形式为:
A& A::operator=(A&);
仅当类的基类拥有形式const&的拷贝赋值函数,或者类的每个非静态成员都有const&的拷贝赋值函数时,生成的拷贝赋值函数形式才为:
A& A::operator=(const A&);
基类的拷贝赋值运算符始终会被隐藏。
和拷贝构造函数类似,当类具有移动构造函数或移动赋值运算符,或者无法被复制(具有const,引用非静态成员,或者无法复制的非静态成员/基类)时,类的拷贝赋值运算符被视为删除的。
拷贝交换
当我们定义拷贝赋值运算按值类型传入参数的时候,我们通常是在做拷贝交换的操作:
A& A::operator=(A a) noexcept // 调用拷贝或移动构造函数
swap(data, a.data);
return *this;
// a离开后释放保存的资源
执行拷贝交换法后,我们总是会重新分配资源。
对于资源需要复用的数据,比如指针数据,我们不应该应用拷贝交换法。
移动构造函数
A(A&& a);
A(A&& a) = default; // 强制编译器生成隐式构造函数
A(A&& a) = delete; // 避免隐式生成移动构造函数
移动构造函数满足以下条件:
① 第一个参数是自身类型的右值引用
② 如果有其它额外的参数,其它额外的参数都有默认值。
当对右值执行拷贝初始化的时候(包含函数值传参和值返回),会调用移动构造函数。移动构造函数通常不会分配新的资源。
移动构造函数的参数可以是const的,但这么做可能会导致我们无法移动资源。
当我们编写移动构造函数时,应该关注以下这些细节:
① 确保资源完成移动后,原对象不再指向被移动的资源
A::A(A&& a) :data(a.data)
delete data; // 释放原有的元素
data = a.data; // 接管资源
a.data = nullptr; // 确保原有资源可被安全的析构
举例来说,我们移动了类的指针后可将其置空,确保对源对象的析构是安全的。
② 移动构造函数通常不应抛出异常
A(A&& a) noexcept
这是基于C++标准容器在重新分配对象的时候,如果需要执行移动构造函数,会要求移动构造函数不抛出异常,否则将无法调用移动构造。因为抛出异常的移动构造会导致容器无法满足自身不变的要求(强异常保证)。
隐式移动构造函数
如果用户没有定义自己的移动构造函数,且类不具有自定义的拷贝构造函数、拷贝赋值运算符、移动赋值运算符以及析构函数,那么编译器将生成一个非explict的移动构造函数。
在以下情况下,类的隐式移动构造函数将被视为删除的:
类的非静态成员或基类是不可移动的,或者具有删除或不可访问的析构函数。
移动赋值运算符
// 移动赋值运算符
A& A::operator=(A&&);
A& A::operator=(A&&)=default; // 强制编译器生成移动赋值运算符
A& A::operator=(A&&)=delete; // 避免隐式移动赋值
移动赋值运算符应用在指针、文件描述符、输入输出流等移动但不重新分配资源的场景。
当我们编写移动赋值运算符时,同样需要确保不抛出异常时标记为noexcept,且需要正确处理自赋值:
A& A::operator=(A&& a) noexcept
if(this != a)
// ...
隐式移动赋值运算符
如果用户没有定义自己的移动赋值运算符,且类不具有自定义的拷贝构造函数,移动构造函数,拷贝赋值运算符和析构函数,那么编译器将生成一个非explict的移动赋值运算符:
A& A::operator=(A&&);
基类的移动赋值运算符将始终被隐藏。
在以下情况下,类的隐式移动构造函数将被视为删除的:
类的非静态成员或基类是不可移动赋值的,或者具有const或引用的非静态成员。
拷贝和移动
在拷贝初始化中,我们提到了拷贝初始化可能是拷贝构造,也有可能是移动构造。
如果同时提供了拷贝和移动赋值运算符/构造函数,那么在实参是右值(含纯右值和亡值)时,会使用移动赋值/构造,而在实参是左值的时候,使用拷贝赋值/构造。
如果只提供了拷贝赋值/构造,那么对于所有的值类型,都会使用拷贝赋值/构造。
转换构造函数
不以explict声明的构造函数被称为转换构造函数。
转换构造可以应用于实参参数到类的类型的隐式转换。编译器默认生成的构造函数也是转换构造函数。
也就是说,如下隐式转换的拷贝初始化形式在转换构造函数存在的情况下是合法的:
class A
public:
A()
A(int)
;
int main()
A a1 = 1;
A a2 = ;
但如果我们添加了explict标识符,这样的构造函数只能用于直接初始化。
值得注意的是,转换构造只允许一步的类型转换,比如,如下的例子就是错误的,因为它需要两步的转换:
class A
public:
A(const string& str)
;
class B
public:
B(const A& b)
;
int main()
B b("test");
默认生成的函数
对于自定义的类,编译器会为我们默认生成很多函数。现在,我们可以总结一下默认生成的函数,它们在什么情况下会生成,且在什么情况下是无法生成的。
我们可以认为,除了移动控制函数,其它的函数在用户未定义的情况下都会自动生成:
但是,即使编译器自动为我们生成了特定函数,这些函数也有可能是删除的:
返回值优化
如果我们在函数返回了一个局部对象,并使用一个新创建的对象来接收这个返回值,我们可以有两种写法:
case 1:
// case 1 :
A func()
return A();
int main()
A a = func():
case 2:
// case 2 :
A func()
A a;
return a;
// case 3 :
class B
A a;
public:
A GetA() return a;
;
int main()
A a1 = func();
B b;
A a2 = b.GetA();
这两者并不是等价的。前者构造了一个临时对象并直接返回它;而后者构造了一个变量a,并返回这个变量。
编译器对这两种写法都进行了优化,我们称之为返回值优化(Return value optimization)。这是由C++标准规定的优化。
返回值优化(RVO)
先来看前者,不考虑优化的情况下,我们首先会构造一个A的临时对象,我们称为var1。var1变量位于函数栈上,因此接下来还会调用一次析构函数。
然后再把var1拷贝给另一个作为返回值的临时对象,我们称为var2。var2变量位于寄存器,它会在整个过程结束后被销毁。
最后,外部存储单元将利用var2的值进行初始化。
在这个过程中,产生了两个冗余的临时对象。并且发生了两次拷贝初始化,还有栈上临时对象的一次构造和一次析构。
为了规避这一点,C++使用匿名返回值优化(URVO, Unnamed return value optimization)进行编译优化,它将删除存储函数返回值的临时对象。
编译器优化后,将避免两个临时对象的生成,因为它会直接在外部存储单元上进行直接初始化,此时将仅调用一次构造函数。
以上就是我们所说的狭义的返回值优化。
命名返回值优化(NRVO)
接下来我们来看后者,也就是返回的对象是一个非临时对象的情况(case2, 3)。
我们在前面提到了,当我们从函数返回一个值类型的时候,我们会使用一个临时对象来拷贝初始化这个返回值。如果我们又把返回值传递给外部存储单元,此时还会发生一次拷贝初始化。
此时,编译器可以通过命名返回值优化(NRVO, Named return value optimization) 来消除这个临时对象。但编译器无法协助我们优化我们声明的非临时对象。
NRVO优化后,避免了临时对象的生成,并且仅调用一次拷贝构造函数。(当然,也需要考虑到我们在函数内声明的局部变量的构造和析构)
由此可见,RVO相比起NRVO,额外的开销是更少的。为了协助编译器的RVO优化,以获得更好的性能,如果我们返回的是一个函数作用域内的局部变量,我们应该尽量使用直接返回匿名变量的写法,如下:
const Rational operator* (const Rational& lhs, const Rational& rhs)
// bad :
// Rational result(lhs.numerator * rhs.numerator,
// lhs.denominator * rhs.denominator);
// return result;
// result变量的构造和析构,返回值的拷贝初始化
// good :
return Rational(lhs.numerator * rhs.numerator, lhs.denominator * rhs.denominator);
// 仅有返回值的直接构造
实现原理
像这样直接返回局部变量的函数,编译器通过传入引用的方式来优化。
它会添加一个隐藏的对象,把该对象的地址传入,使用这个地址来直接构造或者复制构造函数的返回对象。
// before :
A func()
return A();
// after : 执行类似的操作
void func(A* address)
*address = A();
它实际上将返回值和复制的对象视为同一个对象的不同形式,因此消除了作为过渡的临时对象。
作用条件
返回值优化的作用也是有条件的,在有些情况下, 返回值优化有可能不会生效。
① 哪怕拷贝构造或移动构造函数因为返回值优化,并没有被实际使用到,它们也应该是可访问的/未删除的,否则程序会产生错误;
② C++标准允许编译器实现命名返回值的优化(NRVO),但没有强制规定一定要这么做;而返回值优化(RVO) 则是强制的;
③ 当函数根据分支路径返回不同的命名对象时,优化可能不会生效:
A func(bool b)
A a1(1);
A a2(2);
return b ? a1 : a2;
④ 值捕获异常对象的时候,拷贝构造是不可被省略的:
void func()
A a;
throw a; // 拷贝构造
int main()
try
func();
catch(A a) // 拷贝构造
使用方式
当我们想从一个函数返回一个局部变量,怎样才是正确的写法呢?
我们知道C++11中的移动构造函数,并了解到它会窃取资源,因此,利用这一特性来避免拷贝的发生,看起来是一种不错的做法:
A&& func()
A a(1);
return std::move(a);
int main()
A&& a = func();
但需要注意的是,以上写法是错误的。因为返回的值指向了一个已经析构的值。此时它的内存是非法的。
我们可能还会有很多其他的写法:
A func()
A a(1);
return std::move(a);
int main()
A a = func();
像这种做法虽然不会导致错误,但是在这里指定move是不必要的,因为这里满足移动的条件会自然触发移动构造;并且有可能会影响到编译器的RVO优化。我们也许会得到编译器的一个警告。
因此,我们仅有一种最佳写法,那就是直接返回值,并使用值接收。更进一步,我们应该直接返回匿名变量:
A func()
return A(1);
int main()
A a = func();
复制消除
更一般的,除了函数返回值的情况,如果我们在初始化类的时候,使用一个纯右值表达式进行复制构造,且类型相同,那么这里将直接进行构造目标对象,而不是从临时对象中通过拷贝初始化的形式构造。
我们把这种做法统称为复制消除(Copy elision):
A a = A();
string s = "hello";
上述例子本身属于拷贝初始化,由于编译器复制消除的优化,将仅调用一次构造函数。
总结
最后,作为巩固,考虑编译器的优化,让我们来尝试描述变量a1~a8的构建过程中,哪些函数将被调用。更进一步的,如果取消移动构造的注释呢?
class A
public:
int i = 0;
A() cout << "Default Constructor" << endl;
A(int _i) : i(_i) cout << "Param Constructor" << endl;
A(const A& a) i = a.i; cout << "Copy Constructor " << endl;
~A() cout << "Destructor" << endl;
void operator=(const A& a) i = a.i; cout << "Operator = " << endl;
//A(A&& a) noexcepti = a.i;cout << "Move Constructor " <<endl;
;
A func1()
A a(1);
return a;
A func2()
return A(1);
A func3(A a)
return a;
int main()
A a1 = func1();
A a2 = func2();
A a3, a4;
a3 = func1();
a4 = func2();
A a5(func1());
A a6(func2());
A a7 = func3(func3(a1));
A a8 = A(func2());
return 0;
以上是关于[引擎开发] 深入C++拷贝控制的主要内容,如果未能解决你的问题,请参考以下文章