本章主要介绍与对象的“生命周期”有关的技术:我们如何创建对象、如何拷贝对象、如何移动对象以及在对象销毁时如何进行清理工作?
构造函数和析构函数
构造函数与不变式
与类同名的成员称为构造函数(constructor)。构造函数的声明指出其参数列表(与一个函数的参数列表完全一样),但未指出返回类型。
构造函数的任务是初始化该类的一个对象。一般而言,初始化操作必须建立一个类不变式(class invariant),所谓不变式就是当成员函数(从类外)被调用时必须保持的某些东西。
定义不变式有助于:
- 聚焦于类的设计工作上;
- 理清类的行为(如错误状态下的行为);
- 简化成员函数的定义;
- 理清类的资源管理;
- 简化类的文档。
析构函数和资源
析构函数用于在对象销毁时执行清理工作和释放资源的特殊成员函数。它与构造函数互补,保证在对象生命周期结束时被调用。
析构函数不接受参数,并且每个类只能有一个析构函数。它在自动变量离开作用域、对象被释放(delete)时等情况下被隐式调用,通常不需要显式调用。
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); // 在栈上创建Vector对象
return new Vector(s + s); // 在堆上创建Vector对象并返回指针
}
void g(int s) {
Vector* p = f(s); // 接收f()返回的指针
delete p; // 释放堆上分配的内存
}
在这个例子中,当函数 f()
结束时,局部变量 v1
会被销毁,其析构函数会被调用。同时,f()
在堆上创建的 Vector
对象在 g()
中通过 delete
被销毁,其析构函数也会被调用,释放分配的内存。
如果构造函数在尝试分配内存时失败(例如,因为请求的内存量超过了可用内存),new
操作符会抛出一个 std::bad_alloc
异常。异常处理机制会调用适当的析构函数来释放已经分配的内存。
基于构造函数/析构函数的资源管理风格被称为资源获取即初始化(RAII)。这种风格确保资源在对象的整个生命周期内被管理,从而避免资源泄露。
如果程序员为一个类声明了析构函数,他们还必须决定类对象是否可以被拷贝或移动。
基类和成员析构函数
构造函数和析构函数与类层次结构紧密配合,确保对象的正确构建和销毁。
构造函数的执行顺序
- 基类构造函数:首先调用基类的构造函数。
- 成员构造函数:然后调用成员对象的构造函数。
- 自身的函数体:最后执行构造函数自身的函数体。
析构函数的执行顺序
- 自身的函数体:首先执行析构函数自身的函数体。
- 成员析构函数:然后调用成员对象的析构函数。
- 基类析构函数:最后调用基类的析构函数。
构造函数按照成员和基类在类中声明的顺序执行,而不是按照初始化器的顺序。这意味着如果两个构造函数使用了不同的顺序,析构函数不能保证按构造的相反顺序进行销毁,即使能够保证,也会有额外的性能开销。
调用构造函数和析构函数
析构函数通常在对象退出作用域或被 delete
释放时隐式调用。显式调用析构函数通常是不必要的,甚至可能导致严重错误。然而,在某些特定情况下,我们需要显式调用构造函数和析构函数。
放置式 new
在容器类(如 std::vector
)中,可能需要在特定地址创建对象。这种构造函数的用法被称为“放置式 new”。例如,当向容器添加元素时,容器可能需要在预分配的内存池的特定地址上调用构造函数:
void C::push_back(const X& a) {
new(p) X{a}; // 在地址p用值a拷贝构造一个X对象
}
这里,new(p) X{a}
是放置式 new 的语法,它在地址 p
处使用 a
的值来构造一个 X
类型的对象。
显式调用析构函数
与放置式 new 相反,当我们需要从容器中删除元素时,容器需要调用对象的析构函数来显式销毁它:
void C::pop_back() {
p->~X(); // 销毁地址p中的X对象
}
这里的 p->~X()
语法是对指针 p
指向的对象调用 X
的析构函数。
阻止隐式析构
如果为类 X
声明了一个析构函数,那么每当一个 X
对象离开作用域或被 delete
释放时,析构函数就会被隐式调用。我们可以通过将析构函数声明为 =delete
或将其设置为 private
来阻止对象的隐式析构。
使用 private
更为灵活,因为它允许我们创建一个可以显式销毁但不能隐式销毁的对象。例如:
class Nonlocal {
public:
void destroy() { this->~Nonlocal(); } // 显式析构
private:
~Nonlocal(); // 私有析构函数,防止隐式析构
};
void user() {
Nonlocal x; // 错误:不能隐式析构一个Nonlocal对象
Nonlocal* p = new Nonlocal; // 正确:可以显式构造一个Nonlocal对象
// ...
delete p; // 错误:不能隐式析构一个Nonlocal对象
p->destroy(); // 正确:可以显式析构一个Nonlocal对象
}
Virtual 析构函数
如果一个类包含虚函数,通常应该将其析构函数声明为虚函数。这是因为虚析构函数确保了当通过基类指针删除派生类对象时,能够调用到正确的派生类析构函数,从而避免资源泄漏。
class Shape {
public:
virtual void draw() = 0;
virtual ~Shape(); // 虚析构函数
};
class Circle : public Shape {
public:
void draw() override;
~Circle() override; // 覆盖基类的虚析构函数
// ...
};
void user(Shape* p) {
p->draw(); // 调用恰当的draw()
delete p; // 调用恰当的析构函数
}
类对象初始化
不使用构造函数进行初始化
可以用下列方法初始化一个无构造函数的类的对象:
-
逐成员初始化;
- 需要能访问成员时,逐成员初始化才奏效
-
拷贝初始化;
-
默认初始化(不用初始化器或空初始化列表)
- 使用空大括号
{}
进行默认初始化,对每个成员进行默认初始化
- 使用空大括号
#include <string>
class MyClass {
public:
int a;
std::string b;
};
int main() {
// 逐成员初始化
MyClass obj1 = { 10, "hello" };
// 拷贝初始化
MyClass obj2{ obj1 };
// 默认初始化
MyClass obj3{};
return 0;
}
没有声明可接受参数的构造函数,也可以省去初始化器,例如:
#include <string>
class MyClass {
public:
int a;
std::string b;
};
int main() {
MyClass obj4;
return 0;
}
-
对于静态分配的对象
- 这种方式与使用
{}
完全一样
- 这种方式与使用
-
对于局部变量和自由存储空间对象
- 这只是对类类型成员进行默认初始化,内置类型成员不进行初始化
- obj4 的值是
{unknown, ""}
如果一个类有私有的非 static
数据成员,它就需要一个构造函数来进行初始化。
使用构造函数进行初始化
定义了一个接受参数的构造函数后,默认构造函数就不再存在,因为构造函数的存在表明我们需要特定的参数来构造对象。然而,拷贝构造函数不会消失,它假设对象在正确构造之后可以被拷贝。如果这个假设可能导致问题,可以明确地禁止拷贝。
使用 {}
语法明确表示正在进行初始化,而不是赋值、调用函数或声明函数。这种语法可以在任何需要构造对象的地方使用,为构造函数提供参数。
构造函数遵循常规的重载解析规则。例如:
struct S {
S(double*);
S(const char*);
};
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*)?
注意,{}
初始化器语法不允许窄化转换。
默认构造函数
默认构造函数是不接受任何参数的构造函数。它在对象创建时未指定参数或提供了空初始化器列表时被调用。
如果一个构造函数使用了默认参数,它也可以作为默认构造函数。
class String {
public:
String(const char* p = ""); // 默认构造函数:空字符串
};
String s1; // 正确
String s2{}; // 正确
引用和 const
成员必须被初始化,因此包含这些成员的类不能默认构造,除非提供了类内成员初始化器或定义了一个默认构造函数来初始化它们。
初始化器列表构造函数
初始化器列表构造函数是一种特殊的构造函数,它接受一个 std::initializer_list
类型的参数。这种构造函数允许使用大括号 {}
列表来初始化对象。
初始化器列表构造函数在容器类中较为常见。
一个初始化器列表的长度可以是任意的,但它必须是同构的,即所有元素的类型都必须是模板参数 T
,或者可以隐式转换为 T
。
initializer_list
构造消除歧义
如果一个类拥有多个构造函数,编译器会根据提供的参数使用重载解析规则来选择正确的构造函数。当涉及到 initializer_list
构造函数时,有一些具体的规则来解决潜在的歧义:
-
优先选择默认构造函数:如果默认构造函数或初始化器列表构造函数都匹配,优先选择默认构造函数。这符合常理,只要可能,就选择最简单的构造函数。
- 而且,如果你的初始化器列表构造函数在接受空列表时所做的事情与默认构造函数不同,那么你很可能犯了一个设计错误。
-
优先选择初始化器列表构造函数:如果一个初始化器列表构造函数和一个“普通构造函数”都匹配,优先选择列表初始化器构造函数。这条规则避免了依据不同元素数产生不同的解析结果。
使用 initializer_list
std::initializer_list
提供了 begin()
、end()
和 size()
成员函数,允许你访问序列中的元素。但是,它不支持下标操作。
#include <iostream>
#include <initializer_list>
void f(std::initializer_list<int> args) {
for (int i = 0; i != args.size(); ++i) {
std::cout << args.begin()[i] << "\n";
}
}
void f(std::initializer_list<int> args) {
for (auto p = args.begin(); p != args.end(); ++p) {
std::cout << *p << "\n";
}
}
void f(std::initializer_list<int> args) {
for (auto x : args) {
std::cout << x << "\n";
}
}
std::initializer_list
是以传值方式传递的,这不会引入额外开销,因为它只是一个小句柄,通常只有两个字大小,指向一个元素类型为 T
的数组。
std::initializer_list
的元素是不可变的,因此你不能修改它们的值(不能对其中的元素使用移动构造函数)。
成员和基类初始化
构造函数的作用是确保对象在创建时满足类的不变式,并获取必要的资源。这通常是通过初始化类成员和基类来完成的。
成员初始化
成员初始化器列表以冒号开始,成员初始化器用逗号间隔。类成员的构造函数在其函数体执行之前调用,按照成员在类中声明的顺序,而不是在初始化器列表中出现的顺序。因此,最好按照成员声明的顺序指明初始化器,以避免混淆。
class Club {
string name;
vector<string> members;
vector<string> officers;
Date founded;
public:
Club(const string& n, Date fd);
};
Club::Club(const string& n, Date fd)
: name{ n }, members{}, officers{}, founded{ fd }
{
}
如果成员构造函数不需要参数,则不必在成员初始化器列表中提及此成员。例如:
Club::Club(const string& n, Date fd) : name{n}, founded{fd} {}
此构造函数等同于前一个版本,都将 Club::officers
和 Club::members
初始化为空 vector
。
一个构造函数可以初始化其类的成员和基类,但不会初始化其成员或基类的成员或基类。例如:
struct B { B(int);/*...*/ };
struct BB : B { /*...*/ };
struct BBB : BB { BBB(int i) : B(i) {} }; // 错误:尝试初始化基类的基类
成员初始化和赋值
成员初始化和赋值是两种不同的操作,它们在处理不同类型的成员时有着不同的意义和效果。
对于某些类型的成员,如 const
成员和引用成员,必须使用成员初始化器进行初始化,因为这些类型的成员一旦构造就不能被赋值。
class X {
const int i;
Club cl;
Club& rc;
public:
X(int ii, const string& n, Date d, Club& c) : i{ii}, cl{n, d}, rc{c} {}
};
对于大多数类型,程序员可以选择使用成员初始化器或在构造函数体内进行赋值。通常推荐使用成员初始化器,因为它明确表示正在进行初始化操作,并且通常具有性能上的优势。
class Person {
string name;
string address;
public:
Person(const Person&);
Person(const string& n, const string& a) : name{n} {
address = a; // 地址首先被初始化为空字符串,然后被赋值为a的副本
}
};
在这个例子中,name
使用成员初始化器进行初始化,而 address
则在构造函数体内被赋值。使用成员初始化器对 name
进行初始化意味着 name
直接使用 n
的一个副本进行初始化,而 address
则首先被默认初始化为空字符串,然后才被赋值为 a
的副本。
基类初始化器
派生类的构造函数必须正确地初始化其基类。基类的初始化方式与非数据成员类似,如果基类需要一个初始化器,那么在派生类的构造函数中就必须提供相应的基类初始化器。如果基类有默认构造函数,可以显式指出使用默认构造。
class B1 {
public:
B1(); // 具有默认构造函数
};
class B2 {
public:
B2(int); // 无默认构造函数
};
struct D1 : B1, B2 {
D1(int i) : B1{}, B2{ i } {} // 显式初始化B1和B2
};
struct D2 : B1, B2 {
D2(int i) : B2{ i } {} // 隐式使用B1的默认构造函数
};
struct D3 : B1, B2 {
D3(int i) : {} // 错误,B2需要一个int初始化器
};
基类是按照它们在类中声明的顺序进行初始化的。 建议在派生类的构造函数中按照这个顺序指定基类的初始化器。
基类的初始化在成员变量之前进行,而基类的析构则在成员变量之后进行。
委托构造函数
委托构造函数通过在成员和基类初始化器列表中调用另一个构造函数来实现。如下所示:
class X {
int a;
void validate(int x) {
if (0 < x && x <= 100) a = x; else throw x;
}
public:
X(int x) { validate(x); }
X() : X{ 42 } {} // 委托构造函数
};
委托构造函数只能调用同类中的另一个构造函数,不能同时包含成员初始化列表或基类初始化器。
class MyClass {
int x;
int y;
public:
MyClass(int val) : x(val), y(val) {} // 普通构造函数
MyClass() : MyClass(0), y(10) {} // 错误:同时使用了委托和成员初始化
};
直到构造函数执行完毕,对象才被认为完成构造。当使用委托构造函数时,委托者执行完毕才表明构造完成。仅仅被委托者执行完毕是不够的。析构函数在构造函数执行完毕后才可能会被调用。
类内初始化器
类内初始化器是一种在类声明中直接为非静态数据成员指定初始值的机制。这种方式可以简化代码,并提高代码的可读性和可维护性。
class A {
public:
A() {}
A(int a_val) : a{a_val} {}
A(D d) : b{g(d)} {}
private:
int a{7};
int b{5};
HashFunction algorithm{"MD5"};
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,也就是::count2 + ::count
S() { ++count2; }
};
int main() {
S s1; // {0,0}
++count;
S s2; // {1,2}
}
在这个例子中,成员初始化是按成员声明的顺序进行的,因此 m1
首先被初始化为全局变量 count2
。全局变量的值在新 S
对象的构造函数运行时获得,因此它可以改变(本例中确实改变了)。接下来,通过调用全局函数 f()
初始化 m2
。
static 成员初始化
静态类成员(static
成员)不属于单个对象,而是与类本身相关联。通常,静态成员的声明在类内部进行,而定义和初始化在类外部进行。
class Node {
static int node_count; // 声明
};
int Node::node_count = 0; // 定义和初始化
但是,在某些特殊情况下,可以在类内直接初始化静态成员。
- 静态成员必须是整型或枚举类型的
const
,或者是字面值类型的constexpr
。 - 初始化器必须是一个常量表达式。
class Curious {
public:
static const int c1 = 7; // 正确
static int c2 = 11; // 错误:非const
static const int c3 = 13; // 正确,但非static(应放在类内初始化)
static const int c4 = std::sqrt(9); // 错误:类内初始化器不是常量
static const float c5 = 7.0; // 错误:类内初始化成员不是整型(应使用constexpr而非const)
};
对于需要在内存中存储的已初始化成员,必须在某处(唯一)定义。初始化器不能重复:
#include <iostream>
#include <cmath>
class Curious {
public:
static const int c1 = 7; // 正确
};
const int Curious::c1 = 10; // 不重复初始化器
const int* p = &Curious::c1; // 正确:Curious::c1已被定义
拷贝和移动
拷贝
拷贝操作意味着创建一个对象的副本,使得两个对象独立拥有自己的数据。
拷贝操作可以由拷贝构造函数和拷贝赋值运算符实现:
- 拷贝构造函数:
X(const X&)
- 拷贝赋值运算符:
X& operator=(const X&)
拷贝构造函数与拷贝赋值运算符的区别在于前者初始化一片未初始化的内存,而后者必须正确处理目标对象已构造并可能拥有资源的情况。
拷贝操作可能抛出异常,因为可能需要获取资源。如果需要强力保障拷贝异常时对象的状态,可以先使用拷贝构造函数创建一个副本,然后使用 std::swap
交换内容。
默认的拷贝语义是逐成员拷贝。
拷贝基类
拷贝派生类对象时,必须同时拷贝其基类部分。
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;
};
默认的拷贝构造函数能够正确拷贝 virtual
基类。如果定义了自己的拷贝构造函数,最简单的技术是重复拷贝 virtual
基类。对于基类对象较小且 virtual
基类在类层次中只出现几次的情况,重复拷贝 virtual
基类通常比试图避免重复拷贝更高效。
拷贝的含义
拷贝操作必须满足特定的语义才能被认为是“正确的拷贝”:
- 等价性:在执行
x = y
之后,对x
和y
执行相同的操作应该得到相同的结果。如果类型定义了==
操作符,那么应该有x == y
,并且对于任何只依赖于x
和y
的值的函数f()
(与依赖于x
和y
的地址的函数不同),有f(x) == f(y)
。 - 独立性:在执行
x = y
之后,对x
的操作不应隐式地改变y
的状态,即,只要f(x)
未引用y
,它就不会改变y
的值。
浅拷贝和深拷贝
- 浅拷贝仅复制对象的非动态分配的成员变量和动态分配的成员变量的指针,而不是它们指向的内存。这意味着原始对象和拷贝对象将共享相同的动态分配的内存。
- 深拷贝会为对象的每个动态分配的成员变量分配新的内存,并复制内容。这意味着原始对象和拷贝对象将拥有自己的独立内存。
写前拷贝
#include <memory>
#include <iostream>
class CopyOnWrite {
public:
// 构造函数
CopyOnWrite(int value) : data(std::make_shared<int>(value)) {}
// 拷贝构造函数
CopyOnWrite(const CopyOnWrite& other) : data(other.data) {}
// 赋值运算符
CopyOnWrite& operator=(const CopyOnWrite& other) {
if (this != &other) {
data = other.data;
}
return *this;
}
// 获取数据的读方法
int getData() const {
return *data;
}
// 获取数据的写方法,执行写时复制
void setData(int value) {
if (!data.unique()) {
// 若当前对象不是唯一拥有者,则复制一份数据
data = std::make_shared<int>(*data);
}
*data = value;
}
private:
std::shared_ptr<int> data; // 使用 shared_ptr 管理数据
};
int main() {
CopyOnWrite cow1(10);
CopyOnWrite cow2 = cow1;
std::cout << "cow1 data: " << cow1.getData() << std::endl;
std::cout << "cow2 data: " << cow2.getData() << std::endl;
cow2.setData(20);
std::cout << "After modifying cow2:" << std::endl;
std::cout << "cow1 data: " << cow1.getData() << std::endl;
std::cout << "cow2 data: " << cow2.getData() << std::endl;
return 0;
}
切片
切片是指将派生类对象通过公有基类类型的指针或引用进行拷贝时,只有基类部分被拷贝,而派生类特有的部分被“切掉”的现象。
struct Base {
int b;
Base(const Base&);
};
struct Derived : Base {
int d;
Derived();
Derived(const Derived&);
};
void naive(Base* p) {
Base b2 = *p; // 可能发生切片:调用Base::Base(const Base&)
}
void user() {
Derived d;
naive(&d);
Base bb = d; // 发生切片:调用Base::Base(const Base&) 而不是 Derived::Derived(const Derived&)
}
防止切片的方法
-
禁止拷贝基类:通过将拷贝构造函数和拷贝赋值操作符
=delete
,可以阻止基类对象的拷贝。这样可以确保在代码中不会无意间产生对基类的拷贝,从而避免可能的切片问题。这通常通过以下方式实现:class Base { public: Base(const Base&) = delete; // 禁止拷贝构造 Base& operator=(const Base&) = delete; // 禁止拷贝赋值 // ... 其他代码 };
这样一来,试图拷贝
Base
对象或将派生类对象赋值给Base
对象就会导致编译错误,从而有效避免了切片。 -
防止派生类指针转换为基类指针:通过将基类继承声明为
private
或protected
,可以防止派生类对象通过隐式转换变为基类指针(即防止派生类指针被当作基类指针使用)。例如:class Base { // 基类内容 }; class Derived : private Base { // 或者 protected // 派生类内容 };
这样,
Derived
的对象或指针无法直接转换为Base
的指针,减少了切片的可能性(因为基类部分无法单独访问)。
移动
移动操作将一个对象的资源转移给另一个对象,使得源对象处于一个有效但未定义的状态(对于容器来说通常是“空”状态)。移动操作通常比拷贝更高效,因为它避免了不必要的数据复制。
#include <iostream>
#include <utility> // For std::move
class MyClass {
public:
int* data; // 动态分配的资源
size_t size; // 资源的大小
// 构造函数
MyClass(size_t size) : size(size), data(new int[size]) {
std::cout << "Constructor called" << std::endl;
for (size_t i = 0; i < size; ++i) {
data[i] = i; // 初始化数据
}
}
// 拷贝构造函数(删除)
MyClass(const MyClass& other) = delete;
// 移动构造函数
MyClass(MyClass&& other) noexcept : data(other.data), size(other.size) {
std::cout << "Move Constructor called" << std::endl;
other.data = nullptr; // 将资源转移后,将源对象的指针设置为nullptr
other.size = 0; // 将源对象的大小设置为0
}
// 拷贝赋值运算符(删除)
MyClass& operator=(const MyClass& other) = delete;
// 移动赋值运算符
MyClass& operator=(MyClass&& other) noexcept {
std::cout << "Move Assignment called" << std::endl;
delete[] data; // 释放当前对象的资源
data = other.data; // 转移资源
size = other.size; // 转移大小
other.data = nullptr; // 将源对象的指针设置为nullptr
other.size = 0; // 将源对象的大小设置为0
return *this;
}
// 析构函数
~MyClass() {
std::cout << "Destructor called" << std::endl;
delete[] data; // 释放资源
}
// 显示数据的成员函数
void display() const {
for (size_t i = 0; i < size; ++i) {
std::cout << data[i] << " ";
}
std::cout << std::endl;
}
};
int main() {
MyClass obj1(5); // 调用构造函数
obj1.display(); // 显示数据
MyClass obj2 = std::move(obj1); // 调用移动构造函数
// obj2.display(); // 显示数据,obj1的资源已经被移动,其display可能显示未定义的行为
// MyClass obj3 = obj2; // 编译错误,拷贝构造函数被删除
// obj1 = obj2; // 编译错误,拷贝赋值运算符被删除
MyClass obj4(10);
obj4 = std::move(obj2); // 调用移动赋值运算符
// obj4.display(); // 显示数据,obj2的资源已经被移动,其display可能显示未定义的行为
return 0;
}
移动操作通常不抛出异常,并应将源对象置于一个合法的但未指明的状态,因为它最终会被销毁。
移动赋值背后的思想是将左值的处理与右值的处理分离:拷贝赋值操作和拷贝构造函数
接受左值,而移动赋值操作和移动构造函数则接受右值。对于返回值,采用移动构造函数。
编译器在某些情况下(如返回值)会自动使用移动操作,但一般情况下,需要通过传递右值引用参数来告知编译器使用移动操作。
template<class T> void swap(T& a, T& b) {
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
std::move()
是一个标准库函数,它返回其实参的右值引用,允许移动操作。
生成默认操作
默认情况下,编译器会为一个类生成以下操作:
- 一个默认构造函数:
X()
- 一个拷贝构造函数:
X(const X&)
- 一个拷贝赋值运算符:
X& operator=(const X&)
- 一个移动构造函数:
X(X&&)
- 一个移动赋值运算符:
X& operator=(X&&)
- 一个析构函数:
~X()
如果程序员定义了其中一个或多个操作,那么对应的操作就不会自动生成了:
-
如果程序员为一个类声明了任意构造函数,编译器就不会为该类生成默认构造函数。
-
如果程序员为一个类声明了拷贝操作、移动操作或析构函数,则编译器不会为该类生成拷贝操作、移动操作或析构函数。
- 由于向后兼容性的需求,即使程序员已经定义了析构函数,编译器还是会自动生成拷贝构造函数和拷贝赋值运算符。但这一特性在 ISO 标准中已经弃用了,现代编译器可能会对此给出警告。
如有需要,可以显式指出希望编译器生成哪些函数以及不希望生成哪些函数。
显式声明默认操作
class gslice {
valarray<size_t> size;
valarray<size_t> stride;
valarray<size_t> d1;
public:
gslice() = default;
~gslice() = default;
gslice(const gslice&) = default;
gslice(gslice&&) = default;
gslice& operator=(const gslice&) = default;
gslice& operator=(gslice&&) = default;
};
显式声明为默认,有助于编译器优化。
默认操作
-
默认构造:对于一个类,如果没有任何构造函数被定义,编译器会生成一个默认构造函数。这个构造函数会默认逐成员构造构造每个非静态数据成员和基类。
-
拷贝构造:如果一个类没有定义自己的拷贝构造函数,编译器会生成一个默认的拷贝构造函数,它会逐成员拷贝基类和非静态数据成员。
-
拷贝赋值:类似地,如果没有定义拷贝赋值运算符,编译器会生成一个默认的拷贝赋值运算符,逐成员赋值。
-
移动构造:如果没有定义移动构造函数,编译器会生成一个默认的移动构造函数,它会逐成员移动基类和非静态数据成员。
- 注意,如果一个移出对象是内置类型(例如
int*
),其值保持不变。这样做对于编译器而言是最简单也是最快的。如果我们希望对类成员做其他操作,就必须编写自己的移动操作。
- 注意,如果一个移出对象是内置类型(例如
不变式
程序员应该:
- 在构造函数中建立不变式(包括可能的资源获取)。
- 在拷贝和移动操作中保持不变式(利用常用名字和类型)。
- 在析构函数中做任何需要的清理工作(包括可能的资源释放)。
如果一个类有一个指针成员,就要对默认的拷贝和移动操作保持警惕。如果该指针成员表示所有权,逐成员拷贝就是错误的。如果该指针成员不表示所有权而逐成员拷贝是恰当的,采用显式=default 并编写必要的注释通常也是好的风格。
使用 delete 删除的函数
delete
关键字可以用来“删除”一个函数,这意味着声明该函数不存在,任何尝试使用它的操作都会导致编译错误。这种机制通常用于防止不期望的行为,例如防止类的拷贝或移动。
防止类的拷贝和移动
class Base {
public:
Base() = default; // 默认构造函数
Base& operator=(const Base&) = delete; // 删除拷贝赋值运算符
Base(const Base&) = delete; // 删除拷贝构造函数
Base& operator=(Base&&) = delete; // 删除移动赋值运算符
Base(Base&&) = delete; // 删除移动构造函数
};
删除模板特例化
可以使用 delete
关键字删除模板的特例化版本,以防止某些类型的实例化。例如:
struct Foo {};
struct Shape {};
template<class T>
T* clone(T* p) {
return new T(*p);
}
// 删除Foo类型的克隆函数
Foo* clone(Foo*) = delete;
void f(Shape* ps, Foo* pf) {
Shape* ps2 = clone(ps); // 没问题
Foo* pf2 = clone(pf); // 错误:clone(Foo*)已被删除
}
在这个例子中,尝试克隆 Foo
类型的对象会导致编译错误,因为 clone(Foo*)
被删除了。
删除不需要的类型转换
可以删除构造函数来防止使用特定的类型初始化对象。例如:
struct Z {
Z(double); // 可以用double初始化
};
void f() {
Z z1(1); // 正确
Z z2(1.0); // 正确
}
在当前的版本中,可以用整型初始化。如果想要禁止使用整型初始化,可以使用 delete
:
struct Z {
Z(double); // 可以用double初始化
Z(int) = delete; // 但不能用整数初始化
};
void f() {
Z z1(1); // 错误:Z(int)已被删除
Z z2(1.0); // 正确
}
在这个例子中,尝试用整数初始化 Z
类型的对象会导致编译错误。
控制类对象的分配
可以使用 delete
关键字控制类对象的分配,例如禁止在栈上或自由存储空间上分配对象。
class Not_on_stack {
public:
~Not_on_stack() = delete; // 禁止在栈上构造
};
class Not_on_free_store {
public:
void* operator new(size_t) = delete; // 禁止在自由存储空间分配
};
void f() {
Not_on_stack v1; // 错误:不能销毁
Not_on_free_store v2; // 正确
Not_on_stack* p1 = new Not_on_stack; // 正确
Not_on_free_store* p2 = new Not_on_free_store; // 错误:不能分配
}
但是,永远不能使用 delete
删除 Not_on_stack
对象。另一种替代技术是将析构函数声明为 private
,可解决此问题(注:书中说可以,但是我测试好像也不行啊!!!)。
请注意已使用 delete
删除的函数与只是未声明的函数之间的差别。在前一种情况下,编译器会发现程序员试图使用已使用 delete
删除的函数的情况并报错。在后一种情况下,编译器会寻找替代方法,如不调用析构函数或使用全局 operator new()
。