第 2 章 语言可用性的强化

当我们声明、定义一个变量或者常量,对代码进行流程控制、面向对象的功能、模板编程等这些都是运行时之前,可能发生在编写代码或编译器编译代码时的行为。为此,我们通常谈及语言可用性,是指那些发生在运行时之前的语言行为。

2.1 常量

nullptr

nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0

C++ 不允许直接将 void * 隐式转换到其他类型。但如果编译器尝试把 NULL 定义为 ((void*)0),那么在下面这句代码中:

  1. char *ch = NULL;

没有了 void * 隐式转换的 C++ 只好将NULL 定义为 0。而这依然会产生新的问题,将 NULL 定义成 0 将导致 C++ 中重载特性发生混乱。考虑下面这两个 foo 函数:

  1. void foo(char*);
  2. void foo(int);

那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直觉。

为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。而 nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。

你可以尝试使用 clang++ 编译下面的代码:

  1. #include <iostream>
  2. #include <type_traits>
  3. void foo(char *);
  4. void foo(int);
  5. int main() {
  6. if (std::is_same<decltype(NULL), decltype(0)>::value)
  7. std::cout << "NULL == 0" << std::endl;
  8. if (std::is_same<decltype(NULL), decltype((void*)0)>::value)
  9. std::cout << "NULL == (void *)0" << std::endl;
  10. if (std::is_same<decltype(NULL), std::nullptr_t>::value)
  11. std::cout << "NULL == nullptr" << std::endl;
  12. foo(0); // 调用 foo(int)
  13. // foo(NULL); // 该行不能通过编译
  14. foo(nullptr); // 调用 foo(char*)
  15. return 0;
  16. }
  17. void foo(char *) {
  18. std::cout << "foo(char*) is called" << std::endl;
  19. }
  20. void foo(int i) {
  21. std::cout << "foo(int) is called" << std::endl;
  22. }

将输出:

  1. foo(int) is called
  2. foo(char*) is called

从输出中我们可以看出,NULL 不同于 0nullptr。所以,请养成直接使用 nullptr的习惯。

此外,在上面的代码中,我们使用了 decltypestd::is_same 这两个属于现代 C++ 的语法,简单来说,decltype 用于类型推导,而 std::is_same 用于比较两个类型是否相等,我们会在后面 decltype 一节中详细讨论。

constexpr

C++ 本身已经具备了常量表达式的概念,比如 1+2, 3*4 这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。一个非常明显的例子就是在数组的定义阶段:

  1. #include <iostream>
  2. #define LEN 10
  3. int len_foo() {
  4. int i = 2;
  5. return i;
  6. }
  7. constexpr int len_foo_constexpr() {
  8. return 5;
  9. }
  10. constexpr int fibonacci(const int n) {
  11. return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
  12. }
  13. int main() {
  14. char arr_1[10]; // 合法
  15. char arr_2[LEN]; // 合法
  16. int len = 10;
  17. // char arr_3[len]; // 非法
  18. const int len_2 = len + 1;
  19. constexpr int len_2_constexpr = 1 + 2 + 3;
  20. // char arr_4[len_2]; // 非法
  21. char arr_4[len_2_constexpr]; // 合法
  22. // char arr_5[len_foo()+5]; // 非法
  23. char arr_6[len_foo_constexpr() + 1]; // 合法
  24. std::cout << fibonacci(10) << std::endl;
  25. // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
  26. std::cout << fibonacci(10) << std::endl;
  27. return 0;
  28. }

上面的例子中,char arr_4[len_2] 可能比较令人困惑,因为 len_2 已经被定义为了常量。为什么 char arr_4[len_2] 仍然是非法的呢?这是因为 C++ 标准中数组的长度必须是一个常量表达式,而对于 len_2 而言,这是一个 const 常数,而不是一个常量表达式,因此(即便这种行为在大部分编译器中都支持,但是)它是一个非法的行为,我们需要使用接下来即将介绍的 C++11 引入的 constexpr 特性来解决这个问题;而对于 arr_5 来说,C++98 之前的编译器无法得知 len_foo() 在运行期实际上是返回一个常数,这也就导致了非法的产生。

注意,现在大部分编译器其实都带有自身编译优化,很多非法行为在编译器优化的加持下会变得合法,若需重现编译报错的现象需要使用老版本的编译器。

C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证 len_foo 在编译期就应该是一个常量表达式。

此外,constexpr 的函数可以使用递归:

  1. constexpr int fibonacci(const int n) {
  2. return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
  3. }

从 C++14 开始,constexpr 函数可以在内部使用局部变量、循环和分支等简单语句,例如下面的代码在 C++11 的标准下是不能够通过编译的:

  1. constexpr int fibonacci(const int n) {
  2. if(n == 1) return 1;
  3. if(n == 2) return 1;
  4. return fibonacci(n-1) + fibonacci(n-2);
  5. }

为此,我们可以写出下面这类简化的版本来使得函数从 C++11 开始即可用:

  1. constexpr int fibonacci(const int n) {
  2. return n == 1 || n == 2 ? 1 : fibonacci(n-1) + fibonacci(n-2);
  3. }

2.2 变量及其初始化

if/switch 变量声明强化

在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for 语句内能够声明一个临时变量 int,但始终没有办法在 ifswitch 语句中声明一个临时的变量。例如:

  1. #include <iostream>
  2. #include <vector>
  3. #include <algorithm>
  4. int main() {
  5. std::vector<int> vec = {1, 2, 3, 4};
  6. // 在 c++17 之前
  7. const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 2);
  8. if (itr != vec.end()) {
  9. *itr = 3;
  10. }
  11. // 需要重新定义一个新的变量
  12. const std::vector<int>::iterator itr2 = std::find(vec.begin(), vec.end(), 3);
  13. if (itr2 != vec.end()) {
  14. *itr2 = 4;
  15. }
  16. // 将输出 1, 4, 3, 4
  17. for (std::vector<int>::iterator element = vec.begin(); element != vec.end(); ++element)
  18. std::cout << *element << std::endl;
  19. }

在上面的代码中,我们可以看到 itr 这一变量是定义在整个 main() 的作用域内的,这导致当我们需要再次遍历整个 std::vectors 时,需要重新命名另一个变量。C++17 消除了这一限制,使得我们可以在 if(或 switch)中完成这一操作:

  1. // 将临时变量放到 if 语句内
  2. if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
  3. itr != vec.end()) {
  4. *itr = 4;
  5. }

怎么样,是不是和 Go 语言很像?

初始化列表

初始化是一个非常重要的语言特性,最常见的就是在对象进行初始化时进行使用。 在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、 POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体) 类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。 而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行。 这些不同方法都针对各自对象,不能通用。例如:

  1. #include <iostream>
  2. #include <vector>
  3. class Foo {
  4. public:
  5. int value_a;
  6. int value_b;
  7. Foo(int a, int b) : value_a(a), value_b(b) {}
  8. };
  9. int main() {
  10. // before C++11
  11. int arr[3] = {1, 2, 3};
  12. Foo foo(1, 2);
  13. std::vector<int> vec = {1, 2, 3, 4, 5};
  14. std::cout << "arr[0]: " << arr[0] << std::endl;
  15. std::cout << "foo:" << foo.value_a << ", " << foo.value_b << std::endl;
  16. for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
  17. std::cout << *it << std::endl;
  18. }
  19. return 0;
  20. }

为了解决这个问题,C++11 首先把初始化列表的概念绑定到了类型上,并将其称之为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:

  1. #include <initializer_list>
  2. #include <vector>
  3. class MagicFoo {
  4. public:
  5. std::vector<int> vec;
  6. MagicFoo(std::initializer_list<int> list) {
  7. for (std::initializer_list<int>::iterator it = list.begin();
  8. it != list.end(); ++it)
  9. vec.push_back(*it);
  10. }
  11. };
  12. int main() {
  13. // after C++11
  14. MagicFoo magicFoo = {1, 2, 3, 4, 5};
  15. std::cout << "magicFoo: ";
  16. for (std::vector<int>::iterator it = magicFoo.vec.begin(); it != magicFoo.vec.end(); ++it) std::cout << *it << std::endl;
  17. }

这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照。

初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:

  1. public:
  2. void foo(std::initializer_list<int> list) {
  3. for (std::initializer_list<int>::iterator it = list.begin(); it != list.end(); ++it) vec.push_back(*it);
  4. }
  5. magicFoo.foo({6,7,8,9});

其次,C++11 还提供了统一的语法来初始化任意的对象,例如:

  1. Foo foo2 {3, 4};

结构化绑定

结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple 容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie 对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦。

C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:

  1. #include <iostream>
  2. #include <tuple>
  3. std::tuple<int, double, std::string> f() {
  4. return std::make_tuple(1, 2.3, "456");
  5. }
  6. int main() {
  7. auto [x, y, z] = f();
  8. std::cout << x << ", " << y << ", " << z << std::endl;
  9. return 0;
  10. }

关于 auto 类型推导会在 auto 类型推导一节中进行介绍。

2.3 类型推导

在传统 C 和 C++中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长。

C++11 引入了 autodecltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。

auto

auto 在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register 并存。在传统 C++ 中,如果一个变量没有声明为 register 变量,将自动被视为一个 auto 变量。而随着 register 被弃用(在 C++17 中作为保留关键字,以后使用,目前不具备实际意义),对 auto 的语义变更也就非常自然了。

使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:

  1. // 在 C++11 之前
  2. // 由于 cbegin() 将返回 vector<int>::const_iterator
  3. // 所以 itr 也应该是 vector<int>::const_iterator 类型
  4. for(vector<int>::const_iterator it = vec.cbegin(); itr != vec.cend(); ++it)

而有了 auto 之后可以:

  1. #include <initializer_list>
  2. #include <vector>
  3. #include <iostream>
  4. class MagicFoo {
  5. public:
  6. std::vector<int> vec;
  7. MagicFoo(std::initializer_list<int> list) {
  8. // 从 C++11 起, 使用 auto 关键字进行类型推导
  9. for (auto it = list.begin(); it != list.end(); ++it) {
  10. vec.push_back(*it);
  11. }
  12. }
  13. };
  14. int main() {
  15. MagicFoo magicFoo = {1, 2, 3, 4, 5};
  16. std::cout << "magicFoo: ";
  17. for (auto it = magicFoo.vec.begin(); it != magicFoo.vec.end(); ++it) {
  18. std::cout << *it << ", ";
  19. }
  20. std::cout << std::endl;
  21. return 0;
  22. }

一些其他的常见用法:

  1. auto i = 5; // i 被推导为 int
  2. auto arr = new auto(10); // arr 被推导为 int *

注意auto 不能用于函数传参,因此下面的做法是无法通过编译的(考虑重载的问题,我们应该使用模板):

  1. int add(auto x, auto y);
  2. 2.6.auto.cpp:16:9: error: 'auto' not allowed in function prototype
  3. int add(auto x, auto y) {
  4. ^~~~

此外,auto 还不能用于推导数组类型:

  1. auto auto_arr2[10] = arr; // 错误, 无法推导数组元素类型
  2. 2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
  3. auto auto_arr2[10] = arr;

decltype

decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 sizeof 很相似:

  1. decltype(表达式)

有时候,我们可能需要计算某个表达式的类型,例如:

  1. auto x = 1;
  2. auto y = 2;
  3. decltype(x+y) z;

你已经在前面的例子中看到 decltype 用于推断类型的用法,下面这个例子就是判断上面的变量 x, y, z 是否是同一类型:

  1. if (std::is_same<decltype(x), int>::value)
  2. std::cout << "type x == int" << std::endl;
  3. if (std::is_same<decltype(x), float>::value)
  4. std::cout << "type x == float" << std::endl;
  5. if (std::is_same<decltype(x), decltype(z)>::value)
  6. std::cout << "type z == type x" << std::endl;

其中,std::is_same<T, U> 用于判断 TU 这两个类型是否相等。输出结果为:

  1. type x == int
  2. type z == type x

尾返回类型推导

你可能会思考,在介绍 auto时,我们已经提过 auto 不能用于函数形参进行类型推导,那么 auto 能不能用于推导函数的返回类型呢?还是考虑一个加法函数的例子,在传统 C++ 中我们必须这么写:

  1. template<typename R, typename T, typename U>
  2. R add(T x, U y) {
  3. return x+y
  4. }

注意:typename 和 class 在模板参数列表中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的。但在模板中定义有嵌套依赖类型的变量时,需要用 typename 消除歧义

这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add() 这个函数会做什么样的操作,获得一个什么样的返回类型。

在 C++11 中这个问题得到解决。虽然你可能马上会反应出来使用 decltype 推导 x+y 的类型,写出这样的代码:

  1. decltype(x+y) add(T x, U y)

但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,xy 尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:

  1. template<typename T, typename U>
  2. auto add2(T x, U y) -> decltype(x+y){
  3. return x + y;
  4. }

令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:

  1. template<typename T, typename U>
  2. auto add3(T x, U y){
  3. return x + y;
  4. }

可以检查一下类型推导是否正确:

  1. // after c++11
  2. auto w = add2<int, double>(1, 2.0);
  3. if (std::is_same<decltype(w), double>::value) {
  4. std::cout << "w is double: ";
  5. }
  6. std::cout << w << std::endl;
  7. // after c++14
  8. auto q = add3<double, int>(1.0, 2);
  9. std::cout << "q: " << q << std::endl;

decltype(auto)

decltype(auto) 是 C++14 开始提供的一个略微复杂的用法。

要理解它你需要知道 C++ 中参数转发的概念,我们会在语言运行时强化一章中详细介绍,你可以到时再回来看这一小节的内容。

简单来说,decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:

  1. std::string lookup1();
  2. std::string& lookup2();

在 C++11 中,封装实现是如下形式:

  1. std::string look_up_a_string_1() {
  2. return lookup1();
  3. }
  4. std::string& look_up_a_string_2() {
  5. return lookup2();
  6. }

而有了 decltype(auto),我们可以让编译器完成这一件烦人的参数转发:

  1. decltype(auto) look_up_a_string_1() {
  2. return lookup1();
  3. }
  4. decltype(auto) look_up_a_string_2() {
  5. return lookup2();
  6. }

2.4 控制流

if constexpr

正如本章开头出,我们知道了 C++11 引入了 constexpr 关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:

  1. #include <iostream>
  2. template<typename T>
  3. auto print_type_info(const T& t) {
  4. if constexpr (std::is_integral<T>::value) {
  5. return t + 1;
  6. } else {
  7. return t + 0.001;
  8. }
  9. }
  10. int main() {
  11. std::cout << print_type_info(5) << std::endl;
  12. std::cout << print_type_info(3.14) << std::endl;
  13. }

在编译时,实际代码就会表现为如下:

  1. int print_type_info(const int& t) {
  2. return t + 1;
  3. }
  4. double print_type_info(const double& t) {
  5. return t + 0.001;
  6. }
  7. int main() {
  8. std::cout << print_type_info(5) << std::endl;
  9. std::cout << print_type_info(3.14) << std::endl;
  10. }

区间 for 迭代

终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:

  1. #include <iostream>
  2. #include <vector>
  3. #include <algorithm>
  4. int main() {
  5. std::vector<int> vec = {1, 2, 3, 4};
  6. if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
  7. for (auto element : vec)
  8. std::cout << element << std::endl; // read only
  9. for (auto &element : vec) {
  10. element += 1; // writeable
  11. }
  12. for (auto element : vec)
  13. std::cout << element << std::endl; // read only
  14. }

2.5 模板

C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。

外部模板

传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。

为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:

  1. template class std::vector<bool>; // 强行实例化
  2. extern template class std::vector<double>; // 不在该当前编译文件中实例化模板

尖括号 “>”

在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:

  1. std::vector<std::vector<int>> matrix;

这在传统C++编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:

  1. template<bool T>
  2. class MagicType {
  3. bool magic = T;
  4. };
  5. // in main function:
  6. std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码

类型别名模板

在了解类型别名模板之前,需要理解『模板』和『类型』之间的不同。仔细体会这句话:模板是用来产生类型的。在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。例如:

  1. template<typename T, typename U>
  2. class MagicType {
  3. public:
  4. T dark;
  5. U magic;
  6. };
  7. // 不合法
  8. template<typename T>
  9. typedef MagicType<std::vector<T>, std::string> FakeDarkMagic;

C++11 使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效:

通常我们使用 typedef 定义别名的语法是:typedef 原名称 新名称;,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。

  1. typedef int (*process)(void *);
  2. using NewProcess = int(*)(void *);
  3. template<typename T>
  4. using TrueDarkMagic = MagicType<std::vector<T>, std::string>;
  5. int main() {
  6. TrueDarkMagic<bool> you;
  7. }

默认模板参数

我们可能定义了一个加法函数:

  1. template<typename T, typename U>
  2. auto add(T x, U y) -> decltype(x+y) {
  3. return x+y;
  4. }

但在使用时发现,要使用 add,就必须每次都指定其模板参数的类型。

在 C++11 中提供了一种便利,可以指定模板的默认参数:

  1. template<typename T = int, typename U = int>
  2. auto add(T x, U y) -> decltype(x+y) {
  3. return x+y;
  4. }

变长参数模板

模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。 在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。

  1. template<typename... Ts> class Magic;

模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:

  1. class Magic<int,
  2. std::vector<int>,
  3. std::map<std::string,
  4. std::vector<int>>> darkMagic;

既然是任意形式,所以个数为 0 的模板参数也是可以的:class Magic<> nothing;

如果不希望产生的模板参数个数为0,可以手动的定义至少一个模板参数:

  1. template<typename Require, typename... Args> class Magic;

变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf 函数, 虽然也能达成不定个数的形参的调用,但其并非类别安全。 而 C++11 除了能定义类别安全的变长参数函数外, 还可以使类似 printf 的函数能自然地处理非自带类别的对象。 除了在模板参数中能使用 ... 表示不定长模板参数外, 函数参数也使用同样的表示法代表不定长参数, 这也就为我们简单编写变长参数函数提供了便捷的手段,例如:

  1. template<typename... Args> void printf(const std::string &str, Args... args);

那么我们定义了变长的模板参数,如何对参数进行解包呢?

首先,我们可以使用 sizeof... 来计算参数的个数,:

  1. template<typename... Ts>
  2. void magic(Ts... args) {
  3. std::cout << sizeof...(args) << std::endl;
  4. }

我们可以传递任意个参数给 magic 函数:

  1. magic(); // 输出0
  2. magic(1); // 输出1
  3. magic(1, ""); // 输出2

其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:

1. 递归模板函数

递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:

  1. #include <iostream>
  2. template<typename T0>
  3. void printf1(T0 value) {
  4. std::cout << value << std::endl;
  5. }
  6. template<typename T, typename... Ts>
  7. void printf1(T value, Ts... args) {
  8. std::cout << value << std::endl;
  9. printf1(args...);
  10. }
  11. int main() {
  12. printf1(1, 2, "123", 1.1);
  13. return 0;
  14. }

2. 变参模板展开

你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf 的编写:

  1. template<typename T0, typename... T>
  2. void printf2(T0 t0, T... t) {
  3. std::cout << t0 << std::endl;
  4. if constexpr (sizeof...(t) > 0) printf2(t...);
  5. }

事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用 std::bind 及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。

3. 初始化列表展开

递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。

这里介绍一种使用初始化列表展开的黑魔法:

  1. template<typename T, typename... Ts>
  2. auto printf3(T value, Ts... args) {
  3. std::cout << value << std::endl;
  4. (void) std::initializer_list<T>{([&args] {
  5. std::cout << args << std::endl;
  6. }(), value)...};
  7. }

在这个代码中,额外使用了 C++11 中提供的初始化列表以及 Lambda 表达式的特性(下一节中将提到)。

通过初始化列表,(lambda 表达式, value)... 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。 为了避免编译器警告,我们可以将 std::initializer_list 显式的转为 void

折叠表达式

C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:

  1. #include <iostream>
  2. template<typename ... T>
  3. auto sum(T ... t) {
  4. return (t + ...);
  5. }
  6. int main() {
  7. std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
  8. }

非类型模板参数推导

前面我们主要提及的是模板参数的一种形式:类型模板参数。

  1. template <typename T, typename U>
  2. auto add(T t, U u) {
  3. return t+u;
  4. }

其中模板的参数 TU 为具体的类型。 但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:

  1. template <typename T, int BufSize>
  2. class buffer_t {
  3. public:
  4. T& alloc();
  5. void free(T& item);
  6. private:
  7. T data[BufSize];
  8. }
  9. buffer_t<int, 100> buf; // 100 作为模板参数

在这种模板参数形式下,我们可以将 100 作为模板的参数进行传递。 在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数 以具体的字面量进行传递,能否让编译器辅助我们进行类型推导, 通过使用占位符 auto 从而不再需要明确指明类型? 幸运的是,C++17 引入了这一特性,我们的确可以 auto 关键字,让编译器辅助完成具体类型的推导, 例如:

  1. template <auto value> void foo() {
  2. std::cout << value << std::endl;
  3. return;
  4. }
  5. int main() {
  6. foo<10>(); // value 被推导为 int 类型
  7. }

2.6 面向对象

委托构造

C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:

  1. #include <iostream>
  2. class Base {
  3. public:
  4. int value1;
  5. int value2;
  6. Base() {
  7. value1 = 1;
  8. }
  9. Base(int value) : Base() { // 委托 Base() 构造函数
  10. value2 = value;
  11. }
  12. };
  13. int main() {
  14. Base b(2);
  15. std::cout << b.value1 << std::endl;
  16. std::cout << b.value2 << std::endl;
  17. }

继承构造

在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using 引入了继承构造函数的概念:

  1. #include <iostream>
  2. class Base {
  3. public:
  4. int value1;
  5. int value2;
  6. Base() {
  7. value1 = 1;
  8. }
  9. Base(int value) : Base() { // 委托 Base() 构造函数
  10. value2 = value;
  11. }
  12. };
  13. class Subclass : public Base {
  14. public:
  15. using Base::Base; // 继承构造
  16. };
  17. int main() {
  18. Subclass s(3);
  19. std::cout << s.value1 << std::endl;
  20. std::cout << s.value2 << std::endl;
  21. }

显式虚函数重载

在传统 C++中,经常容易发生意外重载虚函数的事情。例如:

  1. struct Base {
  2. virtual void foo();
  3. };
  4. struct SubClass: Base {
  5. void foo();
  6. };

SubClass::foo 可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。

C++11 引入了 overridefinal 这两个关键字来防止上述情形的发生。

override

当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译:

  1. struct Base {
  2. virtual void foo(int);
  3. };
  4. struct SubClass: Base {
  5. virtual void foo(int) override; // 合法
  6. virtual void foo(float) override; // 非法, 父类没有此虚函数
  7. };

final

final 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

  1. struct Base {
  2. virtual void foo() final;
  3. };
  4. struct SubClass1 final: Base {
  5. }; // 合法
  6. struct SubClass2 : SubClass1 {
  7. }; // 非法, SubClass1 已 final
  8. struct SubClass3: Base {
  9. void foo(); // 非法, foo 已 final
  10. };

显式禁用默认函数

在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、 复制构造、赋值算符以及析构函数。 另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。 当程序员有需要时,可以重载这部分函数。

这就引发了一些需求:无法精确控制默认函数的生成行为。 例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private。 尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。

并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。

C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。 例如:

  1. class Magic {
  2. public:
  3. Magic() = default; // 显式声明使用编译器生成的构造
  4. Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
  5. Magic(int magic_number);
  6. }

强类型枚举

在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。

C++11 引入了枚举类(enumeration class),并使用 enum class 的语法进行声明:

  1. enum class new_enum : unsigned int {
  2. value1,
  3. value2,
  4. value3 = 100,
  5. value4 = 100
  6. };

这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较, 更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:

  1. if (new_enum::value3 == new_enum::value4) {
  2. // 会输出
  3. std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
  4. }

在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int)。

而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 << 这个算符来进行输出,可以收藏下面这个代码段:

  1. #include <iostream>
  2. template<typename T>
  3. std::ostream& operator<<(typename std::enable_if<std::is_enum<T>::value, std::ostream>::type& stream, const T& e)
  4. {
  5. return stream << static_cast<typename std::underlying_type<T>::type>(e);
  6. }

这时,下面的代码将能够被编译:

  1. std::cout << new_enum::value3 << std::endl

总结

本节介绍了现代 C++ 中对语言可用性的增强,其中笔者认为最为重要的几个特性是几乎所有人都需要了解并熟练使用的:

  1. auto 类型推导
  2. 范围 for 迭代
  3. 初始化列表
  4. 变参模板

习题

  1. 使用结构化绑定,仅用一行函数内代码实现如下函数:

    1. template <typename Key, typename Value, typename F>
    2. void update(std::map<Key, Value>& m, F foo) {
    3. // TODO:
    4. }
    5. int main() {
    6. std::map<std::string, long long int> m {
    7. {"a", 1},
    8. {"b", 2},
    9. {"c", 3}
    10. };
    11. update(m, [](std::string key) {
    12. return std::hash<std::string>{}(key);
    13. });
    14. for (auto&& [key, value] : m)
    15. std::cout << key << ":" << value << std::endl;
    16. }
  2. 尝试用折叠表达式实现用于计算均值的函数,传入允许任意参数。

参考答案见此

返回目录 | 上一章 | 下一章 运行时强化

许可

知识共享许可协议

本书系欧长坤著,采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议许可。项目中代码使用 MIT 协议开源,参见许可