this
指针
给定类的成员函数总是与其类的一个对象组合调用。对于函数要作用的对象总是存在一个隐式的“substrate”。C++ 定义了一个关键字 this
来访问这个substrate。this
关键字是一个指针变量,总是包含调用成员函数的对象的地址。this
指针由每个成员函数(无论是 public、protected 还是 private)隐式声明。this
指针是一个指向成员函数所属类对象的常量指针。例如,类 Person
的成员函数隐式地声明了以下内容:
extern Person *const this;
一个像 Person::name
这样的成员函数可以有两种实现方式:可以使用或不使用 this
指针:
char const *Person::name() const { return d_name; } // 隐式使用 `this`
char const *Person::name() const { return this->d_name; } // 显式使用 `this`
this
指针很少显式使用,但确实存在一些情况需要实际使用 this
指针(参见第16章)。
顺序赋值与 this
C++ 语法允许顺序赋值,其中赋值运算符是从右到左关联的。在像以下这样的语句中:
a = b = c;
首先计算表达式 b = c
,然后其结果赋值给 a
。
到目前为止,我们遇到的重载赋值运算符的实现不允许这种构造,因为它返回 void
。可以使用 this
指针轻松地解决这一缺陷。重载的赋值运算符期望一个对其类对象的引用,它也可以返回一个对其类对象的引用。然后可以使用此引用作为顺序赋值中的参数。
重载赋值运算符通常返回当前对象的引用(即 *this
)。因此,Person
类的重载赋值运算符的下一个版本如下所示:
Person &Person::operator=(Person const &other)
{
delete[] d_address;
delete[] d_name;
delete[] d_phone;
d_address = strdupnew(other.d_address);
d_name = strdupnew(other.d_name);
d_phone = strdupnew(other.d_phone);
// 返回当前对象的引用
return *this;
}
重载运算符本身也可以被重载。例如,string
类具有重载的赋值运算符 operator=(std::string const &rhs)
,operator=(char const *rhs)
,以及其他多个重载版本。这些附加的重载版本用于处理不同的情况,通常通过其参数类型来识别。这些重载版本都遵循相同的模式:在必要时删除由对象控制的动态分配的内存;使用重载运算符的参数值分配新值,并返回 *this
。
拷贝构造函数:初始化 vs. 赋值
请再次考虑在第9.2节中介绍的 Strings
类。由于它包含多个基本类型的数据成员以及指向动态分配内存的指针,它需要一个构造函数、析构函数和一个重载的赋值运算符。事实上,这个类提供了两个构造函数:除了默认构造函数之外,它还提供了一个期望接收 char const* const*
和 size_t
参数的构造函数。
现在考虑以下代码片段。在示例之后将讨论语句引用:
int main(int argc, char **argv)
{
Strings s1(argv, argc); // (1)
Strings s2; // (2)
Strings s3(s1); // (3)
s2 = s1; // (4)
}
- 在 (1) 处,我们看到一次初始化。对象
s1
使用main
函数的参数进行初始化:使用Strings
的第二个构造函数。 - 在 (2) 处,使用
Strings
的默认构造函数,初始化一个空的Strings
对象。 - 在 (3) 处,又创建了一个
Strings
对象,使用接受现有Strings
对象的构造函数。这种形式的初始化尚未讨论过。这称为拷贝构造,执行初始化的构造函数称为拷贝构造函数。拷贝构造也可以以以下形式出现:
这是一次构造,因此是初始化。这不是赋值,因为赋值需要一个已经定义的左操作数。C++ 允许构造函数只有一个参数时使用赋值语法,但这种用法已经有些过时了。Strings s3 = s1;
- 在 (4) 处,我们看到一个普通的赋值操作。
在上面的示例中,定义了三个对象,每个对象都使用了不同的构造函数。实际上使用的构造函数是根据构造函数的参数列表推导出来的。
在这里遇到的拷贝构造函数是新的。即使它没有在类接口中声明,也不会导致编译错误。这引出了以下规则:
拷贝构造函数(几乎)总是可用的,即使它没有在类的接口中声明。
关于“(几乎)”的原因在第9.7.1节中给出。
编译器提供的拷贝构造函数也称为“平凡拷贝构造函数”。可以很容易地通过使用 = delete
语法来禁止其使用。平凡拷贝构造函数执行的是现有对象的基本数据的逐字节拷贝操作,将对象的类数据成员从现有对象的对应部分初始化为新对象,并在使用继承时调用基类的拷贝构造函数来初始化新对象的基类。
因此,在上面的示例中使用的是平凡拷贝构造函数。由于它对对象的基本类型数据成员执行逐字节拷贝操作,这正是第 (3) 处发生的情况。当 s3
不再存在时,其析构函数会删除其字符串数组。不幸的是,d_string
是一种基本数据类型,因此它也删除了 s1
的数据。我们再次遇到了对象超出作用域后产生的野指针问题。
解决方法很简单:不要使用平凡拷贝构造函数,而是必须显式地将拷贝构造函数添加到类的接口中,并且其定义必须防止野指针问题,类似于在重载赋值运算符中实现的方式。对象的动态分配内存被复制,以便它包含自己的分配数据。但是请注意,如果一个类还保留了额外的(原始)内存,即它支持额外的内存容量,那么该未使用的额外容量在拷贝构造的对象中不会被保留。
拷贝构造可以用于清除多余的容量。拷贝构造函数不必清除多余的容量。例如,在拷贝构造 std::string
对象时,目标对象定义的容量与源对象相同。拷贝构造函数比重载赋值运算符更简单,因为它不必删除先前分配的内存。由于对象即将被创建,因此没有已分配的内存。
Strings
的拷贝构造函数可以实现如下:
Strings::Strings(Strings const &other)
: d_string(new string[other.d_size]),
d_size(other.d_size)
{
for (size_t idx = 0; idx != d_size; ++idx)
d_string[idx] = other.d_string[idx];
}
每当使用另一个类对象初始化对象时,都会调用拷贝构造函数。除了我们迄今为止遇到的普通拷贝构造,还有其他情况会使用拷贝构造函数:
- 当函数定义一个类类型的值参数而不是指针或引用时,它会使用拷贝构造函数初始化函数的参数。例如:
void process(Strings store) // 没有指针,也没有引用 { store.at(3) = "modified"; // 不会修改 `outer` } int main(int argc, char **argv) { Strings outer(argv, argc); process(outer); }
- 当函数定义一个类类型的值返回类型时。例如:
这里使用Strings copy(Strings const &store) { return store; }
store
来初始化copy
的返回值。返回的Strings
对象是一个临时的、匿名的对象,调用copy
的代码可以立即使用它,但不能对其生命周期做任何假设。
修改赋值运算符
重载的赋值运算符具有与拷贝构造函数和析构函数相似的特性:
- (1)拷贝构造函数和(2)重载赋值函数中都会发生对(私有)数据的拷贝。
- (1)重载赋值函数和(2)析构函数中都会删除已分配的内存。
拷贝构造函数和析构函数显然是必要的。如果重载赋值运算符也需要释放已分配的内存并为其数据成员分配新值,是否可以利用析构函数和拷贝构造函数来完成这些任务呢?
正如我们在讨论析构函数(第9.2节)时看到的那样,析构函数可以显式地调用,但这一点对于(拷贝)构造函数并不适用。但让我们简要总结一下重载赋值运算符应该做什么:
- 它应该删除当前对象所控制的动态分配内存;
- 它应该使用提供的现有同类对象重新分配当前对象的数据成员。
第二部分显然很像拷贝构造。如果意识到拷贝构造函数还会初始化类中可能存在的任何引用数据成员,拷贝构造就显得更有吸引力了。实现拷贝构造部分很容易:只需定义一个局部对象并使用赋值运算符的 const
引用参数进行初始化,像这样:
Strings &operator=(Strings const &other)
{
Strings tmp(other);
// 更多内容将接着编写
return *this;
}
你可能会认为优化版的 operator=(Strings tmp)
颇具吸引力,但让我们暂时搁置这个想法(至少等到第9.7节再讨论)。
现在我们已经完成了复制部分,那删除部分该如何处理呢?是不是还有另一个小问题?毕竟我们复制的内容是正确的,但却没有复制到我们预期的(当前的,*this
)对象中。
此时是引入交换的时机。交换两个变量意味着这两个变量交换它们的值。我们将在下一节详细讨论交换,但现在让我们假设已经为 Strings
类添加了一个 swap(Strings &other)
成员函数。这允许我们完成 String
类的 operator=
实现:
Strings &operator=(Strings const &other)
{
Strings tmp(other);
swap(tmp);
return *this;
}
这个 operator=
的实现是通用的:它可以应用于每个对象可交换的类。它是如何工作的呢?
other
对象中的信息被用来初始化一个本地的tmp
对象。这部分完成了赋值运算符的复制工作;- 调用
swap
确保当前对象接收其新值(同时tmp
接收当前对象的原始值); - 当
operator=
终止时,其本地tmp
对象消失,并调用其析构函数。因为此时tmp
包含了之前由当前对象拥有的数据,所以当前对象的原始数据也随之销毁,从而有效地完成了赋值操作的销毁部分。
很棒吧?
交换操作
许多类(例如 std::string
)提供了交换成员函数,使我们可以交换它们的两个对象。标准模板库(STL,第18章)提供了与交换相关的各种函数。实际上,还有一个通用的交换算法(第19.1.55节),它通常使用赋值操作符实现。当为我们的 Strings
类实现交换成员函数时,如果 Strings
的所有数据成员都可以交换,则可以使用这个通用的算法。由于这是正确的(为什么这点将会在稍后讨论),我们可以为 Strings
类添加一个 swap
成员函数:
void Strings::swap(Strings &other)
{
swap(d_string, other.d_string);
swap(d_size, other.d_size);
}
在 Strings
类中添加了这个成员函数之后,我们现在可以使用 copy-and-swap
方法实现 String::operator=
。
当两个变量(例如 double one
和 double two
)被交换时,每个变量在交换后都持有另一个变量的值。例如,如果 one == 12.50
且 two == -3.14
,则在执行 swap(one, two)
后,one == -3.14
而 two == 12.50
。
原始数据类型的变量(指针和内置类型)可以交换,而类类型的对象可以交换,如果它们的类提供了交换成员函数。
那么我们是否应该为我们的类提供一个 swap
成员函数?如果提供,应该如何实现它呢?
上面的示例(Strings::swap
)展示了实现交换成员函数的标准方法:逐个交换其数据成员。但也有一些情况,即使类仅定义了原始数据类型的数据成员,也无法以这种方式实现交换成员函数。考虑下面的情况(图9.3)。
在图9.3中,有四个对象,每个对象都有一个指针指向下一个对象。这样类的基本组织如下:
class List
{
List *d_next;
// 其他成员...
};
最初,四个对象的 d_next
指针分别设置为下一个对象:1 指向 2,2 指向 3,3 指向 4。图的上半部分展示了这种情况。在下半部分,展示了如果对象 2 和 3 被交换会发生什么:3 的 d_next
指针现在指向对象 2,而对象 2 的 d_next
指针指向 3 的地址,但 2 的 d_next
现在指向对象 3,因此 3 也指向了自己。坏消息!
另一个交换对象出现问题的情况是类具有指向或引用自身数据成员的情况。如图9.4所示:
class SelfRef {
size_t *d_ownPtr; // 初始化为 &d_data
size_t d_data;
};
图9.4的上半部分展示了两个对象;它们的上部数据成员指向它们的下部数据成员。但是如果这些对象被交换,则会遇到图下半部分所示的情况:这里,地址 a 和 c 的值被交换了,因此它们突然指向了其他对象的数据成员。再次出现了问题。
这些交换操作失败的常见原因很容易识别:必须避免简单的交换操作,当数据成员指向或引用涉及交换的数据时。如果在图9.4中,a 和 c 数据成员指向的是两个对象之外的信息(例如,它们指向动态分配的内存),那么简单的交换将会成功。然而,遇到的 SelfRef
对象交换问题并不意味着两个 SelfRef
对象不能被交换;它只意味着在设计交换成员函数时必须小心。下面是 SelfRef::swap
的实现:
void SelfRef::swap(SelfRef &other)
{
swap(d_data, other.d_data);
}
在这个实现中,交换保持了自引用的数据成员不变,仅交换了剩余的数据。类似的 swap
成员函数也可以为图9.3中显示的链表设计。
快速交换
正如我们所见,使用 placement new 可以在内存块中构造对象,每个对象的大小为 sizeof(Class)
字节。因此,相同类的两个对象各占用 sizeof(Class)
字节。如果我们的类的对象可以交换,并且类的数据成员不涉及实际交换操作的数据,那么可以实现一种基于对象大小的非常快速的交换方法。
在这种快速交换方法中,我们只需交换 sizeof(Class)
字节的内容。这种方法适用于可以通过逐成员交换操作交换的类对象,并且(尽管在实践中这可能超出了 C++ ANSI/ISO 标准所描述的允许操作,但在实践中是可行的)也可以用于具有引用数据成员的类。它简单地定义了一个 sizeof(Class)
字节的缓冲区,并执行一个循环的 memcpy
操作。以下是一个假设的 Class
类的实现,它实现了这种快速交换方法:
#include <cstring>
void Class::swap(Class &other)
{
char buffer[sizeof(Class)];
memcpy(buffer, &other, sizeof(Class));
memcpy(static_cast<void *>(&other), this, sizeof(Class));
memcpy(static_cast<void *>(this), buffer, sizeof(Class));
}
memcpy
的目标地址的 static_cast
用于防止编译器的警告:由于 Class
是一个类类型,编译器(正确地)会警告直接复制字节的操作。但是,如果你是 Class
的开发者,并且知道自己在做什么,使用 memcpy
是合适的。
以下是一个定义了引用数据成员并提供了上述实现的 swap
成员的类的简单示例。引用数据成员初始化为外部流。运行程序后,one
文件中包含两行 “hello to 1”,two
文件中包含两行 “hello to 2”:
#include <fstream>
#include <cstring>
class Reference
{
std::ostream& d_out;
public:
Reference(std::ostream &out)
: d_out(out)
{}
void swap(Reference &other)
{
char buffer[sizeof(Reference)];
memcpy(buffer, this, sizeof(Reference));
memcpy(static_cast<void *>(this), &other, sizeof(Reference));
memcpy(static_cast<void *>(&other), buffer, sizeof(Reference));
}
std::ostream &out()
{
return d_out;
}
};
int main() {
std::ofstream one{"one"}; // ref1/ref2 持有对流的引用
std::ofstream two{"two"}; // ref1/ref2 持有对流的引用
Reference ref1{one};
Reference ref2{two};
ref1.out() << "hello to 1\n"; // 生成一些输出
ref2.out() << "hello to 2\n";
ref1.swap(ref2);
ref2.out() << "hello to 1\n"; // 更多输出
ref1.out() << "hello to 2\n";
}
快速交换应仅用于自定义类,并且必须证明快速交换不会在交换时损坏对象。
移动数据
传统上,C++ 提供了两种方法来将临时对象的数据成员所指向的信息赋值给左值对象:要么使用拷贝构造函数,要么使用引用计数。除了这两种方法之外,C++ 现在还支持移动语义,允许将临时对象指向的数据转移到目标对象。
移动信息基于匿名(临时)数据的概念。临时值由像 operator-()
和 operator+(Type const &lhs, Type const &rhs)
这样的函数返回,通常是通过返回值而不是返回引用或指针的方式。
匿名值总是短暂的。当返回的值是原始类型(如 int
、double
等)时,不会发生特别的事情,但如果返回的是类类型对象,则在产生该值的函数调用之后,其析构函数可能会被立即调用。在任何情况下,值本身在调用之后会立即变得不可访问。当然,临时返回值可能会绑定到引用(左值或右值),但就编译器而言,值现在有了名字,这本身就结束了其临时值的状态。
本节重点讨论匿名临时值,并展示如何利用它们来提高对象构造和赋值的效率。这些特殊的构造和赋值方法被称为移动构造和移动赋值。支持移动操作的类称为移动感知类(move-aware)。
通常,分配自己内存的类在成为移动感知类时会受益。但类不必使用动态内存分配才能从移动操作中受益。大多数使用组合的类(或基类使用组合的继承类)也可以从移动操作中受益。
对于类 Class
的可移动参数,其形式为 Class &&tmp
。该参数是一个右值引用,右值引用仅绑定到匿名临时值。编译器必须尽可能调用提供可移动参数的函数。当类定义了支持 Class &&
参数的函数,并且传递了匿名临时值作为这些函数的参数时,会发生这种情况。一旦临时值有了名字(这在定义 Class const &
或 Class &&tmp
参数的函数内部已经发生,因为在这些函数内部这些参数的名称是可用的),它就不再是匿名临时值,在这些函数内部,当使用这些参数作为参数时,编译器不再调用期望匿名临时值的函数。
以下示例(为了简洁,使用了内联成员实现)演示了将非 const 对象、临时对象和 const 对象传递给为这些参数定义的函数 fun
时发生的情况。每个函数都调用了为这些参数定义的函数 gun
。第一次调用 fun
时,它(如预期)调用了 gun(Class &)
。然后 fun(Class &&)
被调用,因为其参数是匿名(临时)对象。然而,在 fun
内部,匿名值已经有了名字,因此不再是匿名值。结果,再次调用了 gun(Class &)
。最后,调用 fun(Class const &)
,并且(如预期)现在调用了 gun(Class const &)
。
#include <iostream>
using namespace std;
class Class
{
public:
Class() {}
void fun(Class const &other)
{
cout << "fun: Class const &\n";
gun(other);
}
void fun(Class &other)
{
cout << "fun: Class &\n";
gun(other);
}
void fun(Class &&tmp)
{
cout << "fun: Class &&\n";
gun(tmp);
}
void gun(Class const &other)
{
cout << "gun: Class const &\n";
}
void gun(Class &other)
{
cout << "gun: Class &\n";
}
void gun(Class &&tmp)
{
cout << "gun: Class &&\n";
}
};
int main() {
Class c1;
c1.fun(c1); // 输出: fun: Class & -> gun: Class &
c1.fun(Class()); // 输出: fun: Class && -> gun: Class &&
Class const c0;
c1.fun(c0); // 输出: fun: Class const & -> gun: Class const &
}
通常,定义一个具有右值引用返回类型的函数是没有意义的。编译器会根据提供的参数决定是否使用期望右值引用的重载成员。如果参数是匿名临时值,且有定义右值引用参数的函数,编译器会调用这个函数。右值引用返回类型用于例如 std::move
调用,以保持其参数的右值引用性质,已知参数是一个临时匿名对象。这种情况也可以利用在临时对象被传递到(并从中返回)一个必须能够修改临时对象的函数中。作为替代,传递 const &
的方式不那么吸引人,因为它需要在对象可以被修改之前执行 const_cast
。
以下是一个示例:
std::string &&doubleString(std::string &&tmp)
{
tmp += tmp;
return std::move(tmp);
}
这允许我们执行类似如下的操作:
std::cout << doubleString("hello "s);
这会将 "hello hello"
插入到 cout
中。
编译器在选择要调用的函数时,应用了一个相当简单的算法,还考虑了拷贝消除(copy elision)。这一点将在不久后介绍(第9.8节)。
移动构造函数(动态数据)
我们的 Strings
类有一个数据成员 string *d_string
。显然,Strings
类应该定义一个复制构造函数、一个析构函数和一个重载的赋值操作符。
现在考虑以下函数 loadStrings(std::istream &in)
从 in
中提取字符串并填充 Strings
对象。接下来,loadStrings
返回的 Strings
对象是通过值返回的。函数 loadStrings
返回一个临时对象,该临时对象可以用来初始化一个外部的 Strings
对象:
Strings loadStrings(std::istream &in)
{
Strings ret;
// 将字符串加载到 'ret' 中
return ret;
}
// 使用示例:
Strings store(loadStrings(cin));
在这个示例中,Strings
对象需要进行两次完整的复制:
- 将
loadString
的返回值类型初始化为其本地的Strings ret
对象。 - 将
store
从loadString
的返回值进行初始化。
通过定义一个移动构造函数,我们可以改进上述过程。以下是 Strings
类移动构造函数的声明:
Strings(Strings &&tmp);
使用动态内存分配的类的移动构造函数可以将源对象的指针数据成员的值分配给自身的指针数据成员,而不需要复制源的数据。接下来,将临时对象的指针值设置为零,以防止其析构函数销毁现在由刚构造的对象拥有的数据。移动构造函数已经从临时对象“抓取”或“偷取”了数据。因为临时对象不能再被引用(由于它是匿名的,无法被其他代码访问),我们可以假设临时对象在移动构造函数调用后很快会消失。
以下是 Strings
移动构造函数的实现:
Strings::Strings(Strings &&tmp)
: d_string(tmp.d_string),
d_size(tmp.d_size),
d_capacity(tmp.d_capacity)
{
tmp.d_string = nullptr;
tmp.d_capacity = 0;
tmp.d_size = 0;
}
移动构造(一般来说,移动操作)必须使源对象在信息被移动后处于有效状态。尽管没有规定这种有效状态必须以什么方式实现,但一个好的经验法则是将对象返回到其默认构造状态。在上述移动构造函数的示例中,tmp.d_size
、tmp.d_capacity
和 tmp.d_string
都被设置为零。是否可以将 Strings
的成员全部设置为零取决于 Strings
类的作者。如果有一个默认构造函数正好做到了这一点,那么将值设为零是合适的。如果当 d_size == d_capacity
时,d_capacity
翻倍,设置 d_capacity
为 1 并让 d_string
指向新分配的内存块大小可能是有吸引力的。源对象的容量甚至可以保持不变。例如,在移动 std::string
对象时,源对象的容量不会改变。
在第 9.5 节中提到,复制构造函数几乎总是可用的。之所以“几乎总是”,是因为声明一个移动构造函数会抑制默认复制构造函数的可用性。如果声明了移动赋值操作符(参见第 9.7.3 节),默认复制构造函数也会被抑制。
以下示例展示了一个简单的 Class
类,声明了一个移动构造函数。在 main
函数中,定义了一个 Class
对象,然后将其传递给第二个 Class
对象的构造函数。编译会失败,编译器报告错误:error: cannot bind 'Class' lvalue to 'Class&&'
:
class Class
{
public:
Class() = default;
Class(Class &&tmp)
{}
};
int main()
{
Class one;
Class two{ one };
}
解决方法很简单:在声明(可能是默认)复制构造函数后,错误消失:
class Class
{
public:
Class() = default;
Class(Class const &other) = default;
Class(Class &&tmp)
{}
};
int main()
{
Class one;
Class two{ one };
}
移动构造函数(组合)
即使类的成员没有指向由其对象控制的内存(并且没有基类这样做,参见第13章),也可能从重载成员函数中期望右值引用中受益。当一个或多个组成数据成员本身支持移动操作时,类可以受益于移动操作。
如果组成的数据成员的类类型不支持移动或复制,则无法实现移动操作。目前,流类(如 std::istream
和 std::ostream
)就属于这种情况。
一个支持移动的类的示例是 std::string
。假设有一个 Person
类,使用组合方式定义了 std::string d_name
和 std::string d_address
。它的移动构造函数的原型将是:
Person(Person &&tmp);
然而,以下的移动构造函数实现是错误的:
Person::Person(Person &&tmp)
: d_name(tmp.d_name), d_address(tmp.d_address)
{}
这是不正确的,因为 std::string
的复制构造函数而不是移动构造函数被调用了。如果你想知道为什么会发生这种情况,请记住,移动操作仅对匿名对象进行。对编译器来说,任何具有名称的对象都不是匿名的。因此,提供右值引用并不意味着我们在处理匿名对象。但是我们知道移动构造函数只对匿名参数调用。为了使用相应的字符串移动操作,我们需要告诉编译器我们也在处理匿名数据成员。为此,可以使用类型转换(例如 static_cast<Person &&>(tmp)
),但 C+±0x 标准提供了 std::move
函数来将命名对象匿名化。因此,Person
的移动构造函数的正确实现是:
Person::Person(Person &&tmp)
: d_name(std::move(tmp.d_name)),
d_address(std::move(tmp.d_address))
{}
std::move
函数(间接地)由许多头文件声明。如果没有头文件已经声明了 std::move
,请包含 <utility>
头文件。
当一个使用组合的类不仅包含类类型数据成员,还包含其他类型的数据(指针、引用、原始数据类型)时,这些其他数据类型可以像往常一样初始化。原始数据类型成员可以简单地复制;引用可以按照正常方式初始化;指针可以使用前面讨论的移动操作。
编译器从不对具有名称的变量调用移动操作。让我们通过以下示例来考虑这种情况的影响,假设 Class
类提供了移动构造函数和复制构造函数:
Class factory();
void fun(Class const &other);
void fun(Class &&tmp);
void callee(Class &&tmp) { fun(tmp); }
int main() {
callee(factory());
}
- 在
callee(factory())
中,首先调用了fun(Class &&)
。乍一看,这可能会令人惊讶,但fun
的参数不是匿名临时对象,而是一个命名的临时对象。了解到fun(tmp)
可能被调用两次,编译器的选择是可以理解的。如果在第一次调用时tmp
的数据被抓取,那么第二次调用将会收到没有数据的tmp
。但在最后一次调用中,我们可能知道tmp
不会再被使用,因此我们可能希望确保调用fun(Class &&)
。为此,再次使用std::move
:
fun(std::move(tmp)); // 最后一次调用!
移动赋值操作符
除了重载的赋值操作符外,对于支持移动操作的类,还可以实现移动赋值操作符。在这种情况下,如果类支持交换操作,移动赋值操作符的实现非常简单。不需要复制构造函数,移动赋值操作符可以像这样实现:
Class &operator=(Class &&tmp)
{
swap(tmp);
return *this;
}
如果不支持交换操作,则可以逐个对每个数据成员执行赋值操作,使用 std::move
,就像在前一节中对 Person
类所示的那样。以下是如何在 Person
类中实现移动赋值操作符的示例:
Person &operator=(Person &&tmp)
{
d_name = std::move(tmp.d_name);
d_address = std::move(tmp.d_address);
return *this;
}
如前所述(第9.7.1节),声明移动赋值操作符会抑制默认的复制构造函数。如果需要重新提供复制构造函数,可以在类的接口中声明它(当然,也可以提供显式实现或使用 = default
来使用默认实现)。
修订赋值操作符(第二部分)
现在我们已经熟悉了重载的赋值操作符和移动赋值操作符,让我们再次查看它们在支持通过交换成员进行交换的 Class
类中的实现。以下是重载赋值操作符的通用实现:
Class &operator=(Class const &other)
{
Class tmp{ other };
swap(tmp);
return *this;
}
这是移动赋值操作符的实现:
Class &operator=(Class &&tmp) {
swap(tmp);
return *this;
}
它们在实现上非常相似,因为重载的赋值操作符的代码实际上与移动赋值操作符的代码是相同的,只要有一个 other
对象的副本。
由于重载的赋值操作符中的 tmp
对象实际上只是一个临时的 Class
对象,我们可以利用这一点,通过将重载的赋值操作符实现为移动赋值操作符来简化实现。以下是重载赋值操作符的第二次修订:
Class &operator=(Class const &other) {
Class tmp{other};
return *this = std::move(tmp);
}
这样,重载的赋值操作符通过调用移动赋值操作符来实现,从而减少了重复代码。
移动和析构函数
一旦一个类成为支持移动的类,需要注意的是,它的析构函数仍会按实现的方式执行。因此,当从临时源对象移动指针值到目标对象时,移动构造函数通常会确保临时对象的指针值被设置为零,以防止双重释放内存。如果一个类定义了指向指针的数据成员,通常不仅有一个指针被移动,还有一个 size_t
定义指针数组中的元素数量。
再考虑一下 Strings
类。它的析构函数实现如下:
Strings::~Strings() {
for (string **end = d_string + d_size; end-- != d_string;) delete *end;
delete[] d_string;
}
移动构造函数(以及其他移动操作)必须意识到析构函数不仅删除 d_string
,还考虑 d_size
。当 d_size
和 d_string
被设置为 0 时,析构函数(正确地)不会删除任何东西。此外,当类使用容量加倍(即 d_size
等于 d_capacity
)时,移动构造函数仍然可以将源对象的 d_capacity
重置为 0,因为知道临时对象在移动赋值后将不再存在:
Strings::Strings(Strings &&tmp)
: d_string(tmp.d_string),
d_size(tmp.d_size),
d_capacity(tmp.d_capacity) {
tmp.d_string = 0;
tmp.d_capacity = 0;
tmp.d_size = 0;
}
还有其他变体也是可能的。关键点是:移动构造函数必须确保在目标对象抓取源对象的数据后,源对象仍保持在一个有效的状态。这通常通过将数据成员设置为与默认构造函数相同的值来轻松实现。
仅支持移动的类
类可以非常好地支持移动语义,而不提供拷贝语义。大多数流类属于这一类别。扩展它们的定义以支持移动语义可以大大增强它们的可用性。一旦这些类支持移动语义,就可以轻松实现所谓的工厂函数(返回由函数构造的对象的函数)。例如:
// 假设 char *filename
ifstream inStream(openIstream(filename));
要使此示例有效,ifstream
构造函数必须提供一个移动构造函数。这确保了只有一个对象引用打开的 istream
。
一旦类提供了移动语义,它们的对象也可以安全地存储在标准容器中(参见第12章)。当这些容器执行重新分配(例如,当它们的大小被扩大时)时,它们会使用对象的移动构造函数而不是拷贝构造函数。由于仅支持移动的类抑制了拷贝语义,存储仅支持移动的类的容器在实现上是正确的,因为这些容器之间不能进行赋值操作。
默认的移动构造函数和移动赋值操作符
正如我们所见,类默认提供了拷贝构造函数和赋值操作符。这些类成员的实现旨在提供基本支持:原始数据类型的成员逐字节复制,而对于类类型的数据成员,则调用其相应的拷贝构造函数或赋值操作符。编译器也尝试为移动构造函数和移动赋值操作符提供默认实现。然而,默认构造函数和赋值操作符并不总是可以提供。
以下是编译器决定提供或不提供默认移动构造函数和移动赋值操作符的规则:
- 如果声明了拷贝构造函数、拷贝赋值操作符或析构函数(即使使用
= default
声明),则默认的移动构造函数和移动赋值操作符将被抑制;它们的使用将被相应的拷贝操作(构造函数或赋值操作符)替代。 - 如果声明了移动构造函数或移动赋值操作符,则拷贝构造函数和拷贝赋值操作符将隐式声明为已删除,因此不能再使用。
- 如果声明了移动构造函数或移动赋值操作符,则(除了抑制拷贝操作外)默认实现的另一个移动成员也会被抑制。
- 在所有其他情况下,默认的拷贝和移动构造函数以及默认的拷贝和赋值操作符都会被提供。如果默认实现的拷贝或移动构造函数或赋值操作符被抑制,但应该可用,则可以通过指定所需的签名来轻松提供默认实现,并添加
= default
进行声明。
以下是一个提供所有默认实现的类的示例:构造函数、拷贝构造函数、移动构造函数、拷贝赋值操作符和移动赋值操作符:
class Defaults
{
int d_x;
Mov d_mov;
};
假设 Mov
是一个提供移动操作的类(除了标准拷贝操作),以下操作将在目标对象的 d_mov
和 d_x
上执行:
Defaults factory();
int main() {
Defaults one; // 默认构造函数
Defaults two(one); // 拷贝构造函数,one.d_x
Defaults three(factory()); // 移动构造函数,tmp.d_x
one = two; // 移动赋值操作符,two.d_x
one = factory(); // 移动赋值操作符,tmp.d_x
}
如果 Defaults
声明了至少一个构造函数(不是拷贝构造函数或移动构造函数)以及拷贝赋值操作符,则只有默认的拷贝构造函数和声明的赋值操作符可用。例如:
class Defaults
{
int d_x;
Mov d_mov;
public:
Defaults(int x);
Defaults &operator=(Defaults const &rhs);
};
在这种情况下:
Defaults factory();
int main() {
Defaults one; // 错误:不可用
Defaults two(one); // 拷贝构造函数,one.d_x
Defaults three(factory()); // 拷贝构造函数,one.d_x
one = two; // 拷贝赋值操作符,two.d_x
one = factory(); // 拷贝赋值操作符,tmp.d_x
}
要重新启用默认实现,请在相应的声明中附加 = default
:
class Defaults {
int d_x;
Mov d_mov;
public:
Defaults() = default;
Defaults(int x);
// 默认拷贝构造函数仍然可用(默认情况下)
Defaults(Defaults &&tmp) = default;
Defaults &operator=(Defaults const &rhs);
Defaults &operator=(Defaults &&tmp) = default;
};
在声明默认实现时要小心,因为默认实现会逐字节复制原始数据类型的成员。这可能会导致指针类型数据成员的问题。= default
后缀只能用于在类的公共部分声明构造函数或赋值操作符。
移动操作对类设计的影响
在设计提供值语义(即可以用对象初始化其他对象并可以赋值给其他对象的类)时,以下是一些通用规则:
- 使用指向动态分配内存的指针的类:这些类的对象拥有该内存,因此必须提供拷贝构造函数、重载的拷贝赋值操作符和析构函数。
- 使用指向动态分配内存的指针的类:这些类也应提供移动构造函数和移动赋值操作符。
- 使用组合的类:这些类也可能从移动构造函数和移动赋值操作符中受益。一些类既不支持移动也不支持拷贝构造和赋值(例如,流类就是这样)。如果你的类包含此类数据成员,那么定义移动操作是没有意义的。
在之前的部分中,我们还遇到了一个可以应用于移动感知类的重要设计原则:当一个类的成员函数接受该类自身对象的 const &
参数并创建该对象的副本以执行实际操作时,该函数的实现可以通过期望 rvalue
引用的重载函数来实现。
前面的函数现在可以通过将 std::move(tmp)
传递给后者来调用后者。这种设计原则的优点应该很明显:实际操作只有一个实现,并且类自动变成了对涉及函数的移动感知。
我们在第 9.7.4 节中看到的初步示例展示了这一原则的应用。当然,这一原则不能应用于拷贝构造函数本身,因为你需要拷贝构造函数来创建副本。拷贝构造函数和移动构造函数必须始终独立实现。
拷贝消除与返回值优化
当编译器选择成员函数(或构造函数)时,它会应用一组简单的规则,将实参与形参类型匹配。
下面展示了两个表格。第一个表格用于函数参数有名称的情况,第二个表格用于参数是匿名的情况。在每个表格中,选择 const
或非 const
列,然后使用最上面的重载函数,该函数具有指定的参数类型。
这些表格不处理定义值参数的函数。如果一个函数有重载版本,分别期望值参数和某种形式的引用参数,那么在调用这样的函数时,编译器会报告模糊性。在以下选择过程中,我们可以假设,这种模糊性不会发生,所有参数类型都是引用参数。
参数类型匹配的规则
-
有名称的参数(左值或命名右值)
示例:对于
int x
参数,选择函数fun(int &)
而不是函数fun(int const &)
。如果fun(int &)
不可用,则使用fun(int const &)
。如果两者都不可用(且fun(int)
没有定义),编译器会报告错误。 -
匿名参数(匿名临时对象或字面值)
示例:当函数
int arg()
的返回值传递给函数fun
,并且有多个重载版本时,选择fun(int &&)
。如果这个函数不可用但fun(int const &)
是可用的,则使用后者。如果这两个函数都不可用,编译器会报告错误。
表格显示,最终所有参数都可以与定义为 T const &
参数的函数一起使用。对于匿名参数,类似的匹配规则具有更高的优先级:T const &&
匹配所有匿名参数。通常不会定义具有这种签名的函数,因为它们的实现(应该)与期望 T const &
参数的函数相同。由于临时对象显然不能被修改,定义 T const &&
参数的函数只能复制临时对象的资源。由于这项任务已经由期望 T const &
的函数完成,因此不需要实现期望 T const &&
参数的函数,且这样做被认为是不良风格。
如我们所见,移动构造函数从临时对象中获取信息供自己使用。这是可以的,因为临时对象在那之后会被销毁。这也意味着临时对象的数据成员会被修改。
即使定义了适当的拷贝和/或移动构造函数,编译器可能会选择避免拷贝或移动操作。这是因为不进行拷贝或移动比进行拷贝或移动更高效。
编译器避免拷贝(或执行移动操作)的选项称为拷贝消除或返回值优化。在所有适用的拷贝或移动构造函数的情况下,编译器可能会应用拷贝消除。以下是编译器考虑的规则,按顺序进行,直到可以选择一个选项:
- 如果存在拷贝或移动构造函数,尝试拷贝消除。
- 如果存在移动构造函数,则进行移动。
- 如果存在拷贝构造函数,则进行拷贝。
- 报告错误。
所有现代编译器都应用拷贝消除。以下是一些可能遇到的示例:
class Elide;
Elide fun() // 1
{
Elide ret;
return ret;
}
void gun(Elide par);
Elide elide(fun()); // 2
gun(fun()); // 3
- 在 1 中,
ret
可能永远不存在。编译器可以直接使用包含fun
返回值的区域,而不是使用ret
并最终将ret
复制到fun
的返回值中。 - 在 2 中,
fun
的返回值可能永远不存在。编译器可以直接在elide
中创建fun
的返回值,而不是定义一个包含fun
返回值的区域并将返回值复制到elide
中。 - 在 3 中,编译器可能会对
gun
的par
参数进行同样的优化:fun
的返回值直接在par
的区域中创建,从而消除了从fun
返回值到par
的拷贝操作。
无限制的联合体
标准(C 类型)联合体只能包含基本类型的字段,如 int
、double
和指针。C++ 扩展了 C 类型联合体的概念,提供了无限制联合体(unrestricted unions)。
无限制联合体还允许具有非平凡构造函数的数据字段。这些数据字段通常是类类型。下面是一个无限制联合体的示例:
union Union
{
int u_int;
std::string u_string;
};
在这个例子中,其中一个字段定义为 std::string
(具有构造函数),使得这个联合体成为无限制联合体。由于无限制联合体定义了至少一个具有构造函数的类型字段,问题变成了如何构造和销毁这些联合体。
例如,联合体中包含 std::string
和 int
时,它的析构函数当然不应该在联合体最后(或唯一)使用 int
字段时调用 std::string
的析构函数。类似地,当使用 std::string
字段时,若处理从 std::string
切换到 int
字段,则应该在进行任何赋值给 int
字段之前调用 std::string
的析构函数。
编译器不会为我们解决这个问题,实际上也不会为无限制联合体实现默认构造函数或析构函数。如果我们尝试定义一个像上面那样的无限制联合体,会出现错误信息。例如:
error: use of deleted function 'Union::Union()'
error: 'Union::Union()' is implicitly deleted because the default
definition would be ill-formed:
error: union member 'Union::u_string' with non-trivial
'std::basic_string<...>::basic_string() ...'
这意味着,由于 std::string
类型的构造函数是非平凡的,默认的构造函数不能被生成,因此编译器会给出错误提示。
实现析构函数
虽然编译器不会为无限制联合体提供(默认)构造函数和析构函数的实现,但我们可以(且必须)自己实现。这项任务并不复杂,但有一些注意事项。
考虑无限制联合体的析构函数。它显然应该在 u_string
是当前活动字段时销毁 u_string
的数据;如果 u_int
是当前活动字段,则不做任何操作。但是,析构函数如何知道要销毁哪个字段?它不知道,因为无限制联合体本身不包含有关当前活动字段的信息。
为了解决这个问题,可以将无限制联合体嵌入到一个更大的聚合体(如类或结构体)中,使其成为常规的数据成员。我们仍然将无限制联合体视为一种数据类型,但其使用需要小心。周围的类提供一个 d_field
数据成员来跟踪当前活动的联合体字段。d_field
的值是由联合体定义的枚举值。无限制联合体的实际使用完全由聚合体控制,这样聚合体的用户无需管理无限制联合体相关的任何信息。
使用这种设计时,我们从显式和空的析构函数实现开始,因为析构函数本身没有办法知道要销毁哪个字段:
Data::Union::~Union() {}
然而,无限制联合体必须正确地销毁其类类型的字段。由于无限制联合体本身并不知道其活动字段是什么,因此它必须通过其周围的类来获取这一信息。为了简化对其他类型的推广,我们使用一个指向销毁当前字段值的函数的静态指针数组。这个数组在联合体的私有部分定义为:
static void (Union::*s_destroy[])();
并且初始化如下:
void (Union::*Union::s_destroy[])() =
{
&Union::destroyText,
&Union::destroyValue
};
基本数据类型在作用域结束时通常不需要特别关注,因此 destroyValue
可以定义为空函数:
void Union::destroyValue() {}
另一方面,destroyText
成员必须显式调用 u_text
的析构函数:
void Union::destroyText()
{
u_text.std::string::~string();
}
现在,通过一个简单的函数 void destroy(Field field)
来实现正确的销毁,该函数只需调用适当的函数即可:
void Union::destroy(Field type)
{
(this->*s_destroy[type])();
}
由于无限制联合体被定义为周围类的一个数据成员,周围类的析构函数负责正确地销毁其无限制联合体。由于周围类跟踪当前活动的无限制联合体字段,其实现非常简单:
Data::~Data()
{
d_union.destroy(d_field);
}
将无限制联合体嵌入到周围类中
无限制联合体被作为数据成员嵌入到周围的聚合体(例如,Data
类)中。Data
类提供一个数据成员 Union::Field d_field
,并且 Data
的用户可能会通过访问器字段来查询当前活动的字段:
class Data {
Union::Field d_field;
Union d_union;
public:
Data(int value = 0);
Data(Data const &other);
Data(Data &&tmp);
Data(std::string const &text);
~Data();
// 空体
Union::Field field() const;
// ...
};
Data
的构造函数接收 int
或 string
值。为了将这些值传递给 d_union
,我们需要为联合体的各种字段定义 Union
构造函数。
无限制联合体的定义如下:
union Union {
enum Field { TEXT, VALUE };
private:
std::string u_text;
int u_value;
public:
Union(Union const &other) = delete;
~Union();
// 空体
Union(int value);
Union(std::string const &text);
Union(Union const &other, Field type);
Union(Union &&tmp, Field type);
// ...
};
最后两个 Union
构造函数类似于标准的拷贝构造函数和移动构造函数。然而,对于无限制联合体,需要指定现有联合体的实际类型,以便初始化正确的字段。为了简化对其他类型的推广,我们使用了与销毁无限制联合体类似的方法:定义一个指向拷贝函数的静态指针数组。这个数组在联合体的私有部分声明为:
static void (Union::*s_copy[])(Union const &other);
并且初始化为:
void (Union::*Union::s_copy[])(Union const &other) =
{
&Union::copyText,
&Union::copyValue
};
copyText
和 copyValue
成员负责拷贝其他对象的字段数据。然而,有一个小问题。虽然基本类型可以直接赋值,但类类型字段不能直接赋值。由于要初始化的字段依赖于传递给构造函数的 Field
类型,因此初始化必须在构造函数体内进行。在这时,数据字段只是未初始化的字节,因此使用了定位 new
来拷贝构造类类型字段。以下是拷贝函数的实现:
void Union::copyValue(Union const &other)
{
u_value = other.u_value;
}
void Union::copyText(Union const &other)
{
new(&u_text) std::string{ other.u_text };
}
在实现联合体的移动构造函数时,还需要考虑其他因素。由于我们可以随意操作移动构造函数的 Union &&tmp
对象,因此可以简单地抓取它的当前字段,并将 VALUE
类型的值存储到 tmp
中。为此,我们使用联合体的交换功能、当前对象的字段、另一个 Union
对象和其他 Union
的字段类型(交换将在下一节讨论)。当然,如果没有基本类型字段,这种方法将不起作用。在这种情况下,必须使用特定于字段的移动函数,这些函数类似于在拷贝构造 Union
对象时使用的函数。
现在我们为构造函数实现代码:
Union::Union(std::string const &text)
: u_text(text)
{}
Union::Union(int value)
: u_value(value)
{}
Union::Union(Union &&tmp, Field type) {
swap(VALUE, tmp, type);
}
Union::Union(Union const &other, Field type) {
(this->*s_copy[type])(other);
}
Data
类的构造函数实现如下:
Data::Data(Data const &other)
: d_field(other.d_field), d_union(other.d_union, d_field) {}
Data::Data(int value)
: d_field(Union::VALUE), d_union(value) {}
Data::Data(std::string const &text)
: d_field(Union::TEXT), d_union(text) {}
这里的实现包括了 Union
和 Data
类的构造函数,并且根据字段类型选择正确的构造函数和拷贝或移动操作。
交换不受限制的联合体
不受限制的联合体应定义一个不抛出异常的交换成员函数。它需要三个参数:当前对象的字段、另一个联合体对象以及该联合体的字段。因此,我们的不受限制的联合体的交换成员函数的原型是:
void swap(Field current, Union &other, Field next);
实现时,类似于拷贝构造函数,考虑到一个不受限制的联合体具有 ( k ) 个字段,它必须支持 ( k \times k ) 种不同的交换情况。在 ( k \times k ) 矩阵中,我们注意到对角线元素表示交换相同的元素,对于这些情况,没有特殊的考虑(假设交换相同的数据类型是支持的)。下三角元素与其转置的上三角元素相同,因此可以在还原当前和其他联合体对象后使用这些元素。所有特定字段的交换函数可以组织成一个 ( k \times k ) 的静态指针矩阵。对于 Union
,该矩阵的声明是:
static void (Union::*s_swap[][2])(Union &other);
其定义为:
void (Union::*Union::s_swap[][2])(Union &other) =
{
{ &Union::swap2Text, &Union::swapTextValue },
{ &Union::swapValueText, &Union::swap2Value },
};
对角线和下三角元素的实现相对直接。例如:
void Union::swap2Text(Union &other) {
u_text.swap(other.u_text);
}
void Union::swapValueText(Union &other) {
other.swapTextValue(*this);
}
但实现上三角元素需要一些思考。为了安装类类型字段,再次需要使用定位 new 操作。但这次我们不是复制而是移动,因为当前对象将失去其内容。像交换一样,移动应该总是成功。完成移动构造后,另一个对象已经接收了当前对象的数据。由于当前对象在移动后保持有效状态,因此它也必须显式地销毁,以正确结束其生命周期。
swapTextValue
的实现如下:
void Union::swapTextValue(Union &other) {
int value = other.u_value; // 保存 int 值
// 在 other 上安装 string
new (&other.u_text) string{ std::move(u_text) };
u_text.~string(); // 销毁原来的 string
// 清除旧字段的联合体
u_value = value;
// 存储当前的新值
}
当一个不受限制的联合体具有多个类类型字段时,交换时必须对两个不受限制的联合体应用移动构造。这需要一个临时变量。假设一个不受限制的联合体支持 This
和 That
字段,那么使用这两个字段交换不受限制的联合体的方法如下:
void ThisThat::swapThisThat(ThisThat &other) {
This tmp{ std::move(u_this) }; // 保存当前对象
u_this.~This(); // 正确销毁它
// 在当前对象上安装其他对象
new (&u_that) That{ std::move(other.u_that) };
other.u_that.~That(); // 正确销毁其他对象
// 在其他对象上安装当前对象的原始值
new (&other.u_this) This{ std::move(tmp) };
// tmp 自动销毁
}
现在不受限制的联合体可以被交换,它们的交换成员函数可以被周围类的交换成员函数使用。例如:
void Data::swap(Data &other) {
d_union.swap(d_field, other.d_union, other.d_field);
Union::Field field = d_field; // 交换字段
d_field = other.d_field;
other.d_field = field;
}
赋值操作
有两种方法可以将一个 Data
对象赋值给另一个对象:复制赋值和移动赋值。它们的实现是标准的:
Data &Data::operator=(Data const &other) { // 复制赋值
Data tmp{other};
swap(tmp);
return *this;
}
Data &Data::operator=(Data &&tmp) { // 移动赋值
swap(tmp);
return *this;
}
由于已经定义了 swap
函数,赋值操作符无需进一步关注:它们使用标准实现进行实现。
当不受限制的联合体在周围类之外使用时,可能会出现两个不受限制的联合体直接赋值的情况。在这种情况下,联合体的活动字段必须以某种方式可用。由于 operator=
只能定义一个参数,因此简单地将不受限制的联合体作为右值传递将缺乏有关左值和右值活动字段的信息。因此,建议使用两个成员函数:copy
用于复制赋值,move
用于移动赋值。它们的实现类似于标准赋值操作符:
void Union::copy(Field type, Union const &other, Field next) {
Union tmp{other, next}; // 创建副本
swap(type, tmp, next); // 交换 *this 和 tmp
tmp.destroy(type); // 销毁 tmp
}
void Union::move(Field type, Union &&tmp, Field next) {
swap(type, tmp, next);
}
在源代码分发目录 yo/memory/examples/unions
中,你可以找到一个小型演示程序,其中使用了 Union
和 Data
。
聚合数据类型
C++ 从 C 中继承了 struct
概念,并将其扩展为 class
概念。struct
在 C++ 中仍然被广泛使用,主要用于存储和传递不同数据类型的聚合体。一个常用的术语是聚合体(在一些语言中称为普通数据(POD))。聚合体通常用于 C++ 程序中,将数据组合在专用的(struct
)类型中。例如,当一个函数必须返回一个 double
、一个 bool
和一个 std::string
时,这三种不同的数据类型可以使用一个 struct
进行聚合,struct
只是为了传递值而存在。在这种情况下,数据保护和功能性几乎不成问题。对于这样的情况,C 和 C++ 使用 struct
。但由于 C++ 中的 struct
只是一个具有特殊访问权限的 class
,因此一些成员(构造函数、析构函数、重载赋值操作符)可能会隐式定义。聚合体利用了这一概念,要求它们的定义保持尽可能简单,具有以下特征:
- 没有用户提供的构造函数或用户提供的继承构造函数(参见第 13 章);
- 非静态数据成员具有公共访问权限;
- 没有虚成员;
- 在使用继承时,基类不是虚拟的,仅使用公共继承。
聚合体也可以是数组,在这种情况下,数组元素是聚合体的元素。如果一个聚合体是 struct
,它的直接基类是它的元素(如果有的话),然后是 struct
的数据成员,按声明顺序排列。例如:
struct Outer {
struct Inner {
int d_int;
double d_double;
};
std::string d_string;
Inner d_inner;
Outer out{"hello", {1, 12.5}};
};
Outer
的 d_string
被初始化为 "hello"
,d_inner
成员有两个数据成员:d_int
被初始化为 1,d_double
被初始化为 12.5。
(指定)初始化列表也可以使用(参见第 3.3.5 节)。此外,结构绑定声明(参见第 3.3.7.1 节)也可以用于避免显式定义一个聚合数据类型。
结论
本章介绍了对类的重要扩展:析构函数、拷贝构造函数、移动构造函数和重载赋值操作符。此外,本章还强调了交换操作的重要性,尤其是在与重载赋值操作符结合使用时。
具有指针数据成员的类,这些指针指向由类对象控制的动态分配的内存,是潜在的内存泄漏源。本章介绍的扩展实现了对这种内存泄漏的标准防御措施。
封装(数据隐藏)使我们能够确保对象的数据完整性。构造函数和析构函数的自动激活大大增强了我们确保对象数据完整性的能力。
因此,一个简单的结论是:那些对象分配了由自身控制的内存的类,必须至少实现析构函数、重载赋值操作符和拷贝构造函数。实现移动构造函数是可选的,但它允许我们使用不允许拷贝构造和/或赋值的类的工厂函数。
最终,假设至少有一个拷贝或移动构造函数,编译器可能会通过拷贝消除来避免使用它们。编译器可以在可能的情况下使用拷贝消除,但这并不是一个强制要求。编译器可能会决定不使用拷贝消除。在所有情况下,编译器可以考虑使用拷贝消除,而不是使用拷贝或移动构造函数。
异常
C 语言支持几种程序响应打破正常程序流程的情况的方法:
- 函数可以注意到异常情况并发出消息。 这是程序可能表现出的最轻微的反应。
- 函数可以决定停止其预定任务,并将错误代码返回给调用者。 这是一个很好的推迟决策的例子:现在调用函数面临问题。当然,调用函数也可以采取类似的方式,通过将错误代码传递给它的调用者。
- 函数可以决定情况失控,并调用
exit
终止程序的执行。 这是一种比较激烈的处理问题的方法,因为这样本地对象的析构函数不会被激活。 - 函数可以使用
setjmp
和longjmp
函数组合来执行非局部退出。 这种机制实现了一种跳转方式,允许程序继续在外层级别,跳过需要访问的中间级别,这些级别如果使用从嵌套函数的系列返回,将会被访问。
在 C++ 中,所有这些打破流程的方法仍然可以使用。然而,在 C++(甚至在 C)程序中,setjmp
和 longjmp
的使用并不常见,因为它们会完全扰乱程序的流动。
C++ 提供了异常作为替代方案,相较于 setjmp
和 longjmp
,异常允许 C++ 程序进行受控的非局部返回,而不会带来 longjmp
和 setjmp
的缺点。
异常是处理那些函数自身无法轻易处理但又不至于导致程序完全终止的情况的正确方式。同时,异常提供了一个灵活的控制层,介于短期返回和粗糙退出之间。
在本章中,我们将讨论异常。首先会给出一个例子,展示异常和 setjmp
/longjmp
组合对程序的不同影响。接下来将讨论异常的形式方面,介绍在面对异常时我们的软件应该能够提供的保证。异常及其保证对构造函数和析构函数有影响,我们将在本章最后讨论这些影响。
异常语法
在对比传统 C 语言处理非局部跳转的方式与 C++ 中的异常处理之前,首先介绍一下使用异常时涉及的语法元素。
-
异常是通过
throw
语句生成的。 关键字throw
后面跟着一个特定类型的表达式,该表达式的值会被抛出作为异常。在 C++ 中,任何具有值语义的对象都可以作为异常被抛出:例如int
、bool
、string
等。不过,也存在一种标准异常类型(参见第 10.8 节),可以在定义新异常类型时用作基类(参见第 13 章)。 -
异常是在一个明确定义的本地环境中生成的,称为 try 块。 运行时支持系统确保程序中的所有代码都被包裹在一个全局的 try 块中。因此,我们代码中生成的每个异常都会到达至少一个 try 块的边界。当异常到达全局 try 块的边界时,程序将终止,此时不会调用在异常生成点存在的局部和全局对象的析构函数。这种情况是不可取的,因此所有异常都应该在程序显式定义的 try 块中生成。以下是一个从 try 块中抛出的字符串异常的示例:
try { // 可以在此处定义任何代码 if (someConditionIsTrue) throw "this is the std::string exception"s; // 可以在此处定义任何代码 }
-
catch: 紧接在 try 块后面,必须定义一个或多个 catch 子句。一个 catch 子句由一个 catch 头部(定义可以捕获的异常类型)和一个复合语句(定义对捕获的异常的处理方式)组成:
catch (string const &msg) { // 处理捕获到的字符串对象的语句 }
可以在 catch 子句下方依次出现多个子句,每个子句对应一个需要捕获的异常类型。通常,catch 子句可以按任何顺序出现,但有些情况下需要特定的顺序。为了避免混淆,最好将最一般的异常的 catch 子句放在最后。至多会激活一个异常子句。C++ 不支持 Java 风格的
finally
子句,该子句在完成 catch 子句后激活。
使用异常的示例
在以下示例中,使用了相同的基本程序。该程序使用了两个类,Outer
和 Inner
。
首先,在 main
中定义一个 Outer
对象,并调用其成员函数 Outer::fun
。然后,在 Outer::fun
中定义一个 Inner
对象。定义了 Inner
对象后,调用其成员函数 Inner::fun
。就这样。函数 Outer::fun
终止时调用了 Inner
的析构函数。接着程序终止,激活 Outer
的析构函数。以下是基本程序的代码:
#include <iostream>
using namespace std;
class Inner {
public:
Inner();
~Inner();
void fun();
};
Inner::Inner() { cout << "Inner constructor\n"; }
Inner::~Inner() { cout << "Inner destructor\n"; }
void Inner::fun() { cout << "Inner fun\n"; }
class Outer {
public:
Outer();
~Outer();
void fun();
};
Outer::Outer() { cout << "Outer constructor\n"; }
Outer::~Outer() { cout << "Outer destructor\n"; }
void Outer::fun() {
Inner in;
cout << "Outer fun\n";
in.fun();
}
int main() {
Outer out;
out.fun();
}
生成的输出:
Outer constructor
Inner constructor
Outer fun
Inner fun
Inner destructor
Outer destructor
编译和运行后,程序的输出完全符合预期:析构函数按照正确的顺序调用(逆转构造函数的调用顺序)。
现在我们将注意力集中到两个变体上,其中我们模拟了 Inner::fun
函数中的一个非致命灾难事件。这个事件假设在 main
结束时需要处理。
我们考虑两个变体。第一个变体使用 setjmp
和 longjmp
处理事件;第二个变体使用 C++ 的异常机制处理事件。
时代的遗物:setjmp
和 longjmp
基本程序从上一节稍作修改,包含了一个用于 setjmp
和 longjmp
的变量 jmp_buf
。Inner::fun
函数调用 longjmp
,模拟一个灾难性事件,该事件将在 main
结束时处理。在 main
中,通过 setjmp
定义了 longjmp
的目标位置。setjmp
的零返回值表示 jmp_buf
变量的初始化,此时调用 Outer::fun
。这种情况表示“正常流程”。
程序的返回值只有在 Outer::fun
正常终止时才为零。然而,程序的设计使得这种情况不会发生:Inner::fun
调用 longjmp
。结果,执行流程返回到 setjmp
函数。在这种情况下,返回值不是零。因此,在从 Outer::fun
调用 Inner::fun
后,main
的 if
语句被执行,程序以返回值 1 终止。请在研究以下程序源代码时按照这些步骤进行,该代码是对基本程序的直接修改:
#include <iostream>
#include <setjmp.h>
#include <cstdlib>
using namespace std;
jmp_buf jmpBuf;
class Inner {
public:
Inner();
~Inner();
void fun();
};
Inner::Inner() {
cout << "Inner constructor\n";
}
void Inner::fun() {
cout << "Inner fun\n";
longjmp(jmpBuf, 0);
}
Inner::~Inner() {
cout << "Inner destructor\n";
}
class Outer {
public:
Outer();
~Outer();
void fun();
};
Outer::Outer() {
cout << "Outer constructor\n";
}
Outer::~Outer() {
cout << "Outer destructor\n";
}
void Outer::fun() {
Inner in;
cout << "Outer fun\n";
in.fun();
}
int main() {
Outer out;
if (setjmp(jmpBuf) != 0)
return 1;
out.fun();
}
生成的输出:
Outer constructor
Inner constructor
Outer fun
Inner fun
Outer destructor
这个程序的输出清楚地表明,Inner
的析构函数没有被调用。这是 longjmp
执行的非局部跳转的直接后果。处理过程直接从 Inner::fun
中的 longjmp
调用跳转到 main
中的 setjmp
。在那里,返回值不等于零,程序以返回值 1 终止。由于非局部跳转,Inner::~Inner
从未被执行:返回到 main
的 setjmp
时,现有的堆栈被直接拆除,不考虑任何待调用的析构函数。
这个例子表明,当使用 longjmp
和 setjmp
时,对象的析构函数可能会被轻易跳过,因此 C++ 程序应当避免使用这些函数。
异常:首选的替代方案
异常是 C++ 对 setjmp
和 longjmp
引发的问题的解决方案。以下是使用异常的示例。程序再次从第 10.2 节的基本程序派生而来:
#include <iostream>
using namespace std;
class Inner {
public:
Inner();
~Inner();
void fun();
};
Inner::Inner() {
cout << "Inner constructor\n";
}
Inner::~Inner() {
cout << "Inner destructor\n";
}
void Inner::fun() {
cout << "Inner fun\n";
throw 1;
cout << "This statement is not executed\n";
}
class Outer {
public:
Outer();
~Outer();
void fun();
};
Outer::Outer() {
cout << "Outer constructor\n";
}
Outer::~Outer() {
cout << "Outer destructor\n";
}
void Outer::fun() {
Inner in;
cout << "Outer fun\n";
in.fun();
}
int main() {
Outer out;
try {
out.fun();
} catch (int x) {
// Handle the exception
}
}
生成的输出:
Outer constructor
Inner constructor
Outer fun
Inner fun
Inner destructor
Outer destructor
在这个程序中,Inner::fun
现在抛出一个 int
类型的异常,而之前使用的是 longjmp
。由于 in.fun
是由 out.fun
调用的,因此异常在 out.fun
调用的 try
块内生成。由于抛出了一个 int
值,这个值会在 try
块之外的 catch
子句中重新出现。
现在 Inner::fun
通过抛出异常而不是调用 longjmp
来终止。异常在 main
中被捕获,程序终止。我们看到 inner
的析构函数被正确调用了。值得注意的是,Inner::fun
的执行确实在 throw
语句处终止:位于 throw
语句之后的 cout
语句没有被执行。
这个例子告诉我们什么?
- 异常提供了一种打破函数(和程序)正常流程的手段,无需使用一连串的返回语句,也无需使用像
exit
函数这样的粗暴工具来终止程序。 - 异常不会干扰析构函数的正确调用。由于
setjmp
和longjmp
会干扰析构函数的正确调用,因此在 C++ 中强烈不推荐使用它们。
抛出异常
异常是通过 throw
语句生成的。throw
关键字后跟一个表达式,定义了被抛出的异常值。例如:
throw "Hello world"; // 抛出一个 char * 类型的异常
throw 18; // 抛出一个 int 类型的异常
throw string{"hello"}; // 抛出一个 string 类型的异常
局部对象在函数终止时会消失。对于异常也是如此。函数中局部定义的对象在这些函数抛出异常并离开这些函数后会自动被销毁。被抛出的对象也是如此。然而,在离开函数上下文之前,对象会被复制,而最终到达适当的 catch
子句的其实是这个复制品。
以下示例展示了这一过程。Object::fun
定义了一个局部对象 toThrow
,它被抛出作为异常。异常在 main
中被捕获。但此时原始抛出的对象已经不存在,main
收到了一个副本:
#include <iostream>
#include <string>
using namespace std;
class Object {
string d_name;
public:
Object(string name)
: d_name(name) {
cout << "Constructor of " << d_name << "\n";
}
Object(Object const &other)
: d_name(other.d_name + " (copy)") {
cout << "Copy constructor for " << d_name << "\n";
}
~Object() {
cout << "Destructor of " << d_name << "\n";
}
void fun() {
Object toThrow("'local object'");
cout << "Calling fun of " << d_name << "\n";
throw toThrow;
}
void hello() {
cout << "Hello by " << d_name << "\n";
}
};
int main() {
Object out{ "'main object'" };
try {
out.fun();
} catch (Object o) {
cout << "Caught exception\n";
o.hello();
}
}
Object
的复制构造函数特殊,因为它将名称定义为另一个对象的名称,并附加字符串 " (copy)"。这使我们能够更仔细地监视对象的构造和销毁。Object::fun
生成一个异常,并抛出其局部定义的对象。在抛出异常之前,程序生成了以下输出:
Constructor of 'main object'
Constructor of 'local object'
Calling fun of 'main object'
当异常生成时,接下来的输出是:
Copy constructor for 'local object' (copy)
局部对象被传递给 throw
,它被视为值参数,创建了 toThrow
的一个副本。这个副本被抛出作为异常,而局部 toThrow
对象消失。抛出的异常现在被 catch
子句捕获,该子句定义了一个 Object
值参数。由于这是一个值参数,因此又创建了一个副本。程序输出以下文本:
Destructor of 'local object'
Copy constructor for 'local object' (copy) (copy)
catch
块现在显示:
Caught exception
随后调用了 o
的 hello
成员,显示我们确实收到了原始 toThrow
对象副本的副本:
Hello by 'local object' (copy) (copy)
然后程序终止,剩余对象被销毁,按创建顺序的相反顺序销毁:
Destructor of 'local object' (copy) (copy)
Destructor of 'local object' (copy)
Destructor of 'main object'
catch
子句中创建的副本显然是多余的。通过在 catch
子句中定义对象引用参数(如 catch (Object &o)
)可以避免这种情况。程序现在产生以下输出:
抛出异常
程序的输出如下:
Constructor of 'main object'
Constructor of 'local object'
Calling fun of 'main object'
Copy constructor for 'local object' (copy)
Destructor of 'local object'
Caught exception
Hello by 'local object' (copy)
Destructor of 'local object' (copy)
Destructor of 'main object'
只有一个 toThrow
的副本被创建。
抛出指向局部定义对象的指针是不明智的。指针被抛出,但指针所指向的对象在异常抛出后即不存在,接收者会得到一个悬空指针。这是很危险的。
总结上述发现:
- 局部对象作为副本被抛出;
- 不要抛出指向局部对象的指针;
- 可以抛出指向动态生成对象的指针。在这种情况下,必须确保异常处理程序正确删除生成的对象,以防止内存泄漏。
异常是在函数无法完成其分配的任务但程序仍然能够继续运行的情况下抛出的。想象一个提供交互式计算器的程序。程序期望输入数字表达式,并对其进行求值。表达式可能会出现语法错误,或者在数学上无法进行评估。也许计算器允许我们定义和使用变量,而用户可能引用了不存在的变量:这些都是导致表达式评估失败和抛出异常的原因。这些异常不应该终止程序。相反,程序应该告知用户问题的性质,并邀请用户输入另一个表达式。例如:
if (!parse(expressionBuffer))
// 解析失败
throw "Syntax error in expression";
if (!lookup(variableName)) // 变量未找到
throw "Variable not defined";
if (divisionByZero())
// 无法进行除法
throw "Division by zero is not defined";
这些 throw
语句的位置并不重要:它们可以在程序的深层嵌套中,或者在更表层的级别中。函数也可以用来生成抛出的异常。一个 Exception
对象可能支持类似流的插入操作,例如:
if (!lookup(variableName))
throw Exception() << "Undefined variable '" << variableName << "'";
空的 throw
语句
有时需要检查抛出的异常。异常捕获器可能会决定忽略异常、处理异常、在检查后重新抛出异常或将异常转换为另一种类型。例如,在服务器-客户端应用程序中,客户端可能通过将请求输入到队列中来提交请求。通常每个请求最终都会被服务器回应。服务器可能会回复请求已成功处理,或者发生了一些错误。另一方面,服务器可能崩溃,客户端应能够发现这种灾难,避免无限期地等待服务器回复。
在这种情况下,需要一个中间的异常处理器。抛出的异常首先在中间级别进行检查。如果可以在此处处理异常,则进行处理;如果无法在中间级别处理异常,则将其传递到更高层次的处理程序中,以处理真正棘手的异常。
在异常处理程序的代码中放置一个空的 throw
语句,可以将收到的异常传递到可能能够处理该特定类型异常的下一个级别。重新抛出的异常不会被相邻的异常处理程序处理,而是始终传递给更高层次的异常处理程序。
在我们的服务器-客户端情况中,可以设计一个函数 initialExceptionHandler(string &exception)
来处理字符串异常。收到的消息被检查。如果是简单消息,则处理;否则,将异常传递给外层级别。在 initialExceptionHandler
的实现中,使用了空的 throw
语句:
void initialExceptionHandler(string &exception) {
if (!plainMessage(exception)) throw;
handleTheMessage(exception);
}
下面的例子稍微提前了一些,使用了第14章中的一些主题。尽管如此,可以跳过这个例子,而不会丧失连续性。
可以从一个基本的异常处理类构建出特定异常类型的派生类。假设我们有一个 Exception
类,具有一个成员函数 ExceptionType Exception::severity
。这个成员函数告诉我们(毫不奇怪!)抛出的异常的严重性。它可能是 Info
、Notice
、Warning
、Error
或 Fatal
。异常中包含的信息取决于其严重性,并由 handle
函数处理。此外,所有异常都支持一个类似 textMsg
的成员函数,返回异常的文本信息。
通过定义一个多态函数 handle
,它可以根据抛出异常的性质表现出不同的行为,当从基本 Exception
指针或引用调用时。在这种情况下,程序可以抛出这五种异常类型中的任何一种。假设 Message
和 Warning
类是从 Exception
类派生的,那么与异常类型匹配的 handle
函数将自动由以下异常捕获器调用:
catch (Exception &ex) {
cout << ex.textMsg() << '\n';
if (ex.severity() != ExceptionType::Warning &&
ex.severity() != ExceptionType::Message)
throw;
// 传递其他类型的异常
ex.handle();
// 处理消息或警告
}
现在,在前面的 try
块中,抛出的异常可以是 Exception
对象或其派生类的对象。所有这些异常都将被上述处理程序捕获。例如:
throw Info{};
throw Warning{};
throw Notice{};
throw Error{};
throw Fatal{};
try
块
try
块围绕着 throw
语句。记住,程序总是被一个全局的 try
块包围,因此 throw
语句可以出现在代码的任何地方。然而,更常见的是,throw
语句用于函数体内,而这些函数可能在 try
块中被调用。
try
块由关键字 try
定义,后面跟随一个复合语句。这个块之后必须紧接着至少一个 catch
处理器:
try
{
// 任何语句都可以放在这里
}
catch(...)
{
// 至少一个 catch 子句放在这里
}
try
块通常是嵌套的,从而创建异常处理的多个层次。例如,main
函数的代码被一个 try
块包围,形成了一个外层的异常处理级别。在 main
的 try
块内调用的函数也可能包含 try
块,形成下一个异常处理级别。如我们在第10.3.1节中看到的,内层 try
块中抛出的异常可以在该级别进行处理,也可以不进行处理。通过在异常处理器中放置一个空的 throw
语句,可以将抛出的异常传递到下一个(外层)级别。
捕获异常
catch
子句由关键字 catch
和一个参数列表组成,这个参数列表定义了一个参数,该参数指定了由特定的 catch
处理器捕获的异常的类型和(参数)名称。这个名称可以在 catch
子句后面的复合语句中作为变量使用。例如:
catch (string &message)
{
// 处理该消息的代码
}
基本类型和对象都可以作为异常抛出。抛出指向局部对象的指针或引用是不好的做法,但如果异常处理器能够删除分配的内存以防止内存泄漏,指向动态分配对象的指针是可以抛出的。然而,抛出这样的指针是危险的,因为异常处理器无法区分动态分配的内存和非动态分配的内存,如下面的例子所示:
try
{
static int x;
int *xp = &x;
if(condition1)
throw xp;
xp = new int(0);
if (condition2)
throw xp;
}
catch (int *ptr)
{
// delete ptr 还是不删除?
}
在异常处理器的参数的性质上应特别注意,以确保当抛出指向动态分配内存的指针时,内存在处理器处理完指针后被释放。通常情况下,不应该将指针作为异常抛出。如果必须将动态分配的内存传递给异常处理器,那么应将指针包装在智能指针中,如 unique_ptr
或 shared_ptr
中(参见第18.3节和第18.4节)。
多个 catch
处理器可以跟在一个 try
块之后,每个处理器定义自己的异常类型。异常处理器的顺序非常重要。当抛出一个异常时,首先匹配抛出异常类型的异常处理器会被使用,其余的异常处理器将被忽略。最终,在一个 try
块之后最多只能激活一个异常处理器。通常这不是什么问题,因为每个异常都有自己独特的类型。
例如:如果异常处理器是为 char*
和 void*
定义的,那么 NTBS(Null-Terminated Byte Strings)将被前者处理器捕获。注意,char*
也可以被认为是 void*
,但异常类型匹配过程足够聪明,会使用 char*
处理器来处理抛出的 NTBS。处理器应设计得非常特定,以捕获相应类型的异常。例如,int
异常不会被 double
处理器捕获,char
异常不会被 int
处理器捕获。
这里有一个小例子,说明了当捕获器的类型之间没有任何层次关系时(即 int
不是从 double
派生的;string
不是从 NTBS 派生的),捕获器的顺序并不重要:
#include <iostream>
using namespace std;
int main()
{
while (true)
{
try
{
string s;
cout << "输入 a, c, i, s 以抛出 ascii-z, char, int, string 异常\n";
getline(cin, s);
switch (s[0])
{
case 'a':
throw "ascii-z";
case 'c':
throw 'c';
case 'i':
throw 12;
case 's':
throw string{};
}
}
catch (string const &)
{
cout << "捕获 string 异常\n";
}
catch (char const *)
{
cout << "捕获 ASCII-Z string 异常\n";
}
catch (double)
{
cout << "未捕获的 double 异常\n";
}
catch (int)
{
cout << "捕获 int 异常\n";
}
catch (char)
{
cout << "捕获 char 异常\n";
}
}
}
捕获异常
与其定义特定的异常处理器,不如设计一个特定的类,其对象包含有关异常的信息。之前在第10.3.1节中提到了这种方法。使用这种方法,只需要一个处理器,因为我们知道不会抛出其他类型的异常:
try
{
// 代码仅抛出 Exception 对象
}
catch (Exception &ex)
{
ex.handle();
}
当异常处理器的代码执行完毕后,执行将直接继续到与匹配的 try
块直接相邻的最后一个异常处理器之外的代码(假设处理器本身没有使用 return
或 throw
之类的控制流语句来打破默认的执行流程)。可以区分以下几种情况:
- 如果在
try
块中没有抛出异常,则不会激活异常处理器,执行会从try
块中的最后一个语句继续到最后一个catch
块之外的第一个语句。 - 如果在
try
块中抛出了异常,但当前级别或其他级别没有适当的异常处理器,则程序的默认异常处理器将被调用,程序将终止。 - 如果在
try
块中抛出了异常并且有适当的异常处理器可用,则会执行该异常处理器的代码。之后,程序的执行会在最后一个catch
块之外的第一个语句处继续。
在 try
块中,所有在执行 throw
语句后面的语句将被忽略。然而,在执行 throw
语句之前在 try
块中成功构造的对象,在任何异常处理器的代码执行之前会被销毁。
默认异常处理器
在程序的某一级别中,可能只需要一组有限的处理器。属于该有限集合的异常类型会被处理,所有其他异常则传递给外部 try
块的异常处理器。
可以通过使用默认异常处理器来实现一种中间类型的异常处理,该处理器必须放置在所有其他更具体的异常处理器之后(这是由于异常处理器的层级性质,详见第10.5节)。
默认异常处理器无法确定所抛出异常的实际类型,也无法确定异常的值,但它可以执行一些语句,从而进行一些默认处理。此外,捕获的异常不会丢失,默认异常处理器可以使用空的 throw
语句(详见第10.3.1节)将异常传递给外部级别,在那里它会被实际处理。以下示例展示了默认异常处理器的这种用法:
#include <iostream>
using namespace std;
int main()
{
try
{
try
{
throw 12.25;
// 没有处理 double 的具体处理器
}
catch (int value)
{
cout << "内层: 捕获到 int 类型\n";
}
catch (...)
{
cout << "内层: 通用异常处理\n";
throw;
}
}
catch(double d)
{
cout << "外层可使用被抛出的 double: " << d << '\n';
}
}
程序生成的输出:
内层: 通用异常处理
外层可使用被抛出的 double: 12.25
程序的输出表明,默认异常处理器中的空 throw
语句将接收到的异常抛给了下一个(外部)级别的异常处理器,保持了被抛出异常的类型和值。
因此,可以在内层完成基本或通用的异常处理,而在外层基于抛出的表达式类型进行具体处理。此外,特别是在多线程程序中(详见第20章),可以在将 std::exception
对象转换为 std::exception_ptr
对象后,将抛出的异常在线程之间传递。这一过程甚至可以在默认异常处理器内部使用。有关 std::exception_ptr
类的进一步内容,请参见第10.9.4节。
无法抛出异常的函数:noexcept
关键字
一旦定义了一个函数,通常会从其他函数中调用它。如果被调用的函数没有定义在与调用函数相同的源文件中,那么必须声明这些被调用的函数,通常使用头文件进行声明。这些被调用的函数可能会抛出异常,而这些异常可能是调用这些函数的函数所无法接受的。例如,像 swap
和析构函数这样的函数可能不允许抛出异常。
可以通过指定 noexcept
关键字来声明和定义不能抛出异常的函数(有关指定 noexcept
的函数声明的示例,请参见第10.9节)。使用 noexcept
会有轻微的运行时开销,因为该函数需要一个全局的 try-catch
块,以捕获其(调用的)代码可能抛出的任何异常。
当捕获到异常(违反了 noexcept
规范)时,catch
语句块会调用 std::terminate
,从而终止程序。
除了使用普通的 noexcept
,还可以为其指定一个在编译时计算的参数(例如,void fun() noexcept(sizeof(int) == 4)
):如果计算结果为 true
,则应用 noexcept
规范;如果计算结果为 false
,则忽略 noexcept
规范。有关 noexcept
高级用法的示例,请参见第23.8节。
Iostream 与异常
C++ 的 I/O 库在异常处理可用之前就已经被广泛使用。因此,通常 iostream 库的类不会抛出异常。不过,可以通过使用 ios::exceptions
成员函数来修改这种行为。这个函数有两个重载版本:
ios::iostate exceptions()
: 这个成员函数返回流对象将在何种状态标志下抛出异常。void exceptions(ios::iostate state)
: 这个成员函数会在检测到给定的状态state
时让流对象抛出异常。
在 I/O 库中,异常是 ios::failure
类的对象,该类继承自 ios::exception
。在定义一个 failure
对象时,可以指定一个 std::string const &message
,该消息可以通过其虚函数 char const *what() const
成员函数来检索。
异常应在异常情况下使用。因此,让流对象在诸如 EOF 这样相对正常的情况下抛出异常是值得怀疑的。使用异常来处理输入错误可能是有道理的(例如,在输入错误不应该发生并且意味着文件已损坏的情况下),但通常情况下,终止程序并显示适当的错误消息可能是更合适的做法。例如,考虑下面这个使用异常来捕捉不正确输入的交互程序:
#include <iostream>
#include <climits>
using namespace std;
int main() {
cin.exceptions(ios::failbit); // 使 fail 状态下抛出异常
while (true) {
try {
cout << "enter a number: ";
int value;
cin >> value;
cout << "you entered " << value << '\n';
} catch (ios::failure const &problem) {
cout << problem.what() << '\n';
cin.clear();
cin.ignore(INT_MAX, '\n'); // 忽略错误行
}
}
}
默认情况下,由 ostream
对象内部引发的异常会被这些对象捕获,并因此设置它们的 ios::badbit
标志。有关此问题的更多信息,请参阅第 14.8 节中的相关段落。
标准异常
所有数据类型都可以被抛出作为异常。C++ 标准定义了一些额外的异常类。在使用这些额外的异常类之前,必须包含 <stdexcept>
头文件。
所有这些标准异常都是类类型,它们同时也提供了 std::exception
类的所有功能,因此标准异常类的对象也可以被视为 std::exception
类的对象。
std::exception
类提供了如下成员函数:
char const *what() const;
该函数返回一个简短的文本消息,描述异常的性质。
C++ 定义了以下标准异常类:
std::bad_alloc
(需要包含<new>
头文件):当operator new
失败时抛出;std::bad_array_new_length
(需要包含<new>
头文件):当使用new Type[...]
时请求了非法的数组大小时抛出。非法大小包括负值、超过实现定义的最大值的值、或初始化子句的数量超过指定的数组元素数量(例如new int[2]{ 1, 2, 3 }
);std::bad_cast
(需要包含<typeinfo>
头文件):在多态上下文中抛出(参见第 14.6.1 节);std::bad_exception
(需要包含<exception>
头文件):当函数尝试生成超出其函数抛出列表声明的异常类型时抛出;std::bad_typeid
(需要包含<typeinfo>
头文件):同样在多态上下文中抛出(参见第 14.6.2 节)。
所有额外定义的异常类都继承自 std::exception
。这些额外类的构造函数接受一个 std::string const &
参数,用于总结异常的原因(通过 exception::what
成员函数检索)。额外定义的异常类包括:
std::domain_error
:检测到(数学上的)域错误;std::invalid_argument
:函数的参数具有无效值。std::length_error
:当对象超过其最大允许长度时抛出;std::logic_error
:当检测到程序内部逻辑出现问题时应抛出逻辑错误。例如:调用类似于 C 语言的printf
函数时,传递的参数比格式字符串中的格式说明符还多;std::out_of_range
:当参数超出其允许范围时抛出。例如:当at
成员函数的参数超出可接受的索引值范围时抛出;std::overflow_error
:当检测到算术溢出时应抛出溢出错误。例如:用非常小的值进行除法运算时;std::range_error
:当内部计算结果超过允许范围时应抛出范围错误;std::runtime_error
:当遇到只能在程序执行时检测到的问题时应抛出运行时错误。例如:在程序的输入需要整数值时输入了非整数;std::underflow_error
:当检测到算术下溢时应抛出下溢错误。例如:用非常大的值除以非常小的值时;std::tx_exception<Type>
:派生自std::runtime_error
。此异常可以从atomic_cancel
复合语句中抛出(参见第 20.14 节),以撤销到目前为止执行的语句。
标准异常:使用还是不使用?
由于任何类型的值都可以作为异常抛出,你可能会想什么时候应该抛出标准异常类型的值,以及(如果有的话)什么时候抛出其他类型的值。
当前C++社区的做法是仅在异常情况时才抛出异常。在这方面,C++对于使用异常的哲学与Java等语言显著不同,在Java中,异常通常出现在C++不认为是异常的情况下。
另一种常见做法是在设计软件时遵循“概念化”风格。异常的一个优点是,它们可以在源代码中显示问题发生的点处抛出:例如抛出std::out_of_range
异常对于软件维护人员来说是非常友好的,因为异常的原因可以立即被识别。
在捕获子句中,语义上下文通常不再那么重要,通过捕获std::exception
并显示其what()
内容,程序的用户可以了解发生了什么。
但是抛出其他类型的值也有用。例如在你想要抛出一个异常并在浅层级捕获它的情况下。中间可能有由外部软件库提供的各种软件层级,这些层级的软件工程师无法控制。在这些层级中也可能会生成异常(如std::exceptions
),并且这些异常也可能被库的代码捕获。当抛出标准异常类型时,你可能很难确信该异常不会被外部提供的软件捕获。如果没有使用通用捕获(如catch (...)
),那么抛出std::exception
家族的异常可能不是一个好主意。在这种情况下,抛出一个简单的、也许是空的枚举值效果很好:
enum HorribleEvent {};
...
// 在某个深层级:
throw HorribleEvent{};
...
// 在某个浅层级:
catch (HorribleEvent hs) { ... }
其他例子也很容易找到:设计一个包含消息和错误(退出)代码的类:在需要时抛出该类的对象,在main
的try
块的捕获子句中捕获它,这样你可以确保所有在中间层级定义的对象都被整齐地销毁,最后显示错误消息并返回嵌入在你的非异常对象中的退出代码。
因此,建议是在有可用的std::exception
类型时使用它们,并且明确地完成所需的任务。但如果使用异常只是为了简单地摆脱不愉快的情况,或者如果有可能外部提供的代码可能捕获std::exception
,那么考虑抛出其他类型的对象或值。
系统错误、错误类别和错误条件
std::system_error
类继承自std::runtime_error
,而std::runtime_error
又继承自std::exception
。在使用system_error
类或相关类之前,必须包含<system_error>
头文件。
当发生具有关联(系统)错误值的错误时,可以抛出system_error
异常。这些错误通常与低级别(如操作系统)的函数相关,但也可以处理其他类型的错误(例如,错误的用户输入、不存在的请求)。
除了错误代码(参见第4.3.2节)和错误类别(下面介绍)外,还区分了错误条件。错误条件指定了平台无关的错误类型,如语法错误或不存在的请求。
在构造system_error
对象时,可以指定错误代码和错误类别。首先,我们将查看error_condition
和error_category
类,然后详细介绍system_error
本身。
如图10.1所示,error_category
类使用error_condition
类,error_condition
类使用error_category
类。由于这两个类之间存在循环依赖,因此应将这两个类视为一个整体:在介绍error_category
时应了解error_condition
类,反之亦然。这种类之间的循环依赖是不幸的,是一个糟糕的类设计示例。
由于system_error
最终继承自exception
,它提供了标准的what
成员。它还包含一个error_code
。
在POSIX系统中,errno
变量与许多通常相当隐晦的符号相关联。预定义的enum class errc
尝试提供更直观的符号。由于其符号定义在强类型枚举中,它们不能直接用于定义匹配的error_code
。相反,make_error_code
函数将enum class errc
值和新定义的错误代码枚举值(下文称为ErrorCodeEnum
)转换为error_code
。
在std
命名空间中定义的enum class errc
枚举类定义了与传统C语言错误代码值相等的符号,但以更少晦涩的方式描述错误。例如:
enum class errc
{
address_family_not_supported, // EAFNOSUPPORT
address_in_use, // EADDRINUSE
address_not_available, // EADDRNOTAVAIL
already_connected, // EISCONN
argument_list_too_long, // E2BIG
argument_out_of_domain, // EDOM
bad_address, // EFAULT
...
};
ErrorCodeEnum
的值可以传递给匹配的make_error_code
函数。定义你自己的ErrorCodeEnum
枚举的详细内容将在第23.7节中介绍。
现在,已提供了一般概述,接下来是详细查看图10.1中显示的各种组件。
std::error_category
类
std::error_category
类的对象用于标识一组错误代码的来源。可以为新的错误代码枚举定义新的错误类别(见第23.7节)。
错误类别被设计为单例:每个类只能存在一个对象。因此,当error_category
对象的地址相同时,它们被认为是相等的。错误类别对象可以通过函数(见下文)或错误类别类的静态instance()
成员来返回。
错误类别类定义了几个成员。大多数成员被声明为虚拟函数(见第14章),这意味着这些成员可以在我们自己设计的错误类别类中被重定义:
-
virtual error_condition default_error_condition(int ev) const noexcept
:
返回一个用错误值ev
和当前错误类别(即*this
)初始化的error_condition
对象(见第10.9.2节)。 -
virtual bool equivalent(error_code const &code, int condition) const noexcept
:
如果error_code
对象关联的错误条件与函数的第二个参数(作为整数值指定的错误条件枚举值)之间的等价关系可以建立,则返回true
。 -
virtual bool equivalent(int ev, error_condition const &condition) const noexcept
:
如果通过将ErrorConditionEnum
值与函数传入的整数值关联构造的error_condition
对象与函数传入的error_condition
对象之间的等价关系可以建立,则返回true
。 -
virtual string message(int ev) const
:
返回一个描述错误条件的字符串,其中ev
应该是类别的错误条件枚举值的整数(int
)表示。 -
virtual char const *name() const noexcept
:
返回错误类别的名称作为非空终止字符串(NTBS),例如"generic"。 -
bool operator<(error_category const &rhs) const noexcept
:
返回less<const error_category*>(this, &rhs)
的结果。
错误类别对象可以进行(不)相等比较。
返回预定义错误类别的函数有:
-
error_category const &generic_category() noexcept
:
返回对通用错误类别对象的引用。返回对象的name
成员返回指向字符串"generic"的指针。 -
error_category const &system_category() noexcept
:
返回对操作系统错误类别对象的引用:用于处理操作系统报告的错误。对象的name
成员返回指向字符串"system"的指针。 -
error_category const &iostream_category() noexcept
:
返回对流错误类别对象的引用:用于处理流对象报告的错误。对象的name
成员返回指向字符串"iostream"的指针。 -
error_category const &future_category() noexcept
:
返回对未来错误类别对象的引用:用于处理future
对象(见第20.8节)报告的错误。对象的name
成员返回指向字符串"future"的指针。
std::error_condition
类
error_condition
对象包含有关“高级”错误类型的信息。它们旨在与平台无关,例如语法错误或不存在的请求。
error_condition
对象由 error_code
和 error_category
类的 default_error_condition
成员函数返回,也可以通过函数 std::error_condition make_error_condition(ErrorConditionEnum ec)
返回。
类型 ErrorConditionEnum
是一个正式名称,用于表示枚举类,该枚举类列举了“高级”错误类型。make_error_condition
返回的 error_condition
对象使用 ec
和使用 ErrorConditionEnum
的 error_category
进行初始化。自定义 ErrorConditionEnum
的定义请参见第23.7节。
构造函数:
-
error_condition() noexcept
:
将对象的值初始化为“无错误”(即0)和system_category
错误类别。 -
复制构造函数可用。
-
error_condition(int ec, error_category const &cat) noexcept
:
将对象的值初始化为ec
和错误类别cat
。调用者有责任确保ec
代表cat
的错误条件枚举值(转换为整数)。 -
error_condition(ErrorConditionEnum value) noexcept
:
这是一个成员模板(见第22.1.3节),使用模板头文件<class ErrorConditionEnum>
。它使用make_error_condition(value)
的返回值初始化对象。
成员函数:
-
复制赋值运算符和接受
ErrorConditionEnum
的赋值运算符可用。 -
void assign(int val, error_category const &cat)
:
为当前对象的值和类别数据成员分配新值。 -
error_category const &category() const noexcept
:
返回对象的错误类别的引用(注意这是对类别单例对象的引用)。 -
void clear() noexcept
:
调用此成员后,对象的值设置为0,错误类别设置为generic_category
。 -
string message() const
:
返回category().message(value())
。 -
explicit operator bool() const noexcept
:
如果value()
返回非零值,则返回true
(表示“对象表示错误”)。 -
int value() const noexcept
:
返回对象的错误值。
两个 error_condition
对象可以进行(不)相等比较,并且可以使用 operator<
进行排序。如果两个对象的类别不同,则排序没有意义。如果两个对象的类别不同,则认为它们是不同的。
system_error
类
system_error
对象可以从 error_code
对象或由错误值(整数)和匹配的错误类别对象构造,也可以选择性地附加一个描述错误性质的标准文本说明。
以下是该类的公共接口:
class system_error : public runtime_error {
public:
system_error(error_code ec);
system_error(error_code ec, string const &what_arg);
system_error(error_code ec, char const *what_arg);
system_error(int ev, error_category const &ecat);
system_error(int ev, error_category const &ecat, char const *what_arg);
system_error(int ev, error_category const &ecat, string const &what_arg);
error_code const &code() const noexcept;
char const *what() const noexcept;
};
ev
值通常是系统级函数(如 chmod(2)
)失败时设置的 errno
变量的值。
注意,前面三个构造函数接受 error_code
对象作为第一个参数。由于其中一个 error_code
构造函数也需要一个整数和一个错误类别参数,因此第二组三个构造函数也可以替代第一组构造函数。例如:
system_error(errno, system_category(), "context of the error");
// 与以下代码等效:
system_error(error_code(errno, system_category()), "context of the error");
第二组三个构造函数主要用于当现有函数已经返回 error_code
时。例如:
system_error(make_error_code(errc::bad_address), "context of the error");
// 或者可能是:
system_error(make_error_code(static_cast<errc>(errno)), "context of the error");
除了标准的 what
成员外,system_error
类还提供了一个 code
成员,用于返回异常的错误代码的常量引用。
system_error
的 what
成员返回的 NTBS(以空字符结尾的字符串)可以通过以下格式化:
what_arg + ": " + code().message()
注意,虽然 system_error
从 runtime_error
派生,但在捕获 std::exception
对象时会丢失 code
成员。当然,向下转型是可能的,但这只是权宜之计。因此,如果抛出了 system_error
,必须提供一个匹配的 catch(system_error const &)
子句以检索 code
成员返回的值。这种复杂的类组织结构导致了非常复杂且难以概括的异常处理。在本质上,你获得的是一种用于分类整数或枚举错误值的设施,尽管其复杂度较高。在第23章,特别是第23.7节中,对涉及的复杂性有更多的覆盖(有关灵活的替代方案,请参见作者 Bobcat 库中的 FBB::Exception
类)。
异常传播:std::exception_ptr
在实际应用中,几乎任何东西都可以作为异常被使用。同时,任何被抛出的异常都可以通过 std::current_exception
函数访问,而对任何异常的访问也可以通过 std::make_exception_ptr
标准化。这些函数期望或使用 std::exception_ptr
类的对象。在这一节中,我们将详细了解 std::exception_ptr
类。
std::exception_ptr
类的默认构造函数将其初始化为一个空指针。在以下代码片段中,变量 isNull
被设置为 true
:
std::exception_ptr obj;
bool isNull = obj == nullptr && obj == 0;
std::exception_ptr
类提供了复制构造函数和移动构造函数,以及复制赋值运算符和移动赋值运算符。
两个 exception_ptr
对象可以进行相等性比较。如果它们指向相同的异常,则认为它们是相等的。移动赋值将右侧操作数所指向的异常转移到左侧操作数,并将右侧操作数变成空指针。
没有直接检索 exception_ptr
对象所指向的异常的方法。然而,有一些自由函数用于构造或处理 exception_ptr
对象:
-
std::exception_ptr std::current_exception() noexcept;
该函数返回一个exception_ptr
对象,指向当前处理的异常(或当前处理的异常的副本,如果没有当前异常可用,则返回一个默认构造的exception_ptr
对象)。当使用默认异常捕获器时,也可以调用此函数。current_exception
所返回的异常不一定是std::exception
类的对象。任何类型的对象或值被抛出作为异常时,都可以通过current_exception
获取到一个exception_ptr
。exception_ptr
对象所指向的异常将至少在存在一个指向它的exception_ptr
对象时保持有效。连续两次调用current_exception
,返回的两个exception_ptr
对象可能指向相同的异常对象,也可能不指向。 -
std::exception_ptr make_exception_ptr(Type value) noexcept;
这个函数模板从传递给它的任何类型的值构造一个exception_ptr
。Type
不一定是std::exception
,可以是任何可以作为异常被抛出的类型:一个int
、一个std::string
、一个std::exception
等等。下面是一些示例,展示了如何将不同类型的值作为参数传递给
make_exception_ptr
:auto ptr = make_exception_ptr(exception()); ptr = make_exception_ptr("hello world"s); ptr = make_exception_ptr(12);
-
void std::rethrow_exception(exception_ptr obj);
将抛出obj
所指向的异常。注意:obj
不能是空指针。
异常保证
软件应该是异常安全的:即使面对异常,程序也应继续按照其规范运行。实现异常安全并不总是容易的。在本节中介绍了一些在讨论异常安全时的指导方针和术语。由于异常可能在所有 C++ 函数中生成,因此异常可能在许多情况下生成。这些情况并不总是立即且直观地被识别为可能抛出异常的情况。考虑以下函数,并思考在哪些点可能会抛出异常:
void fun()
{
X x;
cout << x;
X *xp = new X{ x };
cout << (x + *xp);
delete xp;
}
如果可以假设 cout
如上使用不会抛出异常,则至少有 13 个机会可能会抛出异常:
X x
: 默认构造函数可能会抛出异常(#1)。cout << x
: 重载的插入操作符可能会抛出异常(#2),但其右侧参数可能不是X
,而是例如int
,这可能会调用X::operator int() const
,从而提供另一个抛出异常的机会(#3)。*xp = new X{ x }
: 拷贝构造函数可能会抛出异常(#4),operator new
也可能抛出异常(#5a)。但你是否意识到这个异常可能不是从::new
抛出,而是从例如X
自己重载的operator new
抛出?(#5b)cout << (x + *xp)
: 我们可能会被诱导认为这是两个X
对象相加。但情况不一定如此。可能存在一个单独的类Y
,而X
可能具有转换操作符operator Y() const
,以及operator+(Y const &lhs, X const &rhs)
、operator+(X const &lhs, Y const &rhs)
和operator+(X const &lhs, X const &rhs)
可能全部存在。因此,如果存在转换操作符,则根据定义的operator+
的重载,可能会抛出异常的包括左操作数(#6)、右操作数(#7)或operator+
本身(#8)。结果值可能是任何类型,因此重载的cout << return-type-of-operator+
操作符可能会抛出异常(#9)。由于operator+
返回一个临时对象,因此它在使用后不久将被销毁。X
的析构函数可能会抛出异常(#10)。delete xp
: 每当operator new
被重载时,operator delete
也应被重载,并且可能会抛出异常(#11)。当然,X
的析构函数可能再次抛出异常(#12)。}
: 当函数终止时,局部x
对象被销毁:此时可能再次抛出异常(#13)。
这里强调(并在 10.12 节中进一步讨论),尽管可能在析构函数中抛出异常,但这会违反 C++ 标准,因此必须在良好的 C++ 程序中避免这种情况。
当异常可能在如此多的情况下被抛出时,我们如何期望创建工作的程序呢?
异常可能在许多情况下生成,但当我们能够提供以下至少一种异常保证时,可以防止严重问题:
- 基础保证:没有资源泄漏。实际上,这意味着:当抛出异常时,所有分配的内存都被正确释放。
- 强保证:程序的状态在抛出异常时保持不变(例如:重载赋值运算符的标准形式提供了这种保证)。
- 无抛保证:适用于可以证明不会抛出异常的代码。
基础保证
基础保证要求函数在未能完成其分配的任务时,必须在终止前释放所有已分配的资源,通常是内存。由于几乎所有函数和运算符都可能抛出异常,并且一个函数可能会多次分配资源,因此如下面的资源分配函数所示,函数定义了一个 try
块来捕捉所有可能被抛出的异常。catch
处理程序的任务是释放所有分配的资源,然后重新抛出异常。
void allocator(X **xDest, Y **yDest) {
X *xp = 0;
// 非抛出预处理代码
Y *yp = 0;
try { // 这一部分可能会抛出异常
xp = new X[nX]; // 也可以选择:分配一个对象
yp = new Y[nY];
} catch (...) {
delete xp;
throw;
}
delete[] *xDest; // 非抛出后处理代码
*xDest = xp;
delete[] *yDest;
*yDest = yp;
}
在 try
之前的代码中,接收 operator new
调用返回的地址的指针被初始化为 0
。由于 catch
处理程序必须能够释放已分配的内存,这些指针必须在 try
块之外可用。如果分配成功,则目标指针指向的内存会被返回,然后这些指针被赋予新的值。分配和/或初始化可能会失败。如果分配失败,new
会抛出一个 std::bad_alloc
异常,catch
处理程序只是删除 0
指针,这样做是可以接受的。如果分配成功,但对象的构造失败并抛出异常,则以下情况将得到保证:
- 所有成功分配的对象的析构函数会被调用;
- 动态分配的内存会被释放。
因此,当 new
失败时不会有内存泄漏。在上述 try
块中,new X
可能会失败:这不会影响 0
指针,因此 catch
处理程序只是删除 0
指针。当 new Y
失败时,xp
指向已分配的内存,因此必须在 catch
处理程序中释放。这只会发生在 catch
处理程序中。最终的指针(在这里是 yp
)只有在 new Y
正常完成时才不等于零,因此 catch
处理程序不需要释放 yp
指向的内存。
强保证
强保证要求在遇到异常时,对象的状态应保持不变。这通过在单独的数据副本上执行所有可能抛出异常的操作来实现。如果所有操作都成功,则当前对象与其(现在成功修改的)副本交换。以下是这种方法的一个示例,展示了经典的重载赋值运算符:
Class &operator=(Class const &other) {
Class tmp(other); // 可能会抛出异常
swap(tmp); // 交换当前对象与 tmp 的内容
return *this; // 返回当前对象的引用
}
拷贝构造函数可能会抛出异常,但这不会影响当前对象的状态。如果拷贝构造成功,swap
将当前对象的内容与 tmp
的内容交换,并返回对当前对象的引用。为了使 swap
成功,必须保证 swap
不会抛出异常。返回引用(或原始数据类型的值)也保证不会抛出异常。因此,经典形式的重载赋值运算符满足强保证的要求。
一些与强保证相关的经验法则如下(参见 Sutter, H., Exceptional C++, Addison-Wesley, 2000):
- 所有可能抛出异常且影响当前对象状态的代码应与对象控制的数据分开执行。一旦这些代码成功完成而不抛出异常,则替换对象的数据。
- 修改对象数据的成员函数不应返回原始(包含的)对象的值。
经典赋值运算符是第一个经验法则的一个良好示例。另一个示例是存储对象的类。考虑一个存储多个 Person
对象的 PersonDb
类。该类可能提供一个成员函数 void add(Person const &next)
。以下是一个实现示例(为了展示第一个经验法则的应用,忽略了效率考虑):
Person *PersonDb::newAppend(Person const &next) {
Person *tmp = 0;
try {
tmp = new Person[d_size + 1];
for (size_t idx = 0; idx < d_size; ++idx)
tmp[idx] = d_data[idx];
tmp[d_size] = next;
return tmp;
} catch (...) {
delete[] tmp;
throw;
}
}
void PersonDb::add(Person const &next) {
Person *tmp = newAppend(next);
delete[] d_data;
d_data = tmp;
++d_size;
}
(私有)newAppend
成员函数的任务是创建当前分配的 Person
对象的副本,包括 next
对象的数据。其 catch
处理程序捕获分配或复制过程中可能抛出的任何异常,并释放迄今为止分配的所有内存,最后重新抛出异常。该函数是异常中性的,因为它将所有异常传播到调用者。该函数也不会修改 PersonDb
对象的数据,因此满足强保证。返回 newAppend
后,add
成员函数现在可以修改其数据。其现有数据会被返回,并且 d_data
指针会指向新创建的 Person
对象数组。最后,d_size
被递增。由于这三个步骤都不会抛出异常,因此 add
也满足强保证。
第二条经验法则(修改对象数据的成员函数不应返回原始(包含的)对象的值)可以用成员函数 PersonDb::erase(size_t idx)
来说明。下面是一个试图按值返回原始 d_data[idx]
对象的实现:
Person PersonDb::erase(size_t idx) {
if (idx >= d_size)
throw "Array bounds exceeded"s;
Person ret(d_data[idx]);
Person *tmp = copyAllBut(idx);
delete[] d_data;
d_data = tmp;
--d_size;
return ret;
}
虽然拷贝消除通常防止在返回 ret
时使用拷贝构造函数,但这并不保证总是如此。此外,拷贝构造函数可能会抛出异常。如果发生这种情况,函数已经不可逆转地改变了 PersonDb
的数据,从而失去了强保证。与其按值返回 d_data[idx]
,不如将其分配给一个外部 Person
对象,然后再修改 PersonDb
的数据:
void PersonDb::erase(Person *dest, size_t idx) {
if (idx >= d_size)
throw "Array bounds exceeded"s;
*dest = d_data[idx];
Person *tmp = copyAllBut(idx);
delete[] d_data;
d_data = tmp;
--d_size;
}
这种修改有效,但改变了原本返回原始对象的分配方式。然而,这两种函数都存在任务过载的问题,因为它们同时修改 PersonDb
的数据并返回原始对象。在这种情况下,应记住一个函数一职的经验法则:一个函数应有一个明确的责任。推荐的做法是通过类似 Person const &at(size_t idx) const
的成员函数来检索 PersonDb
的对象,并通过类似 void PersonDb::erase(size_t idx)
的成员函数来删除对象。
无异常保证
异常安全性只有在某些函数和操作保证不会抛出异常时才能实现。这称为无异常保证。一个必须提供无异常保证的函数的例子是 swap
函数。再看一次经典的重载赋值运算符:
Class &operator=(Class const &other) {
Class tmp(other);
swap(tmp);
return *this;
}
如果 swap
函数被允许抛出异常,那么它很可能会使当前对象处于部分交换的状态。因此,当前对象的状态很可能已经被改变。由于 tmp
在异常处理程序接收到抛出的异常时已经被销毁,因此检索对象的原始状态变得非常困难(实际上是不可行的)。这会导致强保证的丧失。
因此,swap
函数必须提供无异常保证。它必须像使用以下原型一样被设计(参见第23.8节):
void Class::swap(Class &other) noexcept;
同样,operator delete
和 operator delete[]
也提供无异常保证,并且根据 C++ 标准,析构函数本身也不应抛出异常(如果它们抛出异常,其行为在形式上是未定义的,参见下面第10.12节)。
由于 C 编程语言不定义异常概念,标准 C 库中的函数通过隐式方式提供无异常保证。这使得我们能够在第9.6节中使用 memcpy
定义通用的 swap
函数。
对原始类型的操作提供无异常保证。指针可以重新分配,引用可以返回等,而无需担心可能抛出的异常。
函数 try 块
构造函数内部可能会产生异常。如何在构造函数内部捕获这些异常,而不是在构造函数外部捕获?直观的解决方案是将对象构造嵌套在 try 块中,但这并不能解决问题。异常此时已经离开了构造函数,且我们意图构造的对象已不再可见。
使用嵌套的 try 块在下例中进行了说明,其中 main
定义了一个 PersonDb
类的对象。假设 PersonDb
的构造函数抛出异常,则从 catch 处理程序中无法访问可能已经被 PersonDb
的构造函数分配的资源,因为 pdb
对象已经超出了作用域:
int main(int argc, char **argv) {
try {
PersonDb pdb{ argc, argv }; // 可能抛出异常
...
// main() 的其他代码
} catch (...) {
// 和/或其他处理程序
...
// 从这里无法访问 pdb
}
}
尽管在 try 块内定义的所有对象和变量在其相关联的 catch 处理程序中是不可访问的,但在开始 try 块之前的对象数据成员仍然可用,因此可以在 catch 处理程序中访问它们。在以下示例中,PersonDb
构造函数中的 catch 处理程序能够访问其 d_data
成员:
PersonDb::PersonDb(int argc, char **argv)
: d_data(0), d_size(0) {
try {
initialize(argc, argv);
} catch (...) {
// d_data, d_size: 可访问
}
}
不幸的是,这对我们帮助不大。如果 PersonDb
定义为 const pdb
,initialize
成员不能重新分配 d_data
和 d_size
;initialize
成员至少应提供基本的异常保证,并在由于抛出异常而终止之前返回任何已获取的资源;尽管 d_data
和 d_size
提供无异常保证,因为它们是原始数据类型,但类类型数据成员可能会抛出异常,从而可能违反基本保证。
在下一个 PersonDb
实现中,假设构造函数接收一个已经分配好的 Person
对象块的指针。PersonDb
对象接管了分配的内存,因此它负责最终销毁已分配的内存。此外,d_data
和 d_size
也被一个组成对象 PersonDbSupport
使用,该对象的构造函数期望 Person const *
和 size_t
参数。我们的下一实现可能如下所示:
PersonDb::PersonDb(Person *pData, size_t size)
: d_data(pData), d_size(size), d_support(d_data, d_size) {
// 无进一步操作
}
这种设置允许我们定义一个 const PersonDb &pdb
。不幸的是,PersonDb
不能提供基本保证。如果 PersonDbSupport
的构造函数抛出异常,则不会被捕获,尽管 d_data
已经指向已分配的内存。
函数 try 块提供了这个问题的解决方案。函数 try 块由一个 try 块和它的相关处理程序组成。函数 try 块从函数头部开始,其块定义了函数体。通过将构造函数的基类和数据成员初始化器放置在 try 关键字和开括号之间,可以实现这一点。以下是我们最终实现的 PersonDb
,现在提供基本保证:
PersonDb::PersonDb(Person *pData, size_t size) try
: d_data(pData), d_size(size), d_support(d_data, d_size) {}
catch (...) {
delete[] d_data;
}
让我们看一个简化的示例。构造函数定义了一个函数 try 块。抛出的异常最初被对象本身捕获,然后重新抛出。包围的 Composer
的构造函数也定义了一个函数 try 块,即使异常是在其成员初始化列表内生成的,Throw
重新抛出的异常也会被 Composer
的异常处理程序正确捕获。
示例及说明
#include <iostream>
class Throw {
public:
Throw(int value)
try {
throw value;
}
catch(...) {
std::cout << "Throw's exception handled locally by Throw()\n";
throw;
}
};
class Composer {
Throw d_t;
public:
Composer()
try
// NOTE: try precedes initializer list
: d_t(5) {}
catch(...) {
std::cout << "Composer() caught exception as well\n";
}
};
int main() {
Composer c;
}
运行这个示例程序时,我们会遇到一个令人不快的惊讶:程序运行后会因为 abort
异常而崩溃。输出内容如下,最后两行由系统的最终捕获处理程序添加,捕获所有未处理的异常:
Throw's exception handled locally by Throw()
Composer() caught exception as well
terminate called after throwing an instance of 'int'
Abort
出现这个问题的原因在于 C++ 标准中有文档说明:在构造函数或析构函数的函数 try 块的 catch 处理程序结束时,原始异常会被自动重新抛出。如果处理程序本身抛出了另一个异常,则不会重新抛出原始异常,这提供了一种方式来替换抛出的异常。只有当异常到达构造函数或析构函数的函数 try 块的 catch 处理程序的结尾时,异常才会被重新抛出。被嵌套的 catch 处理程序捕获的异常不会自动重新抛出。
由于只有构造函数和析构函数会重新抛出它们的函数 try 块 catch 处理程序捕获的异常,上述示例中的运行时错误可以通过在 main
中提供自己的函数 try 块来简单地修复:
int main()
try {
Composer c;
}
catch (...) {}
现在程序按预期运行,输出如下:
Throw's exception handled locally by Throw()
Composer() caught exception as well
最后一点说明:如果定义了函数 try 块的函数也声明了异常抛出列表,则只有重新抛出的异常的类型必须与抛出列表中提到的类型匹配。
构造函数中的异常
对象的析构函数只会在对象完全构造完成后被调用。虽然这听起来像是显而易见的事实,但实际上存在一些细微的情况。如果对象的构造因某种原因失败,当对象超出作用域时,其析构函数不会被调用。如果构造函数抛出了异常,并且这个异常没有被构造函数自身捕获,那么对象的析构函数就不会被调用,这可能导致内存泄漏。
以下示例演示了这一情况的典型形式。Incomplete
类的构造函数首先显示一条消息,然后抛出一个异常。它的析构函数也显示一条消息:
class Incomplete {
public:
Incomplete() {
std::cerr << "Allocated some memory\n";
throw 0;
}
~Incomplete() {
std::cerr << "Destroying the allocated memory\n";
}
};
接下来,main
函数在一个 try
块中创建一个 Incomplete
对象。任何可能生成的异常都被捕获:
int main() {
try {
std::cerr << "Creating `Incomplete' object\n";
Incomplete{};
std::cerr << "Object constructed\n";
}
catch(...) {
std::cerr << "Caught exception\n";
}
}
当运行这个程序时,输出如下:
Creating `Incomplete' object
Allocated some memory
Caught exception
因此,如果 Incomplete
的构造函数实际上分配了一些内存,那么程序会遭受内存泄漏。为了防止这种情况发生,可以采取以下措施:
-
防止异常离开构造函数:
如果构造函数的某部分可能会引发异常,可以将这部分代码放在一个try
块中,从而允许构造函数自己捕获异常。这种方法适用于构造函数能够修复异常原因并将对象构造为有效对象的情况。 -
处理基类构造函数或成员初始化构造函数中的异常:
如果异常是由基类构造函数或成员初始化构造函数引发的,那么try
块只能包括构造函数的主体,而不能捕获这些异常。try
块可能包括成员初始化器,并且try
块的复合语句成为构造函数的主体。示例如下:class Incomplete2 { Composed d_composed; public: Incomplete2() try : d_composed(/* arguments */) { // body } catch (...) {} };
无论是成员初始化器还是构造函数主体中的异常,都会导致
catch
子句被执行。由于构造函数的主体没有正确完成,对象不被认为是完整构造的,最终对象的析构函数不会被调用。 -
构造函数的函数
try
块的catch
子句行为与普通函数的catch
子句稍有不同:
构造函数的catch
子句可以将异常转换为另一种异常(并从catch
子句中抛出),但如果catch
子句没有显式地抛出异常,则原始异常总是被重新抛出。因此,异常总是传播到更浅的块,构造函数中的对象被认为是未完成的。 -
避免内存泄漏:
- 如果构造函数定义了一个函数
try
块,则构造函数的catch
子句不能再使用任何数据成员。也就是说,如果构造函数分配了由数据成员指向的内存,则catch
子句不能删除这些内存。相反,构造函数的主体必须确保内存被正确删除。 - 使用多重继承时:如果初始基类已经正确构造,而后续基类抛出异常,则初始基类对象会被自动销毁(因为它们本身是完全构造的对象)。
- 使用组合时:已经构造的组合对象会被自动销毁(因为它们是完全构造的对象)。
- 使用智能指针(例如
shared_ptr
)来管理动态分配的内存。如果构造函数在分配动态内存之前或之后抛出异常,则分配的内存会被正确释放,因为shared_ptr
对象本身就是对象。 - 如果必须使用普通指针数据成员,则构造函数的主体应首先在其成员初始化部分初始化普通指针数据成员。然后,在主体中可以动态分配内存,并重新分配普通指针数据成员。在这些情况下,指针处理必须嵌入在一个
try
块中,以便构造函数能够在最终抛出异常之前释放已分配的内存。最终的catch
子句可以完成所需的操作(例如,写入日志文件)。示例如下:
- 如果构造函数定义了一个函数
class Incomplete2 {
Composed d_composed;
char *d_cp; // 普通指针
int *d_ip; // 普通指针
public:
Incomplete2(size_t nChars, size_t nInts) try
: d_composed(/* arguments */),
d_cp(0),
d_ip(0) {
try {
preamble(); // 可能抛出异常
d_cp = new char[nChars]; // 可能抛出异常
d_ip = new int[nInts]; // 可能抛出异常
postamble(); // 可能抛出异常
} catch (...) {
delete[] d_cp; // 清理已分配的内存
delete[] d_ip; // 清理已分配的内存
throw; // 重新抛出异常
}
} catch (...) {
// 可能写入日志,但也会抛出原始异常
}
};
在这个示例中,Incomplete2
类的构造函数包含一个函数 try
块,它在构造函数的初始化列表之后执行。构造函数体内首先执行初始化操作(如分配内存),这些操作可能会抛出异常。为了避免内存泄漏,如果在内存分配过程中发生异常,捕获异常后会清理已分配的内存,然后重新抛出异常。这样确保了资源的正确释放。
示例代码:Delegate
类
#include <iostream>
using namespace std;
class Delegate {
char *d_p1;
char *d_p2;
public:
Delegate()
: Delegate(0) {
d_p2 = new char[10];
cout << "default, throws...\n";
throw 12; // 抛出异常
}
~Delegate() {
delete[] d_p1;
delete[] d_p2;
cout << "destructor\n";
}
private:
Delegate(int x)
: d_p1(0), d_p2(0) {
cout << "delegated\n";
}
};
int main() {
try {
Delegate del; // 抛出异常
cout << "never reached\n";
} catch (...) {
cout << "main's catch clause\n";
}
}
在这个示例中,Delegate
类演示了构造函数委托。默认构造函数调用了一个私有的带参数构造函数,该构造函数完成了数据成员的初始化。默认构造函数之后分配了内存,并在分配过程中抛出了异常。尽管默认构造函数抛出了异常,但因为它委托给了带参数的构造函数,C++ 运行时系统会认为对象已经部分构造。
在这个例子中,Delegate
类的析构函数会被调用(即使在对象构造失败后),这确保了资源的正确清理。设计 Delegate
类时,开发人员需要确保默认构造函数不会破坏带参数构造函数的初始化操作。析构函数会自动被调用,即使默认构造函数抛出异常。因此,如果有多个异常类型,Delegate
类可以定义一个枚举和一个枚举类型的数据成员,以指示下一次抛出的异常的性质,从而在析构函数中根据异常类型进行处理。
析构函数中的异常
根据 C++ 标准,析构函数抛出的异常不应离开析构函数的主体。提供析构函数的函数 try
块违反了标准:函数 try
块的 catch 子句捕获的异常已经离开了析构函数的主体。如果析构函数被提供了函数 try
块,并且异常被 catch 子句捕获,那么该异常会被重新抛出,就像构造函数函数 try
块中的 catch 子句一样。
异常离开析构函数的主体的后果未被定义,可能导致意外行为。考虑以下示例:
假设一个木匠制造了一个有单个抽屉的橱柜。当橱柜完成后,顾客满意地使用它。顾客要求木匠制造另一个橱柜,这次有两个抽屉。当第二个橱柜完成后,顾客将它带回家,并在第一次使用时发现橱柜完全崩溃了。
听起来奇怪吗?考虑以下程序:
int main() {
try {
cerr << "Creating Cupboard1\n";
Cupboard1{};
cerr << "Beyond Cupboard1 object\n";
} catch (...) {
cerr << "Cupboard1 behaves as expected\n";
}
try {
cerr << "Creating Cupboard2\n";
Cupboard2{};
cerr << "Beyond Cupboard2 object\n";
} catch (...) {
cerr << "Cupboard2 behaves as expected\n";
}
}
运行这个程序会产生以下输出:
Creating Cupboard1
Drawer 1 used
Cupboard1 behaves as expected
Creating Cupboard2
Drawer 2 used
Drawer 1 used
terminate called after throwing an instance of 'int'
Abort
最后的 Abort
表明程序中止,而不是像 Cupboard2 behaves as expected
那样显示消息。
接下来,我们来看看涉及的三个类:
类 Drawer
:
class Drawer {
size_t d_nr;
public:
Drawer(size_t nr) : d_nr(nr) {}
~Drawer() {
cerr << "Drawer " << d_nr << " used\n";
throw 0; // 析构函数抛出异常
}
};
类 Cupboard1
:
class Cupboard1 {
Drawer left;
public:
Cupboard1() : left(1) {}
};
类 Cupboard2
:
class Cupboard2 {
Drawer left;
Drawer right;
public:
Cupboard2() : left(1), right(2) {}
};
在 Cupboard1
的析构函数被调用时,Drawer
的析构函数会被调用,且该析构函数抛出异常,异常被程序的第一个 try
块捕获。这种行为是完全符合预期的。
Cupboard1
的析构函数(以及 Drawer
的析构函数)在对象构造后立即激活。由于 Cupboard1
定义了一个匿名对象,所以 Beyond Cupboard1 object
这行文本永远不会被插入到 std::cerr
中。
然而,Cupboard2
的析构函数在调用时会遇到问题。Cupboard2
有两个 Drawer
对象,其中第二个 Drawer
的析构函数会首先被调用。该析构函数抛出异常,本应在程序的第二个 try
块之外捕获。然而,由于此时程序流已经离开了 Cupboard2
的析构函数,且 Cupboard2
对象尚未完全销毁(left
的析构函数仍需调用),当前的流控制已经混乱,程序只能调用 terminate()
函数,从而调用 abort()
,导致程序中止。
总结
程序中止的原因是由于多个组成对象的析构函数抛出了异常,并且这些异常离开了析构函数的范围。在这种情况下,当程序流已经离开了正确的上下文时,抛出的异常会导致程序中止。因此,C++ 标准明确规定异常不能离开析构函数。
示例:安全的析构函数设计
~ClassName() {
try {
// 执行析构操作
} catch (...) {
// 捕获异常,记录日志或进行其他处理
}
}
在这个示例中,析构函数中的操作都被放在 try
块内,而异常处理也包含在其中,以确保即使发生异常也不会离开析构函数的范围。
操作符重载
在第 9 章中,我们已经介绍了重载赋值操作符,并展示了其他一些重载操作符的示例(例如第 3 章和第 6 章中的插入和提取操作符)。现在,我们将深入探讨操作符重载的一般情况。
重载 operator[]
作为操作符重载的下一个示例,我们介绍一个名为 IntArray
的类,该类封装了一个 int
类型的数组。通过标准数组下标操作符 []
可以索引数组元素,但额外地,IntArray
会执行数组边界溢出的检查(注意,通常情况下,索引操作符不会进行边界检查,因为索引操作符的重载不应包含边界检查)。operator[]
是一个有趣的操作符,因为它既可以用作左值(lvalue)也可以用作右值(rvalue)。
以下是示例程序,演示了该类的基本用法:
int main() {
IntArray x{20}; // 创建一个包含 20 个 int 元素的对象
for (int idx = 0; idx < 20; ++idx)
x[idx] = 2 * idx; // 为元素赋值
for (int idx = 0; idx <= 20; ++idx)
// 结果:边界溢出
cout << "At index " << idx << ": value is " << x[idx] << '\n';
}
首先,构造函数用于创建一个包含 20 个 int
元素的对象。可以使用索引操作符对对象中的元素进行赋值或检索。第一个 for
循环使用索引操作符为元素赋值,第二个 for
循环检索值,但在访问不存在的 x[20]
时会导致运行时错误。
IntArray
类的接口如下:
#include <cstddef>
class IntArray {
size_t d_size;
int *d_data;
public:
IntArray(size_t size = 1);
IntArray(IntArray const &other);
~IntArray();
IntArray &operator=(IntArray const &other);
// 重载索引操作符:
int &operator[](size_t index); // 非 const 对象
int const &operator[](size_t index) const; // const 对象
void swap(IntArray &other);
private:
void boundary(size_t index) const;
int &operatorIndex(size_t index) const;
};
这个类具有以下特点:
- 其中一个构造函数具有一个
size_t
类型的参数,具有默认参数值,用于指定对象中int
元素的数量。 - 类内部使用指针来访问分配的内存。因此,提供了必要的工具:复制构造函数、重载赋值操作符和析构函数。
- 该类有两个重载的索引操作符,为什么会有两个?
第一个重载的索引操作符允许我们访问和修改非 const
IntArray
对象的元素。这个重载操作符的原型是返回一个 int
的引用,允许我们使用类似 x[10]
的表达式作为右值或左值。
对于非 const
的 IntArray
对象,operator[]
可以用来检索和赋值值。因此,非 const
的 operator[]
成员的返回值是 int &
,以便在作为左值时可以修改元素,而 const
的 operator[]
成员的返回值最好是 int const &
,而不是普通的 int
。在这种情况下,我们倾向于使用 const &
返回值,以便可以立即将返回值写入,例如到二进制文件中,如下所示:
void writeValue(IntArray const &iarr, size_t idx) {
cout.write(reinterpret_cast<char const *>(&iarr[idx]));
}
如果没有值可以分配,这种方案将会失败。考虑以下情况,我们有一个 const
对象 IntArray stable(5)
。这样的对象是不可变的 const
对象。如果只有非 const
的 operator[]
可用,编译器将会检测到这一点并拒绝编译该对象定义。因此,类的接口中添加了第二个重载的索引操作符。这个重载形式自动由编译器用于 const
对象。它用于值的检索,而不是值的赋值。这正是我们在使用 const
对象时所需要的。在这种情况下,成员仅通过其 const
属性进行重载。这种函数重载的形式在 C++ 注解中早已介绍过(第 2.5.4 和 7.7 节)。
由于类 IntArray
只有一个指针数据成员,销毁对象分配的内存变得非常简单,只需 delete[] d_data
即可。
下面是成员函数的实现(省略了 swap
的平凡实现,参见第 9 章):
#include "intarray.ih"
IntArray::IntArray(size_t size) : d_size(size) {
if (d_size < 1) throw "IntArray: size of array must be >= 1"s;
d_data = new int[d_size];
}
IntArray::IntArray(IntArray const &other)
: d_size(other.d_size), d_data(new int[d_size]) {
memcpy(d_data, other.d_data, d_size * sizeof(int));
}
IntArray::~IntArray() {
delete[] d_data;
}
IntArray &IntArray::operator=(IntArray const &other) {
IntArray tmp(other);
swap(tmp);
return *this;
}
int &IntArray::operatorIndex(size_t index) const {
boundary(index);
return d_data[index];
}
int &IntArray::operator[](size_t index) {
return operatorIndex(index);
}
int const &IntArray::operator[](size_t index) const {
return operatorIndex(index);
}
void IntArray::swap(IntArray &other) {
// 交换 *this 和 other 的 d_size 和 d_data 数据成员
}
void IntArray::boundary(size_t index) const {
if (index < d_size) return;
ostringstream out;
out << "IntArray: boundary overflow, index = " << index << ", should be < "
<< d_size << '\n';
throw out.str();
}
注意 operator[]
成员的实现:
- 非
const
成员函数可以调用const
成员函数。 - 由于
const
成员函数的实现与非const
成员函数的实现相同,因此这两个operator[]
成员函数可以通过一个辅助函数int &operatorIndex(size_t index) const
来定义。
const
成员函数可以返回一个非 const
的引用(或指针)值,指向对象的一个数据成员。当然,这是一种可能会破坏数据封装的危险通道。然而,公共接口中的成员函数防止了这种破坏,因此这两个公共的 operator[]
成员函数可以安全地调用相同的 int &operatorIndex() const
成员函数,该成员函数定义了一个私有的通道。
多参数 operator[]()
的实现
考虑一个标准的二维数组,它有 nRows
行和 nCols
列。这样的数组是对一维数组的推广:每一行都是一个包含 nCols
元素的数组。如果有一个 DoubleArray
类型,类似于前面章节的 IntArray
,但包含 double
值,那么设计一个 Matrix
类可以如下开始:
class Matrix
{
size_t d_nRows;
size_t d_nCols;
DoubleArray *d_row;
public:
Matrix(size_t nRows, size_t nCols);
...
};
其构造函数分配了 nRows
个 DoubleArray
对象,每个对象有 nCols
列,每个 DoubleArray
通过默认构造函数初始化为 nCols
个元素,初始值为 0:
Matrix::Matrix(size_t nRows, size_t nCols)
: d_nRows(nRows), d_nCols(nCols),
d_row(new DoubleArray[nRows]){}
传统上,访问 Matrix
的元素可以通过以下几种方式实现(加上相应的 const 版本):
- 成员函数
double &element(size_t row, size_t col)
,返回element[row, col]
的值; - 成员函数
DoubleArray &row(size_t row)
,返回指定行的DoubleArray
; - 成员函数
DoubleArray &operator[](size_t row)
,也返回指定行的DoubleArray
。
虽然 element
和 row
成员函数工作良好,但它们有一个缺点,即不使用标准的矩阵元素访问语法。例如,要访问元素 matrix[3, 4]
,我们需要写 matrix.element(3, 4)
或 matrix.row(3)[4]
,而使用第三种方式则需要用两个索引运算符:matrix[3][4]
。而且,使用第二和第三种成员函数会放弃对 DoubleArray
行的封装,如果这些成员函数仅仅是为了访问 Matrix
元素的话。
另一方面,重载索引运算符 operator[]
也可以定义为接受多个参数,这样可以使用标准的数学语法 matrix[row, col]
(一般来说,重载索引运算符也可以接受更多的参数,这在定义例如数组的数组时可能会有用)。
给 Matrix
提供一个接受两个参数的索引运算符是简单的:只需在类接口中添加成员函数 double &operator[](size_t row, size_t col)
(并且可选地添加一个相应的 const
版本)。它的实现可以直接返回所请求的数组元素:
double &Matrix::operator[](size_t row, size_t col)
{
return d_row[row][col];
}
顺便提一下:请注意此实现并未检查提供的索引是否有效。传统上,索引运算符不会执行这样的检查,以提高程序的效率,当已知索引不可能无效时。例如,可以使用以下函数初始化 Matrix
的所有元素为连续的整数值:
void init(Matrix &matrix, size_t value)
{
for (size_t row = 0; row != matrix.nRows(); ++row)
for (size_t col = 0; col != matrix.nCols(); ++col)
matrix[row, col] = value++;
}
另一方面,如果需要检查索引的有效性,则可以定义一个类似于以下的多参数成员函数:
double &Matrix::at(size_t row, size_t col)
{
if (row >= d_nRows || col >= d_nCols)
throw runtime_error("Matrix::at: invalid indices");
return d_row[row][col];
}
这样可以在访问矩阵元素时进行索引有效性检查,并在发现无效索引时抛出异常。
重载插入和提取运算符
类可以被调整,以便它们的对象可以分别插入到 std::ostream
和从 std::istream
提取。
类 std::ostream
定义了用于原始类型(如 int
、char *
等)的插入运算符。本节中,我们将学习如何扩展类(特别是 std::istream
和 std::ostream
)的现有功能,使其能够与后来开发的类一起使用。
特别是,我们将展示如何重载插入运算符,以允许将任何类型的对象(例如 Person
类)插入到 ostream
中。在定义了这样的重载运算符后,我们可以使用以下代码:
Person kr("Kernighan and Ritchie", "unknown", "unknown");
cout << "Name, address and phone number of Person kr:\n" << kr << '\n';
语句 cout << kr
使用 operator<<
。这个成员函数有两个操作数:一个是 ostream &
,另一个是 Person &
。所需的操作在一个重载的自由函数 operator<<
中定义,它期望两个参数:
// 在 `person.h` 中声明
std::ostream &operator<<(std::ostream &out, Person const &person);
// 在某个源文件中定义
std::ostream &operator<<(std::ostream &out, Person const &person)
{
return out <<
"Name: " << person.name() << ", "
"Address: " << person.address() << ", "
"Phone: " << person.phone();
}
重载的 operator<<
函数具有以下值得注意的特点:
- 该函数返回对
ostream
对象的引用,以便可以进行“链式”插入运算。 operator<<
的两个操作数作为参数传递给自由函数。在这个例子中,参数out
被cout
初始化,参数person
被kr
初始化。
为了重载提取运算符(例如 Person
类),需要修改类的私有数据成员。这些修改器通常由类接口提供。对于 Person
类,这些成员函数可能如下:
void setName(char const *name);
void setAddress(char const *address);
void setPhone(char const *phone);
这些成员函数可以很容易地实现:必须删除对应数据成员指向的内存,然后数据成员应该指向一个新的文本副本。例如:
void Person::setAddress(char const *address)
{
delete[] d_address;
d_address = strdupnew(address);
}
一个更复杂的函数应检查新地址的合理性(地址也不应该是 0 指针)。不过,这里不再深入探讨。相反,让我们看一下最终的 operator>>
实现。一个简单的实现如下:
std::istream &operator>>(std::istream &in, Person &person) {
std::string name;
std::string address;
std::string phone;
if (in >> name >> address >> phone)
// 提取三个字符串
{
person.setName(name.c_str());
person.setAddress(address.c_str());
person.setPhone(phone.c_str());
}
return in;
}
请注意这里的逐步方法。首先,使用可用的提取运算符提取所需的信息。然后,如果成功,则使用修改器来修改对象的私有数据成员。最后,将流对象本身作为引用返回。
转换运算符(Conversion Operators)
一个类可以围绕内置类型构建。例如,可以设计一个类 String
,它围绕 char *
类型构建。这样的类可以定义各种操作,比如赋值。考虑以下设计了 String
类接口的示例:
class String
{
char *d_string;
public:
String();
String(char const *arg);
~String();
String(String const &other);
String &operator=(String const &rvalue);
String &operator=(char const *rvalue);
};
这个类的对象可以从 char const *
初始化,也可以从另一个 String
对象初始化。类还定义了两个重载的赋值运算符,允许从 String
对象和 char const *
类型的值进行赋值。
通常,在与数据关联不那么直接的类中,会有一个访问成员函数,比如 char const *String::c_str() const
。然而,在定义 String
对象数组时,例如在 StringArray
类中,使用这个成员函数可能不够直观。如果 StringArray
类提供了 operator[]
以访问单个 String
成员,它可能会至少提供如下的类接口:
class StringArray
{
String *d_store;
size_t d_n;
public:
StringArray(size_t size);
StringArray(StringArray const &other);
StringArray &operator=(StringArray const &rvalue);
~StringArray();
String &operator[](size_t index);
};
这个接口允许我们将 String
元素相互赋值:
StringArray sa{ 10 };
sa[4] = sa[3]; // String 对象之间的赋值
但同样也可以将 char const *
赋值给 sa
的元素:
sa[3] = "hello world";
这里的步骤如下:
- 首先,计算
sa[3]
,结果是一个String
引用。 - 接着,检查
String
类是否有一个重载的赋值运算符,它期望右侧的值是char const *
。找到这个运算符后,sa[3]
的字符串对象将接收到新的值。
但是,如果我们尝试反向操作,即访问存储在 sa[3]
中的 char const *
类型的值,如下所示:
char const *cp = sa[3];
这将会失败。这是因为 sa[3]
的类型是 String
,而我们尝试将其直接赋值给 char const *
类型的变量,这是不允许的。
要实现这种类型的转换,通常可以通过提供一个转换运算符来实现。对于 String
类,你可以定义一个转换运算符,将 String
转换为 char const *
。如下所示:
class String
{
char *d_string;
public:
// 构造函数、析构函数、赋值运算符省略
// 转换运算符
operator char const *() const { return d_string; }
};
这样,你就可以直接将 sa[3]
转换为 char const *
类型:
char const *cp = sa[3]; // 使用转换运算符
这个转换运算符使得 String
对象可以隐式转换为 char const *
,从而可以在需要 char const *
的上下文中使用 String
对象。
转换运算符
一个类可以围绕一个内置类型构建。例如,可以设计一个 String
类,它围绕 char *
类型构建。这样的类可以定义各种操作,比如赋值。考虑以下设计的 String
类接口:
class String
{
char *d_string;
public:
String();
String(char const *arg);
~String();
String(String const &other);
String &operator=(String const &rvalue);
String &operator=(char const *rvalue);
};
这个类的对象可以从 char const *
初始化,也可以从 String
对象初始化。类还定义了两个重载的赋值运算符,允许从 String
对象和 char const *
类型的值进行赋值。
通常,对于与数据关联不那么直接的类,会有一个访问成员函数,比如 char const *String::c_str() const
。然而,在定义 String
对象数组时,例如在 StringArray
类中,使用这个成员函数可能显得不够直观。如果 StringArray
类提供了 operator[]
以访问单个 String
成员,它可能会至少提供如下的类接口:
class StringArray
{
String *d_store;
size_t d_n;
public:
StringArray(size_t size);
StringArray(StringArray const &other);
StringArray &operator=(StringArray const &rvalue);
~StringArray();
String &operator[](size_t index);
};
这个接口允许我们将 String
元素之间进行赋值:
StringArray sa{ 10 };
sa[4] = sa[3]; // String 对象之间的赋值
但同样也可以将 char const *
赋值给 sa
的元素:
sa[3] = "hello world";
这里的步骤如下:
- 首先,计算
sa[3]
,结果是一个String
引用。 - 接着,检查
String
类是否有一个重载的赋值运算符,它期望右侧的值是char const *
。找到这个运算符后,sa[3]
的字符串对象将接收到新的值。
然而,如果我们尝试反向操作,即访问存储在 sa[3]
中的 char const *
类型的值,如下所示:
char const *cp = sa[3];
这将会失败。这是因为 sa[3]
的类型是 String
,而我们尝试将其直接赋值给 char const *
类型的变量,这是不允许的。
解决方案
要解决这个问题,通常可以通过定义一个转换运算符来实现。对于 String
类,可以定义一个将 String
转换为 char const *
的转换运算符,如下所示:
class String
{
char *d_string;
public:
// 构造函数、析构函数、赋值运算符省略
// 转换运算符
operator char const *() const
{
return d_string;
}
};
这样,你就可以直接将 sa[3]
转换为 char const *
类型:
char const *cp = sa[3]; // 使用转换运算符
转换运算符的注意事项
- 转换运算符没有返回类型。转换运算符的返回值类型由
operator
关键字后面的类型决定。 - 转换运算符通常是
const
成员函数,因为它们通常不会修改对象的数据成员。虽然转换运算符通常不修改对象的数据成员,但它们返回的是一个右值,这是转换运算符的常见用法。定义为左值的转换运算符(例如operator int&()
)打开了一个后门,通常不建议使用,除非有特定需求(参见第 11.4 节)。 - 转换运算符可能会引发歧义,特别是在模板函数中使用时。例如,当
String
类定义了operator std::string const() const
转换运算符时,cout << X()
可能无法编译。这是因为编译器需要帮助来解决这种歧义,static_cast
可以解决这个问题。 - 插入流操作符:当对象定义了转换运算符时,插入流操作符会使用该转换运算符。规则如下:
- 如果类
X
定义了一个接受X
对象的插入运算符,则使用该插入运算符。 - 否则,如果转换运算符返回的类型可以被插入,则使用转换运算符。
- 否则,会出现编译错误。
- 如果类
示例代码
以下是一个示例,演示了如何使用转换运算符和插入流操作符:
#include <iostream>
#include <string>
using namespace std;
struct Insertable
{
operator int() const
{
cout << "op int()\n";
return 0;
}
};
ostream &operator<<(ostream &out, Insertable const &ins)
{
return out << "insertion operator";
}
struct Convertor
{
operator Insertable() const
{
return Insertable();
}
};
struct Text
{
operator int() const
{
return 1;
}
};
struct Error
{
operator Text() const
{
return Text{};
}
};
int main()
{
Insertable insertable;
cout << insertable << '\n';
Convertor convertor;
cout << convertor << '\n';
Error error;
cout << error << '\n'; // 这将产生编译错误
}
关于转换运算符的一些最终备注:
-
转换运算符应当是对象功能的“自然扩展”。例如,流类定义了
operator bool()
,允许像if (cin)
这样的语法结构。 -
转换运算符通常返回右值。这样做是为了增强数据隐藏,因为这是转换运算符的预期用途。定义转换运算符为左值(例如,定义
operator int &()
转换运算符)会打开一个后门,这样的运算符只能在显式调用时作为左值使用(例如:x.operator int&() = 5
)。通常不建议使用这种方式,尽管在某些情况下(见第 11.4 节)可能会有例外。 -
转换运算符通常定义为
const
成员函数,因为它们通常不会修改对象的数据成员(不过,见第 11.4 节)。 -
返回复合对象的转换运算符应尽可能返回这些对象的
const
引用,以避免调用复合对象的拷贝构造函数。
Byte
类型的替代实现
在第 3 章中介绍了 std::byte
类型。它提供了按位运算和比较运算符,但缺乏其他算术运算符以及插入和提取运算符,这可能会使它不够实用。幸运的是,利用运算符重载提供的功能,可以开发出一个更通用的 byte
类型。
在本节中,我们开发一个 Byte
类,提供所有数字类型的功能,以及插入和提取运算符,同时 Byte
对象的大小等于 unsigned char
的大小:1 字节。
一个大小为 1 字节的 Byte
类型通过定义一个只有一个 uint8_t
数据成员的 Byte
类来实现。插入和提取运算符由自由函数提供。因此,Byte
类的定义如下:
#include <iostream>
#include <cstdint> // uint8_t
class Byte {
uint8_t d_byte;
public:
// 所有成员函数均为 public:
// 使用默认构造函数、拷贝构造函数以及接受任何可以转换为 uint8_t 的参数的构造函数来定义 Byte 对象
Byte(); // 默认构造函数,默认可用
Byte(uint8_t byte);
// 赋值运算符需要提供,以便 Byte 对象可以作为左值使用
Byte &operator=(Byte const &rhs) = default;
Byte &operator=(uint8_t rhs);
// 提供转换运算符,使 Byte 对象可以用于 uint8_t 值的情况
operator uint8_t &();
operator uint8_t() const;
};
// 大多数成员只需一个语句,可以很好的内联实现。以下是它们的实现:
inline Byte::Byte() : d_byte(0) {}
inline Byte::Byte(uint8_t byte) : d_byte(byte) {}
Byte &Byte::operator=(uint8_t rhs) {
d_byte = rhs;
return *this;
}
inline Byte::operator uint8_t &() {
return d_byte;
}
inline Byte::operator uint8_t() const {
return d_byte;
}
虽然转换操作符通常返回 const
引用,但对于 Byte
类来说,这里做了一个例外。由于 Byte
实质上是一个 uint8_t
的封装器,提供一个非 const
的转换操作符可以让我们使用算术赋值操作符。例如,语句 byte += 13
(假设已定义 Byte byte
)会被编译为 byte.operator uint8_t() = 13
,这需要一个非 const
的转换操作符。
大多数成员函数只需要一行语句,因此可以很好地内联实现。以下是它们的实现:
inline Byte::Byte() : d_byte(0) {}
inline Byte::Byte(uint8_t byte) : d_byte(byte) {}
Byte &Byte::operator=(uint8_t rhs) {
d_byte = rhs;
return *this;
}
inline Byte::operator uint8_t &() {
return d_byte;
}
inline Byte::operator uint8_t() const {
return d_byte;
}
插入和提取操作符的行为类似于 uint8_t
类型值的标准插入和提取操作符:它们插入和提取 Byte
的字符表示,因为插入和提取操作符处理的是文本。以下是它们的一行实现,使用转换操作符来插入或分配 Byte
的 d_byte
值:
inline std::ostream &operator<<(std::ostream &out, Byte const &byte) {
return out << byte.operator uint8_t();
}
inline std::istream &operator>>(std::istream &in, Byte &byte) {
return in >> byte.operator uint8_t &();
}
要写入 d_byte
的二进制值,应该使用流的 write
成员方法,方法是将 Byte
重新解释为 char const *
并写入一个字节。读取 Byte
的二进制值的实现类似,使用流的 read
成员方法。
最后,以下是一些实际使用 Byte
变量的示例:
using namespace std;
int main() {
Byte b1; // 默认构造:d_byte = 0
Byte b2{12}; // 从 int 构造
Byte b3{b2}; // 拷贝构造
b1 = 65; // 直接赋值
b1 += 20; // 算术赋值
b1 <<= 1; // 位移赋值
b1 >>= 1;
b1 |= 1; // 位或赋值
uint8_t u8 = b1; // 将 Byte 转换为 uint8_t
// 一些流插入操作
cout << sizeof(Byte) << ',' << (b1 < b2) << ',' << b1 << ',' << b3 << ','
<< u8 << '\n';
// 使用 'write'
cout.write(reinterpret_cast<char const *>(&b1), 1) << '\n';
}
// 输出:
// 十六进制值 文本(.:不可打印)
// 31 2C 30 2C 55 2C 0C 2C 55 0A 55 0A 1, 0, U, ., U.U.
关键字 explicit
转换不仅由转换操作符完成,还可以由接受一个参数的构造函数完成(即,构造函数可以具有一个或多个参数,并且为所有参数指定默认值,或者为第一个参数之外的所有参数指定默认值)。
假设有一个 DataBase
类定义,其中可以存储 Person
对象。它定义了一个 Person* d_data
指针,并提供了拷贝构造函数和重载的赋值操作符。除了拷贝构造函数之外,DataBase
还提供了默认构造函数和几个额外的构造函数:
DataBase(Person const &)
:DataBase
最初包含一个Person
对象。DataBase(istream &in)
:从输入流in
中读取多个Person
对象的数据。DataBase(size_t count, istream &in = cin)
:从输入流in
中读取count
个Person
对象的数据,默认情况下使用标准输入流。
这些构造函数都很合理。但是,它们也允许编译器编译以下代码而不产生任何警告:
DataBase db;
DataBase db2;
Person person;
db2 = db; // 1
db2 = person; // 2
db2 = 10; // 3
db2 = cin; // 4
语句 1 是完全合理的:db
用于重新定义 db2
。语句 2 可能也是可以理解的,因为我们设计了 DataBase
来包含 Person
对象。然而,当看到语句 3 和 4 时,逻辑就变得更加不清晰。语句 3 实际上等待标准输入流中出现 10 个 Person
的数据。这与 db2 = 10
的意图完全不符。
隐式转换在语句 2 到 4 中被使用。由于 DataBase
类定义了分别接受 Person
、istream
和 size_t
以及 istream
的构造函数,而赋值操作符期望一个 DataBase
类型的右值参数(rhs),编译器首先将右值参数转换为匿名的 DataBase
对象,然后将其赋值给 db2
。
为了防止隐式转换的发生,建议在声明构造函数时使用 explicit
修饰符。使用 explicit
修饰符的构造函数只能用于显式地构造对象。假如构造函数接受一个参数的构造函数被声明为 explicit
,那么上述的语句 2 到 4 将会编译失败,因为它们需要显式指定适当的构造函数,从而明确程序员的意图:
DataBase db;
DataBase db2;
Person person;
db2 = db; // 1
db2 = DataBase{person}; // 2
db2 = DataBase{10}; // 3
db2 = DataBase{cin}; // 4
原则性建议:除非隐式转换是非常自然的(例如 string
的 char const *
接受构造函数),否则应在一参数构造函数前加上 explicit
关键字。
显式转换操作符
除了显式构造函数,C++ 还支持显式转换操作符。例如,一个类可能定义 operator bool() const
,如果该类的对象处于可用状态,则返回 true
,否则返回 false
。由于 bool
是一种算术类型,这可能会导致意外或不希望出现的行为。考虑以下代码:
void process(bool value);
class StreamHandler
{
public:
operator bool() const;
...
};
// true: 对象适合使用
int fun(StreamHandler &sh)
{
int sx;
}
if (sh)
// 预期使用 operator bool()
... 使用 sh 如常,此外还使用 `sx`
process(sh);
// 打字错误:`sx` 是预期的
在这个例子中,process
不小心接收到由 operator bool
返回的 bool
值,并且由于隐式转换从 bool
到 int
,这导致了意外的行为。
定义显式转换操作符可以防止类似于上述示例中的隐式转换。这样的转换操作符只能在需要显式指定转换类型的情况下使用(如 if
或 while
语句的条件子句),或者通过 static_cast
明确请求。要在 StreamHandler
类的接口中声明一个显式的 bool
转换操作符,可以将上述声明替换为:
explicit operator bool() const;
从 C++14 标准开始,istream
定义了一个显式的 operator bool() const
。因此:
while (cin.get(ch)) // 编译正常
;
bool fun1() {
return cin; // 'bool = istream' 无法编译
} // istream 定义了 'explicit operator bool'
bool fun1() {
return static_cast<bool>(cin); // 编译正常
}
重载递增和递减操作符
重载递增操作符 (operator++
) 和递减操作符 (operator--
) 引入了一个小问题:每个操作符都有两个版本,因为它们可以用作后缀操作符(例如 x++
)或前缀操作符(例如 ++x
)。
作为后缀操作符使用时,值的对象被返回为一个 rvalue(右值),这是一个临时的常量对象,后递增的变量本身不再可见。作为前缀操作符使用时,变量被递增,其值作为 lvalue(左值)返回,可以通过修改前缀操作符的返回值再次更改它。虽然这些特性在重载操作符时不是强制要求的,但强烈建议在重载递增或递减操作符时实现这些特性。
假设我们定义一个围绕 size_t
值类型的包装类。这样一个类可以提供如下(部分显示)的接口:
class Unsigned {
size_t d_value;
public:
Unsigned();
explicit Unsigned(size_t init);
Unsigned &operator++();
};
类的最后一个成员声明了前缀递增操作符的重载。返回值为 Unsigned &
。这个成员可以很容易地实现:
Unsigned &Unsigned::operator++()
{
++d_value;
return *this;
}
要定义后缀操作符,需要定义一个重载版本的操作符,接收一个(虚拟的)int
参数。这可能被认为是一种权宜之计,或者是函数重载的一个可接受的应用。无论你对此有何看法,可以得出以下结论:
- 没有参数的重载递增和递减操作符是前缀操作符,应该返回对当前对象的引用。
- 有
int
参数的重载递增和递减操作符是后缀操作符,应该返回一个在后缀操作符使用点时的对象副本。
在Unsigned
类的接口中,后缀递增操作符被声明如下:
Unsigned operator++(int);
它可以如下实现:
Unsigned Unsigned::operator++(int)
{
Unsigned tmp{ *this };
++d_value;
return tmp;
}
请注意,操作符的参数 int
并未被使用。它仅作为实现的一部分,用于在实现和声明中区分前缀和后缀操作符。
在上述示例中,递增当前对象的语句提供了 nothrow
保证,因为它只涉及对基本类型的操作。如果初始的拷贝构造抛出异常,则原始对象不会被修改;如果返回语句抛出异常,则对象已安全地被修改。
但如果递增对象本身可能抛出异常,我们如何实现递增操作符呢?再次,swap
是我们的好帮手。以下是提供强保证的前缀和后缀递增操作符,当成员递增操作可能抛出异常时:
Unsigned &Unsigned::operator++()
{
Unsigned tmp{ *this };
tmp.increment();
swap(tmp);
return *this;
}
Unsigned Unsigned::operator++(int)
{
Unsigned tmp{ *this };
tmp.increment();
swap(tmp);
return tmp;
}
这两个操作符首先创建当前对象的副本。这些副本被递增,然后与当前对象交换。如果递增操作抛出异常,则当前对象保持不变;交换操作确保返回正确的对象(前缀操作符的递增对象,后缀操作符的原始对象),并且当前对象变为递增后的对象。
当使用操作符的完整成员函数名调用递增或递减操作符时,任何传递给函数的 int
参数都会导致调用后缀操作符。省略参数会调用前缀操作符。例如:
Unsigned uns{13};
uns.operator++(); // 前缀递增 uns
uns.operator++(0); // 后缀递增 uns
当应用于 bool
类型变量时,前缀和后缀递增、递减操作符是不推荐使用的。在后缀递增操作符可能有用的情况下,应该使用 std::exchange
(参见第 19.1.13 节)。
重载二元操作符
在各种类中,重载二元操作符(如 operator+
)可以非常自然地扩展类的功能。例如,std::string
类重载了各种 operator+
成员函数。
大多数二元操作符有两种形式:普通二元操作符(如 +
操作符)和复合二元赋值操作符(如 operator+=
)。普通二元操作符返回值,而复合二元赋值操作符通常返回对调用该操作符的对象的引用。例如,对于 std::string
对象,可以使用以下代码(示例下方的注释):
std::string s1;
std::string s2;
std::string s3;
s1 = s2 += s3; // 1
(s2 += s3) + " postfix"; // 2
s1 = "prefix " + s3; // 3
"prefix " + s3 + "postfix"; // 4
- 在 // 1 中,
s3
的内容被添加到s2
。然后,s2
被返回,并将其新内容赋给s1
。注意+=
返回的是s2
。 - 在 // 2 中,
s3
的内容也被添加到s2
,但由于+=
返回的是s2
本身,所以可以进一步添加内容。 - 在 // 3 中,
+
操作符返回一个std::string
,包含文本"prefix "
和s3
的内容。这个由+
操作符返回的字符串随后赋给s1
。 - 在 // 4 中,
+
操作符被应用两次。效果如下:- 第一个
+
返回一个std::string
,包含文本"prefix "
和s3
的内容。 - 第二个
+
操作符将这个返回的字符串作为左操作数,返回一个包含其左操作数和右操作数拼接文本的字符串。 - 第二个
+
操作符返回的字符串表示表达式的值。
- 第一个
现在考虑以下代码,其中 Binary
类支持重载的 operator+
:
class Binary {
public:
Binary();
Binary(int value);
Binary operator+(Binary const &rhs);
};
int main() {
Binary b1;
Binary b2{5};
b1 = b2 + 3; // 1
b1 = 3 + b2; // 2
}
编译这段小程序时,语句 // 2 会失败,编译器报告错误,如:
error: no match for 'operator+' in '3 + b2'
为什么语句 // 1 可以编译而语句 // 2 不行?
为了理解这个问题,请记住类型转换。当提供了适当类型的参数时,构造函数可以被隐式调用。我们在 std::string
对象中已经遇到过这种情况,其中 NUL 结尾的字符串可以用来初始化 std::string
对象。
类似地,在语句 // 1 中,operator+
被调用,使用 b2
作为其左操作数。这个操作符期望另一个 Binary
对象作为其右操作数。然而,提供了一个 int
。由于存在 Binary(int)
构造函数,int
值可以被提升为 Binary
对象。然后,这个 Binary
对象作为参数传递给 operator+
成员函数。
不幸的是,在语句 // 2 中,提升是不可能的:这里的 +
操作符应用于 int
类型的左操作数。int
是一种基本类型,基本类型没有关于“构造函数”、“成员函数”或“提升”的知识。
那么,如何在像 "prefix " + s3
这样的语句中实现提升呢?
由于提升可以应用于函数参数,我们必须确保二元操作符的两个操作数都是参数。这意味着,支持对其左操作数或右操作数进行提升的普通二元操作符应该被声明为自由操作符,也称为自由函数。
像普通二元操作符这样的函数在概念上属于它们实现这些操作符的类。因此,它们应该在类的头文件中声明。我们将在下面覆盖它们的实现,但这是 Binary
类的声明修订版,声明了作为自由函数的重载 +
操作符:
class Binary
{
public:
Binary();
Binary(int value);
};
Binary operator+(Binary const &lhs, Binary const &rhs);
定义为自由函数的二元操作符提供了几种提升的可能性:
- 如果左操作数是目标类类型,则右操作数会被提升(如果可能的话);
- 如果右操作数是目标类类型,则左操作数会被提升(如果可能的话);
- 当两个操作数都不是目标类类型时,不会发生提升;
- 当对两个操作数的提升可能会导致不同的类时,会出现歧义。
例如:
class A;
class B {
public:
B(A const &a);
};
class A {
public:
A();
A(B const &b);
};
A operator+(A const &a, B const &b);
B operator+(B const &b, A const &a);
int {
main() {
A a;
a + a;
}
}
在这里,a + a
编译时有两个重载的 +
操作符可能是候选项。必须通过显式提升其中一个参数来解决歧义,例如 a + B{a}
,这使得编译器能够将歧义解析为第一个重载的 +
操作符。
接下来的步骤是实现所需的重载二元复合赋值操作符,形式为 @=
, 其中 @
代表一个二元操作符。这些操作符总是有左操作数是它们自己类的对象,因此它们作为真正的成员函数实现。复合赋值操作符通常返回对调用这些操作符的对象的引用,因为这些对象可能会在同一个语句中被修改。例如 (s2 += s3) + " postfix"
。
以下是 Binary
类的第二个修订版,显示了普通二元操作符以及相应的复合赋值操作符的声明:
class Binary
{
public:
Binary();
Binary(int value);
Binary &operator+=(Binary const &rhs);
};
Binary operator+(Binary const &lhs, Binary const &rhs);
那么,复合加法赋值操作符应该如何实现呢?在实现复合二元赋值操作符时,始终应该考虑强保证:如果操作可能会抛出异常,使用临时对象并交换。以下是复合赋值操作符的实现:
Binary & Binary::operator+=(Binary const &rhs) {
Binary tmp{*this};
tmp.add(rhs);
// 这可能会抛出异常
swap(tmp);
return *this;
}
实现自由二元操作符非常简单:将左操作数(lhs
)复制到一个 Binary tmp
对象中,然后将右操作数(rhs
)添加到 tmp
中。然后返回 tmp
,使用拷贝消除。Binary
类声明自由二元操作符为友元(参见第15章),这样它可以调用 Binary
的 add
成员函数:
class Binary
{
friend Binary operator+(Binary const &lhs, Binary const &rhs);
public:
Binary();
Binary(int value);
Binary &operator+=(Binary const &other);
private:
void add(Binary const &other);
};
二元操作符的实现如下:
Binary operator+(Binary const &lhs, Binary const &rhs)
{
Binary tmp{ lhs };
tmp.add(rhs);
return tmp;
}
如果 Binary
类支持移动语义,那么添加支持移动的二元操作符会更有吸引力。在这种情况下,我们还需要对左操作数为右值引用的操作符。由于类支持移动语义,许多有趣的实现变得可能,如下所示:
首先,查看这样的二元操作符的签名(它也应该在类接口中声明为友元):
Binary operator+(Binary &&lhs, Binary const &rhs);
由于 lhs
操作数是右值引用,我们可以对其进行任意修改。二元操作符通常被设计为工厂函数,返回由这些操作符创建的对象。然而,lhs
指向的(修改后的)对象本身不应作为返回值返回。根据 C++ 标准:
绑定到函数调用中的引用参数的临时对象在包含调用的完整表达式完成之前会存在。
此外:
绑定到函数返回语句中的返回值的临时对象的生命周期不会延长;临时对象会在返回语句的完整表达式结束时被销毁。
换句话说,临时对象本身不能作为函数的返回值返回,因此 Binary &&
返回类型不应使用。因此,实现二元操作符的函数是工厂函数(不过,返回的对象可以使用类的移动构造函数来构造,只要需要返回一个临时对象)。
另一种方法是,二元操作符首先通过从操作符的 lhs
操作数进行移动构造来创建一个对象,然后对该对象和操作符的 rhs
操作数执行二元操作,并返回修改后的对象(允许编译器应用拷贝消除)。哪一种方法更受欢迎是个人的口味问题。
以下是两种实现方式。由于拷贝消除,显式定义的 ret
对象在返回值的位置上创建。这两种实现虽然看起来不同,但在运行时行为上是相同的:
// 第一种实现:修改 lhs
Binary operator+(Binary &&lhs, Binary const &rhs)
{
lhs.add(rhs);
return std::move(lhs);
}
// 第二种实现:从 lhs 移动构造 ret
Binary operator+(Binary &&lhs, Binary const &rhs)
{
Binary ret{ std::move(lhs) };
ret.add(rhs);
return ret;
}
现在,当执行类似 (所有 Binary 对象) b1 + b2 + b3
的表达式时,将调用以下函数:
- 拷贝构造函数:
tmp(b1)
- 添加操作:
tmp.add(b2)
- 拷贝消除:
tmp
从b1 + b2
中返回 - 移动操作符
+
:tmp + b3
- 添加操作:
tmp.add(b3)
- 移动构造:
tmp2(move(tmp))
被返回
但这还不是全部:在下一节中,我们将遇到关于复合赋值操作符的更多有趣实现。
成员函数引用绑定(& 和 &&)
我们已经看到,二元操作符(如 operator+
)可以非常高效地实现,但需要至少有移动构造函数。
考虑以下表达式:
Binary{} + varB + varC + varD
这将依次返回一个移动构造的对象,表示 Binary{} + varB
,然后是另一个移动构造的对象接收第一个返回值和 varC
,最后是另一个移动构造的对象接收第二个返回的对象和 varD
作为参数。
现在,考虑一个定义了 Binary &&
参数和一个 Binary const &
参数的函数。该函数内部需要将这些值相加,然后将它们的和作为参数传递给其他两个函数。我们可以这样做:
void fun1(Binary &&lhs, Binary const &rhs)
{
lhs += rhs;
fun2(lhs);
fun3(lhs);
}
但请注意,当使用 operator+=
时,我们首先会构造一个当前对象的副本,以便在临时对象上执行添加操作,然后将临时对象与当前对象交换,以提交结果。但等等!我们的 lhs
操作数已经是一个临时对象了。那么为什么还要再创建一个呢?
在这个例子中,确实不需要另一个临时对象:lhs
会一直存在直到 fun1
结束。但与二元操作符不同,二元复合赋值操作符没有显式定义的左操作数。然而,我们仍然可以告知编译器,特定的成员(因此,不仅仅是复合赋值操作符)仅在调用这些成员的对象是匿名临时对象或非匿名(可修改或不可修改)对象时才应使用。为此,我们使用引用绑定,也称为引用限定符。
引用绑定由一个引用符号(&
),可选地前面加上 const
,或者一个右值引用符号(&&
)组成。这些引用限定符直接附加在函数的头部(这适用于声明和实现)。编译器在使用匿名临时对象时会选择提供右值引用绑定的函数,而在使用其他类型的对象时会选择提供左值引用绑定的函数。
引用限定符允许我们精确调整复合赋值操作符(如 operator+=
)的实现。如果我们知道调用复合赋值操作符的对象本身是一个临时对象,那么不需要另一个临时对象。操作符可以直接执行操作,然后返回自身作为右值引用。以下是针对临时对象的 operator+=
实现:
Binary &&Binary::operator+=(Binary const &rhs) &&
{
add(rhs); // 直接将 rhs 添加到 *this
return std::move(*this); // 返回临时对象本身
}
这种实现是最快的。但要小心:在前面的一节中,我们学到临时对象在返回语句的完整表达式结束时会被销毁。在这种情况下,临时对象已经存在,因此(也见前面的一节)它应在包含(operator+=
)函数调用的表达式完成之前持续存在。因此:
cout << (Binary{} += existingBinary) << '\n';
是可以的,但:
Binary &&rref = (Binary{} += existingBinary);
cout << rref << '\n';
则不行,因为 rref
在初始化后立即变成了悬空引用。
一种完全安全的替代实现是返回一个移动构造的副本:
Binary Binary::operator+=(Binary const &rhs) &&
{
add(rhs); // 直接将 rhs 添加到 *this
return std::move(*this); // 返回一个移动构造的副本
}
这种完全安全的实现的代价是额外的移动构造。现在,使用前面的示例(使用 rref
),operator+=
返回 Binary{}
临时对象的副本,这仍然是一个可以安全引用的临时对象。
选择使用哪种实现可能是个人选择的问题:如果 Binary
的用户知道他们在做什么,则可以使用前一种实现,因为这些用户不会使用上述 rref
初始化。如果你不太确定你的用户,使用后一种实现:正式地说,你的用户会做一些不该做的事,但不会有额外的惩罚。
对于由左值引用(即,命名对象)调用的复合赋值操作符,我们使用前面一节中的 operator+=
实现(注意引用限定符):
Binary &Binary::operator+=(Binary const &rhs) &
{
Binary tmp(*this);
tmp.add(rhs); // 这可能会抛出异常
swap(tmp);
return *this;
}
使用以下实现来将 Binary
对象相加(例如,b1 += b2 += b3
)会变成:
-
operator+=(&) = b2 += b3
- 拷贝构造函数 =
tmp(b2)
- 添加 =
tmp.add(b3)
- 交换 =
b2 <-> tmp
- 返回 =
b2
- 拷贝构造函数 =
-
operator+=(&) = b1 += b2
- 拷贝构造函数 =
tmp(b1)
- 添加 =
tmp.add(b2)
- 交换 =
b1 <-> tmp
- 返回 =
b1
- 拷贝构造函数 =
当最左边的对象是一个临时对象时,拷贝构造和交换调用会被匿名对象的构造替代。例如,对于 Binary{} += b2 += b3
,我们观察到:
-
operator+=(&) = b2 += b3
- 拷贝构造函数 =
tmp(b2)
- 添加 =
tmp.add(b3)
- 交换 =
b2 <-> tmp
- 拷贝构造函数 =
-
匿名对象 (&&) =
Binary{}
operator+=
=Binary{} += b2
- 添加 =
add(b2)
- 返回 =
move(Binary{})
对于 Binary &Binary::operator+=(Binary const &rhs) &
,存在一种替代实现,只使用单个返回语句,但实际上需要两个额外的函数调用。这是一个个人选择的问题,你可以选择写更少的代码还是执行更少的函数调用:
Binary &Binary::operator+=(Binary const &rhs) &
{
return *this = Binary{ *this } += rhs;
}
请注意,operator+
和 operator+=
的实现与 Binary
类的实际定义是独立的。因此,为类添加标准二元操作符(即,操作自己的类类型参数的操作符)是很容易实现的。
三路比较操作符 <=>
C++23 标准引入了三路比较操作符 <=>
,也称为宇宙飞船操作符。
该操作符与比较类(在第18.7节中介绍)密切相关。在这一部分,我们重点介绍使用 std::strong_ordering
类。使用三路比较操作符返回的对象有:
strong_ordering::equal
:如果两个操作数相等;strong_ordering::less
:如果左侧操作数小于右侧操作数;strong_ordering::greater
:如果左侧操作数大于右侧操作数。
标准操作数转换由编译器处理。请注意:
- 如果一个操作数是
bool
类型,则另一个操作数也必须是bool
类型; - 不允许缩窄转换,除了从整型到浮点型的转换;
- 当操作数是相同枚举类型时,其值会转换为基础整型,之后进行比较。
其他标准转换,如左值转换和限定符转换,会自动执行。
为什么要使用三路比较操作符?如果它被定义了,你当然可以使用它。例如,对于整数类型,以下代码是正确编译的:
auto isp = 3 <=> 4;
之后,isp
的值可以与可用的结果值进行比较:
cout << (isp == strong_ordering::less ? "less\n" : "not less\n");
这本身并不会使三路比较操作符特别有趣。但它的一个重要特点是,与 operator==
结合使用时,可以处理所有比较操作符。也就是说,当一个类定义了 operator==
和 operator<=>
后,它的对象可以进行相等性比较、不相等性比较,还可以按 <
、<=
、>
和 >=
进行排序。例如,考虑一本书。对于书的拥有者,书名和作者名是书的主要特征。为了在书架上对它们进行排序,我们需要使用 operator<
;为了找到特定的书籍,我们使用 operator==
;为了确定两本书是否不同,我们使用 operator!=
;如果你希望按照书写顺序进行排序(如在主要阅读方向为从右到左的阿拉伯国家),你可能会使用 operator>
。
忽略构造函数、析构函数和其他成员,Book
类的接口如下(注意包含 <compare>
头文件,其中包含比较类的声明):
#include <compare>
#include <string>
class Book
{
friend bool operator==(Book const &lhs, Book const &rhs);
friend std::strong_ordering operator<=>(Book const &lhs, Book const &rhs);
std::string d_author;
std::string d_title;
};
// ... 两个友元函数的实现很简单:
bool operator==(Book const &lhs, Book const &rhs)
{
return lhs.d_author == rhs.d_author && lhs.d_title == rhs.d_title;
}
std::strong_ordering operator<=>(Book const &lhs, Book const &rhs)
{
return lhs.d_author < rhs.d_author ? std::strong_ordering::less
: lhs.d_author > rhs.d_author ? std::strong_ordering::greater
: lhs.d_title < rhs.d_title ? std::strong_ordering::less
: lhs.d_title > rhs.d_title ? std::strong_ordering::greater
: std::strong_ordering::equal;
}
就这样!现在所有比较操作符(以及三路比较操作符本身)都可用。以下代码可以正确编译:
void books(Book const &b1, Book const &b2)
{
cout << (b1 == b2) << (b1 != b2) << (b1 < b2) <<
(b1 <= b2) << (b1 > b2) << (b1 >= b2) << '\n';
}
调用 books
函数用于两个相同的书籍时,会在 cout
中输出 100101
。
三路比较操作符可用于整数类型,并且可能已为类类型定义。例如,它已在 std::string
中定义。但对于浮点类型,它不会自动可用。
重载 operator new(size_t)
当重载 operator new
时,它必须定义为 void*
返回类型,并且第一个参数必须是 size_t
类型。默认的 operator new
仅定义了一个参数,但重载版本可以定义多个参数。第一个参数并没有被显式指定,而是根据为其重载 operator new
的类的对象大小来推断。在本节中讨论了如何重载 operator new
。对于 new[]
的重载,请参见第11.10节。
可以定义多个版本的 operator new
,只要每个版本定义了一组唯一的参数。当重载的 operator new
成员需要动态分配内存时,它们可以使用全局的 operator new
,并应用作用域解析运算符 ::
。
在下例中,类 String
的重载 operator new
将动态分配的 String
对象的基底初始化为 0 字节:
#include <cstring>
#include <iosfwd>
class String
{
std::string* d_data;
public:
void* operator new(size_t size)
{
return memset(::operator new(size), 0, size);
}
bool empty() const
{
return d_data == 0;
}
};
上面的 operator new
在以下程序中使用,说明尽管 String
的默认构造函数没有做任何事情,但对象的成员数据 d_data
已经初始化为零:
#include <iostream>
#include "string.h"
using namespace std;
int main()
{
String *sp = new String;
cout << boolalpha << sp->empty() << '\n'; // 显示: true
}
在执行 new String
时,发生了以下情况:
- 首先,调用了
String::operator new
,分配并初始化了一块内存,大小为String
对象的大小。 - 接着,将这块内存的指针传递给(默认)
String
构造函数。由于没有定义构造函数,因此构造函数本身什么也没有做。
由于 String::operator new
初始化了分配的内存为零字节,因此在 String
对象开始存在时,其成员 d_data
已经被初始化为零指针。
所有的成员函数(包括构造函数和析构函数)都定义了一个(隐藏的)指向它们应操作的对象的指针。这个隐藏指针成为函数的 this
指针。
在下例的伪 C++ 代码中,指针被显式地显示出来,以说明在使用 operator new
时发生的情况。在第一部分直接定义了一个 String
对象 str
,在第二部分示例中使用了(重载的)operator new
:
String::String(String *const this); // 默认构造函数的真实原型
String *sp = new String;
// 这条语句的实现如下:
String *sp = static_cast<String *>(
// 分配
String::operator new(sizeof(String))
);
String::String{ sp }; // 初始化
在上述片段中,成员函数被视为 String
类的无对象成员函数。这些成员函数称为静态成员函数(参见第8章)。实际上,operator new
就是这样一个静态成员函数。由于它没有 this
指针,因此无法访问为其预期分配内存的对象的成员数据。它只能分配和初始化分配的内存,但不能通过名称访问对象的成员,因为在此时还没有定义数据对象布局。
分配内存后,该内存作为 this
指针传递给构造函数以进行进一步处理。
operator new
可以具有多个参数。第一个参数作为隐式参数初始化,总是 size_t
类型。额外的重载操作符可以定义其他参数。一个有趣的额外 operator new
是放置 new 操作符(placement new operator)。使用放置 new 操作符时,已经预留了一块内存,并且使用该内存的构造函数来初始化该内存。重载放置 new 需要一个具有两个参数的 operator new
:size_t
和 char *
,指向已经可用的内存。
size_t
参数是隐式初始化的,但剩余的参数必须使用 operator new
的参数显式初始化。因此,我们达到了放置 new 操作符的熟悉语法形式:
char buffer[sizeof(String)];
// 预定义的内存
String *sp = new(buffer) String;
// 放置 new 调用
在 String
类中,放置 new 操作符的声明如下:
void *operator new(size_t size, char *memory);
它可以像这样实现(同时将 String
的内存初始化为 0 字节):
void *String::operator new(size_t size, char *memory)
{
return memset(memory, 0, size);
}
还可以定义其他重载版本的 operator new
。以下是一个示例,展示了一个重载的 operator new
,将对象的地址立即存储在现有的 String
对象指针数组中(假设数组足够大):
// 使用:
String *next(String **pointers, size_t *idx)
{
return new(pointers, (*idx)++) String;
}
// 实现:
void *String::operator new(size_t size, String **pointers, size_t idx)
{
return pointers[idx] = ::operator new(size);
}
重载 operator delete(void *)
delete
操作符也可以被重载。实际上,重载 operator delete
是一个好的实践,特别是当 operator new
被重载时。
operator delete
必须定义一个 void *
参数。第二个重载版本定义一个 size_t
类型的第二个参数,与重载 operator new[]
相关,这将在第11.10节中讨论。
重载的 operator delete
成员函数返回 void
。
自定义的 operator delete
在执行关联类的析构函数之后用于删除动态分配的对象。因此,语句:
delete ptr;
其中 ptr
是指向 String
类对象的指针,对于这个类重载了 operator delete
,实际上是以下语句的简写:
ptr->~String(); // 调用类的析构函数
// 对 ptr 指向的内存执行操作
String::operator delete(ptr);
重载的 operator delete
可以对 ptr
指向的内存执行任何操作。例如,如果希望执行默认的删除操作,可以使用 ::
范围解析操作符调用默认的 delete
操作符。例如:
void String::operator delete(void *ptr)
{
// 执行任何必要的操作,然后,可能:
::delete ptr;
}
要声明上述重载的 operator delete
,只需将以下行添加到类的接口中:
void operator delete(void *ptr);
与 operator new
一样,operator delete
是一个静态成员函数(参见第8章)。
操作符 new[]
和 delete[]
在第9.1.1、9.1.2 和 9.2.1节中介绍了 operator new[]
和 operator delete[]
。与 operator new
和 operator delete
一样,new[]
和 delete[]
操作符也可以被重载。
由于可以重载 new[]
和 delete[]
以及 operator new
和 operator delete
,因此在选择适当的操作符集时需要小心。以下规则应始终应用:
- 如果使用
new
来分配内存,应使用delete
来释放内存。 - 如果使用
new[]
来分配内存,应使用delete[]
来释放内存。
默认情况下,这些操作符的行为如下:
operator new
用于分配单个对象或基本值。如果是对象,则调用该对象的构造函数。operator delete
用于释放由operator new
分配的内存。同样,对于类类型的对象,会调用类的析构函数。operator new[]
用于分配一系列基本值或对象。如果分配了一系列对象,则会调用每个对象的默认构造函数来初始化每个对象。operator delete[]
用于释放之前通过new[]
分配的内存。如果之前分配了对象,则会调用每个对象的析构函数。然而,需要注意的是,如果分配的是对象的指针,指针指向的对象的析构函数不会被自动调用。指针是基本类型,因此在返回到通用池时不会执行其他操作。
重载 new[]
要在一个类(例如 String
类)中重载 operator new[]
,可以在类的接口中添加以下行:
void *operator new[](size_t size);
这个成员函数的 size
参数由 C++ 运行时系统隐式提供,并初始化为必须分配的内存量。像单个对象的 operator new
一样,它应该返回一个 void *
。可以通过计算 size / sizeof(String)
(当然,替换 String
为适当的类名,如果你为其他类重载 operator new[]
)来轻松计算需要初始化的对象数量。重载的 new[]
成员可以使用例如默认的 operator new[]
或 operator new
来分配原始内存:
void *operator new[](size_t size)
{
return ::operator new[](size);
// 或者:
// return ::operator new(size);
}
在返回分配的内存之前,重载的 operator new[]
有机会执行一些特殊操作。例如,它可以将内存初始化为零字节。
一旦定义了重载的 operator new[]
,在如下语句中会自动使用它:
String *op = new String[12];
与 operator new
一样,也可以定义 operator new[]
的附加重载。一个重载的机会是专门为对象数组重载位置 new
。这个操作符在默认情况下是可用的,但一旦定义了至少一个重载的 operator new[]
,它就会变得不可用。实现位置 new
并不困难。以下是一个例子,在返回之前将可用内存初始化为零字节:
void *String::operator new[](size_t size, char *memory)
{
return memset(memory, 0, size);
}
要使用这个重载的操作符,必须再次提供第二个参数,例如:
char buffer[12 * sizeof(String)];
String *sp = new(buffer) String[12];
重载 delete[]
要在一个类(例如 String
类)中重载 operator delete[]
,可以在类的接口中添加以下行:
void operator delete[](void *memory);
该参数被初始化为先前由 String::new[]
分配的内存块的地址。
实现 operator delete[]
时需要注意一些细节。虽然 new
和 new[]
返回的地址指向已分配的对象,但在这些地址返回的内存块之前,还有一个 size_t
值存在。这个 size_t
值是内存块的一部分,包含了块的实际大小。然而,这一点对于位置 new
操作符是不适用的。
当一个类定义了析构函数时,new[]
返回的地址之前的 size_t
值不包含已分配块的大小,而是包含调用 new[]
时指定的对象数量。通常这并不重要,但在重载 operator delete[]
时,这可能成为有用的信息。在这种情况下,operator delete[]
并没有接收到 new[]
返回的地址,而是接收了初始 size_t
值的地址。虽然这是否有用还不明确,但到 delete[]
执行时,所有对象都已经被销毁,因此 operator delete[]
只是用来确定被销毁了多少对象,而对象本身已经不能再使用了。
以下是一个示例,展示了 operator delete[]
的这种行为,对于一个最小的 Demo
类:
struct Demo
{
size_t idx;
Demo()
{
cout << "default cons\n";
}
~Demo()
{
cout << "destructor\n";
}
void *operator new[](size_t size)
{
return ::operator new(size);
}
void operator delete[](void *vp)
{
cout << "delete[] for: " << vp << '\n';
::operator delete[](vp);
}
};
int main()
{
Demo *xp;
cout << ((int *)(xp = new Demo[3]))[-1] << '\n';
cout << xp << '\n';
cout << "==================\n";
delete[] xp;
}
这个程序会显示(你的 0x??? 地址可能不同,但两者之间的差异应该是 sizeof(size_t)
):
default cons
default cons
default cons
3
0x8bdd00c
==================
destructor
destructor
destructor
delete[] for: 0x8bdd008
重载 operator delete[]
后,它将自动用于如下语句:
delete[] new String[5];
operator delete[]
也可以使用额外的 size_t
参数进行重载:
void operator delete[](void *p, size_t size);
其中 size
被自动初始化为 void *p
指向的内存块的大小(以字节为单位)。如果定义了这种形式,则不应定义 void operator[](void *)
以避免歧义。以下是这种 operator delete[]
形式的示例:
void String::operator delete[](void *ptr, size_t size)
{
cout << "deleting " << size << " bytes\n";
::operator delete[](ptr);
}
可以定义 operator delete[]
的其他重载,但要使用它们,必须明确地将它们作为静态成员函数调用。示例:
// 声明:
void String::operator delete[](void *ptr, ostream &out);
// 使用:
String *xp = new String[3];
String::operator delete[](xp, cout);
operator delete(void *, size_t)
系列
正如我们所见,类可以重载它们的 operator delete
和 operator delete[]
成员。从 C++14 标准开始,全局的 void operator delete(void *, size_t)
和 void operator delete[](void *, size_t)
函数也可以被重载。
当定义了全局的大小化内存释放函数时,它会自动替代默认的、无大小参数的释放函数。如果提供了大小化内存释放函数,程序的性能可能会有所提升。这是因为大小化内存释放函数可以利用块的大小信息来优化内存管理和释放过程,从而提高效率。有关详细信息,可以参阅相关文档:N3663。
new[]
、delete[]
和异常
当在执行 new[]
表达式时抛出异常,会发生什么?在这一节中,我们将展示即使只有部分对象正确构造,new[]
也是异常安全的。
首先,new[]
在尝试分配所需内存时可能会抛出异常。在这种情况下,会抛出 bad_alloc
异常,并且不会发生内存泄漏,因为没有分配任何内存。
在成功分配所需内存后,将使用类的默认构造函数逐一构造每个对象。此时,如果某个构造函数抛出异常,C++ 标准定义了接下来的行为:已经构造的对象的析构函数会被调用,并且为对象分配的内存会被返回到公共池中。因此,即使构造函数可能会抛出异常,只要构造函数提供了基本保证,new[]
也是异常安全的。
下面的示例演示了这种行为。请求分配并初始化五个对象,但在构造两个对象后,构造失败并抛出异常。输出显示了已正确构造的对象的析构函数被调用,以及分配的内存被正确返回:
#include <iostream>
using namespace std;
static size_t count = 0;
class X {
int x;
public:
X() {
if (count == 2)
throw 1;
cout << "Object " << ++count << '\n';
}
~X() {
cout << "Destroyed " << this << "\n";
}
void *operator new[](size_t size) {
cout << "Allocating objects: " << size << " bytes\n";
return ::operator new(size);
}
void operator delete[](void *mem) {
cout << "Deleting memory at " << mem
<< ", containing: " << *static_cast<int *>(mem) << "\n";
::operator delete(mem);
}
};
int main() try {
X *xp = new X[5];
cout << "Memory at " << xp << '\n';
delete[] xp;
} catch (...) {
cout << "Caught exception.\n";
}
程序的输出(你的 0x???
地址可能会有所不同):
Allocating objects: 24 bytes
Object 1
Object 2
Destroyed 0x8428010
Destroyed 0x842800c
Deleting memory at 0x8428008, containing: 5
Caught exception.
函数对象
函数对象是通过重载函数调用运算符 operator()
创建的。通过定义函数调用运算符,一个对象可以伪装成一个函数,因此称为函数对象。函数对象也称为仿函数(functors)。
函数对象在使用泛型算法时非常重要。与函数指针相比,函数对象被更为偏好。虽然在泛型算法的讨论中了解函数对象是必要的,但在当前的 C++ 注解中可能还没有介绍泛型算法。为了解决这种依赖关系,我们将暂时忽略泛型算法,专注于函数对象的概念。
函数对象是那些定义了 operator()
的对象。函数对象不仅在泛型算法中使用,也作为函数指针的(更偏好的)替代品。
函数对象通常用于实现谓词函数。谓词函数返回布尔值。谓词函数和谓词函数对象通常被称为“谓词”。谓词通常用于泛型算法,例如 count_if
泛型算法,用于返回函数对象返回 true
的次数。在标准模板库中,使用了两种谓词:一元谓词接收一个参数,二元谓词接收两个参数。
假设我们有一个 Person
类和一个 Person
对象的数组。进一步假设数组未排序。一个寻找特定 Person
对象的常用方法是使用 lsearch
函数,它执行线性搜索。示例代码如下:
Person &target = targetPerson(); // 确定要查找的人
Person *pArray;
size_t n = fillPerson(&pArray);
cout << "The target person is";
if (not lsearch(&target, pArray, &n, sizeof(Person), compareFunction))
cout << " not";
cout << " found\n";
函数 targetPerson
确定我们要寻找的人,fillPerson
被调用来填充数组。然后使用 lsearch
来定位目标对象。
比较函数必须可用,因为它的地址是 lsearch
的参数之一。它必须是一个真实的函数,并具有地址。如果它是内联定义的,那么编译器必须忽略该请求,因为内联函数没有地址。compareFunction
可能如下实现:
int compareFunction(void const *p1, void const *p2)
{
return *static_cast<Person const *>(p1) != *static_cast<Person const *>(p2);
}
这当然假设 Person
类中已经重载了 operator!=
。不过,重载 operator!=
并不复杂,因此可以假设该运算符是可用的。
在平均情况下,以下操作至少会发生 n / 2
次:
- 比较函数的两个参数被压入栈中;
- 确定
lsearch
的最终参数,产生compareFunction
的地址; - 调用比较函数;
- 然后,在比较函数中,右侧参数的地址被压入栈中;
Person::operator!=
被评估;Person::operator!=
函数的参数被弹出栈;- 比较函数的两个参数被弹出栈。
使用函数对象会得到不同的结果。假设我们构造了一个函数 PersonSearch
,其原型如下(这不是首选的方法。通常更推荐使用泛型算法,而不是自定义函数。但为了说明函数对象的使用和实现,我们使用 PersonSearch
):
Person const *PersonSearch(Person *base, size_t nmemb, Person const &target);
这个函数可以如下使用:
Person &target = targetPerson();
Person *pArray;
size_t n = fillPerson(&pArray);
cout << "The target person is";
if (!PersonSearch(pArray, n, target))
cout << " not";
cout << "found\n";
到目前为止,我们只是替换了对 lsearch
的调用,改为调用 PersonSearch
。现在看看 PersonSearch
本身:
Person const *PersonSearch(Person *base, size_t nmemb, Person const &target)
{
for (int idx = 0; idx < nmemb; ++idx)
if (target(base[idx]))
return base + idx;
return 0;
}
PersonSearch
实现了一个简单的线性搜索。然而,在 for 循环中我们看到 target(base[idx])
。在这里,target
作为一个函数对象被使用。它的实现很简单:
bool Person::operator()(Person const &other) const
{
return *this == other;
}
注意这有些奇特的语法:operator()
。第一组括号定义了被重载的运算符:函数调用运算符。第二组括号定义了为此重载运算符所需的参数。在类头文件中,这个重载的运算符声明为:
bool operator()(Person const &other) const;
显然,Person::operator()
是一个简单的函数。它只有一个语句,我们可以考虑将其定义为内联。假设我们这样做了,那么在调用 operator()
时会发生什么:
Person::operator==
的右侧参数的地址被压入栈中;- 评估
operator==
函数(这通常也比在寻找与指定目标对象相等的对象时调用operator!=
更具语义改进); Person::operator==
的参数从栈中弹出。
由于 operator()
是一个内联函数,因此它实际上没有被调用。相反,operator==
会立即被调用。此外,所需的栈操作也相当简单。
函数对象可以真正地定义为内联函数。被间接调用的函数(即,通过函数指针调用的函数)永远不能定义为内联,因为它们的地址必须是已知的。因此,即使函数对象的工作量很小,如果它要通过指针调用,它仍会被定义为普通函数。间接调用的开销可能会抹杀间接调用函数的灵活性的优势。在这些情况下,使用内联函数对象可以提高程序的效率。
函数对象的另一个好处是它们可以访问对象的私有数据。在使用比较函数的搜索算法中(如 lsearch
),目标和数组元素通过指针传递给比较函数,这涉及到额外的栈处理。使用函数对象时,目标对象在单个搜索任务中不会变化。因此,可以将目标对象传递给函数对象的类构造函数。这实际上发生在表达式 target(base[idx])
中,其中目标对象作为唯一参数传递给搜索的数组的后续元素。
构造操纵符
在第六章中,我们看到像 cout << hex << 13
这样的构造用来以十六进制格式显示值 13。可能会有人好奇,hex
操纵符是如何实现这一功能的。在这一节中,我们将介绍像 hex
这样的操纵符是如何构造的。
实际上,构造一个操纵符是相当简单的。首先,需要定义操纵符。假设我们要创建一个操纵符 w10
,它将设置 ostream
对象中下一个字段的宽度为 10。这个操纵符被构造为一个函数。w10
函数需要知道需要设置宽度的 ostream
对象。通过提供一个 ostream &
参数,函数可以获得这个知识。现在函数知道了我们指的是哪个 ostream
对象,它就可以在该对象中设置宽度。
接下来,必须能够在插入序列中使用操纵符。这意味着操纵符的返回值也必须是对 ostream
对象的引用。
根据以上考虑,我们现在可以构造 w10
函数了:
#include <iostream>
#include <iomanip>
std::ostream &w10(std::ostream &str)
{
return str << std::setw(10);
}
w10
函数当然可以以“独立”模式使用,但它也可以作为操纵符使用。例如:
#include <iostream>
#include <iomanip>
using namespace std;
extern ostream &w10(ostream &str);
int main()
{
w10(cout) << 3 << " ships sailed to America\n";
cout << "And " << w10 << 3 << " more ships sailed too.\n";
}
w10
函数可以作为操纵符使用,因为 ostream
类有一个重载的 operator<<
,它接受一个指向函数的指针,该函数接受一个 ostream &
并返回一个 ostream &
。其定义为:
ostream &operator<<(ostream &(*func)(ostream &str))
{
return (*func)(*this);
}
例如,这个 std::ostream &(∗func)(std::ostream &str)
函数是 std::endl
操纵符的签名。
除了上述重载的 operator<<
,还有一个定义如下:
ios_base &operator<<(ios_base &(*func)(ios_base &base))
{
(*func)(*this);
return *this;
}
这个函数用于插入像 hex
或 internal
这样的操纵符。
上述方法不适用于需要参数的操纵符。确实可以重载 operator<<
以接受一个 ostream
引用和一个期望 ostream &
和,例如,一个 int
的函数地址,但虽然可以通过 <<
运算符指定这样的函数地址,参数本身却不能被指定。因此,人们会想知道以下构造是如何实现的:
cout << setprecision(3);
在这种情况下,操纵符被定义为一个宏。然而,宏是预处理器的领域,并且容易受到不希望的副作用的影响。在 C++ 程序中,应该尽量避免使用宏。以下部分将介绍一种实现需要参数的操纵符的方法,而无需使用宏,而是使用匿名对象。
需要参数的操纵符
需要参数的操纵符可以实现为宏:它们由预处理器处理,并且在预处理阶段之后不可用。
可以在不使用宏的情况下定义需要参数的操纵符。一种适合于修改全局可用对象(如 cin
或 cout
)的解决方案是基于使用匿名对象:
- 首先,定义一个类,例如
Align
,其构造函数接受配置所需操纵的参数。在我们的示例中,分别是字段宽度和对齐类型。 - 该类还支持一个重载的插入(或提取)运算符。例如,
ostream &operator<<(ostream &ostr, Align const &align)
。 - 接下来,将(匿名)对象插入流中。插入运算符将流传递给
Align::operator()
,允许该成员配置(并返回)提供的流。
以下是使用这种自定义操纵符的示例程序,该操纵符需要多个参数:
#include <iostream>
#include <iomanip>
class Align
{
unsigned d_width;
std::ios::fmtflags d_alignment;
public:
Align(unsigned width, std::ios::fmtflags alignment);
std::ostream &operator()(std::ostream &ostr) const;
};
Align::Align(unsigned width, std::ios::fmtflags alignment)
: d_width(width), d_alignment(alignment) {}
std::ostream &Align::operator()(std::ostream &ostr) const
{
ostr.setf(d_alignment, std::ios::adjustfield);
return ostr << std::setw(d_width);
}
std::ostream &operator<<(std::ostream &ostr, Align &&align)
{
return align(ostr);
}
using namespace std;
int main()
{
cout
<< "`" << Align{ 5, ios::left } << "hi" << "'"
<< "`" << Align{ 10, ios::right } << "there" << "'\n";
}
生成的输出:
`hi '
` there'
当需要操作(局部)对象时,必须提供操纵符的类可以定义接收所需参数的函数调用运算符。例如,考虑一个类 Matrix
,它应该允许用户在将矩阵插入到 ostream
中时指定值和行分隔符。
定义了两个数据成员(例如,char const *d_valueSep
和 char const *d_lineSep
),并初始化为合适的值。插入函数在值之间插入 d_valueSep
,并在插入行的末尾插入 d_lineSep
。成员函数调用运算符 operator()(char const *valueSep, char const *lineSep)
简单地将值分配给相应的数据成员。
给定一个 Matrix
对象 matrix
,此时可以调用 matrix(" ", "\n")
。函数调用运算符可能不插入矩阵,因为操纵符的职责是操纵,而不是插入。因此,插入矩阵时应该使用如下语句:
cout << matrix(" ", "\n") << matrix << '\n';
操纵符(即函数调用运算符)将适当的值分配给 d_valueSep
和 d_lineSep
,这些值在实际插入过程中使用。
函数调用运算符的返回值需要指定。返回值应该是可以插入的,但实际上不应插入任何内容。可以返回一个空的 NTBS,但这有点不优雅。相反,可以返回一个不执行任何操作的操纵符函数的地址。这是一个空操纵符的实现:
// static(也可以使用自由函数)
std::ostream &Matrix::nop(std::ostream &out)
{
return out;
}
因此,Matrix
的操纵符实现变为:
std::ostream &(
*Matrix::operator()(char const *valueSep, char const *lineSep) )
(std::ostream &)
{
d_valueSep = valueSep;
d_lineSep = lineSep;
return nop;
}
另一种(可能是个人喜好)的方法是返回一个空函数的地址,操纵符可以首先设置所需的插入特定值,然后返回自身:Matrix
将根据刚刚分配的值进行插入:
Matrix const &Matrix::operator()
(char const *valueSep, char const *lineSep)
{
d_valueSep = valueSep;
d_lineSep = lineSep;
return *this;
}
在这种情况下,插入语句简化为:
cout << matrix(" ", "\n") << '\n';
Lambda 表达式
C++ 支持 Lambda 表达式。正如我们将在第 19 章看到的,通用算法(例如 sort
和 find_if
)通常接受可以是函数对象或普通函数的参数。作为一个经验法则:当被调用的函数必须记住其状态时,函数对象是合适的,否则可以使用普通函数。
经常情况下,函数或函数对象并不容易直接使用,需要在使用的地方或附近进行定义。通常通过在匿名命名空间中定义一个类或函数(比如:类或函数 A),将 A 传递给需要 A 的代码。如果该代码本身是类 B 的成员函数,那么 A 的实现可能会从访问 B 的成员中受益。
这种方案通常会导致大量的代码(定义类),或者导致复杂的代码(使得软件元素可以被 A 的代码自动访问)。它还可能导致当前规范级别上不相关的代码。嵌套类也无法解决这些问题。此外,嵌套类不能在模板中使用。
Lambda 表达式解决了这些问题。Lambda 表达式定义了一个匿名函数对象,可以立即传递给期望函数对象参数的函数,如下节所述。
根据 C++ 标准,Lambda 表达式提供了一种简洁的方式来创建简单的函数对象。这里的重点是“简单”:Lambda 表达式的大小应该与内联函数的大小相当:只有一到两条语句。如果需要更多代码,则可以将这些代码封装在一个单独的函数中,然后从 Lambda 表达式的复合语句中调用该函数,或者考虑设计一个单独的函数对象。
Lambda 表达式:语法
Lambda 表达式定义了一个匿名函数对象,也称为闭包对象,或者简称为闭包。
当 Lambda 表达式被求值时,它会生成一个临时的函数对象(即闭包对象)。这个临时的函数对象具有一个唯一的匿名类类型,称为其闭包类型。
Lambda 表达式可以在代码块、类或命名空间内使用(即,几乎可以在任何你喜欢的地方使用)。它们的隐含闭包类型是在包含该 Lambda 表达式的最小代码块、类或命名空间作用域中定义的。闭包对象的可见性从其定义点开始,直到其闭包类型结束(即,它们的可见性与普通变量的可见性相同)。
闭包类型定义了一个 const
公有内联函数调用运算符。以下是一个 Lambda 表达式的例子:
[] // `lambda-引入`
(int x, int y) // `lambda-声明符`
{ // 一个普通的复合语句
return x * y;
}
这个函数(严格来说:由该 Lambda 表达式创建的闭包类型的函数调用运算符)期望接收两个 int
参数并返回它们的乘积。这个函数是其闭包类型的 const
内联成员。如果 Lambda 表达式指定了 mutable
,那么 const
属性将被移除。例如:
[](int x, int y) mutable
如果没有定义参数,lambda-声明符
可以省略,但当指定 mutable
(或 constexpr
,见下文)时,必须指定一个 lambda-声明符
(至少是一个空的括号)。lambda 声明符
中的参数不能有默认值。
声明符说明符可以是 mutable
、constexpr
或两者兼而有之。constexpr
Lambda 表达式本身就是一个 constexpr
,如果其参数符合 const-expressions
的要求,那么它可以在编译时进行计算。也就是说,如果 Lambda 表达式是在 constexpr
函数内部定义的,那么 Lambda 表达式本身就是 constexpr
,且不需要 constexpr
声明符说明符。
因此,以下两个函数定义是相同的:
int constexpr change10(int n)
{
return [n]
{
return n > 10 ? n - 10 : n + 10;
}();
}
int constexpr change10(int n)
{
return [n] () constexpr
{
return n > 10 ? n - 10 : n + 10;
}();
}
由上面的 Lambda 表达式定义的闭包对象,例如可以与 accumulate
泛型算法结合使用(参见第19.1.2节)来计算存储在 vector
中的一系列 int
值的乘积:
cout << accumulate(vi.begin(), vi.end(), 1,
[] (int x, int y)
{
return x * y;
}
);
这个 Lambda 表达式隐式地将其返回类型定义为 decltype(x * y)
。在以下情况下可以使用隐式返回类型:
- Lambda 表达式不包含
return
语句(即它是一个void
Lambda 表达式); - Lambda 表达式包含单个
return
语句;或 - Lambda 表达式包含多个返回相同类型值的
return
语句(例如,全部都是int
类型的值)。
如果有多个 return
语句返回不同类型的值,则必须使用延迟指定返回类型来显式指定 Lambda 表达式的返回类型(参见第3.3.7节):
[](bool neg, double y) -> int
{
return neg ? -y : y;
}
在 Lambda 表达式位置可见的变量可能在 Lambda 表达式的复合语句中可访问。哪些变量以及如何访问取决于 lambda-引入
的内容。
当 Lambda 表达式是在类成员函数中定义时,lambda-引入
可能包含 this
或 *this
;在下面的概述中,假设使用这种类上下文。
全局变量始终是可访问的,并且如果它们的定义允许,可以修改它们(在下面的概述中通常成立:当声明“变量可以修改”时,仅适用于那些自身允许修改的变量)。
Lambda 表达式所在的函数的局部变量也可以在 lambda-引入
中指定。local
的说明符用于引用在 Lambda 表达式定义点可见的任何周围函数的逗号分隔的局部变量列表。this
、*this
和 local
的说明符没有要求的顺序。
最后,以下概述中提到 mutable
的地方必须指定它,而 mutable_opt
指定的地方是可选的。
访问全局变量、数据成员和局部变量,并定义 Lambda 表达式的数据成员:
[] mutable_opt
:仅限访问全局变量;[this] mutable_opt
:允许访问对象的所有数据成员,并且可以修改它们。[*this]
:提供对对象所有成员的访问,但不能修改它们。[*this] mutable
:与[*this]
类似,但在 Lambda 表达式内部使用可修改的副本,而不影响对象的自身数据。[local]
[this, local]
[*this, local]
:类似于前面的[...]
说明符,但以不可变方式访问local
。[local] mutable
,[this, local] mutable
,[*this, local] mutable
:类似于前面的[...]
说明符,但local
作为局部副本可用,可以修改而不影响周围函数的局部变量。[&local] mutable_opt
,[this, &local] mutable_opt
,[*this, &local] mutable_opt
:类似于前面的[...]
说明符,但local
可作为周围函数局部变量的可修改引用使用。[..., vars]
:除了上述说明符外,Lambda 表达式还可以定义自己的数据成员。例如,为了定义具有自己的int count
和double value
数据成员的 Lambda 表达式,并且还允许它访问和修改调用者的局部变量,可以将其lambda-引入
指定为[&, count = int(0), value = double(0)]
。这些局部变量不会像往常一样通过首先指定它们的类型来定义,而是通过定义它们的初始值来定义,允许编译器推导它们的类型。如果 Lambda 表达式定义为mutable
,则它们也可以被 Lambda 表达式修改。
以下情况必须使用 =
作为 Lambda 引入器的第一个元素。这允许按值访问局部变量,除非……:
[=]
,[=, this]
,[=, *this]
:这是“局部常量”指定符:局部变量是可见的,但不能被修改。[=] mutable
,[=, this] mutable
,[=, *this] mutable
:局部变量是作为可修改的副本访问的。原始的局部变量本身不会受到影响。[=, &local] mutable_opt
:与前面的[= ...]
规定类似,但local
是通过可修改的引用访问的。
以下情况必须使用 &
作为 Lambda 引入器的第一个元素。这允许通过引用访问局部变量,除非……:
[&] mutable_opt
,[&, this] mutable_opt
:这是“局部引用”指定符:局部变量作为可修改的引用是可见的。当 Lambda 表达式在类成员函数内部定义时,对象的成员是可访问和可修改的。[&, *this] mutable_opt
:局部变量作为可修改的引用是可见的,数据成员是可见的,但不能被修改。[&, local] mutable_opt
,[&, this, local] mutable_opt
,[&, *this, local] mutable_opt
:与前面的[& ...]
规定类似,但local
是作为可修改的副本访问的,不影响外围函数的局部变量。
即使没有明确指定,Lambda 表达式也会隐式捕获它们的 this
指针,并且类成员总是相对于 this
进行访问。但是,当成员异步调用时(参见第 20 章),可能会出现问题,因为异步调用的 Lambda 函数可能会引用一个对象的成员,而该对象的生命周期在异步调用 Lambda 函数后不久就结束了。这个潜在问题通过在 Lambda 捕获中使用 *this
解决,如果它是以 =
开始的,例如 [=, *this]
(此外,变量仍然可以像往常一样被捕获)。当指定 *this
时,显式捕获 this
所引用的对象:如果对象的作用域结束,它不会立即销毁,而是 Lambda 表达式会延长该对象的生命周期,直到表达式结束。要使用 *this
规范,对象必须是可用的。请考虑以下示例:
struct S2 {
double ohseven = .007;
auto f() {
return [this] // (1, 见下文)
{
return [*this] // (2)
{
return ohseven; // OK
};
}(); // (3)
}
auto g() {
return [] {
return [*this] {
// 错误:*this 未被外部 Lambda 表达式捕获
};
}();
}
};
尽管 Lambda 表达式是匿名函数对象,它们可以被赋值给变量。通常,变量使用关键字 auto
定义。例如:
auto sqr = [](int x) { return x * x; };
此类 Lambda 表达式的生命周期与接收 Lambda 表达式作为其值的变量的生命周期相同。
还要注意,定义 Lambda 表达式与调用其函数操作符是不同的。函数 S2::f()
返回的是 Lambda 表达式 (1) 的函数调用操作符的返回值:通过使用 ()
(在 (3) 处)调用其函数调用操作符。实际上,它返回的是另一个匿名函数对象(在 (2) 处定义)。因为那只是一个函数对象,要检索其值,仍然需要从 f
的返回值中调用它,如下所示:
S2 s2;
s2.f()();
在这里,第二组括号激活了返回的函数对象的函数调用操作符。如果在 (3) 处省略了括号,那么 S2::f()
只会返回一个简单的匿名函数对象(在 (1) 处定义),这种情况下,需要三组括号来检索 ohseven
的值:
s2.f()()();
使用 Lambda 表达式
现在我们已经介绍了 Lambda 表达式的语法,让我们看看它们如何在各种情况下使用。
首先我们考虑命名的 Lambda 表达式。命名的 Lambda 表达式非常适合用作局部函数:当一个函数需要执行比其主要任务更低层次的计算时,将这些计算封装在一个独立的支持函数中,并在需要时调用这个支持函数是很有吸引力的。尽管支持函数可以在匿名命名空间中定义,但当需要的函数是类成员并且支持函数还必须访问类的成员时,这会变得非常不便。
在这种情况下,可以使用命名的 Lambda 表达式:它可以在需要的函数内部定义,并且可以完全访问周围的类。赋予 Lambda 表达式的名字将成为可以从周围函数中调用的函数的名字。这里有一个示例,将一个数值 IP 地址转换为点分十进制字符串,该字符串也可以直接从 Dotted 对象中访问(所有实现均在类中以节省空间):
class Dotted {
std::string d_dotted;
public:
std::string const &dotted() const { return d_dotted; }
std::string const &dotted(size_t ip) {
auto octet = [](size_t idx, size_t numeric) {
return std::to_string(numeric >> idx * 8 & 0xff);
};
d_dotted = octet(3, ip) + '.' + octet(2, ip) + '.' + octet(1, ip) +
'.' + octet(0, ip);
return d_dotted;
};
};
接下来我们考虑使用通用算法,例如 for_each
(参见第 19.1.18 节):
void showSum(std::vector<int> const &vi) {
int total = 0;
std::for_each(vi.begin(), vi.end(), [&](int x) { total += x; });
std::cout << total << '\n';
}
在这里,变量 int total
被通过引用传递给 Lambda 表达式,并由该函数直接访问。它的参数列表只是定义了一个 int x
,并且在 vi
中存储的每个值依次初始化 x
。一旦通用算法完成,showSum
的变量 total
的值就等于所有向量元素的总和。它的值在 Lambda 表达式结束后仍然有效,并且会显示出来。
但是,尽管通用算法非常有用,但并不总是有适合当前任务的算法。此外,像 for_each
这样的算法现在看起来有点笨重,因为语言已经提供了基于范围的 for
循环。因此,让我们尝试替代上述实现:
void showSum(std::vector<int> const &vi)
{
int total = 0;
for (auto el : vi) [&](int x) { total += x; };
std::cout << total << '\n';
}
但是,当现在调用 showSum
时,其 cout
语句始终报告 0
。这是怎么回事呢?当给定一个 Lambda 函数时,通用算法会实例化对一个函数的引用。然后,引用的函数会在通用算法中被调用。但是,在上面的例子中,基于范围的 for
循环的嵌套语句仅表示 Lambda 函数的定义。实际上并没有调用任何东西,因此 total
仍然等于 0
。因此,为了使上述示例工作,我们不仅需要定义 Lambda 表达式,还需要调用 Lambda 函数。我们可以通过给 Lambda 函数一个名字来做到这一点,然后通过其给定的名字调用 Lambda 函数:
void showSum(std::vector<int> const &vi) {
int total = 0;
for (auto el : vi) {
auto lambda = [&](int x) { total += x; };
lambda(el);
}
std::cout << total << '\n';
}
实际上,不需要给 Lambda 函数命名:auto lambda
定义代表 Lambda 函数,它也可以直接被调用。这种语法看起来可能有点奇怪,但这并没有问题,并且它允许我们完全删除前一个示例中所需的复合语句。如下所示:
void showSum(std::vector<int> const &vi)
{
int total = 0;
for (auto el : vi) [&](int x) { total += x; }(el);
// 立即将参数列表附加到 Lambda 函数的定义中
std::cout << total << '\n';
}
Lambda 表达式还可以用于防止 condition_variable
的 wait
调用中出现意外返回(参见 20.4.3 节)。condition_variable
类允许我们通过提供一个锁和一个谓词的 wait
成员来实现这一点。谓词检查数据的状态,如果数据的状态允许处理则返回 true
。下面是 20.4.3 节中 down
成员的替代实现,检查数据的实际可用性:
void down()
{
std::unique_lock<std::mutex> lock(sem_mutex);
condition.wait(lock,
[&]()
{
return semaphore != 0;
}
);
--semaphore;
}
Lambda 表达式确保了 wait
只有在 semaphore
增加后才会返回。
Lambda 表达式主要用于获取在程序的某个非常局部的部分使用的仿函数。由于它们是在现有函数内部使用的,我们需要意识到,一旦我们使用了 Lambda 函数,多个聚合层次就被混合在一起了。通常,一个函数实现一个任务,这个任务可以在它自己的聚合层次上用几句话来描述。例如,“std::sort
函数按适合 sort
调用上下文的方式对数据结构进行排序”。通过使用现有的比较方法,聚合层次得以保持,并且语句本身也是清晰的。例如:
sort(data.begin(), data.end(), std::greater<DataType>());
如果没有现成的比较方法,则必须创建一个量身定制的仿函数。这可以通过 Lambda 表达式实现。例如:
sort(data.begin(), data.end(),
[&](DataType const &lhs, DataType const &rhs)
{
return lhs.greater(rhs);
}
);
看这个例子时,我们需要意识到这里混合了两个不同的聚合层次:在顶层,目的是对 data
中的元素进行排序,但在嵌套层次(Lambda 表达式内部)发生的是完全不同的事情。在 Lambda 表达式内部,我们定义了如何决定这两个对象中哪个更大。这样的代码混合了不同的聚合层次,难以阅读,应尽量避免。
另一方面,Lambda 表达式也简化了代码,因为避免了定义定制仿函数的开销。因此,建议谨慎使用 Lambda 表达式。当使用时,请确保它们的大小保持较小。一个经验法则是:应像对待内联函数一样对待 Lambda 表达式,并且它们应该只包含一个,或者偶尔包含两个表达式。
有一类特殊的 Lambda 表达式被称为泛型 Lambda 表达式。由于泛型 Lambda 表达式实际上是类模板,因此它们的介绍会推迟到第 22 章。
[io]fstream::open() 的情况
在第 6.4.2.1 节中提到,[io]fstream::open 成员函数期望的最后一个参数是 ios::openmode
类型的值。例如,要打开一个 fstream
对象进行写入,可以如下操作:
fstream out;
out.open("/tmp/out", ios::out);
组合也是可能的。要同时打开一个 fstream
对象用于读取和写入,通常会看到以下用法:
fstream out;
out.open("/tmp/out", ios::in | ios::out);
当尝试使用“自定义”枚举组合枚举值时,可能会遇到问题。考虑以下代码:
enum Permission
{
READ = 1 << 0,
WRITE = 1 << 1,
EXECUTE = 1 << 2
};
void setPermission(Permission permission);
int main()
{
setPermission(READ | WRITE);
}
将这个小程序提供给编译器时,它会返回类似以下的错误信息:
invalid conversion from ’int’ to ’Permission’
问题是:为什么可以将 ios::openmode
值组合在一起并将这些组合值传递给流的 open
成员,而不能将 Permission
值组合在一起?
使用算术运算符组合枚举值会得到 int
类型的值。概念上,这并不是我们的本意。从概念上讲,如果组合的枚举值在原始枚举域内仍然有意义,那么组合枚举值是可以的。请注意,即使在上面的枚举中添加了一个值 READWRITE = READ | WRITE
,我们仍然不能将 READ | WRITE
作为参数传递给 setPermission
。
要回答关于组合枚举值的问题,同时保持在枚举的域内,我们需要使用操作符重载。到目前为止,操作符重载已经应用于类类型。像 operator<<
这样的自由函数已经被重载,这些重载在其类的域内是概念上合理的。
由于 C++ 是强类型语言,定义一个枚举类型实际上超出了仅仅将 int
值与符号名称关联的范围。枚举类型实际上是其自身的一种类型,与任何类型一样,其操作符也可以被重载。当编写 READ | WRITE
时,编译器会执行默认的枚举值到 int
值的转换,并对 int
应用操作符。它在没有其他选择时会这样做。
但也可以重载枚举类型的操作符。这样,即使结果值不是枚举定义的,我们也可以确保保持在枚举的域内。类型安全性和概念清晰性被认为优于引入之前未定义的值。以下是一个重载操作符的示例:
Permission operator|(Permission left, Permission right)
{
return static_cast<Permission>(static_cast<int>(left) | static_cast<int>(right));
}
其他操作符也可以以类似的方式构造。像上面这样的操作符是为 ios::openmode
枚举类型定义的,使我们可以将 ios::in | ios::out
作为参数传递给 open
函数,同时将相应的参数指定为 ios::openmode
。显然,操作符重载可以在许多情况下使用,不仅仅涉及类类型。
用户定义字面量
除了众所周知的字面量,如数字常量(有或没有后缀)、字符常量和字符串(文本)字面量,C++ 还支持用户定义字面量,也称为可扩展字面量。
用户定义字面量是由一个函数定义的(参见第 23.3 节),该函数必须在命名空间范围内定义。这样的函数称为字面量操作符。字面量操作符不能是类成员函数。字面量操作符的名称必须以下划线开头,且使用(调用)时需将其名称(包括下划线)作为后缀添加到必须传递给它的参数上。假设 _NM2km
(海里到公里)是一个字面量操作符的名称,则它可以像 100_NM2km 这样调用,生成例如 185.2 的值。
使用 Type
表示字面量操作符的返回类型,其通用声明如下:
Type operator "" _identifier(parameter-list);
空字符串后面的空格是必需的。字面量操作符的参数列表可以是:
unsigned long long int
。用于例如123_identifier
。传递给此字面量操作符的参数可以是十进制常量、二进制常量(以 0b 开头)、八进制常量(以 0 开头)和十六进制常量(以 0x 开头);long double
。用于例如12.25_NM2km
;char const *text
。文本参数是一个 NTBS(Null Terminated Byte String)。用于例如1234_pental
。参数不需要加双引号,必须表示一个数字常量,这也符合定义unsigned long long int
参数的字面量操作符的要求;char const *text, size_t len
。这里,编译器会根据调用strlen(text)
确定len
。用于例如"hello"_nVowels
;wchar_t const *text, size_t len
,与前一个相同,但接受wchar_t
字符串。用于例如L"1234"_charSum
;char16_t const *text, size_t len
,与前一个相同,但接受char16_t
字符串。用于例如u"utf 16"_uc
;char32_t const *text, size_t len
,与前一个相同,但接受char32_t
字符串。用于例如U"UTF 32"_lc
。
如果字面量操作符被重载,编译器会选择需要最少“努力”的字面量操作符。例如,120 由定义 unsigned long long int
参数的字面量操作符处理,而不是定义 char const *
参数的重载版本。但如果存在定义 char const *
和 long double
参数的重载字面量操作符,那么在提供参数 120 时,会使用定义 char const *
参数的操作符,而在提供参数 120.3 时,则会使用定义 long double
参数的操作符。
字面量操作符可以定义任何返回类型。以下是 _NM2km
字面量操作符的定义示例:
double operator "" _NM2km(char const *nm)
{
return std::stod(nm) * 1.852;
}
double value = 120_NM2km; // 使用示例
当然,参数也可以是 long double
常量。以下是一个替代实现,明确期望一个 long double
:
double constexpr operator "" _NM2km(long double nm)
{
return nm * 1.852;
}
double value = 450.5_NM2km; // 使用示例
数字常量也可以在编译时完全处理。第 23.3 节提供了这种类型的字面量操作符的详细信息。
传递给字面量操作符的参数本身始终是常量。像 _NM2km
这样的字面量操作符不能用于转换,例如变量的值。尽管字面量操作符被定义为函数,但不能像函数一样调用。因此,以下示例会导致编译错误:
double speed;
speed_NM2km; // 没有标识符 'speed_NM2km'
_NM2km(speed); // 没有函数 _NM2km
_NM2km(120.3); // 没有函数 _NM2km
可重载运算符
以下运算符可以被重载:
以下是将运算符按八列分组的表格:
算术运算符 | 位运算符 | 逻辑运算符 | 比较运算符 | 赋值运算符 | 自增自减运算符 | 数组和函数调用运算符 | 成员访问和动态内存运算符 |
---|---|---|---|---|---|---|---|
+ | ^ | ! | == | = | ++ | [] | -> |
- | & | && | != | += | -- | () | ->* |
* | ` | ` | ` | ` | < | -= | |
/ | ~ | > | *= | new[] | |||
% | << | <= | /= | delete | |||
>> | >= | %= | delete[] | ||||
<=> | ^= | ||||||
&= | |||||||
` | =` | ||||||
<<= | |||||||
>>= |
一些运算符还有文本替代形式:
文本替代 | 运算符 |
---|---|
and | && |
and_eq | &= |
bitand | & |
bitor | ` |
compl | ~ |
not | ! |
not_eq | != |
or | ` |
or_eq | ` |
xor | ^ |
xor_eq | ^= |
运算符的“文本”替代品也是可以重载的(例如,operator and
)。然而,需要注意的是,文本替代品并不是额外的运算符。因此,在同一上下文中,operator&&
和 operator and
不能同时重载。
以下运算符只能作为类成员函数进行重载。具体来说,=
, []
, ()
, 和 ->
运算符必须在类内进行重载。因此,不可能全局重定义,例如,将赋值运算符(=
)重载为接受 char const *
作为左值和 String &
作为右值。幸运的是,这并不必要,因为我们在第11.3节中已经看到过相关内容。
最后,以下运算符无法被重载:
.
(成员访问运算符).*
(成员指针访问运算符)::
(作用域解析运算符)?:
(条件运算符)sizeof
(大小运算符)typeid
(类型信息运算符)
抽象容器
C++ 提供了多个预定义的数据类型,这些数据类型都是标准模板库(STL)的一部分,可以用于实现对常见问题的解决方案。本章讨论的数据类型都是容器:你可以将数据放入其中,也可以从中检索存储的信息。
有趣的是,这些容器可以存储的具体数据类型在构建容器时并未指定。因此,它们被称为抽象容器。
抽象容器严重依赖于模板,模板的详细内容在第21章及之后的章节中讨论。使用抽象容器只需要对模板概念有一个基本的理解。在 C++ 中,模板实际上是一种构建函数或完整类的“配方”。这个配方尽可能地将类或函数的功能从其操作的数据中抽象出来。由于模板实现时并不知道模板所操作的数据类型,因此这些数据类型要么从使用函数模板的上下文中推断出来,要么在使用类模板时明确指出(这里称为实例化)。在明确指出类型的情况下,使用尖括号表示所需的数据类型。例如,下面的 pair
容器需要显式指定两个数据类型。这里是一个包含 int
和 string
的 pair
对象:
pair<int, string> myPair;
对象 myPair
被定义为一个同时包含 int
和 string
的对象。
尖括号表示法在接下来的抽象容器讨论中被广泛使用。实际上,理解这部分内容是使用抽象容器的唯一真正要求。现在我们已经介绍了这个表示法,可以将模板的更深入讨论推迟到第21章,集中讨论本章的内容。
大多数抽象容器是顺序容器:它们包含可以以某种顺序存储和检索的数据。例如,数组(array)实现了固定大小的数组;向量(vector)实现了可扩展的数组;列表(list)实现了一个允许轻松插入或删除数据的数据结构;队列(queue),也称为 FIFO(先进先出)结构,其中第一个进入的元素是第一个被检索的元素;栈(stack),这是一个先进后出(FILO 或 LIFO)结构。
除了顺序容器之外,还有一些特殊容器。例如,pair
是一个基本容器,可以存储一对值(类型可以进一步指定),比如两个字符串、两个整数、一个字符串和一个双精度浮点数等。pair
经常用于返回自然成对的数据元素。例如,map
是一个存储键及其关联值的抽象容器。map
的元素以 pair
的形式返回。
pair
的变体是复数容器(complex container),实现对复数的操作。
元组(tuple)(参见第22.6节)将 pair
容器概念推广到可以容纳任意数量不同数据类型的数据结构。
本章描述的所有抽象容器,以及字符串和流数据类型(参见第5章和第6章),都是标准模板库的一部分。
除了无序容器外,几乎所有容器都支持以下基本操作符:
- 重载赋值运算符(
=
),以便我们可以将两个相同类型的容器互相赋值。如果容器的数据类型支持移动赋值,则在将匿名临时容器的值赋给目标容器时,将使用移动赋值。无序容器也支持重载赋值运算符。 - 相等测试:
==
和!=
。两个容器的相等运算符应用于两个容器时,如果两个容器具有相同数量的元素,并且这些元素根据所包含数据类型的相等运算符一一相等,则返回 true。 不等运算符做相反的操作。 - 排序运算符:
<
,<=
,>
和>=
。<
运算符返回 true,如果左侧容器中的每个元素都小于右侧容器中对应的每个元素。左侧容器或右侧容器中的额外元素会被忽略。
container left;
container right;
left = {0, 2, 4};
right = {1, 3}; // left < right
right = {1, 3, 6, 1, 2}; // left < right
请注意,在将用户定义的类型(通常是类类型)存储在容器中之前,用户定义的类型至少应支持以下操作:
- 默认值(例如,默认构造函数)
- 相等运算符(
==
) - 小于运算符(
<
)
顺序容器还可以使用初始化列表进行初始化。
大多数容器(栈(参见第12.4.11节)、优先队列(参见第12.4.5节)和队列(参见第12.4.4节)容器除外)支持成员函数来确定它们的最大大小(通过其成员函数 max_size
)。
几乎所有容器都支持复制构造。如果容器支持复制构造,并且容器的数据类型支持移动构造,则在用匿名临时容器初始化容器时,自动使用移动构造。与标准模板库紧密相关的是通用算法。这些算法可用于执行容器自身无法完成的常见任务或更复杂的任务,例如计数、填充、合并、过滤等。第19章提供了通用算法及其应用的概述。通用算法通常依赖于迭代器的可用性,迭代器表示用于处理存储在容器中的数据的起始和结束点。抽象容器通常支持期望迭代器的构造函数和成员函数,并且它们通常具有返回迭代器的成员(类似于 string::begin
和 string::end
成员)。本章不再进一步探讨迭代器概念。请参见第18章。
容器在其生命周期内通常会收集数据。当容器超出作用域时,它的析构函数尝试销毁其数据元素。如果数据元素本身存储在容器内,则销毁操作会成功。如果容器的数据元素是指向动态分配内存的指针,则这些指针指向的内存不会被销毁,从而导致内存泄漏。这个方案的一个结果是,存储在容器中的数据通常应被视为容器的“财产”:容器应该能够在容器的析构函数被调用时销毁其数据元素。因此,通常容器不应包含指向数据的指针。同时,容器也不应包含 const
数据,因为 const
数据会阻止许多容器成员的使用,如赋值运算符。
本章使用的符号约定
在本章关于容器的讨论中,使用了以下符号约定:
- 容器位于标准命名空间中。在代码示例中这将清晰可见,但在文本中通常省略
std::
。 - 没有尖括号的容器表示该类型的任意容器。可以在脑中添加所需的尖括号表示法。例如,
pair
可能表示pair<string, int>
。 - 符号
Type
代表通用类型。Type
可以是int
、string
等。 - 标识符
object
和container
表示讨论中的容器类型的对象。 - 标识符
value
表示存储在容器中的类型的值。 - 简单的单字母标识符,如
n
,表示无符号值。 - 较长的标识符表示迭代器。例如,
pos
、from
、beyond
。
一些容器,例如 map
容器,包含值对,通常称为“键”和“值”。对于这些容器,还使用了以下符号约定:
- 标识符
key
表示所用键类型的值。 - 标识符
keyvalue
表示与特定容器一起使用的value_type
的值。
pair
容器
pair
容器是一个相当基础的容器,用于存储两个元素,分别称为 first
和 second
。使用 pair
容器之前,必须包含头文件 <utility>
。在定义(或声明)pair
对象时,需要使用模板的尖括号表示法来指定 pair
的数据类型。示例:
pair<string, string> piper("PA28", "PH-ANI");
pair<string, string> cessna("C172", "PH-ANG");
在这里,变量 piper
和 cessna
被定义为包含两个字符串的 pair
变量。可以使用 pair
类型的 first
和 second
字段来检索这两个字符串:
cout << piper.first << '\n'; // 显示 'PA28'
cout << cessna.second << '\n'; // 显示 'PH-ANG'
first
和 second
成员也可以用来重新赋值:
cessna.first = "C152";
cessna.second = "PH-ANW";
如果需要完全重新赋值一个 pair
对象,可以使用匿名 pair
对象作为赋值的右侧操作数。匿名变量定义一个临时变量(没有名称),仅用于(重新)赋值另一个相同类型的变量。其通用形式为:
type(initializer list)
注意,当使用 pair
对象时,仅提到容器名称 pair
是不够的,还需要指定 pair
内存储的数据类型。为此,再次使用(模板)尖括号表示法。例如,cessna
pair
变量的重新赋值可以如下完成:
cessna = pair<string, string>("C152", "PH-ANW");
在这些情况下,类型规范可能会变得相当复杂,这时可以使用声明来提高可读性。如果在源代码中使用了许多 pair<type1, type2>
子句,可以通过首先为子句定义名称,然后在后续使用定义的名称来减少类型输入工作量并提高可读性。例如:
using pairStrStr = pair<string, string>;
cessna = pairStrStr("C152", "PH-ANW");
所有抽象容器都是类模板,并且初始化类模板的类型通常在类模板名称后用尖括号括起来指定。然而,编译器可能能够根据构造容器时指定的参数类型推导容器的类型。例如,当定义:
pair values{ 1, 1.5 };
编译器推导出 values.first
是 int
,values.second
是 double
。有时,类模板的类型无法被推导。在这种情况下,必须显式指定意图的类型:
pair<int, double> values;
虽然编译器会尽可能推导类型,但可能不会推导出我们所期望的类型。如果我们定义:
pair cessna{ "C172", "PH-BVL" };
编译将会成功,但像 cout << cessna.first.length()
这样的表达式将无法编译,因为 "C172"
是一个 NTBS,因此 cessna.first
是 char*
。在这种情况下,简单地将 NTBS 加上 s
可以解决问题,但这样的简单修复可能并不总是可用。有关推导模板参数类型的更多信息,请参见第 12.4.2 节。
除了这些(和基本的操作集合(赋值和比较))外,pair
不提供其他功能。然而,它是即将介绍的抽象容器 map
、multimap
和 hash_map
的基本组成部分。
C++ 还提供了一个通用的 pair
容器:tuple
,详见第 22.6 节。
分配器(allocator)
大多数容器使用一个特殊的对象来分配由它们管理的内存。这个对象称为分配器(allocator),其类型(通常默认为)在容器构造时指定。可以使用容器的 get_allocator
成员函数获取容器的分配器,该函数返回一个容器使用的分配器的副本。分配器提供以下成员函数:
value_type *address(value_type &object)
:返回对象的地址。value_type *allocate(size_t count)
:为容纳count
个value_type
分配原始内存。void construct(value_type *object, Arg &&...args)
:使用放置 new,将参数args
应用于object
,在object
上安装一个值。void destroy(value_type *object)
:调用object
的析构函数(但不释放object
自身的内存)。void deallocate(value_type *object, size_t count)
:调用operator delete
删除由allocate
分配的object
内存。size_t max_size()
:返回allocate
可以分配的最大元素数。
以下是一个示例,使用字符串 vector
的分配器(有关 vector
容器的描述,请参见下面的第 12.4.2 节):
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int main() {
vector<string> vs;
auto allocator = vs.get_allocator(); // 获取分配器
string *sp = allocator.allocate(3); // 为 3 个字符串分配空间
allocator.construct(&sp[0], "hello world"); // 初始化第一个字符串
allocator.construct(&sp[1], sp[0]); // 使用拷贝构造函数
allocator.construct(&sp[2], 12, '='); // 12 个 '=' 字符的字符串
cout << sp[0] << '\n' // 显示字符串
<< sp[1] << '\n'
<< sp[2] << '\n'
<< "could have allocated " << allocator.max_size() << " strings\n";
for (size_t idx = 0; idx != 3; ++idx)
allocator.destroy(sp + idx); // 删除字符串内容
allocator.deallocate(sp, 3); // 再次删除 sp 自身
}
在这个示例中,分配器用于分配内存、构造对象、销毁对象以及释放内存。
Available Containers
array
容器
array
类实现了一个固定大小的数组。在使用 array
容器之前,必须包含头文件 <array>
。
定义 std::array
时,必须指定其元素的数据类型和大小:数据类型在 array
容器名称后面的开角括号中给出。数组的大小在数据类型说明之后提供,最后用闭角括号完成数组的类型。像这种组合(array、类型和大小)定义了一个类型。因此,array<string, 4>
定义了与 array<string, 5>
不同的类型。如果函数明确指定了一个 array<Type, N>
参数,则不能接受 array<Type, M>
类型的实参,前提是 N
和 M
不相等。
数组的大小可以定义为 0(虽然这样的数组可能用处不大,因为它不能存储任何元素)。数组的元素是连续存储的。如果定义了 array<Type, N> arr
,则 &arr[n] + m == &arr[n + m]
,假设 0 <= n < N
和 0 <= n + m < N
。
以下是可用的构造函数、运算符和成员函数:
-
构造函数:
- 支持拷贝构造函数和移动构造函数;
- 可以用固定数量
N
的默认元素构造数组:array<string, N> object;
- 使用花括号括起来的初始化列表初始化数组的初始子集:
在这个例子中,array<double, 4> dArr = {1.2, 2.4};
dArr
被定义为一个包含 4 个元素的数组,其中dArr[0]
和dArr[1]
分别初始化为 1.2 和 2.4,而dArr[2]
和dArr[3]
初始化为 0。数组(以及其他容器)的一个吸引人的特点是,容器将其数据元素初始化为数据类型的默认值。数据类型的默认构造函数用于这个初始化。对于非类数据类型,使用值 0。因此,对于array<double, 4>
数组,我们知道除了显式初始化的元素外,其他元素都初始化为 0。
-
成员函数:
Type &at(size_t idx)
:返回数组中索引位置idx
的元素的引用。如果idx
超过数组的大小,将抛出std::out_of_range
异常。Type &back()
:返回数组中最后一个元素的引用。程序员需确保在数组不为空时使用该成员。array::iterator begin()
:返回一个指向数组第一个元素的迭代器,如果数组为空则返回end
。array::const_iterator cbegin()
:返回一个指向数组第一个元素的const_iterator
,如果数组为空则返回cend
。array::const_iterator cend()
:返回一个指向数组最后一个元素之后的const_iterator
。array::const_reverse_iterator crbegin()
:返回一个指向数组最后一个元素的const_reverse_iterator
,如果数组为空则返回crend
。array::const_reverse_iterator crend()
:返回一个指向数组第一个元素之前的const_reverse_iterator
。value_type *data()
:返回指向数组第一个数据元素的指针。对于const
数组,返回value_type const *
。bool empty()
:如果数组不包含任何元素,则返回true
。array::iterator end()
:返回一个指向数组最后一个元素之后的迭代器。void fill(Type const &item)
:用item
的副本填充数组的所有元素。Type &front()
:返回数组中第一个元素的引用。程序员需确保在数组不为空时使用该成员。array::reverse_iterator rbegin()
:返回一个指向数组最后一个元素的reverse_iterator
。array::reverse_iterator rend()
:返回一个指向数组第一个元素之前的reverse_iterator
。constexpr size_t size()
:返回数组包含的元素数。void swap(array<Type, N> &other)
:交换当前数组和其他数组的内容。other
数组的数据类型和大小必须与调用swap
的对象的数据类型和大小相等。
使用 array
而不是标准的 C 风格数组有几个优点:
- 它的所有元素都立即初始化;
- 可以进行内省(例如,可以使用
size
); - 数组容器可以在模板上下文中使用,这样在代码开发时,数据类型的可用性才确定;
- 由于
array
支持反向迭代器,因此可以立即与执行“反向”操作的通用算法一起使用(例如,执行降序而非升序排序)。
一般来说,当寻找顺序数据结构时,array
或 vector
(在下一节中介绍)应该是你的首选。只有当这些容器显著不适合手头的问题时,你才应该使用其他类型的容器。
vector
容器
vector
类实现了一个可扩展的数组。在使用 vector
容器之前,必须包含头文件 <vector>
。
以下是可用的构造函数、运算符和成员函数:
-
构造函数:
-
支持拷贝构造函数和移动构造函数;
-
可以构造一个空的
vector
:vector<string> object;
-
可以初始化为一定数量的元素:
vector<string> object(5, "Hello"s); // 初始化为5个"Hello" vector<string> container(10); // 初始化为10个空字符串 vector<string> names = {"george", "frank", "tony", "karel"};
注意
vector<int> first(5)
和vector<int> second{5}
之间的区别。first
包含五个元素,初始化为 0,而second
包含一个元素,初始化为 5。参考第 12.2 节:在后面的定义中,编译器能够推导出vector
的模板参数类型(int),所以后面的定义也可以写成vector second{5}
。当观察以下代码时,可能会出现歧义:
vector object{vector{1}};
我们定义的是
vector<int>
还是vector<vector<int>>
?标准将其视为vector<int>
:它使用vector
的移动构造函数从一个抽象的vector<int>
初始化。 -
vector
也可以使用迭代器进行初始化。要使用现有的vector<string>
的元素 5 到 10(包括最后一个)来初始化一个vector
,可以使用以下构造:extern vector<string> container; vector<string> object(&container[5], &container[11]);
请注意,第二个迭代器指向的最后一个元素(
&container[11]
)未存储在object
中。这是使用迭代器的一个简单示例,其中使用的值范围从第一个值开始,包括直到但不包括第二个迭代器所指向的元素。标准表示法为[begin, end)
。
-
-
运算符:
- 除了容器的标准运算符之外,
vector
还支持索引运算符,可以用来检索或重新分配vector
的各个元素。注意,索引的元素必须存在。例如,如果定义了一个空的vector
,则类似ivect[0] = 18
的语句会产生错误,因为vector
为空。因此,vector
不会自动扩展,运算符[]
也不检查数组边界。在这种情况下,应该先调整vector
的大小,或者使用ivect.push_back(18)
(参见下文)。如果需要运行时数组边界检查,请使用vector
的at
成员函数。
- 除了容器的标准运算符之外,
-
成员函数:
-
void assign(...)
:为vector
分配新的内容:assign(iterator begin, iterator end)
将迭代器范围[begin, end)
的值分配给vector
;assign(size_type n, value_type const &val)
将val
的n
个副本分配给vector
;assign(initializer_list<value_type> values)
将初始化列表中的值分配给vector
。
-
Type &at(size_t idx)
:返回vector
中索引位置idx
的元素的引用。如果idx
超过vector
的大小,将抛出std::out_of_range
异常。 -
Type &back()
:返回vector
中最后一个元素的引用。程序员需确保在vector
不为空时使用该成员。 -
vector::iterator begin()
:返回一个指向vector
第一个元素的迭代器,如果vector
为空则返回end
。 -
size_t capacity()
:返回分配内存的元素数量,至少与size
返回的值相等。 -
vector::const_iterator cbegin()
:返回一个指向vector
第一个元素的const_iterator
,如果vector
为空则返回cend
。 -
vector::const_iterator cend()
:返回一个指向vector
最后一个元素之后的const_iterator
。 -
void clear()
:删除vector
的所有元素。 -
vector::const_reverse_iterator crbegin()
:返回一个指向vector
最后一个元素的const_reverse_iterator
,如果vector
为空则返回crend
。 -
vector::const_reverse_iterator crend()
:返回一个指向vector
第一个元素之前的const_reverse_iterator
。 -
value_type *data()
:返回指向vector
第一个数据元素的指针。 -
iterator emplace(const_iterator position, Args &&...args)
:从指定的参数构造一个value_type
对象,并将新创建的元素插入到position
位置。不同于insert
,emplace
使用其参数在容器的目标位置立即构造一个对象,而不需要复制或移动构造或赋值。 -
constexpr &emplace_back(Args &&...args)
:从成员的参数构造一个value_type
对象,并将新创建的元素插入到vector
的最后一个元素之后,返回新添加元素的引用。 -
bool empty()
:如果vector
不包含任何元素,则返回true
。 -
vector::iterator end()
:返回一个指向vector
最后一个元素之后的迭代器。 -
vector::iterator erase()
:删除vector
中特定范围的元素:erase(pos)
删除迭代器pos
指向的元素。返回迭代器++pos
。erase(first, beyond)
删除由迭代器范围[first, beyond)
指定的元素,返回beyond
。
-
Type &front()
:返回vector
中第一个元素的引用。程序员需确保在vector
不为空时使用该成员。 -
allocator_type get_allocator() const
:返回vector
对象使用的分配器对象的副本。 -
... insert()
:可以从某个位置开始插入元素。返回值取决于调用的insert()
版本:vector::iterator insert(pos)
在pos
插入类型Type
的默认值,返回pos
。vector::iterator insert(pos, value)
在pos
插入value
,返回pos
。void insert(pos, first, beyond)
插入迭代器范围[first, beyond)
的元素。void insert(pos, n, value)
在位置pos
插入n
个值为value
的元素。
-
size_t max_size()
:返回此vector
可以包含的最大元素数。 -
void pop_back()
:删除vector
的最后一个元素。避免在空vector
上调用此成员函数:虽然不会返回任何内容,但它内部维护的可用元素数量会减少,导致vector
的size()
成员函数返回值(转换为int
后)依次为 -1、-2 等等。 -
void push_back(value)
:在vector
的末尾添加value
。 -
vector::reverse_iterator rbegin()
:返回一个指向vector
最后一个元素的reverse_iterator
。 -
vector::reverse_iterator rend()
:返回一个指向vector
第一个元素之前的迭代器。 -
void reserve(size_t request)
:如果request
小于或等于capacity
,此调用无效。否则,这是一个请求分配额外内存的操作。如果调用成功,capacity
返回至少为request
的值。否则,capacity
不变。在任一情况下,size
的返回值不会改变,直到调用像resize
这样实际上改变可访问元素数量的函数。 -
void resize()
:可用于更改当前存储在vector
中的元素数量:resize(n, value)
可用于将vector
的大小调整为n
。value
是可选的。如果扩展vector
且未提供value
,则附加元素将初始化为所使用数据类型的默认值,否则使用value
初始化附加元素。
-
void shrink_to_fit()
:可选择性地将vector
分配的内存减少到其当前大小。实现者可以忽略或以其他方式优化此请求。为了保证“收缩以适应”的操作,可以使用以下惯用法:vector<Type>(vectorObject).swap(vectorObject);
-
size_t size()
:返回vector
中的元素数量。 -
void swap(vector<Type> &other)
:交换两个使用相同数据类型的vector
。示例代码:#include <iostream> #include <vector> using namespace std; int main() { vector<int> v1(7); vector<int> v2(10); v1.swap(v2); cout << v1.size() << " " << v2.size() << '\n'; }
输出结果:
10 7
-
list
容器
list
容器实现了一个链表数据结构。使用 list
容器之前,必须包含 <list>
头文件。
图 12.1 展示了链表的结构。图中显示了一个链表由独立的链表元素组成,这些元素通过指针连接。链表可以进行两个方向的遍历:从 Front
开始,链表可以从左到右遍历,直到到达最右边链表元素的末尾,即 0 指针。链表也可以从右向左遍历:从 Back
开始,链表从右向左遍历,直到最终到达从最左边链表元素发出的 0 指针。
需要注意的是,图 12.1 中的表示方式不一定是链表的实际实现方式。例如,考虑以下小程序:
int main() {
list<int> l;
cout << "size: " << l.size() << ", first element: " << l.front() << '\n';
}
当运行此程序时,它可能会输出:
size: 0, first element: 0
甚至可以为其第一个元素赋值。在这种情况下,链表的实现者选择为链表提供一个隐藏的元素。实际上,这个链表是一个循环链表,隐藏的元素作为终止元素,取代了图 12.1 中的 0 指针。如前所述,这是一个细微的差别,它不会影响链表作为以 0 指针结尾的数据结构的概念性认识。还需要注意的是,链表结构有多种实现方式是众所周知的(参考 Aho, A.V., Hopcroft J.E. 和 Ullman, J.D. (1983) 数据结构与算法 (Addison-Wesley))。
在需要存储未知数量的数据元素的情况下,链表和 vector
都是常用的数据结构。然而,在选择合适的数据结构时,有一些经验法则可以遵循:
-
当大多数访问是随机访问时,
vector
是首选的数据结构。例如:在一个程序中统计文本文件中的字符频率时,vector<int> frequencies(256)
是首选的数据结构,因为接收到的字符值可以用作frequencies
向量的索引。 -
前面的例子还说明了第二个经验法则,也倾向于使用
vector
:如果元素数量事先已知(并且在程序生命周期内不会显著变化),则vector
也优于list
。
在插入或删除操作占主导地位且数据结构较大的情况下,通常优先使用链表。
目前,链表不像过去那样有用(当时计算机速度较慢且内存受限)。除了一些罕见的情况外,vector
应该是首选的容器;即使在传统上使用链表的算法中也是如此。
在链表和 vector
之间进行选择时,还应该考虑其他一些因素。虽然 vector
可以动态增长,但动态增长需要进行数据复制。显然,即使在快速计算机上,复制一百万个大型数据结构也需要相当多的时间。另一方面,向链表中插入大量元素不需要复制不相关的数据。向链表中插入新元素只需要调整一些指针。图 12.2 显示了这一点:一个新元素被插入到第二个和第三个元素之间,创建了一个包含四个元素的新链表。从链表中删除一个元素也相当简单。再次从图 12.1 所示的情况开始,图 12.3 显示了如果从链表中删除第二个元素会发生什么。再次说明:只需要调整指针。在这种情况下,它甚至比添加元素更简单:只需要重新定位两个指针。
总结链表和 vector
的比较:可能最好得出的结论是,关于首选哪个数据结构的问题,没有明确的答案。有一些可以遵循的经验法则。但如果情况变得糟糕,可能需要使用分析器来找出最佳选择。
list
容器提供了以下构造函数、操作符和成员函数:
-
构造函数:
- 拷贝构造函数和移动构造函数可用;
- 可以构造一个空链表:
和list<string> object;
vector
一样,引用空链表的元素是一个错误。 - 可以初始化为一定数量的元素。默认情况下,如果初始化值未明确提及,则使用实际数据类型的默认值或默认构造函数。例如:
list<string> object(5, "Hello"s); // 初始化为5个"Hello" list<string> container(10); // 初始化为10个空字符串
- 可以使用两个迭代器初始化链表。要使用
vector<string>
的元素 5 到 10(包括最后一个)初始化链表,可以使用以下构造:extern vector<string> container; list<string> object(&container[5], &container[11]);
-
操作符:
list
没有提供专门的操作符,除了容器的标准操作符。
-
成员函数:
-
void assign(...)
:将新内容分配给链表:assign(iterator begin, iterator end)
:将迭代器范围[begin, end)
的值分配给链表;assign(size_type n, value_type const &val)
:将n
个val
的副本分配给链表。
-
Type &back()
:返回链表中最后一个元素的引用。程序员有责任确保仅在链表非空时使用此成员。 -
list::iterator begin()
:返回指向链表第一个元素的迭代器,如果链表为空,则返回end
。 -
void clear()
:清除链表中的所有元素。 -
value_type &emplace_back(Args &&...args)
:从成员的参数构造一个value_type
对象,并将新创建的元素插入到链表的最后一个元素之后,返回对新添加元素的引用。 -
value_type &emplace_front(Args &&...args)
:从成员的参数构造一个value_type
对象,并将新创建的元素插入到链表的第一个元素之前,返回对新添加元素的引用。 -
bool empty()
:如果链表不包含任何元素,则返回true
。 -
list::iterator end()
:返回指向链表最后一个元素之后的迭代器。 -
list::iterator erase()
:删除链表中特定范围的元素:erase(pos)
:删除由pos
指向的元素。返回迭代器++pos
。erase(first, beyond)
:删除由迭代器范围[first, beyond)
指示的元素。返回beyond
。
-
Type &front()
:返回链表中第一个元素的引用。程序员有责任确保仅在链表非空时使用此成员。 -
allocator_type get_allocator() const
:返回链表对象使用的分配器对象的副本。 -
... insert()
:向链表中插入元素。返回值取决于调用的insert
版本:list::iterator insert(pos)
:在pos
处插入类型Type
的默认值,返回pos
。list::iterator insert(pos, value)
:在pos
处插入value
,返回pos
。void insert(pos, first, beyond)
:插入迭代器范围[first, beyond)
内的元素。void insert(pos, n, value)
:在pos
位置插入n
个值为value
的元素。
-
size_t max_size()
:返回此链表可能包含的最大元素数量。 -
void merge(list<Type> other)
:此成员函数假设当前链表和other
链表是排序的(见下文的sort
成员)。基于此假设,它将other
的元素插入到当前链表中,以使修改后的链表保持排序。如果两个链表都未排序,则结果链表将尽可能排序,考虑到两个链表中元素的初始顺序。list<Type>::merge
使用Type::operator<
来对链表中的数据进行排序,因此必须提供该操作符。下面的例子说明了merge
成员的用法:链表object
未排序,因此结果链表尽可能有序。#include <iostream> #include <string> #include <list> using namespace std; void showlist(list<string> &target) { for (list<string>::iterator from = target.begin(); from != target.end(); ++from) cout << *from << " "; cout << '\n'; } int main() { list<string> first, second; first.push_back("alpha"s); first.push_back("bravo"s); first.push_back("golf"s); first.push_back("quebec"s); second.push_back("oscar"s); second.push_back("mike"s); second.push_back("november"s); second.push_back("zulu"s); first.merge(second); showlist(first); }
输出:
alpha bravo golf oscar mike november quebec zulu
需要注意的是,如果链表本身用作参数,
merge
不会改变链表:object.merge(object)
不会改变链表object
。 -
void pop_back()
:删除链表中的最后一个元素。在对空链表调用时,程序会中止。 -
void pop_front()
:删除链表中的第一个元素。在对空链表调用时,程序会中止。 -
void push_back(value)
:将value
添加到链表的末尾。 -
void push_front(value)
:将value
添加到链表的第一个元素之前。 -
list::reverse_iterator rbegin()
:返回指向链表最后一个元素的反向迭代器。 -
void remove(value)
:从链表中删除所有value
的出现。在下面的示例中,链表对象中的两个"Hello"
字符串被删除。
-
• 在插入或删除操作占主导地位且数据结构较大的情况下,通常更倾向于使用列表(list)。然而,现如今列表不再像过去那样有用(当时计算机的速度较慢且内存受限)。除了少数特殊情况外,矢量(vector)应该是首选的容器,即使在传统上使用列表实现的算法中也是如此。
在选择列表和vector之间时,还应考虑其他因素。尽管vector可以动态增长,但动态增长需要进行数据复制。显然,即使在快速计算机上,复制一百万个大型数据结构也需要相当长的时间。另一方面,向列表中插入大量元素并不需要复制不相关的数据。在列表中插入新元素只需调整一些指针即可。图 12.2 显示了这种情况:在第二个和第三个元素之间插入了一个新元素,创建了一个包含四个元素的新列表。从列表中删除一个元素也相对容易。同样从图 12.1 所示的情况出发,图 12.3 显示了删除列表中的第二个元素时发生的情况。同样:只需要调整指针。在这种情况下,它甚至比添加元素更简单:只需重新调整两个指针。总结列表和矢量之间的比较:可能最好得出的结论是,对于选择哪种数据结构,没有明确的答案。可以遵循一些经验法则。但是,如果情况不明朗,可能需要使用性能分析工具来确定最佳选择。
列表容器提供了以下构造函数、操作符和成员函数:
- 构造函数:
- 拷贝构造函数和移动构造函数是可用的;
- 列表可以构造为空:
与矢量一样,引用空列表中的元素是一个错误。list<string> object;
- 列表可以初始化为一定数量的元素。如果未明确提到初始化值,则使用实际数据类型的默认值或默认构造函数。例如:
list<string> object(5, "Hello"s); // 初始化为 5 个 "Hello" list<string> container(10); // 初始化为 10 个空字符串
- 列表可以使用两个迭代器进行初始化。要使用一个 vector 的元素 5 到 10(包括最后一个元素)来初始化列表,可以使用以下构造:
extern vector<string> container; list<string> object(&container[5], &container[11]);
- 列表没有提供特定的操作符,除了容器的标准操作符之外。
- 以下成员函数是可用的:
void assign(...)
:将新内容分配给列表:assign(iterator begin, iterator end)
将迭代器范围[begin, end)
的值分配给列表;assign(size_type n, value_type const &val)
将 n 个 val 的副本分配给列表;
Type &back()
:返回对列表最后一个元素的引用。程序员有责任确保仅在列表不为空时使用此成员。list::iterator begin()
:返回指向列表中第一个元素的迭代器,如果列表为空,则返回 end。void clear()
:删除列表中的所有元素。value_type &emplace_back(Args &&...args)
:从成员的参数构造一个 value_type 对象,并将新创建的元素插入列表的最后一个元素之后,返回对新添加元素的引用。value_type &emplace_front(Args &&...args)
:从成员的参数构造一个 value_type 对象,并将新创建的元素插入列表的第一个元素之前,返回对新添加元素的引用。bool empty()
:如果列表不包含任何元素,则返回 true。list::iterator end()
:返回指向列表中最后一个元素之后的迭代器。list::iterator erase()
:删除列表中的特定范围的元素:erase(pos)
删除指针指向的元素,并返回迭代器++pos
。erase(first, beyond)
删除迭代器范围[first, beyond)
中的元素,并返回 beyond。
Type &front()
:返回对列表中第一个元素的引用。程序员有责任确保仅在列表不为空时使用此成员。allocator_type get_allocator() const
:返回列表对象使用的分配器对象的副本。list::iterator insert(pos)
:在pos
处插入默认类型的元素,并返回pos
。list::iterator insert(pos, value)
:在pos
处插入value
,并返回pos
。void insert(pos, first, beyond)
:插入迭代器范围[first, beyond)
中的元素到pos
。void insert(pos, n, value)
:在pos
位置插入n
个value
元素。size_t max_size()
:返回此列表可能包含的最大元素数。void merge(list<Type> other)
:此成员函数假设当前和其他列表是排序好的(参见下文的成员sort
)。基于此假设,它将其他列表的元素插入当前列表,以保持修改后的列表是排序的。如果两个列表都没有排序,结果列表将“尽可能排序”,这是由两个列表中的元素的初始顺序决定的。list<Type>::merge
使用Type::operator<
来对列表中的数据进行排序,因此此操作符必须可用。以下示例说明了merge
成员的使用:列表object
未排序,因此结果列表“尽可能排序”。void pop_back()
:删除列表的最后一个元素。如果在空列表上调用此函数,程序将中止。void pop_front()
:删除列表的第一个元素。如果在空列表上调用此函数,程序将中止。void push_back(value)
:将value
添加到列表末尾。void push_front(value)
:将value
添加到列表的第一个元素之前。list::reverse_iterator rbegin()
:返回指向列表中最后一个元素的reverse_iterator
。void remove(value)
:删除列表中所有等于value
的元素。以下示例演示了从列表object
中删除两个 “Hello” 字符串:#include <iostream> #include <string> #include <list> using namespace std; int main() { list<string> object; object.push_back("Hello"s); object.push_back("World"s); object.push_back("Hello"s); object.push_back("World"s); object.remove("Hello"s); while (object.size()) { cout << object.front() << '\n'; object.pop_front(); } } /* 输出结果: World World */
void remove_if(Predicate pred)
:删除列表中满足谓词函数或函数对象pred
返回true
的所有元素。对于列表中存储的每个对象,谓词将以pred(*iter)
的形式被调用,其中iter
表示remove_if
内部使用的迭代器。如果使用函数pred
,其原型应为bool pred(value_type const &object)
。list::reverse_iterator rend()
:返回指向列表中第一个元素之前的reverse_iterator
。void resize()
:更改当前存储在列表中的元素数量:resize(n, value)
可用于将列表调整为大小为n
。value
是可选的。如果列表被扩展且未提供value
,则额外的元素将被初始化为使用的数据类型的默认值,否则使用value
初始化额外元素。
void reverse()
:反转列表中的元素顺序。最后一个元素变为第一个,反之亦然。size_t size()
:返回列表中的元素数量。void sort()
:对列表进行排序。其使用示例在下面的unique
成员函数描述中给出。list<Type>::sort
使用Type::operator<
对列表中的数据进行排序,因此此操作符必须可用。void splice(pos, object)
:使用splice
成员将object
的内容传输到当前列表中,从对象的迭代器位置pos
开始插入。splice
之后,object
为空。例如:
另外,#include <iostream> #include <string> #include <list> using namespace std; int main() { list<string> object; object.push_front("Hello"s); object.push_back("World"s); list<string> argument(object); object.splice(++object.begin(), argument); cout << "Object contains " << object.size() << " elements, " << "Argument contains " << argument.size() << " elements,\n"; while (object.size()) { cout << object.front() << '\n'; object.pop_front(); } }
argument
之后可以跟随argument
的一个迭代器,指示应该拼接的argument
的第一个元素,或者跟随两个迭代器begin
和end
,定义[begin, end)
迭代器范围的argument
元素,应该拼接到object
中。void swap()
:使用相同的数据类型交换两个列表。void unique()
:在排序列表上操作,此成员函数从列表中删除所有连续相同的元素。list<Type>::unique
使用Type::operator==
来识别相同的数据元素,因此此操作符必须可用。以下示例从列表中删除所有多次出现的单词:#include <iostream> #include <string> #include <list> using namespace std; void showlist(list<string> &target) { for (list<string>::iterator from = target.begin(); from != target.end(); ++from) cout << *from << " "; cout << '\n'; } int main() { string array[] = { "charlie", "alpha", "bravo", "alpha" }; list<string> target(array, array + sizeof(array) / sizeof(string)); cout << "Initially we have:\n"; showlist(target); target.sort(); cout << "After sort() we have:\n"; showlist(target); target.unique(); cout << "After unique() we have:\n"; showlist(target); } /* 输出结果: Initially we have: charlie alpha bravo alpha After sort() we have: alpha alpha bravo charlie After unique() we have: alpha bravo charlie */
队列容器 ‘queue’
queue
类实现了队列数据结构。在使用队列容器之前,必须包含头文件 <queue>
。
如图 12.4 所示,队列有一个用于添加元素的点(队尾)和一个用于移除(读取)元素的点(队首)。因此,队列也被称为先进先出(FIFO)数据结构。队列通常用于需要按生成顺序处理事件的场景。
以下构造函数、操作符和成员函数可用于队列容器:
-
构造函数:
- 拷贝构造函数和移动构造函数是可用的;
- 队列可以构造为空:
与矢量(vector)一样,引用空队列中的元素是一个错误。queue<string> object;
-
队列容器仅支持基本的容器操作符。
-
以下成员函数可用于队列:
Type &back()
:返回对队列中最后一个元素的引用。程序员有责任确保仅在队列不为空时使用此成员。
–value_type &emplace(Args &&...args)
:从成员函数的参数构造一个value_type
对象,并将新创建的元素插入队列末尾,返回对新添加元素的引用(或副本)。
–bool empty()
:如果队列不包含任何元素,则返回true
。
–Type &front()
:返回队列中第一个元素的引用。程序员有责任确保仅在队列不为空时使用此成员。
–void pop()
:移除队列前端的元素。请注意,此成员不会返回被移除的元素。避免在空队列上调用此成员:尽管没有返回 任何东西,但它内部维护的可用元素数量会减少,导致队列的size()
成员返回的值(转换为 int 时)为 -1,然后是 -2,依此类推。有人可能会好奇为什么pop
返回void
,而不是返回Type
类型的值(如同front
)。原因之一是良好软件设计的原则:函数应执行一个任务。将移除和返回被移除元素的操作结合起来违反了这一原则。此外,当这一原则被放弃时,pop
的实现总是存在缺陷。考虑一个典型的pop
成员实现,它期望返回队列的前端值:
Type queue::pop() { Type ret{ front() }; erase_front(); return ret; }
由于队列无法控制
Type
的行为,第一条语句(Type ret{ front() }
)可能会抛出异常。尽管这仍然支持“提交或回滚”原则,但队列不能保证Type
提供拷贝构造。因此,pop
不返回队列的front值,而是简单地删除该元素:Type queue::pop() { erase_front(); }
注意,
push
不需要拷贝构造:push
也可以在支持移动构造时实现。例如:void queue::push(Type &&tmp) { d_data.push_back(std::move(tmp)); // 使用移动构造 }
正因为如此,我们必须先使用
front
,然后使用pop
来获取并移除队列的front元素。
–void push(value)
:此成员将value
添加到队列的末尾。
–size_t size()
:返回队列中的元素数量。需要注意的是,队列不支持迭代器或索引操作符。唯一可以访问的元素是其前端和末端元素。可以通过以下方式清空队列:
- 反复移除其前端元素;
- 将一个相同数据类型的空队列赋值给它;
- 调用其析构函数。
优先队列容器 priority_queue
priority_queue
类实现了优先队列数据结构。在使用 priority_queue
容器之前,必须包含 <queue>
头文件。
优先队列与普通队列相同,但允许根据优先级规则插入数据元素。现实生活中的优先队列可以在机场值机柜台找到。例如,在值机柜台,乘客通常排队等待办理登机手续,但迟到的乘客通常被允许插队:他们比其他乘客获得更高的优先级。
优先队列使用存储在优先队列中的数据类型的 operator<
来决定数据元素的优先级。值越小,优先级越低。因此,优先队列可以用于按到达顺序对值进行排序。下面是一个使用优先队列的简单示例程序:它从 cin
读取单词并将排序后的单词列表输出到 cout
:
#include <iostream>
#include <queue>
#include <string>
using namespace std;
int main() {
priority_queue<string> q;
string word;
while (cin >> word) q.push(word);
while (q.size()) {
cout << q.top() << '\n';
q.pop();
}
}
不幸的是,由于底层的 operator<
,单词会按相反的顺序列出:在 ASCII 序列中出现较晚的单词会优先出现在优先队列中。解决此问题的一种方法是围绕 string
数据类型定义一个包装类,反转 string
的 operator<
。下面是修改后的程序:
#include <string>
#include <queue>
#include <iostream>
class Text {
std::string d_s;
public:
Text(std::string const &str) : d_s(str) {}
operator std::string const &() const { return d_s; }
bool operator<(Text const &right) const { return d_s > right.d_s; }
};
using namespace std;
int main() {
priority_queue<Text> q;
string word;
while (cin >> word) q.push(word);
while (q.size()) {
word = q.top();
cout << word << '\n';
q.pop();
}
}
实现相同目的的其他方法也存在。例如,可以将优先队列的内容存储在一个 vector
中,从中可以按相反顺序读取元素。
以下是 priority_queue
容器的构造函数、运算符和成员函数:
-
构造函数:
- 支持拷贝和移动构造函数;
- 可以构造一个空的优先队列:
priority_queue<string> object;
-
priority_queue
仅支持基本的容器操作符。 -
以下是优先队列可用的成员函数:
bool empty()
:如果优先队列不包含任何元素,则返回true
。void pop()
:移除优先队列顶部的元素。请注意,此成员不会返回被移除的元素。与普通队列一样,避免在空的优先队列上调用此成员。void push(value)
:将value
插入到优先队列中的适当位置。size_t size()
:返回优先队列中的元素数量。Type &top()
:返回优先队列第一个元素的引用。程序员有责任确保仅在优先队列不为空时使用此成员。
需要注意的是,优先队列不支持迭代器或索引操作符。唯一可以访问的元素是其顶部元素。可以通过以下方式清空优先队列:
- 反复移除其顶部元素;
- 将一个相同数据类型的空队列赋值给它;
- 调用其析构函数。
deque
容器
deque
(发音为“deck”)类实现了双端队列数据结构。在使用 deque
容器之前,必须包含 <deque>
头文件。
deque
类似于队列,但允许在两端进行读写操作。实际上,deque
数据类型支持比队列更多的功能,如下所述。deque
是向量和两个队列的组合,在向量的两端操作。在随机插入、添加和/或删除元素频繁发生的情况下,应该考虑使用 deque
。
以下是 deque
的构造函数、操作符和成员函数:
-
构造函数:
- 支持拷贝和移动构造函数;
- 可以构造一个空的
deque
:
和deque<string> object;
vector
一样,引用空deque
的元素是错误的。 - 可以初始化为一定数量的元素。如果初始化值未明确提及,则使用实际数据类型的默认值或默认构造函数。例如:
deque<string> object(5, "Hello"s); // 初始化为 5 个 "Hello" deque<string> container(10); // 初始化为 10 个空字符串
- 可以使用两个迭代器进行初始化。要使用
vector<string>
的元素 5 到 10(包括最后一个)初始化deque
,可以使用以下构造:extern vector<string> container; deque<string> object(&container[5], &container[11]);
-
除了标准的容器操作符,
deque
还支持索引操作符,可用于检索或重新分配deque
的随机元素。请注意,索引的元素必须存在。 -
以下是
deque
可用的成员函数:void assign(...)
:为deque
分配新内容:assign(iterator begin, iterator end)
:将迭代器范围[begin, end)
内的值分配给deque
;assign(size_type n, value_type const &val)
:为deque
分配n
个val
的副本。
Type &at(size_t idx)
:返回对deque
中索引位置idx
的元素的引用。如果idx
超出deque
的大小,则抛出std::out_of_range
异常。Type &back()
:返回deque
中最后一个元素的引用。程序员有责任确保deque
不为空时才使用此成员。deque::iterator begin()
:返回指向deque
第一个元素的迭代器。deque::const_iterator cbegin()
:返回指向deque
第一个元素的const_iterator
,如果deque
为空,则返回cend
。deque::const_iterator cend()
:返回指向deque
最后一个元素之后的const_iterator
。void clear()
:擦除deque
中的所有元素。deque::const_reverse_iterator crbegin()
:返回指向deque
中最后一个元素的const_reverse_iterator
,如果deque
为空,则返回crend
。deque::const_reverse_iterator crend()
:返回指向deque
第一个元素之前的const_reverse_iterator
。iterator emplace(const_iterator position, Args &&...args)
:从指定的参数构造一个value_type
对象,并将新创建的元素插入到position
位置。void emplace_back(Args &&...args)
:从成员的参数构造一个value_type
对象,并将新创建的元素插入到deque
的最后一个元素之后。void emplace_front(Args &&...args)
:从成员的参数构造一个value_type
对象,并将新创建的元素插入到deque
的第一个元素之前。bool empty()
:如果deque
不包含任何元素,则返回true
。deque::iterator end()
:返回指向deque
最后一个元素之后的迭代器。deque::iterator erase()
:此成员可用于删除deque
中特定范围的元素:erase(pos)
:删除pos
指向的元素,返回迭代器++pos
。erase(first, beyond)
:删除由迭代器范围[first, beyond)
指示的元素,返回beyond
。
Type &front()
:返回deque
中第一个元素的引用。程序员有责任确保deque
不为空时才使用此成员。allocator_type get_allocator() const
:返回deque
对象使用的分配器对象的副本。... insert()
:从某个位置开始插入元素。返回值取决于调用的insert
版本:deque::iterator insert(pos)
:在pos
处插入一个默认值类型Type
,返回pos
。deque::iterator insert(pos, value)
:在pos
处插入value
,返回pos
。void insert(pos, first, beyond)
:插入迭代器范围[first, beyond)
内的元素。void insert(pos, n, value)
:从迭代器位置pos
开始插入n
个具有值value
的元素。
size_t max_size()
:返回此deque
可能包含的最大元素数。void pop_back()
:移除deque
中的最后一个元素。避免在空deque
上调用此成员:虽然没有返回任何内容,但其内部维护的可用元素数量减少,导致deque
的size()
成员返回值(转换为int
时)依次为-1
、-2
,等等。void pop_front()
:移除deque
中的第一个元素。避免在空deque
上调用此成员:与pop_back()
一样,其内部维护的可用元素数量减少。void push_back(value)
:将value
添加到deque
的末尾。void push_front(value)
:将value
添加到deque
的第一个元素之前。deque::reverse_iterator rbegin()
:返回指向deque
中最后一个元素的reverse_iterator
。deque::reverse_iterator rend()
:此成员返回指向deque
第一个元素之前的reverse_iterator
。void resize()
:改变当前存储在deque
中的元素数量:resize(n, value)
:可用于将deque
的大小调整为n
。value
是可选的。如果deque
扩展并且未提供value
,则额外的元素将初始化为使用的数据类型的默认值,否则将使用value
初始化额外的元素。
void shrink_to_fit()
:可选择将deque
分配的内存减少到其当前大小。实现者可以自由忽略或以其他方式优化此请求。为了保证 “收缩以适应” 操作,可以使用deque<Type>(dequeObject).swap(dequeObject)
的惯用法。size_t size()
:返回deque
中的元素数量。void swap(argument)
:使用相同数据类型交换两个deque
。
map
容器
map
类提供了一个(已排序的)关联数组。在使用 map
容器之前,必须包含 <map>
头文件。
map
存储由键值对组成的数据,这些键值对可以是任何容器接受的类型。由于键和值都有对应的类型,我们必须在尖括号中指定两个类型,这与我们在 pair
容器(参见第 12.2 节)中看到的规范类似。第一个类型表示键的类型,第二个类型表示值的类型。键用于访问其关联的信息。map
使用键的 operator<
对键进行排序,其中最小的键值作为 map
中的第一个元素。例如,一个键为字符串,值为双精度浮点数的 map
可以定义如下:
map<string, double> object;
这些信息被称为值。例如,电话簿使用人名作为键,使用电话号码以及其他信息(如邮政编码、地址、职业)作为值。由于 map
对键进行排序,键的 operator<
必须被定义,并且必须具有合理的实现。例如,使用指针作为键通常是个坏主意,因为排序指针与排序指针所指向的值是不同的操作。此外,除了键和值类型之外,还有一个第三种类型定义了比较类,用于比较两个键。默认情况下,比较类是 std::less<KeyType>
(参见第 18.1.2 节),使用键类型的 operator<
比较两个键值。因此,对于键类型 KeyType
和值类型 ValueType
,map
的类型定义如下:
map<KeyType, ValueType, std::less<KeyType>>
map
上的两个基本操作是存储键值对组合和根据键检索值。使用键作为索引的索引操作符可以用于这两个操作。如果索引操作符作为左值使用,表达式的右值将被插入到 map
中。如果作为右值使用,则检索键的关联值。每个键在 map
中只能存储一次。如果再次插入相同的键,新值将替换之前存储的值,之前的值将丢失。
可以隐式或显式地将特定的键值对插入到 map
中。如果需要显式插入,则必须首先构造键值对。为此,每个 map
定义了一个 value_type
,可以用来创建可以存储在 map
中的值。例如,对于 map<string, int>
,可以如下构造一个值:
map<string, int>::value_type siValue{ "Hello", 1 };
value_type
与 map<string, int>
相关联:键的类型是 string
,值的类型是 int
。匿名的 value_type
对象也经常被使用。例如:
map<string, int>::value_type{ "Hello", 1 };
为了减少输入量并提高可读性,通常会使用 using
声明:
using StringIntValue = map<string, int>::value_type;
现在,可以用以下方式指定 map<string, int>
的值:
StringIntValue{ "Hello", 1 };
另外,pair
也可以用来表示 map
使用的键值对组合:
pair<string, int>{ "Hello", 1 };
map
构造函数
map
容器提供了以下构造函数:
-
复制构造函数和移动构造函数是可用的。
-
可以创建一个空的
map
:map<string, int> object;
请注意,存储在
map
中的值本身也可以是容器。例如,以下定义了一个值为pair
的map
:即一个嵌套在另一个容器下的容器:map<string, pair<string, string>> object;
注意两个连续的右尖括号,它们不会导致歧义,因为其语法上下文与在表达式中用作二元运算符的情况不同。
-
可以使用两个迭代器初始化
map
。这些迭代器可以指向map
的value_type
值,或者指向普通的pair
对象。如果使用pair
,其第一个元素表示键的类型,第二个元素表示值的类型。示例:pair<string, int> pa[] = { pair<string,int>("one", 1), pair<string,int>("two", 2), pair<string,int>("three", 3), }; map<string, int> object(&pa[0], &pa[3]);
在这个示例中,
map<string, int>::value_type
也可以替代pair<string, int>
使用。如果
begin
代表用于构造map
的第一个迭代器,end
代表第二个迭代器,则[begin, end)
将用于初始化map
。与直觉相反,map
构造函数只会插入新的键。如果pa
的最后一个元素是"one", 3
,那么map
中只会插入两个元素:"one", 1
和"two", 2
。值"one", 3
将被默默忽略。map
复制了迭代器所指向的数据,如下例所示:#include <iostream> #include <map> using namespace std; class MyClass { public: MyClass() { cout << "MyClass constructor\n"; } MyClass(MyClass const &other) { cout << "MyClass copy constructor\n"; } ~MyClass() { cout << "MyClass destructor\n"; } }; int main() { pair<string, MyClass> pairs[] = { pair<string, MyClass>{ "one", MyClass{} } }; cout << "pairs constructed\n"; map<string, MyClass> mapsm{ &pairs[0], &pairs[1] }; cout << "mapsm constructed\n"; }
生成的输出:
MyClass constructor MyClass copy constructor MyClass destructor pairs constructed MyClass copy constructor mapsm constructed MyClass destructor MyClass destructor
当追踪这个程序的输出时,我们可以看到,首先,调用了
MyClass
对象的构造函数来初始化数组pairs
的匿名元素。这个对象随后被复制到pairs
数组的第一个元素中,使用了复制构造函数。接下来,原始元素不再需要,因而被销毁。此时,数组pairs
已经被构造完毕。然后,
map
构造了一个临时的pair
对象,该对象用于构造map
元素。构造完map
元素后,临时的pair
对象被销毁。最终,当程序终止时,存储在map
中的pair
元素也会被销毁。
map
操作符
除了标准的容器操作符外,map
还支持索引操作符。索引操作符可以用来检索或重新分配 map
中的单个元素。索引操作符的参数称为键(key)。
如果提供的键在 map
中不存在,则会自动向 map
中添加一个新的数据元素,新的元素的值部分会使用默认值或默认构造函数进行初始化。如果索引操作符作为右值使用,则返回这个默认值。
在初始化或重新分配 map
中的元素时,赋值操作符右边的类型必须与 map
的值部分的类型相同(或能够提升到该类型)。例如,要添加或更改 map
中 "two"
元素的值,可以使用以下语句:
mapsm["two"] = MyClass{};
map
公共成员函数
map
容器提供以下公共成员函数:
-
mapped_type &at(key_type const &key):
返回与指定键关联的map
中的mapped_type
的引用。如果键不在map
中,会抛出std::out_of_range
异常。 -
map::iterator begin():
返回一个指向map
第一个元素的迭代器。 -
map::const_iterator cbegin():
返回一个指向map
第一个元素的常量迭代器,如果map
为空,则返回cend
。 -
map::const_iterator cend():
返回一个指向map
最后一个元素之后的常量迭代器。 -
void clear():
删除map
中的所有元素。 -
size_t count(key):
如果指定的键在map
中存在,返回 1;否则返回 0。 -
map::reverse_iterator crbegin() const:
返回一个反向迭代器,指向map
中的最后一个元素。 -
map::reverse_iterator crend():
返回一个迭代器,指向map
中第一个元素之前的位置。 -
pair<iterator, bool> emplace(Args &&…args):
使用emplace
的参数构造一个value_type
对象。如果map
中已存在具有相同键的对象,则返回一个std::pair
,包含指向该对象的迭代器和false
。如果没有找到相同的键,则将新构造的对象插入map
,返回的std::pair
包含指向新插入的value_type
的迭代器和true
。 -
iterator emplace_hint(const_iterator position, Args &&…args):
使用成员的参数构造一个value_type
对象,并将新创建的元素插入map
,除非提供的键已经存在。实现可能会使用position
作为提示开始查找插入点。返回的迭代器指向使用提供的键的value_type
。它可以指向已经存在的value_type
或新添加的value_type
;如果添加了新值,则map
的大小在emplace_hint
返回时会增加。 -
bool empty():
如果map
不包含任何元素,返回true
。 -
map::iterator end():
返回一个指向map
最后一个元素之后的迭代器。 -
pair<map::iterator, map::iterator> equal_range(key):
返回一对迭代器,分别是lower_bound
和upper_bound
成员函数的返回值。以下例子展示了这些成员函数的使用: -
… erase():
从map
中删除特定元素或一段元素:bool erase(key)
删除具有指定键的元素。如果删除成功,返回true
;如果map
中不存在具有该键的元素,返回false
。void erase(pos)
删除由迭代器pos
指向的元素。void erase(first, beyond)
删除由迭代器范围[first, beyond)
指定的所有元素。
-
map::iterator find(key):
返回一个指向具有指定键的元素的迭代器。如果元素不存在,则返回end
。以下示例演示了find
成员函数的使用:#include <iostream> #include <map> using namespace std; int main() { map<string, int> object; object["one"] = 1; map<string, int>::iterator it = object.find("one"); cout << "`one' " << (it == object.end() ? "not " : "") << "found\n"; it = object.find("three"); cout << "`three' " << (it == object.end() ? "not " : "") << "found\n"; } /* 生成的输出: `one' found `three' not found */
-
allocator_type get_allocator() const:
返回map
对象使用的分配器对象的副本。 -
… insert():
向map
中插入元素。已存在键关联的值不会被新值替换。返回值取决于调用的insert
版本:pair<map::iterator, bool> insert(keyvalue)
向map
中插入新的value_type
。返回值是pair<map::iterator, bool>
。如果返回的布尔值为true
,则keyvalue
被插入map
。布尔值为false
表示指定的键已经存在于map
中,因此keyvalue
没有被插入。无论如何,map::iterator
字段指向具有指定键的数据元素。以下示例演示了这种insert
变体的使用:
#include <iostream> #include <string> #include <map> using namespace std; int main() { pair<string, int> pa[] = { pair<string,int>("one", 10), pair<string,int>("two", 20), pair<string,int>("three", 30), }; map<string, int> object(&pa[0], &pa[3]); // 插入 {four, 40} 并返回 `true` pair<map<string, int>::iterator, bool> ret = object.insert ( map<string, int>::value_type ("four", 40) ); cout << boolalpha; cout << ret.first->first << " " << ret.first->second << " " << ret.second << " " << object["four"] << '\n'; // 插入 {four, 0} 并返回 `false` ret = object.insert ( map<string, int>::value_type ("four", 0) ); cout << ret.first->first << " " << ret.first->second << " " << ret.second << " " << object["four"] << '\n'; } /* 生成的输出: four 40 true 40 four 40 false 40 */
注意上述示例中的一些奇特构造,例如
cout << ret.first->first << " " << ret.first->second << ...
。注意,
ret
是insert
成员函数返回的pair
。它的first
字段是一个指向map<string, int>
的迭代器,因此可以视为指向map<string, int>::value_type
的指针。这些值类型本身也是对,具有first
和second
字段。因此,ret.first->first
是map
中值的键(即字符串),ret.first->second
是值(即整数)。 -
map::iterator insert(pos, keyvalue):
这种方式也可以将map::value_type
插入到map
中。pos
参数会被忽略,返回指向插入元素的迭代器。 -
void insert(first, beyond):
将由迭代器范围[first, beyond)
指向的map::value_type
元素插入到map
中。已经存在的值不会被替换。 -
key_compare key_comp():
返回map
用于比较键的对象的副本。map<KeyType, ValueType>::key_compare
类型由map
容器定义,key_comp
的参数类型为KeyType const &
。比较函数如果第一个键参数应该排在第二个键参数之前,则返回true
。要比较键和值,使用下面列出的value_comp
。 -
map::iterator lower_bound(key):
返回一个迭代器,指向第一个键值对,其中键至少等于指定的键。如果没有这样的元素,函数返回end
。 -
size_t max_size():
返回map
可能包含的最大元素数量。 -
map::reverse_iterator rbegin():
返回一个反向迭代器,指向map
中的最后一个元素。 -
map::reverse_iterator rend():
返回一个迭代器,指向map
中第一个元素之前的位置。 -
size_t size():
返回map
中元素的数量。 -
void swap(argument):
交换两个具有相同键/值类型的map
。 -
map::iterator upper_bound(key):
返回一个迭代器,指向第一个键值对,其中键超过了指定的键。如果没有这样的元素,函数返回end
。以下示例演示了equal_range
、lower_bound
和upper_bound
成员函数的使用:#include <iostream> #include <map> using namespace std; int main() { pair<string, int> pa[] = { pair<string,int>("one", 10), pair<string,int>("two", 20), pair<string,int>("three", 30), }; map<string, int> object(&pa[0], &pa[3]); map<string, int>::iterator it; if ((it = object.lower_bound("tw")) != object.end()) cout << "lower-bound `tw' is available, it is: " << it->first << '\n'; if (object.lower_bound("twoo") == object.end()) cout << "lower-bound `twoo' not available" << '\n'; cout << "lower-bound two: " << object.lower_bound("two")->first << " is available\n"; if ((it = object.upper_bound("tw")) != object.end()) cout << "upper-bound `tw' is available, it is: " << it->first << '\n'; if (object.upper_bound("twoo") == object.end()) cout << "upper-bound `twoo' not available" << '\n'; if (object.upper_bound("two") == object.end()) cout << "upper-bound `two' not available" << '\n'; pair < map<string, int>::iterator, map<string, int>::iterator > p = object.equal_range("two"); cout << "equal range: `first' points to " << p.first->first << ", `second' is " << ( p.second == object.end() ? "not available" : p.second->first ) << '\n'; } /* 生成的输出: lower-bound `tw' is available, it is: two lower-bound `twoo' not available lower-bound two: two is available upper-bound `tw' is available, it is: two upper-bound `twoo' not available upper-bound `two' not available equal range: `first' points to two, `second' is not available */
-
value_compare value_comp():
返回map
用于比较键的对象的副本。map<KeyType, ValueType>::value_compare
类型由map
容器定义,value_comp
的参数类型为value_type const &
。比较函数如果第一个键参数应该排在第二个键参数之前,则返回true
。value_type
对象传递给此成员函数的Value_Type
元素不被使用。
map
的简单示例
正如第 12.4.7 节开头所提到的,map
表示一个排序的关联数组。在 map
中,键是有序的。如果应用程序需要遍历 map
中的所有元素,则必须使用 begin
和 end
迭代器。
以下示例演示了如何创建一个简单的表格,列出 map
中找到的所有键和值:
#include <iostream>
#include <iomanip>
#include <map>
using namespace std;
int main()
{
pair<string, int> pa[] =
{
pair<string, int>("one", 10),
pair<string, int>("two", 20),
pair<string, int>("three", 30),
};
map<string, int> object(&pa[0], &pa[3]);
for (map<string, int>::iterator it = object.begin(); it != object.end(); ++it)
{
cout << setw(5) << it->first.c_str() << setw(5) << it->second << '\n';
}
}
/*
生成的输出:
one 10
three 30
two 20
*/
在这个示例中,我们创建了一个 map<string, int>
对象 object
,并使用一组键值对来初始化它。然后,我们使用 begin
和 end
迭代器遍历 map
中的所有元素,并打印出每个键和对应的值。由于 map
会按键的顺序进行排序,输出结果也是按键的排序顺序排列的。
multimap
容器
与 map
类似,multimap
类实现了一个(有序)关联数组。在使用 multimap
容器之前,必须包含头文件 <map>
。
map
和 multimap
的主要区别在于,multimap
支持多个与同一个键相关联的值,而 map
中的键是单值的。需要注意的是,multimap
也接受多个相同键的多个相同值。
map
和 multimap
具有相同的构造函数和成员函数,唯一的例外是 multimap
不支持索引运算符。这是可以理解的:如果允许相同键的多个条目,object[key]
应该返回哪个可能的值?
参考 12.4.7 节了解 multimap
成员函数的概述。然而,在使用 multimap
容器时,有一些成员函数值得额外关注,这些成员函数如下所述:
-
size_t map::count(key)
:
返回与给定键相关联的multimap
中条目的数量。 -
... erase()
:
从map
中删除元素:size_t erase(key)
:删除所有具有给定键的元素。返回删除的元素数量。void erase(pos)
:删除由迭代器pos
指向的单个元素。其他可能具有相同键的元素不会被删除。void erase(first, beyond)
:删除由迭代器范围[first, beyond)
指定的所有元素。
-
pair<multimap::iterator, multimap::iterator> equal_range(key)
:
返回一对迭代器,分别是lower_bound
和upper_bound
的返回值。该函数提供了一种简单的方法来确定multimap
中所有具有相同键的元素。以下示例演示了这些成员函数的用法。 -
multimap::iterator find(key)
:
返回一个迭代器,指向第一个键为key
的值。如果元素不可用,则返回end
。可以递增该迭代器以访问所有具有相同键的元素,直到它是end
或迭代器的first
成员不再等于键为止。 -
multimap::iterator insert()
:
这个成员函数通常会成功,因此返回一个multimap::iterator
,而不是map
容器中返回的pair<multimap::iterator, bool>
。返回的迭代器指向新添加的元素。
尽管 lower_bound
和 upper_bound
在 map
和 multimap
容器中的作用相同,但在 multimap
中,它们的操作需要额外注意。以下示例演示了 lower_bound
、upper_bound
和 equal_range
在 multimap
中的应用:
#include <iostream>
#include <map>
using namespace std;
int main() {
pair<string, int> pa[] =
{
pair<string,int>("alpha", 1),
pair<string,int>("bravo", 2),
pair<string,int>("charlie", 3),
pair<string,int>("bravo", 6), // unordered `bravo' values
pair<string,int>("delta", 5),
pair<string,int>("bravo", 4),
};
multimap<string, int> object(&pa[0], &pa[6]);
using msiIterator = multimap<string, int>::iterator;
msiIterator it = object.lower_bound("brava");
cout << "Lower bound for `brava': " << it->first << ", " << it->second << '\n';
it = object.upper_bound("bravu");
cout << "Upper bound for `bravu': " << it->first << ", " << it->second << '\n';
pair<msiIterator, msiIterator> itPair = object.equal_range("bravo");
cout << "Equal range for `bravo':\n";
for (it = itPair.first; it != itPair.second; ++it)
cout << it->first << ", " << it->second << '\n';
cout << "Upper bound: " << it->first << ", " << it->second << '\n';
cout << "Equal range for `brav':\n";
itPair = object.equal_range("brav");
for (it = itPair.first; it != itPair.second; ++it)
cout << it->first << ", " << it->second << '\n';
cout << "Upper bound: " << it->first << ", " << it->second << '\n';
}
/*
Generated output:
Lower bound for `brava': bravo, 2
Upper bound for `bravu': charlie, 3
Equal range for `bravo':
bravo, 2
bravo, 6
bravo, 4
Upper bound: charlie, 3
Equal range for `brav':
Upper bound: bravo, 2
*/
特别注意以下特点:
lower_bound
和upper_bound
对于不存在的键产生相同的结果:它们都返回第一个键值大于所提供键的元素。- 尽管
multimap
中的键是有序的,但相同键的值不是有序的:它们按照输入的顺序检索。
set
容器
set
类实现了一个有序的值集合。在使用 set
容器之前,必须包含头文件 <set>
。
set
包含唯一的值(容器接受的类型)。每个值只能存储一次,set
使用 operator<
对其值进行排序,其中最小的值作为集合中的第一个元素。
可以显式创建特定的值:每个 set
定义了一个 value_type
,可以用来创建可以存储在 set
中的值。例如,对于 set<string>
,可以这样构造一个值:
set<string>::value_type setValue{ "Hello" };
与 std::map
容器类似,std::set
也有一个额外的参数声明了用于比较集合中值的类。因此,对于值类型 ValueType
,set
的类型定义如下:
set<ValueType, std::less<ValueType>>
value_type
与 set<string>
相关联。匿名的 value_type
对象也常常被使用。例如:
set<string>::value_type{ "Hello" };
为了减少输入量和提高可读性,通常使用 using
声明:
using StringSetValue = set<string>::value_type;
现在,可以如下构造 set<string>
的值:
StringSetValue{ "Hello" };
或者,可以立即使用 set
的类型值。在这种情况下,类型 Type
的值会隐式转换为 set<Type>::value_type
。
以下是 set
容器的构造函数、操作符和成员函数:
-
构造函数:
- 支持复制构造函数和移动构造函数;
- 可以构造一个空的集合:
set<int> object;
- 可以使用两个迭代器初始化一个集合。例如:
请注意,集合中的所有值必须是不同的:在集合构造时,不可能重复存储相同的值。如果相同的值重复出现,只有第一个实例会被添加到集合中,其余值会被静默忽略。int intarr[] = {1, 2, 3, 4, 5}; set<int> object{ &intarr[0], &intarr[5] };
与
map
类似,set
会接收它所包含的数据的副本。 -
set
容器只支持标准容器的操作符。 -
set
类具有以下成员函数:set::iterator begin()
:
返回一个指向集合第一个元素的迭代器。如果集合为空,则返回end
。void clear()
:
删除集合中的所有元素。size_t count(value)
:
如果指定的值存在于集合中,则返回 1,否则返回 0。pair<iterator, bool> emplace(Args &&...args)
:
使用成员的参数构造一个value_type
对象,并将新创建的元素插入到集合中。返回值的第二个成员为true
表示元素被插入,false
表示元素已存在。对返回值的第一个元素是一个指向集合元素的迭代器。bool empty()
:
如果集合中没有元素,则返回true
。set::iterator end()
:
返回一个指向集合最后一个元素之后的迭代器。pair<set::iterator, set::iterator> equal_range(value)
:
返回一对迭代器,分别是lower_bound
和upper_bound
的返回值。... erase()
:
从集合中删除特定元素或元素范围:bool erase(value)
:删除具有给定值的元素。如果删除成功,返回true
;如果集合中没有该元素,返回false
。void erase(pos)
:删除由迭代器pos
指向的元素。void erase(first, beyond)
:删除由迭代器范围[first, beyond)
指定的所有元素。
set::iterator find(value)
:
返回指向具有给定值的元素的迭代器。如果元素不可用,则返回end
。allocator_type get_allocator() const
:
返回集合对象使用的分配器对象的副本。... insert()
:
向集合中插入元素。如果元素已经存在,现有元素保持不变,待插入的元素被忽略。返回值取决于调用的insert
版本:pair<set::iterator, bool> insert(value)
:插入新的set::value_type
到集合中。返回值是pair<set::iterator, bool>
。如果返回的bool
字段为true
,则表示值被插入到集合中;如果为false
,则表示值已经存在于集合中,因此提供的值没有被插入。无论哪种情况,set::iterator
字段指向集合中具有指定值的数据元素。set::iterator insert(pos, value)
:以这种方式也可以将set::value_type
插入到集合中。pos
被忽略,返回指向插入元素的迭代器。void insert(first, beyond)
:将由迭代器范围[first, beyond)
指定的set::value_type
元素插入到集合中。已存在的值不会被替换。
key_compare key_comp()
:
返回用于比较集合中键的对象的副本。set<ValueType>::key_compare
的类型由集合容器定义,key_comp
的参数类型为ValueType const &
。比较函数返回true
如果第一个参数应该排在第二个参数之前。set::iterator lower_bound(value)
:
返回一个迭代器,指向第一个值元素,其值至少等于指定值。如果没有这样的元素,函数返回end
。size_t max_size()
:
返回集合可以包含的最大元素数量。set::reverse_iterator rbegin()
:
返回一个reverse_iterator
,指向集合中的最后一个元素。set::reverse_iterator rend()
:
返回一个reverse_iterator
,指向集合中第一个元素之前的位置。size_t size()
:
返回集合中的元素数量。void swap(argument)
:
交换两个使用相同数据类型的集合(argument
是第二个集合)。set::iterator upper_bound(value)
:
返回一个迭代器,指向第一个值元素,其值大于指定值。如果没有这样的元素,函数返回end
。value_compare value_comp()
:
返回用于比较集合中值的对象的副本。set<ValueType>::value_compare
的类型由集合容器定义,value_comp
的参数类型为ValueType const &
。比较函数返回true
如果第一个参数应该排在第二个参数之前。其操作与由key_comp
返回的key_compare
对象相同。
multiset
容器
与 set
类似,multiset
类实现了一个有序的值集合。在使用 multiset
容器之前,必须包含头文件 <set>
。
multiset
与 set
的主要区别在于,multiset
支持相同值的多个条目,而 set
仅包含唯一值。
set
和 multiset
具有相同的构造函数和成员函数。不过,multiset
的某些成员函数与 set
的对应函数略有不同。下面是一些需要特别注意的成员函数:
-
size_t count(value)
:
返回multiset
中与给定值相关联的条目数量。 -
... erase()
:
从multiset
中删除元素:size_t erase(value)
:删除具有给定值的所有元素。返回被删除的元素数量。void erase(pos)
:删除由迭代器pos
指向的元素。其他具有相同值的元素不会被删除。void erase(first, beyond)
:删除由迭代器范围[first, beyond)
指定的所有元素。
-
pair<multiset::iterator, multiset::iterator> equal_range(value)
:
返回一对迭代器,分别是lower_bound
和upper_bound
的返回值。该函数提供了一种简单的方法来确定multiset
中所有具有相同值的元素。 -
multiset::iterator find(value)
:
返回一个指向具有指定值的第一个元素的迭代器。如果该元素不存在,则返回end
。可以递增迭代器,以访问所有具有给定值的元素,直到遇到end
或迭代器不再指向该值。 -
... insert()
:
此成员函数通常成功,并返回一个multiset::iterator
,而不是pair<multiset::iterator, bool>
(这是set
容器返回的)。返回的迭代器指向新添加的元素。
虽然 lower_bound
和 upper_bound
在 set
和 multiset
容器中表现相同,但在 multiset
中,它们的操作值得特别注意。在 multiset
容器中,对于不存在的键,lower_bound
和 upper_bound
都返回第一个具有超过指定键的元素。
以下是一个示例,展示了 multiset
各种成员函数的使用:
#include <iostream>
#include <set>
using namespace std;
int main()
{
string sa[] = {
"alpha",
"echo",
"hotel",
"mike",
"romeo"
};
multiset<string> object(&sa[0], &sa[5]);
object.insert("echo");
object.insert("echo");
multiset<string>::iterator it = object.find("echo");
for (; it != object.end(); ++it)
cout << *it << " ";
cout << '\n';
cout << "Multiset::equal_range(\"ech\")\n";
pair<multiset<string>::iterator, multiset<string>::iterator> itpair = object.equal_range("ech");
if (itpair.first != object.end())
cout << "lower_bound() points at " << *itpair.first << '\n';
for (; itpair.first != itpair.second; ++itpair.first)
cout << *itpair.first << " ";
cout << '\n' << object.count("ech") << " occurrences of 'ech'" << '\n';
cout << "Multiset::equal_range(\"echo\")\n";
itpair = object.equal_range("echo");
for (; itpair.first != itpair.second; ++itpair.first)
cout << *itpair.first << " ";
cout << '\n' << object.count("echo") << " occurrences of 'echo'" << '\n';
cout << "Multiset::equal_range(\"echoo\")\n";
itpair = object.equal_range("echoo");
for (; itpair.first != itpair.second; ++itpair.first)
cout << *itpair.first << " ";
cout << '\n' << object.count("echoo") << " occurrences of 'echoo'" << '\n';
}
生成的输出:
echo echo echo hotel mike romeo
Multiset::equal_range("ech")
lower_bound() points at echo
0 occurrences of 'ech'
Multiset::equal_range("echo")
echo echo echo
3 occurrences of 'echo'
Multiset::equal_range("echoo")
0 occurrences of 'echoo'
stack
容器
stack
类实现了一个栈数据结构。在使用 stack
容器之前,必须包含头文件 <stack>
。
栈也被称为先进后出(FILO 或 LIFO)数据结构,因为最先进入栈的项是最后离开栈的项。栈在需要暂时保持数据可用的情况下是一个非常有用的数据结构。例如,程序维护一个栈来存储函数的局部变量:这些变量的生命周期由这些函数的活动时间决定,而与程序自身的生命周期无关(相对于全局变量或静态局部变量,它们的生命周期与程序相同)。另一个例子是在使用逆波兰表示法(RPN)的计算器中,其中操作数保存在栈中,而操作符则从栈中弹出操作数,并将其计算结果推送回栈中。
以下是栈的一个使用示例,展示了在评估表达式 (3 + 4) * 2
时栈的内容。在 RPN 中,这个表达式变成 3 4 + 2 *
,栈的内容在每个标记(即操作数和操作符)从输入中读取后变化的情况显示在图 12.5 中。注意,每个操作数确实被推送到栈中,而每个操作符则改变了栈的内容。这个表达式在五个步骤中被评估。图中的第一个行显示了刚刚读取的标记,下一行显示了实际的栈内容,最后一行显示了参考步骤。注意在步骤 2 中,两个数字被推送到栈中。第一个数字(3)现在位于栈底。接下来,在步骤 3 中,读取 +
操作符。该操作符弹出两个操作数(此时栈为空),计算它们的和,并将结果值(7)推送到栈上。然后,在步骤 4 中,数字 2 被读取,再次推送到栈中。最后,在步骤 5 中,最终操作符 *
被读取,它从栈中弹出值 2 和 7,计算它们的积,并将结果(14)推送回栈中。这个结果(14)可以被弹出并显示在某个介质上。
从图 12.5 中可以看出,栈有一个位置(顶部),可以在此位置上推送或弹出元素。这个顶部元素是栈中唯一可以立即访问的元素。可以直接访问和修改它。
了解了栈的模型后,我们来看一下 stack
容器可以做什么。对于栈,以下构造函数、运算符和成员函数是可用的:
-
构造函数:
- 提供复制构造函数和移动构造函数。
- 可以构造一个空栈:
stack<string> object;
-
成员函数:
value_type &emplace(Args &&...args)
:
从成员参数构造一个value_type
对象,并将新创建的元素推送到栈上。返回值是对(或副本)新推送值的引用。bool empty()
:
如果栈中没有元素,则返回true
。void pop()
:
删除栈顶的元素。注意,弹出的元素不会由此成员函数返回。有关pop
返回类型为void
的原因以及为何不应在空栈上调用pop
的讨论,请参见第 12.4.4 节。void push(value)
:
将值放置在栈顶,隐藏其他元素。size_t size()
:
返回栈中的元素数量。Type &top()
:
返回栈顶(唯一可见)元素的引用。使用此成员函数时,程序员需要确保栈不是空的。
栈不支持迭代器或索引运算符。唯一可以访问的元素是栈顶元素。要清空栈:
- 重复删除其前端元素;
- 将空栈分配给它;
- 调用其析构函数(例如,通过结束其生命周期)。
unordered_map
容器(“哈希表”)
在 C++ 中,哈希表可作为 unordered_map
类的对象使用。在使用 unordered_map
或 unordered_multimap
容器之前,必须包含头文件 <unordered_map>
。
unordered_map
类实现了一个关联数组,其中元素根据某种哈希方案存储。与排序的 map
数据结构不同,unordered_map
使用哈希函数对键进行处理。map
是一个排序的数据结构,其中的键使用键的数据类型的 operator<
进行排序。通常,这种排序方式并不是存储或检索数据最快的方法。排序的主要好处是排序后的键列表比未排序的列表更易于人类阅读。然而,存储和检索数据更快的方法是使用哈希。哈希使用一个函数(称为哈希函数)计算一个(无符号)数字,该数字用作存储键和值的表中的索引。这个数字称为桶编号。检索一个键只需计算提供的键的哈希值,然后在表中查找计算出的索引位置:如果键存在,它会存储在表中计算出的桶位置,并且其值可以返回。如果键不存在,则当前容器中不存储该键。
当计算出的索引位置已经被另一个元素占据时,会发生冲突。对于这些情况,抽象容器提供了可用的解决方案。unordered_map
使用的一个简单解决方案是使用线性链表,将冲突的表元素存储在链表中。
使用 unordered_map
而不是 hash
这个术语,是为了避免与在语言添加哈希表之前开发的哈希表发生名称冲突。
由于使用了哈希方法,unordered_map
在速度上的效率应大大高于 map
。类似的结论也适用于 unordered_set
、unordered_multimap
和 unordered_multiset
。
unordered_map
的构造函数
在定义 unordered_map
类型时,必须指定五个模板参数:
- 一个
KeyType
(对应unordered_map::key_type
), - 一个
ValueType
(对应unordered_map::mapped_type
), - 一个用于根据键值计算哈希值的对象类型(对应
unordered_map::hasher
), - 一个用于比较两个键是否相等的对象类型(对应
unordered_map::key_equal
), - 以及其分配器的类型。通常分配器类型不指定,使用实现者提供的默认分配器即可。
unordered_map
容器的泛型定义如下所示:
std::unordered_map<KeyType, ValueType, 哈希类型, 谓词类型, 分配器类型>
当 KeyType
是 std::string
或内置类型时,哈希类型和谓词类型可以使用默认类型。在实际应用中,通常不指定分配器类型,因为默认分配器已经足够。因此,可以仅通过指定键类型和值类型来定义 unordered_map
对象,例如:
std::unordered_map<std::string, ValueType> hash(size_t size = implSize);
其中,implSize
是容器的默认初始大小,由实现者指定。当有必要时,unordered_map
会自动增大其大小,并重新哈希所有元素。在实际应用中,通常实现者提供的默认大小已足够。
KeyType
常常是文本类型,因此经常使用 std::string
作为 KeyType
。但要注意,不要使用 char const*
作为键类型,因为指向相同内容但存储在不同位置的两个 char const*
会被认为是不同的键,因为比较的是它们的指针值而不是文本内容。下面是一个使用 char const*
作为 KeyType
的例子。需要注意,在这个例子中构造 months
时没有指定任何参数,因为可以使用默认值和构造函数:
#include <cstring>
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
struct EqualCp {
bool operator()(char const *l, char const *r) const {
return strcmp(l, r) == 0;
}
};
struct HashCp {
size_t operator()(char const *str) const {
return std::hash<std::string>()(str);
}
};
int main() {
unordered_map<char const *, int, HashCp, EqualCp> months;
// 或者显式地:
unordered_map<char const *, int, HashCp, EqualCp> monthsTwo(61, HashCp(), EqualCp());
months["april"] = 30;
months["november"] = 31;
string apr("april");
// 不同的指针,相同的字符串
cout << "april->" << months["april"] << '\n'
<< "april->" << months[apr.c_str()] << '\n';
}
如果要使用其他键类型,则 unordered_map
的构造函数需要一个(常量引用的)哈希函数对象,用于根据键值计算哈希值,以及一个谓词函数对象,如果两个 unordered_map::key_type
对象相同,则返回 true。一个通用算法(见第19章)用于执行相等测试(即 equal_to
)。如果键的数据类型支持相等运算符,则可以使用这些测试。或者,可以构造一个重载的 operator==
或专门的函数对象,如果两个键相等则返回 true,否则返回 false。
构造函数
unordered_map
支持以下构造函数:
- 提供了拷贝构造函数和移动构造函数;
explicit unordered_map(size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:这个构造函数也可以用作默认构造函数;unordered_map(const_iterator begin, const_iterator end, size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:此构造函数接受两个迭代器,指定unordered_map::value_type
常量对象的范围;unordered_map(initializer_list<value_type> initList, size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:此构造函数接受一个unordered_map::value_type
值的初始化列表。
下面的例子展示了一个程序,该程序使用一个 unordered_map
来存储一年的月份名称以及这些月份通常具有的天数。然后,使用下标运算符显示几个月份的天数(此处使用的谓词是通用算法 equal_to<string>
,它作为 unordered_map
构造函数的第四个默认参数由编译器提供):
#include <unordered_map>
#include <iostream>
#include <string>
using namespace std;
int main() {
unordered_map<string, int> months;
months["january"] = 31;
months["february"] = 28;
months["march"] = 31;
months["april"] = 30;
months["may"] = 31;
months["june"] = 30;
months["july"] = 31;
months["august"] = 31;
months["september"] = 30;
months["october"] = 31;
months["november"] = 30;
months["december"] = 31;
cout << "september -> " << months["september"] << '\n'
<< "april -> " << months["april"] << '\n'
<< "june -> " << months["june"] << '\n'
<< "november -> " << months["november"] << '\n';
}
生成的输出:
september -> 30
april -> 30
june -> 30
november -> 30
unordered_map
的公共成员函数
unordered_map
支持与 map
的下标运算符相同的索引运算符:返回与给定 KeyType
值关联的 ValueType
的(常量)引用。如果还未找到该键,则会将其添加到 unordered_map
中,并返回一个默认的 ValueType
值。此外,它还支持 operator==
运算符。
unordered_map
提供了以下成员函数(key_type
、value_type
等指的是 unordered_map
定义的类型):
-
mapped_type &at(key_type const &key)
:
返回与key
关联的unordered_map
的mapped_type
的引用。如果键不在unordered_map
中,则抛出std::out_of_range
异常。 -
unordered_map::iterator begin()
:
返回指向unordered_map
第一个元素的迭代器,如果unordered_map
为空,则返回end
。 -
size_t bucket(key_type const &key)
:
返回存储key
的桶的索引位置。如果key
还未存储,则在返回索引位置之前,先将value_type(key, Value())
添加进去。 -
size_t bucket_count()
:
返回容器使用的槽(桶)数量。每个槽可能包含一个(或多个,在发生冲突的情况下)value_type
对象。 -
size_t bucket_size(size_t index)
:
返回在bucket
位置index
处存储的value_type
对象的数量。 -
unordered_map::const_iterator cbegin()
:
返回指向unordered_map
第一个元素的const_iterator
,如果unordered_map
为空,则返回cend
。 -
unordered_map::const_iterator cend()
:
返回指向unordered_map
最后一个元素之后的const_iterator
。 -
void clear()
:
删除unordered_map
中的所有元素。 -
size_t count(key_type const &key)
:
返回使用key_type
key
存储在unordered_map
中的value_type
对象的数量(即1或0)。 -
pair<iterator, bool> emplace(Args &&...args)
:
通过emplace
的参数构造一个value_type
对象。如果unordered_map
中已包含一个使用相同key_type
值的对象,则返回一个包含指向该对象的迭代器和值为false
的std::pair
。如果未找到该key_type
值,则将新构造的对象插入unordered_map
,并返回一个包含指向新插入的value_type
的迭代器和值为true
的std::pair
。 -
iterator emplace_hint(const_iterator position, Args &&...args)
:
通过成员的参数构造一个value_type
对象,并将新创建的元素插入unordered_map
,除非(在args
提供的)键已经存在。实现可能会或不会使用position
作为寻找插入点的提示。返回的迭代器指向使用提供的键的value_type
。它可能引用已存在的value_type
或新添加的value_type
;已存在的value_type
不会被替换。如果添加了新值,则在emplace_hint
返回时容器的大小已增加。 -
bool empty()
:
如果unordered_map
不包含任何元素,则返回true
。 -
unordered_map::iterator end()
:
返回指向unordered_map
最后一个元素之后的迭代器。 -
pair<iterator, iterator> equal_range(key)
:
此成员返回一个迭代器对,定义具有与key
相等的键的元素范围。在unordered_map
中,该范围最多包含一个元素。 -
unordered_map::iterator erase()
:
删除unordered_map
中特定范围内的元素:bool erase(key)
删除具有给定键的元素。如果删除了该值,返回true
;如果unordered_map
中没有该键的元素,返回false
。erase(pos)
删除由迭代器pos
指向的元素。返回++pos
迭代器。erase(first, beyond)
删除由迭代器范围[first, beyond)
指定的元素,返回beyond
。
-
iterator find(key)
:
返回指向具有给定键的元素的迭代器。如果找不到该元素,则返回end
。 -
allocator_type get_allocator() const
:
返回unordered_map
对象使用的分配器对象的副本。 -
hasher hash_function() const
:
返回unordered_map
对象使用的哈希函数对象的副本。 -
insert()
:
可以从特定位置开始插入元素。如果提供的键已在使用中,则不会执行插入。返回值取决于调用的insert()
版本。当返回pair<iterator, bool>
时,pair
的第一个成员是指向具有与提供的value_type
键相等的键的元素的迭代器,pair
的第二个成员为true
表示值已插入容器,否则为false
表示未插入。pair<iterator, bool> insert(value_type const &value)
尝试插入value
。pair<iterator, bool> insert(value_type &&tmp)
尝试使用value_type
的移动构造函数插入value
。pair<iterator, bool> insert(const_iterator hint, value_type const &value)
尝试插入value
,可能使用hint
作为插入值的起始点。pair<iterator, bool> insert(const_iterator hint, value_type &&tmp)
尝试使用value_type
的移动构造函数插入value
,并可能使用hint
作为插入点的起始点。void insert(first, beyond)
尝试插入迭代器范围[first, beyond)
内的元素。void insert(initializer_list<value_type> iniList)
尝试将iniList
中的元素插入容器。
-
hasher key_eq() const
:
返回unordered_map
对象使用的key_equal
函数对象的副本。 -
float load_factor() const
:
返回容器的当前负载因子,即size / bucket_count
。 -
size_t max_bucket_count()
:
返回此unordered_map
可能包含的最大桶数。 -
float max_load_factor() const
:
与load_factor
相同。 -
void max_load_factor(float max)
:
将当前最大负载因子更改为max
。当负载因子达到max
时,容器将增加其桶数,并重新哈希其元素。注意,容器的默认最大负载因子为1.0。 -
size_t max_size()
:
返回此unordered_map
可能包含的最大元素数。 -
void rehash(size_t size)
:
如果size
超过当前桶数,则桶数增加到size
,并重新哈希其元素。 -
void reserve(size_t request)
:
如果request
小于或等于当前桶数,则此调用无效。否则,桶数增加到至少request
,并重新哈希容器的元素。 -
size_t size()
:
返回unordered_map
中的元素数。 -
void swap(unordered_map &other)
:
交换当前unordered_map
与other
的内容。
unordered_multimap
容器
unordered_multimap
允许在无序映射中存储使用相同键的多个对象。unordered_multimap
容器提供了与 unordered_map
相同的一组成员和构造函数,但没有 unordered_map
强加的唯一键限制。
unordered_multimap
不提供 operator[]
,也不提供 at
成员。
以下描述了行为与对应 unordered_map
成员不同的所有成员:
-
at
不受unordered_multimap
容器支持。 -
size_t count(key_type const &key)
:
返回使用key_type
键存储在unordered_multimap
中的value_type
对象的数量。此成员通常用于验证键是否在unordered_multimap
中可用。 -
iterator emplace(Args &&...args)
:
通过emplace
的参数构造一个value_type
对象。返回的迭代器指向新插入的value_type
。 -
iterator emplace_hint(const_iterator position, Args &&...args)
:
通过成员的参数构造一个value_type
对象,并将新创建的元素插入unordered_multimap
。实现可能会或不会使用position
作为寻找插入点的提示。返回的迭代器指向使用提供的键的value_type
。 -
pair<iterator, iterator> equal_range(key)
:
此成员返回一个迭代器对,定义具有与key
相等的键的元素范围。 -
iterator find(key)
:
返回指向具有给定键的元素的迭代器。如果找不到此类元素,则返回end
。 -
insert()
:
可以从特定位置开始插入元素。返回值取决于调用的insert()
版本。当返回迭代器时,它指向插入的元素。iterator insert(value_type const &value)
插入value
。iterator insert(value_type &&tmp)
使用value_type
的移动构造函数插入value
。iterator insert(const_iterator hint, value_type const &value)
尝试插入value
,可能使用hint
作为插入值的起始点。iterator insert(const_iterator hint, value_type &&tmp)
使用value_type
的移动构造函数插入value
,并可能使用hint
作为插入点的起始点。void insert(first, beyond)
插入迭代器范围[first, beyond)
内的元素。void insert(initializer_list<value_type> iniList)
将iniList
中的元素插入容器。
unordered_set
容器
与 map
容器类似,set
容器对其元素进行排序。如果排序不是问题,但需要快速查找,则可以考虑使用基于哈希的 set
或 multi-set
。C++ 提供了这样的基于哈希的集合和多重集合:unordered_set
和 unordered_multiset
。
在使用这些基于哈希的 set
容器之前,需要包含头文件 <unordered_set>
。
存储在 unordered_set
中的元素是不可变的,但它们可以被插入和从容器中删除。与 unordered_map
不同,unordered_set
不使用 ValueType
。集合仅存储元素,并且存储的元素本身就是其键。unordered_set
具有与 unordered_map
相同的构造函数,但集合的 value_type
等于其 key_type
。
定义 unordered_set
类型时,必须指定四个模板参数:
- 一个
KeyType
(成为unordered_set::key_type
), - 一个计算哈希值的对象类型(成为
unordered_set::hasher
), - 一个比较两个键是否相等的对象类型(成为
unordered_set::key_equal
),以及 - 其分配器的类型。通常不指定该类型,使用默认的分配器即可。
unordered_set
容器的通用定义如下所示:
std::unordered_set<KeyType, hash type, predicate type, allocator type>
当 KeyType
是 std::string
或内置类型时,默认类型可用于哈希类型和谓词类型。在实践中,通常不指定分配器类型,因为默认的分配器已足够。在这些情况下,仅需指定键类型和值类型即可定义 unordered_set
对象,如下所示:
std::unordered_set<std::string> rawSet(size_t size = implSize);
其中,implSize
是容器的默认初始大小,由实现者指定。当需要时,集合的大小会自动扩大,此时容器会重新哈希所有元素。实际上,提供的默认大小参数通常是完全可以接受的。
unordered_set
支持以下构造函数:
- 提供复制和移动构造函数;
- 显式构造函数
unordered_set(size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:此构造函数也可用作默认构造函数; - 构造函数
unordered_set(const_iterator begin, const_iterator end, size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:此构造函数期望两个迭代器,指定一范围的unordered_set::value_type const
对象; - 构造函数
unordered_set(initializer_list<value_type> initList, size_type n = implSize, hasher const &hf = hasher(), key_equal const &eql = key_equal(), allocator_type const &alloc = allocator_type())
:期望一个包含unordered_set::value_type
值的initializer_list
。
unordered_set
不提供索引操作符,也不提供 at
成员。除此之外,它提供与 unordered_map
相同的成员。以下是行为不同于 unordered_map
的成员描述。其余成员的描述,请参考 12.4.12.2 节。
-
iterator emplace(Args &&...args)
:
通过emplace
的参数构造一个value_type
对象。如果该对象是唯一的,则将其添加到集合中,并返回指向该value_type
的迭代器。 -
iterator emplace_hint(const_iterator position, Args &&...args)
:
通过成员的参数构造一个value_type
对象,如果新创建的元素是唯一的,则将其插入unordered_set
中。实现可能会或不会使用position
作为寻找插入点的提示。返回的迭代器指向value_type
。 -
unordered_set::iterator erase()
:
删除unordered_set
中指定范围的元素:erase(key_type const &key)
:从集合中删除键key
。如果键被删除,则返回 1;如果集合中没有该键,则返回 0。erase(pos)
:删除由迭代器pos
指向的元素。返回迭代器++pos
。erase(first, beyond)
:删除由迭代器范围[first, beyond)
指示的元素,返回beyond
。
unordered_multiset
容器
unordered_multiset
允许使用相同键的多个对象存储在一个无序集合中。unordered_multiset
容器提供与 unordered_set
相同的成员和构造函数,但不受 unordered_set
强加的唯一键限制。
以下是行为不同于相应的 unordered_set
成员的描述:
-
size_t count(key_type const &key)
:
返回使用key_type
键存储在unordered_set
中的value_type
对象的数量。此成员通常用于验证unordered_multiset
中是否存在键。 -
iterator emplace(Args &&...args)
:
通过emplace
的参数构造一个value_type
对象。返回的迭代器指向新插入的value_type
。 -
iterator emplace_hint(const_iterator position, Args &&...args)
:
通过成员的参数构造一个value_type
对象,并将新创建的元素插入unordered_multiset
中。实现可能会或不会使用position
作为寻找插入点的提示。返回的迭代器指向使用提供的键的value_type
。 -
pair<iterator, iterator> equal_range(key)
:
此成员返回一对迭代器,定义具有与键相等的元素范围。 -
iterator find(key)
:
返回指向具有给定键的元素的迭代器。如果没有这样的元素,则返回end
。 -
insert()
:
元素可以从某个位置开始插入。返回值取决于调用的insert()
版本。当返回一个迭代器时,它指向插入的元素。iterator insert(value_type const &value)
插入值。iterator insert(value_type &&tmp)
使用value_type
的移动构造函数插入值。iterator insert(const_iterator hint, value_type const &value)
插入值,可能使用hint
作为尝试插入值时的起始点。iterator insert(const_iterator hint, value_type &&tmp)
使用value_type
的移动构造函数插入值,并可能使用hint
作为尝试插入值时的起始点。void insert(first, beyond)
插入迭代器范围[first, beyond)
中的元素。void insert(initializer_list<value_type> iniList)
将iniList
中的元素插入容器中。
异质查找
C++ 提供的关联容器允许我们查找与给定键匹配的值(或多个值)。传统上,用于查找的键的类型必须与容器的键类型匹配。然而,自 C++14 标准以来,只要提供了可以将该类型与容器键类型进行比较的比较运算符,就可以使用任意查找键类型。因此,可以使用 char const*
键(或任何其他类型,只要 std::string
可用的 operator<
被重载)来查找 map<std::string, ValueType>
中的值。这称为异质查找。
当赋予关联容器的比较器允许异质查找时,异质查找才被允许。标准库类 std::less
和 std::greater
被扩展,以允许异质查找。
#include <iostream>
#include <map>
#include <string>
int main() {
// 定义一个 map,键是 std::string,值是整数
std::map<std::string, int> myMap = {
{"apple", 1}, {"banana", 2}, {"cherry", 3}};
// 使用 const char* 类型的键来进行查找
const char* key = "banana";
// 进行查找
auto it = myMap.find(key);
// 判断是否找到
if (it != myMap.end()) {
std::cout << "找到键 " << it->first << ",对应的值是 " << it->second
<< std::endl;
} else {
std::cout << "没有找到键 " << key << std::endl;
}
return 0;
}
找到键 banana,对应的值是 2
#include <iostream>
#include <string>
#include <unordered_map>
struct MyHash {
using is_transparent = void; // 允许异质查找
size_t operator()(const std::string& key) const {
return std::hash<std::string>{}(key);
}
size_t operator()(const char* key) const {
return std::hash<std::string>{}(key);
}
};
struct MyEqual {
using is_transparent = void; // 允许异质查找
bool operator()(const std::string& lhs, const std::string& rhs) const {
return lhs == rhs;
}
bool operator()(const std::string& lhs, const char* rhs) const {
return lhs == rhs;
}
bool operator()(const char* lhs, const std::string& rhs) const {
return lhs == rhs;
}
};
int main() {
// 定义一个 unordered_map,键是 std::string,值是整数
std::unordered_map<std::string, int, MyHash, MyEqual> myUnorderedMap = {
{"apple", 1}, {"banana", 2}, {"cherry", 3}};
// 使用 const char* 类型的键来进行查找
const char* key = "cherry";
// 进行查找
auto it = myUnorderedMap.find(key);
// 判断是否找到
if (it != myUnorderedMap.end()) {
std::cout << "找到键 " << it->first << ",对应的值是 " << it->second
<< std::endl;
} else {
std::cout << "没有找到键 " << key << std::endl;
}
return 0;
}
找到键 cherry,对应的值是 3
complex复数容器
complex
容器定义了复数可以执行的标准操作。使用复数容器之前,必须包含头文件 <complex>
。
复数的实部和虚部的类型是通过容器的数据类型指定的。例如:
complex<double>
complex<int>
complex<float>
需要注意的是,复数的实部和虚部的数据类型相同。
在初始化(或赋值)一个复数对象时,虚部可以省略,这将导致其值为0(零)。默认情况下,实部和虚部的值都为零。下面假设使用的复数类型为 complex<double>
。在此假设下,可以如下初始化复数:
target
: 默认初始化,实部和虚部均为0。target(1)
: 实部为1,虚部为0。target(0, 3.5)
: 实部为0,虚部为3.5。target(source)
:target
使用source
的值进行初始化。
匿名复数也可以使用。以下示例展示了如何将两个匿名复数推入复数栈中,然后再将它们弹出:
#include <complex>
#include <stack>
#include <iostream>
using namespace std;
int main() {
stack<complex<double>> cstack;
cstack.push(complex<double>(3.14, 2.71));
cstack.push(complex<double>(-3.14, -2.71));
while (cstack.size()) {
cout << cstack.top().real() << ", " << cstack.top().imag() << "i" << '\n';
cstack.pop();
}
}
生成的输出:
-3.14, -2.71i
3.14, 2.71i
以下成员函数和操作符被定义用于复数(下面的 value
可以是原始标量类型或复数对象):
-
除了标准容器操作符外,
complex
容器还支持以下操作符:complex operator+(value)
: 返回当前复数容器和value
的和。complex operator-(value)
: 返回当前复数容器和value
的差。complex operator*(value)
: 返回当前复数容器和value
的积。complex operator/(value)
: 返回当前复数容器和value
的商。complex operator+=(value)
: 将value
加到当前复数容器中,并返回新值。complex operator-=(value)
: 从当前复数容器中减去value
,并返回新值。complex operator*=(value)
: 将当前复数容器与value
相乘,并返回新值。complex operator/=(value)
: 将当前复数容器除以value
,并返回新值。
-
Type real()
: 返回复数的实部。 -
Type imag()
: 返回复数的虚部。 -
复数容器提供了多种数学函数,如
abs
、arg
、conj
、cos
、cosh
、exp
、log
、norm
、polar
、pow
、sin
、sinh
和sqrt
。这些函数都是自由函数,而不是成员函数,可以接受复数作为参数。例如:abs(complex<double>(3, -5)); pow(target, complex<int>(2, 3));
-
复数可以从
istream
对象中提取,也可以插入到ostream
对象中。插入时的结果是一个有序对(x, y)
,其中x
表示复数的实部,y
表示复数的虚部。当从istream
对象中提取复数时,也可以使用相同的形式。但是,也允许使用更简单的形式。例如,当提取1.2345
时,虚部将被设置为0。
继承
在C语言编程中,通常使用自顶向下的结构化方法来解决编程问题:程序的函数和动作按子函数定义,而子函数又由更小的子子函数定义,依此类推。这种方法产生了代码的层次结构:main
在顶层,其次是从 main
调用的函数层,等等。
在C++中,代码和数据之间的关系也经常通过类之间的依赖关系来定义。这看起来像是组合(参见第7.3节),其中一个类的对象包含另一个类的对象作为其数据。但这里描述的关系是不同类型的:一个类可以根据一个旧的、预先存在的类来定义。这样就生成了一个新类,它具有旧类的所有功能,并额外定义了它自己的特定功能。这种方式不同于组合,其中给定类包含另一个类,这里指的是派生,即一个类“是”或“基于”另一个类来实现。
派生的另一个术语是继承:新类继承了现有类的功能,而现有类不会作为新类接口中的数据成员出现。在讨论继承时,现有类称为基类,而新类称为派生类。
当充分利用C++程序开发的方法时,类的派生经常被使用。在本章中,我们首先讨论C++为派生类提供的语法可能性。随后,我们探讨类派生(继承)所提供的一些具体可能性。
如在介绍章节中所见(参见第2.4节),在面向对象的方法中,类在问题分析过程中被识别。在这种方法中,定义的类的对象代表了当前问题中可以观察到的实体。这些类被放置在一个层次结构中,顶层类包含有限的功能。每次新的派生(因此是类层次结构的下降)都会在现有类的基础上增加新功能。
在本章中,我们将使用一个简单的车辆分类系统来构建类的层次结构。第一个类是 Vehicle
,其功能是设置或检索车辆的质量。对象层次结构的下一级是陆地、水上和空中交通工具。
初始的对象层次结构如图13.1所示。
本章主要关注类派生的技术细节。关于使用继承创建派生类(其对象应被视为基类的对象)与使用继承实现基于基类的派生类之间的区别,将留到下一章(第14章)讨论。
继承(和多态性,参见第14章)可以用于类和结构体。它不适用于联合体(union
)。
相关类型
在这里进一步研究了表示不同类型车辆的提议类之间的关系。图中展示了对象层次结构:汽车(Car
)是陆地车辆(LandVehicle
)的一个特例,而陆地车辆又是车辆(Vehicle
)的一个特例。
类 Vehicle
代表了分类系统中的“最大公约数”。Vehicle
提供了有限的功能:它可以存储和检索车辆的质量。示例代码如下:
class Vehicle {
size_t d_mass;
public:
Vehicle();
Vehicle(size_t mass);
size_t mass() const;
void setMass(size_t mass);
};
使用这个类,可以在创建相应对象时定义车辆的质量。随后,质量可以被更改或检索。
为了表示陆地上行驶的车辆,可以定义一个新的类 Land
,它继承了 Vehicle
的功能,并添加了自己特有的功能。假设我们对陆地车辆的速度和质量感兴趣。车辆与陆地车辆的关系当然可以通过组合来表示,但这样做会显得不自然:组合意味着陆地车辆是通过包含一个 Vehicle
实现的,而自然的关系显然是陆地车辆是一种车辆。
使用组合的关系也会使 Land
类的设计有些复杂。考虑以下示例,显示了使用组合的 Land
类(仅展示了 setMass
功能):
class Land {
Vehicle d_v; // 组合的 Vehicle
public:
void setMass(size_t mass);
};
void Land::setMass(size_t mass) { d_v.setMass(mass); }
使用组合的情况下,Land::setMass
函数只是将参数传递给 Vehicle::setMass
。因此,就质量处理而言,Land::setMass
并没有引入额外的功能,仅仅是额外的代码。显然,这种代码重复是多余的:一个 Land
对象本质上就是一个 Vehicle
;声明一个 Land
对象包含一个 Vehicle
至少有些奇怪。
继承更好地表示了意图。选择继承和组合的规则是区分“is-a”和“has-a”关系。例如,卡车是车辆,所以 Truck
应该从 Vehicle
继承。另一方面,卡车有一个引擎;如果你需要在系统中建模引擎,应该通过在 Truck
类中组合一个 Engine
类来表达这一点。根据上述规则,Land
继承自基类 Vehicle
:
class Land : public Vehicle {
size_t d_speed;
public:
Land();
Land(size_t mass, size_t speed);
void setSpeed(size_t speed);
size_t speed() const;
};
要将一个类(例如 Land
)从另一个类(例如 Vehicle
)继承,需要在 Land
的接口中使用 : public Vehicle
:
class Land: public Vehicle
类 Land
现在包含了基类 Vehicle
的所有功能以及它自己特有的功能。这里这些功能包括一个接受两个参数的构造函数和访问 d_speed
数据成员的成员函数。以下示例展示了派生类 Land
的可能性:
Land veh{ 1200, 145 };
int main() {
cout << "Vehicle weighs " << veh.mass() << ";\n"
"its speed is " << veh.speed() << '\n';
}
这个例子展示了继承的两个特点:
- 首先,
mass
在Land
的接口中并没有提到。然而,它在veh.mass
中被使用。这个成员函数是类的一部分,隐式地从其“父类”Vehicle
继承而来。 - 其次,虽然派生类
Land
包含了Vehicle
的功能,但Vehicle
的私有成员仍然保持私有:只能通过Vehicle
自身的成员函数访问。这意味着Land
的成员函数必须使用Vehicle
的成员函数(如mass
和setMass
)来操作质量字段。在这里,Land
和其他外部代码的访问权限没有区别。
类 Vehicle
封装了特定的 Vehicle
特性,而数据隐藏是实现封装的一种方式。
封装是良好类设计的核心原则。封装减少了类之间的依赖性,提高了类的可维护性和可测试性,并允许我们在不修改依赖代码的情况下修改类。通过严格遵循数据隐藏原则,一个类的内部数据组织可以在不需要修改依赖代码的情况下进行更改。例如,一个原本存储 C 字符串的 Lines
类可以在某个时刻将其数据组织更改为基于 vector<string>
的存储。当 Lines
使用了完美的数据隐藏时,依赖的源代码可以在不需要任何修改的情况下使用新的 Lines
类。
作为一个经验法则,当基类的数据组织(即数据成员)发生变化时,派生类必须完全重新编译(但不需要修改)。向基类添加新的成员函数不会改变数据组织,因此在添加新的成员函数时不需要重新编译。
有一个微妙的例外:如果向基类添加了新的成员函数,而该函数恰好是基类的第一个虚成员函数(参见第 14 章关于虚成员函数概念的讨论),那么这也会改变基类的数据组织。
现在 Land
已经从 Vehicle
派生,我们准备进行下一步的类派生。我们将定义一个 Car
类来表示汽车。我们认为 Car
对象是一个陆地车辆,并且 Car
具有品牌名称,因此很容易设计 Car
类:
class Car : public Land {
std::string d_brandName;
public:
Car();
Car(size_t mass, size_t speed, std::string const &name);
std::string const &brandName() const;
};
在上述类定义中,Car
从 Land
继承,而 Land
又从 Vehicle
继承。这被称为嵌套继承:Land
是 Car
的直接基类,而 Vehicle
是 Car
的间接基类。
继承深度:是否值得?
现在 Car
已经从 Land
派生,而 Land
又从 Vehicle
派生,我们可能会很容易被诱导去认为这种类层次结构是在设计类时的理想选择。但我们也许应该适度地控制我们的热情。
反复从类派生会迅速导致庞大而复杂的类层次结构,这些层次结构难以理解、使用和维护。难以理解和使用,因为我们派生类的用户现在还需要学习其(间接)基类的所有特性。难以维护,因为所有这些类之间的耦合非常紧密。虽然确实当数据隐藏得到细致遵守时,派生类在基类修改其数据组织时不需要进行修改,但一旦越来越多的(派生)类依赖于当前的组织结构,改变这些基类也很快变得实际不可行。
最初看似巨大的好处——继承基类的接口——最终可能成为一种负担。基类的接口几乎从来没有完全需要,最终一个类可能会从显式地定义自己的成员函数中受益,而不是通过继承来获取这些函数。
通常,可以根据现有类定义类:使用它们的一些特性,但其他特性需要被屏蔽。例如,堆栈容器通常是基于双端队列(deque
)实现的,将 deque::back
的值返回作为 stack::top
的值。
当使用继承来实现 is-a 关系时,确保正确理解“使用方向”:旨在实现 is-a 关系的继承应该关注基类:基类的功能不是为了被派生类使用,而是派生类的功能应该通过多态(这将是下一章的主题)重新定义(重新实现)基类的功能,从而允许代码通过基类以多态方式使用派生类的功能。
我们在研究流时看到过这种方法:基类(例如 ostream
)被反复使用。由 ostream
派生的类(如 ofstream
和 ostringstream
)提供的功能只由依赖于 ostream
类提供的功能的代码使用,而不直接使用派生类。
在设计类时,总是应该以最低的耦合度为目标。庞大的类层次结构通常表明对稳健类设计的理解不足。当一个类的接口仅部分被使用,并且如果派生类是基于另一个类实现的,请考虑使用组合而不是继承,并根据组合对象提供的成员定义适当的接口成员。
访问权限:public
、private
、protected
在 C++ 注解的早期部分(参见第 3.2.1 节),我们遇到了两个在开发类时的重要设计原则:数据隐藏和封装。数据隐藏限制了对对象数据的控制,仅允许类的成员访问这些数据;封装用于限制对对象功能的访问。这两个原则是维护数据完整性的重要工具。
private
关键字用于在类接口中定义只能由类本身的成员访问的部分。这是实现数据隐藏的主要工具。根据良好的类设计实践,public
部分包含提供类功能的干净接口的成员。这些成员允许用户与对象进行交互;对象如何处理发给它的请求则由对象自行决定。在一个设计良好的类中,其对象完全控制其数据。
继承不会改变这些原则,也不会改变 private
和 protected
关键字的作用。派生类无法访问基类的 private
部分。有时这会显得过于严格。考虑一个实现随机数生成的 streambuf
类(参见第 6 章)。这样的 streambuf
可以用于构造一个 istream
对象 irand
,之后从 irand
中提取的将是随机数序列,如下例所示,其中生成了 10 个随机数:
RandBuf buffer;
istream irand(&buffer);
for (size_t idx = 0; idx != 10; ++idx)
{
size_t next;
irand >> next;
cout << "next random number: " << next << '\n';
}
问题是,irand
应该能够生成多少个随机数?幸运的是,不需要回答这个问题,因为 RandBuf
可以负责生成下一个随机数。因此,RandBuf
的操作如下:
- 生成一个随机数;
- 将其以文本形式传递给基类
streambuf
; istream
对象提取这个随机数,仅使用streambuf
的接口;
(这个过程对后续的随机数重复进行)
一旦 RandBuf
将下一个随机数的文本表示存储在某个缓冲区中,它必须告诉其基类(streambuf
)随机数字符的位置。为此,streambuf
提供了一个成员函数 setg
,它需要缓冲区的位置和大小。
setg
成员显然不能在 streambuf
的 private
部分中声明,因为 RandBuf
必须使用它来准备提取下一个随机数。但它也不应该在 streambuf
的 public
部分,因为这可能会导致 irand
出现意外行为。考虑以下假设的例子:
RandBuf randBuf;
istream irand(&randBuf);
char buffer[] = "12";
randBuf.setg(buffer, ...); // setg 是 public: 现在 buffer 包含 12
size_t next;
irand >> next;
// 不是 *随机* 值,而是 12。
显然,streambuf
和其派生类 RandBuf
之间有很紧密的联系。通过允许 RandBuf
指定 streambuf
读取字符的缓冲区,RandBuf
保持了控制权,防止程序其他部分破坏其良好定义的行为。
这种基类与派生类之间的紧密联系通过第三个与类成员可访问性相关的关键字:protected
实现。setg
可以在 streambuf
类中声明如下:
class streambuf {
// 私有数据(如通常)
protected:
void setg(... 参数 ...);
// 可供派生类访问
public:
// 公共成员
};
protected
成员是可以被派生类访问的,但不是类的公共接口的一部分。
避免将数据成员声明在类的 protected
部分:这是一种不良类设计的明确标志,因为它会导致基类和派生类之间的紧密耦合。引入 protected
关键字后,不应放弃数据隐藏的原则。如果需要让派生类(但不是软件的其他部分)访问其基类的数据,使用成员函数:在基类的 protected
部分声明的访问器和修改器。这可以强制执行预期的受限访问,而不会导致类之间的紧密耦合。
公共、受保护和私有继承
在继承中,通常使用公共继承。当使用公共继承时,基类接口的访问权限在派生类中保持不变。但继承的类型也可以定义为私有或受保护。
受保护继承用于在派生类的基类前面加上 protected
关键字:
class Derived: protected Base
使用受保护继承时,基类的所有公共和受保护成员在派生类中变成受保护成员。派生类可以访问基类的所有公共和受保护成员。然而,派生类的派生类视基类的成员为受保护成员。其他任何代码(在继承树之外)都无法访问基类的成员。
私有继承用于在派生类的基类前面加上 private
关键字:
class Derived: private Base
使用私有继承时,基类的所有成员在派生类中变成私有成员。派生类成员可以访问所有基类的公共和受保护成员,但基类成员不能在其他地方使用。
公共继承应被用于定义派生类与基类之间的 is-a 关系:派生类对象是基类对象的一种,使得派生类对象可以在期望基类对象的代码中作为基类对象进行多态使用。私有继承用于在派生类对象定义为基类的情况下,而组合不能使用的情况。受保护继承的使用较少,但可能会在定义一个基类时遇到,该基类本身是一个派生类,使其基类成员对从它派生的类可用。
继承类型的组合确实存在。例如,在设计流类时,它通常从 std::istream
或 std::ostream
派生。然而,在构造流之前,必须有一个 std::streambuf
。利用继承顺序在类接口中的定义,我们使用多重继承(参见第 13.6 节)来同时从 std::streambuf
和(随后)std::ostream
派生该类。对于类的用户来说,它是一个 std::ostream
而不是 std::streambuf
。因此,对后者使用私有继承,对前者使用公共继承:
class Derived: private std::streambuf, public std::ostream
提升访问权限
当使用私有或受保护继承时,派生类对象的用户无法访问基类的成员。私有继承会拒绝所有派生类用户访问基类成员,受保护继承也是如此,但允许从派生类进一步派生的类访问基类的公共和受保护成员。
在某些情况下,这种方案过于严格。例如,考虑一个从 RandBuf
私有继承的 RandStream
类,其中 RandBuf
本身是从 std::streambuf
公开继承的,且也从 std::istream
公开继承:
class RandBuf: public std::streambuf
{
// 实现随机数的缓冲区
};
class RandStream: private RandBuf, public std::istream
{
// 实现一个从中提取随机值的流
};
这样的类可以用于通过标准的 istream
接口提取随机数。尽管 RandStream
类的构造考虑了 istream
对象的功能,但 std::streambuf
类的一些成员可能本身也被认为是有用的。例如,streambuf::in_avail
函数返回可以立即读取的字符数的下限。使这个函数可用的标准方法是定义一个调用基类成员的影子成员:
class RandStream: private RandBuf, public std::istream
{
// 实现一个从中提取随机值的流
public:
std::streamsize in_avail();
};
inline std::streamsize RandStream::in_avail()
{
return std::streambuf::in_avail();
}
为了仅仅使一个来自受保护或私有基类的成员可用,这样做似乎工作量很大。如果目的是使 in_avail
成员可用,可以使用访问提升(access promotion)。
访问提升允许我们指定哪些私有(或受保护)基类成员在派生类的受保护(或公共)接口中变得可用。以下是使用访问提升的上述示例:
class RandStream: private RandBuf, public std::istream
{
// 实现一个从中提取随机值的流
public:
using std::streambuf::in_avail;
};
需要注意的是,访问提升会使声明的基类成员的所有重载版本都变得可用。因此,如果 streambuf
不仅提供 in_avail
,还提供 in_avail(size_t *)
,那么这两个成员都会成为公共接口的一部分。
派生类的构造函数
派生类从其基类(或基类集合,因为 C++ 支持多重继承,参见第 13.6 节)继承功能。当构造一个派生类对象时,它是在基类对象的基础上构建的。因此,基类必须在实际初始化派生类元素之前完成构造。这就要求在定义派生类构造函数时必须遵守一些规定。
构造函数的作用是初始化对象的数据成员。派生类构造函数也负责正确初始化其基类。查看之前介绍的 Land
类(见第 13.1 节),它的构造函数可以简单地定义如下:
Land::Land(size_t mass, size_t speed)
{
setMass(mass);
setSpeed(speed);
}
然而,这种实现有几个缺点:
- 在构造派生类对象时,基类的构造函数总是在对派生类对象本身执行任何操作之前被调用。默认情况下,将调用基类的默认构造函数。
- 仅使用基类构造函数在派生类构造函数体内重新分配新值通常效率低下,但在某些情况下,例如必须初始化基类引用或常量数据成员时,这种做法几乎是不可能的。在这些情况下,必须使用专门的基类构造函数,而不是基类默认构造函数。
可以通过在派生类构造函数的初始化列表中调用基类构造函数来初始化派生类的基类。调用基类构造函数的这一过程称为基类初始化器。基类初始化器必须在初始化任何派生类的数据成员之前调用,并且在使用基类初始化器时,派生类的数据成员不能被使用。当构造派生类对象时,基类会先被构造,只有在基类构造成功后,派生类的数据成员才会被初始化。因此,Land
类的构造函数可以改进为:
Land::Land(size_t mass, size_t speed)
: Vehicle(mass), // 初始化基类
d_speed(speed) // 初始化派生类的数据成员
{}
派生类构造函数默认情况下总是调用其基类的默认构造函数。对于派生类的拷贝构造函数,这种情况当然不适用。假设 Land
类需要一个拷贝构造函数,则 Land
的拷贝构造函数参数也代表了其他对象的基类部分:
Land::Land(Land const &other) // 假设需要一个拷贝构造函数
: Vehicle(other), // 拷贝构造基类部分
d_speed(other.d_speed) // 拷贝构造派生类的数据成员
{}
移动构造
与使用组合的类类似,派生类也可能从定义移动构造函数中受益。派生类可能会提供移动构造函数的两个原因:
- 它支持对其数据成员的移动构造
- 它的基类支持移动构造
关于数据成员的移动构造,设计细节已在第 9.7 节中讨论。对于基类支持移动构造的派生类,其移动构造函数必须在将右值引用传递给基类的移动构造函数之前匿名化右值引用。在实现移动构造函数时,应使用 std::move
函数,将基类或组合对象中的信息移动到其新的目标对象中。
以下是一个示例,展示了 Car
类的移动构造函数,假设它有一个可移动的 char* d_brandName
数据成员,并且假设 Land
是一个支持移动的类。第二个示例展示了 Land
类的移动构造函数,假设它本身没有可移动的数据成员,但其基类 Vehicle
是支持移动的:
Car::Car(Car &&tmp)
: Land(std::move(tmp)), // 匿名化 `tmp`
d_brandName(tmp.d_brandName) // 移动 char* 的值
{
tmp.d_brandName = nullptr; // 将原对象的指针置空
}
Land::Land(Land &&tmp)
: Vehicle(std::move(tmp)), // 支持移动的 Vehicle
d_speed(tmp.d_speed) // 普通数据的拷贝
{}
移动赋值
派生类也可以从移动赋值操作中受益。如果派生类及其基类都支持交换操作,那么实现起来会很简单,可以按照第 9.7.3 节中展示的标准方法进行实现。对于 Car
类,移动赋值操作可能如下所示:
Car &Car::operator=(Car &&tmp)
{
swap(tmp);
return *this;
}
如果交换操作不被支持,则可以使用 std::move
调用基类的移动赋值操作符:
Car &Car::operator=(Car &&tmp)
{
static_cast<Land &>(*this) = std::move(tmp);
// 然后移动 Car 自身的数据成员
return *this;
}
继承构造函数
派生类可以在不显式定义派生类构造函数的情况下进行构造。在这种情况下,会调用可用的基类构造函数。
这个特性要么使用,要么不使用。不能省略某些派生类构造函数,而使用对应的基类构造函数。对于从多个基类派生的类(见第 13.6 节),要使用此特性,所有基类构造函数必须具有不同的签名。考虑到这里涉及的复杂性,最好避免在使用多重继承的类中使用基类构造函数。
可以使用以下语法将派生类对象的构造委托给基类构造函数:
class BaseClass
{
public:
// BaseClass 的构造函数
};
class DerivedClass: public BaseClass
{
public:
using BaseClass::BaseClass; // 不定义 DerivedClass 的构造函数
};
聚合初始化
聚合(例如,struct
)可以使用熟悉的大括号语法进行初始化。在初始化派生结构体时,大括号语法也可以用于初始化基类结构体。每个基类结构体在初始化派生结构体时都会有自己的一组大括号。以下是一个示例:
#include <iostream>
#include <string>
struct Base
{
int value;
};
struct Derived : public Base
{
std::string text;
};
// 初始化 Derived 对象:
Derived der{{42}, "hello world"};
int main() {
std::cout << "Base value: " << der.value << std::endl;
std::cout << "Derived text: " << der.text << std::endl;
return 0;
}
在这个示例中:
Base
结构体包含一个int
类型的成员value
。Derived
结构体继承自Base
结构体,并添加了一个std::string
类型的成员text
。- 在初始化
Derived
对象时,Base
结构体的成员value
使用了大括号{{42}}
进行初始化,而Derived
结构体的成员text
使用"hello world"
进行初始化。
程序输出将是:
Base value: 42
Derived text: hello world
这展示了如何通过嵌套的大括号初始化基类结构体和派生结构体。
派生类的析构函数
类的析构函数在对象被销毁时会自动调用。这同样适用于从其他类派生的类的对象。假设我们有如下情况:
class Base
{
public:
~Base();
};
class Derived : public Base {
public:
~Derived();
};
int main() {
Derived derived;
}
在 main
函数结束时,derived
对象将不复存在。因此,Derived
的析构函数(~Derived
)会被调用。然而,由于 derived
也是一个 Base
对象,Base
的析构函数(~Base
)也会被调用。基类析构函数不会从派生类析构函数中显式调用。
构造函数和析构函数的调用遵循类似栈的顺序:当 derived
被构造时,首先调用适当的基类构造函数,然后调用适当的派生类构造函数。当 derived
对象被销毁时,首先调用其析构函数,然后自动调用基类的析构函数。派生类的析构函数总是在基类的析构函数之前被调用。
如果派生类对象的构造没有成功完成(即,构造函数抛出异常),那么其析构函数不会被调用。然而,如果派生类构造函数抛出异常,已成功构造的基类析构函数将会被调用。这是合乎逻辑的:一个正确构造的对象最终也应该被销毁。示例代码如下:
#include <iostream>
struct Base
{
~Base()
{
std::cout << "Base destructor\n";
}
};
struct Derived : public Base
{
Derived()
{
throw 1; // 此时 Base 已经被构造
}
};
int main()
{
try
{
Derived d;
}
catch(...)
{}
}
该程序输出:
Base destructor
这显示了即使在派生类构造函数抛出异常的情况下,基类的析构函数也会被调用,确保已成功构造的基类部分得到正确销毁。
重新定义成员函数
派生类可以重新定义基类的成员函数。假设我们有一个车辆分类系统,其中包括卡车,该卡车由两个部分组成:前部的拖车(tractor)和后部的挂车(trailer)。拖车和挂车都有各自的质量,质量函数应该返回两者的总质量。
以下是一个 Truck
类的定义示例。我们的初始 Truck
类从 Car
类派生,但随后扩展为包含一个额外的 size_t
字段,用于表示附加的质量信息。在这个设计中,我们选择在 Car
类中表示拖车的质量,并在 Truck
类中存储完整卡车(拖车 + 挂车)的质量:
class Truck : public Car
{
size_t d_mass;
public:
Truck();
Truck(size_t tractor_mass, size_t speed, char const *name, size_t trailer_mass);
void setMass(size_t tractor_mass, size_t trailer_mass);
size_t mass() const;
};
Truck::Truck(size_t tractor_mass, size_t speed, char const *name, size_t trailer_mass)
: Car(tractor_mass, speed, name),
d_mass(tractor_mass + trailer_mass)
{}
注意,Truck
类现在包含两个函数,这些函数已经在基类 Car
中定义:setMass
和 mass
。
-
setMass
的重新定义没有问题:该函数被重新定义以执行特定于Truck
对象的操作。 -
重新定义
setMass
隐藏了Car::setMass
。对于Truck
类,只能使用接受两个size_t
参数的setMass
函数。 -
Vehicle
的setMass
函数仍然可以在Truck
类中使用,但现在必须显式调用,因为Car::setMass
已经被隐藏。例如,可以这样实现Truck::setMass
:void Truck::setMass(size_t tractor_mass, size_t trailer_mass) { d_mass = tractor_mass + trailer_mass; Car::setMass(tractor_mass); // 注意:需要使用 Car:: 前缀 }
-
在类外部,
Car::setMass
使用作用域解析运算符访问。因此,如果一个Truck
对象需要设置其Car
的质量,它必须使用:truck.Car::setMass(x);
-
另一种替代方案是向派生类的接口中添加与基类成员具有相同函数原型的成员。这个派生类成员可以通过内联实现来调用基类成员。例如,我们向
Truck
类中添加以下成员:// 在接口中: void setMass(size_t tractor_mass); // 在接口下方: inline void Truck::setMass(size_t tractor_mass) { (d_mass -= Car::mass()) += tractor_mass; Car::setMass(tractor_mass); }
现在,可以在
Truck
对象中使用单参数的setMass
成员函数,而无需使用作用域解析运算符。由于函数是内联定义的,因此不会产生额外的函数调用开销。 -
为了防止隐藏基类成员,可以向派生类的接口中添加
using
声明。Truck
类的相关部分现在变为:class Truck : public Car { public: using Car::setMass; void setMass(size_t tractor_mass, size_t trailer_mass); };
using
声明将(所有重载的)指定成员函数直接导入到派生类的接口中。如果基类成员具有与派生类成员相同的签名,则编译会失败(例如,不能在Truck
的接口中添加using Car::mass
声明)。现在代码可以使用truck.setMass(5000)
和truck.setMass(5000, 2000)
。 -
mass
函数也已经在Car
中定义,因为它从Vehicle
类继承。此时,Truck
类重新定义该成员函数,以返回卡车的总质量:size_t Truck::mass() const { return d_mass; }
示例代码:
int main() {
Land vehicle{1200, 145};
Truck lorry{3000, 120, "Juggernaut", 2500};
lorry.Vehicle::setMass(4000);
std::cout << '\n'
<< "Tractor weighs " << lorry.Vehicle::mass() << '\n'
<< "Truck + trailer weighs " << lorry.mass() << '\n'
<< "Speed is " << lorry.speed() << '\n'
<< "Name is " << lorry.name() << '\n';
}
Truck
类从 Car
类派生。但是,这种类设计可能会引发质疑。由于卡车被视为拖车和挂车的组合,可能使用混合设计更为合适:使用继承来表示拖车部分(从 Car
继承),而使用组合来表示挂车部分。
这种重新设计将我们对卡车的观点从“一个带有奇怪附加数据成员的汽车”转变为“一个仍然是汽车(拖车)并且包含一个车辆(挂车)”。这种设计使得 Truck
的接口非常具体,无需用户研究 Car
和 Vehicle
的接口,并且开启了定义“公路列车”的可能性:拖车拖拽多个挂车。以下是这种替代类设置的示例:
class Truck : public Car { // 拖车
Vehicle d_trailer; // 使用 vector<Vehicle> 实现公路列车
public:
Truck();
Truck(size_t tractor_mass, size_t speed, char const *name, size_t trailer_mass);
void setMass(size_t tractor_mass, size_t trailer_mass);
void setTractorMass(size_t tractor_mass);
void setTrailerMass(size_t trailer_mass);
size_t tractorMass() const;
size_t trailerMass() const;
// 考虑:
Vehicle const &trailer() const;
};
多重继承
除了 Randbuf
类外,之前的类都是从单个基类派生的。除了单继承,C++ 还支持多重继承。在多重继承中,一个类可以同时从多个基类派生,因此可以继承多个父类的功能。
在使用多重继承时,应当合理考虑新派生的类是否可以被视为两个基类的实例。否则,组合设计可能更为合适。通常,线性继承(仅使用一个基类)比多重继承使用得更为频繁。良好的类设计原则要求一个类应有单一且明确的职责,而这一原则通常与多重继承相矛盾,因为我们可以说 Derived
类的对象同时是 Base1
和 Base2
的对象。
但请考虑一个使用多重继承极端的对象原型:瑞士军刀!这个对象既是刀子,又是剪刀,又是开罐器,又是螺旋起子……
“瑞士军刀”是多重继承的极端示例。在 C++ 中,也有许多合理的理由使用多重继承,而不违反“一个类,一个职责”原则。我们将在下一章讨论这些理由。本节集中于构造使用多重继承的类的技术细节。
如何在 C++ 中构造一个“瑞士军刀”呢?首先,我们需要(至少)两个基类。例如,假设我们正在设计一个工具包,允许我们构建飞机驾驶舱的仪表面板。我们设计各种仪表,如人工水平仪和高度计。飞机上经常看到的一个组件是导航通信组合(nav-com set):一个导航信标接收器(“nav” 部分)和一个无线电通信单元(“com” 部分)。要定义导航通信组合,我们首先设计 NavSet
类(假设已存在 Intercom
、VHF_Dial
和 Message
类):
class NavSet
{
public:
NavSet(Intercom &intercom, VHF_Dial &dial);
size_t activeFrequency() const;
size_t standByFrequency() const;
void setStandByFrequency(size_t freq);
size_t toggleActiveStandby();
void setVolume(size_t level);
void identEmphasis(bool on_off);
};
接下来,我们设计 ComSet
类:
class ComSet
{
public:
ComSet(Intercom &intercom);
size_t frequency() const;
size_t passiveFrequency() const;
void setPassiveFrequency(size_t freq);
size_t toggleFrequencies();
void setAudioLevel(size_t level);
void powerOn(bool on_off);
void testState(bool on_off);
void transmit(Message &message);
};
使用 ComSet
类的对象,我们可以接收通过 Intercom
传输的消息,也可以使用传递给 ComSet
对象的 Message
对象来发送消息。
现在我们准备构造我们的 NavComSet
:
class NavComSet : public ComSet, public NavSet
{
public:
NavComSet(Intercom &intercom, VHF_Dial &dial);
};
完成了。现在我们定义了一个 NavComSet
,它既是 NavSet
又是 ComSet
:通过多重继承,派生类现在可以使用两个基类的功能。
请注意以下几点:
public
关键字在两个基类名称(NavSet
和ComSet
)之前都存在。默认情况下,继承使用私有继承,必须在每个基类规格前重复使用public
关键字。基类的继承类型不必相同,一个基类可以使用公共继承,而另一个基类可以使用私有继承。- 多重派生的
NavComSet
类没有引入额外的功能,而只是将两个现有的类组合成一个新的聚合类。因此,C++ 提供了将多个简单类合并为一个更复杂类的可能性。 - 以下是
NavComSet
构造函数的实现:
构造函数无需额外的代码:其目的是激活基类的构造函数。基类初始化器的调用顺序不是由构造函数代码中的调用顺序决定的,而是由类接口中的基类顺序决定的。NavComSet::NavComSet(Intercom &intercom, VHF_Dial &dial) : ComSet(intercom), NavSet(intercom, dial) {}
NavComSet
类定义不需要额外的数据成员或成员函数:在这里(以及通常情况下),继承的接口提供了派生类正常操作所需的所有功能和数据。
当然,在定义基类时,我们通过严格使用不同的成员函数名称使工作变得更加轻松。因此,NavSet
类中有一个 setVolume
函数,而 ComSet
类中有一个 setAudioLevel
函数。这有点作弊,因为我们可以预期这两个单元实际上有一个组合对象 Amplifier
来处理音量设置。一个修订后的类可能提供一个 Amplifier &lifier() const
成员函数,并留给应用程序设置自己的音量设置接口。或者,一个修订后的类可以定义用于设置 NavSet
或 ComSet
部分的音量的成员函数。
在两个基类提供具有相同名称的成员的情况下,需要采取特殊措施以防止歧义:
- 可以使用基类名称和作用域解析运算符显式指定预期的基类:
NavComSet navcom(intercom, dial); navcom.NavSet::setVolume(5); // 设置 NavSet 的音量级别 navcom.ComSet::setVolume(5); // 设置 ComSet 的音量级别
- 类接口提供可以无歧义调用的成员函数。这些附加成员通常是内联定义的:
class NavComSet : public ComSet, public NavSet { public: NavComSet(Intercom &intercom, VHF_Dial &dial); void comVolume(size_t volume); void navVolume(size_t volume); }; inline void NavComSet::comVolume(size_t volume) { ComSet::setVolume(volume); } inline void NavComSet::navVolume(size_t volume) { NavSet::setVolume(volume); }
- 如果
NavComSet
类是从第三方获得的,无法修改,则可以使用消歧义的包装类:class MyNavComSet : public NavComSet { public: MyNavComSet(Intercom &intercom, VHF_Dial &dial); void comVolume(size_t volume); void navVolume(size_t volume); }; inline MyNavComSet::MyNavComSet(Intercom &intercom, VHF_Dial &dial) : NavComSet(intercom, dial) {} inline void MyNavComSet::comVolume(size_t volume) { ComSet::setVolume(volume); } inline void MyNavComSet::navVolume(size_t volume) { NavSet::setVolume(volume); }
基类与派生类之间的转换
当使用公共继承来定义类时,派生类的对象同时也是基类的对象。这对对象赋值和使用指针或引用时有重要的影响。下面讨论这两种情况。
对象赋值中的转换
继续讨论第 13.6 节中引入的 NavComSet
类,我们现在定义两个对象,一个是基类对象,一个是派生类对象:
ComSet com(intercom);
NavComSet navcom(intercom2, dial2);
navcom
对象是使用 Intercom
和 VHF_Dial
对象构造的。然而,NavComSet
同时也是 ComSet
,这允许从 navcom
(派生类对象)赋值给 com
(基类对象):
com = navcom;
这次赋值的结果是 com
现在与 intercom2
通信。由于 ComSet
没有 VHF_Dial
,赋值时会忽略 navcom
的 dial
。当从派生类对象赋值给基类对象时,仅赋值基类的数据成员,其他数据成员会被丢弃,这种现象称为“切片”(slicing)。在这种情况下,切片可能没有严重后果,但当将派生类对象传递给定义了基类参数的函数或从返回基类对象的函数中返回派生类对象时,也会发生切片,可能会有不希望出现的副作用。
从基类对象赋值给派生类对象是有问题的。在像以下的语句中:
navcom = com;
如何重新赋值 NavComSet
的 VHF_Dial
数据成员是一个问题,因为 ComSet
对象 com
中没有这些成员。因此,这种赋值会被编译器拒绝。虽然派生类对象也可以是基类对象,但反过来则不成立:基类对象不一定也是派生类对象。
以下一般规则适用:在涉及基类对象和派生类对象的赋值中,数据被丢弃的赋值是合法的(称为切片)。而数据保持未指定的赋值则是不允许的。当然,也可以重载赋值运算符以允许从基类对象赋值到派生类对象。要编译以下语句:
navcom = com;
NavComSet
类必须定义一个接受 ComSet
对象作为参数的重载赋值运算符。在这种情况下,程序员需要决定赋值运算符将如何处理缺失的数据。
指针赋值中的转换
我们回到我们的 Vehicle
类,并定义以下对象和指针变量:
Land land(1200, 130);
Car car(500, 75, "Daf");
Truck truck(2600, 120, "Mercedes", 6000);
Vehicle *vp;
现在,我们可以将这三个派生类对象的地址赋值给 Vehicle
指针:
vp = &land;
vp = &car;
vp = &truck;
每个赋值都是可以接受的。然而,由于 vp
被定义为指向 Vehicle
的指针,因此会使用隐式转换将派生类转换为基类 Vehicle
。因此,当使用 vp
时,仅可以调用处理质量的成员函数,因为这只是 Vehicle
的功能。
同样的规则适用于对 Vehicle
的引用。如果定义了一个以 Vehicle
引用为参数的函数,该函数可以接受一个派生自 Vehicle
的类的对象。函数内部,特定的 Vehicle
成员仍然可以访问。这种指针和引用之间的类比通常是成立的。记住,引用实际上只是一个伪装成变量的指针:它模仿了普通变量,但实际上它是一个指针。
这种受限的功能对 Truck
类有一个重要的影响。在 vp = &truck
之后,vp
指向一个 Truck
对象。因此,vp->mass()
返回的是 2600,而不是 8600(即车厢和拖车的总质量:2600 + 6000),这本来是 truck.mass()
返回的值。当使用指针调用对象的函数时,指针的类型(而不是对象的类型)决定了可以执行哪些成员函数。换句话说,C++ 隐式地将通过指针访问的对象的类型转换为指针的类型。
如果知道指针指向的对象的实际类型,可以使用显式类型转换来访问对象的完整成员函数集:
Truck truck;
Vehicle *vp;
vp = &truck; // vp 现在指向一个 truck 对象
Truck *trp;
trp = static_cast<Truck *>(vp);
cout << "Make: " << trp->name() << '\n';
这里,倒数第二条语句将 Vehicle*
类型的变量显式地转换为 Truck*
类型。像使用类型转换一样,这段代码并非没有风险。它只有在 vp
确实指向一个 Truck
对象时才会有效。否则,程序可能会产生意外的结果。
使用 new[]
分配非默认构造函数对象
常见的抱怨是,operator new[]
调用类的默认构造函数来初始化分配的对象。例如,要分配一个包含 10 个字符串的数组,我们可以这样做:
new string[10];
但无法使用其他构造函数。假设我们希望将字符串初始化为 “hello world”,我们不能这样写:
new string{ "hello world" }[10];
动态分配对象的初始化通常由两个步骤组成:首先分配数组(隐式调用默认构造函数);其次初始化数组的元素,如下例所示:
string *sp = new string[10];
fill(sp, sp + 10, string{ "hello world" });
这些方法都存在“双重初始化”的问题,类似于在构造函数中不使用成员初始化器。
一种避免双重初始化的方法是使用继承。继承可以有效地与 operator new[]
结合使用非默认构造函数。该方法利用以下几点:
- 基类指针可以指向派生类对象;
- 一个没有(非静态)数据成员的派生类与其基类的大小相同。上述内容也暗示了一种可能的方法:
- 从我们感兴趣的类派生一个简单的、没有成员的类;
- 在其默认构造函数中使用适当的基类初始化器;
- 分配所需数量的派生类对象,并将
new[]
的返回值分配给指向基类对象的指针。
下面是一个简单的例子,生成 10 行包含 “hello world” 的文本:
#include <iostream>
#include <string>
#include <algorithm>
#include <iterator>
using namespace std;
struct Xstr: public string
{
Xstr()
:
string("hello world")
{}
};
int main()
{
string *sp = new Xstr[10];
copy(sp, sp + 10, ostream_iterator<string>{ cout, "\n" });
}
当然,上述例子相当简单,但可以进一步优化:Xstr
类可以定义在匿名命名空间中,只对函数 getString()
可见,该函数可以接受一个 size_t nObjects
参数,允许用户指定他们希望分配多少个 “hello world” 初始化的字符串。
除了硬编码基类参数外,还可以使用变量或函数来提供适当的基类构造函数参数。在下一个例子中,在函数 nStrings(size_t nObjects, char const *fname)
内定义了一个局部类 Xstr
,该函数接受要分配的字符串对象的数量和一个文件名,文件的后续行用于初始化对象。局部类在函数 nStrings
外部不可见,因此不需要特别的命名空间保护。
如第 7.9 节所讨论,局部类的成员无法访问其周围函数的局部变量。然而,它们可以访问由周围函数定义的全局和静态数据。
使用局部类可以巧妙地将实现细节隐藏在函数 nStrings
内部,该函数只需打开文件、分配对象并再次关闭文件。由于局部类是从 string
派生的,它可以使用任何字符串构造函数作为基类初始化器。在这种特定情况下,甚至不需要这样做,因为拷贝消除确保 Xstr
的基类 string
实际上是由 nextLine
返回的字符串。nextLine
函数随后接收刚刚打开的流的行。由于 nextLine
是静态成员函数,即使在 Xstr
对象尚未创建时,它也可以被 Xstr
默认构造函数的成员初始化器访问。
#include <fstream>
#include <iostream>
#include <string>
#include <algorithm>
#include <iterator>
using namespace std;
string *nStrings(size_t size, char const *fname)
{
static thread_local ifstream in;
struct Xstr: public string
{
Xstr()
:
string(nextLine())
{}
static string nextLine()
{
string line;
getline(in, line);
return line; // 拷贝消除将这转化为 Xstr 的基类 string
}
};
in.open(fname);
string *sp = new Xstr[size];
in.close();
return sp;
}
int main()
{
string *sp = nStrings(10, "nstrings.cc");
copy(sp, sp + 10, ostream_iterator<string>{ cout, "\n" });
}
当运行这个程序时,它会显示文件 nstrings.cc
的前 10 行。
注意例子中定义了一个静态的 thread_local ifstream
对象。thread_local
变量在第 20 章中正式介绍。thread_local
说明符确保函数在多线程程序中也能安全使用。
一种完全不同的方法来避免双重初始化(不使用继承)是使用放置 new
(参见第 9.1.5 节):简单地分配所需的内存,然后使用适当的构造函数进行内存中的正确分配。在下例中,使用了一对静态的构造/销毁成员来执行所需的初始化。construct
方法期望一个 istream
来提供初始化字符串用于 String
类对象。construct
首先分配足够的内存来存放 n
个 String
对象以及一个初始的 size_t
值。然后初始化这个初始的 size_t
值为 n
。接下来,在一个 for
语句中,从提供的流中读取行,并将这些行传递给构造函数,使用放置 new
调用。最后,返回第一个 String
对象的地址。然后,由 destroy
方法处理对象的销毁。它从存储在第一个对象地址之前的 size_t
值中检索要销毁的对象数量。然后显式调用它们的析构函数来销毁对象。最后,返回最初由 construct
分配的原始内存。
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
class String
{
union Ptrs
{
void *vp;
String *sp;
size_t *np;
};
std::string d_str;
public:
String(std::string const &txt)
:
d_str(txt)
{}
~String()
{
cout << "destructor: " << d_str << '\n';
}
static String *construct(istream &in, size_t n)
{
Ptrs p = {operator new(n * sizeof(String) + sizeof(size_t))};
*p.np++ = n;
string line;
for (size_t idx = 0; idx != n; ++idx)
{
getline(in, line);
new(p.sp + idx) String{ line };
}
return p.sp;
}
static void destroy(String *sp)
{
Ptrs p = {sp};
--p.np;
for (size_t n = *p.np; n--; )
sp++->~String();
operator delete(p.vp);
}
};
int main()
{
String *sp = String::construct(cin, 5);
String::destroy(sp);
}
运行程序后,在提供 5 行分别包含 “alpha”、“bravo”、“charley”、“delta” 和 “echo” 的情况下,程序会显示:
destructor: alpha
destructor: bravo
destructor: charley
destructor: delta
destructor: echo
多态性
通过使用继承,类可以从其他类派生,称为基类。在上一章中,我们看到基类指针可以用来指向派生类对象。我们还看到,当基类指针指向一个派生类的对象时,指针的类型,而不是对象的类型,决定了哪些成员函数是可见的。因此,当一个 Vehicle *vp
指向 Car
对象时,Car
的 speed
或 brandName
成员无法被访问。
在上一章中讨论了两种基本的类关系方式:一个类可以基于另一个类进行实现,这种关系通常使用组合来实现;另一种关系是派生类是基类的一种,这种关系通常使用一种特殊形式的继承,即多态性,这是本章讨论的主题。
类之间的 “is-a” 关系允许我们应用里氏替换原则(LSP),根据该原则,派生类对象可以传递给期望基类对象指针或引用的代码,并在其中使用。在之前的 C++ 注释中,LSP 已经多次应用。例如,每次将 ostringstream
、ofstream
或 fstream
传递给期望 ostream
的函数时,我们都在应用这个原则。本章中,我们将学习如何设计自己的类以符合这一原则。
LSP 是通过一种叫做多态性的技术来实现的:虽然使用的是基类指针,但它执行的是在实际指向的对象的(派生)类中定义的操作。因此,Vehicle *vp
在指向 Car
时,可能会表现得像 Car *
一样。
多态性是通过一种称为延迟绑定的特性来实现的。之所以称之为延迟绑定,是因为决定调用哪个函数(基类函数还是派生类函数)不能在编译时做出,而是推迟到程序实际执行时:只有在那时才确定哪个成员函数会被调用。
在 C++ 中,延迟绑定并不是默认的函数调用方式。默认情况下使用的是静态绑定(或早期绑定)。在静态绑定中,调用的函数是由编译器确定的,仅仅使用对象、对象指针或对象引用的类类型。
延迟绑定是一个本质上不同(并且稍微慢一些)的过程,因为它是在运行时而不是在编译时决定调用哪个函数。由于 C++ 支持延迟绑定和早期绑定,C++ 程序员可以选择使用哪种绑定方式。选择可以根据具体情况进行优化。许多提供面向对象功能的其他语言(例如 Java)仅提供或默认提供延迟绑定。C++ 程序员应该对此有清晰的认识。期望早期绑定而得到延迟绑定可能会很容易引发严重的错误。让我们来看一个简单的例子,以便开始理解延迟绑定和早期绑定之间的区别。
以下程序只是一个示例。简要解释了为什么结果是这样:
#include <iostream>
using namespace std;
class Base {
protected:
void hello() { cout << "base hello\n"; }
public:
void process() { hello(); }
};
class Derived : public Base {
protected:
void hello() { cout << "derived hello\n"; }
};
int main() {
Derived derived;
derived.process();
}
上面的程序的重要特征是 Base::process
函数调用 hello
。由于 process
是公共接口中唯一定义的成员,它也是唯一可以被不属于这两个类的代码调用的成员。Derived
类从 Base
继承了 Base
的接口,因此 process
也在 Derived
中可用。因此,main
中的 Derived
对象能够调用 process
,但不能调用 hello
。
到目前为止,一切正常。这一切在上一章中都已涉及。可能会有人想知道为什么定义了 Derived
。它可能是为了创建一个对 Derived
更合适的 hello
实现,但与 Base::hello
的实现不同。Derived
的作者的推理如下:Base
的 hello
实现不合适;Derived
类对象可以通过提供一个合适的实现来解决这个问题。作者进一步推理:“由于对象的类型决定了使用的接口,process
必须调用 Derived::hello
,因为 hello
是通过 process
从 Derived
类对象中调用的”。
不幸的是,由于静态绑定,作者的推理是错误的。当 Base::process
被编译时,静态绑定导致编译器将 hello
调用绑定到 Base::hello()
。作者打算创建一个 Derived
类,它是 Base
类的一种。结果只部分成功:Base
的接口被继承,但之后 Derived
对类的行为失去了控制。一旦进入 process
,我们只能看到 Base
的成员实现。多态性提供了一种解决方法,允许我们在派生类中重新定义基类的成员,并允许这些重新定义的成员从基类的接口中使用。
这就是 LSP 的本质:公共继承不应该用于重用基类成员(在派生类中),而是应当被基类(多态地使用派生类成员重新实现基类成员)重用。
请稍作思考上述程序的含义。hello
和 process
成员并不太引人注目,但示例的含义非常重要。process
成员可以实现目录遍历,hello
可以定义在遇到文件时要执行的操作。Base::hello
可能只是显示文件名,但 Derived::hello
可能会删除文件;可能只列出其名称,如果文件较新;如果文件包含某些文本,则列出其名称;等等。到目前为止,Derived
必须自己实现 process
的操作;到目前为止,期望 Base
类引用或指针的代码只能执行 Base
的操作。多态性允许我们重新实现基类的成员,并在期望基类引用或指针的代码中使用这些重新实现的成员。使用多态性,现有代码可以通过派生类重新实现适当的基类成员进行重用。现在是时候揭示这种魔法是如何实现的了。
多态性(在 C++ 中不是默认的)解决了这个问题,并允许类的作者实现其目标。对于好奇的读者:在 Base
类中的 void hello()
前面加上关键字 virtual
并重新编译。运行修改后的程序将产生预期的 derived hello
。为什么会这样,接下来会解释。
虚函数
默认情况下,通过指针或引用调用的成员函数的行为由指针或引用所在类的实现决定。例如,即使 Vehicle *
指向一个派生类对象,它也会调用 Vehicle
的成员函数。这被称为早期绑定或静态绑定:调用的函数是在编译时决定的。在 C++ 中,延迟绑定或动态绑定是通过虚函数来实现的。
当成员函数的声明以关键字 virtual
开头时,它就成为了虚函数。需要再次强调的是,在 C++ 中,这不同于一些其他面向对象语言,虚函数不是默认的。默认情况下使用的是静态绑定。
一旦在基类中声明了虚函数,它在所有派生类中都会保持虚函数状态。在派生类中,如果基类中的成员已经声明为虚函数,则不应再次提及 virtual
关键字。派生类中的这些成员应提供 override
指示符,以允许编译器验证你确实是指向一个已存在的虚函数。
在车辆分类系统(参见第 13.1 节)中,让我们集中关注 mass
和 setMass
成员。这些成员定义了 Vehicle
类的用户接口。我们希望实现的是这个用户接口可以用于 Vehicle
和任何从 Vehicle
继承的类,因为这些类的对象本身也是 Vehicle
。
如果我们能够定义基类(例如 Vehicle
)的用户接口,使其在我们从 Vehicle
派生的类中仍然可用,我们的软件将实现巨大的可重用性:我们围绕 Vehicle
的用户接口设计我们的软件,我们的软件也将对派生类正常工作。使用普通的继承无法实现这一点。如果我们定义如下的插入运算符重载:
std::ostream &operator<<(std::ostream &out, Vehicle const &vehicle)
{
return out << "Vehicle's mass is " << vehicle.mass() << " kg.";
}
假设 Vehicle
的 mass
成员返回 0,但 Car
的 mass
成员返回 1000,那么当以下程序被执行时:
int main()
{
Vehicle vehicle;
Car vw{ 1000, 160, "Golf" };
cout << vehicle << '\n' << vw << '\n';
}
将报告两次 0 的质量。我们定义了一个重载的插入运算符,但由于它只知道 Vehicle
的用户接口,因此 'cout << vw'
也将使用 vw
的 Vehicle
用户接口,从而显示质量为 0。
如果我们在基类接口中添加一个可重定义的接口,将会增强可重用性。可重定义的接口允许派生类填充其自己的实现,而不会影响用户接口。同时,用户接口将根据派生类的意愿进行行为,而不仅仅是基类的默认实现。
可重用接口的成员应该在类的私有部分中声明:从概念上讲,它们仅属于自己的类(参见第 14.7 节)。在基类中,这些成员应该声明为虚函数。这些成员可以被派生类重新定义(重写),并且应该提供 override
指示符。
我们保持用户接口(mass
),并将可重定义成员 vmass
添加到 Vehicle
的接口中:
class Vehicle
{
public:
size_t mass() const;
size_t si_mass() const;
// see below
private:
virtual size_t vmass() const;
};
将用户接口与可重定义接口分离是一种明智的做法。这使我们能够精细调整用户接口(只有一个维护点),同时标准化可重定义接口成员的预期行为。例如,在许多国家使用国际单位制,以千克作为质量单位。一些国家使用其他单位(如磅:1 kg 约等于 2.2046 lbs)。通过将用户接口与可重定义接口分离,我们可以对可重定义接口使用一个标准,同时保留在用户接口中灵活转换信息的能力。
为了保持用户接口和可重定义接口的清晰分离,我们可以考虑向 Vehicle
添加另一个访问器,提供 si_mass
,其实现如下:
size_t Vehicle::si_mass() const {
return vmass();
}
如果 Vehicle
支持成员 d_massFactor
,则其 mass
成员可以这样实现:
size_t Vehicle::mass()
{
return d_massFactor * si_mass();
}
Vehicle
本身可以定义 vmass
以返回一个标记值,例如:
size_t Vehicle::vmass()
{
return 0;
}
现在让我们看看 Car
类。它从 Vehicle
派生,继承了 Vehicle
的用户接口。它还有一个数据成员 size_t d_mass
,并实现了自己的可重定义接口:
class Car : public Vehicle
{
// ...
private:
size_t vmass() override;
};
如果 Car
的构造函数要求我们指定汽车的质量(存储在 d_mass
中),则 Car
只是简单地实现其 vmass
成员如下:
size_t Car::vmass() const
{
return d_mass;
}
Truck
类继承自 Car
,需要两个质量值:拖拉机的质量和拖车的质量。拖拉机的质量传递给它的 Car
基类,拖车的质量传递给它的 Vehicle
d_trailer
数据成员。Truck
也重写了 vmass
,这次返回拖车和拖车的总质量:
size_t Truck::vmass() const
{
return Car::si_mass() + d_trailer.si_mass();
}
一旦类成员被声明为虚函数,它在所有派生类中都成为虚函数,无论这些成员是否提供了 override
指示符。但应使用 override
,因为它允许编译器在编写派生类接口时捕获拼写错误。
成员函数可以在类层次结构中的任何位置声明为虚函数,但这可能会破坏底层的多态类设计,因为原始基类可能无法完全覆盖派生类的可重定义接口。例如,如果 mass
在 Car
中声明为虚函数,但在 Vehicle
中没有声明为虚函数,那么虚函数的特定特性将仅对 Car
对象及其派生类对象可用。对于 Vehicle
指针或引用,仍将使用静态绑定。
延迟绑定(多态性)的效果如下所示:
void showInfo(Vehicle &vehicle)
{
cout << "Info: " << vehicle << '\n';
}
int main() {
Car car(1200); // car with mass 1200
Truck truck(6000, 115, // truck with cabin mass 6000,
"Scania", 15000); // speed 115, make Scania,
// trailer mass 15000
showInfo(car); // see (1) below
showInfo(truck); // see (2) below
Vehicle *vp = &truck;
cout << vp->speed() << '\n'; // see (3) below
}
现在 mass
被定义为虚函数,使用了延迟绑定:
- 在 (1) 处,显示的是
Car
的质量; - 在 (2) 处,显示的是
Truck
的质量; - 在 (3) 处,会生成一个语法错误。
speed
成员不是Vehicle
的成员,因此不能通过Vehicle*
调用。
这个例子说明了,当使用指向类的指针时,只能调用该类的成员。成员的虚拟特性只影响绑定的类型(早期绑定与延迟绑定),而不影响指针可以看到的成员函数集合。
通过虚拟成员,派生类可以重定义通过基类成员或指向基类对象的指针或引用调用的函数的行为。这种通过派生类重定义基类成员的行为称为重写成员。
多态类的构造函数
虽然多态类的构造函数可以(间接地)调用虚成员,但这通常不是你想要的,因为多态类的构造函数不会考虑这些成员可能被派生类重写。例如,假设 Vehicle
类定义了如下成员:
public:
void prepare()
{
vPrepare();
}
private:
virtual void vPrepare()
{
cout << "Preparing the Vehicle\n";
}
而 Car
类重写了 vPrepare
:
virtual void vPrepare() override
{
cout << "Preparing the Car\n";
}
那么以下代码片段会显示 “Preparing the Car”:
Car car{1200};
Vehicle &veh = car;
veh.prepare();
也许准备工作总是需要进行的。那么为什么不在基类的构造函数中执行呢?因此,Vehicle
的构造函数可以定义为:
Vehicle::Vehicle()
{
prepare();
}
然而,以下代码片段会显示 “Preparing the Vehicle”,而不是 “Preparing the Car”:
Car car{1200};
由于基类构造函数不识别被重写的虚成员,Vehicle
的构造函数只是调用了它自己的 vPrepare
成员,而不是 Vehicle::vPrepare
。
基类构造函数不识别被重写的成员函数是有明确逻辑的:多态性允许我们将基类的接口定制为派生类。虚成员存在的目的是为了实现这一定制过程。但这与不能从基类构造函数中调用派生类的成员是完全不同的:在那时,派生类对象还没有完全初始化。当派生类对象被构造时,其基类部分会在派生类对象本身变为有效状态之前完成构造。因此,如果允许基类构造函数调用重写的虚成员,那么这些成员很可能会使用派生类的数据,而这些数据在那时尚未正确初始化(通常会导致未定义行为,如段错误)。
虚析构函数
当一个对象exist时,对象的析构函数会被调用。考虑以下代码片段:
Vehicle *vp = new Land{ 1000, 120 };
delete vp;
// 对象被销毁
在这里,delete
被应用于基类指针。由于基类定义了可用的接口,delete vp
会调用 ~Vehicle
,而 ~Land
则不会被执行。如果 Land
类分配了内存,那么会导致内存泄漏。析构函数不仅仅用于释放内存,还可以执行任何在对象结束时必要的操作。然而,这里没有执行 ~Land
定义的任何操作。这是一个严重的问题……
在 C++ 中,这个问题通过虚析构函数得以解决。析构函数可以被声明为虚函数。当一个基类的析构函数被声明为虚函数时,基类指针 bp
实际上指向的类的析构函数会在 delete bp
执行时被调用。因此,即使析构函数的名称在派生类中是唯一的,析构函数也会实现晚绑定。示例:
class Vehicle {
public:
virtual ~Vehicle(); // 所有派生类的析构函数也变为虚析构函数
};
通过声明一个虚析构函数,上述 delete vp
操作会正确调用 Land
的析构函数,而不是 Vehicle
的析构函数。
一旦析构函数被调用,它会按通常的方式执行,无论它是否是虚析构函数。因此,~Land
首先执行其自己的语句,然后调用 ~Vehicle
。所以,上述 delete vp
语句使用晚绑定来调用 ~Vehicle
,从此之后对象销毁会按常规进行。
在设计为基类并且可能会被其他类继承的类中,析构函数应始终被定义为虚析构函数。即使这些析构函数本身没有任务要执行,虚析构函数也会保持虚拟。对于这些情况,虚析构函数的定义可以是简单的:
Vehicle::~Vehicle()
{}
应避免将虚析构函数(即使是空析构函数)定义为内联函数,因为这会使类的维护变得复杂。第 14.12 节讨论了这一经验法则的原因。
纯虚函数
基类 Vehicle
提供了虚成员函数(如 mass
和 setMass
)的具体实现。然而,虚成员函数在基类中并不一定需要实现。
当基类中省略了虚成员函数的实现时,基类会对派生类施加要求。派生类需要提供这些“缺失的实现”。
这种方法在某些语言(如 C#、Delphi 和 Java)中被称为接口,它定义了一个协议。派生类必须遵循这个协议,通过实现尚未实现的成员函数来满足要求。如果一个类包含至少一个未实现的成员,那么该类的对象不能被实例化。
这种未完全定义的类始终是基类。它们通过仅声明一些成员的名称、返回值和参数来强制实施一个协议。这些类称为抽象类或抽象基类。派生类通过实现尚未实现的成员函数,变成非抽象类。
抽象基类是许多设计模式的基础(参见 Gamma 等人 (1995)),允许程序员创建高度可重用的软件。某些设计模式在 C++ 注释中有涉及(例如,第 25.2 节中的模板方法),但要深入讨论设计模式,请参阅 Gamma 等人的书籍。
在基类中仅声明的成员函数称为纯虚函数。一个虚成员函数通过在声明后加上 = 0
来成为纯虚成员函数(即,将声明末尾的分号替换为 = 0;
)。例如:
#include <iosfwd>
class Base {
public:
virtual ~Base();
virtual std::ostream &insertInto(std::ostream &out) const = 0;
};
inline std::ostream &operator<<(std::ostream &out, Base const &base) {
return base.insertInto(out);
}
所有从 Base
派生的类必须实现 insertInto
成员函数,否则它们的对象无法被构造。这非常简洁:现在,所有 Base
派生类的对象都可以被插入到 ostream
对象中。
基类的虚析构函数是否可以是纯虚函数?答案是否定的。首先,没有必要强制要求派生类提供析构函数,因为析构函数默认情况下会被提供(除非析构函数被声明为 = delete
)。其次,如果析构函数是纯虚函数,它的实现并不存在。然而,派生类的析构函数最终会调用它们基类的析构函数。如果基类的析构函数没有实现,它们如何被调用呢?关于这一点将在下一节中讨论。
通常但不一定,纯虚成员函数是 const
成员函数。这允许构造常量派生类对象。在其他情况下,这可能并不必要(或不现实),可能需要非 const
成员函数。const
成员函数的一般规则也适用于纯虚函数:如果成员函数更改对象的成员数据,它不能是 const
成员函数。
抽象基类通常没有数据成员。然而,一旦基类声明了一个纯虚成员函数,它必须在派生类中以完全相同的方式声明。如果派生类中纯虚函数的实现更改了派生类对象的数据,那么该函数不能被声明为 const
成员。因此,抽象基类的作者应仔细考虑一个纯虚成员函数是否应该是 const
成员函数。
实现纯虚函数
纯虚成员函数可以被实现。为了实现一个纯虚成员函数,可以提供其正常的 = 0;
规范,同时也提供它的实现。由于 = 0;
以分号结束,因此纯虚成员函数在其类中总是最多只是一个声明,但可以在类的接口之外实现它(可以使用内联实现)。
纯虚成员函数可以通过指定基类和作用域解析运算符来从派生类对象或其类成员中调用。例如:
#include <iostream>
class Base
{
public:
virtual ~Base();
virtual void pureimp() = 0;
};
Base::~Base()
{}
void Base::pureimp()
{
std::cout << "Base::pureimp() called\n";
}
class Derived: public Base
{
public:
void pureimp() override;
};
inline void Derived::pureimp()
{
Base::pureimp();
std::cout << "Derived::pureimp() called\n";
}
int main()
{
Derived derived;
derived.pureimp();
derived.Base::pureimp();
Derived *dp = &derived;
dp->pureimp();
dp->Base::pureimp();
}
输出:
Base::pureimp() called
Derived::pureimp() called
Base::pureimp() called
Base::pureimp() called
Derived::pureimp() called
Base::pureimp() called
实现纯虚成员函数的用途有限。可以说,纯虚成员函数的实现可能用于执行一些在基类级别上已经可以完成的任务。然而,没有保证基类的虚成员函数实际上会被调用。因此,基类特定的任务也可以通过单独的成员函数来提供,而不会混淆一个成员函数执行一些工作与一个纯虚成员函数强制实施协议之间的区别。
Explicit virtual overrides
考虑以下情况:
-
类
Value
是一个值类。它提供了一个拷贝构造函数、一个重载的赋值运算符,也许还有移动操作和一个公共的、非虚拟的构造函数。在第14.7节中提到,这些类不适合作为基类。如何强制执行这一点? -
一个多态类
Base
定义了一个虚拟成员v_process(int32_t)
。一个从Base
派生的类需要重写这个成员,但作者错误地定义了v_proces(int32_t)
。如何防止这种错误,避免破坏派生类的多态行为? -
一个从多态基类
Base
派生的类Derived
重写了成员Base::v_process
,但从Derived
再次派生的类不应再重写v_process
,但可以重写其他虚拟成员,如v_call
和v_display
。如何强制执行这种对从Derived
派生的类的多态限制?
使用两个特殊标识符 final
和 override
可以实现上述要求。这些标识符在特定上下文中具有特殊含义。在这个上下文之外,它们只是普通标识符,允许程序员定义像 bool final
这样的变量。
标识符 final
可以应用于类声明,以指示该类不能作为基类。例如:
class Base1 final {}; // 不能作为基类
class Derived1 : public Base1 {}; // 错误: Base1 是 final
class Base2 {}; // 可以作为基类
class Derived2 final : public Base2 {}; // OK,但 Derived2 不能作为基类
class Derived : public Derived2 {}; // 错误: Derived2 是 final
标识符 final
也可以添加到虚拟成员声明中。这表示这些虚拟成员不能被派生类重写。上述对从 Derived
派生的类的多态限制可以通过以下方式实现:
class Base {
virtual int v_process();
// 定义多态行为
virtual int v_call();
virtual int v_display();
};
class Derived: public Base {
// Derived 限制多态性
// 仅限于 v_call 和 v_display
virtual int v_process() final;
};
class Derived2: public Derived {
// int v_process(); // 不允许: Derived:v_process 是 final
virtual int v_display(); // 可以重写
};
为了允许编译器检测拼写错误、参数类型差异或成员函数修饰符(例如 const
与非 const
)的差异,可以将标识符 override
添加到重写基类成员的派生类成员中。例如:
class Base {
virtual int v_process();
virtual int v_call() const;
virtual int v_display(std::ostream &out);
};
class Derived: public Base {
virtual int v_proces() override; // 错误: v_proces != v_process
virtual int v_call() override; // 错误: 不是 const
virtual int v_display(std::istream &out) override; // 错误: 参数类型不同
};
虚拟函数与多重继承
在第6章中,我们遇到过 fstream
类,它结合了 ifstream
和 ofstream
的特性。在第13章中,我们了解到一个类可以从多个基类派生。这样,一个派生类就可以继承所有基类的属性。多态性也可以与多重继承结合使用。
考虑一下如果有多个“路径”从派生类到其(基类),会发生什么。这在下一个(虚构的)例子中被说明,其中类 Derived
从 Base
类中双重派生:
class Base {
int d_field;
public:
void setfield(int val);
int field() const;
};
inline void Base::setfield(int val) {
d_field = val;
}
inline int Base::field() const {
return d_field;
}
class Derived : public Base, public Base {};
由于双重派生,Base
的功能在 Derived
中出现了两次。这会导致歧义:当对 Derived
类对象调用 setfield()
函数时,应该调用哪一个函数,因为有两个这样的函数?作用域解析运算符无法解决这个问题,因此 C++ 编译器无法编译上述代码,并且(正确地)识别出一个错误。
上述代码明显地在继承中重复了基类,这当然可以通过避免双重继承 Base
(或使用组合)来轻松解决。但基类的重复也可以通过嵌套继承发生,例如,一个对象同时从 Car
和 Air
派生(参见第13.1节)。这样的类可能被用来表示一个飞行汽车。例如,一个 AirCar
最终将包含两个 Vehicle
,因此有两个质量字段、两个 setMass()
函数和两个 mass()
函数。这是我们想要的吗?
多重继承中的歧义
让我们更详细地探讨一下,为什么从 Car
和 Air
继承的 AirCar
引入了歧义。
AirCar
是Car
,因此是Land
,并且是Vehicle
。- 但是,
AirCar
也是Air
,因此也是Vehicle
。
Vehicle
数据的重复进一步在图 14.1 中进行了说明。AirCar
的内部组织在图 14.2 中展示。C++ 编译器会检测到 AirCar
对象中的歧义,因此不会编译类似的语句:
AirCar jBond;
cout << jBond.mass() << '\n';
编译器无法确定调用哪个 mass
成员函数,程序员可以通过以下两种方式解决歧义:
-
修改产生歧义的函数调用。使用作用域解析运算符来解决歧义:
// 让我们希望质量信息保存在 Car 部分的对象中 cout << jBond.Car::mass() << '\n';
在成员函数名前添加作用域解析运算符和类名。
-
为
AirCar
类创建一个专用的mass
函数:int AirCar::mass() const { return Car::mass(); }
第二种方法更为推荐,因为它不需要编译器标记错误,也不要求使用
AirCar
类的程序员采取特殊预防措施。
然而,下一节会讨论一个更优雅的解决方案。