modern cpp learning(一)

cpp,古老又传统的语言,但它依然是许多语言希望替代战胜的.这里介绍一些新特性.

由于cpp只有语言标准,不同编译器具体实现不同,这里使用的是clang.

预备知识:C++98 主要包括指针(对于c/c++很重要的东西),结构体,类(尤其是不同的构造函数),lambda函数,STL,引用,命名空间以及模板编程.

C++ 教程 | 菜鸟教程 (runoob.com)

Learn C++ – Skill up with our free tutorials (learncpp.com)

cppreference.com

刘利刚《计算机图形学》2020 (ustc.edu.cn)

前言

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

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

  • 不再允许字符串字面值常量赋值给一个 char \*。如果需要用字符串字面值常量赋值和初始化一个 char \*,应该使用 const char \* 或者 auto

    1
    char *str = "hello world!"; // 将出现弃用警告
  • C++98 异常说明、 unexpected_handlerset_unexpected() 等相关特性被弃用,应该使用 noexcept

  • auto_ptr 被弃用,应使用 unique_ptr

  • register 关键字被弃用,可以使用但不再具备任何实际含义。

  • bool 类型的 ++ 操作被弃用。

  • 如果一个类有析构函数,为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用了。

  • C 语言风格的类型转换被弃用(即在变量前使用 (convert_type)),应该使用 static_castreinterpret_castconst_cast 来进行类型转换。

  • 特别地,在最新的 C++17 标准中弃用了一些可以使用的 C 标准库,例如 <ccomplex><cstdalign><cstdbool><ctgmath>

C++ 不是 C 的一个超集

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// foo.h
#ifdef __cplusplus
extern "C" {
#endif

int add(int x, int y);

#ifdef __cplusplus
}
#endif

// foo.c
int add(int x, int y) {
return x+y;
}

// 1.1.cpp
#include "foo.h"
#include <iostream>
#include <functional>

int main() {
[out = std::ref(std::cout << "Result from C code: " << add(1, 2))](){
out.get() << ".\n";
}();
return 0;
}

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

1
gcc -c foo.c

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

1
clang++ 1.1.cpp foo.o -std=c++2a -o 1.1

也可以使用 Makefile 来编译上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
C = gcc
CXX = clang++

SOURCE_C = foo.c
OBJECTS_C = foo.o

SOURCE_CXX = 1.1.cpp

TARGET = 1.1
LDFLAGS_COMMON = -std=c++2a

all:
$(C) -c $(SOURCE_C)
$(CXX) $(SOURCE_CXX) $(OBJECTS_C) $(LDFLAGS_COMMON) -o $(TARGET)
clean:
rm -rf *.o $(TARGET)

C++98标准: 会生成默认构造函数, 析构函数, 复制构造函数, 复制赋值运算符

C++11标准: 除了C++98标准中生成的函数外, 还会生成移动构造函数和移动赋值运算符

以类A为例, 上述函数表示如下:

1
2
3
4
5
6
7
8
9
class A {
public:
A() = default; // 构造函数
~A() = default; // 析构函数
A(const A&) = default; // 复制构造函数
A& operator=(const A&) = default; // 复制赋值运算符
A(A&&) = default; // 移动构造函数
A& operator=(A&&) = default; // 移动赋值运算符
};

C++11标准中上述函数的生成规律:

  • 只要指定了一个要求传参的构造函数, 就会阻止编译器生成默认构造函数

  • 两种复制操作是彼此独立的, 即显式声明了其中一个, 不会阻止编译器默认生成另一个

  • 两种移动操作并不彼此独立, 即显式声明了其中一个, 就会阻止编译器默认生成另一个

  • 一旦显式声明了复制操作, 就会阻止编译器默认生成移动操作

  • 一旦显式声明了移动操作, 就会阻止编译器默认生成复制操作

  • 一旦显式申明了析构函数, 就会阻止编译器默认生成移动操作

如果编译器默认生成的上述函数能满足你的需求, 但由于各个规则被抑制生成的话, 可以通过= default来显式表达这个想法, 如上述A类所示

大三律 (Rule of Three)
如果你声明了复制构造函数, 复制赋值运算符, 或析构函数的任何一个, 你就得同时声明所有这三个。这个思想源于: 如果有改写复制操作的需求, 往往意味着该类需要执行某种资源管理, 而这就意味着:

  • 在一种复制操作中进行的任何资源管理, 也极有可能在另一种复制操作中也需要进行

  • 该类的析构函数也会参与到该资源的管理中(通常是释放)

可用性的增强

常量

NULL与nullptr

NULL在不同编译器中实现不同,通常是0或者((void)0).但是:C++ 不允许直接将 `void 隐式转换到其他类型,从而((void*)0)不是NULL` 的合法实现。

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

直接使用nullptr.

constexpr

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

1
2
3
4
5
char arr_1[10];
char arr_2[LEN];

constexpr int len2_constexpr = 1 + 2 + 3;
char arr_4[len2_constexpr]

变量初始化

在if/switch中初始化变量

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

初始化列表

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

1
char* ch = {'a','b','c'}

对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行

这些不同方法都针对各自对象,不能通用

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

1
2
3
4
5
6
7
8
9
10
11
12
class MagicFoo {
public:
std::vector<int> vec;
MagicFoo() = default;
MagicFoo(std::initializer_list<int> list) {
for (auto it = list.begin(); it != list.end(); ++it) {
vec.push_back(*it);
}
}
};
MagicFoo magicFoo = {1, 2, 3, 4, 5};

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

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

考虑可以替代麻烦的va_list?

C++11还提供了统一的语法来初始化任意的对象

1
MagicFoo magicFoo  {1, 2, 3, 4, 5};

结构化绑定

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

1
2
3
4
5
std::tuple<int, double, std::string> f() {
return std::make_tuple(1, 2.3, "456");
}
auto [x,y,z] = f();
std::cout << x << " " << y << " " << z << std::endl;

其实就是方便了返回值的获取.

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

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

类型推导

auto

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

从 C++ 14 起,auto 能用于 lambda 表达式中的函数传参

1
2
3
4
5
6
7
8
9
10
11
12
auto add14 = [](auto x, auto y) -> int {
return x+y;
}

int add20(auto x, auto y) {
return x+y;
}

auto i = 5; // type int
auto j = 6; // type int
std::cout << add14(i, j) << std::endl;
std::cout << add20(i, j) << std::endl;

decltype

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

1
2
3
auto x = 1;
auto y = 2;
decltype(x+y) z; // 利用其它变量类型声明变量
1
2
3
4
5
6
if (std::is_same<decltype(x), int>::value)
std::cout << "type x == int" << std::endl;
if (std::is_same<decltype(x), float>::value)
std::cout << "type x == float" << std::endl;
if (std::is_same<decltype(x), decltype(z)>::value)
std::cout << "type z == type x" << std::endl;

判断类型是否相同

尾返回类型推导

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename R, typename T, typename U>
R add(T x, U y) {
return x + y;
}

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

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

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

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

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

从 C++14 开始是可以直接让普通函数具备返回值推导,因此第三种写法也正确.

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

嵌套依赖类型的例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename T>
class Base {
public:
typedef int value_type;
value_type x;
};

template <typename T>
class Derived : public Base<T> {
public:
// 在这里,Derived类需要告诉编译器 value_type 是一个类型,而不是变量
typedef typename Base<T>::value_type value_type;

void foo() {
// 这里可以直接使用 value_type,因为已经在上面声明了
value_type y = 42;
}
};

int main() {
Derived<double> d;
d.foo();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
class Base {
public:
typedef int value_type;
value_type x;
};

template <typename T>
class Derived : public Base<T> {
public:
void foo() {
// 在成员函数中使用基类的成员类型,需要使用 typename 关键字
typename Base<T>::value_type y = 42;
}
};

decltype(auto)

C++14 开始提供的一个略微复杂的用法

简单来说,decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();
}
而有了 decltype(auto),我们可以让编译器完成这一件烦人的参数转发:

decltype(auto) look_up_a_string_1() {
return lookup1();
}
decltype(auto) look_up_a_string_2() {
return lookup2();
}

控制流

if constexpr

constexpr将表达式或函数编译为常量结果.如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#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;
}

区间for迭代

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

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

int main() {
std::vector<int> vec = {1, 2, 3, 4};
if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
for (auto element : vec)
std::cout << element << std::endl; // read only
for (auto &element : vec) {
element += 1; // writeable
}
for (auto element : vec)
std::cout << element << std::endl; // read only
}

模板

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

外部模板

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

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

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

也就是在编译多个单元式使得相同实例化模板只编译一次

尖括号”>”

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

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

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

1
2
3
4
5
6
7
template<bool T>
class MagicType {
bool magic = T;
};

// in main function:
std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码

类型别名模板

模板是用来产生类型的。在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型.

使用using替代typedef

1
2
3
4
5
6
7
8
template <typename T, typename U>
class MagicType {
public:
T dark;
U magic;
};
template <typename T>
using NewProcess = MagicType<std::vector<T>, std::string>;

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

变长参数模板

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

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

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

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

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

对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法,一种使用递归(c++17之后可以使用变参模板展开)另一种使用初始化列表展开.

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;
}

在 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...);
}

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

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

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

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

折叠表达式

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
10
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 作为模板参数

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

1
2
3
4
5
6
7
8
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
18
#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;
}

显示虚函数重载

在c++中通过virtual声明可重载的方法,称为虚函数,但只要不是纯虚函数,子类就可以不用去重载实现.

但是如果实现了一个同名方法,通过override看有没有对应的基类方法.

通过final让类或者方法不能被继承和重载.

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 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

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

enum class替代enum

1
2
enum class Color : uint8_t { RED = 1, GREEN = 2, BLUE = 3 };

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

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

1
2
3
4
5
6
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
2
3
4
5
6
7
8
9
10
11
template <typename Key, typename Value, typename F>
void update(std::map<Key, Value>& m, F foo) {
// TODO:
for (auto&& [key, value] : m) {
value = foo(key);
}
}
template <typename... T>
float average(T... t) {
return (t + ...) / sizeof...(t);
}

重点

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

运行期的强化

Lambda表达式

Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性, 而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多, 所以匿名函数几乎是现代编程语言的标配。

Lambda 表达式的基本语法如下:

1
2
3
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}

所谓捕获列表,其实可以理解为参数的一种类型,Lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的, 这时候捕获列表可以起到传递外部数据的作用

值捕获

1
2
3
4
5
6
7
8
9
10
11
void lambda_value_capture() {
int value = 1;
auto copy_value = [value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// 这时, stored_value == 1, 而 value == 100.
// 因为 copy_value 在创建时就保存了一份 value 的拷贝
}

引用捕获

1
2
3
4
5
6
7
8
9
10
11
void lambda_reference_capture() {
int value = 1;
auto copy_value = [&value] {
return value;
};
value = 100;
auto stored_value = copy_value();
std::cout << "stored_value = " << stored_value << std::endl;
// 这时, stored_value == 100, value == 100.
// 因为 copy_value 保存的是引用
}

隐式捕获

手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 &= 向编译器声明采用引用捕获或者值捕获.

总结一下,捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:

  • [] 空捕获列表
  • [name1, name2, …] 捕获一系列变量
  • [&] 引用捕获, 从函数体内的使用确定引用捕获列表
  • [=] 值捕获, 从函数体内的使用确定值捕获列表
1
2
3
4
5
6
[]      // 沒有定义任何变量。使用未定义变量会引发错误。
[x, &y] // x以传值方式传入(默认),y以引用方式传入。
[&] // 任何被使用到的外部变量都隐式地以引用方式加以引用。
[=] // 任何被使用到的外部变量都隐式地以传值方式加以引用。
[&, x] // x显式地以传值方式加以引用。其余变量以引用方式加以引用。
[=, &z] // z显式地以引用方式加以引用。其余变量以传值方式加以引用。

表达式捕获

上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值

它表示一个即将被销毁的临时对象或表达式的结果。与之相对应的是左值(lvalue),它表示一个可以取地址的对象。下面让我们详细解释一下右值的概念:

  1. 临时对象:
    • 右值通常指的是临时对象,这些对象是表达式的结果,在表达式结束后就会被销毁。
    • 例如: std::string("hello") 是一个临时的 std::string 对象,它是一个右值。
  2. 表达式结果:
    • 任何表达式的结果都是一个右值,除非该表达式的结果是一个可以取地址的对象(即左值)。
    • 例如: 1 + 2 的结果是一个右值,因为它只是一个临时的数值。
  3. 无名对象:
    • 在 C++ 中,无名对象也是右值。无名对象是没有变量名称的临时对象。
    • 例如: MyClass() 创建的是一个无名的 MyClass 对象,它是一个右值。
  4. 移动语义:
    • 在 C++11 中引入了移动语义的概念,它利用了右值的特性来优化性能。
    • 当一个对象作为右值传递时,我们可以”移动”它的资源,而不是复制它,从而避免不必要的内存分配和复制操作。

C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获, 被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <memory> // std::make_unique
#include <utility> // std::move

void lambda_expression_capture() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
std::cout << add(3,4) << std::endl;
}

在上面的代码中,important 是一个独占指针,是不能够被 “=” 值捕获到,这时候我们可以将其转移为右值,在表达式中初始化

泛型lambda

auto 关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。 但是 Lambda 表达式并不是普通函数,所以在没有明确指明参数表类型的情况下,Lambda 表达式并不能够模板化。 幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始,Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型

1
2
3
4
5
6
auto add = [](auto x, auto y) {
return x+y;
};

add(1, 2);
add(1.1, 2.2);

函数对象包装器

std::function

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

using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
f(1); // 通过函数指针调用函数
}

int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
f(1); // lambda 表达式调用
return 0;
}

种是将 Lambda 作为函数类型传递进行调用, 而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型, 统一称之为可调用类型。而这种类型,便是通过 std::function 引入的。

C++11 std::function 是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如

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

int foo(int para) {
return para;
}

int main() {
// std::function 包装了一个返回值为 int, 参数为 int 的函数
std::function<int(int)> func = foo;

int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}

std::bindstd::placeholder

std::bind 则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。 例如:

1
2
3
4
5
6
7
8
9
10
int foo(int a, int b, int c) {
;
}
int main() {
// 将参数1,2绑定到函数 foo 上,
// 但使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1);
}

右值引用

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vectorstd::string 之类的额外开销, 也才使得函数对象容器 std::function 成为了可能

左值引用 左值 右值

左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象

C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、 原始字面量Lambda 表达式都属于纯右值

字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char 数组

将亡值可能稍有些难以理解,我们来看这样的代码:

1
2
3
4
5
6
std::vector<int> foo() {
std::vector<int> temp = {1, 2, 3, 4};
return temp;
}

std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v, 然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到, 而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

要拿到一个将亡值,就需要用到右值引用:T &&,其中 T 是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:

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

void reference(std::string& str) {
std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
std::cout << "右值" << std::endl;
}

int main()
{
std::string lv1 = "string,"; // lv1 是一个左值
// std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
std::cout << rv1 << std::endl; // string,

const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
// lv2 += "Test"; // 非法, 常量引用无法被修改
std::cout << lv2 << std::endl; // string,string,

std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
std::cout << rv2 << std::endl; // string,string,string,Test

reference(rv2); // 输出左值

return 0;
}

历史遗留问题:

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

int main() {
// int &a = std::move(1); // 不合法,非常量左引用无法引用右值
const int &b = std::move(1); // 合法, 常量左引用允许引用右值

std::cout << a << b << std::endl;
}

第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:

1
2
3
4
5
6
7
void increase(int & v) {
v++;
}
void foo() {
double s = 1;
increase(s);
}

由于 int& 不能引用 double 类型的参数,因此必须产生一个临时值来保存 s 的值, 从而当 increase() 修改这个临时值时,调用完成后 s 本身并没有被修改。

第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要

总结一下,非常量左值引用能够引用左值以及右值.左值引用作为函数的参数,能够减少拷贝, 左值引用可以引用函数返回值,也可以减少拷贝构造

右值是不能取出地址的,但是当右值取别名后,这个右值会被存到特定的位置,且可以取到该值的地址,也就是说右值引用值是一个左值。

右值引用只能引用右值. std::move能将左值/右值都转为右值引用

拿到右值引用后就能使用移动构造和赋值进行节省内存了.

1
2
3
4
5
6
7
8
9
10
11
12
13
Vector& func1();
Vector&& func2();
Vector func3();

int main(){
Vector a;
a; //左值表达式
func1(); //左值表达式,返还值是临时的,返还类型是左值引用,因此被认为不可移动。
func2(); //将亡值表达式,返还值是临时的,返还类型是右值引用,因此指代的对象即使非临时也会被认为可移动。
func3(); //纯右值表达式,返还值为临时值。
std::move(a); //将亡值表达式,std::move本质也是个函数,同上。
Vector(); //纯右值表达式
}

右值引用&&类似一种标记,主要用于移动构造和赋值节省拷贝操作,否则拷贝构造和赋值会将函数返回值先拷贝到临时值,临时值再拷贝到到函数接收值上造成浪费(编译器不优化的情况下).

std::move就是一种方便操作将左右值都转为右值引用.

std::forward进行完美转发,也就是将左值得到左值,右值依然是右值.

tips:

  • 右值引用类型只是用于匹配右值,而并非表示一个右值。因此,尽量不要声明右值引用类型的变量,而只在函数形参使用它以匹配右值
  • 实参传递给形参,即形参会根据实参来构造。其结果是调用了移动构造函数;函数结束时则释放形参。

拷贝构造与移动构造

拷贝构造函数,又称复制构造函数,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。其形参必须是引用,但并不限制为const,一般普遍的会加上const限制.

此函数经常用在函数调用时用户定义类型的值传递及返回。拷贝构造函数要调用基类的拷贝构造函数和成员函数。

1
2
3
4
5
CExample(const CExample & c)
{
a=c.a;
printf("copy constructor is called\n");
}

移动构造(Move Constructor)是一种特殊的构造函数,它通过接收一个右值引用参数来创建新对象,并从传入的对象中“移动”资源而不是执行深拷贝

移动构造的使用场景:

  • 在函数中返回临时对象时,可以通过移动构造函数避免不必要的拷贝操作。

  • 在容器中插入临时对象时,可以通过移动构造函数实现高效插入和删除操作。

  • 在进行资源管理时,通过移动构造函数可以从一个对象转移资源所有权,提高性能

1
2
3
4
5
6
7
8
class MyClass {
public:
// 移动构造函数
MyClass(MyClass&& other) {
// 资源的转移或交换操作
// ...
}
};

实现的 拷贝构造 的参数是const 类型,所以既可以进行左值引用也可以进行右值引用 当存在移动构造时,传入右值优先调用移动构造,否则构造此时的拷贝构造。

拷贝赋值与移动赋值

赋值操作符则是处理一个已经存在的对象。对一个对象赋值,当它一次出现时,它将调用复制构造函数,以后每次出现,都调用赋值操作符

1
string& operator=(const string &s);

移动赋值(Move Assignment)是一种在编程语言中用于将一个对象的资源(如内存空间)转移到另一个对象的操作

1
2
3
4
5
6
7
8
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) <---> 移动赋值(资源移动)" << endl;
swap(s);

return *this;
}

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) {
std::cout << "构造" << pointer << std::endl;
}
A(A& a):pointer(new int(*a.pointer)) {
std::cout << "拷贝" << pointer << std::endl;
} // 无意义的对象拷贝
A(A&& a):pointer(a.pointer) {
a.pointer = nullptr;
std::cout << "移动" << pointer << std::endl;
}
~A(){
std::cout << "析构" << pointer << std::endl;
delete pointer;
}
};
// 防止编译器优化
A return_rvalue(bool test) {
A a,b;
if(test) return a; // 等价于 static_cast<A&&>(a);
else return b; // 等价于 static_cast<A&&>(b);
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}

完美转发

当我们使用了万能引用时,即使可以同时匹配左值、右值,但需要转发参数给其他函数时,会丢失引用性质(形参是个左值,从而无法判断到底匹配的是个左值还是右值).一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题

完美转发函数 std:forward 。它可以在模板函数内给另一个函数传递参数时,将参数类型保持原本状态传入(如果形参推导出是右值引用则作为右值传入,如果是左值引用则作为左值传入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&)
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出是左值

std::cout << "传递左值:" << std::endl;
int l = 1;
pass(l); // l 是左值, 输出左值

return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。 而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型实参参数类型推导后函数形参类型
T&左引用T&
T&右引用T&
T&&左引用T&
T&&右引用T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v 作为左值的成功传递。

谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << " 普通传参: ";
reference(v);
std::cout << " std::move 传参: ";
reference(std::move(v));
std::cout << " std::forward 传参: ";
reference(std::forward<T>(v));
std::cout << "static_cast<T&&> 传参: ";
reference(static_cast<T&&>(v));
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1);

std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v);

return 0;
}

输出结果为:

1
2
3
4
5
6
7
8
9
10
传递右值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 右值引用
static_cast<T&&> 传参: 右值引用
传递左值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 左值引用
static_cast<T&&> 传参: 左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发; 由于类似的原因,std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forwardstd::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值, std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward<T>(v)static_cast<T&&>(v) 是完全一样的。

1. 我们应该首先把编写右值引用类型相关的任务重点放在对象的构造、赋值函数上。从源头上出发,在编写其它代码时会自然而然享受到了移动构造、移动赋值的优化效果。

2. 形参:从优化的角度上看,若参数有支持移动构造(或移动赋值)的类型,应提供左值引用和右值引用的重载版本。移动开销很低时,只提供一个非引用类型的版本也是可以接受的。

3. 返还值:不要且没必要编写返还右值引用类型的函数,除非有特殊用途。

可能遇到的报错

  1. 非常量引用的初始值必须为左值
1
2
3
4
5
6
7
8
9
10
int x = 10;
int& ref = x; // x 是一个左值表达式

int y = 20;
int z = y + 5; // y + 5 是一个右值表达式

int x = 10;
int& ref1 = x; // 正确,x 是一个左值
int y = 20;
int& ref2 = y + 5; // 错误,y + 5 是一个右值表达式

这个规则的目的是为了确保引用变量在初始化后能够继续指向一个可修改的对象。如果允许引用绑定到一个临时的右值表达式,那么当这个临时对象被销毁后,引用就会悬空(dangle),这会导致程序出现错误

参考资料

  1. 第 1 章 迈向现代 C++ 现代 C++ 教程: 高速上手 C++ 11/14/17/20 - Modern C++ Tutorial: C++ 11/14/17/20 On the Fly (changkun.de)
  2. 透彻理解C++11 移动语义:右值、右值引用、std::move、std::forward - KillerAery - 博客园 (cnblogs.com)
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道