(译注:本章细节非常多,纷繁复杂,一些语法特点体现了似乎在禁止一个问题,但是又在背后开了一个后门,在实践中极其容易出错,需要特别注意每一个细节。)
第17章 构造、清理、复制、和移
(Construction,Cleanup,Copy,and Move)
目录
17.1 引言
17.2 构造函数和析构函数(Constructors and Destructors)
17.2.1 构造函数和不变量(Constructors and Invariants)
17.2.2 析构函数和资源(Destructors and Resources)
17.2.3 基类和成员的析构函数(Base and Member Destructors)
17.2.4 调用构造函数和析构函数(Calling Constructors and Destructors)
17.2.5 virtual析构函数(virtual Destructors)
17.3 类对象初始化(Class Object Initialization)
17.3.1 无构造函数的初始化(Initialization Without Constructors)
17.3.2 使用构造函数初始化(Initialization Using Constructors)
17.3.2.1 通过构造函数进行初始化(Initialization by Constructors)
17.3.3 默认构造函数 (Default Constructors)
17.3.4 初始化列表构造函数 (Initializer-List Constructors)
17.3.4.1 initializer_list构造函数消歧义(initializer_list Constructor Disambiguation)
17.3.4.2 使用initializer_list (Use of initializer_lists)
17.3.4.3 直接初始化和复制初始化(Direct and Copy Initialization)
17.4 类成员和基类成员的初始化(Member and Base Initialization)
17.4.1 类成员初始化(Member Initialization)
17.4.1.1 类成员的初始化和赋值(Member Initialization and Assignment)
17.4.2 基类初始化器(Base Initializers)
17.4.3 委托构造函数(Delegating Constructors)
17.4.4 类中初始化器(In-Class Initializers)
17.4.5 static成员初始化(static Member Initialization)
17.5 复制和移动(Copy and Move)
17.5.1 复制(Copy)
17.5.1.1 警惕默认构造函数(Beware of Default Constructors)
17.5.1.2 基类(成员)的复制(Copy of Bases)
17.5.1.3 复制的含义(The Meaning of Copy)
17.5.1.4 复制的分片问题(Slicing)
17.5.2 移动(Move)
17.6 生成默认操作(Generating Default Operations)
17.6.1 显式默认(Explicit Default)
17.6.2 默认操作(Default Operations)
17.6.3 使用默认操作(Using Default Operations)
17.6.3.1 使用默认构造函数(Default Constructors)
17.6.3.2 维持不变量(Maintaining Invariants)
17.6.3.3 资源不变量(Resource Invariants)
17.6.3.4 部分指定不变量(Partially Specified Invariants)
17.6.4 被delete的函数 (deleted Functions)
17.7 建议
17.1 引言
本章重点介绍对象“生命周期”的技术方面:如何创建对象、如何复制对象、如何移动对象以及对象消失后如何清理?“复制”和“移动”的正确定义是什么?例如:
string ident(string arg) // 通过值传递的string (复制进arg)
{
return arg; // return string (将arg的值移出ident()并给到调用者)
}
int main ()
{
string s1 {"Adams"}; // 初始化string (在s1中构造).
s1 = indet(s1); // 将s1 拷进ident()
// 将 ident(s1)的结果移入s1;
// s1的值是 "Adams".
string s2 {"Pratchett"}; // 初始化sting (在s2中构造)
s1 = s2; // 将s2的值复制进s1
// s1和s2的值均为"Pratchett".
}
显然,在调用 ident() 之后,s1 的值应该是“Adams”。我们将 s1 的值复制到参数 arg 中,然后将 arg 的值移出函数调用并(返回)移入 s1。接下来,我们用值“Prachett”构造 s2 并将其复制到 s1 中。最后,在退出 main() 时,我们销毁变量 s1 和 s2。移动和复制之间的区别在于,复制后两个对象一定具有相同的值,而移动后,移动的源不需要具有其原始值。当源对象不再使用时,可以使用移动。它们对于实现移动资源的概念特别有用(§3.2.1.2,§5.2)。
这里用到了几个函数:
• 构造函数使用字符串文字量初始化string (用于 s1 和 s2)
• 复制构造函数复制string (到函数参数 arg 中)
• 移动构造函数移动string的值(从 ident() 中的 arg 移动到保存 ident(s1) 结果的临时变量中)
• 移动赋值移动字符串的值(从保存 ident(s1) 结果的临时变量移动到 s1)
• 复制赋值复制string (从 s2 复制到 s1)
• 析构函数释放 s1、s2 和保存 ident(s1) 结果的临时变量所拥有的资源
优化器可以消除部分工作。例如,在这个简单的例子中,临时变量通常被消除。但是,在原则上,这些操作是执行的。
构造函数、复制和移动赋值操作以及析构函数直接支持生命周期和资源管理的方式。对象在其构造函数完成后被视为其类型的对象,并且在其析构函数开始执行之前,它仍然是其类型的对象。对象生命周期和错误之间的相互作用将在§13.2 和 §13.3 中进一步探讨。特别是,本章不讨论半构造和半销毁对象的问题。
对象的构造在许多设计中起着关键作用。这种广泛的用途反映在支持初始化的语言功能的范围和灵活性上。
类型的构造函数、析构函数以及复制和移动操作在逻辑上并不是分开的。我们必须将它们定义为匹配集,否则会遇到逻辑或性能问题。如果类 X 的析构函数执行非平凡的任务(例如释放存储空间或释放锁),则该类可能需要完整的函数:
class X {
X(Sometype); // “普通构造函数”: 创建一个对象
X(); //默认构造函数
X(const X&); // 复制构造函数
X(X&&); //移动构造函数
X& operator=(const X&); // 复制赋值: 清理目标值并复制
X& operator=(X&&); // 移动赋值: 清理目标并移动
˜X(); //析构: 清理
// ...
};
对象被复制或移动有五种情况:
• 作为赋值的来源
• 作为对象初始化器
• 作为函数参数
• 作为函数返回值
• 作为异常
在所有情况下,都会应用复制或移动构造函数(除非可以进行优化)。
除了初始化命名对象和自由存储中的对象之外,构造函数还用于初始化临时对象(§6.4.2)和实现显式类型转换(§11.5)。
除了“普通构造函数”,这些特殊成员函数可以由编译器生成;参见§17.6。
本章充满了规则和技术细节。这些对于全面理解本章必不可少,但大多数人只是从示例中学习一般规则。
17.2 构造函数和析构函数(Constructors and Destructors)
(译注:“析”的本义为“破开,拆开”,与构造反义。)
我们可以通过定义构造函数(§16.2.5,§17.3)来指定如何初始化类的对象。为了补充构造函数,我们可以定义一个析构函数来确保在对象销毁时(例如,当它超出范围时)进行“清理”。C++ 中一些最有效的资源管理技术依赖于构造函数/析构函数对。其他依赖于一对操作的技术也是如此,例如执行/撤消、启动/停止、之前/之后等。例如:
struct Tracer {
string mess;
Tracer(const string& s) :mess{s} { clog << mess; }
˜Tracer() {clog << "˜" << mess; }
};
void f(const vector<int>& v)
{
Tracer tr {"in f()\n"};
for (auto x : v) {
Tracer tr {string{"v loop "}+to<string>(x)+'\n'}; // §25.2.5.1
// ...
}
}
我们可以尝试调用:
f({2,3,5});
这将打印到日志流:
in_f()
v loop 2
˜v loop 2
v loop 3
˜v loop 3
v loop 5
˜v loop 5
˜in_f()
17.2.1 构造函数和不变量(Constructors and Invariants)
与类同名的成员称为构造函数。例如:
class Vector {
public:
Vector(int s);
// ...
};
构造函数声明指定参数列表(与函数完全相同),但没有返回类型。类的名称不能用于类内的普通成员函数、数据成员、成员类型等。例如:
struct S {
S(); //fine
void S(int); // 错 : 构造函数不能指定返回类型
int S; // 错: 类名只能表示构造函数名
enum S { foo, bar }; // 错: 类名只能表示构造函数名
};
构造函数的作用是初始化其类的对象。通常,初始化必须建立一个类不变量,即每次调用成员函数(从类外部)时都必须保持不变的某个东西。考虑一下:
class Vector {
public:
Vector(int s);
// ...
private:
double∗ elem; // elem 指向一个sz大小的double型数组
int sz; // sz 是非负的
};
这里(通常如此)不变量以注释的形式表述:“elem 指向一个 sz 双精度数组”和“sz 非负”。构造函数必须确保这一点为真。例如:
Vector::Vector(int s)
{
if (s<0) throw Bad_siz e{s};
sz = s;
elem = new double[s];
}
此构造函数尝试建立不变量,如果不能,则会抛出异常。如果构造函数不能建立不变量,则不会创建任何对象,并且构造函数必须确保不会泄露任何资源(§5.2,§13.3)。资源是我们需要获取的任何东西,并且最终在完成后(显式或隐式地)归还(释放)。资源的例子包括内存(§3.2.1.2)、锁(§5.3.4)、文件句柄(§13.3)、线程句柄(§5.3.1)、等等。
为什么你要定义一个不变量(译注:即在构造函数中初始化一个类对象的这些变量)?
• 集中精力于类的设计工作(§2.4.3.2)
• 阐明类的行为(例如,在错误条件下;§13.2)
• 简化成员函数的定义(§2.4.3.2,§16.3.1)
• 阐明类的资源管理(§13.3)
• 简化类的文档
平均而言,定义不变量的努力最终会节省工作量。
17.2.2 析构函数和资源(Destructors and Resources)
构造函数初始化一个对象。换句话说,它创建成员函数运行的环境。有时,创建该环境涉及获取资源(例如文件、锁或一些内存),这些资源必须在使用后释放(§5.2,§13.3)。因此,某些类需要一个保证在对象被销毁时调用的函数,其方式类似于保证在创建对象时调用构造函数的方式。不可避免地,这样的函数被称为析构函数。析构函数的名称为 ˜ 后跟类名,例如 ˜Vector()。˜ 的一个含义是“补充(complement)”(§11.1.2),类的析构函数补充了其构造函数(译注:使其成对而完整)。析构函数不接受参数,一个类只能有一个析构函数。当自动变量超出范围、自由存储中的对象被删除等情况时,系统会隐式地调用析构函数。只有在极少数情况下,用户才需要显式调用析构函数(§17.2.4)。
析构函数通常清理并释放资源。例如:
class Vector {
public:
Vector(int s) :elem{new double[s]}, sz{s} { }; // 构造函数: 获取内存
˜Vector() { delete[] elem; } // 析构函数:释放内存
// ...
private:
double∗ elem; // elem 指向一个sz大小的double型数组
int sz; // sz是非负值
};
例如:
Vector∗ f(int s)
{
Vector v1(s);
// ...
return new Vector(s+s);
}
void g(int ss)
{
Vector∗ p = f(ss);
// ...
delete p;
}
这里,Vector v1 在退出 f() 时被销毁。此外,使用 new 在自由存储中创建的 Vector 被 delete 调用销毁。在这两种情况下,Vector 的析构函数都会被调用来释放(释放内存分配)构造函数分配的内存。
如果构造函数无法获取足够的内存会怎样?例如,s∗sizeof(double) 或
(s+s)∗sizeof(double) 可能大于可用内存量(以字节为单位)。在这种情况下,new 会抛出异常 std::bad_alloc(§11.2.3),异常处理机制会调用适当的析构函数,以便释放已获取的所有内存(且仅限此内存)(§13.5.1)。
这种基于构造函数/析构函数的资源管理风格称为资源获取即初始化或简称为 RAII(§5.2,§13.3)。
匹配的构造函数/析构函数对是实现 C++ 中可变大小对象概念的常用机制。标准库容器(例如 vector 和 unordered_map)使用这种技术的变体来为其元素提供存储空间。
没有声明析构函数的类型(例如内置类型)被认为具有不执行任何操作的析构函数。
为类声明析构函数的程序员还必须决定是否可以复制或移动该类的对象(§17.6)。
17.2.3 基类和成员的析构函数(Base and Member Destructors)
构造函数和析构函数与类层次结构正确交互(第 20 章第 3.2.4 节)。构造函数“自下而上”构建类对象:
[1] 首先,构造函数调用其基类构造函数,
[2] 然后,它调用成员构造函数,并且
[3] 最后,它执行其自身的主体。
析构函数以相反的顺序“拆除(析构)”对象:
[1] 首先,析构函数执行其自身的主体,
[2] 然后,它调用其成员析构函数,并且
[3] 最后,它调用其基类析构函数。
具体来说,virtual基类在任何可能使用它的基类之前被构造,并在所有此类基类之后被销毁(§21.3.5.1)。此顺序可确保基类或成员在初始化之前不会被使用,也不会在销毁之后被使用。程序员可以打破这个简单而重要的规则,但只能通过故意规避将指针作为参数传递给未初始化的变量来打破规则。这样做违反了语言规则,结果通常是灾难性的。
构造函数按声明顺序(而不是初始化器的顺序)执行成员和基构造函数:如果两个构造函数使用不同的顺序,则析构函数无法(在没有严重开销的情况下)保证以与构造相反的顺序进行销毁。另请参阅 §17.4。
如果使用某个类,因此需要默认构造函数,并且该类没有其他构造函数,则编译器将尝试生成默认构造函数。例如:
struct S1 {
string s;
};
S1 x; // OK: x.s is initialized to ""
类似地,如果需要初始化器,可以使用成员初始化。例如:
struct X { X(int); };
struct S2 {
X x;
};
S2 x1; // error :
S2 x2 {1}; // OK: x2.x 用1进行初始化
另见 §17.3.1 。
17.2.4 调用构造函数和析构函数(Calling Constructors and Destructors)
析构函数在退出作用域或删除时隐式调用。通常不仅不需要显式调用析构函数;这样做还会导致严重错误。但是,在极少数情况下(但很重要),必须显式调用析构函数。考虑一个容器(如 std::vector),它维护一个内存池,可以在其中增长和收缩(例如,使用 push_back() 和 pop_back())。当我们添加一个元素时,容器必须为具体地址调用其构造函数:
void C::push_back(const X& a)
{
// ...
new(p) X{a}; // 复制构造一个在地址p中具有值a的X
// ...
}
构造函数的这种使用称为“置位new(placement new)”(§11.2.4)。
相反,当我们删除一个元素时,容器需要调用它的析构函数:
void C::pop_back()
{
// ...
p−>˜X(); // 销毁存于地址p中的X
}
p−>˜X() 符号调用 X 的析构函数来处理 ∗p。该符号绝不应该用于以正常方式销毁的对象(因为对象超出范围或被删除)。
有关内存区域中对象的显式管理的更完整示例,请参阅§13.6.1。
如果为类 X 声明了析构函数,则每当 X 超出范围或被删除时,都会隐式调用析构函数。这意味着我们可以通过将其析构函数声明为 =delete (§17.6.4) 或 private 来防止 X 被破坏。
在这两种选择中,使用 private 更灵活。例如,我们可以创建一个类,其中的对象可以显式销毁,但不能隐式销毁:
class Nonlocal {
public:
// ...
void destroy() { this−>˜Nonlocal(); } // 显式析构
private:
// ...
˜Nonlocal(); /不能隐匿销毁
};
void user()
{
Nonlocal x; // 错: 不能销毁 Nonlocal对象
X∗ p = new Nonlocal; // OK
// ...
delete p; // 错: 不能销毁Nonlocal对象
p.destroy(); //OK
}
17.2.5 virtual析构函数(virtual Destructors)
析构函数可以声明为virtual,并且通常应该用于具有virtual函数的类。例如:
class Shape {
public:
// ...
virtual void draw() = 0;
virtual ˜Shape();
};
class Circle {
public:
// ...
void draw();
˜Circle(); // overr ides ˜Shape()
// ...
};
我们之所以需要virtual析构函数,是因为通常通过基类提供的接口操作的对象也经常通过该接口被删除:
void user(Shape∗ p)
{
p−>draw(); // 调用合适的draw()
// ...
delete p; // 调用合适的析构函数
};
如果 Shape 的析构函数不是virtual的,则 delete 将无法调用适当的派生类析构函数(例如,˜Circle())。该失败将导致已删除对象所拥有的资源(如果有)泄露。
17.3 类对象初始化(Class Object Initialization)
本节讨论如何使用和不使用构造函数来初始化类的对象。还展示了如何定义构造函数以接受任意大小的同质初始化列表(例如{1,2,3} 和 {1,2,3,4,5,6})。
17.3.1 无构造函数的初始化(Initialization Without Constructors)
我们不能为内置类型定义构造函数,但我们可以用合适类型的值初始化它。例如:
int a {1};
char∗ p {nullptr};
(译注:至少存在6种初始化形式,其中不带“=”号的初始化只能用于初始化,不能用于赋值,例如:
int a{ 1 };
int b(1);
int c = {1};
int d = int(1);
int e = 1;
int f = (1);
以上6种初始化本质上都是一样的:
int a{ 1 };
00007FF73ABE2DBD mov dword ptr [a],1
int b(1);
00007FF73ABE2DC5 mov dword ptr [b],1
int c = {1};
00007FF73ABE2DCD mov dword ptr [c],1
int d = int(1);
00007FF73ABE2DD5 mov dword ptr [d],1
int e = 1;
00007FF73ABE2DDD mov dword ptr [e],1
int f = (1);
00007FF73ABE2DE5 mov dword ptr [f],1
但推荐使用{}的方式,这种方式是安全的,不进行隐匿转换,例如:
int m{1.2}; //编译时会提示错误
)
类似地,我们可以使用以下方法初始化尚未定义构造函数的类的对象:
• 逐个成员初始化,
• 复制初始化,或
• 默认初始化(没有初始化器或带有空的初始化器列表)。
例如:
struct Work {
string author;
string name;
int year;
};
Work s9 { "Beethoven",
"Symphony No. 9 in D minor, Op. 125; Choral",
1824
}; //逐个成员初始化
Work currently_playing { s9 }; // 复制初始化
Work none {}; // 默认初始化
current_playing 的三名成员是 s9 的复制品。
使用 {} 的默认初始化定义为通过 {} 初始化每个成员。因此,none 被初始化为 {{},{},{}},即 {"","",0} (§17.3.3)。
如果未声明需要参数的构造函数,也可以完全省略初始化程序。例如:
Work alpha;
void f()
{
Work beta;
// ...
}
对此,规则并不像我们所希望的那样清晰。对于静态分配的对象(§6.4.2),规则与使用 {} 时完全相同,因此 alpha 的值为 {"","",0}。但是,对于局部变量和自由存储对象,默认初始化仅针对类类型的成员进行,而内置类型的成员则未初始化,因此 beta 的值为 {"","",unknown}。
这种复杂化的原因是为了在极少数的危急情况下提高性能。例如:
struct Buf {
int count;
char buf[16∗1024];
};
您可以将 Buf 用作局部变量,而无需在将其用作输入操作的目标之前对其进行初始化。大多数局部变量初始化对性能并不重要,未初始化的局部变量是错误的主要来源。如果您想要保证初始化或只是不喜欢意外,请提供一个初始化程序,例如 {}。例如:
Buf buf0; // 静态分配, 因此默认初始化
void f()
{
Buf buf1; // 元素未初始化
Buf buf2 {}; // 我真的想把这些元素归零
int∗ p1 = new int; // *p1 未初始化
int∗ p2 = new int{}; // *p2 == 0
int∗ p3 = new int{7}; // *p3 == 7
// ...
}
当然,只有当我们能够访问成员时,成员初始化才有效。例如:
template<class T>
class Checked_pointer { // 控制对T* member 的访问
public:
T& operator∗(); // 验证nullptr并返回值
// ...
};
Checked_pointer<int> p {new int{7}}; // 错: 不能访问p.p
如果类具有私有非static数据成员,则需要一个构造函数来初始化它。
17.3.2 使用构造函数初始化(Initialization Using Constructors)
当成员逐一复制不够或不合适时,可以定义构造函数来初始化对象。具体来说,构造函数通常用于为其类建立不变量并获取执行此操作所需的资源(§17.2.1)。
如果为类声明了构造函数,则每个对象都会使用某个构造函数。尝试创建对象时,如果没有构造函数所需的适当初始化程序,则会产生错误。例如:
struct X {
X(int);
};
X x0; //错 : 无初始化器
X x1 {}; // 错: 空初始化器
X x2 {2}; // OK
X x3 {"two"}; // 错: 错误的初始化器类型
X x4 {1,2}; // 错误:错误的初始化器数量
X x5 {x4}; // OK: 隐式定义的复制构造函数 (§17.6)
请注意,当您定义需要参数的构造函数时,默认构造函数 (§17.3.3) 会消失;毕竟,X(int) 表示构造 X 需要一个 int。但是,复制构造函数不会消失 (§17.3.3);假设对象可以复制(一旦正确构造)。如果后者可能导致问题(§3.3.1),您可以明确禁止复制(§17.6.4)。
我使用 {} 符号来明确表示我正在初始化。我不是(仅仅)分配一个值、调用一个函数或声明一个函数。初始化的 {} 符号可用于在可以构造对象的地方向构造函数提供参数。例如:
struct Y : X {
X m{0}; //为m提供默认初始化器
Y(int a) :X{a}, m{a} { }; //初始化基类和成员(§17.4)
Y() : X{0} { }; // initialize base and member
};
X g {1}; // 初始化全局变量
void f(int a)
{
X def {}; // 错: X没有默认值
Y de2 {}; // OK: 使用默认构造函数
X∗ p {nullptr};
X var {2}; // 初始化局部变量
p = new X{4}; // 在自由存储区初始化对象
X a[] {1,2,3}; // 初始化数组元素
vector<X> v {1,2,3,4}; // 初始化vector元素
}
因此,{} 初始化有时被称为通用初始化:该符号可以在任何地方使用。此外,{} 初始化是统一的:无论你在哪里用值 v 通过使用 {v} 符号初始化类型 X 的对象,都会创建相同的类型 X 的值 (X{v})。
用于初始化的 = 和 () 符号(§6.3.5)不是通用的。例如:
struct Y : X {
X m;
Y(int a) : X(a), m=a { }; // 语法错误: 对于成员初始化不能使用 =
};
X g(1); // 初始化全局变量
void f(int a)
{
X def(); //函数返回一个 X (惊奇!?)
X∗ p {nullptr};
X var = 2; // 初始化局部变量
p = new X=4; // 语法错误: 对new不能使用 =
X a[](1,2,3); // 错: 对数组初始化不能全用()
vector<X> v(1,2,3,4); // 错: 对列表元素不能使用 ()
}
= 和 () 初始化符号也不统一,但幸运的是,这些符号的例子并不明显。如果你坚持使用 = 或 () 初始化,你必须记住它们被允许出现在哪里以及它们的含义。
常见的重载解析规则(§12.3)适用于构造函数。例如:
struct S {
S(const char∗);
S(double∗);
};
S s1 {"Napier"}; // S::S(const char*)
S s2 {new double{1.0}}; // S::S(double*);
S s3 {nullptr}; //歧义: S::S(const char*) 还是 S::S(double*)?
请注意,{}-初始化符号不允许窄化(§2.2.2)。这是选择 {} 样式而不是 () 或 = 的另一个原因。
17.3.2.1 通过构造函数进行初始化(Initialization by Constructors)
使用 () 表示法,您可以请求在初始化中使用构造函数。也就是说,您可以确保对于类,您将通过构造函数进行初始化,而不是获得 {} 表示法也提供的逐成员初始化或初始化列表初始化(§17.3.4)。例如:
struct S1 {
int a,b; // 无构造函数
};
struct S2 {
int a,b;
S2(int a = 0, int b = 0) : a(aa), b(bb) {} // 构造函数
};
S1 x11(1,2); // 错: 无构造函数
S1 x12 {1,2}; // OK: 逐成员初始化
S1 x13(1); // 错: 无构造函数
S1 x14 {1}; // OK: x14.b becomes 0
S2 x21(1,2); // OK: use constructor
S2 x22 {1,2}; // OK: use constructor
S2 x23(1); // OK: 使用构造函数并使用一个缺省参数
S2 x24 {1}; // OK: 使用构造函数并使用一个缺省参数
统一使用 {} 初始化仅在 C++11 中成为可能,因此较旧的 C++ 代码使用 () 和 = 初始化。因此,您可能更熟悉 () 和 =。但是,除了在极少数情况下需要区分使用元素列表和构造函数参数列表进行初始化之外,我不知道有什么逻辑理由更喜欢 () 符号。例如:
vector<int> v1 {77}; // 具有值77的一个元素
vector<int> v2(77); // 具有默认值0的77个元素
当具有初始化列表构造函数(§17.3.4)的类型(通常是容器)也具有接受元素类型参数的“普通构造函数”时,可能会发生此问题(以及选择的需要)。特别是,我们偶尔必须对整数和浮点数向量使用()初始化,但对于指针字符串向量则无需这样做:
vector<string> v1 {77}; //具有默认值 "" 的77个元素
// (vector<string>(std::initializer_list<string>) 不接受 {77})
vector<string> v2(77); // 具有默认值 "" 的77个元素
vector<string> v3 {"Booh!"}; //具有值 "Booh!"的一个元素
vector<string> v4("Booh!"); // 错: 无构造函数取a 一个string 参数
vector<int∗> v5 {100,0}; // 100个 int* 初始化为 nullptr (100 不是一个 int*)
vector<int∗> v6 {0,0}; // 2个 int* 初始化为 nullptr
vector<int∗> v7(0,0); // 空的vector (v7.size()==0)
vector<int∗> v8; //空的 (v7.size()==0)
(译注:{}用于初始化,由于处处均可用,使其不太好掌握,由于像上述这种应用,极容易混淆,这种初始化似乎是靠编译器去推断。)
v6 和 v7 示例仅引起语言律师和测试人员的兴趣。
17.3.3 默认构造函数 (Default Constructors)
无需参数即可调用的构造函数称为默认构造函数。默认构造函数非常常见。例如:
class Vector {
public:
Vector(); // default constructor : no elements
// ...
};
如果未指定参数或提供了空的初始化列表,则使用默认构造函数:
Vector v1; // OK
Vector v2 {}; // OK
默认参数(§12.2.5)可以创建一个将参数传入默认构造函数的构造函数。例如:
class String {
public:
String(const char∗ p = ""); // default constructor : empty string
// ...
};
String s1; // OK
String s2 {}; // OK
标准库vector和string具有这样的默认构造函数(§36.3.2,§31.3.2)。
内置类型被认为具有默认和复制构造函数。但是,对于内置类型,不会为未初始化的非static变量调用默认构造函数(§17.3)。内置类型的默认值对于整数为 0,对于浮点类型为 0.0,对于指针为 nullptr。例如:
void f()
{
int a0; // 未初始化
int a1(); // 函数声明 (意图 ?)
int a {}; // a 值为 0
double d {}; // d 值为0.0
char∗ p {}; //p 值为nullptr
int∗ p1 = new int; // 未初始化的int
int∗ p2 = new int{}; // 初始化为 0
}
内置类型的构造函数最常用于模板参数。例如:
template<class T>
struct Handle {
T∗ p;
Handle(T∗ pp = new T{}) :p{pp} { }
// ...
};
Handle<int> px; // will generate int{}
产生的int将初始化为0 。
引用和 const 必须初始化(§7.7,§7.5)。因此,包含此类成员的类不能默认构造,除非程序员提供类内成员初始化器(§17.4.4)或定义初始化它们的默认构造函数(§17.4.1)。例如:
int glob {9};
struct X {
const int a1 {7}; // OK
const int a2; // 错 : 要求用户定义的构造函数
const int& r {9}; // OK
int& r1 {glob}; // OK
int& r2; // 错: 要求用户定义的构造函数
};
X x; // 错: 对 X 没有默认构造函数
可以声明数组、标准库向量和类似的容器来分配多个默认初始化元素。在这种情况下,对于用作向量或数组元素类型的类,显然需要默认构造函数。例如:
struct S1 { S1(); }; // 有默认构造函数
struct S2 { S2(string); }; // 无默认构造函数
S1 a1[10]; // OK: 10个默认元素
S2 a2[10]; // 错: 不能初始化元素
S2 a3[] { "alpha", "beta" }; // OK: 两个元素: S2{"alpha"}, S2{"beta"}
vector<S1> v1(10); // OK: 10个默认元素
vector<S2> v2(10); // 错: 不能初始化元素
vector<S2> v3 { "alpha", "beta" }; // OK: 两个元素: S2{"alpha"}, S2{"beta"}
vector<S2> v2(10,""); // OK: 10 个元素,每一个初始化为S2{""}
vector<S2> v4; // OK: 无元素
类何时应该有默认构造函数?一个简单的技术答案是“当您将其用作数组的元素类型等时”。然而,更好的问题是“对于哪些类型,拥有默认值是有意义的?”甚至“这种类型是否有一个我们可以‘自然’用作默认值的‘特殊’值?”字符串有空字符串(“”),容器有空集({}),数值值为零。决定默认Date(§16.3)的麻烦是因为没有“自然”的默认日期(大爆炸太遥远了,与我们日常的日期没有精确的联系)。在发明默认值时不要太聪明是个好主意。例如,没有默认值的元素容器的问题通常最好的解决方法是,直到您为它们分配适当的值(例如,使用 push_back())才分配元素。
17.3.4 初始化列表构造函数 (Initializer-List Constructors)
接受单个 std::initializer_list 类型参数的构造函数称为初始化列表构造函数。初始化列表构造函数用于使用 {} 列表作为其初始化值来构造对象。标准库容器(例如 vector 和 map)具有初始化列表构造函数、赋值等(§31.3.2、§31.4.3)。考虑:
vector<double> v = { 1, 2, 3.456, 99.99 };
list<pair<string,string>> languages = {
{"Nygaard","Simula"}, {"Richards","BCPL"}, {"Ritchie","C"}
};
map<vector<string>,vector<int>> years = {
{ {"Maurice","Vincent", "Wilkes"},{1913, 1945, 1951, 1967, 2000} },
{ {"Mar tin", "Richards"} {1982, 2003, 2007} },
{ {"David", "John", "Wheeler"}, {1927, 1947, 1951, 2004} }
};
接受{}列表的机制是一个函数(通常是构造函数),该函数采用 std::initializer_list<T> 类型的参数。例如:
void f(initializer_list<int>);
f({1,2});
f({23,345,4567,56789});
f({}); // the empty list
f{1,2}; // 错 : 函数调用的 () 丢失
years.inser t({{"Bjarne","Stroustrup"},{1950, 1975, 1985}});
初始化列表的长度可以是任意的,但必须是同质的。也就是说,所有元素都必须是模板参数类型 T,或者可以隐式转换为 T。
17.3.4.1 initializer_list构造函数消歧义(initializer_list Constructor Disambiguation)
当一个类有多个构造函数时,通常使用重载解析规则(§12.3)来为给定的参数集选择正确的构造函数。对于选择构造函数,默认和初始化列表优先。考虑:
struct X {
X(initializer_list<int>);
X();
X(int);
};
X x0 {}; // 空list:默认构造函数或initializer-list 构造函数? (默认构造函数)
X x1 {1}; // 1个整数: 1个int参数或1个元素的列表? (initializer-list 构造函数)
规则是:
• 如果可以调用默认构造函数或initializer-list构造函数,则首选默认构造函数。
• 如果可以同时调用初始化列表构造函数和“普通构造函数”,则首选initializer-list构造函数。
第一条规则“优先使用默认构造函数”基本上是常识:尽可能选择最简单的构造函数。此外,如果您定义一个initializer-list构造函数来使用空列表执行与默认构造函数不同的操作,则您可能遇到了设计错误。
第二条规则“优先使用初始化列表构造函数”是必要的,以避免根据元素数量不同而产生不同的解析度。考虑 std::vector (§31.4):
vector<int> v1 {1}; // one element
vector<int> v2 {1,2}; // two elements
vector<int> v3 {1,2,3}; // three elements
vector<string> vs1 {"one"};
vector<string> vs2 {"one", "two"};
vector<string> vs3 {"one", "two", "three"};
在所有情况下,都会使用initializer-list构造函数。如果我们确实想调用接受一个或两个整数参数的构造函数,则必须使用 () 符号:
vector<int> v1(1); // 具有默认值0的一个元素
vector<int> v2(1,2); // 具有值2的一个元素
17.3.4.2 使用initializer_list (Use of initializer_lists)
具有 initializer_list<T> 参数的函数可以使用成员函数 begin(),end() 和 size() 将其作为序列访问。例如:
void f(initializer_list<int> args)
{
for (int i = 0; i!=args.siz e(); ++i)
cout << args.begin()[i] << "\n";
}
遗憾的是,initializer_list 不提供下标。
initializer_list<T> 是按值传递的。这是重载解析规则 (§12.3) 所要求的,并且不会产生开销,因为initializer_list<T> 对象只是 T 数组的一个小句柄(通常是两个字)。
该循环也可以等效地写成:
void f(initializer_list<int> args)
{
for (auto p=args.begin(); p!=args.end(); ++p)
cout << ∗p << "\n";
}
或:
void f(initializer_list<int> args)
{
for (auto x : args)
cout << x << "\n";
}
要明确使用initializer_list,您必须 #include 定义它的头文件:<initializer_list>。但是,由于 vector、map 等使用initializer_list,因此它们的头文件(<vector>,<map> 等)已经 #include <initializer_list>,因此您很少需要直接这样做。
initializer_list的元素是不可变的。不要试图修改它们的值。例如:
int f(std::initializer_list<int> x, int val)
{
∗x.begin() = val; //错: 试图改变一个initializer-list元素的值
return ∗x.begin(); // OK
}
void g()
{
for (int i=0; i!=10; ++i)
cout << f({1,2,3},i) << '\n';
}
如果 f() 中的赋值成功,则看起来 1(在 {1,2,3} 中)的值可能会发生变化。这会严重损害我们一些最基本的概念。由于 initializer_list 元素是不可变的,因此我们不能对它们应用移动构造函数(§3.3.2,§17.5.2)。
容器可能会实现如下初始化列表构造函数:
template<class E>
class Vector {
public:
Vector(std::initializ er_list<E> s); // initializer-list构造函数
// ...
private:
int sz;
E∗ elem;
};
template<class E>
Vector::Vector(std::initializer_list<E> s)
:sz{s.size()} // set vector size
{
reserve(sz); // 获得恰当数量的空间
uninitialized_copy(s.begin(),s.end(),elem); //在elem[0:s.size())中初始化元素
}
初始化列表是通用和统一初始化设计的一部分(§17.3)。
17.3.4.3 直接初始化和复制初始化(Direct and Copy Initialization)
对于 {} 初始化,保留直接初始化和复制初始化 (§16.2.6) 之间的区别。对于容器,这意味着区别适用于容器及其元素:
• 容器的initializer_list构造函数可以是显式的,也可以不是。
• initializer_list元素类型的构造函数可以是显式的,也可以不是。
对于 vector<vector<double>>,我们可以看到元素的直接初始化与复制初始化的区别。例如:
vector<vector<double>> vs = {
{10,11,12,13,14}, // OK: 5个元素的vector
{10}, //OK: 1个元素的vector
10, //错 : vector<double>(int)是显式的
vector<double>{10,11,12,13}, // OK: 5个元素的vector
vector<double>{10}, // OK:一个具有10.0值元素的vector
vector<double>(10), // OK: 10个具有0.0值元素的vector
};
容器可以有一些显式构造函数,而有些则不是。标准库vector就是一个例子。例如,std::vector<int>(int) 是显式的,但 std::vector<int>(initialize_list<int>) 不是:
vector<double> v1(7); // OK: v1 有7 个元素; 注音: 使用()而不是{}
vector<double> v2 = 9; // 错: 不存在从int向vector的转换
void f(const vector<double>&);
void g()
{
v1 = 9; // 错: 不存在从int向vector的转换
f(9); //错: 不存在从int向vector的转换
}
用{}替换(),我们得到:
vector<double> v1 {7}; // OK: v1 有1个元素 (其元素值为7)
vector<double> v2 = {9}; // OK: v2 具有一个元素 (其元素值为9)
void f(const vector<double>&);
void g()
{
v1 = {9}; // OK: v1 现在有1个元素(具有值9)
f({9}); // OK: 用列表{9} 调用f
}
显然,结果截然不同。
此示例经过精心设计,以给出最令人困惑的情况的示例。请注意,对于较长的列表,明显的歧义(在人类读者眼中,但在编译器眼中不是)不会出现。例如:
vector<double> v1 {7,8,9}; // OK: v1 具有3个元素,其值为{7,8,9}
vector<double> v2 = {9,8,7}; // OK: v2 具有3个元素,其值为{9,8,7}
void f(const vector<double>&);
void g()
{
v1 = {9,10,11}; // OK: v1 现在有3个元素,其值为{9,10,11}
f({9,8,7,6,5,4}); // OK: 用列表列表{9,8,7,6,5,4}调用f
}
类似地,非整数类型元素列表不会出现潜在的歧义:
vector<string> v1 { "Anya"}; // OK: v1 有1个元素 (其值为 "Anya")
vector<string> v2 = {"Courtney"}; // OK: v2 有1个元素 (其值为 "Courtney")
void f(const vector<string>&);
void g()
{
v1 = {"Gavin"}; // OK: v1 有1个元素 (其值为 "Gavin")
f({"Norah"}); // OK:用列表 {"Norah"}调f
}
17.4 类成员和基类成员的初始化(Member and Base Initialization)
构造函数可以建立不变量并获取资源。通常,它们通过初始化类成员和基类成员来实现这一点。
17.4.1 类成员初始化(Member Initialization)
考虑一个可能用于保存小型组织信息的类:
class Club {
string name;
vector<string> members;
vector<string> officers;
Date founded;
// ...
Club(const string& n, Date fd);
};
Club的构造函数以俱乐部(club)的名称和成立日期作为参数。成员构造函数的参数在包含类的构造函数定义中的成员初始化列表中指定。例如:
Club::Club(const string& n, Date fd)
: name{n}, members{}, officers{}, founded{fd}
{
// ...
}
成员初始化列表以冒号开头,各个成员初始化列表之间用逗号分隔。
在执行包含类自己的构造函数主体之前,会调用成员的构造函数(§17.2.3)。构造函数的调用顺序是成员在类中声明的顺序,而不是成员在初始化列表中出现的顺序。为避免混淆,最好按照成员声明顺序指定初始化函数。如果顺序不正确,希望编译器会发出警告。在执行类自己的析构函数主体之后,会按照与构造相反的顺序调用成员析构函数。
如果成员构造函数不需要参数,则成员不需要在成员初始化列表中提及。例如:
Club::Club(const string& n, Date fd)
: name{n}, founded{fd}
{
// ...
}
此构造函数与前一个版本等效。在每种情况下,Club::officers 和 Club::members 都初始化为没有元素的向量。
明确初始化成员通常是一个好主意。请注意,内置类型的“隐式初始化”成员未初始化(§17.3.1)。
构造函数可以初始化其类的成员和其类的基,但不能初始化其成员或基的成员或基(译注:即不能初始化其成员或基的成员,或其成员或基的基)。例如:
struct B { B(int); /* ... */};
struct BB : B { /* ... */ };
struct BBB : BB {
BBB(int i) : B(i) { }; // 错: 试图初始化基类的基类
// ...
};
17.4.1.1 类成员的初始化和赋值(Member Initialization and Assignment)
对于初始化含义与赋值含义不同的类型,成员初始化器是必不可少的。例如:
class X {
const int i;
Club cl;
Club& rc;
// ...
X(int ii, const string& n, Date d, Club& c) : i{ii}, cl{n,d}, rc{c} { }
};
引用成员或 const 成员必须初始化(§7.5,§7.7,§17.3.3)。但是,对于大多数类型,程序员可以选择使用初始化程序或使用赋值。在这种情况下,我通常更喜欢使用成员初始化程序语法来明确表示正在执行初始化。通常,使用初始化程序语法也具有效率优势(与使用赋值相比)。例如:
class Person {
string name;
string address;
// ...
Person(const Person&);
Person(const string& n, const string& a);
};
Person::Person(const string& n, const string& a)
: name{n}
{
address = a;
}
这里 name 用 n 的副本初始化。在另一方面,address 首先初始化为空字符串,然后分配 a 的副本(即赋值)。
17.4.2 基类初始化器(Base Initializers)
派生类的基类的初始化方式与非数据成员的初始化方式相同。也就是说,如果基类需要初始化器,则必须在构造函数中将其作为基类初始化器提供。如果我们愿意,我们可以明确指定默认构造。例如:
class B1 { B1(); }; // 有默认构造函数
class B2 { B2(int); } // 无默认构造函数
struct D1 : B1, B2 {
D1(int i) :B1{}, B2{i} {}
};
struct D2 : B1, B2 {
D2(int i) :B2{i} {} // 隐式使用B1{}
};
struct D1 : B1, B2 {
D1(int i) { } // 错: B2 要求使用一个 int 型初始化器
};
与成员一样,初始化的顺序是声明顺序,建议按该顺序指定基类初始化器。基类在成员之前初始化,在成员之后销毁(§17.2.3)。
17.4.3 委托构造函数(Delegating Constructors)
如果您希望两个构造函数执行相同的操作,您可以重复定义多个构造函数(译注:即重载)或定义“init() 函数”来执行常见操作。这两种“解决方案”都很常见(因为旧版本的 C++ 没有提供更好的解决方案)。例如:
class X {
int a;
validate(int x) { if (0<x && x<=max) a=x; else throw Bad_X(x); }
public:
X(int x) { validate(x); }
X() { validate(42); }
X(string s) { int x = to<int>(s); validate(x); } // §25.2.5.1
// ...
};
冗长会影响可读性,重复容易出错。两者都会影响可维护性。另一种方法是根据一个构造函数来定义另一个构造函数(译注:事实上也有重载的思想):
class X {
int a;
public:
X(int x) { if (0<x && x<=max) a=x; else throw Bad_X(x); }
X() :X{42} { }
X(string s) :X{to<int>(s)} { } // §25.2.5.1
// ...
};
也就是说,使用类自身名称(其构造函数名称)的一个成员风格初始化器作为构造的一部分,去调用类的另一个构造函数。这样的构造函数称为委托构造函数(有时也称为转发构造函数(forwarding constructor))。
您不能同时既委托又显式初始化成员。例如:
class X {
int a;
public:
X(int x) { if (0<x && x<=max) a=x; else throw Bad_X(x); }
X() :X{42}, a{56} { } // 错,不能同时初始化a
// ...
};
通过在构造函数的成员(译注:指作为构造函数的一部分)和基类初始化列表中调用另一个构造函数来进行委托,这与在构造函数主体中显式调用另一个构造函数有很大不同。考虑:
class X {
int a;
public:
X(int x) { if (0<x && x<=max) a=x; else throw Bad_X(x); }
X() { X{42}; } // 可能报错
// ...
};
X{42} 只是创建一个新的未命名对象(临时对象),并且不对其执行任何操作。这种使用往往是一个错误。希望编译器发出警告。
对象在其构造函数完成之前不被视为已构造(§6.4.2)。使用委托构造函数时,对象在其委托构造函数完成之前不被视为已构造——仅完成委托的构造函数是不够的。除非对象的原始构造函数完成,否则不会调用对象的析构函数。
如果您所需要的只是将成员设置为默认值(不依赖于构造函数参数),则成员初始化器(§17.4.4)可能会更简单。
17.4.4 类中初始化器(In-Class Initializers)
我们可以在类声明中为非静态数据成员指定初始化器。例如:
class A {
public:
int a {7};
int b = 77;
};
由于与解析和名称查找相关的相当晦涩的技术原因,{} 和 = 初始化器符号可用于类内成员初始化器,但 () 符号不能(译注:但可用于非类内变量的初始化)。默认情况下,构造函数将使用这样的类内初始化器,因此该示例是
class A {
public:
int a;
int b;
A() : a{7}, b{77} {}
};
这种使用类内初始化器的方式可以节省一些输入,但真正的好处在于更复杂的类,它们有多个构造函数。通常,多个构造函数对一个成员使用相同的初始化器。例如:
class A {
public:
A() :a{7}, b{5}, algorithm{"MD5"}, state{"Constructor run"} {}
A(int a_val) :a{a_val}, b{5}, algorithm{"MD5"}, state{"Constructor run"} {}
A(D d) :a{7}, b{g(d)}, algorithm{"MD5"}, state{"Constructor run"} {}
// ...
private:
int a, b;
HashFunction algorithm; // 加密哈希应用于所有A
string state; // 表示对象生命周期状态的字符串
};
algorithm和state在所有构造函数中具有相同值这一事实在混乱的代码中被忽略,并且很容易成为维护问题。为了使公共值明确,我们可以提取数据成员的唯一初始化程序:
class A {
public:
A() :a{7}, b{5} {}
A(int a_val) :a{a_val}, b{5} {}
A(D d) :a{7}, b{g(d)} {}
// ...
private:
int a, b;
HashFunction algorithm {"MD5"}; // 加密哈希应用于所有A
string state {"Constructor run"}; //表示对象生命周期状态的字符串
};
如果成员由类内初始化器和构造函数初始化,则仅完成构造函数的初始化(它“覆盖”了默认值)。因此我们可以进一步简化:
class A {
public:
A() {}
A(int a_val) :a{a_val} {}
A(D d) :b{g(d)} {}
// ...
private:
int a {7}; // a初始化为7的意义是...
int b {5}; // b初始化为5的意义是...
HashFunction algorithm {"MD5"}; // 加密哈希应用于所有A
string state {"Constructor run"}; //表示对象生命周期状态的字符串
};
正如所示,默认的类内初始化器为记录常见情况提供了机会。
类内成员初始化器可以使用在成员声明中使用时处于作用域内的名称。考虑以下令人头痛的技术示例:
int count = 0;
int count2 = 0;
int f(int i) { return i+count; }
struct S {
int m1 {count2}; // 即, ::count2
int m2 {f(m1)}; // 即, this->m1+::count; that is, ::count2+::count
S() { ++count2; } // 非常奇怪的构造函数
};
int main()
{
S s1; // {0,0}
++count;
S s2; // {1,2}
}
成员初始化按声明顺序进行(§17.2.3),因此首先将 m1 初始化为全局变量 count2 的值。全局变量的值是在运行新 S 对象的构造函数时获得的,因此它可以(并且在此示例中确实)更改。接下来,通过调用全局 f() 初始化 m2。
在成员初始化器中隐藏对全局数据的细微依赖并不是一个好主意。
17.4.5 static成员初始化(static Member Initialization)
static类成员是静态分配的,而不是类的每个对象的一部分。通常,static成员声明充当类外部定义的声明。例如:
class Node {
// ...
static int node_count; // 声明
};
int Node::node_count = 0; // 定义
但是,对于一些简单的特殊情况,可以在类声明中初始化static成员。static成员必须是整数或枚举类型的 const,或文字类类型的 constexpr(§10.4.3),并且初始化器必须是常量表达式。例如:
class Curious {
public:
static const int c1 = 7; // OK
static int c2 = 11; // error : not const
const int c3 = 13; // OK, but not static (§17.4.4)
static const int c4 = sqrt(9); // 错: 类内初始化器不是常量
static const float c5 = 7.0; // 错: 类内不是整数 (使用constexpr 而不是const)
// ...
};
如果(且仅当)您使用已初始化成员的方式需要将其作为对象存储在内存中,则该成员必须在某处(唯一地)定义。初始化器不得重复:
const int Curious::c1; // 不能在此重复初始化器
const int∗ p = &Curious::c1; // OK: Curious::c1 已定义
成员常量的主要用途是为类声明中其他地方需要的常量提供符号名称。例如:
template<class T, int N>
class Fixed { // 固定大小数组
public:
static constexpr int max = N;
// ...
private:
T a[max];
};
对于整数,枚举器(§8.4)提供了一种在类声明中定义符号常量的替代方法。例如:
class X {
enum { c1 = 7, c2 = 11, c3 = 13, c4 = 17 };
// ...
};
17.5 复制和移动(Copy and Move)
当我们需要将一个值从 a 传输到 b 时,我们通常有两个逻辑上不同的选择:
• 复制是 x=y 的常规含义;也就是说,效果是 x 和 y 的值都等于赋值前 y 的值。
• 移动使 x 保留 y 以前的值,使 y 保留一些移出状态。对于最有趣的情况,即容器,移出状态为“空”。
这种简单的逻辑区别因传统以及我们对移动和复制使用相同符号的事实而变得复杂。
通常,移动不会抛出异常,而复制可能会(因为它可能需要获取资源),并且移动通常比复制更高效。编写移动操作时,应将源对象保留在有效但未指定的状态,因为它最终将被销毁,而析构函数无法销毁处于无效状态的对象。此外,标准库算法依赖于能够(使用移动或复制)分配给移动的对象。因此,设计移动时应避免抛出异常,并将其源对象保留在允许销毁和赋值的状态。
为了使我们免于繁琐的重复工作,复制和移动有默认定义(§17.6.2)。
17.5.1 复制(Copy)
类X的复制由两个操作定义:
• 复制构造函数:X(const X&)
• 复制赋值:X& operator=(const X&)
您可以使用更具冒险精神的参数类型(例如 volatile X&)来定义这两个操作,但不要这样做;您只会让自己和他人感到困惑。复制构造函数应该复制一个对象而不对其进行修改。同样,您可以使用 const X& 作为复制赋值的返回类型。我的观点是,这样做会造成比它本身更大的混乱,因此我对复制的讨论假设这两个操作具有常规类型。
考虑一个简单的二维矩阵(Matrix):
template<class T>
class Matrix {
array<int,2> dim; // 2维
T∗ elem; //指向T类型dim[0]*dim[1] 元素的指针
public:
Matrix(int d1, int d2) :dim{d1,d2}, elem{new T[d1∗d2]} {}// 简化(无错误处理)
int size() const { return dim[0]∗dim[1]; }
Matrix(const Matrix&); // 复制构造函数
Matrix& operator=(const Matrix&); // 复制赋值
Matrix(Matrix&&); // 移动构造函数
Matrix& operator=(Matrix&&); // 移动赋值
˜Matrix() { delete[] elem; }
// ...
};
首先,我们注意到默认复制(复制成员)会产生灾难性的错误:Matrix 元素不会被复制,Matrix 副本会有一个指向与源相同元素的指针,Matrix 析构函数会删除(共享)元素两次(§3.3.1)。
然而,程序员可以为这些复制操作定义任何合适的含义,而容器的常规含义是复制所包含的元素:
template<class T>
Matrix:: Matrix(const Matrix& m) // 复制构造函数
: dim{m.dim},
elem{new T[m.siz e()]}
{
uninitialized_copy(m.elem,m.elem+m.siz e(),elem); // 复制元素
}
template<class T>
Matrix& Matrix::operator=(const Matrix& m) // 复制赋值(译注:赋值运算符函数)
{
if (dim[0]!=m.dim[0] || dim[1]!=m.dim[1])
throw runtime_error("bad size in Matrix =");
copy(m.elem,m.elem+m.siz e(),elem); //复制元素
}
复制构造函数和复制赋值的不同之处在于,复制构造函数初始化未初始化的内存,而复制赋值运算符必须正确处理已经构造并可能拥有资源的对象。
Matrix复制赋值运算符具有以下属性:如果元素的副本引发异常,则赋值的目标可能会保留其旧值和新值的混合。也就是说,Matrix赋值提供了基本保证,但没有提供强保证(§13.2)。如果这不可接受,我们可以通过首先进行复制然后交换表示的基本技术来避免这种情况:
Matrix& Matrix::operator=(const Matrix& m) // 复制赋值
{
Matrix tmp {m}; // 复制
swap(tmp,∗this); // 用 *this交换tmp的表示
return ∗this;
}
仅当复制成功时,才会执行 swap()。显然,仅当实现 swap() 不使用赋值(std::swap() 不使用赋值)时,此 operator=() 才有效;参见 §17.5.2。
通常,复制构造函数必须复制每个非static成员(§17.4.1)。如果复制构造函数无法复制元素(例如,因为它需要获取不可用的资源才能执行此操作),则它会抛出异常。
请注意,我没有保护 Matrix 的复制赋值免受自我赋值的影响,m=m。我没有测试的原因是成员的自我赋值已经很安全了:我对 Matrix 复制赋值的实现对于 m=m 都可以正确且合理高效地工作。此外,自我赋值很少见,因此只有当您确定需要时才在复制赋值中测试自我赋值。
17.5.1.1 警惕默认构造函数(Beware of Default Constructors)
编写复制操作时,请确保复制每个基数和成员。考虑:
class X {
string s;
string s2;
vector<string> v;
X(const X&) // 复制构造函数
:s{a.s}, v{a.v} // 可能马虎,也可能错误
{
}
// ...
};
这里,我“忘记”复制 s2,因此它被默认初始化(为“”)。这不太可能是正确的。对于一个简单的类,我也不太可能犯这个错误。但是,对于较大的类,忘记的几率会增加。更糟糕的是,当有人在初始设计之后很久才向类中添加成员时,很容易忘记将其添加到要复制的成员列表中。这是首选默认(编译器生成的)复制操作(§17.6)的一个原因。
17.5.1.2 基类(成员)的复制(Copy of Bases)
对于复制而言,基类只是派生类的一个成员:要复制派生类的对象,您必须复制其基类。例如:
struct B1 {
B1();
B1(const B1&);
// ...
};
struct B2 {
B2(int);
B2(const B2&);
// ...
};
struct D : B1, B2 {
D(int i) :B1{}, B2{i}, m1{}, m2{2∗i} {}
D(const D& a) :B1{a}, B2{a}, m1{a.m1}, m2{a.m2} {}
B1 m1;
B2 m2;
};
D d {1}; // 用int参数进行构造
D dd {d}; // 复制构造
初始化的顺序是常规的(基类在成员之前),但对于复制来说,顺序最好无所谓。
virtual基类 (§21.3.5) 可能作为层次结构中多个类的基类出现。默认复制构造函数 (§17.6) 将正确复制它。如果您定义自己的复制构造函数,最简单的技术是重复复制virtual基类。如果基类对象较小且virtual基类在层次结构中仅出现几次,那么这种方法比避免复制副本的技术更有效。
17.5.1.3 复制的含义(The Meaning of Copy)
复制构造函数或复制赋值必须做什么才能被视为“正确的复制操作”?除了使用正确的类型声明之外,复制操作还必须具有正确的复制语义。考虑两个相同类型的对象的复制操作 x=y。为了适合一般面向值编程(§16.3.4),特别是用于标准库(§31.2.2),该操作必须满足两个标准:
• 等价性:在 x=y 之后,对 x 和 y 的操作应产生相同的结果。具体而言,如果为它们的类型定义了 ==,则对于任何仅依赖于 x 和 y 的值的函数 f(),我们应该有 x==y 和 f(x)==f(y)(而不是使其行为依赖于 x 和 y 的地址)。
• 独立性:在 x=y 之后,对 x 的操作不应隐式更改 y 的状态,即只要 f(x) 不引用 y,f(x) 就不会更改 y 的值。
这是 int 和 vector 提供的行为。提供等价性和独立性的复制操作可使代码更简单、更易于维护。这一点值得一提,因为违反这些简单规则的代码并不罕见,程序员并不总是意识到这种违反是他们一些更严重问题的根本原因。提供等价性和独立性的复制是常规类型概念的一部分(§24.3.1)。
首先考虑等价性要求。人们很少故意违反此要求,默认复制操作不会违反此要求;它们会逐个成员地复制(§17.3.1,§17.6.2)。但是,偶尔会出现一些技巧,例如让复制的含义取决于“选项”,这通常会引起混淆。此外,对象包含不被视为其值一部分的成员的情况并不少见。例如,标准容器的副本不会复制其分配器,因为分配器被视为容器的一部分,而不是其值的一部分。同样,用于统计信息收集的计数器和缓存值有时不会简单地被复制。对象状态的此类“非值”部分不应影响比较运算符的结果。特别是,x=y 应该意味着 x==y。此外,切片(§17.5.1.4)可能导致行为不同的“副本”,这通常是一个严重的错误。
现在考虑独立性的要求。与(缺乏)独立性相关的大多数问题都与包含指针的对象有关。复制的默认含义是成员复制。默认复制操作复制指针成员,但不复制它指向的对象(如果有)。例如:
struct S {
int∗ p; // a pointer
};
S x {new int{0}};
void f()
{
S y{x}; //“复制” x
∗y.p = 1; // 改变 y; 影响x
∗x.p = 2; // 改变x; 影响y
delete y.p; // 影响x 和 y
y.p = new int{3}; // OK: 改变y; 不会影响 x
∗x.p = 4; // oops: 写入已释放的内存
}
这里我违反了独立性原则。将 x “复制” 到 y 之后,我们可以通过 y 操纵 x 的部分状态。这有时被称为浅复制(shallow copy)(译注:仅复制值),并且(也)经常因其“效率”而受到称赞。复制对象完整状态的明显替代方法称为深复制(deep copy)。通常,深复制的更好替代方案不是浅复制,而是移动操作,它可以最大限度地减少复制而不会增加复杂性(§3.3.2,§17.5.2)。
浅复制使两个对象(这里是 x 和 y)具有共享状态,并且极有可能造成混淆和错误。当独立性要求被违反时,我们说对象 x 和 y 已纠缠在一起。不可能单独推理交织对象。例如,从源代码中看不出对 ∗x.p 的两个赋值会产生截然不同的效果。
我们可以用图形来表示两个交织的对象:
请注意,交织可能以各种方式出现。通常,直到出现问题时,交织才会明显出现。例如,像 S 这样的类型可能会被不小心用作其他行为良好的类的成员。S 的原作者可能意识到了交织并准备好应对它,但有人天真地认为复制 S 意味着复制其完整值可能会感到惊讶,而发现 S 深深嵌套在其他类中的人可能会非常惊讶。
我们可以通过引入一种垃圾收集形式来解决与共享子对象的生命周期相关的问题。例如:
struct S2 {
shared_ptr<int> p;
};
S2 x {new int{0}}; //译注:有的编译器不一定能通过
void f()
{
S2 y {x}; // “复制” x
∗y.p = 1; // 改变 y, 影响x
∗x.p = 2; // 改变x; 影响 y
y.p.reset(new int{3}); // 改变y; 影响x (译注:应该是不影响x)
∗x.p = 4; // 改变x; 影响y (译注:应该是不影响y)
}
事实上,浅复制和此类交织对象是垃圾收集需求的来源之一。交织对象会导致代码在没有某种形式的垃圾收集(例如 shared_ptr)的情况下很难管理。
但是,shared_ptr 仍然是一个指针,因此我们不能孤立地考虑包含 shared_ptr 的对象。谁可以更新指向的对象?如何更新?什么时候更新?如果我们在多线程系统中运行,访问共享数据是否需要同步?我们如何确定?交织对象(此处由浅复制产生)是复杂性和错误的来源,最多只能通过垃圾收集(任何形式)部分解决(译注:浅复制在复制指针成员时容易引发问题,浅复制是两个对象成员之间的复制,而复制指针而不是创建一个新的指针,在调用析构函数时释放两次指针必然引发问题)。
请注意,不可变的共享状态不是问题。除非我们比较地址,否则我们无法判断两个相等的值是否恰好表示为一个或两个副本。这是一个有用的观察,因为许多副本从未被修改过。例如,按值传递的对象很少被写入。这一观察导致了写时复制(copy-on-write)的概念。这个思想是,副本实际上并不需要独立,直到共享状态被写入,因此我们可以将共享状态的复制延迟到第一次写入之前。考虑:
class Image {
public:
// ...
Image(const Image& a); // 复制构造函数
// ...
void write_block(Descriptor);
// ...
private:
Representation∗ clone(); // 复制 *rep
Representation∗ rep;
bool shared;
};
假设 Representation 可能非常大,并且 write_block() 与测试 bool 相比成本较高。然后,根据 Image 的使用情况,将复制构造函数实现为浅复制是有意义的:
Image::Image(const Image& a) // 执行浅复制并准备写时复制
:rep{a.rep},
shared{true}
{
}
我们通过在写入之前复制Representation来保护该复制构造函数的参数:
void write_block(Descriptor d)
{
if (shared) {
rep =clone(); // *rep 的复制
shared = false; // 没有更多分享
// ... 现在我们可以安全地写我们自己的rep副本...
}
与任何其他技术一样,写时复制不是万能的,但它可以有效结合真实复制的简单性和浅复制的效率。
17.5.1.4 复制的分片问题(Slicing)
指向派生类的指针会隐式转换为指向其公共基类的指针。当应用于复制操作时,这条简单而必要的规则(§3.2.4、§20.2)会让粗心大意的人陷入陷阱。请考虑:
struct Base {
int b;
Base(const Base&);
// ...
};
struct Derived : Base {
int d;
Derived(const Derived&);
// ...
};
void naive(Base∗ p)
{
B b2 = ∗p; // 可以分片: 调用Base::Base(const Base&)
// ...
}
void user()
{
Derived d;
naive(&d);
Base bb = d; // 分片: 调用Base::Base(const Base&), 非调用Derived::Der ived(const Derived&)
// ...
}
变量 b2 和 bb 包含 d 的 Base 部分的副本,即 d.b 的副本。成员 d.d 未被复制。这种现象称为分片(slicing)。它可能正是您想要的(例如,参见 §17.5.1.2 中 D 的复制构造函数,其中我们将选定的信息传递给基类),但通常这是一个微妙的错误。如果您不想要分片,则有两个主要工具可以防止它:
[1] 禁止复制基类:删除复制操作(§17.6.4)。
[2] 防止将派生类的指针转换为基类的指针:将基类设为私有或受保护的基类(§20.5)。
前者会使 b2 和 bb 的初始化出错;后者会使 naive() 的调用和 bb 的初始化出错。
17.5.2 移动(Move)
将值从 a 复制到 b 的传统方法是复制它。对于计算机内存中的整数,这几乎是唯一有意义的事情:这就是硬件可以用一条指令完成的事情。然而,从一般和逻辑的角度来看,情况并非如此。考虑 swap() 交换两个对象的值的典型实现:
template<class T>
void swap(T& a, T& b)
{
const T tmp = a; // 将a的副本放入 tmp
a = b; // 将b的副本放入 a
b = tmp; //将tmp的副本放入 b
};
初始化 tmp 后,我们有两个 a 值的副本。赋值给 tmp 后,我们有两个 b 值的副本。赋值给 b 后,我们有两个 tmp 值的副本(即 a 的原始值)。然后我们销毁 tmp。这听起来工作量很大,但确实如此。例如:
void f(string& s1, string& s2,
vector<string>& vs1, vector<string>& vs2,
Matrix& m1, Matrix& m2)
{
swap(s1,s2);
swap(vs1.vs2);
swap(m1,m2);
}
如果 s1 有 1000 个字符怎么办?如果 vs2 有 1000 个元素,每个元素都有 1000 个字符怎么办?如果 m1 是一个 1000∗1000 的双精度矩阵怎么办?复制这些数据结构的成本可能非常高。事实上,标准库 swap() 一直经过精心设计,以避免字符串和向量的此类开销。也就是说,已经努力避免复制(利用字符串和向量对象实际上只是其元素的句柄这一事实)。必须进行类似的工作以避免矩阵 swap() 出现严重的性能问题。如果我们唯一的操作是复制,那么必须对不属于标准的大量函数和数据结构进行类似的工作。
根本的问题是,我们实际上根本不想进行任何复制:我们只是想交换值对。
我们也可以从一个完全不同的角度来看待复制问题:除非绝对必要,否则我们通常不会复制物理对象。如果你想借我的手机,我会把手机给你,而不是为你制作自己的副本。如果我借给你我的车,我会给你一把钥匙,然后你开着我的车离开,而不是开着你刚刚复制的我的车。一旦我给了你一个对象,你就拥有了它,而我不再拥有它。因此,我们谈论“赠送”、“移交”、“转移所有权”和“移动”物理对象。计算机中的许多对象与物理对象(我们不会无缘无故地复制它们,而且成本相当高)的相似性要高于整数值(我们通常复制它们,因为这比其他选择更容易、更便宜)。例如锁、套接字、文件句柄、线程、长字符串和大型向量。
为了使用户避免复制的逻辑和性能问题,C++ 直接支持移动和复制的概念。具体来说,我们可以定义移动构造函数和移动赋值来移动而不是复制它们的参数。再次考虑 §17.5.1 中的简单二维矩阵:
template<class T>
class Matrix {
std::array<int,2> dim;
T∗ elem; // 指向类型T的sz 个元素
Matrix(int d1, int d2) :dim{d1,d2}, elem{new T[d1∗d2]} {}
int size() const { return dim[0]∗dim[1]; }
Matrix(const Matrix&); // 复制构造函数
Matrix(Matrix&&); // 移动构造函数
Matrix& operator=(const Matrix&); // 复制赋值
Matrix& operator=(Matrix&&); // 移动赋值
˜Matrix(); // 析构函数
// ...
};
符号 && 表示右值引用(§7.7.2)(译注:临时对象或内置类型、等等非左值的东西)。
移动赋值背后的理念是将左值与右值分开处理:复制赋值和复制构造函数采用左值,而移动赋值和移动构造函数采用右值。对于return值,选择移动构造函数。
我们可以定义 Matrix 的移动构造函数,简单地从其对象源中获得表示并将其替换为空 Matrix(这样的空对象销毁起来很便宜)。例如:
template<class T>
Matrix<T>::Matrix(Matrix&& a) // 移动构造函数
:dim{a.dim}, elem{a.elem} // 占用 a的表示
{
a.dim = {0,0}; // 清掉了a的表示
a.elem = nullptr;
}
对于移动赋值,我们可以简单地进行交换。使用交换来实现移动赋值的思想是,源即将被销毁,因此我们可以让源的析构函数为我们完成必要的清理工作:
template<class T>
Matrix<T>& Matrix<T>::operator=(Matrix&& a) //移动赋值
{
swap(dim,a.dim); // swap 表示
swap(elem,a.elem);
return ∗this;
}
移动构造函数和移动赋值采用非常量(右值)引用参数:它们可以(并且通常会)写入其参数。但是,移动操作的参数必须始终处于析构函数可以处理的状态(最好是能够非常便宜且轻松地处理)。
对于资源句柄,移动操作往往比复制操作更简单、更高效。特别是,移动操作通常不会引发异常;它们不会获取资源或执行复杂的操作,因此不需要异常。在这方面,它们与许多复制操作不同(§17.5)。
编译器如何知道何时可以使用移动操作而不是复制操作?在少数情况下,例如对于返回值,语言规则规定可以(因为下一个操作定义为销毁元素)。但是,一般来说,我们必须通过提供右值引用参数来告诉它。例如:
template<class T>
void swap(T& a, T& b) // "完美 swap" (几乎)
{
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
move() 是一个标准库函数,它返回对其参数的右值引用(§35.5.1):move(x) 表示“给我一个对 x 的右值引用”。也就是说,std::move(x) 不会移动任何东西;相反,它允许用户移动 x。如果 move() 被称为 rval() 会更好,但 move() 这个名字已经用于此操作多年了。
标准库容器具有移动操作(§3.3.2,§35.5.1),其他标准库类型也是如此,例如 pair(§5.4.3,§34.2.4.1)和 unique_ptr(§5.2.1,§34.3.1)。此外,将新元素插入标准库容器的操作(例如 insert() 和 push_back())具有采用右值引用的版本(§7.7.2)。最终结果是,标准容器和算法提供的性能比它们必须复制时的性能更好。
如果我们尝试交换没有移动构造函数的类型的对象会怎么样?我们会复制并付出代价。一般来说,程序员有责任避免过度复制。决定什么是过度的、什么是必要的不是编译器的工作。要为自己的数据结构获得复制到移动的优化,您必须提供移动操作(显式或隐式;参见§17.6)。
内置类型(例如 int 和 double∗)被认为具有简单的复制移动操作。像往常一样,您必须小心包含指针的数据结构(§3.3.1)。特别是,不要假设移动的指针设置为 nullptr。
移动操作会影响从函数返回大对象的习惯用法。考虑一下:
Matrix operator+(const Matrix& a, const Matrix& b)
// res[i][j] = a[i][j]+b[i][j] for each i and j
{
if (a.dim[0]!=b.dim[0] || a.dim[1]!=b.dim[1])
throw std::runtime_error("unequal Matrix sizes in +");
Matrix res{a.dim[0],a.dim[1]};
constexpr auto n = a.size();
for (int i = 0; i!=n; ++i)
res.elem[i] = a.elem[i]+b.elem[i];
return res;
}
Matrix 有一个移动构造函数,因此“按值返回”既简单又高效,而且“自然”。如果没有移动操作,我们会遇到性能问题,必须采取变通方法。我们可能考虑过:
Matrix& operator+(const Matrix& a, const Matrix& b) // beware!
{
Matrix& res = ∗new Matrix; // allocate on free store
// res[i][j] = a[i][j]+b[i][j] for each i and j
return res;
}
在 operator+() 中使用 new 并不常见,并迫使 + 的用户处理棘手的内存管理问题:
• 如何delete掉 new 创建的对象?
• 我们需要垃圾收集器吗?
• 我们应该使用Matrix池而不是一般的 new 吗?
• 我们需要使用计数Matrix表示吗?
• 我们应该重新设计Matrix加法的接口吗?
• operator+() 的调用者必须记得delete结果吗?
• 如果计算引发异常,新分配的内存会发生什么?
没有一种替代方案是优雅或通用的。
17.6 生成默认操作(Generating Default Operations)
编写常规操作(例如复制和析构函数)可能很繁琐且容易出错,因此编译器可以根据需要为我们生成它们。默认情况下,类提供:
• 默认构造函数:X()
• 复制构造函数:X(const X&)
• 复制赋值:X& operator=(const X&)
• 移动构造函数:X(X&&)
• 移动赋值:X& operator=(X&&)
• 析构函数:˜X()
默认情况下,如果程序使用这些操作,编译器会生成这些操作中的每一个。但是,如果程序员通过定义一个或多个这些操作来控制,则会抑制相关操作的默认生成:
• 如果程序员为某个类声明了任何构造函数,则编译器不会为该类生成默认构造函数。
• 如果程序员为某个类声明了复制操作、移动操作或析构函数,则编译器不会为该类生成任何复制操作、移动操作或析构函数。
遗憾的是,第二条规则并未完全执行:为了向后兼容,即使定义了析构函数,也会生成复制构造函数和复制赋值。但是,ISO标准(§iso.D)已弃用该生成,您应该可以预料到现代编译器会对此发出警告。
如果有必要,我们可以明确说明哪些函数是生成的(§17.6.1)以及哪些不是生成的(§17.6.4)。
17.6.1 显式默认(Explicit Default)
由于可以抑制默认操作的生成,因此一定有一种方法来恢复默认操作。此外,有些人更喜欢在程序文本中看到完整的操作列表(即使不需要该完整列表)。例如,我们可以写成:
class gslice {
valarray<siz e_t> siz e;
valarray<siz e_t> stride;
valarray<siz e_t> d1;
public:
gslice() = default;
˜gslice() = default;
gslice(const gslice&) = default;
gslice(gslice&&) = default;
gslice& operator=(const gslice&) = default;
gslice& operator=(gslice&&) = default;
// ...
};
std::gslice 这个实现片段(§40.5.6)等价于:
class gslice {
valarray<siz e_t> siz e;
valarray<siz e_t> stride;
valarray<siz e_t> d1;
public:
// ...
};
我更喜欢后者,但我也明白在经验较少的 C++ 程序员维护的代码库中使用前者的意义:你看不到的东西,你可能会忘记。
使用 =default 总是比自己编写默认语义的实现要好。有人认为写点东西总比什么都不写要好,他们可能会这样写:
class gslice {
valarray<siz e_t> siz e;
valarray<siz e_t> stride;
valarray<siz e_t> d1;
public:
// ...
gslice(const gslice& a);
};
gslice::gslice(const gslice& a)
: siz e{a.size },
stride{a.stride},
d1{a.d1}
{
}
这不仅冗余,使阅读 gslice 的定义变得更加困难,而且还为犯错误提供了机会。例如,我可能会忘记复制其中一个成员并使其默认初始化(而不是复制)。此外,当用户提供一个函数时,编译器不再知道该函数的语义,并且一些优化会受到抑制。对于默认操作,这些优化可能很重要。
17.6.2 默认操作(Default Operations)
每个生成的操作的默认含义(在编译器生成时实现)是将操作应用于类的每个基类和非static数据成员。也就是说,我们得到成员复制、成员默认构造等。例如:
struct S {
string a;
int b;
};
S f(S arg)
{
S s0 {}; // default 构造: {"",0}
S s1 {s0}; // copy 构造
s1 = arg; // copy 赋值
return s1; // move 构造
}
s1 的复制构造复制了 s0.a 和 s0.b。s1 的返回会移动 s1.a 和 s1.b,使 s1.a 保持为空字符串,而 s1.b 保持不变。
请注意,内置类型的被移出对象的值保持不变。这是编译器最简单、最快捷的操作。如果我们想为某个类的成员执行其他操作,我们必须为该类编写移动操作。
默认的移出状态是默认析构函数和默认复制赋值正常工作的状态。不保证(或要求)对移出对象执行的任意操作都能正常工作。如果您需要更强的保证,请编写自己的操作。
17.6.3 使用默认操作(Using Default Operations)
本节将介绍几个示例,说明复制、移动和析构函数是如何在逻辑上相互关联的。如果它们之间没有关联,那么那些显而易见的错误将不会被编译器捕获。
17.6.3.1 使用默认构造函数(Default Constructors)
考虑:
struct X {
X(int); // 初始化X对象时要求一个int参数
};
通过声明一个需要整数参数的构造函数,程序员清楚地表明用户需要提供一个 int 参数来初始化 X的对象。如果我们允许生成默认构造函数,那么就会违反这条简单的规则。我们有:
X a {1}; // OK
X b {}; // 错: 没有默认构造函数
如果我们还想要默认构造函数,我们可以定义一个或显式声明我们想要编译器生成的默认构造函数。例如:
struct Y {
string s;
int n;
Y(const string& s); // 用一个string初始化Y
Y() = default; // 允许具有默认意义的初始化
};
默认(即编译器生成的)默认构造函数默认构造每个成员。这里,Y() 将 s 设置为空字符串。内置成员的“默认初始化”使该成员未初始化。唉!希望编译器发出警告。
17.6.3.2 维持不变量(Maintaining Invariants)
通常,一个类有一个不变量。如果是这样,我们希望复制和移动操作能够维护它,而析构函数能够释放所涉及的任何资源。遗憾的是,编译器无法在每种情况下都知道程序员认为什么是不变量。考虑一个有点牵强的例子:
struct Z { // invariant:
// my_favorite 是我的elem 的最爱元素的索引
// largest是指向elem中具有最大值的元素的指针
vector<int> elem;
int my_favorite;
int∗ largest;
};
程序员在注释中声明了一个不变量,但编译器不会解析注释。此外,程序员没有留下关于如何建立和维护该不变量的提示。特别是,没有声明构造函数或赋值。该不变量是隐式的。结果是可以使用默认操作复制和移动 Z:
Zv0; //没有初始化(oops! 可能未定义值)
Z val {{1,2,3},1,&val[2]}; // OK, 但丑陋且易错
Z v2 = val; //复制: v2.largest 指向val
Z v3 = move(val); //移动: val.elem 成为空; v3.my_favorite 越界
这真是一团糟。根本问题是 Z 的设计很糟糕,因为关键信息被“隐藏”在注释中或完全缺失。默认操作的生成规则是启发式的,旨在捕捉常见错误并鼓励系统地进行构造、复制、移动和销毁。在任何情况下,尽可能:
[1] 在构造函数中建立一个不变量(包括可能的资源获取)。
[2] 使用复制和移动操作(使用通常的名称和类型)维护不变量。
[3] 在析构函数中执行任何必要的清理(包括可能的资源释放)。
17.6.3.3 资源不变量(Resource Invariants)
不变量的许多最关键和最明显的用途都与资源管理有关。考虑一个简单的 Handle:
template<class T> class Handle {
T∗ p;
public:
Handle(T∗ pp) :p{pp} { }
T& operator∗() { return ∗p; }
˜Handle() { delete p; }
};
这个思想是,你构造一个 Handle,给定一个指向使用 new 分配的对象的指针。Handle 提供对指向的对象的访问,并最终删除该对象。例如:
void f1()
{
Handle<int> h {new int{99}};
// ...
}
Handle声明了一个接受参数的构造函数:这会抑制默认构造函数的生成。这很好,因为默认构造函数可能会导致 Handle<T>::p 未初始化:
void f2()
{
Handle<int> h; // 错 : 无默认构造函数
// ...
}
这样就不存在默认构造函数使我们避免使用随机内存地址进行删除的可能性。
此外,Handle 声明了一个析构函数:这抑制了复制和移动操作的生成。同样,这让我们避免了一个棘手的问题。考虑一下:
void f3()
{
Handle<int> h1 {new int{7}};
Handle<int> h2 {h1}; // 错 : 无复制构造函数
// ...
}
如果 Handle有一个默认的复制构造函数,h1 和 h2 都会有一个指针的副本,并且都会删除它。结果将是不确定的,而且很可能是灾难性的(§3.3.1)。警告:复制操作的生成只是被弃用,而不是被禁止,因此如果您忽略警告,您可能会让此示例通过编译。通常,如果类具有指针成员,则默认的复制和移动操作应被视为可疑。如果该指针成员代表所有权,则成员复制是错误的。如果该指针成员不代表所有权并且成员复制是合适的,则显式 =default 和注释很可能是一个好主意。
如果我们想要复制构造,我们可以定义如下内容:
template<class T>
class Handle {
// ...
Handle(const T& a) :p{new T{∗a.p}} { } // clone
};
17.6.3.4 部分指定不变量(Partially Specified Invariants)
依赖于不变量但仅通过构造函数或析构函数部分表达它们的麻烦示例较少见,但并非闻所未闻。考虑:
class Tic_tac_toe {
public:
Tic_tac_toe(): pos(9) {} // always 9 positions
Tic_tac_toe& operator=(const Tic_tac_toe& arg)
{
for(int i = 0; i<9; ++i)
pos.at(i) = arg.pos.at(i);
return ∗this;
}
// ... other operations ...
enum State { empty, nought, cross };
private:
vector<State> pos;
};
据报道,这是真实程序的一部分。它使用“魔法数字”9 来实现复制赋值,该赋值访问其参数 arg,而不检查该参数是否真的有九个元素。此外,它明确实现了复制赋值,但没有实现复制构造函数。这不是我认为的好代码。
我们定义了复制赋值,因此还必须定义析构函数。该析构函数可以是 =default,因为它需要做的就是确保成员 pos 被销毁,如果没有定义复制赋值,这无论如何都会完成。此时,我们注意到用户定义的复制赋值本质上是我们默认获得的复制赋值,因此我们也可以将其置为 =default。为了完整起见,添加一个复制构造函数,我们得到:
class Tic_tac_toe {
public:
Tic_tac_toe(): pos(9) {} // 总有9个位置
Tic_tac_toe(const Tic_tac_toe&) = default;
Tic_tac_toe& operator=(const Tic_tac_toe& arg) = default;
˜Tic_tac_toe() = default;
// ... other operations ...
enum State { empty, nought, cross };
private:
vector<State> pos;
};
看到这一点,我们意识到这些 =default 的净效应只是消除了移动操作。这是我们想要的吗?可能不是。当我们进行复制分配 =default 时,我们消除了对魔法常数 9 的讨厌的依赖。除非到目前为止未提及的 Tic_tac_toe 上的其他操作也“硬连线了魔法数字”,否则我们可以安全地添加移动操作。最简单的方法是删除显式的 =default,然后我们会看到 Tic_tac_toe 实际上是一种非常普通的类型:
class Tic_tac_toe {
public:
// ... other operations ...
enum State { empty, nought, cross };
private:
vector<State> pos {Vector<State>(9)}; // 总有9个位置
};
我从这个例子和其他定义了默认操作的“奇怪组合”的例子中得出的一个结论是,我们应该对此类类型保持高度怀疑:它们的不规则性往往隐藏着设计缺陷。对于每个类,我们都应该问:
[1] 是否需要默认构造函数(因为默认构造函数不够用或已被另一个构造函数抑制)?
[2] 是否需要析构函数(例如,因为需要释放某些资源)?
[3] 是否需要复制操作(因为默认复制语义不够用,例如,因为该类是基类,或者因为它包含指向必须由该类删除的对象的指针)?
[4] 是否需要移动操作(因为默认语义不够用,例如,因为空对象没有意义)?
特别是,我们永远不应该孤立地考虑这些操作之一。
17.6.4 被delete的函数 (deleted Functions)
我们可以“delete”一个函数;也就是说,我们可以声明一个函数不存在,因此尝试使用它(隐式或显式)是错误的。最典型的用途是消除默认的函数。例如,通常希望防止复制用作基类的类,因为这种复制很容易导致分片(§17.5.1.4):
class Base {
// ...
Base& operator=(const Base&) = delete;// 禁用复制
Base(const Base&) = delete;
Base& operator=(Base&&) = delete; // 禁用移动
Base(Base&&) = delete;
};
Base x1;
Base x2 {x1}; // 错: 无复制构造函数
启用和禁用复制和移动通常更方便的方法就是通过说出我们想要什么(使用 =default; §17.6.1),而不是说出我们不想要什么(使用 =delete)。但是,我们可以delete我们可以声明的任何函数。例如,我们可以从函数模板的可能特化集合中消除特化:
template<class T>
T∗ clone(T∗ p) // 返回 *p 的复制
{
return new T{∗p};
};
Foo∗ clone(Foo∗) = delete; // 不要试图克隆一个Foo
void f(Shape∗ ps, Foo∗ pf)
{
Shape∗ ps2 = clone(ps); // 可
Foo∗ pf2 = clone(pf); // 错: clone(Foo*) deleted
}
另一个应用是消除不需要的转换。例如:
struct Z {
// ...
Z(double); //可用double初始化
Z(int) = delete; // 但不可用int初始化
};
void f()
{
Z z1 {1}; // 错: Z(int) deleted
Z z2 {1.0}; // OK
}
进一步的用途是控制类的分配位置:
class Not_on_stack {
// ...
˜Not_on_stack() = delete;
};
class Not_on_free_store {
// ...
void∗ operator new(size_t) = delete;
};
您不能拥有无法销毁的局部变量(§17.2.2),并且当您已 =deleted 其类的内存分配运算符时,您无法在自由存储中分配对象(§19.2.5)。例如:
void f()
{
Not_on_stack v1; //错 : 无法销毁
Not_on_free_store v2; // OK
Not_on_stack∗ p1 = new Not_on_stack; // OK
Not_on_free_store∗ p2 = new Not_on_free_store; // 错: 不能分配
}
但是,我们永远无法删除该 Not_on_stack 对象。将析构函数设为私有(§17.2.2)的替代技术可以解决这个问题。
请注意 =deleted 函数与未声明函数之间的区别。在前一种情况下,编译器会注意到程序员尝试使用已删除的函数并给出错误。在后一种情况下,编译器会寻找替代方案,例如不调用析构函数或使用全局运算符 new()。
17.7 建议
[1] 将构造函数、赋值和析构函数设计为一组匹配的操作;§17.1。
[2] 使用构造函数为类建立不变量;§17.2.1。
[3] 如果构造函数获取资源,则其类需要一个析构函数来释放资源;§17.2.2。
[4] 如果类具有虚函数,则需要虚析构函数;§17.2.5。
[5] 如果类没有构造函数,则可以通过成员初始化来初始化它;§17.3.1。
[6] 优先使用 {} 初始化,而不是 = 和 () 初始化;§17.3.2。
[7] 当且仅当存在“自然”默认值时,才为类提供默认构造函数;§17.3.3。
[8] 如果类是容器,则为其提供初始化列表构造函数;§17.3.4。
[9] 按声明顺序初始化成员和基类;§17.4.1。
[10] 如果类具有引用成员,则可能需要复制操作(复制构造函数和复制赋值);§17.4.1.1。
[11] 在构造函数中优先进行成员初始化而不是赋值;§17.4.1.1。
[12] 使用类内初始化器提供默认值;§17.4.4。
[13] 如果类是资源句柄,则可能需要复制和移动操作;§17.5。
[14] 编写复制构造函数时,请小心复制需要复制的每个元素(注意默认初始化器);§17.5.1.1。
[15] 复制操作应提供等效性和独立性;§17.5.1.3。
[16] 注意交织的数据结构;§17.5.1.3。
[17] 优先使用移动语义和写时复制而不是浅复制;§17.5.1.3。
[18] 如果将类用作基类,则应防止分片(slicing);§17.5.1.4。
[19] 如果类需要复制操作或析构函数,则它可能需要一个构造函数、一个析构函数、
一个复制赋值和一个复制构造函数;§17.6。
[20] 如果类具有指针成员,则它可能需要一个析构函数和非默认复制操作;§17.6.3.3。
[21] 如果类是资源句柄,则它需要一个构造函数、一个析构函数和非默认复制操作;§17.6.3.3。
[22] 如果默认构造函数、赋值或析构函数合适,则让编译器生成它(不要自己重写);§17.6。
[23] 明确说明你的不变量;使用构造函数来建立它们并使用赋值来维护它们;§17.6.3.2。
[24] 确保复制赋值对于自我赋值是安全的;§17.5.1。
[25] 向类添加新成员时,检查是否有用户定义的构造函数需要更新以初始化成员;§17.5.1。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup