主页
文章
分类
系列
标签
简历
【现代C++】语言可用性的强化
发布于: 2021-11-14   更新于: 2021-11-14   收录于: Cpp
文章字数: 1451   阅读时间: 7 分钟   阅读量:

常量

nullptr

nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0。C++11 引入了 nullptr 关键字,专门用来区分空指针、0。而 nullptr 的类型为 nullptr_t。能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。

constexpr

C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式 可以使用递归,从 C++14 开始,constexpr 函数可以在内部使用局部变量、循环和分支等简单语句

1
2
int len = 10;
char arr_3[len]; // 非法,即便这种行为在大部分编译器中都支持,但是)它是一个非法的行为
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++11中编译报错的原因是因为constexpr函数的递归定义中有多个分支,无法被编译器转换为常量表达式。constexpr函数要求其参数和返回值都是常量表达式,并且函数体中只能包含单个表达式。如果有多个分支或递归调用,编译器无法确定函数能否在编译时求值,因此会报错。在C++14标准下,上述代码是可以通过编译的,因为C++14标准中允许constexpr函数的定义中包含多个分支和递归调用,只要在编译时可以确定函数的返回值为常量表达式即可。因此, C++14标准允许constexpr函数在递归中调用自身,只要递归调用最终会终止并返回常量表达式。不过需要注意,C++14标准对constexpr函数的要求更加严格,对于某些复杂的递归函数也可能无法满足constexpr函数的要求。做出如下修改即可在C++11中运行:

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

变量及其初始化

if/switch 变量声明强化

C++17 消除了没有办法在 if 和 switch 语句中声明一个临时的变量的规定,使得我们可以在 if(或 switch)中完成这一操作

1
2
3
4
5
6
// 将临时变量放到 if 语句内
if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
itr != vec.end()) 
{
	*itr = 4;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
int main() {
    const int x = 1;
    switch (int y = x + 1; y) {
        case 1:
            std::cout << "Case 1" << std::endl;
            break;
        case 2:
            std::cout << "Case 2" << std::endl;
            break;
    }
}

初始化列表

C++11 首先把初始化列表的概念绑定到了类型上,并将其称之为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁(在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、 POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体) 类型都可以使用 {} 进行初始化)

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

结构化绑定

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

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

类型推导

auto

auto是C++11引入的关键字,用于类型推导,可以自动推导出表达式的数据类型。使用auto关键字声明变量,编译器会自动进行类型推导,根据变量被初始化赋值的表达式来确定变量的类型。auto可以用于任何类型,包括基础类型、自定义类型、函数指针等等。 auto 不能用于函数传参,考虑重载的问题,我们应该使用模板

decltype

decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。 比如:

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

尾返回类型推导

 auto 不能用于函数形参进行类型推导,那么 auto 能不能用于推导函数的返回类型呢?尾返回类型推导

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

为什么不能用

1
2
decltype(x+y) add(int x , int y){
    return x + y;

编译器读到 decltype(x+y) 时,x 和 y 尚未被定义

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

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

decltype(auto)

decltype(auto) 是 C++14 开始提供的一个略微复杂的用法。 decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
std::string  lookup1();
std::string& lookup2();
//C++11
std::string look_up_a_string_1() {
    return lookup1();
}
std::string& look_up_a_string_2() {
    return lookup2();
}
//C++14
decltype(auto) look_up_a_string_1() {
    return lookup1();
}
decltype(auto) look_up_a_string_2() {
    return lookup2();
}

控制流

if constexpr

C++17将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,可以实现让代码在编译时就完成分支判断。

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

区间 for 迭代

1
for (auto element : vec)

模板

模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。

外部模板

传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。 为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:

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

C++11中的模板强行实例化(template instantiation)是一种强制编译器实例化模板的技术,以确保特定类型或值的模板实例被编译器创建。它是通过在代码中使用特化或显式实例化来实现的。使用template关键字、尖括号和类型或值来指定要实例化的特定模板,然后使用分号结束即可。

这种技术的好处在于,可以实现一些优化,比如提前生成某些类型的实例,减少编译时间和空间占用。另外,它也可以用于在编译期间检查类型错误或者语法错误,避免在运行时出现错误。

举个例子,考虑下面这个模板函数:

1
2
3
4
template <typename T>
T add(T a, T b) {
    return a + b;
}

如果我们要确保这个函数被实例化为int类型,可以使用强行实例化技术,代码如下:

1
template int add<int>(int a, int b);

这段代码告诉编译器要强制实例化add模板为int类型,这样,在编译器遇到使用add模板创建int类型实例的时候,它就会去查找已经实例化的int类型的版本,而不是生成一个新的模板实例。这样可以提高程序的运行效率,尤其是在模板的代码较多的情况下,可以有效地缩短编译时间和减少空间占用。

需要注意的是,强行实例化是一种高级用法,需要谨慎使用,过度使用会导致代码可读性和可维护性下降。只有在特定需求和优化要求下,才可以考虑使用这种方法。

尖括号 “>”

在传统C++编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法

类型别名模板

模板是用来产生类型的 typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。 C++11使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效

1
2
3
template<typename T>
typedef MagicType<std::vector<T>, std::string> FakeDarkMagic;//不合法
using TrueDarkMagic = MagicType<std::vector<T>, std::string>;//合法

默认模板参数

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
2
3
4
5
template<typename... Ts> class Magic;
class Magic<int,
            std::vector<int>,
            std::map<std::string,
            std::vector<int>>> darkMagic;

如果不希望产生的模板参数个数为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... Ts>
void magic(Ts... 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
#include <iostream>
template<typename T0>
void printf1(T0 value) {
    std::cout << value << std::endl;
}
template<typename T, typename... Ts>
void printf1(T value, Ts... args) {
    std::cout << value << std::endl;
    printf1(args...);
}
int main() {
    printf1(1, 2, "123", 1.1);
    return 0;
}

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

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

3. 初始化列表展开

递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。 这里介绍一种使用初始化列表展开的黑魔法:

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

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

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

折叠表达式

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

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

非类型模板参数推导

有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数

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

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

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

面向对象

委托构造

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
class Base {
public:
    int value1;
    int value2;
    Base() {
        value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
        value2 = value;
    }
};
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
#include <iostream>
class Base {
public:
    int value1;
    int value2;
    Base() {
        value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
        value2 = value;
    }
};
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;
}

显式虚函数重载

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

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

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

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
struct Base {
    virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法
struct SubClass2 : SubClass1 {
}; // 非法, SubClass1 已 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 引入了枚举类(enumeration 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;