作为 C++ 标准在这个十年中的第一个大更新, C++11 标准 (定稿前还被称作 C++0x) 中添加了非常多的新特性, 有一些从根本上改良了编程模式, 有一些能大幅简化代码, 而有一些明确了在以前标准中没有定义的行为. 标准中包含的内容林林总总, 以至于 C++ 之父 Bjarne Stroustrup 说 C++11 像一门全新的语言 ("C++11 feels like a new language"), 笔者对此深有同感, 其引入的特性之多, 不同特性之间互相影响, 不免令人觉得纷繁.
然而, 这种纷繁又不同于既有 C++ 标准中一些令人诟病的部分 (如隐式生成复制构造函数, 模板编译错误冗长等), 相反, 它们切实地改正了现有语言中不利于使用, 不利于效率的特性, 使这门历史悠久的语言展现出现代化的新气象.
笔者按照自己的思路对 C++11 的一些内容进行整理, 结合分析 STL 实现源码的方式, 分类介绍新标准中的各个特性, 希望本书能为深入了解 C++11 标准提供参考.
本书会首先介绍一些较为简单的特性 (如 static_assert
, nullptr
) 或较为独立的特性 (如类型推导), 它们中大部分也广泛出现在其他更重量级的特性中, 在开篇中先浏览这些部分有助于对后文的理解. 不过也因此前三章的内容组织会更为松散一些.
之后介绍的各个核心特性将以话题的方式编排, 每个特性为一个章节. 不过, 各个章节之间的内容仍然有依赖性, 因此还请读者按顺序阅览.
而如正则表达式, 随机数等库的引入虽然也属于 C++11 新添内容, 然其领域性较强, 与语言本身关系不大, 若逐个 API 进行介绍不免过于乏味, 故本书中不会包括这些内容.
对于特性的介绍, 笔者更希望谈及的是为何引入某个特性 (如移动语义等部分), 其次是某个特性的 API 设计 (如多线程部分), 以及如何使用或实现某个特性 (如可变参数模板部分). 分析特性时当然会在使用示例代码, 届时会优先使用 STL 源代码中的实现; 其他情况下将会使用一些公有领域在线资料给出的示例, 或自行编制的代码.
使用 STL 源代码自然是考虑其权威性, 集 C++ 实现之大成. 但若深究其各个 API 的实现, 又不免过于繁琐.0 故书中引用 STL 实现将跳过无关部分, 只取对介绍特性或运用特性相关的代码, 同时为了保持代码原意, 对源代码不进行除了重新排版之外的改动, 但这样不免会出现之后章节才会介绍的特性或关键字, 这种情况下会先给出一个简单交代. 另外为了准确描述, 会将一些内容以注释的形式写在代码上下文中.
本书中所引用的 STL 源代码来自 Ubuntu 14.04 LTS desktop-amd64 / GCC 4.8 提供的实现, 位于 /usr/include/g++/4.8 目录下. 引用时, 会以相对此路径的 "文件名:行号" 格式给出源码位置, 同一节中多次出现同一文件中的代码, 在第二次及之后只写出 ":行号". 书中示例若无特别说明, 亦皆在 64 位 Ubuntu 系统下以 gcc 4.8 编译, 编译参数为 -std=c++0x -pthread
.
本书不是 C++ 入门书籍或参考手册, 并不会介绍 C++ 基本语法或者各个标准库 API 的详细使用方式. 编写本书时, 会假定读者已经具备 C++ 基本语法知识, 以及对对象的构造与析构, 函数重载, 模板定义和模板特化这些 C++11 标准之前已存在的基本特性有基本了解.
第一章的内容可以帮助任何想对 C++11 有初步了解的读者快速上手一些简单特性. 这一章中将包含特性简介和示例, 由于这些特性本身并不复杂, 读者会发现它们能立即运用到手头工作中去.
接下来的章节的难度会有所上升, 除了介绍和用例之外, 将增加一些 STL 中的代码, 以演示标准库的容器或工具类型如何运用这些特性. 对于 C++ 库的作者来说, 实现方式相关内容会更有帮助.
使用其他面向对象或过程式编程语言如 Java 或 Python 的读者如果想简单了解 C++11 中新特性解决了什么问题, 除第一章外, 不妨还阅读右值引用与移动语义部分, 以及最后一章多线程部分的内容, 前者将说明 C++ 这门语言与其他语言的不同之处, 并因此需要借助移动语义这样的机制, 而后者则可以作为一个对比, 说明 C++ 中的线程接口设计的独特性.
一些语言概念经常会因为翻译不同而产生不一样的术语, 这里简要说明在书中会出现的术语.
英文 | 中文 | 概念简述 |
---|---|---|
base / parent class | 基类 / 父类 | 类的继承关系中, 被继承的一方 |
derived / sub class | 派生类 / 子类 | 类的继承关系中, 继承的一方 |
lvalue / left value | 左值 | 赋值时等号左侧的对象; 在 C++ 中也可以理解为 locator value, 即为指向一个非临时对象的引用 |
overload | 重载 | 同名函数以不同个数或不同类型参数区分 |
override | 覆盖 | 子类重新实现父类中定义的相同签名的函数 |
virtual | 虚 | 多态需要运用的虚函数, 虚继承机制 |
const and volatile qualifier |
cv 修饰符 | 对象定义时可能使用的 const 或 volatile 修饰的统称 |
template | 模板 | 将类型或其他整数型特性参数化的机制, 可应用于类或函数 |
specialization | 特化 | 将类型参数或整数型参数代入模板, 形成具体类型或函数 |
partial specialization | 偏特化 | 为代入部分或全部模板参数的模板类提供特殊定义的机制 |
namespace | 名字空间 | 标识符定义的一种作用域 |
undefined behaviour | 未定义行为 | 不正确或遗漏边界条件的程序编写方式, 使得代码可以通过编译, 但其一定条件下的运行时行为无法预测 |
trivial / non-trivial | 平凡 / 非平凡 | 类型实例在复制时是否需要执行除了复制各成员的值之外的代码, 析构函数非虚函数且不执行任何代码; 通常这些代码用以处理实例内含的一些系统资源, 如堆内存等 |
一些语言关键字相关的概念, 不翻译, 直接使用英文单词, 包括
public / protected / private |
访问或继承限定 |
const |
不可更改限定 |
new / delete |
对象创建, 销毁 |
typename |
用于模板的类型名参数 |
nul | 字符串字面量取值为整数 0 结束符 |
C++ 标准制定时历来有一种习惯: 如果能用库来做的事情, 就不要设计成语言本身的特性. 结果, 一些很基础的功能诉求或是需要晦涩的固定用法, 或被实现成数十行的复杂代码, 对初学者都很不友好.
为了不在 "解决其他语言不存在的问题" 的歧途上越走越远, C++11 语言本身引入了几项实用的编译器功能, 让代码写起来能更加直观易懂.
本章将介绍这些 C++11 中引入的语言语法和功能改进. 其中有些修正了既有标准中一些不合理之处, 让代码更加易懂; 有些能简化实现, 使得用户编写出更简明的代码. 这些改进创造了一个更加友好的 C++ 语言.
作为较早引入 "指针" 这一概念的语言, C 和 C++ 中在处理空指针的时候却并没有起到表率作用. 用户一般会用 NULL
来表示空指针, 但它并不是关键字, 而是一个宏. 在 C 中它通常被定义为 (void*)0
, 而 C++ 中因为禁止 void*
指针类型向其他指针类型隐式转换, 因此在既有的编译器实现中它通常被定义为整数字面量 0, 或是其它不那么明确的指针常量.
这是个很容易让编译器产生误解的规则. 比如在 C++ 中以 NULL
作为实参时, 可能使得重载决议出错
void f(int x) { std::cout << "overload int" << std::endl; }
void f(int* p) { std::cout << "overload int ptr" << std::endl; }
f(NULL); // NULL 被定义为 0 时输出 "overload int", 而另一些实现会报重载决议错误
// 而不会直接决议为对 f(int*) 的调用
在 C++11 中引入新的空指针关键字 nullptr
来解决这一问题. 不过, 考虑到向前兼容性, NULL
宏的定义并没有被直接改成这一关键字, 要使用它的话必须写上 nullptr
这一名字. 如, 修改上面的代码为
f(nullptr); // 输出为 overload int ptr
nullptr
不仅仅可以表示空指针的值, 它还有一个独立的类型 std::nullptr_t
. 当然这一类型的所有实例都相同, 即都是空指针.
空指针类型并不很常用. 它允许引入针对空指针类型的重载. 如
void f(int* p) { std::cout << "overload int ptr" << std::endl; }
void f(std::nullptr_t) { std::cout << "overload nullptr" << std::endl; }
int main()
{
f(nullptr); // 输出 overload nullptr
int* p = nullptr; // 虽然 p 是空指针, 但其类型是 int*
f(p); // 输出 overload int ptr
// 可以使用 nullptr_t 定义对象实例, 这些实例都是空指针
std::nullptr_t q;
f(q); // 输出 overload nullptr
return 0;
}
C++ 受到诟病的一点是每个类型都会有一个复制构造函数, 即使用户不编写之, 编译器也会合成一个. 而某些类型, 比如文件流 fstream
又是不可复制的. 在既有标准中, 用户想到各种方法来应对这一编译器 "特性", 典型的做法是将复制构造函数声明为 private
访问限制的, 且只声明而不实现它. 如
struct NonCopyable {
NonCopyable() {}
private:
NonCopyable(NonCopyable const&); // 声明为 private, 没有实现
};
int main()
{
NonCopyable n;
NonCopyable m(n); // 编译错误
return 0;
}
// 报错信息为: 此类型的复制构造函数为私有
// error: ‘NonCopyable::NonCopyable(const NonCopyable&)’ is private
这种封印的手法的问题一是报错词不达意, 二是偶尔在类的内部出现误用时 (类的内部可是能够使用 private
复制构造函数的), 它将导致链接错误而非编译错误, 而链接错误往往又是无法看到源代码行号的.
C++11 标准中打算收拾掉这一乱象, 给出一个简单的方案让用户可以明显地指出某个函数不需要了. 如
struct NonCopyable {
NonCopyable() {}
NonCopyable(NonCopyable const&) = delete;
};
NonCopyable n;
NonCopyable m(n); // 编译失败, 报错为复制构造函数被删除了
// use of deleted function ‘NonCopyable::NonCopyable(const NonCopyable&)’
也就是在需要删除的函数后加上一个小尾巴 = delete
, 这样此类型就无法复制构造了. 当然这只是在语法上限制了复制行为, 有关无法复制的类型更详细的特性, 本书将在 "移动语义" 一章将进行讨论.
这一机制除了可以用来禁止合成复制构造函数之外还可以用来取消掉由基类继承而来的函数, 或防止类型转化重载, 如
struct Base {
void print()
{
std::cout << "Base" << std::endl;
}
};
struct Inherit: Base {
// 在子类中声明父类中出现过的 print() 函数
// 并标记为 delete, 那么无法从子类对象调用此函数
void print() = delete;
};
void f(int x) {}
void f(char ch) = delete; // 标记 char 为参数的重载为 delete
int main()
{
Base b;
b.print();
Inherit i;
i.print(); // 编译错误: print 在子类中被标记为 delete 了
f(10); // 正确: 匹配参数为 int 的重载
f('c'); // 编译错误: 匹配为参数为 char, 但被删除掉的重载
// 如果没有 f(char) 的声明, 参数将被扩宽为 int 匹配 f(int) 的重载
return 0;
}
另一可能的需求是防止类型被继承, 在 C++03 中的技巧是将构造函数定义为 private
限定, 这样子类无法构造父类部分. 而后对外暴露工厂函数以产生对象. 如
struct DontInherit {
// 对外暴露工厂方法产生实例
static DontInherit construct()
{
return DontInherit();
}
private:
// 将自身构造函数设为私有的
DontInherit() {}
};
struct SubClass: DontInherit {
// 在子类中, 这一无参默认构造函数将不会被隐式生成
// 手动加上此构造函数也无法编译
// SubClass() : DontInherit() {}
};
int main()
{
// DontInherit 的实例化要借助于工厂方法
DontInherit d = DontInherit::construct();
// 但子类将无法实例化, 此处编译失败
SubClass s;
return 0;
}
在 C++11 中, 不允许类型被继承这一特性直接被编译器所支持, 新引入的关键字为 final
, 与 Java, C# 等语言中的基本一致 (除了关键字摆放的位置). 它除了可以禁止类型被继承, 也能禁止单一虚函数被覆盖. 其语法如下
struct Animal {
virtual void say() = 0;
virtual void breed() = 0;
virtual ~Animal() {}
};
struct Bird : Animal {
// 在虚函数后加上 final 关键字, 该虚函数无法被子类覆盖
void breed() final { std::cout << "lay eggs" << std::endl; }
};
// 在类名后加上 final 关键字, 该类无法被继承
struct Duck final : Bird {
void say() { std::cout << "ga" << std::endl; }
// 编译错误: 基类的 breed 是 final 的
void breed() {}
};
// 编译错误: Duck 是 final 的
struct DonaldDuck : Duck {};
这一特性有助于编译器执行优化. 若子类将其某个虚函数标记为 final
修饰, 或者该子类本身是 final
修饰的, 那么这些成员函数的调用将被编译器视为非虚函数调用, 这样就不必在运行时去查找虚表了.
除了 final
关键字之外, 另一与虚函数有关的关键字 override
可以用来确定当前定义的函数是否覆盖了父类的虚函数. 这一关键字不是必须的, 加上的话有助于检查虚函数签名的正确性.
struct Animal {
virtual void say() = 0;
virtual void breed() = 0;
virtual ~Animal() {}
void drink() {}
};
struct Bird : Animal {
// 正确: 此函数覆盖了父类中声明的 void breed() 函数
void breed() override { std::cout << "lay eggs" << std::endl; }
// 编译错误: 此函数签名与父类中的 breed 函数不同, 多了 int 参数
void breed(int x) override
{ std::cout << "lay " << x << " eggs" << std::endl; }
// 编译错误: 此函数签名与父类中的 breed 函数不同, 多了 const 限定
void breed() const override
{ std::cout << "lay eggs" << std::endl; }
// 编译错误: 父类中的 drink() 函数并不是虚函数
void drink() override {}
};
在 C++03 标准中, 另一由于没有编译器支持而实现得很繁琐的简单需求是编译时断言, 在 C++11 中则直接将关键字 static_assert
(C 语言中为 _Static_assert
) 加入语言本身, 由编译器来检查某个编译时条件是否满足. 其语法如下
// 在编译时常量 BOOL_CONSTANT 为 false 时给出一个编译错误, 错误信息为 ERROR_MESSAGE
static_assert(BOOL_CONSTANT, ERROR_MESSAGE);
// 例
static_assert(true, "this is ok");
static_assert(false, "cause a compile error"); // 此行会产生编译错误
static_assert(sizeof(int) == 4, "int is not 4 bytes");
C++03 中, 在函数签名中可以增加一个 throw(except_a, except_b, /* ... */)
的抛出声明, 表示函数只会抛出这些异常类型, 而一个空的 throw()
表示函数不会抛出异常. 如
void f() throw(std::runtime_error); // f 可能抛出 std::runtime_error, 不可能抛出其他类型异常
void g() throw(); // g 不会抛出任何异常
而 C++11 中新增了关键字 noexcept
, 其功能之一是将上述抛出声明简化为二元形式, 即可能抛出, 或不可能抛出. 其语法形式是在函数签名最后加上 noexcept(bool 常量)
, 该参数 bool
常量表示此函数是否不会抛出异常, 这一常量默认为 true
(但如果根本不写 noexcept
的话, 表示该函数可以抛出异常).
void f() noexcept(true); // f 不会抛出异常
void g(int) noexcept(false); // g 可能抛出异常
void h() noexcept // h 不会抛出异常; 这时不需要圆括号
void k(); // 什么也不写: k 可能抛出异常
声明一个函数不会抛出异常有利于编译器生成更简单的可执行文件. 但如果一个带有 noexcept(true)
声明的函数中直接或间接抛出了异常, std::terminate
会被立即调用, 默认情况下意味着进程将马上结束.
该关键字的另一作用是在编译时评估一个函数调用是否会抛出异常, 并返回一个 bool
值常量. 这个函数表达式只在编译时使用, 不会被运行求值, 类似 sizeof
一样.
// 沿用上面的定义
std::cout << noexcept(f()) << std::endl; // 1
std::cout << noexcept(g(0)) << std::endl; // 0
将这两者结合起来, 可以实现一个需求: 如果函数 X 中只调用了函数 Y, 那么函数 X 是否抛出异常取决于 Y 是否抛出异常. 写成代码是这样的
void f() noexcept;
void g() noexcept(noexcept(f()))
// ^^^^^^^^^^^^^ f 不会抛出异常, 故此表达式为 true
// noexcept( true ) 因此这里等价于声明此函数不抛出异常
{
f();
}
template <typename T> // 泛型中的运用
void h() noexcept(noexcept(T())) // h 函数是否抛出异常取决于 T 类型构造时产生异常
{ /* ... */ }
这是一个美好的愿景, 但在实际运用时, 如果要将一个函数所有调用的其他函数都写进 noexcept
声明中的话, 推广这一声明是极其困难甚至不可能完成的. 最坏的情况下甚至可能在 noexcept
里的部分相当于把整个函数重新实现一遍.
因此, 实际上只有一些很简单的函数才会加上这一声明. 另外, 析构函数默认都带有 noexcept(true)
声明.
需要注意的是, 使用 noexcept(true)
以及 throw()
声明都不会强制编译器检查函数是否真的不会抛出异常, 比如以下代码
void f(int x) noexcept
{
if (x < 0) {
throw std::out_of_range("");
}
}
虽然在函数体中很明确地有一句 throw
, 但是编译器并不会在处理这段代码时报错.
在 C 和 C++ 里有两类循环语句, 一是 while
或 do
-while
, 另一是 for
循环. 相对于 while
系的按单一条件循环的语法, for
的不同之处在于多出了迭代初始化和迭代变更两个成分. 如
size_t const SZ = 10;
int arry[SZ];
for (size_t i = 0; i < SZ; ++i) {
arry[i] = i * 2 + 1;
}
// 等价于以下 while 循环
size_t j = 0;
while (j < SZ) {
arry[j] = j * 2 + 1;
++j;
}
由于开发者之间对循环的功能达成了共识, 上述 for
循环一眼就能看出来是在逐个访问数组的每个元素, 因此相对于使用 while
, for
循环在语义上更为明确, 容易理解.
不过, 在 C++ 中由于标准容器的引入, 这种 for
循环会由于一些容器类型无法按照索引下标随机访问而无法使用. 因此标准库中设立了迭代器 (iterator) 的机制来帮助用户遍历任何容器中的元素. 如
std::set<int> s;
for (size_t i = 0; i < s.size(); ++i) {
// 编译错误: std::set 集合类型不具备按照下标随机访问的能力
int val = s[i];
}
for (std::set<int>::iterator i = s.begin(); i != s.end(); ++i) {
// 正确: 通过迭代器访问元素
int val = *i;
std::cout << val << std::endl;
}
相对而言这种写法还是容易接受的. 不过, C++11 中又设计了一个新语法, 让针对容器迭代语义进一步简化. 这种基于范围的循环 (range-based loop) 语法如下
std::set<int> s;
// 在 for 关键字的括号中使用
// 类型名 标识符 冒号 (:) 要进行迭代的容器 (任何表达式)
// 这一写法与上面传统的 for 循环写法完全等价
for (int val: s) {
std::cout << val << std::endl;
}
编译器处理这一语法的机制实际上就是调用给定的容器表达式的 begin()
和 end()
函数产生迭代器对象, 然后调用迭代器对象的寻址算符重载 (前置 operator*()
重载) 获得相应的元素, 赋值给 for
的括号里定义的标识符.
如果提供容器的表达式是个函数调用, 这个调用只会被执行一次. 换言之, 这个表达式只会在循环开始前被求值一次.
std::vector<std::string> make_vector()
{
std::cout << "call make_vector" << std::endl;
std::vector<std::string> x;
x.push_back("hello");
x.push_back("world");
return x;
}
for (std::string s: make_vector()) {
std::cout << "s= " << s << std::endl;
}
// 等价于以下代码, make_vector() 函数调用只有一次
std::vector<std::string> __ranged_for_loop_expr(make_vector());
for (std::vector<std::string>::iterator i = __ranged_for_loop_expr.begin();
i != __ranged_for_loop_expr.end();
++i)
{
std::string s = *i;
// ...
}
/* 输出
call make_vector
s= hello
s= world
*/
另外, 像上面这样写, 每次迭代返回的 std::string
对象都会被复制到循环中定义的 s
变量里去, 这当然有些性能损耗. 在不需要复制的情况下, 可以将这一变量定义为引用.
// 以引用的方式使用迭代的元素
for (std::string const& s: make_vector()) {
std::cout << "s= " << s << std::endl;
}
// 也可以去掉引用的 const 限制; 这样的话, 循环体中更改引用意味着直接修改了容器内元素的内容
std::vector<std::string> v(make_vector());
for (std::string& s: v) {
s += " !";
}
for (std::string const& s: v) {
std::cout << "s= " << s << std::endl;
}
/* 更改了元素之后, 以上一个 for 循环的输出为
s= hello !
s= world !
*/
然而, 这一方便的新特性并没有顾及到逆向迭代, 也就是说无法通过逆向迭代器 (reverse iterator) 对容器内容进行循环. 不过, 可以在需要的时候加上这样一套工具类型 (需要用户自己添加; STL 中直到 C++17 都没有提供)
template <typename C>
struct reverse_iteration {
C& c;
reverse_iteration(C& c_): c(c_) {}
// 其 begin end 函数返回所引用的容器的 rbegin() rend() 以产生逆向迭代器
typename C::reverse_iterator begin() { return c.rbegin(); }
typename C::reverse_iterator end() { return c.rend(); }
};
template <typename C>
struct reverse_iteration<C const> { // 针对 const 限定的偏特化
C const& c;
reverse_iteration(C const& c_): c(c_) {}
// 此偏特化使用 const_reverse_iterator
typename C::const_reverse_iterator begin() { return c.rbegin(); }
typename C::const_reverse_iterator end() { return c.rend(); }
};
template <typename C>
reverse_iteration<C> make_reverse(C& c)
{
return reverse_iteration<C>(c);
}
std::vector<std::string> v(make_vector());
for (std::string const& s: make_reverse(v)) {
std::cout << "s= " << s << std::endl;
}
/* 输出
call make_vector
s= world
s= hello
*/
从这个例子中也可以看出, 如果用户自己实现了一个容器类型, 或者哪怕是上述这样的包装类型, 只要有合适的 begin()
和 end()
成员函数实现, 就能用在基于范围的循环语法中.
在 C++ 中, 若使用一个模板类型的特化作为另一个模板的类型参数, 连续两个模板的结束符号 >
会合在一起变为右移算符 >>
, 如
std::vector<std::pair<int, int>> x;
// ^^
std::vector<std::pair<int, std::set<std::string>>> y;
// ^^^ 还可能出现无符号右移算符
在 C++03 中这种写法是不允许的, 模板结束处的连续尖括号之间必须加上空格. 而 C++11 标准则允许这样写, 即上述代码可以被支持 C++11 的编译器正确解析. 不过此改动也不是完美的, 这一规则加入后一些原来可以编译通过的代码反而会出错. 如
template <int I>
struct A {};
// 使用右移运算表达式特化接受整型参数的模板
// C++03 中可以编译通过, C++11 中报错
A<3 >> 1> x;
// 两个标准中都认可的写法, 在表达式两边加上括号
A<(3 >> 1)> x;
当然也不能说这是设计上的不周, 现实中像上面这样偏偏用到一个右移运算来特化的例子屈指可数, 而嵌套模板的使用则比比皆是. 所以这只是牺牲不常见的用况来方便更常见的写法, 还是很有道理的.
在编写代码时, 可以为一些类型设置别名, 使得代码更容易理解. 在既有标准中, 可以使用 typedef
关键字定义类型的别名. 如
typedef unsigned char byte;
以上代码中将类型 unsigned char
定义为 byte
. 就这样直白的别名设置而言, typedef
还算看得过去, 但下面这些情况就略显晦涩了
typedef int int_arr[10];
typedef int (* fn_type)(int, int);
其中第一个别名设置是将 10 个 int
构成的数组定义成名为 int_arr
类型; 第二个则定义了名为 fn_type
类型, 它的类型是函数指针, 这类函数指针指向的函数接受两个 int
参数, 返回一个 int
. 这两种类型别名的语法都将名字放在了语句中间某个位置, 读起来并不直观.
为了改善这一弊端, 在 C++11 中为 using
指派了一个新功能, 使得用户可以用更加清晰的方式为类型指定别名. 以上面三种类型别名为例, 它们可写作
// 语法形式为
// using 别名 = 类型
using byte = unsigned char; // 普通类型, 直接写在等号右侧
using int_arr = int[10]; // 数组类型, 用类型名加上方括号和数量
using fn_type = int (*)(int, int); // 函数指针类型, 在返回值类型和参数列表之间加上 (*)
这一语法形式与 typedef
不同的是, 在类型别名和实际的类型之间, 显式地插入了一个等号, 等号左边的标识符就是别名. 如此一来, 用户在阅读代码时就能立即明白为怎样的类型设置了什么别名.
除了提供更为明了的类型别名语法, using
还可支持泛型类型定义, 如
// 语法: 在 template < ... > 泛型声明后立即加上 using 语句
// 泛型参数 T 必须指定, 而 Alloc 有默认值 std::allocator<T>
template <typename T, typename Alloc = std::allocator<T>>
using vec_iter = typename std::vector<T, Alloc>::iterator;
std::vector<int> x;
// 使用 int 特化 vec_iter 别名, 另一泛型参数是默认的 std::allocator<int>
// 因此特化出的类型 vec_iter<int> 为
// std::vector<int, std::allocator<int>>::iterator
vec_iter<int> i = x.begin();
C++11 开始统一千奇百怪的对象初始化语法了, 同时各种 STL 容器增加预设元素的初始化方式.
这个故事还要从 C 的一个语法点说起. 在 C 语言中, 如下的代码
struct Point {
int x;
int y;
};
Point p = {0, 1};
int a[] = {0, 1};
同样的 {0, 1}
在编译器看来语义是截然不同的, 对 p
而言是初始化其成员, 对 a
而言则既要推导 a
的大小又要设定其元素的值.
而在 C++03 中, 如果为 Point
定义一个构造函数, 以下写法就不正确了
struct Point {
int x;
int y;
Point(int x_, int y_): x(x_), y(y_) {}
};
Point p = {0, 1}; // 错误: 非 POD 的类型1不能用初始化列表构造
不过, 只要用 C++11 的编译器, 上述代码中的编译错误就立即消失了. 实际上, C++11 中添加以上语法作为一则初始化语法, 或者说调用构造函数的语法. 如
Point p = {0, 1}; // 与初始化 POD 的语法相同
Point q{0, 1}; // 亦可省去等号
// 这两种写法均等价于传统写法
Point r(0, 1);
也就是说, 针对构造函数, 可以使用花括号替代圆括号. 这一特性还有助于减少调用无参构造函数时的书写错误. 例如
struct Point {
// ...
Point() : x(0), y(0) {}
};
Point p();
上例中定义 p
的实际上并不是调用无参构造函数初始化一个 Point
对象, 实际上它是一个函数前置声明, 该函数没有参数并返回 Point
. 这一新人杀手级语言 "特性" 导致的错误通常在修改代码时, 去掉了构造参数都但没有去掉括号而产生. 而在新语法中, 它可以这样写
Point p{};
另外, 用户可以利用这一语法特性简化一些 return
语句. 当函数需要返回以特定构造函数构造的对象时, 可以使用花括号语法, 而不必写出类名. 如
std::string make_string()
{
return {};
// 而不需要写
// return std::string();
}
Point make_point(int x, int y)
{
return {x, y};
// 而不需要写
// return Point{x, y};
}
除了以上单个对象的构造语法有所更改, 批量构造对象的语法语义也变得不同. 类似 int a[] = {0, 1};
这一例子中, 数组初始化时给出的花括号括起的整数值序列, 它不仅仅可以用于构造数组, 现在也可以用于构造容器了. 比如
std::vector<int> x = {0, 1};
// 当然, 也可以去掉等号
std::vector<int> y{2, 3, 5, 7};
for (int i: x) {
std::cout << "x contains " << i << std::endl;
}
/* 输出
x contains 0
x contains 1
*/
for (int i: y) {
std::cout << "y contains " << i << std::endl;
}
/* 输出
y contains 2
y contains 3
y contains 5
y contains 7
*/
如果编译时知道一个容器里该有些什么东西, 直接写到构造函数里, 比先定义出容器再一个个添加要简单多了.
这一语法看起来很奇特, 不过并不神秘, 也不仅仅被 STL 容器所用 (不仅 vector
, 其他所有的 STL 容器都支持这样构造). 在 C++11 中, 编译器处理到花括号扩起的类型相同的表达式时, 就会试图将其转换为称作 std::initializer_list
的泛型类的实例, 而 STL 容器之所以可以这样构造, 无非是因为它们都有参数 initializer_list<value_type>
的构造函数. 当然, 不仅构造函数可将初始化列表当作参数, 一般函数也可以, 如
#include <iostream>
#include <initializer_list>
// 以 initializer_list 为参数的函数, 这是一个泛型类型, 需要特化
void f(std::initializer_list<int> x)
{
// initializer_list 实例的 size() begin() end() 函数使它可以表现得像一个标准容器
std::cout << "initializer_list size=" << x.size() << std::endl;
for (int i: x) {
std::cout << "-- element " << i << std::endl;
}
}
int main()
{
f({1, 1, 2, 3});
/* 输出
initializer_list size=4
-- element 1
-- element 1
-- element 2
-- element 3
*/
f({});
/* 输出
initializer_list size=0
*/
return 0;
}
而像 std::map
这样, 元素类型不是一个单一的值而是键值对, 在初始化的时候就需要混合使用初始化列表和之前介绍的用花括号括起构造参数的做法
std::pair<std::string, int> jan{"jan", 1};
std::map<std::string, int> x{ // map<std::string, int> 中的值类型是 pair<std::string const, int>
jan, // 可以使用一个 pair 实例来复制构造
{"feb", 2}, // 也可以使用花括号括起的两个值调用构造函数
{"mar", 3},
};
std::cout << x["feb"] << std::endl; // 2
不过这样看起来, 花括号的作用又非常混乱了. 即, 如果花括号中各个元素的类型一致, 那么它被编译器视作一个初始化列表实例; 否则编译器将根据花括号中各表达式的类型, 尝试决议出一个构造函数重载进行调用.
然而, 假如像上面 Point
类那样, 其构造函数的参数类型恰好都是相同的, 构造参数看起来像个初始化列表, 编译器不会很困惑吗?
确实初始化列表的语法仍有歧义. 在这种情况下, 用初始化列表语法去调用函数时, 将优先匹配以 initializer_list
为参数的重载, 若不存在此重载才会以其他重载作为备选. 比如下面的例子
void f(std::initializer_list<int> x) // (a)
{
std::cout << "initializer_list" << std::endl;
}
void f(Point p) // (b)
{
std::cout << "point" << std::endl;
}
int main()
{
f({0, 0}); // 输出: initializer_list
return 0;
}
例子中定义了两个重载, 重载决议会判定为调用 (a), 而将 (a) 删除掉的话, 则重载 (b) 会被执行.
当为一个成员很多的类型编写一组构造函数重载时, 需要给每个构造函数都写上长长的初始化列表. 如
struct Person {
std::string first_name;
std::string last_name;
std::string address;
int score;
int age;
// 显式初始化每个成员的完整初始化
Person(std::string const& fname, std::string lname, std::string const& addr,
int s, int a)
: first_name(fname)
, last_name(lname)
, address(addr)
, score(s)
, age(a)
{}
// 只为部分成员执行默认初始化
// 传统的做法仍然需要将一些原生成员放到初始化列表中
Person(std::string const& fname, std::string lname, int a)
: first_name(fname)
, last_name(lname)
// address 作为 string 类型成员, 有构造函数保证其正确初始化, 不必写入初始化列表
, score(0) // 但整数或指针等原生类型需要显式初始化
, age(a)
{}
};
这种写法一方面初始化列表的代码显得臃肿不堪, 另一方面维护难度也很大, 如果新增原生类型成员, 每个构造函数的初始化列表里都需要加上对其的初始化.
在 C++11 中新增了一种初始化列表的书写方式, 即允许一个构造函数调用另一个构造函数作为初始化对象的手段, 如以上代码中的第二个构造函数重载可以调用第一个重载. 如
struct Person {
// ...
Person(std::string const& fname, std::string lname, std::string const& addr,
int s, int a)
: first_name(fname)
, last_name(lname)
, address(addr)
, score(s)
, age(a)
{}
// 使用委托构造函数的做法是, 将另一构造函数的调用作为唯一成分写在初始化列表中
// 这个例子中, 上一个构造函数重载中初始化了所有成员
// 其他构造函数都可调用上一构造函数重载, 不用担心漏掉个别成员的初始化
Person(std::string const& fname, std::string lname, int a)
: Person(fname, lname, "", 0, a)
{}
};
需要注意的是, C++ 构造函数对成员初始化的要求是不重不漏, 当委托另一构造函数进行构造后, 隐含着 "所有成员都已被正确初始化" 这一结果, 因此不能继续在初始化列表中加任何成分, 或者委托调用其他的构造函数. 例如下面是一些可能误用的情况
struct Person {
// ...
Person(std::string const& fname, std::string lname, int a)
: Person(fname, lname, "", 0, a)
, age(0) // 错误: 不能再初始化其他成员了
{}
};
struct Base { int x; Base(int x_): x(x_) {} };
struct Inherit : Base {
int y;
// 正确: 先调用父类构造函数初始化父类的部分, 再初始化本身的成员
Inherit(int x_, int y_): Base(x_), y(y_) {}
// 正确: 委托构造
Inherit(int x_): Inherit(x_, 0) {}
Inherit(int x_)
: Base(x_)
, Inherit(x_, 0) // 错误, 对父类初始化后, 不能再使用委托构造
{}
};
另一可以简化构造函数代码的特性则是为那些无论如何都需要初始化, 并且有固定初始化模式的成员设置缺省的初始化方式. 比如
std::string current_date();
class Logger {
// 为这个 ofstream 类型成员设定缺省的初始化方式
std::ofstream output{"logs/log-" + current_date() + ".log"};
int id;
std::string format;
public:
Logger(int id) // (a)
: id(id)
{}
Logger(int id, std::string const& fmt); // (b)
: id(id)
, format(fmt)
{}
Logger(std::string const& filename, int id) // (c)
: output(filename, std::ios_base::app);
, id(id)
{}
};
在上例中有三个构造函数重载, 其中 (a) 和 (b) 都没有显式初始化 output
成员, 但是 output
成员其默认构造函数又不能产生一个可以正常工作的文件流对象, 于是就需要给出一个缺省的初始化方式, 这种初始化就是将构造参数直接写在成员声明处. 如果像 (c) 重载那样指定了 output
的初始化方法, 那么指定的缺省初始化就不会被执行.
从例子中还可以看出, 虽然成员初始化参数的模式只能设定一种, 但并不妨碍从这种模式中得出不同的实参, 如果 current_date()
这个函数能返回不一样的值, 那么构造不同 Logger
实例时其 output
指定的文件名仍可以是不同的.
以上写法中不能将初始化的花括号改成圆括号, 否则语法上会被编译器识别为成员函数定义.
struct A {
int m(0); // 错误: 这会被编译器认为是定义函数
int n{0}; // 正确: 使用花括号括起参数
int p{}; // 正确: 使用花括号, 无初始化参数, int 被置为 0
};
可以复制构造的成员亦可用等号设定初始值2. 如下面例子中的写法也都是正确的.
struct A {
int m = 0; // 直接使用等号加上初始值
std::string s = "1"; // 相当于 = std::string("1")
std::string t = std::string(3, '2'); // 显式写作复制构造形式
};
C 和 C++ 作为静态类型语言, 在写声明语句时需要显式写上即将声明的量的类型. 尤其在 C++ 中, 因为名字空间, 模板以及嵌套类型等特性的广泛使用, 常常会需要书写很长的类型名. 比如以下的代码中
void f(std::vector<int>& x)
{
std::vector<int>::iterator i = x.begin();
// ...
}
最长的成分 std::vector<int>::iterator
对程序语义却并无任何影响, 只是起到编译时检查类型之用. 也就是说, 用户既要写 x.begin()
的调用, 又要再写这一调用产生的类型, 并用此类型定义 i
这个变量, 以达到检查的目的.
在 C++11 中, 这种连篇累牍的写法可以由编译器类型推导替代了. 这包括以下两个新特性:
decltype
关键字可以从一个表达式求得其类型auto
关键字, 在定义变量时由初始值推导类型例如
std::vector<int> x;
std::vector<int>::iterator i = x.begin(); // 显式写出类型名
decltype(x.begin()) j = x.begin();
// ^^^^^^^^^^^^^^^^^^^
// decltype(表达式) => 求得此表达式的类型
auto k = x.begin(); // 使用 auto 的写法
// ^^^^ ^ ^
// auto 关键字 标识符 初始值
// 从给定的初始值推导出要定义的变量的类型, 以该初始值进行初始化
// 请注意, 以上写法与 auto 关键字既有的作用不同, 即与以下写法不同
auto int m;
// 这样做是指出 m 使用自动方式 (而非 static) 存储的 int 类型, 初始值待定.
// 实际上所有栈上定义或函数参数都默认地含有这一修饰, 因此无须显式使用
很明显这两种靠编译器推导类型来定义变量的写法都比直接写上 std::vector<int>::iterator
这样的完整类型名要简短得多.
实际上类型推导并不是新鲜事物, 在泛型函数引入时便有了. 如
std::pair<std::string, int> a;
a = std::pair<std::string, int>("C++", 11);
a = std::make_pair("C++", 11);
这个例子中的两个赋值是等效的, 但是用户往往会选择后一种写法, 这种写法不必再重复写上 pair
的参数类型, 而让泛型函数 make_pair
来根据参数类型进行推导.
既然有多种推导工具, 它们的规则又是如何呢? 比如对于某一特定的表达式 expr
decltype(expr) x = expr;
auto y = expr;
以上两句推导定义的 x
和 y
的类型是否相同呢?
下面先来看看 auto
推导定义的规则
// 以下类型均指的是定义出的变量的类型
auto a = 'a'; // 类型为 char
auto b = 0; // 类型为 int: 从数值, 字符字面常量推导, 类型与该字面常量类型相同
auto c = "hello"; // 类型为 char const*, 而不是 char const (&)[6]
int x[8];
auto d = x; // 类型为 int*: 从数组推导, 类型改变为指针类型
int y = 0;
int const z = 1;
auto d = y; // 类型为 int
auto e = z; // 类型为 int: 从左值 (无论是否含有 const 限定) 推导, 类型为表达式自身类型
auto f = y + 0 // 类型为 int
int func();
auto g = func(); // 类型为 int: 从函数调用或运算 (算符重载的情形视作该函数调用)
// 推导为该返回值类型
int& u = y;
int const& gunc();
auto h = u; // 类型为 int
auto k = gunc(); // 类型为 int: 如果表达式是引用, 则推导时去掉引用和 cv 修饰
auto n = &y; // 类型为 int*
除了数组会被推导成指针类型之外, 其他自动推导得出的都是值类型本身 (指针也算值类型). 这一行为与既有的泛型函数的模板类型推导是一致的. 如
template <typename T, typename U>
void f(T t, U u); // 推导非引用的参数类型
// ...
int x = 0;
int const y = 1;
f(x, y); // T 推导为 int 而不是 int& 引用
// U 推导为 int 而不是 int const
由于从非引用表达式无法推导出引用类型, 若需要定义相应的引用, 应该采用 auto
加引用符号的方式, 如
int x = 0;
int const y = 0;
auto& a = x; // a 的类型为 int&
auto& b = y; // b 的类型为 int const&
auto const& c = x; // c 的类型为 int const& : auto 可以与 const 及引用符号配合使用
此外, auto
也可以用于基于范围的循环中. 如
std::vector<std::string> v;
// ...
for (auto s: v) { // 自动推导 s 为 string 类型, 与上面的一样
std::cout << s << std::endl; // 每个元素复制到 s 中
}
for (auto& s: v) { // 自动推导 s 为 string& 类型
s = s + " jumps"; // 因此修改的话会改变元素的内容
}
for (auto const& s: v) { // 自动推导 s 为 string const& 类型
std::cout << s << std::endl; // 免去一次复制, 也可以防止修改
}
使用 auto
推导时, 对于编译器而言 auto
本身代表了一个类型; 但对于用户而言应该更加关心的是定义得到的变量的类型. 所以应该避免写以下这种难以阅读的代码
int a = 0;
auto x = 0, *y = &a; // 推导时, auto 代表 int, 因此 x 是 int 类型而 y 是 int* 类型
// 不建议在一个 auto 定义语句中定义两个变量
以上是 auto
关键字在 C++11 焕发新生后的一个主要作用, 自动类型推导定义. 此外, 它还有一个作用, 就是令函数返回值类型后置. 如
auto func() -> int
{
return 0;
}
在本应该写返回值类型的位置上用 auto
替代之, 然后在参数列表之后加上一个箭头符号 ->
, 最后再写上返回值. 在上面例子里这种写法看起来并没有什么用, 但有些时候是必需的.
考虑设计这样一个 API: 给定一个函数或函数对象 F
, 和参数 vector<T>
, 将 F
应用于此容器内每一个元素得出一个类型为 U
的结果, 然后将这些结果依次放入类型为 vector<U>
的容器中返回. 那么这个 API 的签名应该怎么写呢? 即, 下面这段代码中的问号应该填入什么类型?
template <typename T, typename F>
std::vector< ??? > mapping_vector(std::vector<T> const& v, F f);
如果再加一个泛型参数 U
, 让用户调用时手动指定, 虽然可行, 但是对用户而言是个负担. 这时 auto
与 decltype
搭配出场就能很优雅地解决这个问题. 用法如下
template <typename T, typename F>
auto mapping_vector(std::vector<T> const& v, F f)
-> std::vector<decltype(f(v[0]))>;
// ^^^^ ^^^^^^^^^^^^^^^^^
decltype
中的表达式 f(v[0])
不会在运行时求值, 所以不用担心如果参数 v
内没有元素的情况. decltype(f(v[0]))
取得 f(v[0])
函数调用的返回值类型, 然后用它来特化 vector
, 就得到了所需的 API 返回值类型.
但是这个返回值类型必须后置, 因为编译器在还没有解析参数列表前, 并不知道什么是 f
和 v
. 故用 auto
在前面当作占位符也是必需的.
若在 decltype
时, 上下文中没有可以拿来用的类型实例, 就需要通过一些技巧构造之, 如
template <typename F, typename T, typename U>
auto f(F f, T t) -> decltype(f(t, U())); // U 是需要用户指定的类型, 参数中不存在
然而这样做在 U
类型不存在无参构造函数时无法通过编译, 因此需要其他手段. 另一种典型的做法是使用 *static_cast<U*>(nullptr)
, 但这样书写相当不便. 考虑到这样的需求, 标准库引入了 declval<T>()
泛型函数, 用于构造一个只在编译时上下文中使用的伪对象. 比如以上的需求, 可以实现为
#include <type_traits> // declval
template <typename F, typename T, typename U>
auto f(F f, T t) -> decltype(f(t, std::declval<U>()));
// ^^^^^^^^^^^^^^^^^
// 包含了 declval<T>() 的表达式只能用在 decltype, noexcept 等编译时行为中
接下来, 就看看 decltype
推导类型时的规则, 尤其是, 基于什么规则为推导出的类型附加或去掉引用.
// 以下类型指 decltype 推得的类型
decltype(0) a; // int 类型: 字面常量推导出常量本身的类型
int x = 0;
int const y = 0;
int& u = x;
int const& w = x;
decltype(x) b; // int : 同 x 的声明类型
decltype(y) c = 0; // int const : 同 y 的声明类型
decltype(u) d = u; // int& : 同 u 的声明类型
decltype(w) e = w; // int const& : 同 w 的声明类型
// 由已声明的标识符推导, 类型同该标识符定义的类型
int func();
int& gunc();
decltype(func()) f; // int
decltype(gunc()) g = y; // int&
// 由函数返回值推导, 类型与声明的返回值一致
decltype(x + u) h; // int : 基本数据类型的运算推导出相应的基本数据类型
// 有算符重载的情况同函数调用, 以算符重载函数的返回值为准
decltype(*&x) j = x; // int& : 取得地址后再对指针类型寻址, 得到左值引用类型
int z[10];
decltype(z[0]) k = x; // int& : z[0] 等价于 *(z + 0) 故与上面的情况类似
除了对寻址表达式, 包括数组元素访问推导会得到左值引用类型, 其他情况都会采用标识符的声明类型或函数返回值的声明类型, 不会去掉引用或 cv 修饰, 在这一点上与 auto
关键字截然不同.
如果想要利用数组元素推导出非引用类型, 可以使用 C++11 新加入标准库的 remove_reference
工具类进行变换
#include <type_traits> // remove_reference
int z[10];
std::remove_reference<decltype(z[0])>::type m; // m 是 int 类型
另外, 使用声明的类型还意味着, 如果以某个对象成员作为推导来源, 那么 decltype
会忽略加之该对象本身的 cv 修饰, 如
struct A {
int x;
};
void foo(A const& a)
{
int& r = a.x; // 错误: 因为 a 是 const 修饰的, a.x 实际是 int const 类型
decltype(a.x) m; // x 在声明处为 int, 所以 decltype(a.x) 推得的类型是 int
m = 0; // 正确, 可以赋值
}
decltype
还有一个古怪的行为, 就是它会区分标识表达式 (id-expression) 和非标识表达式. 对于单个的标识符, 名字空间下的标识符, 任何表达式对象的成员访问这些标识表达式, decltype
推导其类型为各标识符声明的类型, 但若为这些标识表达式加上一层圆括号形成非标识表达式的话, decltype
推得的将是相应的引用类型
int x = 0;
decltype((x)) y = x; // int& 类型
// ^ ^ 在标识符 x 外面加了一层圆括号形成的 (x) 不是标识表达式
// 这时推导出左值引用类型
struct A {
int x;
};
A a;
decltype(a.x) m = 0; // int 类型
decltype((a.x)) n = m; // int& 类型
但笔者并不建议用带括号的标识符的方式来推导引用, 因为这实在太晦涩, 容易误读. 在需要一个引用类型的情况下, 完全可以显式写上引用符号
int x = 0;
decltype(x)& a = x;
// ^ 清晰明了的方式, 在推导的非引用类型外加上 &
// 或先重新定义一个类型, 然后加上引用
using x_type = decltype(x);
x_type& a = x;
顺带一提与自动推导有关的一组标准库容器 API 改动. 就像本节最开始的例子中的那样, 使用 auto
往往是为了简化过长名字的定义, 容器的迭代器类型自然是较常见的一种情形. 不过, 使用 begin()
, end()
调用一定程度上是有歧义的, 比如迭代器类型都有这两个 begin()
重载
iterator begin();
const_iterator begin() const;
从非 const
限定的容器对象上获取的迭代器都是可修改元素的迭代器, 以此为初始值进行 auto
推导定义出的变量也都会如此.
为了允许更便捷地推导出不可修改元素的迭代器, C++11 在所有 STL 容器都扩充了以下 API, 供 auto
或 decltype
在推导需要时使用.
const_iterator cbegin() const; // 迭代初始位置
const_iterator cend() const; // 迭代结束位置
const_reverse_iterator crbegin() const; // 逆向迭代开始位置
const_reverse_iterator crend() const; // 逆向迭代结束位置
C++11 新增的较为复杂的一个新语法就是 lambda 函数对象. 在本节中将简单介绍如何使用基本的 lambda 语法替代一般函数.
在标准库中提供了许多便利的算法函数, 这些算法函数允许用户传入指定的函数作为算法的策略. 比如最常用的算法函数之一的 std::sort
, 它允许用户传入一个比较器, 表示如何比较两个对象以决定它们的顺序. 例如
#include <iostream>
#include <algorithm>
#include <vector>
struct Person {
int age;
std::string name;
Person(int a, std::string const& n)
: age(a)
, name(n)
{}
};
// 根据两个属性分别排序的函数
bool cmp_by_age(Person const& lhs, Person const& rhs)
{
return lhs.age < rhs.age;
}
bool cmp_by_name(Person const& lhs, Person const& rhs)
{
return lhs.name < rhs.name;
}
int main()
{
std::vector<Person> p{
{24, "lisi"},
{23, "zhangsan"},
{25, "wangwu"},
};
std::sort(p.begin(), p.end(), cmp_by_age);
for (auto const& x: p) {
std::cout << x.name << " : " << x.age << std::endl;
}
/* 输出
zhangsan : 23
lisi : 24
wangwu : 25
*/
std::sort(p.begin(), p.end(), cmp_by_name);
for (auto const& x: p) {
std::cout << x.name << " : " << x.age << std::endl;
}
/* 输出
lisi : 24
wangwu : 25
zhangsan : 23
*/
return 0;
}
虽然这样可以实现功能, 但显然, 将 cmp_by_age
对 cmp_by_name
分离为单独的函数违背了函数的本意 --- 之所以分离出单独定义的函数是为了代码重用和逻辑封装. 而在本例中, 对象的比较方法作为排序逻辑的一环, 本应在 sort
的调用处, 单独定义成全局函数反而割裂了代码逻辑.
再试想实现下面这一逻辑: 在容器中找到第一个 age
小于 20 的对象, 如果使用标准库中的 find_if
, 应写为
bool age_less_than_20(Person const& p)
{
return p.age < 20;
}
// ...
std::vector<int> p;
// ...
auto i = std::find_if(p.begin(), p.end(), age_less_than_20);
if (i != p.end()) {
std::cout << i->name << " : " << i->age << std::endl;
}
然而想必不会有人这么死脑筋, 相对于下面这样的 for 循环, 上面的写法实在是太繁琐了3
for (auto const& x: p) {
if (x.age < 20) {
std::cout << x.name << " : " << x.age << std::endl;
break;
}
}
而在 C++11 中, 用户可以用 lambda 匿名函数这一更简洁的方式来编写并使用这个函数指针, 下面就用这种方法改写上面的例子
int main()
{
std::vector<Person> p{
{24, "lisi"},
{23, "zhangsan"},
{25, "wangwu"},
};
// 使用匿名函数作为 sort 的比较器参数
// 这是一个函数, 它以一对方括号开头, 之后是参数列表, 然后是花括号括起的函数体
// 但它没有名字, 故被称作匿名函数
std::sort(p.begin(), p.end(),
/* 从这里开始 */
[](Person const& lhs, Person const& rhs) // (a)
{
return lhs.age < rhs.age;
}
/* 到这里结束 */
);
for (auto const& x: p) {
std::cout << x.name << " : " << x.age << std::endl;
}
/* 输出
zhangsan : 23
lisi : 24
wangwu : 25
*/
std::sort(p.begin(), p.end(),
[](Person const& lhs, Person const& rhs) // (b)
{
return lhs.name < rhs.name;
}
);
for (auto const& x: p) {
std::cout << x.name << " : " << x.age << std::endl;
}
/* 输出
lisi : 24
wangwu : 25
zhangsan : 23
*/
// 类似地, 也可以使用匿名函数作为 find_if 的筛选条件参数
auto i = std::find_if(p.begin(), p.end(),
[](Person const& x) // (c)
{
return x.age < 20;
}
);
if (i == p.end()) {
std::cout << "no such person" << std::endl;
}
/* 输出
no such person
*/
p.push_back(Person(16, "xiaoliu"));
auto j = std::find_if(p.begin(), p.end(),
[](Person const& x) // (d)
{
return x.age < 20;
}
);
if (j != p.end()) {
std::cout << j->name << " : " << j->age << std::endl;
}
/* 输出
xiaoliu : 16
*/
return 0;
}
(a) (b) (c) (d) 各处就是这一新语法. 该语法以一对方括号开头, 接下来圆括号中是函数的形参列表, 它是可选的, 若被省略, 表示该函数对象调用时不需要参数. 最后花括号之间的部分就是函数对象的函数体, 当然它是必要的. 还有一个被省略的成分是返回值类型, 它默认由 return
语句中返回的表达式类型推导, 若要显式写上, 须写成后置形式, 如
[](Person const& lhs, Person const& rhs) -> bool
{
return lhs.age < rhs.age;
}
从语法来说, 这样做相当于定义了一个匿名函数, 然后将该函数的函数指针作为此处表达式的值. 因此, 它也适用于各种需要函数指针的场合. 典型地, 当编写一个有计算器机能的程序时, 可能要将算符字符串映射到不同的运算函数, 使用 lambda 可以实现为
int main()
{
using fn = int(*)(int, int);
std::map<std::string, fn> op_map{
{"+", [](int x, int y) { return x + y; }},
{"-", [](int x, int y) { return x - y; }},
{"<<", [](int x, int y) { return x << y; }},
};
std::cout << op_map["+"](1, 2) << std::endl; // 3
std::cout << op_map["-"](3, 4) << std::endl; // -1
std::cout << op_map["<<"](5, 6) << std::endl; // 320
return 0;
}
需要注意的是, 虽然语法上整个 lambda 都写在函数体内部, 但它并不能看作是一个局部变量, 也因此, 一些对于局部变量的约束对 lambda 而言并不成立. 比如, 函数不应该返回局部变量的引用或指向此局部变量的指针, 但可以返回定义在局部的 lambda.
int* return_local_ptr()
{
int x = 0;
return &x; // g++ 会给出一个警告, 返回了局部变量的地址
}
using fn_type = int(*)(int);
fn_type return_lambda()
{
return [](int x) { return x + 1; }; // 而返回一个 lambda 是完全允许的
}
int main()
{
fn_type f = return_lambda();
std::cout << f(1) << std::endl; // 输出 2
return f(-1);
}
// 实际上, 上述 return_lambda 函数等价于先定义一个匿名全局函数, 然后返回该函数指针, 故这一做法是完全合理的
// 即等价于以下写法
int __anonymous_function__(int x)
{
return x + 1;
}
fn_type return_lambda_()
{
return __anonymous_function__;
}
在以上的例子中, 所有 lambda 对象都被可以向函数指针类型直接转换, 不过, 这并不表示 lambda 都是函数指针. 实际上, 默认情况下它更类似于一个函数对象, 即各种定义了 operator()
重载的类型的实例. 有关 lambda 更准确的解释和更进阶的使用方法, 将在 "函数对象" 一章详细介绍.
在既有 C++ 标准中, 使用常量要么通过枚举 (enumeration), 要么通过 const
限定的名字定义. 而两者多多少少都有一些缺陷: 枚举由于其可以隐式与整数类型互相转换, 因而缺乏类型和取值范围的约束; 而 const
关键字有时仅仅表示用户无法修改而并非该值本身是常数, 产生一些二义性.
在新标准中引入了许多常量和字面量的改进, 让枚举类型的运用更加安全, 提供了语义更明确的常量, 并且还加入了用户自定义字面量的机制. 本章中便来一一介绍它们.
在 C++11 中, 用户可以给枚举类型指定更精确和严格的类型了, 这一点体现在两方面: 允许指定枚举类型的宽度和符号; 允许在引用枚举常量时要求用户显式指定枚举的域来减少名字冲突的可能性.
第一项特性的具体做法是, 在声明枚举类型时如声明继承一样, 为这个枚举类型指定一个 "父类" 类型. 当然, 这与继承并无任何关系, 只是规定此枚举类型可能的取值范围, 这样做可以让编译器提供更好的取值检查. 如
enum flags : unsigned short {
READONLY = 1,
READWRITE = 2,
ADMIN = 4,
};
以上声明中规定了 flags
枚举中的常量是无符号数且位宽与 short
类型一样. 一旦编译器检查到该类型的某个常量取值超过此返回就会报错, 如
enum uint_enum : unsigned int {X = -1}; // 错误: -1 不是无符号整数
enum byte_enum : char {Y = 128}; // 错误: 128 超过了 char 的表示范围
当然, 为枚举指定类型限定仍然只能使用整型, 不能写如 enum X : double
的声明.
第二项特性的具体做法是, 在 enum
关键字后加上 struct
或 class
关键字 (凭个人喜好或编码规范, 对编译器而言完全无区别), 作用是所有该枚举类型定义的常量不能直接引用, 必须加上枚举类型名, 这样做可以避免枚举常量重名的问题. 如
enum class Province {HUBEI, HUNAN, GUANGDONG, HAINAN};
enum struct City {WUHAN, CHANGSHA, GUANGZHOU, HAIKOU};
Province p = Province::HUBEI; // 正确
City c = City::WUHAN; // 正确: 以 enum 名作为名字空间访问
City d = WUHAN; // 错误: WUHAN 在当前上下文中未定义
这两个特性也可以结合在一起使用, 如
enum class Direction: unsigned char {RIGHT, DOWN, LEFT, TOP};
Direction d = Direction::RIGHT;
除了使用枚举类型, 在 C++ 中也可以使用 const
来定义常量, 并且除了整数类型的常量, 它也可以作用于其它类型, 如 double
浮点数等.
不过, 使用 const
来限定一个名字, 其初衷只是表示其无法修改, 而若其以常数初始化, 那么编译器认为被定义的名字表示一个常量 (当然常量必然无法修改). 这会产生一定的二义性, 并在给一些代码带来麻烦, 比如
template <int I>
class A {};
void f()
{
int const N = 5;
A<N> a; // 合法: 编译器认为 N 是编译时常量
}
void g()
{
int n = 5;
int const M = n;
A<M> a; // 不合法: 编译器认为 M 只是一个无法修改的量
}
而在 C++11 中, 一个新的用来定义编译时常量的机制加入了标准. 标准引入了 constexpr
这一关键字以区分 const
关键字, 使用这一关键字定义的值必须是编译时常数, 因而可以用于定义固定长度的数组, 或像上例中那样特化模板, static_assert
, 以及任何需要一个常数的地方. 这一机制的另一个方面是允许通过函数计算返回一个编译时常数, 即使用 constexpr
关键字定义的函数在参数都为常数的情况下返回的值也可以被认为是一个常数.
而如果使用了 constexpr
无法定义出一个常数, 或者 constexpr
修饰的函数不满足某些条件, 那么编译器会立即报错. 这也避免了出现 const
那样不明确的语义.
使用 constexpr
修饰一个名字定义, 让它一定是编译时常数, 写法是将 constexpr
关键字放在定义语句的开头.
constexpr int N = 5; // 定义 N 为常量 5
template <int I>
class A {};
A<N> a; // 可以用于特化模板
static_assert(N == 5, ""); // 可以用于 static_assert
与 const
不同的是, 它必须由其他编译时常数在定义处立即初始化, 不能用于声明形式参数. 另外, 如果用作定义类的成员, 它必须是 static
修饰的.
int x = 0;
const int y = x; // 这样写是允许的
int m = 0;
constexpr int n = m; // 错误: 初始值 m 不是一个编译时常数
void f(constexpr int N) {} // 错误: 不能作为参数
struct X {
constexpr int M = 6; // 错误
constexpr static int M = 6; // 正确: 必须带上 static 修饰
};
其中的理由也不复杂: 使用 constexpr
就是在定义编译时常量, 因此编译器当然需要一个常量去初始化它; 并且类型的各个实例也没有必要共享一个编译时常量, 声明为静态是合适的做法. 而如果在函数体内定义一个 constexpr
修饰的量, 它自动获得 static
修饰, 无须显式写出. 当然, 一般而言这些常量并不会真的被编译器放入程序的静态存储区, 它们往往在编译时就被替换为常数了.
除了用来定义常量, constexpr
关键字也可以用来修饰函数. 修饰全局或静态函数时, 表示这个函数在参数都为常量时将返回一个常量. 比如
constexpr int square(int x)
{
return x * x;
}
// 也可以修饰模板函数
template <typename T>
constexpr T cube(T x)
{
return x * x * x;
}
constexpr int I = square(5); // 25
constexpr int K = cube(-5); // -125
constexpr double L = cube(1.6); // 4.096
也就是说, 上面这些函数调用实际上由编译器自己执行并计算出了结果, 然后当作编译时常量使用.
这样定义的函数也能在参数不为常量时使用, 不过这样的话, 返回值也就不能作为常量看待了
int x = 5;
int y = cube(x); // 正确: 参数 x 不是编译时常量, 但是可用来调用 cube, 其结果被视为变量
constexpr z = cube(x); // 错误: 参数 x 不是编译时常量, 调用 cube 返回的结果不被认可为常量
在以上例子中的 constexpr
函数都比较简单, 不过实际上, constexpr
修饰函数时, 有一些规则迫使它必须这么简单. 这些规则是
constexpr
函数只能包括一条 return
作为非编译时语句 ("编译时语句" 是笔者的造语, 指代如 static_assert
, typedef
, using
, enum
等只在编译时产生效果的语句)return
的表达式中, 只能引用 a) 参数 b) 编译时常量 c) 其他 constexpr
函数调用 d) 以上项目的运算或成员 (有运算符重载的情况视作 c 项)第一条规则是理所当然的, 它保证编译器知道这一函数的定义并能够在编译时模拟执行它.
而接下来一条规则就非常严苛了, 甚至这样的代码也不会被认可4
constexpr int successor(int x)
{
constexpr int C = 1; // 可以将这一句改为 enum {C = 1}; 通过编译
return x + C;
}
最后一条规则中约束了这些函数能使用的值也应该都是常数, 毕竟要在编译时执行这些代码. 并且, 函数调用中如果有其他函数也不会产生其他效果, 因为被调用的那些也都是 constexpr
函数.
说到这里不得不提, 所有的数学库函数都不是 constexpr
修饰的. 比如求平方根, 虽然理论上来说完全可以在编译时求得任何常数的平方根, 但实际上 sqrt
函数的实现中有产生副作用的可能性, 因为在参数为负数时, errno
会被设置 EDOM
表示发生了一个定义域错误.
不过这些也只是一些说辞. 在不考虑定义域的情况下, 自行定义 constexpr
求平方根函数并不是不可能
// 使用二分法求平方根
// PRECISION 值为精度要求
constexpr double PRECISION = 1e-3;
// 猜测值 guess 的平方和原参数 x 小于在容许范围内, 也就是小于精度要求时, 认为此猜测值足够好
constexpr bool good_enough(double x, double guess)
{
// | x - guess * guess | < PRECISION
return -PRECISION < x - guess * guess && x - guess * guess < PRECISION;
}
// 如果猜测值 guess 足够好就返回它, 否则, 递归求更精确的值
constexpr double sqrt_impl(double x, double guess)
{
return good_enough(x, guess) ? guess : sqrt_impl(x, (guess + x / guess) / 2);
}
// 求平方根的功能入口, 以 1.0 作为初始猜测值
constexpr double sqrt_c(double x)
{
return sqrt_impl(x, 1.0);
}
constexpr double X = sqrt_c(2); // 约为 1.41422
constexpr double Y = sqrt_c(3); // 约为 1.73214
从上面这一连串的 constexpr
函数也可以看出在严格的规则约束下能做到什么程度. 譬如使用 ?:
三目算符替代分支语句, 包括使用递归都是允许的. 不过如果递归的次数过深, 编译器会选择报一个错误, 避免在可能实际上有缺陷的代码中越陷越深. 譬如, 利用上述代码求 sqrt_c(-2)
时, 在 sqrt_impl
中是会无限递归的, 这时会以编译错误收场.
引入 constexpr
的好处除了区分一般 const
而获得更明确语义, 和使用函数计算常量之外这两点之外, C++11 还允许自定义常量的类型. 举个例子, 下面这种代码也是可行的
struct Vector2d {
double x;
double y;
constexpr Vector2d(double xx, double yy) // (a) constexpr 修饰的构造函数
: x(xx)
, y(yy)
{}
constexpr double length() const // (b) constexpr 修饰的成员函数
{
// 使用刚才例子中的 sqrt_c
return sqrt_c(x * x + y * y);
}
};
constexpr Vector2d v{3.0, 4.0}; // (c) 使用上面定义的 Vector2d 定义常量 v
constexpr double x = v.x; // (d) 使用常量 v 的属性定义常量 x
constexpr double len = v.length(); // (e) 使用常量 v 计算得出常量 len
这个例子中, Vector2d
这个类型定义的内部有两个带有 constexpr
修饰的函数, 一是其构造函数, 另一个是非静态成员函数. 然后定义了 Vector2d
的实例 v
, 它以 constexpr
修饰, 其被认为是一个编译时常量; 然后, 使用其成员去初始化常量 x
, 或调用其成员函数 length()
得到常量返回值去初始化 len
.
例子中多次出现了 constexpr
, 不过其中的内在联系很清晰, 它们是这样的
constexpr
构造函数 (a)constexpr
构造函数来构造被定义为常量的实例 (c), 当然, 这时传给构造函数的实参必须全部是常量mutable
修饰的成员, 它们仍然被视作一般变量)constexpr
函数, 得到的也将是一个常量; 并且, 可以使用 constexpr
修饰成员函数, 让它在对象本身是常量时尽可能返回一个常量 (b)constexpr
修饰的成员函数能得到一个常量 (e)反过来说, 如果上面 (a) 处构造函数的 constexpr
被去掉, 那么 (c) 处就无法编译通过, 连带 (d) (e) 也出错; 而如果去掉 (b) 处成员函数的 constexpr
, 那么 (e) 处调用 v.length()
将不被认为得到的是一个编译时常量, 因而初始化 len
时产生一个错误.
使用 constexpr
修饰成员函数的基本规则跟修饰一般函数差不多, 也是只能有一条 return
语句, 其表达式用到的只能是参数或者其他常量等等, 不过还加上一条, 就是可以使用对象自身非 mutable
的成员. 另外, constexpr
修饰的成员函数在 C++11 标准中自动带有 const
修饰, 但在 C++14 标准中去掉了这一规则, 所以在写代码时最好还是显式写上 const
.
而使用 constexpr
修饰构造函数的规则就不太一样了: 首先, 构造函数并不需要 return
一个值, 于是函数体内不允许有任何非编译期语句; 构造函数的重点在其初始化列表, 如果调用委托构造函数或父类的构造函数, 那么被委托的构造函数或其父类的对应构造函数必须也是 constexpr
修饰的; 调用其他构造函数的参数表达式, 或其他成员的初始化表达式中, 所有用到的部分也都必须是常量, 与约束一般 constexpr
函数 return
表达式的规则相同.
除了以上作用于各个函数上的规则, 用来定义常量的类型本身还有其他要求. 在 C++11 中, 描述能够用来定义常量的类型的术语是字面类型 (literal type), 所有的基本数据类型都是字面类型, 而自定义字面类型, 则必须满足以下条件
constexpr
修饰的第一条规则在前面中已经说过了, 如果没有 constexpr
修饰的构造函数, 那么无法合理地初始化这一常量对象. 而第二条规则是有关编译器如何对待常量的. 对于常量, 编译器可能只在编译时使用它们, 因此, 这个对象可能不会存在于运行时, 也就不会被析构, 这就需要一条约束, 使得这个对象即使不析构也不产生任何问题.
如果不满足字面类型的定义, 那么用此类型就无法定义常量. 比如字符串类型 std::string
, 它的析构函数会释放其所持有的堆上资源, 因此不是字面类型
constexpr std::string S("hello, world"); // 错误: string 不是一个字面类型
constexpr size_t sz = S.size(); // 连带错误: string::size() 也不可能是常量
字面类型除了用来直接定义常量之外, 也可以作为 constexpr
函数的参数类型或返回值类型使用. 比如
constexpr Vector2d multi(Vector2d const& a, double times)
{
return Vector2d(a.x * times, a.y * times);
}
constexpr Vector2d m = multi(Vector2d{1, 2}, 2);
constexpr double xx = m.x; // 2.0
constexpr double yy = m.y; // 4.0
或者, 将这一功能定义为算符重载, 显得更简洁
struct Vector2d {
// ...
// 算符重载成员函数也可以加上 constexpr 修饰, 与其他成员函数规则一样
constexpr Vector2d operator*(double times) const
{
return Vector2d(x * times, y * times);
}
};
constexpr Vector2d m = Vector2d{1, 2} * 2;
constexpr double xx = m.x; // 2.0
constexpr double yy = m.y; // 4.0
// constexpr 也可以用于定义全局的算符重载, 与其他全局 constexpr 函数规则一样
constexpr Vector2d operator*(double times, Vector2d const& a)
{
return a * times;
}
需要指出的是, 若 constexpr
修饰一个泛型函数, 其泛型参数用非字面类型特化时, 不构成编译错误, 只不过, 这一特化退化为非 constexpr
修饰的.
比如标准库中, std::pair
泛型类的构造函数是 constexpr
修饰的, 这意味着在使用两个字面类型特化 std::pair
模板类型的情况下, 其实例可以是常量
constexpr std::pair<int, Vector2d> velocity{70, Vector2d{1, 0}};
constexpr int speed = velocity.first; // 70
constexpr bool toward_east = velocity.second.x > 0; // true
当然谁也不能料定模板参数类型不含 string
之类的非字面类型, 因此需要有这条规则保证在这种情况下不出现编译错误.
// 正确
std::pair<int, std::string> x{0, ""};
// 错误: 特化出的 pair<int, string>(int, string) 构造函数退化为非 constexpr 函数
constexpr std::pair<int, std::string> y{0, ""};
constexpr
函数在上面的介绍中指出了 constexpr
与字面类型之间的联系. 一个 constexpr
函数的返回值类型和各形式参数类型, 如果不是模板类型参数, 那么必须都是字面类型. 理由也很明显, 因为这些函数可用以定义只存在于编译期的常量, 因此, 它们不应该有非平凡的析构函数.
不过相对于其他函数而言, 构造函数有点特殊, 它并没有返回值. 更准确地说, 构造函数在给定地址空间上进行的一系列初始化对象的行为, 虽然将对象构造的调用放在表达式里其值是这个对象, 但这个构造调用并没有 "返回" 一个对象. 这样一来, 如果一个非字面类型的构造函数的各个参数类型是字面类型, 并且满足其他 constexpr
修饰函数时的规则, 那么这个构造函数仍然可以是 constexpr
修饰的.
比如, 实现一个这样的指针包装类型
class IntPtr {
int* ptr;
public:
// 此类型实例内部的 ptr 指向堆上空间
explicit IntPtr(int x)
: ptr(new int(x))
{}
// 析构时, 需要归还堆上空间
// 因此, 这不是一个字面类型
~IntPtr()
{
delete ptr;
}
IntPtr(IntPtr const&) = delete; // 简单起见, 不允许复制
// 但是, 其某些构造函数可以是 constexpr 修饰
// 如下面这个, 满足 constexpr 构造函数的各个规则约束就行
constexpr IntPtr()
: ptr(nullptr)
{}
};
constexpr IntPtr p; // (a) 编译错误: IntPtr 不是字面类型不能用来定义常量
IntPtr q; // (b) 编译通过
上面例子中, IntPtr
类型虽然不是字面类型, 但可以拥有 constexpr
修饰的构造函数; 然而, 即使有 constexpr
修饰的构造函数, 却不能用这个构造函数定义常量, 因此 (a) 处无法编译.
这样绕来绕去的, 看起来这好像是绕过规则而产生的一个漏洞, 但它仍然是有意义的.
实际上, 当一个函数被 constexpr
修饰时, 其真正的作用是, 若该其参数为编译时常数, 那么编译器会将直接编译时初始化该函数返回的对象地址空间, 即有可能的话, 编译器会在编译时计算将该函数的返回值, 然后生成直接写入常数的代码, 减少运行时开销. 这使人感觉像一个更加高级的 inline
修饰, 反而跟编译时常量关系不大, 只不过恰好利用函数计算编译时常量可以藉由这样的函数来完成. 而用 constexpr
修饰非字面类型的构造函数 (或字面类型的构造函数) 就是仅运用了这一高级 inline
机制, 避免构造函数调用开销, 生成直接将实例的各成员的值设为常数的运行时代码. 因此如果程序执行到上述代码的 (b) 处, 它并不会调用构造函数, 而是根据构造函数的指示, 直接在对象的地址上写入一个空指针的值, 对于 x64 架构而言, 就是填上 8 字节的零.
所以, 即使不是用来定义常量, 也可以将一些简单的函数定义为 constexpr
函数, 让编译器尽情优化之.
在 C++11 标准中新引入了 UTF-8 预编码字符串的机制, 可以让编译器在编译时对字符串字面常量进行转码. 如以下代码
#include <iostream>
char u[] = u8"汉";
int main()
{
std::cout << u << std::endl;
return 0;
}
这样在程序运行时将以 UTF-8 的编码输出 "汉" 字, 前提条件之一是编译器能正确处理输入文件的编码, 如 GCC 会从系统上下文获取默认的输入编码方式, 或以 -finput-charset=
来指定编码, 此编码应当与输入文件的编码方式一致, 换言之编译器在这时也扮演一个编码器的角色. 另一个前提条件是命令行软件本身能支持 UTF-8 内容的显示, 比如 Linux 的 xterm 能正确显示 UTF-8 编码的内容, 但 Windows 自带的 cmd 则不能正确显示.
由于编译时完成了编码, 得到的结果就是字节序列, 因此定义的 u
的类型为 char[]
.
如果要以宽字符存储字符串字面量, 以前的 L
前缀仍然可以用, 但定义出的 wchar_t
的宽度仍然是一个编译器确定的值, 这实在是容易引起问题的地方. 所以 C++11 又加入了两个新的固定宽度的字符类型, 以及相应的前缀, 如
char16_t c16 = u'汉'; // 小写 u 开头表示以 UTF-16 编码的字符或字符串
char32_t c32 = U'汉'; // 大写 U 开头表示以 UTF-32 编码的字符或字符串
char16_t s16[] = u"汉";
char32_t s32[] = U"汉";
请注意, 由于 UTF-8 编码得出的序列不是宽字符, 因此并不存在 u8'汉'
字符形式, 只有字符串形式.
然而标准中很尴尬的一点是, 并没有新增对应预定义输出流 (比如 std::cout
之于 char[]
或 std::wcout
之于 wchar_t[]
), 因此如果在程序中用适于一般字符或宽字符的流来输出它们, 这些字符类型将转换成整数输出, 而字符串将转换为地址输出.
当然, 文本编码并不是简单的几个数据类型换来换去就能解决的, 要按照具体的情况选择合适的方法. 比如 HTTP 通信中向客户端发送内容, 宜用 UTF-8 编码的字节序列; 而本地程序的用户界面最好还是使用专门的国际化工具来转换程序中的字符串.
C++11 中开始支持免转义以及多行字符串字面量来降低在源代码中编写复杂文本内容的难度. 其形式为
R"自定义分隔符(任意字符内容)自定义分隔符"
其中 "自定义分隔符" 可以是任意内容, 圆括号前后的两部分自定义分隔符必须相同. 比如
auto x = R"delim(Print "hello, world".)delim";
// ^^^^^^^^^^^^^^^^^^^^^
// 字符串内容为圆括号中间的部分, 不含圆括号, 双引号不会引起字符串结束
// 必须由一个反圆括号, 分隔符, 引号结束
std::cout << x << std::endl;
/* 输出
Print "hello, world".
输出结束 */
如果要指定编码前缀, 这个前缀要出现在 R
之前, 如
auto x = u8R"""(汉字)"""; // 正确, 等价于 u8"汉字"
auto y = Ru8"""(汉字)"""; // 错误, u8 前缀必须出现在 R 之前
此外, 这种方式还可以定义多行字符串, 比如
auto x = R"""(
hello
world
)""";
// 等价于 "\nhello\nworld\n", 请注意开始的圆括号之后和结束的圆括号之前的换行符也计入
auto y = R"""(
________ _____ _____ ________ ________
/ | | | | | / | / |
| | --' '-- --' '-- |_ | |_ |
| -- | | | | | | | |
| | --. .-- --. .-- | | | |
\_______| |___| |___| |_____| |_____|
)""";
// 请注意最后一行文本第一个字符反斜线 (\) 不再有转义功能, 而被作为普通字符进入字符串定义
std::cout << x << std::endl;
std::cout << "====" << std::endl;
std::cout << y << std::endl;
/* 输出
(空行)
hello
world
(空行)
====
(空行)
________ _____ _____ ________ ________
/ | | | | | / | / |
| | --' '-- --' '-- |_ | |_ |
| -- | | | | | | | |
| | --. .-- --. .-- | | | |
\_______| |___| |___| |_____| |_____|
(空行)
输出结束 */
在 C 语言中, 程序员可以使用后缀来指定一个字面常量的类型, 比如以下的代码
printf("%lld\n", -1); // 错误: 常数 -1 压栈时只会占用一个 int 类型的空间, 少于一个 long long 类型的部分将是未初始化的栈空间
printf("%lld\n", -1LL); // 正确输出 -1
可以被指定后缀的类型仅限于对整型类型的修饰, 譬如上面的 LL
, 或者加上 U
表示无符号. 在 C++11 中, 这个功能被开放了, 可以自定义一些后缀置于字面常量之后, 改变该字面常量的特性.
// 定义一个指定后缀的函数重载, operator "" 为语法固定部分, 后缀为 _s
std::string operator "" _s (char const* m, std::size_t)
{
return std::string(m);
}
auto r = "hello, world"_s;
// 其中 "hello, world" 为原始字面量, _s 为后缀
// 表达式 "hello, world"_s 的类型为 string
// 因此定义的 r 为 string 类型而不是 char const*
自定义后缀实际上与算符重载并无任何关系, 只是它借用了算符重载的语法 (这一算符重载不可以被定义为类的成员函数, 只能定义在全局或任何名字空间下), 其中在返回值类型之后的 operator ""
是固定成分, 之后为自定义的后缀名, 接下来是参数列表和函数体.
使用自定义后缀等价于在该表达式位置调用该自定义后缀函数, 如上述定义等价于
auto r = operator "" _s("hello, world", 13); // 13 为字符串 "hello, world" 的字符个数, 含 nul
// ^^^^^^^^^^^^^^ 这一段相当于其函数名
为了防止定义的后缀重复, 实际项目中建议在名字空间内定义后缀, 避免污染全局空间, 需要使用时在源码中 using
之, 如
namespace strliteral {
std::string operator "" _s (char const* m, std::size_t)
{
return std::string(m);
}
}
int main(int argc, char* argv[])
{
// 可以 using 整个名字空间, 或者用以下语法仅导入个别后缀
using strliteral::operator "" _s;
if (argc != 1) {
std::cout << "hello, "_s + argv[1] << std::endl;
std::cout << ("hello, "_s == argv[1]) << std::endl;
}
return 0;
}
虽然声明自定义后缀函数的语法看起来有些怪, 但特定情况下用起来还是能省事不少, 比如上面将字符串字面常量转换为 std::string
类型对象以便与其他 char*
表示的字符串连接或比较等.
使用自定义后缀函数的语法必须是字面常量后直接连接后缀标识符, 中间不得有空格或任何其他内容, 如上面的例子中
auto r = "hello, world"_s; // 这是正确的
auto s = "hello, world" _s; // 这是错误的
在这样的语法限制下, 定义后缀函数重载时可选的参数类型只有 4 大类, 分别对应于整数字面常量, 浮点数字面常量, 字符字面常量 (分为 char
类型和各种宽字符类型的版本) 以及字符串字面常量 (同样区分 char
类型和宽字符类型的版本). 除了这些类型, 其他类型都不可以出现在后缀函数重载的参数中. 例如下面这种写法是错误的.
std::string operator "" _s (std::string s);
// ^^^^^^^^^^^ 以 std::string 类型作为参数是不可以的
更具体的, 自定义字面量的函数重载的参数列表只能是下面几种
参数列表 | 匹配原生字面常量 | 调用举例 |
---|---|---|
(CHAT_TYPE const*, std::size_t) |
字符串常量; CHAT_TYPE 可能是 char wchat_t char16_t char32_t 之一; size_t 参数指出该字符串字面量的字符数量, 计入 nul |
"hello"_s |
(char const*) |
形如 10_suffix 的表达式, 将前方的数字以字符串形式传入, 等价于 operator "" _suffix("10") |
10_km |
(CHAT_TYPE) |
单个字符; CHAT_TYPE 与第一条中的字符类型一致 |
U'c'_encode |
(unsigned long long int) |
也匹配形如 10_suffix 的表达式 (如果是带一个符号的整数字面量, 那么先将整数传给重载函数, 然后符号应用于返回的结果) |
10_km |
(long double) |
浮点数 | 2.718_percent |
以 char const*
为参数的重载也能匹配以整数或浮点数为原始字面常量的变换, 同时定义时, 只有接受数值类型的重载会被调用, 当然一般也不会同时定义两个. 这一重载还有个变种形式
template <char... ch>
RETURN_TYPE operator "" _SUFFIX()
{
/* ... */
}
函数本身无参数, 而可变模板参数 char... ch
则是数字字面常量部分的各个字符. 这一部分的内容将在后文 "可变参数模板" 一章中说明.
要注意的是, 以整数字面量或浮点数字面量为原始字面量时, 如果这个变量是带符号的, 那么字面量会先跟后缀结合调用自定义后缀函数, 然后跟符号结合调用单目算符重载函数. 这一点有时会引起误用, 比如为 "温度" 这个概念定义一个 "摄氏度" 的后缀
struct Temperature {
long degree;
explicit Temperature(long t)
: degree(t)
{}
// ...
// 没有其他算符重载
};
Temperature operator "" _c (unsigned long long t)
{
return Temperature(t + 273);
}
int main()
{
// 需求为定义一个表示零下 57 摄氏度的实例
// 但下面的表达式等价于 -(57_c), 而表达式 57_c 的类型是 Temperature, 没有定义前置负号算符重载会导致编译错误
Temperature co2_bolling_point = -57_c;
return 0;
}
如果希望以上写法成立, 就必须再为 Temperature
重载前置负号操作符, 而不能用括号括起前面的 -57
写成 (-57)_c
, 因为这并不是正确的自定义字面量语法.
然而, 如果这种情况下加上如下定义
struct Temperature {
long degree;
explicit Temperature(long t)
: degree(t)
{}
Temperature operator-() const
{
return Temperature(-this->degree);
}
// ...
};
虽然写出 -57_c
这样的表达式并不会再有编译错误, 但其语义是错误的. 因为这样会先计算 57_c
得到一个 degree
为 330 的实例, 然后运用负号算符构造出 degree
为 -330 的实例, 与初衷相去甚远. 在这种情况下, 直接使用构造函数调用如 Temperature(-57)
才是可行的做法.
自定义字面量这一特性虽然名字上叫做 "字面量", 但切不可将其与字面常量混为一谈, 它的求值过程默认情况下是运行时的. 而若有需要编译器将其结果视作编译时常量, 则需要用到 constexpr
来修饰它. 而另一个前提是其返回值类型 Temperature
是字面类型. 根据这些条件, 可将代码修改成下面这样
struct Temperature {
long degree;
// 构造函数加上 constexpr 修饰
constexpr explicit Temperature(long t)
: degree(t)
{}
// ...
// 不要有析构函数定义
};
// 也加上 constexpr 修饰
constexpr Temperature operator "" _c (unsigned long long t)
{
return Temperature(t + 273);
}
int main()
{
// 万事俱备, 可以用来定义编译时常量了
constexpr Temperature water_bolling_point = 100_c;
static_assert(water_bolling_point.degree == 373,
"I'm supposed to work under a standard atmosphere");
return 0;
}
而若之后的代码中不再使用 water_bolling_point
这个常量, 那么是否可以这么简写呢?
static_assert(100_c.degree == 373,
"I'm supposed to work under a standard atmosphere");
虽然看起来好像完全没问题, 但实际上不行, 而且编译器给出的错误信息会让人匪夷所思
error: unable to find numeric literal operator ‘operator"" _c.degree’
static_assert(100_c.degree == 373,
^
在给出的错误信息中, 编译器显然认为 _c.degree
是一个整体, 而尝试去找这样的后缀函数重载. 显然不可能存在这样的重载, 因为后缀算符重载不能作为某个类的成员函数定义, 而且即使作为名字空间内的函数定义, 也不存在如 100_literal::_c
的带名字空间的写法.
要解决这个 "错误" 当然不困难, 只要在字面量两边套上一层括号即可, 如 (100_c).degree
, 这也是推荐的编码方式.
造成以上编译错误的原因也并不是编译器实现本身的问题. 实际上这个问题与编译器对数值字面常量处理有关.
这里举一些数值字面常量的简单例子, 它们都是正确的
1e-5
0x1ULL
1e+7L
再举一些错误的字面常量的例子
1..0
1e++5
1e+5.5
第一个错误的原因不难发现, 是小数点太多了, 第二个是指数中加号多出来一个, 第三个是指数带了小数点. 总之错误都一目了然.
那么, 各位读者, 若你们认同此处用一目了然这个词, 不妨考虑一下, 对于编译器来说是否也应当是一 "目" 了然? 如果编译器要做到一目了然地报错, 应当采取哪些措施呢?
在实现时, 编译器词法分析处理到 1..0
有两种选择:
1.
和 .0
两个词法元素 (token), 两者都是正确的字面常量, 之后语法分析时报错在这个简单例子里, 采用两者报错似乎都差不多, 至少数量上都是 1 个错误, 而且错误也应该都比较好懂.
但是后面的例子呢? 比如 1e++5
这个, 如果词法分析时一定要正确分词, 那么分得的词元将依次是 1
, e
, ++
, 5
, 语法分析器一看这一排什么鬼, 至少会报 2 个语法错误
1
后面不应该跟一个标识符 e
++
运算符不是双目运算符之后不能再放一个 5
这样就非常地不 "一目了然" 了. 所以在实现时, 编译器往往在词法分析时就尽量采用最长适配策略将一段输入尽可能揉成一个词元, 然后再抽取其中的子项. 对于数值字面量来说, 其规则大致是
先姑且全部拿下作为一个数, 然后再慢慢抽取其中的底数指数后缀. 不过这个策略有时就会造成误判, 如 100_c.degree
就满足上面这两个条件, 所以前面的底数部分 100
解析完之后, _c.degree
整个被拿出来当作了一个后缀.
除了以上情形之外, 这种写法也会踩到坑
int foo = 0;
int bar = 0xe+foo; // 错误
其中 0xe+foo
会被认为是一整个字面量, 而非 0xe
加上 foo
. 当然, 在双目运算符前后加上空格是良好编码风格的表现之一, 遵循此风格就不会出现这种错误了.
引用类型是 C++ 类型系统中的一个重要而独特的组成部分. 通过引用, 开发者可以为对象创建别名, 以便高效地使用之.
既有的类型体系中, 引用被二元地分为非 const
引用和 const
引用, 在一些情况下这种简单的区分会导致误解和误用. 为了使代码在语义上更加明确, C++11 中加入了右值引用和广义引用类型以对应各种不同的语义需求, 并为移动语义和完美转发等特性打下了基础.
C++ 中的引用一直以来有个令人困惑的特性, 就是带有 const
限定的引用可以绑定临时对象甚至字面常量, 如
#include <iostream>
void test_ref(int& m)
{
std::cout << "non-const " << m << std::endl;
}
void test_ref(int const& m)
{
std::cout << "const " << m << std::endl;
}
int zero() { return 0; }
int main()
{
int a = 0;
int const b = 1;
test_ref(a); // 输出 non-const 0
test_ref(b); // 输出 const 1 : 这两句没有争议
test_ref(zero()); // 输出 const 0 : 临时对象匹配 const 引用重载
test_ref(1); // 输出 const 1 : 字面常量也匹配 const 引用重载
// 一个特别的规则, const 引用可以显式直接绑定临时对象或字面量
int const& m = zero();
int const& n = 1;
return 0;
}
这一规则里, const
限定引用实际扮演着两种不同的身份: 对不可修改的值的引用, 还有对临时对象或字面常量的引用. 这就会产生混淆, 进而导致误用. 然而这是为什么呢? 有一个非常朴素的原因, 就是防止临时对象被作为左值赋值. 即防止类似下面的代码编译成功
zero() = 1;
但这样的防御措施并不完美, 有时反而还会造成更大的混乱, 比如下面这段代码
#include <iostream>
struct MyClass {
// 定义重载分别对应 const 限定和非 const 限定的情况
void print() { std::cout << "non const" << std::endl; }
void print() const { std::cout << "const" << std::endl; }
};
// 同样定义两个全局函数重载, 分别对应 const 限定和非 const 限定的情况
template <typename T>
void p(T& t) { t.print(); }
template <typename T>
void p(T const& t) { t.print(); }
int main()
{
MyClass().print(); // (a) 直接在临时对象上调用 print() 成员函数
::p(MyClass()); // (b) 将临时对象传给全局 p 函数, 由 p 来决议 print() 成员函数调用
return 0;
}
那么这段代码输出是什么呢? 结果可能有些令人惊讶, 是
non const
const
也就是说, (a) 处直接用临时对象调用其成员函数 print
, 重载决议使其调用的是无 const
版本; 而在 (b) 处稍作更改, 将临时对象传给全局 p
函数, 决议的结果的是参数为 const
限定引用的 p
函数重载, 进而在 p
内用 const
限定引用决议出带有 const
版本的 print
成员函数重载.
这一规则直接导致了一些更离谱的代码, 比如 operator=
重载一般都不会有 const
限定, 那么下面这种代码完全合法
struct IntWrap {
int x;
explicit IntWrap(int xx) : x(xx) {}
IntWrap& operator=(int xx)
{
this->x = xx;
return *this;
}
};
int main()
{
IntWrap(10) = 20; // 使用临时对象的 = 算符重载, 可将临时对象放在等号左侧
return 0;
}
// 更极端的例子
IntWrap& f()
{
// return IntWrap(0); 不合法, 不能直接返回临时对象
return IntWrap(0) = 0; // 合法, 因为一次 operator= 调用将返回值 "洗" 成了左值引用
}
此大乱之道也, 焉能不正之. 而混乱的根源就是对待临时对象时, 调用成员函数决议重载的规则与调用其他函数决议的规则大相径庭.
因此, 新标准中增加了针对临时对象和字面量的新的引用类型, 并更改了相应的重载决议规则, 以改正上述这些令人困惑的行为.
首先, C++11 中加入了针对临时对象和字面量右值引用 (rvalue reference) 类型, 这种类型是专门针对临时对象和字面常量的. 如果为一族函数加上以右值引用为参数的版本, 那么使用临时对象作为实参时, 优先决议出的就是这一重载. 如
void test_ref(int& m) { std::cout << "non-const " << m << std::endl; }
void test_ref(int const& m) { std::cout << "const " << m << std::endl; }
// 增加参数为右值引用的重载
// int 的右值引用类型的写法为 int&&
void test_ref(int&& m)
{
std::cout << "rvalue " << m << std::endl;
}
int zero() { return 0; }
int main()
{
int a = 0;
int const b = 1;
test_ref(a); // non-const 0
test_ref(b); // const 1 : 这两句输出仍然不变
test_ref(zero()); // rvalue 0 : 临时对象匹配右值引用重载
test_ref(1); // rvalue 1 : 字面常量也匹配右值引用重载
return 0;
}
这一类型语法上写为类型名之后加上 &&
符号, 如 int&&
就是 int
类型的右值引用, std::string&&
是 std::string
的右值引用类型, 等等. 不得不说, 这个符号跟逻辑与运算的操作符完全一样, 不免有时会引起歧义, 就像引用符号 &
也是按位与运算符一样. 在编码时, 如果用到双目运算符而非声明引用, 也应当在其前后加上空格 (就像在自定义字面量中所提到的应注意的情况一样). 而在后文叙述中, 本书将尽量以 "右值引用符号" 指代表示右值引用的 &&
符号.
另外, 右值引用这一命名的由来是相对于左值引用, 听起来略带一些调侃. 之前已经提到, C++ 的一些特性使得 "左值" "右值" 这些概念实际上没有与等号有左右方向上的对应关系, 仅仅是一个名字而已. 如果可以的话, 读者更应该将其理解成对临时对象的引用.
右值引用除了可以区别于 const
限定引用, 在非成员函数的调用时影响重载决议之外, 还有一个好处是, 允许修改其绑定的临时对象. 这一点其实与从临时对象上调用成员函数, 决议出其非 const
限定的重载一样. 或者反过来说, 本来临时对象就应当是可修改的, 在决议非成员函数调用时强制其匹配 const
引用并不那么合适. 而且允许修改临时对象, 有时还能提高程序性能, 如
#include <vector>
#include <algorithm>
#include <iostream>
std::vector<int> make_vector()
{
std::vector<int> x{2, 1, 5};
return x;
}
void output_sorted(std::vector<int> const& v)
{
std::cout << "use const ref" << std::endl;
// std::sort(v.begin(), v.end()); // 编译错误: v 是 const 限定的
// 如果临时对象匹配 const 引用, 那么不能在容器上排序, 必须复制一份
std::vector<int> u(v);
std::sort(u.begin(), u.end());
for (auto x: u) {
std::cout << x << std::endl;
}
}
void output_sorted(std::vector<int>&& v)
{
std::cout << "use rvalue" << std::endl;
// 而右值引用允许修改引用对象, 故可以直接排序
std::sort(v.begin(), v.end());
for (auto x: v) {
std::cout << x << std::endl;
}
}
int main()
{
output_sorted(make_vector());
return 0;
}
/* 输出
use rvalue
1
2
5
*/
而将字面常量绑定到右值引用, 也可以修改, 似乎有些无法说通. 这时可以将字面常量考虑为由一个函数返回的临时值, 就不那么难以理解了. 即
int&& i = 0; // 直接把字面常量绑定到右值引用上?
int&& j = int(0); // 实际上等价于这种写法, 看起来构造函数产生的临时对象
以上临时对象和字面常量是两种典型的右值, 其它可能是右值的情况也都在下面这个例子中
void test_ref(int& m) { std::cout << "non-const " << m << std::endl; }
void test_ref(int const& m) { std::cout << "const " << m << std::endl; }
void test_ref(int&& m) { std::cout << "rvalue " << m << std::endl; }
struct Point {
int x;
int y;
Point(int x_, int y_): x(x_), y(y_) {}
};
int four()
{
return 4;
}
int main()
{
int x = 1, y = 2;
test_ref(2); // rvalue 2 : 字面常数
test_ref(x + y); // rvalue 3 : 原生类型运算
test_ref(four()); // rvalue 4 : 函数调用返回的值
test_ref(Point(5, 6).x); // rvalue 5 : 临时对象的某个属性
int six = 6; int const seven = 7;
// 显式转换非 const 左值引用为右值引用
test_ref(static_cast<int&&>(six)); // rvalue 6
// 但无法应用于 const 引用, 即 static_cast<int&&>(seven) 无法编译
// 虽然 constexpr 常量在编译之后会被编译器优化为字面常量
// 并且它本身还可以作为数组长度定义或作为模板实参
// 但它是 const 限定左值引用
constexpr int M = 7;
test_ref(M); // const 7
// 而枚举常量则算是字面常量右值
enum {N = 8};
test_ref(N); // rvalue 8
return 0;
}
区分成员函数是否允许从临时对象上调用, 也是右值引用加入之后一项重要的修订工作. 修订工作的首要一项是, 增加一个语法规则使得临时对象无法调用某些成员函数. 它的做法是在对应的成员函数后加上一个左值引用符号, 如
struct IntWrap {
int x;
explicit IntWrap(int xx) : x(xx) {}
IntWrap& operator=(int xx) & // 限制无法从右值, 也就是临时对象上调用
{
this->x = xx;
return *this;
}
};
int main()
{
IntWrap(10) = 20; // 编译错误, 此 operator= 必须从左值上调用
return 0;
}
不过, 如果不加上这个引用符号, 本着最大限度兼容既有代码的规则, 这样的成员函数仍然可以从临时对象上调用.
当然话说回来, 这种代码毕竟也是极少数, 甚至上面那些极端的示例应该不会出现在生产项目中, 因此也不必太过担心这一规则兼容. 如果一定要针对各种不同的引用类型的成员函数重载, 那么应当用如下的方式编写代码
struct MyClass {
MyClass() {}
// 在成员函数之后加上右值引用符号
void print() && { std::cout << "rvalue" << std::endl; }
// 为避免歧义, 成员函数如果有针对右值引用的重载, 在定义针对左值的重载时必须加上 & 符号
// 对 const 限定的成员函数也一样
void print() & { std::cout << "non const lvalue" << std::endl; }
void print() const& { std::cout << "const lvalue" << std::endl; }
};
int main()
{
MyClass().print(); // 输出 : rvalue
MyClass a;
a.print(); // 输出 : non const lvalue
MyClass const b;
b.print(); // 输出 : const lvalue
return 0;
}
以上就是右值引用和相应的重载决议机制加入后, 对现有体系的修正.
最后有一个小问题需要解释: 临时对象是否应该有 const
修饰, 换言之, 针对某个类型 T
是否应该有接受 T const&&
的重载, 以及是否应该有以 const&&
限制的成员函数呢? 答案为否. 通常情况下, 不应该为函数返回的对象还加上 const
限定, 因此也就不会实际产生 T const&&
类型; 而即使有这样的类型, 由于它带有了不可被修改的限制, 因此使用既有的引用类型 T const&
处理之, 效果也会一样.
由于 C++ 中等号算符是可以重载的, 因此以等号赋值语法来讨论左右值不会有太大意义. 实际上 C++ 的左值概念更多的是基于是否可以取得对象地址, 并在这一地址空间上执行相应的行为来界定的. 并且, 它衍生出了被称之为 同一性 (identity) 的概念, 也就是两个对象引用可以根据其地址是否相同而确定是否引用了同一个对象.
而 C 和 C++ 均禁止对临时对象使用取得地址算符 (前置单目 &
算符), 无法直接获取地址的表达式当然谈不上同一性, 在 C++11 中这些表达式被称作纯右值 (pure rvalue). 字面常量, 对返回值类型定义为值类型的函数的调用, 各种运算 (包括所有原生对象的算术, 逻辑, 比较运算等), 以及对右值对象的成员, 还包括 lambda 表达式, 都属于纯右值.
同一性的特性还产生了一个 C++ 与 C 行为不一致的地方, 就是 C++ 中对空类型 (没有数据成员或虚函数, 若有父类, 其父类也必须全是空类型) 求 sizeof
得到的结果至少为 1, 而 C 中对空结构体求 sizeof
得到的是 0. 因此 C++ 可以确保两个对象的地址一定不同. (此外 C++ 中 struct Empty {} x, y; ptrdiff_t m = &x - &y;
这样的代码没问题, 但在 C 中会因除零错误而崩溃)
临时对象或字面量都是典型的右值. 而另一种右值则通过非 const
限定的左值对象转换而来, 它们被称作临终值 (eXpiring value 或简写作 xvalue). 例如在上一节中提到的方法
int six = 6;
test_ref(static_cast<int&&>(six)); // 使用 static_cast 转换得到右值引用
当然用户不必每次需要将左值类型转换为临终值都写这么别扭的 static_cast
, 在标准库中提供了一个函数包装, 它是 std::move
. 上面的例子中的 static_cast
用 std::move
替换的等价实现会是这样的
int six = 6;
test_ref(std::move(six));
调用 std::move
, 除了让编译器选择右值而不是左值引用的重载, 并没有其它的作用. 不过, 换一个重载在有些情况下大有其作用. 这些用况, 以及为何将左值表达式转换为右值引用的函数称作 move
, 将在下一章移动语义中重点介绍.
反过来, 右值引用是否可以转化为左值引用呢? 当然可以, 而且不同的是, 甚至都不需要进行一次 static_cast
. 在 C++11 中规定, 任何带有名字的引用, 这个名字构成的表达式都是一个左值引用, 因为它已经满足同一性的要求, 可以通过名字取得对象地址了 (虽然 C++ 标准没有规定临时对象具体存储在何处).
典型的情况是以函数参数的形式出现. 定义在栈上的局部变量如果是右值引用, 也会受这一规则制约. 例如
void test_ref(int& m) { std::cout << "non-const " << m << std::endl; }
void test_ref(int const& m) { std::cout << "const " << m << std::endl; }
void test_ref(int&& m) { std::cout << "rvalue " << m << std::endl; }
void proxy(int&& g)
{
test_ref(g);
}
int main()
{
int&& f = four();
// 将右值引用传给 test_ref, 匹配的是左值引用重载
test_ref(f); // 输出 non-const 4
// 将右值引用先传给 proxy, proxy 将右值引用形参传给 test_ref
// 匹配的是左值引用
proxy(four()); // 输出 non-const 4
return 0;
}
也就是说无论是定义一个具名右值引用还是右值引用作为参数, 用它去调用函数实际匹配的重载都将是左值引用重载, 这是右值应用一个容易误用的特性.
如果需要将右值引用参数还原为一个右值, 则还需要再添加 std::move
调用. 如
// ...
void proxy(int&& g)
{
// 将参数 g 传给 std::move, 重新变为临终值
test_ref(std::move(g));
}
int main()
{
proxy(four()); // 输出 rvalue 4
return 0;
}
因此在实际项目中处理临时对象时, 应该立即使用之, 而不建议在函数的栈中定义右值引用绑定临时对象, 更不应该将右值引用定义为对象成员.
在 C++11 中引入了右值引用来针对临时对象, 但是作为向前兼容, const
限定的左值引用仍然可以通配所有引用类型. 不过在新标准中还引入了一个特殊规则, 使得一些泛型引用类型的参数也可以适配任何参数. 这种参数类型被称为广义引用 (universal reference) 类型.
使用 const
限定的引用类型的缺点是, 无论实参是否为变量, 都因为加上了 const
限定而无法被修改了. 而引入广义引用要解决的问题, 就是保持各参数原有的状态.
广义引用的基本规则是, 如果模板函数的某个参数为引用类型 T&&
, 并且满足以下几个条件
T
是这个函数的模板类型参数那么此参数类型 T&&
是广义引用, 而不是一个右值引用类型.
广义引用类型的形参虽然语法上看起来像右值引用, 但它可以匹配任意类型的实参. 例如
template <typename T>
void f(T&& t) // 参数 T&& 中的 T 是模板函数 f 的模板类型参数
{
std::cout << t << std::endl;
}
int main()
{
f(0); // 输出 0 : 可以匹配右值, 实际 T&& = int&&
int x = 1;
f(x); // 输出 1 : 可以匹配左值, 实际 T&& = int&
int const y = 2;
f(y); // 输出 2 : 可以匹配 const 左值, 实际 T&& = int const&
return 0;
}
不得不说, 由于广义引用与右值引用的语法形式一样, 很容易就出现误解和误用. 比如以下两个很典型的例子, 其中出现的模板参数都不是广义引用, 因为它们违反了上述广义引用规则的第一条.
template <typename T>
void g(std::vector<T>&& v);
// ^^^^^^^^^^^^^^^^ 非广义引用而是右值引用; 因为参数类型是 std::vector<T>, 它不是模板类型参数 T
template <typename T>
struct X {
template <typename U>
void f(T&& t, U&& u);
// ^^^^^ T&& t 不是广义引用而是右值引用类型参数; 因为它不是函数的模板参数而是外层模板类的类型参数
// U&& u 是广义引用
};
而下面这个例子则演示了编译器是如何应用上述的第二条规则的
template <typename T>
void f(T&& t) {} // 类型参数 T&& 定义上满足广义引用的条件
int main()
{
int i = 0;
f(i); // 编译通过: 编译器认为 f 的参数是广义引用, 因此能够接受左值参数
f<int>(i); // 编译失败: 由于用户指定了 T = int, 因此编译器认为 f 的形参类型是 int&& 右值, 无法接受左值实参
// 即使函数在定义上满足广义引用的条件, 调用函数时也可能违反广义引用的条件
return 0;
}
广义引用的一个直接应用就是在对参数没有具体类型要求的工具函数的场景. 比如用于将左值转换为临终值的 std::move
函数的参数就是一个广义引用. 其实现为
// bits/move.h:99
template<typename _Tp>
typename std::remove_reference<_Tp>::type&& move(_Tp&& __t) // 此类型是泛型参数, 因此是一个广义引用
noexcept // 这个简单的工具函数, 它不会抛出异常, 因此加上 noexcept 声明是有必要的
{
// 使用 static_cast 将引用类型进行转换
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}
// type_traits:1373
// remove_reference 可用于将任何引用类型还原成其值类型
// 因此 move 的返回值类型就会是 _Tp 类型参数去掉引用符号之后再加上右值引用
template<typename _Tp>
struct remove_reference
{
typedef _Tp type;
};
template<typename _Tp>
struct remove_reference<_Tp&>
{
typedef _Tp type;
};
template<typename _Tp>
struct remove_reference<_Tp&&>
{
typedef _Tp type;
};
因此, 即使对一个右值使用了 std::move
也无妨, 其结果仍会被正确地当作一个右值对待.
在介绍广义引用基本语法时用了如下的例子
template <typename T>
void f(T&& t) { std::cout << t << std::endl; }
int main()
{
f(0); // T&& = int&& : 匹配右值引用
int x = 1; f(x); // T&& = int& : 匹配左值引用
int const y = 2; f(y); // T&& = int const& : 匹配 const 限定左值引用
return 0;
}
这样写注解有一个细节问题, 就是当 T&& = int&&
成立时, 是否意味着 T = int
的特化; 以及 T&& = int&
时, 特化类型又是什么. 在这里解释一下广义引用匹配参数时, 具体的引用推导规则.
广义引用只可能匹配上述三个引用类型而不可能是值类型, 当广义引用匹配一个左值引用时, 推导出的类型实参为相应的左值引用类型; 而当广义引用类型匹配右值引用时, 推导出的类型实参则为相应的值类型, 即
f(0); // T&& = int&& <=> T = int
int x = 1;
f(x); // T&& = int& <=> T = int&
int const y = 2;
f(y); // T&& = int const& <=> T = int const&
反过来看上面结论的话, 就会发现, 当 T = int
时意味着, T&& = int&&
, 这一点没问题, 但 T = int&
时, T&& = int&
, 右值引用符号去哪里了呢?
这就是引用折叠 (reference collapsing) 的效果. 在 C++ 中, 各种引用类型的叠加并不是做简单的数学运算, 用类型加上引用符号就得到相应的引用类型. 而引用类型相对于指针类型很大的一处不同也在于此: 存在指向指针的指针类型, 但不存在引用另一个引用的引用类型. 如
typedef int* int_ptr;
int_ptr* x; // x 的类型是 int**
typedef int& int_ref;
int_ref& y; // y 的类型仍然是 int&
在这种情况下两个引用符号折叠在一起, 形成 y
的最终类型定义. 在 C++11 中加入了右值引用后, 引用折叠扩充成了以下三条规则
T& && => T&
T&& & => T&
T&& && => T&&
简而言之只有两个右值引用折叠在一起才会得出右值引用, 其他情况都会是左值引用. 写成代码示例就是
#include <iostream>
#include <type_traits> // include is_lvalue_reference : 指出一个类型是否是左值引用
// is_rvalue_reference : 指出一个类型是否是右值引用
int main()
{
using int_lref = int&;
using int_rref = int&&; // 使用 using 定义两种引用类型的别名, 然后对别名加上引用符号
// 可不能直接写 int& && 这样的类型
// 输出 1 0 : 折叠为左值引用
std::cout << std::is_lvalue_reference<int_lref&>::value << ' '
<< std::is_rvalue_reference<int_lref&>::value << std::endl;
// 输出 1 0 : 折叠为左值引用, 附加上的右值引用符号失效
std::cout << std::is_lvalue_reference<int_lref&&>::value << ' '
<< std::is_rvalue_reference<int_lref&&>::value << std::endl;
// 输出 1 0 : 折叠为左值引用, 原有的右值引用符号失效
std::cout << std::is_lvalue_reference<int_rref&>::value << ' '
<< std::is_rvalue_reference<int_rref&>::value << std::endl;
// 输出 0 1 : 折叠为右值引用
std::cout << std::is_lvalue_reference<int_rref&&>::value << ' '
<< std::is_rvalue_reference<int_rref&&>::value << std::endl;
return 0;
}
在 C++ 中一直有一个需要解决的问题就是让函数具备完美转发 (perfect forwarding) 一些参数给其他函数的能力. 在设计上, 此功能可用于实现业务代码和非业务代码的分离. 比如
// implements 是具体业务逻辑的实现函数
R implements(T const& t);
// 另实现一个包装函数, 包装函数中包含日志与统计
template <typename FuncType, typename ArgType>
auto wrapper(FuncType f, ArgType const& a) -> decltype(f(a))
{
auto start = time();
logging("Call implements start at %d", start);
auto&& r = f(a); // 转发参数给业务逻辑函数
auto end = time();
logging("Call implements finished at %d", end);
function_statistic("implements", end - start);
return std::move(r);
}
int main()
{
// 调用处可以这样使用 wrapper 与 implements
R r = wrapper(implements, T());
// ...
}
在上面的代码片段中, 负责业务逻辑的 implements
函数外围的日志和统计代码由 wrapper
函数管理. wrapper
虽然不会直接用到要向 implements
传递的参数, 但由于它要将参数传给 implements
, 它仍需要一定程度上了解 implements
函数的签名.
比如在上面的例子里, 如果以后 implements
函数的参数变为非 const
的引用, 即声明改成了 implements(T&)
, 那么 wrapper
函数的编译就会因为 const
修饰而问题. 或者反过来说, wrapper
函数只适合包装那些参数是 const
限定引用的函数.
那么, 是否存在更通用的引用参数 (暂且不考虑参数个数的问题) 声明, 使得无论被包装的函数的参数类型如何, 外层的包装函数都可以应对. 这就是参数完美转发要达到的目标.
在 C++11 之前这是无法做到的, 因为通配各种引用的是 const
限定引用, 这使得非 const
左值也被无故加上了无法修改的限制. 而在 C++11 中, 则可以通过广义应用来定义这样的包装函数.
R implements(T const& t);
// 将参数 ArgType&& 的定义转换为广义引用
// 注意, FuncType 也被转换为了广义引用, 这样无论可调用对象实参是一般函数指针, 带有或不带有 const 的函数对象的引用都可适配之
template <typename FuncType, typename ArgType>
auto wrapper(FuncType&& f, ArgType&& a) -> decltype(f(a))
{
// 调用开始前的工作
auto&& r = implements(t);
// 调用结束后的工作
return std::move(r);
}
这样看起来很好. 然而事情不是这么简单, 因为前文中介绍过的具名右值引用的一个特性 --- 形参中的右值引用实际上表现为左值引用 --- 在广义引用的情况下也不例外. 所以看起来在 wrapper
传给 implements
的是广义引用, 而这些引用本应该保留它们原有的形式, 但实际上它们都会变成左值引用.
不妨来做个简单的实验.
#include <iostream>
#include <type_traits>
template <typename T, typename U>
int implements(T&& t, U&& u)
{
std::cout << "at implements, t is l/r reference: "
<< std::is_lvalue_reference<decltype(t)>::value << ' '
<< std::is_rvalue_reference<decltype(t)>::value << std::endl;
std::cout << "at implements, u is l/r reference: "
<< std::is_lvalue_reference<decltype(u)>::value << ' '
<< std::is_rvalue_reference<decltype(u)>::value << std::endl;
return 0;
}
template <typename T, typename U>
int wrapper(T&& t, U&& u)
{
std::cout << "at wrapper, t is l/r reference: "
<< std::is_lvalue_reference<decltype(t)>::value << ' '
<< std::is_rvalue_reference<decltype(t)>::value << std::endl;
std::cout << "at wrapper, u is l/r reference: "
<< std::is_lvalue_reference<decltype(u)>::value << ' '
<< std::is_rvalue_reference<decltype(u)>::value << std::endl;
return implements(t, u);
}
int main()
{
std::unique_ptr<int> t;
// 推导出 T 为左值, U 为右值的重载
wrapper(t, std::unique_ptr<int>());
/* 输出
at wrapper, t is l/r reference: 1 0
at wrapper, u is l/r reference: 0 1
at implements, t is l/r reference: 1 0
at implements, u is l/r reference: 1 0
输出结束 */
return 0;
}
从输出可以看到, 在 wrapper
函数里得到的参数类型还是预期的一个左值一个右值, 但是转发到 implements
函数里, 就全部成了左值. 这并不是用户希望的行为.
解决这个问题可以借鉴 move
的做法, 可以进行 static_cast
进行转换. 不过, 由于在 wrapper
函数中并不关心传入并转发给 implements
函数的 T
U
等引用类型具体是什么, 故针对不同引用类型的 static_cast
的结构应该尽可能相似. 其实现方式可能类似这样
template <typename T, typename U>
int wrapper(T&& t, U&& u)
{
std::cout << "at wrapper, t is l/r reference: "
<< std::is_lvalue_reference<decltype(t)>::value << ' '
<< std::is_rvalue_reference<decltype(t)>::value << std::endl;
std::cout << "at wrapper, u is l/r reference: "
<< std::is_lvalue_reference<decltype(u)>::value << ' '
<< std::is_rvalue_reference<decltype(u)>::value << std::endl;
return implements(static_cast<T&&>(t), static_cast<U&&>(u));
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
}
为什么是这样的 static_cast
呢? 对于 T = unique_ptr<int>&
来说, 在它上面附加右值引用符号, 仍然会折叠到 T&& = unique_ptr<int>&
左值引用类型, 所以参数 t
上附加的 static_cast
其实什么都没做; 而对于 U = unique_ptr<int>
值类型来说, U&&
就是右值引用类型, 那么这次 static_cast
等价于对参数 u
进行了一次 move
, 让它还原成了右值引用类型.
当然, 标准库中也提供了转发工具函数, 使得用户不必在代码中写上一堆 static_cast
. 这个工具函数 forward
的声明如下
// bits/move.h:74
// 接受左值引用的重载
// 如果按照以上的做法, 只转发广义引用参数, 这一个重载就够了, 因为所有的具名引用都被认为是左值
template<class _Tp>
_Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept; // 与 move 一样不会抛出异常
// :85
// 针对纯右值的重载, 实际几乎用不到, 因为纯右值直接写在实参列表中即可
template<class _Tp>
_Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept;
由于 forward
参数列表中使用 remove_reference
对模板参数类型 T
进行了一些转换, 因此不能直接写 forward(u)
来让编译器推导模板参数类型, 需要用户手动加上, 如
template <typename T, typename U>
int wrapper(T&& t, U&& u)
{
// ...
return implements(std::forward<T>(t), std::forward<U>(u));
// ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
}
两次 forward
调用的类型特化, 详细解释如下
_Tp |
返回值类型 _Tp&& |
参数类型 std::remove_reference<_Tp>::type& |
T = unique_ptr<int>& |
unique_ptr<int>& 左值 |
unique_ptr<int>& |
U = unique_ptr<int> |
unique_ptr<int>&& 右值 |
unique_ptr<int>& |
可以看出, forward
是转发不同引用类型的有力工具. 在 "可变参数模板" 一章中将提到, forward
结合可变参数模板函数, 还能实现转发任意多个参数的功能.
本章内容将包含 C++ 中一些非常重要的更新, 它们是移动语义及与之有关的一系列语言语义改造.
C++ 语言中长期以来有个令人困惑的特性: 对象复制构造. 在第一章中简单提到过, 无论什么类型的实例, 默认都是可复制的. 而如果不希望其具有复制的能力, 却反而需要额外编写一些代码.
在这一章中将详细说明这一特性的历史原因, 造成的问题以及在 C++11 中的改进方案. 这些改动造就了更完备的 C++ 语言.
C++ 最早被作为一门 C with classes 语言被发明出来. 不得不说这一第一印象也成为了对 C++ 最为广泛接受的刻板的印象. 然而在超过 30 年的发展之后, C++ 这门语言有着繁多的语言特性, 是 C 语言无法企及的, 而兼容 C 语言, 也只是 C++ 众多语言特性中的一个部分.
在兼容 C 语言这个部分, C++ 可以利用几乎所有 C 语言的 API, 包括一些操作系统 API. 在其他高级语言中, 调用 C API 可能需要编译封装, 或打包成库, 或以虚拟机接口的形式才能供用户所使用, 而 C++ 代码中却可以几乎没有任何代价地直接调用, 对于开发者而言显然是重大利好. 然而这样做有一些也有一些不妥之处, 最终导致了 C++ 复制构造的产生.
为了实现与 C API 的兼容, C++ 必须使用与 C 语言一致的栈式空间布局模型, 即栈式对象模型. 这一点与其他许多高级语言不一样. 将对象分配在栈空间内的好处是对其成员的使用皆可以编译为从栈基指针偏移寻址, 执行效率会非常高. 比如以下定义
struct Person {
char name[16];
int age;
};
int main()
{
Person p; // 如果认为函数栈空间内定义的第一个对象的地址就是栈基址
p.name; // 那么引用 name 属性地址就相当于引用栈基指针
p.age; // 引用 age 属性相当于从栈基指针偏移 16 字节
// 这些偏移量都可以静态给出, 因此执行效率会很高.
// ...
}
当然, 优点往往会伴有缺点, 对于栈式对象模型也不例外: 其不足之处就是光凭栈内静态空间难以处理动态长度的数据, 譬如, 若须将 Person
的 name
属性设定为可动态扩展长度的字符串, 则这一段数据所需的地址空间就需要从堆上分配, 而在 C 语言中很麻烦的是, 用户还需要在使用完毕后手动归还这些堆空间.
但若是编写 C++ 程序, 则有更好的方案. 作为个例, 这里使用标准模板库中的 std::string
定义 Person
的 name
属性就能很容易地处理变长字符串. 之所以 string
具有这样的能力, 正是其内部管理了一份在堆空间中的动态资源所致. 当空间不够时, string
对象可以扩展其持有的空间. 然而, 与 C 中需要用户手动管理资源不同, string
将资源归还的行为进行了封装, 对用户而言是透明的, 比如, 在栈区定义一个 Person
对象, 会让函数返回后自动执行一些额外的操作
struct Person {
std::string name;
int age;
};
int main()
{
Person p;
std::cin >> p.name;
// ...
return 0;
// 函数结束, 清理在栈上的对象 p:
// p.name 持有的资源被自动释放
// p.age 是个整数, 不用清理
}
这个规则当然不仅仅只被 string
所用, 更一般地, 在 C++ 中用于自动处理资源释放语言特性被称之为 "资源获取时初始化" (Resource Acquisition Is Initialization, 缩写为 RAII). 光看这个名字, 并不能很好地在字面上解释其背后的语言特性, 若要完整地说, 之后还应包含半句 "对象析构时将资源释放". 换言之, 在 C++ 中, 对于那些持有资源 (堆空间, 文件句柄等) 的对象, 编译器生成的代码将保证这些对象所持有的资源的有效期与该对象的生命期严格一致, 并且在对象生命周期结束时使用指定的方法自动归还这些资源.
具体的做法是用户按照以下规则编写代码
然后编译器保证生成的应用程序有以下运行时行为
在这样的保障下, 对象所控制的资源的自动释放机制得以实现. 这是 C++ 之于 C 语言的本质不同, 甚至是 C++ 相对于其他编程语言的一个独有的性质5, 使得这门没有动态内存回收机制在某些情况下在资源自动回收方面比其它语言表现得更好.
在 C 中, 当一个函数的返回值定义为结构体时, 调用这个函数获得该结构体实例, 其行为是将这个实例的所有数据复制一份: 从被调用函数的栈区复制到调用者栈区. 譬如以下代码示例
struct Person {
char name[16]; // 栈区
int age; // .-------------------.
}; // | main() |
// | |
Person read_person() // | person q -------. |
{ // | . name:char[16] |<---.
Person p; // | | age:int | | | 函数返回时
fgets(p.name, 16, stdin); // | |_______________| | | 整个 Person 对象会被复制
scanf("%d", &p.age); // | | | 包括 16 字节的 char 数组
return p; // |-------------------| | 和一个 int
} // | read_person() | |
// | | |
int main() // | person q -------. | |
{ // | . name:char[16] |----'
Person q(read_person()); // | | age:int | |
// ... // | |_______________| |
return 0; // | |
}
在 main
函数中调用了 read_person
后, 很有可能这个对象是从 read_person
函数中定义的 p
中复制数据而产生 q
.6 对于以上定义的纯粹静态对象而言, 问题不大, 除了程序可能会因为大量的复制行为而变慢.
但如果 Person
的定义中包含携带资源的对象呢?
struct Person {
std::string name;
int age;
};
Person read_person()
{
Person p;
std::cin >> p.name;
std::cin >> p.age;
return p;
}
int main()
{
// C++ 亦会将被调用函数栈区内的 p 的数据复制到调用者栈区内的 q 中去
Person q(read_person());
// ...
return 0;
}
由于 Person
对象会包含一个 std::string
对象, 如前所述, 作为控制资源 (堆空间中分配的字符串内容) 的对象, 其生命周期结束之后其持有的资源就失效了, 那么在 read_person
函数结束的时候, p.name
所包含的资源就被归还了, 那么其包含的 name
属性内容如何传递给调用者栈区的 q.name
中去呢?
首先, 显然不可能等到 p.name
析构结束后再从 p.name
中复制出 q.name
, 因为 p.name
析构后其持有的资源已经失效, 不可作为复制的来源. 因此这时必然是先复制构造出 q.name
, 然后再析构掉 p.name
. 换言之, p.name
与 q.name
的生命周期会有一点重叠.
然后, 也是更重要的一点, name
这个属性是如何复制的? 在解答这个问题时 C++ 引入了有争议性的一个特性: 对象复制构造. 即由用户指定一个资源持有对象的复制行为, 称之为复制构造函数, 像 std::string
这样的类型, 它的复制构造函数行为被指定为
换言之, 在 p
和 q
生命周期重叠的这么看似不起眼的一段时间内, 会发生很多事情, 包括新资源分配, 数据复制.
然而这种行为显然是不合理的, 有点像朋友找我借一本书, 我就把这本书带到复印店复印一整本给他, 然后烧了自己手头上这本. 对于字符串这样可以复制的资源来说虽有些蠢但至少还能复制得出副本; 但对于文件句柄, 线程锁, 或者遵守质能守恒的宇宙而言复制是不应发生的. 但是回头看一下这个特性的由来, 又会发现它本是为了兼容 C 在这一情况下的行为, 并且衍生出适用于 C++ 的至少更加安全的资源复制行为.
而不得不说这一兼容的代价是惨重的. 然而实际上, 这些复制行为是 C++ 的日常. 直到 C++11 标准出台.
在上一个标准 C++03 制定之后的 8 年时间里, 修正以上这种无意义的复制行为的补救措施逐步形成文案并被加入了新标准中, 最终形成了 C++11 标准中一个重要的更新: 移动语义 (move semantic). 简而言之, 移动语义允许一份资源从一个对象中移动到另一个对象中去, 使得资源可以在不同对象中以较低的运行开销交接, 扩展该资源的有效期.
先来看看几个移动语义运作的例子.
#include <iostream>
#include <string>
int main()
{
std::string s0("hello, move semantic");
std::string s1(s0); // 仍然是复制构造
s1[7] = 'c';
s1[9] = 'p';
s1[10] = 'y';
std::cout << "s0 => " << s0 << std::endl; // hello, move semantic
std::cout << "s1 => " << s1 << std::endl; // hello, copy semantic
// 复制构造之后, 两者所控制的资源的地址空间是独立的
std::cout << std::endl << "000 MOVE CONSTRUCTION" << std::endl;
std::string s2(std::move(s0));
// ^^^^^^^^^ 以 move 后的 s0 临终值构造 s2 将会是 "移动构造" (move construction)
// 具体的原理之后会详细介绍, 这里先看看移动构造之后各个对象的状态
std::cout << "s0 EMPTY? " << s0.empty() << std::endl; // 1 : s0 在移动构造之后成了空字符串
std::cout << "s1 => " << s1 << std::endl; // hello, copy semantic : s1 不变
std::cout << "s2 => " << s2 << std::endl; // hello, move semantic : s2 接管了 s0 的内容
std::cout << std::endl << "111 MOVE ASSIGNMENT" << std::endl;
s0 = std::move(s1);
// ^^^^^^^^^ 将 move 后的 s1 赋值给 s0 将会是 "移动赋值" (move assignment)
std::cout << "s0 => " << s0 << std::endl; // hello, copy semantic : s0 接管了 s1 的内容
std::cout << "s1 EMPTY? " << s1.empty() << std::endl; // 1 : s1 在移动构造之后成了空字符串
std::cout << "s2 => " << s2 << std::endl;
return 0;
}
在这个例子中可以看到, 使用 s0
构造 s1
使用的仍然是复制构造, 复制之后修改 s1
不会对 s0
有任何影响; 而使用 std::move(s0)
产生 s0
的临终值, 用以构造 s2
后, s0
的内容就移交给了 s2
, 因而变成了空字符串, 之后使用 std::move(s1)
给 s0
赋值也类似. 这就是移动构造或者移动赋值会产生的效果. 从此处亦可看出将转换左值表达式为右值引用类型的函数命名为 move
的原因, 而 "临终" 这一名字也很贴切地体现出了这一运行结果.
以上例子从表面观察如此, 但并不能保证这些字符串所控制的堆空间没有真正复制过, 要确信这一点, 可以用其他 STL 容器, 比如 vector
来验证之
#include <iostream>
#include <vector>
// 探测复制行为的类型
// 发生普通构造, 复制构造, 析构时, 都会输出一些信息
struct CopyProbe {
int value;
explicit CopyProbe(int v)
: value(v)
{
std::cout << "Construct " << this << " value=" << this->value << std::endl;
}
CopyProbe(CopyProbe const& rhs)
: value(rhs.value)
{
std::cout << "Copy to " << this << " from " << &rhs
<< " value=" << this->value << std::endl;
}
~CopyProbe()
{
std::cout << "Destruct " << this << " value=" << this->value << std::endl;;
}
};
int main()
{
// 以下为相应语句之后可能的输出
CopyProbe p0(0); // Construct 0xffefffbe0 value=0
CopyProbe p1(1); // Construct 0xffefffbf0 value=1
std::vector<CopyProbe> v0;
v0.reserve(10); // 预留一些空间, 避免追加元素时
// 触发重新分配, 引起元素复制输出
v0.push_back(p0); // Copy to 0x5c3a040 from 0xffefffbe0 value=0
v0.push_back(p1); // Copy to 0x5c3a044 from 0xffefffbf0 value=1
std::cout << std::endl << "000 COPY CONSTRUCTION"
<< std::endl; // 000 COPY CONSTRUCTION
std::vector<CopyProbe> v1(v0); // Copy to 0x5c3a0b0 from 0x5c3a040 value=0
// Copy to 0x5c3a0b4 from 0x5c3a044 value=1
std::cout << "v0.size() " << v0.size() << std::endl; // v0.size() 2
std::cout << "v1.size() " << v1.size() << std::endl; // v1.size() 2
std::cout << std::endl << "111 MOVE CONSTRUCTION"
<< std::endl; // 111 MOVE CONSTRUCTION
std::vector<CopyProbe> v2(std::move(v0)); // (((没有输出)))
std::cout << "v0.size() " << v0.size() << std::endl; // v0.size() 0
std::cout << "v1.size() " << v1.size() << std::endl; // v1.size() 2
std::cout << "v2.size() " << v2.size() << std::endl; // v2.size() 2
std::cout << std::endl << "END" << std::endl; // END
return 0; // Destruct 0x5c3a040 value=0
} // Destruct 0x5c3a044 value=1
// Destruct 0x5c3a0b0 value=0
// Destruct 0x5c3a0b4 value=1
// Destruct 0xffefffbf0 value=1
// Destruct 0xffefffbe0 value=0
从这个例子中可以看到, 在向 v0
中添加元素时, 还是会使用对象复制的方式将副本加入容器中; 使用 v0
构造 v1
时, 因为是复制构造, 所以 v0
中的两个元素各复制了一次, 输出了两行; 而之后使用移动构造的方式用 v0
构造 v2
时则没有输出, 说明移动构造时至少元素复制被避免了.
从代码表象上, 复制 vector
和移动 vector
的差别就在于是否对构造参数使用 std::move
这个函数. 这是 C++ 向前兼容的结果: 默认写法产生的行为仍然是复制, 确保以前的代码仍然可以正常工作. 而使用 std::move
只是为了产生一个右值引用类型.
之前介绍过, 右值引用是 C++11 中新引入的一种针对函数返回的临时对象的引用, 或从一个非 const
左值引用上强行转化而来, 以匹配接受右值引用参数的函数重载. 当然, 构造函数重载和 operator=
算符重载也都算是函数重载. 那么, 若这些特别的函数有接受右值引用类型参数的重载, 在使用右值实参进行重载决议时就会产生上述效果. 而这些特别的函数, 在 C++11 中则被命名为移动构造函数 (move constructor) 和移动赋值 (move assignment) 算符重载.
不难想象, 它们在 vector
中的声明应写作如下形式
struct vector {
vector(vector&& src); // 移动构造函数
vector& operator=(vector&& src); // 移动赋值算符重载
};
而其他类型的移动构造函数和移动赋值算符重载也类似.
当使用左值表达式去调用某个有重载函数时, 那么将匹配的是左值引用的重载, 如上面例子中直接使用 v0
去构造 v1
, 就会匹配到复制构造函数. 相对地, 如果需要匹配移动构造函数, 就需要产生一个右值引用类型去匹配移动构造函数.
当然, 更典型的情况应该是针对临时对象的纯右值引用: 使用函数返回的临时对象去构造另一对象, 将优先匹配移动构造函数. 如下面这样的代码
std::vector<int> make_some_vector()
{
std::vector<int> r;
// ...
return std::move(r);
}
int main()
{
std::vector<int> s(make_some_vector());
// ...
}
在 make_some_vector
函数内很明显地使用 std::move
作用于即将返回的对象 r
, 那么将 r
传出毫无疑问是移动构造; 而在 main
函数中, 使用 make_some_vector()
调用得到的返回值, 也就是一个临时对象作为构造函数参数, 同样会匹配 vector
的移动构造函数. 即上面这样的代码不会产生任何 vector
的复制. 本章开头所谈到的那些问题至此已经完全解决了.
对于那些没有定义移动构造函数的类型而言, 类似上述代码中以函数返回值作为构造参数的行为, 将仍然以复制构造函数作为备选匹配.
那么移动行为要如何实现呢? 这一点不妨来看看标准库容器是如何实现的. 以下便选取 vector
的源代码作为范例.
vector
的存储结构与移动构造函数实现std::vector
也许是 STL 中最常用的线性容器. 它有非常好的尾端插入效率和极快的随机寻访能力, 适用于不少常见场景. 从这些特性上不难猜测 vector
是对动态可变数组的封装, 实际上也正是如此. 以下是 vector
的存储结构的一种实现
// bits/stl_vector.h:71
// vector 的基类, 将 vector 中管理堆空间资源的部分剥离到此基类中
template<typename _Tp, typename _Alloc>
struct _Vector_base {
// :80
// vector 数据存储结构 _Vector_impl 定义
// 此内部类继承的 _Tp_alloc_type, 故也会被当作配置器使用
// 此内部类中只定义了无参构造函数, 而没有其他构造函数, 也不包含显式析构函数
// 这样做是为了让这个内部类非常单纯地仅仅处理数据元素
struct _Vector_impl
: public _Tp_alloc_type
{
pointer _M_start;
pointer _M_finish;
pointer _M_end_of_storage;
// :86
// 在默认构造函数里, 仅仅是将三个指针都初始化为 0 (即 NULL)
_Vector_impl()
: _Tp_alloc_type()
, _M_start(0)
, _M_finish(0)
, _M_end_of_storage(0)
{}
// :95
// 从配置器类型引用构造, 仅初始化配置器部分
// 三个成员指针仍然是被设置为空
_Vector_impl(_Tp_alloc_type&& __a)
: _Tp_alloc_type(std::move(__a))
, _M_start(0)
, _M_finish(0)
, _M_end_of_storage(0)
{}
// ..
};
// :164
// 使用以上内部类作为成员
_Vector_impl _M_impl;
// ...
};
// :208
// 以 protected 方式继承上述类型, 作为最终暴露给用户使用的 vector 模板
template <typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector
: protected _Vector_base<_Tp, _Alloc>
{
// :232
typedef size_t size_type;
// :644
// 获取元素个数, _M_finish 与 _M_start 之差
// _M_start 标记了元素存储的开始位置
// _M_finish 标记了元素存储结束位置
size_type size() const _GLIBCXX_NOEXCEPT
{
return size_type(this->_M_impl._M_finish - this->_M_impl._M_start);
}
// :724
// 获取分配的存储容量, _M_end_of_storage 与 _M_start 之差
// _M_start 亦标记了分配的空间的开始位置
// _M_end_of_storage 标记了分配的空间结束位置
size_type capacity() const _GLIBCXX_NOEXCEPT
{
return size_type(
this->_M_impl._M_end_of_storage - this->_M_impl._M_start);
}
// ...
};
从 size()
, capacity()
的实现可以看出, 当有元素存储时, 这些成员所标记出的连续线性的 vector
存储结构大致如下
_M_finish ----.
_M_start | _M_end_of_storage
| V |
'->+---+---+---+---+---+---+---+---+<----'
| E | E | E | E | E | - | - | - |
+---+---+---+---+---+---+---+---+
| <---- size -----> | | size() 求得的元素个数
| <-------- capacity ---------> | capacity() 求得的所分配的空间大小
在观察了 vector
的存储结构之后, 现在就来看看 vector
是如何实现其移动构造函数的. 如之前所说, 大部分情况下用户从函数内返回一个 vector
对象并不希望它的内容被复制一份, 这个其实并不难做到, 代码如下
class vector
: protected _Vector_base<_Tp, _Alloc>
{
// bits/stl_vector.h:217
// 基类的简洁的别名
typedef _Vector_base<_Tp, _Alloc> _Base;
// :327
// vector 移动构造函数定义
// 函数体并没有内容, 直接使用基类的移动构造函数即可
vector(vector&& __x) noexcept
: _Base(std::move(__x))
{}
// ...
};
struct _Vector_base {
// :142
// 在基类的构造函数中, 让 _Vector_impl 配置器的部分进行移动构造
// 这样构造之后, _Vector_impl 的三个成员指针都是空值
// 而数据部分并非传入 _Vector_impl 构造函数, 而是使用其 swap 函数
_Vector_base(_Vector_base&& __x)
: _M_impl(std::move(__x._M_get_Tp_allocator()))
{
this->_M_impl._M_swap_data(__x._M_impl);
}
struct _Vector_impl {
// :101
// 在上面 _Vector_base 移动构造函数中调用的 _M_swap_data 函数的定义如下
void _M_swap_data(_Vector_impl& __x)
{
std::swap(_M_start, __x._M_start);
std::swap(_M_finish, __x._M_finish);
std::swap(_M_end_of_storage, __x._M_end_of_storage);
}
};
// ...
};
以上代码中可以看出 vector
的移动构造函数转调了其基类 _Vector_base
的移动构造函数, 后者的移动构造函数实现中, _M_impl
成员在初始化列表中使用的构造函数重载仅初始化了配置器部分, 这一点并不用太多关心, 而 _M_impl
的各数据部分仍然是全空, 也就是说此时负责数据结构的 _M_impl
仍然是一个空容器; 紧接着调用 _Vector_impl::_M_swap_data
函数, 此函数完成的工作一目了然: 三次 std::swap
将当前容器的指针与目标容器的指针值对换, 那么当前的 _M_impl
的内容就会是参数容器的数据, 而参数容器的数据则被交换为当前的, 而当前的容器数据在前面一步初始化时被设为空, 所以在移动构造结束后, 作为参数的容器会成为一个空的容器.
由于右值引用类型的参数是一个可修改的引用类型, 在上面的代码中, 它的成员可以被用于 swap
交换. 实际上, 所有移动构造函数的实现原理都是修改来源参数, "窃取" 其中的资源为自身所用, 而让来源参数进入 "空" 的状态. 不过请注意, 虽然在代码实现中移动构造通过交换两个容器的数据内容使得移动的来源回归到刚初始化的状态, 但标准中并不保证这一点, 而是规定作为移动来源的对象会进入一个可用但不确定的状态, 在下一次使用之前, 须调用容器的 clear()
函数重置其状态, 或对其进行赋值. 一般而言, 最好不要再使用它.7
另外需要注意的是, 如果使用 auto
类型推导从右值初始化对象, 那么会优先调用移动构造函数, 并且定义出对象类型, 而非右值引用类型. 比如
std::vector<int> create_vector() { return std::vector<int>{23, 29, 31, 37}; }
int main()
{
auto x = create_vector(); // x 类型为 vector<int>, 从临时对象移动构造
std::cout << x.size() << std::endl; // 4
auto y = std::move(x); // y 类型为 vector<int>, 从 x 移动构造
std::cout << x.empty() << std::endl; // 1 : 作为移动来源的 x 失去其中元素的控制权
std::cout << y.size() << std::endl; // 4 : 这些元素全部转交给 y
return 0;
}
上一节谈到了 STL 中具有代表性的容器 vector
实现移动构造函数的一种方式, 实质上是通过类似 swap
的机制将构造来源容器的内容交换给新构造的容器. 如果读者回忆一下, 可能立即会想到在 C++11 标准出台之前有一个模板类型就具有这样类似的行为, 它就是 auto_ptr
.
这个来自上世纪的指针模板类型有一个诡异特性就是它在看起来被复制的时候实际上会发生移动, 如以下这段示例
std::auto_ptr<int> a(new int(91));
std::auto_ptr<int> b(NULL);
std::cout << (b.get() == NULL) << std::endl; // 1
std::cout << *a << std::endl; // 91
b = a;
std::cout << *b << std::endl; // 91
std::cout << (a.get() == NULL) << std::endl; // 1 : "赋值" 给 b 之后, a 就成了空指针
这个特性的初衷就是为了让一份资源同一时间只有一个, 因此当使用一个 auto_ptr
对象去构造另一个 auto_ptr
对象, 或像上面代码里这样使用赋值运算符时, 作为参数, 也就是指针原来的持有者会被设置为空值.
如果这样能够工作那当然是最好, 然而不要孤立地看这一小段代码, 若一个场景需要不定个数的 auto_ptr
, 编写代码时可能会考虑使用一个 STL 容器如 vector
存储它们, 但即使只写这么三行代码
std::vector<std::auto_ptr<int> > x;
std::auto_ptr<int> e(new int(91));
x.push_back(e);
编译器便会报错: vector
在构造元素时无法将 T const&
转换成 T&
其中 T
是 std::auto_ptr<int>
. 其中原因也很容易看出来, C++11 之前, vector::push_back
会以复制的方式传入的元素放入存储区内, 即要求模板参数类型 T
具有 T(T const&)
的复制构造函数定义, 而 auto_ptr
没有这一复制构造函数重载. 更确切地说, auto_ptr
正是被设计得不具备这一定义, 实际上, 它的看起来像复制构造的构造函数定义与一般意义上的复制构造函数截然不同, 源代码如下
// backward/auto_ptr.h:86
template<typename _Tp>
class auto_ptr
{
private:
_Tp* _M_ptr;
public:
typedef _Tp element_type;
// :112
// 要求可以修改参数引用的 "复制" 构造函数
auto_ptr(auto_ptr& __a) throw()
: _M_ptr(__a.release())
{}
template<typename _Tp1>
auto_ptr(auto_ptr<_Tp1>& __a) throw()
: _M_ptr(__a.release())
{}
// :135
// 要求可以修改参数引用的赋值算符重载函数
auto_ptr& operator=(auto_ptr& __a) throw()
{
reset(__a.release());
return *this;
}
template<typename _Tp1>
auto_ptr& operator=(auto_ptr<_Tp1>& __a) throw()
{
reset(__a.release());
return *this;
}
// :170
~auto_ptr()
{
delete _M_ptr;
}
// :224
element_type* release() throw()
{
element_type* __tmp = _M_ptr;
_M_ptr = 0;
return __tmp;
}
// :239
void reset(element_type* __p = 0) throw()
{
if (__p != _M_ptr) {
delete _M_ptr;
_M_ptr = __p;
}
}
};
这段代码摘选了 auto_ptr
中一些构造函数与相关成员函数的实现, 可以看到 auto_ptr
既不需要空间配置器也不支持设定指针释放方法, 析构函数实现中指明了该指针最终会被 delete
掉的命运. 代码很容易读懂, 唯一需要注意的就是之前说到的, 它并没有一般意义上的复制构造函数, 这里列出的两个构造函数以及两个 operator=
的重载都要求传入参数是一个非 const
引用, 以便在新对象构造完毕或赋值完毕后重置来源对象的指针为空.
在介绍 C++11 标准中引入了新的替代方案之前, 先说说为什么自动释放资源的指针类型很重要, 或者反过来说为什么尽可能不要在代码中使用裸指针类型. 原因是不利于代码阅读. 比如这个 API
struct hostent {
char *h_name;
char **h_aliases;
int h_addrtype;
int h_length;
char **h_addr_list;
};
struct hostent *gethostbyname(const char *name);
它返回一个指针, 这样的声明会让用户对返回值心存顾虑, 得继续看一下文档该怎么处理这个指针, 比如当上下文结束之后是否需要释放之类的; 当然更糟糕的是这个指针指向的结构体里有相当多的指针甚至指针的指针, 是否需要对它们进行一一释放? 换句话说, 这个 API 并不能很好地自文档化, 除非改名字叫
struct hostent *gethostbyname_returns_pointer_to_static_data_so_dont_free(const char*);
但如果这是一个 C++ 风格的 API, 倘若使用过时的 auto_ptr
, 对于用户而言仍会是非常易懂的
auto_ptr<struct hostent> gethostbyname(const char *name);
如果返回值是这么声明的, 作为调用者当然立即知道要怎么去处理.
而封装的指针类型能与容器配合产生更好的可读性也是基于此原因. 假如在代码里看到一个这样的类定义
class InputFiles {
std::vector<std::istream*> files;
public:
void add(std::istream* i)
{
files.push_back(i);
}
// ...
~InputFiles();
};
成员 files
中也许有成吨的指针, 但用户并不知道每个指针来自于哪里, 也不能确定在 InputFiles
的实例析构时这些指针指向的对象是否会被自动 delete
, 自然也无法确定能否将 std::cin
的地址放进去, 等等, 除非阅读了文档甚至阅读了整个 InputFiles
析构有关的实现.
如果定义是这样就太赞了
class InputFiles {
std::vector<std::auto_ptr<std::istream> > files;
public:
void add(std::auto_ptr<std::istream> i)
{
files.push_back(i);
}
};
以上定义可以一目了然地自诠释 files
里的所有元素会随着 InputFiles
析构而析构, 只剩下一个问题: 它无法编译.
unique_ptr
如上所述的这样, 在 C++11 标准之前自文档性和编译性似乎是有矛盾的, 而这一点在移动语义和 unique_ptr
引入后被改变了. 这个新引入的模板类型的主要用途便是自动地管理控制权单一的一个指针或一个动态数组. 例如, 它可以被以如下方式使用
#include <memory>
#include <vector>
#include <fstream>
int main()
{
// 利用 unique_ptr 管理一个 new 获得的对象地址, 与 auto_ptr 功能类似
std::unique_ptr<int> one(new int(1));
// 利用 unique_ptr 管理一个 new 获得的数组地址, 这一点 auto_ptr 无法做到
std::unique_ptr<int[]> int_arr(new int[10]);
// 使用对象池来管理某个类型的对象的方案
struct ObjectInPool {
static ObjectInPool* create()
{
// 创建对象
return new ObjectInPool;
}
static void destroy(ObjectInPool* obj)
{
// 销毁对象
delete obj;
}
private:
~ObjectInPool() {}
};
// 那么此时 unique_ptr 类型声明需要指定第二个模板参数
// 并且在构造时传入相对应的函数作为第二参数
std::unique_ptr<ObjectInPool, void(*)(ObjectInPool*)> obj(
ObjectInPool::create(), ObjectInPool::destroy);
// 从一个 unique_ptr 构造另一个 unique_ptr
// 编译错误! unique_ptr 并没有从左值或 const 限定左值引用构造的函数重载
std::unique_ptr<int> two(one);
// 正确, unique_ptr 支持以右值引用类型进行移动构造
std::unique_ptr<int> three(std::move(one));
// 将 unique_ptr 作为 vector 的元素使用可行
// 因为 vector::push_back 有接受右值引用的重载
std::vector<std::unique_ptr<std::istream>> files;
files.push_back(std::unique_ptr<std::istream>(
new std::ifstream("hello.txt", std::ifstream::in)));
return 0;
// 以上所有栈区的 unique_ptr 和被包含在 vector 中的 unique_ptr 都会析构并自动释放其控制的资源
}
与 auto_ptr
相比, unique_ptr
除了提供了更加灵活的析构时行为覆盖机制, 还可以作为 vector
或其他 STL 容器的模板参数使用 (当然这个特性得益于所有 STL 容器都支持使用移动构造添加元素, 后文将提到).
现在来一探 unique_ptr
的实现. 首先是默认的指针销毁函数包装类型
// bits/unique_ptr.h:53
template<typename _Tp>
struct default_delete
{
// ...
// :62 对 delete 的包装
void operator()(_Tp* __ptr) const
{
// 判定模板参数类型 _Tp 是否已经给出完整定义
static_assert(sizeof(_Tp) > 0, "can't delete pointer to incomplete type");
delete __ptr;
}
};
它以及代码中后面一个针对数组类型的模板偏特化是 unique_ptr
的官方指定默认第二模板参数类型; 而主菜 unique_ptr
的主要成员函数则如下
// : 108
template <typename _Tp, typename _Dp = default_delete<_Tp> >
class unique_ptr
{
class _Pointer {
template<typename _Up>
static typename _Up::pointer __test(typename _Up::pointer*);
template<typename _Up>
static _Tp* __test(...);
typedef typename remove_reference<_Dp>::type _Del;
public:
typedef decltype(__test<_Del>(0)) type;
};
// : 126
// 将指针类型和删除行为类型定义为一个整体 tuple
// tuple 将在后面的可变参数模板章节中介绍; 以下这种情况可以认为它等同于一个 std::pair
typedef std::tuple<typename _Pointer::type, _Dp> __tuple_type;
__tuple_type _M_t;
public:
typedef typename _Pointer::type pointer;
typedef _Tp element_type;
typedef _Dp deleter_type;
// ...
// 从裸指针构造, 接管这个指针并默认构造一个删除处理类型对象
explicit unique_ptr(pointer __p) noexcept
: _M_t(__p, deleter_type())
{
static_assert(!is_pointer<deleter_type>::value,
"constructed with null function pointer deleter");
}
// ...
// :415
// 获取指针
// std::get<0> 是从 tuple 类型中取得相应位置的元素的方法
// 若将 _M_t 看作一个 std::pair, std::get<0>(_M_t) 等价于 _M_t.first
pointer get() const noexcept
{
return std::get<0>(_M_t);
}
// :248
// 将指针的控制权交给调用者, 并重置此 unique_ptr 为空
pointer release() noexcept
{
pointer __p = get();
std::get<0>(_M_t) = pointer();
return __p;
}
// :159
// 移动构造函数
// 令参数交出其内含指针的控制权, 用于初始化自身
// 对于构造 deleter_type 使用到的 std::forward 函数会在后文中详细介绍
unique_ptr(unique_ptr&& __u) noexcept
: _M_t(__u.release(), std::forward<deleter_type>(__u.get_deleter()))
{}
// :236
// 获取 deleter; std::get<1> 相当于取 std::pair 的 second 成员
deleter_type& get_deleter() noexcept
{
return std::get<1>(_M_t);
}
// :180
// 析构函数: 若有必要, 删除指针指向的对象
~unique_ptr() noexcept
{
auto& __ptr = std::get<0>(_M_t);
if (__ptr != nullptr)
get_deleter()(__ptr);
__ptr = pointer();
}
// :273
// 删除复制构造函数和复制赋值算符
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
};
以上. unique_ptr
的使用方式以及代码实现中关于指针控制权管理和移交的部分并没有太难以理解的部分, 然而它无疑是 C++11 中非常重要的一个进步. 回顾这门脱胎于 C 的语言, 在 C 之上又增加了很多包装, 这些包装并不是为了显示华丽的技巧, 而往往是为了代码的可读性, 即代码应该一定程度上不需要注释就能表达清楚自身的作用. C++ 中引入 std::string
类型去替代 char*
, 因为后者不能很好地表达它到底是一个字符串还是单纯的一个字符的地址; 而 C++ 中又引入引用类型 char&
来确定地表达这是一个字符的引用, 这是在类型系统上 C++ 做出的努力. 而在资源管理方面, C++ 做出的努力就是 unique_ptr
, shared_ptr
等这些自动指针类型替代裸指针类型来明确其控制权.
在上文中提到了所有 STL 容器都支持使用移动构造添加元素, 这一点用标准的语言来说是元素类型是 "可移动构造的" (move-constructible) 或 "可复制构造的" (copy-constructible). 标准库中甚至提供了一些这样的类型判定工具, 如
#include <memory>
#include <type_traits>
static_assert(std::is_move_constructible<std::auto_ptr<int>>::value,
"auto_ptr is not move-constructible");
static_assert(std::is_copy_constructible<std::auto_ptr<int>>::value,
"auto_ptr is not copy-constructible"); // 这一句报错
这段代码编译时, 编译器便会指出第二个 static_assert
的条件不满足. 但第一个 static_assert
没有报错, 是否意味着 auto_ptr
是可移动构造的因而可以当作 vector
的参数呢?
确实, 在代码中单独写上一行 std::vector<std::auto_ptr<int>> x;
的定义并不会引起编译错误, 甚至一些情况下使用 push_back
也不会引起编译错误, 如
// 单独一个定义, 不会编译错误
std::vector<std::auto_ptr<int>> x;
// 使用右值 push_back 也不会引起编译错误
x.push_back(std::auto_ptr<int>(new int));
// 使用左值 push_back 才会引起编译错误
std::auto_ptr<int> m(new int);
x.push_back(m);
所以需要说明的是, vector
或其他容器并不是要求只要元素满足可移动构造或可复制构造两个特性之一便可, 并且不同的成员函数的使用方式对类型参数的要求可能是不同的. 一般而言, STL 容器对元素类型的基本要求是 "可擦除的" (erasable); 而要调用 push_back
则需要右值参数满足可移动插入 (move-insertable) 或其他参数满足可复制插入 (copy-insertable). 这些概念 (C++ concepts) 的定义实际上与一连串有些冗长的 allocator_traits
有关, 这里就不展开介绍了. 而上面例子里最后一次调用 push_back
出错就是因为左值的 auto_ptr
不满足可复制插入的概念.
对象引用 (如 std::string&
类型) 并不满足以上容器对元素的移动或复制的要求, 因而无法声明一个 std::vector<std::string&>
的容器实例. 这是由于 C++ 的设计, 引用在初始化的时候必须指定所引用的对象, 并且之后不能再变更. 如
std::string message("hello, world");
std::string& ref_to_msg = message;
std::string another_message("nihao, shijie");
// 此处的等号并不能将引用变更为指向 another_message
// 而是等价于调用被引用的 message.operator=(another_message)
ref_to_msg = another_message;
相对而言, 裸指针类型却都是完完全全的值类型, 这一点上无疑引用与指针的语义有天壤之别. 而要表现一个存储 "弱" 引用的容器, 自然要借助一些包装类型.
在 C++11 中, 标准库引入了一个模板类型 reference_wrapper
来替代引用本身的语义, 其目的明晰, 用法简单, 虽然名字略长, 不便书写. 如
#include <functional> // reference_wrapper, ref, cref
#include <iostream>
#include <vector>
#include <string>
int main()
{
std::string message("hello, world");
std::string message_pinyin("nihao, shijie");
std::vector<std::reference_wrapper<std::string>> messages;
// std::ref 构造一个相应类型的 std::reference_wrapper 对象 (如 std::make_pair 之于 std::pair)
messages.push_back(std::ref(message));
messages.push_back(std::ref(message_pinyin));
// get() 获得引用; 此处赋值等价于赋值给 message
messages[0].get() = "Hello, world!";
std::cout << message << std::endl; // 输出: Hello, world!
// const 限定引用包装
// 对 reference_wrapper 的参数添加 const
// 使用 cref 构造引用对象
std::reference_wrapper<std::string const> const_msg = std::cref(message);
// const_msg.get().clear(); // 编译错误: get() 获取的是 const 限定引用
std::cout << const_msg.get() << std::endl; // 输出: Hello, world!
// 通过赋值可以让 reference_wrapper 绑定到另一个对象上
const_msg = message_pinyin;
std::cout << const_msg.get() << std::endl; // 输出: nihao, shijie
// 上面一句变更 reference_wrapper 绑定的对象不会影响它原来绑定的对象
std::cout << message << std::endl; // 输出: Hello, world!
return 0;
}
当然, 使用 reference_wrapper
时, 一定要确保其绑定的对象本身没有被析构掉, 否则会产生未定义行为.
除了兼容容器元素之外, std::reference_wrapper
以及 std::ref
/std::cref
也广泛用于各种可能需要传递引用的场景, 在后文的可变参数模板, 多线程等部分都会提到它们. 而所有这些指针和引用的封装类型的引入使绝大部分情形都可以使用对象而不是裸指针来编写程序:
unique_ptr
shared_ptr
reference_wrapper
剩下的一小部分? 当与 C API, 特别是操作系统 API 交互时会有必要用到裸指针, 不过这时最好提供一个 C++ 的包装.
C++ 有一些规则会隐式地为用户生成复制构造函数, 复制赋值算符重载或析构函数, 在引入移动语义之后, 这些隐式生成还扩展到了移动构造函数与移动赋值算符重载. 本节中就来对 C++ 标准中这些部分加以说明.
在 C++11 标准之前, 有个 "三法则" (the rule of three) 约定, 指的是以下三个函数
需要被定义, 如果用户没有定义它们, 则编译器会隐式生成它们. 如
struct AllImplicit {
int x;
int y;
AllImplicit(int xx, int yy)
: x(xx)
, y(yy)
{}
};
int main()
{
AllImplicit a(10, 20);
AllImplicit b(a); // 调用隐式生成的复制构造函数
AllImplicit c(0, 0);
c = a; // 调用隐式生成的赋值算符重载
std::cout << a.x << ',' << a.y << std::endl;
std::cout << b.x << ',' << b.y << std::endl;
std::cout << c.x << ',' << c.y << std::endl;
return 0;
// 调用隐式生成的析构函数
}
而隐式生成的复制构造函数, 复制算符重载和析构函数则是
struct AllImplicit {
AllImplicit(AllImplicit const& rhs)
: x(rhs.x)
, y(rhs.y)
{}
AllImplicit& operator=(AllImplicit const& rhs)
{
this.x = rhs.x;
this.y = rhs.y;
}
~AllImplicit() {}
};
编译器这么做的规则是调用其每个成员的复制构造函数对其进行初始化; 赋值算符重载类似, 为每个成员进行一次赋值; 析构函数则什么都不做. 对于以上这个例子而言, 默认的复制构造函数, 析构函数等已经足够了, 因为它并不包含任何资源的管理. 这也是编译器执行隐式函数生成的根据. 而这也正是在介绍 constexpr
时提到过的字面类型, 这些类型跟 int
, double
等一样, 在 C++ 中被视为纯粹的基本数据类型, 不必在复制或析构时有特别的关照.
而以下这个例子, 就需要引入用户自定义的这一组函数
class Buffer {
typedef unsigned char byte;
// _buffer 指针指向的是一块堆内存区
byte* _buffer;
// 以及这块堆内存区分配的大小
std::size_t _size;
public:
// 复制的时候, 不只复制指针的值, 而要根据被复制对象的资源信息
// 另外分配一份相同大小的内存区, 并拷贝数据
Buffer(Buffer const& rhs)
: _buffer(rhs._size != 0 ? new byte[rhs._size] : nullptr)
, _size(rhs._size)
{
std::copy(rhs._buffer, rhs._buffer + rhs._size, this->_buffer);
}
// C++ 隐式函数生成规则的逻辑在此体现为, 如果有以上定义的复制构造函数
// 那么 operator= 可以用隐式生成的吗? 显然答案为否. 用户必须自己定义赋值行为
Buffer& operator=(Buffer const& rhs)
{
if (this != &rhs) {
Buffer tmp(rhs);
this->swap(tmp);
}
return *this;
}
// C++ 隐式函数生成规则的逻辑在此体现为, 如果有以上定义的复制构造函数
// 那么析构函数可以用隐式生成的吗? 显然答案亦为否. 用户必须自己定义析构行为
~Buffer()
{
delete[] this->_buffer;
}
void swap(Buffer& rhs);
};
以上的例子可以看出, 如果用户定义了这三个函数中的一个, 也应当定义另外两个, 这是由于在 C++ 的语义上, 自定义其中任何一个函数是有资源管理的情形.
是否需要编写相应的控制函数也显示出来 C++ 的类应当区分为两种: 基本数据对象类型和资源控制对象类型. 基本数据对象持有的都是可以平凡复制的基本数据, 不需要非平凡的复制或析构行为; 而资源控制对象型则需要用户定义更细致地复制和析构行为.
在 C++11 标准中, 这一约定被更换成了 "五法则" (the rule of five), 多出来的两个是移动构造函数和移动赋值算符重载. 对于新的这些函数的实现要领, 在 STL 源码中已经见识过了这里就不再举例赘述. 只是对它们而言情况稍有不同, 新的约定是
delete
, 那么对应的函数不会隐式生成例如
struct Person {
std::string name;
int age;
// 由于三法则中任何一个函数都没有定义
// 编译器隐式生成五法则中所有函数
// 其中隐式生成的移动构造函数将类似于
/*
Person(Person&& x)
: name(std::move(x.name)) // 移动构造 string 类型的 name 属性
, age(std::move(x.age)) // int 类型的移动实际跟复制行为一样
{}
*/
// 这种隐式生成, 一般而言都应该是语义正确的
};
struct PointerHolder {
// 作为成员的 unique_ptr 的复制构造, 复制赋值算符都被标记为 delete
// 因此这个类也不会生成这两个函数
std::unique_ptr<int> p;
PointerHolder()
: p(nullptr)
{}
// 如果不注释这一句, 那么移动构造函数和移动赋值算符重载都不会隐式生成
// 下面使用处会报错
// ~PointerHolder() {}
};
int main()
{
Person p;
p.name = "xiaoming";
p.age = 10;
Person q(std::move(p)); // 调用隐式生成的移动构造函数
std::cout << p.name.empty() << std::endl; // 1 : p 作为移动来源, 其 name 属性管理的资源移交给了 q.name
std::cout << p.age << std::endl; // 10 : 移动之后基本数据类型的值还会保留
std::cout << q.name << std::endl; // xiaoming
std::cout << q.age << std::endl; // 10
// ====================
PointerHolder x;
x.p = std::unique_ptr<int>(new int(40));
// 调用隐式生成的移动构造函数
PointerHolder y(std::move(x));
std::cout << (x.p == nullptr) << std::endl; // 1
std::cout << *y.p << std::endl; // 40
// 调用隐式生成的移动赋值算符
PointerHolder z;
z = std::move(y);
std::cout << (y.p == nullptr) << std::endl; // 1
std::cout << *z.p << std::endl; // 40
return 0;
// 调用隐式生成的析构函数
}
上面最后一条规则向前兼容的防范, 现有代码中某个管理资源的类型立即使用支持 C++11 编译器编译时, 由于没有隐式生成移动相关函数, 从右值构造或赋值时还是会决议到复制构造函数或复制赋值算符重载. 因此在更新了编译器之后, 这些类型也要更新代码才能获得移动语义带来的优化.
五法则的着眼点在类型是否手动管理资源. 而在另一个领域里有一个小例外, 就是不包含资源, 但需要多态行为的基类在定义时, 用户必须定义一个虚析构函数, 但并没有必要定义复制或移动的相关函数. 这时如果用户去一个个手写这些函数就太麻烦了, 尤其是移动相关的函数, 它们并不会被隐式生成. 为了解决这个问题, C++11 又引入了用于生成默认行为的构造函数, 赋值运算符的机制. 如
struct BaseClass {
// 空函数体的析构函数, 只是为了加上 virtual 修饰
virtual ~BaseClass() {}
// 复制相关函数
// 在函数签名后使用 = default 来生成默认行为
// 与 = delete 不同, 它只可以被用在这些能被隐式生成的函数之后
BaseClass(BaseClass const&) = default;
BaseClass& operator=(BaseClass const&) = default;
// 移动相关函数
BaseClass(BaseClass&&) = default;
BaseClass& operator=(BaseClass&&) = default;
// 无参数的构造函数也适用 = default; 在本例中这不是必需的
BaseClass() = default;
};
使用 = default
生成默认行为的函数, 只能用于无参构造函数和五法则约定中的函数, 且析构函数不能是虚函数 (因此上例中析构函数不能使用 = default
). 在函数签名后, 不能写初始化列表或函数体, 而必须直接加上 = default;
声明. 而若类不需要其中的某些函数, 也最好使用 = delete
显式将其标记为不可用.
虽然 C++ 语言是一门静态类型语言, 但因为有较为完善的泛型编程支持, 编写一些与类型无关的代码也没有太大困难.
而新引入的可变参数模板机制则让既有的泛型特性如虎添翼, 自此, 泛型函数或泛型类型中的泛型参数的个数也不必在编写时确定了, 有助于开发者编写出更加灵活的代码.
可变参数这一特性, 早在 C 语言中就有了, 可变参数函数这一特性, 在 C 语言悠久的发展历史中, 实际上是很早就引入的一项特性, 这跟 C 的编译特性有关系. 早期的 C 语言并不支持前置函数声明, 而编译器在处理到没有定义过的函数调用时, 会假定该函数返回整型 (int
) 并且接受不定个数个整型参数, 并按照这样的规则生成参数入栈和返回的代码, 因此 C 几乎天生支持这个看起来很复杂的语言特性.
其中最典型的运用当属与 C 语言标准 IO 的一族函数, 如
int printf(char const* format, ...);
int scanf(char const* format, ...);
而作为 C++11 中新加入的可变参数模板 (variardic template) 特性中的一个方面, 可变参数模板函数当然不是 C++ "天生" 支持的特性, 它的设计是利用 C++ 中原本支持的重载机制和泛型函数机制来实现构造可以接受任意多个泛型参数的函数. 比如
template <typename T, typename... More> // typename 之后加上省略号 (...) 表示这是可变参数模板
void print(T const& t, More const&... more); // 相应地在泛型参数类型后也加上省略号
虽然也使用了省略号这一词法元素, 但它与 C 中的可变参数函数的机制不同. 实际上, 声明了以上函数, 等价于声明了这样一组不同参数个数的泛型函数重载
template <typename T>
void print(T const& t); // More 参数个数为 0
template <typename T, typename More0>
void print(T const& t, More0 const& more0); // More 参数个数为 1
template <typename T, typename More0, typename More1>
void print(T const& t, More0 const& more0, T2 const& more1); // More 参数个数为 2
// ... 任意多个这样的重载
每个参数的类型都是泛型类型, 而不是默认为是整数, 或由用户自己从固定参数中推定得出, 因此相对于 C 的可变参数函数, 这一机制也有更好的类型安全性.
可变参数模板函数的运用通常都是对某种允许有任意多个参数的行为的建模, C 语言在这个方面用得最多的是格式化输入输出, 那么作为一个简化的例子, 不妨来考虑一个需求: 输出任意多个 (至少一个) 对象, 以逗号分隔它们, 而不需要考虑格式化输出. 如以下的调用所示
print(0); // 输出: 0
int const x = 10;
std::string msg("A quick brown fox");
print(x, msg, 3.14); // 输出: 10, A quick brown fox, 3.14
print(msg, "and a lazy dog", &x); // 可能的输出: A quick brown fox, and a lazy dog, 0xffefffc4c
print(); // 无法编译
从以上示例也可以看出, 每个传递给 print
的参数的类型也可以是任意类型的, 这与 STL 算法库中实现迭代, 累加, 变换等需求的函数有本质区别. STL 算法函数都是从给定的迭代区间中获取参数, 而这些参数都有同样的类型, 这一点与可变参数模板函数要达成的目的不同.
因此, 要实现以上的需求, 需要引入的函数不仅仅是接受任意个参数的函数, 而且所有这些参数都是泛型参数. 此函数的声明在本节开始的时候给出了
template <typename T, typename... More>
void print(T const& t, More const&... more);
现在介绍其中各部分的含义. 此模板函数的声明中有两个 typename
但其接受的模板参数是至少 1 个. 这就是因为第二个 typename
之后的省略号 ...
表示此参数为一个类型参数包 (parameter pack). 参数包指的是从零个到任意多个类型参数, 因此参数列表中的 More const&... more
指的是从 0 个到任意多个参数, 那么若要函数至少接受 1 个参数, 就需要在前面额外加上一个 T const& t
.
这两部分的位置不可颠倒, 也就是形式参数列表中参数包只能出现在列表最后. 但出现在 template
声明中的 typename
没有这个限制
template <typename T, typename... More>
void print(More const&... more, T const& t); // 错误, 参数包应该放在参数列表最后
template <typename... More, typename T> // 将 typename... 放在模板声明的其他位置可以编译
void print(T const& t, More const&... more);
函数的实现可以按照以下思路进行
template <typename T, typename... More>
void print(T const& t, More const&... more)
{
std::cout << t; // 输出第一个对象
if (/* (a) more 代表的参数包内有多于 1 个参数 */) {
std::cout << ", ";
print(/* (b) 将 more 中的参数展开到此处作为实参, 以输出剩下的对象 */);
} else {
std::cout << std::endl;
}
}
以上 (a) (b) 两处都需要用到可变参数模板的配套设施, 即以下 C++11 中新增的语法
sizeof...(More)
或 sizeof...(more)
得到参数包中的参数个数, 这是一个编译时常数print(more...)
将参数包 more
中的参数展开为 print
调用的实参将这些内容补充到上面实现中, 便是
template <typename T, typename... More>
void print(T const& t, More const&... more)
{
std::cout << t;
if (sizeof...(more) > 0) {
std::cout << ", ";
print(more...);
} else {
std::cout << std::endl;
}
}
按照可变参数模板函数的逻辑, 以上代码实际上等价于编写了一族函数重载
template <typename T>
void print(T const& t)
{
std::cout << t;
if (0 > 0) { // 没有 More 参数, sizeof...(more) 为 0
std::cout << ", ";
print(); // 展开时 print 就没有参数了
} else {
std::cout << std::endl;
}
}
template <typename T, typename More0>
void print(T const& t, More0 const& more0)
{
std::cout << t;
if (1 > 0) { // More 参数个数为 1
std::cout << ", ";
print(more0); // 展开时 print 有 more0 一个参数
// 匹配上一重载
} else /* ... */
}
template <typename T, typename More0, typename More1>
void print(T const& t, More0 const& more0, More1 const& more1)
{
std::cout << t;
if (2 > 0) { // More 参数个数为 2
std::cout << ", ";
print(more0, more1); // 展开时 print 有 2 个参数
// 匹配上一重载
} else /* ... */
}
// 更多参数的情况
简而言之, print(more...)
这个带有参数包的表达式, 就是编译器为用户将参数包中的参数填入函数调用的实参列表中. 当然还有一些较为复杂的情况, 将在下一节中详细说明.
不过很遗憾的, 编译器会在 print(more...)
这一句报错, 提示给 print
函数提供了 0 个参数, 对应于第一个没有 more
参数的重载.
虽然 sizeof...
求得的是编译时常数, 编译器应该能在编译时就优化并只选出条件分支语句其中一个路径, 不过这种优化的时机要晚于编译两个路径, 所以还未开始优化, 就会先给出编译错误.
而正确的实现应当是另行提供一个仅 1 参数的重载, 来替代编译器生成的. 如下
template <typename T>
void print(T const& t) // 提供一个单独的重载, 处理没有 more 参数的情况
{
std::cout << t;
std::cout << std::endl; // 只有一个参数的重载也包括了 sizeof...(more) == 0 时的分支路径
}
template <typename T, typename... More>
void print(T const& t, More const&... more)
{
std::cout << t;
std::cout << ", "; // 只留下 sizeof...(more) > 0 的分支路径
print(more...);
}
提供了这个重载后, 也同时将分支语句中的两个路径拆分到两个重载中去了, 换言之, 在后面一个重载中, 实现所表现出的逻辑是, more
中肯定有至少一个参数, 不可能是 0 个.
这实际上也是可变参数模板的重载匹配规则所确保的. 当有给定个数个参数的重载提供时会被优先匹配, 所以在给 print
的参数只有 1 个时, 就会匹配前一个重载. 当然, 这有一个前提: 在后一个重载实现时, 应当先给出前一个重载的前置声明或实现, 否则后一个重载也不会知道当参数包个数为 0 时去匹配前一个重载了.
下面是一个完整的例子.
#include <iostream>
template <typename T>
void print(T const& t)
{
std::cout << t << std::endl;
}
template <typename T, typename... More>
void print(T const& t, More const&... more)
{
std::cout << t << ", ";
print(more...);
}
int main()
{
print(0);
int const x = 10;
std::string msg("A quick brown fox");
print(x, msg, 3.14);
print(msg, "and a lazy dog", &x);
return 0;
}
可以看出, 可变参数模板函数的实现实质上是不断对模板进行递归特化, 并逐渐减少参数包中参数数量, 最终耗尽参数包而得以匹配到更一般的非可变参数的重载来实现的, 也因此在实现可变参数模板函数时, 一般需要定义一个单独的重载来匹配参数包中参数个数为 0 的情形.
而严格地讲, print
调用自身来 "递归" 实现的说法并不正确, 因为每次调用生成的特化函数都是不同的函数. 当后文中为了叙述方便, 仍然会说这样的模板函数或模板类型递归地调用自身, 或递归地特化自己来实现.
这个例子也能展现出 C 中的可变参数函数和 C++11 的可变参数模板函数之间的本质区别. 对于 C 中的函数, 如标准 IO 中正统的 printf
, 其参数的个数和类型都是从模式字符串推导出来的, 而可变参数模板函数的参数类型都是编译时可以确定的, 体现了模板函数更好的类型安全性. 而而其缺点也很典型, 就是不同类型的特化会生成不同的代码, 使程序体积增大, 不过在新世纪的今天, 为了代码的可维护性, 这应该也不算大问题了.
如上面所说的, 当同时存在一个可变参数的重载, 和一个固定个数参数的重载时, 会优先适配后者, 如
template <typename T>
void print(T const& t) { /* ... */ }
template <typename T, typename... More>
void print(T const& t, More const&... more) { /* ... */ }
template <typename T, typename U, typename S>
void print(T const& t, U const& u, S const& s) // 增加一个固定 3 参数的重载
{
std::cout << "matches me, no further output" << std::endl;
}
int main()
{
// ...
print(msg, "and a lazy dog", &x); // 输出 matches me, no further output
return 0;
}
此外, 如果固定个数参数的重载同时也有固定的类型而非泛型类型, 那么与既有标准中的行为一样, 这些更精准的重载将在合适时被优先匹配.
可变参数模板还有一种特别的重载: 更换参数包类型的引用形式
template <typename T>
void print(T const& t) { /* ... */ }
template <typename T, typename... More>
void print(T const& t, More const&... more) // 参数包引用形式为 const 限定引用
{
std::cout << t << " [C] "; // 分隔符为 [C]
print(more...);
}
template <typename T, typename... More>
void print(T const& t, More&... more) // 参数包引用形式为非 const 限定引用
{
std::cout << t << " [-] "; // 分隔符为 [-]
print(more...);
}
int main()
{
int const x = 10;
std::string msg("A quick brown fox");
print(x, msg, 3.14); // 输出: 10 [C] A quick brown fox [C] 3.14
print(x, msg); // 输出: 10 [-] A quick brown fox
return 0;
}
从这个例子中可以看出, 如果加上一个非 const
限定引用的重载版本, 而且调用时参数包中的实参又恰好都是非 const
的左值, 那么也会遵循尽可能精准的规则匹配非 const
限定的版本. 当然这种做法并不建议, 而应该使用广义引用
template <typename T, typename... More>
void print(T const& t, More&&... more) // More 是模板参数类型, 因此这不是右值引用, 而是广义引用
{
// ...
}
实际情况中, 根据是否允许修改参数, 选择性地给出 const
限定引用或广义引用之一即可.
除了可以在 typename
关键字之后加省略号表示多个类型参数之外, 整数类型参数的个数也可以是可变的. 如上面所说的
template <typename T, int... I> // 任意多个整数参数
void func();
在自定义字面量后缀一节中提到的如下模板函数定义, 其中模板参数为 char... ch
, ch
参数包中包含该字符串中每一个字符. 可通过如下方式实现之
#include <iostream>
#include <gmpxx.h> // GMP 高精度数值库, 包括高精度整数类型 mpz_class 和高精度浮点数类型 mpf_class 等
// 由于 gcc 4.5 以上版本依赖 GMP 库, 若正确安装了 gcc, 应该可以直接包含此头文件
// 链接时, 需要增加 -lgmp -lgmpxx 参数
template <char... ch>
mpf_class operator "" _mf()
{
// 将参数包展开到数组初始化列表中, 等价于 {ch0, ch1, ch2, /* ... */, 0}
// 末尾处的 0 表示 nul 字符, 换言之这一自定义字面量重载的模板参数中没有自带 nul 字符
char s[] = {ch..., 0};
return mpf_class(s);
}
/* 以上重载完全等价于下面的重载, 而 char const* 参数比可变模板参数要更容易使用
因此若不是要将 char... ch 中的字符常量当作编译时常量使用, 建议用以下重载
mpf_class operator "" _mf(char const* s)
{
return mpf_class(s);
}
*/
int main()
{
// 传给模板的字符参数包为 '1' 'e' '+' '1' '0' '0' '0' 共 7 个字符
std::cout << 1e+1000_mf / 100 << std::endl; // 输出为 1e+998, 超过 double 类型可表示的范围
return 0;
}
最后需要提一下, 可变参数模板这一特性并没有 "可变参数但不是模板" 的形式. 比如, 需要可变个字符串作为参数, 不能写作
std::string concat_all(std::string const&... s);
这种情况下仍然只能写为泛型声明, 然后通过其他手段来验证参数类型是否满足条件. 比如最简单的做法, 利用 is_convertible
判定参数是否可以转换为 string
#include <type_traits> // is_convertible
template <typename T, typename... U>
void concat_all(T const& t, U&&... u)
{
static_assert(
// C++11 中新加的泛型类型 is_convertible
// 用于确定其第一参数类型是否可以转换为第二参数类型
std::is_convertible<T, std::string>::value,
"not convertible to string");
// ...
}
对可变参数模板函数的介绍就到此, 接下来将详细说明各种参数包, 也就是省略号在可变参数模板特性中的使用方式.
在以上例子中, 代码中出现 ...
表示这是一个省略号词法元素, 若是写成注释如 /* ... */
则表示这里有更多但不重要的或者任意的代码. 本书之后的代码部分也将沿用这种记法.
在上一节的例子中参数包省略号出现在了三种不同的位置, 这也是参数包的三种语法形式. 分别是
// a. 出现在模板内关键字之后, 如
// 不定个数个类型
template <typename... T>
// 等价于声明如下的模板
template <typename T0, typename T1, typename T2, /* 任意多个参数 */>
// 不定个数个常数
template <int... N>
// 等价于声明如下的模板
template <int N0, int N1, int N2, /* 任意多个参数 */>
// 类似的, 不定个数个模板类型
template <template <typename> class... T>
// b. 出现在类型上下文中, 如
template <typename... T>
void f(T&&... args);
// 等价于
template <typename T0, typename T1, typename T2, /* 任意多个 */>
void f(T0&& arg0, T1&& arg1, T2&& arg2, /* 相应的任意多个 */);
// c. 出现在表达式上下文中, 如
template <typename... T>
void f(T&&... args)
{
g(args...);
// 等价于用逗号分隔拆开 args 包
g(arg0, arg1, arg2, /* 相应的任意多个 */);
// 注意, 并不等价于逗号运算符, 不能单独写出来是
// 如 g(0, (args..)) 是错误的写法, 其中 (args..) 并不会变成逗号分隔的表达式
}
头一种情形就像武术的起手式, 无论是可变模板函数还是可变模板类的开头必然都有它, 但变化并不多, 在模板声明时视情况写上即可.
而类型上下文中的省略号则可用于任何可能出现任意多个类型的位置, 除了上一节中的例子里提到的直接以模板类型为各参数类型的 print
函数, 也包括如下这样的参数声明方式
template <typename... T>
void g(std::vector<T>&&... args);
// 等价于
template <typename T0, typename T1, /* 任意多个 */>
void g(std::vector<T0>&& arg0, std::vector<T1>&& arg1, /* 相应的任意多个 */);
那么这个 g
模板函数可以接受任意多个任意元素类型的 vector
对象参数. 需要注意的是, 上面这个写法跟下面的写法
template <typename... T>
void g(std::vector<T...>&& args);
完全不同, 后面这种写法是将参数包展开为 std::vector
的模板参数
template <typename T0, typename T1, /* ... */>
void g(std::vector<T0, T1, /* ... */>&& args);
如果这个参数包恰好只有一个参数, 或者恰好有两个参数且第二个参数又恰好是个 allocator
类型, 这个模板还姑且可以特化, 但其他情况就大错特错了.
除了可以在形参列表中出现参数包, 在类型继承处也可以出现任意多个类型, 这似乎有点出乎意料, 但确实可以这么干
template <typename... T>
class MyClass: public T...
{
// ...
};
// 等价于
template <typename T0, typename T1, /* ... */>
class MyClass
: public T0
, public T1 // 所有继承访问限制均与第一个相同, 如这里都是 public 继承
/* ... */
{
// ...
};
当然一般并不会直接这么用, 且一旦参数包里有两个类型相同的参数, 或者有原生类型, 那么这种继承就无法编译. 故使用时需要一些技巧, 作为一个对可变模板参数继承的例子, 下一节将介绍 std::tuple
的实现方式.
而表达式上下文中的参数包则花样更多一些, 参数包表达式可以带着表达式模式展开. 比如
template <typename... T>
void f(T&&... args)
{
// 原样展开
g(args...); // => g(arg0, arg1, /* ... */);
// 函数调用模式的展开
g(h(args)...); // => g(h(arg0), h(arg1), /* ... */);
// 运算模式的展开
g(args < value...); // => g(arg0 < value, arg1 < value, /* ... */);
// 如果是数值常数, 要加个空格以免省略号被当作小数点
g(args < 0 ...); // => g(arg0 < 0, arg1 < 0, /* ... */);
// 还可以将以上模式组合在一起
g(m(args) < n(value)...); // => g(m(arg0) < n(value), m(arg1) < n(value), /* ... */);
}
理解这种模式展开不妨将省略号视作一个优先级非常低的后置算符, 然后将省略号之前的表达式当作模式展开参数包
template <typename... T>
void f(T&&... args)
{
g(h(args)...);
// ^^^^^^^ 省略号之前的表达式
// 按此模式展开成
g(h(arg0), h(arg1), /* ... */);
// ^^^^^^^ ^^^^^^^
g(m(args) < n(value)...);
// ^^^^^^^^^^^^^^^^^^ 省略号之前的表达式, 省略号的 "优先级" 低于比较运算或任何其他算符
// 按此模式展开成
g(m(arg0) < n(value), m(arg1) < n(value), /* ... */);
// ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
}
为了让代码更容易看明白, 可以给模式表达式额外加上一层括号, 以及加上额外的空格让代码显得更清晰, 比如上述最后一个例子可以写成
g( ( m(args) < n(value) )... );
再举一例
// bits/vector.tcc:291
// vector::emplace 函数实现
// 置位式构造, 给定一个位置 (首参数) 在这个位置上构造元素
template <typename... _Args>
iterator emplace(iterator __position, _Args&&... __args)
{
size_type const __n = __position - begin();
if (this->_M_impl._M_finish != this->_M_impl._M_end_of_storage
&& __position == end())
{
_Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish,
std::forward<_Args>(__args)...);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
++this->_M_impl._M_finish;
} else {
_M_insert_aux(__position, std::forward<_Args>(__args)...);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
return iterator(this->_M_impl._M_start + __n);
}
构造参数是一组可变广义引用, 它们最终将被直接传递给元素的某个构造函数重载, 传递的过程当然要用到前文中提到的完美转发. 然而问题是如何完美转发一组任意多个参数呢? 答案就在这里, 以 forward
调用为模式展开参数包.
需要注意的是, forward
的特化类型参数 _Args
是个类型参数包, 在这个模式里相应的类型和相应的值在同一个表达式中一同展开, 相当于以下代码. 这也可以视作一种 forward
的固定用法.
template <typename _Arg0, typename _Arg1, /* ... */>
iterator emplace(iterator __position, _Arg0&& __arg0, _Arg1&& __arg1, /* ... */)
{
// ...
_Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish,
std::forward<_Arg0>(__arg0),
std::forward<_Arg1>(__arg1),
/* ... */);
// ...
}
这种固定用法非常适合于这种场景: 某个函数仅处理一部分参数, 然后让所有其他参数都透过此函数传给另一个函数, 并且转发的参数的个数是不定的. 像上面的例子一样, 这一写法在 STL 中也被广泛使用, 各种容器在 C++11 中都添加了类似 API 支持置位式构造元素, 通过这样的函数入口提供构造参数, 容器内部在分配好的空间上再根据参数构造对象, 而避免了任何构造好的元素的复制或移动. 比如, vector
有 emplace_back
API 支持在容器的末尾进行插入
struct Point {
int x;
int y;
Point(int xx, int yy)
: x(xx)
, y(yy)
{}
};
int main()
{
std::vector<Point> pts;
// 以下两个调用的效果相同, 均为向 vector 末尾添加 x=2, y=3 的 Point 对象
pts.push_back(Point(2, 3)); // 使用 push_back 将已经构造的对象加入 vector 中
pts.emplace_back(2, 3); // 使用 emplace_back 将用来构造 Point 的参数传入, 而无需直接构造对象
return 0;
}
以上就是省略号的各种用法. 到此为止可变参数模板机制都以可变参数模板函数为例, 而接下来将介绍 C++11 中 STL 加入的可变参数模板类 tuple
的实现技巧.
在第一章介绍 unique_ptr
时简单提过 tuple
类型. 因为在 unique_ptr
中定义的 tuple
类型只用了两个模板参数, 逻辑上与使用 pair
类型相同, 故也没有更深入解释.
作为可变参数模板类型的一个先驱, tuple
的使用和实现都有很典型, 围绕 tuple
本身进行分析, 便能大致摸清可变参数模板类型的实现方式和一些技巧了.
tuple
类型的用法与 pair
非常类似, 只是因其使用了可变参数模板特性, 可以扩展到一次包括任意多个元素.
// 以单个类型特化 tuple
std::tuple<int> x(0);
// 通过 get<int> 函数获取 tuple 指定位置上的元素的左值引用; 此处获取的是字符串的引用
// 不可以用下标索引 x[0]
std::cout << std::get<0>(x) << std::endl; // 0
// 以三个类型特化 tuple
std::tuple<std::string, double, int> y("hello", 3.14, 2);
std::cout << std::get<0>(y) << std::endl; // hello
std::cout << std::get<1>(y) << std::endl; // 3.14
std::cout << std::get<2>(y) << std::endl; // 2
// 不包含任何元素的 tuple
std::tuple<> tuple_of_nothing;
// 特化类型分别是 int, int, std::string 的 tuple
std::tuple<int, int, std::string> point(0, 0, "origin");
// 若 tuple 非 const 限定, 那么获取了引用就能修改它们了
std::get<2>(point) = "A";
std::get<0>(point) += 1;
// make_tuple 类似与 make_pair, 返回由其参数类型特化出的一个 tuple 对象
std::cout << (point == std::make_tuple(1, 0, "A")) << std::endl; // 1
不过, 像 tuple
或 pair
这样单纯将若干个对象聚合在一起的结构的可读性非常差. 如下面这个例子用到了 std::map
和 std::pair
void vote_for(std::map<std::string, int>& votes, std::string const& name)
{
std::pair<std::map<std::string, int>::iterator, bool>
r = votes.insert(std::make_pair(name, 0));
if (r.second) {
// 有新的名字被加入了
}
r.first->second += 1;
// ^^^^^^^^^^^^^^^
}
函数的逻辑是给某个人投票, 如果这个人没有记录, 新建之并触发新建事件, 然后给这个人票数加一. 由于 map::insert
的返回值是一个 pair
里套着另一个实质上还是 pair
的迭代器对象, 票数加一这句代码 r.first->second += 1
看起来简直是随手拼凑的涂鸦. 假如它能写作 r.iterator->value += 1
都会好很多, 可惜由于使用的是 pair
因而只得用 first
, second
这样的成员名字. tuple
同理. 因此, 不建议在实际项目中大量使用 pair
或 tuple
.
pair
的结构非常简洁明了, 包含两个模板参数类型定义的数据成员; 然而 tuple
并不能这样实现, 因为没有参数包展开语法能够用于定义一组成员.
因此, tuple
使用多重继承来实现. 这种多重继承并不是直接在类型继承处展开模板参数包, 否则有以下导致编译失败的可能
final
修饰的, 不能用于继承要解决以上问题, tuple
的实现中运用了如下的包装模板 _Head_base
, 用来包装参数类型
// tuple:78
// 参数包装类型声明, 各模板参数含义如下
// _Idx: 索引标识
// _Head: 将由传给 tuple 的模板参数来特化的元素类型参数
// _IsEmptyNotFinal: 是否是空结构体 (不含数据成员或虚函数) 且非 final 修饰
template<std::size_t _Idx, typename _Head, bool _IsEmptyNotFinal>
struct _Head_base;
// :81 类型参数是空结构体, 且不为 final 修饰的特化: 继承此类型
template<std::size_t _Idx, typename _Head>
struct _Head_base<_Idx, _Head, true>
: public _Head // _Head 为父类
{
// ...
};
// :128 除以上情况的特化: 聚合此类型
template<std::size_t _Idx, typename _Head>
struct _Head_base<_Idx, _Head, false>
{
// :174 使用 _Head 类型定义的成员
_Head _M_head_impl;
// ...
};
而 tuple
以如下方式使用 _Head_base
// :185 先导定义, 用于产生索引标识的 _Tuple_impl
template<std::size_t _Idx, typename... _Elements>
struct _Tuple_impl;
// :192 类型参数包 _Elements 的数量降为 0 的偏特化
// 既然所有类型参数已经使用完毕, 这是一个空结构体
template<std::size_t _Idx>
struct _Tuple_impl<_Idx> {};
// :230 更一般的偏特化, 至少 1 个 _Head 参数, 剩下的类型被称之为 _Tail
template<std::size_t _Idx, typename _Head, typename... _Tail>
struct _Tuple_impl<_Idx, _Head, _Tail...>
// _Tuple_impl 递归特化, 将 _Tail 传递过去, 同时将 _Idx 加上 1 以产生不同的特化类型
: public _Tuple_impl<_Idx + 1, _Tail...>
// 将 _Head_base 作为自己的基类; 使用 private 继承防止对象被基类引用误用
, private _Head_base<_Idx, _Head,
// 是否采用继承的方式产生 _Head_base, 后文将详细分析
__empty_not_final<_Head>::value>
{
// ...
};
// :389 暴露出的 tuple 类型, 以 0 为初始 _Idx
template<typename... _Elements>
class tuple
: public _Tuple_impl<0, _Elements...>
{
// ...
};
tuple
类型直接继承的是内部类型 _Tuple_impl
, 后者直接继承各种 _Head_base
的特化. 用 _Head_base
将需要继承的类型, 也就是传给 tuple
的模板参数类型包装起来, 这样无论原始类型是什么都可以裹在这个模板里继承.
然后, 使用 _Idx
整型参数对每个 _Head_base
基类进行一次顺序编号, 这样即使有相同参数类型 T
, 其通过不同编号参数特化出的基类. 如特化 std::tuple<int, int>
, 产生的两个基类 _Head_base<0, int>
和 _Head_base<1, int>
不是同一类型.
下面对 std::tuple<int, std::string, bool>
这个特化的继承方式作一个图解, 以更清晰地理解其结构
tuple<int, std::string, bool>
|
|
'----: _Tuple_impl<0, int, std::string, bool> // _Elements = int, std::string, bool
|
|
|---: _Head_base<0, int, false>
|
|
'---: _Tuple_impl<1, std::string, bool> // _Elements = std::string, bool
|
|
|---: _Head_base<1, std::string, false>
|
|
'---: _Tuple_impl<2, bool> // _Elements = bool
|
|
|-: _Head_base<2, bool, false>
|
|
'-: _Tuple_impl<3> // _Elements 为空
而 _Head_base
最后有一个 bool
参数, 用以决定此 _Head_base
是否将参数类型作为父类使用, 而不是作为成员. 为何要有继承方式的偏特化呢? 把所有类型参数全部用聚合的方式, 定义为类型成员不行吗?
行是行, 但是会有代价. 在介绍对象的同一性时提到过, 为了确保两个对象地址不同, C++ 规定对空结构体求 sizeof
要得到一个非 0 值; 而若将这个结构体定义为另一个结构体的成员, 那么再加上内存对齐, 后者可能会占用更多的空间. 不妨看看下面这个测试
int main()
{
struct E {};
std::cout << sizeof(E) << std::endl; // 1 : 同一性基本要求
struct F { E e; };
std::cout << sizeof(F) << std::endl; // 1 : 将空结构体作为成员, 体积不会继续增加
struct G { E e; int x; };
std::cout << sizeof(int) << std::endl; // 4
std::cout << sizeof(G) << std::endl; // 8 : 但如果在空结构体成员后加上 int 成员
// 因为内存对齐, 空结构体实际消耗了 4 字节
struct H: E { int x; };
std::cout << sizeof(H) << std::endl; // 4 : 空结构体作为父类继承, 就只占用 int 的空间
return 0;
}
从最后 H
的定义看出, 如果是继承空结构体, 然后在子类中加入其他成员, 则其没有额外空间消耗. 这个优化被称之为空基类优化 (Empty Base Optimization, 简称 EBO). 也就是说, std::tuple
的实现倾向于用这种技巧来节省空间. 下面这两段代码便可以一窥其中端倪.
// 使用聚合的 HeadBase
template <typename Head>
struct HeadBaseAggr { Head head; };
template <typename First, typename Second>
struct TupleAggr
: HeadBaseAggr<First>
, HeadBaseAggr<Second>
{};
// 两个空类型和一个包含 int 的类型
struct E {};
struct F {};
int main()
{
// 继承聚合模板类型的特化, 求得 sizeof 却是 2
// 因为继承了两个空间占用各 1 字节的基类
std::cout << sizeof(TupleAggr<E, F>) << std::endl; // 2
// 此处更明显, 使用聚合会引起编译器生成内存对齐的结构, 进一步增加空间消耗
std::cout << sizeof(TupleAggr<E, int>) << std::endl; // 8
return 0;
}
而使用继承的方式, 结果则是
template <typename Head>
struct HeadBaseInherit: Head {};
// 两种 Tuple 都使用继承, 只是继承的基类模板不同
template <typename First, typename Second>
struct TupleInherit
: HeadBaseInherit<First>
, HeadBaseInherit<Second>
{};
// 两个空类型和一个包含 int 的类型
struct E {};
struct F {};
struct G { int x; };
int main()
{
// 用两个空结构体来特化, 结果纯继承方式求得 sizeof 为 1
// 因为这是纯粹继承的方式, 基类占用的空间可以被完全优化掉
// 剩下 1 字节是同一性的要求
std::cout << sizeof(TupleInherit<E, F>) << std::endl; // 1
// 大小等于 G 类型, 也就是 int 类型
std::cout << sizeof(TupleInherit<E, G>) << std::endl; // 4
return 0;
}
可能有读者会问, 什么情况下会向 std::tuple
传递一个空结构体呢? 真的会有这样的用况吗? 有! 例子就在第一章: unique_ptr
中, 使用 tuple<pointer_type, deleter_type>
来管理, 也许会有读者当时就觉得很困惑, 为什么要用个 tuple
而不是 pair
, 或者直接分别用 pointer_type
, deleter_type
定义两个成员呢? 看了上面这部分, 答案自然浮出水面: std::default_deleter
就是一个空结构体 (只有非虚成员函数, 也算空结构体), 使用 这种情况下 tuple
能使 unique_ptr
的空间占用与裸指针相同, 不必产生额外消耗.
最后简单分析一下 make_tuple
实现的一些注意事项. make_tuple
的定义是
// tuple:858
template<typename... _Elements>
constexpr // 与 make_pair 一样, 在元素类型都是字面类型时被 constexpr 修饰
tuple<typename __decay_and_strip<_Elements>::__type...> // 返回 tuple 类型中的参数类型是经过转换的
make_tuple(_Elements&&... __args) // 参数类型是广义引用
{
typedef tuple<typename __decay_and_strip<_Elements>::__type...> __result_type;
return __result_type(std::forward<_Elements>(__args)...);
}
虽然在本节开头的例子中说它 "返回由其参数类型特化" 的 tuple
, 但实际上 make_tuple
对结果 tuple
(以及 make_pair
对 pair
) 的特化行为要更复杂. 至少从上面的声明中可以看出, 返回类型并非直接是 tuple<_Elements...>
而是用了叫做 __decay_and_strip
的类型转换. 这种做法与其参数类型全是广义引用有关, 比如给定以下的代码
int x = 0;
int const y = 1;
std::make_tuple(x, y, "A");
就例子中的这个表达式 std::make_tuple(x, y, "A")
而言, 按广义引用类型参数的推导方式, _Elements = {int&, int const&, char const(&)[2]}
, 得到的结果亦会是 tuple<int&, int const&, char const(&)[2]>
, 这与想要得到的 tuple<int, int, char const*>
相去甚远. 因此 make_tuple
并非照搬参数类型给 tuple
, 而要经过一次类型转换去掉引用类型, 以及将数组转化为指针等, 具体包括
T
是任何引用, 去掉其引用T
是某个 U[]
类型, 则将 T
转换成 U*
类型T
是一个函数, 则转换为函数指针类型const
volatile
修饰reference_wrapper
转换成引用类型可以看出这个规则除开最后一条, 其他与第一章提到的 auto
自动推导得出的类型一致.
这种防止从广义引用参数推导出左值引用类型引用, 并且要求在需要引用时传入 reference_wrapper
的行为也被称为退化复制 (decay copy). 除了在 tuple
中, 在后文中的 bind
, thread
也有使用.
如果希望 tuple
的元素类型就是 reference_wrapper
, 则应该显式写出, 如
int main()
{
int x = 0;
int y = 5;
// 显式写出元素类型为 reference_wrapper
std::tuple<std::reference_wrapper<int>, int> t = make_tuple(std::ref(x), y);
std::cout << x << std::endl; // 0
std::cout << y << std::endl; // 5
// 因为 x 是引用传入的, 这里自增会改变 x 的值; 但不会改变以值传入的 y 的值
std::get<0>(t).get()++;
std::get<1>(t)++;
std::cout << x << std::endl; // 1
std::cout << y << std::endl; // 5
return 0;
}
作为 C++ 众多特性中另一个重要部分的算符重载 (operator overloading) 允许用户通过编写类型的函数调用算符重载 operator()( /* 参数列表 */ )
来使实例能够像函数一样被调用. 这一特性被广泛应用在泛型设计当中.
而在 C++11 中, lambda 语法的引入大幅简化了函数对象的编写和使用, 同时, 标准库也引入了一些包装类型, 使函数对象的表现和使用都变得更加容易.
第一章中说明了如何使用 lambda 匿名函数结合 find_if
来寻找容器的一个特定属性小于某个常数的元素. 若对这个需求稍作修改, 要寻找容器中比指定的参数要小的元素, lambda 还能简洁地实现吗?
// 例如, 给定容器 x 和参数 n, 寻找容器中一个比 n 小的值
void print_less_than(std::vector<int> const& x, int n)
{
auto r = std::find_if(
x.begin(), x.end(),
[](int i)
{
return i < n; // 编译错误: n 不能在直接 lambda 函数体内使用
}
);
if (r != x.end()) {
// ...
}
}
普通 lambda 函数不能直接使用其上下文中所定义的局部变量或参数, 如果需要在 lambda 函数体中引用上下文中的名字, 就需要显式地 "捕获" 之. 带有捕获的 lambda 语法如下
#include <iostream>
#include <vector>
#include <algorithm>
void print_less_than(std::vector<int> const& x, int n)
{
auto r = std::find_if(
x.begin(), x.end(),
[=](int i) // 语法形式有变化, 开头的方括号中多了一个等号
{
return i < n; // 在函数体中使用的 n 为外层函数的参数
}
);
if (r != x.end()) {
std::cout << *r << std::endl;
} else {
std::cout << "no such element" << std::endl;
}
}
int main()
{
std::vector<int> x{8, 4, 2, 1};
print_less_than(x, 3); // 输出 2
std::sort(x.begin(), x.end());
print_less_than(x, 3); // 输出 1 : 排序后 1 到了首位最先被找到
print_less_than(x, 1); // 输出 no such element
return 0;
}
由于策略函数含有一个参数 n
, 它不再能被定义为静态函数了. 这种需求在 C++ 中通常以带有成员的函数对象来实现, 而在 C++11 中亦可用上述 lambda 语法实现.
而与第一章中提到的基本 lambda 语法不同的是, 此处 lambda 开头的方括号中间多了一个等号, 写为 [=]
形式, 这被称作 lambda 的捕获列表 (capture list), 它表示此 lambda 对象以何种方式处理它所引用的定义在上下文中的对象.
在这个例子中的 [=]
表示以复制的方式进行捕获, 捕获的对象包括上文中定义的 int n
, 因为只有它被这个 lambda 对象的函数体所使用了. 如果需要以引用的方式捕获上下文中的对象, 则应该使用 [&]
; 而如果需要更精细的控制, 则需要使用类似如下的语法 [n, &m]
标识以复制的方式捕获 n
(这时不能有等号) 并以引用的方式捕获 m
. 如
int main()
{
// 两个整数的初始值均为 0
int n = 0, m = 0;
auto fn = [n, &m]()
{
std::cout << "n=" << n << ", m=" << m << std::endl;
};
// 然后改变 n 和 m 的值
n = 1;
m = 2;
// 由于 n 是复制捕获, 其改变不会反映到 lambda
// 相应地, 以引用方式捕获的 m 的改变则会影响到 lambda 的行为
fn();
// 输出
// n=0, m=2
int k = 0;
// 如下的代码, 捕获上下文中中定义的引用
int& r = k;
auto gn = [r]() // 虽然 r 本身是引用, 但捕获时没有以引用方式捕获, 则等价于以复制方式捕获
{
std::cout << "r=" << r << std::endl;
};
r = 3; // 此处修改 r 对 lambda 对象无影响
gn(); // 输出: r=0
return 0;
}
捕获上下文内容的 lambda 语法特性实际上只是一个语法糖, 等价于编译器在该 lambda 的上下文中生成了一个匿名类型的对象, 并且它实现了 operator()
算符重载, 该重载的参数列表与 lambda 一致, 返回值类型默认由 return
语句中返回的表达式类型推导; 而这个对象的成员便是捕获列表中声明的那些上下文对象, 并且根据以复制方式或引用方式捕获而推导出相应的类型.
比如, 上述 find_if
的例子中使用 lambda 的写法等价于以下代码
struct __anonymous_functor__ {
int n; // lambda 以复制的方式捕获上下文变量, 等价于在函数对象体存储这个值
explicit __anonymous_functor__(int n_) : n(n_) {}
bool operator()(int i) // 返回值类型也是推导出的
{
return i < this->n;
}
};
auto r = std::find_if(x.begin(), x.end(),
__anonymous_functor__(n) // lambda 等价于此函数对象
);
这也是在没有 lambda 语法支持时会采用的写法. 而使用 lambda 匿名函数对象的好处也是显而易见的: 不再需要为了一个返回值语句的逻辑定义一个冗长的函数对象类型.
因此, 编译器并没有给 lambda 任何特殊的待遇, lambda 在使用上下文中的定义时也必须遵从一般的规则, 包括
例如, 下面的做法是错误的
#include <iostream>
#include <memory>
#include <string>
int main()
{
int n; // 没有初始化的局部变量
auto fn = [n]() { std::cout << "n=" << n << std::endl; };
n = 0; // n 在 fn 定义之后才初始化
fn(); // 不可预料的输出
std::unique_ptr<std::string> name_ptr(new std::string("Zhangsan"));
std::string const& name = *name_ptr;
auto append_action = [&name](std::string const& action) // 引用方式捕获了 name
{
return name + " is going to " + action;
};
std::cout << append_action("use C++11") << std::endl; // OK: 正常输出
name_ptr.reset(); // 重置 name_ptr, 之后 name 引用的对象生命周期结束
std::cout << append_action("revert changes") << std::endl; // 运行时错误: lambda 中捕获的 name 引用已经失效
return 0;
}
C++ 中旧有的关键字 mutable
也被卷入了此类 lambda 的语法中. 默认情况下, lambda 对象复制捕获的值都是 const
限定的 (引用的 const
限定指的是它不能再去引用其他对象, 这一点已是引用自身的特性), 也就是说并不允许修改它们, 或调用这些对象的非 const
限定成员函数. 若有这样的需求, 需要在 lambda 参数列表之后加上 mutable
. 如
std::vector<std::string> ss{"hello", "world"};
// ...
int i = 0;
std::for_each(ss.begin(), ss.end(),
[i](std::string const& s) mutable // 这样才允许修改捕获的 i 变量
{
std::cout << ++i << ". " << s << std::endl;
});
std::cout << i << std::endl; // 0
std::for_each(ss.begin(), ss.end(),
[&](std::string const& s) // 以引用方式捕获则不需要 mutable, 但外部空间的 i 就被修改了
{
std::cout << ++i << ". " << s << std::endl;
});
std::cout << i << std::endl; // 与 ss 的 size() 一致
this
当在成员函数上下文使用 lambda 对象时, 可以利用它调用其他成员函数或访问成员变量, 前提条件是捕获列表中要至少声明对 this
的捕获.
class Counter {
int counter;
public:
Counter()
: counter(0)
{}
void print_strings(std::vector<std::string> const& ss)
{
// 错误: this 没有捕获
std::for_each(ss.begin(), ss.end(), [](std::string const& s)
{
std::cout << ++this->counter << ". " << s << std::endl;
});
// 正确: 显式捕获 this; 捕获之后, 可以使用 private 修饰的成员属性或成员函数
// 由于捕获的是 this 指针而不是具体成员, 可以在不附加 mutable 的情况下修改 this 的成员
std::for_each(ss.begin(), ss.end(), [this](std::string const& s)
{
std::cout << ++this->counter << ". " << s << std::endl;
});
// 正确: 捕获了 this 可以不显式使用 "this->" 来访问成员
// 建议还是加上 "this->" 提高可读性
std::for_each(ss.begin(), ss.end(), [this](std::string const& s)
{
std::cout << ++counter << ". " << s << std::endl;
});
// 正确: 以复制方式捕获上下文的变量, 此法可以用于捕获 this
std::for_each(ss.begin(), ss.end(), [=](std::string const& s)
{
std::cout << ++this->counter << ". " << s << std::endl;
});
// 正确: 以引用方式捕获上下文的变量, 此法可以用于捕获 this
std::for_each(ss.begin(), ss.end(), [&](std::string const& s)
{
std::cout << ++this->counter << ". " << s << std::endl;
});
}
};
从捕获规则可以看出, 因为 lambda 捕获的上下文变量只可能有可复制的值和引用, 所以每个 lambda 对象必然是可复制构造的.8 要复制一个 lambda 对象并构造一个同样的新 lambda 对象, 可以使用以下的方法
int i = 0;
auto f = [i](std::string const& s) mutable -> void
{
std::cout << ++i << ". " << s << std::endl;
};
auto g = f; // 仍然使用 auto 自动推导定义
decltype(f) h = f; // 使用 decltype 推导类型来定义函数对象
// 复制出的 lambda 对象捕获的 i 是独立的
f("hello"); // 1. hello
f("world"); // 2. world
g("hello"); // 1. hello
g("world"); // 2. world
h("hello"); // 1. hello
h("world"); // 2. world
不过, 通常 lambda 对象并没有必要复制, 但是与 STL 算法函数的配合使用中, 这一行为是难免的, 因为 STL 算法函数的签名中, 函数对象都是以复制传值的方式传入的, 如
int i = 0;
auto f = [i](std::string const& s) mutable -> void
{
std::cout << ++i << ". " << s << std::endl;
};
// 第一次使用 f 输出为
// 1. hello
// 2. world
std::for_each(ss.begin(), ss.end(), f);
// 因为 std::for_each 等算法函数会复制函数对象, 第二次使用 f 输出仍然为
// 1. hello
// 2. world
std::for_each(ss.begin(), ss.end(), f);
// 要使得之后输出的编号继续上升, 那么需要将 for_each 的返回值赋值给 f 自身
f = std::for_each(ss.begin(), ss.end(), f);
// 再一次使用之, 输出变为
// 3. hello
// 4. world
std::for_each(ss.begin(), ss.end(), f);
在上面的代码中定义 lambda 对象都通过 auto
和 decltype
来推导其类型. 这样做的原因是 lambda 对象的类型均为 "匿名类型", 其确切类型无法直接写成代码, 也就不能直接用来定义实例, 必须借助类型推导.
各个独立定义 lambda 对象的类型也都是不相同的, 即使这些函数对象有完全相同的捕获列表和参数, 甚至完全相同的函数体, 它们也分属于不同类型而不可相互赋值. 如
int n = 0;
auto f = [n](int x) -> bool { return n < x; };
// 定义 fn_t 为上述 lambda 的类型, 捕获 int n, 以一个 int 为参数, 返回 bool 类型
using fn_t = decltype(f);
// 以下均无法通过编译, 即使这些 lambda 的捕获列表, 参数, 返回值类型甚至函数体与 f 一致
// 在编译器的视角看来, 每次定义一个 lambda 对象, 编译器就即时生成一个匿名类型
// 而所有这些匿名类型都互不相干, 也没有公有基类, 因此无法互相转换
fn_t g = [n](int x) -> bool { return n > x; };
fn_t h = [n](int x) -> bool { return n < x; };
因此, 直接对临时 lambda 对象使用 decltype
推导类型没有意义, 这样的代码无法通过编译, 如
using fn_t = decltype([n](int x) {}); // 编译错误
因为以上的的各种限制, 在必须直接写出类型之处, 比如类中的成员, 以及函数的返回值, 无法使用 lambda 对象或其类型.
Lambda 也不能在不使用泛型手段的情况下直接定义为成员, 使用泛型手段可以用类似以下的方式定义特殊类型的 tuple
等
auto x = std::make_tuple(0, [n](int x) {});
// x 的类型是 std::tuple<int, __lambda__>
但这样的做法使得 lambda 的匿名性质产生了 "传染性", 即, 特化出的这个 tuple
实例的类型仍是无法名状的, 故这个 tuple
类型同样也不能被指定为返回值类型或成员类型, 实际使用意义不大.
因此, 如果只是配合算法库函数的使用, 定义随写随用的函数对象, lambda 是很方便的, 不过一旦要作为返回值类型传递就或作为用来声明成员变量, 事情就变得复杂了.
Lambda 语法说到底也只是新加入标准的函数对象简便写法, 考虑到既有的具名函数对象类型, 以及 C 中就存在的函数指针. 为了兼容所有这些具有函数行为的对象, C++11 中引入了一个强有力的模板类型 std::function
, 为函数对象的使用提供了更多便利.
标准库中新增的 function
是一个用来为不同函数对象以及函数指针提供统一对外接口的模板类型. 它的声明语法采用了一种较为少见的形式
// 以下声明没有实现
// functional:1865
template<typename _Signature>
class function; // 没有定义, 不可特化
// 能特化的只有下面这种偏特化声明形式
// :2173
template<typename _Res, typename... _ArgTypes>
class function<_Res(_ArgTypes...)> // 实际被使用的偏特化
// 函数签名形式的类型组合 _Res(_ArgTypes...) 是一个整体
// ...
在之后介绍 function
对象的实现时会介绍上述语法, 这里先来看看 function
模板的一般用法.
首先, 单纯一个类型实参是无法特化的, 如 function<int>
是不能编译的. 其需要以返回值类型和参数类型进行特化, 如果此函数对象的 operator()
重载没有参数, 也应当在返回值类型之后加上一对空的圆括号. 比如
#include <functional> // 此头文件包含 function
#include <iostream>
int sum_square(int x, int y);
int main()
{
std::function<int()> make0([]() { return 0; }); // 以无捕获的 lambda 初始化
// ^ ^
// | 此函数对象类型的 operator() 无需参数
// 此函数对象类型的 operator() 返回值类型为 int
std::cout << "make0() returns " << make0() << std::endl; // 输出: make0() returns 0
std::function<int(int, int)> fn(sum_square); // 以函数指针初始化
// ^ ^ ^
// | 此函数对象类型的 operator() 的参数为 int, int
// 此函数对象类型的 operator() 的返回值类型为 int
std::cout << fn(1, 2) << std::endl; // 输出: 9
int i = 5;
fn = [=](int a, int b) // 赋值以捕获单个 int 的 lambda 对象
{
return a + b + i;
};
std::cout << fn(1, 10) << std::endl; // 输出: 16
return 0;
}
int sum_square(int x, int y)
{
return (x + y) * (x + y);
}
除了跟 lambda 一样在函数内部使用之外, std::function
的特化类型能很方便地用作函数返回值类型, 参数类型和类的成员类型
struct MyClass {
int x;
int y;
std::function<int(int, int)> f; // 作为成员
// 作为函数参数
MyClass(int xx, int yy, std::function<int(int, int)> fn)
: x(xx)
, y(yy)
, f(fn)
{}
int call()
{
return this->f(this->x, this->y);
}
};
// 作为返回值类型
std::function<int(int, int)> make_fn(int u, int v)
{
int m = 5;
int n = 8;
int p = 13;
// 以捕获了上下文 5 个 int 的 lambda 对象隐式构造 function 对象
return [=](int a, int b)
{
return a + b + u + v + m + n + p;
};
}
int main()
{
MyClass m(1, 1, make_fn(2, 3));
std::cout << m.call() << std::endl; // 33
// 赋值以其它 lambda
m.f = [](int, int) { return 4; };
std::cout << m.call() << std::endl; // 4
return 0;
}
上面的例子中需要注意的是, 在 make_fn
函数返回后, return
语句中临时的 lambda 对象会被复制到 function
对象中去, 因此在 make_fn
函数调用后使用其返回值是可行的. 当然, 如果一个 lambda 以引用方式捕获上下文中的对象, 然后用这个 lambda 构造 function
对象, 构造出的 function
对象会仍然以引用形式使用 lambda 中捕获的内容, 调用 operator()
时必须保证其生命周期的有效性.
除了用 lambda 对象构造或赋值产生 function
对象之外, function
对象本身也支持复制构造和赋值. 尤其是赋值方面, 只要等号两侧两个 function
对象的类型一致即可. 如
int i = 0;
std::function<void(std::string const&)> fn = [i](std::string const& s) mutable
{
std::cout << ++i << ". " << s << std::endl;
};
fn("first"); // 输出: 1. first
fn("second"); // 输出: 2. second
// 从 fn 复制构造得到 gn, 调用时, 内部 lambda 捕获的 i 与 fn 一致
std::function<void(std::string const&)> gn(fn);
gn("third"); // 输出: 3. third
// 调用 gn 不影响 fn 的状态, 此处使用 fn 输出的序号仍然是 3
fn("fourth"); // 输出: 3. fourth
gn = [](std::string const& s)
{
std::cout << "0. " << s << std::endl;
};
// 然后将 gn 赋值给 fn, 这样会使得 fn 的状态变得跟 gn 一样
fn = gn;
fn("nothing"); // 输出: 0. nothing
从上面的例子中也可以看出来, 不同 function
对象进行复制时, 对内含 lambda 的复制是深复制.
function
克服了 lambda 在类型方面的缺陷, 可以用在 lambda 无法使用的地方, 也支持相互赋值的行为. 但是 function
对象的实例还是不能互相比较, 实际上, 它们只可以跟空指针进行等于或不等于的比较
int sum_square(int x, int y);
int main()
{
std::function<int(int, int)> fn(sum_square);
std::function<int(int, int)> gn = [](int a, int b)
{
return (a + b) * (a + b);
};
// 下面这一句会导致编译错误, 两个 function 对象不能比较
std::cout << (fn == gn) << std::endl;
// function 对象可以跟 nullptr 进行比较, 以判定该函数对象是否为空
std::cout << (fn == nullptr) << std::endl; // 这不是一个空的函数对象, 因此输出: 0
// 一个空的函数对象可以直接由空指针构造或赋值得来
fn = nullptr;
std::cout << (fn == nullptr) << std::endl; // 输出: 1
// 也可以由默认构造得来
std::function<int(int, int)> hn; // 默认构造
// nullptr 也可以放在比较算符的左侧
std::cout << (nullptr == hn) << std::endl; // 输出: 1
std::cout << (nullptr != hn) << std::endl; // 输出: 0
return 0;
}
显然, 如果一个 function
对象为 "空" 的状态, 那么使用它的 operator()
会产生问题. 在 STL 中的规定是, 这样一个调用会抛出 std::bad_function_call
异常. 如果要检查函数对象是否为空, 除了上述使用与 nullptr
的比较之外, 更简单的方式是使用其隐式 operator bool
重载, 直接将函数对象放入 if
条件中. 如
std::function<int(int, int)> fn(sum_square);
// ...
if (fn) {
// 已确认函数对象不为空, 可以正常调用之
fn(1, 2);
}
以上介绍用法的各个例子中体现出, function
的实例可以从各种各样的可调用对象转化而来, 并且这种转化很可能不是内部存储了一个可调用对象的引用, 因为从 function
对象复制出的副本之间是独立的. 那么, 究竟这样简单而通用的接口之下藏着怎样玄妙的设计与实现呢? 现在就来一窥其中的机巧.
首先是 function
模板的签名. 这种函数签名形式的特化类型并不是 C++11 才有的, 然而非常少见, 并且只在 C++11 中可以定义可变参数模板的形式. 以下的代码在 C++03 标准下也可以编译通过 (不加 -std=c++0x
选项)
template <typename _>
struct Map;
template <typename K, typename V>
struct Map<K(V)> // 使用圆括号语法的偏特化
: std::map<K, V>
{};
int main()
{
Map<std::string(int)> m;
m["jan"] = 1;
m["feb"] = 2;
std::cout << m["feb"] << std::endl; // 2
return 0;
}
实际上在 function
实现中能看到的 C++11 新特新只有可变参数模板, 也就是其模板参数 _ArgTypes
类型参数包而已.
function
实例可以在任何时候被改变为任何形式的函数指针或对象, 还能支持对内含对象的复制行为, 要做到这一点就需要在存储可调用对象数据之外, 加上区分类型的行为.
函数对象可能的类型千千万万, 而且 lambda 之前没有继承关系, 因此不能使用 dynamic_cast
. 虽然可以利用堆空间存储, 但需要使用或复制或析构时, 应当如何取得相应的类型信息, 这是 function
实现的一个挑战.
以下代码将从如何存储和调用不同的函数对象入手, 给出实现上述行为的一种参考.
template <typename R, typename... Args>
class function<R(Args...)>
{
void* functor_ptr; // 由于无法确定函数对象的具体类型, 就用 void* 保存
// 并利用以下模板函数转换类型
template <typename Functor>
static Functor& get_functor(void* p)
{
return *static_cast<Functor*>(p); // (c)
}
// 定义一个函数指针, 当需要调用函数对象时, 从此函数指针上调用
using invoke_fn_t = R(*)(void*, Args&&...);
invoke_fn_t invoke_f;
// 它将以下面这个函数的特化作为有效值
template <typename Functor>
static R invoke_functor(void* p, Args&&... args)
{
return get_functor<Functor>(p)(std::forward<Args>(args)...); // (b)
}
public:
// 对外暴露的函数调用算符重载
R operator()(Args&&... args)
{
// 检查是否为空, 并在为空时抛出异常
// if (!this->invoke_f) throw bad_function_call("function is null");
return this->invoke_f(this->functor_ptr, std::forward<Args>(args)...);
}
// 从函数对象构造
template <typename Functor>
function(Functor f)
{
this->invoke_f = invoke_functor<Functor>; // (a)
this->functor_ptr = new Functor(f);
}
// 允许隐式从 nullptr 转换构造, 此构造函数没有 explicit
function(std::nullptr_t = nullptr)
: invoke_f(nullptr)
{}
};
来看看其中的几个重点. 在 (a) 处用参数类型 Functor
特化了 invoke_functor
函数并赋值给了 invoke_f
成员. 对 invoke_functor
的特化还造成了一串链式特化, 也就是在 (b) 处同时特化了 get_functor
函数, 因此在 (c) 处 static_cast
就会正确地转换得到对应的函数对象类型. 当然, 记得在 (a) 处之后要将函数对象给存到 functor_ptr
里去. 藉由这种方式, function
对象实际上 "记住" 了 Functor
类型.
当然仅仅存储函数对象和重新拿出来调用肯定不够, 有 new
而没有相应的 delete
, 内部存储的函数对象是会泄漏的; 此外还需要正确处理函数对象的复制行为. 下面就加上这两种行为的实现
template <typename R, typename... Args>
class function<R(Args...)>
{
void* functor_ptr;
template <typename Functor>
static Functor& get_functor(void* p)
{
return *static_cast<Functor*>(p);
}
using invoke_fn_t = R(*)(void*, Args&&...);
invoke_fn_t invoke_f;
template <typename Functor>
static R invoke_functor(void* p, Args&&... args)
{ return get_functor<Functor>(p)(std::forward<Args>(args)...); }
// 增加用于删除和复制函数对象的函数指针成员, 它们的实现方式与 invoke_f 相似
using destroy_fn_t = void(*)(void*);
destroy_fn_t destroy_f;
using clone_fn_t = void(*)(void*&, void*);
clone_fn_t clone_f;
template <typename Functor>
static void destroy_functor(void* p)
{
delete &get_functor<Functor>(p); // 同样内部 "记住" Functor 类型, 这样可以正确转换类型并删除
}
template <typename Functor>
static void clone_functor(void*& p, void* other_p)
{
// 请注意参数 p 是引用传入的
p = new Functor(get_functor<Functor>(other_p)); // "记住" Functor 类型以便复制函数对象
}
public:
R operator()(Args&&... args)
{ return this->invoke_f(this->functor_ptr, std::forward<Args>(args)...); }
function(std::nullptr_t = nullptr)
: invoke_f(nullptr), destroy_f(nullptr), clone_f(nullptr)
{}
template <typename Functor>
function(Functor f)
{
this->invoke_f = invoke_functor<Functor>;
// 同样的手法, 特化这两个函数并赋值给相应函数指针成员
this->destroy_f = destroy_functor<Functor>;
this->clone_f = clone_functor<Functor>;
this->functor_ptr = new Functor(f);
}
// 复制构造时, 先将相应的函数指针都原样复制
function(function const& rhs)
: invoke_f(rhs.invoke_f)
, destroy_f(rhs.destroy_f)
, clone_f(rhs.clone_f)
{
if (this->invoke_f) {
// 在复制来源不为空时, 将复制来源的相应函数对象复制构造到自身的存储区中
this->clone_f(this->functor_ptr, rhs.functor_ptr);
}
}
// 析构时删除掉函数对象
~function()
{
if (this->destroy_f) {
this->destroy_f(this->functor_ptr);
}
}
};
那么这样实现的 function
在面对各种各样不同函数对象都能正常使用了. 上面实现中还缺少移动构造函数, 赋值算符重载, 读者可以自行尝试实现它们.
不过, 除了可用, 实际上 function
还要考虑更多效率上的问题, 比如以函数指针构造或赋值的话, 不必分配堆空间, 赋值给 void* functor_ptr
就行了, 并相应地实现一族与 invoke_functor
, clone_functor
等相似的函数将 void* functor_ptr
转换为函数指针加以利用; 以及还有一类优化: 如果一个函数对象的尺寸小于指针的大小, 那么也不需要分配堆空间, 而可以将函数对象直接构造在指针所占有的空间上 (利用 placement new).
在 function
实现中, 存储实际函数对象时使用 void*
而忽略其原有类型, 相应地类型相关代码由几个模板函数批量生成, 这个技巧也被称作类型擦除 (type erasure).
如果单单从 invoke_functor
的视角来看, 这个技巧的运用包括
template <typename Functor>
static R invoke_functor(void* data, Args&&... args)
{
return get_functor<Functor>(p)(std::forward<Args>(args)...);
}
// 实际由这两个函数根据 Functor 类型的不同而进行指针类型转换
template <typename Functor>
static Functor& get_functor(AnyData& data)
{
return *static_cast<Functor*>(data.functor_ptr);
}
// ...
template <typename Functor>
function(Functor&& f)
: invoke_f(invoke_functor<Functor>) // 每个 Functor 类型就特化一次
// ...
{ /* ... */ }
每当有一个 Functor
类型可能被用以构造 function
实例时, 以上模板函数就会被特化一次, 相应地在目标代码中就会增加函数实现代码, 也因此最后链接得到的程序体积相应增大. 而透过这一表象, 其背后的本质相当于在用空间换取类型信息, 这些类型信息又不同于 RTTI, 它们是纯静态化类型转化.
另外代码中有一处需要注意的是, 从函数对象构造时有一句
this->functor_ptr = new Functor(std::forward<Functor>(f));
等号右侧是一处复制构造. 这段代码能够成立意味着 Functor
类型必须是可复制的, 这也是标准中要求的. 因此, 如果一个函数对象只能支持移动语义而不能复制, 是无法与 function
配合使用的.
struct PtrHandle {
// PtrHandle 有 unique_ptr 作为成员, 因此不会隐式合成复制构造函数
std::unique_ptr<int> p;
void operator()() const
{
if (p == nullptr) {
std::cout << "I'm null" << std::endl;
} else {
std::cout << "I'm " << *p << std::endl;
}
}
};
int main()
{
PtrHandle h;
std::function<void()> fn(std::move(h)); // 编译错误: h 不能复制构造
// 虽然 h 以移动方式交给 function 对象
// 但 function 对象内部仍然以复制方式将其构造至存储区
return 0;
}
当一个 function
对象在由另一个函数对象赋值时, 其原有的内在函数对象会被清理掉, 实现方式可能如下
template <typename Functor>
function& operator=(Functor f)
{
if (this->destroy_f) {
this->destroy_f(this->functor_ptr); // 如果之前存储的是一个函数对象, 先清理之
}
this->invoke_f = invoke_functor<Functor>;
this->clone_f = clone_functor<Functor>;
this->destroy_f = destroy_functor<Functor>;
this->clone_f(this->functor_ptr, &f);
return *this;
}
function& operator=(function const& rhs); // 复制赋值重载也会有类似清理行为
此时如果原有的函数对象捕获了反过来引用了该 function
对象的其他对象, 那么不适当的赋值可能引起问题.
作为一个例子, 以下是用于解析字符串字面量的一个自动机实现, 解析从开始的双引号之后, 直到结束的双引号的输入. 但这个实现有一些问题
class StringLiteralAutomation {
std::string literal;
bool finished_;
std::function<void(char)> next; // 遇到下一个字符时应该做什么, 由此函数对象决定
void initial_state(char ch)
{
switch (ch) {
case '"': // 遇到双引号, 字面量结束
this->finished_ = true;
return;
case '\\': // 遇到反斜线, 进入转义状态
this->next = [this](char escaping)
{
this->next = [this](char ch) // 重置 next 到初始状态
{
this->initial_state(ch);
};
switch (escaping) { // 转义接下来的一个字符
case 't':
this->literal += '\t';
break;
case 'n':
this->literal += '\n';
break;
case '\\':
this->literal += '\\';
break;
case '"':
this->literal += '"';
break;
default:
// 不合法的转义, 错误处理
// throw "unknown escape";
};
};
return;
default:
this->literal += ch; // 默认情况, 添加该字符
}
}
public:
bool finished() const
{
return this->finished_;
}
std::string const& get_literal() const
{
return this->literal;
}
// 遇到下一个字符, 转调 next 函数对象
void next_char(char ch)
{
this->next(ch);
}
StringLiteralAutomation()
: finished_(false)
{
// 初始时 next 执行 initial_state, 不计起始的双引号
this->next = [this](char ch)
{
this->initial_state(ch);
};
}
};
int main()
{
std::string input;
std::cin >> input; // 示例输入 hello, world" (不会出错)
// 或者输入 hello, \"world\"" (可能会出错)
StringLiteralAutomation a;
for (size_t i = 0; i < input.size() && !a.finished(); ++i) {
a.next_char(input[i]);
}
std::cout << "Input string finished? " << a.finished() << std::endl;
std::cout << "Literal is " << a.get_literal() << std::endl;
return 0;
}
只看这个例子的结构的话, 会发现使用 function
实现状态自动机很省事, 只需要设定一个 function
成员, 在需要时更换此成员的函数对象即可. 而对外暴露的状态跳转行为只需实现为该函数对象调用的代理即可.
当然, 前提是, 更换函数对象的时机要恰当. 上面的例子中就有一个不恰当的更换时机, 它在
void initial_state(char ch)
{
// ...
this->next = [this](char escaping) // a
{
this->next = [this](char ch) // b
{
this->initial_state(ch);
};
switch (escaping) // ...
this->literal += // ... // c
};
// ...
}
上述代码的 (b) 处.
在 (a) 处定义的这个 lambda 捕获了上下文中的 this
, 赋值给 next
之后, next
内也间接包含了这个 this
的副本.
下一次调用 next
执行到 (b) 处, 重新定义了一个 lambda 并赋值给 next
, 之后 switch 语句内的某个 (c) 处又使用 this
. 这时 (c) 处的 this
是无效的, 引用其 literal
成员就是未定义行为. 准确地说, 存储 this
值的地址空间失效了. 如果编译器生成的代码中, 执行 (a) 处定义的 lambda 的函数体时恰好一直将捕获的 this
值放在寄存器内, 程序运行就没问题, 否则下次去取得 this
的值时就会发生错误.9
要解释这种行为还要回到 lambda 的原理上. 在 (a) 处定义的 lambda 捕获了 this
, 会将其存储在自身地址空间内, 在其函数体内能使用 this
的前提条件就是该 lambda 自身的地址空间是合法有效的. 那么该 lambda 何时失效呢? 就是在 (b) 处赋值时. 既有的 lambda, 也就是正在执行的这个函数体的所有者, 失效后, 并不会影响函数体的执行, 任何编译器都会把函数内容单独编译到代码空间里去, 只不过之后引用 this
都有可能是已失效的引用.
解决这个问题的手段当然不少, 最简单的是将 (b) 处的赋值放到函数最后去, 这样函数体内其他引用 this
就不会失效了. 还有一种方法是将 this
的值复制一份到函数体栈区内
this->next = [this](char escaping)
{
auto self = this; // 将捕获的 this 的值提前赋给栈区变量
self->next = [self](char ch)
{
self->initial_state(ch);
};
switch (escaping) {
case 't':
self->literal += '\t'; // 之后引用的都是这个栈区变量的值, 即使 this 失效了也无妨
break; // 这种写法除了看起来有点像 Python 也并无不可
// ...
};
};
然而, 即使解决了这个问题, 上述例子中还有另一个更难解决的问题, 就是 StringLiteralAutomation
类型实质上不可以复制, 甚至也无法移动构造.
将 main
函数修改为这段测试代码便可发现重现这个缺陷
int main()
{
std::string input;
std::cin >> input;
StringLiteralAutomation copy; // 另外创建一个自动机对象
StringLiteralAutomation a(copy); // a 由该对象复制构造得到
for (size_t i = 0; i < input.size() && !a.finished(); ++i) { // 输入操作仍然对 a 进行
a.next_char(input[i]);
}
std::cout << "Input string finished? " << a.finished() << std::endl;
std::cout << "Literal is " << a.get_literal() << std::endl; // 但输出时会发现 a 中的字符串是空的
std::cout << std::endl << "Print information of the copy source" << std::endl;
std::cout << "Input string finished? " << copy.finished() << std::endl;
std::cout << "Literal is " << copy.get_literal() << std::endl; // 实际上内容都在被复制的对象内
return 0;
}
肇因并不难发现, 因为在自动机类型复制构造时, function
对象内的 lambda 绑定的 this
也被原封不动地复制过来了, 两个 lambda 对象的 this
实际上都指向的是初始构造的 copy
对象, 结果对 a
进行操作实际上就等同于对 copy
进行操作.
在这段测试代码里, 姑且还只是让一个对象成为另一个对象的傀儡. 实际代码中, 有可能这种对象复制后原对象被析构掉, 再使用时程序就崩溃了. 本质上这是 lambda 的缺陷, 但因为 lambda 并不会被显式复制, 只有被包装到 function
对象里, 问题才会显现出来, 所以这应该被看作是 function
的一种误用.
如果一个函数对象捕获了 this
, 同时该函数对象又是 this
的一个直接或间接的成员, 这种设计本身就触及了循环依赖的雷区. 这时应当修改设计不让函数对象捕获 this
, 而是将 this
也作为参数传递给函数对象.
实际上, 最直接地实现上述功能的方式就是使用 function
包装成员函数, 然后在使用时再传入 this
. 如
class StringLiteralAutomation {
std::string literal;
bool finished_;
std::function<void(StringLiteralAutomation*, char)> next;
// ^^^^^^^^^^^^^^^^^^^^^^^^ 增加参数为此类型对象指针
void escape_state(char ch) // 将 lambda 修改为成员函数
{
this->next = &StringLiteralAutomation::initial_state; // 让 next 使用 initial_state
switch (ch) {
// ...
};
}
void initial_state(char ch)
{
switch (ch) {
case '\\':
this->next = &StringLiteralAutomation::escape_state; // 让 next 使用 escape_state
return;
// ...
}
}
public:
// ...
void next_char(char ch)
{
// 调用 next 时, 传入 this
this->next(this, ch);
}
StringLiteralAutomation()
: finished_(false)
, next(&StringLiteralAutomation::initial_state) // 初始化为 initial_state
{}
};
不过使用 function
去包装成员函数指针显然是牛刀杀鸡, 无论是空间效率还是运行效率都不如直接使用函数指针类型.
C++11 除了利用可变参数模板机制实现了 function
类型之外, 还吸收了 Boost 库中的 bind
函数, 以及通常会与之一同使用的参数占位符. 使用 bind
的动机可能多种多样, 比如函数实参不是一次凑齐, 而是分两次给出, 那么可以利用 bind
先存储其中一部分实参; 先绑定部分参数后, 可以提供不同的其他实参, 反复调用 bind
得到的函数对象, 达到套接实际可调用对象的作用.
bind
模板函数的参数也包括一个可调用对象, 并且返回另一个可调用对象, 但它与 function
设计上不同的是, 它并不着眼于对各种不同的可调用对象进行包装, 而是为了将参数可调用对象和一部分实参存储在一起, 之后用户提供剩下的实参时再实际调用.
举个简单的例子
#include <functional> // bind 和占位符也都位于此头文件中
#include <iostream>
int add_mul(int a, int b, int c)
{
return (a + b) * c;
}
int main()
{
using namespace std::placeholders; // std::placeholders 中定义了若干个占位符
auto sum_and_triple = std::bind(add_mul, _1, _2, 3); // 这些占位符的名字就像这里用到的 _1 _2
// _3 _4 ... _29
std::cout << sum_and_triple(4, 5) << std::endl; // 27
return 0;
}
其中 bind
的参数一共有 4 个, 第一个 add_mul
是可调用对象, 这个可调用对象调用时需要 3 个参数, 因此还需要额外的三个参数传给 bind
. 例子中前两个参数是参数占位符 _1
, _2
, 占位符的作用是告诉 bind
这两个位置上的参数之后再传入; 最后一个非占位符实参 3 是绑定给此可调用对象作为第三个实参.
add_mul(int a, int b, int c) 实际调用 add_mul 需要 3 个参数
| | |
sum_and_triple = std::bind(add_mul, _1, _2, 3) 利用 bind 绑定其中一个参数
| |
sum_and_triple (4, 5) 调用 sum_and_triple 传入剩余的 2 个参数
请注意, _1
, _2
这些占位符指的是传给 bind
返回的可调用对象 sum_and_triple
的参数位置, 而不是原可调用对象 add_mul
的参数位置. 假如预绑定的实参是第一个参数, 那么接下来的占位符仍然应该是 _1
, _2
而不是 _2
, _3
. 并且, 同一个编号的占位符还可以重复使用.
using namespace std::placeholders;
auto x = std::bind(add_mul, 10, _1, _2);
std::cout << x(3, 4) << std::endl; // 52 : (10 + 3) * 4
// x 的第一实参 3 会替换 bind 中的 _1
// x 的第二实参 4 会替换 bind 中的 _2
auto time_6 = std::bind(add_mul, _1, _1, 3); // 实际调用 add_mul 时, 前两个参数会是相同的
// 此时 bind 给出的函数对象只需要 1 个参数即可调用
std::cout << time_6(7) << std::endl; // 42 : (7 + 7) * 3
默认情况下, 传入的预绑定参数都会以值的形式存储在 bind
返回的可调用对象中. 如果要绑定一个左值的引用, 同 make_tuple
一样使用退化复制规则, 需要用 std::ref
或者 std::cref
显式指出.
void incr(int& x, int delta) // 第一参数是引用
{
x += delta;
}
int main()
{
using namespace std::placeholders;
int lvalue = 0;
auto bind_by_val = std::bind(incr, lvalue, _1); // 直接将 lvalue 传给 bind
bind_by_val(1);
std::cout << lvalue << std::endl; // 输出 0 : lvalue 的值传给了 bind 修改的是 bind 内部的副本
auto bind_by_ref = std::bind(incr, std::ref(lvalue), _1); // 使用 ref 传递 lvalue 的引用给 bind
bind_by_ref(2);
std::cout << lvalue << std::endl; // 输出 2 : 修改的会是被引用的 lvalue
return 0;
}
但就上面这些简单的需求, 用 lambda 也可以实现, 而且可以做的更好
using namespace std::placeholders;
int times = 3;
auto sum_and_triple = std::bind(add_mul, _1, _2, times);
auto x = [=](int __1, int __2)
{
return add_mul(__1, __2, times);
};
std::cout << sizeof(sum_and_triple) << std::endl; // 16 : bind 有额外存储, 包括存储可调用对象
std::cout << sizeof(x) << std::endl; // 4 : lambda 只需要存储捕获的 int
所以很明显 bind
不是用来干这种工作的. 当然, bind
也有自己独有的功能. 在介绍 lambda 时说过, lambda 无法移动捕获上下文中的对象, 这一点对于 bind
来说不成问题. 例如
#include <fstream>
#include <functional>
#include <memory>
struct Logger {
int const logger_id;
Logger()
: logger_id(0x123456)
{}
template <typename T>
void operator()(std::unique_ptr<std::ostream>& os, T const& t) // 泛型函数调用重载
{
*os << logger_id << ':' << t << std::endl;
}
};
int main()
{
using namespace std::placeholders;
int some_data = 0;
std::unique_ptr<std::ostream> output(new std::ofstream("example.log"));
auto logger = std::bind(Logger(), std::move(output), _1); // bind 可以绑定不可复制的上下文对象
logger("the quick brown fox");
logger(some_data);
logger(&some_data); // 可以用任何类型参数调用 bind 返回的函数对象
return 0;
}
不过有些尴尬的是, 这段代码中展现的 bind
相对于 lambda 的两个优点, 在 C++14 标准中 lambda 的功能被增强后也都不存在了. 等价的 C++14 代码是这样的
std::unique_ptr<std::ostream> output(new std::ofstream("example.log"));
auto logger =
[os = std::move(output), callable = Logger()] // 通用式捕获列表
// 可以从上下文中移动捕获对象
// 或构造对象作为作为 lambda 成员
(auto const& t) // 定义参数类型为 auto, 可以接受泛型参数
mutable
{
callable(os, t);
};
logger("the quick brown fox");
logger(some_data);
不过, 这也并不意味着, bind
只生存在 C++03 和 C++14 之间的这个夹缝中. 它仍然有一个重要的优势, 就是其给出的函数对象类型是可控的.
关于 bind
返回的对象的类型有一点需要说明的是, 在 C++ 标准中不规定它返回的类型的具体名字, 而是由库的实现根据实际情况 (传入的函数对象类型和预绑定的实参类型) 给出. 这一点在 C++11 中有了自动类型推导后也不会造成不便. 所以可以结合 decltype
对 bind
的返回值提供一个类型别名, 这样就可以将此类型用于函数的返回值类型, 或类的成员类型了.
using namespace std::placeholders;
using bind_fn_t = decltype(std::bind(std::declval<Logger>(),
std::unique_ptr<std::ostream>(), _1));
// 可以定义 bind 返回的别名
// 相比之下 lambda 不可以被放入 decltype 中
// 即使可以 decltype, 不同的 lambda 之间不能互相赋值的规则仍会阻止下面的代码生效
bind_fn_t file_logger(std::string const& filename) // 将定义出的类型用于函数返回值
{
std::unique_ptr<std::ostream> output(new std::ofstream(filename));
return std::bind(Logger(), std::move(output), _1);
}
int main()
{
int some_data = 0;
bind_fn_t logger(file_logger("example.log"));
logger("the quick brown fox");
logger(some_data);
logger(&some_data);
return 0;
}
与 function
一样, bind
也可以用于绑定成员函数, 且同样其第一参数逻辑上应当是这个类型的实例. 要符合这一要求, 既可以在调用 bind
处传入一个对象引用或对象指针作为第一参数, 也可以在调用 bind
返回的函数对象时传入相应的对象或对象指针. 如
using namespace std::placeholders;
int main(int argc, char** argv)
{
std::string s;
std::string t("lazy dog");
// 调用 bind 时就传入实例参数, 传入指针
auto h = std::bind(&std::string::size, &t);
std::cout << h() << std::endl; // 8
// 调用 bind 时就传入实例参数, 引用传入
// 允许传入指针是为了在成员函数中传入 this 来更方便地调用
// 在其他函数中调用时, 最好使用 reference_wrapper
auto m = std::bind(&std::string::size, std::cref(t));
std::cout << m() << std::endl; // 8
// 调用 bind 时就传入实例参数, 复制或使用 std::move 移动传入
// 比只能使用对象指针的 function 更灵活, 在需要函数对象控制对象的生命周期时更安全
auto g = std::bind(&std::string::size, s);
std::cout << g() << std::endl; // 0
auto f = std::bind(&std::string::size, _1);
std::cout << f(s) << std::endl; // 0 : 输出 s.size()
std::cout << f(t) << std::endl; // 8 : 输出 t.size()
std::cout << f(&s) << std::endl; // 0 : 也可以使用指针
std::cout << f(&t) << std::endl; // 8
// 绑定 string::clear 函数
// 绑定时给出对象左值, 会以复制方式存储一个副本在 bind 中
// 故这时调用 flush_copy 也只是清理掉 bind 内的副本的数据
auto flush_copy = std::bind(&std::string::clear, t);
flush_copy(t);
std::cout << t.size() << std::endl; // 8 : 原来对象不受影响
// 如果留出参数位置, 之后调用时传入左值, 则相当于在该左值上调用此成员函数
auto flush_ref = std::bind(&std::string::clear, _1);
flush_ref(t);
std::cout << t.size() << std::endl; // 0 : t 以左值方式传入并被清空数据
return 0;
}
在调用 bind
时, 会将函数对象和各预绑定的参数都存储在 bind
返回的函数对象中. 之后, 在调用 bind
返回的函数对象时, 会将这些预存储下来的实参 "传" 到实际函数对象对应的参数位置上去. 这就产生了一个小问题, 如果一个实参无法复制, 那么它要怎么传参呢? 例如
struct SumPtr {
std::unique_ptr<int> p; // SumPtr 含有只能移动构造的成员, 因此自身也只能移动构造
explicit SumPtr(int x)
: p(new int(x))
{}
void operator()(std::unique_ptr<int> q)
{
std::cout << *this->p + *q << std::endl;
}
};
int main()
{
SumPtr s(2);
std::unique_ptr<int> q(new int(3));
// 两个只能移动构造的对象, 都必须转为右值才能传递 bind
auto f = std::bind(std::move(s), std::move(q));
std::cout << (q == nullptr) << std::endl; // 输出 1 : q 在移动之后会变成空指针
std::cout << (s.p == nullptr) << std::endl; // 输出 1 : s.p 同理
// f(); 此处如果调用 f 会报错
return 0;
}
上面例子中, 去掉 std::bind(std::move(s), std::move(q))
中的任何一个 move
都会报错, 因为 bind
需要将对象以实例形式存下来, 这就需要将参数构造到其内部; 若不加 move
就会使用复制构造, 这显然是不可行的. 然而, 这样即使能产生如 f
的预绑定对象, 却无法调用它, 否则会产生 unique_ptr
无法从 bind
产生的对象内部复制到参数位置导致的编译错误.
补救的方法是使得 SumPtr
的函数调用算符重载相应参数位置接受一个引用, 那么存储在 bind
返回的对象内部的 unique_ptr
将以引用形式传给 s
struct SumPtr {
std::unique_ptr<int> p;
explicit SumPtr(int x)
: p(new int(x))
{}
void operator()(std::unique_ptr<int> const& q) // 更改参数为 const 左值引用
{
std::cout << *this->p + *q << std::endl;
}
};
int main()
{
SumPtr s(2);
std::unique_ptr<int> q(new int(3));
auto f = std::bind(std::move(s), std::move(q));
f(); // 编译通过, 输出 5
return 0;
}
而实际在项目中最可能遇到的难题是, 像 SumPtr
这样的可调用对象类型或函数签名的控制权并不在使用 bind
的开发者手中, 若是这样的话就必须再自行编写一个套接类型或套接函数了.
除了实参对象无法复制可能导致的一些问题, 缓存的实参的类型与交给 bind
的原始可调用对象相应参数位置上的参数类型不同时也可能产生一些意料之外的行为. 比如给定
void func(int x) { std::cout << x << std::endl; }
double y = 3.14;
auto g = std::bind(func, y);
g(); // 输出 3
那么得到的 g
会把 double
类型的实参保存下来, 然后调用 func
时再把它适配为 int
. 内建数值类型之间互相转换或许算不上什么问题, 但对于其他类型可能会大有不同. 比如, 若传给 bind
的函数对象的某个位置上的形参类型和预绑定的实参类型不符, 但可以隐式转换, 那么每次调用 bind
返回的函数对象时, 这种转换都会发生
struct A {
~A()
{
std::cout << "~A" << std::endl;
}
};
struct B {
/* implicit */ B(A const&)
{
std::cout << "B(A const&)" << std::endl;
}
/* implicit */ B(A&&)
{
std::cout << "B(A&&)" << std::endl;
}
~B()
{
std::cout << "~B" << std::endl;
}
};
void func(B x)
{
std::cout << "func" << std::endl;
}
int main()
{
// 输出
auto fn = std::bind(func, A()); // ~A : 临时对象析构
std::cout << "bound" << std::endl; // bound
fn(); // B(A const&)
// func
// ~B : func 形参析构
std::cout << "#0" << std::endl; // #0
fn(); // B(A const&) : 又一次从 A 转换到 B
// func
// ~B
std::cout << "#1" << std::endl; // #1
return 0; // ~A : fn 中存储的 A 析构
}
以上是使用 bind
需要注意的问题. 在下一章介绍线程对象 API 时, 会看到有与 bind
的机制类似之处.
C++ 标准中终于引入了多线程相关的基础设施. 这意味着什么, 一组统一了 POSIX 线程, Windows 线程或任何其他平台线程库的 API 吗? 当然是, 但也不仅仅如此.
在之前的 C++ 中并没有任何关于多线程的描述, 因此编译器可以自由地处理与多线程相关的代码, 并实施任意优化. 这可能导致一些潜在的问题. 而多线程被加入到标准中, 实际上也为编译器在处理多线程相关的代码提供了指导.
在 C++11 多线程标准中, 除开能看得到用得着的线程库 (std::thread
等) 和多线程工具集 (std::atomic
, std::mutex
等), 底层由编译器做出的一些改动也相当多, 值得注意.
在并发程序设计领域, 多线程之于多进程模型的一个优势在于不同线程直接可以直接通过内存访问来方便地共享数据. 而在 C++11 的线程模型中, 最重要的一点也当属为多个线程提供共享地址空间访问机制的内存位置模型.
在现有的 C++ 标准中, 描述对象所在的位置并不是直接用 "内存" 而是使用 "地址空间" 这一术语, 因为 C++ 这门语言是对程序设计的一种抽象. 语言在抽象的过程中不涉及到任何具体编译器, 操作系统, 硬件, 寄存器10或多级缓存, 只有地址空间的概念.
C++11 标准诞生之前并没有对应于多线程并发的地址空间模型, 任何多线程程序设计都算是 "野路子", 编译器对多个可能同时访问一个对象所在地址空间的线程概念一无所知, 也不会描述任何在这种情况下会产生的后果, 甚至根本不提两个线程同时读写一个对象是不是未定义行为. 在 C++11 中这些内容才被引入, 并提出内存模型 (memory model) 来统一理想中的多线程模型与实际中的硬件体系结构.
内存模型与地址空间不同的地方在于, 内存模型会一定程度上考虑如何在具体的硬件架构上给出独立的地址空间的定义. 这种 "独立" 到什么程度呢? 不妨来考虑一下执行以下代码
struct Cell {
char x;
char y;
};
Cell c = { '0', '0' };
void thread_a()
{
c.x = 'x';
}
void thread_b()
{
c.y = 'y';
}
int main()
{
// 假设函数 thread_start 用来启动线程
thread_t a = thread_start(thread_a);
thread_t b = thread_start(thread_b);
// 假设函数 thread_join 用来等待线程结束
thread_join(a);
thread_join(b);
// 两个线程都结束后, 输出 Cell 对象的各个值
std::cout << c.x << ", " << c.y << std::endl;
return 0;
}
按照常理来说, 预期的输出应当是 "x, y", 因为两个线程都分别修改了一个成员的值, 并且都结束了. 但若没有独立内存模型的话, 结果仍然可能是 "0, y" 或 "x, 0". 换言之, 即使不同线程访问的是不同的地址空间, 仍然有可能相互影响. 然而这是为什么呢?
对于 C++ 来说, c
的 x
和 y
两个成员确实处在不同的地址空间上, 但对于硬件来说, 它们可能在同一个内存区块. 一个内存区块通常是该体系结构上的一个字长的内存区, 可能存在某些体系结构的指令为了效率只能整个字长读写内存, 而缺乏单个字节访问内存的能力, 对于如以上 thread_a
里为一个 char
赋值的代码, 实际会产生 3 条指令
c.x
所在的一个字长大小的内存区 (这时会连同 c.y
一起读入)c.x
对应的部分c.x
所在的一个字长大小的内存区 (这时会连同 c.y
一起写回)在多线程环境下, 非原子性的这三条指令当然有可能使得 thread_a
中写回 c.x
时错误地覆盖了另一线程写入的 c.y
的值. 反之在 thread_b
函数中亦然. 于是就出现了与期望不符的结果.11
因此为了避免上述情况发生, C++11 内存模型提出了一条核心保障: 当不同线程同时访问独立的内存位置 (memory location) 时不会互相影响. 在前面的章节中提到过, C++ 为了保证不同的对象一定有不同的内存位置, 还设置了对空类型求 sizeof
会大于零这一与 C 不同的地方. 因此这一条保障也可以这么说, 当不同线程同时访问两个不同的对象时一定不会互相影响, 自然就包括了上面这种, 多个不同的变量挤占同一个硬件单元的情况.
绝大部分的 C++ 内建类型都有独立的内存位置, 多个线程即使访问同一个对象, 只要最终读写的成员是不同的, 就不会相互影响, 就像上例中同时写入 c.x
和 c.y
在 C++11 中不会出现数据竞争一样. 但位域 (bits field) 成员一般不具备独立的内存位置. 在 C++11 中规定, 长度非零的连续位域都可以具有相同的内存位置, 而长度为零的位域则用于分隔出不同的内存位置. 如
struct X {
bool a; // 内存位置 #0
bool b; // 内存位置 #1 : 连续 bool 也有单独的内存位置
int b : 3; // 内存位置 #2
int c : 5; // 内存位置 #2 : 与上面的 b 的位域连续, 处于统一内存位置
int : 0; // 没有内存位置, 分隔上下两段位域的内存位置
int d : 3; // 内存位置 #3
struct Nested {
int e : 5; // 内存位置 #4 : 单独 struct 定义, 不计作与 d 连续
} f;
};
若一定要以位域方式节省内存, 并将对象交给多个线程并发访问, 则一定要加上同步, 否则就会导致未定义行为.
线程应该以何种形式体现出来? 作为一个可执行流, 就像程序的入口被设计为 main
函数一样, 是一个函数应该很自然, 至少 POSIX 拟定的线程 API 就是这样的, 每个线程都是一个接受 void*
参数的函数, 至于这个 void*
应该如何解释为线程参数就看用户自己实现了.
而在 C++ 中, 一个可以被调用执行的不一定是函数, 也可能是函数对象. 事实上, C++11 线程的设计中, 除了可以使用任何函数作为新线程的执行过程, 还可以使用一个函数对象. 那么接下来就看看线程是如何被表现的.
下面是一个简单例子12
#include <thread> // thread
#include <iostream>
struct ThreadExec {
int n;
explicit ThreadExec(int nn)
: n(nn)
{}
void operator()()
{
for (int i = 0; i < this->n; ++i) {
std::cout << "Thread object output\n";
}
}
};
void thread_exec()
{
for (int i = 0; i < 4; ++i) {
std::cout << "Thread function output\n";
}
}
int main()
{
std::thread t(ThreadExec(4)); // 使用函数对象构造 thread 对象
std::thread u(thread_exec); // 使用函数指针构造 thread 对象
t.join(); // 等待线程结束
u.join();
return 0;
}
可能的输出为
Thread object output
Thread object output
Thread object output
Thread function output
Thread function output
Thread object output
Thread function output
Thread function output
在这个例子中, 分别使用函数对象和函数指针构造各构造了一个 std::thread
的实例. 函数 thread_exec
的参数列表为空, 而类型 ThreadExec
的函数调用算符重载的参数列表也是空. 这并不是一种巧合, 而是在默认情况下, 传递给 thread
的可执行对象参数必须满足这一规格, 在新的线程产生后, 其任务就是对此参数对象或参数函数进行调用. 函数或函数对象的调用算符重载的返回值可以不是 void
, 但任何返回值都将被线程忽略.
例子中构造线程对象后, 并未对这些对象进行任何其他操作, 而这两个线程就立即开始对交予它的参数进行调用并分别输出内容. 直到 main
结束之前调用 t.join()
和 u.join()
等待这两个线程结束. 可以看出, C++11 的线程对象设计与其他流行的高级语言如 Java, C# 等线程对象设计有所不同, 它是非常 "急躁" 的, 即当它被构造后就立即开始执行, 而不需要一个额外的 start()
之类的成员函数调用.
当然, 除了使用常规函数对象之外, 也可以利用等价的 lambda 语法, 比如上面例子中的 ThreadExec
对象, 可以更改为下面这样等价的代码
void thread_exec() { for (int i = 0; i < 4; ++i) std::cout << "Thread function output\n"; }
int main()
{
int n = 4;
std::thread t(
[n]() // 使用 lambda 替代函数对象
{
for (int i = 0; i < n; ++i) {
std::cout << "Thread object output\n";
}
}
);
std::thread u(thread_exec);
t.join();
u.join();
return 0;
}
上面这些是单使用可调用对象构造对象, 而这些可调用对象也不需要参数, 那么线程中可以直接使用它们. 而若要向线程提供参数, 则也应在线程构造时立即提供, 而不能等到线程开始执行了才以别的手法进行设置. 提供给线程构造函数的额外参数会被线程移交给可调用对象, 因此可调用对象也要具备相应的调用算符函数重载, 或者作为函数指针的可调用对象要具有相应的签名.
void thread_exec(int times)
{
for (int i = 0; i < times; ++i) {
std::cout << "t0 output\n";
}
}
int main()
{
std::thread t0(thread_exec, 4); // 函数需要一个参数, 相应的实参为 4
std::thread t1(
[](char const* name, int times)
{
auto msg = std::string(name) + " output\n";
for (int i = 0; i < times; ++i) {
std::cout << msg;
}
}, "t 1", 3); // lambda 需要两个参数, 也都通过线程构造参数传入
t0.join();
t1.join();
return 0;
}
可能的输出为
t0 output
t0 output
t 1 output
t0 output
t0 output
t 1 output
t 1 output
在处理整数或指针这样的基本数据类型的参数时, thread
的构造行为非常类似于 bind
, 甚至以上例子中的 thread
对象构造就等价于这样的代码
std::thread t0(std::bind(thread_exec, 4)); // 使用 bind 预绑定参数 4, 并将返回的函数对象交给 thread
// ^^^^^^^^^^ ^
std::thread t1(std::bind( // 使用 bind 预绑定任何其他参数
[](char const* name, int times)
{
auto msg = std::string(name) + " output\n";
for (int i = 0; i < times; ++i) {
std::cout << msg;
}
}, "t 1", 3));
t0.join();
t1.join();
不过, thread
底层的机制与 bind
又有本质不同. 首先, thread
对象自身不存储任何参数, 那么这些参数去了哪里? 其实直接构造到新线程的栈上, 也就是构造线程所用的可调用对象的形参列表中.
由于中间省略了参数中转的步骤, 一些只可移动的对象在构造 thread
时是可行的. 如
struct SumPtr {
std::unique_ptr<int> p;
explicit SumPtr(int x)
: p(new int(x))
{}
void operator()(std::unique_ptr<int> q) // 参数非引用类型
{
std::cout << *this->p + *q << std::endl;
}
};
int main()
{
SumPtr s(2);
std::unique_ptr<int> q(new int(3));
// auto f = std::bind(std::move(s), std::move(q));
// f(); 这样是无法编译的
// 但向 thread 传参可以利用右值移动构造参数到新线程的函数栈上
std::thread t(std::move(s), std::move(q));
t.join(); // 线程内输出 5
return 0;
}
从上面这个例子还可以推断出, 默认情况下, 将左值传递给 thread
构造函数时会复制这些参数, 无论是可调用对象, 还是传给此可调用对象的其他参数; 而如果这些参数中有无法复制的对象就会报错.
而如果线程所使用的可调用对象的相应参数位置接受左值引用作为参数, 那么必须使用 ref
或 cref
对当前上下文中的对象进行引用, 否则会引起编译错误, 这一点也和 bind
不同.
int main()
{
int x = 0;
std::thread a(
[](int& r) // 如果要使用左值引用
{ // 必须使用 reference_wrapper 包装参数
// ...
}, x); // 编译报错
a.join();
std::thread b(
[](int& r)
{
r++;
}, std::ref(x)); // 正确
b.join();
std::cout << x << std::endl; // 输出 1 : 在线程中被改动了
return 0;
}
另外有个与 bind
一致的规则是, 构造 thread
的实参类型也可以与可调用对象相应的形式参数类型不同, 但需要注意两点不同, 一是被转换参数是完美转发的, 因此可能会匹配右值重载的转换或构造函数, 二是转换的过程在新线程中执行. 第二点很重要, 因为如果转换的过程抛出异常, 此异常会出现在新线程中而不能被主线程捕获.
关于这一规则, 请阅读以下例子
struct A {};
struct B {
/* implicit */ B(A&& a) { // (c)
// ...
throw std::runtime_error("bang!");
}
/* implicit */ B(A const& a) {}
};
int main(int argc, char** argv)
{
try {
std::thread t(
[](B x) // (a)
{
// ...
}, A()); // (b)
t.join();
} catch (std::runtime_error&) { // (d)
std::cout << "exception caught" << std::endl;
}
return 0;
}
上述程序在运行时会因发生未捕获的异常而终止. 这是由于在 (a) 处线程所执行的函数要求的形参类型与 (b) 处传给 thread
构造函数的实参类型可以不同, 因此会执行一次 A
实例向 B
实例的转换构造, 这次构造匹配到了 (c) 处定义的隐式构造函数, 而这个函数中可能有某种情况导致一个异常抛出. 而由于此调用栈已经处在新线程中, 主线程中 (d) 处的 catch
语句不会有任何作用. 因此, 新线程中产生了一个未捕获的异常.
在任何线程包括主线程中抛出的异常如果没有被捕获, 程序都会调用 terminate
求个速死; 而对于上面例子中的这种情况而言, 甚至没有位置安插异常处理代码, 因此最好在主线程中多写一些代码将 A()
转换为 B
的实例再交给新线程. 同时也应当避免使用可能导致异常的复制构造函数去构造新线程的参数, 而尽量使用右值将对象移动到新线程的栈上去.
随着可能启动的线程越来越多, 像以上代码这样把所有的其他线程都放在 main
函数中构造并等待的做法很不方便, 也是不建议的. 如何在其他函数中创建线程, 从函数调用中返回线程, 或将线程放入容器中管理很自然是开发人员会面对的问题. 在解决这些问题时, 自然就会考虑 thread
对象作为 C++ 对象的一些本性: 它可以复制吗, 它可以移动吗, 以及它可以与容器配合使用吗?
如果有复制的要求, thread
是不能直接满足的. 无论传给 thread
的各个参数本身是否可以复制, 线程对象本身是不能复制的, 这听起来也比较令人安心. 而如果想要产生一个一模一样的执行过程并从头开始, 应该使用相同的参数构造一个新的线程.
线程对象是可以移动的, 虽然这一说法有些不直观. 换言之, 线程对象可以从另一个线程对象移动构造, 移动来源将变为一个 "空" 线程对象; 另外, 使用默认无参的构造函数也可以构造出空的线程对象. 空线程对象不会执行任何过程, 但它可以被作为另一个线程的移动目标. 如
std::thread create_thread()
{
return std::thread(
[]()
{
for (int i = 0; i < 4; ++i) {
std::cout << "Thread output\n";
}
});
}
int main()
{
std::thread t; // 使用无参构造函数构造空的线程对象
t = create_thread(); // 使用移动赋值, 将函数返回的线程对象交给 t
std::cout << "move assigned\n";
std::thread u(std::move(t)); // 使用移动构造
std::cout << "move constructed\n";
for (int i = 0; i < 2; ++i) {
std::cout << "Main output\n";
}
u.join(); // 只需要等待 u 结束, 而不必再关心作为移动来源的 t
return 0;
}
可能的输出为
move assigned
Thread output
Thread output
move constructed
Main output
Main output
Thread output
Thread output
上面例子中在函数中构造了一个线程对象, 产生了一个线程, 并两度使用移动语义交接该线程移交到别处. 在移动过程中, 这个线程的执行不受影响, 并且, 最终要等待结束的也只有一个线程.
另外, 也可以用容器来存储并管理线程对象.
std::thread create_thread(int index)
{
return std::thread(
[](std::string msg)
{
for (int i = 0; i < 8; ++i) {
std::cout << msg;
}
}, "Thread #" + std::to_string(index) + "\n");
// ^^^^^^^^^^^^^^
// to_string(T) 其中 T 为各种数值类型; 这是 C++11 新加入的将数值转换为字符串的函数
}
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 3; ++i) {
threads.push_back(create_thread(i)); // 存入 vector
}
for (auto& t: threads) { // 逐一等待结束
t.join();
}
return 0;
}
移动一个线程对象和移动一个像 vector
这样存储数据的容器给人的感受显然是不一样的, 实际上对线程的移动只限于这个线程的本地句柄 (native handle) 数据, 而线程本身以及其执行状态仍然由操作系统管理.
也就是说, 在 C++ 代码中线程对象的构造, 移动和析构, 实际上是线程对象本身管理的线程句柄数据的操作在代码上的体现, 而其底层的线程本身执行行为却不会受到任何影响. 在 thread
头文件中的这一部分行为的实现如下
// thread:60
class thread
{
public:
// :68
// id 类, 包装本地线程句柄数据的内部类型
// 此内部类型是标准 API 的一部分, 任何平台上都可以通过线程对象的 `get_id()` 函数获得此句柄类型
class id {
// 此成员类型 native_handle_type 在不同平台上可能不同
native_handle_type _M_thread;
public:
// 默认构造的 id 是无效的
id() noexcept
: _M_thread()
{}
explicit id(native_handle_type __id)
: _M_thread(__id)
{}
// ...
// :82
// 两个 id 对象可以被判定是否相同, 判定依据是其内含的本地句柄
friend bool operator==(thread::id __x, thread::id __y) noexcept
{
return __gthread_equal(__x._M_thread, __y._M_thread);
}
};
// ...
private:
// :119
// thread 对象的内部数据可能只有这一 id 成员
id _M_id;
public:
// ...
// :156
// 交换两个线程的句柄内容
void swap(thread& __t) noexcept
{
std::swap(_M_id, __t._M_id);
}
// :160
// 是否可以等待此线程结束, 此函数也可用于判定此线程是否不为空线程
// 判定方式就是比较内含的 id 成员是否等同于默认构造的无效 id
bool joinable() const noexcept
{
return !(_M_id == id());
}
// :128
// 移动构造函数, 与 vector 一样, 使用默认构造加交换方式
thread(thread&& __t) noexcept
{
swap(__t);
}
// :148
// 移动赋值算符重载, 首先会验证自身是否为空, 不为空的情况会调用 terminate 终止进程
// 然后以交换的方式进行移动
thread& operator=(thread&& __t) noexcept
{
if (joinable())
std::terminate();
swap(__t);
return *this;
}
// :140
// 线程对象析构时, 若自身不为空, 亦会调用 terminate 终止进程
~thread()
{
if (joinable())
std::terminate();
}
// ...
};
从以上实现可以看出, 线程的移动行为都与 id
类型的成员有关, 而这个成员只管理本地句柄. 所以移动线程对象的本质仍然只是表现为数据的句柄资源的移动, 与底层线程的执行没有关系.
需要一提的是 joinable()
这个成员函数, 字面意思上来说, 它表示这个线程是否可以 join()
, 其他函数在执行时会调用之, 以验证线程的状态. 而实际上, 它不仅仅表示此线程对象是否可以 join()
, 而是表示此对象是否必须在失效之前 join()
.
在上面的代码中, 有两种情况线程会调用 terminate
, 通常这就意味着进程将退出. 这两种情况, 一是线程对象处在 joinable()
时析构, 另一是线程对象处在 joinable()
时被当作了另一个线程对象的移动目标. 所以, 如果不在析构之前, 或作为移动来源之前 join()
此线程, 程序就会出错退出.
而实际上由于线程对象的移动赋值或析构只是数据上的更改, 因此强行 terminate
进程只是 C++ 层面为了满足资源管理语义而制定的要求. 这一点也是 C++11 中的线程对象 API 设计与 Java 等带有自动垃圾回收机制的语言中线程对象设计的另一个显著区别. 在 Java 这样的语言中并没有严格的析构函数语义, 无用对象的回收依赖于虚拟机, 因此这些语言中可以用类似 new Thread().start()
的代码在堆上配置一个线程对象, 之后便可置之不理. 而 C++ 中的对象生命周期规则则制约了 thread
对象的析构行为, 在 thread
析构的前提条件是其内部的 id
对象为无效状态.
而使一个线程对象的 id
失效的方法包括调用 join()
. 调用 join()
函数有两个后效: 会使得当前线程等待目标线程执行结束 (两个线程不能相同, 即当前线程不能等待自己结束), 以及 id
失效. 但一个线程执行结束并不直接意味着对应的线程对象的 id
失效. 所以即使确信一个线程对象析构之前该线程有充足的时间执行完毕, 仍然需要调用其 join
函数令其 id
失效并可以安全析构.
另一个方法是调用该线程对象的 detach()
函数, 单方面让该对象管理的 id
信息失效, 但不影响当前线程或目标线程的执行状态. 通常这用来实现一个后台守护线程.13
void create_daemon()
{
std::thread t([]()
{
int i = 0;
while (true) {
std::cout << i++ << std::endl;
}
});
t.detach(); // 调用 detach 后, 此线程对象所管理的线程 id 立即失效, 因此可以立即析构
// 线程会继续执行, 而主线程不等待线程结束
}
int main(int argc, char** argv)
{
create_daemon();
// ...
return 0;
}
调用 join
和 detach
的前提条件是该线程的 id
是有效的, 这既可以调用 joinable()
函数确认, 也可以调用线程对象的 get_id()
成员函数验证其返回值. 无论是 join
还是 detach
都不能重复地在一个线程对象上调用, 或在一个线程对象先后调用这两个函数. 若有多个线程要同时等待一个线程结束, 则需要其他手段, 后文中将有介绍.
C++11 为多线程引入的除了统一的 API 之外还有用于修饰对象声明和定义的关键字 thread_local
, 指出一个对象以线程本地存储 (thread local storage) 方式初始化并被各线程独立使用.
比如下面的例子, 在全局空间中声明一个 thread_local
修饰的对象并在两个不同的线程中使用它
thread_local int local_j = 0;
void thread_exec(int i)
{
for (int j = 0; j < 5; ++j) {
// 累加并输出全局空间中的 local_j
::local_j += j;
std::cout << "At thread #" + std::to_string(i) +
" local_j=" + std::to_string(local_j) + "\n";
}
}
int main(int argc, char** argv)
{
std::thread t0(thread_exec, 0);
std::thread t1(thread_exec, 1);
t0.join();
t1.join();
return 0;
}
可能的输出为
At thread #1 local_j=0
At thread #1 local_j=1
At thread #1 local_j=3
At thread #1 local_j=6
At thread #0 local_j=0
At thread #0 local_j=1
At thread #0 local_j=3
At thread #0 local_j=6
At thread #0 local_j=10
At thread #1 local_j=10
可以看出两个线程访问的 local_j
是隔离的, 这也就是 thread_local
关键字的作用, 让所定义的变量在每个线程中都是独立副本.
除了在全局空间中定义线程本地存储变量, thread_local
还可以被用在名字空间中, 函数体中和类体中. 其中出现在函数体中的线程本地存储对象隐式带有 static
修饰, 而出现在类体中的则需要用户显式地给出 static
, 否则会报错.
比如, 在上述例子中扩充以下内容
struct A {
void print() { std::cout << "A::print\n"; }
~A() { std::cout << "~A\n"; }
// 在类体中使用 thread_local 声明, 必须同时使用 static 将其定义为非实例成员
static thread_local A a;
};
void thread_exec(int i)
{
// ...
// 增加下面一句
A::a.print();
}
int main(int argc, char** argv)
{
std::thread t0(thread_exec, 0);
std::thread t1(thread_exec, 1);
t0.join();
t1.join();
return 0;
}
// 定义处也须加上 thread_local 关键字, 但不要加 static
thread_local A A::a;
可能的输出为
At thread #1 local_j=0
At thread #1 local_j=1
At thread #0 local_j=0
At thread #0 local_j=1
At thread #1 local_j=3
At thread #0 local_j=3
At thread #1 local_j=6
At thread #0 local_j=6
At thread #1 local_j=10
A::print
~A
At thread #0 local_j=10
A::print
~A
从这里还看出来, 当线程执行结束后, 线程本地存储对象会自动析构. 不过, 这里只析构了 2 个 A
对象, 但包含主线程的话, 一共有 3 个线程, 是主线程被 thread_local
排除在外了吗? 当然并不是, 主线程中也可以使用本地存储对象. 如将 main
函数改成
int main()
{
std::thread t0(thread_exec, 0);
std::thread t1(thread_exec, 1);
t0.join();
t1.join();
A::a.print(); // 使用 A::a
return 0;
}
那么输出的内容会增加两行
A::print
~A
结果被析构掉的 A
实例就有了 3 个. 也就是说, 如果主线程中根本不使用 A::a
, 那么编译器可以不构造这个本地存储对象.
实际上, 在标准中也没有明确规定线程本地存储对象的构造时机. 这一点跟函数本地存储的 static
对象很相似: 只要线程首次使用这个对象时, 该对象被构造完毕就可以了, 且如果构造则配套地给出析构函数. 至于具体的规则就由编译器自己去实现. 因此在主线程完全没有使用 A::a
时, 编译器也可以选择根本不构造这一对象.
这一节将介绍可被线程共享访问的基本数据类型.
当提到多线程共享访问同一变量时, 不得不提到现在标准中的 volatile
关键字. 在维基百科上有这样一个例子
static volatile int foo; // 声明为 volatile
void bar (void) {
foo = 0;
while (foo != 255)
;
}
若不加上 volatile
关键字, 编译器优化时可能得出 foo != 255
永久为真的结论, 进而导致无限循环的可能. 在多线程环境下, 将 foo
设定为 volatile
是一种必要手段.
但是 volatile
关键字与多线程无关, 它只是一处内存屏障 (memory barrier) 防止编译器优化或硬件缓存取到过期数据, 并没有其他保障. 因此如果要在多线程环境下共享使用一些简单的数据类型, 还是需要使用原子数据类型.
按照说明多线程数据竞争的惯例, 还是先给出一个例子, 几个线程同时调整一个全局变量, 那么最终它的取值会是随机的
#include <thread>
#include <iostream>
static volatile int global = 0;
void incr(int times)
{
for (int i = 0; i < times; ++i) {
::global += 1;
}
}
void decr(int times)
{
for (int i = 0; i < times; ++i) {
::global -= 1;
}
}
int main()
{
std::thread a(incr, 100000);
std::thread b(decr, 100000);
a.join();
b.join();
std::cout << ::global << std::endl;
return 0;
}
在标准中将以上可能并发同时写一个内存位置, 或一个线程写, 同时可能有其他线程读的行为都认定为未定义行为.
要使用标准库中的设施来消除数据竞争, 最简易的方式是使用原生数据类型的原子化版本.
#include <thread>
#include <iostream>
#include <atomic> // atomic 模板
static std::atomic<int> global(0); // 使用 atomic<int>
// 其他代码不变
这样 ::global += 1
或 ::global -= 1
这样的表达式在不同线程间就会加上简单但有效的同步.14
虽然 std::atomic
是一个模板, 但标准库中只对原生整型类型 (int
, unsigned long long
, char
等等), 任何指针类型和 bool
类型有完整定义, 因为这些类似整数或类似整数的类型的类型的行为具备最广泛的硬件支持, 而其它的类型有些虽然可以定义, 但没有预定义累加写回等操作, 使用会相当不便, 如
std::atomic<double> x(0.0); // 可以定义
x += 1.0; // 累加就会导致编译错误
所有标准库中提供的预定义原子整数类型和原子 bool
类型也都有非模板语法的别名. 如
std::atomic<int> i;
std::atomic_int j; // 这两个类型等价
std::atomic<unsigned short> s;
std::atomic_ushort t; // 这两个类型等价
std::atomic<bool> f;
std::atomic_bool h; // 这两个类型等价
// 等等
另外有一处细节需要注意, 就是 atomic
变量在初始化时不能写成
std::atomic<int> global = 0; // 错误
因为 atomic
只具有以下两个构造函数和被删除的复制构造函数
template <typename T>
struct atomic {
atomic();
constexpr atomic(T t); // 从基本类型转换构造是隐式的
atomic(atomic const&) = delete; // 不可复制
};
而编译器对使用等号的构造的解释是先将 0 隐式转换为临时 std::atomic<int>
对象, 然后再复制构造给 global
, 而复制构造是不可用的, 于是出现错误. 这时必须改为使用括号的构造方法. 能够编译通过的写法包括
std::atomic<int> global(0);
std::atomic<int> global{0};
std::atomic<int> global = {0};
除了加减数值并写回之外, 整数原子类型和指针原子类型提供的算符重载还包括前置或后置的自增自减算符重载, 对整数原子类型还提供了位运算并写回的算符重载, 所有这些运算都是原子的. (但并没有 operator*=
, operator%=
等乘除法, 取模或移位并写回的原子操作)
std::atomic_int x;
x = 13;
x |= 7;
x ^= 9;
std::cout << x << std::endl; // 6
int a[] = {2, 3, 5, 7, 11};
std::atomic<int*> p{a + 1};
std::cout << *p-- << std::endl; // 3
p += 2;
std::cout << *++p << std::endl; // 7
而所有这些算符重载实际上又是对直接提供的一组成员函数重载的包装或转调, 它们包括
算符重载 | 对应的成员函数调用式 |
---|---|
+= |
fetch_add |
-= |
fetch_sub |
&= |
fetch_and |
|= |
fetch_or |
^= |
fetch_xor |
前置 ++ |
fetch_add(1) + 1 |
后置 ++ |
fetch_add(1) |
前置 -- |
fetch_sub(1) - 1 |
后置 -- |
fetch_sub(1) |
其中前置或后置的自增或自减算符等价于第二列对应的表达式, 但 +=
-=
等算符与 fetch_*
的返回值则有所不同. 算符重载返回的是运算后的结果, 但 fetch_*
返回的是运算前的值.
另外, 直接对原子类型赋值以普通类型的值, 或隐式从原子类型转换为普通类型的值, 则有另外一组 API: load()
用于获取值, 以及 store(T value)
用于将数值写入原子类型变量中.
也就是说, 之前例子中的前一部分, 与下面这样的写法结果一样
std::atomic_int x;
x.store(13); // 赋值算符是 store 操作
x.fetch_or(7); // |= 是 fetch_or 操作
x.fetch_xor(9); // ^= 是 fetch_xor 操作
std::cout << x.load() << std::endl; // 获取值是 load 操作
而所有以上这些 API 除了有些接受一个数值型参数之外, 都可以另外传入一个调整内存顺序 (memory order) 的参数, 若未提供, 则默认是最强的内存顺序参数: 顺序一致 (sequentially consistent).
在 C++11 引入多线程之后, 给编译器实现的挑战不仅限于内存模型和一些库, 另一与硬件架构紧密联系的内存顺序概念与一些编译优化的控制也被纳入标准.
在没有多线程的情况下, 编译器可以根据一些优化规则调整生成指令之间的顺序, 而更底层地, 硬件在执行指令时可以通过乱序流水线加快执行速度, 以及将对变量的访问局限于缓存中而不是写回内存或从内存读取最新值. 这些优化的手段在多线程引入之后都面临一个基本问题: 指令乱序执行的结果会对其他线程造成的可察觉的影响.
比如这样的一段代码
volatile bool x = false;
volatile bool y = false;
void store()
{
x = true;
y = true;
}
void load()
{
while (!y)
;
if (x) {
std::cout << "x is true\n";
} else {
std::cout << "x is false\n";
}
}
int main()
{
std::thread a(store);
std::thread b(load);
a.join();
b.join();
return 0;
}
虽然在代码中 store
函数先修改 x
为真, 然后才修改 y
, 但在实际运行过程中, 有可能因为编译优化导致 store
中指令顺序被调换, 也有可能在执行的时候, 两条指令被乱序执行, 最终导致 x is false
输出.
在 C++11 中就提供了这样的优化防止选项, 不过这一选项并不是编译器参数, 而是可以灵活指定给每一条原子变量操作函数的. 如, 上面无任何防护的代码, 以加之于 C++11 原子变量操作的内存顺序参数等价于以下代码
std::atomic_bool x(false);
std::atomic_bool y(false);
void store()
{
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_relaxed);
}
void load()
{
while(!y.load(std::memory_order_relaxed))
;
if (x.load(std::memory_order_relaxed)) {
std::cout << "x is true\n";
} else {
std::cout << "x is false\n";
}
}
例子中, 所有的 store
和 load
函数, 也就是赋值和隐式转换所代表的两个函数都额外接受了一个 std::memory_order_relaxed
参数, 这个参数就是最弱的松散顺序要求. 相对地, 默认的顺序参数为 std::memory_order_seq_cst
, 要求原子操作全部以串行方式执行, 至少对其他线程表现出的结果必须是这样.15
介于两者之间又另有 4 个不同的参数, 其中按照读取要求和写回要求分为三种. 对原子读取有要求的 std::memory_order_consume
, std::memory_order_acquire
就不能用于 store
函数, 而对于原子写回有要求的 std::memory_order_release
则不能用于 load
函数. 还有一个 std::memory_order_acq_rel
则用于像 fetch_add
这样的 RMW 操作 (读取-修改-写回, read-modify-write). 它们的具体规则如下
memory_order_consume |
所有在此读操作之后的直接依赖于此操作对应的原子变量的读操作不能在此操作之前执行 |
memory_order_acquire |
所有在此读操作之后的读操作不能在此操作之前执行 |
memory_order_release |
所有在此写操作之前的写操作不能在此操作之后执行; 写回的内容对其他线程立即可见 |
memory_order_acq_rel |
用于 fetch_* 函数, 同时具有 memory_order_acquire 和 memory_order_release 的效用 |
其中 memory_order_consume
与 memory_order_acquire
在后续语句中的变量读取是否 "直接依赖读取的原子变量", 在一般情况下并不会造成问题, 但在一些特殊的依赖关系中会引起混乱. 考虑以下代码片段
struct T { int a; }
std::atomic<T*> ptr(nullptr);
std::atomic_int value(0);
void store_thread(T* x)
{
value.store(1, std::memory_order_release); // (a)
ptr.store(x, std::memory_order_release); // (b)
}
void load_thread(T* q)
{
T* p;
if ((p = ptr.load(std::memory_order_consume)) != nullptr) { // (c)
int r = value.load(std::memory_order_consume); // (d)
if (q->a == p->a) { // (e)
// ...
}
}
}
在 store_thread
执行时使用的内存顺序为 std::memory_order_release
因此这两条写入 (a) 和 (b) 不会调换顺序; 而在 load_thread
中, (e) 处的比较操作 q->a == p->a
直接依赖于 (c) 处的结果, 因此 (e) 处的操作不会被调换到 (c) 之前; 但 (c) (d) 两个 load
语句不是直接依赖的, 因此可以调换顺序. 这种调换顺序一般也不会有问题, 因为如果语句 (b) 还没执行, 分支 (c) 的条件就不会为真; 反过来若 (b) 执行了, 那么 (a) 一定也执行了, 这时才会进入分支, (d) 处的 load
才会起作用, 因此 (d) 应该也在 (a) 之后执行. 然而, 在允许分支预测的体系结构中, 可能出现以下的执行顺序而破坏这种预设
r
的初始值可能为 0从语义上来说 (c) 与 (d) 两处存在依赖关系, 但在数据上这种依赖不够 "直接", 因此允许 (d) 调整顺序到 (c) 之前, 从而导致意料之外的结果.
以上就是从具体硬件体系结构抽象出来的内存顺序模型, 合理地使用它们在一些特定情况下能让程序获得最优的性能. 但若用户没有精力甄选准确用于具体问题的顺序模型, 选择默认的顺序一致模型也未尝不可.
需要注意的是, 这些内存模型参数都是枚举常量, 且并不存在简单的累加或位或关系, 比如 memory_order_acq_rel
并不等于 memory_order_acquire | memory_order_release
.
在基本类型上进行简单的算术运算使用原子数据类型就足够了, 这一点由编译器和硬件体系结构保证不会发生出现访问冲突. 在复杂一些的情形下, 就需要使用更高阶的线程互斥手段了.
不过, 在并发访问复杂对象, 尤其是容器时, 一定还是要查看文档, 看看并发到什么程度才需要互斥访问. 比如在本章开头所说的, 即使访问同一个对象, 只要同时读写的内存位置不同, 或者所有线程都只读访问同一个内存位置, 就可以不加限制.
如, 在一个 vector
(vector<bool>
特化除外) 对象上使用 operator[]
, at
同时访问不同下标的元素, 也不会发生数据竞争; 但 map::operator[]
则不然, 因为它会在某个键不存在时新建一个键值对, 然后插入到 map
对象中去, 这个过程会修改 map
内部结构, 故有数据竞争的可能.
而向输出流对象如 cout
传入内容时, 单个 operator<<
调用是同步的, 但一个线程若要多次调用它来输出一行内容, 就无法保证在输出 std::endl
之前不被其他线程插入别的内容. 比如
#include <iostream>
#include <thread>
void thread_exec(std::string message, int times)
{
for (int i = 0; i < times; ++i) {
std::cout << "#" << i << ": " << message << std::endl;
}
}
int main()
{
std::thread a(thread_exec, std::string("I'm the quick fox"), 10);
std::thread b(thread_exec, std::string("I'm the lazy dog"), 10);
a.join();
b.join();
return 0;
}
可能出现类似这样的结果
#0: I'm the quick fox
#1: I'm the quick fox
#2: I'm the quick fox
#3: I'm the quick fox
#4#0: : I'm the quick fox
#5: I'm the quick fox
...
要解决这个问题, 有两个方法, 一是先将要输出的内容先放入一个 stringstream
, 然后一次输出其内容. 另外就是使用互斥量了16
#include <mutex>
void thread_exec_using_stringstream(std::string message, int times)
{
for (int i = 0; i < times; ++i) {
// 使用 stringstream 缓存所有的输出内容, 再一次性交给 cout
std::stringstream ss;
ss << "#" << i << ": " << message << std::endl;
std::cout << ss.str();
}
}
// 或者使用互斥量; 互斥量应当与需要互斥访问的对象定义在相似的空间内
// 如此处以全局对象形式出现, 因为 cout 是一个全局可见对象
std::mutex cout_mutex;
void thread_exec(std::string message, int times)
{
for (int i = 0; i < times; ++i) {
cout_mutex.lock(); // 对互斥量上锁
std::cout << "#" << i << ": " << message << std::endl;
cout_mutex.unlock(); // 一定要记得解锁互斥量
}
}
对 mutex
对象调用 lock
返回后, 该线程便独占了此对象的锁. 在调用 unlock
释放此锁之前, 其他线程调用相同 mutex
的 lock
会阻塞, 这样就达到了线程同步的目的. 如果线程不愿阻塞等待, 可以调用 try_lock
, 这个函数会立即返回 bool
值表示是否获得了锁.
对互斥量的上锁和解锁的操作一定要配对, 以 C++ 的思维显然这一对操作中解锁一处可以利用 RAII 机制自动进行, 并由此提供异常安全保障. 而在标准库中确实提供了对应的工具
for (int i = 0; i < times; ++i) {
// 定义 lock_guard 对象, 将互斥量作为参数传入, 就不需要再对互斥量上锁或解锁了
std::lock_guard<std::mutex> __lk__(cout_mutex);
std::cout << "#" << i << ": " << message << std::endl;
}
模板类型 lock_guard
不仅可以为 mutex
服务, 也可以为任何正确实现了 lock()
, unlock()
函数的自定义互斥量类型服务, 其对象在构造时锁住目标互斥量, 而析构时解锁之. 这也是 C++ RAII 机制在多线程方面提供的一个便利之处.
需要注意的是, mutex
和 lock_guard
都是不可复制构造或移动构造的. 不可复制很容易理解, 而不可移动似乎过于严苛了.
所有只能移动构造不可复制的类型都对应于某种资源的管理, 而如果这种资源不存在于对象中, 那么对象应该以默认方式构造. 但上面的例子中可以看到, 可以用来上锁解锁的 mutex
实例就是无参数默认构造的, 且它只有这一个构造函数.
实际上, mutex
所持有的资源的内容不由用户指定, 换言之, 不同的 mutex
虽然各不相同, 但初始化的方式一样, 用户对此没有干涉的余地. 因而 mutex
构造函数没有参数, 但一旦构造, 这个对象的状态就是正确的, lock
, unlock
函数就是可用的.
但这样一来, 如果可以把一个 mutex
对象移动到另一个 mutex
对象中, 那么移动来源在移动后的内容会变成什么, 为 "不持有资源" 的状态吗? 若真的这样定义, 那么每次在上锁或解锁之前, 都需要检查 mutex
的有效性, 这样会非常麻烦; 而移动目标原有的内容又会怎样, 设计这些行为也会很复杂. 本着多一事不如少一事的理念, 就将 mutex
设定为不可移动的类型了.
而设计 lock_guard
为不可移动的类型, 纯粹只是一个规定, 在标准库中就有一个可移动构造且同样为模板的自动上锁解锁的类型叫做 unique_lock
, 在上面的例子中直接用这个名字替换 lock_guard
, 语义完全一样. 不过, 正是因为 lock_guard
这种 "不能做什么" 的特性, 使得用户在阅读相关的代码会很省心: 这个锁不会被移交到其他地方.
更为复杂一些多线程程序会需要一次锁多个互斥量, 如哲学家进餐这样典型的问题, 在这一点上, 标准库也提供了一些便利.
哲学家进餐问题指的是一组哲学家们围坐在圆桌边, 如果他们都喜欢东方美食的话, 那么每两个哲学家之间置有一只筷子, 因此筷子的数量与哲学家的数量相同. 任何一位哲学家们在进餐时, 会试图先后拿起左手边的筷子和右手边的筷子, 如果筷子已经被其他哲学家拿起, 那么他会等待持有该筷子的其他哲学家进餐完毕后将筷子放回原位后再拿起.
这个模型中哲学家可以被抽象为线程, 而筷子则是需要互斥访问的资源. 而问题的挑战之处则是有可能所有的哲学家都同时拿起了左手边的筷子, 并等待右手边的筷子而造成死锁.
下面的例子演示了模拟 5 名哲学家进餐. 筷子和哲学家都有各自的编号 0-4; N 不为 4 时, 编号为 N 的哲学家左手边的筷子编号为 N 而右手边的筷子编号为 N + 1, 而 4 号哲学家左手边的筷子编号为 4, 右手边的筷子则是 0 号筷子.
#include <iostream>
#include <sstream>
#include <vector>
#include <thread>
#include <mutex>
// 筷子
struct Chopstick {
int index;
// 由于 mutex 不能移动构造, 因此直接把 mutex 作为成员的类型也无法直接移动构造
// 为了将互斥量和可移动对象绑定到一起, 最简单的方式就是使用 unique_ptr 存储之
std::unique_ptr<std::mutex> mutex{new std::mutex};
std::mutex& mtx()
{
return *this->mutex;
}
explicit Chopstick(int i)
: index(i)
{}
};
constexpr int NUM_PHILOSOPHERS = 5;
std::vector<Chopstick> chopsticks;
// 哲学家
struct Philosopher {
int index;
explicit Philosopher(int i)
: index(i)
{}
};
// 线程执行的内容就是一位哲学家进餐
void dine_thread(Philosopher p)
{
Chopstick& left = chopsticks[p.index];
Chopstick& right = chopsticks[(p.index + 1) % NUM_PHILOSOPHERS];
std::unique_lock<std::mutex> lock_left(left.mtx()); // 使用 unique_lock 代替 lock_guard
std::unique_lock<std::mutex> lock_right(right.mtx()); // 分别对左手边和右手边的筷子上锁
// 成功获取两只筷子后开始进餐
std::stringstream ss;
ss << "Philosopher #" << p.index << " start dining after picking up chopsticks #"
<< left.index << " and #" << right.index << std::endl;
std::cout << ss.str();
}
void dine()
{
std::vector<std::thread> dining;
for (int i = 0; i < NUM_PHILOSOPHERS; ++i) {
// emplace_back 是 vector 提供的用于在容器末尾进行置位式构造的方法
dining.emplace_back(dine_thread, Philosopher(i));
}
for (auto& t: dining) {
t.join();
}
}
int main()
{
for (int i = 0; i < NUM_PHILOSOPHERS; ++i) {
chopsticks.emplace_back(i);
}
dine();
return 0;
}
这个例子在给互斥量上锁的方式, 正是按照先拿起左手边的筷子, 再拿起右手边的筷子的规程进行的, 并且如果拿起了一边筷子之后, 如果另一边的筷子被其他哲学家占用了, 那么已经拿起来的筷子是不会放下去的. 因而这就是一个典型地可能导致死锁的实现.
而在 C++ 标准库中则提供了一个对多个互斥量一次上锁并且可以避免死锁的函数 std::lock
, 它接受至少两个可锁的对象作为参数, 并保证
lock
, try_lock
, unlock
最终锁住每个参数并返回; 这些成员函数的调用顺序是未定义的第一条保障提示了有可能 std::lock
在无法获取某个互斥量的锁时会解锁其他已锁的互斥量, 以实现第二条避免死锁的保障.
不过要利用它, 就不能用构造时就自动上锁的 lock_guard
了. 而之前提到的 unique_lock
则可以稍加修改达到此目的.
void dine_thread(Philosopher p)
{
Chopstick& left = chopsticks[p.index];
Chopstick& right = chopsticks[(p.index + 1) % NUM_PHILOSOPHERS];
// 增加 std::defer_lock 参数可以阻止 unique_lock 在构造时自动锁住互斥量
std::unique_lock<std::mutex> lock_left(left.mtx(), std::defer_lock);
std::unique_lock<std::mutex> lock_right(right.mtx(), std::defer_lock);
// 调用 lock 去锁住这两个 unique_lock
std::lock(lock_left, lock_right);
// ...
}
当给 unique_lock
提供一个额外的 defer_lock
(它是 std::defer_lock_t
空类型的一个预定义实例, 只用于重载区分) 后, 其构造时就不会立即对参数互斥量进行加锁了, 且允许在之后的代码中再锁住互斥量. 若其生命期内没有对内含的互斥量上锁, 析构时也不会对其解锁. 并且 unique_lock
也提供了 lock
, try_lock
, unlock
这些函数, 因此可以传递给 std::lock
加锁.
因此, 上述修改过的 dine_thread
函数中, lock_left
和 lock_right
构造时都并没有立即锁上两只筷子的互斥量, 而在之后一句 std::lock
返回后才加锁, 且两个 unique_lock
在之后析构时都会解锁互斥量. 经过这样修改后的版本便不会产生死锁了.
除了 defer_lock
, 标准库还提供了 std::adopt_lock
和 std::try_to_lock
两个预定义的重载决议对象. 若 unique_lock
以 adopt_lock
构造, 它不会锁互斥量, 但会认为互斥量已经上锁并在析构时解锁; 若以 try_to_lock
构造, unique_lock
会以 try_lock
方式非阻塞地尝试锁互斥量, 在构造之后, 可以调用其 owns_lock
函数检查是否获取了锁.
在需要多个线程等待某一个线程完成一项任务时, 使用线程的 join
或锁都不合适. 在多线程领域中有更好的解决工具用来实现多个线程之间等待与唤醒的行为, 体现在 C++ 标准库中便是 condition_variable
.
下面这个例子演示了两个线程等待第三个线程从 std::cin
输入一个整数, 然后各线程再处理之.
#include <iostream>
#include <sstream>
#include <vector>
#include <thread>
#include <atomic>
#include <mutex>
#include <condition_variable>
std::condition_variable input_cond_var; // 定义所有线程可见的 condition_variable
std::mutex input_mtx; // condition_variable 的等待函数要与一个 mutex 配合使用
std::atomic_int input(0); // 存放输入的内容
int main()
{
using mutex_lock = std::unique_lock<std::mutex>;
std::vector<std::thread> vt;
vt.emplace_back(
[]() // 消费者线程
{
// ...
mutex_lock lk(::input_mtx); // 调用 wait 之前, 要用 unique_lock 锁住 mutex
::input_cond_var.wait(lk); // 调用 wait 传入此 unique_lock, wait 会解锁 mutex
int result = input + 1;
std::stringstream ss;
ss << "The successor of " << input << " is " << result << std::endl;
std::cout << ss.str();
});
vt.emplace_back(
[]() // 另一消费者线程
{
// ...
mutex_lock lk(::input_mtx);
::input_cond_var.wait(lk); // 同样锁住 mutex 后调用 wait
bool result = input < 0;
std::stringstream ss;
ss << input << " is " << (result ? "" : "not ") << "negative" << std::endl;
std::cout << ss.str();
});
vt.emplace_back(
[]() // 生产者线程
{
int i;
std::cin >> i; // 注: 不能直接输入到 atomic 变量中
::input = i;
::input_cond_var.notify_all(); // 数据就绪后, 调用 notify_all 唤醒所有的等待线程
});
for (auto& t: vt) {
t.join();
}
return 0;
}
这个例子中 wait
与 notify_all
是一对操作, 不过这种配对不是一个线程对另一个线程, 而是一个 notify_all
对应所有其他在同一个 condition_variable
上阻塞等待的 wait
. 如果这种资源是排他的, 只随机唤醒一个消费者, 则应该使用 notify_one()
函数.
另外, 在调用 wait
之前, 等待数据的线程都要先使用 unique_lock
锁上特定的互斥量, 这个互斥量通常与 condition_variable
组合使用. 如果没有特别的需求, 可以在调用 wait
之前再锁住它, 尽量减少对其他线程的影响.
不过这个例子有个潜在的问题: 如果在上面两个线程 wait
之前, 内容就被输入且 notify_all
被调用了, 那么 wait
就会陷入无限等待中. 为了防止这一错误发生, 应该使用 wait
的一个条件重载版本, 并配合一个标志变量使用.
修改的部分如下
std::condition_variable input_cond_var;
std::mutex input_mtx;
std::atomic_int input(0);
// 在所有线程可见处定义一个标志
std::atomic_bool input_ready(false);
// ...
int main()
{
// ...
// 调用 wait 的线程作如下修改
vt.emplace_back(
[]()
{
// ...
mutex_lock lk(::input_mtx);
// ::input_cond_var.wait(lk);
// 改为调用下面的重载
::input_cond_var.wait(lk, []() -> bool { return input_ready; });
// ...
});
// ...
// 调用 notify_all / notify_one 的线程作如下修改
vt.emplace_back(
[]()
{
// ...
// ::input_cond_var.notify_all();
// 将 notify_all 的调用变更为
{
mutex_lock lk(::input_mtx);
::input_ready = true;
input_cond_var.notify_all();
}
// ...
});
// ...
}
wait
的一个重载版本让用户提供一个检验条件是否满足的可调用对象, 它的返回值指出该线程是否可以不再等待此 condition_variable
. 换言之如果该线程执行到此时, input_ready
就已经被置为真值, 那么就不再需要等待, 可直接使用输入的值.
而在 notify_all
调用处之前, 获取了 input_mtx
的锁之后设置 input_ready
为真. 这是为了避免当消费者线程在读取 input_ready
为非真值和调用 wait
等待之间, 生产者插入执行了设置 input_ready
为真并唤醒这一过程, 导致之后 wait
仍然会无限等待.
以上便是 C++11 中提供的各种基本的多线程相关的 API 了. 这些 API 在 POSIX 线程库中不乏有相近的面孔, 有些实践也早已广泛运用于生产中, 并不是 C++ 中发明的新事物. 而并发程序设计最根本的部分, 则是拆解任务的方法, 调试排错手段等, 这些还需读者在实践中根据具体情况选择正确的方式了.
public
访问限制 e) 所有的成员亦均为 PODsort
, set_intersection
这样的算法函数, 即使需要在其他地方定义冗长的函数也无妨, 因为这些算法本身更复杂; 而像 find_if
, count_if
甚至如 for_each
这样的函数则不然, 通常情况下, 一个 for 循环足以替代相应的功能const
量或使用简单的分支语句swap
的实现, 每交换一对成员需要三次赋值, 并不经济.this
, 这种重叠发生时也不会出错. 实际测试用 clang 3.4 编译运行上述示例不会发生错误; gcc 4.8 编译生成的程序运行时往往不会立即崩溃, 但使用 valgrind 工具可以看到不合法的内存访问. 这些行为差异并不体现编译器的优劣, 它就是未定义行为.register
关键字, 但时至今日很少有人会使用它, 并且它在 C++11 中被列为淘汰的关键字detach
过的线程是未定义行为. 大部分的操作系统中, 这样的线程将终止, 然后进程退出, 就像 Java 这样有虚拟机支持的语言中的守护线程的行为一样memory_order_relaxed
的例子在 x86 机器上不会出现输出 x is false
的情况.stringstream
先缓存所有待输出内容的方法, 代价是缓冲在 stringstream
需要更多的内存资源; 为了更少干涉线程的执行, 之后的例子中不再会对 cout
加锁, 而是采用 stringstream
缓存的方法