C++高级编程(第3版)_学习记录

《C++高级编程(第3版)》

Professional C++, Third Edition

[美]Narc Gregoire 著,张永强 译,清华大学出版社,2015.5第1版

文章目录

  • 一、类型相关
    • (一)枚举类型
    • (二)const引用
    • (三)std::string字面量
    • (四)数值相关
    • (五)类型转换
      • 1. const_cast<>
      • 2. static_cast<>
      • 3. dynamic_cast<>
      • 4. reinterpre_cast<>
    • (六)前置声明
    • (七)断言
  • 二、函数相关
    • (一)拖尾返回类型(trailing return type)
    • (二)typedef和using声明函数指针
    • (三)自定义字面量
    • (四)C风格可变参数列表
  • 三、类相关
    • (一)构造函数
      • 1. 显示默认与显示删除
      • 2. 构造函数初始化器
      • 3. 初始化列表构造函数
      • 4. 类内成员初始器
      • 5. 委托构造函数
      • 6. 创建对象时出错
    • (二)运算符重载
      • 1. 机制
      • 2. 赋值运算符与复制构造函数
      • 3. 常见函数重载的格式
      • 4. 函数调用运算符与仿函数
    • (三)成员的static、const属性
      • 1. constexpr关键字
      • 2. static关键字
    • (四)内联函数
    • (五)嵌套类的访问
    • (六)子类继承
      • 1. 方法覆盖(override)
        • (1) 修改方法的返回类型
        • (2) 覆盖即重写,与隐藏、重载的区别
        • (3) 静态方法不能覆盖
        • (4) 覆盖private方法
        • (5) 覆盖方法与参数默认值
      • 2. virtual虚函数
      • 3. 子类对象的创建与销毁
      • 4. 向上向下转型与截断
    • (七)右值引用与移动语义
      • 1. 右值引用
      • 2. 移动语义
  • 四、内存管理
    • (一)堆栈
    • (二)指针
    • (三)自定义内存管理
      • 1. 重载内存分配和释放运算符
        • (1) new和delete的工作原理
        • (2) new表达式和operator new运算符
        • (3) delete表达式和operator delete运算符
        • (4) 重载operator new和operator delete
        • (5) 带有额外参数的operator new和operator delete
    • (三)内存分配
      • 1. 内存分配错误
      • 2. 垃圾回收
    • (四)智能指针
      • 1. unique_ptr
      • 2. shared_ptr
      • 3. weak_ptr
      • 4. 对移动语义的支持
  • 五、流
    • (一)cout / cin方法
      • 1. cout
      • 2. cin
    • (二)字符串流
    • (三)文件流
    • (四)将流连接在一起
    • (五)双向流
  • 六、模板相关
    • (一)编译器处理模板的原理
    • (二)嵌套依赖类型
    • (三)模板与多文件
    • (四)模板参数说明
      • 1. 非类型的模板参数
      • 2. 方法模板
      • 3. 模板特例化
      • 4. 为独立函数编写模板
      • 5. 从模板类中派生
    • (五)模板的高级特性
      • 1. 三种模板参数
      • 2. 模板的部分特例化
      • 3. 模板递归
      • 4. 类型推导
      • 5. 可变参数模板
        • (2) 概述
        • (2) 使用分析
      • 6. 模板元编程
        • (1) 编译时阶乘
        • (2) 循环展开
        • (4) 打印元组
      • 7. 类型 trait
  • 七、异常处理
    • (一)自定义异常类
    • (二)抛出列表(已废弃)
    • (三)捕获异常
    • (四)嵌套异常
  • 八、C++标准库
    • (一)C++标准库概述(Standard Library)
    • (二)STL概述(Standard Template Library)
      • 1. 顺序容器(sequential container)
      • 2. 容器适配器(container adapter)
      • 3. 有序关联容器(ordered associative container)
      • 4. 无序关联容器(unordered associative container)
      • 5. 其他容器
      • 6. 泛型算法
      • 7. 迭代器
      • 8. 语义
    • (三)lambda表达式
      • 1. 基本语法
      • 2. 机制
      • 3. 泛型lambda表达式
      • 4. 将lambda表达式作为返回值
    • (四)运算符函数对象
    • (五)仿函数的一些使用
      • 1. 绑定器(binder)
      • 2. 取反器(negator)
      • 3. 用类方法作函数对象
      • 4. 分析
    • (六)字符串本地化
  • 九、STL容器
    • (一)vector
    • (二)deque、list、forward_list、array
      • 1. deque
      • 2. list
      • 3. forward_list
      • 4. array
    • (三)queue、priority_queue、stack
      • 1. queue
      • 2. priority_queue
      • 3. stack
    • (四)pair、map、multimap、set、multiset
      • 1. pair
      • 2. map
      • 3. multimap
      • 4. set
      • 5.multiset
    • (五)unordered_xxx
    • (六)其他容器
  • 十、STL算法
    • (一)非修改序列算法
      • 1. 搜索算法
      • 2. 比较算法
      • 3. 工具算法
    • (二)修改序列算法
      • 1. 转换
      • 2. 复制
      • 3. 移动
      • 4. 替换
      • 5. 删除
      • 6. 反转
      • 7. 生成算法
    • (三)操作算法
    • (四)分区算法
    • (五)排序算法
    • (六)二分搜索算法
    • (七)集合算法
    • (八)最大/最小值算法
    • (九)数值处理算法
  • 十一、自定义和扩展STL
    • (一)迭代器适配器
      • 1. 反向迭代器
      • 2. 流迭代器
      • 3. 插入迭代器
      • 4. 移动迭代器
    • (二)编写STL算法
    • (三)编写STL容器
      • 1. 标准所指定自定义容器必须实现的内容
      • 2. 标准没有指定必须但一些容器可能需要的内容
      • 3. 实现(有序)关联容器还需要的内容
        • (1) 类型别名
        • (2) 方法
    • (四)编写迭代器
  • 十二、正则表达式
    • (一)概述
    • (二)ECMAScript语法
      • 1. 正则表达式模式
      • 2. 字符集合匹配
      • 3. 词边界(word boundary)
      • 4. 后向引用(back reference)
      • 5. 零宽断言(zero-width assertion)
      • 6. 正则表达式和原始字符串字面量
    • (三)regex库
      • 1. 概述
      • 2. 使用
  • 十三、其他库工具
    • (一)编译时有理数库
    • (二)时间操作库
      • 1. 持续时间 duration
      • 2. 时钟 clock
      • 3. 时点 time_point
      • 4. 获得系统当前时间举例
    • (三)生成随机数
    • (四)元组
  • 十四、C++多线程编程
    • (一)线程基础概述
      • 1. 启动线程
      • 2. 取消线程
      • 3. 从线程获得结果
      • 4. 线程本地存储
      • 5. 异常机制和多线程
    • (二)原子操作库
    • (三)同步机制
      • 1. 互斥体
      • 2. 锁
      • 3. 确保函数或方法只执行一次
      • 4. 条件变量
      • 5. 示例:多线程日志记录器类
    • (四) 线程间通信
      • 1. future/promise
      • 2. packaged_task
      • 3. async
  • 十五、后记

Typist : Akame Qixisi / Excel Bloonow


一、类型相关

属性(attribute)是在源代码中添加可选信息(或者供应商指定的信息)的一种机制。自C++11以后可用[[ xxx ]]支持。

(一)枚举类型

枚举类型声明如下:enum 枚举类型名 {元素1, 元素2, xxx};,枚举类型的变量定义:枚类类型名 枚举变量 = 元素i;。但这种枚举类型并不是强类型的,意味着其并非类型安全的,它们总是被解释为整型数据,因此可以比较完全不同的枚举类型行中的枚举值。为此可以用enum class定义一个类型实全的枚举,枚举名称不会自动超出封闭的作用域,这表示总是要使用作用域解析操作符::,如枚举类型名::元素i。枚举值不会自动转换为整数。默认情况下,校举值的基本类型是整型,也可以显式改变,enum class 救举类型名 : 数据类型 { 元素, xxx };

(二)const引用

const引用,作看上去有点自相矛盾,引用参数允许在另一种环境中改变变量的值,而const会阻止这种改变量。const引用参数的主要价值在于效率。当向函数传递值时,会制作一个完整的副本(实参复制给形参)。当传递引用时,实际上只传递了一个指向原始数据的指针,这样计算机就不需要制作副本。通过const引用作形参,可以二者兼顾:不需要副本(节省资源),原始变量也不会修改。在处理对象时,const引用会变得更加重要,因为对象可能比较庞大,复制对象可能需要很大的代价。

(三)std::string字面量

std::string字面量。源代码中的字符串字面量通常解释为const char*,使用用户定义的标准字面量s可以把字符事解释为std::string类型,如:auto str1 = "hello";中str1为 const char * 类型 , 不可变;auto str2 = "hello"s;中str2为string类的对象,可用string类的方法来操作。

std名称空间包含很多辅助函数,以便完成数值和字符串之间的转换。将数字转换成字符串:string to_string(数值类型实参)重载实现了多种数据。还有将字符串转换成数值。整型:int stoi(const string& str, size_t* idx = 0, int base = 10);,其中str表示要转换的string,idx是一个指针,接收第一个未转换字符的索引,base表示转换时使用的进制(只在整型才有),idx可为空指针(此时被忽略)。如果不能执行任何转换,这些函数会抛出invailid_argument异常,如果转换的值超出了返回类型的范围,则抛出out_of_range异常。函数如下,整型:stoi()stol()stoul()stoll()stoull()。浮点型:stof()stod()stold()。注:若字符串中有非数值字符,则转换只有该非数值字符之前的数值部分。还有:atof()atoi()atoll()等。

原始字符串字面量。不嵌入转义字符,可以跨越多行代码。格式如:string str = R"可选分隔符序到( // 内容 )可选分隔符序列”;,其中,可选分隔符序列最长为16个字符,且两侧应一致。且内容中不允插入与可选分隔一样的内容,否则出错。可选分隔序列可为空,即只是R"(内容)"

(四)数值相关

头文件中有std::numeric_limits::quiet_NaN()表示不是一个数字。

(五)类型转换

使用C风格的类型转换在C++仍有效,除此C++还提供了C++风格的类型转换,因为更安全,在语法上更加优秀。

1. const_cast<>

用来舍弃变量的const特性,转换成non-const类型。如:char* chs = const_cast("Hello World");

2. static_cast<>

static_cast<>显式地执行语言支持的类型转换,在具有继涵类层次结构中转换对象的指针或引用。若在类中显式定义了构造函数(参数为其他类的对象),实现类对象转换为另一类对象,则可执行用户自定义构造函数或者转换例程所支持的类型转换。无法将某个类的对象转换为其他无关对对象,无法完成C++规则认为没有意义的转换。注:static_cast<>类型转换不执行运行时的类型检测。

3. dynamic_cast<>

dynamic_cast<>为继承层次结构内的类型转换提供运行时检测,可用(且推荐)它来转换类继承结构上的指针或引用。dynamic_cast<>在运行时检测底层对象的类型信息,如果转换没有意义,dynamic_cast<>返回一个空指针(用来转换指针时)或者抛出一个std::bad_cast异常(用于转换引用时)。注意运行时类型信息存储在对象的虚表中。因此,为使用dynamic_cast<>,类至少有一虚方法(通常虚析构函数)。如果没有虚表,尝试使用dynamic_cast<>会导致编译错误,这一错误较为嗨涩。

4. reinterpre_cast<>

reinterpret_cast<>比static_cast<>功能更强大,同时安全性也更差。可用它执行一些在技术上不被C++类型规则允许、但在某些情况下程序员又需要的类型转换。它可将某指针或引用转换为其他无关类型的指针或引用,或将某个函数指针转换为其他函数指针。这个关键字经常用于将指针与void*之间转换。void* 指针指向内存是某个位置,没有相关的类型信息。使用reinterpret_cast<>时要特别小心,因为在执行转换时不会执行任何类型检测。

(六)前置声明

使用#ifndef机制可用来避免循环包含和多次包含。如果编译器支持#pragma once指令,也可使用之,这两个指令还可以保证不会由于多次包含某个头文件而引起重复定义。

前置声明(forward declarations)是另一个避免头文件问题的工具。如果需要使用某个类,但是无法包含它的头文件(例如,这个类严重依赖当前编写的类),就可以使用前置声明告诉编译器存在这么一个类,但是无法使用#include机制提供一个正式的定义。如:class 类名;,当然,在代码中无法真正地使用这个类,因为编译器对此一无所知,只知道在链接之后存在这个已命名的类。然而,仍然可以在代码中使用这个类的指针或引用。

建议尽可能在头文件中使用前置声明,而不是包含其他头文件。这可以减少编译和重泽时间,因为它破坏了一个头文件对其他头文件的依赖。当然,实现文件需要包含前置声明的类型的正确头文件,否则就无法编译。

(七)断言

断言在头文件中定义了assert宏,它接受一个布尔表达式,果表达式求值为false,则打印出一条错误消息并终止程序。如果表达式求值为true,则什么也不值。注意不要把程序正确执行所需的任何代码放在断言中,如函数调用,当代码的发行版剥离了断言代码,对函数的调用也会玻璃。

标准的assert宏的行为取决于NDEBUG预处理符号:如果没有定义该符号,则发生断言,否则忽略断言。编译器通常在编译发布版时定义这个符号。如果要在发布版中保留断言,就必须改变编译器的设置,或者编写自己的不受NDEBUG值影响的断言。

静态断言static_assert允许在编译时对断言求值,其接收一个表达式和一个字符串(const char*)。当表达式计算为false时,编译器将给出一个包含指定字符串的错误。另一个展示static _assert强大功能的例子是和类型trait结合使用。


二、函数相关

每个函数都有一个预定义的局部变量,即__func__,如下所示:static const char __func__[] = "function-name";。此外还有如__FILE____LINE__

(一)拖尾返回类型(trailing return type)

自从C++11以来,该语言就通过拖尾反回类型(trailing return type)支持一种替代的函数语法,这种新语法在普通函数中用得不多,但在指定模板函数的返回类型时非常有用。auto 函数名(形参列表) -> 返回类型 { // 函数体 }。从而,C++14允许要求编译器自动推断出函数的返回类型,即忽略拖尾:auto 函数名(形参列表) { // 函数体 }。编译器根据return语句使用的表达式推断返回类型。函数中可以有多个return语句,它们应为相同类型。这种函数甚至可以递归,但函数的第一个return语句必须是非递归调用。但是,以auto作函数返回类型的接收值的数据类型,会丢失掉引用和const限定符。例:const int& func() { neturn aInt; },若写auto x = func();,则x的类型为int(const和引用会掉失),当然可以写const int& x = func();但引入auto便无意义了。为此,C++提供了decltype(值或表达式或函数名)用以推断其形参所传实参的数据类型,但当其实参太长时,代码重复会显得繁琐。如decltype(func()) x = func();,当其中 “func()” 实参太长时,不好编程,因止引入decltype(auto)来实现上述功能。说明:其中auto和decltype(auto)作为一种特殊的数据类型,也可定义变量。总结auto的4种用法:告诉编译器在编译时自动推断变量类型;用于替代函数语法;函数返回类型的推断;用于通用的lambda表达式(后面介绍)。

(二)typedef和using声明函数指针

typedef可以为已有类型提供一个新名称,最常见用法是当实际类型的声明过于笨拙时,提供易于管理的名称,这一情形通常出现在模板中。typedef可以包括作用域限定符。采用typedef可定义函数指针,格式如下:typedef 返回类型 (*函数指针名)(形参列表);,还可以使用类型别名using,如下:using 函数指针名 = 返回类型 (*)(形参列表);。函数指针的类型取决于兼容函数的参数类型和返回类型。

当将函数名作为参数传递时,取地址符&在技术上是可选的。在C++中可以取得类成员和方法的地址,但应注意,不能在没有对象的情况下调用非静态方法。声明方法如:返回类型(类名::*指针名)(形参类型列表) = &类名::方法名;,也可以使用typedef,方法的const属性应保持一致。可以使用类型别名来简化:using 某方法类型名 = 返回类型 (类名::*)(形参类型列表);某方法类型名 指针名 = &类名::方法名;。使用auto可以进一步简化:auto 指针名 = &类名::方法名;。其使用型式如:(对象名.*指针名)(实参列表);,不能在没有实例对象的情况下解除对非静态方法或数据成员指针的引用,即无法参与回调。C++允许在没有对象的情况下解除对静态静态成员或方法的指针的引用。C++是供了mem_fn()转换函数,生成一个可用于回调的函数对象,用法如:mem_fn(&类名::方法名)

(三)自定义字面量

C++允许定义自己的字面量。用户定义的字面量应该以下划线开头,并通过编写字面量运算符函数(literal operators)来实现。字面量运算符能够以生模式(raw)或者熟模式(cooked)运行。在生模式中,字面量运算符接收一个字符串序列,在熟模式中字面量运算符接收一个经过解释的特定类型(如1.23f、3u等)。自定义格式如下:返回类型 operator"" 字面量名(参数表) {},其中字面量名建议以下划线开头。在C++11标准中,对字面量操作符的使用定了一些规则:

  1. 如果字面量为整形数,那么操作符函数只可接受unsigned long long或者const char*作为参数,当unsigned long long无法容纳该字面量的时候,编译器会自动将该字面量转化为以'\0'结尾的字符串,并调用以const char*为参数的版本进行处理。
  2. 如果字面量为浮点数,操作符函数只可接受long double或者const char*为参数,与整形一样,在当long double无法容纳时调用以const char*为参数的版本进行处理。
  3. 如果字面量为字符串,操作符函数只可接受(const char*, size_t)为参数。
  4. 如果字面量为字符,则操作符函数只接受一个char为参数。

一个示例如下:

class Int {
private:
	int value;
public:
	Int(int v) : value(v) {}
};

Int operator"" _I(unsigned long long i) {
	return Int(i);
}

Int operator"" _II(const char* chs, size_t n) {
	int v = stoi(chs);
	return Int(v);
}

int main(int argc, char* argv[]) {
	cout << typeid(74).name() <<  " , " << typeid(74_I).name() << " , " << typeid("74"_II).name();
	// 输出:int , class Int , class Int
	return 0;
}

C++14定义了如下标准的用户定义字面量。s用于创建std::stinghminsmsusns用于创建std::chrono::duration时间段。iilif分别用于创建复数complexcomplexcomplex复数。

(四)C风格可变参数列表

C风格可变函数列表需要头文件中的宏。所有具有变长参数列表的函数都至少应该有一个已命名参数,可变参数使用...占位符。在函数中先使用va_list 列表名;声明一个列表,再声明列表的开始位置va_start(列表名, x);其中x是可变列表前一个已命名参数,最后必须用va_end(列表名);以确定函数结束后,推栈处于稳定状态。如果要访问突示参数,可使用va_arg(列表名, 实参解释类型),通常用for循环遍历。但应注意,变长参数列表无结束标志,因而要显式规定,如可以让第一个参数计算参数的数目,或当参数是一组指针时,可以要求最后一个指针是nullptr等。注:不推荐使用C风格的变长参数列表,因为其十分不安全:不知道参数的数目,不知道参数的类型。一个例子如下:

void foo(int argc, ...) {
	va_list lis;
	va_start(lis, argc);
	for (int i = 0; i < argc; ++i) {
		cout << va_arg(lis, int) << " ";
	}
	va_end(lis);
}

int main(int argc, char* argv[]) {
	foo(5, 1, 2, 3, 4, 5);	// 输出:1 2 3 4 5
	return 0;
}

三、类相关

C++语言本质上对抽象原则并不友好。其语法要求将public接口和private或protected数据成员和方法放在一个类定义中,从而将类的某些实现细节向客户公开。这种做法的缺点在于,如果不得不在类中加入新的非公有方法或者数据成员,所有的客户代码都必须重新编译,对于较大的项目而言这是一个负担,解决的基本原则为:为想编写的每个类都定义两个类:接口类和实现类。接口类中只有一个数据成员,即指向实现类对象的一个指针。并在接口类给出了与实现类一样的public方法,通过指针调用实现类的方法。这称pimpl : idiom或私有实现习语。这样,无论实现类如何改变,都不会影响public接口类,从而降低了重新编译的必要性。

建意将所有数据成员都默认声明为private,如果希望派生类访问它们就可以提供protected的获取器和设置器。私有成员仍在子类中,但不能访问。

(一)构造函数

1. 显示默认与显示删除

为了避免手动编写默认构造函数,C++显式默认构造函数(explicity defaulted constructor)的概念,在定义类中定义:构造函数名() = default;则不需要在源文件中实现,且此时再显式手动重载构造函数,编译器仍然会生成一个标准的由编译器生成的默认构造函数。与之对应,显式删除构造函数(可以定义个只有静态成员的类),即某个类没有任何构造函数,也不想让编译器自动生成,即可:构造函数名() = delete;。也可以将复制构造函数设为显式默或显式删除(= default;= delete;)。

注意默认构造函数和复制构造函数之间缺少对称性。只要没有显式定义复制构造函数,编译器就会自动生成一个。另一方面,只要定义了任何构造函数编译器就不会生成默认构造函数(任何构造函数包括复制构造函数)。当然,可以通过显式默认或是式删除构造涵数,来影响自动生成的默认构造函数和默认复制构造函数。

重载方法可以被显式删除,可以用这种方法禁止调用具有特定参数的成员函数,例:void func(int);,当调用func(1.23);时,编译器将double型的1.23转换成整型值,然后调用 func(int),编译器可能会给出警告,但仍然会执行这一隐式转换。而通过显式删除func(double) = delete;实例方法,可以禁止编译器执行这一转换,通过这一改动,用double做参数调用 func() 时,编译器会给出错误提示,而不是将其转换为整数。

2. 构造函数初始化器

C++还提供了一种初始化列表,叫做构造函数初始化器或者ctor-initializer。当C++创建某个对象时,必须在调用构造函数前创建对象的所有数据成员,如果数据成员本身是另一个类的对象,那么在创建这些数据成员时,必须为其调用它的构造函数。当在外部类的构造函数体内给某个对象赋值时,并没有真正创建这个对象,而只是改变对象的值。而ctor-initializer允许在创建数据成员时赋初值(即初始化,可调用成员的复制构造函数),这样做比在后面赋值效率高。注:类的某些数据类型成员必须在ctor-initializer中初始化:

  1. const数据成员,当const变量创建之后无法对其正确赋值,必须在创建时初始化;
  2. 引用数据成员,如果不指向一个量,引用将无法存在;
  3. 没有默认构造函数的对象;
  4. 没有构造函数的基类。

3. 初始化列表构造函数

初始化列表构造函数(Initializer-List Constructors)将std::initializer_list作为第一个参数(多为移动语义),并且没有其他任何参数(或者其他参数具有默认值)。在使用std::initializer_list模板之前,必须包含头文件。在初始化列表构造函数内部,可以使用基于区间的for循环来复制(访问)初始化列表的元素,使用size()方法获取列表中元素的数目。而实际上,建议尽可能使用STL算法。C++11中STL完全支持初始化列表构造函数。初始化列表并不限于构造函数,还可以用于普通函数。初始化列表构造函数创建对象时,可为:类名 对象名 = { 元素i };类名 对象名{ 元素i };

C++11和C++14标准具有许多新功能。自从C++11以后,可使用统一初始化方法,即{xxx}语法。统一初始化还可以用于把变量初始化为0,只需指定一系列空花括号。使用统一初始化还可以阻止窄化(截断)。统一初始化还可用于STL容器,还可以用来初始化动态分配的数组,统一初始化还可以在构造函数初始化器中初始化类成员数组。

初始化列表定义在头文件中。初始化列表(initializer lists)简化了参数数量可变函数的编写。初始化列表中所有的元素都应该是同一种预定义类型,须定义列表中允许类型,因而是安全的。示例如下:返回类型 函数名(initializer_list<> 列表名) { for (auto 因子 : 列表名) {} }

4. 类内成员初始器

类内成员初始化器(In-class Member Initializer),自C++11开始,允许在定义类时直接初始化成员变量。而在C++11之前,只有 static const 整型 成员变量才能在类定义中初始化,其他成员数据类型只有在构造函数体或ctor-initializer中初始化。

5. 委托构造函数

委托构造函数(Delegating Constructor)允许构造函数调用同一个类的其他构造逐数。然而这个调用不能放在构造函数体内,而必须放在构造而数初始化器中,且必须是列表中唯一的成员初始化器,如:构造函数名(形参) : 另一构造函数名(形参) { }。当使用委托构造函数时,要注意避免出现构造函数的递归,C++标准没有定义此行为,这取决于编译器。

6. 创建对象时出错

创建对象时出错,即构造函数中的错误。在构造函数中抛出异常,而若异常离开构造函数,对象的构析将无法调用。因此在异常离开构造函数之前,必须在构造函数中释放内存(在构造函数中用try-catch语句),释放完后再重新执出异常。若继承时,没有发生异常的类的构造函数的析构函数都会运行。若在构造函数的ctor-initializer中执出异常,可用function-try-blocks用于构造函数。格式如:构造函数(形参表) try : 初始化器列表 { // 构造函数体 } catch (异常) { },它存在不少限制,主要用于将ctor-initializer中抛出的异常转换为其他异常,释放在抛出异常之前就在ctor-initializer中分配了内存的裸资源,将信息记录到日志文件。catch处理中应能抛出当前异常。不应该让析构函数抛出异常(程序会调用terminate())。头文件中声明了一个函数uncaught_exception(),若存在未捕获的异常且正在堆栈释放的过程中,这个函数返回true,否则返回false。

(二)运算符重载

1. 机制

不能对内建类型重定义运算符,运算符必须是类中的一个方法或友元。全局重载运算符函数至少有一个参数必须是一个用户定义的类型(例如一个类)。这意味着不允许做一些荒唐的事。不过有个例外,内存分配和释放例程,可以替换程序中所有的内存分配和释放的全局例程。可以重载具有双重意义的运算符的两个意义。运算符重戴函数的返回类型理论上可以是任何类型(包括void),但通常返回被调用对象的引用(使用引用是为了提高性能),从而可连续使用运算符。如x = y = 2;实际为:x.operator=(y.operator=(x));

应该按引用接受每一个非基本类型的参数。除非要真正修改参数,否则将每一个参数都设置为const。返回的可以作为左值修改的返回值必须是非const(如+=、-=),否则应是const。将运算符重载为方法时,若其不修改对象,应将方法标记为const,使得const对象可以调用这个方法。(两个版本,const和非const。)

当C++编译器分析一个程序,遇到运算符时,就会试着查找operator#且具有适当参数的函数或者方法,不只查找类型指定的operator#,而是所有。且为了找到operator#,编泽器还试图查找合适的类型转换,构造函数会对有问题的类型进行适当的转换。当编译器看到对象#变量时,会查找对象所属类中,以变量的数据类型作参数的构造函数(只有一个形参或其他默认),从而创建一个临时对象,完成对象#临时对象操作(临时对象是以变量为实参调用构造函数生成的)。在该过程中发生了隐式转换,可以使用关键字explicit标记构造函数,禁止隐式转换。如:explicit 构造函数名() { }

自C++11以来,explicit关键字还可以用于转换运算符,来避免引发的歧义问题,格式如下:explicit operator type() {},如果定义了显式转换运算符,如果要使用它必须显式调用。注:转换运算符没有返回类型。

为了解决 x#y 可行而 y#x 则报错的问题,通常将可交换律的双目运算符声明为全局operator#,即友元函数。

2. 赋值运算符与复制构造函数

在C++中允许将对象值赋给自身,赋值运算符不应阻止自身赋值,也不应该在自身赋值时执行完整的操作,故赋值运算符应该在方法开始时检测自身赋值,发现自身赋值,则立即返回自身*this,检测方法为,判断两者的指针是否都指向内存中同一个地址,如下:类名& 类名::operator=(const 类名& src) { if (this==&src) return *this; // 赋试值操作; return *this; },也可以通过 =default 和 =delete 显式地默认或显示删除编译器生成的赋值运算符。

基本上,在声明时会使用复制构造函数,赋值话句时会使用赋值运算符。注:类名 新对象 = 旧对象;为声明语句,使用复制构造函数。

无论什么时候在类中动态分配了内存,都应该编写自己的复制构造函数和赋值运算符,以提供深层的内存复制。还应编写析构函数来释放内存。在类中有动态分配内存时,构造函数(复制构造函数)应使用new开辟新的内存空间。而在operator=()函数中,应先在重新赋值前用delete释放旧空间避免造成内存泄漏(注意任何赋值函数应先进行自身赋值检测,否则使用delete删除this中的内容时,由于和右值传的的地址相同,也会删除要赋的对象,从而造成数据丢失),之后再用new分配新内存空间,从而完成赋值。

复制构造函数与赋值运算符十分相似,因此可将通用的任务放在辅助方法中通常会带来便利。如在类中:private: void deepCopyFrom(const 类名& src) { this.point = new ..; *(this.point) = *(src.point); }来实现深复制,则复制构造函数为:类名::类名(const 类名& src) { deepCopyFrom(src); },而赋值运算函数为:类名& operator=(const 类名& src) { // 自身赋值检查和释放原内存;deepCopyFrom(src); }

如果在派生类中指定了复制构造函数,就需要显式地链接到父类的复制构造函数,否则将调用父类的默认构造函数(而不是复制构造函数)来初始化对象的父类部分。与此类似,如果派生类重写了operator=(),则几乎总是需要调用父类版本的operator=()。子类复制构造函数一个典例:子类::复制构造函数(const 子类名& src) : 父类复制构造函数(src) { xxx };子类复制运算符的一个典例:子类::operator=(const 子类名& src) { 父类名::operator=(dynamic_cast<父类名&>(src)); xxx }

在类中动态分配内存时,如果只想禁止其他人复制对象或为对象赋值时,只需显式地将operator=()和复制构造函数标记为 =delete。通过这种方法,当企图按值传递对象、从函数或者方法按值返回对象时,或为对象赋值时,编译器都会报错。不需要提供delete复制构造函数和赋值运算符函数的实现,链接器永远不会查看它们,因为编译器不允许代码调用它们。注:如果编译器不支持显示删除成员函数,就可以把复制构造函数和赋值运算态函数标记为private,且不提供任何实现,来禁用复制和赋值。

3. 常见函数重载的格式

下标(数组索引)运算符,必须为方法:T& operator[](int);const T& operator[] (int) const;。函数调用运算符,必须为方法,其具体实现根据函数不同而不同,如:operator()();operator type() 类型转换(cast)运算符,必须为方法,将自己编写的类转换成其他类型时,无返回类型,但要有return语句:operator type() const;。内存分配例程,建议为方法,需要控制类的内存分配时:void* operator new();void* operator new[](size_t)。内存释放例程,建议为方法,重载了new时:operator delete(void*) noexcept;operator delete[](void*)。解除引用运算符,*建议为方法,->必须为方法,同样适用于智能指针:T& operator*() const;T* operator->() const;

注:

  1. 重载operator*解除对指针的引用,返回的是底层普通指针指向的对象或变量的引用。C++将 operator-> 当成一个特例,如:A->foo(x)被解释为(A.operator->())->foo(x);,将 operator-> 的返回结果应用了另一个 operator-> ,因此它必须返回一个指向对象地址的指针。(可重载operator->*,但非常麻烦且不值得)。
  2. C++11之后,允许将类型转换运算符标记为explicit来避免隐式转换的歧义问题,数值+对象是将数值用构造函数变为对象,还是将对象用类型转换运算符转换为数值,这一多重选择导致的多义性问题)。
  3. operator bool()技术上存在若干问题,如:类型隐士转换将bool自动提升转换为int,指针比较时和nullptr的nullptr_t类型不匹配,在比较指针是否相等时,多使用operator void*() const;

C++标准库有一个头文件,它包含几个辅助函数和类。在std::rel_ops名称空间中给出了关系运算符!=><=>=的函数模板。这些函数模板根据==<运算符给任意类型定义了剩下的四种关系。如果在类中实现了 == 和 < ,就可以通过#include 和导入using std::rel_ops::operator#;来获得剩下的四种 # 关系运算符。

重载一元负号、正号,其不会改变原值,返回一个新值。重载递增和递减运算符,前缀形式先运算,故结果值和最终值一致,返回引用(return *this),后缀形式先作一个新对象保存旧值,将源对象变化后,返回旧对象(原来的值)。递增和通减不能应用于指针,当编写的类是智能指针或迭代器时,可重载递变运算符,以提供指针的递变操作。重载索引远算符应反回索引处元素的引用,通过这个引用可以对元素赋值。

4. 函数调用运算符与仿函数

C++允许重载函数调用运算符,写作operator(),如果自定义类中编写了一个operator(),那么这个类的对象就可以当作函数指针使用。只能将这个运算符重载为类中的非static方法(根据要仿的方法的形参列表等不同可重载为多个版本,应使operator()和要仿的函数有相同的返回类型和形参列表)。带有函数运算符的类的对象称为函数对象,或简称仿函数functor。其优点:这些函数对象有时可以伪装为函数指针,只要函数指针的类型是模板化的,就可以把这些函数对象当成回调函数传入需要接受函数指针的例程。函数调用运算符还可用于提供数组的多重索引下标,只要编写一个行为类似于 operator[] ,但接受多个参数的operator()即可,唯一问题是需要使用 () 而不是 [] 进行索引。C++提供了一些预定义的仿函数类,在头文件中,执行最常用的回调。

(三)成员的static、const属性

不仅要在类定义中定义出static类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可以初始化静态成员,但注意与普通的变量和数据成员不同,在默认情况下它们会初始化为0,static指针会初始化为nullptr。

类内的const数据成员因为不能被赋值,所以需要在ctor-initializer中初始化它们。

不能将静态方法声明为const方法,因为这是多余的,静态方法没有类的实例,因此不可能修改对象内部的值。const方法的工作原理是将方法内用的数据成员都标记为const引用,如果试图修改数据成员,编译器会报错。const对象只能调用const方法,将不修改对象的所有方法声明为const,这样const对象可以调用const方法,可以使用const引用对象来做参数。

注意:const对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为const。

通过将变量设置为mutable,从而实现在const方法中修改该变量的值,即:mutable 数据类型 变量名;

C++不允许仅根据方法的返回类型重载方法名称,但可以根据const重载方法,也就是说可以编写两个名称相同、参数也相同的方法,其中一个为const,另一个不是。如果是const对象,就调用const方法,如果是非const对象,就调用非const方法。

1. constexpr关键字

C++一直存在常量表达式的概念,在某些情况下需要常量表达式。例如当定义数组时,数组的大小就必须是一个常量表达式。即用一个返回整型的函数定义数组会出错,可使用constexpr关键字定义函数,把它变成常量表达式,编译器必须在编译期间对constexpr函数求值。constexpr 返回类型 函数名() { return aConst; }。函数有和下限制:函数体是一个return语句(返回值是字面量类型,不能为void),不包含goto语句或try-catch块,也不执出异常,不允许使用dynamic_cast、new和delete;但可以调用其他constexpr函数。

如果constexpr时类的一个成员,这个函数不能是虚函数。函数的所有参数都应该是字面量类型。在编译单元(translation unit)中定义了constexpr函数之后,才能调用这个函数,因为编译器需要知道它的完整的定义。

通过定义constexpr构造函数,可以创建用户自定义类型的常量表达式变量,应满足:所有参数都应该是字面量类型。构造函数体不应该是function-try-block。构造应该满足与constexpr函数体相同的要求。所有数据成员都应该用常量表达式初始化。

2. static关键字

静态链接(Static Linkage)。在C++中连接的概念,C++每个源文件都是单独编译的,编译得到的目标文件会彼此链接。C++源文件中的每个名称,包括函数和全局变量,都有一个内部或外部链接。外部链接意味着这个名称在其他源文件中也有效,内部链接(也称静态链接)意味着在其他源文件中无效。默认情况下,函数和全局变量都拥有外部链接。注意在两个不同的文件中编写相同函数的原型是合法的。如果将原型放在头文件中,并在每个源文件中都用 #include 包含这个头文件,预处理器就会自动在每个源文件中给出函数原型。使用头文件的原因是便于维护(并保持同步)原型的一个副本。

可在声明前面使用关键字static指定内部(静态)链接,可以使其他文件无法使用该声明的内容。每个源文件都可以成功编译,但是链接时不成功)。如果在源文件中定义了静态方法但没有使用它,有些编译器会给出警告(指出这些方法不应该是静态的,因为其他文件可能会用到它们)。不需要重复使用static关键字,只要在声明的第一个实例前使用这个关键字,就不需要重复它。

用于内部链接的另一种方式是使用匿名名称空间(anonymous namespace),将变量或者函数封装到一个没有名称的命名空间namespace { xxx }。在同一源文件中,可在声明匿名空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。这一语义与static关键字相同。

extern用在变量或函数的声明前,用来说明此变量或函数是在别处定义(且在别处应具有外部链接)的,要在此处引用。若要链接其他文件中的数据变量,应在要使用它的文件中显式链接,即extern 数据类型 变量名;extern 返回类型 函数名(形参表);(const和typedef在默认情况下是内部连接的,可以使用extern使其变为外部全连接)。注:当指定某个名称为extern时,编译器将这条语句当作声明,而非定义。对于变量而言,这意味着编译器不会为这个变量分配空间,必须为这个变量提供单独的、不使用extern关键字的定义行,如:extern 类型 变量名; 类型 变量名 = 初值;,也可以在extern行初始化,既是声明又是突义:extern 类型 变量名 = 初值;。建议不要使用全局变量,令人迷惑且容易出错,在大型程序中尤其如此,为了获得类似功能可使用类的静态数据成员和方法。

C++中static关键字的最终目的是创建离开和进入作用域时都可以保留值的局部变量。然而静态变量容易令人迷惑,在构建代码时,避免使用单独的静态变量,为了维持状态可改用对象。

非局部变量按照其初始化的逆序进行销毁,在不同源文件中非局部变量的初始化顺序无法确定,所以其销毁顺序也是不确定的。

(四)内联函数

因为内联函数执行代码特换,故在使用内联函数的文件中,须实现内联函数,这意味着须将内联函数的实现放在类的定义中的头文件中。注:高级C++编译器不要求把内联方法的定义实现放在头文件中,如Microsoft Visual C++。

(五)嵌套类的访问

类定义不仅可以包含成员函数和数据成员,还可以编写嵌套类和嵌套结构、声明typedef或者创建枚举类型。类中声明的一切内容都具有类作用域。如果声明的内容是public,那么可以在类外面使用className::作用域解析语法访问。若声明了一个内外嵌套的两层类,为了避免多次使用外部类::内部类::内部成员这样的代码造成繁琐,可以使用易于管理的别名:using 别名 = 外部类::内部类,类型别名应该在外部类的外部定义,否则就须用外部类::别名::内部成员来访问内部类,这无意义。普通的访问控制也适用于嵌套类定义。如果声明了private或者protected的嵌套类,这个类只能在外部类中使用。通常,嵌套类定义只适用于微小的类。如果想在类内定义许多常量,应该使用枚举类型而不是一组#define。

全局作用域没有名称,但可以使用作用域解析运算符本身(无名称前缀)访问全局作用域中的成员,如:::全局成员

(六)子类继承

C++允许在定义类时在类名后面加上final关键字,从而禁止类被继承,如:class 类名 final [: 继承权限控制 父类] { }

在多继承中,若两父类有相同成员,可使用using显式指定继承哪一个成员,即在子类中:using 某父类::同名成员;

子类从父类继承而来的方法,只能操作在父类作用域的成员。

1. 方法覆盖(override)

在C++中,覆盖(override)方法有一点别扭,因为必须使用关键字virtual,只有在基类中声明为virtual的方法才能被派生类正确地覆盖。根据经验,为了避免遗漏virtual关键字引发的问题,可将所有方法设置为virtual(包括析构函数,但不包括构造函数)。建议在覆盖方法的声明末尾添加override关键字,如:void foo() override { };也可将基类的方法后面加关键字final从而禁止在派生类中覆盖这个方法,如:void bar() final { }

(1) 修改方法的返回类型

修改方法的返回类型。在C++中,如果原始的返回类型是某个类的指针或者引用,覆盖的方法可以将返回类型改为其子类的指针或引用。这种类型称为协变返回类型(covariant return types)。如果基类和子类处于平行层次结构(parallel hierarchy)中,使用这个特性可以带来方便。平行结构是指,一个层次结构与另一个层次结构没有相交,但是存在联系。如:

class Product {};
class ConcreteProduct : public Product {};

class Factory {
public:
	Product getInstance() {
		return Product();
	}
};
class ConcreteFactory : public Factory {
public:
	ConcreteFactory getInstance() {
		return ConcreteFactory();
	}
};

(2) 覆盖即重写,与隐藏、重载的区别

重载只在同一个类中。用virtual修饰父类的方法且在继承关系中的为覆盖(即重写)。若父类没用virtual修饰方法且子类有与之同名(仅同名,不需要参数也相同)的方法,则父类中方法被隐藏。可以使用using关键字在子类中显示显现基类中被隐藏的方法:using 基类名::被覆盖的方法;

(3) 静态方法不能覆盖

在C++中,不能覆盖静态方法,因为静态方法是基于类的,与对象没有关系(没有隐式的this指针)。因而若基类中和子类中存在同名的静态方法,两个方法之间是毫无关系的。

(4) 覆盖private方法

在C++中,子类虽然无法周用父类的private方法,但可以覆盖这个方法。实际上,在C++中,覆盖private或者protected方法是一种常见模式,这种模式允许子类定义自己的独特性,在基类中会引用这种独特性。(注意:Java和C#仅允许重写public和protected方法,不能覆盖private方法。)

可以修改从基类继承来的方法的访问权限,对protected方法提供较为宽松的访问限制。

(5) 覆盖方法与参数默认值

派生类和基类的某方法可以具有不同的默认参数。由于C++根据描述对象的表述达式类型在编译时绑定默认参数,而不是根据实际的对象类型绑定参数,故使用的参数取决于声明的变量类型,而不是底层的对象。注:当覆盖具有默认参数的方法时,也应该提供默认参教,这个参数值应与基类版本相同,建议使用符号常量做默认值,这样可以在派生类中使用同一个符号。

class Base {
public:
	virtual void foo(int x = 1) {
		cout << x << " , Base\n";
	}
};

class Sub : public Base {
public:
	virtual void foo(int x = 2) {
		cout << x << " , Sub\n";
	}
};

int main(int argc, char* argv[]) {
	Base* p = new Sub;
	p->foo();	// 输出:1 , Sub,默认值使其声明时由 Base 确定的为1
	return 0;
}

2. virtual虚函数

如何实现virtual。在C++编译类时,会创建一个包含类中所有方法的二进制对象。在非虚情况下,将控制交给正确方法的代码是硬编码,此时会根据编译时的类型调用方法。

如果方法声明为virtual,会使用名为虚表vtable的特定内存区域调用正确的实现。每个具有一个或者多个虚方法的类都有一张虚表,这种类的每个对象都包含指向虚表的指针,这个虚表包含了指向虚方法的实现的指针。当使用某个对象调用方法时,指针也进入虚表,然后根据实际的对象类型执行正确版本的方法,virtual对每个对象的内存有影响,即还需要一点内存空向来储存虚表指针。而在调用虚方法时,程序需要执行额外操作,即对指针解除引用,以执行正确的代码,而由于虚表的开销问题,有认为不应将一切都声明为virtual,而有人认为将一切声virtual(Java语言就如此),这也是关键字virtual存在原因,将选择权交给程序员。

即使不认为应该将所有方法都声明为virtual的程序员,也坚持认为应该将析构函数声明为virtual,原因是如果析构函数未声明为virtual,很容易在销毁对象时不释放内存。唯一允许不把析构函数声明为virtual的情况是类标记为final。

3. 子类对象的创建与销毁

子类创建对象时必须同时创建父类和包含于其中的对象。C++定义了如下的创建顺序:

  1. 若某个类有基类,执行基类的默认构造函数,除非在ctor-initializer中调用了基类中的构造函数;

  2. 类的非静态数据成员按声明顺序创建;

  3. 执行该类的构造函数。

说明:因为先调用基类的构造函数再创建子类成员,故若将子类成员作实参传给基类构造函数时,无法正常初始化。

析构逐数销毁顺序正好相反:

  1. 调用该类的析构函数;

  2. 销毁该类的数据成员,与创建顺序相反;

  3. 调用父类的析构函数。

注意:应将所有的析构函数声明为virtual,否则当使用delete删除一个指向子类对象的基类指针时,析构函数调用链将会被破坏,只调用基类的析构函数。编译器默认生成的析构函数不是virtual,因此应该定义自己的虚析构函数。

4. 向上向下转型与截断

基类的指针或者引用指向子类对象时,保留其特性。但通过类型转换将派生类对象转换为基类对象时,就会生失其特性,称之为截断(slicing)。当向上转型时,使用基类指针或引用以避免截断。

(七)右值引用与移动语义

创建引用时必须初始化它。而若引用为类的数据成员时,要在ctor-initializer中初始化引用,而不是构造函数体内。不能创建一个对未命名值(如字面量)的引用,除非该引用为const类型。

1. 右值引用

右值引用。在C++中,左值(lvalue)是可以获取其地址的一个量,例如一个有名称的变量。另一方面,例如常量值,表达式,临时对象或者临时值,通常位于赋值运算符右边的称为右值。右值引用是个对右值(rvalue)的引用,即:数据类型&& 引用名 = 右值;。右值引用是为了提供在涉及临时对象时可以选用的特定方法。函数可将 && 作为参数说明的一部分,来指定右值引用参数,当函数调用结束后,会丢失掉右值。

2. 移动语义

移动语义(Move Semantics)来实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。如果源对象是在复制或者赋值结束后被销毁的临时对象,编译器就会使用这两个方法。(只有在知道源对象会被销毁的情况下,移动语文才有意义)。注:右值引用会改变对象,参数不能为const;函数参数的私有成员能够直接被用.访问。。

移动构造函数和赋值移动运算符将成员变量从源对象复制/移动到新对象,然后将源对象的变量设置为空值(指针设为nullptr)。将源对象的指针设为nullptr,是为了防止源对象的析构逐数释放它(故一个类的析构函数在释放一个指针时,应加一个判断如:if (point != nullptr) { delete point; point = nullptr; }),因为这块内存现在属于新对象,实际上是将内存的所有权从一个旧对象移动到一个新的对象。这两个法基本上只对成员变量进行表层复制(shallow copy),然后转移已分配内存的所有权,从而阻止悬挂指针和内存泄漏,可以引入一个辅助方法freeMemory(),用于释放当前对象的内存(指针),这个辅助方法在析构函数、普通赋值运算符和移动赋值运算符中调用。同样还可以添加一个辅助方法,用于把数据从源对象移动到目标对象,接着在移动构造函数和移动赋值运算符中调用它。

移动语义是通过右值引用实现,为了对类增加移动语义,需用关键字noexcept限定符标记移动构造函数和移动赋值符,它告诉编译器不会抛出异常。这对于与标准库兼容非常重要,如果实现了移动语义,与标准库的完全兼容只会移动存储的对象,且确保不执出异常。定又格式如下:复制构造函数名(类名&l src) noexcept;类名::operator=(类名& rhs) noexcept。可以使用std::move(src)将左值转换为右值,从而调用右值版本,该函数不抛出异常,如l = r; r = 0;等价于l = std::move(r);

如vector矢量尺才会自动增加,以容纳新的对象。为此,需要分配一个较大的内存块,然后将对象从旧失量,复制或者移动到新失量。如果编译器找到了移动构造函数,将会移动(而不是复制)对象,这样不再需要深度复制(deep copying),从而提高了效率。


四、内存管理

(一)堆栈

堆栈:当作用域改变时,将旧作用域压下,并将来新作用域的声明压入到栈顶层,每层提供栈帧供其运行,新作用域用完时会弹出。(如函数调用和结束调用等)。堆是一块平行内存空间,只读内存。

指针所声明的变量引用/指向的某个整数内存,即指向动态分配堆上内存的一个箭头。如果不希望立即分配内存,可以把它们初始化为空指针nullptr(注:NULL是一个空指针常量,等价于整数0)。为了访问指针向指的值,需要指针解除引用,即沿着指针箭头的方向寻找堆中实际的值,指针并非总是指向堆内存,可以声明一个指向堆栈中变量甚至指向其他指针的指针。为了让指针指向某个变量,需要使用&取址符,使用完动态分配的内存后,需要使用delete操作符释放内存,为防止释放指针指向的内存后再使用指针,建议把指针设置为nullptr。动态分配数组,指针变量在堆栈中,动态创建的数组在堆中,莫忘记使用delete释放内存。

在C中经常使用指向堆栈变量的指针,以允许函数修改其他堆栈中的变量,这实际上是按引用传递。通过对指针解除引用,即使这个变量不在当前堆栈帧中,函数也可以改变变量所在的内存。而在C++中有更为安全的引用机制(不能创建常量的引用)。

通过Visual C++在Windows或通过Valgrind在Linux中查找内存泄漏。

(二)指针

用new来创建堆上多维数组,形如二维数组:类型** 二维指针名 = new 类型*[第一维长度]; for (int i = 0; i < 第一维长度; 二维指针名[i++] = new 类型[第二维长度]);,释放时也应用for循环逐层释放。

因为指针很容易被滥用,所以名声不佳。因为指针只是一个内存地址,所以理论上可手动修改其地址。如:类型* 指针名 = (类型*)整数x;构建了一个指向内存地址为 x 的指针,而这个位置可能是内存中的一个随机的垃圾,或其他程序使用的内存。如果使用未通过new分配的内存区域,可能会出现各种故障。

由于指针是内存地址(或指向某处的箭头),因此指针的类型比较弱。在同一位的系统下,指针大小相同(32位是4字节,64位是8字节)。编译器允许通过C风格的类型转换将任意指针类型方便地转换为其他的任意指针类型。使用C++风格的类型转换有更高的安全性。

指针运算。C++编译器通过声明的指针类型允许执行指针运算,通过指针加减一个整数,可改变其指向位置,将一个指针减去另一个同类型的指针,得到的是两个指针之间指针指向的类型的元素的个数,而不是两个指针之向字节数的绝对值。

(三)自定义内存管理

基本上自己管理内存通常意味着编写一些分配大块内存,并在需要的情况下使用大块内存中片段的类。当使用new分配内存时,程序还需要预留少量空间来记录分配了多少内存,这样当调用delete时,可以释放正确数量的内存。对于大多数对象,这个开销比实际分配的内存小得多,所以差别不大,然而对于很小的对象或分配了大量对象的程序来说,这个开销的影响可能会很大。当自己管理内存时,可以事先知道每个对象的大小,因此可以避免每个对象的开销。对于大量小对象来说,这个差别可能会很大。

1. 重载内存分配和释放运算符

C++允许重定义程序中内存分配和释放的方式,既可以在全局层次也可以在类层次进行这种自定义。这种能力在可能产生内存碎片的情况下最有用,当分配和释放大量小对象时会产生内存碎片。例如,每次需要内存时,不使用默认的C++内存分配,而是编写一个内存池分配器,以重用固定大小的内存块。

(1) new和delete的工作原理

对如下:类型* 指针名 = new 类名();,其中new 类名();称为new表达式,它完成两件事:先调用operator new为类型对象分配了空间,再为这个对象调用构造函数,之后返回指针。而对于delete 指针名;称为delete表达式,它先调用指针所指对象的析构函数,然后调用operator delete来释放内存。

可以重载 operator new 和 operator delete 来控制内存的分配和释放,但不能重载new表达式和delete表达式。因此,可以自定义实际的内存分配和释放,但不能自定义构造函数和析构函数的调用。

就如 x#y 会被编译器解释为 operator#(x, y) 一样,Type* p = new Type();会被解释为:void* p = ::operator new(sizeof(Type)); p = new(p) Type();。同理delete p;会被解释为:p->~Type(); ::operator delete(p);

(2) new表达式和operator new运算符

共有6种不同形式的new表达式,每种形式都有对应的 operator new ,在头文件中有其形式。new表达式和对应的operator new形式如下表:

new 表达式 operator new 运算符
new void* operator new(size_t);
new[] void* operator new[](size_t);
new(nothrow) void* operator new(size_t, const nothrow_t&) noexcept;
new(nothrow)[] void* operator new[](size_t, const nothrow_t&) noexcept;
new(void*) void* operator new(size_t, void*) noexcept;
new(void*)[] void* operator new[](size_t, void*) noexcept;

注:最后两种特殊的new表达式,它们不进行内存分配,而是在已存在的内存上调用构造函数来构造对象。这种操作称为placement new运算符使用如下:类型* 指针名 = new(void类型指针内存的指针) 类名();,这一特性虽偏但非常重要,如果要实现内存池,以便在不释放内存的情况下重用内存,这一特性就很方便。C++标准禁止重载它对应的 operator new 。

nothrow是一个nothrow_t类型的变量,可用于传给带nothow_t&的operator new或operator delete运算符。

(3) delete表达式和operator delete运算符

只有2种不同形式的delete表达式可以用以调用:deletedelete[],没有nothrow和placement形式。因为C++标准指出,从delete抛出异常的行为是未定义的,也就是说delete永远都不应该抛出异常,因此nothrow版本的operator detele是多余的;而placement版本的delete应该是一个空操作,因为在placement的operator new中并没有分配内存,因此也不需要释放内存。不过operator delete有6种形式,其各原型如下所示:void operator delete(void*) noexcept;void operator delete[](void*) noexcept;void operator delete(void*, const nothrow_t&) noexcept;void operator delete[](void*, const nothrow_t&) noexcept;,还有两个特殊的禁止重载的版本:void operator delete(void*, void*) noexcept;void operator delete[](void*, void*) noexcept;

(4) 重载operator new和operator delete

如有必要,可以替换全局的operator new 和operator delete例程。这些函数会被程序中的每个new表达式和delete表达式调用,除非在类中有更特别的版本。注:若在重载的全局中,不要对new进行任何调用,否则会产生无限循环。不过正如C++之父Bjarne Stroustrup所说,“…替换全局的operator new和operator delete是需要胆量的”。不建议替换全局的operator new和operator delete。

更有用的技术是重载特定类的operator new和operator delete,仅当分配或释放特定类的对象时,才会调用这些重载的运算符。当重载operator new时,要重载对应形式的operator delete,否则内存会根据指定的方式分配,但是根据内建的语义释放,这两者可能不兼容。建议重所有不同形式的operator new和operator delete,从而避免内存分配的不一致,若不想提供任何实现,可使用 =delete 显式地删除函数,以避免客户使用。

(5) 带有额外参数的operator new和operator delete

除了重载标准形式的operator new之外,还可编写带有额外参数的版本,如void* operator new(size_t size, int extra);void operator delete(void* ptr, int extra) noexcept;。编写带有额外数的重载operator new时,编译器会自动允许编写对应的new表达式。如:new(aInt) 类名();,new的额外参数以函数调用的语法传递(和 new(nothrow) 一样)。这些额外参数可用于向内存分配例程传递各种标志或计数器。定义带有额外参数的operator new时,还应该定义带有额外参数的对应的operator delete。

注:如果类声明了两一样版本的operator delete,只不过一个不接收额外参数,一个接收额外参数,那么不接受额外参数的版本总是会调用。如果要使用带有额外参数的版本,要只编写带额外参数的版本。不能自已调用这个带有额外参数的operator delete,只有在使用了带有额外参数的operator new且对象的构造函数抛出异常时,才会调用这个operator delete。

(三)内存分配

1. 内存分配错误

如果new和 new[] 的默认行为分配内存失败,默认抛出bad_alloc异常,这个类型在头文件中定义,另外,C++提供了new和 new[] 的nothrow版本,即new(nothrow),如果内存失败,将返回nullptr。C++允许指定new_handler回调函数,当内存分配失败时,调用new_handler。(注:若new_handler返回,会再次尝试分配内存,若失败会再次调用new_handler,变成无限循环。)若在new_handler中抛出异常,必须是bad_alloc异常或其子类。调用在头文件中声明的set_new_handler(),可以没置new_handler。 set_new_handler()是C++设置回调函数的三个函数之一,另外两个是set_terminate()和set_unexpected ( ),它们返回之前的handler。注:xxx_handler是函数指针类型的typedef。

2. 垃圾回收

垃圾回收是内存请理的另一个方面。在支持垃圾回收的环境中,如C#、Java程序员几乎不用显式地释放与对象关联的内存,运行库会在某时刻自动清理没有任何引用的对象;在C++中没有内建垃圾回收。当然可以手动编写垃圾回收器,如标记和请扫,但因其实现需付出不对等的努力,且“完美”的垃圾回收机制仍存在不少缺点,故此处不再介绍。在现代C++中,使用智能指针管理内存,在旧代码在对象层次通过new和delete管理内存。

对象池是回收的代名词。使用对象池的理想情况是:随着时间的推移,需要使用大量同类型的对象,而且创建每个对象都会有开销。

(四)智能指针

动态内存分配和指针的使用很容易产生bug。当多次释放动态分配的内存时,可能会导致内存损坏或致命的运行时错误;当忘记释放动态分配的内存时,会导致内存世露。智能指针来自于一个事实:把所有内容都放在堆栈上,可以避免大部分和内存相关的问题。堆栈比堆安全的多,因为当堆栈变量离开作用域时,会自动销毁并清理。

为了避免常见的内存问题,应使用智能指针代替通常的C样式的“裸”指针。智能指针结合了堆栈变量的安全性和堆变量的灵活性,智能指针对象在超出作用域时(如函数执行完),会自动释放内存。C++有3种智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr。它们都在头文件中定义。

1. unique_ptr

unique_ptr意味看所有权,只属于它指向的对象,unique_ptr类似于普通指针,但在unique_ptr超出作用域(函数返回、抛出异常)或被删除时,会自动释放内存或资源。unique_ptr是一个通用的智能指针,它可以指向任意类型的内存,也可在unique_ptr中存储C风格的数组、array或vector容器。作为一个经验法则,总将动态分配的对象保存在堆栈中的unque_ptr实例中。

所以它是一个模板,需要用失括号指定模板参数,在尖括号中必须指定unique_ptr要指向的内存类型。声明如下,unique_ptr<类型> 指针名(普通指针);,其中普通指针常用new 类名(实参)来指定。推荐使用自C++14中支持的:auto 指针名 = make_unique<类型>(实参列表);。用unique_ptr存储动态分配的旧C风格数组(也可用array或vector容器),如下:auto 指针名 = make_unique<类型[]>(数组长度);

unique_ptr默认使用标准的new和delete运算符来分配和释放内存,可以修改这个形为,如下所示:unique_ptr<类型, decltype(释放内存函数)> 指针名(普通指针, 释放内存函数);,其中释放内存函数一般为自定义的operator delete,普通指针是由自定义的分配内存函数所返回的指针,一般为自定义的operator new。

2. shared_ptr

shared_ptr允许数据的分布式“所有权”,可以有多个shared_ptr指向同一块动态分配的内存,每次指定shared_ptr时,都递增一个引用计数,指出数据又多了一个“拥有者”。shared_ptr超出作用域时,就递减引用计数。当引用计数为0时,就表示数据不再有任何拥有者,于是释放指针引用的对象,用来确保某一处内存只被释放一次。不能在shared_ptr中存储C风格的数组。它的两种普通的创建方式和unique_ptr相同,不过其构造函数接收的参数是一个智能指针,而非普通指针。

与unique_ptr一样,shared_ptr在默认情况使用new和delete,可以更改其行为:shared_ptr<类型> 指针名(已有智能指针, 释放内存函数)。可以用函数类转换shared_ptr的类型,如下:const_pointer_cast<>()dynamic_pointer_cast<>()static_pointer_cast<>()

根据C++标准,使用shared_ptr智能指针引用计数来创建两个指向同一对象的shared_ptr时应使用make_shared()和复制构造函数建立副本,如下:shared_ptr<类型> 指针名(同类型的源shared_ptr指针);。如果程序在使用智能指针时进行了复制、赋值或作为参数按值传入函数,那么shared_ptr是完美的选择。

3. weak_ptr

C++还有一个模板类weak_ptr其与shared_ptr有关,它的构造函数接收一个shared_ptr或另一个weak_ptr。其包含关联shared_ptr管理内存的引用,但不拥有这个内存,不能阻止shared_ptr释放内存。weak_ptr离开作用域时不会销毁它指向的内存,它被用来判断所关联的shared_ptr是否被释放。要访问时需将weak_ptr转换为shared_ptr来用,可使用其lock()方法,也可以使它作为shared_ptr构造函数的参数,构建一个新的shared_ptr。若与weak_ptr关联的shared_ptr已经释放,新shared_ptr就是nullptr。

4. 对移动语义的支持

对shared_ptr和unique_ptr都支持移动语义,使它们非常高效,从函数返回shared_ptr或unique_ptr也很高效,C++会在return语句中自动调用std::move(),从而触发临时shared_ptr或unique_ptr的移动语义。注:unique_ptr不支持普通的赋值运算符和复制构造函数,但支持移动赋值运算符和移动构造函数。


五、流

C++流可以正确地解析C风格的转义字符,例如包含\n的的字符串,也可以使用std::endl开始一个新行。’\n’ 和endl的区别是,’\n’ 仅开始一个新行,而endl还会剧新缓存区。使用endl要小心,因为过多的缓存区剧新会降低性能。

(一)cout / cin方法

1. cout

毫无疑向,<<运算符是输出流最有用的部分。而put()write()则是原始的输出方法,这两个方法接受的不是定义了输出行为的对象或变量。put()公有方法接受单个字符,write()公有方法接受一个字符数组。传给这些方法的数据按照原本的形式输出,没有特定的格式化和处理操作。flush()公有方法,向输出流写入数据时,流不一定会将数据立即写入目标,大部分输出流都会进行缓存,也就是积累数据,当满足以下条件之一时,流进行刷新(flush)操作,即将积累的数据写出:

  1. 到达某个标记时,如endl标记;

  2. 流离开作用域被析构时;

  3. 要求从对应的输入流输入数据时(即要求从cin输入时,cout会刷新);

  4. 流缓存满时;

  5. 显式调用流的flush()方法要求流刷新缓存时。

不是所有的输出流都会缓存,如cerr流就不会缓存其输出。

当一个流处于正常可用状态时,称这个流是“好的”。调用流的good()方法可以判断这个流当前是否处于正常状态。通过good()可以方便地获得流的基本验证信息,但是不能提供流不可用的原因。还有一个bad()方法提供了稍多信息,如果 bad() 返回true,则意味着发生了致命错误(相对于非致命错误来说,如遇到文件结尾)。另一个方法fail()在最近一次操作失败时返回true,但没有说明下一次操作是否也会失败。例如,对输出流调用 flush() 方法后,可以调用fail()来确保流仍然可用。还可以要求流在发生故障时抛出异常。然后编写一个catch处理程序来捕获ios_base::failure异常,然后对这个异常调用what()方法,获得错误的描述信息,调用code()方法获得错误代码。不过,是否能获得有用的信息取决于所使用的STL实现。可以使用流的clear()方法重置流的错误状态,控制台输出流的错误检查不如文件输入输出流的错误检查频繁。这里讨论的方法也适用于其他类型的流。

注:有关于流,其不仅包含数据,还句含一个称为当前位置(current position)的数据,当前位置指的是流将要进行下一次读或写操作的位置。

2. cin

与输出流一样,输入流也提供了一些方法,它们可以获得比普通>>运算符更底层的访问。get()方法允许从流中读入原始输入数据,有多个版,最简单的版本返回流中的下一个字符。常用于避免 >> 运算符的自动标志化。

对于大多数场合来说,理解输入流的正确方式是将输入流理解为一个单方向的滑槽。数据丢入滑槽,然后进入变量。unget()方法打破了这个模型,允许将数据塞回滑槽。调用 unget() 导致流回退一个位置,将前一个读入的字符放回流中。调用fail()方法可以查看unget()是否成功。例如,如果当前位置就是流的起始位置,那么unget()会失败。putback()方法和unget()一样,允许向输入流中返回一个守符。区别在于putback()方法将放回流中的字符接收为参数。通过peek()方法可以预览调用get()返回的下一个值,即查看一下滑槽,但是不把值取出来。从输入流中获得一行数据是一个非常常见的需求,所以有一个方法getline()用一行数据填充字符缓存区,数据量最多可为值定参数n。指定大小中包括'\0'字符,因此最多读取n-1个字符或者读到行尾为止。注意字符的行尾不会出现在守符串中(行尾序列和平台相关,例如Windows行尾是\r\n,Unix、Linux行尾是\n,Mac行尾是\r)。有一个版本的get()执行的操作和getline()一样,区别在于get()把换行序列留在输入流中。还有一个用于C++中string的getline()函数,定义在 头文件和std命名空间中。第一个参数为流引用,第二个为string引用,第三个为可选分隔符(默认为换行)。使用这个getline()函数版本的优点是不需要指定缓存区的大小。

输入流提供了一些方法用于检测异常情形。大部分和输入流有关的错误条件都发生在无数据可读时。例如,可能到达了流尾(称为文件尾EOF,即使不是文件流)。查询输入流状态的最常见方法是在条件语句中访问输入流。例如,只要cin保持在“好的”状态,便有循环体执行while (cin)while (cin >> ch)。还可以调用good()bad()fail()方去,就像输出流一样。还有一个eof()方法,如果流到达尾部,就返回true。

也可以输入操认算子,如skipwsnoskipws:告诉输入流在标记化时跳过空白字符,或者读入空白字符作为标记。

(二)字符串流

字符串流。可以通过字符事流将语义用于string。通过这种方式,可以得到一个内存内的流(in memory stream),来表示字符文本数据。字符串流也非常适用于解析文本,因为流内建了标记化的功能。ostringstream类继承自ostream,用于将数据从string写入流;istringstream类继承自istream,用于将流中数据提取出到变量;还有一个stringstream,它们都定义在头文件中。相对于标准C++的string,字符串流的主要优点是除了数据之外,这个对象还知道从哪里进行下一次读或写操作,这个位置也为当前位置。根据字符串流的特定实现,还可能会有性能优势:在用字符串流进行数据转换分割时,注意调用clear()方法。

(三)文件流

文件流。ifstreamofstream的析构函数会自动关闭底层文件,因此不需要显式调用close()。文件指针位置的类型为ios_base::streampos,偏移量的类型为ios_base::streamoff,这两个类型都以字节计数,整数隐式地转换为这两种类型。注:所有流都有seek()tell()方法。

(四)将流连接在一起

任何输入和输出流之间都可以建立连接,从而实现访问时刷新的行为,这种行为可用于所有流,但是对于可能互相依赖的文件流来说特别有用,通过tie()方法完成流的连接。要将输出流连接至输入流,输入流调用 tie() 方法,并传入输出流的地址(取&),要解除连接,传入nullptr。flush()方法在ostream基类上定义,因此可将一个输出流连接至另一个输出流,这意味着每次写入一个文件时,发送给另一个文件的缓存数据也会写入。可以通过这种机制保持两个相关文件的同步。其一个流连接的实例是cin和cout之间的连接。

(五)双向流

双向I/O流可以同时以输入流和输出流的方式操作。双向流是iostream的子类,而iostream是istream和ostream的子类,因此这是一个多重继承的实例。双向流支持 << 和 >> ,还支持输入流和输出流的方法。fstream类提供了双向文件流,特别适用于需要替换文件中数据的应用程序,因为可以通过读取文件找到正确的位置,然后立即切换为写入文件,当然只有在数据大小固定时这种方法才能正常工作。输出数据会改写文件中的其他数据。为了保持文件的格式,并避免写入下一条记录,数据必须相同大小。还可以通过stringstream类双向访问字符串流。双句流用不同的指针保存读位置和写位置,在读取和写入之间切换时,需要定位到正确的位置。stringstream并不会取出数据,只会移动指针。clear()并不清除流。


六、模板相关

编写类模板时,以前的类台(classname)现在实际上是模板名称。讨论实际的classname类或类型时,实际上讨论的是classname类模板对某个类型实例化的结果。在定义模板时没有指定这个模板要实例化的类型,因此必须使用一个占位的模板参数T,这个T表示未来可能使用的任何类型。(名称T没有什么特别之处,可以使用任何名称,这里只是一个历史约定,只使用一个类型时,这个类型称为T)。当需要表示classname对象的类型作为方法的传入参数或返回值时,使用classname。在类定义中,编译器会根据需要将classname解释为classname,然而最好养成显示指定classname的使用习惯,因为这种语法要用于在类的外面表示模板产生的类型。只有构造函数和析构函数应该使用classname而不是classname

template说明符必须在classname模板的每一个方法定义的前面。且域解析运算之前应用classname而不是classname,必须在所有的方法和静态数据成员定义中将classname指定为类名。注:模板要求将方法的实现也放在头文件中,因为编译器在创建模板的实例之前,需要知道完整的定义,包括方法的定义。

为了避免每次都编写完整的classname<类型实参>的类型名称,可以typedef class<类型实参> 别名;来指定一个简单名称。

classname<>模板能保存的数据不只是基本数据类型,也可保存一个类的对象类型、指针类型、基至是另一个模板类型。注:对于多重使用模板如std::vector>所产生的 >> 符号,在C++11之前和提取运算符 >> 相左,因而在类模板的双尖指号中间放一个空格。自C++11后来,这个语法被扩改了,不需要再加空格。

(一)编译器处理模板的原理

编译器遇到模板方法定义时,会进行语法检查,但是并不编译模板,因为它不知道要使用什么类型。当编译器遇到不同的实例类型时,就为每个元素类型编写一个不同的类,模板只是自动完成一个令人厌烦的过程。编译器总是为泛型类的所有虚方法生成代码。但对于非虚方法,只会为实际调用的某个类型的方法生成代码,不会为其他方法生成代码。编写与类型无关的代码时,肯定对这些类型有一些假设,如假设某个元素类型T会有赋值运算符、默认构造函数,允许创建矢量等。如果在程序中试图用一个模板所有方法都不支持的类型实例化模板时,那么这段代码无法编译。但若有某类型不支持模板的某些方法,也可调用只支持的某些方法(选择性实例化),这样代码就能正常工作;然而只要调用了不支持的方法,就会编译错误。

(二)嵌套依赖类型

在模板中typenameclass关键字作用一样,不过typename还有一个作用,即当访问基于一个或多个模板参数的类型时,必须显示指定typename,表示嵌套依赖类型(nested depended name)。一个例子如:

class Int {
public:
	typedef int Type;
};

template 
class Array {
public:
	typedef typename T::Type value_type;	// 嵌套依赖类型
};

int main(int argc, char* argv[]) {
	cout << typeid(Array).name() << "\n";	// 输出:class Array
	cout << typeid(Array::value_type).name();	// 输出:int
	return 0;
}

这个时候typename的作用就是告诉C++编译器,typename后面的字符串为一个类型名称,而不是成员函数或者成员变量,这个时候如果前面没有typename,编译器没有任何办法知道 T::Type 是一个类型还是一个成员名称(静态数据成员或者静态函数),所以编译不能够通过。

(三)模板与多文件

  1. 方法定义与类定义真直接放在同一个头文件中;
  2. 将方法实现放在另一个头文件中,然后在类模板定义的未尾用 #include 包含这个头文件;
  3. 将方法定义放在一个源文件中,并在模板头文件的末尾用 #include 包含。(此时,该源文件不要添加到项目文件中,因为这个文件本不应在项目中,而且无法单独编译,这个文件只能通过 #include 包含在一个头文件中)实际上,可以任意命名包含方法实现的文件,如xxx.inl等;
  4. 将头文件和源文件取相同文件名放在同一项目文件中,像一般文件一样。此时为了使这个方法能够运行,需要给允许客户使用的类型显式实例化模板,即在 .cpp 文件的未尾定义:template class classname<指定类型>;,(通常用此方法限制模板类的实例化的类型。)

(四)模板参数说明

模板参数可以有多个,也可以有非类型的模板参数,也可有默认值。(在实现了默认值的模板中,<>可以不指定实际类型)。

1. 非类型的模板参数

非类型的模板参数是"普通"参数,只能为整数类型(char、int、long、etc.)、枚类类型、指针和引用。在模板列表中指定非类型的参数而不是在构造逐数中指定的主要好处是:在代码编译代码之前就知道这些参数的值了。如:可以使用这些参数声明数组长度template,而不需要矢量,数据成员在构造函数之前创建。注意之前所有指定classname的地方,都必须指定为classname

但也有很多限制,如不能通过非常量的数值指定非类型实参,可以使用const或constexpr。此外,非类型模板参数是实例化的对象的类型的一部分,那么意味着classnameclassname是两种不同类型,不能将一种类型的对象赋给另一种类型的对象,而且一种类型的变量不能传递给接受另一种类型的变量的函数或方法。

2. 方法模板

C++允许模板化类中的单个方法。这些方法可以在模板类中,也可以在非模板化的类中。在类模板中,方法模板对赋值运符和复制构造函数非常有用。因是两种类型,不能复制和赋值,这是由于它们的模板对赋值和复制函数的解释都是模板类的typename T,是一种且仅有一种类型)。注:不能用方法模板编写虚方法和析构函数。

可在模板类中模板化新的复制构函数:template classname(const classname& src);, 在类外实现方法时,必须用双重模板化:template typename template classname::classname(const className& src) { }。对于赋值运算符,它接受const classname作为参数,但返回classname&的类型。如下:classname& classname::operator=(const classname& src) { }

说明:在模板的赋值运算符中不需要检查自身赋值,因为相同类型的赋值仍然是通过老的、非模板化的,编译器生成的operator=版本进行;类型不同的肯定不是同一对象,因此在这里不可能进行自赋值,故不需要检查自身赋值。

通过编写带有非类型参数的方法模板,便可以解决上述中之间的复制和赋值问题(复制构造函数,赋值运算符和copyFrom()辅助方法等)。(注:使用T()为T类型的默认值。)

3. 模板特例化

模板可能对于某些类型没有意义,如const char*,因为模板使用浅复制而指针需要深复制等各种原因。而模板特例化(template specialization)便可以为特定类型实现特例化的实现代码。其形式如下:template<> class 类名<特定类型> { // 类体 }。特例化和派生类化不同,必须重新编写类的实现,但不要求提供和模板类相同的名称的方法或行为。实现时不必在每个方法或静态成员定义之前重复template<>,而是直接如下:返回类型 类名<特定类型>::方法(形参列表) { }

4. 为独立函数编写模板

还可以为独立函数编写模板,在使用时,可以显式通过尖括号指定类型,也可以忽略尖括号,让编译器根据参数自动推断类型。函数模板还可以接受非类型的参数。与类模板一样,可以模板特例化,可以实现重载(若两者都为同一类型提供了实现,则编译器优先选择非模板化的函数;可以显式指定模板实例化,强制编译器使用模板化的版本)。注:函数模板定义(不仅是原型)必须能用于使用它们的所有源文件。因此,如果多个源文件使用函数模板,就应把其函数定义放在头文件中。注意:类模板的friend模板函数与普通的friend函数类似。C++提供了可变模板,根据参数类型而变化。

5. 从模板类中派生

如果派生类从模板本身继承,那么这个派生类也必须是一个模板。还可以派生自类模板的某个特定的实例,此时子类不再是模板,template class 子类 : public 父类 { };。C++模板继承的名称查找规则要求在子类中使用this指针调用父类方法。如:

template 
class Type {
public:
	string type() {
		return typeid(T).name();
	}
};

template 
class PtrType : public Type {
public:
	string ptrType() {
		return this->type();	// return type(); 编译不通过
	}
};

int main(int argc, char* argv[]) {
	cout << PtrType().ptrType();	// 输出:int *
	return 0;
}

若继承自模板类的某个实例,则在子类中不需使用this指针便可访问父类方法(此时父类方法全是对某个实际类型实例化的方法),继承时也不需模板语句,如下:class 子类 : public 父类<实例类型> { };,实现方法时也不需模板。

如果要用typedef给模板化的类赋予另一个名称要指定每个实例化类型参数,否则就需要使用using别名。

(五)模板的高级特性

1. 三种模板参数

模板有三种参数,可以指定默认值(声明时给出,定义时不能重复指定)。

模板的类型参数,用typenameclass声明表示一个类型参数。

模板的模板参数(应完整规范作为参数的模板的模板参数),一般形式如:template class 作为外层模板的模板参数的内层模板名, xxx> class 外层模板类名。注:在外层模板类名的类的实现中,应指定内层模板名实例化后的类型,如使用“内层模板名",其实现:向其他模板传入模板作为模板参数。

模板的非类型参数,不能为对象,甚至不能为double和float,非类型参数限定为整型、枚举、指针和引用,非类型的模板参数传入的引用必须是一个常量表达式,必须引用具有静态存储时间和外部或内部链接范围的完整对象。

2. 模板的部分特例化

模板特例化除模板参数全部特例化之外,还可以进行模板类的部分特例化。即特例化部分模板参数而不处理其他参数,此时 template<> 的模板参数列表中不需要再指出已特例化的模板参数,而在特例化类中要使用类的类型时,在 类名<> 的模板参数列表中要写出已特例化的类型参数。

另一种形式的部分特例化:可为一个可能的类型子集编写特例化的实现,而不需要为每个类型特例化。如:可以为所有指针类型编写特例化模板类的实现,以实现复制构造函数和赋值运算符可对指针指向的对象执行深层复制。如下形式:template class 原模板名 { };

注:C++不允许函数模板的部分特例化,然而可用另一个模板函数重载来模拟函数模板部分特例化。在所有重载的版本、函数模板特例化和特定的函数模板实例化中,编译器总是选择“最具体的”函数版本,(即根据推导规则选择最合适的版本调用),如果非模板化的版本与函数模板实例化的版本等价,编译器更偏向非模板化的版本。

3. 模板递归

模板递归,可编写真正的N维模板。其形式如:template class 类名 { };,注意在实现时,凡是得到 T 或 T& 等类型的返回值或其他时,应改用类名,因为想要递归的话,模板类的元素类型不应该是模板类型参数指定的元素类型 T,而是上一层递归的维度中的类型即 类名。使用递归时,需要一个基本情形(base case),即维度为1的模板,此时其元素类型就是模板类型参数指定的类型了,如下:template class 类名 { };,本质上是特例化。

4. 类型推导

类型推导(Type inference)。结合模板使用auto和decltype(可不指定函数模板参数),如下用法:template auto 函数名(形参) -> decltype(xxx) { },C++14后有了函数返回类型推断功能,可简化为:template auto 函数名(形参) { }

5. 可变参数模板

(2) 概述

可变参数模板(variadic template)可以接受可变数目的模板参数,例如:template,可以接收任意数目的模板参数,其中Types代表一个参数包(parameter pack),可以用任何数量(其至是零个)模板参数来实例化。为了编写避免用零个模板参数实例化可变参数模板,可以如:template 。注:可变参数包应放在列表结尾。

不能直接遍历传给可变参数模板的不同参数,唯一的方法是借助模板递归的帮助。对于递归模板可变参数的函数模板,要用一种方法来停止递归,即实现只有一个模板参数的函数版本。这种可变参数列表的方法是完全类型安全的,C++中的自动类型转换也会进行,不过若是在递归中对其类型使用的方法没有提供支持,编译器会产生一个错误。

前面的实现存在一个小问题,由于这是一个递归的实现,所以每次调用时都会复制参数,根据参数的类型,这种做法的代价可能会很高。可以通过引用传递,但遗憾的是这样就无法通过字面量调用函数了,因为不允许使用字面的引用,除非使用const引用。为了在使用非const引用的同时也能使用字面量值,可以使用右值rvalue引用,并编写其rvalue引用版本的函数。还可以使用过std::forward<>()完美转发所有参数。因std::move()只是将左值lvaule转换成rvalue,以适配接收右值引用的函数,然而在函数内部,其形参的类型仍是一个lvaule,再度传参时还是配用的左值版本,除非再次使用std::move()。而std::forward()则会保持一个值的rvalue或lvaule属性,在函数传递过程中不会再改变。

参数包几乎可用在任何地方。如定义可变数目的混入类,示例如:template class 类名 : public Type... { public: 类名(Types... args) : Types(args)... { } xxx };

(2) 使用分析

关于三点运算符...的说明。使用在模板参数列表的typename...表示可变类型参数,使用在函数的参数列表的类型...表示可变参数。在这两种用法中,它都表示一个参数包,这一包参数可以是一包类型(模板参数列表中),也可以是一包对象(函数参数列表的实参),参数包可以接收可变数目的参数。

如果用在函数调用的实参列表中,如:函数名(args...);,(这个函数应为可变模板参数的函数),此时表示参数包装扩展,...运算符会解包/展开参数包args,得到各个参数。它基本上提取出运算符左边的内容,为包中的每个模板参数重复该内容,并用逗号隔开。sizeof...(args)求得参数包args中参数的个数。

递归函数展开参数包:

template // 终止函数,需要在递归函数之前声明或定义,否则会编译出错
void print(T t) { 	// 此处终止的条件为:参数包中只有一个参数
    cout << typeid(t).name() << " : " << t << "\n--The End--"; 
}
template
void print(T t, Types... args) {
	cout << typeid(t).name() << " : " << t << " , rest: " << sizeof...(args) << "\n";
	print(args...);
}
// 以下为一个测试调用,及其输出
print("Hello", 20, 'C', 3.14f); /*
char const * : Hello , rest: 3
int : 20 , rest: 2
char : C , rest: 1
float : 3.14
--The End--
*/

逗号表达式和初始列表展开参数包:

template void deal(T t) { cout << t << " "; }
template
void expend(Types... args) {
	int arr[] = { (deal(args), 0)... };	
	// 此处arr数组仅为了触发初始列表,其解包为 { (deal(arg1), 0), (deal(arg2), 0), ..., etc. },arr最后指全为0
    // arr数组也可直显示使用初始列表:initializer_list { (deal(args), 0)... };
}
// 一个测试调用
expend("Hello", 20, 'C', 3.14f);	// 输出:Hello 20 C 3.14

// 可支持lambda表达式
template
void expend(const Fun_type& fun, Types&&... args) {
	initializer_list { (fun(forward(args)), 0)... };
}
// 一个测试调用
expend([](int x) { cout << x << " "; }, 1, 2, 3, 4, 5);
// 输出:1 2 3 4 5

除了上述方式,还有可变模板参数类、特例化、递归展开参数包、继承方式展开参数包等。可变参数模板可用来消除重复代码,实现泛化的delegate委托。

6. 模板元编程

模板元编程,目标是在编译时执行一些计算,而不是在运行时执行,其主要使用了模板递归。元编程基本上是C++之上的一个小型的编程语言。下面通过一些例子来说明。

(1) 编译时阶乘

template
struct Factorial { static const unsigned long long val = f * Factorial::val; };
template<>
struct Factorial<0> { static const unsigned long long val = 1; };
// 一个测试调用
cout << Factorial<6>::val;	// 输出:720;

(2) 循环展开

template
struct Loop {
	template
	static inline void execute(Func_type func) { Loop::execute(func); func(N); }
};
template<>
struct Loop<0> {
	template
	static inline void execute(Func_type /*func*/) { }
};
// 一个测试调用
Loop<3>::execute([](int x) { cout << 2 * x << " "; });	// 输出:2 4 6

(4) 打印元组

template
struct TuplePrintHelper {
	TuplePrintHelper(const Tuple_type& tup) {
		TuplePrintHelper tp(tup);	// 声明对象,同时在此处(构造函数中)递归模板
		cout << get(tup) << " ";	// 打印一个元素
	}
};
template
struct TuplePrintHelper<0, Tuple_type> {
	TuplePrintHelper(const Tuple_type& /*tup*/) {}
};

template
void tuplePrint(const T& tup) {
	TuplePrintHelper::value, T> tph(tup);	// 利用tph的构造函数,递归模板,打印元组
}
// 一个测试调用
auto t = make_tuple(167, "Hello", false, 3.14f);
tuplePrint(t);	// 输出:167 Hello 0 3.14

7. 类型 trait

类型trait可在编译时根据类型做出决策,所有与类型trait相关的功能都定义在头文件中。其有:原始类型类别、复合类型类别、类型属性、类型的关系、const_volatile修改、引用修改、符号性修改、其他转换。

如类型分类中标准对integral_constant类的定义如下所示:

	// STRUCT TEMPLATE integral_constant
template
	struct integral_constant
	{	// convenient template for integral constant types
	static constexpr _Ty value = _Val;

	using value_type = _Ty;
	using type = integral_constant;

	constexpr operator value_type() const noexcept
		{	// return stored value
		return (value);
		}

	_NODISCARD constexpr value_type operator()() const noexcept
		{	// return stored value
		return (value);
		}
	};

	// ALIAS TEMPLATE bool_constant
template
	using bool_constant = integral_constant;

using true_type = bool_constant;
using false_type = bool_constant;

也有三种类型的关系:is_sameis_base_ofis_convertible

使用 enable_if 需要了解“替换失败不是错误(Substitution Failure Is Not An Error,SFINAE)”的特性。enable_if<>接收两个模板类型参数(布尔值和默认void的类型)。如果布尔值是true,enable_if 类就有一个可以使用 ::type 接收的嵌套类型(即第二个模板参数给定的)。如果bool值为false,就没有嵌套类型可用,因此调用 ::type 将会失败,这就是SFINAE发挥作用的地方。建议审慎地使用 enable_if ,仅在需要解析重载歧义时使用它,即无法使用其他技术(例如特例化、部分特例化等解析重载歧义)时使用。


七、异常处理

当某段代码执出异常时,程序控制立刻停止逐步执行,并转向异常处理程序(exception handler),异常处理程序可以在任何地方,可以位于同一函数中的下一行,也可以在堆栈中相隔好几个函数调用。

如果存在未捕获的异常,程序的行为也可能发生变化。当程序遇到未捕获的异常时,会调用内建的terminate()函数,这个函数调用头文件中的abort()来终止程序。可调用set_terminate()函数设置自己的terminate_hander,这个函数采用指向回调函数的指针作参数(既没有参数,也没有返回值的函数)。terminate()、set_terminate()、terminate_handler都在头文件中声明。

可抛出异常可以是任何类型,可以是基本类型,也可以是类的对象。标准异常体系中exception是基类,除它之外,它的子类的构造函数都要求传一个 const char* 字符串(用来描述异常)。所有异常类都支持what()方法,用来返回描述异常的 const char* 字符串。exception在头文件中,它的两个子类invalid_argumentruntime_error在头文件中。

(一)自定义异常类

编写自己的异常类,可以在程序中为特定的错误创建更有意义的类名称,而不是使用较泛概括的异常类,可以在异常类中加入自己的信息。建议自己编写的异常类从标准的exception直接或间接地继承,便于用多态性处理异常。注:可能因为原抛出的对象在堆栈中的位置较高,若捕获异常的语句在堆栈中的位置较低。抛出对象会超出作用域,对象被销毁,因此复制是必须的,这意味着如果动态分配了内存,必须编写析构函数、复制构造函数和赋值运算符。若使用按引用捕获异常对象可以避免不必要的复制。

(二)抛出列表(已废弃)

抛出列表(C++11之后已废弃),格式如:返回类型 函数名(形参列表) throw (异常列表) { }。没有抛出引表的函数可以抛出任何类型的异常,带有noexcept的函数不能执出异常。遗憾的是,C++抛出列表不会阻止函数抛出未列出的异常类型,但是会阻止异常离开这个函数,因此会异致运行时错误。标记为noexcept的函数抛出异常时,C++会调terminate()终止程序。

当函数抛出一个没有在抛出列表中列出的异常时,C++调用了一个特殊的函数unexpected(),内建的unexpected()实现会调用terminate()。然而,正如可以设置自己的terminate_handler一样,也可以设置自己的unexpected_handler。该函数的自定义版本必须抛出一个异常或者终止程序,不应该只是退出函数。如果抛出一个新的异常,那么这个异常将替换意料之外的异常,就像新的异常是最初被抛出的异常一样。如果用替换的异常也没有在抛出列表中列出,程序会做出下面选择,如果函数列表给出了bad_exception,则将抛出bad_exception,否则程序会终止。unexpected()的自定义实现通常用于将意外的异常转换为预期的异常。(unexpected()、set_unexpected()和bad_exception都在头文件声明的。)注:应接受set_terminate()set_unexpected()返回的之前的terminate_handlerunexpected_handler,可以用来在必要时恢复设置terminate()和unexpected()方法。

在重写的方法中修改抛出刻表时,列表的范围必须在原方法的列表范围之内,因为调用方法基类版本的任何代码必须能够用子类的版本,故抛出列表的范围不能扩大。

(三)捕获异常

建议按const引用捕获异常,这可以避免按值捕获异常时可能出现的对象截断,catch可捕获任何类型异常,通常:catch (const 异常类型& 异常名)。使用catch (...)可以捕获所有类型的异常。

当某代码协出一个异常时,会在堆栈中寻找catch处理程序。当发现一个catch时,堆栈会释放所有中间堆栈帧,直接跳到定义catch处理程序的堆栈层。堆栈释放(stack unwinding)意味着调用所有具有局部作用域的名称的析构函数,并忽略在当前执行点之前的每个函数中所有的代码。然而当释放堆栈时,并不释放指针变量,也不会执行其他清理,因而可能造成内存泄漏。可使用智能指针,或在高堆栈区捕获异常,先进行指针释放,后重新抛出异常。

(四)嵌套异常

嵌套异常,当处理第一个异常时,可能会触发第二种异常情况,从而要求抛为出第二个异常。遗憾的是,当抛出的异常为第二个时,正在处理的第一个异常的所有信息都会丢失。C++11用嵌套异常(nested exception)的概念提供了解决这一问题的方案,嵌套异常允许将捕获的异常嵌套到新的异常环境。使用std::throw_with_nested(异常对象);来抛出嵌套了异常的异常。第二个异常的catch处理程序可以使用dynamic_cast(&异常);返回一个所嵌套的异常(nested_exception类型的对象指针),使用嵌套异常的rethrow_nested();方法来再次抛出被嵌套的异常(即第一次异常)。若无嵌套异常,上述类型转换所返回的是空指针。可以在另一个try-catch块中捕获嵌套异常。上述方法中想要检测嵌套异常,就不得不常常执行dynamic_cast,因此标准库提供了一个名为std::rethrow_if_nested()的小型包,应放在try语句中,参数为可能嵌套了异常的异常。

class MyE : public exception {
public:  MyE(const char* what) : exception(what) {} };

void foo() {
	try { throw MyE("First Exception.\n"); } catch (const MyE& m) {
		// Handling the exception m, but throws a new exception.
		throw_with_nested(MyE("Second Exception.\n"));
	}
}

void bar1() {
	try { foo(); } catch (const MyE& m) {
		cout << m.what();	// Second Exception.
		const nested_exception* ne = dynamic_cast(&m);
		if (ne != nullptr) {
			try { ne->rethrow_nested(); } catch (const MyE& nm) {
				cout << nm.what();	// First Exception.
			}
		}
	}
}

void bar2() {
	try { foo(); } catch (const MyE& m) {
		cout << m.what();	// Second Exception.
		try {  rethrow_if_nested(m);  } catch (const MyE& nm) {
			cout << nm.what();	// First Exception.
		}
	}
}

int main(int argc, char* argv) {
	bar1();		// 都输出:Second Exception.
    bar2();		//		  First Exception.
	return 0;
}

八、C++标准库

(一)C++标准库概述(Standard Library)

头文件中提供了内建的string类,在中提供了正则表达式。I/O流。智能指针。异常。数学工具。复数类。编译时有理数运算库。对高性能数值应用做优化的。C++还提供了一系列获取数值极限的标准方式,例如当前平台允许整数的最大值,在C语言中可以用#define的INT_MAX,C++又是供了在中的numeric_limits<>类模板,如:numeric_limits::max()。在中简化了与时间相关的操作。在头文件中完善随机数库。在头文件提供了初始化列表。。在预定义函数对象。多线程编码。类型特质提供编译期间的类型信息。STL标准模板库。

(二)STL概述(Standard Template Library)

STL提供了常用数据结构的实现,例如链表和队列。数据结构的实现使用了一个称为容器的概念,容器中保存的信息称为元素;保存信息的方式能够正确地实现数据结构。STL中的容器都是模板,因此可以通过这些容器保存任意类型的数据,但应注意C++STL容器都是同构的,即每个容器的实例只能保存一种类型的对象。C++标准定文了每个容器和算法的接口(interface),而没有定义实现。因此,不同的供应商可以自由提供不同的实现,然而作为接口的一部分,标准还定义了性能需求,实现必须满足性能需求。

STL非常强大,但并不完美,如多线程不安全,无泛型树、图结构等,STL是可扩展的,可以编写适用于现有算法和容器的内容。

1. 顺序容器(sequential container)

顺序容器中存放的是元素的序列。定义了vector,类试动态数组,保存在连续内存空间中。尾部插入和删除元素为分推常量时间(amortized constant time),大部分为 O(1),而有时需要增长大小以容纳新元素时为 O(n)。其他部分插入较慢,需要挨个移动为O(n)。随机访问O(1)。中的list是一个双向链表,元素不一定保存在连续内存中。list提供了较慢的元素查找和访问O(n),而找到相应元素后的插入和删除很快O(1)。中定义的forward_list是一个单向列表,只支持单向迭代,内存需比list小,其他类似。中的deque时双头队列(double-ended queue)的简称,两端插入和删除为分推常量时向,中间插入册除为O(n),访问为O(1)。中定义的array是C风格数组的包装,固定长度(知道自身长度,允许在堆栈上分配空间),访问为O(1)。

2. 容器适配器(container adapter)

从技术角度看,容器适配器只是构建在某种标准顺序容器上的简单接口。中定义了queue,提供了标准的先入先出(First In First Out,FIFO)语义。一端插入元素分推常量时间,另一端删除元素为常量时间O(1)。还定了priority_queue,提供了queue功能,但每个元素都有一个优先级,元素按优先顺序从列队中删除,在优先级相同的情况下,删除元素的顺序没有定义。priority_queue的插入和删除为分摊对数时间,一般比简单的queue要慢,因为只有对元素重排序,才能支持优先级顺序。中定义了stack,提供了标准的先入后出(First In Last Out,FILO)语义或也称后入先出(Last In First Out,LIFO)语义。

3. 有序关联容器(ordered associative container)

这些容器会对元素进行排序O(logN),因此称为排序或有序关联容器。中定义了set,每个元素唯一,和数学中集合的区别为,STL中,set元素有顺序。它的插入、删除和查找操作为对数时间O(logN)。如果需要保持顺序,还要求播入、删除和查找操作性能接近,应选用set而非vector或list,若要有储重复元素,使用set头文件中定义的multiset。头文件中定义了map模板,保存键值对,要求键值唯一。要用重复的键,应使用map中的multimap。set和map是关联容器,因为它们关联了键和值(在set中,键本身就是值)。

4. 无序关联容器(unordered associative container)

无序关联容器在行为上和对应的有序关联容器类似,只不过不会对元素排序。在中定义的unordered_setunordered_multisetunordered_mapunordered_multimap为无序关联容器,也称哈希表(hashtable)。遗憾的是在C++11之前,哈希表不属于C++标准库的一部分,因此导致很多第三方库实现的哈希表用了hash作前缀,因为而为了避免命名冲突,C++标准委员会决定使用unordered作前缀。这些无序关联容器的插入、删除和查找工作能够以平均常量时间完成O(1),最坏是线性时间0(n)。在无序中查找快得多,在容器数量特别大的情况下尤其如此。

5. 其他容器

C/C++程序员常常将一组标志位保存在一个int或long中,每位对应一个标志,通过按位运算符操作。C++标准库提供了bitset类,抽象了这些位字段的操作,在头文件中定义了bitset容器(不是常规意义的容器,没有实现某种特定的可以插入或删除的数据结构,有固定大小,不支持迭代器),bitset不局限原始数据类型大小。bitset实现N个位所需的足够存储空间,访问时为P(1)。

注意,vector应是默认使用的容器。实际上,vector中的插入和删除常常快于list或forward_list。这是因为现代CPU上内存和缓存的工作方式,而list或forward_list需要先移动到要插入或删除元素的位置上。list或forward_list的内存可能是碎片化的,所以迭代慢于vector。

6. 泛型算法

除容器外,STL提供了很多泛型算法的实现,也是用模板实现的,因此可以用于大部分不同类型的容器。为了在STL中支持泛型编程,正交性的指导原则使算法和容器分离开,不过有些容器以类方法的形式提供了某些算法,因为泛型算法在这些中特定类型的容器上表现不出色或特定算法更高效。算法大部分都定义在中,部分在中,其中数值处理算法在头文件中(容器序列通过迭代器向算法呈现)。注意STL在设计时考虑的一般性,这些一般性情况可能永远也用不到,但是一旦需要,会非常重要。那些比较模糊的参数就是为了满足可能发生的一般性情况。

这些算法大致可分为:非修改顺序算法、搜索算法、比较算法、工具算法、修改序列算法、操作算法、交换算法、分区算法、排序算法、二又树搜索算法、集合算法、堆算法、最大最小算法、置换真法、数值处理算法。

7. 迭代器

泛型算法并不是直接对容器操作,而使用一种称为迭代器(iterator)的中介。STL中的每个容器都提供一个迭代器,通过迭代器可以顺序遍历容器中的元素。不同容器中的不同迭代器都遵循标准接口,因此算法可以通过迭代器完成计算工作,而不需要关心底层容器的实现。容器头文件中定义了一些辅助函数,返回容器的特定迭代器:begin()end()返回非const迭代器,在C++14后又有cbegin()cend()返回const迭代器,rbegin()rend()返回非const反向迭代器,crbegin()crend()返回const反向迭代器。

迭代器实际上是增强版的智能指针,这种指针知道如何遍历特定容器的元素。所有迭代器都必须可以通过复制来构建、赋值,且可以析构,迭代器的左值必须是可以交换的。C++标准定义了五大类迭代器,即输入迭代器,输出迭代器,前向迭代器,双向迭代器,随机访问迭代器。迭代器的实现类似于智能指针类,因为它们都重载了特定的运算符。不同迭代器有不同重载运算符,如:++、–、->、复制构造函数、=、==、!=、*、>、<、>=、<=、[] 等。只有顺序容器、关联容器、无序关联容器提供迭代器。容器适配器和bitset类都不支持迭代器。容器还提供了begin()方法(返回第一个元素),end()方法(返回最后一个元素的后一个元素),提供了半开区间,以支持空区间,即不包含任何元素的空容器。标准库还支持全局非成员函数 std::begin() 和 std::end() ,建议使用这些非成员函数,而不是其成员版本。可用std::distance()计算两个迭代器之差。

STL中每个支持迭代器的容器类都为其迭代器类型提供了公共typedef,名为iteratorconst_iterator,允许反向迭代元素的容器还提供了reverse_iteratorconst_reverse_iterator。如,整数失量的const迭代器类型是:std::vector::const_iterator

8. 语义

STL容器对元素使用值语义(value semantic)。也就是说,在输入元素时保存元素的一份副本,通过赋值运算符给元素赋值,通过析构函数销毁元素。故应保证类型是可以复制的。STL容器经常会调用元素的复制构函数和赋值运算符,因此要保证这些操作的高效性,实现元素的移动语义也可以提高效率。请求容器中的元素时,会返回所存副本的引用。

STL容器的一个模板类型参数是所谓的分配器(allocator)。该容器可使用该分配器为元素分配内存或释放内存。有些容器(如map)也接受一个比较器(comparator)作为一个模板类型参数,比较器用作顺序元素。所有这些板参数都有默认值。

(三)lambda表达式

1. 基本语法

lambda表达式可以编写内嵌的匿名函数,而不必编写独立函数或函数对象。语法:[捕捉变量](形参列表) ->返回类型 { // 函数体 };

lambda表达式的方括号部分称为lambda捕捉块(capture block),指定如何从所在的作用城中捕捉变量,可在表达式体中使用这个捕捉变量,指定空白[]表示不捕捉。不带前缀表示按值捕捉,带&前缀表示按引用捉提。特别地,[=]表示按值捕捉所有变量,[&]表示按引用捕捉所有变量,[this]表示捕捉周围的对象,即使没有使用this->,也可以在lambda表达式体中访向这个对象。[&,x]表示所有变量通过引用捕捉,除x按值捕捉,[=,&x]类似表示所有变量按值捕捉,除x按引用捕捉。不建议使用[=][&]

lambda可像普通函数一样有形参列表,若无形参,可指定空括号或直接省略(若指定了mutable,则不能省略括号)。通过拖尾方式指定返回类型,也可以省略返回类型而让编译器推断。可使用auto 变量名 = λ表达式;,然后根据变量名(实参表);像普通函数一样使用lambda表达式。

2. 机制

编译器把lambda表达式转换为某种仿函数,捕捉的变量变成这个仿函数的数据成员,按值捕捉的变量复制到仿函数的数据成员中,这些数据成员与捕提变量具有相同的const性质。仿函数总是实现了operator()函数调用运算符。而对于lambda表达式,函数调用运算符默认标况为const,这意味着即使lambda表达式按值捕捉了非const变量,lambda表达去也不能修改其副本(按引用捕捉的非const变量可修改)。把lambda表达式指定为mutable,就可以把函数调用运算符标记为非const,从而修改按值捕捉的非const变量的副本。如:[]() mutable -> type { }

3. 泛型lambda表达式

从C++14开始,把形参类型指定为auto,参数使用自动推断类型功能(类型推断规则与模板参数推断规则相同)。C++14支持lambda捕捉表达式,允许用任何类型的表达式初始化捕捉变量。(这可以用于在lambda表达式中引入根本不在其内部的作用域中捕提的变量,也允许使用与内部作用域中相同的名称),[捕提变量 = 初始化表达式]。注:用捕捉初始化器来初始化按值捕捉的变量时,要使用复制语义,即变量是通过复制构建的(这表示省略了const限定符,默认为const变量)。而对于不能复制只能移动的对象来说(如unique_ptr),要使用std::move()初始化,使用lambda表达式通过移动来捕捉它。

4. 将lambda表达式作为返回值

定义在头文件中的std::function可作为返回类型,它是一个多态的函数对象包装,类似于函数指针。它可以绑定至任何能调用的对象(仿函数、成员函数指针、函数指针和lambda表达式),只要参数和返回类型符合包装的类型即可。如下:function<返回类型(形参类型列表)> 变量名;

函数返回的lambda表达式匹配的类型,即可用function做返回类型,也可用auto,将函数调用赋给相匹配的变量名。通过auto关键字可以简化调用。注:lambda表达式在程序后面执行时,引用捕捉不再有效。可将lambda表达或作返回值的函数赋给function<>类型的变量,用该变量作参数实现回调函数。建议尽可能地使用lambda表达式执行回调操作,而不是函数对象,因为lambda表达式更方便使用和理解。

(四)运算符函数对象

二元算术函数对象的好处为在于可以将它们以回调的形式传递给算法,而使用算术运算符时却不能直接这样做。类模板有plusminusmultipliesdividesmodulus。这些类对操作数的类型上是模板化的,是实际运算符的包装,它们接受一个或两个模板类型的参数,执行操作并返回结果。注:二元算术函数对象只不过是算术运算符的简单包装。如果在算法中使用函数对象作为回调,务必保证容器中的底层元素对象实现了相应的操作。C++14支持透明运算符仿函数,允许忽略模板类型的参数,其一个重要特征是它们是异构的,即它们不仅比非透明仿函数更简明,而且具有真正的函数性优势,建议总是使用透明运算符仿函数。

C++语言还提供了所有的标准比较类模板:equal_tonot_equal_tolessgreaterless_equalgreater_equal。STL容器中的priority_queue和关联容器使用less作为元素的默认比较操作。也有透明仿函数。

C++为3个逻辑操作提供了函数对象类:logical_not表示!logical_and表示&&logical_or表示||。逻辑操作只操作true和false值,故常实例化为logical_xx()。C++为所有按位操作添加提供了函数对象类:bit_and表示&bit_or表示|bit_xor表示^。C++14增加了bit_not表示~

(五)仿函数的一些使用

仿函数类在头文件中定义。函数对象适配器(function adapter)和函数组合(functional composition)提供了一些支持,即能够将函数组合在一起,以精确提供所需行为。

1. 绑定器(binder)

绑定器可用于将函数的参数绑定至特定的值。为此要使用头文件定义的std::bind(),它允许采用灵活的方式,绑定函数的参数,既可以将函数的参数绑定至固定值,甚至还够重新安排参数的顺序。其返回类型比较复杂,是function<>类型,建议使用auto。示例如下:auto 变量名 = bind(函数名, placeholders::_1, 绑定的变量名xxx);,其中没有绑定至指定值的参数应该标记为_1_2_3等,这些都在std::placeholders名称空间中;绑定的变量名是原变量名的一个副本,故既使原来的函数是引用传递参数,所改变的也是副本变量,真正的原变量没有改变,即绑定可斩断引用的传递,头文件定文了std::ref()std::cref()辅助函数,分别用来绑定引用和const引用。给重载函数绑定参数时,需要指明绑定的是哪个版本,即第一个参数使用((函数返回类型*)(形参类型列表))函数名

2. 取反器(negator)

取反器是类似绑定器的函数,但是取反器计算谓词结果的反结果。auto 函数名反 = not1(函数名);,其中not1()表示一元函数的反结果,not2()表示二元函数的反结果。

3. 用类方法作函数对象

当需要传递一个指向类方法的指针作为算法的回调时,必须在对象的上下文内调用,与调用普通函数指针不一样。C++是供了mem_fn()转换函数,生成一个可用于回调的函数对象,用法如:mem_fn(&类名::方法名)。对象本身或对象都使用如此。

4. 分析

从以上可以看出,仿函数、适配器、调用成员函数等的使用很快就会变得非常复杂,建议尽量使用lambda表达式而不是仿函数。

如果需要完成不适合用lambda表达式执行的更复杂的任务,可以编写自己的函数对象类,来执行预定义仿函数不能执行的更特定的任务。如果需要将函数适配器用于这些仿函数,还必须提供特定的typedef。实现这一点最简单的方法是:根据接受一个参数还是两参数,从unary_function<>binary_function<>派生自己的函数对象类,这两个基类位于头文件。根据它们提供的函数的参数类型和返回类型进行模板化。C++11之后可将在函数作用域内局部定义的类用作模板参数。注:算法可以生成函数对象谓词的多份副本,并对不同的元素调用的副本。函数调用运算符必须为const,编写时不能依赖对象的内部状态。

(六)字符串本地化

介绍内容。本地化字符串守面量;宽字符wchar_t、L、wsting、wcout、wcin、 wcerr、 wclog;非西方字符集(Universal Character Set,Unicode)、UTF-8、UTF-16、UTF32;char16_t、char32_t、_STD_UTF_16_、_STD_UTF_32_、u8、u、U、U16string、u32string。

标准C++中,将一组特定的文化参数相关的数据组合起来的机制称为locale,locate中的独立组件,例如日期格式、时间格式和数字格式等称为facet。字等串本地化允许编写为全世界不同地区本地化的软件。


九、STL容器

(一)vector

vector在头文件中定义为一个带有两个类型参数的类模板:一个参数为要保存的元素类型,另一个参数为分配器(allocator)类型:template > class vector { }

其默认构造函数创建一个带有0个元素的vector。可以提供元素的个数和初始值,也可使用统一初始化,initializer_list列表,还可以在堆内存上分配vector。vector上的operator[]没有提供边界检查功能,其方法at()提供了边界检查(出界抛出out_of_range异常),还有front()back()方法返回第一个和最后一个元素的引用。使用循环可遍历,如for(类型 因子 : vector名称)。使用push_back()方法向尾部添加一个元素。pop_back()删除尾部元素。

vector存储对象的副本,其析构函数调用每个对象的析构函数。vector类的复制构造函数和赋值运算符对vector中的所有元素执行深复制。因此出于效率方面的考虑,应该通过引用或const引用向函数和方法传递vector。所有的STL容器都包含了移动构造函数和赋值运算符,从而实现了移动语义。与此类似,push操作在某些情况下也会通过移动语对是升性能。除普通的复制和赋值外,vector还提供了assign()方法,这个方法删除所有现有的元素,并添加任意数目的新元素。vector还提供了成员方法swap(),用于交换两个vector的内容,常量时间复杂度。vector重载了6种比较运算等,要求每个元素都能通过运算符进行比较(采用字典顺序比较)。

普通的iterator支持读和写,const_iterator是只读的,可向const单向转换。如果不需要修改vector中元素,那么应该使用const_iterator,遵循这条原则将更容易保证代码的正确性,还允许编译器执行特定的优化。可通过解除引用访问迭代器所引用的元素,即operator*。如果容器中的元素是对象,那么可以对迭代器使用->运算符,调用对象的方法或访问对象的成员。其安全性和指针接近(即非常不安全),解除引用迭代器可能会产生不确定的后果。vector迭代器是随机访问的,因此可以向前向合移动,还可以随意跳跃(+-操作)。注:只要可能,尽量使用前自增而不使用后自增,因为前自增至少效率不会差,一般更为高效。因后自增必须返回一个新的对象,而前自增只返回原对象的目用,这与它们的语义及重载时实现有关。

通过insert()方法可在vector中任意位置插入元素(迭代器来作位置参数),这个方法在迭代器指定的位置添加一个或多个元素,将所有后续元素向后移动,给新元素腾出空间。insert()有5种不同的重载形式。通过erase()可在vector中任意置删除元素,两个版本,一个接收单个迭代器,删除单个元素;接受两个迭代器删除范围元素。通过clear()可以删除所有元素。

C++11在大部分STL容器(包括vector)中添加了对emplace操作的支持(emplace的意思是放置到位,不会复制或移动任何数据,而是在容器中分配空间,然后就地构建对象)。emplace_back()在尾部构建,其性能和使用移动语义的push_back()之间的性能差异取决于特定编译器实现这些操作的方式,emplace()可以在指定的位置构建对象。

在vector中插入或删除元素,会导致后面的所有元素向后移动,给插入的元素腾出空间,或向前移动,将删除的元素空出来的空间填满。因为迭代器并不会自动移动,故引用插入点、删除点或随后位置的所有迭代器在操作之后都失效了。此外,若因原预留空间不足而导致内存重分配,则引用vector中元素的所有迭代器失效。对所有顺序容器来说,改变元素个数,迭代器都会失效。

vector提供的size()方法返回vector中元素的个数(即大小),capacity()方法返回的是vector在重分配之前可以保存的元素的个数。因此在重分配之前还能插入的元素个数为 capacity() - size() 。通过empty()方法可以查询vector是否为空。vector可以为空,但容量不能为0。如果希望程序尽可能高效(减少重分配,当然要估计vector将存储多少个元素)或确保迭代器(尽量)不失效,以强迫vector预先分配足够的空间。(为元素预留空间是改变容量,而非大小,即该过程不会创建真正的元素)。可以使用reserve()resize()方法或在构造时指定容量。

(二)deque、list、forward_list、array

1. deque

deque几乎和vector是等同的,但用的更少。主要区别:deque不要求元素保存在连续内存中。首尾两端的插入和删除操作O(1),提供了push_front()pop_front()emplace_front()。没有通过reserve()和capacity()公开内存管理方案。list大部分操作和vector操作一致,包括构造函数、析构函数、复制操作、赋值操作和比较操作。

2. list

list提供的访问元素的方法仅有front()back(),常量时间O(1),不支持元素的随机访问,故其他所有元素的访问必须通过迭代器进行。list迭代器是双向的,不像vector那样提供随机访问,这意味着list迭代器之间不能进行加减操作和其他指针运算(只支持递增++和递减--操作)。添加和删除元素的方法包括push_back()push_front()pop_back()pop_front()emplace_back()emplace_front()emplace()、5种形式的insert()、2种形式的erase()clear()

list和deque一样,但和vector不同,不公开底层的内存模型。因此list支持size()empty()resize(),但不支持reserve()capacity()。注意list的size()方法时间复杂度为O(n),而forward_list没有size()方法。

list提供了一些特殊操作,以利用其快速插入和删除特性,全面请参考标准库。如splice()方法用于将list串联,注:splice()对作为参数的list来说是破坏性的,它会从源list中拆出要插入的部分(或整个源list)。list类还提供了一些泛型STL算法的特殊实现,如remove()remove_if()unique()merge()sort()reverse()。如果可以选择,建议使用list方法而不是STL泛型算法,因为其特定实现更高效。而有时不必选择,必须使用list特定方法。

3. forward_list

forward_list采用头插法增加素,故无在尾部增加元素的xx_back()方法,而多了一些xx_after()方法。由于它的头插实现,且只能单向遍历迭代,故要修改链表时需要一个前开区间,故定义了一个before_begin()方法,返回一个指向列表开头元素之前的假想元素的迭代器(不能解除该迭代器的引用),用来作修改序列算法如erase()splice()的参数,它递增一次和begin()返回的迭代器相同。(注:其xx_front()方法仍可使用。)

4. array

array和vector类似,但大小固定,故无添加、删除和改变大小的方法。访问元素与vector类似,array的swap()为O(1)。用fill()方法通过特定元素将array填满。注意声明时要两个模板参数,即array<类型, 元素个数>

(三)queue、priority_queue、stack

1. queue

queue容器的定义:template > class queue { }。queue第二个模板参数指定了queue适配的底层容器,要求同时支持尾插、头删(先入先出),因此有两个内建的选项:deque(默认)和list。支持的方法有push()emplace()back()front()pop(),注front()和back()可读写。还有size()empty()swap()

2. priority_queue

priority_queue容器定义:template , class Compare = less> class priority_queue { }。其中Container可使用vector和deque,不能用list,因为priority_queue要求随机访向元素,第三个参数compare提供确定优先级的标准,less是一个类模板,支持两个类型为T的元素通过 operator< 运算符进行比较(要确保在priority_queue中的元素的类型正确定义了 operator< )。支持方法push()emplace()pop()top(),注:top()返回const引用(因修改元素可能会改变元素的顺序,所以不许修改,故返回const,头元素优先级最高)。还有size()empty()swap()。queue支持普通比较,priority_queue不支持。

3. stack

stack容器定义:template > class stack { },底层容器可以为vector、list、deque(默认)。支持的方法有push()emplace()pop()top()size()empty()swap()、标准比较运算符。

(四)pair、map、multimap、set、multiset

1. pair

在学习关联容器之前,首先要了解pair工具类,在头文件中定义。pair是一个类模板,如:pair<类型1, 类型2>,将两个可能属于不同类型的值组合起来,通过firstsecond公共数据成员访向这两个值。pair定义了==<,用于比较。这个库还提供了一个工具函数模板make_pair(参数1, 参数2),用于从两个值构造一个pair。可用pair<类型1, 类型2> 变量名;或用auto类型来接收(与类模板不同,函数模板可以参数中推导类型)。注:在pair中使用一般指针是危险的,因为pair的复制构造函数和赋值运算符只对指针类型进行浅复制和赋值,而保存shared_ptr这样的智能指针是很安全的。

2. map

map定义在头文件中,定义:map<键类型, 值类型, 比较类型, 分配器类型>,其中比较类型默认为less(要确保键都支持 < 比较),分配器类型默认。顺序容器,插入、查找、删除操作的复杂度为O(logN)。构建时用map<类型1, 类型2> 变量名;。插入元素有两种insert()方法和 operator[] 方法。其中insert()方法的位置参数只是容器找到按顺序的正确位置的一种“提示”,不要求容器在那个位置插入元素。该方法的要插入参数必须为pair对象或initializer_list(键值对)。其返回类型为一个pair对象,该对象的second为一个bool值,若指定插入的键不存在,为true,此时时该pair对象的first为插入成功后新的键值对的map迭代器(一个pair对象);若指定要插入的键已存在,根据map键的唯一性,插入操作失败,bool值为false,迭代器为原来已存在的键值对pair对象。建议用auto类型接收insert()的返回值。而使用operator[]时为:变量名[键] = 值;,该方法若键不存在则创建之,键若存在则替换之。注:它总是会构建一个新的对象,即使不需要使用这个对象,因此需为元素的值提供一个默认构造函数,其效率可能比insert()低。该 operator[] 没有标记为const因为要添加新元素,故const的map对象使用 operator[] 会编译失败。

在map中找查元素时,若确定键在map中,且为非const对象,可直接使用operator[]。若不确定键是否存在,则使用替换方案find(key)方法,(因为对于不存在的键,operator[] 会创建它),若键存在,find()则返回其迭代器,键不存在则返回end()迭代器。此外,count()成员函数返回容器中给定键的元素个数。在map中删除时用erase()方法,可提供参数为一个迭代器(分推常量时间),两个迭代器(删除范围O(logN)),还重载了一个指定键的erase()版本,用于删除匹配键的元素。

map迭代器的工作方式类似于顺序容器的迭代器,主要区别在于迭代器引用的是键值对(一个pair对象)。map迭代器是双向的,可双向遍历。可以通过非const迭代器修改元素值,不能修改键(即使是非const),编译器会生成一个错误,因为修改键会破坏map中元素的排序。

3. multimap

multimap是一个允许多个元素使用同一个键的map,支持统一初始化。multimap的接口和map的接口几乎相同,区别在于:multimap不提供 operator[] ,它的语义在多个无素可用同一个键的情况下没有意义。在multimap上执行插入操作总会成功,因此添加单个元素的insert()方法只返回一个iterator。

查找时,无 operator[] 可用。find()方法也无太大作用(若某键存在的多个,该方法返回任意一个iterator),于是,由于multimap将所有带有同一个键的元素保存在一起,提供了获得某键子范围iterator的方法,即lower_bound()upper_bound()方法,分别返回满足给定键的第一个和最后一个再加一的元素(半开区域)对应的iterator,若无键匹配,两个方法返回的iterator相等。也可用equal_range()获得某键的子范围,它返回上述两个方法所得迭代器构成的pair对象。注:在map中也有lower_bound()、upper_bound()、equal_range()方法,但由其键的唯一性,这些方法用处不大。删除时,erase()所传的迭代器参数注意根据需求是否为具体的iterator。

4. set

set容器定义在头文件,和map十分相似,其接口几乎完全相同,区别为set容器没有提供 operator[] ,其set中保存的不是键值对,在set中值本身就是键,不能修改set中的值/键,因为修改后容器中的set元素会破坏顺序。如果希望进行排序以便快速地执行插入、查找和删除,且信息无显式的键,就可以考虑使用set容器。

5.multiset

multiset和set关系等同于multimap和map的关系,multiset支持set所有操作,但允许容器中同时保存多个相等的元素,此处不再赘述。

(五)unordered_xxx

unordered_map在头文件中定义,是一个类模板,如下:template , class Pred = std::equal_to, class Alloc = std::allocator>> class unordered_map { }。共有5个模板参数:键类型、值类型、哈希类型、判断比较类型和分配器类型。通过后面了个数可以分别自定义哈希函数,判等比较函数和分配器函数。可使用统一初始化机制来初始化。

接口与普通map类似,此外还有一些哈希专用方法。load_factor()返回每一个桶的平均元素数(来反映冲突的次数)。bucket_count()方法返回容器中桶的数目。local_iteratorconst_local_iterator用于遍历单个桶中的元素(迭代器为键值对的pair对象)。bucket(key)返回包含指定键的桶索引,一个整数。begin(n)end(n)方法分别返回索引为n的桶中第一个元素和最后一个的后一个元素的local_iterator(半开区间)。

无序关联容器也称为哈希表,这是因为它们使用了哈希函数。哈希表的实现通常会使用某种形式的数组,数组中的每个元素都称为桶(bucket),每个桶都有一个特定的数值索引即桶索引。哈希函数就是将键转换成桶索引的函数,键的值就在桶中存储。当出现多给键对应同一个桶时称为哈希冲突,解决方式有线性链或二次重哈希等方法。C++标准为指针和所有基本数据类型(如bool、char、int、float、double等)、string、error_code、bitset、unique_ptr、shared_ptr、type_index、thread、vector提供了哈希函数。如果要使用的键类型(如用户自定义类型UDT)没有可用的标准哈希函数,就必须自己实现。一个例子如下:

class Int {		// 用户自定义类型 Int 作为 unorder_map 的键类型
private:
	int value;
public:
	Int(int v) : value(v) { }
	bool operator==(const Int& rhs) const { return value == rhs.value; }
	int getInt() const { return value; }
};

namespace std {
	template<>
	struct hash {	// 实现自定义类型的 hash 函数(一个对象函数)
		size_t operator()(const Int& src) const {
            // 根据UDT的数据特性实现hash,返回桶索引
			return src.getInt();	// 此例中简单的返回int值,肯定有大量哈希冲突,hash并不符合性能要求
		}
	};
}

int main() {
	unordered_map map;

	Int a = Int(1);
	Int b = Int(2);
	map[a] = "Hello";
	map[b] = "World";
	map[b] = "C++";

	string aValue = map.begin(map.bucket(a))->second;
	string bValue = map.begin(map.bucket(b))->second;
	cout << aValue << " , " << bValue << "\n";	// 输出:Hello , C++
	return 0;
}

注:一般不允许把任何内容放在std名称空间,但std类模板特例是这个规则的例外。要实现UDT的 operator==() 运算符重载,格式如:bool operator==(cosnt UDT& src) const { return xx; }

unordered_multimap和unordered_map的关系与map和multimap关系一样。unordered_set和unordered_multiset也分别类似于set和multiset,在此处不再赘述。

(六)其他容器

把标准的C风格数组看成STL容器,指向数组元素的指针当成迭代器即可使用STL中的算法(部分)。

string可看作字符的顺序容器,包含基本顺序容器的所有成员。

流可看作元素的序列,虽然C++流没有直接提供STL方法,但STL提供了名为istream_iteratorostream_iterator的特殊迭代器,用于"遍历"输入和输出流。

bitset根据保存的位数模板化。默认构造函数将bitset的所有字段初始化0,另一个构造函数根据0和1字符的字符串创建bitset。(bitset以包含0和1字符的string的形式进行流式处理)。可通过set()reset()filp()方法分别实现单个位的置1、清0、翻转。operator[]运算符可以访问和设置单个字段的值。还可以通过test()方法访问单独字段。止外,普通的插入和抽取运算符可以流式处理bitset(从左到右为高位到低位)。还实现了对bitset整体的按位运算符。


十、STL算法

STL算法把迭代器作为中介操作容器,而不直接操作容器本身,某些算法对传给它的迭代器有一些要求,这意味着这种算法不能操作没有提供所
需迭代器的一些容器。大部分算法都接回调(call back),回调可以是一个函数指针,也可以是行为上类似于函数指针的对象(例如重载了operator()的对象)或内嵌lambda表达式。为了方便起见,STL还提供了一组类,用于创建算法使用的回调对象。这些回调对象称为函数对象或仿函数(functor),大部分算法都定义在头文件中,一些数值算法定义在头文件中,它们都在std名称空间中。

与STL容器一样,STL算法也做了优化,以便在合适时使用移动语义。这可以极大地加速特定的算法,例如sort()。因此强烈建议在需要保存到容器中的自定义元素类中实现移动语义(移动构造函数和移动赋值运算符)。

(一)非修改序列算法

1. 搜索算法

使用默认的比较运算符 operator== 和 operator< ,还提供了重载版本,允许指定比较回调。

find()用于在一个迭代器范围中(半开区间)查找特定元素,返回引用所找到元素的迭代器;若没有找到元素,返回迭代器范围内(函数参数中所定的,而不是底层容器的)尾迭代器。一些容器如map、set提供了自己的find()。find_if()和find()类似,区别在于它接收谓词函数(bool返回类型),回调作为参数,而不是简单的匹配元素。find_if()算法对范围内的每个元素调用谓词,直致谓词返回true,如果返回true,find_if()返回引用这个元素的迭代器。

此外还有算法:find_if_not()find_first_of()find_end()adjacent_find()serach()serach_n()min_element()max_element()minmaxelement()

2. 比较算法

比较算法可以比较不同容器内的范围,如果要比较两个同类型的元素,可使用 == 和 < 运算符。一般情况下:这些算法最适用于顺序容器,它们的工作方式是比较两个序列对应位置的值,equal()。参数为第一容器的首尾迭代器,第二容器的首迭代器,要求两个容器元素个数相同,所有对应元素都对应相等时返回true。C++14之后,可以接受两容器的首尾迭代器共4个参数,不再要求长度相同。mismatch()接收如上3个迭代器,要求第二容器长于第一容器,返回一个由两容器相同位置上的迭代器组成的pair对,该位置为两容器的两元素第一次不匹配(相等)的位置,若全部匹配则返回两容器的尾迭代器组成的pair。C++14之后,可接收如上4个迭代器,并不要求第二容器长于第一容器。lexicographical_compare()字典比较小于,接收两容器首尾迭代器4个参数。

3. 工具算法

count_if()算法计算给定迭代器区间内满足特定条件的元素的个数。通过lambda表达式的形式给出条件(可用引用单独一个变量记录调用次数)。此外还有:all_of()any_of()none_of()count()

(二)修改序列算法

修改算法都涉及源范围和目标范围的概念。从源范围读取元素,然后将读取的元素添加到目标范围或在目标范围中进行修改,源范围和目标范围经常是同一范围,此时算法称为就地(in place)操作。注:map、set、multimap、multiset的键标记为const,因此不能用作修改算法的目标范围,替换方案是使用插入迭代器。

1. 转换

转换transform()算法对一个源范围中的每个元素应用回调,期望回调生成一个新元素,并指定保存在目标范围中(可与源范围相同),其参数是源序列的首尾迭代器,目标序列的首迭代器和一元回调。第二个形式是对两个源序列执行二元回调,结果保存在目标序列中(第二容器长度要大于第一容器),其参数为第一序列首尾迭代器,第二序列首迭代器,目标序列首迭代器,二元回调函数。注: transform() 和其他修改算法通常返回一个引用目标范围最后一个值的后一个位置(the one past the end)的迭代器。

2. 复制

复制copy()可将一个范围中的元素复制到另一个范围中,从源范围中的第一个元素开始到最后一个元素(源范围和目标范围必须不同,但可以重叠),其参数接收源序列的首尾迭代器,目标序列的首迭代器。注:copy()不会向目标范围中插入目标元素,只是改写已有的元素,因此在复制前必须手动确保目标序列容量足够,可使用如resize()方法。

copy_backward()算法将源范围中的元素反向复制到目标范围中,即从源范围的最后一个元素开始,放到目标范围的最后一个位置,此时目标范围使用尾送代器。

copy_n()从源范围复制元素到目标范围,参数依次为源范围迭代器,参数n,目标范围迭代器,算法不执行任何边界检查,因此一定要确保起始迭代器增加了n个要复制的元素后,不会超过集合的end(),要手动确保不会越界,否则程序会产生未定义的行为。

copy_if()需要一个源范围的首尾迭代器,一个目标范围的首迭代器和一个谓词回调,对源范围中的元素执行谓词,如果返回值为true,则复制这个元素并递增目标迭代器;若为false,则不复制也不递增目标迭代器,因此,目标中包含的数目可能少于源范围。对于一些容器来说,由于复制前手动扩充目标范围,可能需要删除在目标范围中超出最后一个复制元素位置的空间。为便于达到这个目的,copy_if()返回了目标范围中最后一个复制元素的后一个位置(one-past-the-last-copied element)的迭代器,以便确定需要从目标容器中删除的元素。

3. 移动

移动语义的 move() 可将lvalue转换为rvalue。而作为STL中的move()算法,它接收3个参数,源范围的首尾迭代器和目标范围的首迭代器。如果要在自定类型元素的容器中使用移动算法,那么要在元素类中提供移动赋值运算符。注:在移动操作中,源对象被重置了,因为目标对象接管了源对象资源的所有权,这意味着在移动操作之后,不应再使用源对象中的内容了。且要确保目标范围更足够大。

move_backward()使用了和 move() 同样的移动机制,但是它是按照从最后一个元素向第一个元素的顺序移动。

4. 替换

替换replace_if()接收范围的首尾迭代器(就地操作),第3个参数是一个谓词回调,为true时转换为指定的第4个参数的值。replace()将范围内匹配(等于)某个值的元素替换为另一个值。也有replace_copy()replace_copy_if()的变体,将结果复制到不同的目标范围中,它们类似于 copy() ,因为目标范围必须足够大,以容纳新元素。

5. 删除

删除正确解决方案是“删除-擦除法”(remove-erase-idiom)。算法只能访问迭代器抽象,不能访向容器。因此删除算法不能真正地从底层容器中删除元素,而是用匹配给定值或谓词的元素替换为下一个不匹配给定值或谓词的元素,然后返回最后一个不匹配(即不删除)元素的后一个位置迭代器,即remove()remove_if()算法。结果是将源范围分为前后两个部分,前半部分为要保留的元素,后半部分为匹配后剩下的不匹配的元素。如果真的要从容器中删除这些元素,必须先使用 remove() 算法,返回一个匹配范围最后一个元素后一个位置的迭代器(即不匹配范围的首迭代器),然后调用容器的erase()方法,将不匹配范围的首迭代器到源范围的尾迭代器之间的所有元素删除。

其变体remove_copy()remove_copy_if()不会改变源范围,而是将所有未删除(即匹配)的元素复制到另一个目标范围中,和 copy() 类似,要求目标范围必须足够大,以便保存新元素。注:remove()系列函数是稳定的,因为这些函数保持了容器中剩余元素的顺序。

唯一化unique()算法是特殊的remove(),将所有重复的连续元素删除。list容器提供了自己的具有同样语义的unique()方法。通常情况下应该对有序序列使用 unique() ,但也能用于无序序列。unique()的基本形式是就地操作,还有一个名为unique_copy()的版本,将结果复制到一个新的目标范围中。

6. 反转

反转reverse()算法反转一个范围中元素的顺序,最基本形式就地操作,要传入源范围的首尾迭代器。还有reverse_copy()版本,要传入目标范围的首迭代器。

7. 生成算法

乱序shuffle()以随机顺序重新安排范围中的元素,复杂度O(n),参数为源范围的首尾迭代器,和一个统一的随机数生成器对象,指定如何生成随机数。

generate()算法需要一个迭代器范围,把该范围的值替换为从回调函数返回的值,回调函数作为第三个参数(lambda表达式)不依赖源元素。

此外还有:fill()fill_n()generate_n()rotaterotate_copy()

(三)操作算法

操作算法for_each()它对范围中的每个元素执行回调,使用基于区间的for循环是更简单,容易理解。for_each() 使用lambda或回调时,允许通过引用获得参数并对其进行修改,这样可以修改实际迭代器范围中的值。用自己编写的仿函数类对象作为回调参数传入 for_each() ,最终会移出 for_each() ,为了获得正确的行为,用仿函数类对象去接收 for_each() 的返回。

(四)分区算法

分区算法partition_copy()算法将一个来源的元素依据谓词回调的true和false将元素复制到两个不同的目标。其返回是一个迭代器的pair对,分别指向两个范围的最后一个复制元素的后一个位置,应用于 erase() 可以删除两个目标范围中多余的元素。其参数为源范围的首尾迭代器,两个目标范围的首迭代器,谓词回调函数。partition()算法将使谓词返回true的所有元素放在前面,谓词返回false的所有元素放在后面,在每个分区中不保留元素的最初顺序,就地操作。还有is_partitioned()stable_partition()保留源顺序,partition_point()返回迭代器。

(五)排序算法

排序算法重新排序容器内容的顺序,使容器中的元素保持连续顺序。排序算法只能应用于顺序容器,和关联容器和无序关联容器无关。有一些容器,如list和forward_list提供了自己的排序算法。

sort()函数在一般情况下为O(NlogN),应用于一个范围,根据运算符 operator< 排序,也可以指定不同的比较回调,如greater或自定义等。其名为stable_sort()的变体能够保持范围中相等元素的相对顺序。

还有is_sorted()is_sorted_until()返回true和一个迭代器。partial_sort()partial_sort_copy()进行部分排序。nth_element()会重新安排所有元素,使n之前的元素都小于n ,之后元素都大于n。

(六)二分搜索算法

二分搜索算法中有几个搜索算法只用于有序序到或至少已分区的元素序列。

binary_search()算法参数为搜索范围的首尾迭代器,要搜索的值以及可选的比较回调,找到返回true,否则返回false,其为O(logN)。还有lower-bound()upper-bound()equal_range()

(七)集合算法

集合算法可用以任何有序的迭代器范围。

includes()函数实现了标准的子集判断功能,检查一个有序范围的所有元素是否包含在另一个有序范围中,顺序任意。

set_union()并集操作,应确保结果容器大于两源序列之和。set_intersection()交集操作。set_difference()集合差,即所有存在于第一序列集合但不属于第二集合的元素,结果上限为第一容器大小。set_symmetric-difference()对称集合差为所有存在于某一集合但不同时存在于两个集合的元素,结果上限为两容器的和。这是算法的参数都为5个,第一集合(容器)的首尾迭代器,第二容器的首尾迭代器,目标范围的首迭代器。注:不能使用关联容器(包括set)的迭代器范围来保存结果,因为这些容器不允许修改键。

merge()将两个排序好的范围归并在一起,并保持排序顺序。结果是一个包含两个源范围中所元素的有序范围,需要两源范围的首尾迭代器和目标范围的首迭代器,还有一个可选的比较回调,要确保目标范围足够大。inplace_merge()就地操作。

(八)最大/最小值算法

最大最小值算法min()max()算法通过运算符 operator< 或用户提供的二元谓词比较两个任意类型的元素,分别返回一个最小或最大元素的canst引用。minmax()算法返回一个包含多个元素中最小最大值的pair。这些算法不接收迭代器参数。还有使用迭代器范围的min_element()max_element()minmax_element()

有时可能会遇到查找最大最小值的非标准宏。例如:GNU C Library(glibc)中有宏 min() 和 max() ,头文件定义了宏 min() 和 max() ,因为它们是宏,所以可能对其参数进行二次求值。而 std::min() 和 std::max() 对每个参数正好进行一次求值。确保总是使用C++版本的 std::min() 和 std::max()。

(九)数值处理算法

数值处理算法在头文件numeric>中定义。accumulate()函数最基本的形式是计算指定范围中元素的总和,即“累加”一个序列中所有元素的值,默认为求和。前两个参数接收迭代器范围,第三个参数为初始值(一般加法为0,乘法为1等)。第二种形式允许调用者指定要执行的操作,这个操作的形式是二元回调(带两个参数的函数回调),来定义所需操作。

inner_product()对两个序列操作,接收第一序列(容器)的首尾迭代器,第二序列的首迭代器,默认初始值,可选的回调谓词。它对两个序列的对应位置调用二元函数(默认做乘法)得到一系列对应位置的结果值,再通过另一个二元函数(默认做加法)累加结果值。其默认的数学意义为求向量的点积。

rota()算法生成一个指定范围内的序列值,从给定的值开始,并用 operator++ 生成每个后续值,可用于任意实现了 operator++ 的元素类型。其接收范围迭代器,首尾迭代器和一个初始值。

此外还有:adjacent_difference()partial_sum()算法。


十一、自定义和扩展STL

每个STL容器都接受一个Allocator类型作为模板参数,大部分情况下可使用默认。容器构造函数数还允许指定一个Allocator类型对象。通过这些额外的参数可以自定义容器分配内存的方式,容器执行的每一次内存分配都是通过调用Allocator对象的allocate()方法进行的,每一次内存释放都是通过调用Allocator对象的deallocate()方法进行的。标准库提供了一个默认的Allocator类,名为allocator,这个类通过对operator new和operator delete的包装方法实现了这些方法。如果程序中的容器需要使用自定义的内存分配和释放方案,那么可以编写自己的Allocator类。任何提供了allocate()、deallocate()和其他要求提供方法和typedef的类都可以替换默认的allocator类。不过应该对其机制有足够掌握,并能正确驾驭。

STL库本质上就是可扩展的:提供符合STL标准的迭代器,就可以编写用于STL算法的容器,还可以编写操作STL容器的算法。因为STL库将存储数据的容器和操作数据的算法分离开了,它们通过迭代器完成交互。注:不能把自己的容器和算法放在std名称空间。

(一)迭代器适配器

标准库提供了4个迭代器适配器(iterator adapter):基于其他迭代器构建的特殊迭代器,都在头文件中定义。也可以编写自己的迭代器适配器。

1. 反向迭代器

STL提供了reverse_iterator类,以相反的方向遍历双向迭代器或随机访向迭代器。对reverse_iterator应用 operator++ 运算符,会对底层容器调用 operator-- 运算符,反之亦然。STL中所有可反向迭代的容器都提供了一个typedef的reverse_iterator以及rbegin()和rend()方法。rbegin()方法返回指向容器中最后一个元素的reverse_iterator,而rend()方法返回指向容器中第一个元素之前的元素。如用reverse_iterator应用于find()算法找最后一次出现的元素位置。注:通过reverse_iterator调用算法时,返回的也是一个reverse_iterator对象,对它调用base()方法返回被reverse_iterator所引用的底层迭代器的后一个元素,为得到同一元素,要进行减一操作。

2. 流迭代器

STL提供的一些了适配器把输入和输出流用作迭代器。通过这些输入输出的流迭代器,可将它们在不同的STL算法中分别当成来源和目标。

ostream_iterator模板类是一个输出流迭代器,接收元素类型作为模板类型参数,其构造函数接收一个输出流对象和要在写入每个元素之后写入流的string,其通过 operator<< 运算符写入元素。如下声明一个对象:ostream_iterator<类型> 对象名(出流对象, sting对象);,例:用copy()输出容器中内容,copy(cbegin(容器), cend(容器), ostream_iterator<类型>(cout, " ");

类似的istream_iterator通过抽象从输入流中读取数值,可作为算法和容器方法的来源,其通过 operator>> 运算符读取元素,直到流尾(Windows下,Ctrl+z和回车,代表达到流尾)。其构造函数接收一个输入流对象,如istream_iterator<类型> 对象名(输入流对象);。和regex_iterator类似,其默认构造函数隐式地转换声明一个流迭代器的尾迭代器。一个示例如下:

vector v(8);	// 此处需指定vector即目标范围,且在cin时不能超出该指定范围,否则会出错
istream_iterator ii(cin), iiEnd;
copy(ii, iiEnd, begin(v));
ostream_iterator oi(cout, " ");
copy(cbegin(v), cend(v), oi);
// 输入:1 2 3 4 5 6 7 8^Z
// 输出:1 2 3 4 5 6 7 8
copy(istream_iterator(cin), istream_iterator(), ostream_iterator(cout, "_"));
// 输入:1 2 3 4^Z
// 输出:1_2_3_4_

3. 插入迭代器

引例:copy()这类算法并不会将元素插入容器,只是将一个范围内的旧元素替换为新元素,为此需要保证目标范围容器足句多大。

STL提供了3个插入迭代器适配器,真正的将元素插入容器,insert_iterator调用容器的insert(pos, element)back_insert_iterator调用容器的push_back()front_insert_iterator调用容器的push_front()。这些模板类接收容器类型作为模板类型参数,构造函数接受实际容器的引用。如下尾插入迭代器:back_insert_iterator<容器类型> 迭代器名(容器对象);,也可以通过辅助工具函数生成尾插入迭代器,如auto 迭代器名 = back_inserter(容器对象);。生成首插入迭代器的类似工具函数为front_inserter(容器对象);

不过区别有insert_iterator在构造函数中还接收初始迭代器位置作参数,并将这个位置传入第一次 insert(pos, element) 调用,后续的迭代器pos位置通过每次insert()的返回值生成。使用insert_iterator的一个巨大好处是可将关联容器用作修改序列算法的目标,因为关联容器的键为const问题,不允许修改迭代的元素,而通过insert_iterator则可以插入元素,从而允许容器在内部正确地对元素排序。关联容器实际上支持一个接收迭代器位置作为参数的insert(),并将这个位置用作提示(可以忽略)。在关联容器上使用insert_iterator时,可以传入容器的begin()或end()迭代器用作提示。注:insert_iterator在每次调用insert()后修改了传递给insert()的迭代器位置,使这个位置为刚插入元素位置的后一个位置。

4. 移动迭代器

有一个移动迭代适配器move_iterator的解除引用运算符会自动将值转换为rvalue右值引用,也就是说这个值可以移动到新目的地,而不会有复制开销,需要保证对象支持移动语义,通过make_move_iterator(普通迭代器)函数数创建一个move_iterator。注:移动对象后,再不能访问原来的对象。

(二)编写STL算法

编写STL算法,此处以在指定范围中找到满足某个谓词的所有元素的find_all_if()算法为例。

第一个任务是定义函数原型,可以遵循copy_if()采用的模型,这应该是一个带有了个模板类型参数的模板化函数:输入迭代器、输出迭代器和谓词。与copy_if()一样,该算法返回一个输出序列的迭代器,指向输出序列中存储的最后一个元素后面的元素,其定义如下:

template 
OutputIterator find_all_if(InputIterator first, InputIterator last, OutputIterator dest, Predicate pred) {
	while (first != last) {
		if (pred(*first)) {
			*dest = *first;  ++dest;
		}
		++first;
	}
	return dest;
}
// 与copy_if()一样,该算法也只覆盖输出序列中的已有元素,所以确保输出序列足够大或使用插入迭代器。
int main() {
	vector src{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 7, 5, 4, 3, 2, 1 };
	vector de(15);
	auto iter = find_all_if(begin(src), end(src), begin(de), [](int x) { return x >= 5; });
	de.erase(iter, end(de));
	copy(begin(de), end(de), ostream_iterator(cout, " "));
	// 输出:5 6 7 8 9 7 5
	return 0;
}

(三)编写STL容器

编写STL容器,要使容器遵循STL标准,并能应用STL算法。C++标准指定了数据结构作为STL容器必须提供的类型别名和方法。

实现顺序容器的简单方法是基于deque容器,deque几乎完美地符合了所规定的顺序容器的需求,唯一的区别在于deque提供了额外的resize()方法(标准没有要求实现这个方法)。无序关联容器的一个例子是unordered_map,可在这个模型的基础上构建自己的无序关联容器模型。

1. 标准所指定自定义容器必须实现的内容

C++标准指定了每个STL容器都要有的public类型别名:

public类型别名 表示的含义
value_type 容器中保存的元素的类型,typedef 元素类型 value_type;
reference 容器中保存的元素的类型的引用,using reference = 元素类型&;
const_reference 容器中保存的元素类型的const引用,using const_reference = const 元素类型&;
size_type 表示容器中元素个数的类型,通常为size_t
iterator 遍历容器中元素的迭代器的类型
const_iterator 另一个版本的iterator,遍历容器中元素的const迭代器(不能修改容器中元素的值)
difference_type 表示容器的两个iterator差值的类型,通常为ptrdiff_t(来自

每个容器必须提供的方法有:

容器的方法 表示的含义
默认构函数 构造一个空容器,O(1)
复制构造函数、赋值值运算符 执行容器的深度复制,O(N)
移动构造函数、移动赋值运算符 执行移动构造、赋值操作,O(1)
析构函数 销毁动态分配的内存,对容器中剩余的所有元素调用析构函数,O(N)
==!=<><=>= 逐元素比较两个容器的比较运算符,O(N)
void swap(container&); 将作为参数传入这个方法的容器中的内容成员和在其中调用这个方法的对象的内容进行交换,O(1)
bool empty() const; 容器是否为空,即不包含任何元素,O(1)
size_type size() const; 返回容器中元素的个数,O(1)
size_type max_size() const; 返回容器可以保存的最大元素数,O(1)
iterator begin();const_iterator begin() const; 返回引用容器中第一个元素的迭代器,O(1)
const_iterator cbegin() const; 返回引用容器中第一个元素的const迭代器
iterator end();const_iterator end() const; 返回引用容器中最后一个元素后一个位置的迭代器
const_iterator cend() const; 返回引用容器中最后一个元素后一个位置的const迭代器

2. 标准没有指定必须但一些容器可能需要的内容

容器的删除元素操作:void erase(iterator);void erase(iterator start, iterator end);,删除迭代器位置或范围的元素。STL不要求必须实现,但一般而言,在客户应用时需要应用。

允许反向迭代的容器可能还需要额外提供名为reverse_iteratorconst_reverse_iterator 的公共的typedef,以及rbegin()rend()crbegin()crend()方法。

3. 实现(有序)关联容器还需要的内容

(1) 类型别名

key_type,容器中实例化时选择的键的类型。value_type,容器中元素的类型即键值对 pair<键类型, 值类型> 。key_compare,实例化时的比较函数类或函数指针类型。value_compare,比较两个value_type元素的类。

(2) 方法

接收一个迭代器范围作为参数的构造函数:构造函数,并插入迭代器范围中的元素,不要求迭代器范围引用同一类型的其他容器。注意:所有关联容器都必须接收一个比较对象,构造函数应该提供一个默认构造的对象作为默认值,O(NlogN)。接收intializer_list为参数的构造函数,构造一个容器,并将初始化列表中的元素插入容器,O(NlogN)。

容器类型& operator=(intializer_list);,将容器中所有的元素替换为初始化列表中的元素,O(NlogN)。

key_compare key_comp() const;value_compare value_comp() const;,返回只比较键或比较整个元素的比较对象,O(1)。

pair insert(value_type&);iterator insert(iterator, value_type);void insert(InputIterator start, InputIterator end);第二种的iterator的position是一个可以忽略的提示,第三种形式中的范围不要求来自同类型的容器,因而要使用一个模板类型参数InputItreator)。另:允许重复键的容器在第一种形式中只返回iterator,因为其insert()始终会成功。O(logN)(若第二种提示恰当,为分推常量时间)。void insert(initializer_list);,将初始化列表中的元素插入容器,O(logN)。

pair emplace(value_type&&);,实现了放置操作,就地构造对象,O(logN)。iterator emplace_hint(iterator hint, value_type&&);, 就地放置,O(logN) (若提示恰当为分推常量时间)。

size_type erase(key_type&);,返回删除值的个数,O(logN)。iterator erase(iterator);iterator erase(itreator start, iterator end);,删除送代器位置或范围上的元素,返回删除的最后一个元素的后一个位置,O(N)(分摊常量时间)。void clear(),删除所有元素,O(N)。

iterator find(key_type&);const_iterator find(key_type&) const;,查找匹配指定键的元素,O(logN)。size_type count(key_type&) const;,返回匹配指定键的元素的个数,O(logN)。pair equal_range(key_type&);pair equal_range(key_type&) const;, 返回引用匹配指定键的第一个元素的迭代器,以及匹配指定键的最后一个元素后一位置的迭代器,O(logN)。

(四)编写迭代器

编写迭代器,为了能够用于泛型算法,每个容器都必须提供一个能够访向容器中元素的迭代器。迭代器一般应该提供重载的operator*operator->运算符,再加上其他一些取决于特定行为的操作。容器需要提供非const迭代器和const迭代器(一般而言是非const迭代器的子类),且尾迭代器的表示方式要一致(最后一个元素的后一个位置)。迭代器的种类有:

迭代器 提供的运算符、函数 说明
输出迭代器 ++*=、复制构造函数 提供只写访向,只能向前访向,只能赋值,不能比较判断,无 operator->
输入迭代器 ++*->===!=、复制构造函数 提供了只读访问,只能向前访向,没有 operator-- 提供向后访向的能力
前向迭代器 ++*->===!=、复制构造函数、默认构造函数 提供读写功能,只能向前访向
双向迭代器 ++*->===!=--、复制构造函数、默认构造函数 可以后退到前一个元素
随机访的迭代器 ++*->===!=--+-+=-=<><=>=[]、复制构造函数、默认构造函数 等同于普通指针,此类迭代器支持指针运算、数组索引语法以及所有形式的比较

如何选择迭代器?第一决策是迭代器的类型,第二决策是如何对容器的元素排序,第三决策是迭代器的内部表示形式。

迭代器trait,一些算法需要迭代器的额外信息。例:为了保存临时对象,算法可能需要知道迭代器引用的元素的类型,还有可能知道迭代器是双向访向的还是随机访向的。C++提了一个名为iterator_traits的模板类,通过要使用的迭代器类型实例化iterator_traits类模板,然后访问以下5个typedef:value_typedifference_typeiterator_categorypointerreference。注:在iterator_traits这行前面要使typename关键字(访向基于一个或多个模板参数的类型时,必须显式地指定typename)。用法如:typename std::iterator_triats<迭代器类型>::value_type 变量名;,其中变量类型为迭代器引用元素的类型。

如果想让迭代器适用于泛型算法的函数,必须指定迭代器的traits,iterator_traits类模板的默认实现就从迭代器类本身获取了5个typedef,因此可以在自己的迭代器类中直接定义这些typedef,并对iterator_traits类模板部分特例化以满足新迭代器类型。

而事实上,在C++中通过种iterator类模板中派生即可,iterator类模板提供了那些typedef,通过这种方式,只需要在iterator类模板的类型参数中指定预定义迭代器种类和容器中元素类型即可,迭代器种类有:input_iterator_tagoutput_iterator_tagforward_iterator_tagbidirectional_iterator_tagrandom_access_iterator_tag。派生型式如下:

template 
class MyIter : public iterator {
public:
	using value_type = Container_type::value_type;
	// operator#();
};

可以使用 increment() 和 decrement() 辅助函数来辅助迭代器的递增递减操作。

注:若迭代器类中的方法访问容器类的私有成员,因此容器类须将迭代器类声明为一个friend类,而迭代器类也需要将容器类声明为一个friend类,即相互友元。如果容器提供了双向访向或随机访问迭代器,那么认为这个容器是可反向的,需额外提供类型别名和方法在上一节有所描述,其迭代器的实现一般使用本章第一节描述的reverse_iterator适配器即可。


十二、正则表达式

(一)概述

正则表达式在头文件中定义,是标准库的一部分,正则表达式是一种用于字符串处理的微型语言,适用于一些与字符串相关的操作。验证:检查输入的字符串格式是否正确。决策:判断输入表示哪种字符串。解析:从输入字符串中提取信息。转换:搜索子字符串,并将子字符串替换为新的格式化的子字符串。遍历:搜索所有的子字符串。符号化:根据一组分隔符将字符串分为多个子字符串。

关于正则表达式的一些术语:模式(pattern)、匹配(match)、搜索(search)、替换(replace)。模式:正则表达式实际上是通过字符串表示的一个模式。匹配:判断给定的正则表达式和给定序列 [first, last) 中的所有字符是否匹配。搜索:判断在给定序列 [first, last) 中是否存在匹配给定正则表达式的子字符串。替换:在给定序列中识别子字符串,然后将子字符串替换为从其他模式(称为替换模式,substitution pattern)计算得到的新的子字符串。

C++11包含了对以下几种语法的支持:ECMAScript(默认)、basic、extended、awk、grep、egrep,在使用前应告诉正则表达式库使用哪种语法(syntax_option_type)。

(二)ECMAScript语法

1. 正则表达式模式

正则表达式模式是一个字符序列,这个模式表示了要匹配的内容。正则表达式中的任何字符都表示匹配自己,但除以下特殊的字符:^$.|()*+?{}\。如果要匹配这些特殊字符,需要通过\字符将其转义。它们的特定的用法如下表:

名称 符号 作用
锚点(anchor) ^$ ^ 守符匹配行终止符前面的位置,$ 字符匹配行终业符的位置
通配(wildcard) . . 字符可用于匹配除了换行字符以外的任意字符
替代(alternation) | | 字符表示或的关系
分组(group) () ( ) 用于标记子表达式(也称捕捉组capture group)
重复(repeat) *+?{} * 匹配之前部分>=0次,+为>=1次,?为0或1次,{n}为n次,{n,}为>=n次,{n,m}为[n,m]次

^ 和 $ 默认还分别匹配字符串的开头和结尾位置(以...开头或结尾),可以禁用这个行为。

分组能标记子表达式,使得除了匹配整个正则表达式外,也对子表达式匹配,不匹配返回空。捕捉组可以用于识别源字符串中单独的子序列,在结果中会返回每一个标记的子表达式(捕捉组)。捕捉组可以在匹配的过程中用于向后引用的目的,可以在替换操作的过程中用于识别组件。

表格中的重复匹配字符串称为贪婪匹配(greedy),因为这些字符会找出最长匹配(但仍匹配正则表达式的其余部分)。可以在重复字符后面加上一个?,如:*?+???{..}?,表示非贪婪匹配,将模式重复尽可能少的次数,但仍匹配正则表达式的其余部分。

符号的优先级。元素(element) > 量词(quantifier)紧密绑定至左侧元素 > 串联 > 替代符(如 | )。括号可以改变优先级顺序,不过会形成捕捉组。使用(?: ... )可以避免创建新的捕捉组又能修改优先级。

2. 字符集合匹配

字符集合匹配[xxx]指定一组字符或字符的范围,还可以使用^表示除了这些字符中的任意字符(如果需要匹配^[]字符本身,需要用\转义)。为了避免写一长串,可以使用连子符-表示范围,如[a-z]表示识别为a到z范围内的所有字母(如果要匹配连词符 - ,需要转义)。还可以使用某种字符类(character class)来表示特定类型的字符,表示方法为[:xxx:]。可以使用什么字符类取决于locale,下表为通用的一些(这些字符类的含义也取决于locale,假定使用标准C的locale):

[:xxx:] 含义
digit、d 数字
xdigit 16进制数字和大小写字母
alpha 字符数字字符,C locale中表示所有大小写字母
alnum、w alpha和digit的组合
lower、upper 小、大写字母(假定适用于locale)
blank 空白字符(在一行中用于分隔单词的空格符,对于C locale为’ ‘或’\t’)
space、s 空白字符(对C locale为’ ‘、’\t’、’\n’、’\r’、‘v’、’\f’
print 可打印字符,占用一个打印位置,例如显示器上
cntrl 控制符,与可打印符相反,不占打印位置(对C locale如换页符’\f’、换行符’\n’、回车符’\r’等)
graph 带有图形表示的字符,包括除了’ '空格之外的所有可打印符
punct 标点符号字符(对C locale为除alnum的所有graph)

由于一些概念使用非常频繁,某些字符类有缩写模式,例如[[:digit]][[:d:]]都等同于[0-9]。(注:字符类用在字符集合中)。有些类甚至有更短的转义符号的模式(小写表示对应字符,大写表示非),如下:

转义符号模式 表示的字符集合 转义符号模式 表示的字符集合
\d [[:d:]] \D [^[:d:]]
\s [[:s:]] \S [^[:s:]]
\w [_[:w:]] \W [^_[:w:]]

3. 词边界(word boundary)

词边界指的是:如果源字符串的第一个字符或最后一个字符是单词字符[a-zA-Z0-9_]之一,则表示源字符串的开头位置或结束位置,默认为启用,但可以禁用它(regex_constants::match_not_bowregex_constants::match_not_eow);一个单词的第一个字符,这个字符是单词字符之一,而且之前的字符不是单词字符;一个单词的结尾处,这是单词之后的第一个非单词字符,之前的字符是单词字符。

通过\b可以匹配一个单词边界,通过\B匹配除单词边界之外的任何内容。

4. 后向引用(back reference)

后向引用可以引用正则表达式本身中的一个捕捉组(无须再写一遍),格式如:\$,表示第 $ 个捕捉组,且 $ > 0。

5. 零宽断言(zero-width assertion)

零宽断言用于查找某些内容之前或之后的东西,像\b^$那样用于指定一个位置,但不会消耗。断言用来声明一个应该为真的事实,正则表达式中只有断言为真时才会继续进行匹配。

正向:先行断言(?=exp),表示自身出现位置的后面能匹配exp;后发断言(?<=exp)表示自身出现位置的前面能匹配exp。

负向,即正向的反义(不匹配):先行断言(?!exp)表示自身出现位置的后面不匹配exp;后发断言(?表示自身出现位置的前面不能匹配exp。

6. 正则表达式和原始字符串字面量

如果需要正则表达式匹配单个反斜杠\,因为 \ 是正则表达式语法本身的一个特殊字符,应该将其转义为 \\ 。而 \ 在C++字符串字面量中也是一个特殊字符,所以需要在C++字符串字面量中转义,最终得到\\\\。使用C++原始字符串可表示为R"(\\)"。当然在最后还是需要双反斜杠,因为反斜杠在正则表达式本身中需要转义。注:上述如 \d 需实际转义写成"\\d"

(三)regex库

1. 概述

regex库的所有内容都在头文件中和std名称空间中。正则表达式库中定义的基本模板类型包括:

basic_regex,表示某个特定的正则表达式对象,其模板类型参数实例化后的类型的typedef为regex,用来定义正则表达式对象。(regex库支持对应类型的宽字符w类型。)用法如下:regex 变量名(表示正则表达式模式的字符串);

sub_match,表示一个特定的匹配的捕捉组,包含输入序列中的一个迭代器对,第一个迭代器为捕捉组匹配的第一个字符,第二个迭代器为最后一个字符的后一个。它的str()方法把匹配的捕捉组返回为一个字符串。其类型模板实例化的typedef有csub_matchssub_match(C风格和string字符串,还有其宽字符等形式)。

match_results,匹配正则表达式中的子字符串,包括所有的捕捉组(这是sub_match的集合),其模板类型参数实例化的typedef有cmatchsmatch。如下用法:smatch 变量名;。若成功匹配,即正则表达式匹配字符串时会将match_results对象中的元素填入,通过对象的索引来使用,[0]表示整个匹配的字符串或子字符串,[i]表示第 i 个捕捉组,通过查看[i].first[i].second迭代器,可以得到匹配的子字符串在源字符串中的准确位置。如果匹配返回false,就只能调用match_results::empty()match_results::size(),某余内容都未定义。match_results对象还有prefix()方法和suffix()方法,这两个方法分别返回这个匹配之前和之后的字符串。

regex_iteratorregex_token_iterator迭代器模板类型实列化后的类型有cregex_iteratorsregex_iteratorcregex_token_iteratorsregex_token_iterator。对此类选代器而言,只需要通过默认构造函数声明,如:const sregex_iterator 尾名;,就可以获得这个尾选代器,它会隐式地初始化为end值。通过解除引用可得到一个对应的match对象。由于此类迭代器都包含了一个指向正则表达式的指针,因此不能通过临时的regex对象创建它们,其用法如下:sregex_iterator 迭代器名(要匹配字符串的首尾选代器, 正则表达式对象);sregex_token_iterator 迭代器名(要匹配字符串的首尾选代器, 正则表达式[, 表示捕捉组的索引[, Flags]]);。其中捕捉组索引可表示为:一个整数、C风格数组、initializer_list、一个vector,若省略或指定为0时,获得的迭代器将遍历索引为0的的所有捕捉组,即是匹配整个正则表达式的子字符串。可选Flags指定匹配算法,大多数情况默认。token迭代器解除引用得到的是match对象取索引之后的。还可用于执行字段分解或标记化任务(将索引指定为-1时触发),会遍历源字符串中不匹配正则表达式的所有子字符串。

2. 使用

这个库提供了3个关键算法regex_match()regex_search()regex_replace()。其不同重载版本允许将源字符串输入序列指定为:STL string、C风格字符串、字符数组、首尾选代器(可有const char*、string::const_iterator),事实上,任何具有双向迭代行为的迭代器都可用。

用法如下:bool regex_match(输入序列即源字符串[, match_results对象], 正则表达式对象[, Flags]);bool regex_search(输入序列[, match_results对象], 正则表达式对象[, Flags]);。注:不要在循环中通过regex_search()在源字符串搜索一个模式的所有实例,可能由于空匹配陷入死循环,应改用regex_iterator等迭代器。regex_search()算法可在输入字符串中匹配子序列而非整个正则表达式。

regex_replace()算法要求输入一个正则表达式,以及一个用于替换匹配子字符串的格式化字符串。这个格式化字符串可以通过转义序列引用匹配子字符串中的部分内容:$n为匹配第n个捕捉组、$&配整个正则表达式的字符串,即$0$`在输入序列中位于正则表达式左侧的部分,$'右侧部分,$$美元符号,其用法如下:string regex_place(输入序列, 正则表达式, 格式化字符串[, Flags]);,其中输入序列不是迭代器,它接收一系列控制工作方式的标志(在regex_constants::使用),如下:format_default,默认替换所有实列,且将不匹配(即不替换)的内容复制到结果字符中;format_no_copy,替换所有实例,但不复制不匹配的内容;format_first_only,只替换第一个实例。该算法还有一种形式:输出迭代器类型 regex_replace(输出选代器, 输入序列首尾迭代器, 正则表达式对象, 格式化字符串[, Flags]);,它把得到的字符串写入给定的输出选代器,并返回这个输出迭代器。一个替换的例子如:

string resultStr = regex_replace("I am a boy.", regex("boy"), "girl");
cout << resultStr;	// 输出:I am a girl.

十三、其他库工具

C++标准库中其他的库工具,如:std::function、编译时有理数、时间、随机数、元组等。

(一)编译时有理数库

有理数ratio库精确地表示任何可以在编译时使用的有限有理数。相关内容都在头文件中定义,并且在std名称空间中。其编译时特性,使得其比较复杂(每一个有理数其实是一个类型)。ratio“对象”的定义方式需使用typedef且不能调用ratio“对象”的方法,因为它实质上是一个类型。使用如下:typedef ratio<分子常量, 分母常量> 有理数名;,分母默认为1。ratio是一个编译时常量,故分子分母需要在编译时确定,其类型为std::intmax_t的编译时常量(一个有符号的整数,其最大宽度由编译器指定),可以通过如下方式获得有理数的信息:intmax_t 分子名 = 有理数名::num;intmax_t 分母名 = 有理数名::den;。注:由于ratio是编译时常量因此不能直接用于 operator<< 输出,而是要获得分子与分母分别打印。

有理数总是化简的。库支持有理数的加法、减法、乘法、除法运算,由于所有这些操作都是编译时进行的,所以不能使用标准的算术运算符,而是应该使用特定的模板和typedef组合。可用的算术ratio模板包括:ratio_addratio_subtractratio_multiplyratio_divide,这些模板将计算结果作为新的ratio类型,这个类型可以通过名为type的内嵌typedef访问。如加法:typedef ratio_add<有理数1, 有理数2>::type 结果有理数名;

标准还定义了一些ratio比较模板:ratio_equalratio_not_equalratio_lessratio_less_equalratio_greaterratio_greater_equal。与算术ratio模板一样,ratio比较模板也是在编译时求值的,这些比较模板创建了一个新的类型std::integral_constant来表示结果,它是一个struct模板,保留了一个类型和一个编译时常量,如integral_constant保存了一个值为true的布尔值。与其关联的值可以通过value数据成员访向。如小于比较:typedef ratio_less<有理数1, 有理数2> 结果变量名;,可访问如:结果变量名::value

为了方便,这个库还提供了一些SI(国际单位制)的typedef,如deci为1/10、deca为10、centi为1/100、hecto为100等。

(二)时间操作库

chrono库是一组操作时间的库,包含组件:持续时间、时钟、时点。所有很组件都在std::chrono名称空间中定义,且实现在头文件中。

1. 持续时间 duration

持续时间表示时间点之间的间隔。通过模板化的duration类表示。duration类保存了滴答数(tick)和一个滴答的周期(tick period),一个duration表示的持续时间为滴答数乘以一个周期的时间,故duration也可以作为一个时间的单位。滴答周期是两个滴答之间的时间,用多少秒表示,是一个编译时ratio常量。duration接收两个模板参数,定义如:template> class duration { },模板参数Rep表示保存滴答数的变量类型,是一个算术类型如long、double;模板参数Period是表示滴答周期的有理数常量。有默认的构造函数、接收一个滴答数的构造函数、或接收另一个duration(用以转换)的构造函数。默认的如下:duration<滴答数类型, ratio有数理> 变量名;

duration支持算标运算,如+-*/%+++=-=*=/=%=,还支持比较运算符。其方法有count()以滴答数返回duration值,zero()返回持续时间为0的duration。min()max()返回指定模板类数表示的最小最大值。不同单位之间的转换如:新duration类型 转换后 = duration_cast<新duration类型>(旧duration对象);,注:向大单位转换时,由于可能出现小数,因此需将类型参数指定为将点型,指为整型会编译出错。

这个库还以整数类型提供了标准的hoursminutessecondsmillisecondsmicrosecondsnanoseconds类型,由于是整数类型,所以如果单位转换会得到非整值,那么会出现编译器错误。尽管整数除法通常会截断,但在使用通过ratio类型实现的duration时,编译器会将所有可能导致非零余数的计算声明为编译时错误。

C++14之后可以使用标准的用户自定义字面量hminsmsusns来创建duration。

2. 时钟 clock

时钟clock类由time_point和duration组成。标准定义了3个clock类,即:system_clock(系统时钟)、steady_clock(绝不递减时针)、high_resolution_clock(高精度时钟,其滴答周期到达了最小值最精细)。每个时钟类都有一个静态的now()方法,用于把当前时间返回为一个time_point(实际为duration)。注:还有和C风格时间time_t之间的转换等。

3. 时点 time_point

时点time_point类表示的是时间中的一个点,用从纪元(epoch)的到某一时间点所经历duration表示一个时点,它本质上就是一个duration值。如Linux的纪元为1970-1-1 0:0:0。总是和特定的clock关联,纪元就是这个关联clock的原点。其time_since_epoch()函数返回的duration表示这个时点和它的纪元之间的时间。时点声明如下:time_point<时钟类[, duration对象]> 时点名;时钟类::time_point 时点名;,其中时钟类一般指定为上述3个标准的clock类,duration为时间所基于的时间单位。

时间支持有意义的算术操作,例如+-+=-=等。时点可以进行类型转换(即转换时点所基于的时间单位):time_point<时钟类, 新duration类型> 新时点 = time_point_cast<新duration类型>(旧时点);

4. 获得系统当前时间举例

typedef time_point sTimePoint_type;
sTimePoint_type stp = time_point_cast(system_clock::now());
cout << stp.time_since_epoch().count() << endl;
time_t tt = system_clock::to_time_t(stp);	// 转换成C风格时间
char cTime[50];
ctime_s(cTime, sizeof(cTime), &tt);		// 格式化时间
cout << cTime << endl;
// 输出:1582293038 == Fri Feb 21 21:50:38 2020

(三)生成随机数

生成随机数,有C风格的srand()rarnd()函数。C++11添加了一个生成随机数的库,在头文件中,有3个主要组件:引擎(engine)、引警适配器(engine adapter)和分布(distribution)。

C++11定义了以下随机数模板:random_devicelinear_congruential_engineMersenne_twister_enginesubtract_with_carry_engine,其中random_device引擎是一个能连接硬件的引擎,若无连接引擎,则使用基于软件的伪随机数生成器。如下使用:random_device 变量名;,它是一个函数对象,可用变量名();即可得到一个随机数,还有min()max()方法。其余3个引擎都是基于软件的根据数学公式生成随机效果的伪随机数生成引擎,且创建生成器实例时需要指定一些数学参数。这些参数可能会很复杂(有的引擎参数多达17个),参数的选择极大的影响了生成随机数的质量。

因此标准定义了一些预定义的引擎。随机数引擎适配器修改相关联的随机数引擎生成的结果,关联的随机数引擎称为基引擎(base engine)。定义了以下3个适配器模板discard_block_engineindependent_bits_engineshuffle_order_engine。预定义的引擎和引擎适配器有:minstd_randominstd_randmt19937mt19937_64ranlux24_baseranlux48_baseranlux24ranlux48knuth_bdefault_random_engine

随机数分布类模板有:均匀分布、伯努力分布、泊松分布、正态分布、采样分布。它们都有对应的模板,此处例举均匀分布的使用如下:uniform_int_distribution 分布实例名(开始值, 结束值);

在生成随机数之前,首先要创建一个引擎实例。对于基于软件的引擎,要使用种子进行初始化,srand() 的种子常常是当前时间,在C++中,通常使用random_device生成种子,然后传给(预定义)引擎的构造函数,得到一个引擎实例。如:mt19937 引擎实例名(种子);,然后将引擎实例作为参数传给名为一个分布,就可以生成随机数了,如:分布实例名(引擎实例名);。为了通过基于软件的引擎生成随机数,总是需要指定引擎和分布,可以使用std::bind()函数绑定器将其合为一块。

mt19937 eng(random_device().operator()());
uniform_int_distribution dis(0, 100);
auto ran = bind(dis, eng);
for (int i = 0; i < 10; cout << ran() << " ", ++i);
// 一个可能的输出:25 89 48 18 99 29 15 78 93 6

(四)元组

C++还有std::tuple类,在头文件中定义,它是pair的泛化,允许存储任意数量的值,每个值都有已特定的类型,和pair一样tuple的大小和值类型都是编译时确定的,都是固定的。tuple中还可以包含其他容器。

tuple可以通过tuple构造函数创建,指定模板类型和实际值,如下:tuple<模板类型实参列表> 元组名(实参列表);,可以使用typedef或using。创建tuple的另一种方法是使用std::make_tuple()工具函数,只需要实际值,在编译时会自动推导出类型。由于类型的自动推导,故要包含引用或const引用的元素时,要使用中的ref()cref()工具函数,用法如:auto 元组名 = make_tuple(实参列表);

std::get<索引>(元组名);用以从一个元组中获得索引位置上的元素,其返回值的类型是tuple那个索引位置上元素的类型,C++14允许根据类型使用std::get<类型>(元组名);从元组中提取元素,不过同类型元素有多个时,编译器会生成错误。可以通过std::tuple_size<元组类型>::value模板来查询tuple的大小,注要求指定的是tuple的类型(typedef或using别名),而不是实际的tuple实例。还有定义了一个std::tie()函数,可以将一组变量生成一个引用的tuple,然后用一个元组对这些变量一次性赋值。std::tuple_cat()可以将两个元组连接在一起(串联)。tuple还支持6个比较运算符(元组的元素也需要支持这些操作)。遗憾的是,并不能方便的遍历tuple的值,不能编写简单的循环,执行类似于 get(元组名); 这样的调用,因为 i 的值必须在编译时确定。


十四、C++多线程编程

C++多线程编程,自C++11引入了标准线程库,其在头文件中,命名空间std中。多线程所面临的问题有:竞争条件(当多个线程要读写共享内存位置时,若指令交错执行时,得到的结果会不同,某些操作的结果可能会丢失);死锁(无限待机);撕裂(tearing,某线程正在修改数据的同时,其他线程正好操作这个数据);缓存的一致性(在多核处理器上有缓存和缓存结构的CPU是很复杂的,如果一个核心修改了数据,这些数据在其缓存中会立即改变,但这改变不会立即呈现给使用另一缓存的核心,故在读写多个线程时,即使是简单的数据类型,也需要同步)。

线程池中所有线程都是预先存在的,因此操作系统调度这些线程运行的效率大大高于操作系统创建一个线程响应输入的效率,有几个库实现了线程池,如TBB、PPL等。

尽量确保线程设计最佳实践。

(一)线程基础概述

1. 启动线程

std::thread模板类的构造函数是一个可变参数长度的模板,其通过函数指针等来创建一个新的线程对象,如下:thread 线程名(函数指针[, 参数表]);,可以让新的线程执行全局函数、函数对象、lambda表达式、某个类实例的成员函数等。在启动线程后,需使用线程对象的方法如线程名.join();让调用者线程一直运行(通常为main主线程)直到线程执行完毕。否则其调用者线程在执行完所有指令后会立即结束执行,由其加载的子线程也会终止,不论这些线程是否结束执行。注:在真实的应用程序中,应该避免使用join(),因为会导致调用join()的线程阻塞。通常而言其调用者线程本身有一个消息循环来处理消息,也可以从线程接收消息,并做出响应。这些都不需要通过join()调用来阻塞调用者线程。

线程函数的参数总是复制到线程的某个内部存储中,通过头文件中的std::ref()按引用传递参数。

当使用函数指针技术,向线程传递信息的唯一方式是向函数传递参数;而使用函数对象可向函数对象类添加成员变量,并可以采用任何方式初始化使用这些变量。注:若函数对象构造函数不需要任何参数,则 thread 线程名(构造函数()); 会导致一个编译错误,C++会将其解释为“线程名”函数的声明,这个函数接收一个函数指针(指向返回一个函数对象的无参函数),返回一个thread对象,正确应为:thread 线程名((构适函数()));,或先创建一个对象名再传入,或使用统一初始化语法:thread 线程名(构造函数{});

可以通过成员函数创建线程,如下:thread 线程名(&类名::方法名, 对象名);以这种技术可在不同线程中执行某个对象中的方法,其中对象名可为this指针。lambda表达式也能很好地用于C++标准线程库。

可以使用std::this_thread::sleep_for(std::chrono::duration对象);在线程中引入一小段延迟,即睡眠。

2. 取消线程

在一个线程中取消另一个运行线程的最简单机制为设置一个共享变量,目标线程定期检查这个变量,判断是否应该终止,其他线程可以设置这个共享变量,间接指示线程关闭。

3. 从线程获得结果

一种方法是向线程传入一个结果变量的指针或引用,计算线程将结果保存在其中;另一种方法是将结果存储在函数对象的类成员变量中,线程执行结束后可以获得结果值;一种更简单的方法是future。

4. 线程本地存储

标准支持线程本地存储的概念。通过thread_local关键字,可将任何变量标记为线程本地数据,即每个线程都有这个变量的独立副本,
而且这个变量能在线程的整个生命周期中持续存在。对于每个线程,该变量正好初始化一次。声明如下:thread_local 类型 变量名;。注:如果thread_local变量在函数作用域中声明,那么这个变量行为和声明为静态变量是一致的,只不过每个线程都有自己独立的副本,而且不论这个函数在线程中调用了多少次,每个线程仅初始化一次这个变量。

5. 异常机制和多线程

异常机制和多线程结合在一起时,希望一个线程中抛出的异常在另一线程中捕获,需要特殊的方法。exception_ptr current_exception() noexcept;,函数返回当前正在处理的异常或其副本的引用对象,若无处理异常则返回空的exception_ptr对象,可以将其赋给一个线程作为结果传入的一个引用类型的exception_ptr对象,通过计判断结果是否为空,来执行[[noreturn]] void rethrow_exception(exception_ptr p);,这个函数重新执行抛出由exception_ptr参数引用的异常,重新抛出的异常不要求在最开始产生这个引用的异常的那个线程中,因此这个特性特别适合于跨不同线程的异常处理。[[noreturn]]特性表示这个函数绝不会正常返回。template exception_ptr make_exception_ptr(E e) noexcept;,函数创建 一个引用给定异常对象副本的exception_ptr对象。实际上是以下的简写形式:try { throw e; } catch(...) { return current_exception(); }

(二)原子操作库

原子操作库头文件为。指令在操作数据时,编译器首先将值从内存加载到寄存器中,执行操作,再把结果保存回内存。不同线程可能在其他线程操作数据的执行操作阶段接触到该内存,导致一个竞争条件。而对原子类型执行操作时,会在一个原子事务中加载值、操作值并保存值,这个过程不会被打断。

声明原子类型的变量如下:atomic<类型> 变量名[(初始值)];,标准为基本类型定义了命名的整型原子类型,如atomic_ullong等价于atomic。在多线程中访向一段数据时,原子也可以解决缓存一致性、内存排序、编译器优化等问题。注:应试着最小化同步次数,包括原子操作和显式同步,以减少性能开销。

标准定义了一些原子操作,这里列举一部分。如:bool atomic_compare_exchange_strong(atomic* object, C* expected, C desired);bool atomic::compare_exchange_strong(C* expected, C desired);。 还有一些原子整型支持的方法:fetch_add(x),(将x递增给原子类型的当前值,返回为未变化的原值)、fetch_sub()fetch_and()fetch_or()fetch_xor()++--+=-=&=^=!=。原子指针类型支持fetch_add()fetch_sub()++--+=-=

大部分原子操作可以接受一个额外的参数,用于指定想要的内存顺序,例如:T atomie::fetch_add(T value, memory_orde = memory_order_seg_cst);,标准提供了一些memory_order,不过推荐使用默认,因为稍有不当之处,有可能会再次引入竞争条件或其他和线程相关的很难跟踪的问题。

(三)同步机制

若不能避免数据共享,那么必须提供同步机制,使同一时间一次只有一个线程能更改数据。基本数据类型等标量常常使用前述原子操作,能够很好地同步,但当数据更复杂,且必须在多个线程中使用这个数据时,必须提供显式同步机制。

1. 互斥体

互斤体类在头文件中。

非定时的互介体类mutexrecursive_mutex支持:lock()try_lock()unlock()

定时的互斤体类timed_mutexrecursive_timed_mutexshared_timed_mutex支持:lock()try_lock()unlock()try_lock_for()try_lock_until()。且shared_timed_mutex还支持:lock_shared()try_lock_shared()try_lock_shared_for()try_lock_shared_until()unlock_shared()

创建一个互斥体对象如:互斥体类 变量名;

2. 锁

锁类是一个RAII(resource acquisition is initialization)类,用于更方便地正确获得和释放互斥体上的锁,锁类的析构函数会自动释放关联的互介体。标准定义了3种锁:lock_guardunique_lockshared_lock,(独占unique拥有权也称写入锁、排它锁,共享shared拥有权也称读取锁、共享锁)。其构造函数接收一个mutex_type& m,还可以接收adopt_lock_t类型、defer_lock_t类型、try_to_lock_t类型的实例作为可选参数。

unique的owns_lock()或bool转换符可以确定是否获得了锁。其构造函数还可以接收chrono::time_pointchrono::duration实例来指定等待阻塞的时间,shared_lock与共相同。

都有如下:lock()try_lock()lock_for()try_lock_until()unlock()等方法,不过其底层所关联的mutex所调用的方法不同。有两个可变参数的泛型函数,用于同时获得多个互斤体上的锁,而不会出现死锁,都在std中,如:lock(xxx)try_lock()

锁用在一个函数中或方法中开头的第一行(即定义一个锁对象,自动析构);函数对象类应该有一个互斥体成员数据(可以为静态)。锁的作用域是一个代码块,即锁所在的花括号。

3. 确保函数或方法只执行一次

确保某函数或方法只调用一次,如下:once_flag 变量名(全局);call_once(变量名, 函数名);,即在once_flag实例上调用有效在其他后续调用的之前完成,同一个once_flag实例上调用call_once()的其他线程会阻塞,直到有效调用结束。

可以使用锁来实现双重检查锁定(double-checked locking)模式,尽量避免使用这个模式,而改为其他机制如简单锁、原子变量、call_once()。

4. 条件变量

条件变量在头文件头文件中,允许一个线程阻塞,直到另一个线程设置了某个条件或系统时间到达了某个指定的时间。有类:condition_variable只能等待unique_lock的条件变量。condition_variable_any有方法notify_one()唤醒一个等待这个条件变量的线程、notify_all()唤醒所有等待的线程。还有方法wait(Look&)wait_for(锁, 时间)wait_until(),还可以接收一个额外的谓词函数回调。

等待条件变量的线程可以在另一个线程调用notify_one()或notify_all()时被唤醒,或在超过给定时间后面醒来。注:应使用同一个条件变量。标准还定义了一个辅助函数std::notify_all_at_thread_exit(条件变量, 锁);,当线程退出时唤醒所有等待的线程。

5. 示例:多线程日志记录器类

Logger类实现如:

// Logger.h
#pragma once
#include 
#include 
#include 
#include 
#include 
#include 

class Logger {
private:
	std::atomic mIsExited;	// 标志 是否退出
	std::queue mMessageQueue;	// 消息队列
	std::mutex mMutex;	// 互斥体
	std::condition_variable mCondVar;	// 条件变量
	std::thread mThread;	// 后台线程
public:
	Logger() : mIsExited(false) {
		mThread = std::thread(&Logger::process, this);	// 构造函数启动一个后台线程等待线程
	}
	Logger(const Logger&) = delete;	// 删除复制构造函数和赋值运算符
	Logger& operator=(const Logger&) = delete;
	
	virtual ~Logger() {	// 析构函数时将消息队列中还有的消息写出
		{
			std::unique_lock lock(mMutex);	// 上锁
			mIsExited = true;
			mCondVar.notify_all();	// 唤醒所有等待 porcess() 的线程,即后台线程
		}
		mThread.join();	// 等待 process() 后台线程执行完毕
	}

	void log(const std::string& msg) {
		std::unique_lock lock(mMutex);
		mMessageQueue.push(msg);	// 将消息放入队列
		mCondVar.notify_all();	// 唤醒后台线程
	}
private:
	void process() {
		std::ofstream ofs("log.txt");	// 打开文件流
		if (ofs.fail()) {
			std::cerr << "Failed to open logfile." << std::endl;
			return;
		}
		std::unique_lock lock(mMutex);
		while (true) {	// 消息响应处理循环
			if (!mIsExited) {
				mCondVar.wait(lock);	// 若mIsExited为假,即正常运行时,等待 log() 唤醒
			}
			lock.unlock();	// 释放锁
			while (true) {	// 在每次迭代中 上/放锁,确保并发性
				lock.lock();	// 在内循环中建锁,一条一条处理消息队列中的消息
				if (mMessageQueue.empty()) break;
				else {	// 队列非空,写入一条消息进文件
					ofs << mMessageQueue.front() << std::endl;
					mMessageQueue.pop();
				}
				lock.unlock();
			}
			if (mIsExited) break;
		}
	}
};

一个测用例如:

#include 
#include 
#include 

#include "Logger.h"

using namespace std;

void func(int id, Logger& logger) {
	// do something else.
	for (size_t i = 0; i < 74; ++i) {
		stringstream ss;
		ss << "The No." << id << " thread's test for the " << i << " times to call Logger's log.";
		logger.log(ss.str());
	}
}

void Test() {
	Logger logger;
	vector myThreads;
	for (size_t i = 0; i < 10; ++i) {
		myThreads.emplace_back(func, i, ref(logger));
	}
	for (auto& aThread : myThreads) {
		aThread.join();
	}
}

int main() {
	Test();
	return 0;
}

(四) 线程间通信

根据前面的讨论,通过std::thread启动一个线程,计算得到一个结果,当线程结束执行时不容易取回计算的结果。而且如果一个线程抛出一个异常,而这个异常没有被线程本身处理,C++运行时将调用std::terminate,这通常会终止整个应用程序。可以通过std::future避免这一点,std::future能将未捕捉到的异常转移到另一个线程中,然后另一线程可以任意处置这个异常。当然应该总是尝试在线程本身中处理异常,不应该让异常离开线程。在头文件中。使用future的一大优点是它们会自动在线程之间传递异常。在future上调用get()时,会检索请求的结果,否则就会在调用get()的线程中重新抛出原线程中出现的异常,这些异常可以使用普通的try/catch块捕捉。

1. future/promise

结合使用std::futurestd::promise更容易获得同一线程或另一个线程中的函数返回的结果。一旦在同一线程中或在另一个线程上运行的函数计算出希望返回的值,就把这个值放在promise中,然后通过future来获取这个值。可将future/promise对想象成线程间传递结果的通信通道。其为模板类。

promise是结果的输入端,future是输出端。promise是线程存储计算结果的地方,父线程应该把promise对象传递给新创建的线程,新线程可以将果保存在这里,使用如下:promise 输入对象名; 输入对象名.set_value(结果值);,也可以将异常保存在promise中,如:输入对象名.set_exception(异常对象);。用future来获取结果,如:future 输出对象名; T 结果变量名 = 输出对象名.get();,调用get()取出结果,并保存在变量中,调用get()时将阻塞,直到新线程结果值被设置,可加if条件判断避免阻塞:if(输出对象名.wait_for(0)) { T 结果变量名 = 输出对象名.get(); }

2. packaged_task

使用std::packaged_task自动将future和promise联系在一起,其构造函数接收一个函数指针等,可用方法get_future()从packaged_task获取future。用法如下:package_task<函数返回类型(形参类型列表)> 对象名(函数指针); auto 输出对象名 = 对象名.get_future(); 对象名(函数实参); 函数数返回类型 结果变量名 = 输出对象名.get();

可以定期使用wait_for()检查future中是否有可用的结果,或使用条件变量等同步机制。当结果不可用时,可做其他事情,而不是阻塞。

3. async

使用std::async()接收一个要执行的函数,并返回用于检查结果的future。async()通过两种方法调用提供的函数:创建一个新的线程,异步运行提供的函数;或在返回的future调用get()时运行提供的函数。如果没有额外的参数来调用async(),运行库会根据一些因素(例如系统中处理器数目和并行数目)从两种方法中自动选择一种方法。

也可以指定策略参数:launch::async表示创建新线程、launch:deferred表使用当前线程,来强制运行时选择第一种方法或第二种方法。如:auto 输出对象名 = async(函数名); 结果变量名 = 输出对象名.get();。std::async()是在不同线程或同一线程中执行一些计算并在随后获得结果的最简单方法之一。调用async()锁返回的future会在其析构函数中阻塞,直到结果可用为止。


十五、后记

编写高效的C++程序。性能:速度、内存使用、磁盘访向和网络使用。效率:程序运行时不要做无用功。

语言层次的效率:按引用传示、按引用返回、通过引用捕捉异常、使用移动语义、避免创建临时对象、返回值优化、使用内联方法和函数。

设计层次的效率:使用最优的算法和数据结构,尽可能多地缓存。如果任务或计算特别慢,应该确保不会执行不必要的重复计算,第一次执行任务时将结果保存在内存中,使这些结果可以用于未来的需求。

磁盘访向:在程序中应该避免多次打开和读取同一个文件。如果内存可用,且需要频繁访问这个文件,应将文件的内容保存在内存中。

网络通信:如果需要经由网络通信,那么程序会受网络负载的影响而行为不定,将网络访向当成文件访向处理,尽可能多地缓存静态信息。

数学计算:如果需要在多个地方使用一个非常复杂的计算结果,那么执行这个计算一次并共享这个结果。但是,如果计算不是非常复杂,仅计算它可能比缓存中提取更快。如果需要确定这种情形,可使用分析器。

对象分配:如果程序需要大量创建和使用短期对象,考虑使用对象池。

线程创建:这个任务也很慢,可以将线程“缓存”在线程他中,类似于在对象地中缓存对象。

缓存失效:若用户在程序运行时更改源信息,这会使缓存版本的信息过和期。这种情况下需要“缓存失效”机制:当底层数据发生变化时,必须停止使用缓存的信息,或重新填写缓存。缓存失效的技术之一是要求管理底层数据的实体通知程序发生了变化。可以通过程序在管理器中注册一个回调的方式实现这一点。另外,程序还可以轮询某些会触发自动重新填充缓存的事件。根据分析器说明是一个性能瓶颈时,添加该领域的缓存。环形缓冲区。

最好一开始就建立一个清晰、结构良好的设计和实现方案,再使用分析器,仅优化分析器标记为性能瓶颈的部分。90/10法则:大部分程序中90%的运行时间都在执行10%的代码。这意味着可能优化了90%的代码,但是程序运行时间只改进了10%,故需要优化典型负载下程序中运行最多的部分。因此,需要剖析(profile)程序,判断哪些部分的代码需要优化。有很多可用的剖析工具,可以在程序运行时分析它们,并生成性能数据。大部分剖析工具都提供了函数级别的分析功能,可以分析程序中每个函数的运行时间(或总执行时间的百分比)。在程序上运行剖析工具之后,通常可以立即判断出程序中的哪些部分需要优化。优化之前和之后的剖析也有助于证明优化是否有效。

调试技术、bug分类学、为bug做好规划、错误日志、调试跟踪。调试模式:启动时调试模式、编译时调试模式、运行时调试模式。调试可重现的bug、调试不可重现的bug、调试退化、调式内存问题(释放堆栈上内存通常引起程序崩溃等)、调试多线程程序。

你可能感兴趣的:(C++高级编程(第3版)_学习记录)