0%

C++11/14 特性介绍(二)

本系列整理转载实验楼的 C++11/14 教程,对 C++11/14 中出现的部分重要特性进行介绍。

前言

作为系列的第二篇文章,本文主要介绍 C++11/14 中语言可用性的一些强化特性。内容包括:

  • nullptrconstexpr
  • 自动类型推导
    • auto
    • decltype
    • 尾返回类型、autodecltype 配合
  • 区间迭代
    • 基于范围的 for 循环
  • 初始化列表
    • std::initializer_list
    • 统一初始化的写法
  • 模板增强
    • 外部模板
    • 尖括号 >
    • 类型别名模板
    • 变长参数模板
  • 面向对象增强
    • 委托构造
    • 继承构造
    • 显式虚函数重载
      • override
      • final
    • 显式禁用默认函数
  • 强枚举类型

nullptr 和 constexpr

nullptr

nullptr 出现是为了替代 NULL。在传统C++中(主要是继承自C),NULL 和0是一个东西,而这会导致重载出现问题(C中没有重载)。

1
2
3
4
5
void foo(int);
void foo(char*);

foo(0);
foo(NULL);

对于这两个函数来说,如果 NULL 被定义为0,那么 foo(NULL) 会调用 foo(int),从而导致代码违反了直观。为了解决这个问题,C++11 中引入了 nullptr 关键字,专门用来区分空指针和0,nullptr 的类型为 nullptr_t,能够隐身的转换为任何指针或者成员指针的类型,也能和他们进行是否相等的比较。

使用例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

void foo(char *) {
std::cout << "call foo(char*)" << std::endl;
}

void foo(int) {
std::cout << "call foo(int)" << std::endl;
}

int main() {
if(NULL == (void *)0) {
std::cout << "NULL == 0" << std::endl;
} else {
std::cout << "NULL != 0" << std::endl;
}

foo(0);
// foo(NULL); // 编译无法通过
foo(nullptr);

return 0;
}

输出:

1
2
3
NULL == 0
call foo(int)
call foo(char*)

所以,当需要使用 NULL 的时候,请养成直接使用 nullptr 的习惯

constexpr

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define LEN 10

int len_foo() {
return 5;
}

int main() {
char arr_1[10];
char arr_2[LEN];
int len = 5;
char arr_3[len+5]; // 非法
const int len_2 = 10;
char arr_4[len_2+5]; // 合法
char arr_5[len_foo()+5]; // 非法

return 0;
}

在 C++11 之前,可以在常量表达式中使用的变量必须被声明为 const, 在上面的代码中,len_2 被定义成了常量, 因此 len_2+5 是一个常量表达式,所以能够合法的分配一个数组;

对于 arr_5 来说,C++98 之前的编译器无法得知 len_foo() 在运行期实际上是返回一个常数,这也就导致了非法的产生。

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

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

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

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

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

类型推导

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

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

auto

auto 在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register 并存。在传统 C++ 中,如果一个变量没有声明为 register 变量,将自动被视为一个 auto 变量。而随着 register 被弃用,对 auto 的语义变更也就非常自然了。

使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。

在以前我们需要这样来书写一个迭代器:

itr
1
2
3
4
5
6
7

而有了 `auto` 之后可以:

```c++
// 由于 cbegin() 将返回 vector<int>::const_iterator
// 所以 itr 也应该是 vector<int>::const_iterator 类型
for(auto itr = vec.cbegin(); itr != vec.cend(); ++itr);

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

int add(auto x, auto y);

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

decltype

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

1
2
3
4
5
6
7

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

```c++
auto x = 1;
auto y = 2;
decltype(x+y) z;

尾返回类型、auto 与 decltype 配合

你可能会思考,auto 能不能用于推导函数的返回类型。考虑这样一个例子加法函数的例子,在传统 C++ 中我们必须这么写:

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

C++11 引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置,配合decltype能够很好的解决问题:

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

区间迭代

基于范围的for循环

终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句:

1
2
3
4
int array[] = {1, 2, 3, 4, 5};
for (auto &x : array) {
std::cout << x << std::endl;
}

最常用的 std::vector也可以同样适用。

列表初始化

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

1
2
3
4
5
6
7
8
9
10
int arr[3] = {1, 2, 3};     // 列表初始化

class Foo {
private:
int value;
public:
Foo(int) {};
};

Foo foo(1); // 普通构造初始化

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

1
2
3
4
5
6
7
8
9
#include <initializer_list>

class Magic {
public:
Magic(std::initializer_list<int> list) {} // 初始化列表构造函数
};

Magic magic = {1, 2, 3, 4, 5};
std::vector<int> v = {1, 2, 3, 4, 5};

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

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

1
2
3
4
5
void func(std::initializer_list<int> list) {
return;
}

func({1, 2, 3});

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct A {
int a;
float b;
};

struct B {
B(int _a, float _b): a(_a), b(_b) {}

private:
int a;
float b;
};

// 统一的初始化语法
A a{1, 1.1};
B b{2, 2.2};

模板增强

外部模板

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

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

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

如果外部模板指令出现于一个编译单元中,那么与之对应的显示实例化必须出现于另一个编译单元中或者同一个编译单元的后续代码中,以供本编译单元调用。

模板的类型别名

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

1
2
3
4
5
6
7
8
9
10
template< typename T, typename U, int value>
class SuckType {
public:
T a;
U b;
SuckType():a(value),b(value){}
};

template< typename U>
typedef SuckType<std::vector<int>, U, 1> NewType; // 不合法

C++11 使用 using 来支持对传统 typedef 相同的功效,并且通过其可以实现对模板的类型别名定义:

1
2
3
4
5
typedef int (*process)(void *);  // 定义了一个返回类型为 int,参数为 void* 的函数指针类型,名字叫做 process
using process = int(*)(void *); // 同上, 更加直观

template <typename T>
using NewType = SuckType<int, T, 1>; // 合法

默认模板参数

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

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

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

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

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

变长模板参数

这个绝对是黑魔法了。

C++11 加入了新的表示方法,允许任意个数、任意类别的模板参数,同时也不需要再定义时将参数的个数固定。

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

既然是任意形式,所以个数为0的模板参数也是可以的:

1
class Magic<> noting;

如果不希望产生的模板参数个数为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
2
3
4
template<typename... Args>
void magic(Args... args) {
std::cout << sizeof...(args) << std::endl;
}

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

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

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

1. 递归解析

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

template<typename T>
void printf(T value) {
std::cout << value << std::endl;
}

template<typename T, typename... Args>
void printf(T value, Args... args) {
std::cout << value << std::endl;
printf(args...);
}

int main() {
printf(1, 2, "123", 1.1);
return 0;
}

2. 初始化列表展开

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 编译这个代码需要开启 -std=c++14

template<typename T, typename... Args>
auto print(T value, Args... args) {
std::cout << value << std::endl;
return std::initializer_list<T> {
([&] {std::cout << args << std::endl;} (), value)...
};
}

int main() {
print(1, 2.1, "123");
return 0;
}

通过初始化列表,(lambda 表达式, value)... 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。唯一不美观的地方在于如果不使用 return 编译器会给出未使用的变量作为警告。

面向对象的增强

委托构造

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托Base() 构造函数
value2 = 2;
}
};

int main() {
Base b(2);
std::cout << b.value1 << std::endl;
std::cout << b.value2 << std::endl;
}

继承构造

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base {
public:
Base() {
value1 = 1;
}
Base(int val): Base() {
value2 = val;
}
private:
int value1;
int value2;
};

class Subclass : public Base {
public:
using Base::Base; // 继承构造
};

int main() {
Subclass s(3);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}

显式虚函数重载

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

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

struct Subclass : Base {
void foo();
};

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

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

override

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

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

final

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

1
2
3
4
5
6
7
8
9
10
11
12
struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法

struct SubClass2 : SubClass1 {
}; // 非法, SubClass 已 final

struct SubClass3: Base {
void foo(); // 非法, foo 已 final
};

显示禁用默认函数

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

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

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

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

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

强类型枚举

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

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

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

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

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

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

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

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

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

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