本章将简略地呈现C++的文法、内存模型和计算模型,和把代码组织成程序的基本机制。
这部分语言特性所支持的编程风格常见于 C,
也被称为过程式编程(procedural programming)。
C++是种编译型语言。要运行一个程序,其源文本需要通过编译器处理,
生成一些目标文件,再经链接器组合后给出一个可执行文件。
一个典型的C++程序要使用多个源代码文件(通常简称为源文件(source files))生成。
可执行文件针对特定的 硬件/操作系统 组合而生成,不可移植,比方说,
不能从 Mac系统 拿到 Windows系统 上运行。
当我们说到 C++ 程序的可移植性时,一般是在说源代码的可移植性;
就是说,这份源码可以在多个系统上被编译并运行。
ISO C++ 标准定义了两类东西:
char 和 int)for-语句 和 while-语句)vector 和 map)<< 和 getline())标准库组件完全是普通的 C++ 代码,由具体的 C++ 实现提供。
换句话说, C++ 标准库可以且确实是用 C++ 自身
(包括少量的机器语言代码,用于线程上下文切换等功能)实现的。
这意味着,对多数需求严苛的系统编程而言,C++ 具有足够的表达能力和效率。
C++ 是静态类型语言。
就是说,任何一个东西(例如对象、值、名称和表达式)被用到的时候,
编译器都必须已经知晓其类型。对象的类型确定了可施加操作的集合。
最小的 C++ 程序是
int main(){} // 最小的 C++ 程序
这定义了一个名为main的函数,它不接受参数,也不执行任何操作。
花括号{},在 C++ 中表示结组。在此处,它标示了函数体的起止点。
双斜杠,//,发起一个注释并延续至行尾。
注释是供人阅读的,编译器忽略所有注释。
每个 C++ 程序必须有且只有一个名为main()的全局函数。
整个程序由该函数开始执行。
如果main()返回了任何int整数值,该值被返回给“系统”。
如果没有返回值,系统会接收到一个值,表示执行成功。
main()返回的非零值表示出错。并非所有操作系统和执行环境都会用到这个返回值。
基于 Linux/Unix 的环境用它,而基于 Windows 的环境几乎不用。
一般来说,程序会有些输出,以下程序写出Hello, World!:
#include <iostream>
int main()
{
std::cout << "Hello, World!\n";
}
#include <iostream>这行告诉编译器,
在iostream中查找标准输入输出流相关的声明,并把它们*包含(include)*进来。
如果缺少了这些声明,以下表达式
std::cout << "Hello, World!\n"
就无效了。操作符<<(“输向”)把它的第二个参数写入第一个。
在本例中,字符串文本(string literal)"Hello, World!\n"
被写入到标准输出流std::cout上。
字符串文本是由双引号包围的一连串字符。
在字符串文本中,反斜杠\后跟一字符共同表示一个“特殊字符”。
在这里,\n是换行符,所以写出的字符是Hello, World!紧跟一个换行。
std::指出名称cout需要在标准库(standard-library)命名空间(§3.4)中查找。
我在谈论标准功能的时候通常略掉std::;
§3.4 里展示了一个方法,无需显式指出命名空间名,就可以让其中的名称可见。
一般来说,所有的可执行代码都会被置于函数中,
再由main()函数直接或者间接地调用,例如:
#include <iostream> // 包含(“引入”)I/O 流程序库的声明
using namespace std; // 让std里的名称无需使用 std:: 就可见 (§3.4)
double square(double x) // 计算一个双精度浮点数的平方
{
return x*x;
}
void print_square(double x)
{
cout << "the square of " << x << " is " << square(x) << "\n";
}
int main()
{
print_square(1.234); // 打印:the square of 1.234 is 1.52276
}
如果“返回类型”为void,表示该函数不返回任何值。
如果在 C++ 程序里要做一件事,主要的方式是调用某个函数去执行它。
定义函数就是指定某个操作怎样被执行。
除非事先声明过,否则函数无法被调用。
函数声明给出了该函数的名称、返回值类型(如果有的话)、
以及调用它时必须提供的参数数量和类型。例如:
Elem* next_elem(); // 不接受参数;返回指向 Elem 的指针(一个 Elem*)
void exit(int); // int 参数;无返回值
double sqrt(double); // double 参数;返回一个 double
在函数声明中,返回值出现在名称前面,参数类型被括在小括号里,跟在名称后面。
参数传递的语法跟初始化(§3.6.1)的语法一致。
就是说,会检查参数类型,在必要的时候会对参数进行隐式类型转换(§1.4)。例如:
double s2 = sqrt(2); // 以 double 类型参数 double{2} 调用 sqrt()
double s3 = sqrt("three"); // 错误:sqrt() 要求参数类型为 double
编译期类型检查和类型转换的价值至关重要。
函数声明中可以包含参数名。
这对程序的读者有益,但除非该声明同时也是函数定义,编译器将忽略这些参数名。例如:
double sqrt(double d); // 返回 d 的平方根
double square(double); // 返回参数的平方
函数的类型包含返回值类型和参数类型序列。例如:
double get(const vector<double>& vec, int index); // 类型: double(const vector<double>&,int)
函数可以作为类(§2.3, §4.2.1)的成员。
对于成员函数 member function来说,类名也是该函数类型的组成部分。例如:
char& String::operator[](int index); // 类型: char& String::(int)
我们希望代码可读性好,因为这是可维护性的前提。
而可读性的前提是,将运算任务拆分成有意义的小块(体现为函数和类),并为其命名。
如同类型(内置类型和用户定义类型)为数据提供基本的词汇表那样,函数为运算提供词汇表。
C++ 标准算法(例如 find, sort 和 iota)开了个好头。
然后,我们可以编排函数,把通用或专用的作业表示成更大型的运算任务。
代码的报错数量跟代码的量和复杂度有关。
这两个问题都可以通过更多、更简短的函数来解决。
使用函数完成一项特定任务,总是能搭救我们,避免在其它代码中再敲入一段特定的代码;
创建函数会促使我们给一个行为命名并给它单独创建文档。
如果两个函数以同样的名称定义,但使用不同的参数类型,编译器将选用最恰当的函数调用。
例如:
void print(int); // 接收一个整型参数
void print(double); // 接收一个浮点型参数
void print(string); // 接收一个 string 参数
void user()
{
print(42); // 调用 print(int)
print(9.65); // 调用 print(double)
print("Barcelona"); // 调用 print(string)
}
如果有两个备选函数可供调用,但都不优于另一个,
此次调用将被判定为具有二义性,编译器会报错。例如:
void print(int,double);
void print(double,int);
void user2()
{
print(0,0); // 错误:二义性
}
多个同名函数的定义被称为函数重载(function overloading),
是泛型编程(§7.2)的重要组成部分。
当函数被重载,所有同名函数应当具有相同的语义。
print()函数就是这样的示例;每个print()函数都打印其参数。
所有的名称、表达式都具有类型,以确定可执行的运算。例如,声明
int inch;
指出,inch的类型是int;也就是说,inch是个整数变量。
声明(declaration) 是把一个实体引入程序的语句。它规定了这个实体的类型:
C++ 提供了一小撮基本类型,既然我并非组织学家,也就不把它们全部逐一列出了。
如果需要完整列表,可以求助参考资料,
例如网络上的 [Stroustrup,2013] 或者 [Cppreference]。
举几个例子:
bool // 布尔类型,可能的值是 true 和 false
char // 字符,例如:'a'、'z' 和 '9'
int // 整数,例如:-273、42、和 1066
double // 双精度浮点数,例如:-273.15、3.14 和 6.626e-34
unsigned // 非负整数,例如:0、1、和 999 (用于逻辑位运算)
每个基本类型都直接对应于硬件结构,其容量是固定的,
该容量决定了其中存储的值的取值范围。
char类型变量,在给定的机器上是保存一个字符的自然大小(通常是个8比特位的字节),
其余类型的容量都是char的整数倍。
类型的容量是实现定义的(就是说,在不同的机器上可以不一样),
可使用sizeof运算符获取;例如:
sizeof(char) 等于 1,sizeof(int) 则通常是4。
数字可以是浮点数或者整数。
3.14)或者使用科学计数法(如:3e-2)。42的意思是四十二)。0b表示二进制(基数是2)整数文本(如:0b10101010)。0x表示十六进制(基数是16)整数文本(如:0xBAD1234)。0表示八进制(基数是8)整数文本(如:0334)。为提高长文本的可读性,可以使用单引号(')作为数字分隔符。
例如:π 大概是 3.14159'26535'89793'23846'26433'83279'50288,
如果用十六进制就是 0x3.243F'6A88'85A3'08D3。
算术运算符可以对基本类型进行适当的组合:
x+y // 加法(plus)
+x // 一元加(unary plus)
x-y // 减法(minus)
-x // 一元减(unary minus)
x*y // 乘法(multiply)
x/y // 除法(divide)
x%y // 对整数取余(取模)(remainder(modulus))
比较运算符也是这样:
x==y // 相等(equal)
x!=y // 不等(not equal)
x<y // 小于(less than)
x>y // 大于(greater than)
x<=y // 小于等于(less than or equal)
x>=y // 大于等于(greater than or equal)
除此之外,还提供了逻辑运算符:
x&y // 按位与(bitwise and)
x|y // 按位或(bitwise or)
xˆy // 按位异或(bitwise exclusive or)
~x // 取补(bitwise complement)
x&&y // 逻辑与(logical and)
x||y // 逻辑或(logical or)
!x // 逻辑非(否定)(logical not(negation))
按位的逻辑运算,其结果的类型与操作数一致,值是对每个对应的位进行运算的结果。
逻辑运算符&& 和 || 依据操作数的值仅返回 true 或 false。
在赋值和算术运算中,C++ 会为操作数在基本类型之间执行任何有意义的转换,
以便它们可以任意混合:
void some_function() // 函数不返回任何值
{
double d = 2.2; // 初始化浮点数
int i = 7; // 初始化整数
d = d+i; // 把和赋值给d
i = d*i; // 把积赋值给i;注意:double类型的 d*i 被截断为一个 int
}
表达式里用到的转换被称为常规算术转换(the usual arithmetic conversions),
旨在确保按操作数中最高的精度执行表达式运算。
比如说,double 和 int 的加法运算,以双精度浮点数算术执行。
请留意,= 是 赋值运算符,而 == 是进行相等性判定。
除了传统的算术和逻辑运算符,C++ 还提供了专门的运算符用于修改变量:
x+=y // x = x+y
++x // 自增: x = x+1
x-=y // x = x-y
--x // 自减: x = x-1
x*=y // 倍增 x = x*y
x/=y // 倍缩 x = x/y
x%=y // x = x%y
这些运算符简明、便利,用得很频繁。
表达式的估值顺序是从左至右,赋值除外,它是从右到左。很不幸,函数参数的估值顺序是未指定。
在某个对象被使用之前,必须给定一个值。
C++ 有多种初始化方法,比如上面用到的 =,
还有一种通用形式,基于花括号内被隔开的初值列表:
double d1 = 2.3; // d1 初始化为 2.3
double d2 {2.3}; // d2 初始化为 2.3
double d3 = {2.3}; // d3 初始化为 2.3(使用 { ... } 时,此处的 = 可有可无)
complex<double> z = 1; // 一个复数,使用双精度浮点数作为标量
complex<double> z2 {d1,d2};
complex<double> z3 = {d1,d2}; // (使用 { ... } 时,此处的 = 可有可无)
vector<int> v {1,2,3,4,5,6}; // 一个承载 int 的 vector
= 是传统形式,可追溯至C语言,但如果你拿不准,请使用 {}-列表 这种通用形式。
最起码,在涉及信息损失的类型转换时,它不会袖手旁观。
int i1 = 7.8; // i1 的值将是 7 (惊不惊喜,意不意外?)
int i2 {7.8}; // 错误:浮点数向整数转换
很不幸,缩窄转换(narrowing conversion) 这种有损信息的形式,
比如double到int以及int到char,在使用=(而非{})的时候,
会被默许并悄无声息地进行。
这种由隐式缩窄转换导致的问题,是对C语言向后兼容(§16.3)的代价。
常量(§1.6)必须初始化,变量也只该在极罕见的情况下不初始化。
在准备好合适的值以前,别引入这个名称。
对于用户定义的类型(比如string、vector、Matrix、
Motor_controller以及Orc_warrior)可将其定义为隐式初始化(§4.2.1)。
定义变量时,如果可以从初始值中推导出类型,就无需明确指定:
auto b = true; // bool类型
auto ch = 'x'; // char类型
auto i = 123; // int类型
auto d = 1.2; // double类型
auto z = sqrt(y); // 无论sqrt(y)返回什么类型,z都被指定为该类型
auto bb {true}; // bb是bool类型
使用auto的情况下,我们往往用=,因为不涉及类型转化的隐患,
如果你更青睐{}初始化,但用无妨。
没有特定原因去指明类型时,就可以用auto,“特定原因”包括:
double,而非float)。运用auto,可以避免冗余,也不用敲很长的类型名。
在泛型编程中这尤为重要,这种情况下,对象的确切类型难于知晓,
而且类型名可能还特别长(§12.2)。
声明会把其名称引入到某个作用域:
{}界定。函数参数的名称也被视为局部名称。enum类(§2.5)之外的名称,{开始,到这个类声明的末尾。enum类(§2.5)之外,未定义于任何其它结构内的名称,被称作全局名称(global name),
位于全局命名空间 global namespace中。
此外,某些对象可以不具名,例如临时变量,以及通过new创建的对象。例如:
vector<int> vec; // vec是全局的(一个承载整数的全局 vector)
struct Record {
string name; // name 是Record的成员(string类型成员)
// ...
};
void fct(int arg) // fct是全局的(全局函数)
// arg是局部的(一个整数参数)
{
string motto {"Who dares wins"}; // motto 是局部的
auto p = new Record{"Hume"}; // p指向一个不具名Record(通过 new 创建)
// ...
}
对象在使用前必须先被构造(初始化),并将在其作用域末尾被销毁。
对于命名空间中的对象,其销毁的时间点位于程序的终止。
对成员来说,其销毁的时间点,由持有它的对象的销毁时间点确定。
经由new创建的对象,将“存活”至被delete(§4.2.2)销毁为止。
关于不可变更,C++有两种概念:
const:相当于“我保证不会修改这个值”。
它主要用于指定接口,对于通过指针以及引用传入函数的数据,无需担心其被修改。
编译器为const作出的“保证”担保。一个const的值可在运行期间得出。
constexpr:相当于“将在编译期估值”。
它主要用于定义常量,指定该数据被置于只读内存(在这里被损坏的几率极低)中,
并且在性能方面有益。
constexpr的值必须由编译器算出。
例如:
constexpr int dmv = 17; // dmv是个具名常量
int var = 17; // var不是常量
const double sqv = sqrt(var); // sqv是个具名常量,很可能要在运行时得出
double sum(const vector<double>&); // sum不会修改其参数(§1.7)
vector<double> v {1.2, 3.4, 4.5}; // v不是一个常量
const double s1 = sum(v); // OK:sum(v)将在运行期估值
constexpr double s2 = sum(v); // 报错:sum(v)不是常量表达式
如果函数要在常量表达式 constant expression中使用,就是说,
用在由编译器估值的表达式里,则必须用constexpr定义。例如:
constexpr double square(double x) { return x*x; }
constexpr double max1 = 1.4*square(17); // OK 1.4*square(17) 是常量表达式
constexpr double max2 = 1.4*square(var); // 报错:var不是常量表达式
const double max3 = 1.4*square(var); // OK,可在运行时估值
constexpr函数可以用于非常量的参数,但此时其结果就不再是常量表达式。
对于constexpr函数,在无需常量表达式的语境里,就可以用非常量表达式参数调用它。
如此一来,就不必把本质上相同的函数定义两遍:一遍用于常量表达式,另一遍用于变量。
要成为constexpr,函数必须极其简单,且不能有副作用,且只能以传入的数据作为参数。
尤其是,它不能修改非局部变量,但里面可以有循环,以及它自己的局部变量。例如:
constexpr double nth(double x, int n) // 假定 n>=0
{
double res = 1;
int i = 0;
while (i<n) { // while-循环:在条件为真时执行(§1.7.1)
res*=x;
++i;
}
return res;
}
在某些场合下,语言规则强制要求使用常量表达式(比如:数组界限(§1.7)、
case标签(§1.8)、模板的值参数(§6.2),以及用constexpr定义的常量)。
其它情况下,编译期估值都侧重于性能方面。
抛开性能问题不谈,不变性(状态不可变更的对象)是一个重要的设计考量。
最基本的数据集合是:一串连续分配的,相同类型元素的序列,被称为数组(array)。
它基本脱胎于硬件。char类型元素的数组可以这样定义:
char v[6]; // 6个字符的数组
与之相似,指针的定义是这样:
char* p; // 指向字符的指针
在声明里,[]的意思是“什么什么的数组”,而*的意思是“指向什么什么东西”。
所有数组都以0作为下界,所以v有六个元素,从v[0]到v[5]。
数组容量必须是常量表达式(§1.6)。指针变量可持有相应类型对象的地址:
char* p = &v[3]; // p指向v的第四个元素
char x = *p; // *p是p指向的对象
在表达式里,一元前置运算符*的意思是“什么什么的内容”,
而一元前置运算符&的意思是“什么什么的地址”。
我们可以把前面初始化定义的结果图示如下:
思考一下,从一个数组里复制十个元素到另一个:
void copy_fct()
{
int v1[10] = {0,1,2,3,4,5,6,7,8,9};
int v2[10]; // 将成为v1的副本
for (auto i=0; i!=10; ++i) // 复制所有元素
v2[i]=v1[i];
// ...
}
for-语句可读作“把i置零;在i不等于10的时候,复制第i个元素并把i增1。”
自增运算符++应用在整数或浮点数变量上时,简单地给变量加1。
C++还提供一个简化的 for-语句,名为 区间-for-语句,是遍历序列最简单的方式:
void print()
{
int v[] = {0,1,2,3,4,5,6,7,8,9};
for (auto x : v) // 针对v中的每个x
cout << x << '\n';
for (auto x : {10,21,32,43,54,65})
cout << x << '\n';
// ...
}
第一个 区间-for-语句 可读作“对于v的每个元素,从头至尾,复制进x并打印它。”
请留意,以列表初始化数组的时候,无需为它指定界限。
区间-for-语句 可用于任何的元素序列(§12.1)。
若不想把v中的值复制到变量x,而是仅让x引用一个元素,可以这么写:
void increment()
{
int v[] = {0,1,2,3,4,5,6,7,8,9};
for (auto& x : v) // 为v里的每个x加1
++x;
// ...
}
在声明中,一元前置运算符&的意思是“引用到什么什么”。
引用和指针类似,只是在访问引用指向的值时,无需前缀*。
此外,在初始化之后,引用无法再指向另一个对象。
在定义函数参数时,引用就特别有价值。例如:
void sort(vector<double>& v); // 把v排序(v是个承载double的vector)
通过引用,我们确保了在调用sort(my_vec)的时候,不会复制my_vec,
并且被排序的确实是my_vec,而非其副本。
想要不修改参数,同时还避免复制的开销,可以用const引用(§1.6)。例如:
double sum(const vector<double>&)
接收const引用参数的函数很常见。
运算符(例如&、*及[])用在声明中的时候,
被称为声明运算符(declarator operator):
T a[n] // T[n]: 具有n个T的数组
T* p // T*: p是指向T的指针
T& r // T&: r是指向T的引用
T f(A) // T(A): f是个函数,接收一个A类型的参数,返回T类型的结果
我们尽量确保指针总是指向一个对象,以便解引用操作合法。
当没有对象可指,或我们想表达“不存在有效对象”(比如:列表的终结)的概念时,
就让指针的值为nullptr(空指针)。仅有一个nullptr,供所有指针类型共享:
double* pd = nullptr;
Link<Record>* lst = nullptr; // 指向承载Record的Link
int x = nullptr; // 报错:nullptr是指针而非整数
检查指针参数,以确保其有所指,小心驶得万年船:
int count_x(const char* p, char x)
// 统计p[]中出现x的次数
// 假定p指向 以零结尾 的字符数组(或者不指向任何对象)
{
if (p==nullptr)
return 0;
int count = 0;
for (; *p!=0; ++p)
if (*p==x)
++count;
return count;
}
请留意,可以用++递增指针,使其指向数组的下一个元素;
以及,在用不到的时候,for-语句的初始化部分可以留空。
在count_x()的定义里,
假定了这个char*是一个 C-风格 字符串(C-style string),
就是说,该指针指向一个以零结尾的char数组。
字符串文本中的字符是不可变的,为了能处理count_x("Hello!"),
我给count_x()声明了const char*参数。
在老式代码里,通常用0或NULL,而非nullptr。
但是,采用nullptr,
可以消除整数(比如0或NULL)和指针(比如nullptr)之间的混淆。
在count_x()的例子中,没使用for语句的初始化部分,
因此可以用更简单的while-语句:
int count_x(const char* p, char x)
// 统计p[]中出现x的次数
// 假定p指向 以零结尾 的字符数组(或者没有指向)
{
if (p==nullptr)
return 0;
int count = 0;
while (*p) {
if (*p==x)
++count;
++p;
}
return count;
}
while-语句会一直执行到其条件变成false为止。
对数值的判定(比如count_x()里的while(*p)),
等同于将其与0比较(也就是while(*p!=0))。
对指针指的判定(比如if(p)),
等同于将其与nullptr比较(也就是if(p!=nullptr))。
不存在“空引用”。引用必须指向有效的对象(并且编译器的实现假定是这样)。
有些隐晦的小聪明可以绕过这些规则;别那么干。
C++有一套传统的语句表达选择和循环,
诸如if-语句、switch-语句、while-语句、for-语句。
举个例子,这有个很简单的函数,它向用户提问,并返回一个布尔值表示用户的反馈:
bool accept()
{
cout << "Do you want to proceed (y or n)?\n"; // 输出问题
char answer = 0; // 初始化一个值,无需显示
cin >> answer; // 读取回应
if (answer == 'y')
return true;
return false;
}
跟输出运算符<<(“输至”)配对,运算符>>(“取自”)用于输入;
cin是标准输入流(第10章)。
>>右操作数的类型决定可接受的输入内容,这个右操作数也是输入操作的收货方。
待输出字符串末尾的字符\n表示另起一行(§1.2.1)。
请留意,answer定义在被需要的地方(而非更靠前的位置)。
声明能够出现在任何可以出现语句的地方。
可以改良此例,让它也接收一个n(代表“no”)作为回应:
bool accept2()
{
cout << "Do you want to proceed (y or n)?\n"; // 输出问题
char answer = 0; // 初始化一个值,无需显示
cin >> answer; // 读取回应
switch (answer) {
case 'y':
return true;
case 'n':
return false;
default:
cout << "I'll take that for a no.\n";
return false;
}
}
switch-语句把一个值跟一组常量进行比较。
这些常量被称为case-标签,必须互不相同,
如果该值与其中任何一个常量都不匹配,就执行default。
如果值不匹配任何case-标签,又没有default,就什么都不做。
如果一个函数里有switch-语句,在从该函数返回的时候,可以不退出case。
我们通常要继续执行switch-语句后续的内容。这可以使用break语句实现。
举个例子,这是个电子游戏的命令解析器,略原始,还有点小聪明:
void action()
{
while (true) {
cout << "enter action:\n"; // 询问动作
string act;
cin >> act; // 把字符串读入一个string
Point delta {0,0}; // Point里存有一个{x,y}对
for (char ch : act) {
switch (ch) {
case 'u': // 上(up)
case 'n': // 北(north)
++delta.y;
break;
case 'r': // 右(right)
case 'e': // 东(east)
++delta.x;
break;
// ... more actions ...
default:
cout << "I freeze!\n";
}
move(current+delta*scale);
update_display();
}
}
}
与for-语句(§1.7)类似,if-语句接收一个值并对它判定。例如:
void do_something(vector<int>& v)
{
if (auto n = v.size(); n!=0) {
// ... 如果 n!=0 就走到这 ...
}
// ...
}
此处的整数n的定义仅在if-语句内使用,以v.size()初始化,
并立即由分号后的条件n!=0进行判定。
在条件中声明的名称,其作用域同时囊括了if-语句的两个分支。
与for-语句的情况相同,把名称声明在if-语句的条件中,
目的是限制变量的作用域以提升可读性,并减少错误发生。
最常见的情况是针对0(或nullptr)判定变量。
这种情况下,无需明确提及判定条件。例如:
void do_something(vector<int>& v)
{
if (auto n = v.size()) {
// ... 如果 n!=0 就走到这 ...
}
// ...
}
请尽可能采用这种简洁的形式。
C++ 提供到硬件的直接映射。
当你用到基础运算,其操作由硬件执行,通常是单个的机器操作。
例如把两个int相加,x+y执行一条整数加法的机器指令。
C++编译器实现把机器的内存视为一连串的存储位置,
可向其中放置(带类型的)对象,并可用指针对其寻址:
指针在内存里以机器地址表示,所以图中p的数值会是3。
如果这看起来像数组(§1.7),那是因为在C++中,数组就是对“内存中一连串对象”的抽象。
这种基本语言构件向硬件的映射,对底层性能至关重要,数十年来,C和C++就是闻名于斯。
C和C++的基本机器模型基于计算机硬件,而非某种数学概念。
内置类型的赋值就是简单的机器复制操作。例如:
int x = 2;
int y = 3;
x = y; // x 变成 3
// 注意: x==y
显而易见。可以图示如下:
注意,两个对象是独立的。可以修改y的值却不牵连x。
比如x=99并不会修改y的值。
这一点对所有类型都成立,不仅仅是int,
这跟Java、C#以及其它语言不同,但和C语言一样。
如果想让不同的对象指向相同(共享)的值,必须明确指出。可以用指针:
int x = 2;
int y = 3;
int* p = &x;
int* q = &y; // 现在 p!=q 且 *p!=*q
p = q; // p 成了 &y; 现在 p==q,因此(很明显)*p == *q
可图示如下:
用88和92作为int的地址,是随便选的。
与前例相同,可见 赋值目标 获得了 赋值源 的值,
结果是两个独立的对象(此处都是指针),具有相同的值。
就是说p=q导致p==q。执行p=q后,两个指针都指向y。
引用和指针都 指引向/指向 对象,而且在内存里都表现为机器地址。
但是在语言规则里,二者的使用形式不同。
向一个引用赋值,不会改变它引用的目标,而是会给它引用的对象赋值:
int x = 2;
int y = 3;
int& r = x; // r 引用向 x
int& r2 = y; // 现在r2引用向 y
r = r2; // 经由r2读取,通过r写入:x变成3
可图示如下:
想要访问指针指向的值,需要借助*;但访问 引用所指的值 却是自动(隐式)的。
对于所有内置类型,以及设计良好——提供=(赋值)和==(相等判定)——的用户定义类型(第2章),执行过x=y后,都会得到x==y。
初始化和赋值不一样。
一般来说,想要让赋值操作正确运行,被赋值对象必须已经有一个值。
另一边,初始化的任务是让一块未初始化过的内存成为一个有效的对象。
对绝大多数类型来说,针对 未初始化变量 的读取和写入都是未定义的(undefined)。
对于内置类型,这在引用身上尤其明显:
int x = 7;
int& r {x}; // 把r绑定到x上(r引用向x)
r = 7; // 不论r引用向什么,给它赋值
int& r2; // 报错:未初始化引用
r2 = 99; // 不论r2引用向什么,给它赋值
很幸运,不存在未初始化的引用;
如果能,那么r2=99就会把99赋值给某个不确定的内存位置;
其结果会导致故障或者崩溃。
=可用于初始化引用,但千万别被它搞糊涂了。例如:
int& r = x; // 把r绑定到x上(r引用向x)
这依然是初始化r,并把它绑定到x上,而不涉及任何的值复制操作。
初始化和赋值的区别,对很多用户定义的类型
——比如string和vector——而言同样极度重要,
在这些类型中,被赋值的对象拥有一份资源,该资源最终将被释放(§5.3)。
参数传递和返回值返回的基本语义是初始化(§3.6)。
举例来说,传引用(pass-by-reference)就是这么实现的。
此处的忠告是 C++ Core Guidelines [Stroustrup,2015] 的子集。
以类似 [CG: ES.23]的形式引用向核心指南,
其意思是 Expressions and Statement 章节的 第23条规则。
通常,核心指南提供更深入的理论和用例。
constexpr声明它;§1.6; [CG: F.4]。ALL_CAPS)名称;[CG: ES.9]。{}-初始化 语法;§1.4; [CG: ES.23]。auto以避免重复输入类型名;§1.4.2; [CG: ES.11]。if-语句的条件中定义变量,尽量采用针对0的隐式判定;§1.8。unsigned;§1.4; [CG: ES.101] [CG: ES.106]。nullptr,而非0或NULL;§1.7; [CG: ES.47]。仅使用基本类型(§1.4)、const修饰符(§1.6)以及声明运算符(§1.7)构建出的类型,
被称为内置类型(built-in type)。
C++的内置类型和运算都很丰富,但有意地低级化了。
它们直接、高效地反映出了传统计算机硬件的能力。
但是在开发高级应用程序的便利性方面,它的高层设施可就捉襟见肘了。
相反,C++用一套精细的*抽象机制(abstraction mechanism)*强化了内置类型及运算,
借助这套机制,程序员可以构建出这些高层设施。
C++的抽象机制的主要设计意图是让程序员设计并实现自己的类型,
这些类型具备适当的表现和运算,同时,还能让程序员用起来简单而优雅。
遵循C++的抽象机制,借助其它类型构建出的类型被称为
用户定义类型(user-defined type)。
也被称为类(class)和枚举(enumeration)。
构造用户定义类型时,即可以动用内置类型,也可以动用其它用户定义类型。
用户定义类型通常优于内置类型,因为更易使用,出错少,
而效率则通常与内置类型相差无几,有时候甚至更快。
本章后面的内容,介绍最简单也是最基础的用于创建和使用类型的工具。
对这种抽象机制及其支持的编程风格,在第4-7章给出了更完整的描述。
第5-8章给出了标准库的概览,同时标准库主要由用户定义类型构成,
因此对第1-7章所介绍的语言构造及编程技术而言,标准库提供了用例。
构建新类型的第一步,通常是把它所需的要素组织到一个数据类型
——结构体(struct)里:
struct Vector
{
int sz; // 元素数量
double* elem; // 指向元素的指针
};
这Vector的第一个版本包含了一个int和一个double*。
Vector类型的变量可以这样定义:
Vector v;
但是,光有它自己用处不大,因为v的指针elem没有指向任何东西。
想让它有用,必须让v指向某些元素。比方说,可以这样构造Vector:
void vector_init(Vector& v, int s)
{
v.elem = new double[s]; // 分配一个数组,里面有s个double
v.sz = s;
}
这样,v的成员elem就得到一个用new生成的指针,
而v的成员sz也得到了元素数量。
Vector&里的&意思是:v通过非const引用(§1.7)方式传递;
这样一来,vector_init()就可以修改传入的Vector了。
new运算符从一块叫自由存储(free store)
(也叫动态内存(dynamic memory)和堆(heap))的区域里分配内存。
分配在自由存储上的对象,与其被创建的作用域无关,
而是会一直“存活”下去,直到用delete运算符(§4.2.2)把它销毁。
Vector可以简单应用如下:
double read_and_sum(int s)
// 从cin读取s个整数,返回它们的和;假定s时正数
{
Vector v;
vector_init(v,s); // 给v分配s个元素
for (int i=0; i!=s; ++i)
cin>>v.elem[i]; // 向元素中读入内容
double sum = 0;
for (int i=0; i!=s; ++i)
sum+=v.elem[i]; // 对元素求和
return sum;
}
要媲美标准库中vector的优雅和灵活,Vector还有待提升。
尤其是,Vector的用户必须对Vector的细节了如指掌。
本章后续及接下来的两章内容,将逐步改进Vector,作为语言特性及技术的示例。
第11章介绍标准库的vector,它包含很多精致的改进。
我拿vector和其它标准库组件做例子是为了:
对于vector和string这样的标准库组件,别造轮子;直接用。
通过变量名(及引用)访问struct成员用.(点),
而通过指针访问struct成员用->。例如:
void f(Vector v, Vector& rv, Vector* pv)
{
int i1 = v.sz; // 通过变量名访问
int i2 = rv.sz; // 通过引用访问
int i3 = pv->sz; // 通过指针访问
}
把具体数据和运算分离有它的优势,比方说,能够随心所欲地使用数据。
但是,想让用户定义类型具有“真正的类型”那些属性,就需要让数据和运算结合得更紧密些。
具体而言,我们通常希望数据表示对用户不可访问,从而避免被使用,
确保该类型数据的使用一致性,这还让我们后续能够改进数据表示。
要达成这个目的,必须区分类型的(供任何人使用的)接口和(可对数据排他性访问的)实现。
这个语言机制叫做类(class)。
类拥有一组成员(member),成员可以是数据、函数或者类型成员。
接口由类的public成员定义,而private成员仅允许通过接口访问。例如:
class Vector{
public:
Vector(int s) :elem{new double[s]}, sz{s} { }
double& operator[](int i) { return elem[i]; }
int size() { return sz; }
private:
double* elem; // 指向元素的指针
int sz; // 元素的数量
};
有了这些,就可以定义新的Vector类型的变量了:
Vector v(6); // 具有6个元素的Vector
Vector对象可图示如下:
大体上,Vector对象就是个“把手”,
其中装载着指向元素的指针(elem)和元素数量(sz)。
元素数量(例中是6)对不同的Vector对象是可变的,而同一个Vector对象,
在不同时刻,其元素数量也可以不同。但是Vector对象自身的大小始终不变。
在C++中,这是处理可变数量信息的基本技巧:以固定大小的把手操控数量可变的数据,
这些数据被放在“别处”(比如用new分配在自由存储上;§4.2.2)。
设计与使用这些对象的方法,是第4章的主要内容。
在这里,Vector的数据(成员elem及sz)只能通过接口访问,
这些接口都是public成员:Vector()、operator[]()及size()。
§2.2 中的示例read_and_sum()可简化为:
double read_and_sum(int s)
{
Vector v(s); // 创建持有s个元素的vector
for (int i=0; i!=v.size(); ++i)
cin>>v[i]; // 把数据读入元素
double sum = 0;
for (int i=0; i!=v.size(); ++i)
sum+=v[i]; // 对元素求和
return sum;
}
和类具有相同名称的成员“函数(function)”叫做构造函数(constructor),
就是说,这个函数的用途是构建此类对象。
因此,构造函数Vector()取代了§2.2里的vector_init()。
与一般函数不同,在构造其所属的类对象时,构造函数保证会被调用。
由此,定义构造函数,就类消除了类的“变量未初始化”问题。
Vector(int)定义了怎样构造Vector对象。
具体来说,它明确指出需要一个整数。
该整数作为元素的数量使用。
这个构造函数通过成员初始化列表来初始化Vector的成员:
:elem{new double[s]}, sz{s}
意思是说,先用一个指针初始化elem,该指针指向s个double类型的元素,
这些元素的空间取自自由存储区。
然后用s的值初始化sz。
对元素的访问由取下标函数提供,该函数叫做operator[]。
它返回相应元素的引用(即可读又可写的double&)。
函数size()把元素的数量交给用户。
一望而知,错误处理被彻底忽略了,但是我们会在§3.5讲到它。
与此类似,对于通过new获取的double数组,
我们也并未提供一个机制把它“送回去(give back)”;
§4.2.2展示了用析构函数优雅地做到这一点的方式。
struct和class没有本质上的区别,struct就是个class,
只不过其成员默认是public的。
比方说,你可以为struct定义构造函数和其它成员函数。
联合(union)就是结构体(struct),只不过联合的所有成员都分配在相同的地址上,
因此联合所占据的空间,仅跟其容量最大的那个成员相同。
自然而然,任何时候联合都只能持有其某一个成员的值。
举例来说,有个符号表条目,它包含一个名称和一个值。其值可以是Node*或int:
enum Type { ptr, num }; // 一个 Type 可以是ptr和num(§2.5)
struct Entry {
string name; // string是个标准库里的类型
Type t;
Node* p; // 如果t==ptr,用p
int i; // 如果t==num,用i
};
void f(Entry* pe)
{
if (pe->t == num)
cout << pe->i;
// ...
}
成员p和i永远不会同时使用,但这样空间就被浪费了。
可以指定它们都是某个union的成员,这样空间就轻而易举地节省下来了,像这样:
union Value {
Node* p;
int i;
};
语言并不会追踪union保持了哪种类型的值,所以程序员要亲力亲为:
struct Entry {
string name;
Type t;
Value v; // 如果t==ptr,用v.p;如果t==num,用v.i
};
void f(Entry* pe)
{
if (pe->t == num)
cout << pe->v.i;
// ...
}
*类型信息(type field)和union所持的类型之间的一致性很难维护。
想要避免错误,可以强化这种一致性——把联合与类型信息封装成一个类,
仅允许通过成员函数访问它们,再用成员函数确保准确无误地使用联合。
在应用层面,依赖于这种附有标签的联合(tagged union)*的抽象常见且有用。
尽量少用“裸”的union。
标准库有个类型叫variant,使用它就可以避免绝大多数针对 联合 的直接应用。
variant存储一个值,该值的类型可以从一组类型中任选一个(§13.5.1)。
举个例子,variant<Node*,int>的值,可以是Node*或者int。
借助variant,Entry示例可以写成这样:
struct Entry {
string name;
variant<Node*,int> v;
};
void f(Entry* pe)
{
if (holds_alternative<int>(pe->v)) // *pe的值是int类型吗?(参见§13.5.1)
cout << get<int>(pe->v); // 取(get)int值
// ...
}
很多情况下,使用variant都比union更简单也更安全。
除了类,C++还提供一种简单的用户定义类型,使用它可以把一组值逐一列举:
enum class Color { red, blue, green };
enum class Traffic_light { green, yellow, red };
Color col = Color::red;
Traffic_light light = Traffic_light::red;
注意,枚举值(比如red)位于其enum class的作用域里,
因此可以在不同的enum class里重复出现,而且不会相互混淆。
例如:Color::red是Color里面的red,跟Traffic_light::red毫无关系。
枚举用于表示一小撮整数值。
使用它们,可以让代码更具有可读性,也更不易出错。
enum后的class指明了这是个强类型的枚举,并且限定了这些枚举值的作用域。
作为独立的类型,enum class有助于防止常量的误用。
比方说,Traffic_light和Color的值无法混用:
Color x = red; // 错误:哪个颜色?
Color y = Traffic_light::red; // 错误:此red并非Color类型
Color z = Color::red; // OK
同样,Color的值也不能和整数值混用:
int i = Color::red; // 错误:Color::red不是int类型
Color c = 2; // 初始化错误:2不是Color类型
捕捉这种向 enum 的类型转换有助于防止出错,
但我们通常需要用底层类型(默认情况下是int)的值初始化一个 enum,
因此,这种初始化就像显式从底层类型转换而来一样合法:
Color x = Color{5}; // 可行,但略有些啰嗦
Color y {6}; // 同样可行
默认情况下,enum class仅定义了赋值、初始化和比较(也就是==和<; §1.4)。
不过,既然枚举是用户定义类型,我们就可以给它定义运算符:
Traffic_light& operator++(Traffic_light& t) // 前置自增:++
{
switch (t) {
case Traffic_light::green: return t=Traffic_light::yellow;
case Traffic_light::yellow: return t=Traffic_light::red;
case Traffic_light::red: return t=Traffic_light::green;
}
}
Traffic_light next = ++light; // next 将是 Traffic_light::green
如果你的枚举值不需要独立的作用域,并希望把它们作为int使用(无需显式类型转换),
可以省掉enum class中的class,以获得一个“普通”enum。
“普通”enum中的枚举值的作用域跟这个enum相同,还能隐式转换成整数值,例如:
enum Color { red, green, blue };
int col = green;
此处的col值为1。
默认情况下,枚举值的整数值从0开始,每增加一个新枚举值就递增一。
“普通”enum是C++(和C)与生俱来的,因此,尽管它问题多多却仍然很常见。
struct或class);§2.2; [CG: C.1]。class区分接口和实现;§2.3; [CG: C.3]。struct就是其成员默认为public的class;§2.3。class定义构造函数,以确保执行初始化操作并简化它;§2.3; [CG: C.2]。union,把它们和类型字段凑一起放进类里面;§2.4; [CG: C.181]。enum class替代“普通”enum以避免事故;§2.5; [CG: Enum.3]。一个C++程序由许多独立开发的部分组成,比如函数(§1.2.1)、
用户定义类型(第2章)、类的层级(§4.5)及模板(第6章)。
管理它们的关键在于给它们之间的交互作出清晰的定义。
第一步也是最重要的一步,是区分接口和实现。
这语言层面上,C++用声明表示接口。
*声明(declaration)*逐一列举那些用于操控函数或类的事物。例如:
double sqrt(double); // 平方根函数接收double参数,返回double值
class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
};
这里的重点是函数体,即函数定义(definition),位于“别的地方”。
对于这个示例,可能会有需求让Vector的数据也位于“别的地方”,
但我们得到后面(抽象类型;§4.3)处理这个问题。
sqrt()的定义可能是这样:
double sqrt(double d) // sqrt()的定义
{
// ... 数学课本里的算法 ...
}
对于Vector,我们需要给它的三个成员函数都提供定义:
Vector::Vector(int s) // 构造函数的定义
:elem{new double[s]}, sz{s} // 初始化成员变量
{
}
double& Vector::operator[](int i) // 取下标运算符的定义
{
return elem[i];
}
int Vector::size() // size()的定义
{
return sz;
}
Vector的函数必须提供定义,sqrt()就不必,因为它隶属于标准库。
但是二者并无本质差别:程序库仅仅是“我们恰好用到的某些其它代码”,
编写这些代码的语言构件与我们用的语言构件相同。
任何实体都可以具有多个声明,但定义只能有一个。
C++支持分离编译,这种情况下,对于用到的类型和函数,用户仅能见到它们的声明。
这些类型和函数的定义位于独立的源文件中,并且被单独编译。
借助分离编译,可以把程序组织成一系列半独立的代码片段。
这种分离操作可用来缩短编译时间,把逻辑上独立的部件严格分离开(从而减少犯错)。
程序库通常就是一组独立编译的代码片段(比如函数)。
一般来说,我们把定义模块接口的声明部分放在一个文件里的模块中,
并通过文件名表明它的用途,例如:
// Vector.h:
class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
};
该声明会被置于名为Vector.h的文件中,然后,用户包含(include)
这个头文件(header file),以访问该接口。例如:
// user.cpp:
#include "Vector.h" // 获取Vector的接口
#include <cmath> // 获取标准库中的数学函数,其中包括sqrt()
double sqrt_sum(Vector& v)
{
double sum = 0;
for (int i=0; i!=v.size(); ++i)
sum+=std::sqrt(v[i]); // 平方根的和
return sum;
}
为了确保对编译器的一致性,提供Vector实现的
.cpp文件同样会包含提供接口的.h文件:
// Vector.cpp:
#include "Vector.h" // 获取Vector的接口
Vector::Vector(int s)
:elem{new double[s]}, sz{s} // 初始化成员变量
{
}
double& Vector::operator[](int i)
{
return elem[i];
}
int Vector::size()
{
return sz;
}
user.cpp和Vector.cpp的代码共享Vector.h内的Vector接口信息,
但除此之外它们是独立的,并可以独立编译。该程序的各部分可图示如下:
严格来说,分离编译并非语言自身的问题,而是事关如何最大化利用特定语言实现优势的问题。
无论如何,这在实践中意义重大。
优秀的程序组织方法是:
把程序当作一组明确定义过依赖关系的模块,利用语言特性表达这种模块化的逻辑关系,
再把这种关系通过文件以实体形式暴露出来,以实现高效的分离编译。
可以被独自(包括#include进来的.h文件)编译的.cpp文件,
叫做编译单元(translation unit)。
一个程序可以由数以千计的编译单元组成。
使用#include把程序组织成多个部件的方法,很古老、易出错且成本高昂。
如果在101个编译单元中用到#include header.h,
header.h中的文本将被编译器处理101次。
如果在header2.h之前用了#include header1.h,那么header1.h
中的声明和宏可能会影响header2.h里代码的意义。
很明显,这不太理想,实际上自从1972年这个机制被引入C语言以来,
对于成本高和bug多,它就一直负有不可推卸的责任。
寻找在C++中表示物理模块的好方法,终于初见曙光了。
这个语言特性叫module,尚未进入 ISO C++,
但已经是一个 ISO技术细则(Technical Specification)[ModulesTS],
并将成为C++20的一部分[^1]。
因为在编译器实现中已经提供了该功能,所以,尽管其细节可能还要变动,
而且可能要等好几年才能广泛用于生产环境,我依然要在这里推荐使用它。
使用#include的老代码,还会“存活”相当长的时间,
因为要更新它们,代价高昂而且非常耗时。
使用module组织§3.2中Vector和sqrt_sum()的示例,方法如下:
// 文件 Vector.cpp:
module; // 此编译单元将定义一个模块
// ... 此处为 Vector 的实现所需的内容 ...
export module Vector; // 定义名为"Vector"的模块
export class Vector {
public:
Vector(int s);
double& operator[](int i);
int size();
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
};
Vector::Vector(int s)
:elem{new double[s]}, sz{s} // 初始化成员变量
{
}
double& Vector::operator[](int i)
{
return elem[i];
}
int Vector::size()
{
return sz;
}
export int size(const Vector& v) { return v.size(); }
以上代码定义了名为Vector的module,
它导出了类Vector和它所有的成员函数,以及非成员函数 size()。
使用该module的方法是:在需要的地方import它。例如:
// 文件 user.cpp:
import Vector; // 获取Vector的接口
#include <cmath> // 获取标准库中的数学函数,其中包括sqrt()
double sqrt_sum(Vector& v)
{
double sum = 0;
for (int i=0; i!=v.size(); ++i)
sum+=std::sqrt(v[i]); // 平方根的和
return sum;
}
我本来也可以import标准库中的数学函数,但我用了老式的#include,
就是为了表明新老方式可以混用。
这样混用的形式,对于老代码逐步从#include迁移到import是必不可少的。
头文件和模块方式的区别不仅仅在语法层面:
import顺序可任意颠倒,而不会影响其意义import不会跨模块传导。模块会显著影响可维护性编译期的性能。
除了函数(§1.3)、类(§2.3)以及枚举(§2.5),
C++还提供一个叫*命名空间(namespace)*的机制,
用来表示某些声明相互依托,以便这些名称不会跟其它名称发生冲突。
比如说,我想弄个复数类型(§4.2.1, §14.4):
namespace My_code {
class complex {
// ...
};
complex sqrt(complex);
// ...
int main();
}
int My_code::main()
{
complex z {1,2};
auto z2 = sqrt(z);
std::cout << '{' << z2.real() << ',' << z2.imag() << "}\n";
// ...
}
int main()
{
return My_code::main();
}
把我的代码放到命名空间My_code里后,
确保了我定义的名称没有跟标准库命名空间std(§3.4)中的名称产生冲突。
这种谨慎就很明智了,因为标准库确实提供了complex算术(§4.2.1, §14.4)。
想要在另一个命名空间中访问某个名称,最简单的方式就是:
使用命名空间名进行限定(比如std::cout和My_code::main),
“真正的main”定义在全局命名空间里,就是说并非位于某个命名空间、类或者函数的局部。
反复用命名空间名限定名称,很单调且容易分神,
可以用using-声明把名称引入某个作用域:
void my_code(vector<int>& x, vector<int>& y)
{
using std::swap; // 使用标准库中的 swap
// ...
swap(x,y); // std::swap()
other::swap(x,y); // 别的 swap()
// ...
}
using-声明 让命名空间中的一个名称在当前作用域可用,
其效果就像该名称声明在当前作用域中一样。
在using std::swap语句之后,就好像swap被声明在my_code()中一模一样。
要访问标准库命名空间中的所有名称,可以用这个using-指令:
using namespace std;
在using-指令所处的作用域内,它提及的命名空间内的所有名称都能以非限定方式访问。
因此在针对std的using-指令后,我们可以直接用cout代替std::cout。
使用using-指令后,我们无法再对该命名空间内的名称进行精细挑选,
因此要慎用这个功能,它通常适用于程序中广泛使用的库(比如std),
或者在转化某个未使用namespace程序的过渡期。
命名空间主要用于组织较大的程序组件,比如程序库。
在使用多个单独开发的部分组成程序时,它能化简这个过程。
错误处理是个即庞大又复杂的主题,它的关注点和覆盖面超出了语言特性本身,
而牵涉到编程技术和相关工具。
好在C++的某些特性能帮个忙。主要帮手就是类型系统。
我们不是大费周章地使用内置类型(比如char、int及double)
和语句(比如if、while及for)直接构建程序,
而是构建适用于我们应用程序的类型(比如string、map及regex)
和算法(比如sort()、find_if()及draw_all())。
这些高级别的设施简化了编程,限制了犯错误的几率
(比如说,你不太可能尝试把树遍历算法应用在对话框上),还能帮助编译器捕获错误。
C++的主要语言设施致力于设计并实现出优雅且高效的抽象
(比如,用户定义类型和应用在它们之上的算法)。
这种抽象的一个影响是:在程序运行时,把故障的检测点和处理点相互分离。
当程序增大,尤其是库被广泛应用,错误处理的标准化就愈加重要了。
在程序开发的早期就规划错误处理规划的策略,是个好主意。
回到Vector的例子。
对于§2.3中的vector,对元素越界访问(out-of-range)的时候,应当做些什么呢?
在这个情形下,Vector的作者不会知道用户的意图
(一般来说,Vector的作者甚至不知道这个vector跑在哪个程序里。)
Vector的用户也没办法一直去检测这个问题
(如果有办法,那这种越界访问压根儿就不该发生。)
假设我们想从越界访问的错误中恢复运行,解决方案是:
实现Vector的人检测越界访问的企图,然后把它告知用户。
然后用户就可以采取适当的措施。举例来说:
Vector::operator[]()能检测越界访问的企图,并抛出out_of_range异常:
double& Vector::operator[](int i)
{
if (i<0 || size()<=i)
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
throw把控制权转给处理out_of_range异常的代码,
这个代码位在某些函数里,而这些函数直接或间接地调用了Vector::operator[]。
要做到这些,编译器就得*展开(unwind)*函数调用堆栈,以便回退到调用者的代码环境。
就是说,异常处理机制将根据需要退出作用域和函数,以便回退到有意处理该类异常的调用者,
必要时沿途调用析构函数(§4.2.2)。例如:
void f(Vector& v)
{
// ...
try { // 此处的异常将被下面定义的代码处理
v[v.size()] = 7; // 试图访问v到末尾之后
}
catch (out_of_range& err) { // 坏菜了:out_of_range错误
// ... 处理越界错误 ...
cerr << err.what() << '\n';
}
// ...
}
我们把有意异常处理的代码放到try-代码块里。
给v[v.size()]赋值的企图不会得逞。
因此,会进入catch-子句,里面包含处理out_of_range异常的代码。
out_of_range异常定义在标准库中(<stdexcept>里),
并且实际上已经被标准库里某些容器的访问函数用到了。
我以引用方式捕捉此异常以避免复制,并使用what()函数打印错误信息,
这个信息是在throw-位置放进异常里的。
使用异常处理机制可以让错误处理更简洁、更系统化,也更具可读性。
要确保这一点,就别滥用try-语句。
以简洁和系统化方式实现错误处理的主要技术(被称为
资源请求即初始化(Resource Acquisition Is Initialization; RAII))
将在 §4.2.2 中细聊。
RAII的大体思路是:让类的构造函数获取正常运作所需的全部资源,
然后让析构函数释放全部资源,这样资源的释放就可以有保障地隐式执行。
如果一个函数绝对不应该抛出异常,可以用noexcept声明它。例如:
void user(int sz) noexcept
{
Vector v(sz);
iota(&v[0],&v[sz],1); // 用1,2,3,4...填充v(详见§14.3)
// ...
}
万一user()还是抛出了异常,就会立即调用std::terminate()以终止程序。
使用异常来指示越界访问,演示了这样的情形:
函数检验参数,并在不满足基本假设——先决条件(precondition)——时拒绝运行。
如果我们要郑重其事地声明Vector的下标运算符,就应该有些诸如此类的表达:
“索引必须位于区间[0:size())内”,
而实际上,我们在operator[]()里检查的就是这个。
[a:b)这个标记定义了一个半开区间,就是说,a在这个区间里,而b不在。
在定义函数的时候,应当考虑一下:前置条件是什么;是否要检测它(§3.5.3)。
对绝大多数程序来说,应该对简单的不变式进行测试,请参阅 §3.5.4。
既然operator[]()运算符要操作Vector类型的对象,
那么,如果Vector的成员不具备“合理的”值,这个运算就毫无意义。
确切的说,我们指出了“elem指向承载sz个元素的数组”,但仅仅止步于注释中。
这种为类声称某个假设为真的语句被称为
类的不变式(class invariant),简称不变式(invariant)。
为类制定不变式(以确保成员函数有的放矢)的职责归构造函数,
而成员函数运行完成之后,要确保不变式依然成立。
不巧的是,我们Vector的构造函数有点虎头蛇尾了。
它出色地为Vector的成员变量完成了初始化,却没留意传入的参数是否合理。
考虑一下这个:
Vector v(-27);
基本上,这就要出事了。
更靠谱的定义是这样的:
Vector::Vector(int s)
{
if (s<0)
throw length_error{"Vector constructor: negative size"};
elem = new double[s];
sz = s;
}
我使用标准库里的length_error异常报告“元素数量不是正整数”的问题,
因为标准库也用这个异常报告这类问题。
如果new运算符没找到可分配的内存,将抛出std::bad_alloc。
我们可以这么写:
void test()
{
try {
Vector v(-27);
}
catch (std::length_error& err) {
// 处理容量为负数的情况
}
catch (std::bad_alloc& err) {
// 处理内存耗尽的问题
}
}
你可以定义自己的类当作异常使用,可以在检测到异常的地方塞入任意信息,
这些信息会被带到处理异常的地方(§3.5.1)。
一般来说,函数在捕获异常之后就已经没法搞定待处理的任务了。
然后,异常“处理”就意味着低限度的局部资源清理,然后重新抛出该异常。例如:
void test()
{
try {
Vector v(-27);
}
catch (std::length_error&) { // 处理一下,然后重新抛出
cerr << "test failed: length error\n";
throw; // 重新抛出
}
catch (std::bad_alloc&) { // 糟!这个程序没法处理内存耗尽的问题
std::terminate(); // 终止程序
}
}
在设计良好的代码里,try-代码块并不多见。
请系统化地使用 RAII 技术(§4.2.2,§5.3)以避免滥用try-代码块。
不变式的概念对于类的设计很重要,对于函数的设计,前置条件有类似的作用。不变式
不变式的概念是C++资源管理的基础,
构造函数(第4章)和析构函数为它提供支持(§4.2.2,§13.2)。
对于所有现实世界的软件而言,错误处理都是个主要的问题,
所以就自然而然地存在多种多样的方案。
如果一个错误在函数内被侦测到,并且无法就地进行处理,
那么这个函数就必须想办法跟调用者沟通这个问题。
对此,抛出异常是C++最常规的机制。
对于某些语言来说,设计异常机制仅仅是提供返回值的替代方案。
C++不在其列:异常机制的设计意图是给未能完成的任务报告故障。
异常机制跟构造函数及析构函数结合起来,
为错误处理和资源管理(§4.2.2, §5.3)提供了一致的框架。
编译器专门做过优化,跟通过异常抛出同样的值相比,返回值的操作成本低廉得多。
抛出一个异常不仅仅是为当前无法处理的错误报告出错这么简单。
对于分派到的任务,函数可以这样说明自己无法执行:
terminate()、exit()或abort())终止程序返回某个错误标识(“错误码(error code)”)的情形有:
抛出异常的情形有:
pringf()的返回值是什么时候?pair; §13.4.3),终止程序的情形有:
要确保程序被终止,一个方法是为函数添加noexcept,
这样该函数实现中任意位置的throw都能触发terminate()。
注意,某些应用程序不接收无条件终止,因此必须采取替代方案。
很遗憾,以上这些情形并非泾渭分明、易于运用。程序的体量和复杂度都有影响。
有时在程序的演化过程里,取舍会发生变化。这有赖于经验。
如果拿不准,尽量用异常,因为它在体量变化时工作良好,
没必要借助外部工具检查去确保所有错误都被处理。
别笃信所有的错误码或者所有的异常都不可取;它们都有明确的用途。
另外,也别相信有关异常处理缓慢的谣言;
相较于复杂或罕见错误情形的妥善处理和对错误码的反复检查,它通常都快得多。
简洁高效利用异常进行错误处理,RAII(§4.2.2, §5.3)至关重要。
充斥着try-块的代码往往体现了错误处理策略中最糟糕的方面,
这种情形应该采用错误码。
目前尚无通用和标准的方式书写不变式、前置条件等可选的运行时测试。
一个名为“契约”的机制提交到了C++20[Garcia,2016] [Garcia,2018]。
它的目标是帮助一部分用户把程序做得更稳妥,他们依赖于测试
——进行地毯式的运行时检查——然后在部署的代码里进行最低限度的检查。
在依赖系统化大规模代码检查的组织中,这种方法广泛用于高性能应用程序。
目前只能依赖权宜之计,例如用命令行里的宏控制运行时检查:
double& Vector::operator[](int i)
{
if (RANGE_CHECK && (i<0 || size()<=i))
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
标准库提供了调试用的宏assert(),以确保某个条件在运行时成立。例如:
void f(const char* p)
{
assert(p!=nullptr); // p绝不能是nullptr
// ...
}
如果这个assert()条件在“调试模式”不成立,程序将终止。
如果不在调试模式,assert()就不做检查。
这个方法忒糙还不灵活,不过通常也凑合够用了。
异常给运行时发现的问题报错。如果能在编译时发现错误就该大力推广。
对于绝大多数类型系统、区分用户定义类型的接口那些语言特性而言,这就是意义所在。
最起码,我们可以对编译期已知的大多数属性进行基本检查,
以编译器错误信息的形式汇报不满足需求的情况。例如:
static_assert(4<=sizeof(int), "integers are too small"); // 检查整数容量
在不满足4<=sizeof(int)时,这段代码输出integers are too small;
就是说在系统里的int不足4个字节时。
我们把这种陈述预期的语句称为断言(assertion)。
static_assert机制可用于任意——可以通过常量表达式(§1.6)表示的——情形。例如:
constexpr double C = 299792.458; // km/s
void f(double speed)
{
constexpr double local_max = 160.0/(60*60); // 160 km/h == 160.0/(60*60) km/s
static_assert(speed<C,"can't go that fast"); // 错误:speed必须是常量
static_assert(local_max<C,"can't go that fast"); // OK
// ...
}
如果A不为true,那么static_assert(A,S)就会把S作为编译器错误信息输出。
如果不想输出特定信息就把S留空,编译器会采用默认信息:
static_assert(4<=sizeof(int)); // 采用默认信息
默认信息的内容通常是static_assert所在的源码位置,外加一个表示断言谓词的字母。
static_assert最重要的用途体现在泛型编程(§7.2, §13.9)
中对用作参数的类型有特定要求时。
把信息从程序的一个位置向另一个位置传递,最主要且推荐的方法是通过函数调用。
执行功能所需的信息作为参数传入函数,生成的结果以返回值形式传出。例如:
int sum(const vector<int>& v)
{
int s = 0;
for (const int i : v)
s += i;
return s;
}
vector fib = {1,2,3,5,8,13,21};
int x = sum(fib); // x变成53
在函数间传递信息还有其它途径,比如全局变量(§1.5)、
指针和引用参数(§3.6.1)以及类对象(第4章)的共享状态。
全局变量极不推荐,它是恶名昭彰的出错根源;状态应该仅在特定函数中共享,
这些函数要共同实现出良好定义过的抽象(也就是:类的成员函数;§2.3)。
既然函数信息的传入和传出如此重要,就不难想见它们有多种方式。主要涉及:
参数传递和返回值传出的默认行为都是“复制”(§1.9),某些复制可以隐式优化为转移。
在sum()例子中,作为结果的int是以复制方式传出sum()的,
但对于可能容量巨大的vector,让它以复制方式进入sum()将会低效且毫无意义,
所以参数以引用方式传入(用&标示;§1.7)。
sum()无需修改其参数,这种不可变更性通过将vector声明为const(§1.6)来标示,
因此vector通过const-引用传递。
先研究怎么把值弄进函数里。默认情况下是复制(“传值(pass-by-value)”),
如果想指向调用者环境里的对象,可以通过引用(“传引用(pass-by-reference)”)。
例如:
void test(vector<int> v, vector<int>& rv) // v是传值;rv是传引用
{
v[1] = 99; // 修改v(局部变量)
rv[2] = 66; // 修改rv引用向的内容
}
int main()
{
vector fib = {1,2,3,5,8,13,21};
test(fib,fib);
cout << fib[1] << ' ' << fib[2] << '\n'; // 输出2 66
}
关心性能的时候,通常对小型的值传值而较大的值传引用。
此处的“小”意味着“复制的成本低廉东西”。
具体来说,“小”的含义因机器架构而异,
但是“不超过两或三个指针容量”是个颇受赞扬的指标。
如果出于性能原因选择传引用但无需修改参数,
可以像sum()例中那样,传const(常)引用。
这是迄今为止的优秀代码中的常见情形:它运行快还不易错。
函数参数带有缺省值的情形也很常见;就是说,一个首选值或者最常用的值。
可以使用*缺省函数参数(default function argument)*进行指定,例如:
void print(int value, int base =10); // 以“base”为基数输出value
print(x,16); // 十六进制
print(x,60); // 六十进制(苏美尔人)
print(x); // 使用缺省值:十进制
``
这是写法简化过的重载:
```cpp
void print(int value, int base); // 以“base”为基数输出value
void print(int value) // 以10为基数输出value
{
print(value,10);
}
完成计算之后,需要把结果弄出函数并交回给调用者。
跟参数一样,值返回也默认采用复制方式,并且这对于较小的对象很完美。
只有在把不属于函数局部作用域的东西转给调用者时,才通过“传引用”返回。例如:
class Vector {
public:
// ...
double& operator[](int i) { return elem[i]; } // 返回对第i个元素的引用
private:
double* elem; // elem指向一个数组,该数组承载sz个double
// ...
};
Vector的第i个元素的存在不依赖于取下标运算符,因此可以返回对它的引用。
另一方面,在函数的返回操作结束后,局部变量就消失了,所以不能返回指向它的指针或引用:
int& bad()
{
int x;
// ...
return x; // 糟糕:返回了指向局部变量x的引用
}
万幸,所有主流C++编译器都能捕获bad()里这个明显的错误。
返回引用或者较“小”类型的值很高效,但是要把大量信息传出函数该怎么办呢?
考虑这个示例:
Matrix operator+(const Matrix& x, const Matrix& y)
{
Matrix res;
// ... 对所有 res[i,j], res[i,j] = x[i,j]+y[i,j] ...
return res;
}
Matrix m1, m2;
// ...
Matrix m3 = m1+m2; // 没有复制
即便对时下的硬件而言,Matrix可能都非常(very)大,而且复制的代价高昂。
因此我们不进行复制,
而是为Matrix定义一个转移构造函数(move constructor)(§5.2.2),
从而以低廉的代价把Matrix从operator+()传出。
此处不需要抱残守缺地使用手动内存管理:
Matrix* add(const Matrix& x, const Matrix& y) // 复杂且易错的20世纪风格
{
Matrix* p = new Matrix;
// ... 对所有的 *p[i,j], *p[i,j] = x[i,j]+y[i,j] ...
return p;
}
Matrix m1, m2;
// ...
Matrix* m3 = add(m1,m2); // 仅复制一个指针
// ...
delete m3; // 这个操作太容易忘记
很遗憾,通过指针返回大型对象在老式代码里很常见,而且是个难以捕获错误的主要根源。
别写这样的代码。
注意,operator+()跟add()同样高效,但是定义简单、易于使用还不易出错。
如果函数搞不定手上的活儿,它可以抛出异常(§3.5.1)。
这可以避免因为充斥着测试“罕见问题”错误码而把代码搞得一团糟。
函数的返回类型可以从返回值本身推断出来。例如:
auto mul(int i, double d) { return i*d; } // 此处的“auto”意思是“推断返回类型”
这很方便,尤其对于泛型函数(函数模板(function template); §6.3.1)以及
lambda表达式(§6.3.3)来说,但是请谨慎采用,因为推导出来的类型会让接口不稳定:
对函数(或lambda表达式)内容的修改,会改变返回类型。
函数只能返回单独的一个值,但这个值可以是包含多个成员的类对象。
这使得我们得以高效地返回多个值。例如:
struct Entry {
string name;
int value;
};
Entry read_entry(istream& is) // 很菜的读取函数(更好的版本参看 §10.5)
{
string s;
int i;
is >> s >> i;
return {s,i};
}
auto e = read_entry(cin);
cout << "{ " << e.name << " , " << e.value << " }\n";
此处的{s,i}被用于构建Entry类型的返回值。
与之类似,可以把Entry的成员“拆包”到本地变量里:
auto [n,v] = read_entry(is);
cout << "{ " << n << " , " << v << " }\n";
auto [n,v]这句声明了两个局部变量n和v,
它们的类型从read_entry()的返回类型推导出来。
这个给类对象成员命名的机制叫结构化绑定(structured binding)。
考虑以下示例:
map<string,int> m;
// ... 填充 m ...
for (const auto [key,value] : m)
cout << "{" << key "," << value << "}\n";
按惯例,可以用const和&限定auto,例如:
void incr(map<string,int>& m) // 为m的每个元素自增1
{
for (auto& [key,value] : m)
++value;
}
在结构化绑定用于不包含私有数据的类时,绑定方式显而易见:
绑定行为定义的名称数量必须跟类里面的非静态数据成员数量相同,
绑定行为中的引入名称按次序对应成员变量。
与显式使用复合对象相比,这种代码的质量并无差异;
使用结构化绑定的主旨在于恰如其分地表达意图。
通过成员函数访问类对象的操作同样可行。例如:
complex<double> z = {1,2};
auto [re,im] = z+2; // re=3; im=2
conplex有两个数据成员,但它的接口包含两个访问函数,
分别是real()和imag()。
把一个complex<double>映射到两个局部变量,比如re和im,
是可行且高效的,但此种技巧不在此书的关注范畴。
#include它的头文件;§3.2;[CG: SF.5]。module的地方)以module替代头文件;§3.3。std),或在局部作用域内使用using-指令;using-指令放在头文件里;§3.4;[CG: SF.7]。try-代码块;§3.5.1, §3.5.2;[CG: E.6]。noexcept声明它;§3.5;[CG: E.12]。const)引用而非普通引用;§3.6.1;[CG: F.17]。int和float一样真实 — 道格·麦克罗伊本章及后续三章在避免牵涉过多细节的情况下,阐述了C++对抽象和资源管理的支持:
这语言构造被用来支持名为
*面向对象编程(object-oriented programming)*和
*泛型编程(generic programming)*的编程风格。
随后的第8-15章给出标准库的示例及应用。
C++的主要语言构造是类(class)。类是用户定义类型,在程序代码中代表某个概念。
每当程序的设计中出现一个概念、构想或实体等,我们就试图用一个类去代表它,
使其在代码里具象化,而不仅仅存在于脑子、设计文档或注释里。
借助一套精选的类构建的应用程序,远比直接用内置类型来得容易理解和靠谱。
特别是,程序库通常以类的形式呈现。
除类基本类型、运算符和语句之外的所有语言构造,
从本质上讲都是用来辅助定义更好的类以及更便捷地使用它们。
“更好”的意思是更准确、更易于维护、更高效、更优雅、更顺手、更具可读性、更符合直觉。
多数编程技术有赖于特定种类的类的设计和实现。程序员的需求和品味天差地别。
因此对类的支援近乎广阔无边。此处仅介绍对三种重要的类提供的支持:
浩如烟海的实用的类都可以被归为其中某一种。
还有更多的类是它们的细微变种或者用它们组合出来的。
*实体类(concrete class)*的基本思想是:它们的表现“恰如内置类型一样”。
例如:复数类和无限精度的整数类都非常类似于内置的int,
当然,它们各自特色的语意和运算除外。
与之类似,vector和string都类似于内置的数组,
但却能青出于蓝(§9.2, §10.3, §11.2)。
实体类型在定义方面的特征是:它的表征数据(representation)[^1]位于定义之中。
在许多重要的案例中,比如vector,
其表征数据是一个或多个指针——指向存储在别处的数据,
但其表征数据却出现在实体类的每个对象之中。
这使得具体的对象在运行时间和存储空间方面都效率上佳。
具体来说,它允许我们:
表征数据可以是私有的(像Vector的情形;§2.3)
并且仅允许通过成员函数访问,但它的存在是毫无疑问的。
因此,一旦表征数据有任何实质性的变动,用户就必须重新编译。
这是让实体类型跟内置类型行为一致的必要代价。
对于变动不频繁的类型,并且局部变量给出了关键的明确性和高效性的情况下,
这种重新编译是可接受的,而且通常都比较理想。
为增强灵活性,实体类型可以把其表征数据的绝大部分放在自由存储区(动态内存,堆)里,
并通过放置在类对象内的部分进行存取。
vector和string就是这样实现的;
它们可以被视为具有精心打造接口的资源操控器(resource handle)。
“经典的用户定义算术类型”是complex:
class complex {
double re, im; // 表征数据:两个double
public:
complex(double r, double i) :re{r}, im{i} {} // 用两个标量构造complex
complex(double r) :re{r}, im{0} {} // 用一个标量构造complex
complex() :re{0}, im{0} {} // complex的默认值:{0,0}
double real() const { return re; }
void real(double d) { re=d; }
double imag() const { return im; }
void imag(double d) { im=d; }
complex& operator+=(complex z)
{
re+=z.re; // 加至re和im
im+=z.im;
return *this; // 返回结果
}
complex& operator-=(complex z)
{
re-=z.re;
im-=z.im;
return *this;
}
complex& operator*=(complex); // 定义在类外某处
complex& operator/=(complex); // 定义在类外某处
};
这是个略微简化版本的标准库complex(§14.4)。
类定义本身仅包含特定运算——那些需要访问表征数据的。
表征数据是简单且常规的。
由于实用性原因,它必须兼容 Fortran 60年前定义的版本,所以需要一套常规运算。
除逻辑方面的要求之外,complex还必须高效,否则就没什么用了。
这意味着简单的运算必须是内联的。
就是说简单运算(如构造函数、+=和imag())的实现,在机器代码里不能有函数调用。
在类里定义的函数,默认就是内联的。
也可以显式要求内联——在函数声明前使用关键字inline。
具备工业强度的complex(比如标准库里那个)在是否内联方面的权衡很谨慎。
能够不带参数调用的构造函数叫默认构造函数(default constructor)。
所以complex()就是complex的默认构造函数。
类型定义过默认构造函数后,就为这种类型消除了未初始化变量的隐患。
返回实部和虚部的函数用了说明符const,
意思是此函数通过某个对象调被用时,不会修改该对象。
const成员函数既可以通过const对象也可以通过非const对象调用,
但是非const成员函数只能通过非const对象调用。例如:
complex z = {1,0};
const complex cz {1,3};
z = cz; // OK:赋值给非const变量
cz = z; // 错误:complex::operator=()是个非const成员函数
double x = z.real(); // OK: complex::real() 是个const成员函数
很多有用的运算无需直接访问complex的表征数据,因此可以与类定义分开:
complex operator+(complex a, complex b) { return a+=b; }
complex operator-(complex a, complex b) { return a-=b; }
complex operator-(complex a) { return {-a.real(), -a.imag()}; } // 一元负号
complex operator*(complex a, complex b) { return a*=b; }
complex operator/(complex a, complex b) { return a/=b; }
这个例子利用了参数传值的特性,因此修改参数不会影响调用者(作为参数)使用的变量,
还可以把结果用作返回值。
==和!=就很直白了:
bool operator==(complex a, complex b) // 相等
{
return a.real()==b.real() && a.imag()==b.imag();
}
bool operator!=(complex a, complex b) // 不等
{
return !(a==b);
}
complex sqrt(complex); // 定义在别处
// ...
complex类可以这样用:
void f(complex z)
{
complex a {2.3}; // 从 2.3 构造出 {2.3,0.0}
complex b {1/a};
complex c {a+z*complex{1,2.3}};
// ...
if (c != b)
c = -(b/a)+2*b;
}
编译器会把涉及complex数值的运算符转换成相应的函数调用。
例如:c!=b对应operator!=(c,b),1/a对应operator/(complex{1},a)。
用户定义的运算符(“重载运算符(overloaded operator)”)
应该谨慎并且遵循约定俗成的规则使用。
重载运算符的语法是语言规定好的,所以你无法定义出一元的/。
另外,不可以修改内置类型运算符的含义,所以你不能把+重定义成int的减法。
容器(container)是承载元素集合的对象。
我们把Vector称为容器,因为Vector类型的对象是容器。
如 §2.3 所定义,Vector作为double的容器是顺理成章的:
它易于理解,建立了有用的不变式(§3.5.2),对存取操作提供了区间有效性检查,
提供了size()以便使用它对元素进行循环访问。
但是它有个致命的缺陷:它用new给元素分配空间,却从未释放它们。
这就不太妙了,因为尽管C++定义了垃圾回收(§5.3)接口,
但却不能确保有个垃圾回收器把未使用的内存供新对象使用。
某些情况下你无法使用垃圾回收器,更常见的情形是:
出于逻辑和性能原因,你倾向于更精细地控制销毁行为。
我们需要一个机制确保把构造函数分配的内存释放掉;
这个机制就是析构函数(destructor):
class Vector {
public:
Vector(int s) :elem{new double[s]}, sz{s} // 构造函数:申请资源
{
for (int i=0; i!=s; ++i) // 初始化元素
elem[i]=0;
}
~Vector() { delete[] elem; } // 析构函数:释放资源
double& operator[](int i);
int size() const;
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
};
析构函数的名称是取补运算符~后跟类名;它跟构造函数互补。
Vector的构造函数用new运算符在自由存储区
(也叫堆(heap)或动态存储区(dynamic store))里分配了一些内存。
析构函数去清理——使用delete[]运算符释放那块内存。
普通delete删除单个对象,delete[]删除数组。
这些操作都不会干涉到Vector用户。
用户仅仅创建并使用Vector,就像对内置类型一样。例如:
void fct(int n)
{
Vector v(n);
// ... 使用 v ...
{
Vector v2(2*n);
// ... 使用 v 和 v2 ...
} // v2 在此被销毁
// ... 使用 v ..
} // v 在此被销毁
像int和char这些内置类型一样,Vector遵循相同的命名、作用域、
内存分配、生命期等一系列规则(§1.5)。
此处的Vector版本为简化而略掉了错误处理;参见 §3.5。
构造函数/析构函数 这对组合是很多优雅技术的根基。
确切的说,它是C++大多数资源管理技术(§5.3, §13.2)的根基。
考虑如下的Vector图示:
构造函数分配这些元素并初始化Vector响应的成员变量。析构函数释放这些元素。
这个*数据操控器模型(handle-to-data model)*常见于数据管理,
管理那些容量在对象生命期内可能变化的数据。
这个构造函数申请资源、析构函数释放资源的技术叫做
资源请求即初始化(Resource Acquisition Is Initialization)
或者RAII,为我们消灭“裸的new操作”,
就是说,避免在常规代码中进行内存分配,将其隐匿于抽象良好的实现中。
与之类似,“裸的delete操作”也该竭力避免。
避免裸new和裸delete,能大大降低代码出错的几率,
也更容易避免资源泄漏(§13.2)。
容器的作用是承载元素,因此很明显需要便利的方法把元素放入容器。
可以创建元素数量适宜的Vector,然后给这些元素赋值,但还有些更优雅的方式。
此处介绍其中颇受青睐的两种:
push_back():在序列的末尾(之后)添加一个新元素。它们可以这样声明:
class Vector {
public:
Vector(std::initializer_list<double>); // 用一个double列表初始化
// ...
void push_back(double); // 在末尾新增元素,把容量加一
// ...
};
在输入任意数量元素的时候,push_back()很有用,例如:
Vector read(istream& is)
{
Vector v;
for (double d; is>>d; ) // read floating-point values into d
v.push_back(d); // add d to v return v;
}
循环的终止条件是文件终止或格式错误。
在终止之前,每个读入的数字都被添加到Vector,
因此在函数结束的时候,v的容量是读入的元素数量。
我用了for循环,而非按惯例的while循环,目的是把d的作用域限制在循环内。
可以为Vector定义一个转移构造函数,以便在read()返回大量数据时的运行成本低廉,
§5.2.2 阐释此内容:
Vector v = read(cin); // 此处未对Vector的元素进行复制
有关std::vector提供的push_back()以及其它高效地修改vector容量的方式,
详见§11.2。
用于定义初始化列表构造函数的std::initializer_list是个标准库中的类型,
编译器对它有所了解:当我们用{}列表,比如{1,2,3,4}的时候,
编译器会为程序创建一个initializer_list对象。
因此,可以这样写:
Vector v1 = {1,2,3,4,5}; // v1有5个元素
Vector v2 = {1.23, 3.45, 6.7, 8}; // v2有4个元素
Vector的初始化列表构造函数可能长这样:
Vector::Vector(std::initializer_list<double> lst) // 用列表初始化
:elem{new double[lst.size()]}, sz{static_cast<int>(lst.size())}
{
copy(lst.begin(),lst.end(),elem); // 从lst复制到elem(§12.6)
}
很遗憾,标准库为容量和下标选择了unsigned整数,
所以我需要用丑陋的static_cast把初始化列表的容量显式转换成int。
这个选择有点书呆子气了,因为手写列表的元素数量基本不会超出有符号整数的上限
(16位整数是32,767,32位整数是2,147,483,647)。
但是类型系统没有常识。
它仅仅知道变量的取值值范围,而非具体的值,
所以即便实际上没违反规则,它依然要牢骚不断。
这种警告偶尔也能帮程序员避免严重的失误。
static_cast不对它转换的值进行检查;它相信程序员能运用得当。
可它也总有走眼的时候,所以如果吃不准,检查一下值。
应该尽可能避免显式类型转换
(通常也叫强制类型转换(cast),用来提醒你它可能会把东西弄坏)。
尽量把不带检查的类型转换限制在系统底层。它们极易出错。
还有两种类型转换分别是:
reinterpret_cast,它简单地把对象按一连串字节对待;
const_cast用于“转掉const限制”。
对类型系统的审慎运用以及设计良好的库,都有助于在顶层软件中消除不带检查的类型转换。
complex和Vector这些被称为实体类型,因为表征数据是它们定义的一部分。
因此,它们与内置类型相仿。
相反,*抽象类型(abstract type)*是把用户和实现细节隔绝开的类型。
为此,要把接口和表征数据解耦,并且要摒弃纯局部变量。
既然对抽象类的表征数据(甚至其容量)一无所知,
就只能把它的对象分配在自由存储区(§4.2.2),
并通过引用或指针访问它们(§1.7, §13.2.1)。
首先,我们定义Container类的接口,它将被设计成Vector更抽象的版本:
class Container {
public:
virtual double& operator[](int) = 0; // 纯虚函数
virtual int size() const = 0; // const 成员函数 (§4.2.1)
virtual ~Container() {} // 析构函数 (§4.2.2)
};
该类是一个用于描述后续容器的纯接口。
virtual这个词的意思是“后续可能在从此类派生的类中被重新定义”。
用virtual声明的函数自然而然的被称为虚函数(virtual function)。
从Container派生的类要为Container接口提供实现。
古怪的=0语法意思是:此函数是纯虚的(pure virtual);
就是说,某些继承自Container的类必须定义该函数。
因此,根本无法直接为Container类型定义对象。例如:
Container c; // 报错:抽象类没有自己的对象
Container* p = new Vector_container(10); // OK:Container作为接口使用
Container只能用做作接口,
服务于那些给operator[]()和size()函数提供了实现的类。
带有虚函数的类被称为抽象类(abstract class)。
Container可以这样用:
void use(Container& c)
{
const int sz = c.size();
for (int i=0; i!=sz; ++i)
cout << c[i] << '\n';
}
请注意use()在使用Container接口时对其实现细节一无所知。
它用到size()和[ ],却完全不知道为它们提供实现的类型是什么。
为诸多其它类定义接口的类通常被称为多态类型(polymorphic type)。
正如常见的抽象类,Container也没有构造函数。毕竟它不需要初始化数据。
另一方面,Container有一个析构函数,并且还是virtual的,
以便让Container的派生类去实现它。
这对于抽象类也是常见的,因为它们往往通过引用或指针进行操作,
而借助指针销毁Container对象的人根本不了解具体用到了哪些资源。
抽象类Container仅仅定义接口,没有实现。
想让它发挥作用,就需要弄一个容器去实现它接口规定的那些函数。
为此,可以使用一个实体类Vector:
class Vector_container : public Container { // Vector_container 实现了 Container
public:
Vector_container(int s) : v(s) { } // s个元素的Vector
~Vector_container() {}
double& operator[](int i) override { return v[i]; }
int size() const override { return v.size(); }
private:
Vector v;
};
:public可以读作“派生自”或者“是……的子类型”。
我们说Vetor_container派生(derived)自Container,
并且Container是Vetor_container的基类(base)。
还有术语把Vetor_container和Container分别称为
子类(subclass)和亲类(superclass)。
我们说派生类继承了其基类的成员,
所以这种基类和派生类的关系通常被称为继承(inheritance)。
我们这里的operator[]()和size()*覆盖(override)
*了基类Container中对应的成员。
我明确使用override表达了这个意向。
这里的override可以省略,但是明确使用它,可以让编译器查错,
比如函数名拼写错误,或者virtual函数和被其覆盖的函数之间的细微类型差异等等。
在较大的类体系中明确使用override格外有用,否则就难以搞清楚覆盖关系。
这里的析构函数(~Vector_container())
覆盖了基类的析构函数(~Container())。
请注意,其成员的析构函数(~Vector)
被该类的析构函数(~Vector_container())隐式调用了。
对于use(Container&)这类函数,使用Container时不必了解其实现细节,
其它函数要创建具体对象供它操作的。例如:
void g()
{
Vector_container vc(10); // 十个元素的Vector
// ... 填充 vc ...
use(vc);
}
由于use()只了解Container接口而非Vector_container,
它就可以对Container的其它实现同样有效。例如:
class List_container : public Container { // List_container implements Container
public:
List_container() { } // empty List
List_container(initializer_list<double> il) : ld{il} { }
~List_container() {}
double& operator[](int i) override;
int size() const override { return ld.size(); }
private:
std::list<double> ld; // double类型的(标准库)列表 (§11.3)
};
double& List_container::operator[](int i)
{
for (auto& x : ld) {
if (i==0)
return x;
--i;
}
throw out_of_range{"List container"};
}
此处的表征数据是标准库的list<double>。
一般来说,我不会给list容器实现取下标操作,
因为list取下标操作的性能比vector差得一塌糊涂。
但我在这里只是展示一个略有点偏激的实现。
某函数可以创建一个List_container,然后让use()去使用它:
void h()
{
List_container lc = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
use(lc);
}
这里的重点是,use(Container&)不知道它的参数是Vector_container、
List_container,还是什么其它容器类型;它也没必要知道。
它可以使用任意类型的Container。它知道的仅仅是Container定义的接口。
因此,就算是List_container的实现发生改变,
甚至是使用一个派生自Container的全新的类,
use(Container&)都不需要重新编译。
与这种灵活性密不可分的是:必须通过指针或引用去操作对象(§5.2, §13.2.1)。
复习一下Container的用法:
void use(Container& c)
{
const int sz = c.size();
for (int i=0; i!=sz; ++i)
cout << c[i] << '\n';
}
use()中的c[i]调用是怎么解析到对应的operator[]()呢?
当h()调用use()时,List_container的operator[]()必须被调用。
g()调用use()时,Vector_container的operator[]()必须被调用。
想要实现这种解析,Container对象必须包含某种信息,
以便在运行时找到正确的待调用函数。
常见的实现技术是:编译器把虚函数的名称转换成一个指向函数指针表的索引。
这个表格通常被称为虚函数表(virtual function table),或者简称vtbl。
每个带有虚函数的类都有自己的vtbl以确认其虚函数。这可以图示如下:
vtbl中的函数能够正确地使用其对象,
即便调用者对该对象的容量以及数据布局全都一无所知。
调用者的实现仅需要知道某个Container中指向
vtbl指针的位置以及每个待用虚函数的索引。
虚函数调用机制几乎能做到与“常规函数调用”机制同样高效(性能差别不到25%)。
其空间消耗是带有虚函数的类的每个对象一个指针,再加上每个类一个vtbl。
Container是个很简略的类层次示例。
*类层次(class hierarchy)*是一组类,
通过派生(比如 : public)创建,按棱形格次序排列。
类层次用来表示具有层次关系的概念。
比如“消防车是一种卡车,卡车是一种车”,以及“笑脸图是一种圆圈,圆圈是一种图形”。
巨大的,具有成百上千个类,还即深又宽的类体系也属平常。
作为一个半真实的经典案例,考虑一下屏幕上的形状:
箭头代表继承关系。例如:Circle类派生自Shape类。
习惯上,类层次把最基础的类作为根,自上而下朝向派生(定义更晚)类生长。
为了用代码表示这个简单的图示,就必须先声明一个类,以定义所有类型的通用属性:
class Shape {
public:
virtual Point center() const =0; // 纯虚函数
virtual void move(Point to) =0;
virtual void draw() const = 0; // 在“画布”上绘制
virtual void rotate(int angle) = 0;
virtual ~Shape() {} // 析构函数
// ...
};
自然而然的,此接口是个抽象类:就表征数据而言,各种Shape之间
(除指向vtbl位置的指针之外)*毫无(nothing)*共通之处。
根据这个定义,可以写一个通用的函数操纵一个vector,其中的元素是指向图形的指针:
void rotate_all(vector<Shape*>& v, int angle) // 把v的元素旋转给定角度
{
for (auto p : v)
p->rotate(angle);
}
要定义特定的图形,必须指明它是个Shape,定义它特有的属性(包括其虚函数):
class Circle : public Shape {
public:
Circle(Point p, int rad); // 构造函数
Point center() const override
{
return x;
}
void move(Point to) override
{
x = to;
}
void draw() const override;
void rotate(int) override {} // 优美且简洁的算法
private:
Point x; // 圆心
int r; // 半径
};
截至目前,Shape和Circle的例子跟Container相比还没有什么亮点,
请接着往下看:
class Smiley : public Circle { // 用圆圈作为笑脸的基类
public:
Smiley(Point p, int rad) : Circle{p,rad}, mouth{nullptr} { }
~Smiley() {
delete mouth;
for (auto p : eyes)
delete p;
}
void move(Point to) override;
void draw() const override;
void rotate(int) override;
void add_eye(Shape* s)
{
eyes.push_back(s);
}
void set_mouth(Shape* s);
virtual void wink(int i); // 让第i只眼做“飞眼”
// ...
private:
vector<Shape*> eyes; // 一般是两只眼睛
Shape* mouth;
};
vector的成员函数push_bach()把参数复制进vector(这里是eyes),
让它成为末尾的元素,并且把 vector 的容量增一。
现在,可以利用Smiley的基类和成员函数draw()
的调用来定义Smiley::draw()了:
void Smiley::draw() const
{
Circle::draw();
for (auto p : eyes)
p->draw();
mouth->draw();
}
请注意,Smiley把它的眼睛保存在一个标准库的vector里,
并且会在析构函数中把它们销毁。
Shape的析构函数是virtual的,而Smiley又覆盖了它。
虚析构函数对于抽象类来说是必须的,
因为操控派生类的对象通常是借助抽象基类提供的接口进行的。
具体地说,它可能是通过其基类的指针被销毁的。
然后,虚函数调用机制确保正确析构函数被调用。
该析构函数则会隐式调用其基类和成员变量的析构函数。
在这个简化过的例子中,把眼睛和嘴巴准确放置到代表脸的圆圈中,是程序员的的任务。
在以派生方式定义一个新类时,我们可以添加新的 成员变量 或/和 运算。
这带来了极佳的灵活性,同时又给逻辑混乱和不良设计提供了温床。
类的层次结构有两个益处:
接口继承(interface inheritance):
派生类对象可以用在任何基类对象胜任的位置。
就是说,基类充当了派生类的接口。
Container和Shape这两个类就是例子。
这种类通常是抽象类。
实现继承(implementation inheritance):
基类的函数和数据直接就是派生类实现的一部分。
Smiley对Circle的构造函数、Circle::draw()的调用就是这方面的例子。
这种基类通常具有成员变量和构造函数。
实体类——尤其是带有少量表征数据那些——跟内置类型非常类似:
通常作为局部变量定义,通过名称进行访问,复制来复制去的,凡此种种。
位于类层次结构中那些就不一样了:
它们通常用new分配在自由存储区中,通过指针或引用访问它们。
举个例子,有这么个函数,它从输入流读取描述图形的数据,
然后构造对应的Shape对象:
enum class Kind { circle, triangle, smiley };
Shape* read_shape(istream& is) // 从输入流is读取图形描述
{
// ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
switch (k) {
case Kind::circle:
// 把圆圈的数据 {Point,int} 读取到p和r
return new Circle{p,r};
case Kind::triangle:
// 把三角形的数据 {Point,Point,Point} 读取到p1、p2、和p3
return new Triangle{p1,p2,p3};
case Kind::smiley:
// 把笑脸的数据 {Point,int,Shape,Shape,Shape} 读取到p、r、e1、e2、和m
Smiley* ps = new Smiley{p,r};
ps->add_eye(e1);
ps->add_eye(e2);
ps->set_mouth(m);
return ps;
}
}
某个程序可以这样使用此图形读取器:
void user()
{
std::vector<Shape*> v;
while (cin)
v.push_back(read_shape(cin));
draw_all(v); // 为每个元素调用 draw()
rotate_all(v,45); // 为每个元素调用 rotate(45)
for (auto p : v) // 别忘了销毁元素(指向的对象)
delete p;
}
显而易见,这个例子被简化过了——尤其是错误处理相关的内容——
但它清晰地表明了,user()函数对其所操纵图形的类型一无所知。
user()的代码仅需要编译一次,在程序加入新的Shape之后可以继续使用。
请留意,没有任何图形的指针流向了user()之外,因此user()就要负责回收它们。
这实用运算符delete完成,且严重依赖Shape的虚析构函数。
因为这个析构函数是虚的,delete调用的是距基类最远的派生类里的那个。
这至关重要,因为可能获取了各式各样有待释放的资源(比如文件执柄[^2]、锁及输出流)。
在本例中,Smiley要删除其eyes和mouth的对象。
删完这些之后,它又去调用Circle的析构函数。
对象的构建通过构造函数“自下而上”(从基类开始),
而销毁通过虚构函数“从顶到底”(从派生类开始)。
read_shape()函数返回Shape*,以便我们对所有Shape一视同仁。
但是,如果我们想调用某个派生类特有的函数,
比方说Smiley里的wink(),该怎么办呢?
我们可以用dynamic_cast运算符问这个问题
“这个Shape对象是Smiley类型的吗?”:
Shape* ps {read_shape(cin)};
if (Smiley* p = dynamic_cast<Smiley*>(ps)) { // ... ps指向一个 Smiley 吗? ...
// ... 是 Smiley;用它
}
else {
// ... 不是 Smiley,其它处理 ...
}
在运行时,如果dynamic_cast的参数(此处是ps)指向的对象不是期望的类型
(此处是Smiley)或其派生类,dynamic_cast就返回nullptr。
当一个指向其它派生类对象的指针是有效参数时,
我们把dynamic_cast用于指针类型。
然后测试结果是否为nullptr。
这种测试一般放在条件表达式的初始化参数位置,很便利。
如果其它类型不可接受,我们就直接把dynamic_cast用于引用类型。
如果该对象不是期望的类型,dynamic_cast抛出一个bad_cast异常:
Shape* ps {read_shape(cin)};
Smiley& r {dynamic_cast<Smiley&>(*ps)}; // 某处可以捕捉到 std::bad_cast
有节制地使用dynamic_cast可以让代码整洁。
如果能避免用到类型信息,就可以写出简洁且高效的代码,
但类型信息系偶尔会被丢掉而且必须找回来。
发生这种情况,通常是我们把对象传给了一个系统,
该系统以某个特定基类指定的接口接收了这个对象。
当该系统后续把这个对象传回来时,我们可能要找回初始的类型。
类似于dynamic_cast的运算被称为“属于……类别”或者“是……实例”运算。
经验丰富的程序员可能注意到了我有三个纰漏:
Smiley的程序员可能忘记delete指向mouth的指针read_shape()的用户可能忘记delete返回的指针Shape指针容器的所有者可能忘记delete它们指向的对象从这个意义上讲,指向自由存储区中对象的指针是危险的:
“直白老旧的指针(plain old pointer)”不该用于表示所有权。例如:
void user(int x)
{
Shape* p = new Circle{Point{0,0},10};
// ...
if (x<0) throw Bad_x{}; // 资源泄漏潜在危险
if (x==0) return; // 资源泄漏潜在危险
// ...
delete p;
}
除非x为正数,否则就会导致资源泄漏。
把new的结果赋值给“裸指针”就是自找麻烦。
这类问题有一个简单的解决方案:在需要释放操作时,
使用标准库的unique_ptr(§13.2.1) 而非“裸指针”:
class Smiley : public Circle {
// ...
private:
vector<unique_ptr<Shape>> eyes; // 一般是两只眼睛
unique_ptr<Shape> mouth;
};
这是一个示例,展示简洁、通用、高效的资源管理(§5.3)技术。
这个修改有个良性的副作用:我们不需要再为Smiley定义析构函数了。
编译器会隐式生成一个,以便将vector中的unique_ptr(§5.3)销毁。
使用unique_ptr的代码跟用裸指针的代码在效率方面完全一致。
重新审视read_shape()的使用:
unique_ptr<Shape> read_shape(istream& is) // 从输入流is读取图形描述
{
// ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
switch (k) {
case Kind::circle:
// 把圆圈的数据 {Point,int} 读取到p和r
return unique_ptr<Shape>{new Circle{p,r}};
// ...
}
void user()
{
vector<unique_ptr<Shape>> v;
while (cin)
v.push_back(read_shape(cin));
draw_all(v); // 为每个元素调用 draw()
rotate_all(v,45); // 为每个元素调用 rotate(45)
} // 所有 Shape 都隐式销毁了
现在每个对象都被一个unique_ptr持有,当不再需要这个unique_ptr,
也就是它离开作用域的时候,就会销毁持有的对象。
想让unique_ptr版本的user()正常运作,
就需要能够接受vector<unique_ptr<Shape>>版本的
draw_all()和rotate_all()。
写很多这种_all()函数很烦冗,所以 §6.3.2 会介绍一个替代方案。
[1] 用代码直接表达意图;§4.1; [CG: P.1]。
[2] 实体类型是最简单的类。情况许可的时候,
请优先用实体类,而非更复杂的类或者普通的数据结构 §4.2; [CG: C.10]。
[3] 用实体类去表示简单的概念;§4.2。
[4] 对于性能要求严苛的组件,优先用实体类,而不是选择类层次;§4.2。
[5] 定义构造函数去处理对象的初始化;§4.2.1, §5.1.1; [CG: C.40] [CG: C.41]。
[6] 只在一个函数需要直接访问类的表征数据时,把它定义为成员函数;§4.2.1; [CG: C.4]。
[7] 自定义运算符的主要用途应该是模拟传统运算;§4.2.1; [CG: C.160]。
[8] 为对称运算符使用非成员函数;§4.2.1; [CG: C.161]。
[9] 把不修改对象状态的成员函数定义为const;§4.2.1。
[10] 如果构造函数申请了资源,这个类就需要虚构函数去释放这个资源;§4.2.2; [CG: C.20]。
[11] 避免使用“裸的”new和delete操作;§4.2.2; [CG: R.11]。
[12] 利用资源操控器和 RAII 去管理资源;§4.2.2; [CG: R.1]。
[13] 如果类是容器,请给它定义一个初始化列表构造函数;§4.2.3; [CG: C.103]。
[14] 需要接口和实现完全分离的时候,请用抽象类作为接口;§4.3; [CG: C.122]。
[15] 请通过指针和引用访问多态对象;§4.3。
[16] 抽象类通常不需要构造函数;§4.3; [CG: C.126]。
[17] 对于与生俱来就具有层次结构的概念,请使用类层次结构表示它们;§4.5。
[18] 带有虚函数的类,应该定义虚析构函数;§4.5; [CG: C.127]。
[19] 在较大的类层次中,显式用override进行覆盖;§4.5.1; [CG: C.128]。
[20] 设计类层次的时候,要分清实现继承和接口继承;§4.5.1; [CG: C.129]。
[21] 在不可避免要在类层次中进行辨别的时候,使用dynamic_cast;§4.5.2;[CG:C.146]。
[22] 当“转换目标不属于所需的类”需要报错时,就把dynamic_cast用于引用类型;
§4.5.2; [CG: C.147]。
[23] 如果“转换目标不属于所需的类”可接受,就把dynamic_cast用于指针类型;
§4.5.2; [CG: C.148]。
[24] 对于通过new创建的对象,用unique_ptr和shared_ptr避免忘记delete;§4.5.3; [CG: C.149]。
请原谅我硬搞出来一个词,因为它在本章出现了太多次。其实按我的理解,更合理的说法是“用到的所有数据”,本质上是在说“数据”,可是仅译成“数据”容易跟“data”混淆;而“representation”在这是名词“表示”的意思,说成“用到的所有数据的表示”又太拗口。如果改成“数据表示”,容易被误会成主谓短语,用在句子的语境中,容易引起误解;改成“表示数据”又容易被误会成动宾短语,引发类似的误解。所以我把“表示”改成了“表征”,这样无论理解成“名词+名词”还是“形容词+名词”的偏正短语,意思都是“什么什么样的数据”,同时还能跟单纯的“数据(data)”区分开来。—— 译者注
一般翻译为“文件句柄”,我很讨厌“句柄”这词,它让我在初Windows编程时困惑了很久,我在实践了一段时间之后,慢慢理解到,这个英文handle是很直白的,意思就是“把手”,我觉得翻译成“把手”要比“句柄”好得多。参阅了[为什么handle会被翻译成句柄? - 薛非的回答 - 知乎]后,我决定改一个基本能望文生义的两字词汇去替代“句柄”,handle本义的“柄”作为名词词尾保留,同时不使用常见单词,因为会导致“难以作为关键词搜索”的问题。首字存在多个备选:1,“操”,取自“操作”一词,感觉最适宜,但因为有人把“操”污名化出“肏”的意义,为免给用户带来“解释吧,没必要;不解释吧,又让文盲耻笑”的困境,不得不忍痛放弃了;2,“持”,初望难免有“静止不动”的意味(例如“僵持”),不合适;3,“握”,太直白,容易跟常规词汇冲突。最后考虑了“执柄”,“执”乍看就是拿着,后续可以动也可以不动,有成词但不常见,故选它,希望初次接触这个概念的人不至于太过困惑。—— 译者注
有些操作,比如初始化、赋值、拷贝和转移,从语言规则所做的假设来看,属于基础操作。
而其它一些,比如==和<<,其意义约定俗成,不遵从这种约定,麻烦就大了。
在众多设计中,对象的构建至关重要。
其用法的多样性体现在语言特性对初始化操作支持的广度和灵活性上。
类型的构造函数、析构函数以及拷贝、转移这些操作的逻辑关系盘根错节。
定义它们的时候一定要彼此配合,否则就会带来逻辑或者性能方面的问题。
如果一个类X有析构函数,它执行一些不容忽视的任务,
比如自由存储区资源回收或者释放锁,那这个类很可能就需要整一套全活了:
class X {
public:
X(Sometype) // “常规构造函数”:创建对象
X(); // 缺省构造函数
X(const X&) // 拷贝构造函数
X(X&&); // 转移构造函数
X& operator=(const X&); // 拷贝赋值:清理目标对象并拷贝
X& operator=(X&&); // 转移赋值:清理目标对象并转移
~X(); // 析构函数:清理资源
// ...
};
对象的拷贝和转移有五种情形:
赋值操作采用拷贝或者转移运算符。原则上,其它情形均使用拷贝或者构造函数。
不过,拷贝或转移构造函数的调用通常会被优化掉,方法是把初始值直接在该对象上进行构造。例如:
X make(Sometype);
X x = make(value);
此处,编译器通常把make()返回的X在x上构造;从而消除(“省略”)掉一次拷贝。
除了初始化具名对象和分配在自由存储区上的对象之外,
构造函数也被用于构造临时对象,还被用于实现显式类型转换。
除了“常规构造函数”,以下这些成员函数也会在按需被编译器生成。
如果想显式操控默认实现的生成,可以这样:
class Y {
public:
Y(Sometype);
Y(const Y&) = default; // 我确定想要默认的拷贝构造函数
Y(Y&&) = default; // 以及默认的转移构造函数
// ...
};
如果你显式生成了一部分默认实现,那其它的缺省定义就不会再生成了。
如果某个类具有指针成员变量,那么显式定义拷贝和转移操作通常是比较明智的。
原因是指针可能指向某个资源,需要类去delete,这种情况下,
默认将成员采用的逐个复制操作会导致错误。
又或者,这个指针指向的资源,要求类绝对不能delete。
无论属于哪种情况,代码的读者都需要弄清楚。相关例子,请参阅 §5.2.1。
一个值得力荐的规则(有时候也叫零规则(the rule of zero))是:
要么定义全部基础操作,要么全不定义(全用默认实现)。例如:
struct Z {
Vector v;
string s;
};
Z z1; // 默认初始化 z1.v 和 z1.s
Z z2 = z1; // 默认拷贝 z1.v 和 z1.s
本例中,在有需求的情况下,
编译器会合成逐成员操作的默认构造函数、拷贝、转移和析构函数,且语意全都正确。
与=default相对,=delete用于表示拒绝生成某个操作。
类层次中的基类是个经典的例子,这种情况下,我们要禁止将成员逐个复制的操作。例如:
class Shape {
public:
Shape(const Shape&) =delete; // 没有拷贝操作
Shape& operator=(const Shape&) =delete;
// ...
};
void copy(Shape& s1, const Shape& s2)
{
s1 = s2; // 报错:Shape的拷贝操作已移除
}
=delete会导致被delete函数的使用触发编译器报错;
=delete可用于屏蔽任何函数,不仅仅是基本成员函数。
仅接受单个参数的构造函数定义了源自参数类型的转换。
例如,complex(§4.2.1)有个源自double的构造函数:
complex z1 = 3.14; // z1 成为 {3.14,0.0}
complex z2 = z1*2; // z2 成为 z1*{2.0,0} == {6.28,0.0}
有时候这种隐式转换很理想,但也不总那么理想。
例如,Vector(§4.2.2)有一个源自int的构造函数:
Vector v1 = 7; // OK:v1有7个元素
这通常都不是个隐患,标准库的vector就禁止int到vector的“类型转换”。
避免这一问题的途径是仅允许显式“类型转换”;就是说这样定义构造函数:
class Vector {
public:
explicit Vector(int s); // 不会隐式从 int 转换到 Vector
// ...
};
效果是这样的:
Vector v1(7); // OK: v1 有 7 个元素
Vector v2 = 7; // 报错:不能从int隐式转换到Vector
涉及类型转换到时候,更多的类型像Vector,而不像complex,
因此,应该把explicit用于单参数的构造函数,除非有好理由不用它。
当某个类定义了数据成员,我们应该为其提供默认初始值,这被称为成员变量默认初始值。
考虑一下complex(§4.2.1)的这个新版本:
class complex {
double re = 0;
double im = 0; // 表征数据:两个默认值为 0.0 的 double
public:
complex(double r, double i) :re{r}, im{i} {} // 用两个标量构造complex:{r,i}
complex(double r) :re{r} {} // 用一个标量构造complex:{r,0}
complex() {} // 默认的complex:{0,0}
// ...
}
只要构造函数未给定一个值,就会应用默认值。
这可以简化编码,并有助于避免粗心大意导致的成员变量未初始化。
默认情况下,对象可以被复制。无论对用户定义类型还是内置类型,这都成立。
默认的复制是逐成员的:复制每个成员变量。例如使用 §4.2.1 的complex:
void test(complex z1)
{
complex z2{z1}; // 拷贝初始化
complex z3;
z3 = z2; // 拷贝赋值
// ...
}
现在z1、z2和z3的值相同,因为不论赋值还是初始化函数,
都把两个成员变量全复制了。
当我们设计类时,必须要始终考虑是否以及如何复制一个对象。
对于简单的实体类型,逐成员复制通常是正确的语意。
但是对于某些精细的实体类型,比如Vector,
逐成员复制并非正确语意,对于抽象类型则几乎从来不是。
当某个类是一个资源执柄(resource handle)
——就是说,当该类负责通过指针访问某个对象——默认的逐成员复制通常是个灾难。
逐成员复制会违反资源执柄的不变式(§3.5.2)。
例如,默认复制会导致Vector的一个副本指向与原件相同的元素:
void bad_copy(Vector v1)
{
Vector v2 = v1; // 把v1的表征数据复制到v2
v1[0] = 2; // v2[0] 现在也是2!
v2[1] = 3; // v1[1] 现在也是3!
}
假设v1有四个元素,结果可以图示如下:
万幸的是,Vector具有析构函数的事实强烈暗示了“默认(逐成员)的复制语意不对”,
而编译器起码应该对本例给出警告。
我们需要定义一个更优的复制语意。
某个类对象的复制由两个成员函数定义:
拷贝构造函数(copy constructor)和拷贝赋值函数(copy assignment):
class Vector {
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
public:
Vector(int s); // 构造函数:建立不变式,申请资源
~Vector() { delete[] elem; } // 析构函数:释放资源
Vector(const Vector& a); // 拷贝构造
Vector& operator=(const Vector& a); // 拷贝赋值
double& operator[](int i);
const double& operator[](int i) const;
int size() const;
};
一个合格的Vector拷贝构造函数的定义,要按元素数量所需分配存储空间,
然后把元素复制到里面,以便在复制后每个Vector都有它自己的元素副本:
Vector::Vector(const Vector& a) // 拷贝构造函数
:elem{new double[a.sz]}, // 为元素分配存储空间
sz{a.sz}
{
for (int i=0; i!=sz; ++i) // 复制元素
elem[i] = a.elem[i];
}
现在,v2=v1示例的结果可以这样表示:
在成员函数里,名称this是预定义的,它指向调用该成员函数的那个对象。
我们可以通过定义拷贝构造函数和拷贝赋值函数来控制复制操作,
但对于庞大的容器,复制操作代价高昂。
在传递一个对象给函数时,可以采用引用,从而避免复制的代价,
但却不能返回一个指向局部对象的引用作为结果
(在调用者查看的时候,局部变量就已经被销毁了),考虑这个:
Vector operator+(const Vector& a, const Vector& b)
{
if (a.size()!=b.size())
throw Vector_size_mismatch{};
Vector res(a.size());
for (int i=0; i!=a.size(); ++i)
res[i]=a[i]+b[i];
return res;
}
从+返回一个值,涉及到复制局部变量res出去,并且放置于某个调用者可以访问的位置。
该+操作可以这么用:
void f(const Vector& x, const Vector& y, const Vector& z)
{
Vector r;
// ...
r = x+y+z;
// ...
}
这起码要复制Vector两次(在每次调用+时候)。
如果某个Vector很大,比方说有 10,000 个 double,可就太令人汗颜了。
最汗颜的是operator+()里的res复制后再没用过。
我们不是真的想复制;而是想把结果从函数里弄出来:
想要*转移(move)一个Vector,而非复制(copy)*它。
万幸的是,我们可以表明这个意图:
class Vector {
// ...
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值函数
Vector(Vector&& a); // 转移构造函数
Vector& operator=(Vector&& a); // 转移赋值函数
};
根据这个定义,编译器在把返回值传出函数时,
将使用*转移构造函数(move constructor)*执行。
这意味着,r=x+y+z将不涉及Vector的复制。
取而代之的是,Vector仅被转移了。
与之相似,Vector转移构造函数的定义也是小菜一碟:
Vector::Vector(Vector&& a)
:elem{a.elem}, // 从a“拿来元素”
sz{a.sz}
{
a.elem = nullptr; // a现在没有元素了
a.sz = 0;
}
&&的意思是“右值引用(rvalue reference)”,是个可以绑定到右值的引用。
“右值(rvalue)”这词有意跟“左值(lvalue)”相对,
“左值”大体上是“可以出现在赋值左侧的东西”。
因此右值——大概其——是不能对其赋值的东西,比如函数调用返回的某个整数。
这样,右值引用就是一个*没有其他人(nobody else)*能为其赋值的东西,
所以可以安全地“偷取”其值。
operator+()中的局部变量res就是一个例子。
转移构造函数不接受const参数:毕竟,它本该从其参数中把值移除。
*转移赋值函数(move assignment)*的定义相似。
每当右值引用作为初值,或作为赋值右侧的值,就应用转移操作。
转移操作之后,“移自”对象所处的状态应允许执行析构函数。
一般来说,也允许向一个“移自”对象赋值。
标准库算法(第12章)假定如此。
我们的Vector也是这样。
程序员知道某个值再也不会用到了,但编译器不见得这么聪明,程序员可以指明:
Vector f()
{
Vector x(1000);
Vector y(2000);
Vector z(3000);
z = x; // 执行复制(x可能在f()后续被用到)
y = std::move(x); // 执行转移(转移复制函数)
// ... 最好别在这用x了 ...
return z; // 执行转移
}
标准库函数move()并不真移动什么。
而是返回其(我们想转移的)参数的引用——右值引用;实际上进行了类型转换(§4.2.3)。
在return执行之前,情况是:
当我们从f()返回后,z就在其元素被return移出f()后而销毁了。
不过y的析构函数则将delete[]其元素。
编译器(由C++标准规定)对消除大多数跟初始化相关的复制操作负有义务,
因此转移构造函数的调用并不如你想的那样频繁。
这种*拷贝消除(copy elision)*甚至消除了转移操作中极微小的性能损失。
另一方面,隐式消除赋值操作中的复制和转移操作,几乎是不可能的,
因此,转移赋值对性能影响巨大。
定义构造函数、拷贝操作、转移操作和析构函数后,
程序员能完全控制所持资源(比如容器的元素)的生命期。
此外,转移构造函数可以将对象从一个作用域移到另一个,轻而易举且代价低廉。
这样,对于无法或者不该通过复制方式取出作用域的对象,就能轻易低廉地转移出来。
考量标准库的thread,它相当于一个并发行为(§15.2),
以及承载上百万个double的Vector。
前者无法复制,后者不该复制。
std::vector<thread> my_threads;
Vector init(int n)
{
thread t {heartbeat}; // 并发运行心跳(在别的线程里)
my_threads.push_back(std::move(t)); // 把 t 转移到 my_threads(§13.2.2)
// ... 其它初始化操作 ...
Vector vec(n);
for (int i=0; i!=vec.size(); ++i)
vec[i] = 777;
return vec; // 把 vec 转移出 init()
}
auto v = init(1'000'000); // 开始心跳并初始化 v
类似于Vector和thread这种资源执柄,在任何情况下,都是内置指针的优秀替代品。
事实上,诸如unique_ptr这种标准库的“智能指针(smart pointer)”,
本身就是资源执柄(§13.2.1)。
我用了标准库的vector承载thread,因为在 §6.2 之前,
我们简单的Vector还不具备元素类型参数化的能力。
就像让new和delete在应用代码中消失那样,我们可以让指针匿踪在资源执柄身后。
这两种情况的结果都是更简洁、更易维护的代码,而且不增加额外负担。
确切地说,可以达成强资源安全(strong resource safety);
就是说,对于常规意义上的资源来说,可以消灭资源泄漏的情况。
这种例子有:vector持有内存、thread持有系统线程,以及fstream持有文件执柄。
在很多语言中,资源管理主要是委派给某个资源回收器。
C++也有个垃圾回收接口,以便你接入一个资源回收器。
但是我认为垃圾回收器是个无奈之选,
在更整洁、更通用也更接地气的资源管理器替代方案无能为力之后,才会用它。
我的观点是不要制造垃圾,这样就化解了对垃圾回收器的需求:禁止乱丢垃圾!
垃圾回收大体是个全局内存管理机制。
精巧的实现可以值回性能开销,但计算机系统越来越趋向于分布式
(想想缓存、多核心,以及集群),局部性比以往更重要了。
还有,内存并非仅有的资源。
资源是任何必须使用前(显式或隐式)申请,使用后释放的东西。
例子有内存、锁、socket、文件执柄以及线程执柄。
如你所料,内存以外的资源被称为非内存资源(non-memory resource)。
优秀的资源管理系统能处理所有类型的资源。
在任何长时间运行的系统里,泄漏必须避免,但资源的过度占用跟泄漏一样糟糕。
例如:如果一个系统把内存、锁、文件等,都持有双倍时长,那它就需要双倍的资源供给。
在采用垃圾回收器之前,请先系统化地使用资源执柄:
让每个资源都有个位于某个作用域内的有所有者,并且所有者在作用域结束处释放该资源。
在C++里,这叫
RAII(资源请求即初始化 Resource Acquisition Is Initialization),
它已经跟错误处理机制中的异常整合在一起。
资源可以通过转移的语意或者“智能指针”,从一个作用域移到另一个作用域,
还可以通过“共享指针(shared pointer)”表示共享的所有权。
在C++标准库里,RAII无处不在:
例如内存(string、vector、map、unordered_map等),
文件(ifstream、ofstream等),线程(thread),
锁(lock_guard,unique_lock等),
以及常规对象(通过unique_ptr和shared_ptr)。
其效果是隐式的资源管理,它在寻常使用中不可见,且降低了资源持有时长。
部分运算定义在特定类型中,具有约定俗成的意义。
程序员和库(特别是标准库)对这些约定俗成的意义会有想当然的观点,
因此在设计新类型时,如果想让运算合理,最好遵从这些意义。
==、!=、<、<=、>和>=(§5.4.1)size()、begin()和end()(§5.4.2)>>和<<(§5.4.3)swap()(§5.4.5)hash<>(§5.4.6)相等性比较(==、!=)的意义跟复制密切相关。在复制后,副本间应该比较相等性:
X a = something;
X b = a;
assert(a==b); // 如果在这里 a != b,就会非常怪异 (§3.5.4)
如果定义了==,也要定义!=,并确保a!=b和!(a==b)同义。
类似地,如果你定义了<,也要定义<=、>、>=,还要确保符合常规相等性:
a<=b、(a<b)||(a==b)、!(b<a)同义a>b、b<a同义a>=b、(a>b)||(a==b)、!(a<b)同义想要对二元操作符——比如==——的两个操作数一视同仁,
最好在类所在的命名空间里定义一个非成员函数。
例如:
namespace NX {
class X {
// ...
};
bool operator==(const X&, const X&);
// ...
};
除非违反的理由充分,否则设计容器应遵循标准库容器(第11章)的风格。
具体来说,需要达成容器资源安全,将其作为一个资源执柄来实现,
并附带适当的基础操作(§5.1.1, §5.2)。
标准库容器全都知晓其元素数量,可调用size()进行获取。例如:
for (size_t i = 0; i<c.size(); ++i) // size_t 是标准库 size() 返回类型的名称
c[i] = 0;
不过,除了用从0到size()的下标遍历容器外,
标准算法依赖于由一对*迭代器(iterator)界定的序列(sequence)*的概念:
for (auto p = c.begin(); p!=c.end(); ++p)
*p = 0;
此处,c.begin()是个指向c第一个元素的迭代器,
而c.end()指向c最后一个元素之后的位置。
跟指针一样,迭代器支持++操作移至下一个元素,还支持*以访问其指向的元素的值。
*迭代器模型(iterator model)*带来了极佳的通用型和性能(§12.3)。
迭代器还被用于把序列传递给标准库算法。例如:
sort(v.begin(),v.end());
详情及更多容器操作,请参考第11章和第12章。
另一个隐式使用元素数量的方式是 区间-for循环:
for (auto& x : c)
x = 0;
这里隐式利用了c.begin()和c.end(),大体上等同于显式使用它们的循环。
对于一对整数,<<的意思是左移,>>的意思是右移。
但是,对于iostream,它们分别是输出和输入运算符(§1.8;第10章)。
详情及更多 I/O 操作,参见第10章。
类的一个目标是让程序员设计、实现类型,并尽可能模拟内置类型。
构造函数提供了初始化操作,在灵活性和效率方面已经等同或超越了内置那些的初始化,
但对于内置类型来说,可以有文本值:
123是一个int0xFF00u是一个unsigned int123.456是一个double"Surprise!"是一个const char[10]如果用户定义类型也具备这样的文本值可就太有用了。
实现它的方法是为文本值定义适当的后缀,从而得到:
"Surprise!"s是一个std::string123s是second(秒)12.7i是imaginary(虚部),12.7i+47是一个complex number(复数)(即:{47, 12.7})具体来说,这些来自标准库的例子,可以借由适当的头文件和命名空间得到:
| 标准库文本值后缀 | ||
|---|---|---|
<chrono> |
std::literals::chrono_literals |
h,min,s,ms,us,ns |
<string> |
std::literals::string_literals |
s |
<string_view> |
std::literals::string_literals |
sv |
<complex> |
std::literals::complex_literals |
i,il,if |
不难想见,带有用户定义后缀的文本值被称为
用户定义文本值(user-difined literal)或UDL。
这些文本值通过*文本值操作符(literal operator)*定义。
文本值操作符用于转换文本值,从其带有后缀的参数类型,转化到返回值类型。
例如,imaginary后缀的i可能是这样实现的:
constexpr complex<double> operator""i(long double arg) // 虚部文本值
{
return {0,arg};
}
此处:
operator"" 表示我们要定义一个文本值操作符""后的i是后缀,它从这个操作符获得意义long double,表示后缀(i)是为浮点型文本值定义的complex<double>指明了结果文本值的类型据此,可以这样写:
complex<double> z = 2.7182818+6.283185i;
有很多算法,尤其是sort(),会用一个swap()函数,交换两个对象的值。
这些算法通常假定swap()快速,并且不会抛出异常。
标准库提供了一个std::swap(a,b),
它的实现用了三次转移操作:(tmp=a, a=b, b=tmp)。
假设你设计一个类型,如果复制它代价高昂又很可能被交换(比方说,被sort函数),
那么就给它定义一个转移操作,或者一个swap(),又或者干脆一起定义。
稍微提一下,标准库容器(第11章)和string(§9.2.1)具有快速转移操作。
hash<>标准库的unordered_map<K,V>是个哈希表,其中K是键类型,V是值类型(§11.5)。
如果想用某个类型X作为键,就必须定义hash<X>。
标准库为我们给常见类型定义了它,比如std::string。
explicit;§5.1.1; [CG: C.46]。const引用参数类型;§5.2.2; [CG: F.16]。出自文章《Epigrams on Programming》:https://en.wikipedia.org/wiki/Epigrams_on_Programming
需要 vector 的人不太可能总是想要一个double的vector。
vector 是个泛化的概念,它本身独立于浮点数的概念存在。
因此,vector 的元素类型也应该具有独立的表现形式。
模板 template是一种类或者函数,我们用一组类型或值去参数化它。
我们用模板表示这样一种概念:
它是某种通用的东西,我么可以通过指定参数来生成类型或函数,
至于这种参数,比方说是vector的元素类型double。
我们那个 承载double的vector,可以泛化成一个 承载任意类型的vector,
只要把它变成一个template,并用一个类型参数替代具体的double类型。例如:
template<typename T>
class Vector {
private:
T* elem; // elem指向一个数组,该数组承载sz个T类型的元素
int sz;
public:
explicit Vector(int s); // 构造函数:建立不变式,申请资源
̃Vector() { delete[] elem; } // 析构函数:释放资源
// ... 复制和移动操作 ...
T& operator[](int i); // 为非const Vector取下标元素
const T& operator[](int i) const; // 为const Vector取下标元素(§4.2.1)
int size() const { return sz; }
};
前缀template<typename T>把T作为紧跟在它后面的声明的参数。
这是数学上“对所有T”的C++版本,或者更确切的说是“对于所有类型T”。
如果你想要数学上的“对所有T,有P(T)”,那你需要概束(concept)(§6.2.1, §7.2)。
用class引入类型参数与typename是等效的,
在旧式代码里template<class T>做前缀很常见。
成员函数可能有相似的定义:
template<typename T> Vector<T>::Vector(int s)
{
if (s<0)
throw Negative_size{};
elem = new T[s];
sz = s;
}
template<typename T>
const T& Vector<T>::operator[](int i) const
{
if (i<0 || size()<=i)
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
有了以上这些定义,我们可以定义如下这些 Vector:
Vector<char> vc(200); // 承载200个字符的 vector
Vector<string> vs(17); // 承载17个string的 vector
Vector<list<int>> vli(45); // 承载45个int列表的的 vector
Vector<list<int>>里的>>是嵌套模板参数的结尾;并非放错地方的输入运算符。
可这样使用Vector:
void write(const Vector<string>& vs) // Vector of some strings
{
for (int i = 0; i!=vs.size(); ++i)
cout << vs[i] << '\n';
}
想让我们的Vecor支持区间-for循环,就必须定义适当的begin()和end()函数:
template<typename T>
T* begin(Vector<T>& x)
{
return x.size() ? &x[0] : nullptr; // 指向第一个元素的指针或者nullptr
}
template<typename T>
T* end(Vector<T>& x)
{
return x.size() ? &x[0]+x.size() : nullptr; // 指向末尾元素身后位置
}
有了以上这些,就可以这样写:
void f2(Vector<string>& vs) // 某种东西的 Vector
{
for (auto& s : vs)
cout << s << '\n';
}
同理,可以把list、vector、map(也就是关联数组)、
unordered map(也就是哈希表)等都定义为模板(第11章)。
模板是个编译期机制,因此使用它们跟手写的代码相比,并不会在运行时带来额外的负担。
实际上,Vector<double>生成的代码与第4章Vector版本的代码一致。
更进一步,标准库vector<double>生成的代码很可能更好
(因为实现它的时候下了更多功夫)。
模板附带一组模板参数,叫做实例化(instantiation)或者
特化(specialization)。
编译过程靠后的部分,在实例化期(instantiation time),
程序里用到的每个实例都会被生成(§7.5)。
生成的代码会经历类型检查,以便它们与手写代码具有同样的类型安全性。
遗憾的是,此种类型检查通常处于编译过程较晚的阶段——在实例化期。
绝大多数情况下,只有当模板参数符合特定条件的时候,这个模板才说得通。
例如:Vector通常提供复制操作,如果它确实提供了,就必须要求其元素是可复制的。
这样,我们就得要求Vector的模板参数不仅仅是typename,而是一个Element,
其中的“Element”规定了一个作为元素的类型所需要满足的需求:
template<Element T>
class Vector {
private:
T* elem; // elem指向一个数组,该数组承载sz个T类型的元素
int sz;
// ...
};
前缀template<Element T>就是数学中“对所有令Element(T)为真的T”的C++版本;
就是说,Element是个谓词,用来检测T,判断它是否具有Vector要求的全部属性。
这种谓词被称为概束(concept)(§7.2)。
指定过概束的模板参数被称为受限参数(constrained argument),
参数受限的模板被称为受限模板(constrained template)。
如果用于实例化模板的类型不满足需求,会触发一个编译期错误。例如:
Vector<int> v1; // OK:可以复制一个int
Vector<thread> v2; // 报错:不能复制标准线程 (§15.2)
因为在 C++20 之前,C++没有官方支持概束,
较老的代码采用了未约束模板,而把需求内容留在了文档中。
除了类型参数,模板还可以接受值参数。例如:
template<typename T, int N>
struct Buffer {
using value_type = T;
constexpr int size() { return N; }
T[N];
// ...
};
别名(value_type)和constexpr函数允许我们(只读)访问模板参数。
值参数在很多语境里都很有用。
例如:Buffer允许我们创建任意容量的缓冲区,却不使用自由存储区(动态内存):
Buffer<char,1024> glob; // 用于字符的全局缓冲区(静态分配)
void fct() {
Buffer<int,10> buf; // 用于整数的局部缓冲区(在栈上)
// ...
}
值模板参数必须是常量表达式。
考虑一下标准库模板pair的应用:
pair<int,double> p = {1,5.2};
很多人发现要指定模板参数类型很烦冗,因此标准库提供了一个函数make_pair(),
以便借助其函数参数,推导其返回的pair的模板参数。:
auto p = make_pair(1,5.2); // p 是个 pair<int,double>
这就导致一个明显的疑问“为什么不直接通过构造函数的参数推导模板参数呢?”,
因此在C++17里,就可以了。这样:
pair p = {1,5.2}; // p 是个 pair<int,double>
这不仅是pair的问题;make_函数的应用很常见。考虑如下这个简单的例子:
template<typename T>
class Vector {
public:
Vector(int);
Vector(initializer_list<T>); // 初始化列表构造函数
// ...
};
Vector v1 {1,2,3}; // 从初始值类型推导v1的元素类型
Vector v2 = v1; // 从v1的元素类型推导v2的元素类型
auto p = new Vector{1,2,3}; // p 指向一个 Vector<int>
Vector<int> v3(1); // 此处,我们需要显式指定元素类型(未提及元素类型)
显然,这简化了拼写,并消除了因误拼冗余的模板参数类型而导致的烦躁。
不过,它并非万全之策。
模板参数推导可能会令人诧异(无论make_函数还是构造函数)。考虑:
Vector<string> vs1 {"Hello", "World"}; // Vector<string>
Vector vs {"Hello", "World"}; // 推导为 Vector<const char*> (诧异吗?)
Vector vs2 {"Hello"s, "World"s}; // 推导为 Vector<string>
Vector vs3 {"Hello"s, "World"}; // 报错:初始化列表类型不单一
C-风格字符串文本值的类型是const const*(§1.7.1)。
如果这不符合意图,请用一个s后缀,让它明确成为string(§9.2)。
如果初始化列表中具有不同类型,就无法推导出一个单一类型,因此会报错。
如果无法从构造函数参数推导某个模板参数,
我们可以用推导引导 deduction guide辅助。考虑:
template<typename T>
class Vector2 {
public:
using value_type = T;
// ...
Vector2(initializer_list<T>); // 初始化列表构造函数
template<typename Iter>
Vector2(Iter b, Iter e); // [b:e) 区间构造函数
// ...
};
Vector2 v1 {1,2,3,4,5}; // 元素类型是 int
Vector2 v2(v1.begin(),v1.begin()+2);
很明显,v2应该是个Vector2<int>,但是因为缺少辅助信息,编译器无法推导出来。
这段代码仅表明:有个构造函数接收一对同类型的值。
缺乏概束(§7.2)的语言支持,对于该类型,编译器无法假设任何情况。
如果想进行推导,可以在Vector2的声明后添加一个推导指引(deduction guide):
template<typename Iter>
Vector2(Iter,Iter) -> Vector2<typename Iter::value_type>;
意思是,如果我们看到Vector2使用一对迭代器初始化,
应该把Vector2::value_type推导为迭代器的值类型。
推导指引的效果通常很微妙,因此在设计类模板的时候,尽量别依靠它。
不过,标准库里满是(目前还)未使用concept且带有这种二义性的类,
因此它们用了不少的推导指引。
除了用元素类型参数化容器,模板还有很多别的用途。
具体来说,它们被广泛用于泛化标准库中的类型和算法(§11.6, §12.6)。
表示一个操作被类型或值泛化,有三种方式:
可以写一个元素求和函数,针对可以利用 区间-for
遍历的任意序列(也就是容器),像这样:
template<typename Sequence, typename Value>
Value sum(const Sequence& s, Value v)
{
for (auto x : s)
v+=x;
return v;
}
模板参数Value和函数参数v,
允许调用者指定这个累加函数的类型和初值(累加到和里的变量):
void user(Vector<int>& vi, list<double>& ld, vector<complex<double>>& vc)
{
int x = sum(vi,0); // 承载 int 的vector的和(与 int 相加)
double d = sum(vi,0.0); // 承载 int 的vector的和(与 double 相加)
double dd = sum(ld,0.0); // 承载 double 的vector的和
auto z = sum(vc,complex{0.0,0.0}); // 承载 complex<double>s 的vector的和
}
把int加到double上的意义在于能优雅地处理超出int上限地数值。
注意sum<Sequence,Value>从函数参数中推导模板参数的方法。
巧的是不需要显式指定它们。
这个sum()是标准库里accumulate()(§14.3)的简化版本。
函数模板可用于成员函数,但不能是virtual成员。
在一个程序里,编译器无法知晓某个模板的全部实例,因此无法生成vtbl(§4.4)。
有一种特别有用的模板是函数对象(function object)
(也叫仿函数(functor)),用于定义可调用对象。例如:
template<typename T>
class Less_than {
const T val; // 参与比对的值
public:
Less_than(const T& v) :val{v} { }
bool operator()(const T& x) const { return x<val; } // 调用运算符
};
名为operator()的函数实现“函数调用”、“调用”或“应用”运算符()。
可以为某些参数类型定义Less_than类型的具名变量:
Less_than lti {42}; // lti(i) 将把i用<号与42作比(i<42)
Less_than lts {"Backus"s}; // lts(s) 将把s用<号与"Backus"作比(s<"Backus")
Less_than<string> lts2 {"Naur"}; // "Naur"是个C风格字符串,因此需要用 <string> 获取正确的 <
可以像调用函数一样调用这样的对象:
void fct(int n, const string& s)
{
bool b1 = lti(n); // true if n<42
bool b2 = lts(s); // true if s<"Backus"
// ...
}
这种函数对象广泛用做算法的参数。例如,可以统计使特定谓词为true的值的数量:
template<typename C, typename P>
// requires Sequence<C> && Callable<P,Value_type<P>>
int count(const C& c, P pred)
{
int cnt = 0;
for (const auto& x : c)
if (pred(x))
++cnt;
return cnt;
}
谓词 predicate是调用后能返回true或false的东西。例如:
void f(const Vector<int>& vec, const list<string>& lst, int x, const string& s)
{
cout << "number of values less than " << x << ": " << count(vec,Less_than{x}) << '\n';
cout << "number of values less than " << s << ": " << count(lst,Less_than{s}) << '\n';
}
此处,Less_than{x}构造一个Less_than<int>类型的对象,
它的调用运算符会与名为x的int进行比较;
Less_than{s}会构造一个对象,与名为s的string进行比较。
这些函数对象的妙处在于,它们随身携带参与比较的值。
我们无需为每个值(以及每种类型)写一个单独的函数,
也无需引入一个恼人的全局变量去持有这个值。
还有,类似于Less_than这种函数对象易于内联,
因此调用Less_than远比间接的函数调用高效。
携带数据的能力再加上高效性,使函数对象作为算法参数特别有用。
用在通用算法中的函数对象,可指明其关键运算的意义
(例如Less_than之于count()),通常被称为策略对象(policy object)。
在 §6.3.2 中,我们把Less_than的定义和它的应用拆开了。这样不太方便。
你猜怎么着,还有个隐式生成函数对象的写法:
void f(const Vector<int>& vec, const list<string>& lst, int x, const string& s)
{
cout << "number of values less than " << x
<< ": " << count(vec,[&](int a){ return a<x; })
<< '\n';
cout << "number of values less than " << s
<< ": " << count(lst,[&](const string& a){ return a<s; })
<< '\n';
}
[&](int a){return a<x;}这个写法叫lambda表达式(lambda expression)。
它跟Less_than<int>{x}一样会生成函数对象。
此处的[&]是一个抓取列表(capture list),
表明lambda函数体内用到的所有局部名称,将以引用的形式访问。
如果我们仅想“抓取”x,应该这么写:[&x]。
如果我们把x的副本传给生成的对象,就应该这么写:[=x]。
不抓取任何东西写[ ],以引用方式抓取所有局部名称写[&],
以传值方式抓取所有局部名称写:[=]。
使用lambda表达式方便、简略,但也略晦涩些。
对于繁复的操作(比方说超出一个表达式的内容),
我倾向于为它命名,以便明确用途,并让它可以在程序中多处访问。
在 §4.5.3 中,我们遇到了一个困扰,
就是在使用元素类型为指针或unique_ptr的vector时,
要写很多针对其元素的操作,比方说draw_all()和rotate_all()。
函数对象(确切的说是lambda表达式)有助于把容器遍历和针对每个元素的操作分离开。
首先,我们需要一个函数,操作指针容器中元素指向的对象:
template<typename C, typename Oper>
void for_all(C& c, Oper op) // 假定C是个承载指针的容器
// 要求 Sequence<C> && Callable<Oper,Value_type<C>> (see §7.2.1)
{
for (auto& x : c)
op(x); // 把每个元素指向的对象传引用给 op()
}
现在,针对 §4.5 中的 user(),可以写个不需要一堆_all函数的版本了:
void user2()
{
vector<unique_ptr<Shape>> v;
while (cin)
v.push_back(read_shape(cin));
for_all(v,[](unique_ptr<Shape>& ps){ ps->draw(); }); // draw_all()
for_all(v,[](unique_ptr<Shape>& ps){ ps->rotate(45); }); // rotate_all(45)
}
我把unique_ptr<Shape>&传给lambda表达式,
这样for_all()就无需关心对象存储的方式了。
确切的说,这些for_all()函数不影响传入的Shape生命期,
lambda表达式的函数体使用参数时,就像用旧式的指针一样。
跟函数一样,lambda表达式也可以泛型。例如:
template<class S>
void rotate_and_draw(vector<S>& v, int r)
{
for_all(v,[](auto& s){ s->rotate(r); s->draw(); });
}
此处的auto,像变量声明里那样,
意思是初始值(在调用中,实参初始化形参)接受任何类型。
这让带有auto的lambda表达式成了模板,一个泛型lambda(generic lambda)。
由于标准委员会政策方面的疏漏,此种auto的应用,目前无法用于函数参数。
可以用任意容器调用这个泛型的rotate_and_draw(),
只要该容器内的对象能执行draw()和rotate()。例如:
void user4()
{
vector<unique_ptr<Shape>> v1;
vector<Shape*> v2;
// ...
rotate_and_draw(v1,45);
rotate_and_draw(v2,90);
}
利用lambda表达式,可以把任何语句变成表达式。
其主要用途是,把用于求值的运算当作参数值传递,但它的能力是通用的。
考虑以下这个复杂的初始化:
enum class Init_mode { zero, seq, cpy, patrn }; // 各种初始化方式
// 乱糟糟的代码:
// int n, Init_mode m, vector<int>& arg, 和 iterators p 以及 q 在别处定义
vector<int> v;
switch (m) {
case zero:
v = vector<int>(n); // n个初始化为0的元素
break;
case cpy:
v = arg;
break;
};
// ...
if (m == seq)
v.assign(p,q); // 从序列[p:q)复制
// ...
这是个风格化明显的例子,但不幸的是这种情况并不罕见。
我们要在一组初始化方法中进行选择,
去初始化一个数据结构(此处是v),并为不同方法做不同的运算。
这种代码通常一塌糊涂,声称“为了高效”必不可少,还是bug之源:
取而代之,可以把它转化为一个lambda表达式,用作初值:
// int n, Init_mode m, vector<int>& arg, 和 iterators p 以及 q 在别处定义
vector<int> v = [&] {
switch (m) {
case zero:
return vector<int>(n); // n个初始化为0的元素
case seq:
return vector<int>{p,q}; // 从序列[p:q)复制
case cpy:
return arg;
}
}();
// ...
我仍然“忘掉”了一个case,不过现在这就不难察觉了。
要定义出好的模板,我们需要一些辅助的语言构造:
if constexpr(§6.4.3)。requires表达式(§7.2.3)。另外,constexpr函数(§1.6)和static_asserts(§3.5.5)
也经常参与模板设计和应用。
对于构建通用、基本的抽象,这些基础机制是主要工具。
在使用某个类型时,经常会需要该类型的常量和值。
这理所当然也发生在我们使用类模板的的时候:
当我们定义了C<T>,通常会需要类型T以及依赖T的其它类型的常量和变量。
以下示例出自一个流体力学模拟[Garcia,2015][^2]:
template <class T>
constexpr T viscosity = 0.4;
template <class T>
constexpr space_vector<T> external_acceleration = { T{}, T{-9.8}, T{} };
auto vis2 = 2*viscosity<double>;
auto acc = external_acceleration<float>;
此处的space_vector是个三维向量。
显然,可以用适当类型的任意表达式作为初始值。考虑:
template<typename T, typename T2>
constexpr bool Assignable =
is_assignable<T&,T2>::value; // is_assignable 是个类型 trait (§13.9.1)
template<typename T>
void testing()
{
static_assert(Assignable<T&,double>, "can't assign a double");
static_assert(Assignable<T&,string>, "can't assign a string");
}
经历一些大刀阔斧的变动,这个点子成了概束定义(§7.2)的关键。
出人意料的是,为类型或者模板引入一个同义词很有用。
例如,标准库头文件<cstddef>包含一个size_t的别名,可能是这样:
using size_t = unsigned int;
用于命名size_t的实际类型是实现相关的,
因此在另一个实现里size_t可能是unsigned long。
有了别名size_t的存在,就让程序员能够写出可移植的代码。
对参数化类型来说,为模板参数相关的类型提供别名是很常见的。例如:
template<typename T>
class Vector {
public:
using value_type = T;
// ...
};
实际上,每个标准库容器都提供了value_type作为其值类型的名称(第11章)。
对于所有遵循此惯例的容器,我们都能写出可行的代码。例如:
template<typename C>
using Value_type = typename C::value_type; // C 的元素的类型
template<typename Container>
void algo(Container& c)
{
Vector<Value_type<Container>> vec; // 结果保存在这里
// ...
}
通过绑定部分或全部模板参数,可以用别名机制定义一个新模板。例如:
template<typename Key, typename Value>
class Map {
// ...
};
template<typename Value>
using String_map = Map<string,Value>;
String_map<int> m; // m 是个 Map<string,int>
if思考编写这样一个操作,它在slow_and_safe(T)和simple_and_fast(T)里二选一。
这种问题充斥在基础代码中——那些通用性和性能优化都重要的场合。
传统的解决方案是写一对重载的函数,并基于 trait(§13.9.1) 选出最适宜的那个,
比方说标准库里的is_pod。
如果涉及类体系,slow_and_safe(T)可提供通用操作,
而某个继承类可以用simple_and_fast(T)的实现去重载它。
在 C++17 里,可以利用一个编译期if:
template<typename T> void update(T& target)
{
// ...
if constexpr(is_pod<T>::value)
simple_and_fast(target); // 针对“简单旧式的数据”
else
slow_and_safe(target);
// ...
}
is_pod<T>是个类型trait (§13.9.1),它辨别某个类型可否低成本复制。
仅被选定的if constexpr分支被实例化。
此方案即提供了性能优化,又实现了优化的局部性。
重要的是,if constexpr并非文本处理机制,
不会破坏语法、类型和作用域的常见规则。例如:
template<typename T>
void bad(T arg)
{
if constexpr(Something<T>::value)
try { // 语法错误
g(arg);
if constexpr(Something<T>::value)
} catch(...) { /* ... */ } // 语法错误
}
如果允许类似的文本操作,会严重破坏代码的可靠性,而且对依赖于新型程序表示技术
(比方说“抽象语法树(abstract syntax tree)”)的工具,会造成问题。
[1] 可应用于很多参数类型的算法,请用模板去表达;§6.1; [CG: T.2]。
[2] 请用模板去表达容器;§6.2; [CG: T.3]。
[3] 请用模板提升代码的抽象层级;§6.2; [CG: T.1]。
[4] 模板是类型安全的,但它的类型检查略有些迟滞;§6.2。
[5] 让构造函数或者函数模板去推导模板参数类型;§6.2.3。
[6] 使用算法的时候,请用函数对象作参数;§6.3.2; [CG: T.40]‘
[7] 如果需要简单的一次性函数对象,采用lambda表达式;§6.3.2。
[8] 虚成员函数无法作为模板成员函数;§6.3.1。
[9] 用模板别名简化符号表示,并隐藏实现细节;§6.4.2。
[10] 使用模板时,确保其定义(不仅仅是声明)在作用域内;§7.5。
[11] 模板提供编译期的“鸭子类型(duck typing)”;§7.5。
[12] 模板不支持分离编译:把模板定义#include进每个用到它的编译单元。
这是一段极妙的引言,作者的很多书籍中“模板”一章都用了这段,这句话出现在“引言”位置,同时告诉你它可以被替换,所以这个引言本身就是一个“模板”。—— 译者啰嗦
一篇论文《Humanitarian security regimes》,网址是:https://doi.org/10.1111/1468-2346.12186
模板是干嘛用的?换句话说,使用模板对什么样的编程技术更有效?它提供了:
换句话说,模板提供了强大的机制,用于编译期计算、类型操控,并导向紧凑高效的代码。
谨记,类型(类)即包含代码(§6.3.2)又包含值(§6.2.2)。
首要的也是最常见的模板用途,是支持泛型编程(generic programming),
也就是关注于通用算法设计、实现和应用的编程。
此处,“通用”的意思是:算法的设计可接纳很广泛的类型,
只要它们符合算法在参数方面的要求即可。
搭配概束,模板是C++对泛型编程的主力支援。
模板提供了(编译期的)参数化多态。
考虑来自 §6.3.1 的 sum():
template<typename Seq, typename Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v+=x;
return v;
}
它可以针对任何支持begin()和end()的数据结构调用,
以便 区间-for循环 能运行。
这类数据结构包括标准库的vector、list和map。
另外,该数据结构的元素类型在使用方面有限制:该类型必须能够与Value参数相加。
可行的例子有int、double和Matrix(对任何靠谱的Matrix定义而言)。
我们说sum()在两个维度上是通用的:
用于存储元素的数据结构类型(“是个序列(sequence)”),以及元素的类型。
因此sum()要求其第一个模板参数是某种序列,其第二个模板参数是某种数字。
我们把这种要求称为概束(concept)。
对概束的支持尚未进入ISO C++,
但已经是一条ISO技术细则(Technical Specification)[ConceptsTS]。
某些编译器实现里已经有所应用,因此,
尽管其细节很可能会变动,尽管还要等几年才能进入生产环境代码,
我依然要冒险在这里推荐它。
多数模板参数必须符合特定需求,以便模板能通过编译并生成正确的代码。
就是说,多数模板都是受限模板(§6.2.1)。
类型名称引入符号typename是最松散的约束,仅要求该参数是个类型。
通常仍有改进空间,重新考虑sum()例子:
template<Sequence Seq, Number Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v+=x;
return v;
}
这样就明确多了。一旦我们定义了概束Sequence和Number的意义,
编辑器仅根据sum()的接口就能驳回错误的调用,而无需再检查其函数体。
这改善了错误报告。
但是,这份sum()接口的细节并不完善:
我“忘了”说,需要把Sequence的元素于Number相加。
可以这样做:
template<Sequence Seq, Number Num>
requires Arithmetic<Value_type<Seq>,Num>
Num sum(Seq s, Num n);
序列的Value_type是序列中元素的类型。Arithmetic<X,Y>是个规则,
指明我们可以对X和Y类型的数字做算术运算。
这让我们避免了给vector<string>或者vector<int*>计算sum()的尝试,
却还能接受vector<int>以及vector<complex<double>>。
在上例中,我们仅需要+=运算,但处于简化及灵活性,不该把模板参数限制的太紧。
具体来说,我们后续可能用+和=,而非+=来实现sum(),
到那时就会庆幸用了更通用的规则(此处是Arithmetic),
而没有把需求缩窄到“可以+=“。
像第一个使用概束的sum()那样,指明部分细节已经很有用了。
如果细节不够完整,那么某些错误只能等到实例化期才能发现。
无论如何,指明部分细节就很有帮助了,它表明了意图,对平缓的增量开发至关重要,
也就是我们未能一开始就认清所有需求的情形。
有了完善的概束库的帮助,初步的规划就将近乎完善。
不难猜到,requires Arithmetic<Value_type<Seq>,Num>
被称为requirements子句。
template<Sequence Seq>写法仅仅是显式requires Sequence<Seq>应用的简写。
如果喜欢详尽的方式,可以写出以下等价形式:
template<typename Seq, typename Num>
requires Sequence<Seq> && Number<Num> && Arithmetic<Value_type<Seq>,Num>
Num sum(Seq s, Num n);
另一方面,也可以用与以上两个写法的等价形式这样写:
template<Sequence Seq, Arithmetic<Value_type<Seq>> Num>
Num sum(Seq s, Num n);
无论使用哪种形式,在设计模板的时候,
确保模板参数具有语意方面的合理约束是很重要的(§7.2.4)。
在妥善声明模板接口后,可以基于其属性进行重载,这与函数重载极其类似。
考虑一下标准库函数advance()(§12.3)的简化版本,它推进迭代器:
template<Forward_iterator Iter>
void advance(Iter p, int n) // 将p向前移动n个元素
{
while (n--)
++p; // 前向迭代器可以++,但不能+或+=
}
template<Random_access_iterator Iter>
void advance(Iter p, int n) // 将p向前移动n个元素
{
p+=n; // 随机访问迭代器可以+=
}
编译器会选择参数对需求匹配最紧密的模板。本例中,list仅有前向迭代器,
而vector提供了随机访问迭代器,因此有:
void user(vector<int>::iterator vip, list<string>::iterator lsp)
{
advance(vip,10); // 应用快速的 advance()
advance(lsp,10); // 应用缓慢的 advance()
}
像其它类型的重载一样,这是个编译期机制,就是说没有运行时开销,
在编译器找不到最佳匹配时,会报二义性错误。
基于概束的重载,其规则远比普通重载(§1.3)简单。
首先考虑单个模板参数对应多个可选函数的情况:
某个备选函数想被选中就必须:
有关模板实参是否满足模板对形参的需求这个问题,归根结底是某些表达式有效与否的问题。
使用requires表达式,可以检测一组表达式的有效性。例如:
template<Forward_iterator Iter>
void advance(Iter p, int n) // 将p向前移动n个元素
{
while (n--)
++p; // 前向迭代器可以++,但不能+或+=
}
template<Forward_iterator Iter, int n>
requires requires(Iter p, int i) { p[i]; p+i; } // Iter有取下标和加法操作
void advance(Iter p, int n) // 将p向前移动n个元素
{
p+=n; // 随机访问迭代器可以+=
}
不,那个requires requires不是笔误。
前面的requires发起了一个requirements从句,
后面的requires发起了一个requires表达式
requires(Iter p, int i) { p[i]; p+i; }
requires表达式是一个断言,若其内部的表达式是有效代码,
它就为true,无效则为false。
我把requires表达式看作是泛型编程的汇编代码。
像常规的汇编代码那样,requires表达式极其灵活并且没有为编程限定任何规则。
就某种形式而言,它们是大部分重要泛型代码的根基,
就像汇编码是大部分重要常规代码的根基那样。
像汇编码那样,requires表达式不应该在“常规代码”中出现。
如果你的代码里出现了 requires requires,很可能是把它的层级搞得太低了。
advance()中的requires requires用法特意弄得粗劣和耍小聪明。
注意,我“忘记”要求+=,以及该运算所需的返回值类型。
尽量用命名概束,它们的名称暗含了语意方面的意义。勿谓言之不预也。
尽量采用命名良好的概束,它们具有定义准确的语意(§7.2.4),
然后在定义这些概束的时候使用requires表达式。
到头来,我们想找到有用的概束,
比方说程序库尤其是标准库里的Sequence和Arithmetic。
区间技术细则(Ranges Technical Specification)[RangesTS]提供了一套,
用于约束标准库算法(§12.7)。不过,简单的概束不难定义。
概束是一个编译期的谓词,用于规定一个或多个类型该如何使用。
考虑这第一个最简单的例子:
template<typename T>
concept Equality_comparable =
requires (T a, T b) {
{ a == b } -> bool; // 用==比较两个T
{ a != b } -> bool; // 用!=比较两个T
};
Equality_comparable这个概束的用途是:
确保能够对某个类型的值进行相等或不等比较。
我们直白地说,给定某个类型的两个值,它们必须可以用==和!=进行比较,
并且比较的结果必须能够转化到bool类型。例如:
static_assert(Equality_comparable<int>); // 成功
struct S { int a; };
static_assert(Equality_comparable<S>); // 失败,因为结构体不会自动获得==和!=
概束Equality_comparable的定义跟英语描述严格等同,并不冗长。
任何concept的值总是bool类型。
定义Equality_comparable去处理不同类型的比较,几乎同样易如反掌:
template<typename T, typename T2 =T>
concept Equality_comparable =
requires (T a, T2 b) {
{ a == b } -> bool; // 把一个 T 和一个 T2 用 == 进行比较
{ a != b } -> bool; // 把一个 T 和一个 T2 用 != 进行比较
{ b == a } -> bool; // 把一个 T2 和一个 T 用 == 进行比较
{ b != a } -> bool; // 把一个 T2 和一个 T 用 != 进行比较
};
typename T2 =T是说,如果没指明第二个模板参数,T2就和T一样,
T是默认模板参数(default template argument)。
可以这样测试Equality_comparable:
static_assert(Equality_comparable<int,double>); // 成功
static_assert(Equality_comparable<int>); // 成功(T2默认是int)
static_assert(Equality_comparable<int,string>); // 失败
对于更复杂的例子,考虑某个如下的序列:
template<typename S>
concept Sequence = requires(S a) {
typename Value_type<S>; // S必须具备一个值类型
typename Iterator_type<S>; // S必须具备一个迭代器类型
{ begin(a) } -> Iterator_type<S>; // begin(a) 必须返回一个迭代器
{ end(a) } -> Iterator_type<S>; // end(a) 必须返回一个迭代器
requires Same_type<Value_type<S>,Value_type<Iterator_type<S>>>;
requires Input_iterator<Iterator_type<S>>;
};
如果类型S要作为Sequence使用,必须提供Value_type(其元素的类型),
以及一个iterator_type(其迭代器的类型;参见§12.1)。
还要确保具备返回迭代器的begin()和end()函数,以符合标准库容器(§11.3)的惯例。
最后,iterator_type必须是个input_iterator,其元素与S元素的类型相同。
最难定义的概束是表示基本语言概念的那种。总而言之,最好从现存的库里拿一套出来用。
对于一套有用的概束集,参见§12.7。
C++所支持的泛型编程(generic programming)形式,围绕着这样一个思想:
从具体、高效的算法抽象得出泛型算法,再把这个泛型算法跟多种数据形式结合,
继而生成各种有用的软件[Stepanov,2009]。
这种代表了基本运算和数据结构的抽象被称为概束(concept);
它们的表现形式是对模板参数提出的条件。
优秀、好用的概束(concept)是很基础的,多数情况下它们是被发现的,而非出于设计。
比如整数和浮点数(连古典的C语言中都有定义)、序列,
以及更通用的数学概念,例如域和向量空间。
它们代表某个应用领域里的基本概念。
这就是它们被称作“概束(concept)”的原因。
要识别概束并将其形式化,以达到高效泛型编程必要的程度,这极具挑战。
对于基本用法,考虑概束Regular(§12.7)。
某个类型如果是常规(regular)的,就要表现得像int或者float那样。
某个常规类型的对象要:
==和!=进行比较string是另一个常规类型的例子。
像int一样,string也是StrictTotallyOrdered的(§12.7)。
就是说,两个字符串可以用<、<=、>和>=进行语意良好的比较。
概束不仅是一个语法概念,根本上讲它事关语意。
比如说:别把+定义成除法;对于任何合理的数字系统,这都不符合要求。
不幸的是,在表达语意方面,我们尚无任何语言层面的支持,
因此只能依靠专业知识和直觉去获取暗合语意的概束。
不要定义语意上没意义的概束,比如Addable(可相加)和Subtractable(可相减)。
相反的,要在某个应用领域里,依靠该领域内的知识,去定义符合其基本概念的概束。
良好的抽象是从具体例子中精心培育而来的。
试图给所有假想中的需求和技术做准备,并对其进行“抽象”是个馊主意;
必将导致粗鄙和代码膨胀。
相反,应该以一个——最好是多个——实际使用的具体例子为开端,
并努力去消除那些无关本质的细节。考虑:
double sum(const vector<int>& v)
{
double res = 0;
for (auto x : v)
res += x;
return res;
}
显而易见,这是诸多给数值序列求和的方法之一。
考虑一下,是什么让它变得不够通用:
int?vector?double?0开始计算?把具体类型换成模板参数,就可以回答前四个问题,
得出标准库算法accumulate最简单的形式:
template<typename Iter, typename Val>
Val accumulate(Iter first, Iter last, Val res)
{
for (auto p = first; p!=last; ++p)
res += *p;
return res;
}
这样就得到:
一个快捷的检验或者——更靠谱的——评测表明,
针对多种数据结构调用生成的代码与手动编码的原版一致。
例如:
void use(const vector<int>& vec, const list<double>& lst)
{
auto sum = accumulate(begin(vec),end(vec),0.0); // 累加在一个double上
auto sum2 = accumulate(begin(lst),end(lst),sum);
//
}
保留性能的同时,把一段具体代码(多段就更好了)泛化的过程叫做提升(lifting)。
反过来说,开发模板最好的办法通常是:
自然而然的,不断重复begin()和end()很烦冗,因此可以把用户接口简化一点:
template<Range R, Number Val> // Range是具有begin()和end()的东西
Val accumulate(const R& r, Val res = 0)
{
for (auto p = begin(r); p!=end(r); ++p)
res += *p;
return res;
}
如果要彻底通用化,还可以抽象+=运算;参阅 §14.3。
模板可定义成接受任意数量任意类型参数的形式。
这样的模板叫可变参数模板(variadic template)。
考虑一个简单的函数,对于具有<<运算符的任意类型,它都能输出该类型的值。
void user()
{
print("first: ", 1, 2.2, "hello\n"s); // first: 1 2.2 hello
print("\nsecond: ", 0.2, 'c', "yuck!"s, 0, 1, 2, '\n'); // second: 0.2 c yuck! 0 1 2
}
习惯上,实现可变参数模板的时候,要把第一个参数跟其余的分离开来,
然后为参数的尾部递归调用该可变参数模板:
void print()
{
// 对于无参数的这份:什么都不做
}
template<typename T, typename... Tail>
void print(T head, Tail... tail)
{
// 为每一个参数执行的,例如:
cout << head << ' ';
print(tail...);
}
typename...表明Tail是一个类型多样的序列。
Tail...表明tail是一系列类型位于Tail中的值。
使用...声明的参数叫做参数包(parameter pack)。
此处tail是一个(函数参数的)参数包,
其元素类型可以在(模板参数的)参数包Tail中找到。
因此print()可以接收任意类型、任意数量的参数。
调用print(),会把参数分成头部(第一个)和尾部(其余的)。
头部会被输出,然后再为尾部调用print()。
到最后,理所当然地,tail就空了,于是就需要一个无参版本的print()去处理。
如果想避免 零参数 的情形,可以用一个编译期if去消除它:
template<typename T, typename... Tail>
void print(T head, Tail... tail)
{
cout << head << ' ';
if constexpr(sizeof...(tail)> 0)
print(tail...);
}
我用了 编译期if(§6.4.3),而非普通的 运行时if,
以避免生成最终那个“永不会被调用的print()”被生成出来。
可变参数模板(有时候称为变参(variadic))的强大之处在于,
能够接收你交给它的任何参数。
其弱点包括:
由于其灵活性,可变参数模板被广泛用于标准库中,偶尔甚至被滥用了。
为简化简短变参模板的实现,C++17提供了一个针对参数包元素进行有限迭代的形式。
例如:
template<typename... T>
int sum(T... v)
{
return (v + ... + 0); // 以0为初值,把v的所有元素相加
}
此处,sum()可以接收任意类型的任意多个参数。
假设sum()确实会将其参数相加,则有:
int x = sum(1, 2, 3, 4, 5); // x 成为 15
int y = sum('a', 2.4, x); // y 成为 114(2.4被截断了,'a'的值是97)
sum的函数体使用了折叠表达式:
return (v + ... + 0); // 以0为初值,把v的所有元素相加
此处,(v+...+0)意思是把v的所有元素相加,从初始值0开始执行。
第一个参与加法运算的是“最右边”(下标值最大)的值:
(v[0]+(v[1]+(v[2]+(v[3]+(v[4]+0)))))。
就是说,从右边,也就是0那个位置开始。这叫做右叠(right fold)。
或者,也可以用左叠(left fold):
template<Number... T>
int sum2(T... v)
{
return (0 + ... + v); // 把v的所有元素加到0上
}
这次,最先参与加法运算是“最左边”(下标值最小)的元素:
(((((0+v[0])+v[1])+v[2])+v[3])+v[4])。
就是说,从左边,也就是0那个位置开始。
*折叠(fold)*是个非常强大的抽象,很明显与标准库的accumulate()有关联,
在不同的编程语言和社区里有多种名称。
在C++中,折叠表达式的使用,目前被限定在可变参数模板的简化方面。
折叠涉及的操作可以不是数值计算。考虑这个著名的例子:
template<typename ...T>
void print(T&&... args)
{
(std::cout << ... << args) << '\n'; // 输出所有参数
}
print("Hello!"s,' ',"World ",2017); // (((((std::cout << "Hello!"s) << ’ ’) << "World ") << 2017) << ’\n’);
很多用例仅涉及一组值,且可以转化为一个通用类型。
这种情况下,直接把参数复制到一个vector或者期望的容器类型,通常可以简化后续使用:
template<typename Res, typename... Ts>
vector<Res> to_vector(Ts&&... ts)
{
vector<Res> res; (res.push_back(ts) ...); // 不需要初始值
return res;
}
可以这样使用to_vector:
auto x = to_vector<double>(1,2,4.5,'a');
template<typename... Ts>
int fct(Ts&&... ts)
{
auto args = to_vector<string>(ts...); // args[i]是第i个参数
// ... 在这里使用 args ...
}
int y = fct("foo", "bar", s);
通过接口把参数原封不动传递,是可变参数模板的重要用途。
考虑一个网络输入信道,其中输送具体值的方法是个参数。
不同的传送机制拥有各自的一套构造参数:
template<typename Transport>
requires concepts::InputTransport<Transport>
class InputChannel {
public:
// ...
InputChannel(TransportArgs&&... transportArgs)
: _transport(std::forward<TransportArgs>(transportArgs)...) {}
// ...
Transport _transport;
};
标准库函数forward()(§13.2.2)被用于传递参数,
从inputChannel的构造函数原封不动地送给Transport构造函数。
此处的重点是,inputChannel的作者可以构造一个Transport类型的对象,
而无需知晓用于构造特定Transport所需的参数为何。
inputChannel的实现者仅需要知道所有Transport的通用接口。
前向操作在基础的函数库中十分常见,在那里通用性和较低运行时消耗是必要的,
而且极具通用性的接口很常见。
假定有概束(§7.2)支持,模板参数会针对其概束进行检查。
此处发现的错误会报出来,程序员必须修复这些问题。
目前尚不能检查的部分,比如无约束参数,会推迟到模板会同一组模板参数生成的时候:
“在模板实例化期间”。对于有概束支持以前的代码来说,在这里进行所有检查。
在使用概束的时候,只有概束检查通过,编译才能走到这儿。
实例化期间(较迟的)类型检验有个糟糕的副作用:
因为编译器要在信息从程序内多处汇集之后才能发现问题,
所以发现类型错误的时间特别迟,导致错误信息糟糕到令人发指。
模板的实例化期间,类型检查会对模板定义中的参数进行检查。
这提供了一个俗称鸭子类型(duck type)
(“如果它走路像鸭子,叫声像鸭子,那它就是个鸭子(
If it walks like a duck and it quacks like a duck, it’s a duck)”)
的编译期变种。
或者——用术语说——我们操作的是值,该操作的形式和意义都仅依赖于其操作数。
这有别于另一种观点,即对象具有类型,而类型决定了该操作的形式和意义。
值“居住”在对象里。
这就是C++里对象(也即变量)运作的方式,并且必须是符合该对象需求的值才住得进去。
在编译期用模板搞定的内容,基本不涉及对象,仅有值。
有个例外,是constexpr函数(§1.6)里的局部变量,它在编译器里作为变量使用。
要使用某个无约束模板,其定义(而不仅是其声明)必须位于它被用到的作用域内。
例如,标准库头文件<vector>里有vector的定义。
在实践中,这意味着模板定义一般位于头文件,而非.cpp文件。
这一点在使用模块(§3.3)的时候有些变化。
使用模块时,常规函数和模板函数的源代码的组织方式相同。
在两种情况下,定义都不会受到与文本包含有关问题的干扰。
[1] 模板提供了一个编译期编程的通用机制;§7.1。
[2] 设计模板的时候,针对其模板参数,谨慎考虑假定的概束(需求);§7.3.2。
[3] 设计模板的时候,从一个具体的版本开始实现、调试及评估;§7.3.2。
[4] 把模板作为一个设计工具;§7.3.1。
[5] 为所有的模板参数指定概束;§7.2; [CG: T.10]。
[6] 尽可能使用标准概束(如:区间概束,Ranges concepts);§7.2.4; [CG: T.11]。
[7] 如果需要个简单的一次性函数对象,用lambda表达式;§6.3.2。
[8] 模板没有分离编译:请在每个用到它的编译单元里#include模板定义
[9] 用模板表示容器和区间;§7.3.2; [CG: T.3]。
[10] 避免缺乏语意意义的“概束”;§7.2; [CG: T.20]。
[11] 为概束定义一组完善的操作;§7.2; [CG: T.21]。
[12] 在需要一个函数接收不同类型不定数量的参数时,采用可变参数模板;§7.4。
[13] 对于类型相同的参数列表,别用可变参数模板(这种情况尽量用初值列表);§7.4。
[14] 使用模板时,确保其定义(而不仅仅是声明)在作用域内;§7.5。
[15] 模板提供编译期的“鸭子类型”;§7.5。
出自一份意大利杂志《INFOMEDIA》对 Alexander A. Stepanov 的访谈录,访谈内容全文位于 http://www.stlport.org/resources/StepanovUSA.html。—— 译者注
任何重要的程序都不能单纯使用基本的编程语言。首先,要开发一套程序库。
这些库会构成后续工作的基石。
如果仅使用基本的编程语言编写,绝大多数程序都很烦冗,
相反,采用良好的程序库,几乎任何业务都能化繁为简。
继1-7章之后,9-15章对主要的标准库部件作了概览。
我很简略地介绍了实用的标准库类型,比如string、ostream、variant、
vector、map、path、unique_ptr、thread、regex和complex,
及其基本用法。
像1-7章一样,对部分细节无法完全理解的时候,千万别分神、灰心。
本章的目的是:基本理解最实用的标准库部件。
标准库的内容占据了 ISO C++ 标准超过三分之二。
请浏览并尽量采用它,摒弃山寨货。
许多思想注入了它的设计当中,更多的则进入了实现,
还有许多劳作将投入到它的维护和扩展中去。
本书所描述的标准库部件,是每个完整C++实现的组成部分。
除这些标准库组件以外,多数实现还提供了
“图形用户接口(graphical user interface)”系统(GUIs)、
Web接口、数据库接口等等。
类似地,多数应用开发环境还为企业级或工业级“标准”的开发 和/或
运行环境提供了“基本库”。
在此,我不会讲述那些系统和库。
而旨在为标准定义下的C++提供一个自洽的描述,并确保示例可移植。
当然,也鼓励程序员去浏览各个系统提供的范畴广泛的基础部件。
标准库提供的基础部件可按此分类:
vector和map)框架和算法find()、sort)和merge());参见第11章和第12章。thread和锁;参见第15章。sort()和reduce());pair;§13.4.3),通用编程variant和optional;§13.5.1,§13.5.2)和clock(§13.7)。unique_ptr和shared_ptr,§13.2.1)。array(§13.4.1)、bitset(§13.4.2)tuple(§13.4.3)。ms和i(§15.4.4)。某个类被纳入库中的判断标准是:
基本上,C++标准库提供了最常见的基础数据结构,以及应用其上的基本算法。
每个标准库部件都通过某些头文件提供。例如:
#include<string>
#include<list>
这样就可以使用标准的string和list了。
标准库被定义在一个名为std的命名空间(§3.4)里。
要使用标准库部件,可借助前缀std:::
std::string sheep {"Four legs Good; two legs Baaad!"};
std::list<std::string> slogans {"War is Peace", "Freedom is Slavery", "Ignorance is Strength"};
出于简洁的原因,本书极少像例中那样显式使用std::,
也不总显式#include必要的头文件。
要编译并运行这个代码片段,必须#include对应的头文件并确保其声明的名称可访问。
例如:
#include<string> // make the standard string facilities accessible
using namespace std; // make std names available without std:: prefix
string s {"C++ is a general−purpose programming language"}; // OK: string is std::string
一般来说,把某个命名空间里的所有名称一股脑丢进全局这种做法,颇有不妥。
但在此书专注于使用标准库,而熟知其涵盖的内容颇为有益。
以下是标准库头文件的一个精选,其中所有声明在命名空间std里:
|精选标准库头文件||
-|-|-
<algorithm>|copy(),find(),sort()|第12章
<array>|array|§13.4.1
<chrono>|duration,time_point|§13.7
<cmath>|sqrt(),pow()|§14.2
<complex>|complex,sqrt(),pow()|§14.2
<filesystem>|path|§10.10
<forward_list>|forward_list|§11.6
<fstream>|fstream,ifstream,ofstream|§10.7
<future>|future,promise|§15.7
<ios>|hex,dec,secientific,fixed,defaultfloat|§10.6
<iostream>|istream,ostream,cin,cout|第10章
<map>|map,multimap|§11.5
<memory>|unique_ptr,shared_ptr,allocator|§13.2.1
<random>|default_random_engine,normal_distribution|§14.5
<regex>|regex,smatch|§9.4
<string>|string,basic_string|§9.2
<set>|set,multiset|§11.6
<sstream>|istringstream,ostringstream|§10.8
|精选标准库头文件(续)||
-|-|-
<stdexcept>|length_error,out_of_range,runtime_error|§3.5.1
<thread>|thread|§15.2
<unordered_map>|unordered_map,unordered_multimap|§11.5
<utility>|move(),swap(),pair|第13章
<variant>|variant|§13.5.1
<vector>|vector|§11.2
此列表远称不上面面俱到。
C标准库的头文件,例如stdlib.h也都在。
每个这样的头文件还有个改版,加了c前缀,移除了.h。
这样的版本,比如<cstdlib>把它的声明都放进了命名空间std。
#include其头文件;§8.3。std里;§8.3; [CG: SL.3]。文本操作占据了多数程序的大部分工作。
C++标准库提供了一个 string 类型以解救大多数用户,
不必再通过指针进行字符串数组的 C 风格字符串操作。
string_view类型可以操作字符序列,无论其存储方式如何
(比如:在std::string或char[]中)。
此外还提供了正则表达式匹配,以便在文本中寻找模式。
正则表达式的形式与大多数现代语言中呈现的方式类似。
无论string还是regex对象,都可以使用多种字符类型(例如:Unicode)。
标准库提供了string类型,用以弥补字符串文本(§1.2.1)的不足;
string是个Regulae类型(§7.2, §12.7),
用于持有并操作一个某种类型字符的序列。
string提供了丰富有用的字符串操作,比方说连接字符串。例如:
string compose(const string& name, const string& domain)
{
return name + '@' + domain;
}
auto addr = compose("dmr","bell−labs.com");
此处,addr被初始化为字符序列dmr@bell−labs.com。
string“加法”的意思是连接操作。
你可以把一个string、一个字符串文本、C-风格字符串或者一个字符连接到string上。
标准string有个转移构造函数,所以就算是传值返回长的string也很高效(§5.2.2)。
在大量应用中,最常见的字符串连接形式是把什么东西添加到某个string的末尾。
此功能可以直接使用+=操作。例如:
void m2(string& s1, string& s2)
{
s1 = s1 + '\n'; // 追加换行
s2 += '\n'; // 追加换行
}
这两种附加到string末尾的方式语意等价,但我更青睐后者,
对于所执行的内容来说,它更明确、简练并可能更高效。
string是可变的,除了=、+=,还支持取下标(使用 [])、取自字符串操作。
例如:
string name = "Niels Stroustrup";
void m3()
{
string s = name.substr(6,10); // s = "Stroustrup"
name.replace(0,5,"nicholas"); // name 变成 "nicholas Stroustrup"
name[0] = toupper(name[0]); // name 变成 "Nicholas Stroustrup"
}
substr()操作返回一个string,该string是其参数标示出的子字符串的副本。
第一个参数是指向string的下标(一个位置),第二个参数是所需子字符串的长度。
由于下标从0开始,s的值便是Stroustrup。
replace()操作以某个值替换一个子字符串。
本例中,子字符串是始自0,长度5的Niels;它被替换为nicholas。
最后,我将首字符替换为对应的大写字符。
因此,name最终的值便是Nicholas Stroustrup。
请留意,替代品字符串无需与被替换的子字符串长度相同。
string有诸多便利操作,诸如赋值(使用=),
取下标(使用[]或像vecor那样使用at();§11.2.2),
相等性比较(使用==和!=),以及字典序比较(使用<、<=、>和>=),
遍历(像vector那样使用迭代器;§12.2),输入(§10.3)和流(§10.8)。
显然,string可以相互之间比较,与C-风格字符串比较(§1。7.1),
与字符串文本比较,例如:
string incantation;
void respond(const string& answer)
{
if (answer == incantation) {
// 施放魔法
}
else if (answer == "yes") {
// ...
}
// ...
}
如果你需要一个C-风格字符串(零结尾的char数组),
string为其持有的字符提供一个只读访问。例如:
void print(const string& s)
{
printf("For people who like printf: %s\n",s.c_str()); // s.c_str() 返回一个指针,指向 s 持有的那些字符
cout << "For people who like streams: " << s << '\n';
}
从定义方面讲,字符串文本就是一个 const char*。
要获取一个std::string类型的文本,请用s后缀。例如:
auto s = "Cat"s; // 一个 std::string
auto p = "Dog"; // 一个C-风格字符串,也就是: const char*
要启用s后缀,
你需要使用命名空间std::literals::string_literals(§5.4.4)。
string的实现实现一个字符串类,是个很受欢迎并有益的练习。
不过,对于广泛的用途来说,就算费尽心力打磨的处女作,
也罕能与标准库的sting便利及性能匹敌。
如今,string通常使用 *短字符串优化(short-string optimization)*来实现。
就是说,较短的字符串值保留在string对象内部,只有较长的字符串会置于自由存储区。
考虑此例:
string s1 {"Annemarie"}; // 短字符串
string s2 {"Annemarie Stroustrup"}; // 长字符串
其内存配置将类似于这样:
当某个string的值从短字符串变成长字符串(或相反),其内存配置也将相应调整。
一个“短”字符串应该有多少个字符呢?这由实现定义,但是“14个字符左右”当相去不远。
string的具体性能严重依赖运行时环境。
尤其在多线程实现中,内存分配的代价相对高昂。
并且在使用大量长度参差不齐的字符串时将产生内存碎片。
这些因素就是短字符串优化如此普遍应用的原因。
为处理多种字符集,string实际上是采用字符类型char
的通用模板basic_string的别名:
template<typename Char>
class basic_string
{
// ... string of Char ...
};
using string = basic_string<char>;
用户可定义任意字符类型的字符串。
例如:假设我们有个日文字符类型Jchar,就可以这么写:
using Jstring = basic_string<Jchar>;
现在,就可以针对Jstring——日文字符的字符串——进行所有常规操作了。
针对字符串序列,最常见的用途是将其传给某个函数去读取。
此操作可以有多种方式达成,将string传值,传字符串的引用,或者是C-风格字符串。
在许多系统中,还有进一步的替代方案,例如标准之外的字符串类型。
所有这些情形中,当我们想传递一个子字符串,就要涉及额外的复杂度。
为了解决这个问题,标准库提供了string_view;
string_view基本上是个(指针,长度)对,以表示一个字符序列:
string_view为一段连续的字符序列提供了访问方式。
这些字符可采用多种方式储存,包括在string中以及C-风格字符串中。
string_view像是一个指针或引用,就是说,它不拥有其指向的那些字符。
在这一点上,它与一个由迭代器(§12.3)构成的STL pair相似。
考虑以下这个简单的函数,它连接两个字符串:
string cat(string_view sv1, string_view sv2)
{
string res(sv1.length()+sv2.length());
char* p = &res[0];
for (char c : sv1) // 一种复制方式
*p++ = c;
copy(sv2.begin(),sv2.end(),p); // 另一种方式
return res;
}
可以这样调用cat():
string king = "Harold";
auto s1 = cat(king,"William"); // 字符串和 const char*
auto s2 = cat(king,king); // 字符串和字符串
auto s3 = cat("Edward","Stephen"sv); // const char * 和 string_view
auto s4 = cat("Canute"sv,king);
auto s5 = cat({&king[0],2},"Henry"sv); // HaHenry
auto s6 = cat({&king[0],2},{&king[2],4}); // Harold
跟接收const string&参数的compose()(§9.2)相比,
这个cat()具有三个优势:
string参数。请注意sv(“string_view”)后缀,要启用它,需要:
using namespace std::literals::string_view_literals; // § 5.4.4
何必要多此一举?原因是,当我们传入Edward时,
需要从const char*构建出string_view,因而就需要给这些字符串计数。
而对于"Stephen"sv,其长度在编译期就计算。
当返回string_view时,请谨记这与指针非常相像;它需要指向某个东西:
string_view bad()
{
string s = "Once upon a time";
return {&s[5],4}; // 糟糕:返回了指向局部变量的指针
}
此处返回了一个指针,指向一些位于某个string内的字符,
在我们用到这些字符之前,这个string就会被销毁。
string_view有个显著的限制,它是其中那些字符的一个只读视图。
例如,对于一个将其参数修改为小写字符的函数,你无法使用string_view。
这种情况下,请考虑采用gsl::span或gsl::string_span(§13.3)。
string_view越界访问的行为是 未指明的(unspecified)。
如果你需要一个确定性的越界检查,请使用at(),
它为越界访问抛出out_of_range异常,
也可以使用gsl::string_span(§13.3),或者“加点儿小心”就是了。
正则表达式是文本处理的强大工具。
它提供一个容易而简洁方式来描述文本中的模式
(例如:诸如TX 77845的美国邮编,或者诸如2009-06-07的ISO-风格日期)
并能够高效地发现这些模式。
在regex中,标准库为正则表达式提供了支持,其形式是std::regex类和配套函数。
作为regex风格的浅尝,我们定义并打印出一个模式:
regex pat {R"(\w{2}\s*\d{5}(-\d{4})?)"}; // 美国邮编模式: XXddddd-dddd 及变体
在任何语言中用过正则表达式的人都能发现\w{2}\s*\d{5}(-\d{4})?很眼熟。
它指定了一个模式,以两个字母\w{2}开头,其后紧跟的一些空格\s*是可选的,
接下来是五位数字\d{5}以及可选的连接号加四位数字-\d{4}。
如果你不熟悉正则表达式,这应该是个学习它的好时机
([Stroustrup,2009], [Maddock,2009], [Friedl,1997])。
为了表示这个模式,我用了个以R"(开头和)结尾的
原始字符串文本(raw string literal)。
这使得反斜杠和引号可直接用在字符串中(无需转义——译注)。
原始字符串特别适用于正则表达式,因为正则表达式往往包含大量的反斜杠。
如果我用了传统字符串,该模式的定义就会是:
regex pat {"\\w{2}\\s*\\d{5}(-\\d{4})?"}; // 美国邮编模式
在<regex>中,标准库为正则表达式提供了如下支持:
regex_match():针对一个(长度已知的)字符串进行匹配(§9.4.2)。regex_search():regex_replace():regex_iterator():在匹配和子匹配中进行遍历(§9.4.3)。regex_token_iterator():在未匹配中进行遍历。对一个模式最简单的应用就是在某个流中进行查找:
int lineno = 0;
for (string line; getline(cin,line); ) { // 读进行缓冲区
++lineno;
smatch matches; // 保存匹配到的字符串
if (regex_search(line,matches,pat)) // 在 line 中查找 pat
cout << lineno << ": " << matches[0] << '\n';
}
regex_search(line,matches,pat)在line中进行查找,
查找任何能够匹配存储于正则表达式pat中的内容,
并将匹配到的任何内容保存在matches里。
如果未发现匹配,regex_search(line,matches,pat)返回false。
matches变量的类型是smatch。
其中的“s”代表“子(sub)”或者“字符串(string)”,
一个smatch是个承载string类型子匹配的vector。
第一个元素,此处为matches[0],是完整的匹配。
regex_match()的结果是一个匹配内容的集合,通常以smatch表示:
void use()
{
ifstream in("file.txt"); // 输入文件
if (!in) // 检查文件是否打开
cerr << "no file\n";
regex pat {R"(\w{2}\s*\d{5}(-\d{4})?)"}; // 美国邮编模式
int lineno = 0;
for (string line; getline(in,line); ) {
++lineno;
smatch matches; // 保存匹配到的字符串
if (regex_search(line, matches, pat)) {
cout << lineno << ": " << matches[0] << '\n'; // 完整匹配
if (1<matches.size() && matches[1].matched) // 如果有子模式
// 并且匹配成功
cout << "\t: " << matches[1] << '\n'; // 子匹配
}
}
}
此函数读取一个文件并寻找美国邮编,例如TX77845和DC 20500-0001。
smatch类型是个正则表达式匹配结果的容器。
此处,matches[0]是整个匹配,而matches[1]是可选的四位数字子模式。
换行符\n可以是模式的组成部分,因此可以查找多行模式。
显而易见,如果要查找多行模式,就不该每次只读取一行。
正则表达式的语法和语意是有意这样设计的,目的是编译成状态机以便高效执行[Cox,2007]。
regex类型在运行时执行该编译过程。
regex库可以识别正则表达式表示法的多个变体。
此处,我使用默认的表示法,ECMA标准用于ECMAScript(俗称JavaScript)的一个变体。
正则表达式的语法基于具备特殊意义的字符们:
| 正则表达式特殊字符 | |
|---|---|
.任意单个字符(“通配符”) |
\下一个字符具有特殊意义 |
[字符类起始 |
*零个或更多(后缀操作) |
]字符类终止 |
+一个或更多(后缀操作) |
{计数起始 |
?可选的(零或一个)(后缀操作) |
}计数终止 |
|可替换的(或) |
(分组起始 |
^行首;取反 |
)分组终止 |
$行尾 |
例如,可以指定一行文本以零或多个A开头,后跟一或多个B,
再跟一个可选的C,就像这样:
ˆA*B+C?$
可匹配的范例如下:
AAAAAAAAAAAABBBBBBBBBC
BC
B
不可匹配的范例如下:
AAAAA // 没有 B
AAAABC // 以空格开头
AABBCC // C 太多了
模式的一部分若被括在小括号中,则被当作子模式(可单独从smatch中提取)。例如:
\d+-\d+ // 无子模式
\d+(-\d+) // 一个子模式
(\d+)(-\d+) // 两个子模式
模式可借助后缀设置为可选的或者重复多次(默认有且只能有一次):
| 重复 |
|---|
{n} 恰好n次 |
{n,} n或更多次 |
{n,m} 至少n次并且不超过m次 |
* 零或多次,即{0,} |
+ 一或多次,即{1,} |
? 可选的(零或一次),即{0,1} |
例如:
A{3}B{2,4}C*
可匹配范例如:
AAABBC
AAABBB
不可匹配范例如:
AABBC // A 不够
AAABC // B 不够
AAABBBBBCCC // B 太多了
在任意的重复符号(?、*、+和{})后添加后缀?,
可令该模式匹配行为“懒惰”或者“不贪婪”。
就是说,在寻找模式的时候,它将寻找最短而非最长的匹配。
默认情况下,模式匹配总是寻找最长匹配;这被称为最长匹配规则(Max Munch rule),
考虑:
ababab
模式(ab)+匹配整个ababab,而(ab)+?仅匹配第一个ab。
最常见的字符分类如下:
| 字符分类 |
|---|
alnum 任意字母和数字字符 |
alpha 任意字母字符 |
blank 除了行分割符以外的任意空白字符 |
cntrl 任意控制字符 |
d 任意十进制数字字符 |
digit 任意十进制数字字符 |
graph 任意绘图字符 |
lower 任意小写字符 |
print 任意可打印字符 |
punct 任意标点符号 |
s 任意空白字符 |
space 任意空白字符 |
upper 任意大写字符 |
w 任意成词字符(字母数字字符外加下划线) |
xdigit 任意十六进制数字字符 |
在正则表达式中,字符分类的类名必须用中括号[: :]括起来。
例如[:digit:]匹配一个十进制数字字符。
另外,要代表一个字符分类,它们必须被置于一对中括号[]中。
有多个字符串分类支持速记表示法:
| 字符分类简写 | ||
|---|---|---|
\d |
一个十进制数字字符 | [[:digit:]] |
\s |
一个空白字符(空格、制表符等等) | [[:space:]] |
\w |
一个字母(a-z)或数字字符(0-9)或下划线(_) |
[_[:alnum:]] |
\D |
非\d |
[^[:digit:]] |
\S |
非\s |
[^[:space:]] |
\W |
非\w |
[^_[:alnum:]] |
此外,支持正则表达式的语言常常会提供:
| 字符分类简写 | ||
|---|---|---|
\l |
一个小写字符 | [[:lower:]] |
\u |
一个大写字符 | [[:upper:]] |
\L |
非\l |
[^[:lower:]] |
\U |
非\u |
[^[:upper:]] |
为了最优的可移植性,请使用字符分类名而非这些简写。
作为一个例子,请考虑写一个模式以描述C++的标志符:
一个下划线或字母,后跟一个可能为空的序列,该序列由字母、数字字符或下划线组成。
为阐明细微的差异,我列出了几个错误的范例:
[:alpha:][:alnum:]* // 错误: ":alpha:"集合中的字符后跟...
[[:alpha:]][[:alnum:]]* // 错误: 不接受下划线 ('_' 不是字母)
([[:alpha:]]|_)[[:alnum:]]* // 错误: 下划线也不属于 alnum
([[:alpha:]]|_)([[:alnum:]]|_)* // OK:但略显笨拙
[[:alpha:]_][[:alnum:]_]* // OK:在字符分类里包括了下划线
[_[:alpha:]][_[:alnum:]]* // 也 OK
[_[:alpha:]]\w* // \w 等价于 [_[:alnum:]]
最后,这里有个函数使用regex_match()(§9.4.1)的最简形式测试某字符串是否标志符:
bool is_identifier(const string& s)
{
regex pat {"[_[:alpha:]]\\w*"}; // 下划线或字母
// 后跟零或多个下划线、字母、数字字符
return regex_match(s,pat);
}
注意,使用在常规字符串文本里的双反斜杠用于引入一个反斜杠。
可用原始字符串文本以缓解特殊字符带来的麻烦。例如:
bool is_identifier(const string& s)
{
regex pat {R"([_[:alpha:]]\w*)"};
return regex_match(s,pat);
}
以下是一些模式的范例:
Ax* // A, Ax, Axxxx
Ax+ // Ax, Axxx 而非 A
\d-?\d // 1-2, 12 而非 1--2
\w{2}-\d{4,5} // Ab-1234, XX-54321, 22-5432 数字字符包含在\w中
(\d*:)?(\d+) // 12:3, 1:23, 123, :123 而非 123:
(bs|BS) // bs, BS 而非 bS
[aeiouy] // a, o, u 英文元音字母, 而非 x
[ˆaeiouy] // x, k 不是英文元音字母, 而非 e
[aˆeiouy] // a, ˆ, o, u 英文元音字母或者 ˆ
潜在的以sub_match形式表示的group(子模式)由小括号界定。
如果你需要一对小括号但却不想定义一个子模式,要使用(?:而非常规的(。例如:
(\s|:|,)*(\d*) // 可选的空白字符、冒号、和/或逗号,其后跟随一个可选的数字
假设我们不关心数字之前那些字符(比方说是分隔符之类的),就可以这么写:
(?:\s|:|,)*(\d*) // 可选的空白字符、冒号、和/或逗号,其后跟随一个可选的数字
这样可以避免正则表达式引擎存储第一个字符:采用(?:的这个版本只有一个子模式。
| 正则表达式群组示例 | |
|---|---|
\d*\s\w+ |
无群组(子模式) |
(\d*)\s(\w+) |
两个群组 |
(\d*)(\s(\w+))+ |
两个群组(群组不能嵌套) |
(\s*\w*)+ |
一个群组;一个或更多子模式; 只有最后一个子模式保存为 sub_match |
<(.*?)>(.*?)</\1> |
三个群组;\1的意思是(同群组1) |
表中最后一个模式在解析XML的时候非常好用。它查找 标签/尾标签 的记号。
注意,我为标签和尾标签间的子模式用了一个不贪婪匹配(懒惰匹配),.*?。
如果我用了常规的.*,以下输入将引发一个问题:
Always look on the <b>bright</b> side of <b>life</b>.
针对第一个子模式的贪婪匹配将匹配到第一个<和最后一个>。
那也是正确的行为,但却不太可能是程序员想要的。
有关正则表达式更详尽的阐述,请见 [Friedl,1997]。
可以定义一个regex_iterator去遍历一个字符序列,寻找符合某个模式的匹配。
例如,可使用sregex_iterator(即regex_iterator<string>),
去输出某个string中所有空白字符分割的单词:
void test()
{
string input = "aa as; asd ++eˆasdf asdfg";
regex pat {R"(\s+(\w+))"};
for (sregex_iterator p(input.begin(),input.end(),pat); p!=sregex_iterator{}; ++p)
cout << (*p)[1] << '\n';
}
输出是:
as
asd
asdfg
我们丢掉了第一个单词,aa,因为它前面没有空白字符。
如果把模式简化成R"((\w+))",就会得到:
aa
as
asd
e
asdf
asdfg
regex_iterator是双向迭代器,
故无法将其在(仅提供输入迭代器的)istream上直接遍历。
另外,也无法借助regex_iterator执行写操作,
regex_iterator默认值(regex_iterator{})是唯一可能的序列末尾。
[1] 用std::string去持有字符序列;§9.2;[CG: SL.str.1]。
[2] 多用string,而非C-风格的字符串函数;§9.1。
[3] 用string去声明变量和成员,而非用作基类;§9.2。
[4] 以传值方式返回string(依赖转移语意);§9.2,§9.2.1。
[5] 以直接或间接的方式,通过substr()读子字符串,
replace()写子字符串;§9.2。
[6] string可按需变长或缩短;§9.2。
[7] 当你需要越界检查的时候,用at()而非迭代器或[];§9.2。
[8] 当你需要优化速度,用迭代器和[],而非at();§9.2。
[9] string输入不会溢出;§9.2,§10.3。
[10] (仅)在你迫不得已的时候,
使用c_str()为string生成一个C风格字符串的形式;§9.2。
[11] 用stringstream或者通用的值提取函数(如to<X>)
做字符串的数值转换;§10.8。
[12] basic_string可用于生成任意类型字符的字符串;§9.2.1。
[13] 把s后缀用于字符串文本的意思是使之成为标准库的string;
§9.3 [CG: SL.str.12]。
[14] 在待读取的字符序列存储方式多种多样的时候,
以string_view作为函数参数;§9.3 [CG: SL.str.2]。
[15] 在待写入的字符序列存储方式多种多样的时候,以gsl::string_span
作为函数参数;§9.3 [CG: SL.str.2] [CG: SL.str.2]。
[16] 把string_view看作附有长度的指针;它并不拥有那些字符;§9.3。
[17] 把sv后缀用于字符串文本的意思是使之成为标准库的string_view;§9.3。
[18] 对于绝大多数常规的正则表达式,请使用regex;§9.4。
[19] 除了最简单的模式,请采用原始字符串文本;§9.4。
[20] 用regex_match()去匹配完整的输入;§9.4,§9.4.2。
[21] 用regex_search()在输入流中查找模式;§9.4.1。
[22] 正则表达式的写法可以针对多种标准进行调整;§9.4.2。
[23] 默认的正则表达式写法遵循 ECMAScript;§9.4.2。
[24] 请保持克制,正则表达式很容易沦为只写语言;§9.4.2。
[25] 记住,\i可以表示同前面某个子模式;§9.4.2。
[26] 可以用?把模式变得“懒惰”;§9.4.2。
[27] 可以用regex_iterator在流中遍历查找模式;§9.4.3。
出自 上海译文出版社 1992年12月 出版的《英文写作指南》(陈一鸣 译),引用内容出现在该书第157页的“提示二十一”。 —— 译者注
I/O流程序库为文本和数字值提供带缓冲输入输出,无论这些值是格式化还是未格式化的。
ostream将带有类型的对象转化成字符(字节)流:
istream将字符(字节)流转化成带有类型的对象:
这些有关istream和ostrean的操作在 §10.2 和 §10.3 叙述。
这些操作是类型安全、大小写敏感的,并且可扩展以便处理用户定义的那些类型(§10.5)。
其它形式的用户交互,例如图形I/O,可利用程序库处理,但它们并非ISO标准的组成部分,
因此在这里不加赘述。
这些流可用于二进制的 I/O,用于多种字符类型,可进行本地化,还可以采用高级缓冲策略,
但这些主题不在本书的讨论范畴之内。
这些流可用于输入进或输出自string(§10.3),可格式化进string缓冲(§10.8),
还可以处理文件 I/O(§10.10)。
I/O 流相关的类全都具有析构函数,
这些函数会释放所拥有的全部资源(比如缓冲以及文件执柄)。
也就是说,它们是“资源请求即初始化”(RAII;§5.3)的示例。
I/O流库在<ostream>中为所有内建类型定义了输出。
此外,为用户定义类型定义输出也不难(§10.5)。
操作符<<(“输出到”)是指向ostream类型对象的输出操作符。
默认情况下,写到cout的那些值会被转化成一个字符流。
例如,要输出十进制数字10,可以这样写:
void f()
{
cout << 10;
}
这将把字符1后跟着个字符0放置到标准输出流中。
还可以写成这样的等价形式:
void g()
{
int x{10};
cout << x;
}
不同的类型在输出时可以清晰直观的方式组合:
void h(int i)
{
cout << "the value of i is ";
cout << i;
cout << '\n';
}
对于h(10),将会输出:
the value of i is 10
输出多个相关内容的时候,人们很快就会不耐烦去反复敲打输出流的名字。
好在,输出表达式的结果可用于后续输出,例如:
void h2(int i)
{
cout << "the value of i is " << i << '\n';
}
h2()的输出跟h()一样。
字符常量是由单引号包围的字符。请注意,字符会作为字符输出而非一个数值。例如:
void k()
{
int b = 'b'; // 注意:char 被隐式转换为 int
char c = 'c';
cout << 'a' << b << c;
}
'b'的整数值是98(在C++实现所采用的ASCII编码中),因此输出是a98c。
标准库在<istream>中提供了istream用于输入。
与ostream相似,istream也处理以字符串形式表现的内建类型,
也不难处理用户定义类型。
操作符>>(提取自)被用作输入操作符;cin是标准输入流。
>>的右操作数类型决定了可接受的输入内容和输入对象。例如:
void f()
{
int i;
cin >> i; // 把一个整数读入 i
double d;
cin >> d; // 把一个双精度浮点数读入 d
}
以上操作从标准输入读取一个数字,例如1234,进入变量i,
以及一个浮点数,例如12.34e5,进入双精度浮点数变量d。
与输出操作相似,输入操作也可以链式进行,因此可以写作等价的:
void f()
{
int i;
double d;
cin >> i >> d; // 读入 i 和 d
}
在以上两例中,整数的读取会被任何非数字字符终止。
默认情况下,>>会忽略起始的空白字符,因此一个合适的完整输入可以是:
1234
12.34e5
通常,我们需要读取一个字符序列。一个便捷的方式是将其读进一个string。例如:
void hello()
{
cout << "Please enter your name\n";
string str;
cin >> str;
cout << "Hello, " << str << "!\n";
}
如果你输入Eric,输出将是:
Hello, Eric!
默认情况下,任意空白字符,例如空格或换行,都会终止输入,
因此,如果你假装自己是命运多舛约克之王而输入Eric Bloodaxe,输出仍会是:
Hello, Eric!
你可以用getline()函数读取一整行。例如:
void hello_line()
{
cout << "Please enter your name\n";
string str;
getline(cin,str);
cout << "Hello, " << str << "!\n";
}
借助这段程序,输入的Eric Bloodaxe就能得到预期的输出:
Hello, Eric Bloodaxe!
行尾的换行符被丢弃了,因此cin就已经准备好接收下一行输入了。
使用格式化的 I/O 操作通常不易出错,高效,也比一个个字符的操作代码量少。
特别是,istream还会负责内存管理和越界检查。
可以用stringstream针对内存中的内容进行格式化输入和输出(§10.8)。
标准字符串具有良好的可扩展性,能够保存你放入其中的内容;你无需预计算最大长度。
因此,如果你输入几兆字节的分号,程序就会显示很多页的分号给你。
一个iostream具有一个状态,可以查询它,以确定某个操作是否成功。
最常见的操作是读取一连串的值:
vector<int> read_ints(istream& is)
{
vector<int> res;
for (int i; is>>i; )
res.push_back(i);
return res;
}
这段代码从is中读取,直到遇到非数字的内容。该内容通常会成为输入的结束。
此处的情形是is>>i操作返回is的引用,
并检测iostream是否产生了true,并为下一个操作做好准备。
通常,I/O状态持有待读取和写入全部信息,例如格式化信息(§10.6),
错误状态(例如,到达 输入的结尾(end-of-input)了吗?),以及所使用缓冲的类型。
另外,用户可以设置这些状态,以表示某个错误的发生(§10.5)
还可以清除该状态,如果它不是严重问题。
例如,可以想象read_ints()的某个版本接收一个终止字符串:
vector<int> read_ints(istream& is, const string& terminator)
{
vector<int> res;
for (int i; is >> i; )
res.push_back(i);
if (is.eof()) // 很好:文件末尾
return res;
if (is.fail()) { // 没能读取一个 int;它是终止符吗?
is.clear(); // 重置状态为 good()
is.unget(); // 把这个非数字字符放回到流里
string s;
if (cin>>s && s==terminator)
return res;
cin.setstate(ios_base::failbit); // 把 fail() 添加到 cin 的状态
}
return res;
}
auto v = read_ints(cin,"stop");
除了针对内建类型和string的 I/O,
iostream库还允许程序员为他们自己的类型定义I/O。
例如,考虑一个简单的类型Entry,可用它表示电话薄的一个条目:
struct Entry {
string name;
int number;
};
可以定义一个简单的输出操作符,使用 {“name”,number} 格式将其打印出来,
该格式与代码中初始化它的时候类似:
ostream& operator<<(ostream& os, const Entry& e)
{
return os << "{\"" << e.name << "\", " << e.number << "}";
}
用户定义的输出操作符接收输出流(传引用)作为第一个参数,并返回这个流作为结果。
对应的输入操作符相对复杂一些,因为此操作需要检查格式的正确性,并处理错误:
istream& operator>>(istream& is, Entry& e)
// 读取 { "name" , number } 对。注意:使用 { " " 和 } 进行格式化
{
char c, c2;
if (is>>c && c=='{' && is>>c2 && c2=='"') { // 以 { " 开头
string name; // 字符串的默认值是空字符串: ""
while (is.get(c) && c!='"') // " 前的所有内容都是 name 部分
name+=c;
if (is>>c && c==',') {
int number = 0;
if (is>>number>>c && c=='}') { // 读取 number 和一个 }
e = {name,number}; // 赋值给这条记录
return is;
}
}
}
is.setstate(ios_base::failbit); // 在流中标记 失败
return is;
}
输入操作返回一个指向其istream的引用,可用于检测操作是否成功。
例如:作为条件使用时,is>>c表示“是否成功从is读取一个char置入了c?”
is>>c默认会跳过空白字符,但是is.get(c)不会,
因此这个Entry-输入 操作符忽略(跳过)name字符串外的空白字符,
但是不会忽略name中的。例如:
{ "John Marwood Cleese", 123456 }
{"Michael Edward Palin", 987654}
可以这样从输入读取这对值进入一个Entry:
for (Entry ee; cin>>ee; ) // 从 cin 读入 ee
cout << ee << '\n'; // 把 ee 写出到 cout
输出是:
{"John Marwood Cleese", 123456}
{"Michael Edward Palin", 987654}
有关在字符流中识别模式的更系统化的技术(正则表达式匹配),请参阅 §9.4。
iostream库提为控制输入和输出格式化供了大量的控制操作。
最简单的格式化控制叫做操控符(manipulator),
可见于<ios>、<istrean>、<ostream>和
<iomanip>(针对接收参数的操控符)。
例如,可以将整数作为(默认的)十进制、八进制或十六进制数字输出:
cout << 1234 << ',' << hex << 1234 << ',' << oct << 1234 << '\n'; // 打印1234,4d2,2322
可以显式为浮点数设置输出格式:
constexpr double d = 123.456;
cout << d << "; " // 为 d 使用默认格式
<< scientific << d << "; " // 为 d 使用 1.123e2 风格的格式
<< hexfloat << d << "; " // 为 d 使用十六进制表示法
<< fixed << d << "; " // 为 d 使用123.456 风格的格式
<< defaultfloat << d << '\n'; // 为 d 使用默认格式
这将输出:
123.456; 1.234560e+002; 0x1.edd2f2p+6; 123.456000; 123.456
浮点数的精度是个决定其显示位数的整数:
defaultfloat)让编译器选择格式去呈现某个值,浮点数会被四舍五入而不是截断,precision()不会影响整数的输出。例如:
cout.precision(8);
cout << 1234.56789 << ' ' << 1234.56789 << ' ' << 123456 << '\n';
cout.precision(4);
cout << 1234.56789 << ' ' << 1234.56789 << ' ' << 123456 << '\n';
cout << 1234.56789 << '\n';
这将输出:
1234.5679 1234.5679 123456
1235 1235 123456
1235
这些浮点数操控符是“有粘性的(sticky)”;
就是说,它们的效果会对后续浮点数操作都生效。
在<fstream>中,标准库提供了针对文件的输入和输出:
ifstream 用于从一个文件进行读取ofstream 用于向一个文件进行写入fstream 用于对一个文件进行读取和写入例如:
ofstream ofs {"target"}; // “o” 的意思是 “输出(output)”
if (!ofs)
error("couldn't open 'target' for writing");
测试一个文件流是否正确打开,通常要检测其状态。
ifstream ifs {"source"}; // “i” 的意思是 “输入(input)”
if (!ifs)
error("couldn't open 'source' for reading");
假设这些测试都成功了,那么ofs可以像普通ostream(类似cout)那样使用,
ifs可以像普通istream(类似cin)那样使用。
文件定位,以及更详尽的文件打开控制都行得通,但这些不在本书的范畴里。
对于文件名和文件系统的操作,请参阅 §10.10。
在<sstream>中,标准库提供了针对string的写入和读出的操作:
istringstream 用于从一个string进行读取ostringstream 用于向一个string进行写入stringstream 用于对一个string进行读取和写入。例如:
void test()
{
ostringstream oss;
oss << "{temperature," << scientific << 123.4567890 << "}";
cout << oss.str() << '\n';
}
ostringstream的结果可以通过str()进行读取。
ostringstream的用法通常是在把结果交给GUI之前进行格式化。
类似地,从GUI接收的字符串可置于istringstream中进行格式化读取(§10.3)。
stringstream即可用于读取也可用于输出。例如,可以定义一个操作,
对于任何可呈现为string的类型,将其转换为另一个可呈现为string的类型:
template<typename Target =string, typename Source =string>
Target to(Source arg) // 从 Source 转换到 Target
{
stringstream interpreter;
Target result;
if (!(interpreter << arg) // 把 arg 写入到流
|| !(interpreter >> result) // 从流里读取结果
|| !(interpreter >> std::ws).eof()) // 还有东西剩在流中吗?
throw runtime_error{"to<>() failed"};
return result;
}
只有在无法被推断出或者没有默认值的情况下,模板参数才需要显式指定,所以可以这样写:
auto x1 = to<string,double>(1.2); // 非常明确(并且多余)
auto x2 = to<string>(1.2); // Source 被推断为 double
auto x3 = to<>(1.2); // Target 使用了默认的 string; Source 被推断为 double
auto x4 = to(1.2); // <> 冗余了;
// Target 使用了默认的 string; Source 被推断为 double
如果所有函数模板参数都有默认值,<>可以省略。
我认为这是个有关借助语言特性和标准库构件达成通用性和易用性的好例子。
C++标准库也支持C标准库的I/O,包括printf()和scanf()。
以特定的安全视角来看,大量应用这个库是不安全的,因此我不推荐用它们。
特别是,它们在输入的安全和便利性方面荆棘密布。
它们还不支持用户定义类型。
如果你不使用C风格I/O并且对I/O的性能锱铢必较,就调用:
ios_base::sync_with_stdio(false); // 避免显著的开销
没有这个调用,iostream会为了兼容C风格I/O而被显著拖慢。
多数系统都有一个*文件系统(file system)的概念,
以文件(file)*的形式对持久化的信息提供访问。
很不幸,文件系统的属性和操作它们的方式五花八门。
为应对这个问题,<filesystem>
中的文件系统库为多数文件系统的多数构件提供了统一的接口。
借助<filesystem>,我们能够可移植地:
文件系统库可以处理 unicode,但其实现方式的解释则不在本书范畴内。
有关更详尽的信息,我推荐 cppreference[Cppreference]
和 Boost文件系统文档[Boost]。
考虑一个例子:
path f = "dir/hypothetical.cpp"; // 命名一个文件
assert(exists(f)); // f 必须存在
if (is_regular_file(f)) // f 是个普通文件吗?
cout << f << " is a file; its size is " << file_size(f) << '\n';
注意,操作文件系统的程序通常跟其它程序运行在同一台电脑上。
因此,文件系统的内容可能在两条命令之间发生变化。
例如,尽管我们事先精心地确保了f存在,但是在运行到下一行,
当我们询问f是否为普通文件的时候它可能就没了。
path是个相当复杂的类,足以处理本地字符集以及大量操作系统的习惯。
特别是,它能处理来自命令行的文件名,就像示例中main()展示的那样:
int main(int argc, char* argv[])
{
if (argc < 2) {
cerr << "arguments expected\n";
return 1;
}
path p {argv[1]}; // 从命令行参数创建一个 path
cout << p << " " << exists(p) << '\n'; // 注意:path可以像字符串那样打印出来
// ...
}
在使用之前,path不会检测有效性。
就算到使用的时候,其有效性也取决于该程序所运行系统的习惯。
显而易见,path可用来打开一个文件
void use(path p)
{
ofstream f {p};
if (!f) error("bad file name: ", p);
f << "Hello, file!";
}
除了path,<filesystem>还提供了用于遍历目录以及查询文件属性的类型:
| 文件系统中的类型(部分) | |
|---|---|
path |
目录路径 |
filesystem_error |
文件系统异常 |
directory_entry |
目录入口 |
directory_iterator |
用于遍历一个目录 |
recursive_directory_iterator |
用于遍历一个目录和其子目录 |
考虑一个简单但不失真实性的例子:
void print_directory(path p)
try
{
if (is_directory(p)) {
cout << p << ":\n";
for (const directory_entry& x : directory_iterator{p})
cout << " " << x.path() << '\n';
}
}
catch (const filesystem_error& ex) {
cerr << ex.what() << '\n';
}
字符串可以隐式地转换成path,因此可以这样运用print_directory:
void use() {
print_directory("."); // 当前目录
print_directory(".."); // 父目录
print_directory("/"); // Unix 根目录
print_directory("c:"); // Windows C 盘
for (string s; cin>>s; )
print_directory(s);
}
如果我也想列出子目录,应该用recursive_directory_iterator{p}。
如果我想以字典序列出条目,就该把这些path复制到一个vector中并排序后输出。
path类提供很多常见且有用的操作:
路径操作(部分)p和p2都是path
|
|
|---|---|
value_type |
本地系统用于文件系统编码的字符类型: POSIX上是 char,Windows上是wchar_t
|
string_type |
std::basic_string<value_type> |
const_iterator |
value_type为path的const BidirectionalIterator |
iterator |
const_iterator的别名 |
p=p2 |
把p2赋值给p |
p/=p2 |
把p和p2用文件名分隔符(默认是/)连接 |
p+p2 |
把p和p2连接(无分隔符) |
p.native() |
p的本地系统格式 |
p.string() |
p以其所在的本地系统格式表示的string |
p.generic_string() |
p以通用格式表示的string |
p.filename() |
p的文件名部分 |
p.stem() |
p的主干部分(不带扩展名的文件名——译注) |
p.extension() |
p的文件扩展名部分 |
p.begin() |
p的元素序列的起始 |
p.end() |
p的元素序列的终止 |
p==p2,p!=p2 |
p和p2的相等、不等性判定 |
p<p2,p<=p2,p>p2,p>=p2 |
字典序比对 |
is>>p,os<<p |
进入/取出p的流I/O操作 |
u8path(s) |
以UTF-8编码的源字符串s构造一个路径 |
例如:
void test(path p)
{
if (is_directory(p)) {
cout << p << ":\n";
for (const directory_entry& x : directory_iterator(p)) {
const path& f = x; // 指向一个目录条目的路径部分
if (f.extension() == ".exe")
cout << f.stem() << " is a Windows executable\n";
else {
string n = f.extension().string();
if (n == ".cpp" || n == ".C" || n == ".cxx")
cout << f.stem() << " is a C++ source file\n";
}
}
}
}
我们把path当作一个字符串(即:f.extension)使用,
还可以从path中提取各种类型的字符串(即:f.extension().string())。
请当心,命名习惯、自然语言以及字符串编码的复杂度非常高。
文件系统库的抽象提供了可移植性与极大的简化。
文件系统操作(部分)p,p1和p2都是path;
e是个error_code;
b是个标志成功或失败的布尔值
|
|
|---|---|
exists(p) |
p指向的文件系统对象是否存在?
|
copy(p1,p2) |
把p1的文件或目录复制到p2;将错误以异常形式报告 |
copy(p1,p2,e) |
复制的文件或目录;将错误以错误码形式报告 |
b=copy_file(p1,p2) |
把p1的文件内容复制到p2;将错误以异常形式报告 |
b=create_directory(p) |
创建名为p的新目录;通向p的中间目录必须存在 |
b=create_directories(p) |
创建名为p的新目录;通向p的中间目录一并创建 |
p=current_path() |
p是当前工作目录 |
current_path(p) |
让p成为当前工作目录 |
s=file_size(p) |
s是p的字节数 |
b=remove(p) |
如果p是个文件或空目录,移除它 |
许多操作都有接收额外参数的重载,例如操作系统权限。
这类操作大大超出了本书的范畴,所以在需要用它的时候去搜索吧。
像copy()一样,所有的操作都有两个版本:
exists(p)。filesystem_error。error_code参数的版本,即exists(p,e)。检测e查看操作是否成功。如果在常规使用中预期会频繁失败,请使用错误码,
如果出错是异常状况,就请使用抛出异常的操作。
通常,使用查询函数是检查文件属性最简单、最直接的方式。
<filesystem>库略知几个常见的文件类型,并将其余的归类为“其它”:
文件类型f是个path或者file_status
|
|
|---|---|
is_block_file(f) |
f是个块设备吗? |
is_character_file(f) |
f是个字符设备吗? |
is_directory(f) |
f是个目录吗? |
is_empty(f) |
f是个空的文件或目录吗? |
is_fifo(f) |
f是个命名管道吗? |
is_other(f) |
f是其他类型的文件吗? |
is_regular_file(f) |
f是个常规(普通)文件吗? |
is_socket(f) |
f是个命名的IPC socket 吗? |
is_symlink(f) |
f是个符号链接吗? |
status_known(f) |
f的文件状态已知吗? |
[1] iostream 是类型安全、大小写敏感并可扩展的;§10.1。
[2] 只在不得不的时候再用字节级别的输入;§10.3; [CG: SL.io.1]。
[3] 在读取的时候,永远该考虑格式有问题的输入;§10.3; [CG: SL.io.2]。
[4] 避免使用endl(如果你不知道什么是endl,你没漏读任何内容);[CG: SL.io.50]。
[5] 对于用户定义类型,如果其值是有意义文本形式,
请给它定义<<和>>;§10.1;§10.2;§10.3。
[6] 用cout输出常规内容,cerr输出错误信息;§10.1。
[7] 针对普通字符和宽字符,有不同的iostream,
你还可以为任意字符类型定义iostream;§10.1。
[8] 二进制 I/O 是受支持的;§10.1。
[9] 对标准 I/O 流有标准的iostream,
文件和string也都有对应的标准流;§10.2;§10.3;§10.7;§10.8。
[10] 为简明的符号使用链式 <<;§10.2。
[11] 为简明的符号使用链式 >>;§10.3。
[12] 往string里输入不会导致溢出;§10.3。
[13] 默认情况下,>>会跳过起始的空白字符;§10.3。
[14] 对于可能恢复处理的I/O错误,使用流状态fail去处理;§10.4。
[15] 可以为你自定义的类型定义<<和>>操作符;§10.5。
[16] 不需要为新添加的<<和>>修改istream和ostream;§10.5。
[17] 使用操控符去控制格式化;§10.6。
[18] precision()规格会被应用到其后所有的浮点数输出操作;§10.6。
[19] 浮点数格式化规格(即scientific)
会被应用到其后所有的浮点数输出操作;§10.6。
[20] 需要使用标准操控符时就#include <ios>;§10.6。
[21] 需要使用带参数的标准操控符时就#include <iomanip>;§10.6。
[22] 别尝试去复制文件流。
[23] 在使用前,请记得检查文件流确定关联到了某个文件;§10.7。
[24] 对内存中的格式化,请使用stringstream;§10.8。
[25] 可以在任何具有字符串表示的两个类型之间定义转换操作;§10.8。
[26] C-风格的I/O操作不是类型安全的;§10.9。
[27] 除非你需要 printf 相关的函数,否则应该调用
ios_base::sync_with_stdio(false);§10.9; [CG: SL.io.10]。
[28] 使用<filesystem>而非特定操作系统的接口;§10.10。
这句话的原文是“What you see is all you get.”,没找到出处,可见于他的维基百科页面 https://en.wikipedia.org/wiki/Brian_Kernighan ,未找到官方翻译,有一个译法是 所见即全部所得,但我理解这里强调的是“某些内容被抛弃”,故按此翻译。 —— 译者注
它新颖。它卓越。它简洁。它必将成功!
这句话的原文出自“The Letters of Lord Nelson to Lady Hamilton, Vol II.”书中的“LETTER LX.”,该书可在网址 https://www.gutenberg.org/ebooks/15437 查阅,未发现中文译本。" — 霍雷肖·纳尔逊
大多数计算都要涉及创建值的集合,并且还要操作这些集合。
把一些字符读进一个string再打印出这个string就是简单的例子。
如果某个类的主要用途是装载一些对象,它通常被称为容器(container)。
在构建程序过程中,有个至关重要的任务:
为给定任务提供合适的容器,并使用便利的基础操作为它们提供支持。
为阐明标准库容器,请考虑一个程序用于保存名字和电话号码。
对于不同背景的人,这个程序会以不同的方法归类到“简单且明了”的分类中。
§10.5 中的 Entry类可用于保存一个简单的电话簿条目。
此处,我们有意忽略了现实世界的许多复杂性,
例如“很多电话号码无法以32位int表示”的这个情况。
vector最有用的标准库容器是vector。vector是给定类型元素的一个序列。
这些元素在内存中连续存储。
vector典型的实现(§4.2.2,§5.2)会包含一些指针,指向首元素、
最后一个元素后的位置、已分配空间后的位置(§12.1)(或以指针加偏移表示的等价信息):
此外,它还会包含一个内存分配器(此处的alloc),
vector可以用它为元素申请内存。
默认的内存分配器使用new和delete对内存进行申请和释放(§13.6)。
可以用一组元素类型的值对vector进行初始化:
vector<Entry> phone_book = {
{"David Hume",123456},
{"Karl Popper",234567},
{"Bertrand Arthur William Russell",345678}
};
元素可以通过下标进行访问。假设已经为Entry定义了<<,可以写:
void print_book(const vector<Entry>& book)
{
for (int i = 0; i!=book.size(); ++i)
cout << book[i] << '\n';
}
按惯例,下标自0开始,因此book[0]保存着David Hume的条目。
vector的成员函数size()给出元素的数量。
vector的元素构成一个区间,因此可以应用区间-for循环(§1.7):
void print_book(const vector<Entry>& book)
{
for (const auto& x : book) // 关于 "auto", 见 §1.4
cout << x << '\n';
}
定义一个vector的时候,会给它一个初始容量(元素的初始数量):
vector<int> v1 = {1, 2, 3, 4}; // 容量为 4
vector<string> v2; // 容量为 0
vector<Shape*> v3(23); // 容量为 23;初始元素值:nullptr
vector<double> v4(32,9.9); // 容量为 32;初始元素值:9.9
显式的容量由一对普通的小括号包围,例如(23),默认情况下,
这些元素被初始化为元素类型的默认值(即:指针为nullptr,而数值为0)。
如果你不想使用默认值,可以通过第二个参数指定一个值
(即:9.9之于v4的32个元素那样)。
初始容量可变的。
vector最有用的一个操作是push_back(),
它在vector末尾添加一个新元素,并将其容量加一。
例如,假设我们为Entry定义了>>,就可以这样写:
void input()
{
for (Entry e; cin>>e; )
phone_book.push_back(e);
}
这段代码从标准输入读取Entry放到phone_book中,
遇到输入结束(end-of-input)(即 到达文件末尾)或读取操作遭遇格式错误都会停止。
标准库中vector的具体实现确保了反复通过push_back()增长这个操作的效率。
为演示其方法,请考虑这个精致的简化版Vector(第4章和第6章),其结构如上图所示:
template<typename T>
class Vector {
T* elem; // 指向首个元素的指针
T* space; // 指向首个未使用(且未初始化)的空位的指针
T* last; // 指向最后一个空位的指针
public:
// ...
int size(); // 元素数量 (space-elem)
int capacity(); // 可容纳元素的数量 (last-elem)
// ...
void reserve(int newsz); // 增加 capacity() 到 newsz
// ...
void push_back(const T& t); // 把 t 复制进 Vector
void push_back(T&& t); // 把 t 移动进 Vector
};
标准库的vector具有成员capacity()、reverse()和push_back()。
reserve()供vector的用户和其它成员函数使用,用途是为将来的元素预留空间。
它可能不得不分配新的内存,此时,它会将当前的元素移至新分配的内存里。
有了capacity()和reverse(),实现push_back()就轻而易举了:
template<typename T>
void Vector<T>::push_back(const T& t)
{
if (capacity()<size()+1) // 确保有空间保存 t
reserve(size()==0?8:2*size()); // 把容量翻倍
new(space) T{t}; // 将 *space 初始化为 t
++space;
}
如此一来,分配内存和移动元素位置就不至于很频繁。
我曾经试图利用reserve()提高性能,但结果是白费力气:
总的来说,vector的方法优于我的臆测,因此,我目前只会在用指针指向其元素时,
才显式调用reserve(),以避免元素移动位置(而导致指针空悬——译者)。
vector可以在赋值和初始化的时候被复制。例如:
vector<Entry> book2 = phone_book;
vector的复制和移动经由构造函数和赋值运算符实现,详情请见 §5.2。
赋值vector涉及复制其元素。
因此,在book2初始化后,book2和phone_book为每个Entry分别保存副本。
当一个vector存有大量元素,这种看似人畜无害的赋值和初始化就会代价高昂。
在不该执行复制操作的地方,应该使用引用或指针(§1.7),或者移动操作(§5.2.2)。
标准库的vector非常灵活而高效。请把它作为你默认的容器;
就是说,除非你有足够的理由使用其它容器,否则就应该用它。
如果你出于“效率”考量方面的担忧而打算弃用vector,请测试一下效率。
在容器使用的性能方面,我们的直觉往往漏洞百出。
与所有标准库容器相似,vector是个某种类型T元素的容器,
简言之是个vector<T>。
任何类型都可以成为元素类型:内建的数值类型(例如char、int和double),
用户定义类型(例如string、Entry、list<T>及Matrix<double,2>),
以及指针(例如const char*、Shape*和double*)。
当你插入一个新元素,它的值会被复制进入容器。
例如,当你把一个值为7的整数放进元素,所产生元素的值为7。
该元素不是指向某个装载着7的对象的引用或指针。
这样做可令容器优雅、紧凑且访问迅速。
对于在意内存消耗以及运行时性能的用户,这至关重要。
如果你有一个类体系(§4.5),该体系依赖于virtual函数以实现多态行为,
别直接在容器里保存对象。用指针(或者智能指针;§13.2.1)取代它。例如:
vector<Shape> vs; // 别这么做——没有空间容纳 Circle 或 Smiley
vector<Shape*> vps; // 好一些,参看 §4.5.3
vector<unique_ptr<Shape>> vups; // OK
标准库vector不保证进行越界检查。例如:
void silly(vector<Entry>& book)
{
int i = book[book.size()].number; // book.size() 越界了
// ...
}
那条初始化语句很可能给i一个不确定的值,而不是报错。
这可不合时宜,而且越界访问(out-of-range)是个常见的问题。
因此,我通常使用一个带有越界检查的vector修改版:
template<typename T>
class Vec : public std::vector<T> {
public:
using vector<T>::vector; // (以名称Vec)使用vector的构造函数
T& operator[](int i) // 越界检查
{ return vector<T>::at(i); }
const T& operator[](int i) const // const 对象的越界检查; §4.2.1
{ return vector<T>::at(i); }
};
除了为越界检查重定义过的取下标操作以外,Vec从vector继承了所有内容。
at()是个vector的取下标操作,如果其参数越界了,
它将抛出out_of_range类型的异常(§3.5.1)。
对于Vec来说,越界访问将抛出异常供用户捕获。例如:
void checked(Vec<Entry>& book)
{
try {
book[book.size()] = {"Joe",999999}; // 将会抛出异常
// ...
}
catch (out_of_range&) {
cerr << "range error\n";
}
}
这将会抛出异常,然后被捕获(§3.5.1)。
如果用户不捕获某个异常,程序会以良好定义的方式终止,
而不是继续运行或导致未定义的行为。
有个方法会尽可能避免未捕获异常导致的慌乱,
就是用try-块作为main()的函数体。
例如:
int main()
try {
// 你的代码
}
catch (out_of_range&) {
cerr << "range error\n";
}
catch (...) {
cerr << "unknown exception thrown\n";
}
这提供了缺省的的异常处理,对于漏掉的异常,
会有一条错误信息输出到标准错误诊断流cerr(§10.2)。
为什么标准不确保越界检查呢?
许多追求性能的应用程序使用vector,而对所有的取下标操作意味着10%的性能损失。
显而易见,该性能损失对于不同的硬件、优化器和执行的取下标操作而有所不同。
然而,经验显示此代价会导致人们转而采用安全性奇差的内建数组。
尽管对此代价的些许担忧会导致弃用。
vector在debug时仍易于进行越界检查,
而且还可以在未检查的默认版本上构建提供检查的版本。
某些编译器提供了带有越界检查的vector版本(即:使用编译器选项),
以解除你定义Vec(或等价物)的烦恼。
区间-for借助迭代器在[bdgin():end())区间访问元素以避免越界错误。
只要其迭代器参数有效,标准库中的算法以同样的机制确保越界错误不会发生。
如果你可以在代码中直接使用vector::at(),就无需使用我那个Vec变通方案。
另外,某些标准库具备带有越界检查的vector实现,提供了比Vec更完善的检查。
list标准库提供了一个名为list的双向链表:
对于某些序列,需要在插入和删除元素时避免移动其它元素,此时我们为其应用list。
对于电话薄,插入和删除是常规操作,因此用list表示电话薄就很适宜。例如:
list<Entry> phone_book = {
{"David Hume",123456},
{"Karl Popper",234567},
{"Bertrand Arthur William Russell",345678}
};
在使用链表时,通常不会像使用vector那样以取下标的方式访问元素。
相反,会为了找到某个给定值而对链表进行查找操作。
为此,我们要借助第12章提及的“list是个序列”这一优势:
int get_number(const string& s)
{
for (const auto& x : phone_book)
if (x.name==s)
return x.number;
return 0; // 用 0 表示“号码未发现”
}
对s的查找自链表的头部开始一路向后执行,直至找到s或者抵达phone_book的尾部。
有时候,我们需要确定list中的某个元素。
例如,可能需要删除某个元素或在其前面插入一个元素。
此操作需要使用迭代器(iterator):list的迭代器确定list中的某个元素,
还可以用于遍历(iterate)该list(并由此得名)。
所有的标准库容器都提供begin()和end()函数,
它们分别返回指向首元素和尾元素之后一个位置
(one-beyond-the-end)的迭代器(第12章)。
使用迭代器可以——略失优雅地——这样写get_number():
int get_number(const string& s)
{
for (auto p = phone_book.begin(); p!=phone_book.end(); ++p)
if (p->name==s)
return p->number;
return 0; // 用 0 表示“号码未发现”
}
实际上,编译器大致就是这样实现了更简洁且更不易出错的区间-for。
给定一个迭代器p,*p就是它指向的元素,++p自增p,使之指向下一个元素,
当p指向一个具有成员m的类,p->m等价于(*p).m。
向list添加和从中删除元素都很简单:
void f(const Entry& ee, list<Entry>::iterator p, list<Entry>::iterator q) {
phone_book.insert(p,ee); // 在 p 指向的元素前插入 ee
phone_book.erase(q); // 移除 q 指向的元素
}
对于list,insert(p,elem)在p指向的元素前插入elem的一个副本作为元素。
此处,p可能是一个指向list尾元素后一个位置的迭代器。
反之,erase(p)移除p指向的元素并销毁它。
这些list的例子都可以使用vector并以相同的方式去写,
并且(惊人的是,除非你理解机器架构)在一个小vector上的性能优于小list。
如果我们只需要一个元素的序列,那就用vector。
vector在遍历(即find()和count())及排序和查找(即sort()、
equal_range();§12.6,§13.4.3)方面的性能更好。
标准库还提供了一个名为forward_list的单链表:
forward_list跟list的区别是它仅允许前向遍历。其目的是节约存储空间。
它无需在每个节点上都保存前一个元素的指针,
并且空forward_list只占用一个指针的空间。
forward_list甚至不保存其元素数量,如果你需要知道元素数量,就得数一遍。
如果你无法承担计数元素数量的开销,可能就不该用forward_list。
map通过写代码,在一个*(name,number)*对的列表里面查找某个name相当烦冗。
另外,线性查找对于短列表以外的情况都效率低下。
标准库还提供一个名为map的平衡二叉树(通常是红黑树):
在其它语境中,map也被称为关联数组或字典。它以平衡二叉树的方式实现。
标准库map是一个承载 值对 的容器,针对查找进行了优化。
可以跟list和vector以相同的方式进行初始化(§11.2,§11.3):
map<string,int> phone_book {
{"David Hume",123456},
{"Karl Popper",234567},
{"Bertrand Arthur William Russell",345678}
};
当使用其中第一个类型(被称为键(key))的值去索引时,map返回对应的第二个类型
(被称为 值(value) 或 映射类型(mapped type))的值。例如:
int get_number(const string& s)
{
return phone_book[s];
}
换句话说,对map取下标基本上就是我们称之为get_number()的查找操作。
如果没找到key,那么它就跟value的默认值一起被插入map。
整数的默认值是0;恰恰是我选取的用于表示无效电话号码的值。
如果要避免将无效号码插入电话薄,可以用find()和insert()替代[]。
unordered_mapmap的查找开销是O(log(n)),其中n是map的元素数量。这已经相当好了。
比方说对于具有 1,000,000 个元素的map,
只需要大约20次比对和转向即可找到某个元素。
不过,在很多情况下,使用哈希(hash)查找而非<这样的排序比对函数,还能更进一步。
标准库的哈希容器被称为“无序(unordered)”,是因为他们不需要一个排序比对函数:
例如,可以用<unordered_map>中的unordered_map实现电话薄:
unordered_map<string,int> phone_book {
{"David Hume",123456},
{"Karl Popper",234567},
{"Bertrand Arthur William Russell",345678}
};
就像使用map那样,可以对unordered_map取下标:
int get_number(const string& s)
{
return phone_book[s];
}
标准库为string和其它内建及标准库类型提供了缺省的哈希函数。
如果有必要,你可以提供你自己的版本(§5.4.6)。
对于“定制的”哈希函数,最常见的需求可能就来自于我们要为自己的类型创建无序容器。
哈希函数通常以函数对象(§6.3.2)的形式提供。例如:
struct Record {
string name;
int product_code;
// ...
};
struct Rhash { // 针对 Record 的哈希函数
size_t operator()(const Record& r) const
{
return hash<string>()(r.name) ˆ hash<int>()(r.product_code);
}
};
unordered_set<Record,Rhash> my_set; // Record类型的set,使用Rhash进行查找
良好哈希函数的设计是一门艺术,有时候需要对使用它的数据有一定的了解。
把现有的哈希函数用异或(^)进行组合从而创建一个新哈希函数很简单,通常也很高效。
如果定义成标准库hash的一个特化,就不必显式传递hash操作了:
namespace std { // 给 Record 弄个哈希函数
template<> struct hash<Record> {
using argument_type = Record;
using result_type = std::size_t;
size_t operator()(const Record& r) const
{
return hash<string>()(r.name) ˆ hash<int>()(r.product_code);
}
};
}
请注意map和unordered_map之间的差异:
map需要一个排序比对函数(默认情况下是<)并产生一个有序的序列unordered_map需要一个相等性判定函数(默认情况下是==);给定一个好的哈希函数,对于大容量的容器,unordered_map会比map快很多。
不过,对于糟糕的哈希函数,unordered_map的最差情况又比map差很多。
标准库提供了某些最常规且有用的容器类型,以便程序员从中挑选最适合的去构建应用:
|
标准容器概要 |
|
|---|---|
vector<T> |
长度可变的数组(§11.2) |
list<T> |
双向链表(§11.3) |
forward_list<T> |
单链表 |
deque<T> |
双向队列 |
set<T> |
集合(有key无value的map) |
multiset<T> |
同一个值可以存在多份的集合 |
map<K,V> |
关联数组(§11.4) |
multimap<K,V> |
同一个key可以存在多份的map |
unordered_map<K,V> |
使用哈希查找的map(§11.5) |
unordered_multimap<K,V> |
使用哈希查找的multimap |
unordered_set<T> |
使用哈希查找的set |
unordered_multiset<T> |
使用哈希查找的multiset |
无序容器为通过key(通常是字符串)查找而优化;换句话说它们是用哈希表实现的。
这些容器定义在命名空间std中,
并放置在vector、list、map等头文件(§8.3)里。
另外,标准库还提供容器适配器queue<T>、stack<T>、priority_queue<T>。
如果你需要,请查找它们。
标准库还提供更多特化的类容器(container-like)类型,
例如array<T,N>(§13.4.1)和bitset<N>(§13.4.2)。
从书写形式的角度看,标准容器及其基本操作被设计得相互形似。
而且,对于不同容器而言操作的语意是等价的。
可应用于每种容器的,有意义且实现高效的基本操作包括:
|
标准容器操作(部分) |
|
|---|---|
value_type |
元素的类型 |
p=c.begin() |
p指向c的首个元素;
还有返回const迭代器的cbegin()
|
p=c.end() |
p指向c的尾元素后的位置;
还有返回const迭代器的cend()
|
k=c.size() |
k是c中元素的数量
|
c.empty() |
c是否为空? |
k=c.capacity() |
k是c无需申请新内存的情况下所能承载的元素数量
|
c.reserve(k) |
把capacity变成k |
c.resize(k) |
把元素数量改成k;新增元素的值为value_type{} |
c[k] |
c的第k个元素;不做越界检查 |
c.at(k) |
c的第k个元素;若越界则抛出out_of_range
|
c.push_back(x) |
把x添加到c末尾;并把c的size加一
|
c.emplace_back(a) |
把value_type{a}添加到c末尾;并把c的size加一
|
q=c.insert(p,x) |
在c中把x添加到p前
|
q=c.erase(p) |
从c中删除p
|
c=c2 |
赋值 |
b=(c==c2),以及!= |
c和c2中的元素是否全相等;如果相等b==true
|
x=(c<c2),
以及<=、>、>= |
c和c2的字典序:
若小于则x<0,
若相等则x==0,
若大于则0<x
|
这种符号跟语意的一致性使得程序员能够创造新的容器类型,并能在用法上与标准容器类似。
提供越界检查的vector,Vector(§3.5.2,第4章)就是这样的例子。
容器接口的一致性让我们能够定义独立于特定容器类型的算法。可惜,有一利就有一弊。
比方说,对vector取下标和遍历开销小且易操作。
但是vector在插入或移除元素的时候,要对元素进行移动;list的特性则刚好相反。
请注意,对于小元素构成的较短序列,vector通常比list高效
(就连insert()和erase()也是如此)。
我推荐标准库的vector作为元素序列的默认类型:
如果你选择其它容器,就需要找到足够的理由。
考虑一下单链表,forward_list,一种专为空序列而优化的容器(§11.3)。
一个空的forward_list仅占据一个机器字的空间,而空vector要占三个。
空序列或者仅存放一或两个元素的序列,出乎意料地常见且有用。
在容器内直接构造元素(emplace)的操作,比如emplace_back(),
为一个元素的构造函数接收参数,并在容器中新分配的空间上构造出这个对象,
而不是把对象复制进入容器。
比如,对于vector<pair<int,string>>可以这么写[^2]:
v.push_back({1,"copy or move"}); // 构造一个 pair 并移进 v
v.emplace_back(1,"build in place"); // 在 v 里构造一个 pair
[1] 一个 STL 容器定义一个序列;§11.2。
[2] STL 容器是资源执柄;§11.2,§11.3,§11.4,§11.5。
[3] 把vector作为你的默认容器;§11.2,§11.6;[CG: SL.con.2]。
[4] 为容器的简单遍历使用 区间-for
或者迭代器的 begin/end 对;§11.2,§11.3。
[5] 使用reserve()以避免指向元素的指针和迭代器失效;§11.2。
[6] 未经测试的情况下,别对reserve()的性能优势抱有期待;§11.2。
[7] 在容器上push_back()或resize(),而不是在数组上realloc();§11.2。
[8] 别用迭代器访问resize过的vector;§11.2。
[9] 别期待[]会进行越界检查;§11.2。
[10] 需要确保进行越界检查的情况下用at();§11.2,[CG: SL.con.3]。
[11] 用 区间-for 和标准库算法可以零成本避免越界访问错误;§11.2.2。
[12] 元素进入容器的方式是复制;§11.2.1。
[13] 为保留元素的多态行为,请存储指针;§11.2.1。
[14] vector的插入操作,例如insert()和push_back(),
效率通常意外的好;§11.3。
[15] 为通常置空的序列使用forward_list;§11.6。
[16] 涉及性能的时候,别主观臆断,先测试;§11.2。
[17] map通常是以红黑树的方式实现的;§11.4.
[18] unordered_map是个哈希表;§11.5.
[19] 以引用的方式传递容器,作为返回值的时候以值的方式返回;§11.2。
[20] 对于容器,用()-初始化 语法指定size,
{}-初始化 语法指定元素列表;§4.2.3,§11.2。
[21] 优先使用紧凑连续的数据结构;§11.3。
[22] list的遍历操作相对高昂;§11.3。
[23] 如果需要在大规模数据中迅速查找,使用 unordered 容器;§11.5。
[24] 如果需要按顺序遍历元素,请使用有序的关联容器(即map和set);§11.4。
[25] 为不需要常规顺序(即,不存在合理的<)
的元素类型使用unordered 容器;§11.4。
[26] 做试验以确保哈希函数是否可接受;§11.5。
[27] 用异或操作(^)将元素的标准哈希结果组合起来的哈希函数通常不错;§11.5。
[28] 去了解标准库容器并优先使用它们,
而不要使用私下里手工打造的数据结构;§11.6。
这句话的原文出自“The Letters of Lord Nelson to Lady Hamilton, Vol II.”书中的“LETTER LX.”,该书可在网址 https://www.gutenberg.org/ebooks/15437 查阅,未发现中文译本。 —— 译者注
原书第一行代码是v.push_back(pair{1,"copy or move")); // make a pair and move it into v,无法通过编译,我改了一下。 —— 译者注
若无必要,勿增实体。
著名的“奥卡姆剃刀”法则,此处作者可能误会了说这短引言作者的名字,因为其名字应该是“William of Occam”,意思是“奥卡姆这个地方的威廉”,作者的写法里“奥卡姆”是他的姓。该名词的维基百科页面为: https://zh.wikipedia.org/wiki/奥卡姆剃刀 — 威廉·奥卡姆
如果单打独斗,vector和list这些数据结构的用途颇为有限。
使用时,我们需要添加和删除元素这些基本操作(就像list和vector实现的那样)。
不过,使用容器仅仅存储对象的情况寥寥无几。
而是还要排序、打印、提取子集、移除元素、查找对象等等。
相应地,标准库里除了最常见的容器类型之外,还为这些容器提供了最常见的算法。
例如,我们可以简单高效地把持有Entry的vector进行排序,
并为vector中每个不重复的元素在list中创建副本:
void f(vector<Entry>& vec, list<Entry>& lst)
{
sort(vec.begin(),vec.end()); // 用 < 进行排序
unique_copy(vec.begin(),vec.end(),lst.begin()); // 不复制相邻的等值元素
}
要让这段代码运行,必须为Entry定义小于(<)和等于(==)操作。例如:
bool operator<(const Entry& x, const Entry& y) // 小于
{
return x.name<y.name; // 用 name 给 Entry 排序
}
标准算法以元素的(半开)序列的方式表示。
一个*序列(sequence)*以指向首元素和尾元素后位置的一对迭代器表示:
在本例中,sort()为vec.begin()和vec.end()
定义的 迭代器对 定义的序列排序,该 迭代器对 恰好包括了vector的全部元素。
对于写(输出)操作,仅需指定待写入的首元素位置。
如果有不止一个元素输出,起始元素后的元素会被覆盖。
就是说,要避免出错,lst的元素数量至少跟vec中不重复值的数量一样多。
如果要把不重复元素放进一个新(空的)容器里,应该这么写:
list<Entry> f(vector<Entry>& vec)
{
list<Entry> res;
sort(vec.begin(),vec.end());
unique_copy(vec.begin(),vec.end(),back_inserter(res)); // 添加到 res 尾部
return res;
}
back_inserter(res)这个调用为res在容器末尾构造一个迭代器,
并且用它添加这些元素,为它们扩展容器以便提供存储空间。
这样我们就盛事了,不必去先分配一块固定容量的空间,而后再填充它。
于是,标准容器加上back_inserter()消灭了对realloc()
——这个易出错的显式C-风格内存管理——的需求。
标准库容器list有个转移构造函数(§5.2.2),
它可以令res的传值返回变得高效(哪怕是装载着成千上万元素的list)。
如果你觉得这个 迭代器对 风格的代码
——例如sort(vec.begin(),vec.end())——繁琐,
还可以定义容器版本的算法,然后这么写sort(vec)(§12.8)。
对于某个容器,有几个指向特定元素的迭代器可以获取;
begin()和end()就是这种例子。
另外,很多算法也会返回迭代器。
例如,标准算法find()在某个序列中查找一个值并返回指向该元素的迭代器:
bool has_c(const string& s, char c) // s 是否包含字符 c ?
{
auto p = find(s.begin(),s.end(),c);
if (p!=s.end())
return true;
else
return false;
}
与很多标准库查找算法相似,find()返回end()以表示“未找到(not found)”。
一个等价且更简短的has_c()定义是:
bool has_c(const string& s, char c) // s 是否包含字符 c ?
{
return find(s.begin(),s.end(),c)!=s.end();
}
一个更有意思的练习是找到一个字符在某字符串中出现的所有位置。
我们可以返回以承载 string迭代器 的vector,以表示的出现位置集合。
返回vector是高效的,因为它提供了转移语意(§5.2.1)。
如果我们想对找到的位置进行修改,就需要传入 非-const 字符串:
vector<string::iterator> find_all(string& s, char c) // 查找s中出现的所有c
{
vector<string::iterator> res;
for (auto p = s.begin(); p!=s.end(); ++p)
if (*p==c)
res.push_back(p);
return res;
}
我们用一个常规的循环在这个字符串中进行遍历,
借助++每次把迭代器p向容器尾部移动一个元素,
并借助解引用操作符*查看这些元素。可以这样测试find_all():
void test() {
string m {"Mary had a little lamb"};
for (auto p : find_all(m,'a'))
if (*p!='a')
cerr << "a bug!\n";
}
上面的find_all()调用可图示如下:
对于每个合乎情理的使用情形而言,迭代器和标准算法在所有标准容器上的应用都是等效的。
因此,可以这样泛化find_all():
template<typename C, typename V>
vector<typename C::iterator> find_all(C& c, V v) { // 查找v中出现的所有c
vector<typename C::iterator> res;
for (auto p = c.begin(); p!=c.end(); ++p)
if (*p==v)
res.push_back(p);
return res;
}
为了让编译器知道C的iterator应该被推断为类型而非一个值,比方说整数7,
那个typename是必须的。
可以为iterator引入一个类型别名(§6.4.2)以隐藏这个实现细节:
template<typename T>
using Iterator = typename T::iterator; // T的迭代器
template<typename C, typename V>
vector<Iterator<C>> find_all(C& c, V v) // 查找v中出现的所有c
{
vector<Iterator<C>> res;
for (auto p = c.begin(); p!=c.end(); ++p)
if (*p==v)
res.push_back(p);
return res;
}
现在可以这样写:
void test()
{
string m {"Mary had a little lamb"};
for (auto p : find_all(m,'a')) // p是个 string::iterator
if (*p!='a')
cerr << "string bug!\n";
list<double> ld {1.1, 2.2, 3.3, 1.1};
for (auto p : find_all(ld,1.1)) // p是个 list<double>::iterator
if (*p!=1.1)
cerr << "list bug!\n";
vector<string> vs { "red", "blue", "green", "green", "orange", "green" };
for (auto p : find_all(vs,"red")) // p是个 vector<string>::iterator
if (*p!="red")
cerr << "vector bug!\n";
for (auto p : find_all(vs,"green"))
*p = "vert";
}
迭代器的作用是分离算法和容器。
算法通过迭代器操作数据,并对元素所在的容器一无所知。
反之,容器对操作元素的算法也是不知所以;
它所做的不过是按需提供迭代器(即begin()和end())而已。
这种把数据存储和算法分离的模型催生出了泛化且灵活的软件。
到底什么是迭代器?任何迭代器都是某种类型的一个对象。
只不过,有着很多种不同的迭代器类型,
因为一个迭代器需要为特定的容器类型保存作业所需的信息。
这些迭代器类型可以像容器那般多种多样,还可以按实际情况进行特化。
例如,vector的迭代器可以是普通的指针,
因为需要指向vector元素的时候,指针是个相当合理的的方式:
或者,vector迭代器也可以实现为指向vector的指针外加一个索引:
使用这种迭代器可以进行越界检查。
list迭代器不得不比指向元素的指针更复杂一些,
因为一般来说list的元素并不知道该list中下一个元素在哪儿。
因此,list的迭代器有可能是个指向节点的指针:
所有迭代器共通的部分是它们的语意和操作的命名。
例如,对任何迭代器应用++都会得到指向指向下一个元素的迭代器。
类似地,用*可以得到该迭代器指向的元素。
实际上,任何对象,只要符合几个诸如此类的简单规则就是一个迭代器
——*迭代器(Iterator)*是个概束(§7.2,§12.7)。
另外,用户极少需要知道具体迭代器的类型;每个容器都“知道”它自己迭代器类型,
并按惯例以iterator和const_iterator为名称提供它们。
例如,list<Entry>::iterator就是list<Entry>的通用迭代器类型。
我们几乎没必要操心该类型定义的细节。
在处理容器中的元素序列时,迭代器是个通用且便利的概念。
但是,容器并非元素序列栖身的唯一所在。
例如,一个输入流产生一个值序列,另外,我们会向输出流写入值序列。
所以,迭代器的概念在输入和输出方面的应用也颇为有益。
要得到一个ostream_iterator,需要指定使用的流以及待写入对象的类型。
例如:
ostream_iterator<string> oo {cout}; // 向cout写入string
给*oo赋值待结果就是把该值写入到cout。例如:
int main()
{
*oo = "Hello, "; // 意思是cout<<"Hello, "
++oo;
*oo = "world!\n"; // 意思是cout<<"world!\n"
}
这是另一种将规范化消息写向标准输出的方式。
++oo模拟了利用指针向数组写入的行为。
类似地,istream_iterator允许我们把一个输入流作为只读容器使用。
同样,还是要指定使用的流和待读取对象的类型:
istream_iterator<string> ii {cin};
输入迭代器通常成对出现来表示一个序列,
因此还需要提供一个istream_iterator以表示输入的末尾。
这就是默认的istream_iterator:
istream_iterator<string> eos {};
一般来说,istream_iterator和ostream_iterator不会直接拿来就用。
而是会作为参数传递给算法去使用。
例如,可以写个简单的程序读取文件,把读到的单词排序、去重,
然后把结果写入到另一个文件:
int main()
{
string from, to;
cin >> from >> to; // 获取源文件名和目标文件名
ifstream is {from}; // 以"from"文件作为输入流
istream_iterator<string> ii {is}; // 流的输入迭代器
istream_iterator<string> eos {}; // 输入截止信号
ofstream os {to}; // 以"to"文件作为输出流
ostream_iterator<string> oo {os,"\n"}; // 流的输出迭代器
vector<string> b {ii,eos}; // b 是个以输入流初始化的vector
sort(b.begin(),b.end()); // 给缓存排序
unique_copy(b.begin(),b.end(),oo); // 把缓存复制到输出流,丢弃重复的值
return !is.eof() || !os; // 返回错误状态(§1.2.1, §10.4)
}
ifstream是个可附着到文件上的istream,
而一个ofstream是个可以附着到文件的ostream(§10.7)。
ostream_iterator的第二个参数用于分隔输出值。
实际上,此程序没必要写这么长。
我们将字符串读取到vector,然后给它们sort(),继而去重再输出。
更优雅的方案是根本不存储重复值。
要做到这一点,可以把string存储在set中,
set不会保存重复的值,并且会维持元素的顺序(§11.4)。
这样,可以把使用vector的两行代码以使用set的一行取代,
并使用更简单的copy()取代unique_copy():
set<string> b {ii,eos}; // 从输入流收集字符串
copy(b.begin(),b.end(),oo); // 把缓存复制到输出流
ii、eos和oo都只用了一次,因此可以进一步缩减程序的代码量:
int main()
{
string from, to;
cin >> from >> to; // 获取源文件名和目标文件名
ifstream is {from}; // 以"from"文件作为输入流
ofstream os {to}; // 以"to"文件作为输出流
set<string> b {istream_iterator<string>{is},istream_iterator<string>{}}; // 读输入流
copy(b.begin(),b.end(),ostream_iterator<string>{os,"\n"}); // 复制到输出流
return !is.eof() || !os; // 返回错误状态(§1.2.1, §10.4)
}
至于最后一步简化是否提高可读性,取决于个人偏好和经验。
到目前为止,例子中的算法对序列中的元素执行简单的“内建(built in)”操作。
但是,我们经常需要把这个操作作为参数传给算法。
比方说,算法find(§12.2,§12.6)提供了便捷的方式查找特定的值。
有个更通用的变体可以查找一个符合特定条件——谓词(predicate)——的元素。
例如,我们可能需要在一个map里查找第一个大于42的值。
map对其元素以*(key,value)*对的序列的方式提供访问,
因此,可以在map<string,int>查找一个其int大于42的pair<const string,int>:
void f(map<string,int>& m)
{
auto p = find_if(m.begin(),m.end(),Greater_than{42});
// ...
}
此处,Greater_than是个函数对象(§6.3.2)持有42以便用于比对操作:
struct Greater_than {
int val;
Greater_than(int v) : val{v} { }
bool operator()(const auto& r) const { return r.second>val; }
};
此外,还可以使用lambda表达式(§6.3.2):
auto p = find_if(m.begin(), m.end(), [](const auto& r) { return r.second>42; });
谓词不能对其访问的元素进行修改。
算法的通用定义是
“由规则组成的有限集合,这些规则为解决一组特定问题规定一系列操作,(并)具有五个重要特性:
有限性……确定性……输入……输出……高效性” [Knuth,1968,§1.1]。
在C++标准库的语境里,算法是一个对元素序列执行操作的函数模板。
标准库提供了数十种算法。这些算法定义在命名空间std中,
呈现在<algorithm>头文件里。
这些标准库算法全都以序列作为输入。
一个从b到e的半开区间序列表示为[b:e)。
以下是几个范例:
|
部分标准库算法 <algorithm> |
|
|---|---|
f=for_each(b,e,f) |
为[b:e)中的每个元素执行f(x) |
p=find(b,e,x) |
p是[b:e)中第一个满足*p==x的p |
p=find_if(b,e,f) |
p是[b:e)中第一个满足f(*p)的p |
n=count(b,e,x) |
n是[b:e)中满足*q==x的元素*q的数量 |
n=count_if(b,e,f) |
n是[b:e)中满足f(*q)的元素*q的数量 |
replace(b,e,v,v2) |
在[b:e)中用v2替换满足*q==v的元素*q |
replace_if(b,e,f,v2) |
在[b:e)中用v2替换满足f(*q)的元素*q |
p=copy(b,e,out) |
从[b:e)复制到[out:p) |
p=copy_if(b,e,out,f) |
从[b:e)复制满足f(*q)的元素*q到[out:p) |
p=move(b,e,out) |
从[b:e)移动到[out:p) |
p=unique_copy(b,e,out) |
从[b:e)复制到[out:p);相邻的重复元素不复制 |
sort(b,e) |
以<作为排序依据,对[b:e)中的元素进行排序 |
sort(b,e,f) |
以f作为排序依据,对[b:e)中的元素进行排序 |
(p1,p2)=equal_range(b,e,v) |
[p1:p2)是有序序列[b:e)中值为v的子序列;大体上就是针对v的二分查找 |
|
部分标准库算法 <algorithm>(续表) |
|
|---|---|
p=merge(b,e,b2,e2,out) |
把[b:e)和[b2:e2)两个有序序列和并进[out:p) |
p=merge(b,e,b2,e2,out,f) |
把[b:e)和[b2:e2)两个有序序列和并进[out:p),以f作为比对依据 |
此处和许多其它的算法(见 §14.3),可应用于容器、string以及内建数组的元素。
有些算法,例如replace()和sort(),修改元素值,
但不存在将容器元素增加或减少的算法。
原因是,一个序列并不知道持有此元素序列的是什么容器。
要增加或删除元素,你需要某个了解该容器的事物(比方 back_inserter;§12.1)
或者直接在容器上进行操作(即 push_back()或erase();§11.2)。
Lambda表达式经常作为操作以参数的形式传递,例如:
vector<int> v = {0,1,2,3,4,5};
for_each(v.begin(),v.end(),[](int& x){ x=x*x; }); // v=={0,1,4,9,16,25}
相较于手工书写的循环,标准库算法通常更谨慎、更有针对性地进行设计和实现,
因此,请了解并使用它们,以避免重复造轮子。
在C++20中,标准库算法会被指定概束(第7章)。
相关的初期准备工作请参考 Ranges Technical Specification[RangesTS]。
其具体实现可以在互联网上找到。
对于C++20,区间这个概束定义在<ranges>中。
Range是针对 通过begin()/end()定义的C++98序列 的一个泛化。
Range是个指定元素序列概念的概束。它的定义包括:
{begin,end}对{begin,n}对,其中begin是个迭代器,n是元素数量begin,pred对,其中begin是个迭代器,pred是个谓词;p来说,pred(p)为true,Range这个概束让我们可以用sort(v)取代sort(v.begin(),v.end()),
后者是STL自1994年开始的使用方式。例如:
template<BoundedRange R>
requires Sortable<R>
void sort(R& r)
{
return sort(begin(r),end(r));
}
Sortable的关系默认是less。
一般来说,在标准库算法要求用一对迭代器表示某个序列的地方,
C++20就允许使用一个Range作为简化的替代写法。
除Range之外,C++20还提供许多便利的概束。
这些概束定义在头文件<ranges>、<iterator>和concepts中。
|
核心语言概束<concepts> |
|
|---|---|
Same<T,U> |
T和U是相同的类型 |
DerivedFrom<T,U> |
T继承自U |
ConvertibleTo<T,U> |
一个T可以转化成一个U |
CommonReference<T,U> |
T和U的共通引用类型相同 |
Common<T,U> |
T和U的共通类型相同 |
Integral<T> |
T是个整数类型 |
SignedIntegral<T> |
T是个有符号整数类型 |
UnsignedIntegral<T> |
T是个无符号整数类型 |
Assignable<T,U> |
U可以赋值给T |
SwappableWith<T,U> |
T和U可以被std:swap() |
Swappable<T> |
SwappableWith<T,T> |
对于某些算法,需要在应用于多个相关类型的时候具备数学上的合理性,
Common在定义这些算法的时候就很重要。
Common<T,U>是指某个类型C,可以把T和U都先转换成C进行比对。
例如,当我们可能想要把std::string跟C-风格字符串(char*),
或者把int跟double进行比对,但不会把std::string和int进行比对。
在用于定义Common时,为确定common_type_t的特化,适宜的方式为:
using common_type_t<std::string,char*> = std::string;
using common_type_t<double,int> = double;
Common的定义略有点棘手,但解决了一个很难的基本问题。
幸运的是,除非需要进行操作的混合类型在库中(尚)无适当的定义,
我们无需为common_type_t定义一个特化。
在定义那些需要对不同的类型做比对的概束和算法时,
多数都用到了Common或CommonReference。
与比对相关的概束受到了来自 [Stepanov,2009] 的重要影响:
|
比对相关的概束<concepts> |
|
|---|---|
Boolean<T> |
T可用作布尔类型(Boolean) |
WeaklyEqualityComparableWith<T,U> |
T与U可使用==和!=进行相等性比对 |
WeaklyEqualityComparable<T> |
WeaklyEqualityComparableWith<T,T> |
EqualityComparableWith<T,U> |
T和U可使用==做等价性比对 |
EqualityComparable<T> |
EqualityComparableWith<T,T> |
StrictTotallyOrderedWith<T,U> |
T和U可使用
<、<=、
>和>=
进行比对,得出全序关系
|
StrictTotallyOrdered<T> |
StrictTotallyOrderedWith<T,T> |
WeaklyEqualityComparableWith和WeaklyEqualityComparable二者的使用,
揭示了(到目前为止一直都)被忽视的重载机会。
|
对象概束<concepts> |
|
|---|---|
Destructible<T> |
T可被销毁且可用一元的&获取其地址 |
Constructible<T,Args> |
T可通过一个Args类型的参数列表构造 |
DefaultConstructible<T> |
T有默认构造函数 |
MoveConstructible<T> |
T有转移构造函数 |
CopyConstructible<T> |
T有拷贝构造函数和转移构造函数 |
Movable<T> |
MoveConstructible<T>、
Assignable<T&,T>和
Swapable<T>
|
Copyable<T> |
CopyConstructable<T>、
Movable<T>和
Assignable<T,const T&>
|
Semiregular<T> |
Copyable<T>和
DefaultConstructable<T>
|
Regular<T> |
SemiRegular<T>和
EqualityComparable<T>
|
Regular是类型的理想状态。
Regular类型用起来大体和int差不多,
并且在某个类型的具体应用(§7.2)方面省却了许多操心。
类中默认==的缺失,意味着多数类只能以SemiRegular的形式面世,
尽管它们中的多数都本可以并应该成为Regular。
|
可调用概束<concepts> |
|
|---|---|
Invocable<F,Args> |
F可通过一个Args类型的参数列表调用 |
InvocableRegular<F,Args> |
F可通过一个Args类型的参数列表调用,并
维持等同性
|
Predicate<F,Args> |
F可通过一个Args类型的参数列表调用,返回bool值 |
Relation<F,T,U> |
Predicate<F,T,U> |
StrictWeakOrder<F,T,U> |
可确保
严格弱序
的Relation<F,T,U>
|
对于某个函数f(),如果x==y可导致f(x)==f(y),
则该函数是*维持等同性(equality preserving)*的。
严格弱序(strict weak ordering)是标准库针对顺序比对通常的假设,比如<;
如果你觉得有必要了解就查一下(或者点击表格中该名称的链接——译者)。
Relation和StrictWeakOrder仅在语意上有所差别。
我们(目前)还无法在代码层面表示这一差异,因此这两个命名仅体现了我们的意图。
|
迭代器概束<iterators> |
|
|---|---|
Iterator<I> |
I可被++自增或*解引用 |
Sentinel<S,I> |
S是某个Iterator类型的哨兵,
就是说,S是个用于I的值类型的谓词
|
SizedSentinel<S,I> |
S是个哨兵,且可以用-运算符和I运算(即减法运算s-i——译者) |
InputIterator<I> |
I是个输入迭代器,*可用于只读操作 |
OutputIterator<I> |
I是个输出迭代器,*可用于只写操作 |
ForwardIterator<I> |
I是个前向迭代器,支持
multi-pass
|
BidirectionalIterator<I> |
I是个ForwardIterator,支持--
|
RandomAccessIterator<I> |
I是个BidirectionalIterator,
支持+、-、+=、-=和[]
|
Permutable<I> |
I是个ForwardIterator,
并且I支持移动和交换元素
|
Mergeable<I1,I2,R,O> |
可以按Relation<R>把有序序列I2和I2合并入O
|
Sortable<I> |
可以按less把承载I的序列进行排序
|
Sortable<I,R> |
可以按Relation<R>把承载I的序列进行排序
|
对于给定的算法,迭代器的不同类型(分类)可用于选择最优的方式;
见 §7.2.2 和 §13.9.1。对于InputIterator的范例,请参见 §12.4。
哨兵的基本思路是这样的:我们针对某个区间进行迭代,
该区间始自一个迭代器,直到谓词对于某个元素为 true 终止。
这样,一个迭代器p和一个哨兵s就定义了一个区间[p:s(*p)]。
例如,为了遍历以指针作为迭代器的C-风格字符串,
可以为其哨兵定义一个谓词:
[](const char* p) {return *p==0; }
与C++20中的定义相比,此处Mergeable和Sortable的介绍进行了简化。
|
区间概束<ranges> |
|
|---|---|
Range<R> |
R是个区间,由一个起始迭代器和一个哨兵界定 |
SizedRange<R> |
R是个区间,其size可在常数时间内获知 |
View<R> |
R是个区间,其复制、移动和赋值的执行是常数时间 |
BoundedRange<R> |
R是个区间,其迭代器和哨兵的类型一致 |
InputRange<R> |
R是个区间,其迭代器类型符合 InputIterator 的要求 |
OutputRange<R> |
R是个区间,其迭代器类型符合 OutputIterator 的要求 |
ForwardRange<R> |
R是个区间,其迭代器类型符合 ForwardIterator 的要求 |
BidirectionalRange<R> |
R是个区间,其迭代器类型符合 BidirectionalIterator 的要求 |
RandomAccessRange<R> |
R是个区间,其迭代器类型符合 RandomAccessIterator 的要求 |
<ranges>里还有几个其它的概束,但此表也够入门的了。
在等不及 Range 概束的情况下,可以定义我们自己的简化版区间算法。
例如,提供sort(v)这种简短写法取代sort(v.begin(),v.end())可谓易如反掌:
namespace Estd {
using namespace std;
template<typename C>
void sort(C& c)
{
sort(c.begin(),c.end());
}
template<typename C, typename Pred>
void sort(C& c, Pred p)
{
sort(c.begin(),c.end(),p);
}
// ...
}
我把容器版本的sort()(和其它算法)置于独有的命名空间Estd
(“extended std”)中,以免跟其他程序员对命名空间std的使用产生冲突,
同时还便于将来用Range版本取代这个权宜之计。
当对大量数据项执行同样的操作时,只要针对不同数据项的运算相互独立,
就能够以并行的方式去执行:
标准库对这两种都提供支持,还可以指定顺序执行,在<execution>中有:
seq:顺序执行par:(在可行的情况下)并行执行par_unseq:(在可行的情况下)并行执行 和/或 非顺序(向量化)执行。以std::sort()为例:
sort(v.begin(),v.end()); // 顺序执行
sort(seq,v.begin(),v.end()); // 顺序执行(与默认方式相同)
sort(par,v.begin(),v.end()); // 并行执行
sort(par_unseq,v.begin(),v.end()); // 并行执行 和/或 向量化执行
至于并行执行 和/或 向量化执行是否划算,要取决于算法、序列中元素的数量、硬件,
以及运行该程序的机器的利用率等等。
因此*执行策略标志(execution policy indicators)*仅仅是个示意(hint)。
编译器 和/或 运行时调度器将决定在多大程度上采用并发。
这并非无关紧要的小事,此外,“切勿未经测试就对性能下断言”的规则在此处尤为重要。
绝大多数标准库算法,包括 §12.6 表中除equal_range外的全部,
都能像sort()使用par和par_unseq那样被并行化和向量化。
为什么equal_range()不行呢?因为到目前为止,它尚无有益的并行算法。
很多并行算法主要用于数值数据;参见 §14.3.1。
采用并行执行时,请确保避免数据竞争(§15.2)和死锁(§15.5)。
你乐于挥霍的时间,都不能算作浪费。
此引言的出处略复杂,详情请见 https://quoteinvestigator.com/2010/06/11/time-you-enjoy/ ,译法取自网络。— 伯特兰·罗素
并非所有标准库组件都置身于“容器”或“I/O”这样显而易见的分类中。
本章节将为那些短小但用途广泛的组件给出使用范例。
因为这些组件(类或模板)在描述设计和编程的时候被用作基本词汇,
它们也常被称为词汇表类型(vocabulary types)。
对于那些更强大的库设施——包括标准库的其它组件而言,这些库组件通常充当零件。
一个实用的函数或者类型,无需很复杂,也没必要跟其它函数和类搞得如胶似漆的。
只要不是小打小闹的程序,其关键任务之一就是管理资源。
资源就是需要先申请且使用后进行(显示或隐式)释放的东西。
比方说内存、锁、套接字、线程执柄以及文件执柄等等。
对于长时间运行的程序,不及时释放资源(“泄漏”)会导致严重的性能下降,
更有甚者还可能崩溃得让人心力交瘁的。
即便是较短的程序,资源泄露也会弄得很尴尬,
比方说因为资源不足而导致运行时间增加几个数量级。
标准库组件在设计层面力求避免资源泄露。
为此,它们依赖成对的 构造函数/析构函数 这种基础语言支持,
以确保资源随着持有它的对象一起释放。
Vector中管理器元素生命期的 构造函数/析构函数对 就是个范例(§4.2.2),
所有标准库容器都是以类似方式实现的。
重要的是,这个方法跟利用异常的错误处理相辅相成。
例如,以下技法用于标准库的锁类:
mutex m; // used to protect access to shared data
// ...
void f()
{
scoped_lock<mutex> lck {m}; // acquire the mutex m
// ... manipulate shared data ...
}
在lck的构造函数获取mutex(§15.5)之前,该thread不会继续执行。
对应的析构函数会释放此资源。
因此在本例中,在控制线程离开f()时(return、“经由函数末尾”或抛出异常),
scoped_lock的析构函数会释放mutex。
这是 RAII(“资源请求即初始化”技术;§4.2.2)的一个应用。
RAII是C++中约定俗成管理资源方式的基础。
容器(例如vector、map、string和iostream)
就以类似的方式管理其资源(比如文件执柄和缓存)。
unique_ptr 和 shared_ptr到目前为止的示例都仅涉及作用域内的对象,申请的资源在离开作用域时被释放,
那么分配在自由存储区域内的对象又改怎么处理呢?
在<memory>中,标准库提供了两个“智能指针”用于管理自由存储区中的对象:
unique_ptr用于独占所有权shared_ptr用于共享所有权这些“智能指针”最基本的用途是避免粗心大意导致的内存泄漏。例如:
void f(int i, int j) // X* vs. unique_ptr<X>
{
X* p = new X; // new一个X
unique_ptr<X> sp {new X}; // new一个X,并将其指针交给unique_ptr
// ...
if (i<99) throw Z{}; // 可能会抛出异常
if (j<77) return; // 可能会“提前”return
// ... 使用 p 和 sp ..
delete p; // 销毁 *p
}
此处,如果i<99或j<77,我们就“忘记了”删除p。
另一方面,无论以怎样的方式离开f()(抛出异常、执行return或“经由函数末尾”),
unique_ptr都会确保其对象妥善销毁。
搞笑的是,这个问题本可以轻易就解决掉,只要不使用指针且不使用new就行:
void f(int i, int j) // 使用局部变量
{
X x;
// ...
}
不幸的是,new(以及指针和引用)的滥用是个日益严重的问题。
无论如何,在你切实需要使用指针语意的时候,与努力把裸指针用对相比,
unique_ptr是个轻量级机制,没有额外空间和时间开销。
它的一个进阶用法是,把分配在自由存储区的对象传入或传出函数:
unique_ptr<X> make_X(int i)
// 分配一个X并直接交给一个unique_ptr
{
// ... 检查i,以及其它操作. ...
return unique_ptr<X>{new X{i}};
}
unique_ptr是个单个对象(或数组)的执柄,
这跟vector作为对象序列的执柄如出一辙。
都(借由RAII)控制其它对象的生命期,也都依赖于转移语意实现简洁高效的return。
shared_ptr跟unique_ptr相似,区别是shared_ptr被复制而非被转移。
某个对象的所有shared_ptr共享其所有权;这些shared_ptr全部销毁时,
该对象也随之销毁。例如:
void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);
void user(const string& name, ios_base::openmode mode)
{
shared_ptr<fstream> fp {new fstream(name,mode)};
if (!*fp) // 确保文件打开正常
throw No_file{};
f(fp);
g(fp);
// ...
}
此处,由fp的构造函数打开的文件会确保被关闭
——在最后一个函数(显示或隐式地)销毁fp副本的时候。
请留意,f()或g()可能创建任务或以其它方式保存fp的一份副本,
该副本的生命期可能比user()要长。
这样,shared_ptr就提供了一种垃圾回收形式,
它遵守 内存受管对象 基于析构函数的资源管理方式。
这个代价既非没有成本,又不至于高到离谱,
但它确实导致了 共享对象的生命期难以预测 的问题。
请只在确定需要共享所有权的时候再使用shared_ptr。
先在自由存储区上创建对象,然后再把它的指针传给某个智能指针,这套操作有些繁琐。
而且还容易出错,比如忘记把指针传给unique_ptr,
或者把非自由存储区上的对象指针传给了shared_ptr。
为避免这些问题,标准库(在<memory>中)为 构造对象并返回相应智能指针
提供了函数make_shared()和make_unique()。例如:
struct S {
int i;
string s;
double d;
// ...
};
auto p1 = make_shared<S>(1,"Ankh Morpork",4.65); // p1是个shared_ptr<S>
auto p2 = make_unique<S>(2,"Oz",7.62); // p2是个unique_ptr<S>
此处p2是个unique_ptr<S>,指向分配在自由存储区上的S类型对象,
该对象的值为{2,"Oz"s,7.62}。
跟先使用new去构造对象,然后传给shared_ptr的分步操作相比,
make_shared()不光是便利,其效率也明显更好,
因为它不需要为引用计数单独执行一次内存分配,
对于shared_ptr的实现来说,引用计数是必须的。
有了unique_ptr和shared_ptr的帮助,就可以给很多程序实现一个彻底
“无裸new(no naked new)”的策略(§4.2.2)。
不过这些“智能指针”在概念上仍然是指针,因此在资源管理方面只能是次选
——首选是 容器 和 其它在更高概念层级上进行资源管理的类型。
尤其是,shared_ptr本身并未为它的所有者提供针对共享对象的 读 和/或 写 规则。
仅仅消除了资源管理问题,并不能解决数据竞争(§15.7)和其它形式的混乱。
我们该在何处使用(诸如unique_ptr的)“智能指针”,
而非带有专门为资源设计过操作的资源执柄(比如vector或thread)呢?
答案在意料之中,是“在我们需要指针语意的时候”。
shared_ptr就成为顺理成章的选择(除非有个显而易见的唯一所有者)。unique_ptr就是个显而易见的选择。shared_ptr。从函数中返回对象的集合时,不需要使用指针;
容器在这个情形下作为资源执柄,即简单又高效(§5.2.2)。
转移和复制之间的选择通常是隐式的(§3.6)。
在对象将被销毁时(如return),编译器倾向于采用转移,
因为这是更简洁、更高效的操作。
不过,有时候不得不显式指定。例如:某个unique_ptr是一个对象的唯一所有者。
因此,它无法被复制:
void f1()
{
auto p = make_unique<int>(2);
auto q = p; // 报错:无法复制 unique_ptr
// ...
}
如果某个unique_ptr需要去在别处,就必须转移它。例如:
void f1()
{
auto p = make_unique<int>(2);
auto q = move(p); // p 现在持有 nullptr
// ...
}
令人费解的是,std::move()并不会移动任何东西。
相反,它只是把它的参数转换成一个右值引用,
以此说明其参数不会再被使用,因此可以被转移(§5.2.2)。
它本应该取一个其它的函数名,比如rvalue_cast()。
跟其它类型转换相似,它也容易出错,最好避免使用。
它为某些特定情况而存在。参考以下这个简单的互换:
template <typename T>
void swap(T& a, T& b)
{
T tmp {move(a)}; // T 的构造函数见到右值并转移
a = move(b); // T 的赋值见到右值并转移
b = move(tmp); // T 的赋值见到右值并转移
}
我们不想反复复制可能容量很大的对象,因此用std::move()进行转移。
跟其它类型转换相似,std::move()很诱人但也很危险。参考:
string s1 = "Hello";
string s2 = "World";
vector<string> v;
v.push_back(s1); // 使用"const string&"参数;push_back()将进行复制
v.push_back(move(s2)); // 使用转移构造函数
此处s1被(通过push_back())复制,而s2则是被转移。
这(仅仅是)有时候会让s2的push_back()更节约些。
问题是这里遗留的移出对象(moved-from object)。如果再次使用s2就会出问题:
cout << s1[2]; // 输出 ’l’
cout << s2[2]; // 崩溃?
我认为,如果std::move()被广泛应用,太容易出错。
除非能证明有显著和必要的性能提升,否则别用它。
后续维护很可能不经意就意想不到地用到了移出对象。
移出对象的状态通常来说是未指定的,但是所有标准库类型都会使之可销毁并且可被赋值。
如果不遵循这一惯例,可就不太明智了。
对于容器(如vector或string),移出状态将会是“置空”。
对于许多类型,默认值就是个很好的空状态:有意义且构建的成本低廉。
参数转发是个转移操作的重要用途(§7.4.2)。
有时候,我们需要把一套参数原封不动地传递给另一个函数
(以达成“完美转发(perfect forwarding)”):
template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
return unique_ptr<T>{new T{std::forward<Args>(args)...}}; // 转发每个参数
}
标准库std::forward()与简易的std::move()区别在于,
它能够正确处理左值和右值间细微的差异(§5.2.2)。
请仅在转发的时候使用std::forward(),并且别把任何东西forward()两次;
一旦你转发过某个对象,它就不再属于你了。
gsl::span按以往经验,越界错误是C和C++程序中严重错误的一个主要来源。
容器(第11章)、算法(第12章)和区间-for的使用显著缓解了这个问题,
但仍有改进的余地。
越界错误的一个关键原因在于人们传递指针(裸指针或智能指针),
而后依赖惯例去获取所指向元素的数量。
对于资源执柄以外的代码,有个颠扑不破的建议是:
假设所指向的对象至多有一个[CG: F.22],
但如果没有足够的支持,这个建议也不太好操作。
标准库的string_view(§9.3)能帮点忙,但它是只读的,并且只支持字符。
大多数程序员需要更多的支持。
C++ Core Guidelines [Stroustrup,2015] 提供了指导以及一个小型的
指导支持库(Guidelines Support Library)[GSL],
其中有个span类型用于指向包含元素的区间。
这个span已经向标准提交,但如果目前需要它,
只能下载使用
。
大体上,span是个表示元素序列的(指针,长度)对:
span为 包含元素的连续序列 提供访问。
其中的元素可以按多种形式存储,包括vector和内建数组。
与指针相仿,span对其指向的内容并无所有权。
在这方面,它与string_view(§9.3)和STL的迭代器对(§12.3)如出一辙。
考虑这个常见的接口风格:
void fpn(int* p, int n)
{
for (int i = 0; i<n; ++i)
p[i] = 0;
}
此处假设p指向n个整数。
遗憾的是,这种假设仅仅是个惯例,因此无法用于区间-for循环,
编译器也没办法实现一个低消耗高性能的越界检查。
此外,这个假设还会出错:
void use(int x)
{
int a[100];
fpn(a,100); // OK
fpn(a,1000); // 我艹,手滑了!(fpn里越界错误)
fpn(a+10,100); // fpn里越界错误
fpn(a,x); // 可疑,但看上去无害
}
可利用span对它进行改进:
void fs(span<int> p)
{
for (int& x : p)
x = 0;
}
fs用法如下:
void use(int x)
{
int a[100];
fs(a); // 隐式创建一个 span<int>{a,100}
fs(a,1000); // 报错:需要一个 span
fs({a+10,100}); // fs里报越界访问错误
fs({a,x}); // 明显很可疑
}
此处,最常见的情形是直接由数组创建一个span,
于是获得了安全性(编译器计算元素数量)以及编码的简洁性。
至于其它情形,出错的概率也降低了,因为程序员不得不显式构造一个span。
对于在函数间进行传递这种常见情形,span要比(指针,数量)接口简洁,
而且很明显还无需额外的越界检查:
void f1(span<int> p);
void f2(span<int> p)
{
// ...
f1(p);
}
在用于取下标操作(即r[i])时,会执行越界检查,
在触发越界错误情况下会抛出gsl::fail_fast。
对于极度追求性能的代码,越界检查是可以禁掉的。
在span被纳入标准时,我期待std::span能够按照
[Garcia,2016] [Garcia,2018]中的约定去管理针对越界访问的响应。
请留意,对于循环来说,越界检查只需要进行一次。
因此,对于函数体仅仅是 针对span的循环 这种常见情形而言,越界访问几乎没有开销。
针对字符的span直接获得支持,并且名称是gsl::string_span。
标准库提供了几个未能全然符合STL框架的容器(第11章、第12章)。
比方说内建数组、array和string。
我有时候称之为“次容器(almost containers)”,但这么说有点略失公允:
它们承载了元素,因此也算是容器,但由于各自的局限性或附带的便利性,
使它们在STL的语境中略失和谐。将其单独介绍有助于简化STL的叙述。
|
“次容器” |
|
|---|---|
T[N] |
内建数组:定长且连续分配的N个T类型元素的序列;隐式转化为T* |
array<T,N> |
定长且连续分配的N个T类型元素的序列;与内建数组类似,但消除了大部分问题 |
bitset<N> |
定长的N个二进制位的序列 |
vector<bool> |
一个以特化vector进行存储的紧凑二进制位的序列 |
pair<T,U> |
包含T和U类型的两个元素 |
tuple<T...> |
任意元素数量且元素间类型可各不相同的序列 |
basic_string<C> |
字符类型为C的序列;提供字符串操作 |
valarray<T> |
数值类型为T的数组;支持数值运算 |
标准库为何要提供这么多容器呢?它们针对的是普遍却略有差异(常有交叠)的需求。
如果标准库未能提供,许多人就不得不造轮子。例如:
pair和tuple是异质的;其它所有容器都是同质的(所有元素类型都相同)。array、vector和tuple是连续分配的;forward_list和map都是链式结构。bitset和vector<bool>承载二进制位且通过代理对象提供访问;basic_string规定其元素类型是某种形式的字符且提供字符串操作,valarray要求其元素是数字且提供数值运算。这些容器都提供特定的服务,这些服务对诸多程序员社群是必不可少的。
这些需求无法用单一容器满足,因为其中某些需求相互矛盾,例如:
“长度可增”和“确保分配在特定位置”就相互矛盾,
“添加元素时元素位置不可变”和“分配必须连续”也是。
array定义在<array>中的array是个给定类型的定长序列,
其中的元素数量要在编译期指定。
因此,array的元素可以分配在栈上,在对象里或者在静态存储区。
元素被分配的作用域包含了该array的定义。
array的最佳理解方式是看做内置数组,其容量不能改变,
但不会被隐式、令人略感诧异地转换成指针类型,它还提供了几个便利的函数。
与内建数组相比,使用array不会带来(时间或空间上的)开销。
array并不遵循STL容器模型的“元素执柄”模型。
相反,array直接包含了它的元素。
array可以用初始化列表进行初始化:
array<int,3> a1 = {1,2,3};
初始化列表中的元素数量必须等于或小于为array指定的数量。
元素数量是必选项:
array<int> ax = {1,2,3}; // 报错,未指定容量
元素数量必须是常量表达式:
void f(int n)
{
array<string,n> aa = {"John's", "Queens' "}; // 报错:容量不是常量表达式
//
}
如果需要用变量作为元素数量,请使用vector。
在必要的时候,array可以被显式地传给要求指针的C-风格函数。例如:
void f(int* p, int sz); // C-风格接口
void g()
{
array<int,10> a;
f(a,a.size()); // 报错:无隐式转换
f(&a[0],a.size()); // C-风格用法
f(a.data(),a.size()); // C-风格用法
auto p = find(a.begin(),a.end(),777); // C++/STL-风格用法
// ...
}
有了更加灵活的vector,为什么还要用array呢?array不那么灵活,所以更简单。
偶尔,与 借助vector(执柄)访问自由存储区内的元素并将其回收 相比,
直接访问栈上分配的元素具有大幅度的性能优势。
反之,栈是个稀缺资源(尤其在嵌入式系统上),栈溢出也太恶心。
那么既然可以用内建数组,又何必用array呢?
array知悉其容量,因此易于搭配标准库算法使用,它还可以用=进行复制。
不过,我用array的主要原因是避免意外且恶心地隐式转换为指针。考虑:
void h()
{
Circle a1[10];
array<Circle,10> a2;
// ...
Shape* p1 = a1; // OK:灾难即将发生
Shape* p2 = a2; // 报错:没有从array<Circle,10>到Shape*的隐式转换
p1[3].draw(); // 灾难
}
带有“灾难”注释的那行代码假设sizeof(Shape)<sizeof(Circle),
因此借助Shape*去对Circle[]取下标就得到了错误的偏移。
所有标准库容器都都提供这个优于内建数组的益处。
bitset系统的某个特性,例如输入流的状态,经常是一组标志位,以表示二元状态,
例如 好/坏、真/假、开/关。
C++支持针对少量标志位的高效表示,其方法要借助整数的位运算(§1.4)。
bitset<N>类泛化了这种表示法,提供了针对N个二进制位[0:N)的操作,
其中N要在编译期指定。
对于无法装进long long int的二进制位集合,
用bitset比直接使用整数方便很多。
对于较小的集合,bitset通常也优化得比较好。
如果需要给这些位命名而不使用编号,可以使用set(§11.4)或者枚举(§2.5)。
bitset可以用整数或字符串初始化:
bitset<9> bs1 {"110001111"};
bitset<9> bs2 {0b1'1000'1111}; // 使用数字分隔符的二进制数字文本(§1.4)
常规的位运算符(§1.4)以及左移与右移运算符(<<和>>)也可以用:
bitset<9> bs3 = ~bs1 // 取补: bs3=="001110000"
bitset<9> bs4 = bs1&bs3; // 全零
bitset<9> bs5 = bs1<<2; // 向左移: bs5 = "000111100"
移位运算符(此处的<<)会“移入(shift in)”零。
to_ullong()和to_string()运算提供构造函数的逆运算。
例如,我们可以输出一个int的二进制表示:
void binary(int i)
{
bitset<8*sizeof(int)> b = i; // 假设字节为8-bit(详见 §14.7)
cout << b.to_string() << '\n'; // 输出i的二进制位
}
这段代码从左到右打印出1和0的二进制位,最高有效位在最左面,
因此对于参数123将有如下输出:
00000000000000000000000001111011
对此例而言,直接用bitset的输出运算符会更简洁:
void binary2(int i)
{
bitset<8*sizeof(int)> b = i; // 假设字节为8-bit(详见 §14.7)
cout << b << '\n'; // 输出i的二进制位
}
pair和tuple我们时常会用到一些单纯的数据,也就是值的集合,
而非一个附带明确定义的语意以及 其值的不变式(§3.5.2)的类对象。
这种情况下,含有一组适当命名成员的简单struct通常就很理想了。
或者说,还可以让标准库替我们写这个定义。
例如,标准库算法equal_range返回一个表示符合某谓词子序列的迭代器pair:
template<typename Forward_iterator, typename T, typename Compare>
pair<Forward_iterator,Forward_iterator>
equal_range(Forward_iterator first, Forward_iterator last, const T& val, Compare cmp);
给定一个有序序列[first:last),equal_range将返回一个pair,
以表示符合谓词cmp的子序列。
可以用它对一个承载Record的有序序列进行查找:
auto less = [](const Record& r1, const Record& r2) { return r1.name<r2.name;}; // compare names
void f(const vector<Record>& v) // 假设v按照其“name”字段排序
{
auto er = equal_range(v.begin(),v.end(),Record{"Reg"},less);
for (auto p = er.first; p!=er.second; ++p) // 打印所有相等的记录
cout << *p; // 假设定义过Record的<<
}
pair的第一个成员被称为first,第二个成员被称为second。
该命名在创造力方面乏善可陈,乍看之下可能还有点怪异,
但在编写通用代码的时候,却能够从这种一致的命名中受益匪浅。
在first和second过于泛化的情况下,可借助结构化绑定(§3.6.3):
void f2(const vector<Record>& v) // 假设v按照其“name”字段排序
{
auto [first,last] = equal_range(v.begin(),v.end(),Record{"Reg"},less);
for (auto p = first; p!=last; ++p) // 打印所有相等的记录
cout << *p; // 假设定义过Record的<<
}
标准库中(来自<utility>)的pair在标准库内外应用颇广。
如果其元素允许,pair就提供诸如=、==和<这些运算符。
类型推导简化了pair的创建,使得无需再显式提及它的类型。例如:
void f(vector<string>& v)
{
pair p1 {v.begin(),2}; // 一种方式
auto p2 = make_pair(v.begin(),2); // 另一种方式
// ...
}
p1和p2的类型都是pair<vector<string>::iterator,int>。
如果所需的元素多于(或少于)两个,可以使用(来自<utility>的)tuple。
tuple是个 元素的异质序列,例如:
tuple<string,int,double> t1 {"Shark",123,3.14}; // 显式指定了类型
auto t2 = make_tuple(string{"Herring"},10,1.23); // 类型推导为tuple<string,int,double>
tuple t3 {"Cod"s,20,9.99}; // 类型推导为tuple<string,int,double>
较旧的代码往往用make_tuple(),因为从构造函数推导模板参数类型是C++17的内容。
访问tuple成员需要用到get()函数模板:
string s = get<0>(t1); // 获取第一个元素: "Shark"
int x = get<1>(t1); // 获取第二个元素: 123
double d = get<2>(t1); // 获取第三个元素: 3.14
tuple的成员是数字编号的(从零开始),并且下标必须是常数。
通过下标访问tuple很普遍、丑陋,还颇易出错。
好在,tuple中具有唯一类型的元素可在该tuple中以其类型“命名(named)”:
auto s = get<string>(t1); // 获取string: "Shark"
auto x = get<int>(t1); // 获取int: 123
auto d = get<double>(t1); // 获取double: 3.14
get<>()还可以进行写操作:
get<string>(t1) = "Tuna"; // 写入到string
get<int>(t1) = 7; // 写入到int
get<double>(t1) = 312; // 写入到double
与pair类似,只要元素允许,tuple也可被赋值和进行算数比对。
与tuple类似,pair也可以通过get<>()访问。
和pair的情况相同,结构化绑定(§3.6.3)也可用于tuple。
不过,在不必使用泛型代码时,带有命名成员的简单结构体通常可以让代码更易于维护。
标准库提供了三种类型来表示待选项:
variant表示一组指定的待选项中的一个(在<variant>中)optional表示某个指定类型的值或者该值不存在(在<optional>中)any表示一组未指定待选类型中的一个(在<any>中)这三种类型为用户提供相似的功能。只可惜它们的接口并不一致。
variantvariant<A,B,C>通常是union(§2.4)显式应用的更安全、更便捷的替代品。
可能最简单的示例是在值和错误码之间返回其一:
variant<string,int> compose_message(istream& s)
{
string mess;
// ... 从s读取并构造一个消息 ...
if (no_problems)
return mess; // 返回一个string
else
return error_number; // 返回一个int
}
当你用一个值给variant赋值或初始化,它会记住此值的类型。
后续可以查询这个variant持有的类型并提取此值。例如:
auto m = compose_message(cin);
if (holds_alternative<string>(m)) {
cout << m.get<string>();
}
else {
int err = m.get<int>();
// ... 处理错误 ...
}
这种风格在不喜欢异常(参见 §3.5.3)的群体中颇受欢迎,但还有更有意思的用途。
例如,某个简单的编译器可能需要以不同的表示形式区分不同的节点:
using Node = variant<Expression,Statement,Declaration,Type>;
void check(Node* p)
{
if (holds_alternative<Expression>(*p)) {
Expression& e = get<Expression>(*p);
// ...
}
else if (holds_alternative<Statement>(*p)) {
Statement& s = get<Statement>(*p);
// ...
}
// ... Declaration 和 Type ...
}
此模式通过检查待选项以决定适当的行为,它非常普遍且相对低效,
故此有必要提供直接的支持:
void check(Node* p)
{
visit(overloaded {
[](Expression& e) { /* ... */ },
[](Statement& s) { /* ... */ },
// ... Declaration 和 Type ...
}, *p);
}
这大体上相当于虚函数调用,但有可能更快。
像所有关于性能的声明一样,在性能至关重要时,应该用测算去验证这个“有可能更快”。
对于多数应用,性能方面的差异并不显著。
很可惜,此处的overloaded是必须且不合标准的。
它是个“小魔法(piece of magic)”,
可以用一组(通常是lambda表达式)的参数构造出一个重载:
template<class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>; // 推导指南(deduction guide)
“访问者(visitor)”visit继而对overloaded应用(),
后者依据重载规则选择最适合的lambda表达式进行调用。
*推导指南(deduction guide)*是个用于解决细微二义性的机制,
主要用在基础类库中的模板类构造函数。
如果试图以某个类型访问variant,但与其中保存类型不符时,
将会抛出bad_variant_access。
optionaloptional<A>可视作一个特殊的variant(类似一个variant<A,nothing>),
或者是个关于A*概念的推广,它要么指向一个对象,要么是nullptr。
如果一个函数可能返回一个对象也可能不返回时,optional就派上用场了:
optional<string> compose_message(istream& s)
{
string mess;
// ... 从s读取并构造一个消息 ...
if (no_problems)
return mess;
return {}; // 空 optional
}
现在,可以这么用:
if (auto m = compose_message(cin))
cout << *m; // 请留意这个解引用 (*)
else {
// ... 处理错误 ...
}
这在不喜欢异常(参见 §3.5.3)的群体中颇受欢迎。请留意这个*古怪的用法。
optional被视为指向对象的指针,而非对象本身。
等效于nullptr的optional是空对象{}。例如:
int cat(optional<int> a, optional<int> b)
{
int res = 0;
if (a) res+=*a;
if (b) res+=*b;
return res;
}
int x = cat(17,19);
int y = cat(17,{});
int z = cat({},{});
如果试图访问无值的optional,其结果未定义;而且不会抛出异常。
因此optional无法确保类型安全。
anyany可以持有任意的类型,并在切实持有某个类型的情况下,知道它是什么。
它大体上就是个不受限版本的variant:
any compose_message(istream& s)
{
string mess;
// ... 从s读取并构造一个消息 ...
if (no_problems)
return mess; // 返回一个 string
else
return error_number; // 返回一个 int
}
在你用某个值为一个any赋值或初始化的时候,它会记住该值的类型。
随后可以查询此any持有何种类型并提取其值。例如:
auto m = compose_message(cin);
string& s = any_cast<string>(m);
cout << s;
如果试图以某个类型访问any,但与其中保存的类型不符时,
将会抛出bad_any_access。
还有些别的方法可以访问any,它们不会抛出异常。
默认情况下,标准库容器使用new分配空间。
操作符new和delete提供通用的自由存储(也叫动态内存或者堆),
该存储区域可存储任意容量的对象,且对象的生命期由用户控制。
这意味着时间和空间的开销在许多特殊情况下可以节省下来。
因此,在必要的时候,标准库容器允许使用特定语意的分配器。
这可以消除一系列烦恼,能够解决的问题有性能(如 内存池分配器)、
安全(回收时擦除内存内容的分配器)、线程内分配器,
以及非一致性内存架构(在特定内存区域上分配,这种内存搭配特定种类的指针)。
尽管它们很重要、非常专业且通常是高级技术,但此处不是讨论这些技术的地方。
不过,我还是要举一个例子,它基于实际的问题,解决方案使用了内存池分配器。
某个重要、长期运行的系统使用事件队列(参见 §15.6),该队列以vector表示事件,
这些事件以shared_ptr的形式传递。因此,一个事件最后的用户隐式地删除了它:
struct Event {
vector<int> data = vector<int>(512);
};
list<shared_ptr<Event>> q;
void producer() {
for (int n = 0; n!=LOTS; ++n) {
lock_guardlk{m}; // m 是个 mutex (§15.5)
q.push_back(make_shared<Event>());
cv.notify_one();
}
}
理论上讲这应该工作良好。它在逻辑上很简单,因此代码健壮而易维护。
可惜这导致了内存大规模的碎片化。
在16个生产者和4个消费者间传递了100,000个事件后,消耗了超过6GB的内存。
碎片化问题的传统解决方案是重写代码,使用内存池分配器。
内存池分配器管理固定容量的单个对象且一次性为大量对象分配空间,
而不是一个个进行分配。
好在C++17直接为此提供支持。
内存池分配器定义在std的子命名空间
pmr(“polymorphic memory resource”)中:
pmr::synchronized_pool_resource pool; // 创建内存池
struct Event {
vector<int> data = vector<int>{512,&pool}; // 让 Events 使用内存池
};
list<shared_ptr<Event>> q {&pool}; // 让 q 使用内存池
void producer() {
for (int n = 0; n!=LOTS; ++n) {
scoped_locklk{m}; // m 是个 mutex (§15.5)
q.push_back(allocate_shared<Event,pmr::polymorphic_allocator<Event>>{&pool});
cv.notify_one();
}
}
现在,16个生产者和4个消费者间传递了100,000个事件后,消耗的内存不到3MB。
这是2000倍左右的提升!当然,实际利用(而非被碎片化浪费的)内存数量没变。
消灭了碎片之后,内存用量随时间推移保持稳定,因此系统可运行长达数月之久。
类似的技术在C++早期就已经取得了良好的效果,但通常需要重写代码才能用于专门的容器。
如今,标准容器通过可选的方式接受分配器参数。
容器的默认内存分配方式是使用new和delete。
在<chrono>里面,标准库提供了处理时间的组件。
例如,这是给某个事物计时的基本方法:
using namespace std::chrono; // 在子命名空间 std::chrono中;参见 §3.4
auto t0 = high_resolution_clock::now();
do_work();
auto t1 = high_resolution_clock::now();
cout << duration_cast<milliseconds>(t1-t0).count() << "msec\n";
时钟返回一个time_point(某个时间点)。
把两个time_point相减就得到个duration(一个时间段)。
各种时钟按各自的时间单位给出结果(以上时钟按nanoseconds计时),
因此,把duration转换到一个已知的时间单位通常是个好点子。
这是duration_cast的业务。
未测算过时间的情况下,就别提“效率”这茬。臆测的性能总是不靠谱。
为了简化写法且避免出错,<chrono>提供了时间单位后缀(§5.4.4)。例如:
this_thread::sleep(10ms+33us); // 等等 10 毫秒和 33 微秒
时间后缀定义在命名空间std::chrono_literals里。
有个优雅且高效的针对<chrono>的扩展,
支持更长时间间隔(如:年和月)、日历和时区,将被添加到C++20的标准中。
它当前已经存在并被广泛应用了[Hinnant,2018] [Hinnant,2018b]。可以这么写:
auto spring_day = apr/7/2018;
cout << weekday(spring_day) << '\n'; // Saturday
它甚至能处理闰秒。
以函数作为参数传递的时候,参数类型必须跟被调用函数的参数声明中的要求丝毫不差。
如果预期的参数“差不离儿(almost matches expectations)”,
那有三个不错的替代方案:
std::mem_fn()把成员函数弄成函数对象(§13.8.2)。std::function(§13.8.3)。还有些其它方法,但最佳方案通常是这三者之一。
琢磨一下这个经典的“绘制所有图形”的例子:
void draw_all(vector<Shape*>& v)
{
for_each(v.begin(),v.end(),[](Shape* p) { p->draw(); });
}
跟所有标准库算法类似,for_each()使用传统的函数调用语法f(x)调用其参数,
但Shape的draw()却按 OO 惯例写作x->f()。
lambda表达式轻而易举化解了二者写法的差异。
mem_fn()给定一个成员函数,函数适配器mem_fn(mf)输出一个可按非成员函数调用的函数对象。
例如:
void draw_all(vector<Shape*>& v)
{
for_each(v.begin(),v.end(),mem_fn(&Shape::draw));
}
在lambda表达式被C++11引入之前,men_fn()
及其等价物是把面向对象风格的调用转化到函数式风格的主要方式。
function标准库的function是个可持有任何对象并通过调用操作符()调用的类型。
就是说,一个function类型的对象是个函数对象(§6.3.2)。例如:
int f1(double);
function<int(double)> fct1 {f1}; // 初始化为 f1
int f2(string);
function fct2 {f2}; // fct2 的类型是 function<int(string)>
function fct3 = [](Shape* p) { p->draw(); }; // fct3 的类型是 function<void(Shape*)>
对于fct2,我让function的类型从初始化器推导:int(string)。
显然,function用途广泛:用于回调、将运算作为参数传递、传递函数对象等等。
但是与直接调用相比,它可能会引入一些运行时的性能损耗,
而且function作为函数对象无法参与函数重载。
如果需要重载函数对象(也包括lambda表达式),
请考虑使用overloaded(§13.5.1)。
*类型函数(type function)*是这样的函数:它在编译期进行估值,
以某个类型作为参数,或者返回一个类型。
标准库提供丰富的类型函数,用于帮助程序库作者(以及普通程序员)在编码时
能从语言本身、标准库以及普通代码中博采众长。
对于数值类型,<limits>中的numeric_limits提供了诸多有用的信息(§14.7)。
例如:
constexpr float min = numeric_limits<float>::min(); // 最小的正浮点数
同样的,对象容量可以通过内建的sizeof运算符(§1.4)获取。例如:
constexpr int szi = sizeof(int); // int的字节数
这些类型函数是C++编译期计算机制的一部分,跟其它方式相比,
它们提供了更严格的类型检查和更好的性能。
这些特性的应用也被称为元编程(metaprogramming)
或者(在涉及模板的时候)叫模板元编程(template metaprogramming)。
此处,仅对标准库构件列举两个应用:
iterator_traits(§13.9.1)和类型谓词(§13.9.2)。
概束(§7.2)让此技术的一部分变得多余,在余下的那些中还简化了一部分,
但概束尚未进入标准且未得到广泛支持,因此这些技术仍有广泛的应用。
iterator_traits标准库的sort()接收一对迭代器用来定义一个序列(第12章)。
此外,这些迭代器必须支持对序列的随机访问,就是说,
它们必须是随机访问迭代器(random-access iterators)。
某些容器,比方说forward_list,不满足这一点。
更有甚者,forward_list是个单链表,因此取下标操作代价高昂,
且缺乏合理的方式去找到前一个元素。
但是,与大多数容器类似,forward_list
提供前向迭代器(forward iterators),
可供标准算法和 for-语句 对序列遍历(§6.2)。
标准库提供了一个名为iterator_traits的机制,可以对迭代器进行检查。
有了它,就可以改进 §12.8 中的 区间sort(),
使之同时接受vector和forward_list。例如:
void test(vector<string>& v, forward_list<int>& lst)
{
sort(v); // 排序vector
sort(lst); // 排序单链表
}
能让这段代码生效的技术具有普遍的用途。
首先要写两个辅助函数,它接受一个额外的参数,
以标示当前用于随机访问迭代器还是前向迭代器。
接收随机访问迭代器的版本无关紧要:
template<typename Ran> // 用于随机访问迭代器
void sort_helper(Ran beg, Ran end, random_access_iterator_tag) // 可在[beg:end)范围内取下标
{
sort(beg,end); // 直接排序
}
前向迭代器的版本仅仅将列表复制进vector、排序,然后复制回去:
template<typename For> // 用于前向迭代器
void sort_helper(For beg, For end, forward_iterator_tag) // 可在[beg:end)范围内遍历
{
vector<Value_type<For>> v {beg,end}; // 用[beg:end)初始化一个vector
sort(v.begin(),v.end()); // 利用随机访问进行排序
copy(v.begin(),v.end(),beg); // 把元素复制回去
}
Value_type<For>是For的元素类型,被称为它的值类型(value type)。
每个标准库迭代器都有个成员value_type。
我通过定义一个类型别名(§6.4.2)来实现Value_type<For>这种写法:
template<typename C>
using Value_type = typename C::value_type; // C的值类型
这样,对于vector<X>而言,Value_type<X>就是X。
真正的“类型魔法”在辅助函数的选择上:
template<typename C> void sort(C& c)
{
using Iter = Iterator_type<C>;
sort_helper(c.begin(),c.end(),Iterator_category<Iter>{});
}
此处用了两个类型函数:
Iterator_type<C>返回C的迭代器类型(即C::iterator),
然后,Iterator_category<Iter>{}构造一个“标签(tag)”值标示迭代器类型:
std::random_access_iterator_tag: 如果C的迭代器支持随机访问std::forward_iterator_tag:如果C的迭代器支持前向迭代这样,就可以在编译期从这两个排序算法之间做出选择了。
这个技术被称为标签分发(tag dispatch),
是标准库内外用于提升灵活性和性能的技术之一。
Iterator_type可定义如下:
template<typename C>
using Iterator_type = typename C::iterator; // C的迭代器类型
但是,要把这个思路扩展到缺乏成员类型的类型,比方说指针,
标准库对标签分发的支持就要以<iterator>中的iterator_traits形式出现。
为指针所做的特化会是这样:
template<class T>
struct iterator_traits<T*> {
using difference_type = ptrdiff_t;
using value_type = T;
using pointer = T*;
using reference = T&;
using iterator_category = random_access_iterator_tag;
};
现在可以这样写:
template<typename Iter>
using Iterator_category = typename std::iterator_traits<Iter>::iterator_category; // Iter的类别
现在,尽管int*没有成员类型,仍可以用作随机访问迭代器;
iterator_category<int*>是random_access_iterator_tag。
由于概束(§7.2)的出现,许多 trait 和基于 trait 的技术都变得多余了。
琢磨一下sort()这个概束版本的例子:
template<RandomAccessIterator Iter>
void sort(Iter p, Iter q); // 用于 std::vector 和其它支持随机访问的类型
template<ForwardIterator Iter>
void sort(Iter p, Iter q)
// 用于 std::list 和其它仅支持前向遍历的类型
{
vector<Value_type<Iter>> v {p,q};
sort(v); // 使用随机访问排序sort
copy(v.begin(),v.end(),p);
}
template<Range R> void sort(R& r)
{
sort(r.begin(),r.end()); // 以适宜的方式排序
}
精进矣。
标准库在<type_trait>里面提供了简单的类型函数,
被称为类型谓词(type predicates),
为有关类型的一些基本问题提供应答。例如:
bool b1 = std::is_arithmetic<int>(); // 是,int是个算数类型
bool b2 = std::is_arithmetic<string>(); // 否,std::string 不是算数类型
其它范例包括is_class、is_pod、is_literal_type、
has_virtual_destructor,以及is_base_of。
它们主要在编写模板的时候发挥作用。例如:
template<typename Scalar>
class complex {
Scalar re, im;
public:
static_assert(is_arithmetic<Scalar>(), "Sorry, I only support complex of arithmetic types");
// ...
};
为提高可读性,标准库定义了模板别名。例如:
template<typename T>
constexpr bool is_arithmetic_v = std::is_arithmetic<T>();
我对_v后缀的写法不以为然,但这个定义别名的技术却大有作为。
例如,标准库这样定义概束Regular(§12.7):
template<class T>
concept Regular = Semiregular<T> && EqualityComparable<T>;
enable_if常见的类型谓词应用包括static_assert条件、编译期if,以及enable_if。
标准库的enable_if在 按需选择定义 方面应用广泛。
考虑定义一个“智能指针”:
template<typename T>
class Smart_pointer {
// ...
T& operator*();
T& operator->(); // -> 能且仅能在T是类的情况下正常工作
};
该且仅该T是个类类型时定义->。
例如,Smart_pointer<vector<T>>就应该有->,
而Smart_pointer<int>就不该有。
这里没办法用编译期if,因为不在函数里。但可以这么写:
template<typename T> class Smart_pointer {
// ...
T& operator*();
std::enable_if<is_class<T>(),T&> operator->(); // -> 在且仅在T是类的情况下被定义
};
如果is_class<T>()为true,operator->()的返回类型为T&;
否则operator->()的定义就被忽略了。
enable_if的语法有点怪,用起来也有点麻烦,
且在许多情况下会因为概束(§7.2)而显得多余。
不过,enable_if是大量当前模板元编程和众多标准库组件的基础。
它依赖一个名为SFINAE
(“替换失败并非错误(Substitution Failure Is Not An Error)”)的语言特性。
[1] 有用的的程序库不一定要庞大或者复杂;§13.1。
[2] 任何需要申请并(显式或隐式)释放的东西都是资源;§13.2。
[3] 利用资源执柄去管理资源(RAII);§13.2;[CG: R.1]。
[4] 把unique_ptr用于多态类的对象;§13.2.1;[CG: R.20]。
[5] (仅)将shared_ptr用于被共享的对象;§13.2.1;[CG: R.20]。
[6] 优先采用附带专用语意的资源执柄,而非智能指针;§13.2.1。
[7] 优先采用unique_ptr,而非shared_ptr;§5.3,§13.2.1。
[8] 用make_unique()去构造unique_ptr;§13.2.1;[CG: R.22]。
[9] 用make_shared()去构造shared_ptr;§13.2.1;[CG: R.23]。
[10] 优先采用智能指针,而非垃圾回收;§5.3,§13.2.1。
[11] 别使用std::move();§13.2.2;[CG: ES.56]。
[12] 仅在参数转发时使用std::forward();§13.2.2。
[13] 将某个对象std::move()或std::forward()之后,
绝不能再读取它;§13.2.2。
[14] 相较于 指针加数量 的方式,优先使用span§13.3;[CG: F.24]。
[15] 在需要容量为 constexpr 的序列时,采用array;§13.4.1。
[16] 优先采用array,而非内建数组;§13.4.1;[CG: SL.con.2]。
[17] 如果要用到N个二进制位,而N又不一定是内建整数类型位数的时候,
用bitset;§13.4.2。
[18] 别滥用pair和tuple;具名struct通常能让代码更具可读性;§13.4.3。
[19] 使用pair时,利用模板参数推导或make_pair(),
以避免冗余的类型指定§13.4.3。
[20] 使用tuple时,利用模板参数推导或make_tuple(),
以避免冗余的类型指定§13.4.3;[CG: T.44]。
[21] 相较于显式的union,优先采用variant;§13.5.1; [CG: C.181]。
[22] 利用分配器以避免产生内存碎片;§13.6。
[23] 在做效率方面的决定之前,请先测定程序的执行时间;§13.7。
[24] 使用duration_cast为测量结果的输出选择合适的时间单位;§13.7。
[25] 指定duration时,采用适当的时间单位;§13.7。
[26] 在需要传统的函数调用写法的地方,
请使用men_fn()或lambda表达式将成员函数转换成函数对象;§13.8.2。
[27] 需要把可调用的东西保存起来时,请采用function;§13.8.3。
[28] 编写代码时,可显式依赖于类型的属性;§13.9。
[29] 只要条件允许,就优先使用概束去取代 trait 和enable_if;§13.9。
[30] 使用别名和类型谓词以简化书写;§13.9.1,§13.9.2。
此引言的出处略复杂,详情请见 https://quoteinvestigator.com/2010/06/11/time-you-enjoy/ ,译法取自网络。—— 译者注
计算的目的在于洞悉事物,而非数字本身
这段话刊载于 R. W. Hamming 的著作《Numerical methods for scientists and engineers》,此书未见中文版 — 理查德·卫斯里·汉明
……但对学生而言,
数据往往是开启洞察力的最佳途径。
Anthony Ralston 简介见 https://history.computer.org/pioneers/ralston.html — Anthony Ralston
C++在设计之初并未做数值计算方面着重考虑过。
但数值计算却常穿插于其它业务——比如数据库访问、网络系统、仪器控制、图形学、
仿真及金融分析等等——因此对于较大系统中的计算部分,C++就成了香饽饽。
此外,数值方法早已远非遍历浮点数向量这样简单的任务。
在参与计算的数据结构日益复杂之处,C++的威力变得举足轻重。
这导致C++广泛用于科学、工程、金融及其它涉及复杂计算的领域。
因此,此类计算的辅助构件和技术则应运而生。
本章讲述标准库中有关数值计算的部分。
头文件<cmath>提供了标准数学函数(standard mathematical functions),
例如针对参数类型float、double以及long double的
sqrt()、log()和sin()等:
|
标准数学函数 |
|
|---|---|
abs(x) |
绝对值 |
ceil(x) |
>=x的最小整数 |
floor(x) |
<=x的最大整数 |
sqrt(x) |
平方根;x不能是负数 |
cos(x) |
余弦函数 |
sin(x) |
正弦函数 |
tan(x) |
正切函数 |
acos(x) |
反余弦函数;结果不为负 |
asin(x) |
反正弦函数;返回最靠近0的结果 |
atan(x) |
反正切函数 |
sinh(x) |
双曲正弦函数 |
cosh(x) |
双曲余弦函数 |
tanh(x) |
双曲正切函数 |
exp(x) |
e(自然常数)的x次幂 |
log(x) |
自然对数,以e为底;x必须是正数 |
log10(x) |
以10为底的对数 |
针对complex(§14.4)的版本在<complex>中。
以上函数的返回值类型与参数相同。
错误报告的方式是将errno设置为<cerrno>中的值,
定义域超出范围设为EDOM,值域超出范围设为ERANGE。例如:
void f()
{
errno = 0; // 清除错误状态
sqrt(-1);
if (errno==EDOM)
cerr << "sqrt() not defined for negative argument";
errno = 0; // 清除错误状态
pow(numeric_limits<double>::max(),2);
if (errno == ERANGE)
cerr << "result of pow() too large to represent as a double";
}
有些被称为特殊数学函数(special mathematical functions)
的数学函数在<cstdlib>里,
还有几个在<cmath>比如 beta()、rieman_zeta()、sph_bessel()。
<numeric>里有几个泛化过的数值算法,比如accumulate()。
|
数值算法 |
|
|---|---|
x=accumulate(b,e,i) |
x是i与[b:e)间元素的和 |
x=accumulate(b,e,i,f) |
调用accumulate时用f替换+ |
x=inner_product(b,e,b2,i) |
x是[b:e)与
[b2:b2+(e-b))的内积,
即i与(*p1)*(*p2)的和,
其中p1是[b:e)
中的元素,且对应来自[b2:b2+(e-b))
中的元素p2
|
x=inner_product(b,e,b2,i,f,f2) |
调用inner_product时用f
和f2分别替换+和* |
p=partial_sum(b,e,out) |
[out:p)的第i个元素是
[b:b+i]间所有元素的和
|
p=partial_sum(b,e,out,f) |
调用partial_sum时以f替换+
|
p=adjacent_difference(b,e,out) |
i>0时,[out:p)
的第i个元素是*(b+i)-*(b+i-1);
e-b>0时,*out就是*b
|
p=adjacent_difference(b,e,out,f) |
调用adjacent_difference时以
f替换-
|
iota(b,e,v) |
把++v依次赋值给[b:e)
之间的元素,因此元素序列就变成v+1,v+2,……
|
x=gcd(n,m) |
x是整数n和m的最大公约数 |
x=lcm(n,m) |
x是整数n和m的最小公倍数 |
这些算法泛化了常见运算,比如求和运算被应用到所有类型的元素序列上了。
也把应用在元素序列上的操作参数化了。
对于每个算法,最常规的版本是将常规运算代入到通用版本得到的。例如:
list<double> lst {1, 2, 3, 4, 5, 9999.99999};
auto s = accumulate(lst.begin(),lst.end(),0.0); // 求和得到:10014.9999
这些算法适用于标准库中的所有元素序列,可以将操作以参数的形式传入
(§14.3)。
<numeric>中,数值算法具有略带差异的并行版本(§12.9):
|
并行数值算法 |
|
|---|---|
x=reduce(b,e,v) |
无序执行的x=accumulate(b,e,v) |
x=reduce(b,e) |
x=reduce(b,e,V{}),其中V
是b的值类型 |
x=reduce(pol,b,e,v) |
采用执行策略pol的
x=reduce(b,e,v) |
x=reduce(pol,b,e) |
x=reduce(pol,b,e,V{}),其中V
是b的值类型 |
p=exclusive_scan(pol,b,e,out) |
按照pol策略执行
p=partial_sum(b,e,out)
计算第i个和的时候,第i个输入元素不参与计算 |
p=inclusive_scan(pol,b,e,out) |
按照pol策略执行
p=partial_sum(b,e,out)
计算第i个和的时候,第i个输入元素参与计算 |
|
并行数值算法(续表) |
|
|---|---|
p=transform_reduce(pol,b,e,f,v) |
对[b:e)中的每个
x执行f(x),
而后执行reduce |
p=transform_exclusive_scan(pol,b,e,out,f,v) |
对[b:e)中的每个
x执行f(x),
而后执行exclusive_scan |
p=transform_inclusive_scan(pol,b,e,f,v) |
对[b:e)中的每个
x执行f(x),
而后执行inclusive_scan |
为简化叙述,此处没有提及那些采用仿函数参数替代+和=算法版本。
除reduce()意外,采用默认(顺序)执行策略和缺省值的版本也未提及。
此处的算法和<algorithm>里的并行算法一样,可以指定执行策略:
vector<double> v {1, 2, 3, 4, 5, 9999.99999};
auto s = reduce(v.begin(),v.end()); // 以double的默认值为初值累加求和
vector<double> large;
// ... 以大量的值填充large ...
auto s2 = reduce(par_unseq,large.begin(),large.end()); // 求和,并行策略可用则用,不可用就顺序执行
(reduce()之类的)并行算法区别于顺序版本(即accumulate())之处在于:
并行算法中针对元素的操作执行顺序不定。
标准库提供了一系列的复数类型,它们符合§4.2.1中complex的描述。
为了让其中的标量能支持单精度浮点数(float)、双精度浮点数(double)等类型,
标准库的complex是个模板:
template<typename Scalar>
class complex {
public:
complex(const Scalar& re ={}, const Scalar& im ={}); // 函数参数缺省值;参见 §3.6.1
// ...
};
复数支持常见的算术操作和大多数的数学函数。例如:
void f(complex<float> fl, complex<double> db)
{
complex<long double> ld {fl+sqrt(db)};
db += fl*3;
fl = pow(1/fl,2);
// ...
}
sqrt()和pow()(幂运算)属于<complex>中定义的常见数学函数。
许多领域需要随机数,比如测试、游戏、仿真以及安全系统。
标准库在<random>中提供了种类繁多的随机数发生器,它们反映了应用领域的多样性。
随机数发生器由两部分组成:
分布器的例子有:uniform_int_distribution(生成所有可能值的概率相同)、
normal_distribution(正态分布,即“铃铛曲线”)、
exponential_distribution(指数分布);它们都可以指定生成的随机数范围。
例如:
using my_engine = default_random_engine; // 引擎类型
using my_distribution = uniform_int_distribution<>; // 分布器类型
my_engine re {}; // 默认引擎
my_distribution one_to_six {1,6}; // 映射到整数 1..6 的分布器
auto die = [](){ return one_to_six(re); } // 创建一个随机数生成器
int x = die(); // 掷骰子:x的值在闭区间[1:6]内
出于对标准库中随机数组件通用性和性能的持续关注,
一位专家称其为“每个随机数程序库成长的榜样”。
但是要论“新手之友”的称号,它可就愧不敢当了。
前述代码示例借助using语句和lambda表达式,稍稍提升了一点代码可读性。
对于(任何背景的)新手而言,随机数程序库那个完整的通用接口绝对是个大坑。
一个简洁统一的随机数生成器往往就足以起步了。例如:
Rand_int rnd {1,10}; // 创建一个[1:10]之间的随机数生成器
int x = rnd(); // x是闭区间[1:10]内的一个值
但到哪儿去找这个东西呢?我们得弄个跟die()差不多的东西,
把引擎和分布器撮合起来,装进一个Rand_int类:
class Rand_int {
public:
Rand_int(int low, int high) :dist{low,high} { }
int operator()() { return dist(re); } // 抽一个 int
void seed(int s) { re.seed(s); } // 设置新的随机数引擎种子
private:
default_random_engine re;
uniform_int_distribution<> dist;
};
这个定义仍然是“专家级”的,但是Rand_int()的使用,
学习C++第一周的新手就可以轻松掌握了。例如:
int main()
{
constexpr int max = 9;
Rand_int rnd {0,max}; // 创建一个统一的随机数生成器
vector<int> histogram(max+1); // 创建一个容量合适的vector
for (int i=0; i!=200; ++i)
++histogram[rnd()]; // 以[0:max]之间的数字作为频率填充直方图
for (int i = 0; i!=histogram.size(); ++i) { // 绘制柱状图
cout << i << '\t';
for (int j=0; j!=histogram[i]; ++j) cout << '*';
cout << endl;
}
}
输出是个(索然无味的)均匀分布(具有合理的统计波动)。
0 *********************
1 ****************
2 *******************
3 ********************
4 ****************
5 ***********************
6 **************************
7 ***********
8 **********************
9 *************************
C++没有标准的图形库,所以这里用了“ASCII图形”。
毫无疑问,C++有众多开源以及商业的图形和GUI库,
但我在本书中限定自己仅使用ISO标准的构件。
§11.2叙述的vector设计目的是作为一个承载值的通用机制,
要灵活,并且能融入容器、迭代器、算法这套架构。
可惜它不支持数学矢量(vector)的运算。
给vector添加这些运算没什么难度,
但它的通用性和灵活性跟繁重的数值作业所需的优化格格不入。
因此,标准库(在<valarray>中)提供了一个类似vector的模板,
被称为valarray,它的通用性不济,但在数值计算所需的优化方面却精益求精:
template<typename T>
class valarray {
// ...
};
valarray支持常见的算术运算和大多数的数学函数,例如:
void f(valarray<double>& a1, valarray<double>& a2)
{
valarray<double> a = a1*3.14+a2/a1; // 数值数组运算符 *、+、/、和=
a2 += a1*3.14;
a = abs(a);
double d = a2[7];
// ...
}
除算术运算之外,valarray还支持跨步访问,以辅助多维计算的实现。
标准库在<limits>中提供了一些类,用于描述内建类型的属性——
比如float指数部分的最大值,或者每个int的字节数。
比如说,可以断言char是有符号的:
static_assert(numeric_limits<char>::is_signed,"unsigned characters!");
static_assert(100000<numeric_limits<int>::max(),"small ints!");
请留意,第二个断言能够运行的原因,来(且仅来)自于
numeric_limits<int>::max()是个constexpr函数(§1.6)这一事实。
[1] 数值计算问题通常很微妙,如果对这类问题的某一方面没有100%的确信,
要么尝试咨询专家建议,要么实践检验,或者干脆双管齐下;§14.1。
[2] 别把繁重的数值计算建立在编程语言的基础构件上,请采用程序库;§14.1。
[3] 如果要从序列里计算出一个值,尝试写循环之前,请先考虑accumulate()、
inner_product()、partial_sum()、adjacent_difference()
;§14.3。
[4] 为复数运算采用std::complex;§14.4。
[5] 把随机数引擎和一个分布器组合起来创建随机数生成器;§14.5。
[6] 确保你的随机数足够随机;§14.5。
[7] 别用C标准库的rand();对于实际应用而言,它的随机度不够;§14.5。
[8] 如果运行时的效率压倒操作和元素类型方面的灵活性,
请为数值计算采用valarray;§14.6。
[9] 数值类型的属性可以通过numeric_limits获取;§14.7。
[10] 请使用numeric_limits查询数值类型的属性,确保它们够用;§14.7。
这段话刊载于 R. W. Hamming 的著作《Numerical methods for scientists and engineers》,此书未见中文版。—— 译者注
Anthony Ralston 简介见 https://history.computer.org/pioneers/ralston.html —— 译者注
让事物保持简单:
越简单越好,
但不要过头。
没有明确证据证明爱因斯坦说过这句话,但是也没有证据证明他没说过,有一个考据在这个页面: https://quoteinvestigator.com/2011/05/13/einstein-simple/ ,我参考了网络上的一些翻译,感觉这样译合理,但也没有足够论据 — 阿尔伯特·爱因斯坦
并发——同时执行多个任务——被大量用于提高吞吐量(为单个计算任务使用多处理器的方式)
或提升响应能力(方法是在程序的一部分处理业务的同时,另一部分提供响应)。
所有的现代编程语言都支持并发。
C++里有个久经20多年考验,获得现代硬件普遍支持的并发方式,
C++标准库提供的支持是上述方法的一个变种,具备可移植性且类型安全。
标准库对并发的支持着眼于系统层面的并发,而不是直接提供高级并发模型,
后者可以作为程序库的形式呈现,利用标准库提供的设施进行构建。
对于多线程在单地址空间上的并发执行,标准库提供了直接的支持。
为此,C++提供一个相应的内存模型以及一组原子操作。
原子操作支援无锁编程[Dechev,2010]。
关于这个内存模型,只要程序员能避免数据竞争(对可变数据的失控并发访问),
它就确保一切能够无脑地执行得丝般顺滑。
但是,大多数用户所见的并发形式,表现为标准库以及构建于其上的程序库。
本章为标准库支持并发的构件提供简短示例:thread、mutex、
lock()操作、packaged_task、future。
这些特性直接建立在操作系统提供的功能之上,且不会造成性能损失。
当然,对操作系统的相关功能也没有显著的性能提升。
千万别以为并发是万全之策。
如果一个任务可以顺序搞定,那它通常是更简单、更快捷的方式。
作为显式应用并行特性的替代方案,
利用并行算法通常可以获取更好的性能(§12.9,§14.3.1)。
thread某个计算,如果有可能与其它计算并发执行,我们就称之为一个任务(task)。
*线程(thread)*是任务在程序里的系统级表示。
对于需要与其它任务并发执行的任务,可做为参数构造一个std::thread
(在<thread>里)来启动。任务是一个函数或者函数对象:
void f(); // 函数
struct F { // 函数对象
void operator()(); // F的调用操作符(§6.3.2)
};
void user()
{
thread t1 {f}; // f() 在单独的线程里和执行
thread t2 {F()}; // F()() 在单独的线程里和执行
t1.join(); // 等待t1
t2.join(); // 等待t2
}
这两个join()确保我们在两个线程运行完之前不会退出user()。
“加入(join)”某个thread的意思是“等待这个线程终止”。
一个程序的线程之间共享同一个地址空间。
此处的线程有别于进程,进程间一般不会直接共享数据。
因为线程共享同一个地址空间,它们可以通过共享对象(§15.5)进行通信。
此类通信通常用锁或其它机制以避免数据竞争(对变量的失控并发访问)。
编程并发任务可能特别棘手。
琢磨以下任务f(函数)和F(函数对象)可能的实现:
void f()
{
cout << "Hello ";
}
struct F {
void operator()() { cout << "Parallel World!\n"; }
};
这展示了一个严重的错误:f和F()都用到了cout对象,但没通过任何形式进行同步。
输出的结果是无法预料的,而且该程序每次执行的结果都可能有差异,
原因在于,两个任务中各自操作的执行顺序是未定义的。
这个程序的输出可能会有点“莫名其妙”,比如这样的:
PaHerallllel o World!
只有标准库里某个特定的保障才能力挽狂澜,
以避免ostream定义里的数据竞争导致可能的崩溃。
为并发程序定义任务的目标是,除简单明确的通信之外,保持任务相互间完全独立。
关于并发任务,最简单的思路是把它看做一个函数,仅仅是恰好与它的调用者同时运行。
为此,只需要传递参数,获取结果,并确保它们间不使用共享数据(没有数据竞争)。
一般来说,任务需要待加工的数据。
数据(或指向它的指针、引用)可以简单地作为参数传递。琢磨这个:
void f(vector<double>& v); // 函数:处理v
struct F { // 函数对象:处理v
vector<double>& v;
F(vector<double>& vv) :v{vv} { }
void operator()(); // 调用操作符 §6.3.2
};
int main()
{
vector<double> some_vec {1,2,3,4,5,6,7,8,9};
vector<double> vec2 {10,11,12,13,14};
thread t1 {f,ref(some_vec)}; // f(some_vec) 在单独的线程里运行
thread t2 {F{vec2}}; // F(vec2)() 在单独的线程里运行
t1.join();
t2.join();
}
显然,F{vec2}在F中保存了参数vector的引用。
然后F就可以操作这个vector,但要祈祷在F运行时没有其它任务访问vec2。
为vec2传值可以消除这个风险。
{f,ref(some_vec)}这个初始化利用了thread的可变参数模板构造函数,
它接受任意的参数序列(§7.4)。
ref()是个来自<functional>的类型函数,很遗憾,
让可变参数模板把some_vec作为引用而非对象时少不了它。
如果缺少ref(),some_vec将以传值的方式传递。
编译器会检查能否用后续参数调用第一个参数,然后构造必要的函数对象传递给线程。
这样,F::operator()()和f()就执行同样的算法,两个任务的处理方式如出一辙:
两种情况下,都是构造一个函数对象给thread去执行。
在 §15.3 的例子中,参数按照非const引用的方式传递。
我仅在需要任务对引用的数据(§1.7)进行修改时才这么做。
这个返回结果的方式略有点旁门左道,但并不稀奇。
有个更通俗的方式是把输入数据以const引用方式传递,
再传递一个位置作为单独的参数,用于保存结果:
void f(const vector<double>& v, double* res); // 从v获取输入;结果放进 *res
class F {
public:
F(const vector<double>& vv, double* p) :v{vv}, res{p} { }
void operator()(); // 结果放进 *res
private:
const vector<double>& v; // 输入源
double* res; // 输出目标
};
double g(const vector<double>&); // 使用返回值
void user(vector<double>& vec1, vector<double> vec2, vector<double> vec3)
{
double res1;
double res2;
double res3;
thread t1 {f,cref(vec1),&res1}; // f(vec1,&res1) 在单独的线程里运行
thread t2 {F{vec2,&res2}}; // F{vec2,&res2}() 在单独的线程里运行
thread t3 { [&](){ res3 = g(vec3); } }; // 以引用的方式捕获局部变量
t1.join();
t2.join();
t3.join();
cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}
此代码运行良好,并且该技术也很常见,但我觉得通过引用传回结果不够优雅,
所以,§15.7.1会再聊到这个话题。
有时候任务之间不得不共享数据。
这种情况下,数据访问必须要同步,以便同一时刻最多只有一个任务访问数据。
资深程序员会觉得这有点以偏概全(比如,多个任务同时读取不可变的数据就没问题),
但请考虑这个问题:
对于一组给定的对象,在同一时刻,如何确保至多只有一个任务进行访问。
解决方案的基本要素是mutex(互斥量),
一个“互斥对象(mutual exclusion object)”。
thread执行lock()操作来获取mutex:
mutex m; // 参与控制的互斥量
int sh; // 共享数据
void f()
{
scoped_lock lck {m}; // 获取互斥量
sh += 7; // 操作共享数据
} // 隐式释放互斥量
lck的类型被推导为scoped_lock<mutex>(§6.2.3)。
scoped_lock的构造函数负责获取互斥量(方法是调用m.lock())。
如果另一个线程已经获取了这个互斥量,
当前线程就要等待(“阻塞”)到另一个线程访问结束。
待某个线程访问共享数据结束,
scoped_lock就会(调用m.unlock())释放这个mutex。
当mutex被释放,等待它的thread就恢复执行(被唤醒)。
互斥量和锁构件的定义都在<mutex>里。
请留意RAII(§5.3)的运用。
使用scoped_lock、unique_lock之类的执柄,
比显式锁定和解锁mutex简单而且安全得多。
共享数据和mutex间的对应关系是约定俗成的:
程序员只需要知道哪个mutex对应哪个数据即可。
显而易见这容易出错,同样不言而喻的是,明确这种对应关系的方式也多种多样。
例如:
class Record {
public:
mutex rm;
// ...
};
无需天赋异秉就能猜到,对于一个名为rec的Record,
在访问rec的其余内容之前,应该先获取rec.rm,
使用注释或更贴切的变量名,可以进一步提升可读性。
同时访问多个资源的操作并不罕见。这可能导致死锁。
例如,如果thread1获取了mutex1,然后试图再获取mutex2,
而此时thread2已经获取了mutex2并试图获取mutex1,
这样,二者就都无法继续执行了。
这是scoped_lock的用武之地,它同时获取多个锁来解决这个问题:
void f()
{
scoped_lock lck {mutex1,mutex2,mutex3}; // 三个锁全部获取
// ... 处理共享数据 ...
} // 隐式释放所有的互斥量
只有在获取参数中的所有锁之后,这个scoped_lock才继续执行,
并且在持有mutex的情况下绝不会阻塞(“进入休眠”)。
当thread离开这个作用域时,scoped_lock的析构函数会确保释放这些mutex。
使用共享数据通信是个非常低级的方法。
尤其是,程序员还要搞清楚不同任务之间工作完成情况的各种组合。
在这方面,共享数据的使用相较于调用与返回的概念可就逊色太多了。
另一方面,有些人坚信数据共享比复制参数和返回的效率高。
如果涉及的数据量非常大,可能的确如此,但锁定和解锁操作的代价都相对高昂。
相反,现代的硬件都很擅长复制数据,尤其是紧凑的数据,比如vector那些元素。
因此,不要出于“效率”原因而不假思索也不进行测量就选择共享数据进行通信。
基础的mutex限定同一时刻仅有一个线程访问数据。
有一个最普遍的方式,是在众多读取者和单一的写入者之间进行共享。
shared_mutex支持这种“读写锁”。
一个读取者会申请“共享的”互斥量,以便其它读取者仍然能够访问,
而写入者则要求排他性的访问,比如:
shared_mutex mx; // 可共享的互斥量
void reader()
{
shared_lock lck {mx}; // 乐于同其它读取者分享访问权限
// ... 读取 ...
}
void writer()
{
unique_lock lck {mx}; // 需要排他性(唯一的)访问
// ... 写入 ...
}
有些情况下,thread需要等待外部事件,比如另一个thread完成了某个任务,
或者经过了特定长度的时间。最简单的“事件”是时间流逝。
利用<chrono>中的时间相关功能,可以这么写:
using namespace std::chrono; // 参见 §13.7
auto t0 = high_resolution_clock::now();
this_thread::sleep_for(milliseconds{20});
auto t1 = high_resolution_clock::now();
cout << duration_cast<nanoseconds>(t1-t0).count() << " nanoseconds passed\n";
请注意,我甚至不需要启动一个thread;
默认情况下,this_thread指向当前唯一的线程。
此处用了duration_cast将时间单位调整为我想要的纳秒。
对于利用外部事件进行通信的基本支持由<condition_variable>
中的condition_variable提供。
condition_variable是个允许一个thread等待其它线程的机制。
确切地说,它允许thread等待某种条件(condition)
(通常称为事件(event))的发生,作为其它thread完成作业的结果。
使用condition_variable可以支持许多优雅且高效的共享方式,但可能要费些周折。
考虑经典的例子:两个thread借助一个queue传递消息进行通信。
为叙述简洁,我把queue和避免它发生数据竞争的机制定义在全局作用域
供生产者和消费者访问:
class Message { // 通信中使用的对象
// ...
};
queue<Message> mqueue; // 消息队列
condition_variable mcond; // 变量通信事件
mutex mmutex; // 用于同步对mcond的访问
queue、condition_variable、mutex这些类型都由标准库提供。
consumer()读取并处理Message:
void consumer()
{
while(true) {
unique_lock lck {mmutex}; // 申请 mmutex
mcond.wait(lck,[] { return !mqueue.empty(); }); // 释放 lck 并进入等待
// 在被唤醒时重新申请 lck
// 只要mqueue是空的就不要唤醒
auto m = mqueue.front(); // 取消息
mqueue.pop();
lck.unlock(); // 释放 lck
// ... 处理 m ...
}
}
此处,我把一个unique_lock用于mutex,
显式为queue和condition_variable上的操作提供保护。
condition_variable进入等待时释放它的锁参数,
等待结束(即队列非空)时重新获取它。
此处的!mqueue.empty()对条件进行显式查询,
以防唤醒后发现其它任务已经“领先一步”,以至于该条件不再成立。
此处选择unique_lock而非scoped_lock,出于两个原因:
condition_variable的wait()。scoped_lock无法复制,而unique_lock可以。mutex加锁以保护条件变量。unique_lock提供了lock()、unlock()这些操作,用于低层次的同步控制。另外,unique_lock只能处理单个的mutex。
配套的producer是这样的:
void producer() {
while(true) {
Message m;
// ... 填写信息 ...
scoped_lock lck {mmutex}; // 保护下一条代码的操作
mqueue.push(m);
mcond.notify_one(); // 提示
} // 释放锁(在作用域结束处)
}
标准库提供了几个构件,以便程序员能够在任务(可能需要并发的工作)的概念层级进行操作,
而不必直面线程和锁这么低的层级:
future和promise从 在单独线程上生成的任务 里返回一个值package_task协助启动任务并连接返回结果的机制async()以酷似函数调用的方式启动一个任务这些构件都在头文件<future>内。
future和promisefuture和promise侧重点在于,两个任务之间传递值时,它们能避免锁的显式使用;
“系统”高效地实现这个传递。
基本思路很简单:一个任务需要给另一个任务传递值时,就把这个值放进promise。
“大变活值”之后,具体实现会把这个值弄进对应的future里,
(通常是该任务的启动者)就能从future里把值读出来了。
此过程图示如下:
如果有个名为fx的future<X>,就能从里面get()到一个X类型的值:
X v = fx.get(); // 必要的话,等待这个值被计算出来
如果这个值尚未就绪,等待读取它的线程就会阻塞,直到它现身为止。
如果这个值算不出来,get()可能会抛出异常
(来自系统,或者从试图get()这个值的任务里传出来)。
promise的作用是提供一个简洁的“放置(put)”操作
(名称是set_value()和set_exception()),
匹配future的get()。
“future”和“promise”的命名是个历史遗留问题;
所以,别让我代人受过,也别让我掠人之美。
很多其它俏皮话儿也都是从它们这来的。
如果你的promise需要发送X类型的结果给future,
有两种选择:传一个值或者传一个异常。例如:
void f(promise<X>& px) // 一个任务:把结果放进px
{
// ...
try {
X res;
// ... 给res计算值 ...
px.set_value(res);
}
catch (...) { // 矮油:res难产了
px.set_exception(current_exception()); // 把异常传给future所在的线程
}
}
此处的current_exception()指向被捕获的异常。
要处理future发过来的异常,get()的调用者必须在某处做好准备捕捉它。例如:
void g(future<X>& fx) // 一个任务:从fx里提取结果
{
// ...
try {
X v = fx.get(); // 必要的话,等待这个值被计算出来
// ... 使用 v ...
}
catch (...) { // 矮油:有银儿搞不定v了涅
// ... 处理错误 ...
}
}
如果g()无需自己处理错误,代码可以做到最精简:
void g(future<X>& fx) // 一个任务:从fx里提取结果
{
// ...
X v = fx.get(); // 必要的话,等待这个值被计算出来
// ... 使用 v ...
}
packaged_task怎么做才能把future放进等待结果的任务,
并且把对应的promise放进那个产生结果的线程呢?
想要把任务跟future对接,把promise跑在thread上,
使用packaged_task类型可以简化设置操作。
packaged_task封装了代码,能够把任务的返回值或异常放进promise
(如§15.7.1中代码所示)。
如果调用get_future()对packaged_task进行查询,
它会给你对应其promise的future。
例如,可以设置两个任务,分别用accumulate()(§14.3)
累加某个vector<double>一半的元素:
double accum(double* beg, double* end, double init)
// 计算[beg:end)的和,初始值为init
{
return accumulate(beg,end,init);
}
double comp2(vector<double>& v)
{
using Task_type = double(double*,double*,double); // 任务的类型
packaged_task<Task_type> pt0 {accum}; // 把任务(即accum)打包
packaged_task<Task_type> pt1 {accum};
future<double> f0 {pt0.get_future()}; // 获取pt0的future
future<double> f1 {pt1.get_future()}; // 获取pt1的future
double* first = &v[0];
thread t1 {move(pt0),first,first+v.size()/2,0}; // 为pt0启动一个线程
thread t2 {move(pt1),first+v.size()/2,first+v.size(),0}; // 为pt1启动一个线程
// ...
return f0.get()+f1.get(); // 取结果
}
packaged_task模板接收任务的类型作为其模板参数
(此处是Task_type,为double(double*,double*,double)取的别名)
并以任务作为构造函数的参数(此处是accum)。
move()操作是必须的,因为packaged_task无法被复制。
packaged_task无法复制的原因在于,它是个资源执柄:
它拥有其promise并且(间接地)要对该任务占有的资源负责。
请留意,这份代码中并未显式用到锁:
现在可以把精力集中在业务上,而不必费神去管理通信机制。
这两个任务将运行在两个独立的线程上,因此可能会并行。
async()本章追寻的思考轨迹,导向这个我认为是最简单但且可跻身于最强大技术之列的良策:
把任务当作可能凑巧跟其它任务同时运行的函数。
它绝非C++标准库支持的唯一模型,但它面对诸多需求都能游刃有余。
必要的时候,还有更微妙更诡秘的模型(比方依赖共享内存的编程样式)可用。
想发起一个可能异步执行的任务,可以用async():
double comp4(vector<double>& v)
// 如果v足够大,就触发多个任务
{
if (v.size()<10000) // 值得采用并发吗?
return accum(v.begin(),v.end(),0.0);
auto v0 = &v[0];
auto sz = v.size();
auto f0 = async(accum,v0,v0+sz/4,0.0); // 第一份
auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0); // 第二份
auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0); // 第三份
auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0); // 最后一份
return f0.get()+f1.get()+f2.get()+f3.get(); // 收集并求和结果
}
基本上,async()把函数调用的“调用部分”和“获取结果部分”拆开,
并把它们都与实际执行的任务分离开。
使用async(),就不用再去操心线程和锁,
相反,你需要考虑的就只是那个有可能异步执行的任务。
这有个明显的限制:想把用于需要对共享资源加锁的任务,门儿都没有。
采用async(),你甚至不知道有多少个thread,
因为这取决于async(),而它依据调用时刻的系统资源状况决定。
比方说,在决定用多少个thread之前,
async()可能会查询是否有空闲的核(处理器)可用。
用运行成本与启动thread成本间的关系,
比如v.size()<10000,进行揣摩,
相当粗略,而且关于性能优劣的结论往往错得离谱。
只是,此处的着眼点不在于thread管理方面的技术。
这是个简略且大概率有失偏颇的臆测,因此你就姑妄听之,别拿它太当真。
需要对标准库算法,比方说accumulate(),手动并行处理的情况凤毛麟角,
因为并行算法,比如reduce(par_unseq,/*...*/)通常都是出类拔萃的(§14.3.1)。
但是,这此处的技术是通用的。
请注意,async()不仅是专门用来提升性能的并行计算机制。
比如,还可以用它生成一个任务,用于获取来自用户的信息,
以便“主程序”保持活动状态去处理其它事物(§15.7.3)。
[1] 用并发提升响应能力或者提升吞吐量;§15.1。
[2] 在可承受范围内的最高抽象级别作业;§15.1。
[3] 把进程视作线程的替代方案;§15.1。
[4] 标准库的并发构件是类型安全的;§15.1。
[5] 内存模型存在的意义,是让大多数程序员不必在计算机硬件架构层级兜圈子;§15.1。
[6] 内存模型让内存表现得大致符合稚朴的预期;§15.1。
[7] 原子操作成全了无锁编程;§15.1。
[8] 让专家们去跟无锁编程斗智斗勇吧;§15.1。
[9] 有时候,顺序执行的方案比并发方案更简单、更快速;§15.1。
[10] 避免数据竞争;§15.1,§15.2。
[11] 与并发相比,优先考虑并行算法;§15.1,§15.7.3。
[12] thread是系统线程的类型安全接口;§15.2。
[13] 用join()等待thread完成;§15.2。
[14] 请竭力避免显式使用共享数据;§15.2。
[15] 优先采用RAII,而非显式的加锁/解锁;§15.5;[CG: CP.20]。
[16] 使用scoped_lock管理mutex;§15.5。
[17] 使用scoped_lock申请多个锁;§15.5; [CG: CP.21]。
[18] 使用scoped_lock实现读写锁;§15.5。
[19] 把mutex跟它保护的数据定义在一起;§15.5;[CG: CP.50]。
[20] 使用condition_variable管理thread间的通信;§15.6。
[21] 在需要复制一个锁或者在较低层次进行同步操作的时候,
采用unique_lock(而非scoped_lock);§15.6。
[22] 针对condition_variable采用
unique_lock(而非scoped_lock);§15.6。
[23] 不要无条件的等待(wait);§15.6;[CG: CP.42]。
[24] 最小化加锁代码(critical section)的运行时间;§15.6;[CG: CP.43]。
[25] 把任务看作是可并发执行的,而不要直接以thread方式看待它;§15.7。
[26] 别忽视了简单性的价值;§15.7。
[27] 优先采用packaged_task和promise,
而非直接死磕thread和mutex;§15.7。
[28] 用promise返回结果,并用future去提取它;§15.7.1;[CG: CP.60]。
[29] 用packaged_task处理任务抛出的异常和值返回;§15.7.2。
[30] 用packaged_task和future表示针对外部服务的请求并等待它的响应;
§15.7.2。
[31] 用async()去启动简单的任务;§15.7.3;[CG: CP.61]。
没有明确证据证明爱因斯坦说过这句话,但是也没有证据证明他没说过,有一个考据在这个页面: https://quoteinvestigator.com/2011/05/13/einstein-simple/ ,我参考了网络上的一些翻译,感觉这样译合理,但也没有足够论据。 —— 译者注
搞快点儿,莫急。
(festina lente).
这是一句古希腊格言,所以屋大维这个罗马皇帝仅仅是引用过,而不是原创这句话的人。因为一句话里同时用到了“快”和“慢”两个意思,所以这种修辞被称为“矛盾修饰法”,维基百科页面说它前一半是“第二人称单数现在主动祈使句”,后一半是“副词”。它被广泛认可的的英译是“make haste slowly”或“more haste, less speed”(美式英语为“haste makes waste”),仍然是祈使句。英译的“more haste, less speed”在《朗文当代高级英语辞典》、《剑桥高阶英汉双解词典》、《牛津高阶英汉双解词典》的“haste”词条中都被译为“欲速则不达”,仍然是个谚语或者格言,我认为这个汉译仅仅强调了“要避免忙中出错”的意思,丢失了祈使句的形式,也没表达出“要求加速”的意思。网络上有一些符合“即要提速,又要避免忙中出错”意思的理解,但我没发现仍保留祈使句形式的译法,因此私自主张这样译 — 屋大维,凯撒·奥古斯都
我发明了C++,给它起草了定义,并出品了第一个实现(编译器)。
我筛选、制定了C++的设计标准,设计了它的主要语言特性,开发或参与了许多早期程序库,
并在长达25年的时间里负责处理扩展提案。
C++的设计意图是把Simula的程序组织方式[Dahl,1970]
与C在系统编程方面的效率和灵活性[Kernighan,1978]结合起来。
Simula是C++抽象机制的最初来源。
类的概念(利用派生类和虚函数)就是从它借鉴来的。
但是模板和异常加入C++,则受到了其它来源的启发。
C++的演化始终跟它的使用密不可分。
我投入了大量时间倾听用户的呼声,征询资深程序员的见解。
特别是我AT&T贝尔实验室的同事们,对C++起初十年的成长功不可没。
本章做一简要的概述,不会事无巨细地讲到所有的语言特性和库组件。
另外,也不会深究细节。有关更多信息,尤其是诸多贡献者的名字,
请查阅我在ACM编程语言历史大会上的文章[Stroustrup,1993] [Stroustrup,2007],
还有我的书《C++语言的设计与演化(The Design and Evolution of C++)》[Stroustrup,1994]。
它们详尽地描述了C++的设计与演化,并记述了来自其它语言的影响。
C++标准化工作中产生的文档,大多可以在线获得[WG21]。
在我的FAQ中,努力维系了标准构件与它的发起人、改进者间的关联[Stroustrup,2010]。
C++并非出自面目模糊的无名氏团体,也非某个冥冥中全能
“终身独裁者(dictator for life)”的手笔;
而是众多热忱敬业、经验丰富、勤奋努力者的劳动成果。
演化出C++的项目名为“带类的C(C with Classes)”,始自1979年秋。
以下是简化的时间线:
dynamic_cast、许多模板方面的改进。auto)、区间-for、可变参数模板、lambda表达式、variant和optional类型。在开发过程中,C++11被广泛称为C++0x。
恰如大项目喜闻乐见的那样,我们对完成日期的估计过于乐观了。
快要截止的时候,我们戏称C++0x里的’x’是个十六进制数,于是C++0x就成了C++0B。
除此之外,委员会如期交付了C++14和C++17,主要的编译器厂商也跟上了这个步伐。
我最初设计并实现这个语言的原因是:
想把一个Unix内核服务做成跨多处理器和局域网的分布式
(现在广为人知的名称是多核和集群)。
为此,我需要精确指定系统的各组成部分以及它们相互通信的方式。
如果不在意性能方面的问题,Simula[Dahl,1970]本该是个理想的选择。
我还需要直接跟硬件打交道,提供高性能的并行编程机制,
C在这方面是理想的选择,但它的软肋在模块化支持和类型检测方面。
Simula风格的类和C的结晶,“带类的C”,在关键项目中得到了应用,
在这些项目中,它精打细算地利用时间和空间的编程能力经受住了严苛的考验。
它缺失了运算符重载、引用、虚函数、模板、异常和许多许多内容[Stroustrup,1982]。
C++在研究机构以外的应用始自1983年七月。
C++(发音是“see 普拉斯 普拉斯”)这个名称,是 Rick Mascitti 在1983年提出的,
我选它取代了“带类的C”。
此名称意指对C的改变具有演化的性质;“++”是C的自增运算符。
稍短的名称“C+”是个语法错误;有个不相干的语言用它命名了。
对C语言语意懂行的人觉得C++不如++C。
它没有取名为D,因为它是对C的扩展,也因为它无意以丢弃某些特性的方式来修缮问题,
还因为已经有好几个试图取代C的语言取了D这个名字。
关于C++这个名字的另一个解读,请参见[Orwell,1949]的附录。
C++设计之初主要是为了我和我的朋友们编程时能避开汇编、
C和当时还挺流行的几种高级语言。
主要目标是让程序员更容易、愉悦地编写优秀的程序。
初期并没有C++设计文稿;语言设计、文档编写和编译器实现是同步推进的。
当时也没有“C++项目”或者什么“C++设计委员会”。
自始至终,C++的演化是为了应对用户面临的问题,也是我的朋友、同事和我讨论的结果。
C++起初的设计(当时还叫“带类的C”)包括带参数类型检查的函数声明和隐式转换、
用public/private区分接口与实现的的类、派生类、构造函数和析构函数。
我用了宏实现了最初的参数化[Stroustrup,1982]。
到八十年代中期,这已经不再是实验性应用。
那年下半年,我展示了一组语言特性,用于支持相辅相成的编程风格。
现在想来,我觉得最重要的是引入了构造函数和析构函数。
用[Stroustrup,1979]的术语来讲,就是:
某个“new函数”为成员函数创建运行环境,“delete函数”则反其道而行之。
没过多久,“new函数”和“delete函数”就改称为了“构造函数”和“析构函数”。
这就是C++资源管理策略(导致了异常机制的必要性)的根本,
也是许多技术能够使得用户代码简明的关键所在。
就算当时有其它语言支持多个构造函数用于执行通用代码,我也(到现在都)不知道。
析构函数是C++引入的新事物。
C++在1985年进行了商业发布。
当时,我已经添加了内联函数(§1.3, §4.2.1)、const(§1.6)、
函数重载(§1.3)、引用(§1.7)、运算符重载(§4.2.1)和虚函数(§4.4)。
这些特性里,以虚函数形式支持运行时多态是当时最具争议的。
我通过Simula知道它的价值,但发现要说服系统编程领域的人们简直难如登天。
系统程序员习惯性地觉得间接函数调用有蹊跷;至于通过其它语言接触过面向对象编程的人,
对于virtual函数的运行速度能快到在系统编程里有一席之地也感到难以置信。
相反,很多有面向对象背景的程序员都曾(还有些仍然)不能接受这个思想,
即,使用虚函数调用,仅仅是表达:某个选择必须在运行时做出。
对虚函数的反感,很可能是源于对另一个想法的抵制,即:
借助由编程语言支持的更常规的代码结构,能获得更好的系统。
许多C程序员深信,最重要的因素是绝对的灵活性以及对程序全方位无死角的精雕细琢。
我的观点当时(并且现在也)是,有必要尽可能利用语言和工具提供的帮助:
我们试图构建的这些系统,其内在复杂度总是让我们的表达能力捉襟见肘。
早期文档(即[Stroustrup,1985] 和 [Stroustrup,1994])这样描述C++:
C++是一种通用的编程语言,它:
请注意,并非“C++是一种面向对象的编程语言”。
在这里,“支持数据抽象”指的是信息隐藏,类继承体系以外的类,以及泛型编程。
起初,泛型编程由宏提供勉强的支持[Stroustrup,1982]。
至于模板和概束,那都是很久之后的事了。
C++的许多设计工作都是在我同事们的黑板上搞定的。
早些年来自Stu Feldman、 Alexander Fraser、 Steve Johnson、
Brian Kernighan、 Doug McIlroy、 和Dennis Ritchie的反馈都是无可替代的。
在1980年代的后半段,我为响应用户的讨论而继续添加语言特性。
这当中最重要的两个是模板[Stroustrup,1988]和异常处理[Koenig,1990],
后者在标准化工作开始的时候被当作实验性的特性。
在模板的设计中,我被迫在灵活性、效率和初期的类型检查间进行取舍。
当时没人知道怎么才能一举三得。
为了在严苛的系统应用领域跟C-风格代码较量,我选择了前两条。
回顾当时,我仍认为这个决定在当时是合理的,而且有关改进模板类型检查的研究也没松懈
[DosReis,2006] [Gregor,2006] [Sutton,2011] [Stroustrup,2012a]。
异常机制的设计着眼于异常在多层次结构上的传播,向异常处理器递交任意信息,
以及融合异常和资源管理,后者使用局部变量和析构函数分别表示和释放资源。
我蹩脚地给这个至关重要的技术命名为
资源请求即初始化( Resource Acquisition Is Initialization)
很快就有人把它简化成了缩写RAII(§4.2.2)。
我泛化了C++的继承机制,以支持多基类[Stroustrup,1987]。
它被称为多继承(multiple inheritance),被认为是艰深且有争议的。
我认为它的重要性远逊于模板和异常机制。
抽象类(常被称为接口(interface))的多继承如今在
支持静态类型检查和面向对象编程的语言中普及了。
C++语言本身的演化和一些关键库的构建是相辅相成的。
比如,我设计运算符重载机制的同时设计了复数[Stroustrup,1984]、
矢量、栈和(I/O)流类[Stroustrup,1985]。
最初的字符串和列表两个类由Jonathan Shopiro和我共同完成,工作量五五开。
Jonathan的字符串和列表类是程序库最先得到广泛采纳的部分。
标准库里的字符串类就极大地得益于这些早期的贡献。
[Stroustrup,1987b]中描述的task类来自“带类的C”最初在1980年的首个程序。
它提供了协程和调度器。我为了支持Simula风格的仿真而编写了它和相关的类。
很遗憾,我们不得不等(了30年!)到2011年才把并发标准化且提供普遍支持(第15章)。
协程可能会成为C++20的一部分 [CoroutinesTS]。
模板机制的开发受到了vector、map、list、sort多样性的影响,
这些模板由Andrew Koenig、 Alex Stepanov、我和其他一些人设计开发。
1998年标准库最重要的创新是STL,一个算法和容器的框架(第11章,第12章)。
这是Alex Stepanov(与Dave Musser、Meng Lee和其他一些人)的杰作,
构筑在十余年的泛型编程成果之上。STL对于C++社群内外全都影响深远。
C++都成长环境中有着众多广为人知或者实验性的编程语言(如,Ada [Ichbiah,1979]、
Algol 68 [Woodward,1974]、和ML [Paulson,1996])。
在当时,我对大概25种语言能够得心应手,
它们对C++的影响记述于[Stroustrup,1994]和[Stroustrup,2007]中。
不过,决定性的影响总是来自于那些不期而遇的应用程序。
把“问题驱动”作为C++开发的策略,而没有采用“拿来主义”,是个深思熟虑的结果。
C++的爆发式成长引发了一些变革。
1987年的某个时候情形日益凸显,正式把C++标准化已经势在必行了,
我们需要开始为标准化工作进行铺垫了[Stroustrup,1994]。
于是就开始有意识地保持编译器开发者和其用户之间的接触。
这项工作采用了纸质和电子邮件,以及C++研讨会和其它地方的面对面会议。
AT&T贝尔实验室为C++及其社群做出了巨大的贡献,
他们允许我向编译器开发者和用户分享C++参考手册的草稿及修订版本。
因为这当中的很多人就职于跟AT&T有竞争关系的那些公司,
这份贡献意义深远,不应该被磨灭。
如果是个不通情理的公司,仅仅袖手旁观就足以引发语言碎片化的灾难。
标准化工作展开之初,就有来自数十个公司的上百人参与阅读并回应了文档,
它成为得到广泛接受的参考手册以及ANSI C++标准化工作的基础。
他们的名字都可见于《带评注的C++参考手册
(The Annotated C++ Reference Manual)》(“the ARM”)[Ellis,1989]。
ANSI的X3J16委员会在惠普的倡议下于1989年12月召开。
1991年六月,C++的ANSI(美国国家)标准化并入了C++的ISO(国际)标准化工作。
ISO C++委员会被称为WG21。
从1990年开始,这些联合委员会一直是C++演进和定义完善的主要论坛。
我自始至终都在这些委员会中任职。
特别是1990至2014年,作为扩展工作组(后更名为演进工作组)的主席,
我直接负责处理有关C++重大变更以及新特性添加的提案。
一份供公众审阅的初始标准草案在1995年四月形成。
第一个ISO C++标准(ISO/IEC 14882-1998) [C++,1998]
在1998年以 22-0 的全体投票获得批准。
该标准的“错误修复版本”于2003年发布,
因此你有时候会听人们提起C++03,但本质上它与C++98是同一个语言。
C++11,在很多年里都被称作C++0x,是WG21成员们的成果。
委员会在自发性日益繁重的流程和工序下工作。
这些流程或许能促成更好(也更严谨)的规范,但同时也限制了创新[Stroustrup,2007]。
一份供公众审阅的初始标准草案在2009年形成。
第二个ISO C++标准(ISO/IEC 14882-2011) [C++,2011]
在2011年八月以 21-0 的全体投票获得批准。
两版标准间漫长的时间跨度要部分归咎于一个误会,
委员会的大部分成员(包括我)都误以为ISO有规定,
在标准发布后需要有个“等待期(waiting period)”,而后才能开展新特性的工作。
因此有关语言新特性的正经事直到2002年才启动。
还有一部分原因是现代语言及其程序库容量的增加。
按标准文档的页码算,语言本身大概增加了30%,标准库增加了100%。
大部分内容的增加都是因为更详尽的语言规格,而非新功能。
此外,新C++标准的工作显然得谨慎行事,以免因为不兼容的修改遗祸旧有的代码。
满世界跑着数十亿行C++代码,委员会要确保别把它们弄残了。
几十年的稳定性是个基本的“特性”。
C++11对标准库进行了大规模的扩充,还推进了支持某种编程风格的特性集的完善,
这种编程风格是“范式”和惯例的结合,已经被C++98证明是成功的。
C++11当时的整体目标是:
这个目标被记录在案并详细说明于[Stroustrup,2007]。
有个主要的工作内容旨在让并行系统编程类型安全且可移植。
这涉及一个内存模型(§15.1)以及对无锁编程的支持。
该工作内容由Hans Boehm、Brian McKnight以及并行编程工作组其他成员负责。
在这些内容的基础上,我们添加了thread库。
C++11之后,大家普遍认同两个标准间相隔13年过于漫长了。
Herb Sutter提议委员会采取一个定期准时发布的规则,即“火车模型(train model)”。
我强烈要求采取较短的时间周期,以最大程度避免发生类似推迟:
仅仅因为有人坚持要延时以便能囊括“最后一个重要特性”。
我们一致赞同了三年这个雄心勃勃的日程表,并且交替发布主要和次要版本。
C++14特意定了一个次要版本发布,目标是“完善C++11”。
这反映了定期发布的实际问题,有些特性明知是必要的,但没办法按期完成。
还有,一旦被广泛应用,特性集里那些瑕疵就势必能被察觉。
委员会为加快进度、让互相独立的特性同步开发、更好地利用众多志愿者的热情和技能,
采用了开发和出版方面的ISO机制“技术明细(Technical Specification)”(TSs)。
这似乎对标准库组件运作良好,尽管会导致开发过程的阶段数增加,并因此延迟。
在语言特性方面,TS似乎就差点儿意思。
原因可能在于重要语言特性很少能真正独立于其它特性,
标准和TS之间在文字工作方面的差异也没多大,
还因为极少有人能在编译器实现上做试验。
C++17原本要成为一个主要版本。
说“主要”,意思是包含重大特性,能改变我们对软件设计和结构的思考方式。
按这个定义看,C++17顶多是个半成品。
它包括了大量的次要扩展,但那些原本能带来剧变的修改(如概束、模块和协程)
要么尚未完成,要么掉进漫长争论的坑里,没了设计方向。
结果是C++17到处敲敲打打,但对于已经了解C++11和C++14的程序员却聊胜于无。
我希望C++20会是兑现承诺、被翘首以盼的主要修订版,
还希望主要的新特性在2020年之前就得到广泛支持。
危险在于“委员会设计”、特性臃肿、风格缺乏一致性以及目光短浅。
在一百多人的委员会里出席每个会议,参与更多的线上会议,
这种不痛快的情形几乎无可避免。
想要把语言朝更加易用且更具一致性的方向推进,是很艰巨的。
标准阐述了什么东西会生效,以及如何生效。
它没说明怎样才算优秀和高效的使用。
“理解编程语言特性的技术细节”跟
“将此特性配合其它特性、库及工具高效地使用,从而打造更好的软件”之间有着天壤之别。
“更好”意指“更易于维护、更不易出错、运行速度更快”。
我们需要开发、普及、支持具有一致性的编程风格。
此外,还必须为陈旧代码向这种更现代、高效且具一致性风格的演化提供支持。
随着语言自身和标准库的壮大,普及高效编程风格的问题日益凸显。
让大批程序员摒弃尚有可取之处的东西极其困难。
仍有些人把C++看作C语言微不足道的辅助物,
仍有人把八十年代那种基于类继承体系的面向对象编程推崇备至。
许多人仍在充斥大量陈旧C++代码的环境里挣扎着应用C++11。
另一头,也有许多人狂热地滥用新特性。
比方说,有些码农确信只有大量使用模板元编程才是真正的C++。
什么是现代C++(modern C++)?
2015年,我为了回答这个问题而着手定制一套由明确基本原理支持的编码指南。
我很快意识到自己不是一个人在战斗,并聚集了遍及全球——主要来自微软、
红帽子和脸书——的人们,开启了“C++ 核心指南”项目 [Stroustrup,2015]。
这是个雄心勃勃的项目,致力于以彻底类型安全、彻底资源安全为基础,
打造更简单、更高效、更易于维护的代码[Stroustrup,2015b]。
除了解释详尽的具体编码规则,我们还用静态分析工具和小型支持库作为该指南的后援。
我将其视为必不可少的要素,用以策动大规模的C++社群前行,
从语言特性、库和工具的改良中受益。
如今的C++是一个应用广泛的编程语言。
其用户数量从1979年的一个迅速增长到1991年的40万,
就是说,在十余年的时间里用户数每7.5个月就翻一番。
当然,初期的井喷式增长率已然放缓,
但我最乐观的估计是2018年已经有450万C++程序员[Kazakova,2015]。
这种增长主要在2005年之后,处理器速度的指数级爆发停止了,
因此语言性能的重要性提高了。
这样的增长是在不借助正式的市场推广、有组织的用户社群的情况下达成的。
C++主要是一种工业语言,相比教育和编程语言研究,它在工业领域更广为人知。
它成长于贝尔实验室,从电信和系统编程(包括驱动程序、网络和嵌入式系统)
变化无常且异常严苛的需求中受到启发。
从那以后,C++的应用就扩展到了各行各业:微电子、Web应用程序和基础设施、
操作系统、金融、医疗、汽车、航空航天、高能物理、生物学、能源生产、机器学习、
视频游戏、图形学、动画、虚拟现实等等等等。
它主要的应用场景是 C++高效利用硬件的能力 与 管理复杂度能力 搭配使用的情形。
看起来,这个应用的集合在持续扩张[Stroustrup,1993] [Stroustrup,2014]。
此处列出在C++11、C++14、C++17标准中加入C++的语言特性和标准库组件。
看语言特性列表让人头大。
请记住,语言特性不该单打独斗。
尤其是,C++11里的大多数功能如果跟原有特性提供的框架割裂开就毫无用处了。
{}-列表 进行统一且通用的初始化(§1.4,§4.2.3)auto(§1.4)constexpr(§1.6)for语句(§1.7)nullptr(§1.7)enum:enum class(§2.5)static_assert(§3.5.5){}-列表到std::initializer_list的语言层级的映射(§4.2.3)>>结尾(在>之间不用加空格了)long long整数类型alignas和alignofdecltypeunion[[carries_dependency]]和[[noreturn]]noexcept说明符(§3.5.1)throw的可能性:noexcept操作符__STDC_HOSTED__;_Progma(X);__func__作为字符串的名称,持有当前函数的名称inline命名空间default和delete(§5.1.1)template实例化更显式的控制:extern templateoverride和final(§4.5.1)thread_local有关C++98到C++11变化更完整的描述,请参见[Stroustrup,2013]
constexpr函数,即:允许for循环了(§1.6)[[deprecated]]属性u8)auto模板参数)if(§6.4.3)if和switch——译注)(§1.8)constexprlambda表达式inline变量[[fallthrough]]、[[nodiscard]]、[[maybe_unused]]std::byte类型enum(§2.5)C++11对标准库的扩充有两种形式:新组件(比如正则表达式匹配库)
和针对C++98组件的改进(比如容器的转移构造函数)。
initializer_list构造函数(§4.2.3)forward_list(§11.6)unordered_map、unordered_multimap、unordered_set、unordered_multiset(§11.6,§11.5)unique_ptr、shared_ptr、weak_ptr(§13.2.1)thread(§15.2)、mutex(§15.5)、condition_variable(§15.6)packaged_thread、future、promise、async()(§15.7)tuple(§13.4.3)regex(§9.4)int16_t、uint32_t、int_fast64_tarray(§13.4.1)system_erroremplace()操作(§11.6)constexpr函数的广泛应用noexcept函数的系统化使用function和bind()(§13.8)string到数值的转换is_integral、is_base_of(§13.9.2)duration、time_point(§13.7)ratioquick_exitmove()、copy_if()、is_sorted()(第12章)atomicshared_mutex(§15.5)tuple元素(§13.4.3)string_view(§9.3)any(§13.5.3)variant(§13.5.1)optional(§13.5.2)invoke()to_chars和from_chars亿万行C++代码在“岁月静好”,没人知道具体哪些特性在负重前行。
因此,ISO委员会在移除旧特性的时候总是百般无奈,而且要经过多年的警告。
无论如何,那些添乱的特性还是移除了:
C++17终于移除了异常说明:
void f() throw(X,Y); // C++98; 现在会报错
为异常说明提供支持的构件,unexcepted_handler、set_unexpected()、
get_unexpected()、unexpected()也一并移除了。
请使用noexcept(§3.5.1)代替。
不再支持三字符组。
auto_ptr废弃了。取而代之,请用unique_ptr(§13.2.1)。
存储指示符register被移除了。
在bool类型上使用++操作的支持被移除了。
C++98的export特性被移除了,因为它太复杂,而且主要的编译器厂商都没有提供支持。
而后,export用作了模块系统的关键字(§3.3)。
对于带有析构函数的类,拷贝构造函数的自动生成被废弃了(§5.1.1)。
字符串文本值向char*的赋值被移除了。请用const char*和auto代替。
部分C++标准库的函数对象和相关的函数被废弃了。
主要是参数绑定相关的。请用lambda表达式和function代替(§13.8)。
通过针对功能的废弃声明,标准化委员会表达了把对应特性干掉的期望。
不过,委员会并不会强制立刻删除某个重度使用的特性——
但如果它属于冗余或者危险范畴,就有可能。
这样,废弃就是对避免使用该功能一个强烈的暗示。它将来可能会消失。
如果使用已废弃的功能,编译器很可能给出警告。
不过,被废弃的功能是标准的一部分,
以往的经验显示,可能会出于兼容性原因“永久地”支持它们。
除了极少数例外,C++是C(意指C11;[C,2011])的超集。
绝大多数差异的原因是C++更强调类型检查。
精心编写的C程序往往也是C++程序。
编译器可以判断C和C++之间的所有差异。
C99/C++11不兼容的部分在标准的附录C中列出。
经典C有两个主要的后代:ISO C 和 ISO C++。
随着时间迁移,二者各走各的路,分道扬镳了。
结果是二者对传统C-风格编程提供了略有差异的支持。
由此导致的不兼容,让某些人处境艰难:同时使用C和C++的用户们,
写一种语言但使用另一种语言程序库的人们、C和C++程序库和工具的程序员们。
为什么说C和C++是手足呢?请看这个简化的家族树:
实线意思是特性大规模继承,短划线的意思是借鉴了主要特性,
虚线的意思是借鉴了次要特性。
由图可见,ISO C和ISO C++是作为手足脱胎自K&R C[Kernighan,1978]的两个主要后代。
分别保留了经典C的关键性状,但都不能跟经典C做到100%兼容。
“经典C”这个说法是我从一个便签上摘取的,这个便签曾贴在Dennis Ritchie的终端上。
它相当于 K&R C 外加枚举和struct赋值。
BCPL由[Richards,1980]定义,C89由[C1990]定义。
请注意,C和C++的差异不尽然是C++里对C的修改。
某些情形下,不兼容性是由于C以不兼容方式引入了某个特性,而它们在C++中却由来已久了。
比如,把T*赋值给void*的功能,以及对全局const的链接[Stroustrup,2002]。
有时候,某个特性在以不兼容方式引入C的时候,它已经进入ISO C++标准了,
比如,有关inline意义的细节。
C和C++之间有许多细微的不兼容。
全都给程序员惹麻烦,但又全都可以在C++语境里克服。
不出意外的话,C的代码片段可以作为C进行编译,并利用extern "C"机制进行链接。
从C程序转化到C++程序的主要问题可能是:
void*隐式转换到T*(就是说,没cast的转换)class、private,在C代码里作为标识符理所当然的,C程序会以C风格编写,比如K&R[Kernighan,1988]所用的风格。
这意味着指针和数组的普遍应用,可能还有很多的宏。
这些构件在大规模的程序里很难得到可靠的应用。
资源管理和错误处理通常是就地专门写一个,记入文档(而非由语言和工具提供支持),
文档通常也不完整,并且跟具体问题记在同一处。
把C程序逐行转化到C++,结果程序往往是进行更严谨的检查。
实际上,我把C程序转化成C++程序时,一定会发现bug。
但是,基本结构无需改变,基本的错误根源也还在那。
如果原本的C程序里存在不完善的错误处理、资源泄露或者缓冲溢出,
那么C++版本里也是一样。
想要从中获益,就必须修改代码的基础结构:
=,而非strcpy()去复制字符串,用==取代strcmp()进行比对)。const(§1.6)、constexpt(§1.6)、enum或enum class(§2.5)去定义常量,inline(§4.2.1)以避免函数调用的开销,template(第6章)定义函数和类型的族群,namespace(§3.4)避免命名冲突。for-语句初始化部分(§1.7)、malloc()。new操作符(§4.2.2)可谓青出于蓝而胜于蓝,vector(§4.2.3,§12.1)以避免realloc()。new和delete(§4.2.2)替换malloc()和free()。void*、联合和类型转换,除非深藏在某些函数或类的具体实现里。static_cast;§16.2.7),把意图表达得更明确。string(§9.2)、array(§13.4.1)和vector(§11.2)写出的代码,++p)。void*在C里,void*可在赋值操作中作为右值操作数,
或者用在任何指针类型变量的初始化中;
这种做法在C++里行不通。例如:
void f(int n)
{
int* p = malloc(n*sizeof(int)); /* C++里不行;在C++里用“new”分配内存 */
// ...
}
这种不兼容通常是最难处理的。
注意,void*隐式转换到不同的指针类型并非总是无害:
char ch;
void* pv = &ch;
int* pi = pv; // C++里不行
*pi = 666; // 覆盖了ch和ch附近的其它字节
在两种语言里,都要把malloc()的结果转化到正确的类型。
如果你只用C++,请避免使用malloc()。
C和C++可以(并经常都)使用不同链接惯例。
这种做法最基本的原因是C++强调类型检查。
实践角度的原因是C++支持重载,因此全局作用域里可以有两个名为open()的函数。
这必须反映在链接器运作的方式上。
要给C++函数使用C链接(以便可以在C程序片段里面调用),
或者让C函数可以在C++程序片段里被调用,使用extern "C"声明它。例如:
extern "C" double sqrt(double);
现在sqrt(double)就可以被C或者C++代码片段调用了。
sqrt(double)的定义也可以按C函数或者C++函数进行编译。
对于给定名称的函数,同一作用域内只能有一个具备C链接方式(因为C不允许函数重载)。
链接规范不影响类型检查,因此对于extern "C"声明的函数,
C++针对函数调用和参数类型的检查仍然有效。
[1] ISO C++ 标准[C++,2017]定义了C++。
[2] 在 为新项目选择编程风格 或者 翻新代码库 时,请遵循C++核心指南;§16.1.4。
[3] 学习C++时,别孤立地关注于语言特性;§16.2.1。
[4] 别拘泥于陈旧了几十年的语言特性集和设计技术;§16.1.4。
[5] 在把新特性应用于产品代码之前,写点小程序测试它与标准的一致性以及性能如何。
[6] 学习C++时,请采用支持最新C++标准且支持程度最完善的编译器。
[7] C和C++共有的功能子集并非学习C++的最佳起点;§16.3.2.1。
[8] 相对于C-风格的类型转换,请采用命名良好的转换,如static_cast;§16.2.7。
[9] 把C程序转化到C++时,首先请确认函数声明(原型)
并使用一致的标准头文件;§16.3.2。
[10] 把C程序转化到C++时,请把使用了C++关键字的变量重命名;§16.3.2。
[11] 为确保可移植性和类型安全,如果不得不使用C,请采用C和C++的公共子集;
§16.3.2.1。
[12] 把C程序转化到C++时,请把malloc()的结果转换到适当的类型,
或者用new替换所有的malloc();§16.3.2.2。
[13] 在从malloc()和free()转换成new和delete时,
请考虑采用vector、push_back()和reserve()
取代realloc();§16.3.2.1。
[14] 在C++中,不存在从int到枚举的隐式转换;
必要的情况下,请使用显式的类型转换。
[15] 对于每个向全局作用域添加名称的标准C头文件<X.h>,
对应的<cX>头文件把这些名称放进了命名空间std中。
[16] 声明C函数的时候,请使用extern "C";§16.3.2.3。
[17] 请用string而非C-风格字符串(直接操作零结尾的char数组)。
[18] 请用iostream而非stdio。
[19] 请用容器(即vector)而非内建数组。
这是一句古希腊格言,所以屋大维这个罗马皇帝仅仅是引用过,而不是原创这句话的人。因为一句话里同时用到了“快”和“慢”两个意思,所以这种修辞被称为“矛盾修饰法”,[维基百科页面](https://en.wikipedia.org/wiki/Festina_lente “The words σπεῦδε and festina are second-person-singular present active imperatives, meaning “make haste”, while βραδέως and lente are adverbs, meaning “slowly”.”)说它前一半是“第二人称单数现在主动祈使句”,后一半是“副词”。它被广泛认可的的英译是“make haste slowly”或“more haste, less speed”(美式英语为“haste makes waste”),仍然是祈使句。英译的“more haste, less speed”在《朗文当代高级英语辞典》、《剑桥高阶英汉双解词典》、《牛津高阶英汉双解词典》的“haste”词条中都被译为“欲速则不达”,仍然是个谚语或者格言,我认为这个汉译仅仅强调了“要避免忙中出错”的意思,丢失了祈使句的形式,也没表达出“要求加速”的意思。网络上有一些符合“即要提速,又要避免忙中出错”意思的理解,但我没发现仍保留祈使句形式的译法,因此私自主张这样译。—— 译者注
参考书目内容多为专有名词,比如书名、人名、出版社名,虽然部分书籍有中文版,但考虑到使用英文版书名搜索更为准确,故此维持英文原文不译。—— 译者注
有中文版《正则文法匹配可以简单快捷》位于:http://ylonely.github.io/2016/07/22/regex-simpleandfast1/ —— 译者注
此书第三版有中文版《精通正则表达式》,余晟 译,于2012-7由电子工业出版社出版,ISBN: 9787121175015 —— 译者注
此书有中文版《C程序设计语言(第2版·新版)》,徐宝文、李志 译,于2004-1由机械工业出版社出版,ISBN: 9787111128069 —— 译者注
此书有中文版《计算机程序设计艺术》多卷本,具体参考北京图灵文化发展有限公司页面: https://www.ituring.com.cn/search/result?q=计算机程序设计艺术 —— 译者注
此书中文版《一九八四》,董乐山 译,于2006-8由上海译文出版社出版,ISBN: 9787532739974 —— 译者注
此书中文版《ML程序设计教程》,柯韦 译,于2005-5由机械工业出版社出版,ISBN: 9787111161219 —— 译者注
此书中文版《C++语言的设计和演化》,裘宗燕 译,于2020-9-20由人民邮电出版社出版,ISBN: 9787115497116 —— 译者注
此书中文版《C++程序设计原理与实践》,王刚 等 译,于2010.7由机械工业出版社出版,ISBN: 9787111303220 —— 译者注
此书中文版分两本,分别是:《C++ 程序设计语言(第 1 - 3 部分)》,王刚 杨巨峰 译,于2016-7由机械工业出版社出版,ISBN: 9787111539414;《C++ 程序设计语言(第 4 部分:标准库)》,王刚 杨巨峰 译,于2016-9由机械工业出版社出版,ISBN: 9787111544395 —— 译者注
此书中文版《C++并发编程实战》,周全 梁娟娟 宋真真 许敏 译,于2015-5由人民邮电出版社出版,ISBN: 9787115387325;另外,此书第二版中文版《C++并发编程实战(第2版)》,吴天明 译,于2021-11-1由人民邮电出版社出版,ISBN: 9787115573551 —— 译者注