C++11&14高速上手教程(未完)

发布于 28 天前  50 次阅读


C++11&14 简介

一、概述

目标读者

本教程假定读者已经熟悉了传统 C++ ,至少在阅读传统 C++ 代码上不具备任何困难。

引言

C++ 算是一个用户群体比较大的语言了,从 C++ 98 到 C++ 11 经历了长达十年多之久的积累,C++ 14 则是作为对 C++ 11 的重要补充和优化,所有这些新标准中扩充的特性,给 C++ 这门语言注入了新的活力。

那些还在坚持使用 传统 C++ (本教程把 C++ 98 及其之前的 C++ 特性均称之为传统 C++)而未接触过 C++ 11 / 14 的 C++ 程序员在见到诸如 Lambda 表达式这类全新特性时,甚至会流露出『学的不是同一门语言』的惊叹之情。

C++ 1x (本教程中指 C++ 11 / 14, 甚至 C++ 17) 为传统 C++ 注入的大量特性使得整个 C++ 变得更加像一门现代化的语言。C++ 1x 不仅仅增强了 C++ 语言自身的可用性,auto 关键字语义的修改使得我们更加有信心来操控极度复杂的模板类型。同时还对语言运行期进行了大量的强化,Lambda 表达式的出现让 C++ 具有了『匿名函数』的『闭包』特性,而这一特性几乎在现代的编程语言(诸如 Python/Swift/... )中已经司空见惯,右值引用的出现解决了 C++ 长期以来被人诟病的临时对象效率问题等等。

C++ 1x 为自身的标准库增加了非常多的工具和方法,诸如在语言层面上提供了 std::thread 支持了并发编程,在不同平台上不再依赖于系统底层的 API,实现了语言层面的跨平台支持;std::regex提供了完整的正则表达式支持等等。

C++ 98 已经被实践证明了是一种非常成功的『范型』,而 C++ 1x 的出现,则进一步推动这种范型,让 C++ 成为系统程序设计和库开发更好的语言。

提示

提示:本课程所有代码至少需要开启 -std=c++11 选项来支持 C++ 11 相关特性,在介绍 C++ 14 特性时的相关代码需要开启 -std=c++14 的编译选项,例如:

$ g++ main.cpp -std=c++11

$ g++ main.cpp -std=c++14

推荐所有代码均使用 -std=c++14 选项进行编译。

二、教程目录

本教程虽号称高速上手教程,但实际上对 C++ 11 / 14 的相关特性做了一个较为全面的介绍,读者可以自行根据下面的目录选取感兴趣的内容进行学习,快速熟悉需要了解的内容,这从某种意义上来说,也算是高速上手了。

这些特性并不需要全部掌握,只需针对特定的应用场景,学习、查阅最适合自己的新特性即可(但总结部分依然给出了建议学习的特性)。

值得一提的是,本教程在介绍这些特性的过程中,尽可能简单明了的介绍了这些特性产生的历史背景和技术需求,这为理解这些特性、运用这些特性提供了很大的帮助。

Part 1

  • C++11/14 简介
    • 概述
    • 教程目录
    • 被弃用的特性
    • 与 C 的兼容性

Part 2

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

Part 3

  • 语言运行期的强化
    • lambda 表达式
    • lambda 表达式基础
      • 值捕获
      • 引用捕获
      • 隐式捕获
      • 表达式捕获
    • 泛型 lambda
    • 函数对象包装器
    • std::function
    • std::bind/std::placeholder
    • 右值引用
    • 左值、右值的纯右值、将亡值、右值
    • 右值引用和左值引用
    • 移动语义
    • 完美转发

Part4

  • 对标准库的扩充: 新增容器
    • std::array

    • std::forward_list

    • std::unordered_set

    • std::unordered_map

    • std::tuple
      
    • 基本操作

    • 运行期索引
    • 合并与迭代

Part 5

  • 对标准库的扩充: 智能指针和引用计数
    • 引用计数
    • std::shared_ptr
    • std::make_shared
    • std::unique_ptr
    • std::weak_ptr

Part 6

  • 对标准库的扩充: 正则表达式库

    • 正则表达式简介

    • 普通字符

    • 特殊字符
    • 限定符

    • std::regex
      

    及其相关

    • std::regex
    • std::regex_match
    • std::match_results

Part 7

  • 对标准库的扩充: 语言级线程支持
    • std::thread
    • std::mutex
    • std::unique_lock
    • std::future
    • std::packaged_task
    • std::condition_variable

Part 8

  • 其他杂项
    • 新类型
    • long long int
    • noexcept 的修饰和操作
    • 字面量
    • 原始字符串字面量
    • 自定义字面量

Part 9

  • 扩展主题: C++17 简介
    • 主要入选特性
    • 非类型模板参数的 auto
    • std::variant<>
    • 结构化绑定(Structured bindings)
    • 变量声明的强化
    • 未入选特性
    • Concepts

三、被弃用的特性

在学习 C++ 1x 之前,我们先了解一下从 C++ 11 开始,被弃用的主要特性:

注意:弃用不等于废弃,只是用于暗示程序员这些特性将从未来的标准中消失,应该尽量避免使用。但是,已弃用的特性依然是标准库的一部分,并且出于兼容性的考虑,这些特性其实会『永久』保留。

  • 弃用的特性(1):如果一个类有析构函数,为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用了。**
  • 弃用的特性(2):不再允许字符串字面值常量赋值给一个 char *。如果需要用字符串字面值常量赋值和初始化一个 char *,应该使用 const char * 或者 auto
char *str = "hello world!"; // 将出现弃用警告
  • 弃用的特性(3):C++98 异常说明、 unexpected_handlerset_unexpected() 等相关特性被弃用,应该使用 noexcept。**
  • 弃用的特性(4):auto_ptr 被弃用,应使用 unique_ptr
  • 弃用的特性(5):register 关键字被弃用。
  • 弃用的特性(6):bool 类型的 ++ 操作被弃用。
  • 弃用的特性(7):C 语言风格的类型转换被弃用,应该使用 static_castreinterpret_castconst_cast 来进行类型转换。

还有一些其他诸如参数绑定(C++11 提供了 std::bindstd::function)、export 等特性也均被弃用。前面提到的这些特性如果你从未使用或者听说过,也请不要尝试去了解他们,应该向新标准靠拢,直接学习新特性。毕竟,技术是向前发展的。

四、与 C 的兼容性

出于一些不可抗力、历史原因,我们不得不在 C++ 中使用一些 C 语言代码(甚至古老的 C 语言代码),例如 Linux 系统调用。在 C++11 出现之前,大部分人当谈及 『C 与 C++ 的区别是什么』时,普遍除了回答面向对象的类特性、泛型编程的模板特性外,就没有其他的看法了,甚至直接回答『差不多』,也是大有人在。下面的韦恩图大致上回答了 C 和 C++ 相关的兼容情况:

img

从现在开始,你的脑子里应该树立 『C++ 不是 C 的一个超集』 这个观念(而且从一开始就不是)。

在编写 C++ 时,也应该尽可能的避免使用诸如 void* 之类的程序风格。而在不得不使用 C 时,应该注意使用 extern "C" 这种特性,将 C 语言的代码与 C++ 代码进行分离编译,再统一链接这种做法,例如:

  • 头文件 foo.h
// foo.h
#ifdef __cplusplus
extern "C" {
#endif

int add(int x, int y);

#ifdef __cplusplus
}
#endif
  • 源文件 foo.c
// foo.c
int add(int x, int y) {
    return x+y;
}
  • 源文件 main.cpp
// main.cpp
#include "foo.h"
int main() {
    add(1, 2);
    return 0;
}

应先使用 gcc 编译 C 语言的代码:

gcc -c foo.c

编译出 foo.o 文件,再使用 g++ 将 C++代码和 .o 文件链接起来(或者都编译为 .o 再统一链接):

g++ main.cpp foo.o -o main

本节代码:https://labfile.oss.aliyuncs.com/courses/605/1.zip

# 下载
$ wget https://labfile.oss-internal.aliyuncs.com/courses/605/1.zip

# 解压
$ unzip 1.zip

进一步阅读的参考资料

  1. C++ 语言导学. Bjarne Stroustrup
  2. C++ 历史
  3. C++ 1x 特性在 GCC/Clang 等编译器中的支持情况

语言可用性的强化

一、本节内容

本节内容包括:

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

二、nullptr 与 constexpr

(1)nullptr

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

C++ 不允许直接将 void * 隐式转换到其他类型,但如果 NULL 被定义为 ((void*)0),那么当编译 char *ch = NULL; 时,NULL 只好被定义为 0。而这依然会产生问题,将导致了 C++ 中重载特性会发生混乱,考虑:

void foo(char *);

void foo(int);

对于这两个函数来说,如果 NULL 又被定义为了 0 那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直观。

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

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

#include <iostream>
void foo(char *);
void foo(int);
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;
}
void foo(char *ch) {
    std::cout << "call foo(char*)" << std::endl;
}
void foo(int i) {
    std::cout << "call foo(int)" << std::endl;
}

将输出:

NULL == 0
call foo(int)
call foo(char*)

当我们加上注释中的 foo(NULL) 将编译无法通过:

foo.cpp: In function ‘int main()’:
foo.cpp:10:13: error: call of overloaded ‘foo(NULL)’ is ambiguous
     foo(NULL); // 编译无法通过
             ^
foo.cpp:2:6: note: candidate: void foo(char*)
 void foo(char *);
      ^
foo.cpp:3:6: note: candidate: void foo(int)
 void foo(int);

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

(2)constexpr

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

一个非常显著的例子就是在数组的定义阶段:

#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 的函数可以使用递归:

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

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

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++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯。

(1)auto

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

使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。在以前我们需要这样来书写一个迭代器:

for(vector<int>::const_iterator itr = vec.cbegin(); itr != vec.cend(); ++itr)

而有了 auto 之后可以:

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

一些其他的常见用法:

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

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

int add(auto x, auto y);

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

#include <iostream>

int main() {
 auto i = 5;

 int arr[10] = {0};
 auto auto_arr = arr;
 auto auto_arr2[10] = arr;

 return 0;
}

(2)decltype

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

它的用法和 sizeof 很相似:

decltype(表达式)

在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值。

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

auto x = 1;
auto y = 2;
decltype(x+y) z;   // z 是一个 int 型的

(3)尾返回类型、auto 与 decltype 配合

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

template<typename R, typename T, typename U>
R add(T x, U y) {
    return x+y
}

typename 和 class 在模板中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的

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

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

decltype(x+y) add(T x, U y);

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

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

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

template<typename T, typename U>
auto add(T x, U y) {
    return x+y;
}

四、区间迭代

区间迭代是指基于范围的 for 循环。

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

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

最常用的 std::vector 遍历将从原来的样子:

std::vector<int> arr(5, 100);
for(std::vector<int>::iterator i = arr.begin(); i != arr.end(); ++i) {
    std::cout << *i << std::endl;
}

变得非常的简单:

// & 启用了引用, 如果没有则对 arr 中的元素只能读取不能修改
for(auto &i : arr) {
    std::cout << i << std::endl;
}

未完待续


昨日的月光悄然退场,曦阳已经渐亮