第11章:选择运算(Selection Operations)
目录
11.1.1 逻辑运算符(Logical Operators)
11.1.2 按位逻辑运算符(Bitwise Logical Operators)
11.1.3 条件表达式(Conditional Expressions)
11.1.4 递加和递减(Increment and Decrement)
11.2.1 内存管理(Memory Management)
11.3.2 限定列表(Qualified Lists)(译注:有类型限定)
11.3.3 非限定初始化列表(Unqualified Lists)
11.4.2 Lambda 的替代方案(Alternatives to Lambdas)
11.4.5 Lambda 的类型(The Type of a Lambda)
11.5 显式类型转换(Explicit Type Conversion)
11.1 各种运算符(Etc. Operators)
本节将介绍一些简单的运算符:逻辑运算符(&&,|| 和 !),位逻辑运算符(&,|,˜,<< 和 >>),条件表达式(?:)以及递增和递减运算符(++ 和 −−)。除了它们的细节不适合在运算符的其他地方讨论之外,它们几乎没有共同之处。
11.1.1 逻辑运算符(Logical Operators)
逻辑运算符 &&(与),||(或)和 !(非)接受算术和指针类型的操作数,将它们转换为bool值,并返回bool结果。&& 和 || 运算符仅在必要时才估算其第二个参数,因此它们可用于控制估算顺序(§10.3.2)。例如:
while (p && !whitespace(∗p)) ++p;
在此,若 p 是 nullptr 则它不进行解引用操作。
11.1.2 按位逻辑运算符(Bitwise Logical Operators)
按位逻辑运算符 &(与),|(或),ˆ(异或,xor),˜(求补),>>(右移)和 <<(左移)适用于整型对象,即 char,short,int,long,long long 及其无符号对应项,以及 bool,wchar_t,char16_t 和 char32_t。普通枚举(但不是枚举类)可以隐式转换为整数类型,并用作按位逻辑运算的操作数。惯用算术转换(§10.5.3)决定结果的类型。
按位逻辑运算符的典型用途是实现小集合(位向量)的概念。在这种情况下,无符号整数的每个位代表集合的一个成员,位数限制成员的数量。二元运算符 & 被解释为交集,| 被解释为并集,ˆ 被解释为对称差集,˜ 被解释为补集。枚举可用于命名此类集合的成员。下面是一个从 ostream 的实现中借用的小示例:
enum ios_base::iostate {
goodbit=0, eofbit=1, failbit=2, badbit=4
};
流的实现可以像这样设置和测试其状态:
state = goodbit;
// ...
if (state&(badbit|failbit)) // stream not good
额外的括号是必要的,因为 & 的优先级高于 | (§10.3)。
到达输入结束的函数可能会像这样报告:
state |= eofbit;
|= 运算符用于添加状态。简单的赋值,state=eofbit,会清除所有其他位。
这些流状态标志可以从流实现外部观察到。例如,我们可以看到两个流的状态如何不同,如下所示:
int old = cin.rdstate(); // rdstate() 返回state
// ... use cin ...
if (cin.rdstate()ˆold) { // 改变了什么吗?
// ...
}
计算流状态的差并不常见。对于其他类似类型,计算差异至关重要。例如,考虑将表示正在处理的一组中断的位向量与表示等待处理的一组中断的另一个位向量进行比较。
请注意,此位操作取自 iostreams 的实现,而不是用户界面。方便的位操作可能非常重要,但出于可靠性、可维护性、可移植性等考虑,它应保持在系统的底层。有关集合的更多一般概念,请参阅标准库set(§31.4.3)和bitset(§34.2.2)。
按位逻辑运算可用于从字(word)(译注:两个字节为一个字)中提取位域(bit-fields)。例如,可以像这样提取 32 位 int 的中间 16 位:
constexpr unsigned short middle(int a)
{
static_assert(sizeof(int)==4,"unexpected int size");
static_assert(sizeof(shor t)==2,"unexpected short siz e");
return (a>>8)&0xFFFF;
}
int x = 0xFF00FF00; // 假设 sizeof(int)==4
short y = middle(x); // y = 0x00FF
使用位域(§8.2.7)是进行此类移位和屏蔽的便捷简写。
不要将按位逻辑运算符与逻辑运算符混淆:&&,|| 和 !。后者返回 true 或 false,它们主要用于在 if-,while- 或 for- 语句中编写测试(§9.4、§9.5)。例如,!0(非零)是值 true,转换为 1,而 ˜0(零的补码)是位模式全 1,在二进制补码表示中为值 −1(译注:整数在计算机存储中用补码表示)。
11.1.3 条件表达式(Conditional Expressions)
一些 if 语句可以方便地用条件表达式替换。例如:
if (a <= b)
max = b;
else
max = a;
更直接的表达如下:
max = (a<=b) ? b : a;
条件周围的括号不是必需的,但我发现使用它们会使代码更易于阅读。
条件表达式很重要,因为它们可用于常量表达式(§10.4)。如果一对表达式 e1 和 e2 属于同一类型,或者存在一个共同类型 T,并且它们都可以隐式转换为该类型,则它们可以用作条件表达式 c?e1:e2 中的替代项。对于算术类型,通常的算术转换(§10.5.3)用于查找该共同类型。对于其他类型,e1 必须可以隐式转换为 e2 的类型,反之亦然。此外,一个分支可以是 throw 表达式(§13.5.1)。例如:
void fct(int∗ p)
{
int i = (p) ? ∗p : std::runtime_error{"unexpected nullptr};
// ...
}
11.1.4 递加和递减(Increment and Decrement)
++ 运算符用于直接表示递加,而不是使用加法和赋值的组合来间接表示。假设左值没有副作用,++lvalue 表示 lvalue+=1,这又表示 lvalue=lvalue+1。表示要递加的对象的表达式只被估算一次。递减同样由 −− 运算符表示。
运算符 ++ 和 −− 既可用作前缀运算符,又可用作后缀运算符。++x 的值是 x 的新值(即增加的值)。例如,y=++x 等同于 y=(x=x+1)。而 x++ 的值是 x 的旧值。例如,y=x++ 等同于 y=(t=x,x=x+1,t),其中 t 是与 x 类型相同的变量。
就像将一个 int 加到指针上或减去它一样,指针上的 ++ 和 −− 操作是根据指针指向的数组的元素进行的;p++ 使 p 指向下一个元素(§7.4.1)。
++ 和 −− 运算符对于在循环中增加和减少变量特别有用。例如,可以像这样复制以零结尾的 C 风格字符串:
void cpy(char∗ p, const char∗ q)
{
while (∗p++ = ∗q++) ;
}
和 C 一样,C++ 也因其简洁、面向表达式的编码方式而受到人们的喜爱和憎恨。考虑一下:
while (∗p++ = ∗q++) ;
对于非 C 程序员来说,这有点晦涩难懂,但由于这种编码风格并不罕见,因此值得更仔细地研究。首先考虑一种更传统的复制字符数组的方法:
int length = strlen(q);
for (int i = 0; i<=length; i++)
p[i] = q[i];
这很浪费。以零结尾的字符串的长度是通过读取字符串并寻找终止零来求得的。因此,我们读取字符串两次:一次查找其长度,一次复制它。所以,我们尝试这样做:
int i;
for (i = 0; q[i]!=0 ; i++)
p[i] = q[i];
p[i] = 0; // terminating zero
用于索引的变量 i 可以消除,因为 p 和 q 是指针:
while (∗q != 0) {
∗p = ∗q;
p++; // point to next character
q++; // point to next character
}
∗p = 0; // terminating zero
因为后增操作允许我们先使用该值,然后再增加它,所以我们可以像这样重写循环:
while (∗q != 0) {
∗p++ = ∗q++;
}
∗p = 0; // terminating zero
∗p++ = ∗q++ 的值是 ∗q。因此,我们可以将这个例子重写如下:
while ((∗p++ = ∗q++) != 0) { }
在这种情况下,我们直到将 ∗q 复制到 ∗p 并增加 p 后才注意到 ∗q 为零。因此,我们可以消除终止零的最终分配。最后,我们可以通过观察不需要空块并且 !=0 是多余的来进一步简化示例,因为整数条件的结果无论如何总是与零进行比较。因此,我们得到了我们所欲求的版本:
while (∗p++ = ∗q++) ;
此版本是否比以前的版本更难读?对于经验丰富的 C 或 C++ 程序员来说并非如此。此版本在时间或空间上是否比以前的版本更高效?除了第一个调用 strlen() 的版本外,实际上并非如此;性能将是等效的,并且通常会生成相同的代码。
复制以零结尾的字符串的最有效方法通常是标准 C风格字符串复制函数:
char∗ strcpy(char∗, const char∗); // from <string.h>
对于更一般的复制,可以使用标准复制算法(§4.5,§32.5)。尽可能使用标准库工具,而不是摆弄指针和字节。标准库函数可以内联(§12.1.3),甚至可以使用专门的机器指令实现(译注:对于字符串操作,处理器有一套单的指令)。因此,在相信某些手工编写的代码优于库函数之前,您应该仔细衡量。即使确实如此,其他一些手工编写的代码+编译器组合可能不存在这种优势,而您的替代方案可能会让维护人员头疼。
11.2 自由存储(Free Store)
命名对象的生命周期由其作用域决定(§6.3.4)。但是,创建一个独立于其创建作用域而存在的对象通常很有用。例如,创建可以在从创建它们的函数返回后使用的对象是很常见的。运算符 new 创建此类对象,运算符 delete 可用于销毁它们。由 new 分配的对象被称为“位于自由存储区”(也称为“在堆上”或“在动态内存中”)。
考虑一下我们如何以桌面计算器(§10.2)的风格编写编译器。语法分析函数可能会构建一个表达式树供代码生成器使用:
struct Enode {
Token_value oper;
Enode∗ left;
Enode∗ right;
// ...
};
Enode∗ expr(bool get)
{
Enode∗ left = term(get);
for (;;) {
switch (ts.current().kind) {
case Kind::plus:
case Kind::minus:
left = new Enode {ts.current().kind,left,term(true)};
break;
default:
return left; // return node
}
}
}
在 Kind::plus 和 Kind::minus 的case 中,在自由存储中创建一个新的 Enode,并用值 {ts.current().kind,left,term(true)} 初始化。结果指针被赋值给 left,并最终从 expr() 返回。
我使用 {} 列表符号来指定参数。或者,我可以使用旧式的 () 列表符号来指定初始化程序。但是,尝试使用 = 符号来初始化使用 new 创建的对象会导致错误:
int∗ p = new int = 7; // error
如果类型有默认构造函数,我们可以省略初始化程序,但内置类型默认未初始化。例如:
auto pc = new complex<double>; // the complex is initialized to {0,0}
auto pi = new int; // int 类型数未初始化
这可能会令人困惑。要确保获得默认初始化,请使用 {}。例如:
auto pc = new complex<double>{}; // the complex is initialized to {0,0}
auto pi = new int{}; // the int is initialized to 0
代码生成器可以使用 expr() 创建的 Enode 并删除它们:
void generate(Enode∗ n)
{
switch (n−>oper) {
case Kind::plus:
// use n
delete n; // 从自由存储区删除一个Enode
}
}
由 new 创建的对象在进程运行期间会一直存在,直到被 delete 明确销毁。然后,它所占用的空间可以被 new 重用。C++ 实现不保证存在“垃圾收集器”,该收集器会查找未引用的对象并让 new 可以重用它们。因此,我将假设由 new 创建的对象是使用 delete 手动释放的。
delete 运算符只能应用于 new 返回的指针或 nullptr。将 delete 应用于 nullptr 无效。
如果被删除的对象属于具有析构函数的类(§3.2.1.2、§17.2),则在释放对象的内存以供重用之前,delete 会调用该析构函数。
11.2.1 内存管理(Memory Management)
自由存储的主要问题:
• 对象泄露:人们使用 new,然后忘记删除已分配的对象。
• 不当删除(Premature deletion):人们删除了带有其他指针的对象,然后使用该其他指针。
• 删除两次:对象被删除两次,调用其析构函数(如果有)两次。
泄漏的对象可能是一个严重的问题,因为它们可能导致程序空间不足。过早删除几乎总是一个严重的问题,因为指向“已删除对象”的指针不再指向有效对象(因此读取它可能会产生不良结果),并且可能确实指向已被另一个对象重用的内存(因此写入它可能会损坏不相关的对象)。考虑这个非常糟糕的代码示例:
int∗ p1 = new int{99};
int∗ p2 = p1; // 潜在麻烦
delete p1; // 现在p2 未指向有效对象
p1 = nullptr; // 给出错误的安全意义
char∗ p3 = new char{'x'}; // p3 现在可能指向由p2指向的内存
∗p2 = 999; //可能引起麻烦
cout << ∗p3 << '\n'; // 可能不会打印 x
删除两次会起发问题,因为资源管理器通常无法跟踪哪些代码拥有资源。考虑:
void sloppy() // 非常糟糕的代码
{
int∗ p = new int[1000]; // 主张内存
// ... use *p ...
delete[] p; // 释放内存
// ... wait a while ...
delete[] p; // 但sloppy()并不拥有 *p
}
在第二个 delete[] 时,∗p 指向的内存可能已被重新分配用于其他用途,并且分配器可能已损坏。在该示例中,将 int 替换为 string ,我们将看到 string 的析构函数尝试读取已重新分配且可能被其他代码覆盖的内存,并使用其读取的内容尝试删除内存。一般而言,删除两次是未定义的行为,结果是不可预测的,并且通常是灾难性的。
人们犯这些错误的原因通常不是恶意的,甚至通常也不是简单的粗心大意;在大型程序中始终如一地释放每个已分配的对象确实很困难(一次且在计算的正确时间点)。首先,对程序局部部分的分析不会检测到这些问题,因为错误通常涉及多个独立部分。
作为使用“裸”new和删除的替代方案,我可以推荐两种通用的资源管理方法来避免此类问题:
[1] 如果没有必要,不要将对象放在自由存储中;最好使用有作用域的变量。
[2] 在自由存储中构造对象时,将其指针放入管理器对象(有时称为句柄)中,并使用会销毁它的析构函数。这样做的例子包括字符串、向量和所有其他标准库容器、unique_ptr(§5.2.1,§34.3.1)和 shared_ptr(§5.2.1,§34.3.2)。尽可能让该管理器对象成为有作用域的变量。通过使用移动语义(§3.3,§17.5.2)从函数返回表示为管理器对象的大对象,可以消除许多传统的自由存储用法。
该规则 [2] 通常被称为 RAII(“资源获取即初始化”;§5.2,§13.3),是避免资源泄漏和使用异常进行错误处理变得简单和安全的基本技术。
标准库vector是这些技术的一个例子:
void f(const string& s)
{
vector<char> v;
for (auto c : s)
v.push_back(c);
// ...
}
向量将其元素保存在自由存储空间中,但它自己处理所有分配和释放。在此示例中,push_back() 执行消息以获取其元素的空间,并删除不再需要的元素以释放空间。但是,vector的用户不需要了解这些实现细节,只需依赖vector不泄漏即可。
计算器示例中的 Token_stream 是一个更简单的例子(§10.2.2)。在那里,用户可以使用 new 并将结果指针交给 Token_stream 进行管理:
Token_stream ts{new istringstream{some_string}};
我们不需要使用自由存储来从函数中获取大对象。例如:
string reverse(const string& s)
{
string ss;
for (int i=s.size()−1; 0<=i; −−i)
ss.push_back(s[i]);
return ss;
}
和vector一样,字符串实际上是其元素的句柄。因此,我们只需将 ss 移出 reverse() 即可,而不是复制任何元素(§3.3.2)。
资源管理“智能指针”(例如 unique_ptr 和 smart_ptr )是这些思想的另一个例子(§5.2.1,§34.3.1)。例如:
void f(int n)
{
int∗ p1 = new int[n]; // potential trouble
unique_ptr<int[]> p2 {new int[n]}; // ...
if (n%2) throw runtime_error("odd");
delete[] p1; // we may nev er get here
}
对于 f(3),p1 指向的内存被泄漏,但 p2 指向的内存被正确且隐式地释放。
我对使用 new 和 delete 的经验法则是“无裸new”;也就是说,new 属于构造函数和类似操作,delete 属于析构函数,它们一起提供了一种连贯的内存管理策略。此外,new 经常用于资源句柄的参数中。
如果所有其他方法都失败了(例如,如果有人拥有大量旧代码,其中大量不规范地使用了 new),则C++ 会为垃圾收集器提供一个标准接口(§34.5)。
11.2.2 数组 (Arrays)
数组也可以使用new创建。例如:
char∗ save_string(const char∗ p)
{
char∗ s = new char[strlen(p)+1];
strcpy(s,p); // copy from p to s
return s;
}
int main(int argc, char∗ argv[])
{
if (argc < 2) exit(1);
char∗ p = save_string(argv[1]);
// ...
delete[] p;
}
“普通”运算符 delete 用于删除单个对象;delete[] 用于删除数组。
除非您确实必须直接使用 char∗,否则可以使用标准库字符串来简化 save_string():
string save_string(const char∗ p)
{
return string{p};
}
int main(int argc, char∗ argv[])
{
if (argc < 2) exit(1);
string s = save_string(argv[1]);
// ...
}
特别是,new[] 和 delete[] 消失了。
要释放 new 分配的空间,delete 和 delete[] 必须能够确定分配的对象的大小。这意味着使用 new 的标准实现分配的对象将比静态对象占用更多的空间。至少需要空间来保存对象的大小。通常每个分配使用两个或更多字来进行自由存储管理。大多数现代机器使用 8 字节字。当我们分配许多对象或大对象时,这种开销并不大,但如果我们在自由存储上分配大量小对象(例如 int 或 Points),则可能会产生影响。
请注意,vector(§4.4.1,§31.4)是一个真正的对象,因此可以使用普通的 new 和 delete 进行分配和释放。例如:
void f(int n)
{
vector<int>∗ p = new vector<int>(n); // 单个对象
int∗ q = new int[n]; // array
// ...
delete p;
delete[] q;
}
delete[] 运算符只能应用于指向由 new 数组返回的数组的指针或空指针(§7.2.2)。将 delete[] 应用于空指针无效。
但是,不要使用 new 来创建局部对象。例如:
void f1()
{
X∗ p =new X;
// ... use *p ...
delete p;
}
这很冗长、低效且容易出错(§13.3)。特别是,在删除之前返回或抛出异常将导致内存泄漏(除非添加更多代码)。相反,请使用局部变量:
void f2()
{
X x;
// ... use x ...
}
退出 f2 时局部变量 x 被隐式销毁。
11.2.3 获得内存空间
自由存储运算符 new、delete、new[] 和 delete[] 是使用 <new> 标头中提供的函数实现的:
void∗ operator new(siz e_t); // 为单个对象分配空间
void operator delete(void∗ p); // if (p) 则释放使用 operator new() 分配的空间
void∗ operator new[](siz e_t); // 为数组分配空间
void operator delete[](void∗ p); // if (p) 则释放使用operator new() 分配的空间
当operator new需要为一个对象分配空间时,它会调用operator new()来分配合适数量的字节。同样,当operator new需要为一个数组分配空间时,它会调用operator new[]()。
operator new() 和 operator new[]() 的标准实现不会初始化返回的内存。
分配和释放函数处理的是无类型和未初始化的内存(通常称为“原内存(raw memory)”(译注:指没有与编程语言对象关联的内存,保持内存的原样)),而不是类型化对象。因此,它们接受 void∗ 类型的参数或返回值。运算符 new 和 delete 处理此无类型内存层和类型化对象层之间的映射。
当 new 找不到要分配的存储时会发生什么?默认情况下,分配器会抛出标准库 bad_alloc 异常(有关替代方法,请参阅 §11.2.4.1)。例如:
void f()
{
vector<char∗> v;
try {
for (;;) {
char ∗ p = new char[10000]; // acquire some memory
v.push_back(p); //make sure the new memory is referenced
p[0] = 'x'; // use the new memor y
}
}
catch(bad_alloc) {
cerr << "Memory exhausted!\n";
}
}
无论我们有多少可用内存,这最终都会调用 bad_alloc 处理程序。请注意:当物理主内存用尽时,不能保证抛出 new 运算符异常。因此,在具有虚拟内存的系统上,此程序可能会消耗大量磁盘空间,并且需要很长时间才能抛出异常。
我们可以指定当内存耗尽时 new 应该做什么;参见§30.4.1.3。
除了 <new> 中定义的函数外,用户还可以为特定类定义运算符 new() 等(§19.2.5)。根据通常的作用域规则,类成员运算符 new() 等优先于 <new> 中的成员。
11.2.4 重载new
默认情况下,运算符 new 在自由存储中创建其对象。如果我们想将对象分配到其他地方怎么办?考虑一个简单的类:
class X {
public:
X(int);
// ...
};
我们可以通过提供一个带有额外参数的分配器函数(§11.2.3)来将对象置放在任何地方,然后在使用 new 时提供这些额外的参数:
void∗ operator new(siz e_t, void∗ p) { return p; } // 显式置放运算符
void∗ buf = reinterpret_cast<void∗>(0xF00F); // 重要地址
X∗ p2 = new(buf) X; // 在buf处构造一个X;
// 调用: operator new(sizeof(X),buf)
由于这种用法,为运算符 new() 提供额外参数的 new(buf) X 语法被称为置放语法(placement syntax)。请注意,每个运算符 new() 都将大小作为其第一个参数,并且分配的对象的大小是隐式提供的(§19.2.5)。new 运算符使用的运算符 new() 由通常的参数匹配规则(§12.3)选择;每个运算符 new() 都有一个 size_t 作为其第一个参数。
“置放”运算符 new() 是此类分配器中最简单的一个。它在标准标头 <new> 中定义:
void∗ operator new (siz e_t sz, void∗ p) noexcept; // 将大小为sz的对像置于p
void∗ operator new[](siz e_t sz, void∗ p) noexcept; //将大小为sz的对像置于p
void operator delete (void∗ p, void∗) noexcept; // if (p) 则使 *p 无效
void operator delete[](void∗ p, void∗) noexcept; // if (p) 则使用*p 无效
“置放删除”运算符不执行任何操作(除了可能通知垃圾收集器已删除的指针不再是安全派生的(§34.5))。
置放 new 构造也可用于从特定区域分配内存:
class Arena {
public:
virtual void∗ alloc(size_t) =0;
virtual void free(void∗) =0;
// ...
};
void∗ operator new(siz e_t sz, Arena∗ a)
{
return a−>alloc(sz);
}
现在,可以根据需要从不同的 Arena分配任意类型的对象。例如:
extern Arena∗ Persistent;
extern Arena∗ Shared;
void g(int i)
{
X∗ p = new(Persistent) X(i); // X in persistent storage
X∗ q = new(Shared) X(i); // X in shared memory
// ...
}
将对象置于不受标准自由存储管理器(直接)控制的区域意味着销毁对象时需要小心谨慎。基本机制是显式调用析构函数:
void destroy(X∗ p, Arena∗ a)
{
p−>˜X(); // call destructor
a−>free(p); // free memory
}
请注意,除了资源管理类的实现之外,应避免显式调用析构函数。甚至大多数资源句柄都可以使用 new 和 delete 编写。但是,如果不使用显式的析构函数调用,就很难实现一个类似于标准库向量(§4.4.1,§31.3.3)的高效通用容器。新手在显式调用析构函数之前应该三思,也应该在这样做之前咨询更有经验的同事。
有关置放new 如何与异常处理交互的示例,请参阅 §13.6.1。
对于数组的放置,没有特殊的语法。也不需要,因为可以通过置放 new 来分配任意类型。但是,可以为数组定义一个运算符 delete()(§11.2.3)。
11.2.4.1 nothrow new
在必须避免异常的程序中(§13.1.5),我们可以使用 new 和 delete 的无抛出版本。例如:
void f(int n)
{
int∗ p = new(nothrow) int[n]; // allocate n ints on the free store
if (p==nullptr) {// 无可用内存
// ...处理分配内存错误 ...
}
// ...
operator delete(nothrow,p); // deallocate *p
}
nothrow 是标准库类型 nothrow_t 的对象的名称,用于消除歧义;nothrow 和 nothrow_t 在 <new> 中声明。
实现此功能的函数位于 <new> 中:
void∗ operator new(siz e_t sz, const nothrow_t&) noexcept; // allocate sz bytes;
// return nullptr if allocation failed
void operator delete(void∗ p, const nothrow_t&) noexcept; // deallocate space allocated by new
void∗ operator new[](siz e_t sz, const nothrow_t&) noexcept; // allocate sz bytes;
// return nullptr if allocation failed
void operator delete[](void∗ p, const nothrow_t&) noexcept; // deallocate space allocated by new
如果没有足够的内存可供分配,这些 operator new 函数将返回 nullptr,而不是抛出 bad_alloc。
11.3 花括号({})列表
除了用于初始化命名变量(§6.3.5.2)之外,{} 列表还可以在许多(但不是所有)地方用作表达式。它们可以以两种形式出现:
[1] 限定类型花括号 T{...},意思是“创建一个由 T{...} 初始化的类型为 T 的对象”;§11.3.2
[2] 非限类型花括号 {...},其类型必须根据使用上下文确定(译注:而没有明确的类型限定);§11.3.3
例如:
struct S { int a, b; };
struct SS { double a, b; };
void f(S); // f() takes an S
void g(S);
void g(SS); // g() is overloaded
void h()
{
f({1,2}); // OK: call f(S{1,2})
g({1,2}); // 错: 歧义
g(S{1,2}); // OK: call g(S)
g(SS{1,2}); // OK: call g(SS)
}
就像在初始化命名变量时使用列表一样(§6.3.5),列表可以有零个、一个或多个元素。{} 列表用于构造某种类型的对象,因此元素的数量及其类型必须是构造该类型对象所需的数量。
11.3.1 实现模式
{}列表的实现模式分为三部分:
• 如果将 {} 用作构造函数参数,则实现方式与使用() 时一样。列表元素不会被复制,除非作为按值构造函数参数。
• 如果使用 {} 初始化聚合(aggregate)元素(数组或没有构造函数的类),则每个列表元素都会初始化聚合元素。列表元素不会被复制,除非作为聚合元素构造函数的按值参数。
• 如果使用{}构造一个初始化器对象,则每个列表元素都会用于初始化initializer_list底层数组的一个元素。元素通常会从始化器列表复制到我们使用它们的任何地方。
请注意,这是我们可以用来理解 {} 的语义的通用模式;只要保留含义,编译器就可以应用巧妙的优化。
考虑:
vector<double> v = {1, 2, 3.14};
标准库向量具有初始化列表构造函数(§17.3.4),因此初始化列表 {1,2,3.14} 被解释为临时构造和使用,如下所示:
const double temp[] = {double{1}, double{2}, 3.14 } ;
const initializer_list<double> tmp(temp,sizeof(temp)/sizeof(double));
vector<double> v(tmp);
也就是说,编译器会构造一个数组,其中包含转换为所需类型(此处为double)的初始化器。此数组作为initializer_list传递给vector initializer_list构造函数。然后,initializer_list构造函数将数组中的值复制到其自己的元素数据结构中。请注意,initializer_list是一个小对象(可能只有两个字),因此按值传递它是有意义的。
底层数组是不可变的,因此(在标准规则内) {} 列表的含义不可能在两次使用之间发生变化。考虑:
void f()
{
initializer_list<int> lst {1,2,3};
cout << ∗lst.begin() << '\n';
∗lst.begin() = 2; // 错 : lst 是不可变的
cout << ∗lst.begin() << '\n';
}
具体而言,如果 {} 不可变,则意味着从中获取元素的容器必须使用复制操作,而不是移动操作。
{}(及其底层数组)的生命周期由其使用作用域决定(§6.4.2)。当用于初始化类型为 initializer_list<T> 的变量时,列表的生命周期与变量的生命周期相同。当在表达式中使用时(包括作为其他类型变量的初始化器,例如 vector<T>),列表在其完整表达式的末尾被销毁。
11.3.2 限定列表(Qualified Lists)(译注:有类型限定)
初始化列表作为表达式的基本思想是,如果你可以使用
T x {v};
初始化变量 x,那么你就可以使用 T{v} 或 new T{v} 创建与表达式具有相同值的对象。使用 new 会将对象放置在自由存储中并返回指向它的指针,而“普通T{v}”会在局部作用域内创建一个临时对象(§6.4.2)。例如:
struct S { int a, b; };
void f()
{
S v {7,8}; //直接初始化变量,无类型限定
v = S{7,8}; // 用限定列表赋值 (译注:有明确类型限定,但实现上和上一句一样)
S∗ p = new S{7,8}; //使用限定列表在自由存储区构造
}
使用限定列表构造对象的规则是直接初始化的规则(§16.2.6)(译注:也就是说上例中的两种初始化本质一样)。
查看具有一个元素的限定列表初始化程序列表的一种方法是将其视为从一种类型到另一种类型的转换。例如:
template<class T>
T square(T x)
{
return x∗x;
}
void f(int i)
{
double d = square(double{i});
complex<double> z = square(complex<double>{i});
}
§11.5.1 进一步探讨了这个思想。
11.3.3 非限定初始化列表(Unqualified Lists)
当预期类型明确已知时,使用非限定初始化列表。它只能用作表达式:
• 函数参数
• 返回值
• 赋值运算符的右侧操作数(=、+=、∗= 等)(译注:非声明时赋值认为是赋值,事实无本质区。)
• 下标
例如:
int f(double d, Matrix& m)
{
int v {7}; // 初始化器 (直接初始化) (译注:非限定,没有类型限定)
//译注:实现方式为(分配栈并移入7):00007FF6A44C288D mov dword ptr [v],7
int v2 = {7}; //初始化器(复制初始化),非限定列表
//译注:实现方式为(分配栈并移入7):00007FF6A44C2895 mov dword ptr [v2],7
//译注:可以看出,上述两种方式初始化没有区别
int v3 = m[{2,3}]; //假设 m 以成对数值作为下标
v = {8}; //右赋值操作数(译注:赋值当然是右侧的)
v += {88}; //右赋值操作数(译注:这一句也是错的,事实这是运算,不是赋值)
{v} = 9; // 错: 不存在左赋值操作数
v = 7+{10}; //错:不是非赋值运算符的操作数(译注:应该用表达式而非赋值)
f({10.0}); // 函数参数
return {11}; // 返回值,非限定
}
赋值语句左侧不允许使用非受限列表的原因主要是 C++ 语法允许在复合语句(块)的该位置使用 { ,因此可读性对人类来说是一个问题,而歧义解决对编译器来说也很棘手。这不是一个无法克服的问题,但决定不朝这个方向扩展 C++。
当用作命名对象的初始化器而不使用 = (如上文中的 v)时,非限定的{}列表执行直接初始化(§16.2.6)。在所有其他情况下,它执行复制初始化(§16.2.6)。特别是,初始化器中多余的 = 限制了可以使用给定的 {}列执行的初始化集。
标准库类型 initializer_list<T> 用于处理可变长度的 {} 列表 (§12.2.3)。其最明显的用途是允许用户定义容器的初始化列表 (§3.2.1.3),但它也可以直接使用;例如:
int high_value(initializ er_list<int> val)
{
int high = numeric_traits<int>lowest();
if (val.siz e()==0) return high;
for (auto x : val)
if (x>high) high = x;
return high;
}
int v1 = high_value({1,2,3,4,5,6,7});
int v2 = high_value({−1,2,v1,4,−9,20,v1});
{} 列表是处理不同长度的同类列表的最简单方法。但是,请注意零元素可能是一种特殊情况。如果是这样,这种情况应该由默认构造函数处理(§17.3.3)。
仅当所有元素都属于同一类型时,才可以推断出{}列表的类型。例如:
auto x0 = {}; // 错(无元素类型)
auto x1 = {1}; // initializer_list<int>
auto x2 = {1,2}; // initializer_list<int>
auto x3 = {1,2,3}; // initializer_list<int>
auto x4 = {1,2.0}; // 错: 非齐次列表
不幸的是,我们无法推断普通模板参数的非限定列表的类型。例如:
template<typename T>
void f(T);
f({}); //错: 初始化类型未知
f({1}); //错: 未限定列表与 ‘‘普通过T’’不匹配
f({1,2}); //错: 未限定列表与 ‘‘普通过T’’不匹配
f({1,2,3}); //错: 未限定列表与 ‘‘普通过T’’不匹配
我说“不幸的是”是因为这是语言限制,而不是基本规则。从技术上讲,可以推断出这些 {}列表的类型为initializer_list<int>,就像我们对自动初始化器所做的那样。
类似地,我们不会推断以模板表示的容器的元素类型。例如:
template<class T>
void f2(const vector<T>&);
f2({1,2,3}); //错:不能推断T
f2({"Kona","Sidney"}); //错:不能推断T
这也是不幸的,但从语言技术的角度来看,它更容易理解:在这些调用中没有任何地方提到vector。要推断 T,编译器首先必须决定用户是否真的想要一个vector,然后查看vector的定义以查看它是否有一个接受 {1,2,3} 的构造函数。一般来说,这需要实例化vector(§26.2)。可以处理这个问题,但编译时成本可能很高,而且如果有许多重载版本的 f2(),可能会出现歧义和混淆,因此需要谨慎处理。要调用 f2(),请更具体:
f2(vector<int>{1,2,3}); // OK
f2(vector<string>{"Kona","Sidney"}); // OK
11.4 Lambda表达式
lambda 表达式,有时也称为 lambda 函数或(严格地说是错误的,但通俗地说)lambda,是定义和使用匿名函数对象的简化符号。我们可以使用简写,而不是使用operator() 定义命名类,稍后创建该类的对象,最后调用它。当我们想要将操作作为参数传递给算法时,这特别有用。在图形用户界面(和其他地方)的上下文中,此类操作通常称为回调(callbacks)。本节重点介绍 lambda 的技术方面;可以在其他地方求得使用 lambda 的示例和技术(§3.4.3,§32.4,§33.5.2)。
lambda 表达式由一系列部件组成:
• 可能为空的捕获列表,指定定义环境中的哪些名称可用于 lambda 表达式的主体,以及这些名称是复制的还是通过引用访问的。捕获列表由 [] 分隔(§11.4.3)。
• 可选参数列表,指定 lambda 表达式需要哪些参数。参数列表由 () 分隔(§11.4.4)。
• 可选可变说明符,表示 lambda 表达式的主体可以修改 lambda 的状态(即更改 lambda 的按值捕获的变量副本)(§11.4.3.4)。
• 可选 noexcept 说明符。
• 形式为 −> type 的可选返回类型声明(§11.4.4)。
• 主体,指定要执行的代码。主体由 {} 分隔(§11.4.3)。
传递参数、返回结果和指定主体的细节属于函数的细节,并将在第 12 章中介绍。函数不提供局部变量的“捕获”概念。这意味着 lambda 可以充当局部函数,即使函数不能。
11.4.1 实现模式
Lambda 表达式可以以多种方式实现,并且有一些相当有效的优化方法。但是,我发现通过将其视为定义和使用函数对象的简写来理解 lambda 的语义很有用。考虑一个相对简单的例子:
void print_modulo(const vector<int>& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
for_each(begin(v),end(v),
[&os,m](int x) { if (x%m==0) os << x << '\n'; });
}
为了理解这个含义,我们可以定义等价的函数对象:
class Modulo_print {
ostream& os; //存储捕获列表的成员
int m;
public:
Modulo_print(ostream& s, int mm) :os(s), m(mm) {} // 捕获
void operator()(int x) const
{ if (x%m==0) os << x << '\n'; }
};
捕获列表 [&os,m] 变成两个成员变量和一个用于初始化它们的构造函数。os 之前的 & 表示我们应该存储一个引用,而 m 中没有 & 表示我们应该存储一个副本。这种 & 的使用反映了它在函数参数声明中的使用。
lambda 的主体只是变成了 operator()() 的主体。由于 lambda 不返回值,因此 operator()() 为 void。默认情况下,operator()() 为 const,因此 lambda 主体不会修改捕获的变量。这是迄今为止最常见的情况。如果您想从其主体修改 lambda 的状态,可以将 lambda 声明为可变的(§11.4.3.4)。这对应于未将 operator()() 声明为 const。
从 lambda 生成的类的对象称为闭包对象(closure object)(或简称为闭包)。我们现在可以像这样编写原始函数:
void print_modulo(const vector<int>& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
for_each(begin(v),end(v),Modulo_print{os,m});
}
如果 lambda 可能通过引用捕获每个局部变量(使用捕获列表 [&]),则闭包可能会被优化为仅包含指向封闭堆栈框架的指针。
11.4.2 Lambda 的替代方案(Alternatives to Lambdas)
print_modulo() 的最终版本实际上非常有吸引力,命名非平凡操作通常是一个好主意。单独定义的类也比嵌入在某些参数列表中的 lambda 留下了更多的注释空间。
但是,许多 lambda 都很小,并且只使用一次。对于此类用途,实际等效方法涉及在其(唯一)使用之前立即定义的本地类。例如:
void print_modulo(const vector<int>& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
class Modulo_print {
ostream& os; // members to hold the capture list
int m;
public:
Modulo_print (ostream& s, int mm) :os(s), m(mm) {} // capture
void operator()(int x) const
{ if (x%m==0) os << x << '\n'; }
};
for_each(begin(v),end(v),Modulo_print{os,m});
}
相比之下,使用 lambda 的版本显然更胜一筹。如果我们真的想要一个名字,我们可以直接给 lambda 命名:
void print_modulo(const vector<int>& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
auto Modulo_print = [&os,m] (int x) { if (x%m==0) os << x << '\n'; };
for_each(begin(v),end(v),Modulo_print);
}
命名 lambda 通常是一个好主意。这样做会迫使我们更仔细地考虑操作的设计。它还简化了代码布局并允许递归(§11.4.5)。
编写 for 循环是使用带有 for_each() 的 lambda 的替代方法。考虑:
void print_modulo(const vector<int>& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
for (auto x : v)
if (x%m==0) os << x << '\n';
}
许多人会发现这个版本比任何 lambda 版本都清晰得多。但是,for_each 是一种相当特殊的算法,而 vector<int> 是一种非常具体的容器。考虑将 print_modulo() 泛化以处理任意容器:
template<class C>
void print_modulo(const C& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
for (auto x : v)
if (x%m==0) os << x << '\n';
}
此版本非常适合用于映射。C++ 范围for 语句专门用于从头到尾遍历序列的特殊情况。STL 容器使这种遍历变得简单而通用。例如,使用 for 语句遍历映射可实现深度优先遍历。我们如何进行广度优先遍历?print_modulo() 的 for 循环版本无法更改,因此我们必须将其重写为算法。例如:
template<class C>
void print_modulo(const C& v, ostream& os, int m)
// output v[i] to os if v[i]%m==0
{
breadth_first(begin(v),end(v),
[&os,m](int x) { if (x%m==0) os << x << '\n'; });
}
因此,lambda 可以用作表示为算法的广义循环/遍历构造的“主体”。使用 for_each 而不是 breadth_first 可以实现深度优先遍历。
lambda 作为遍历算法的参数的性能与等效循环的性能相同(通常相同)。我发现这在不同的实现和平台上非常一致。这意味着我们必须根据风格以及对可扩展性和可维护性的评估,在“算法加 lambda”和“带主体的 for 语句”之间做出选择。
11.4.3 捕捉(Caputure)
lambda 的主要用途是指定要作为参数传递的代码。lambda 允许以“内联”方式完成此操作,而无需命名函数(或函数对象)并在其他地方使用它。某些 lambda 不需要访问其局部环境。此类 lambda 使用空 lambda 引入器 [] 定义。例如:
void algo(vector<int>& v)
{
sort(v.begin(),v.end()); // sor t values
// ...
sort(v.begin(),v.end(),[](int x, int y) { return abs(x)<abs(y); }); //排序绝对值
// ...
}
如果我们想要访问局部名称,我们必须这样说,否则会出现错误:
void f(vector<int>& v)
{
bool sensitive = true;
// ...
sort(v.begin(),v.end(),
[](int x, int y) { return sensitive ? x<y : abs(x)<abs(y); } // 错: 不能访问 sensitive);
}
我使用了 lambda 引入符 [。这是最简单的 lambda 引入符,不允许lambda 引用调用环境中的名称。lambda 表达式的第一个字符始终是[。lambda 引入符可以采用多种形式:
• []:空捕获列表。这意味着在 lambda 主体中不能使用周围上下文中的任何本地名称。对于此类 lambda 表达式,数据是从参数或非局部变量中获取的。
• [&]:通过引用隐式捕获。可以使用所有本地名称。所有局部变量都通过引用访问。
• [=]:通过值隐式捕获。可以使用所有本地名称。所有名称都引用在 lambda 表达式调用点获取的局部变量的副本。
• [捕获列表]:显式捕获;捕获列表是要通过引用或值捕获(即存储在对象中)的局部变量名称列表。名称前面带有 & 的变量通过引用捕获。其他变量通过值捕获。捕获列表还可以包含此列表和名称后跟 ... 作为元素。
• [&,捕获列表]:通过引用隐式捕获列表中未提及名称的所有局部变量。捕获列表可以包含此列表。列出的名称前面不能加 &。捕获列表中命名的变量按值捕获。
• [=, 捕获列表]:隐式按值捕获列表中未提及名称的所有局部变量。捕获列表不能包含此内容。列出的名称前面必须加 &。捕获列表中命名的变量按引用捕获。
请注意,以 & 开头的本地名称始终通过引用捕获,而以 & 开头的局部名称始终通过值捕获。只有通过引用捕获才允许修改调用环境中的变量。
捕获列表样例(cases)用于对调用环境中的名称的使用方式进行细粒度的控制。例如:
void f(vector<int>& v)
{
bool sensitive = true;
// ...
sort(v.begin(),v.end()
[sensitive](int x, int y) { return sensitive ? x<y : abs(x)<abs(y); });
}
通过在捕获列表中提及sensitive,我们可以从 lambda 内部访问它。通过不另行指定,我们确保sensitive 的捕获是“按值”完成的;就像参数传递一样,传递副本是默认的。如果我们想“按引用”捕获sensitive,我们可以通过在捕获列表中的sensitive 之前添加一个 & 来表示:[&sensitive]。
按值捕获还是按引用捕获的选择与函数参数的选择基本相同(§12.2)。如果需要写入捕获的对象或对象很大,我们会使用引用。但是,对于 lambda,还有一个额外的担忧,即 lambda 可能会比其调用者存活得更久(§11.4.3.1)。将 lambda 传递给另一个线程时,按值捕获([=])通常是最好的:通过引用或指针访问另一个线程的栈可能最具破坏性(对性能或正确性而言),而尝试访问已终止线程的栈可能会导致极难发现的错误。
如果需要捕获可变模板(§28.6)参数,请使用...例如:
template<typename... Var>
void algo(int s, Var... v)
{
auto helper = [&s,&v...] { return s∗(h1(v...)+h2(v...)); }
// ...
}
请注意,在捕获方面很容易耍小聪明。通常,可以在捕获和参数传递之间做出选择。在这种情况下,捕获通常是输入最少的,但最容易引起混淆。
11.4.3.1 Lambda和生命周期
lambda 的寿命可能比其调用者长。如果我们将 lambda 传递给其他线程,或者调用者存储 lambda 以供以后使用,则会发生这种情况。例如:
void setup(Menu& m)
{
// ...
Point p1, p2, p3;
// compute positions of p1, p2, and p3
m.add("draw triangle",[&]{ m.draw(p1,p2,p3); }); // 可能发生灾难
// ...
}
假设 add() 操作是将 (name,action) 对添加到菜单的操作,并且 draw() 操作有意义,那么我们就剩下一个定时炸弹:setup() 完成,然后——也许几分钟后——用户按下绘制三角形按钮,lambda 尝试访问早已消失的局部变量。在这种情况下,写入通过引用捕获的变量的 lambda 会更糟糕。
如果 lambda 可能比其调用者存活更久,我们必须确保所有局部信息(如果有)都已复制到闭包对象中,并且通过返回机制(§12.1.4)或通过合适的参数返回值。对于setup() 示例,这很容易做到:
m.add("draw triangle",[=]{ m.draw(p1,p2,p3); });
将捕获列表视为闭包对象的初始化列表,将 [=] 和 [&] 视为简写符号(§11.4.1)。
11.4.3.2 命名空间名称
我们不需要“捕获”命名空间变量(包括全局变量),因为它们始终是可访问的(只要它们在作用域内)。例如:
template<typename U, typename V>
ostream& operator<<(ostream& os, const pair<U,V>& p)
{
return os << '{' << p.first << ',' << p.second << '}';
}
void print_all(const map<string,int>& m, const string& label)
{
cout << label << ":\n{\n";
for_each(m.begin(),m.end(),
[](const pair<string,int>& p) { cout << p << '\n'; }
);
cout << "}\n";
}
这里,我们不需要捕获 cout 或 pair 的输出运算符。
11.4.3.3 Lambda 和 this指针
我们如何从成员函数中使用的 lambda 访问类对象的成员?我们可以通过将其添加到捕获列表中,将类成员包含在可能捕获的名称集合中。当我们想在成员函数的实现中使用 lambda 时,就会使用这种方法。例如,我们可能有一个用于构建请求和检索结果的类:
class Request {
function<map<string,string>(const map<string,string>&)> oper; // operation
map<string,string> values; // arguments
map<string,string> results; // targets
public:
Request(const string& s); // parse and store request
void execute()
{
[this]() { results=oper(values); } // do oper to values yielding results
}
};
成员始终通过引用捕获。也就是说,[this] 意味着通过 this 访问成员,而不是复制到 lambda 中。不幸的是,[this] 和 [=] 不兼容。这意味着不谨慎使用可能会导致多线程程序中的竞争条件(§42.4.6)。
11.4.3.4 mutable Lambda
通常,我们不想修改函数对象(闭包)的状态,因此默认情况下我们不能这样做。也就是说,生成的函数对象(§11.4.1)的 operator()() 是一个 const 成员函数。如果我们想修改状态(而不是修改通过引用捕获的某些变量的状态;§11.4.3),我们可以将 lambda 声明为可变的。例如:
void algo(vector<int>& v)
{
int count = v.siz e();
std::generate(v.begin(),v.end(),
[count]()mutable{ return −−count; });
}
−−count 减少了闭包中存储的 v 的大小的副本。
11.4.4 调用和返回(Call and Return)
向 lambda 传递参数的规则与函数的规则相同(§12.2),返回结果的规则也相同(§12.1.4)。事实上,除了捕获规则(§11.4.3)之外,lambda 的大多数规则都借鉴了函数和类的规则。但是,应该注意两个不规则之处:
[1] 如果 lambda 表达式不接受任何参数,则可以省略参数列表。因此,最小的 lambda 表达式是 []{}。
[2] lambda 表达式的返回类型可以从其主体中推导出来。不幸的是,函数也无法做到这一点。
如果 lambda 主体没有返回语句,则 lambda 的返回类型为 void。如果 lambda 主体仅包含一个返回语句,则 lambda 的返回类型为返回表达式的类型。如果两者都不是,我们必须明确提供返回类型。例如:
void g(double y)
{
[&]{ f(y); } // 返回类型是void
auto z1 = [=](int x){ return x+y; } // 返回类型是double
auto z2 = [=,y]{ if (y) return 1; else return 2; } // error : body too complicated
// for retur n type deduction
auto z3 =[y]() { return 1 : 2; } // return type is int
auto z4 = [=,y]()−>int { if (y) return 1; else return 2; } // OK: explicit return type
}
当使用后缀返回类型表示法时,我们不能省略参数列表。
11.4.5 Lambda 的类型(The Type of a Lambda)
为了实现 lambda 表达式的优化版本,lambda 表达式的类型未定义。但是,它被定义为 §11.4.1 中所示的样式中的函数对象类型。此类型称为闭包类型,是 lambda 独有的,因此没有两个 lambda 具有相同的类型。如果两个 lambda 具有相同的类型,模板实例化机制可能会混淆。lambda 是一种局部类类型,具有构造函数和 const 成员函数 operator()()。除了使用 lambda 作为参数之外,我们还可以使用它来初始化声明为 auto 或 std::function<R(AL)> 的变量,其中 R 是 lambda 的返回类型,AL 是其参数类型列表(§33.5.3)。
例如,我可能会尝试编写一个 lambda 来转换 C 样式字符串中的字符:
auto rev = [&rev](char∗ b, char∗ e)
{ if (1<e−b) { swap(∗b,∗−−e); rev(++b,e); } }; // error
但是,这是不可能的,因为在推断出自动变量的类型之前,我无法使用它。相反,我可以引入一个名称,然后使用它:
void f(string& s1, string& s2)
{
function<void(char∗ b, char∗ e)> rev =
[&](char∗ b, char∗ e) { if (1<e−b) { swap(∗b,∗−−e); rev(++b,e); } };
rev(&s1[0],&s1[0]+s1.siz e());
rev(&s2[0],&s2[0]+s2.siz e());
}
现在,在使用之前已经指定了 rev 的类型。
如果我们只想命名一个 lambda,而不是递归地使用它,auto 可以简化事情:
void g(vector<string>& vs1, vector<string>& vs2)
{
auto rev = [&](char∗ b, char∗ e) { while (1<e−b) swap(∗b++,∗−−e); };
rev(&s1[0],&s1[0]+s1.siz e());
rev(&s2[0],&s2[0]+s2.siz e());
}
可以将未捕获任何内容的 lambda 分配给适当类型的函数指针。例如:
double (∗p1)(double) = [](double a) { return sqrt(a); };
double (∗p2)(double) = [&](double a) { return sqrt(a); }; // 错 : lambda captures
double (∗p3)(int) = [](int a) { return sqrt(a); }; // 错:参数类型不匹配
11.5 显式类型转换(Explicit Type Conversion)
有时,我们必须将一种类型的值转换为另一种类型的值。许多(可能太多了)此类转换都是根据语言规则(§2.2.2,§10.5)隐式完成的。例如:
double d = 1234567890; // 整数转到浮点数
int i = d; //浮点转换到整数
在其他情况下,我们必须显式转换。
由于逻辑和历史原因,C++ 提供了不同便利性和安全性的显式类型转换操作:
• 构造器,使用 {} 符号,提供类型安全的新值构造 (§11.5.1)
• 命名转换(named conversions),提供各种程度的“变态”转换:
(1) const_cast 用于获取对声明为 const 的内容的写访问权 (§7.5)
(2) static_cast 用于定义明确的隐式转换 (§11.5.2)
(3) reinterpret_cast 用于更改位模式的含义 (§11.5.2)
(4) dynamic_cast 用于动态地检查类层级的导向 (§22.2.1)
• C 风格强制转换,提供任何命名转换及其某些组合(§11.5.3)
• 函数转换法(functional notation),为 C 风格强制转换提供不同的转换函数 (§11.5.4)
我已经按照我的喜好和使用安全性的顺序排列了这些转换。
除了 {} 构造符号外,我不能说我喜欢其中任何一个,但至少 dynamic_cast 是运行时检查的。对于两个标量数值类型之间的转换,我倾向于使用自制的(homemade)显式转换函数 narrow_cast,其中被转换的值可能会窄化:
template<class Target, class Source>
Targ et narrow_cast(Source v)
{
auto r = static_cast<Target>(v); // 转换为目标类型
if (static_cast<Source>(r)!=v)
throw runtime_error("narrow_cast<>() failed");
return r;
}
也就是说,如果我可以将值转换为目标类型,将结果转换回源类型,并返回原始值,那么我对结果就很满意。这是语言应用于以 {} 初始化的值的规则的泛化(generalization)(§6.3.5.2)。例如:
void test(double d, int i, char∗ p)
{
auto c1 = narrow_cast<char>(64);
auto c2 = narrow_cast<char>(−64); // 若char是无符号则仍出异常
auto c3 = narrow_cast<char>(264); 若char是8位且有符号则仍出异常
auto d1 = narrow_cast<double>(1/3.0F); // OK
auto f1 = narrow_cast<float>(1/3.0); // 可能扔异常
auto c4 = narrow_cast<char>(i); //可能扔异常
auto f2 = narrow_cast<float>(d); //可能扔异常
auto p1 = narrow_cast<char∗>(i); // 编译时错误
auto i1 = narrow_cast<int>(p); // 编译时错误
auto d2 = narrow_cast<double>(i); //可能扔异常(但也可能不仍)
auto i2 = narrow_cast<int>(d); //可能扔异常
}
根据您对浮点数的使用情况,使用范围测试进行浮点转换可能是值得的,而不是 !=。使用特化(§25.3.4.1)或类型特征(§35.4.1)可以轻松完成。
11.5.1 构造器类型转换(Construction)
从一个值 e 构造类型 T 的值可以用符号 T{e} (§iso.8.5.4) 来表示。例如:
auto d1 = double{2}; // d1==2.0
double d2 {double{2}/4}; // d1==0.5
T{v} 符号的吸引力之一在于它只执行“表现良好”的转换。例如:
void f(int);
void f(double);
void g(int i, double d)
{
f(i); //call f(int)
f(double{i}); //错: {}不进行int 至浮点的转换
f(d); //call f(double)
f(int{d}); //error : {} doesn’t truncate
f(static_cast<int>(d)); // call f(int) 且截断值
f(round(d)); //call f(double) 且舍入
f(static_cast<int>(lround(d))); // call f(int) 且舍入
// 若 d 值越过int 范围 ,此调用会产生截断
}
我认为浮点数的截断(例如,将 7.9 截断为 7)“表现不佳”,因此在需要时必须明确说明是一件好事。如果需要舍入,我们可以使用标准库函数 round();它执行“传统的 4/5 舍入”,例如将 7.9 截断为 8 和将 7.4 截断为 7。
有时,{} 构造不允许将 int 转换为 double,这令人感到惊讶,但如果(这种情况并不罕见)int 的大小与 double 的大小相同,则某些此类转换必定会丢失信息。考虑:
static_assert(sizeof(int)==sizeof(double),"unexpected sizes");
int x = numeric_limits<int>::max(); //最大可能的整数
double d = x;
int y = x;
我们不会得到 x==y。但是,我们仍然可以用可以精确表示的整数文字量初始化double 数(译注:用整数变量初始化double却不可)。例如:
double d { 1234 }; // fine
使用所需类型的显式限定不会引发行为不当的转换。例如:
void g2(char∗ p)
{
int x = int{p}; // error : no char* to int conversion
using Pint = int∗;
int∗ p2 = Pint{p}; // error : no char* to int* conversion
// ...
}
对于 T{v},“合理表现良好”的定义是具有从 v 到 T 的“非窄化”转换(§10.5)或具有适合 T 的构造函数(§17.3)。
构造器符号 T{} 用于表达类型 T 的默认值。例如:
template<class T> void f(const T&);
void g3()
{
f(int{}); //默认int 值
f(complex<double>{}); // 默认complex 值
// ...
}
显式使用内置类型的构造函数的值是 0,转换为该类型(§6.3.5)。因此,int{} 是 0 的另一种写法。对于用户定义类型 T,T{} 由默认构造函数(§3.2.1.1,§17.6)(如果有)定义,否则由每个成员的默认构造 MT{} 定义。
显式构造器的未命名对象是临时对象,并且(除非绑定到引用)它们的生命周期仅限于使用它们的完整表达式(§6.4.2)。在这方面,它们不同于使用 new 创建的未命名对象(§11.2)。
11.5.2 命名类型转换(Named Casts)
有些类型转换表现不佳或不易进行类型检查;它们不是从一组定义明确的参数值中简单地构造值。例如:
IO_device∗ d1 = reinterpret_cast<IO_device∗>(0Xff00); // device at 0Xff00
编译器无法知道整数 0Xff00 是否是有效地址(I/O 设备寄存器的地址)。因此,转换的正确性完全掌握在程序员手中。显式类型转换(通常称为强制类型转换)有时是必不可少的。然而,传统上它被严重滥用,是错误的主要来源。
需要显式类型转换的另一个典型示例是处理“原内存(raw memory)”,即保存或将保存编译器未知类型的对象的内存。例如,内存分配器(如operator new();§11.2.3)可能返回指向新分配内存的 void∗:
void∗ my_allocator(siz e_t);
void f()
{
int∗ p = static_cast<int∗>(my_allocator(100)); // 用于int 的新分配内存
// ...
}
编译器不知道 void∗ 指向的对象的类型(译注:所以需要程序员告诉编译器,即强制转换之目的)。
命名强制类型转换背后的基本思想是使类型转换更加明显,并允许程序员表达强制类型转换的意图:
• static_cast 在相关类型之间进行转换,例如将一个指针类型转换为同一类层次结构中的另一个指针类型、将一个整型转换为枚举类型,或将一个浮点类型转换为一个整型类型。它还执行由构造函数(§16.2.6,§18.3.3,§iso.5.2.9)和转换运算符(§18.4)定义的转换。
• reinterpret_cast 处理不相关类型之间的转换,例如将整数转换为指针或将指针转换为不相关的指针类型(§iso.5.2.10)。
• const_cast 在仅在 const 和 volatile 限定符方面不同的类型之间进行转换(§iso.5.2.11)。
• dynamic_cast 对指针和引到类层次的转换做运行时检查(§22.2.1,§iso.5.2.7)。
这些命名转换之间的区别允许编译器应用一些最小的类型检查,并使程序员更容易找到表示为 reinterpret_cast 的更危险的转换。一些 static_cast 是可移植的,但很少有 reinterpret_cast 是可移植的。reinterpret_cast 几乎没有任何保证,但通常它会生成一个新类型的值,该值具有与其参数相同的位模式。如果目标至少具有与原始值一样多的位数,我们可以将结果用 reinterpret_cast 转回其原始类型并使用它。只有将 reinterpret_cast 的结果转换回精确的原始类型,才能保证其结果可用。请注意,reinterpret_cast 是必须用于指向函数的指针的转换类型(§12.5)。考虑:
char x = 'a';
int∗ p1 = &x; // 错 : 不存在char* 到 int* 的隐式转换
int∗ p2 = static_cast<int∗>(&x); // 错: 不存在char* 到 int* 的隐式转换
int∗ p3 = reinterpret_cast<int∗>(&x); // OK: 但后果果自负
struct B { /* ... */ };
struct D : B { /* ... */ }; // 见 §3.2.2 and §20.5.2
B∗ pb = new D; // OK:从D* 到 B* 的隐式转换
D∗ pd = pb; // 错:不存在从 B* 到 D*的隐式转换
D∗ pd = static_cast<D∗>(pb); //OK
类指针之间的转换和类引用类型之间的转换在§22.2 中讨论。
如果您想使用显式类型转换,请花点时间考虑一下它是否真的有必要。在 C++ 中,当 C 需要显式类型转换时,大多数情况下都不需要显式类型转换(§1.3.3),而且在早期版本的 C++ 中也有很多需要显式类型转换的情况(§1.3.2、§44.2.3)。在许多程序中,可以完全避免显式类型转换;在其他程序中,可以将其使用局限于少数例程。
11.5.3 C风格类型转换
C++ 从 C 中继承了符号 (T)e,它执行任何可以表示为static_casts,reinterpret_casts,const_casts 组合的转换,以从表达式 e (§44.2.3) 生成类型 T 的值。不幸的是,C 风格转换还可以从指向类的指针转换为指向该类的私有基的指针。永远不要这样做,如果您不小心这样做了,希望编译器发出警告。这种 C 风格转换比命名转换运算符危险得多,因为在大型程序中这种符号更难发现,并且程序员想要的转换类型并不明确。也就是说,(T)e 可能在相关类型之间进行可移植转换,在无关类型之间进行不可移植转换,或者从指针类型中删除 const 修饰符。如果不知道 T 和 e 的确切类型,您就无法分辨。
11.5.4 函数风格类型转换
从值 e 构造类型 T 的值可以用函数符号 T(e) 来表示。例如:
void f(double d)
{
int i = int(d); // truncate d
complex z = complex(d); // 从d 构造一个 complex 对象
// ...
}
T(e) 构造有时被称为函数式强制类型转换。不幸的是,对于内置类型 T,T(e) 等同于 (T)e (§11.5.3)。这意味着对于许多内置类型来说,T(e) 并不安全。
void f(double d, char∗ p)
{
int a = int(d); // 截断
int b = int(p); // 不可移植
// ...
}
即使将较长的整数类型显式转换为较短的整数类型(例如将 long 转换为 char),也会导致不可移植的实现定义行为。
对于行为良好的构造器,优先使用 T{v} 转换,对于其他转换,优先使用命名转换(例如,static_cast)。
11.6 建议
[1] 优先使用前缀 ++ 而不是后缀 ++;§11.1.4。
[2] 使用资源句柄来避免泄漏、误删和删两次;§11.2.1。
[3] 如无必要,请勿将对象放在自由存储空间中;优先使用作用域变量;§11.2.1。
[4] 避免使用“裸新建”和“裸删除”;§11.2.1。
[5] 使用 RAII;§11.2.1。
[6] 如果操作需要注释,则优先使用命名函数对象而不是 lambda;§11.4.2。
[7] 如果操作通常有用,则优先使用命名函数对象而不是 lambda;§11.4.2。
[8] 保持 lambda 简短;§11.4.2。
[9] 为了可维护性和正确性,请谨慎使用引用捕获; §11.4.3.1.
[10] 让编译器推断 lambda 的返回类型;§11.4.4.
[11] 使用 T{e} 符号进行构造;§11.5.1.
[12] 避免显式类型转换(强制类型转换);§11.5.
[13] 当需要显式类型转换时,最好使用命名强制类型转换;§11.5.
[14] 考虑使用运行时检查强制类型转换,例如 narrow_cast<>(),用于数值类型之间的转换;§11.5.
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup