第 2 章 语言可用性的强化
当我们声明、定义一个变量或者常量,对代码进行流程控制、面向对象的功能、模板编程等这些都是运行时之前,可能发生在编写代码或编译器编译代码时的行为。为此,我们通常谈及语言可用性,是指那些发生在运行时之前的语言行为。
2.1 常量
nullptr
nullptr
出现的目的是为了替代 NULL
。 C 与 C++ 语言中有空指针常量,它们能被隐式转换成任何指针类型的空指针值,或 C++ 中的任何成员指针类型的空成员指针值。 NULL
由标准库实现提供,并被定义为实现定义的空指针常量。在 C 中,有些标准库会把 NULL
定义为 ((void*)0)
而有些将它定义为 0
。
C++ 不允许直接将 void *
隐式转换到其他类型,从而 ((void*)0)
不是 NULL
的合法实现。如果标准库尝试把 NULL
定义为 ((void*)0)
,那么下面这句代码中会出现编译错误:
char *ch = NULL; |
没有了 void *
隐式转换的 C++ 只好将 NULL
定义为 0
。而这依然会产生新的问题,将 NULL
定义成 0
将导致 C++
中重载特性发生混乱。考虑下面这两个 foo
函数:
void foo(char*); |
那么 foo(NULL);
这个语句将会去调用 foo(int)
,从而导致代码违反直觉。
为了解决这个问题,C++11 引入了 nullptr
关键字,专门用来区分空指针、0
。而 nullptr
的类型为 nullptr_t
,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。
你可以尝试使用 clang++
编译下面的代码:
#include <iostream> |
将输出:
foo(int) is called |
从输出中我们可以看出,NULL
不同于 0
与 nullptr
。所以,请养成直接使用 nullptr
的习惯。
此外,在上面的代码中,我们使用了 decltype
和 std::is_same
这两个属于现代 C++ 的语法,简单来说,decltype
用于类型推导,而 std::is_same
用于比较两个类型是否相同,我们会在后面 decltype 一节中详细讨论。
constexpr
C++ 本身已经具备了常量表达式的概念,比如 1+2
, 3*4
这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。一个非常明显的例子就是在数组的定义阶段:
#include <iostream> |
上面的例子中,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
修饰的函数可以使用递归:
constexpr int fibonacci(const int n) { |
从 C++14 开始,constexpr
函数可以在内部使用局部变量、循环和分支等简单语句,例如下面的代码在 C++11 的标准下是不能够通过编译的:
constexpr int fibonacci(const int n) { |
为此,我们可以写出下面这类简化的版本来使得函数从 C++11 开始即可用:
constexpr int fibonacci(const int n) { |
2.2 变量及其初始化
if/switch 变量声明强化
在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for
语句内能够声明一个临时变量 int
,但始终没有办法在 if
和 switch
语句中声明一个临时的变量。例如:
#include <iostream> |
在上面的代码中,我们可以看到 itr
这一变量是定义在整个 main()
的作用域内的,这导致当我们需要再次遍历整个 std::vector
时,需要重新命名另一个变量。C++17 消除了这一限制,使得我们可以在 if
(或 switch
)中完成这一操作:
// 将临时变量放到 if 语句内 |
怎么样,是不是和 Go 语言很像?
初始化列表
初始化是一个非常重要的语言特性,最常见的就是在对象进行初始化时进行使用。
在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、
POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体)
类型都可以使用 {}
进行初始化,也就是我们所说的初始化列表。
而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 ()
进行。
这些不同方法都针对各自对象,不能通用。例如:
#include <iostream> |
为解决这个问题,C++11 首先把初始化列表的概念绑定到类型上,称其为 std::initializer_list
,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:
#include <initializer_list> |
这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照。
初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:
public: |
其次,C++11 还提供了统一的语法来初始化任意的对象,例如:
Foo foo2 {3, 4}; |
结构化绑定
结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple
容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie
对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦。
C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:
#include <iostream> |
关于 auto
类型推导会在 auto 类型推导一节中进行介绍。
2.3 类型推导
在传统 C 和 C++ 中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长。
C++11 引入了 auto
和 decltype
这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。
auto
auto
在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register
并存。在传统 C++ 中,如果一个变量没有声明为 register
变量,将自动被视为一个 auto
变量。而随着 register
被弃用(在 C++17 中作为保留关键字,以后使用,目前不具备实际意义),对 auto
的语义变更也就非常自然了。
使用 auto
进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:
// 在 C++11 之前 |
而有了 auto
之后可以:
|
一些其他的常见用法:
auto i = 5; // i 被推导为 int |
从 C++ 14 起,auto
能用于 lambda 表达式中的函数传参,而 C++ 20 起该功能推广到了一般的函数。考虑下面的例子:
auto add14 = [](auto x, auto y) -> int { |
注意:
auto
还不能用于推导数组类型:
auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型
2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
auto auto_arr2[10] = {arr};
decltype
decltype
关键字是为了解决 auto
关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 typeof
很相似:
decltype(表达式) |
有时候,我们可能需要计算某个表达式的类型,例如:
auto x = 1; |
你已经在前面的例子中看到 decltype
用于推断类型的用法,下面这个例子就是判断上面的变量 x, y, z
是否是同一类型:
if (std::is_same<decltype(x), int>::value) |
其中,std::is_same<T, U>
用于判断 T
和 U
这两个类型是否相等。输出结果为:
type x == int |
尾返回类型推导
你可能会思考, auto
能不能用于推导函数的返回类型呢?还是考虑一个加法函数的例子,在传统 C++ 中我们必须这么写:
template<typename R, typename T, typename U> |
注意:typename 和 class 在模板参数列表中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的。但在模板中定义有嵌套依赖类型的变量时,需要用 typename 消除歧义
这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add()
这个函数会做什么样的操作,以及获得一个什么样的返回类型。
在 C++11 中这个问题得到解决。虽然你可能马上会反应出来使用 decltype
推导 x+y
的类型,写出这样的代码:
decltype(x+y) add(T x, U y) |
但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,x
和 y
尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto
关键字将返回类型后置:
template<typename T, typename U> |
令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:
template<typename T, typename U> |
可以检查一下类型推导是否正确:
// after c++11 |
decltype(auto)
decltype(auto)
是 C++14 开始提供的一个略微复杂的用法。
要理解它你需要知道 C++ 中参数转发的概念,我们会在语言运行时强化一章中详细介绍,你可以到时再回来看这一小节的内容。
简单来说,decltype(auto)
主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype
的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:
std::string lookup1(); |
在 C++11 中,封装实现是如下形式:
std::string look_up_a_string_1() { |
而有了 decltype(auto)
,我们可以让编译器完成这一件烦人的参数转发:
decltype(auto) look_up_a_string_1() { |
2.4 控制流
if constexpr
正如本章开头出,我们知道了 C++11 引入了 constexpr
关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr
这个关键字引入到 if
语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:
#include <iostream> |
在编译时,实际代码就会表现为如下:
int print_type_info(const int& t) { |
区间 for 迭代
终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:
#include <iostream> |
2.5 模板
C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。
外部模板
传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。
为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:
template class std::vector<bool>; // 强行实例化 |
尖括号 ">"
在传统 C++ 的编译器中,>>
一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:
std::vector<std::vector<int>> matrix; |
这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:
template<bool T> |
类型别名模板
在了解类型别名模板之前,需要理解『模板』和『类型』之间的不同。仔细体会这句话:模板是用来产生类型的。在传统 C++ 中,typedef
可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。例如:
template<typename T, typename U> |
C++11 使用 using
引入了下面这种形式的写法,并且同时支持对传统 typedef
相同的功效:
通常我们使用
typedef
定义别名的语法是:typedef 原名称 新名称;
,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。
typedef int (*process)(void *); |
变长参数模板
模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。 在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。
template<typename... Ts> class Magic; |
模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:
class Magic<int, |
既然是任意形式,所以个数为 0
的模板参数也是可以的:class Magic<> nothing;
。
如果不希望产生的模板参数个数为 0
,可以手动的定义至少一个模板参数:
template<typename Require, typename... Args> class Magic; |
变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf
函数,
虽然也能达成不定个数的形参的调用,但其并非类别安全。
而 C++11 除了能定义类别安全的变长参数函数外,
还可以使类似 printf
的函数能自然地处理非自带类别的对象。
除了在模板参数中能使用 ...
表示不定长模板参数外,
函数参数也使用同样的表示法代表不定长参数,
这也就为我们简单编写变长参数函数提供了便捷的手段,例如:
template<typename... Args> void printf(const std::string &str, Args... args); |
那么我们定义了变长的模板参数,如何对参数进行解包呢?
首先,我们可以使用 sizeof...
来计算参数的个数,:
template<typename... Ts> |
我们可以传递任意个参数给 magic
函数:
magic(); // 输出0 |
其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:
1. 递归模板函数
递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:
#include <iostream> |
2. 变参模板展开
你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf
的编写:
template<typename T0, typename... T> |
事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用
std::bind
及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。
3. 初始化列表展开
递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。
这里介绍一种使用初始化列表展开的黑魔法:
template<typename T, typename... Ts> |
在这个代码中,额外使用了 C++11 中提供的初始化列表以及 Lambda 表达式的特性(下一节中将提到)。
通过初始化列表,(lambda 表达式, value)...
将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。
为了避免编译器警告,我们可以将 std::initializer_list
显式的转为 void
。
折叠表达式
C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:
#include <iostream> |
非类型模板参数推导
前面我们主要提及的是模板参数的一种形式:类型模板参数。
template <typename T, typename U> |
其中模板的参数 T
和 U
为具体的类型。
但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:
template <typename T, int BufSize> |
在这种模板参数形式下,我们可以将 100
作为模板的参数进行传递。
在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数
以具体的字面量进行传递,能否让编译器辅助我们进行类型推导,
通过使用占位符 auto
从而不再需要明确指明类型?
幸运的是,C++17 引入了这一特性,我们的确可以 auto
关键字,让编译器辅助完成具体类型的推导,
例如:
template <auto value> void foo() { |
2.6 面向对象
委托构造
C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:
#include <iostream> |
继承构造
在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using
引入了继承构造函数的概念:
#include <iostream> |
显式虚函数重载
在传统 C++ 中,经常容易发生意外重载虚函数的事情。例如:
struct Base { |
SubClass::foo
可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。
C++11 引入了 override
和 final
这两个关键字来防止上述情形的发生。
override
当重载虚函数时,引入 override
关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的其函数签名一致的虚函数,否则将无法通过编译:
struct Base { |
final
final
则是为了防止类被继续继承以及终止虚函数继续重载引入的。
struct Base { |
显式禁用默认函数
在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、
复制构造、赋值算符以及析构函数。
另外,C++ 也为所有类定义了诸如 new
delete
这样的运算符。
当程序员有需要时,可以重载这部分函数。
这就引发了一些需求:无法精确控制默认函数的生成行为。
例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private
。
尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。
并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。
C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。 例如:
class Magic { |
强类型枚举
在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果。
C++11 引入了枚举类(enumeration class),并使用 enum class
的语法进行声明:
enum class new_enum : unsigned int { |
这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较, 更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:
if (new_enum::value3 == new_enum::value4) { |
在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int
)。
而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 <<
这个算符来进行输出,可以收藏下面这个代码段:
#include <iostream> |
这时,下面的代码将能够被编译:
std::cout << new_enum::value3 << std::endl |
总结
本节介绍了现代 C++ 中对语言可用性的增强,其中笔者认为最为重要的几个特性是几乎所有人都需要了解并熟练使用的:
auto
类型推导- 范围
for
迭代 - 初始化列表
- 变参模板
习题
使用结构化绑定,仅用一行函数内代码实现如下函数:
template <typename Key, typename Value, typename F>
void update(std::map<Key, Value>& m, F foo) {
// TODO:
}
int main() {
std::map<std::string, long long int> m {
{"a", 1},
{"b", 2},
{"c", 3}
};
update(m, [](std::string key) {
return std::hash<std::string>{}(key);
});
for (auto&& [key, value] : m)
std::cout << key << ":" << value << std::endl;
}尝试用折叠表达式实现用于计算均值的函数,传入允许任意参数。
参考答案见此。
欧长坤 © 2016-2024 版权所有, 采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议许可,代码使用 MIT 协议开源。
如果你认为本书对你起到了帮助,可以资助作者。