[C++进阶]C++有趣细节与现代C++

目录

0x00 前言

这篇文章适合那些已经掌握C++基础的读者,尤其是希望深入理解C++的细节,或在学习过程中遇到疑惑的开发者。文章内容偏向进阶,涵盖的知识点有时可能过于深入,或在实际应用中不常用,因此可以根据个人兴趣选择阅读。

C++有许多细节藏匿在各种基础教材、进阶书籍、技术博客以及一些资深程序员的经验中,甚至一些用法可能是C++标准委员会在设计时未曾预见的。本篇文章旨在将这些有价值的内容汇总,提供一个C++细节的集大成之作,会不断更新。

尽管标题为“基础篇”,但这里的“基础”相对于C++模板元编程、深入了解C++对象模型、C++编译原理与C++源码等内容而言。本文章将对C++基础进行适当的深入探讨。如果你使用的是较旧的C++教材,部分内容可能会显得陌生,学习起来也会有一定难度。

由于C++的细节繁多且深奥,本文在某些章节只提供简要说明,相当于给出了一个引子。对于那些想进一步研究的知识点,建议读者自行查阅更多资料。

文章中会同时列出旧用法与新用法。尽管现代C++不推荐使用一些老旧方法,但了解它们对于阅读老代码和避免潜在的bug非常重要。为了便于区分,C++11及之后版本的特性将特别标注。

最后,为了简化理解,部分表述可能不会足够严谨或专业,敬请理解。

0x01 从头开始

在初学C++时,由于学到的东西还少,所以写的一些东西暂时视作固定写法。这里回过头来看看初学内容的细节。

后缀名c、cc、cpp、cxx等有什么区别

大家在阅读不同教材或者某些开源项目时,可能会看到各种各样的扩展名,你可能会好奇这些不同的扩展名都有什么区别。

事实上,代码文件使用哪种扩展名取决于所使用的编译器,有些编译器可能要求C++文件使用.cxx的扩展名,而有些则要求使用.cpp的扩展名。

不过,现代编译器一般都允许使用任意扩展名,只要在编译时正确指定文件名和文件类型即可。

但是即便这样,仍应该避免随意使用后缀名。

一般情况下,

  • .c主要用于C语言源代码文件
  • .cpp是最常见的C++源代码文件
  • .cc也比较常见的C++源代码文件
  • .cxx是早期C++源代码文件后缀名之一
  • .cp也是C++源代码文件后缀名之一,比较少见

一般我们不会使用.c这个后缀名来写C++源代码。

对于头文件,也有如.h.hpp等不同的后缀名。

  • .h可用于C或C++的头文件
  • .hpp一般只做C++的头文件

你当然可以选择使用不同后缀名来区分不同的文件,
例如,有一个C++项目,大部分源代码文件后缀名都是.cpp;但某些代码文件可以直接移植到C下使用,于是你把这些代码文件的后缀名设置为.c;有些源代码文件是从某个古老的开源项目中复制过来的,它们原本使用的是.cxx后缀名,由于使用了比较古老的C++特性且这些C++特性已被标记为”不建议使用“,可能会在未来的C++标准中被移除,于是你保留.cxx的后缀名以说明它们可能在未来无法通过编译。

不过,这样的做法可能导致一些潜在问题,许多构建系统、编译器或工具链会根据文件后缀名来识别编程语言并自动应用相应的编译规则。如果在同一个项目中混用多种后缀名,可能会导致构建系统或工具无法正确识别某些文件,从而增加配置的复杂度。不同后缀名的使用可能会使代码不易理解和维护,尤其是当没有足够注释或文档时。

虽然使用不同后缀名在某些情况下是可以的,但如果没有特殊需求,最佳做法是保持项目中源代码文件后缀的一致性,以提高代码的可维护性和构建系统的兼容性。

命名空间

标准命名空间

在初学C++时,我们在文件的顶部加上这些代码:

#include <iostream>
using namespace std;

int main() {
    cout << "Hello World" << endl;
}

这样写会导入std中的所有内容,但如果我们只用std里面的一部分内容,又担心命名冲突,此时我们可以去掉using namespace std;,改用std::xxx的形式来使用std中的内容:

#include <iostream>

int main() {
    std::cout << "Hello World" << std::endl;
}

命名空间的存在是为了避免不经意间的命名冲突,如果感觉这样写很麻烦,而且只需用到std的一部分内容(比如cout和endl),可以这样写:

#include <iostream>
using std::cout;
using std::endl;

int main() {
    cout << "Hello World" << endl;
}

到了C++17,两个using可改为using std::cout, std::endl;

此外,我们应避免在头文件中使用using,因为当这个头文件被其他文件引入时,也会引入这个using,而引入者并不知道引入了一个命名空间,从而导致可能的命名冲突。

对于一个大型项目的某个大的代码文件,应当避免导入一个或多个命名空间的所有内容,因为这样可能会引起命名冲突,导致一些令人头疼的bug。

为了代码简洁,本文章的示例代码可能会省略include和using。对于部分代码片段,会省略主函数,因此如果需要执行这些代码片段需要将其放在主函数中才能执行。

你可能不知道的命名空间

using并不是只能在全局范围内使用:

int main() {
    using namespace my_namespace;
    if (my_namespace::b) {
        using std::cout;
        cout << my_namespace::s;
    }
}

于是当你只需在一个函数中使用大量的cout但又不想写std::cout又担心全局引入容易导致代码的其他地方出现命名冲突时,这是一个不错的选择。

命名空间可以嵌套定义:

namespace first {
    namespace second {
        int a = 1;
        void func1() {
            std::cout << "1" << std::endl;
        }
        void func2();
        void func3();
    }
    void second::func2() {  // 在first命名空间中定义second命名空间中的函数
        std::cout << "2" << std::endl;
    }
}
void first::second::func3() {  // 定义first命名空间中second命名空间中的函数
    std::cout << "3" << std::endl;
}
int main() {
    int b = first::second::a;
    first::second::func1();
    first::second::func2();
    first::second::func3();
}

命名空间可以起别名

(这是C++11后的特性)

int main() {
    namespace haha = std;
    haha::cout << "Hello" << haha::endl;
}

这个特性有利于给名字比较长的命名空间起一个缩短的名称,以让代码更加简洁:

namespace mnp = my_namespace;
mnp::xxx();

内部链接和外部链接

如果没有为一个命名空间指定名称,命名空间中的符号是外部链接的:

namespace {
    void func();
}

int main() {
    func();  // 合法
    return 0;
}

不过,这个匿名命名空间中的名字只能被当前文件作用域(当前.cpp文件)使用,其他文件访问不了这个命名空间。

也就是说,它等效于在当前文件的全局作用域中将变量或函数声明为static:

static void func();  // 该函数只能在当前文件中被访问

命名空间也可能出现名字冲突

如果多个文件中将代码放在相同名称的命名空间中,对于命名空间本身来说并不会像变量名、类名或函数签名等那样发生冲突,而是会将它们视为同一个命名空间,即进行命名空间的合并。
如果多个代码中都采用了相同名称的命名空间,但彼此之间不清楚,则也是可能发生命名冲突的。
例如,开发小组A在命名空间内添加了一个函数func,开发小组B也在命名空间内添加了一个函数func,且最终将它们编译在一起,则会惊奇地发现编译器报错了。

用于模板元编程中的类型特征或标签

namespace可以用于标记类型或指定一些“类型特征”,常用于类型推导、SFINAE(Substitution Failure Is Not An Error)技术以及模板编程中。

namespace is_integral {
    template<typename T>
    struct type {
        static const bool value = false;
    };
    
    template<>
    struct type<int> {
        static const bool value = true;
    };
}

int main() {
    std::cout << is_integral::type<int>::value << std::endl;   // 输出 1
    std::cout << is_integral::type<double>::value << std::endl;  // 输出 0
    return 0;
}

如果你学习过type_traits,你会发现这种写法非常眼熟。不过,type_traits不是用这种方式实现的,而是使用类来实现,感兴趣的可以阅读它的源码。

你可能不知道的作用域

我们都知道,一个局部变量只在当前块作用域内有效,在当前块作用域内嵌套的其他块中也有效。允许在嵌套的内作用域中重新定义外作用域已有的名字:

int a = 1;
int main() {
    std::cout << a << std::endl;  // 输出1
    int a = 2;  // main函数内作用域定义的a,只在该函数中起作用,隐藏了全局变量a
    if (a > 0) {
        int a = 3;  // if内作用域定义的a,隐藏掉了外层a,只在该if块中起作用
        std::cout << a << std::endl;  // 输出3,即第6行定义的a
    }
    std::cout << a << std::endl;  // 输出2,即第4行定义的a
    //要想用全局的a,可以在前面加上::
    std::cout << ::a << std::endl;  // 输出1,即第1行定义的a
}

内层a会在内层中暂时隐藏掉外层a,但对内层a的修改并不会改变外层a的值,当内层代码执行结束并继续执行外层的代码时,外层a仍是外层a本身,不受内层影响。

当然,不建议这样给变量起名,因为这样容易造成混乱,导致代码维护困难。

此外,与 Java 等语言不同,当内层作用域有变量与外层的函数重名时,这个变量会隐藏掉外层的函数,比如:

int read();

int main() {
    int read;
    read = read();  // 报错: read是一个变量而不是一个函数
    
    return 0;
}

当然,根据上面的经验,只要这样就可以访问与变量重名的函数:

int read();

int main() {
    int read;
    read = ::read();  // 合法
}

不过还是建议尽量避免变量与函数重名。

using的其他作用

C++ 11后,using可以用来代替typedef给某个类型起别名:

using int32 = int;
int32 a = 1;

对于函数式编程,定义一个可以指向某一类函数的函数指针:

double (*f)(double, double);

这段代码指的是,变量f是指向某一类返回类型为double,有两个类型为double的形参的函数指针。

为了能做到符合直觉的func f;,func为函数,f为变量名,使用typedef时:

typedef double (*func)(double, double);
func f;

使用using时:

using func = double(*)(double, double);
func f;

相比于使用typedef时函数名放在了奇怪的位置,使用using时符合关键字 变量名 = 值的格式,更符合直觉。当然直觉因人而异,你可以选择合适的方式。

这一部分也有很多坑,比如漏括号、漏星号等,都会有不同的效果,都会让你在debug时看着谜语人般的报错时叫苦连天。感兴趣的可以阅读我的另一篇文章:C++函数声明的一些有趣细节

再例如,使用typedef给数组起别名时,可以:

typedef int[5] int5_array;
typedef int int_array5[5];

这两种写法都是等效的,具体采用哪种取决于个人或团队的编码风格和习惯。
而使用using时,统一使用关键字 变量名 = 值的格式:

using int_array = int[5];

using和typedef都不会创建新的数据类型,他们仅创建现有类型的同义词(synonyms),或者说给现有类型“起别名”。

using和typedef的一个比较大的区别是,typedef不能给模板类起别名,而using可以。

template <class T>
using VectorT = std::vector<T>;

于是,使用using可以为模板元编程提供更灵活的别名设计。

为什么需要给类型起别名

给类型起别名仅仅是给已有类型(或模板)起别名,为什么要这样做呢?直接使用原始类型不好吗?

举个例子,如果需要使用类似于枚举或位组表示一个或多个状态时,例如使用int存储这些状态:

const int a = 0b0001;
const int b = 0b0010;
const int c = 0b0100;
const int d = 0b1000;

void func(const int state);

当调用者使用func时,如果没有文档说明,可能无法识别函数func中这个int类型的state是做什么用的。

但是,如果采用类型别名,它就非常清楚了:

typedef int state_bit_pattern;

const state_bit_pattern a = 0b0001;
const state_bit_pattern b = 0b0010;
const state_bit_pattern c = 0b0100;
const state_bit_pattern d = 0b1000;

void func(const state_bit_pattern state);

此外,你可能需要在不同操作系统或不同编译条件下对某个类型做出修改,例如:

#ifdef precise_float
typedef double my_float;
#else
typedef float my_float;
#endif

main函数

程序并非一定是从main函数开始执行

我们常说main函数是程序的入口,是执行程序时第一个被执行的函数,实际上并不是。

例如,在main函数外构造一个对象,那么这个对象的构造函数会比main函数先被执行:

void func() {
    std::cout << "函数func被执行" << std::endl;    
}

class Widget {
public:
    Widget() {
        std::cout << "Widget构造函数被执行" << std::endl;
        func();
    }
};

Widget widget;

int main() {
    std::cout << "main函数被执行" << std::endl;
    return 0;
}

输出:

Widget构造函数被执行
函数func被执行
main函数被执行

main函数处理命令行选项

我们通常写的main函数都是没有形参的:

int main() {
    return 0;
}

我们有时候希望我们的命令行程序可以在命令行执行接受一定的参数,比如,假设我们的程序叫add,我们希望可以:

$ add.exe 1 2 -o result.txt

此时,我们就可以这样修改main函数,使之可以接受命令行参数:

int main(int argc, char **argv) {
    // do something ...
}

或者

int main(int argc, char *argv[]) {
    // do something ...
}

可见,argv是一个二维数组。由于可以用字符数组表示一个字符串,所以argv可以表示一个字符串数组。

argc表示的是字符串数组的大小,即字符串的数量。
上面的add.exe也会被算在里面,因此在这个例子中argc的值是5,argv的内容如下:

  • argv[0] = "add.exe"
  • argv[1] = "1"
  • argv[2] = "2"
  • argv[3] = "-o"
  • argv[4] = "result.txt"

main函数返回值

我们有时可能会看到这样的main函数:

void main() {
    // do something ...
}

实际上,这种main函数的写法是错误的,无论是C还是C++都不支持这种写法。虽然在某些编译器中它能够通过编译,但并非所有编译器都支持这种写法。

而我们将main函数返回值定为int并返回0,通常表示程序成功执行并正常退出。但main函数不一定需要return 0;,如果你没写上这个返回语句,大部分编译器都不会有报错:

int main() {
    // do something ...
    // 没有返回0,合法
}

这是因为编译器会在程序末尾隐式地自动加上返回0。不过还是建议手动写上。

当程序出现错误时,会返回一些非0的值,而返回不同的值可以用来识别出现的是什么错误,即返回值可以起到错误码的作用。不过错误码是不能自已随意定义的,感兴趣的可以上网搜索。

标准IO的缓冲区

cout 与 endl

在写下cout << "Hello World" << endl;时,这个endl有换行的作用。但\n也能表示换行,难道endl = '\n'?实际上不然。endl并不是一个字符,也不是字符串,我们称他为操作符(manipulator),如果查看它的源码会发现它是一个函数模板,作用是换行并刷新缓冲区,即把缓冲区的内容写入到输出设备中并清空缓冲区,相当于std::cout << '\n' << std::flush;

那么,缓冲区是什么呢?为什么要先将输出的内容写入到缓冲区,然后再把内容输出到输出设备,而不直接一步到位呢?

这是因为,CPU与I/O设备的速度是不同的,大部分情况下I/O设备的速度很慢,某些I/O设备的一秒相当于CPU的一星期甚至更久。假设需要向输出设备写入一万次数据,如果每输出一次都需要向输出设备做一次写操作,那么就会导致程序很多时间用在等待I/O设备上,浪费大量的CPU时间。为了解决这一问题,可以先划定一定的内存空间,先把数据写入到这个空间中,等到这片空间满了、设备空闲了或者程序手动写入,才把这个内存中的数据全部写入到输出设备上,这样不仅可以加快程序运行的速度,还能减少写入的次数,延长输出设备的寿命。而这片内存区域,就被称为缓冲区。

因此,使用cout来调试程序时(特别是在定位程序崩溃的位置时),应保证及时刷新流,防止输出调试信息前程序就已经崩溃了,导致无法定位崩溃的位置。在写入文件时,不应一个一个字符地写入,使用缓冲区可以提高写入速度。

对缓冲区感兴趣并想深入了解的,可以阅读相关书籍,如计算机组成原理或操作系统I/O部分章节。

标准错误流

除了cout这个标准输出流以外,还有cerrclog这两个标准错误流,用来输出错误或警告信息。

cerrclog的区别是,cerr不经过缓冲区,会直接写入到输出设备上。这是为了在紧急情况下(比如内存已满,没空间用来做缓冲区了,或者程序崩溃时),提供一个输出信息的可能性。

输出流的重定向

coutcerrclog都可以被重定向,比如将cout重定向到文件:

#include <iostream>
#include <fstream>

int main() {
    std::ofstream out("test.txt");
    std::cout.rdbuf(out.rdbuf());
    std::cout << "test" << std::endl;
}

当然,你也可以直接写入文件而不重定向:

std::ofstream out("test.txt");
out << "test" << std::endl;

提示:如果使用IDE中的“运行”按钮,你可能找不到test.txt这个文件在哪里,其实它会生成在当前工作目录下,不一定是当前代码的目录,一般是可执行文件(如.exe文件)所在目录,可以在项目的可执行文件构建目标目录中找到它。

重定向是有作用范围限制的,比如:

if (true) {
    std::ofstream out("test.txt");
    std::cout.rdbuf(out.rdbuf());
    std::cout << "1" << std::endl;
}
std::cout << "2" << std::endl;

运行以上代码,你会发现test.txt中只有“1”没有“2”。
这是因为,out的生命周期仅限于if中,当if语句块运行结束时,out便会运行其析构函数将其设置为无效,接着cout会转回默认的输出位置(到控制台)。

如果需要让“2”也能输出到文件里,需要把out写在if的外面:

std::ofstream out("test.txt");
if (true) {
    std::cout.rdbuf(out.rdbuf());
    std::cout << "1" << std::endl;
}
std::cout << "2" << std::endl;

那么你可能会想,如果out是动态分配的,那么重定向后是否会一直有效,直到另一次重定向或out被手动结束生命周期?

std::ofstream* ptr;
if (true) {
    auto *out = new std::ofstream("test.txt");
    ptr = out;
    old = std::cout.rdbuf(out->rdbuf());
    std::cout << "1" << std::endl;
}
std::cout << "2" << std::endl;

delete ptr;

std::cout << "3" << std::endl;

运行这段代码,可见“1”和“2”都被写入到了文件中,但“3”并没有输出到控制台中,而是报错了。
这是因为释放out后,它的文件流对象已被销毁,而标准输出流尝试使用这个已被销毁的文件流,导致了未定义行为的发生。
所以,如果是重定向到动态分配的out,在out释放后应恢复或重定向标准输出流。

重定向之后如何恢复呢:

std::ofstream out("out.txt");
std::streambuf* old = std::cout.rdbuf(out.rdbuf());  // 重定向时返回旧的缓冲区
std::cout << "test1" << std::endl;
std::cout.rdbuf(old);  // 恢复缓冲区
std::cout << "test2" << std::endl;

运行以上代码,可以发现out.txt中有test1,屏幕上输出test2

如果既要输出到屏幕,又要写入文件中呢?比如想要在控制台中输出信息以便监控,同时生成对应的日志文件。这其实算是两次输出了。虽然你可以自定义一个cout来实现它,但不建议再折腾cout了。你可以自己写一个日志框架,或者使用第三方日志框架,比如log4cpp

cout中也许有用的内容

我们可以修改输出整数的形式:以十六进制(hex)、十进制(dec)、八进制(oct)形式输出。

比如十六进制:

std::cout << std::hex;

在此之后输出的整数就是十六进制形式了。同理,把上面的hex改为dec、oct就可以改为十进制、八进制形式。

对于整数,可以使用std::showbase来显示进制的前缀,使用std::noshowbase不显示(默认),如:

std::cout << std::showbase << std::hex << 15;
std::cout << ", "<< std::oct << 8;

输出:0xf, 010

使用std::uppercase,可以在输出16进制时a-f变为A-F,输出16进制前缀时0x0X,输出科学记数法时eE。默认情况为std::nouppercase

对于浮点数,需要控制输出的精度(包括整数位),可以使用std::setprecision(n),需要引入头文件iomanip,如:

#include <iostream>
#include <iomanip>
int main() {
    double pi = 3.1415926
    std::cout << std::fixed << std::setprecision(3) << pi << std::endl;
}

输出:3.14

这个包括整数位是什么意思呢?比如setprecision(4),那么1.23456 -> 1.23412.3456 -> 12.34
也可以使用std::cout.precision(n)来设置精度。精度默认为6。

对于浮点数,如果刚好是整数,又想输出后面的.0时,可以使用std::showpoint

double d = 4.0;
std::cout << d << ", " << std::showpoint << d << std::endl

输出:4, 4.0

对于浮点数,可以使用std::fixed设置小数部分自动补0,直到符合精度:

std::cout << std::setprecision(5) << std::fixed << 3.14 << std::endl;

输出: 3.1400

想要取消掉std::fixed等设定,可以使用std::cout.unsetf(s);

对于浮点数,使用std::scientific设为科学计数法输出。

对于布尔值,默认情况下输出truefalse时会显示为1和0。为了能够输出true或者false,你可以使用std::boolalpha

std::cout << std::boolalpha << true;

如果需要输出指定长度的内容,不足时补齐,可以引入头文件iomanip并使用std::setfill(char)std::setw(int),其中setw是暂时性的,只会对下一次输出起作用:

std::cout << std::setfill('-') << std::setw(5) << 123 << "\n" << 456;

输出:

–123
456

这个特性可以用来制表,用于在输出时对齐数据。

标准输入流

处理用户输入错误数据

我们常用标准输入流cin来让用户输入数字,例如:

int a;
std::cin >> a;
std::cout << a << std::endl;

如果用户输入的不是数字呢?
如果输入的是abc,得到的是0
如果输入的是123abc,得到的是123
我们可以这样判断输入的是否是数字:

int a;
if (std::cin >> a) {
    std::cout << "输入了" << a << std::endl;
} else {
    std::cout << "输入的不是数字" << std::endl;
}

不过,如果输入的是123abc,那么输出会是输入了123

如果程序中有多个cin,当输入的数据错误时(比如上面的应输入整数却输入了字符串,或是输入的整数超过int最大限制等),会导致后面的cin都失效:

int a, b;
std::cout << "请输入a:";
std::cin >> a;
std::cout << "请输入b:";
std::cin >> b;
std::cout << a << "\n" << b << std::endl;

运行以上代码,并输入abc并回车,会发现并没有让我们第二次输入,且输出结果是:

请输入a:abc
请输入b:
0
0

这是因为错误的输入让cin处于错误状态,必须消除它的错误状态才行:

if (std::cin.fail()) {
    std::cin.clear();
    std::cin.sync();
}

std::cin重载了运算符!,即!std::cin返回std::cin.fail()
于是你便可以在用户输入错误时不断让用户输入新数据:

int a;
while (!(std::cin >> a)) {
    std::cout << "输入的不是数字,请重新输入" << std::endl;
    std::cin.clear();
    std::cin.sync();
}
std::cout << "输入的是" << a << std::endl;

单字符读取和 getline()

char c1, c2;
std::cin >> c1;
std::cin >> c2;
std::cout << c1 << "\n" << c2 << std::endl;

如果用户输入的不止一个字符,比如abcd,会是怎样呢?
答案是用户只需输入一次,输出为:

a
b

cin中有一个储存字节的流,类似于缓冲区,每当给字符赋值时,会从中拿出最前面的一个字符,直到它被清空。在它被清空之前,不会再让用户输入。

我们同样可以使用std::cin.get()来从中读取字符,即上述代码可改为:

char c1, c2;
c1 = std::cin.get();
c2 = std::cin.get();
std::cout << c1 << "\n" << c2 << std::endl;

利用这个特性,我们可以写一个求和程序:

int sum = 0, input;
while (std::cin >> input)
    sum += input;
std::cout << sum << std::endl;

输入:1 3 5 7 8回车,再输入2 4 6 9回车,然后按键盘上的Ctrl+D(Unix)或Ctrl+Z(Windows),以输入一个文件结束符(end-of-file)。
输出:45

std::cin >> input返回std::cin本身,当std::cin作为条件时,是检测流的状态,若流正常,则是true,若流遇到错误,或者接收到了一个文件结束符(end-of-file),则为false。

std::cin.getline(char*, int, char='\n')可以获取一行文本(字符串):

char str[128];
std::cin.getline(str, sizeof(str));
std::cout << str << std::endl;

该函数的三个参数分别是:需要赋值的字符串对应的字符指针,字符串长度,结束标识符(默认为换行\n)。
同样,在cin中的数据被清空之前,不会再让用户输入:
比如将小写字母z作为结束符:

char str1[128], str2[128];
std::cin.getline(str, sizeof(str1), 'x');
std::cin.getline(str, sizeof(str2), 'y');
std::cout << str1 << "\n" << str2 << std::endl;

输入:abcxdefyghi
输出:

abc
efh

clear() 和 sync()

std::cin.clear()第一眼看起来像是清空cin的数据流,但实际上并不是,它的作用是去除cin中的错误。

int a;
char str[128];
std::cin >> a;
std::cin.clear();
std::cin >> str;
std::cout << str << std::endl;

输入:123a456
输出:a456

如果去掉上上述代码中的std::cin.clear(),再输入123a456,将不会有任何输出。

std::cin.sync()的作用才是清空数据流。clear()sync()一起使用,就可以去除cin的错误并清空数据流,前面已经提到过了,这里不再复述。

失败的组件?

相比于 Java、Python 等其他一些高级编程语言,C++的iostream被很多人认为是一个失败的组件。

对于一些简单的输入输出操作(比如本文章大量用到的),那么iostream确实已经够用了。

但是在实际工程项目中,iostream既过于复杂,又过于简单。
复杂是指它的使用方法相对繁琐,且因为它是随着C++早期设计逐步发展而来的,所以在现代编程中可能显得有些过时,不符合现代编程习惯。
简单是指它提供的方法又太少,例如缺少多线程安全的特性、缺少处理字符集和本地化问题、缺少处理复杂的数据格式或自定义的数据类型等高级功能。

所以在实际工程项目中,使用其他库而不是标准库中的组件是很常见的事情,不仅仅是iostream这一个标准库组件。

为什么要先声明函数再定义?为什么要写一个头文件用来存放声明?

我们在定义函数时,一般要先在前面声明函数,然后再定义函数,如:

void func();  // 声明函数
int main() {
    func();  // 调用函数
}
void func() {  // 定义函数
    // do something
}

这是因为,函数的作用域是从它被定义时开始的,虽然你也可以把定义写到前面去,但是这样不太美观。为了能把主函数放到上面,因此先声明,然后是主函数,然后再定义前面声明的函数。
另外,如果函数间存在互相调用,比如:

void func1() {
    func2();
}
void func2() {
    func1();
}

那么无论哪个在前哪个在后,上述代码都会报错。因此先声明再定义,可以让你免去函数互相调用时调整函数位置的痛苦。

另外,当多个文件需要调用某个函数时,在每一个文件都定义一遍显然是不智慧的选择,为了能把函数的定义只写在一个文件里,你可以在调用它的地方声明这个函数就行了:

// 这里是b.cpp
// a.cpp里有一个func(),我想调用它:
void func();  // 声明这个函数
void fb() {
    func();
}

当这样做还是有点小问题,因为它并不能直接看出func是哪来的。为了能做到“导入一个模块”的效果,我们可以把func写到头文件中,然后在需要用到它的地方包含这个头文件:

// a.h
void func();  // 声明函数
// a.cpp
#include "a.h"
void func() {  // 定义函数
    // do something
}
// b.cpp
#include "a.h"  // “导入了a这个库”,相当于把a.h中对func的声明加进来
void fb() {
    func();  // 调用函数
}

而#include的作用,是将被include的文件中的所有内容复制到该文件中。

先声明再定义还有一个好处是,对于一个大型合作项目,你可以先声明一系列函数,然后用注释标注这些函数有什么用,接着你就可以以整体的思想去构思整个项目,而不必先一个个实现他们,或者将这个头文件交给合作者,让他们去编写函数的实现,而你就可以直接调用这些函数来实现具体的内容了,即便现在他们还没有定义。比如,你想编写一个把大象装进冰箱的程序,需要三步:打开冰箱门,把大象放进冰箱,关闭冰箱门。于是需要三个函数,你可以先声明这些函数,然后调用这三个函数,完成整体的内容而不必关心这三个操作是怎么实现的。然后再编写细节,即这三个函数的具体实现。

对于某些企业的项目或者一些商业项目,需要提供一些函数。但这些函数的具体实现可能涉及到机密,不能直接把这些函数的具体实现给到对方,此时你就可以只把这些函数的声明写在头文件中,然后把头文件给对方,这样对方就可以知道有哪些函数供他们使用,但无法看到函数的具体实现。但只给头文件无法运行测试,那么你可以给对方提供编译后的动态库文件,可以使用一些工具对其进行混淆以增加反编译难度,并以动态链接的方式链接与执行,在后面的章节会介绍动态链接的方法。

避免头文件被重复包含

看这个例子:

h1.h:

void func();

h2.h:

#include "h1.h"
void func2();

main.cpp:

#include "h1.h"
#include "h2.h"

main.cpp中需要用到h1和h2两个头文件,但编译时报错:void func()重复定义。

这是因为h2中已经包含了头文件h1了,所以在main.cpp中同时包含h1和h2势必造成h1被重复包含。

为了防止这一问题的发生,可以在头文件中加上:

#ifndef __H1_H__
#define __H1_H__
void func();
// 更多内容...
#endif

#pragma once
void func();
// 更多内容...

第一个方法是标准做法,可以兼容任何平台,而第二种做法不受一些老编译器支持,但对于大型项目,它不会像第一种方法那样因为要处理宏定义而降低编译速度。

此外,我们应避免在头文件中使用using,因为当这个头文件被其他文件引入时,也会引入这个using,而引入者并不知道引入了一个命名空间,从而导致可能的命名冲突。

编译和链接

编译和链接是大家一定会用到但很少重视的步骤,这是因为大部分的集成开发环境(IDE)已经把它们封装好了,帮我们隐藏掉了这些细节。如果离开IDE,要怎么对代码进行编译呢?

这里以Linux环境为例,测试环境为ubuntu 20.04,编译器为MinGW 9.3.0。

多文件的编译

在实际的开发中,我们会把不同代码放在不同的文件中。

// lib.cpp
int add(int a, int b) {
    return a + b;
}
// main.cpp
#include <iostream>
int add(int, int);
int main() {
    int b = add(1, 2);
    std::cout << b << std::endl;
    return 0;
}

编译这两个文件:
g++ -c lib.cpp
g++ -c main.cpp

此时会生成两个文件:lib.omain.o,他们被称为目标文件,是不能直接执行的。
比如main.o中,函数add只是一个声明,具体的实现是在lib.o中,所以在main.o中函数add的跳转地址暂时设为0,需要将这些文件链接起来后,才会修正函数add的地址。

链接这两个文件:
g++ main.o lib.o -o target

运行这段指令,会生成一个文件target,运行它:
./target

输出:

3

但这样做效率实在太低了,实际的项目可能会有非常多的文件,一个个手动编译会非常慢。

Makefile

Make是一个常用的构建工具,它除了能够完成程序的自动化构建外,还有其他的奇技淫巧。

makefile的格式是:

目标: 所需文件1 所需文件2 ...
	指令

目标: 所需文件1 所需文件2 ...
	指令

...

前面例子的makefile就是:

# 可以使用井号作为注释。
# 如果要在makefile中表示井号而不作为注释,可以使用转义:\#
all: target clean

target: main.o lib.o
	g++ main.o lib.o -o target

main.o: main.cpp
	g++ -c main.cpp

lib.o: lib.cpp
	g++ -c lib.cpp

clean:
	rm -f *.o

执行指令make target,意思就是构建目标target,此时终极目标就是这个target。
因为target所需文件是main.o和lib.o,这两个文件还未被构建,因此会去找这两个文件。
main.o所需文件是main.cpp,存在这个文件,于是执行指令g++ -c main.cpp,生成了main.o。lib.o同理。
此时目标target所需文件已经有了,于是执行指令g++ main.o lib.o -o main,得到目标文件target。

可以发现,make是根据一个“依赖树”递归地去构建文件,依赖不存在,那就先去构建依赖,依赖的依赖不存在,就先去构建依赖的依赖,直到完成终极目标。当然,如果最后的依赖不存在,比如上面的main.cpp不存在或lib.cpp不存在,则会报错并退出。

构建完后,再次执行指令make target,会得到提示“make: ‘target’ is up to date.”,意思是所有文件都已存在并且是是最新的了,不需要重复构建。如果我们修改了main.cpp再执行make target,则会只更新main.o和目标文件target,lib.cpp没有被修改所以无需重复构建,可见它也可以节省构建所需的时间。

上面构建的是目标target,但如果我们要构建文件中的目标“all”,即make all,则是这样:
目标all需要的是target和clean两个目标,目标target上面已经讲过了。目标target完成后是目标clean,于是执行指令rm -f *.o,即删除所有后缀名为.o的文件。当然,这个终极目标由实际情况而定,并不一定需要清理文件。

makefile第一个目标会被视作默认目标,因此上面的指令make all可改为make

目标clean后面没有依赖,因此你可以执行make clean来清理.o文件,即执行目标clean,对应的指令为rm -f *.o

可是,当文件多起来之后,每次添加或去除一个.o文件,都需要在目标文件后面的所需文件和指令中写两遍,麻烦且容易出错,此时我们就可以使用变量

# 文件很多时,可以用反斜杆换行
objects = main.o lib.o a.o b.o c.o \
		  d.o e.o

target: $(objects)
	g++ $(objects) -o target

...

实际上,gnu的make还可以自动推导,如果没有特殊需求,make可以推导出指令,而且,假设需要构建main.o,它也会把main.cpp自动加入依赖。假设main还需要abc.h和xyz.h,那么makefile就可以简化成这样:

target: main.o lib.o
	g++ main.o lib.o -o target

main.o: abc.h xyz.h

没错,main.o不需要加上main.cpp,而且lib.o的依赖干脆就不需要写了。
甚至你还能:

objects = a.o b.o c.o
libs = lib1.h lib2.h lib3.h
target: $(objects)
	g++ $(objects) -o target
$(objects): $(libs)

当然,奇技淫巧是要慎用的,上面的代码很偷懒,但容易出错误,而且也不容易看清依赖关系。

另外,每一个makefile都建议加一个clean用来清理.o文件和构建出的可执行文件,而且最好放在最后面,以便清理并重新构建。而且,clean建议这样写:

.PHONY: clean
clean:
	-rm target $(objects)

.PHONY表示clean是一个“伪目标”,因为clean没有依赖文件,而且也不需要生成一个文件clean。
rm $(objects)前还加了一个减号-表示忽略某些文件的错误。

使用变量,你还可以:

PROJECT_NAME = MyProject
VERSION = 1.0.0
TARGET = $(PROJECT_NAME)-$(VERSION)

OBJS = ...

$(TARGET): $(OBJS)
	g++ $(OBJS) -o $(TARGET)

...

makefile实际上像是一个脚本语言,它可以做很多复杂的事情。除了最基本的构建,你还能实现提交代码、备份代码、自动化部署程序等功能。
如果你还想知道makefile的详细内容,可以查阅:

CMake

CMake是一个开源的,跨平台的构建工具。大部分IDE都支持CMake。

首先需要创建一个CMakeLists.txt,对于一个最简单的项目,CMakeList只需三行:

cmake_minimum_required(VERSION 3.10)
project(ProjectName)
add_executable(ProjectName main.cpp)

第1行指定了所需的CMake最低版本,第2行指定项目的名称,第三行指定可执行文件的名称和它由那些文件编译而成。

在项目所在目录下创建新目录build,并把cmake生成的文件放在这里:

mkdir build
cd build
cmake ..

运行以上指令后,便会在build下生成相关文件和Makefile,接着只需输入make并回车,即可生成可执行文件target。

对于多文件工程,则需修改第3行代码,如:

add_executable(ProjectName main.cpp lib.cpp)

一个个添加太麻烦了,那么我们可以:

file(GLOB SRC_FILES
        "${PROJECT_SOURCE_DIR}/*.h"
        "${PROJECT_SOURCE_DIR}/*.cpp")

add_executable(${CMAKE_PROJECT_NAME} ${SRC_FILES})

${CMAKE_PROJECT_NAME}指的是project()中定义的项目名,我们通过file()将项目目录下所有以.h.cpp结尾的文件都添加到变量SRC_FILES中,并在add_executable()使用它。

我们也可以使用set()来设置cmake或自定义的变量:

set(CMAKE_CXX_STANDARD 11)
set(MY_VARIABLE 666)

第一行表示构建用的C++的版本为C++11,第二行是我们的自定义变量。

当我们使用了第三方库时,需要链接库:

target_link_libraries($(CMAKE_PROJECT_NAME) 库名)

你还可以使用CMake生成Doxygen文档、自动化测试、自动部署等,甚至能当成一个编程语言使用,知乎上甚至有人用它写了个光追

CMake建议边用边学,因为你很难不结合项目一起使用。这里列举几个学习资源:

静态链接与动态链接

静态链接

以前面多文件编译为例,有两个文件:

// main.cpp
int add(int, int);
int main() {
    int b = add(1, 2);
}
// lib.cpp
int add(int a, int b) {
    return a + b;
}

分别编译它们,生成main.o和lib.o两个文件,在main.o中,函数add的地址会暂时设定为0,在与lib.o链接的过程再把它重定位到add的正确地址。

强引用与弱引用

这里的强引用与弱引用与智能指针的有一定的区别,请注意区分。

上面的代码中,add是一个强引用。如果链接过程中add没有被定义,则链接会报错。

我们也可以创建一个弱引用,它与强引用类似,但如果它没有被定义,则链接不会报错。

__attribute__((weakref)) void func();

attribute并不是只能放在开头:

void __attribute__((weakref)) func1();
void func2() __attribute__((weakref));

在现代编译器中,使用weakref还需要指定别名:

__attribute__((weakref, alias("y"))) static void func();

如果函数未被定义,那么它的地址将会是0,我们可以这样判断函数是否被定义了:

if (func) {
    func();
}

这样一来,只有该函数被定义了,才会执行它。

变量也可以是弱引用:

__attribute__((weak)) extern int a;

弱引用可以给程序模块化提供方便。我们可以将扩展模块设定为弱引用,当扩展模块和主程序链接在一起时,就可以用到扩展模块的内容。如果没有扩展模块,就只使用主程序的部分功能。

不过要注意的是,__attribute__是给编译器看的,像#define那样。且并非所有编译器都支持weakref的特性。具体来说,__attribute__((weakref))是GCC提供的扩展,其他兼容GCC的编译器也能使用,但有些编译器,例如MSVC,并不支持此功能。

动态链接

很多程序都需要用到iostream等内容,如果每个程序都把它们的实现代码都打包到最终的可执行文件中,就会导致每个程序都有一段重复的内容,势必会造成空间的浪费。

同样,我们在使用或给其他用户提供库时(特别是很大的库),也不希望它们被重复打包。如果能把它分离开来,保存成一个单独的文件,让每一个要用到的程序都去共享这个文件,不就可以避免重复了吗?

动态链接库很好的解决了这个问题。除了节省硬盘空间,由于它可以被多个程序共享,所以只需读取一份到内存中,减少了内存的浪费。因为动态链接库是一个单独的文件。所以我们也可以很方便地安装和更新他们。假设你发行了一个1GB甚至更大的程序,当程序需要更新时,也可以只下载被修改的动态链接库文件,避免了重新下载整个程序。另外,你也可以利用动态链接库的特性让你的程序支持插件功能。

动态链接库一般在Windows下是.dll后缀,在Linux下是.so后缀。

Windows下,使用动态链接库时,会先在当前目录下寻找,然后再搜索Windows/System和Windows/System32目录。
而Linux是,先搜索/lib、/lib64等,然后再搜索/usr/lib等,最后再搜索ld.so.conf配置下的路径。默认情况下不会搜索当前路径。
为了解决这个问题,你可以把库拷贝到/lib下,或者修改ld.so.conf的配置,再或者临时指定:

$ export LD_LIBRARY_PATH="$(pwd)"

第一个”$”指命令提示符。该命令只在当前terminal的会话中有效。

创建一个简单的动态链接库并调用(Linux环境):

#include <iostream>
// main.cpp
int add(int, int);
int main() {
    std::cout << add(1, 2) << std::endl;
}
// add.cpp
int add(int a, int b) {
    return a + b;
}

编译和链接:

$ g++ -shared -fPIC add.cpp -o libadd.so  # 将add.cpp编译并打包为动态链接库
$ g++ main.cpp -ladd -L. -o target  # -l指定动态链接库名称,省略lib和.so,-L指定动态链接库位置,.指当前目录
$ export $LD_LIBRARY_PATH="$(pwd)"  # 临时设置,在当前目录下查找动态链接库
$ ./target  # 运行target
3

extern “C”

有时候你可能在某些C++代码中看到这样的片段:

extern "C" {
    int func();
    // anything else ...
}

这里的extern "C"是什么意思呢?

这里要先介绍一下C与C++编译时函数名称的解析规则。
对于C语言,函数名是函数的唯一标识符,标准C是不支持函数重载的,因此一个函数名就唯一地标识了一个函数。
但对于C++来说,为了支持函数重载,于是在编译时对函数名进行修饰。
例如这个函数:

void func(double a);

在C语言中,编译后这个函数的函数名仍然是func。
而在C++中,编译时它的函数名会发生变化,以GCC编译器为例,这个函数的函数名会被改为_Z4funcd。其中_Z是GCC用来表示修饰名称的前缀,4表示原函数名有4个字符,d表示一个形参是double类型。

extern "C"的作用就是告诉编译器采用C的链接规则来编译和链接。在编译动态链接库时,为了避免因函数名被修改而无法链接,可以为函数加上extern "C":

extern "C" void func(double a);

如果有多个函数都采用C的链接规则,则可以使用花括号将多个函数包含在内:

extern "C" {
    void func1(double a);
    int func2(int a, int b);
}

它可以让C与C++,乃至类C(与C语言有类似链接规约的,如Fortran等)的程序可以互相调用,从而实现跨语言协作。
例如,某些编译器还支持extern "FORTRAIN"等。

动态库对外接口尽量返回C类型

C 是几乎所有编程语言都可以调用的接口标准语言。很多现代编程语言(如 C++、Python、Java、Go、Rust 等)都有现成的机制,可以与 C 语言代码进行交互。C 函数的调用约定(例如参数传递、返回值处理等)通常是标准化的,因此,如果动态库的接口使用 C 类型,可以确保其他编程语言能顺利与该库进行交互。

然而,如果你返回一个std::string,就算用Java的String去接收,也会是失败的,因为它们并不是相同的东西。类比一下,在C++不同命名空间中的两个同名类,也是不同的类:

namespace CPPString {
    class String{};
}
namespace JavaString {
    class String{};
}
CPPString::String getString();
int main() {
    JavaString::String str = getString();  // 错误: CPPString::String无法转化为JavaString::String 
    
    return 0;
}

如果你的动态库只提供C++支持,使用一个std::string来接收std::string,不就没问题了吗?

实际上,在相同平台下使用相同编译器下并通过相同的编译参数,通常是没有问题的。

但是,如果编译动态库使用的编译器与编译使用了动态库的程序的编译器不同,也有可能报错。
因为,不同编译器对std::string乃至STL中的其他类的实现方法都可能是不一样的,导致它们的ABI不一致。
然而,即便是同一个编译器,但使用了不同编译器版本,编译出来的std::string也可能是不一样的。
甚至,即便是同一个编译器的相同版本,在使用了不同编译参数的情况下,编译出来的std::string也可能是不一样的,导致了二进制不兼容。

为此,相比于在动态库接口返回一个std::string,返回一个const char*显然是兼容性更好的选择,因为它完全可以在任何支持string的编程语言转换为相应的string

跨平台开发

C++可以写出跨平台的代码,但C++的编译器不一定是跨平台的。通常,当你写出一个跨平台的程序代码后,想得得到它在不同平台的可执行程序,需要在不同的平台安装对应平台的编译器,并在该平台下构建该项目。

例如,一个C++程序想要在Windows和Linux下执行,使用GNU的编译器,你需要在Windows下安装Windows平台的GNU编译器并在Windows下完成编译和链接;在某个Linux环境下安装对应的GNU编译器,再在这个环境下构建并链接。也就是说,你的代码是可以跨平台构建的,但在某个平台下构建出来的可执行程序不能直接复制到另一个不同的平台下直接执行。

这也是为什么对于某些跨平台项目但需要使用非跨平台的代码时,使用宏定义区分不同平台并在不同平台下构建时使用不同的代码,例如:

#ifdef _WIN32
#include <windows.h>
void open() { /* 只能在Windows下执行的代码 */ }
#endif

#ifdef __linux__
#include <unistd.h>
void open() { /* 只能在Linux下执行的代码 */ }
#endif

当然,对于跨平台开发,可以考虑使用专门的库如Boost或CMake的跨平台工具来帮助管理不同的平台代码。这些工具可以更智能地处理平台特定的代码和依赖。例如,CMake提供了条件编译指令来根据不同的平台设置不同的编译选项和包含不同的源文件。

考虑代码的底层逻辑与编译器行为

使用C或C++的一个特点是它们更接近硬件,因此在使用它们编写代码特别是底层代码时,需要考虑它们底层会发生什么。

例如以下代码:

int a = 1;
int b = a + 2;
int c = b;
int d = 4;
int e = d + 1;

这段代码中,ba有关系,ca有关系,ed有关系。由于a,b,c这一组变量与d,e这一组变量没有关系,于是在一些CPU可并行执行指令的平台下,编译器可能会进行指令级的优化,使这两组指令并行执行,于是本来需要5个指令周期的这段代码可以缩减到3个指令周期完成:

  1. 同时设置 a=1d=4
  2. 同时设置 b=a+2e=d+1
  3. 设置 c=b

有些时候,我们希望利用这样的优化,以提高我们代码的性能。

但是,这样的优化有时候可能并不是我们想要的,例如在异步或多线程情况下,这些变量可能被其他代码段共享,于是这种优化可能带来线程安全问题。

这也是为什么,有时候你能在一些底层代码发现一些莫名其妙的写法:

int a = 1;
int b = a + 2;
int c = b;
int d = 4;
int e = d + 1;
int unused[2] = {b, e};  // 这是在干什么?

上述代码通过将两组原本不相关的指令变为相关,试图通过增加复杂性的方式避免编译器的优化。不过上述代码并不一定能避免优化,因为编译器可能会因为最后一行代码没有被使用而为了优化去除它。

在实际情况下,这种技巧要对编译器特别熟悉才能避免出错,且越接近于底层越应该注意这些问题。但我们不是人肉编译器,且实际工程中特别是对于刚入行的程序员来说不会去处理过于底层的东西,因此我们在工程中也许应该使用笨办法,比如使用互斥锁、volatile关键字等方法解决该问题。

再例如,现代CPU大多支持分支预测功能,即在代码执行到流程控制语句(如条件判断)之前,提前预测会执行哪个分支,并提前准备好这个分支需要用到的数据(如提前将数据从内存存到Cache中)。这样一来,当程序执行到这个分支时,就不需要等待数据的准备了。但如果预测错误,那么提前加载好的数据势必就会浪费掉,这通常意味着CPU需要丢弃错误分支上的计算结果,并重新执行正确的分支。而且,这个特性还能用来恶意攻击,如幽灵熔断漏洞,感兴趣的可以自行上网搜索。

养成良好的编程习惯

我们为什么需要编码规范

对于项目而言,通常维护一个项目的成本远大于开发它的成本,特别是对于一个大型项目与团队项目而言。

编码规范确保所有团队成员在编写代码时遵循一致的风格,减少由于个人风格差异带来的混乱。这有助于其他开发人员(包括未来的开发人员)快速理解代码,从而减少理解上的障碍。而且,可读性高的代码更容易进行修改和扩展。规范化的代码可以帮助开发者快速找到问题并有效地修改和改进代码,减少了意外错误的发生。

良好的编程习惯不应该因为功能简单就可以暂时放飞自我,随着一个项目的不断发展和迭代,即便是一个最简单的模块也可能因不断增加的需求变得越来越复杂,从而难以维护和增加新功能。

良好的编程习惯不只是让代码变得好看和提高效率那么简单。许多编码规范不仅仅关心代码的格式,也关注代码的结构和逻辑。这些规范有助于开发者避免常见的编码错误,减少潜在安全漏洞,增强安全性。
例如,许多函数即便功能很简单,却用了更多的代码只是用来检查输入参数的合法性,而这些检查是有必要的,特别是当输入是由用户提供的时候。忽视输入合法性的检查可能导致许多错误甚至程序崩溃,即便是一个很小的错误也可能引起重大损失,例如: b站 2021.07.13 崩溃原因

常见编码规范

不同的项目可能有不同的规范要求,需要根据实际情况选用,如果只是个人项目,可以选择一个自己喜欢的,但不应该在同一个项目中混用不同的规范。

命名

  • 命名选用: 少用缩写,除非一些特定的广为人知的缩写,避免单字母命名、temp、拼音命名等
  • 文件命名: 文件名全部要小写,使用下划线_分隔单词,尽量让文件名更明确,比如http_server_logslogs更明确
  • 变量命名: (google等)全部小写,使用下划线_分隔单词,如query_result;(华为等)小驼峰命名法,如queryResult
  • 常量和枚举: 全部大写,使用下划线_分隔单词,如COLOR_RED
  • 函数命名: (google等)大驼峰命名法,如GetVersion,(华为等)小驼峰命名法,如getVersion
  • 类名: 使用驼峰命名法,每个单词首字母大写,比如class HttpServerManager
  • 宏命名: 全部大写,使用下划线_分隔单词,比如INT64_MAX

注释

在该注释的地方加上注释,应言简意赅,不应过分简单或过于复杂,也应避免无意义的评价或情绪表达,并保持风格的统一。

对于一些明显的逻辑,良好的变量命名和代码排版等就是最好的注释,以下是一个反例:

// 这段代码使用for循环,下标i从0开始,每执行一次循环体后i自增一次,若i小于5则继续执行
// 因为i在执行过程中其值会逐次达到0、1、2、3、4,并在到达5时退出循环,所以总共会执行5次循环
/* 这个双斜线注释有点麻烦,换一个多行注释
每次循环,数组a下标i对应的值是i的平方,例如当i为3时,a[3]就是3*3=9
于是a从下标0到下标4的内容就会是:
arr[0] = 0
arr[1] = 1
arr[2] = 4
arr[3] = 9
arr[4] = 16
只需1行代码就能做到这样的操作,真的很厉害呢 */
for( int i=0;i<5;i++){a[i]=i*i ;}

建议对每一个函数都加上注释以描述其功能、参数、返回值、可能抛出的错误等。例如:

/*
 * 该函数用于计算指定周长圆的半径
 * 参数perimeter: 圆的周长
 * 返回值: 圆的半径
 * 可能抛出的错误: 当输入的perimeter为负数、无穷或非数时会抛出错误
 */
double calRadius(double perimeter) {
    if (perimeter < 0) {
        throw "周长不能为负数";
    }
    if (std::isinf(perimeter)) {
        throw "周长不能为无穷";
    }
    if (std::isnan(perimeter)){
        throw "周长不能为非数";
    }
    return perimeter / 3.14 / 2.0;
}

不要保留被注释掉的代码。
我们有时可能会使注释掉某些代码,以方便测试,或者只是想表明这段代码暂时褯废弃了。
但注释掉代码只是表示这段代码临时不用了,而不应该表示”未来可能用到“。对于废弃的代码,应及时删除,如果未来可能会用到,可以使用版本控制工具来找回它们。

此外,我们有时候会在注释中使用TODO来表示某些功能还未实现或者bug仍未修复,以便以后补全。但它不应该长久地存在,如果有bug,就应该及时修,如果有需求未实现,则应该及时实现。对于未来(很久的以后)要实现的,应该写在需求文档或项目管理中,而不应该存在于注释的TODO中。

适当地留空和缩进

比较这两段代码的可读性:

void calFactorial(int x)
{ int res=1;
for(int i=1;i<=x;i++){
res*=i;
}
return res;    

}

void calFactorial(int x) { 
    int res = 1;
    for (int i = 1; i <= x; ++i) {
        res *= i;
    }
    return res;
}

相比前者,后者对不同层级的代码采用了不同长度的缩进,并在适当的位置增加了空格,避免无意义的空行,花括号的使用方式统一,使代码变得更加容易阅读,不会显得零乱和过于紧凑,便于阅读和日后维护。

不过,有些地方是不需要增加缩进层级的,即便是某个花括号内的,例如命名空间、extern "C"等:

namespace MyNamespace {

class Widget;
void func();
int var;
    
}

减少不必要的嵌套

例如前面的calRadius函数,由于需要检查输入数据的合法性,所以需要多个if进行判断,有些人可能会这样写:

double calRadius(double perimeter) {
    if (perimeter >= 0) {
        if (!std::isinf(perimeter)) {
            if (!std::isnan(perimeter)){
                return perimeter / 3.14 / 2.0;
            }
            throw "周长不能为非数";
        }
        throw "周长不能为无穷";
    }
    throw "周长不能为负数";
}

这样的写法嵌套层数过多,难以分析它的逻辑。

尝试改写以下代码:

void sendGift(Player *player, Gift *gift) {
    if (player->isOnline()) {
        if (gift->isValid()) {
            if (!player->getBackpack()->isFull()) {
                player->getBackpack()->add(gift);
            } else {
                fail(player, gift);                
            }
        } else {
            fail(player, gift);
        }
    } else {
        if (gift->isValid()) {
            if (!player->getEmail()->isFull()) {
                player->getEmail()->add(gift);
            } else {
                fail(player, gift);
            }
        } else {
            fail(player, gift);
        }
    }
}

参考改法:

void sendGift(Player *player, Gift *gift) {
    if (!gift->isValid()) {
        fail(player, gift);
        return;
    }
    
    // 玩家在线的情况
    if (player->isOnline()) {
        if (player->getBackpack()->isFull()) {
            fail(player, gift);
            return;
        }
        player->getBackpack()->add(gift);
        return;
    }
    
    // 玩家不在线的情况
    if (player->getEmail()->isFull()) {
        fail(player, gift);
        return;
    }
    player->getEmail()->add(gift);
}

以上只是一个参考改法,它尽量降低了嵌套的层数,但不一定是最好的改法,例如在线与不在线两种情况的判断逻辑可能不如加一层else方便阅读。

随着需求的增加和项目的迭代,一个函数可能会增加越来越多的内容和层级,例如某个函数可能从最早的只有几个if变成后来几百甚至几千个if。为了避免这种情况的发生,可以考虑使用状态机或其他设计模式来简化复杂逻辑。

宁可让代码更长,也不使用复杂的、混合的表达式

复杂的、混合的表达式虽然可以减少代码长度,但难以阅读和维护。

例如

while(*p2++ = *p1++);

可以改成

while(*p2) {
    *p2 = *p1;
    ++p1;
    ++p2;
}

再例如

int value = condition > 0 ? (condition > 100 ? (condition / 2) : (condition * 2)) : condition; 

可以改成

int value;
if (condition > 100) {
    value = condition / 2;
} else if (condition > 0) {
    value = condition * 2;
} else {
    value = condition;    
}

不过,对于一些常用的简化,还是可以学习一下的,比如:

std::cout << *iter++ << std::endl;

代替

std::cout << *iter << std::endl;
++iter;

少用魔法值

魔法值是指在代码中直接出现的某些字面值,比如

for (int i = 1; i <= 7; i++) {
    // do something
}

在这段代码中,不容易判断出数字1和7代表什么,应该使用变量代替。为了避免这样做影响性能,可以将该变量设置为inlineconstexpr等:

constexpr int MONDAY = 1; 
constexpr int SUNDAY = 1;
for (int day = MONDAY; day <= SUNDAY; ++day) {
    // do something
}

所有流程控制语句都加上花括号

有时候,当流程控制语句下只包含住了一行代码时,可以不写花括号,比如:

if (b)
    statement;

这种写法虽然简洁且省事,但很容易让我们犯错误,比如:

if (a)
    if (b)
        statement_1;
else
    statement_2;

上述代码虽然从缩进上看,else属于外层if,但事与愿违,它实际上属于内层if:

if (a)
    if (b)
        statement_1;
    else
        statement_2;

在C++中,else会属于离它最近的if,缩进不会对代码真实的层级有任何影响,特别对于python程序员来说,这是一个值得注意的区别。

为了避免这样的错误,有些规范要求所有的流程控制语句都加上花括号,以避免不经意间的错误,比如以下才是我们的真实意图:

if (a) {
    if (b) {
        statement_1;
    }
} else {
    statement_2;
}

对于个人而言,即便不写花括号方便,但至少应该在出现多个流程控制语句嵌套时加上花括号,以避免错误,例如:

if (a) {  // 外层都加上花括号
    if (b)  // 最内层只有一个流程控制语句且下面只有一行,可以不加
        statement_1;
} else {
    statement_2;
    if (b) {  // 最内层if块虽然只有一行statement_3,但外层还有其他代码(statement_4等),为防出错加上花括号
        statement_3
    }
    statement_4
}

等等其他

关于代码规范的内容很多,除了以上主要是代码风格上的规范,还有代码复用和模块化设计、代码测试和调试习惯、代码审查和团队协作等等,大家可以自行上网搜索学习。

关于是否采用 Clean Code 的讨论

良好的、美观的代码可以增加代码的可阅读性和可维护性,这是毋庸置疑的。

不过,有些人认为,代码只是工具而不是艺术,矫枉过正反而会起到相反的效果。

例如,有些规范为了代码的简洁性,为了降低代码的复杂度,要求一个函数最多不能超过多少行,一旦超过了就应利用拆分函数等方式降低复杂度。

这样的要求在一定程度上是合理的,但实际工程中往往非常复杂。如果强制所有函数都不能超过一定的行数,那么拆分后如何设计其结构、如何命名,也是一个令人头疼的问题,并不能保证程序员能够设计良好的结构和命名,反而增加了程序员的负担以及增大了因结构设计出错的风险。

对于一个抽象层足够多的项目更是如此,为了降低函数的行数,可能需要设计更多的抽象层,导致原本一致性强的代码分散在各个不同的地方,增大了理解代码的成本,且抽象层的增多还会增加模块的耦合性,导致修改其中一层会同时影响到更多的模块,这反而违背了 Clean Code 提高可维护性的初衷。

至于是否遵守,如何灵活地采用这些规则,应根据实际情况决定。

避免意义不大的优化

在学习C++的过程中,会学习到不少优化程序的技巧。但有些技巧对程序的优化程度并不大,且可能会有一些副作用。

例如,一个从互联网上获取资源的函数,通过一定的优化手段降低了几个CPU周期的消耗,但这个函数的执行需要数秒的网络访问,相比之下前面的优化不仅没有明显提高函数的执行速度,反而可能降低了代码的可读性。

更好的方法是,利用多线程的特性,让函数在等待网络的过程中转而让CPU去执行其他代码,这样对程序整体的性能提升会更加明显。

0x02 变量

基本数据类型

变量的声明类型

我们可以用一个类型声明多个变量,比如:

int a, b;

这里,基本数据类型是int。

当我们需要指针时:

int* pa, b;

这里的pa就是int型指针。但你可能会把b也错看成一个int指针,但实际上b只是一个int。pa和b的基本数据类型都是int,*只修饰pa。这也是为什么声明指针型变量时常常把星号和变量名连接在一起。同时声明两个指针的写法是:

int *pa, *pb;

尝试解释以下变量的含义:

int* pa, a, &ra, func1(), *func2(), (*func3)();

a是一个int型变量,pa是一个int型指针,ra是一个int型引用,func1是返回值类型为int的函数的声明,func2是返回值类型为int*的函数的声明,func3是一个函数指针指向一个返回值类型为int且无形参的函数。

int和long有什么区别?

对于如今大部分编译器(Windows环境下)来说,int和long这两种类型都是占用4字节,为什么会这样呢?

对于没有经历过16位时代的人来说,可能会好奇这个问题。
其实,在早期16位环境下,int的大小并非如今的4字节,而是2字节。那时候,4字节的long就已经很“长”了。
到了32位乃至64位时代,int也和long一样进化到4字节,此时就有了long long这种类型,为8字节。
但在64位的Linux环境下,long为8字节的情况却很常见。
其实,int和long是多少字节并不是取决于系统的位数或者是在什么系统下,而是取决于编译器。

目前,最稳定的类型是char(1字节)、short(2字节)、long long(8字节)。
int也是比较稳定的,可以放心使用,除非你的环境是16位的。如果不确定当前环境某个数据类型的大小,可用使用sizeof运算符来判断,如sizeof(int)

另外,long int和long是等效的,long long int和long long也是。

C++标准规定,int的大小大于等于short,long的大小大于等于int,long long大于等于long。

对于字面常量,你可以用字母标注:

1e-10F;  //单精度浮点数
9999999999LL;  //long long
9999999999ULL;  //unsigned long long
9999999999LLU;  //同上
3.14159265358979L;  //long double

既可以用小写字母,也可以用大写字母,但不建议使用小写字母“l”,它容易与数字“1”弄混。

请注意这里的long longLL,而不是L,java程序员请小心不要弄混。

对于浮点类型,大部分情况下double就已经够用了。long double也是一个不稳定的类型,可能是10字节,可能是12字节,也有可能是16字节,用它来计算开销也会比较大。float的精度太小,不建议使用,而且目前的计算机大部分都对双精度浮点数的计算做了优化,运算速度甚至比单精度还快。

long long是C++ 11标准新增的,long double是C 99标准新增的。

整型字面量分隔符

C++ 14引入了整型字面量分隔符,用于将一个比较大的字面整型值分隔开来以提高可阅读性。

使用一个单引号来作为分隔符,分隔符可以插入在数字中间,但不能在数字开头或结尾:

int bigNum = 1'000'000;

stdint

我们常用char表示一个字符,但它只能表示ASCII字符表内的字符,因为它只有1字节的大小。由于C++没有“byte”类型(即占用1字节的整型),我们常用char来代替它。由于C++是弱类型语言,因此以下代码是合法的:

char a = 1;

当然,为了让这个类型好看一些,你可以使用typedef:

typedef signed char byte;
byte a = 1;

或者使用stdint.h提供的int8_t来代表有符号的8比特位整数,uint8_t代表无符号的。

不过呢,不建议直接使用char来记录数值,因为char不一定是signed char,它也有可能是unsigned char,具体由编译器决定。数值建议使用int8_t,而char则用来表示字符。

另外,它也提供了int16_tuint16_tint32_tuint32_tint64_tuint64_t

慎用无符号类型

如果一个数是非负整数,使用unsigned可以让变量表示更大的数值。
对于大部分情况你完全可以用一个更大的数据类型来代替它,比如用long long代替int。
这是因为使用unsigned有可能会导致很多奇怪的bug。比如:

unsigned int i;
for (i = 3; i >= 0; --i) {
    std::cout << i << " ";
}

以上代码将会造成无限循环。

尽管我们不会故意给无符号赋负值,但我们依然可能在无意中写出这样的代码:

unsigned int a = 1;
int b = -2;
std::cout << b + a << std::endl;

输出:4294967295

b是一个有符号的类型,给他加1,不应该是-1吗?实际上,如果一个算术表达式中既有无符号类型又有有符号数时,会把有符号数转化为无符号数,无论他们谁在前谁在后。

另外,一些你没有意识到的隐形转换也会让你寻找bug时痛苦不已。

除非你是在表示一个位组(bit pattern),即使用整型来保存一系列二进制的0和1,而不是表示一个真实的数值,或是表示一个不会参与运算的非负整数号码,不然请慎用无符号类型。
如果非要使用无符号类型来表示数值并参与运算,一定要注意保护数据,例如使用断言来阻止负数的进入。

char、wchar_t、char16_t、char32_t

char只占1个字节,也就是8bits的空间,那么它能够表示的只有有符号的0-127或无符号的0-255。我们一般是使用有符号的char,也就是只能表示ASCII字符集中的内容。

对Java程序员的提示: 因为C++中的char只有1字节,所以不能直接用来表示任意字符或中文字符。

由于char只能表示8bits的内容,所以如果我们想表示中文或其他不在ASCII范围内符号时,char就无能为力了。
wchar_t用来表示宽字符,但一般Windows普通下它是16位(2字节),Linux下是32位(4字节),是不稳定的。
wchar_t来表示宽字符或字符串时,需要在前面加上L,如:

wchar_t c = L'啊';
wchar_t str[] = L"中文";

对于宽字符,cout无法直接输出,对此iostream提供了wcout来输出它。但大部分情况下直接输出它会发现什么都没有输出:

std::wcout << L"汉字" << std::endl;

(啥都没有的)输出:

为此,你需要设置一下locale,让它能够支持中文环境:

std::setlocale(LC_ALL, "zh-CN");
std::wcout << L"汉字" << std::endl;

输出:汉字
或者…(还是啥都没有的)输出:

为什么会这样呢?这是因为L"..."是宽字符,但未必是Unicode字符。在不同的编码字符集中,同一个数字代表的可能是不同的字符,比如专门用来支持中文的GBK编码,和为了支持所有语言字符而制定的Unicode编码等。这也是为什么在不同编码环境下中文可能会变成乱码,而英文却不会(因为几乎所有编码都向下兼容ASCII,英文的编码都是相同的)。即便是Unicode编码,虽然它也在不断迭代升级的,但是否能永远成为以后的标准还是未知数。

出于历史原因,C++输出非ASCII范围的字符并没有后来的一些编程语言那么轻松。对于如何正确输出他们,不在本文的讨论范围内,感兴趣的可以使用搜索引擎查询。

这里给的建议是,无论目标设备的编码是什么,都使用Unicode作为中间格式,字面常量、文件都使用Unicode编码,到输出设备中再做转换。

C++11开始有了char16_t和char32_t,分别用于表示16位(2字节)和32位(4字节)的字符,而且是固定位数的,不像wchar_t那样不稳定。但在标准输出中,缺少输出他们的方法。不过如果使用第三方库或者为图形化界面编写程序的话,大部分还是会提供相应的方法输出他们的。

C++11还引入了一种新的转义字符,用来表示Unicode字符:

const char *c = "\u597d";

以及说明字符串是以UTF-8格式编码(只能用于字符串字面常量):

const char *c = u8"好";

除此之外还有前缀u(Unicode 16位字符,对应char16_t)、U(Unicode 32位字符,对应char32_t)。

对于字符与字符串的处理,可见后续章节“字符串”。

联合体有什么用

想必许多人在学习联合体union时会有所好奇,它究竟有什么用呢?它确实使用频率比较低,也有许多替代方案,但对于某些特殊情况,特别是对存储空间敏感的情况还是有用的。

与结构体struct类似,一个联合体内可以包含多个成员,每个成员可以是不同的数据类型,但与结构体不同的是,联合体的所有成员共享同一块内存空间。也就是说,在任意时刻,联合体的大小等于其最大成员的大小。

在某些情况下,我们可能需要表示某个数据,但这个数据的类型不是固定的,在某一时刻它只可能是几个类型中的一种,此时就可以利用联合体的特性。

例如,在IP协议中,可以使用IP地址标识一个网络设备,而IP地址又有IPv4和IPv6两种,假设某个数据包头部只包含其中的一种,就可以使用联合体:

union PacketHeader {
    IPv4 ipv4;
    IPv6 ipv4;
};

结构中的位字段

在C语言中,可以为结构体或联合体中的整型指定占用的二进制位数,比如:

struct MyStruct {
    unsigned int a : 2;
    unsigned int b : 3;
    unsigned int c : 4;
};

其中,变量a占用2个比特位,可表示0~3范围的整数;变量b占用3个比特位,可表示0~7范围的整数;变量c占用4个比特位,可表示0~15范围的整数。
这三个变量仅占用9个比特位,这样一来即可使用更少的比特位存储多个数据。
不过,当为这些变量赋值时,不能超过其表示范围,否则会出现未定义行为。

请注意,对于位字段的存储顺序,不同编译器的实现可能是不一样的,这意味着它可能不可移植。

取地址运算符&不能用于位字段。

另一种表示和处理位组的方法,可见判断二进制数某一位或某些位的值

左值与右值

左值可看作内存中有明确地址的数据,它是可寻址的。
右值是可提供数据值的数据,不一定可寻址。
比如以下代码

int x = 1;

x是左值,它是可寻址的,生命周期较长,我们可以对它进行其他操作;1是右值,它是一个临时的常量,不可寻址,生命周期较短。

左值可以作为右值使用(注意是作为右值使用),如:

int a, b;
a = b = 1;

上面的b = 1中,1是右值,b是左值,而在a = b中,b又作为了右值,此时a为左值。

总的来说,

  • 左值:能够用&取地址的表达式是左值表达式,或者是具名的变量就是左值。如:++i,解引用的值*p
  • 右值:不能使用&取地址的表达式,在表达式的运行或计算过程中产生的临时量或临时对象(不具名变量对象)

纯右值与将亡值

所谓纯右值,就是C++ 98中的右值,临时变量值、不跟对象关联的字面量值。
由内置类型产生的右值就是纯右值。如:3falsenullptr、取地址后的值&a、以指针作为返回值。它们只可读而不可写。

而将亡值,就是自定义类型(结构体、类)产生的右值,可读可写。
对于一个将亡值,例如一个函数返回的自定义类型,是可以进行修改的:

class Widget {
public:
    Widget(int a);
};

Widget func() {
    return Widget{1};
}

int main() {
    func() = Widget{2};  // 居然可以将右值赋给一个函数返回值,只不过这行之后 func() 就消亡了

    return 0;
}

这样的将亡值又被称为泛左值。

左值和将亡值合称泛左值(glvalue),纯右值和将亡值合称右值(rvalue)。

左值引用和右值引用

注意区分 左值与左值引用、右值与右值引用。

左值引用(lvalue-reference)

左值引用是我们最熟悉的给变量起别名:

int a = 1;
int &b = a;
b = 2;
std::cout << a << std::endl;

输出:2

左值引用必须指向一个可寻址的值,下面的代码是非法的:

int &b = 1;  // 错误

这是因为1是右值,没有指向它的内存地址。
这便是常见报错:“非常量引用的初始值必须是左值”的原因。

如果需要将常量赋给左值引用,可以把左值声明为const,下面的代码是合法的:

const int &a = 1;

编译器会创建一个隐藏的变量储存这个右值的字面常量,然后再把这个隐藏的变量与引用绑定。

引用必须初始化,以下代码是错误的:

int &ra;  // 错误

不能定义引用的引用,也不能定义指向引用的指针(即不存在int &*pra;,但是存在int *& rpa;,即指向指针的引用)。

在许多C++入门教科书中,只提到了引用是给变量起别名,这听起来好像没有什么用对吧?为什么要引入一个这样的功能呢?接着往下看。

函数在使用引用形参时,传值不会复制一个新的对象,可以节省开销。

void func(int &b) {
    b++;
    std::cout << "func中a的地址: " << &b << std::endl;
}
int main() {
    int a = 1;
    std::cout << "main中a的地址: " << &a << std::endl;
    func(a);
    std::cout << "调用func后main中a的值: " << a << std::endl;
    return 0;
}

输出:

main中a的地址: 0x61fe1c
func中b的地址: 0x61fe1c
调用func后main中a的值: 2

可见,当函数形参声明为引用时,传入的变量a的地址是相同的,没有发生拷贝。因此,func中对变量b的修改,会影响到main中的变量a的值。

不过这样的func只能传入一个变量,不能传入一个常量,也不能传入字面常量:

void func(int &a);
int main() {
    const int a = 1;
    func(a);  // 非法
    func(2);  // 非法
    return 0;
}

这是因为func中的形参a没有声明为const,这意味着func有权利对引用a进行修改,自然不能传入一个常量。

为了既能够传入变量也能够传入常量,且告诉用户不会修改实参的值,那么形参的声明就可以这样写:

void func(const int &a);
int main() {
    // 传入常量
    const int a = 1;
    func(a);  // 合法
    func(2);  // 合法
    // 传入变量
    int b = 2;
    func(b);  // 合法
    return 0;
}

我们可以认为,int&const int&是两种不同的数据类型,因此使用这两种不同的数据类型可以对函数进行重构,从而使传入的是变量或常量的不同而去调用不同的函数:

void func(int &a);  // 第一个func
void func(const int &a);  // 第二个func
int main() {
    int a = 1;
    const int b = 2;
    func(a);  // 调用的是第一个func
    func(b);  // 调用的是第二个func
    func(2);  // 调用的是第二个func
    return 0;
}

对于Java程序员来说,这是 C++的const 与 Java的final 相比的一个很大的区别,C++的const有着更严格的约束,还请注意这点。

再来看这个例子:

void func(int *&a);
int main() {
    int a, *p = &a;
    func(p);  // 合法
    func(&a);  // 非法
}

第1行代码函数func的形参int *&a指的是int类型的指针的引用,要理解这个,首先int &a表示a的引用,只能传入变量不能传入常量。因为是引用,即变量的别名,假设函数func内改变了a的值,那么在main中调用func时,传入的实参a也会被改变,因为传入func的实参a的地址,和func处理的a的地址,是相同的。而int *a是指针,传入的是地址。而int *&a就是指针类型的引用,传入的指针实参可能被修改,同时不能传入常量只能传入变量。
第3行p是a的指针,且p是变量(左值),故可以把p传入func,且p可能被修改。而第5行的&a为右值,不能赋给左值引用,故无法传入func。

如果把第一行的int *&a改为int * const &a,则以下调用均合法:

void func(int * const &a);
int main() {
    int a, *p = &a;
    func(p);  // 合法
    func(&a);  // 合法
    int * const p2 = &a;
    func(p2);  // 合法
}

注:const放在星号*左边表示变量指向的数据不可修改,即指向的数据是常量;const放在星号*右边表示指针的指向不能修改,即指针本身是常量。所以上面要把const放*右边。

右值引用(rvalue-reference)

C++11引入了右值引用,用&&表示,表示一个临时变量即右值,而且可以修改这个临时变量的值:

int &&a = 1;
a = 2;

不能将左值赋给右值引用,以下代码是非法的:

int a = 1;
int &&b = a;  // 错误

如果需要将一个左值赋给右值引用,需要先将这个左值转为右值。STL提供了一个方法std::move()来实现:

int a = 1;
int &&b = std::move(a);

需要注意的是,当你将一个左值移动给另一个左值时,原先的左值便放弃了它的内容:

string a = "test";
string b = std::move(a);
std::cout << "a的内容: " << a << std::endl;

输出:

a的内容:

因此,std::move实质上是把变量a的值移动给了b,这个值只有一份。a和b有着不同的内存地址,如果不复制的话,a和b这两个不同的变量无法拥有相同的值。

常量引用

常量引用是指对const的引用。

我们可以用一个常量引用与常量或非常量绑定,但不将用一个非常量引用与一个常量绑定:

int a = 1;
const int cb = 2;

const int &ra = a;  // 正确
const int &rcb = cb;  // 正确
int &rb = cb;  // 错误

当用一个常量引用与普通变量绑定时,我们便可对这个变量作限制,阻止该变量被修改。

class Resource {
public:
    const int& get() {  // 返回一个常引用
        return value;
    }
private:
    int value = 1;
};

int main() {
    Resource resource;
    
    int a = resource.get();
    a = 2;  // 合法,但不会修改resource中value的值,因为a的值是value值的拷贝
    
    int& b = resource.get();  // 非法,只能用常引用去接收
    
    const int& c = resource.get();  // c就是value,value就是c
    c = 2;  // 非法,不能修改常量的值
}

引用作为函数返回值

对于一个值类型的函数返回值,它返回的是一个右值:

int func();
int main() {
    int && a = func();  // 合法,a只能赋右值
}

当引用作为函数的返回值时,你不能返回一个函数内的临时变量或者右值,因为的生命周期只在该函数的作用域内,出了函数就被销毁了:

int& func() {
    return 2;  // 错误,不能返回右值
}
int& func() {
    int i = 1;
    return i;  // 错误,i的生命周期只在该函数内,不能返回局部变量的引用
}

不过你有没有想过,既然临时变量的生命周期只在作用域内,那么当函数的返回值类型不是引用时,返回函数中的一个局部变量时,这个变量不会被清理掉吗?

int func() {  // 注意这里返回值类型不是引用
    int a = 1;  // a的生命周期只在func内
    return a;
}

int main() {
    int b = func();  // 变量a出了函数func还能活吗?
}

事实上,a确实会被清理掉,不过在返回时,会将该对象复制一份,b得到的,其实是另外一个对象:

int func() {
    int a = 1;
    std::cout << "address a: " << &a << std::endl;
    return a;
}

int main() {
    int b = func();
    std::cout << "address b: " << &b << std::endl;
}

运行结果:

address a: 0x61fc0c
address b: 0x61fccc

从上面的结果可以发现,a和b的地址并不相同,表明它们并不是同一个对象。

而使用引用作为函数返回值,可以避免复制对象产生的额外开销。

回到刚刚的问题,对于以引用为返回值的函数,既然不能返回函数内的临时变量作为返回值,那返回一个全局变量不就行了?
不使用引用作为返回值时:

string globalVar;
string func() {  // 注意这里返回值类型不是引用
    globalVar = "test";
    std::cout << &globalVar << std::endl;
    return globalVar;
}
int main() {
    string a = func();
    std::cout << &a << std::endl;
}

输出:

0x7ff6b8078040
0xaede1ff7ec

可见即便返回的是globalVar,变量a仍然是一个新的值,与globalVar有着不同的地址。如果返回的globalVar是个很大的字符串,那么复制这样一个字符串将会消耗大量的资源,除非你希望复制,不然不必要的复制是应该避免的。

使用引用作为返回值:

string globalVar;
string& func() {  // 返回值是引用
    globalVar = "test";
    return globalVar;
}
string main() {
    std::cout << "globalVar地址:" << &globalVar << std::endl;
    string a = func(); //将返回值赋给普通变量
    std::cout << "a地址:" << &a << std::endl;
    string &b = func(); //将返回值赋给引用
    std::cout << "b地址:" << &b << std::endl;
}

输出:

globalVar地址: 0x7ff77a438040
a地址: 0x1b22bff814
b地址: 0x7ff77a438040

我们可以看到,func返回的是引用,可以理解为返回的是左值。
main中的a是一个普通变量,声明一个普通的变量会在内存中开辟一个新的地址,然后才把func得到的值赋给它,相当于string a = globalVar;
main中的b是一个引用,func返回的是左值,是可寻址的,所以可以赋给b,而且它指向globalVar,b是globalVar的别名,相当于string& b = globalVar;

因为func返回的是左值,于是我们可以给他赋值:

int globalVar;
int& func() {  // 返回值是引用
    return globalVar;
}
int main() {
    func() = 2;  // 给返回的左值赋值
    std::cout << globalVar << std::endl;
}

输出:2

也可以返回引用函数的引用:

string& longer(string &str1, string &str2) {
    return str1.size() > str2.size() ? str1 : str2;
}

int main() {
    string str1 = "12345";
    string str2 = "123456";
    longer(str1, str2) = "longer string";
    std::cout << str1 << std::endl;
    std::cout << str2 << std::endl;

    return 0;
}

输出:

12345
longer string

可见,因为str1str2属于main函数,在main函数调用longer函数,投入的引用str1str2的生命周期不会因longer函数的结束而结束,因为它们并不是属于longer函数的拷贝。

另外,不应该去返回在函数内由new分配的变量的引用,这是不好的习惯,因为如果返回的变量被赋给了一个普通的临时变量而不是赋给另一个引用时,就会导致申请的内存无法被释放,造成内存泄露。
比如:

int& func();
int main() {
    int result = func() + 1;
}

这个func看着人畜无害的样子,但实际上它返回的是函数内使用new分配的值。用户在不看源码或文档的情况下,怎么知道它分配了内存呢?
此时因为将其赋给了非引用的result,因此func()返回的处于堆中的变量值被复制给了处于栈中result。而在堆中分配的内存就被忽略掉了,于是该片内存无法被释放,造成了内存泄露。

引用作为类成员函数返回值

上面说到不要在函数中返回局部变量的引用(指针也不能),但在类里呢?

比如这个例子,类Widget中有一个变量name,它是私有的,因此不能在类的外部修改它,但我们希望用户可以读取它:

class Widget {
public:
    std::string getName() {
        return name;    
    }

private:
    std::string name;
};

getName()返回的是name的一个拷贝,不过这样产生了复制,假如name是更复杂且巨大的数据类型,拷贝这样一个巨大的对象是可能会对性能有毁灭性的影响。

还记得引用的作用吗?为了不产生复制,我们可以返回一个引用。但是,既然是引用,会不会导致用户取得了name的所有权呢?这样将name设为私有不就没意义了吗?

其实,你可以返回一个常引用,这样,返回的引用就是只读的:

class Widget {
public:
    // 返回一个常引用
    const std::string & getName() {
        return name;    
    }

private:
    std::string name;
};

int main() {
    Widget w;
    const std::string & s = w.getName();
}

当然,因为返回的是常引用,所以变量s的前面也得加上const。

别忘了用一个引用接收返回的常引用,否则还是会产生复制。

实际上,忘记用引用接收是常见的,特别是当你把你写好的接口交给别人使用时。而且,不会有报错,甚至连警告都没有。

不过,换个思路,你也可以返回一个常指针:

class Widget {
public:
    // 返回一个常指针
    const std::string * getName() {
        return &name;    
    }

private:
    std::string name;
};

int main() {
    Widget w;
    const std::string * ps = w.getName();
    // 访问指针指向的实例
    int size = ps->size();
    // 为不产生拷贝,用常引用获取指针ps指向的实例
    const std::string & s = *ps;
    // 如果这样做仍然会产生拷贝
    std::string s2 = *ps; 
}

返回一个常指针,也可以保证变量不被修改,而且如果用户将返回值赋给变量时,编译器也会强制用户使用一个常指针去接收。
即便它会拷贝一个指针,但拷贝指针的消耗相比拷贝一个巨大的对象来说显然微不足道。

右值引用的作用

右值引用的概念看起来很迷惑,它有什么用呢?其实它大有用处。有了前面的铺垫,这里详细介绍右值引用的作用。

用左值引用和右值引用重构函数

如果你希望对传入的左值和右值做不同的操作,那么你可以这样重构函数:

void func(const int &a) {
    std::cout << "lvalue" << std::endl;
}
void func(int &&a) {
    std::cout << "rvalue" << std::endl;
}
int main() {
    int a = 1;
    func(a); //输出lvalue
    func(2); //输出rvalue
}

前面提到,以引用作为返回值时不能返回一个右值,但你可以返回一个右值引用:

int& func(int &&a) {
    return a;
}

于是就有

int func(const int &a) {
    std::cout << "lvalue" << std::endl;
    return a;
}
int& func(int &&a) {
    std::cout << "rvalue" << std::endl;
    return a;
}
int main() {
    int a = func(func(1));
}

输出:

rvalue
lvalue

这是因为在func(func(1));中,func(1)中的1是右值,故调用int& func(int &&a),而该函数又返回了一个左值,故func(返回的左值);时调用了int func(const int &a)

但这又有什么用呢?以上内容请反复熟读,确保已经可以辨析各种值是左值还是右值,接下来的内容会比较难,判断不出来是左值还是右值会对阅读造成障碍。

移动语义

思考这样一个问题:如何将一头大象装进冰箱中。我们可以自然地想到,打开冰箱门,放入大象,关闭冰箱门。
不幸的是,在C++11之前是这样做的:复制一头大象,装入冰箱,删除冰外的大象:

class Refrigerator {
public:
    std::string content;
    Refrigerator(std::string s) : content(s) {}
};
int main() {
    Refrigerator r("Elephant");
}

main中,"Elephant"会复制一份传入Refrigerator的构造函数,这复制操作明显增加了开销。
为了解决这一问题,我们使用左值引用不就好了吗:

class Refrigerator {
public:
    std::string content;
    Refrigerator(const std::string &s) : content(s) {}
};
int main() {
    std::string s = "Elephant";
    Refrigerator r(s);
}

可是,为了传入参数,我们不能直接传入字面常量,需要先声明一个变量,再传入,多了一步,一点也不优雅。

有了右值引用,我们就可以:

class Refrigerator {
public:
    string content;
    Refrigerator(string &&s) : content(s) {}
};
int main() {
    Refrigerator r("Elephant");
}

这样,临时的"Elephant"就可以废物利用,避免了复制大象的额外开销。
上述代码投入的是右值。对于左值,因为构造函数形参为左值和右值这两种情况都可以分别被重载,所以只需再加一个Refrigerator(const std::string &s) : content(s) {}
或者,使用std::move将左值转为右值:

int main() {
    string s = "Elephant";
    Refrigerator r(std::move(s));
}

这样,由于变成了右值,就可以调用移动构造,从而避免了拷贝。

注意,以上虽然定义了通过右值构造一个对象的方法,但它只是一个普通的构造函数(直接初始化),只是可以接受右值,并不是一个移动构造函数。对于移动构造函数,可见移动构造

再来看下面的代码:

int main() {
    string a;
    {
        string s = "123";

        a = std::move(s);
        std::cout << "移动后的s: " << s << std::endl;
    }
    
    std::cout << a << std::endl;
}

输出:

移动后的s:
123

这里的a = std::move(s);便是移动语义,将临时变量s的值移动到了变量a,于是避免了复制。当变量s生命周期结束后,并不会影响到a的值,因为此时变量s的值已经被移动了。
另外,我们可以发现,输出移动后的s的值是空的,即便此时它的生命周期还未结束。这意味着,当我们使用std::move时,我们便放弃了该变量的所有权。
我们可以说,std::move延长了变量s对应的“123”的生命周期(没有延长变量s本身的生命周期),或者说,将这个值从一个短生命周期的对象移动到了长生命周期的对象上,于是这个值可以重复利用,避免复制一份新的再销毁旧的。

查看std::move()的源码,可以发现它其实是static_cast<T&&>()的简单封装。这意味着,当我们使用std::move()时,我们并没有移动它在内存中的位置,只是将对象的值类别从“左值”转换为“右值”,即作了类型转换。

对于static_cast<T&&>(),可见后续章节中的“类型转换”。

一个错误的做法:

string&& func() {
    string s = "test";
    std::cout << &s << std::endl;
    return std::move(s);
}

int main() {
    const string& a = func();
    std::cout << &a << std::endl;
    std::cout << a << std::endl;
}

输出:

0x61fcd0
0x61fcd0
????

函数func内创建了一个临时变量s,通过std::move返回一个右值引用,可见返回的右值引用与s虽然有相同的地址,但这并不能阻止变量s被销毁,因为std::move仅仅只是将对象通过强制转换转为了右值,并没有改变其生命周期,于是变量a的值便是不确定的。

万能引用

当我们使用模板时,如:

template<typename T>
void func(T&& arg) {
    // do something
}

这里的T&&便是万能引用。对于万能引用,即可以给它赋右值,也可以给它赋左值:

template<typename T>
void func() {
    int a = 2;
    T&& r1 = 1;  
    T&& r2 = a;  // 合法
    int&& r3 = a;  // 非法
}

我们发现,T&& r2 = a是合法的,而int&& r3 = a却是非法的。
这是由于,当一个万能引用被赋一个左值时,它就会发生引用折叠,变成一个T&

于是,在模板编程中,我们便可以只用一个T&&同时接收左值和右值,这也是为什么它”万能“。
特别是在函数参数特别多时,如果不使用模板和万能引用,则需要大量的重载函数来实现对左值和右值进行不同的处理:

void func(const int& a, const int& b);
void func(const int& a, int&& b);
void func(int&& a, const int& b);
void func(int&& a, int&& b);

当使用模板和万能引用时:

template<typename T, typename V>
void func(T&& a, V&& b);

为了检查ab的类型,可以使用<type_traits>中的方法:

template<typename T, typename V>
void func(T&& a, V&& b) {
    std::cout << "a是否为左值引用: " << std::is_lvalue_reference<decltype(r1)>::value << std::endl;
    std::cout << "a是否为右值引用: " << std::is_rvalue_reference<decltype(r1)>::value << std::endl;
    std::cout << "b是否为左值引用: " << std::is_lvalue_reference<decltype(r2)>::value << std::endl;
    std::cout << "b是否为右值引用: " << std::is_rvalue_reference<decltype(r2)>::value << std::endl;
}

int main() {
    int a = 2;
    func(a, 1);

    return 0;
}

输出:

a是否为左值引用: 1
a是否为右值引用: 0
b是否为左值引用: 0
b是否为右值引用: 1

注意这里用的是std::is_xxxvalue_reference<decltype(r1)>,而不是std::is_xxxvalue_reference<T>,原因在于后者是推断T的原始类型,会丢失引用的属性。

完美转发

有时候我们需要根据传入的是左值还是右值做不同的操作,如下:

void func(int& a) {
    std::cout << "左值" << std::endl;
}

void func(int&& a) {
    std::cout << "右值" << std::endl;
}

template<typename T>
void execute(T&& x) {
    func(x);
}

int main() {
    execute(2);
}

输出: 左值

可以发现,明明输入的是右值,但输出的信息居然显示的是左值。
难道,变量T&& x虽然是个右值引用,但变量本身是个左值。

为了解决这个问题,可以使用std::forward进行完美转发:

void func(int& a) {
    std::cout << "左值" << std::endl;
}

void func(int&& a) {
    std::cout << "右值" << std::endl;
}

template<typename T>
void execute(T&& x) {
    func(std::forward<T>(x));
}

int main() {
    execute(2);
}

输出: 右值

因此,当你在一个函数模板中接收到参数并将其转发给另一个函数时,你希望保留原始的左值/右值特性,此时便可以使用完美转发。

要注意的是,如果传入的是一个右值引用,它仍识别为左值:

int main() {
    int &&a = 2;
    execute(a);
}

输出:

左值

你可能不知道的 const

常量指针?指针常量?

对于指针来说,const在不同的位置会有不同的效果。

  • const在*前:被指物是常量
  • const在*后:指针本身是常量
  • const在*两边:被指物和指针都是常量

  • const char * c;:指针指向的字符是常量,不能修改这个字符的值。而指针本身是变量,可以修改指针的指向。
  • char const * c;:同上,因为const还是在*的前面,与char的相对位置无关。
  • char * const c;:指针本身是常量,不能修改指针的指向,但可以修改指针指向的字符。
  • const char * const c;:指针是常量,指针指向的值也是常量。
  • char const * const c;:同上。

一般,我们称

  • const在*前:被指物是常量 -> 称为底层const(low-level const)
  • const在*后:指针本身是常量 -> 称为顶层const(top-level const)
  • const在*两边:被指物和指针都是常量 -> 左边的const称为底层const,右边的const称为顶层const

这左边右边看起来很容易记混,不过不用担心,我们使用const对变量的约束是很频繁的,很快你就会写一万遍,从而形成肌肉记忆。

严格的const

当我们使用一个指针指向一个常量时,这个指针也必须声明为“指向的是常量”:

const int a = 1;
    
int* pa = &a;  // 非法
const int* pca = &a;  // 合法

C++对于常量或者const的要求十分严格,这与Java等语言相比有很大的差距,不仅仅只是像Java的final那样声明不允许改那么简单。

再例如,一个函数返回一个指向常量的指针,那么也必须用指向常量的指针去接收:

const int* func();

int main() {
    int *pa = func();  // 非法
    const int *pb = func();  // 合法
    return 0;
}

而当一个函数返回一个常引用时,与指针不同:

const int& func();

int main() {
    int pa = func();  // 合法
    const int pb = func();  // 合法
    
    int &pc = func();  // 非法
    const int &pd = func();  // 合法
    
    return 0;
}

虽然以上pa和pb都合法,但它其实是func()返回值的拷贝,也就是说放弃了访问func返回引用的权限。
而如果用引用去接收,就必须用一个常引用去接收。
也就是说,func()返回给你一个引用,但对这个引用作了限制。

对于类来说,当返回类成员的引用时,最好是const引用。这样可以避免在无意的情况下破坏该类的成员。

如果一个函数的一个形参是引用,且没有const约束,那么你将不能传入一个const变量:

void func(int& a);

int main() {
    const int a = 2;
    func(a);  // 非法
    
    return 0;
}

因为对于函数func,a没有const约束,这意味着func有权限修改引用a对应的值,但传入的值有const约束,造成了矛盾。

所以,如果func的一个形参是引用,但它不会修改a的值的话,应该将这个形参声明为const,这样它就可以接收const参数了。

在面向对象编程中,const也有着很严格的约束:

class Widget {
public:
    Widget(int a = 0) : a(a) {}
    int func() {
        std::cout << "调用了 int func()" << std::endl;
        return a;
    }
    int func() const {
        std::cout << "调用了 int func() const" << std::endl;
        return a;
    }
private:
    int a;
};

int main() {
    Widget widget1;
    widget1.func();
    const Widget& widget2 = widget1;
    widget2.func();
}

输出:

调用了 int func()
调用了 int func() const

可见,在类Widget中,即便func没有形参(或者形参相同),在func后面添加一个const,也可以重构函数。此时,若Widget对象声明为const,则调用的func是int func() const这个函数。

当Widget对象声明为const,则它是一个只读的对象,也就是说不能修改成员(如a)的值,所以如果函数int func() const修改其成员则是非法的:

class Widget {
public:
    Widget(int a = 0) : a(a) {}
    void func() const {
        a++;  // 非法
    }
private:
    int a;
};

于是,如果需要用func() const返回一个引用,则必须返回一个常引用:

class Widget {
public:
    Widget(int a = 0) : a(a) {}
    const int& func() const {
        return a;
    }
private:
    int a;
};

其中const int& func() const不能改成int& func() const

对于Java程序员,Java中的final对一个对象的约束只是该变量的指针不会被修改,但不能保证对象中的成员不被修改,而C++的const则有更细致和严格的要求。

除此之外,const在其他地方还有很多的约束,对于const的讲解会贯穿这个文章。

实现常量的方式

const的作用是实现一个语义约束,让一个对象不可改动。

除了const,也有其他实现常量的方式。

宏定义是其中一种方法,但非常不建议使用这种方法,因为宏定义的常量并不一定会被编译器发现,而且一旦出现错误,你将会收获匪夷所思的错误:

// error_is_here.h
#define MY_CONSTANT 1.463
func(MY_CONSTANT);  // 假设这里错误使用了宏常量

抛出的错误:Error:1.463

你会发现,抛出的错误是1.463,而不是MY_CONSTANT,更不是error_is_here.h,你就会疑惑这个1.463是哪来的,从而难以定位到错误的位置。如果这个头文件是你写的还好,但如果它是由别人写的,你就更难知道这个变量是哪来的了。

Effective C++[2]给出的建议是,当需要声明一个全局常量时,使用const、enum、inline代替#define。

常量表达式

constexpr关键字

常量表达式是指值不会改变,且在编译时就能得到计算结果的表达式,而不必在程序运行时才计算:

const int a = 1;
const int b = a + 1;

对于一些情况,我们希望使用一个复杂的表达式,而这个表达式的值是确定的且不会变,我们希望在编译时就能得到这个值,而不必在运行时再计算。

例如,假设一个常量的值是10!,即10的阶乘,我们当然可以手动算出来然后再把值代进去,但这样不优雅。

在C++ 11,我们便可以使用constexpr这个关键字声明一个函数:

constexpr int factorial(int n) {
    return n > 1 ? n * factorial(n - 1) : 1;    
}

constexpr double num = factorial(10);

在常量表达式使用函数时,该函数也必须是constexpr函数,也就是在编译期间能得到结果的而不是运行期间。

把运行期间的运算提前到编译期间,可以减少运行期间不必要的计算,从而优化程序。

此外,constexpr常量必须初始化,没有初始化将会无法通过编译;无法将一个非constexpr变量或常量赋给constexpr常量(整型常量除外):

constexpr int a;  // 错误: constexpr常量必须初始化

const float b = 3.14;
constexpr float c = b;  // 错误: 不能将一个非constexpr变量或常量赋给constexpr常量

const int d = 1;
constexpr int e = d + 1;  // 合法

constexpr与指针

首先要区分constexpr与const指针。当constexpr单独与指针使用时,代表指针本身是常量:

const int * pa = nullptr;      // pa是指向常量的指针
constexpr int * pb = nullptr;  // pb指针本身是常量

constexpr常量指针的使用:

// 注: 在函数体外使用
int a = 1;
constexpr int *pa = &a;  // 合法 
constexpr const int *pca = &a; // 合法

当constexpr指针指向一个const常量或一个constexpr常量时,这个指针也要标注const:

// 注: 在函数体外使用
const int ca = 1;
constexpr int *pa = &ca;  // 非法
constexpr const int *pca = &ca;  // 合法

constexpr int cb = 2;
constexpr int *pb = &cb;  // 非法
constexpr const int *pcb = &cb;  // 合法

练习: 解释 constexpr const int * const 的含义和作用。
解答:
对于

// 注: 在函数体外使用
int a = 1;
constexpr int * const pa = &a;  // 合法

这里的pa指向的不是一个常量,也就是说我们可以通过pa去修改a的值:

int a = 1;
constexpr int * const pa = &a;

int main() {
    *pa = 2;  // 合法
    return 0;
}

如果我们把pa声明为 constexpr const int * const,则无法使用这个指针修改a的值:

int a = 1;
constexpr const int * const pa = &a;

int main() {
    *pa = 2;  // 非法
    return 0;
}

register变量

在C语言中,用register修饰的变量可以建议编译器将该变量储存在寄存器中,寄存器的速度远远比内存要快。
因此,对于一个经常使用的变量,比如某个循环中会访问上亿次的变量,可以将其修饰为register:

for (register int i = 0; i < 100000000; ++i) {
    // do something
}

然而,这只是一个建议,编译器会不会真的将其放入寄存器中,得看心情。

如果你有学过计算机组成原理,你就会清楚,你不应该在C++中对一个寄存器变量取地址(&),因为取的是内存地址,而寄存器没有内存地址。

另外,大部分的CPU中的寄存器数量非常少,相比于内存来说微不足道。

在C++中,register几乎没有用处,它可以提示编译器这是一个常用的变量,当然,编译器完全可以忽略这个提示。到了C++17,register被移除,因此你不应该在C++中使用register。

实际上,现代编译器会自动进行优化,无需程序员显式地使用register。

volatile关键字

volatile关键字在C/C++中用于指示编译器某些变量可能会在程序的控制之外被改变,通常是由于硬件或操作系统的影响‌。使用volatile关键字可以防止编译器对这些变量进行优化,确保每次访问这些变量时都会从内存中重新读取其值,而不是使用缓存中的旧值。

例如在嵌入式编程中,假设需要对一个设备进行初始化,将地址0xff800000映射到设备的寄存器中,并通过依次为其发送数字0~9进行初始化:

int *output = (unsigned int *) 0xff800000; 
int init(void) {
    for (int i = 0; i < 10; ++i) {
        *output = i;  
    }
}

但是编译器认为,这个循环是多余的,对最终结果没有影响,于是将其优化为:

int init(void) {
    *output = 9;
}

此外,由于volatile确保从内存中重新读取其值,而不是使用缓存中的旧值,所以可能可以在多线程中起作用?详情请看并发编程-volatile关键字对多线程有用吗

volatile的实际含义取决于编译器,具体需要阅读编译器的文档,也就是说它可能是不可移植的。

虽然volatile关键字在字面意义上看起来与const冲突,但实际上它们可以同时出现,也就是说可以有一个变量既是volatile的又是const类型。

当一个变量被修饰为volatile时,与const一样当使用一个指针指向它时,这个指针也需要是volatile,且同样有类似于底层或顶层volatile之分,例如:

  • int volatile *p: 指针本身是volatile
  • volatile int *p: 指针指向的变量是volatile
  • volatile int * volatile p: 指针本身是volatile,且指针指向的变量也是volatile

同样的,如果一个指针需要指向一个volatile变量,需要将指针声明为底层volatile,对于引用也是如此:

volatile int a;
int *pa = &a;  // 非法
volatile int *pva = &a;  // 合法
int &ra = a;  // 非法
volatile int &rva = a;  // 合法

const volatile int b = 0;
int *pb = &b;  // 非法
const int *pcb = &b;  // 非法
volatile int *pvb = &b;  // 非法
const volatile int *pcvb = &b;  // 合法

volatile也能修饰成员函数,详情可见volatile成员函数

内存对齐: alignof 和 alignas

为什么要内存对齐

CPU的运行速度和访问内存(以下简称“访存”)的速度是不一样的。为了缓解内存拖慢CPU速度,引入了Cache。CPU对Cache的访问速度很快,可以将内存中的部分数据放在Cache中,这样当CPU需要从内存中取数据时,如果这个数据在Cache中已经有了,就可以直接从Cache中获取而不用去内存中取,大大加快了取数的速度。

不过,Cache的单位容量成本比内存大很多,所以Cache的容量比内存少很多,因此Cache只能保存部分内存中数据的副本,当Cache满时,访问一个Cache中没有的数据,就会出现Cache缺失。此时才会去内存中找,并把找到的数据替换掉Cache中某个数据。

根据空间局部性原理,当访问一个数据时,很有可能会再访问这个数据内存地址临近的数据,比如数组遍历,或从顺序读入代码等。所以,每次从内存取数据到Cache时,不会只取一个字节或字,而是会取连续的很多个字节组成一个块放入Cache中,这样当需要读取一系列连续的数据时,只需从内存中取一次数据,就可以有多个数据能够直接从Cache中读取,大大提高了性能。

对Cache以及CPU访问数据过程感兴趣的,可以阅读计算机组成原理相关教材。

利用这个特性,我们可以对不同长度的数据进行对齐,从而提高性能。

假设一个Cache块是64字节,且我们有一个64字节的数据结构,我们希望它能一次读入Cache中。但是,由于它在内存中的储存位置不一定刚好对齐在这64字节中,比如:

|    一个64字节内存块    |    一个64字节内存块    |
|8字节数据|   你的64字节数据结构   | …

于是,这个64字节数据结构横跨了2个内存块,需要2次访存才能全部读入Cache中。

我们希望,这个64字节数据结构刚好与一个内存块对齐,比如:
|    一个64字节内存块    |    一个64字节内存块    |
| 8字节数据 |  暂时留空白  |   你的64字节数据结构   | …

这样一来,这个64字节数据结构不就能只访存一次就能全部读入Cache了吗?

特别地,在现代操作系统中,普遍采用了虚拟内存的机制,内存中的数据可能会被替换到硬盘中的交换区中。如果一个数据能被一个内存页(通常是4KB)容纳,但是它跨了两个页,那么当缺页中断触发时,处理器需要从硬盘中读取两个页到内存中才能读取这个数据。众所周知,硬盘的速度非常慢,于是读这两个页会耗费大量的时间。

为此,GNU C编译器提供了__attribute__((aligned(n)))的扩展以支持内存对齐。但是,它只是一个编译器的扩展,如果换一个编译器可能就不支持了,从而无法跨平台使用。

C++ 11提供了可以查询和修改对齐方式的关键字: alignof和alignas。

不过,在一般的程序编写中我们很少会去手动对齐,只有对性能要求极高的程序,或者像驱动程序这样的底层程序,又或者某些嵌入式程序的特定要求(例如有些处理器要求必须从偶数地址读数),才会要求我们使用对齐特性。很多时候编译器会自动优化我们的程序,自动进行对齐,但这不是能够保证的。

alignof

alignof用于查询一个数据类型的对齐要求,返回一个std::size_t类型的整数,表示该类型所需的最小对齐字节数。

struct A {
    char c;
    int i;
};

struct B {
    double d;
    char c;
};

int main() {
    std::cout << "alignof(char) = " << alignof(char) << std::endl; // 通常为 1
    std::cout << "alignof(int) = " << alignof(int) << std::endl;   // 通常为 4
    std::cout << "alignof(A) = " << alignof(A) << std::endl;     // A 的对齐要求
    std::cout << "alignof(B) = " << alignof(B) << std::endl;     // B 的对齐要求
    
    return 0;
}

输出:

alignof(char) = 1
alignof(int) = 4
alignof(A) = 4
alignof(B) = 8

alignas

alignas用于指定某个类型或变量的对齐要求。它允许你手动指定内存对齐,而不仅仅依赖于编译器默认的对齐方式。它可以用于结构体、类或者任何数据类型。

例如,假设Cache行的大小为64B,那么我们可以让一个容量为64的byte数组按64字节对齐,这样一来这个数组就能只用一次访存就全部存入Cache中:

alignas(64) char arr[64];

alignas的参数通常是2的幂次数,如1、2、4、8、16等。

那么既然有alignas提供更好跨平台兼容性的内存对齐,那前面为什么还要介绍__attribute__((aligned(n)))呢?
这是因为,alignas是C++ 11后才支持的,而并不是所有平台都支持C++ 11,且有些老旧的代码大量使用__attribute__((aligned(n))),知道它总还是有好处的。

auto

简化代码

在C++ 11中,引入了一个关键字auto,它可以替我们为一个变量自动推导一个表达式的类型,不过推导过程是在编译时进行的,所以auto修饰的变量必须有初始值。

当变量类型很复杂时,auto能帮我们节省很多精力,例如:

auto *container = new std::map<std::string, std::vector<std::string*>>;  // 不用写两遍这个复杂类型

又或者,一个函数返回了一个复杂的类型:

template<typename T, V>
std::map<T, std::vector<V>> func(T, V);

int main() {
    auto container = func(3, "test");  // 自动推导出返回值类型,包括模板类型
    
    return 0;
}

此外,auto一般会忽略掉顶层const(即指针本身是常量),保留底层const(即被指物是常量)。如果希望推导出的auto类型是顶层const,需明确指出:

int a = 1;
const int * const pa = &a;

auto a1 = pa;
a1 = nullptr;  // 合法

const auto a2 = pa;
a2 = nullptr;  // 非法

我们也可以用auto获取特定类型的指针或引用:

std::map<std::string, std::vector<std::string*>> a;

// 这里的 pa1 和 pa2 等效
auto pa1 = &a;
auto *pa2 = &a;

auto &ra = a;

设置一个auto类型的引用时,顶层const属性是会保留的:

int a = 1;
const int * const pa = &a;

auto &rpa = pa;
rpa = nullptr;  // 非法
*rpa = 2;  // 非法

避免滥用 auto

虽然许多高级编程语言(如Python、JavaScript)都支持不指明类型,但近年来它们都逐渐开始建议声明类型(如Python在变量名后加上类型、TypeScript的兴起等)。

auto的滥用会降低代码的可读性,例如:

auto a = b;
auto c = d + ": " + a;
auto e = func(a, c);

对于这段代码,如果不查看上下文的话很难直观看出变量的类型,特别是在程序非常复杂的时候。

此外,因为auto在推导类型时可能会忽略是否是引用或值类型。例如auto对于赋值给一个左值引用时,推导可能会不符合预期:

int x = 42;
auto& ref = x;  // ref是int&,这没问题
auto val = x;   // val是int类型,原意可能是想获取ref的引用

对于某些类型(例如容器的元素类型),如果使用auto,可能会导致不必要的拷贝或内存分配,尤其是没有明确指定引用类型时。

何时使用 auto ?

  • 类型已显而易见的情况下: 当变量的类型非常明显或者不容易改变时,使用auto反而可以减少冗余代码,例如:
    const std::vector<int>::iterator it = vec.begin();
    auto it = vec.begin();  // 这样写更简洁
  • 在模板代码中:当类型复杂且不容易手动指定时,auto是很好的选择。例如在Lambda表达式中,类型通常非常复杂,使用auto来推导类型更加方便,例如:
    auto lambda = [](auto x) { return x + 1; };

decltype类型指示符

decltype的基本使用

decltype也是一种在编译时期进行类型推导的关键字,在C++ 11中引入。它可以获取表达式的类型,而不会对表达式进行求值。

它可以指定变量的类型为另一某变量的类型:

int a = 1;
decltype(a) b = 2; 

也可以获取一个表达式的类型:

int a = 1;
double b = 3.14;
decltype(a + b) c;

这里可以将c的类型设定为a+b的类型,即double,且无需计算出a+b的值。
你可能会好奇,我们明明可以直接看出一个int加上一个double,应该得到一个double类型,那为什么还要费劲写上decltype(a+b)呢?
其实,这种情况它更适合用于模板编程,当用在模板编程时,两个变量的类型是不确定的,因此需要使用这个功能:

decltype也可以获取某个函数的返回类型,而无需真正地调用该函数:

template<typename T, typename V>
auto func(T t, V v) -> decltype(t + v) {
    return t + v;
}

int main() {
    decltype(func(1, 3.14)) a;

    return 0;
}

以上代码的func函数,可以将返回值类型设为auto并使用一个decltype推导返回值类型

decltype与引用

看以下代码:

int a = 1, *pa = &a;
decltype(*pa) b;  // 出现报错

为什么会报错呢?对一个int指针解引用,得到的类型不应该是int类型吗?其实得到的是int&,即引用,因为我们可以通过*pa = 2的方式修改a的值,如果*pa不是一个引用则无法实现。因此b是一个引用,引用必须初始化。这里的b没有初始化,因此出现了报错。

前面提到,我们可以用decltype获取一个变量的类型:

decltype(a) b;

如果给被获取类型的变量加上两个括号,它就会变成表达式,从而变成获取表达式的类型。由于变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会变成引用类型:

decltype((a)) b;  // 非法,引用必须初始化
decltype((a)) c = d;  // 合法

相比于decltype(a):

  • decltype((a))的结果永远是引用;
  • decltype(a)的结果只有当a本身是一个引用时才是引用。

decltype(auto)

decltype(auto)是C++ 14引入的,可以根据表达式的实际类型推导变量的类型:

decltype(auto) a = func();

相比于auto,它可以精确推导类型,保留表达式中的引用、常量等修饰符,而auto一般会去掉引用和常量。

decltype(auto)在一些需要精确类型推导的场景(如返回引用的函数、模板函数)特别有用,而auto则适合大多数普通场景,尤其是当你不关心引用或常量时。

0x03 运算

运算符

i++ 与 ++i

i++++i单独出现时的作用都是自增。但i++返回的是i自增前的数值,而++i返回自增后的值,如:

int i = 1;
std::cout << i++ << std::endl;
i = 1;
std::cout << ++i << std::endl;

输出:

1
2

由于i++的特性,便可以这样复制字符串:

char str1[16] = "Hello World";
char str2[16];
char *p1 = str1, *p2 = str2;
while(*p2++ = *p1++);

注: 以上while写法虽然是合法的,但不建议这样做,因为它的可读性很差,即便是经验丰富的程序员也容易不小心弄错。

同时,++i可以理解为返回i自增后i的引用,所以你可以给他赋值:

int i = 1;
++i = 5;
std::cout << i << std::endl;
i++ = 10;  // 错误!

输出:5

int i = 1;
int &a = ++i;
a = 5;
std::cout << i << std::endl;
int &b = i++;  // 错误!

输出:5

在性能上,++i会比i++效率高,但大多数编译器都会对代码进行优化,当他们单独出现时(普通变量),是没有什么区别的。但对于某些类对象,obj++会产生一个新的临时对象,故此时++obj性能要高一些,特别是在循环中。

这里推荐使用++i,除非必须,不然少用i++的形式。

运算符优先级

在计算正整数乘法时有一个技巧,如果乘数是2的N次幂,可以使用左移运算符(<<),左移的位数为N。
比如,3 * 16可表示为3 << 4
实际开发中,运算的表达式可能会比较复杂,比如,在上面的乘法之后再加一个数:3 << 4 + 1,在这个例子中,期望得到的结果是49,但实际上得到的是96,这是因为左移运算符(<<)的优先级比加号(+)要低,所以会先计算4 + 1,然后再3 << 5。因为乘号(*)的优先级比加号(+)高,所以在我们利用上述乘法技巧,把乘号改为左移符号时就容易犯这种错误。加上括号即可解决该问题:(3 << 4) + 1

对于难以分辨的运算符优先级,建议加上括号,这样不仅不容易出问题,也能更容易看出运算的前后顺序,提高可阅读性。

还有一个程序员常犯的优先级错误:

*container.size();
// 以为是 (*container).size();
// 实际是 *(container.size());
// 当然可以用 container->size(); 代替

表达式优先级?

阅读以下代码

int a() { std::cout << 'a'; return 0; }
int b() { std::cout << 'b'; return 0; }
int c() { std::cout << 'c'; return 0; }
void f(int, int, int) {}
void main() {
    f(a(), b(), c());
}

结果会输出abc还是cba呢?换句话说,f(a(), b(), c());中f内的三个函数运行的优先级是什么呢?

当你尝试验证时,你已经上当了。

事实上,在C++标准中(C++ 11前)并没有对此作出规定。不同于Java、Python等语言,C++的编译器有很多种,对于C++标准没有规定的内容,不同编译器可以有自己的解释,这就造成了以上不同的编译器会有不同的结果。

在C++ 11及以后版本中,C++标准明确规定了从右到左的求值顺序。所以在C++ 11之后,输出的是cba。但即便在C++ 11此做法有了定义,仍旧不建议这样做。

“i+=i++”、“a+=a+=a+=(a++)+(++a)”

看起来像是一个考运算符优先级的题,但在考虑这个表达式的结果之前,不妨考虑一下为什么要写出这样的代码。

i+=i++为例,实际上(严格上来说是C++17之前),这种写法属于未定义的行为,不同编译器对该行为可能会有不同的解释,从而造成结果的不同,所以这样的写法毫无意义。

与其去纠结回字的多种写法,不如脚踏实地采用最简单的写法。对于绝大部分开发规范来说,以上写法是不允许出现的,比如像+=这样的赋值运算符后面不允许有复杂表达式,应该杜绝两个或以上的自增(++)、自减(--)进行合成等。这样不仅可以提高程序的可阅读性,还能避免出现不可预料的结果。

a --> b ?

这看起来有点像->,但多了一个-,且可以通过编译,难道这是一个什么不为人知的运算符?

其实,它是指a-- > b,即执行a--,然后将a--得到的值(即a自减前的值)与变量b比较大小,返回一个布尔值。

在实际编程过程中,可能不经意间写出这样的代码,但因为没有报错,于是在程序运行过程中出现问题时难以发现问题。
这里只是举了一个简单的例子,实际情况中运算符可能被重载,导致问题更难被发现。因此要特别小心,避免写出这样的代码。

此外,要对基本的运算符足够熟悉,这样你就可以一眼发现a --> b其实是a-- > b。另外,也要注意空格加在哪,如果真要这么写,a-- > b也比前者更不容易看错。
前面也有说到,int *pa的写法要比int* pa更不容易看错,特别是在连续定义多个变量时,想要写int *pa, *pb时可能不小心写成int* pa, pb,然而后者只有变量pa是指针,变量pb是整型而不是指针。

初始化对象时的疑惑

首先要区别初始化和赋值的区别。很多语言使用等号“=”来给对象进行初始化,比如Java:

Object obj = new Object();

而在C++中初始化是很复杂的过程。初始化和赋值是两个不同的操作,初始化不是赋值,初始化是在创建变量时赋予一个初始值,而赋值则是把当前值擦除,然后用一个新的值代替。

对于声明和初始化,C++与Java、Python等很多高级语言有很大的区别,所以对于一个熟练Java或Python的程序员,一定不要把对Java或Python对于变量声明和初始化的认知代入到C++中。本文章会反复提醒这点,以便读者能够重视C++初始化的特性和与其他语言的区别。

在C++中,假设有个类Widget,并初始化一个obj:

class Widget {
public:
    Widget();
    Widget(std::string);
};
int main() {
    Widget w("123");
}

Widget类中有两个构造函数,调用第二个构造函数时给形参传入值“123”,这没什么问题吧?但当我们调用第一个无参构造函数时,就有可能写下这样的代码:

Widget w();

看似创建了一个对象w,但实际上居然是声明了一个返回值类型是Widget的函数。
实际上你应该这样写才是对的:

Widget w;  // 调用默认构造函数
// 或者
Widget ww = Widget();

但第一个看起来又像是声明了一个变量,不像是初始化的样子,第二个也不太美观。

实际上,C++的初始化相当的混乱不堪,你可以用等号“=”和括号“()”初始化对象:

int a = 1;
int b(2);
int c = int(3);
Widget w;
Widget ww = Widget();

于是,C++ 11提供了一种统一的初始化方式,即大括号初始化:

int a{1};
Widget w{};
//也可以
int b = {2};
Widget ww = Widget{};

而且,如果存在信息丢失的风险,例如将一个int赋给了short,则会报错:

int a = 1;
short b = a, c(a);  // 可以通过编译,实际上存在信息丢失风险
short d = {a}, e{a};  // 不能通过编译

这有利于提醒你注意信息丢失风险,而不会因为编译器偷偷摸摸的转换而头疼。

大括号初始化对列表初始化也有作用,这个到后面再讲。

大括号初始化也不是没有缺点,比如当类的构造函数的形参中有std::initializer_list时,就容易被劫持:

class Widget {
public:
    Widget(int, int);
    Widget(std::initializer_list<int>);
};
int main() {
    Widget w{1, 2};
}

对于Widget w{1, 2};,调用的是Widget(std::initializer_list<int>);这个构造函数。
但我想要调用Widget(int, int);这个函数!
此时使用圆括号就没有问题:Widget w(1, 2);
要想用圆括号格式调用initializer_list的构造函数,则需要:Widget w({1, 2});

另外一种情况,如果类中既有无参构造函数,也有initializer_list,那么用空大括号初始化对象时,调用哪一个构造函数呢?

class Widget {
public:
    Widget();
    Widget(std::initializer_list<int>);
};
int main() {
    Widget w{};
}

答案是Widget();,如果需要调用含initializer_list的构造函数,则需要这样初始化:Widget w({});

位运算

位运算替代名

除了使用符号&|等来进行位运算,C++还提供了保留字用来代替他们,他们在C++98就已经存在了:

  • compl:~
  • bitand:&
  • bitor:|
  • xor:^
  • and_eq:&=
  • or_eq:|=
  • xor_eq:^=

注意和逻辑与和逻辑或区分:

  • and:&&
  • or:||
  • not:!
  • not_eq !=

位组的字面常量

我们一般情况下使用十进制表示一个字面值,例如:

int a = 12;

我们也可以使用十六进制表示一个字面值,例如:

int a = 0xc;  // 表示十进制的12

在C++ 14之后,我们可以使用二进制表示一个字面值,例如:

int a = 0b1010;  // 表示十进制的12

不过,即便使用的是C++ 14之前的版本,部分编译器可能也支持这样的二进制表示字面值的形式,但是要注意这样可能会降低程序的跨平台兼容性,即在其他平台的其他编译器下可能无法通过编译。

为了在C++ 14之前的版本更好地表示0b1010(或更复杂的数字),同时保证其可读性,可以使用移位运算和按位或运算:

constexpr int a = (1 << 1) | (1 << 3);  // 表示0b1010,即十进制的12

事实上,移位运算的优先度比按位或运算高的,也就是说上述括号可以省略,不过也可以选择保留括号以保证一定的可读性。

下面的例子为了方便阅读,采用二进制表示一个字面值。

位运算有什么用

想必许多人学到位运算时,并不清楚它有什么用。

大多数人使用移位运算符都是它的重载版本,如std::cout << ...,这里的左移是被重载过的,表示其他用途,而不是真正的移位运算。你也可以自己重载位运算符来实现代码的简化。

其实大部分编程语言都有位运算,并不是C++才会大量用到位运算,这节本不应该出现在该C++教程中,但鉴于大多数人都没有学过含有位运算算法,这里举几个简单但有用的算法。

位运算的效率很高,因为它只需对二进制数各位进行一次运算。相比于四则运算(如加法),可以理解为加法是由多个位运算组合而成的。

移位代替整数乘除

这个应该大部分人都学过,对于整数(不考虑溢出):

  • a << n: 相当于 $a \times 2^n$
  • a >> n: 相当于 $a \div 2^n$

判断奇偶

对于一个十进制整数二进制部分,若二进制低位是1,则该整数是奇数,反之是偶数。

利用按位与的特性,因为两整数二进制形式的同一位置都是1时,按位与得到相应位置为1,将整数与1进行按位与运算,因为数字1只有低位是1,因此高n-1位都得到0,而当另一数字低位是1是,得到1。

因此,将整数与1进行按位与运算,得到1时是奇数;得到0时是偶数,用一个布尔值接收:

bool isOdd = num & 1;

此外,模运算也能判断奇偶,不过通常位运算比模运算性能更好。

判断二进制数某一位或某些位的值

由于二进制每一位只能是1或0,于是你可以用一个整数表示多个布尔值,例如一个32位的unsigned int可以表示32个布尔值。

由上一个判断奇偶的例子可以知道,它是整数与1按位与的结果,即参与按位与运算的一固定值是只有最低位为1。

如果这个固定值不在最低位,就能用来判断任一位是否为1,例如:

std::uint8_t bitPattern = 0b00100100;
constexpr std::uint8_t mask = 0b00000100;
bool b = bitPattern & mask;

这段代码可以判断低第3位是否为1,由于将一个整数赋给bool值时,当且仅当整数为0时为false,当整数为除0外其他值时都为true,因此可以这样用,或把bitPattern & mask放在if里等。

不过,像0b00000100这样可读性并不高,假设操作的是一个比较大的整形(如32位),则数起来很麻烦,此时可以使用对1移位:

constexpr std::uint32_t mask = 1 << 16;  // 表示从右往左第16位(从第0开始数)或第17位(从第1开始数)是1
bool b = bitPattern & mask;

或者,也可以将多个位组合,例如低0~1位表示一个数字(0~3)或状态(最多四种状态),然后低2~4位表示另一个数字(0~7)或状态(最多八种状态)等,于是用一个int就能表示多个不同范围的数值。
例如:

std::uint8_t bitPattern = 0b01000101;
constexpr std::uint8_t mask = 0b00000111;
std::uint8_t result = bitPattern & mask;

result0b00000101,可以理解为mask是一个遮罩,mask的二进制形式中为1的部分保留bitPattern相应位置的数值,而mask为0的部分去除bitPattern相应位置的数值。

上述代码刚好都在低位,如果不在低位,只进行一次位运算得到的数字直接转你为结果的十进制数是不对的。

配合移位,可将非最低位指定部分转为一个合理的数值,比如bitPattern高四位储存一个整数,想要提取这个整数:

std::uint8_t bitPattern = 0b01010111;
constexpr std::uint8_t mask = 0b11110000;
std::uint8_t result = (bitPattern & mask) >> 4;  // 注意不要漏掉括号,因为移位运算优先度比按位与高

result为5(即二进制0101)。

这种用法在对于数据大小敏感的情况是很有用的,例如在网络通信中,通常每个数据包中都有一个头部,记录这个数据包的来源、去向、类型等,而头部数据有些只需几个二进制位就足够了,此时就能减少头部的大小。因为在大带宽网络通信中,每秒可能有上百万甚至更多的数据包传输,于是一个头部大小的减少积累起来就是每秒上百万或更多的资源节省。

mask不是常量时,可以通过动态改变mask来实现一些操作,比如计算一个二进制数有多少位为1:

std::uint8_t bitPattern = 0b01010111;
std::uint8_t mask = 0b00000001;
int amount = 0;
for (int i=0; i < 8; ++i) {  // 位组一共有8位
    if(bitPattern & mask) {
        amount++;
    }
    mask << 1;
}

在上述代码中,每轮循环判断指定二进制位是否为1,若是则amount加1。然后mask左移一位,以在下一轮循环中判断下一个二进制位。

改变二进制数某一位的值

上面我们知道了如何判断位组中一个或多个位的值,但有时候我们需要修改其中某几个位的值而不影响其他位的值,又要如何做呢?

对于按位或运算,两参与运算的整数相应位置只要有一个为1,该位就是1。当且仅当相应位置都是0时才为0。

于是,如果bitPattern与0作按位与运算,则bitPattern的值不变。

bitPatternmask作运算,则mask中某位为1时,bitPattern对应位会强制变成1。

例如,要将bitPattern低第二位变成1:

std::uint8_t bitPattern = 0b01010011;
constexpr std::uint8_t mask = 0b00000100;
std::uint8_t result = bitPattern | mask;

result的值为0b01010111

如果翻转bitPattern指定位的值,例如某一位为1时变成0;为0时变成1,其他位不变,则可以使用异或运算。
因为当异或运算时,只有对应位置不同时,例如1和0、0和1,结果才为1;当两个位相同时,例如1和1、0和0,结果为0。
所以,翻转bitPattern低第3位的值:

std::uint8_t bitPattern = 0b01010111;
constexpr std::uint8_t mask = 0b00000100;
std::uint8_t result = bitPattern ^ mask;

result的值为0b01010011

再比如,观察ASCII表字母部分数值的二进制形式,可以发现相同字母大小写形式的低4位是相同的,只有低第6位不同,于是通过改变这一位的值,就能进行大小写转换:

char letter = 'a';
char uppercase = letter & 0b00100000;  // 小写变大写,大写不变
char lowercase = letter | 0b00100000;  // 大写变小写,小写不变 
char flip = letter ^ 0b00100000;  // 大小写翻转,即大写变小写,小写变大写

一个使用案例

例如一个函数,可选择一定的配置,但如果是以下的方法,则函数参数列表会特别长,且调用起来也麻烦:

void sendSignal(int signal, bool enableDebug, bool async, bool getCallback, bool allowDefiance, bool allowXXX);

void invoke() {
    sendSignal(123456, true, true, true, false, false);  // 不仅长,可读性也差
}

更糟糕的是,它的扩展性也不好,例如需要给这个函数添加新形参,虽然可以使用默认形参解决,但如果这是一个C的项目,则不支持默认形参。

一种解决方法是,使用结构体:

struct SendSignalConfiguration {
    bool enableDebug;
    bool async;
    bool getCallback;
    bool allowDefiance;
    bool allowXXX;
};

void sendSignal(int signal, struct SendSignalConfiguration conf);

void invoke() {
    struct SendSignalConfiguration conf;
    conf.enableDebug = true;
    conf.async = true;
    conf.getCallback = true;
    conf.allowDefiance = false;
    conf.allowXXX = false;
    sendSignal(123456, conf);
    
    // 或者采用这种初始化结构体的方法,但可读性差
    // 即哪个参数是true哪个参数是false不明显
    // 此外,不能修改结构体SendSignalConfiguration中各变量的顺序
    struct SendSignalConfiguration conf2{true, true, true, false, false};
    sendSignal(123456, conf2);
}

还有一种方法是,使用位运算:

const int ENABLE_DEBUG = 0x1;   // 相当于0b00001 
const int ASYNC = 0x2;   // 相当于0b00010
const int GET_CALLBACK = 0x4;   // 相当于0b00100
const int ALLOW_DEFIANCE = 0x8;   // 相当于0b01000
const int ALLOW_XXX = 0x10;  // 相当于0b10000

void sendSignal(int signal, int conf);

void invoke() {
    sendSignal(123456, ENABLE_DEBUG | ASYNC | GET_CALLBACK);
}

这样一来,调用者使用了什么配置就一目了然。
而且,由于使用的是常量表达式,ENABLE_DEBUG | ASYNC | GET_CALLBACK的结果是能在编译时就确定的,性能也较好。对于sendSignal的实现,只需使用上面章节提到的如何获取二进制某一位的值的方法,即可判断传入的参数conf分别使用了什么配置。
此外,这个方法兼容C语言,可以直接移植到C语言程序中。
唯一的问题是,可能需要为sendSignal函数添加说明文档,否则用户可能不清楚conf这个int类型的形参的意义和使用方法。

像这种情况,一般我们使用16进制而不是二进制来表示一个常量,这是因为,当常量数量足够多时,使用二进制将会特别长,一个个去数它们很可能产生错误,降低了可读性。
而使用十六进制时,只有可能是1、2、4、8以及后面带数个0,例如:

十六进制 二进制
0x1 0b1
0x2 0b10
0x4 0b100
0x8 0b1000
0x10 0b10000
0x20 0b100000
0x40 0b1000000
0x80 0b10000000
0x100 0b100000000

显然,使用十六进制更有可读性。
虽然对于没了解过这个写法的人来说可能会产生疑惑,特别是没有计算机背景的从Python等新高级语言开始学习的程序员,但这应该是一个程序员必须了解的技巧。

在过去内存资源匮乏的年代,使用类似的从位中抠出资源的技巧以节省内存资源是必须掌握的。不过即便是现在,对于一些底层的、调用十分频繁的场景,也可使用这些技巧,例如对于一个高负载的网络传输来说,即便是1%的节省累积起来也是一个不小的数目。

不过,对于上面这种情况,假设int是32位,在不超过32个配置的情况下,无论加多少个新的配置,不都要占用32位吗?

下面介绍一个新的技巧。

使用前缀编码和哈夫曼树节省资源

对于上面使用一个32位固定值的情况进行优化,我们采用一个可变长度的方法。

例如我们需要顺序地记录一个很大的数据,这个数据由数个小的数据构成,而每一个小的数据的大小是可变的。下面我们把每个“小的数据”称为“记录”。

例如,每个记录只可能由小写字母a-z组成,因为26个字母最多只需要5个二进制位(可表示32个数据)就能全部表示,所以我们不采用8位的ASCII码。

如果每个不同的记录都用5个二进制位表示,其实还不是最节省的,而且固定5个二进制位也无法在以后进行扩展。

因此我们可以这样:

  • 0b0表示a
  • 0b101表示b
  • 0b100表示c
  • 0b111表示d
  • 0b1101表示e
  • 0b1100表示f

这样一来,举一个极端情况: 一个数据中全部100个记录都是a,那么它只占用100个二进制位,相比固定的情况下占用500个二进制位来说,更加节省资源。

不过使用这样的变长记录需要注意一个问题,不可以有一个记录的编码为另一个编码的前缀。
例如,假设用0b1001来表示g,就会出现问题,因为它的前缀与用0b100c重复了。当即将读入的数据为0b100100时,就无法确定它是gaa(1001 0 0),还是cc(100 100)。

采用这种方式也有一个问题,为了表示所有26个字母,占用最多的一个字母会超过5个二进制位。所以最坏情况下,一个数据全是占用最多的一个记录,反而比固定长度的情况还要占用更多的二进制位。

因此,如何对这些记录进制编码也是一个问题。

假设某个记录是最常出现的,那么它应该选用一个短的编码;而对于一个很少出现的记录,它的编码长一点其实问题也不大。虽然最坏情况或接近最坏情况是可能发生的,但发生的几率很低,我们只需保证平均的情况下占用最少即可。

于是,你为最常出现的一个记录分配一个最短的编码,为第二常出现的一个记录分配一个第二短的编码,以此类推。

但是这样也有一个问题,如何给已知出现频率的记录分配编码,同时保证它们不会出现前缀冲突呢?

一个合适的方法是: 使用哈夫曼树。关于哈夫曼树的知识可自行上网搜索学习。

将每一个记录都作为哈夫曼树的叶结点,将它们出现的频率作为叶结点的权值,然后构造哈夫曼树。在构造好的哈夫曼树中,从根结点出发,直到每一个叶结点(即记录),每往下走一层,若往左子树走记为0,若往右子树走则记为1,把行走的路径作为记录的编码(例如路径是 左-右-左,则编码为010)。
这样生成的每个记录的编码所组合成的数据,就是最短的。

不过可能会有这样的情况: 不同的数据中,记录出现的频率是不同的,假设某数据中最长编码的记录中只出现一次,而在另一个数据中大量出现前面编码最长的记录,且还需要存储一个新的记录编码(如数字),而前面只构造了由字母所组成的数据。

为了可扩展且保证每个数据都是最短的,可以为每个不同的数据都构造哈夫曼树,并添加另一段数据用来保存每个记录的编码。
不过因为要多出这样的一段数据,可能又不是最优的了。对于总数据量大但记录种类少的情况,这种方法是有效的。具体情况具体分析。

不利用第三个变量或其他函数实现交换两个整数的值

我们可以使用加减法交换两个数字的值,不使用第三个变量:

void swap(int &a, int &b) {
    a = a - b;  // a 变成 a - b
    b = a + b;  // b 变成原来的 a
    a = b - a;  // a 变成原来的 b
}

但是上述代码有一个问题,当数字很大时,第2行的减法可能导致溢出。

使用位运算也能实现:

void swap(int &a, int &b) {
    a = a ^ b;
    b = a ^ b;
    a = a ^ b;
}

使用位运算可以避免溢出,且位运算的效率更高。

虽然知道了这个技巧,但在实际情况中,还是建议使用第三个变量或std::swap

只用位运算实现加法

其实就是对于硬件的加法器的模拟实现,具体原理可阅读数字电路或计算机组成原理相关书籍。

int add(int a, int b) {
    while (b != 0) {
        // 计算无进位部分
        int sum = a ^ b;
        
        // 计算进位部分,并左移一位
        int carry = (a & b) << 1;
        
        // 更新a和b
        a = sum;
        b = carry;
    }
    return a;
}

对于减法,实质上就是被减数加上取负后的减数,例如 5 - 2 相当于 5 + (-2)。由于负数是补码表示的,所以上述代码可以得出正确的结果,具体分析可以阅读计算机组成原理相关书籍。

只用位运算实现乘法

假设某处理器不支持乘法运算(现实存在这种处理器),也是可以通过加法实现乘法的。对于加法,上面已经提过如何使用位运算实现了。

一种思路是,乘法可以转化为多次相加,例如100*100转化为100个100相加,但是当乘数相当大时,就需要很多次循环,效率很低。

另一种思路是,回忆一下我们是怎么手算两个数乘法的,我们把被乘数每一位先依次与乘数的个位数相乘,注意进位,然后再将被乘数与乘数的十位数相乘,相乘的结果左移一位对齐,然后百位计算后再相对左移一位对齐、千位,以此类推。最后钭几个对齐后相加,即可得到乘法的结果。

上面的十进制相乘的方法需要记住九九乘法表,且需要处理相乘过程中的进位。好在在二进制时,位与位相乘只有1或0相乘,所以可以将按位相乘转化为按位与运算(只有两个都为1时相乘结果才为1,同时只胡两个都为1时按位与才为1),且按位相乘过程不发生进位,只是过程会比较长。最后,将按位相乘的结果加起来,就能得到乘法的结果。

于是,乘法就能转化为相对来说少量的加法运算和位运算,加法运算又可以转化为位运算(见上节)。Booth算法是其中的一种实现,由于代码较长,对其具体代码实现感兴趣的可以阅读计算机组成原理或数字电路相关教材。

使用异或运算实现简单加密

异或运算有一个特性,当一个数a与另一数key做异或运算后得到的结果,再与key做一次异或运算(即与key异或两次),就会得到原来的数a,于是我们可以利用这个特性写一个简单的加密算法:

const int KEY = 0b01101001;  // 密钥

void encrypt(char text[]) {
    for (char *p = &text[0]; *p != '\0'; ++p) {
        *p ^= KEY;
    }
}

int main() {
    char text[] = "an example text";

    // 加密
    encrypt(text);
    std::cout << "加密后: " << text << std::endl;

    // 解密
    encrypt(text);
    std::cout << "解密后: "<< text << std::endl;

    return 0;
}

输出:

加密后: ?I?????I????
解密后: an example text

不过不建议使用这种加密方法,因为它太容易被破解了。

许多其他的加密算法也用到了位运算,感兴趣的可以阅读现代密码学相关资料。

检错、纠错

在网络传输过程中,因为受到干扰信号(噪声)的影响,传输的数据可能会出现错误。有没有办法知道传输的数据是否有错误呢?或者,当数据出现错误时,有没有办法纠错,即就算出现了错误,也能知道哪里出现了错误并自动纠正。

一种简单的方法是奇偶校验,即计算传输的二进制数据中有多少个1或0,当是奇数个时,记为1(或0);当是偶数个时,记为0(或1)。并将这个记号也放在传输的数据中。当接收方收到数据时,也计算一次1或0的数量,看是否与记号一致。这种方法只用在传输的数据中多加一个二进制位,便可以起到一定的检错功能。
例如,传输的数据中有一个二进制位出现错误,翻转成了另一个数(1变0 或 0变1),那么1或0个数的奇偶性就会发生变化,从而知道数据出错了。
但是,如果刚好有偶数个二进制位出现错误时,奇偶性没有发生改变,于是错误的数据就会被当成正确的数据。

另外还有几种简单的检错、纠错算法: 循环冗余校验码(CRC)、海明码(Hamming code),它们只用在原始数据中加上少量的二进制位,就能实现低误判率的检错,甚至可以实现纠错。由于篇幅问题这里就不详细展开了,感兴趣的可以自行搜索(b站有详细的动画描述,建议观看)。

std::bitset

这里复习(或预习)一下操作系统相关知识。

在现代计算机中,磁盘可以分为许多个磁盘块(有时称为“簇”),每个磁盘块的大小是固定且相同的。一个文件可以占用一个或多个磁盘块。在某些文件系统中,为了提高查找效率,即便一个文件很小,远小于一个磁盘块的大小,也要占用整个磁盘块。

于是,每个磁盘块可能有两种状态: 空闲 和 已被占用。

假设给定一个4TB的磁盘,有4G个磁盘块,即每个磁盘块的大小是4KB,现在需要写入一个新的文件,这个文件需要占用N个磁盘块(N>0且为整数),此时就需要在磁盘中寻找N个空闲的磁盘块,如何知道哪些磁盘块是空闲的呢?

一种方法是,给每个磁盘块作一个标记,若该磁盘块为空闲,则打上空闲标记。寻找空闲块时,只需找到有空闲标记的块即可。
可是,假如一个磁盘空闲块数量已经接近0,那么最坏情况就是要扫描接近整个磁盘,才能找到空闲块,这显然不行。

或者,为什么不把每个磁盘块空闲标记单独拿出来全部放在一个位置呢?
简化一下,假设磁盘只有128KB容量,有32个磁盘块,每个磁盘块的大小是4KB。因为只有32个磁盘块,我们刚好可以用一个32位int位组来表示空闲块数量。例如,当编号为0的的磁盘块为空闲时,我们将32位int的第0号二进制位设定为1(或0,取决于设计);编号为1的磁盘块为空闲时,第1号二进制位设定为1,以此类推。
那么,对于4TB的磁盘,4G个磁盘块就只需要512MB的位图用来表示空闲块情况,相比于扫描整个4TB磁盘,扫描这512MB位图显然快很多。

对于一个相当大的bitset,使用数组显然很麻烦。
C++提供了一个标准库std::bitset来存储位图,并提供了方便的方法访问和修改其中的位。
使用std::bitset时,需要引入bitset头文件。

定义一个std::bitset:

std::bitset<8> bs;
std::bitset<8> bs(std::string("01101001"));  // 定义并赋值

访问或修改位图中的位:

std::bitset<8> bs(std::string("01101001"));  // 定义并赋值
std::cout << bs[0] << std::endl;
bs[0] = 0;  // 因为只接收一个二进制位,所以后面的0可以改成false,或定义一个宏表示BIT_0为false
std::cout << bs << std::endl;

输出:

1
01101000

这样就可以很方便地使用位图像数组或容器那样访问和修改位了,比上面位运算的方式方便。

不过,std::bitset也重载了位运算的运算符,可以使用位运算实现更多的操作:

bitset<8> bs1(string("11001010"));
bitset<8> bs2(string("11110101"));

cout << (bs1 & bs2) << endl;
cout << (bs1 | bs2) << endl;
cout << (bs1 ^ bs2) << endl;

输出:

11000000
11111111
00111111

不过,如果要使用一个非常大的位图存储到磁盘中,一般不会一次性全部读入内存中,比如前面提到的512MB的位图,还是太大了。可以分批读入,例如每批1MB,假设在前1MB就找到了空闲块,剩下的就没必要再读入内存中了。还可以使用二级索引等方式进一步提高性能。

0x04 指针

虽然本章叫指针,但实际上是指针与数组、函数、类型转换等内容的大杂烩,包含内容较多。

C语言的指针是重点,也是难点,而到了C++,又给指针赋予了许多更有趣的特性。

空指针: NULL和nullptr

在C++11之前,我们常用NULL表示空指针。但NULL只是一个宏,而且C和C++中对此的定义均不相同。
C:

#define NULL (void*)0

C++:

#define NULL 0

可见在C++中,NULL是整数0。
如果int* p = NULL;,由于C++中当一个指针为0时,视作空指针,所以这是没问题的。
但如果有这样的情况:

void func(int);
void func(int*);
int main() {
    func(NULL);
}

则会报错,因为并不知道这里的NULL究竟指的是数值0,还是空指针,因此产生了二义性(ambiguity)。

为了解决这一问题,C++11引进了nullptr,它是一个特殊的字面常量,类型为std::nullptr_t,用于代表空指针,可以转换为其他不同类型的指针。
如果把上面的代码中的NULL改为nullptr,则调用的函数原型为void func(int*)

野指针和悬空指针

野指针(Wild Pointer)

野指针是指指向一个未初始化的内存地址的指针,是一种指向不明确的、随机的指针。

当我们声明一个局部变量时,它会在内存中寻找一个空闲的位置。这个位置上可能原先是有内容的,当这个内容不再被使用后,它并不会被清理掉。
所以如果我们声明一个变量后没有去初始化它,那么它的值将会是随机的(事实上不应该叫随机,而是内存中未被清理的脏数据):

int a;
std::cout << a;

输出:

32759 (随机)

我们知道,指针存放的内容是一个地址,那么当一个局部指针被声明却没被初始化时,恰巧这片空间中有内容,那么此时这个指针就会指向一个随机的地址。

因为野指针的指向是不明确的,所以当我们操作这个指针时,就会产生各种意料之外的事情(去修改内存中随机位置的数据的值?)。

当指针越界访问时,也会变成野指针。比如我们操作数组时,不小心操作过头了,C++并不会像其他语言那些报数组越界错误,而是去访问越界后对应内存的数据:

int arr[] = {1, 2, 3, 4, 5};
std::cout << arr[5] << std::endl;  // 访问“数组中的下标5”后面内存区域的数据。
int *p = arr;
p += 5;
std::cout << *p << std::endl;  // 同理,越界访问

还有一种情况,指针一般指向某个对象的第一个字节的地址。假设这个对象有4个字节,而指针却指向了这个对象的第2个字节的位置,也会出问题。

如何避免野指针的出现?

  1. 初始化指针。我们最好在指针被声明时就初始化它,或者初始化为nullptr。
  2. 注意不要让指针越界。
  3. 当指针指向的对象被释放时,将指针设为nullptr。
  4. 确保字符数组有”\0”结尾。

悬空指针(Dangling Pointer)

悬空指针指的是指向已经被释放的内存的指针。换句话说,悬空指针在内存释放(例如通过 delete 或 delete[])之后,仍然指向原来已经被释放的内存区域。

当我们释放一个对象时,可能不经意间又使用了它。访问已经被释放的内存会导致未定义行为,从而引起程序崩溃或数据损坏。

int* ptr = new int(10);  // 动态分配内存
delete ptr;  // 释放内存

std::cout << *ptr << std::endl;  // 未定义行为,可能崩溃

为了防止不小心使用了悬空指针,建议在使用delete或delete[]后,将该指针设为空指针:

delete ptr;
ptr = nullptr;

当指针指向的临时变量被释放时,也会成为悬空指针:

int* func() {
    int a = 1;
    return &a;
}
int main() {
    int *p = func();
    std::cout << p << std::endl;
}

在函数func中,a的作用域只在函数func中,当func运行结束时,a被释放,a所在的内存空间变为空闲空间,随时可能被其他数据占领。此时在main函数中的指针p,指向的就是未知的内容,成为了悬空指针。同样,如果p指向使用new或malloc分配的空间,当这个空间被释放后,p也会成为悬空指针。

也许你会想,这种低级错误自己是不会犯的。

实际上,悬空指针非常容易出现,在不经意之间就会犯错误,看下面这个例子:

Widget * result = nullptr;
while (query.hasNext()) {
    Widget cur = query.next();
    if (valid(widget))
        result = &cur;
}
if (result != nullptr)
    result -> getXXX();

由于cur的生命周期只在while内,所以result此时成了悬空指针。

此外,区别野指针与悬空指令的概念,悬空指针是指向已释放内存的指针,而野指针是指向未初始化的内存地址的指针,它们的本质不同。因此,它们不应混为一谈,尽管在某些情况下,两者的后果可能类似。

另一个避免野指令和悬空指令的方法是使用智能指针,会在后面的章节中进行讲解。

数组与指针

定义数组

在定义数组时,假设我们这样做:

int main() {
    int arr[8];
    int len = sizeof(arr) / sizeof(int);
    for (int i = 0; i < len; ++i) {
        std::cout << arr[i] << " ";
    }
}

则会得到乱七八糟的输出:

-281020096 70 1435572696 32758 -1203169216 487 0 1

这是因为,声明int arr[8]时,分配的空闲内存中的这一片区域的数据未被清理,仅仅是被标注为了“空闲”。不清理是因为当它再次被利用时可以直接覆盖掉原来的数据,省略掉清理这一步可以提高性能。

为了解决这一问题,你可以这样做:

int arr[8] = {};

因为使用大括号初始化数组的数据时,没有指定的数据会被初始化为0,故一个空的大括号可以让数组内的所有数据都初始化为0。
不过,如果数组作为全局变量,则无需这样做:

int arr[8];
int main() {
    return 0;
}

此外,如果定义一个类类型(非指针)的数组且没有显式初始化数组元素,那么这个数组的所有未初始化的元素都会进行默认初始化。如果这个类没有可以访问的默认构造函数,则会导致编译错误:

class HasDefault {
public:
    HasDefault() { std::cout << "类被构造" << std::endl; }
};
class NoDefault {
public:
    NoDefault(int a) {}
};

int main() {
    HasDefault arr1[3];
    NoDefault arr2[3];  // 报错
    return 0;
}

去除arr2后,输出:

类被构造
类被构造
类被构造

而想要arr2可以通过编译,需要显式地初始化所有数组元素:

NoDefault arr2[3] = {NoDefault(1), NoDefault(2), NoDefault(3)};

有时候,我们需要根据实际情况确定一个数组的大小,这个数组大小可能不是固定的。这里有一个小细节,比如:

int arr1[size];
int arr2[get_size()];

这里通过变量sizeget_size()获取数组的大小,而不是一个字面值,这样写是合法的吗?
实际上,数组的维度应该要在编译时是确定的,因此sizeget_size()必须是常量表达式,即constexpr,才是合法的。
不过,部分编译器可能支持变长数组,即数组大小可以指定为一个变量,不需要是一个常量表达式,但这并不是C++标准支持的,而是编译器的扩展。由于不是所有编译器都支持变长数组,所以使用变长数组可能导致其降低其跨平台性,因此不建议使用。

如果数组的大小只能在运行时才确定,那么可以使用动态分配:

int size;
std::cout << "请输入数组的大小: ";
std::cin >> size;
int *arr = new int[size];

// 省略对数组的操作...

delete[] arr;  // 释放内存

使用动态分配时,一定要记得在使用完毕时释放其内存,否则将会造成内存泄露。关于动态分配的内存可见后续章节”智能指针“。

此外,delete[] arr只会释放数组本身,如果数组内的元素也是动态分配的,并不会释放其中的元素。

数组在指定大小后,其大小就不能再修改了。如果数组大小需要在运行期间动态变化,可以使用vector,在后面的章节介绍。

数组不支持使用等号将一个数组拷贝给另一个数组:

int a[] = {1, 2, 3};

int b[] = a;  // 非法

int c[3];
c = a;  // 非法

即便有些编译器可能支持,但不建议使用非标准特性。

我们可以定义存放指针的数组,不过不存在存放引用的数组。

字符数组

如果一个数组是字符(char)类型,则在使用它会发生一些奇妙的事情,详情可见后面的“字符串”章节。

在函数间传递数组

上面讲过,当一个函数的返回值类型为非引用类型,返回函数内一个临时对象时,会返回它的一份拷贝。

但C/C++里有个很怪的规定,就是不能返回一个数组。当你返回一个数组(T[])时,会将其视为T*,而不是T[]:

int* func() {
    int arr[8];
    return arr;  // 先讲一下,这样写是错误的
}

返回的arr退化为了指针,所以这里的操作,是在返回一个指针,而不是数组。又arr是一个临时对象,返回一个临时对象的指针,这意味着什么?因为arr出了函数,它的生命周期就结束了,所以你返回的是一个悬空指针。

但是,应该怎么返回一个数组呢,难怕数组产生了拷贝?当然,你可以将数组用结构体或类封装起来再返回,或者使用std::array,就可以避免这个问题,不过,你也可以使用函数的参数来传递数组(推荐):

void init_arr(int *out, int length) {
    for (int i=0; i<length; ++i) {
        out[i] = i;
    }
}
int sum(int *arr, int length) {
    int res = 0;
    for (int i=0; i<length; ++i) {
        res += arr[i];
    }
    return res;
}
int main() {
    int arr_len = 5;
    int arr[arr_len];
    init_arr(arr, arr_len);
    int a = sum(arr, arr_len);
    std::cout << a << std::endl;
}

数组的指针

存放数组的指针?指向数组的指针?

一个数组即可存放对象,也可存放对象的指针:

int *a[5];

像这样的定义,就是一个容量为5的数组,可以存放5个int型指针。

当我们想要定义一个指针,这个指针本身指向一个数组时,则需要这样写:

int (*a)[5];

这是一个指针,指向的是一个容量为5的数组,数组本身存放的是整型数字。

同理,我们可以定义一个数组的引用,即数组的别名:

int arr[5];
int (&rarr)[5] = arr;

注意不存在存放引用的数组。

将其进行组合:

int *arr[5] = {};
int *(&rarr)[5] = arr;

rarrarr的引用,arr存储5个int*

使用指针访问数组

我们可以直接使用一个等号将数组中第一个元素的指针赋给一个指针:

int arr = {1, 2, 3};
int *pa = arr;

也就是说,int *pa = arr;等效于int *pa = &arr[0];
根据前面提过的初始化方式,我们也可以这样对pa初始化:

int arr = {1, 2, 3};
int *pa1(arr);
int *pa2{arr};

如果直接对一个数组使用取地址符号(&),则取的是指向数组本身的指针:

int arr = {1, 2, 3};
int (*parr)[3] = &arr;

分别输出 原数组、指向数组第一个元素的指针、指向数组的指针 的对应地址,及解引用后的值:

int a[] = {1, 2, 3, 4, 5};

int *pa = a;  // 指向数组第一个元素的指针
int (*parr)[5] = &a;  // 指向数组的指针

std::cout << "地址" << std::endl;
std::cout << a << std::endl;
std::cout << pa << std::endl;
std::cout << parr << std::endl;
std::cout << "解引用" << std::endl;
std::cout << *a << std::endl;
std::cout << *pa << std::endl;
std::cout << *parr << std::endl;

输出:

地址
0x61fdf0
0x61fdf0
0x61fdf0
解引用
1
1
0x61fdf0

可见,三者的地址当然都相同;而直接解引用数组本身、解引用指向数组第一个元素的指针,得到的都是第一个元素的数值 1 ,但解引用指向数组的指针得到的却是指针(实际上得到的是数组的引用)。

不过既然parr解引用后是地址,那么如果我们对其解两次引用,不就能得到它指向的值了吗?

std::cout << **parr << std::endl;

输出:

1

事实上,我们对parr解一次引用可以作为int*,与pa等效。

如果我们对它们分别进行自增操作(对数组a不能直接进行自增操作):

int a[] = {1, 2, 3, 4, 5};

int *pa = a;
int (*parr)[5] = &a;

std::cout << "原地址" << std::endl;
std::cout << a << std::endl;

pa++;
parr++;
std::cout << "修改后地址" << std::endl;
std::cout << pa << std::endl;
std::cout << parr << std::endl;
std::cout << "修改后地址解引用对应的值" << std::endl;
std::cout << *pa << std::endl;
std::cout << **parr << std::endl;

输出:

原地址
0x61fdf0
修改后地址
0x61fdf4
0x61fe04
修改后地址解引用对应的值
2
0

可见,对指向数组第一个元素的指针pa进行自增,会使之指向下一个元素;而对指向数组的指针parr自增,则它会指向最后一个元素的下一个元素(即超出了数组范围)的地址。

所以如果我们对parr自增,然后解引用后自减,便是指向数组最后一个元素的指针:

int a[] = {1, 2, 3, 4, 5};
int (*parr)[5] = &a;

parr++;
int *pa = *parr;
pa--;
std::cout << *pa << std::endl;

输出:

5

另外,就算pa是个指针,我们仍可以使用下标:

int a[] = {1, 2, 3, 4, 5};
int *pa = a;

std::cout << a[2] << std::endl;
std::cout << pa[2] << std::endl;
std::cout << *(pa + 2) << std::endl;

输出:

3
3
3

可见,对于指针来说,pa[2]等效于*(pa + 2),且pa + 2并不是指其地址加2,而是往后移动两个元素的距离,对应数组第三个(下标2)元素。

那么,如果是指向一个普通变量的指针,而不是指向数组中某一个元素的指针,是否也可以用pa[i]pa + i的形式呢?答案是这种操作是合法的:

int a = 1;
int *pa = &a;
std::cout << pa[2] << std::endl;
std::cout << *(pa + 2) << std::endl;

但是这种做法没什么意义,因为它将一个指针指向了一个不确定的位置,如果尝试修改这个指针对应位置的值可能引起程序崩溃。

注意区分*(pa + 2)*pa + 2,前者是指针pa往后移动两个元素的位置后解引用,后者是pa本身解引用后得到的值再加上数字2。

既然指针也可以做运算,那如果将两个指针相减,又会发生什么呢?
事实上,两个指针相减会得到一个std::ptrdiff_t的类型,是个有符号数,表示两个指针之间的距离。这个距离是指两个指针间相隔了多少个元素的位置,而不是相隔了多少字节地址。

int arr[] = {1, 2, 3, 4, 5};
int *pa = arr;
pa += 2;
std::cout << pa - arr << std::endl;

输出:

2

由于相减得到的类型是有符号的,它有可能会是一个负数,取决于哪个在前哪个在后(哪个是被减数哪个是减数)。

此外,数组或者指针的下标可以是负数。
不过我们一般不会访问数组下标为负数的元素,因为它超出了数组的范围。
但我们可以访问下标为负数的指针,它表示指针往负方向移动后的指针:

int arr[] = {1, 2, 3, 4, 5};
int *pa = arr[2];  // pa此时指向数组第三个元素,即数字3
int a = pa[-2];  // pa往负方向移动2个元素位置后的指针所指值,即数组第一个元素: 数字1

对于Python程序员,请区分Python使用负数下标访问列表的情况,与C++是不同的。

使用 for range 遍历数组

for range 是C++ 11引入的特性,可以让我们方便地遍历数组:

int arr[] = {1, 2, 3, 4, 5};

for (auto a : arr) {
    std::cout << a << " ";
}
std::cout << std::endl;

输出:

1 2 3 4 5

但是,如果是指向数组第一个元素的指针,或指向数组的指针,则无法使用该特性。

数组作为函数形参

前面的章节中有一个获取数组长度的写法:

int len = sizeof(arr) / sizeof(int);

这确实是一种有效的获取数组长度的方法,但是当数组arr作为函数形参时,这段代码会发生什么呢?

void test(int arr[]) {
    std::cout << sizeof(arr) / sizeof(int) << std::endl;
}
int main() {
    int arr[] = {1, 5, 4, 3, 6};
    test(arr);
    return 0;
}

输出: 2

可以看到,输出的内容与预期不符,这是因为当arr作为函数形参时,它实际上是一个指针(即数组会退化成指针),于是sizeof(arr)得到的便是一个指针的长度。这里测试用机和编译器都是64位,因此指针长度为8,int为4位,于是输出结果为2。

为此,当使用方括号形式的数组作为函数形参时,一般还需要一个参数来确定数组长度:

void test(int arr[], int arr_length) {}

对于其他高级语言的程序员来说,这样的C语言风格数组并没有像arr.length这样方便地获取数组长度的功能。因为C++是个更接近于底层的编程语言,在汇编语言中,想要实现数组就要分配一个连续区域,然后一个个去访问他们,至于数组长度,则需要自行分配另一个内存空间去存储。

不过,STL提供了封装的数组数据结构std::array,比这样原始的数组更方便一些,会在后面介绍。

当数组arr作为函数的形参时,它会退化成一个指针:

void func(int arr[]) {
    for (auto a : arr) {}  // 非法
}

此时,虽然arr的声明长得像一个数组int[],但它实际上是一个指针int*,因此 for range 就失效了。

不过因为指针仍可以使用诸如arr[2]这种用下标的方式,于是能够使用arr+下标来访问数组元素。

此外,数组退化成指针也是当数组作为形参时,无法使用sizeof(arr) / sizeof(int)获取数组长度的原因,因此需要在函数中多加一个参数用于传递数组的长度:

void func(int arr[], const std::size_t length);

这里把第二个参数的类型是std::size_t而不使用int,这是因为std::size_t是一种无符号类型,它被设计得足够大以便能表示内存容量可以表示的所有可能的长度。

数组引用作为函数参数

思考这样一个有意思的问题: 如果数组引用作为函数参数,又会发生什么呢?

void func1(int (&arr)[5]);
void func2(int (&arr)[]);

对于第一个函数,因为是一个指定大小数组的引用,于是它并不会退化成一个指针,也就是说我们可以使用sizeof(arr) / sizeof(int)和 for range 了。
不过,因为数组的大小是指定的,所以似乎不需要用sizeof的方法获取数组长度,而且调用这个函数时传入的数组的长度是被限定的。

前面学到引用的时候知道,修改一个引用可以保证修改的是这个引用对应的原数据,但对于一个数组来说,就算不是引用,在函数中修改这个数组也能影响到原数组,因为它会被退化成一个指针,所以传入一个数组实际上没有发生对数组的拷贝。

而对于第二个函数,它并没有指定大小,且因为它是一个数组的引用,所以它也不会退化成指针。那么sizeof获取长度的方法和 for range 可以用吗?事实上并不能用,因为这个数组没有指定大小,那么在编译阶段它的大小是不确定的,而sizeof和 for range 都需要在编译时就知道数组的大小,显然并不可以使用。于是,对于第二个函数,你仍需要再传递一个参数才能知道数组的长度。

看起来好像没什么用的样子,这个特性究竟有什么用呢?
对于数组引用作为函数参数的特性,它的作用是用于接收一个数组引用而不退化成指针,避免了指针语义的限制,能保留数组本身的性质。

所以,最重要的是它能能保留数组本身的性质,特别在模板编程中有重要的作用。

比如,STL提供了两个函数beginend,可以获取指向数组第一个元素的指针和指向最后一个元素的下一个位置的指针:

int arr[] = {1, 2, 3, 4, 5};
int *begin = std::begin(arr);
int *end = std::end(arr);
end--;
std::cout << *begin << std::endl; 
std::cout << *end << std::endl; 

输出:

1
5

于是我们便可以这样遍历数组:

int arr[] = {1, 2, 3, 4, 5};
int *begin = std::begin(arr);
int *end = std::end(arr);
for (int *pa = begin; pa != end; ++pa) {
    std::cout << *pa << " ";
}

输出:

1 2 3 4 5

但是我们也没给它传入数组大小啊,它是怎么知道最后一个元素的位置的?
看一下它的源码(为方便阅读经过了修改):

template<typename T, size_t N>
inline constexpr T* end(T (&arr)[N]) {
    return arr + N; 
}

当我们往函数中传递arr这个数组时,我们往其中传入了一个int[5]参数,函数用数组引用接收,模板类型是T和N,而传入的参数的int和5落入了这个模板,从而函数得到的是int[5]的引用,于是便知道了数组的大小。所以,使用模板也能获取数组的长度。

C++的模板功能非常强大,但要正确使用它需要对于C++有很深的理解,本文章将逐步加深读者对C++的理解,可作为使用模板元编程的前置知识。

此外,尾指针end因为指向的是最后一个元素的下一个位置,超出了数组的范围,所以不能对它进行解引用或继续递增。

二维数组与指针

多维数组简单复习

严格来说,C++没有二维及多维数组,C++的二维数组可以理解为数组的数组

可以用以下方法将一个多维数组所有元素初始化为0:

int mat[10][20][30] = {0};

内嵌花括号形式初始化:

int mat[3][3] = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

由于多维数组的内存分配也是连续的,因此可以不使用内嵌花括号,只使用一对花括号对多维数组进行初始化:

int mat[3][3] = {
    1, 2, 3,
    4, 5, 6,
    7, 8, 9
};

还记得前面指向数组的指针吗?既然多维数组是数组的数组,那么可以使用指针指向某个多维数组的某一层数组、使用引用绑定的某一层数组:

int mat[4][5];
int (*pmat)[5] = &mat[1];
int (&rmat)[5] = mat[1];

当我们要将多维数组的某一层数赋给一个变量时,不可以这样做:

int mat[4][5];
int mat_inner[5] = mat[1];  // 非法

由于元素mat[1]是个数组,使用等号时它会被识别为一个指向内层元素的指针(int*),因此需要将内层数组赋给一个变量时,应使用指向数组的指针或引用,以下做法是正确的:

int mat[4][5];
int (&mat_inner)[5] = arr[1];  // 合法

因此,在使用 for range + auto 遍历多维数组时,外层 for 应使用引用:

int mat[3][4];
for (auto &row : mat)  // 外层 for 使用引用
    for (auto num : row)
        std::cout << num << std::endl;

否则,外层for的auto会推导为指针,导致内层for不合法。

二维数组作函数形参

当一个多维数组作为函数形参时,第一维的大小可以是动态的,但第一维以外维度的大小必须都是固定的:

// 以下是合法的
void func1(int mat[][3]);
void func2(int mat[2][3]);
void func3(int mat[][3][4]);

// 以下是非法的
void func4(int mat[][]);
void func5(int mat[2][]);
void func6(int mat[][3][]);
void func7(int mat[][][4]);
void func8(int mat[2][][4]);

二维数组在函数中的传递本质上是传递指针,这意味着二维数组的传递会丢失数组的维度信息,只能通过额外的参数(如行数或列数)来获取维度信息。
例如,int mat[3][4]会被传递为int (*mat)[4],即指向含有 4 个元素的数组的指针:

void func(int mat[3][4], int rows);  // mat解析为 int (*mat)[4]

还记得前面提到的指向数组的指针吗?对一个指向数组的指针进行一次自增,会让指针指向超过这个数组范围的位置,但这并不是无意义的,因为这个数组本身可能也在一个数组中,从而自增的结果是指向下一个数组。

二维数组与二级指针

我们在基础C或C++教材中学到,二级指针就是指向指针的指针:

int a;
int *pa = &a;
int **ppa = &pa;

前面学习到,我们可以使用指针加下标形式访问数组,二级指针自然也可以。那么,将一个二级指针使用一次下标访问,就会得到一级指针,这个一级指针又可以使用下标访问,从而可以写出:

int **ppa;
int a = ppa[2][3];

那么,二级指针是否可以用于访问二维数组呢?
当然可以。不过我们不能直接用一个等号将一个二维数组赋给一个二级指针:

int mat[3][4];
int **ppmat = mat;  // 非法

因为 arr 本质上是 int (*mat)[4],而不是int **mat,这两者是不同的类型:

int mat[3][4];
int (*pmat)[4] = mat;  // 合法

不过,可以在函数形参中使用二级指针接收二维数组:

void func(int **mat, int rows, int cols) {
    for (int i = 0; i < rows; ++i) {
        for (int j = 0; j < cols; ++j) {
            mat[i][j] = i * j;
        }
    }
}

这种方法常用于处理动态分配的数组,比如:

int main() {
    int rows = 3, cols = 4;
    
    // 动态分配内存
    int **mat = (int **)malloc(rows * sizeof(int *));
    for (int i = 0; i < rows; ++i) {
        mat[i] = (int *)malloc(cols * sizeof(int));
    }
    
    func(mat, rows, cols);
    
    // 释放内存
    for (int i = 0; i < rows; ++i) {
        free(mat[i]);
    }
    free(mat);
    
    return 0;
}

数组替代方案 std::array

上述C语言风格的数组用起来并不方便,实际上,在现代C++编程中,如果没有特殊要求的话,更加推荐使用STL提供的std::arraystd::vector来替代C风格数组,它们的性能接近甚至(因为编译器优化而)更优于原始C风格数组,而且提供了更多有用的操作,例如与其他一些高级语言类似的获取数组长度,不用再投机取巧或是辛辛苦苦去传递数组长度了。

对于二维数组,可以用std::array<std::array<int,4>,3> mat{};std::vector<std::vector<int>>代替。

std::array是C++ 11引入的,使用它时需要引入头文件<array>

对于std::vector的详细介绍,可见后面的“容器”章节。

声明与初始化

声明一个std::array,需要指定元素类型和数组大小:

// std::array<元素类型, 数组大小> 数组名;
std::array<int, 5> arr;

和前面学到的数组一样,std::array数组的大小是需要在编译时就已知的,因此不能用变量指定数组的大小。

至于初始化,如果不提供任何初始值,则会按所用类型的默认值进行初始化。
可以使用花括号进行初始化,以下是几个初始化例子:

std::array<int, 5> arr{1, 2, 3, 4, 5};
std::array<int, 5> arr({1, 2, 3, 4, 5});
std::array<int, 5> arr = {1, 2, 3, 4, 5};

可以使用std::fill对数组指定范围设置为指定元素,例如将数组所有元素设置为1:

std::array<int, 5> arr;
std::fill(arr.begin(), arr.end(), 1);

std::array的基本操作

  • arr.size(): 获取数组大小
  • arr[i]: 访问下标为i的元素,返回该元素的引用,不会进行越界检查
  • arr.at(i): 访问下标为i的元素,返回该元素的引用,会进行越界检查
  • arr.front(): 返回第一个元素的引用
  • arr.back(): 返回第最后一个元素的引用
  • arr.begin(): 返回指向第一个元素的迭代器
  • arr.end(): 返回指向最后一个元素的下一个位置的迭代器
  • arr1.swap(arr2): 交换两个数组的内容

std::array重构了关系运算符:

  • arr1 == arr2: 两数组大小相等且相同下标的元素均相等时,两数组相等
  • arr1 != arr2: 两数组的大小或相同下标的元素有任一不同,两数组不等
  • <,<=,>,>=: 按字典序比较两数组

除此之外,标准库中还提供了许多对数组及其他容器的操作,如std::sort()std::find()等,感兴趣的可以查看“标准库篇”或自行搜索。

函数的指针

当函数指针与数组相遇

对于常见的变量类型,你一定能一眼认出来他们:
int a;:int型变量a
int *a;:int型指针变量a
int a[5];:长度为5的数组a,每个元素是指向int,即

a : [int, int, int, int, int]

但当变量类型复杂起来时,就没那么好认了:

int *a[5];:长度为5的数组a,每个元素是指向int的指针,即

a : [int*, int*, int*, int*, int*]
因为`[]`的优先级高于`*`,故先a是数组,再数组中的元素是int\*类型指针。

int (*a)[5];:一个指针,指向长度为5的数组,数组的每个元素是指向int,即

a -> [int, int, int, int, int]

这两个的区别,一个是指针的数组,另一个是数组的指针。如果我们对他们分别做自增操作,会怎么样呢?这个到下面再讲。

再来看点更复杂的:

int (*f[5])();:长度为5的数组a,每个元素是指向函数的指针,函数返回值类型是int,即

a : [int (*f)(), int (*f)(), int (*f)(), int (*f)(), int (*f)()]

int (*(*f)[5])();:一个指针f,指向长度为5的数组,数组的每个元素是指向函数的指针,函数返回值类型为int,即

a -> [int (*f)(), int (*f)(), int (*f)(), int (*f)(), int (*f)()]

int* (*f[5])();:长度为5的数组f,每个元素是指向函数的指针,函数返回值类型是int*,即

a : [int* (*f)(), int* (*f)(), int* (*f)(), int* (*f)(), int* (*f)()]

int* (*(*f)[5])();:一个指针,指向长度为5的数组,数组的每个元素是指向函数的指针,函数返回值类型为int*,即

a -> [int* (*f)(), int* (*f)(), int* (*f)(), int* (*f)(), int* (*f)()]

如果用上二级指针,情况可能就没那么好对付了。比如:
int (*(*f[5]))()相当于int (**f[5])()

但这已经脱离了实用价值,虽然可以通过编译,但实际情况很难像这样复杂,且这样的代码对可阅读性将会是灾难性的破坏。

函数的返回值类型也可以是函数指针:
int *(*f(int*))(int*):声明一个函数,其参数类型是int*,返回值为一个函数指针。

int *(*(*f)(int*))(int*):一个函数指针,指向一个参数类型是int*,返回值为函数指针的函数。

智能指针

智能指针的由来和必要性

想必许多读者,特别是先学习过Java、Python等语言再学习C++的,会很好奇为什么要有动态分配内存这样的功能,即便在学习了很久的C++并熟练掌握动态分配内存后,仍不是很清楚这个问题。

C++ 被认为是一种更接近硬件的语言,这里的“接近硬件“不仅仅指可以直接操作硬件,更重要的是,C++ 允许程序员手动管理低级资源,例如内存分配和释放。而像 Java 和 Python 等高级语言则通过自动化内存管理(如垃圾回收机制)来隐藏这些底层操作,从而简化了开发过程。

一个程序在装入内存前,需要为其分配一定的内存空间,但究竟给它分配多少,通常是不好确定的。内存的分配分为静态分配和动态分配:

  • 静态分配: 在程序编译时就确定内存的大小和位置,分配的内存在程序整个运行期间保持不变。
  • 动态分配: 在程序运行时根据需要分配内存,内存的大小可以在运行时调整。

由于静态分配是在编译时就确定的,这意味着一旦分配,其大小就不变。而动态分配则根据实际需求在运行时分配和释放内存。例如,如果某个操作需要大量内存但仅在短时间内使用,动态分配可以在需要时才分配,使用完毕后释放,从而提高了灵活性并节省了系统资源。

这里举一个简单的例子,比如前面章节提到,在使用数组时,非动态分配的情况下,需要在编译时就知道其大小,因此数组的大小不能是一个变量,而必须是在编译时就能确定的常量或常量表达式:

// 以下写法是合法的
int arr1[5];
int arr2[3 * 2];
constexpr int size = 1 << 5;
int arr3[size];

// 以下写法是非法的
int arr_size = 100;
int arr4[arr_size];

注意,有些编译器可能支持上述arr4这样的变长数组,但这不是C++标准支持的,可能是编译器的扩展。使用它可能导致在其他平台无法通过编译,从而降低你的程序的跨平台支持,因此不建议使用。

对于不支持变长数组的编译器,要实现在程序运行时才确定数组的大小,可以使用动态分配:

int size;
std::cout << "请输入数组的大小: ";
std::cin >> size;
int *arr = new int[size];  // 动态分配

// 此处省略对数组的操作

delete[] arr;  // 释放内存

静态分配是由系统自动完成的,局部变量和全局变量通常使用静态分配。而动态分配则需要程序员手动进行,程序员的疏忽可能导致分配的内存未被释放,从而造成内存泄漏,即分配的内存在程序运行过程中始终没有被释放,即使它已经不再被使用。

这里大家可能有一个疑问,既然局部变量有一定的生命周期,那么在其生命周期结束时不就被释放了吗?那又如何理解”静态分配的内存空间一旦分配,其大小就不变“呢?
其实实际的情况会更复杂些,静态分配的内存和局部变量的内存管理是有所不同的。对于局部变量,它们的内存通常是栈分配,栈分配是动态的。而部分局部变量如静态变量(用static修饰的变量),则使用的是静态分配。对于这些内容感兴趣的可以查看相关教材或资料,例如《深入理解计算机系统》(Computer Systems: A Programmer’s Perspective, CSAPP)。

在现代操作系统中,当一个程序结束时,操作系统会自动回收进程的所有资源,包括其泄露的内存。即便如此,对于一个程序来说,因为内存泄露会累积,所以即便是一个运行时间短的小型程序,如果某频繁操作出现内存泄露,依然存在因内存泄露而占用大量内存空间的情况,从而影响整个系统的性能。所以无论什么程序都应该避免内存泄露。

在一个复杂的程序中,例如某动态分配内存的变量需要在多个函数中传递,且其嵌套的层数多,逻辑复杂,此时同时要保证动态分配的内存得以及时释放,又要保证不会因提前释放而操作悬空指针,是非常困难的。
更糟糕的是,程序运行过程中可能还会出现异常,从而导致后续内存释放的代码没有被运行到,因此需要在捕获异常时进行内存释放,这更加大了内存管理的复杂性。
而智能指针的出现,就是为了可以更方便地操作动态分配内存的变量,能够自动地在该释放的时候释放而不需要头痛何时释放。

智能指针的实现

智能指针其实不是一个指针,而是一个类,这个类可以实现动态分配内存对象指针的自动释放。

在使用C++的智能指针之前,不妨自己实现一遍智能指针的功能,以便可好地理解智能指针的原理。

为了能让动态分配的内存自动释放,我们可以利用局部变量有一定生命周期的特性,使用一个类记录这个动态分配内存的变量指针,并在这个类生命周期结束时,即在析构函数中释放内存:

template<class T>
class smart_ptr {
public:
    explicit smart_ptr(T* ptr) : _ptr(ptr) {}
    ~smart_ptr() {
        delete _ptr;
    }
    
    // 为了让智能指针也能像普通指针那样操作,重构其运算符 
    T& operator*() {
		return *_ptr;
	}
	T* operator->() {
		return _ptr;
	}
    
    // 对 const 的支持,设计为对 smart_ptr 的 const 为指针提供底层 const 支持
    const T& operator*() const {
		return *_ptr;
	}
	const T* operator->() const {
		return _ptr;
	}
private:
    T* _ptr;
};

使用这个智能指针:

smart_ptr<int> pa(new int(1));
(*pa)++;
std::cout << *pa << std::endl;

输出:

2

由于smart_ptr会在其生命周期结束时释放掉动态分配的内存,因此使用它可以不用手动释放内存,即便程序十分复杂或出现异常。

可是我们设计的这个智能指针有一个缺陷,smart_ptr不能被拷贝,这意味着它并不能在函数中传递。这是因为,当它发生拷贝时,会出现两个或多个smart_ptr管理同一片内存,而当它被释放时,这片内存空间就会被释放多次,造成程序崩溃:

int func(smart_ptr<int> pa) {}
int main(){
    smart_ptr<int> pa(new int(1));
    func(pa);  // 出现报错
    
    return 0;
}

为此,我们可以禁止这个智能指针的拷贝并使用引用传递:

template<class T>
class smart_ptr {
public:
    // 禁止拷贝构造函数
    smart_ptr(const smart_ptr& other) = delete;
    // 禁止赋值操作符
    smart_ptr& operator=(const smart_ptr& other) = delete;
    // 省略其他成员
}

上述代码中的= delete的写法是 C++ 11 才支持的,如果是 C++ 11 之前的版本,则可以将拷贝构造函数设为私有来阻止拷贝:

template<class T>
class smart_ptr {
public:
    // 省略其他成员
private:
    T* ptr;
    // 禁止拷贝构造函数
    smart_ptr(const smart_ptr& other);
    // 禁止赋值操作符
    smart_ptr& operator=(const smart_ptr& other);
}

这样一来,通过拷贝传递会无法通过编译,只能使用引用传递:

int func1(smart_ptr<int> pa) {}  // 拷贝传递
int func2(smart_ptr<int>& pa) {}  // 引用传递
int main(){
    smart_ptr<int> pa(new int(1));
    func1(pa);  // 报错,无法通过编译
    func2(pa);  // 合法
    
    smart_ptr<int> pa2 = pa;  // 报错,无法通过编译
    return 0;
}

又或者,通过修改拷贝构造函数,当发生拷贝时,进行所有权转移,比如下面的auto_ptr

auto_ptr

auto_ptr是C++ 98提供的智能指针,它对拷贝的处理是转移所有权。使用它时,需要引用memory库。

auto_ptr的拷贝构造函数简化后可以表示为:

template<class T>
class auto_ptr {
public:
    // 拷贝构造函数进行所有权转移
    auto_ptr(auto_ptr& other){
        _ptr = other._ptr;
        other._ptr = nullptr;
    }
    // 省略其他成员
}

这样一来,当发生拷贝时,只有一个智能指针拥有动态分配的资源的指针,因此当资源被释放时只有一个会被释放,避免了双重释放造成的崩溃。

但这又有一个问题,因为所有权被转移,那么原来的智能指针就会变成悬空指针,如果操作原来的指针就会出现问题。于是,你还得保证智能指针在传递的过程中拥有所有权的智能指针不丢失,且操作的都是拥有所有权的智能指针。

顺便提一下,我们在上面设计的smart_ptr对const的处理与auto_ptr是不一样的,利用对二者的区别强调一下C++对const的严格性。

auto_ptr对const的处理可以表示为:

T& operator*() const;
T* operator->() const;

这样的设计可以保证auto_ptr有或没有被const修饰均可以使用这两个重构的运算符:

class Widget {
public:
    void test() {
        std::cout << "第一个test" << std::endl;
    }
    void test() const {
        std::cout << "第二个test" << std::endl;
    }
};

int main() {
    std::auto_ptr<Widget> p1(new Widget);
    p1->test();

    const std::auto_ptr<Widget> p2(new Widget);
    p2->test();
    
    return 0;
};

输出:

第一个test
第一个test

也就是说,对auto_ptr的const修饰是顶层const,即上述代码的p2不能被修改,而p2所指的Widget对象并不是常量。

而我们的smart_ptr的设计则是:

T& operator*() {
    return *_ptr;
}
T* operator->() {
    return _ptr;
}

// 对 const 的支持,设计为对 smart_ptr 的 const 为指针提供底层 const 支持
const T& operator*() const {
    return *_ptr;
}
const T* operator->() const {
    return _ptr;
}

于是smart_ptr是否被const修饰,会造成执行的重构运算符函数的不同:

class Widget {
public:
    void test() {
        std::cout << "第一个test" << std::endl;
    }
    void test() const {
        std::cout << "第二个test" << std::endl;
    }
};

int main() {
    smart_ptr<Widget> p1(new Widget);
    p1->test();

    const smart_ptr<Widget> p2(new Widget);
    p2->test();
    
    return 0;
};

输出:

第一个test
第二个test

也就是说,我们设计的smart_ptr在被const修饰时,既是顶层const,同时也有底层const的效果,即对smart_ptr加const的效果类似于:

const Widget * const p2 = new Widget;

而使用auto_ptr叶,如果需要传入一个const修饰的Widget,或者说给Widget加底层const约束,则需要这样做:

std::auto_ptr<const Widget> ptr(new Widget);
ptr->test();

输出:

第二个test

我们设计的智能指针对const有着更严格的约束,从而可以支持在声明smart_ptr时加上const来使用只读版的Widget

这个例子是为了举例 const 的严格性,实际上我们对const设计因为过于严格反而可能会失去一定的灵活性,例如不能只对智能指针设置顶层const而不设置底层const。

C++ 11 智能指针: unique_ptr, shared_ptrweak_ptr

由于auto_ptr在所有权转移、不支持数组、不适用于STL容器及缺乏移动语义等方面存在缺陷,在C++ 11标准中auto_ptr被标记为了废弃,并在C++ 17标准中被移除。即便某些编译器为了兼容旧代码还支持auto_ptr,在C++ 11之后你不应该继续使用auto_ptr

为此,C++ 11 提供了三种新的智能指针: unique_ptr, shared_ptrweak_ptr

如果你的程序必须使用C++ 11之前的版本,但想要使用C++ 11的智能指针,你可以选择使用Boost库的scoped_ptrshared_ptrweak_ptr
Boost库社区建立的初衷之一就是为C++的标准化工作提供可供参考的实现,C++ 11提供的智能指针就是Boost中的智能指针实现的。

unique_ptr

unique_ptr通过禁止拷贝的方式解决智能指针拷贝问题,禁止拷贝的方法我们已经在上面提到过了,这里就不再赘述。

除了可以使用引用传递,unique_ptr还提供了以下方法:

  • release: 放弃智能指针对指针的控制权,返回一个裸指针
    Widget* func(Widget *p) {
        std::unique_ptr<Widget> ptr(p);
        // do something with ptr
        ptr->xxx();
        return ptr.release();
    }
    
    int main() {
        std::unique_ptr<Widget> ptr(new Widget());
        // do something with ptr
        ptr->xxx();
        
        Widget *p = func(ptr.release());
        ptr = std::unique_ptr<Widget>(p);
        // do something with ptr
        ptr->xxx();
        
        return 0;
    }
  • reset: 手动释放智能指针所指对象,并将智能指针置空
    std::unique_ptr<Widget> ptr(new Widget());
    ptr.reset();
    if (ptr == nullptr)  // true
        std::cout << "指针为空" << std::endl;
    reset可以传入另一个裸指针作为参数,释放掉智能指针原指对象并指向一个新对象:
    std::unique_ptr<Widget> ptr1(new Widget());
    std::unique_ptr<Widget> ptr2(new Widget());
    ptr1.reset(ptr2.release());
  • = nullptr: 释放智能指针所指对象并将其设为空指针
    std::unique_ptr<Widget> ptr(new Widget());
    ptr = nullptr;
    它与不带参数的ptr.reset()是等效的。
    不过,ptr = nullptr“将指针置空”的语义更明确;ptr1.reset(ptr2)正如它名字一样将智能指针“重置”为了另一个对象。二者均可以释放原指针所指对象。
  • std::move: 可以使用移动来转移unique_ptr对对象的所有权:
    std::unique_ptr<Widget> ptr1(new Widget());
    std::unique_ptr<Widget> ptr2 = std::move(ptr1);

unique_ptr支持自定义删除器:

class CustomDeleter {
public:
    void operator()(int* ptr) const {
        std::cout << "CustomDeleter: Deleting pointer " << ptr << std::endl;
        delete ptr;  // 进行自定义的删除操作
    }
};

int main() {
    std::unique_ptr<int, CustomDeleter> ptr(new int(42));

    // 当 ptr 超出作用域时,CustomDeleter 会被调用来删除指针
    return 0;
}

也可以采用 Lambda 表达式以避免显式定义一个类来作为删除器:

int main() {
    // 使用 lambda 作为删除器
    auto deleter = [](int* ptr) {
        std::cout << "Lambda Deleter: Deleting pointer " << ptr << std::endl;
        delete ptr;
    };

    std::unique_ptr<int, decltype(deleter)> ptr(new int(42), deleter);

    // 当 ptr 超出作用域时,lambda 删除器会被调用
    return 0;
}

shared_ptr

unique_ptr简单粗暴地禁止了拷贝,但有些时候我们是需要拷贝的,这种情况下unique_ptr就无法满足我们的要求了。

shared_ptr通过维护一个引用计数,记录这个资源被多少个对象共享了。当发生拷贝时,引用计数加一;而当调用析构函数时,引用计数减一。只有当引用计数为0时,才会真正地释放资源。

思考一下,这个引用计数应如何实现才好呢?如果是直接使用一个int,则每个shared_ptr都会有一个单独的引用计数值,我们很难保证多个shared_ptr的计数值一致。
如果使用static,则会有另一个问题: 如果我们有多个不同的资源要使用shared_ptr,那么static修饰的计数值会导致所有不同资源的智能指针都共用同一个计数值,这显然也不是我们想要的。

我们可以使用一个指针指向这个计数值,当发生拷贝时,其他的智能指针的计数值指针都指向同一个数,这样当计数值发生变化时,由于这个资源的所有智能指针都指向同一个计数值,于是它们的计数值都是一致的。

shared_ptr的简化版实现可以表示为:

template<class T>
class shared_ptr {
public:
    // 构造函数,将计数值初始化为1
    shared_ptr(T* ptr) 
    : _ptr(ptr), 
    _pcount(new int(1)) {}
    
    // 析构函数,减少计数值。当计数值为0时,释放资源
    ~shared_ptr() {
        release();
    }
    
    // 拷贝构造,对应同一个资源指针和同一个计数值指针,并增加计数值
    shared_ptr(const shared_ptr<T>& other) :_ptr(other._ptr), _pcount(other._pcount) {
        ++(*_pcount);
    }

    // 通过等号拷贝
    shared_ptr<T>& operator=(const shared_ptr<T>& other) {
        if (_ptr != other._ptr) {  // 不是同一个资源(地址不一样)
            release();
            _pcount = other._pcount;
            _ptr = other._ptr;
            ++(*_pcount);
        }
        return *this;
    }
    
    void release() {
        --(*_pcount);
        if(*_pcount == 0) {
            delete _pcount;
            delete _ptr;
        }
    }
    
    // 获取引用计数值(的拷贝)
    int count() {
        return *_pcount;
    }
    
    // 模仿指针行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
private:
    T* _ptr;
    int* _pcount;
};

shared_ptr实际的实现方式很复杂,比如因为需要操作变量_pcount,在多线程程序中存在线程安全问题;再比如对数组的支持等。感兴趣的可以阅读shared_ptr的源码。

使用这样的一个智能指针,就可以更加安全地拷贝它而不用担心所有权被转移。

此外,由于shared_ptr所维护的引用计数是原子变量,于是它对引用计数的操作是线程安全的,在多线程环境下不用担心其引用计数会出现问题。不过,它不能保证智能指针所指向的对象的线程安全。

weak_ptr

shared_ptr存在一个问题,例如在一个循环链表中使用智能指针,而可能导致资源无法释放:

struct Node {
    int data;
    std::shared_ptr<Node> prev;
    std::shared_ptr<Node> next;
};
int main() {
    std::shared_ptr<Node> n1(new Node);
    std::shared_ptr<Node> n2(new Node);
    
    n1->next = n2;
    n2->prev = n1;
    
    return 0;
}

上述代码首先n1n2两个指针创建时本身各有一次引用,而n1->next = n2使用等号拷贝了一次指针,从而使n2的引用加一,n2->prev = n1同理。于是它们的引用数分别都为2。

而当n1n2的生命周期结束时,两者的引用次数分别减一,但动态分配的两个Node中各自引用了对方,n1最后一次计数释放的条件是n2解除对n1的引用,而n2最后一次计数释放的条件是n1解除对n2的引用,显然这造成了一个循环,导致两者均无法被释放。

为了防止引用循环导致指针无法被释放,C++提供了weak_ptr用来指向一个shared_ptrweak_ptr支持用shared_ptr来构建,它的引入就是来解决shared_ptr的循环引用问题的。当一个weak_ptr指向一个shared_ptr时,它们共同管理同一个资源,但weak_ptr不会增加shared_ptr的引用次数,从而避免了循环引用量资源无法释放的问题。

使用weak_ptr后,上述代码可修改为:

struct Node {
    int data;
    std::weak_ptr<Node> prev;
    std::weak_ptr<Node> next;
};
int main() {
    std::shared_ptr<Node> n1(new Node);
    std::shared_ptr<Node> n2(new Node);
    
    n1->next = n2;
    n2->prev = n1;
    
    return 0;
}

特别地,当shared_ptr管理的资源被释放时,weak_ptr会自动变成nullptr

通过 std::make_xxx 创建智能指针

在C++ 14,除了通过类似于

std::unique_ptr<int> ptr1(new int(1));
auto ptr2 = std::unique_ptr<int>(new int(1));

的方式创建智能指针,还可以通过:

auto ptr1 = std::make_unique<int>(1);
auto ptr2 = std::make_shared<int>(2);

的方式创建。
注意: 后者的参数中没有new

使用第二种方式的好处是,它可以接收构造对象的参数,而无需像第一种方式那样将对象的类型写两遍,比如:

class MyClass {
public:
    MyClass(int, double, std::string);
};

int main() {
    auto ptr = std::make_unique<MyClass>(1, 3.14, "a string");
    
    return 0;
}

这样可以减少冗余的代码,增加代码的可读性。

此外,使用make的形式会更加安全,因为编译器可能对代码的执行顺序进行优化,而使用new形式时实际上是分为了两个步骤: 使用new分配内存、将分配的内存通过构造函数交给智能指针管理。
而使用make时,则是直接将参数传递给函数,让它来负责内存分配,在当前函数(如上面的main函数),它只有一个步骤: 调用。因此在当前上下文中不会被编译器优化执行顺序。

此外,使用std::make_unique不支持自定义删除器。

使用shared智能指针时,使用make形式的性能也更好,因为在使用new时除了我们手动用new申请的一次内存,智能指针本身也需要申请一次内存给控制块(存储引用计数等),共两次内存申请。而使用make的形式时,会一次性申请好所有需要的内存,从而减少申请内存所需的时间。

因此在C++ 14及之后的版本,使用make是比较推荐的。但是,有些情况也不能用make,例如当newdelete被重载时;以及对于shared智能指针有weak_ptr指向该对象时,因为使用make申请的内存中,对象和控制块在同一块连续内存中,而shared_ptr的控制块有两个引用计数,除了我们熟悉的引用计数还有一个弱引用计数器,当两个计数器都为0时控制器才会被释放,但因为控制块和对象是在同一片连续内存中的,只要控制块存在,包含它的内存就一定存在。于是即便没有强引用后对象被销毁了,但由于弱引用还存在,控制块还没有被销毁,于是分配的这一整块内存仍得不到释放。

智能指针的使用陷阱

  • 不要把同一个裸指针交给多个智能指针管理
  • 对于release方法不会释放原指针,记得用一个指针去接收它并作进一步处理
  • 禁止delete智能指针get方法得到的裸指针,否则智能指针内的裸指针就变成了悬空指针,进而造成双重释放

智能指针的性能

我们可以看到,由于智能指针对裸指针进行了包装,那么访问智能指针时势必要多出调用函数这一步,那么它的性能理应比裸指针差一点。

但是,编译器在编译过程中会进行优化,从而像内联函数那样将调用函数的过程替换成使用裸指针,于是裸指针与智能指针的性能几乎是相同的。

不过,编译器的优化是有限的,例如对于shared_ptr,它还需要维护其引用计数,于是其性能会比裸指针差一点。

类型转换: static_cast、dynamic_cast、const_cast 和 reinterpret_cast

小心隐式转换

C++的隐式转换有时候会造成一些不可预料的结果,比如:

void func(int a);
int main() {
    func(3.14);  // 合法
    return 0;
}

可见,在C++中,double类型的数居然可以合法地隐式转换为int,而作为对比,许多其他编程语言double并不能隐式向int的转换,而是需要强制转换。因此,你在编写代码时应该格外小心可能的隐式转换。

C风格类型转换

我们在学习C++基础时,有学习过隐式类型转换和强制类型转换。我们有时候需要进行强制类型转换,比如将一个int转换为float,或是父类与子类之间的转换。

例如,使用C风格类型转换将一个float值转换为int值:

float a = 2.5;
int b = (int) a;
// 或者 int b = int(a);  两种方法是等价的
std::cout << b << std::endl;

输出:

2

可见,将一个float转换为int会导致精度损失,这个数字发生了改变。

如果是指针呢?

float f = 2.5;
float *pf = &f;
int *pa = (int*) pf;
std::cout << *pa << std::endl;

输出:

1075838976

这个数值正好是单精度浮点数2.5在IEEE 754标准下的表示的十进制数值。对于IEEE 754浮点数感兴趣的可以阅读计算机组成原理相关教材。

可见,这样对指针进行的强制转换,只是改变了编译器对内存地址对应数据的解释方式,没有转换实际对象。

上述代码使用的float和int长度均是4字节,当我们对长度不同的数据进行重新解释时,就会出现问题:

short s = 1;
short *ps = &s;
int *pa = (int*) ps;
std::cout << *pa << std::endl;

输出:

-32636927 (随机)

上述代码中的short长度是2字节,但将其解释为了一个4字节的int,那么读取的数据就不是正确的了。

在实际的面向对象编程中,我们会利用多态的特性,对一个对象指针进行类型转换,但利用以上C语言风格的类型转换在出现错误时并不会提示错误,那么在运行过程中错误的类型转换可能导致未定义的行为,从而引用程序崩溃。比如:

class Animal {};
class Dog : public Animal {};
class Cat : public Animal {};
int main(){
    Animal *pa = new Dog();
    Cat *pc = (Cat*) pa;
    // 对pc的操作...
    delete pa;
    return 0;
}

以上代码做出了错误的转换,但它可以通过编译,甚至没有任何警告,这显然不是我们希望看到的。

再例如,C风格类型转换允许将一个底层const约束的指针重新解释为无const约束:

const int a = 1;
const int *pa = &a;
int *pb = (int*) pa;
*pb = 2;

std::cout << &a << std::endl;
std::cout << pa << std::endl;
std::cout << pb << std::endl;
std::cout << a << std::endl;
std::cout << *pa << std::endl;
std::cout << *pb << std::endl;

以上代码是可以通过编译的,但如果你尝试输出它们的值,就会发现神奇的现象:

0x61fe0c
0x61fe0c
0x61fe0c
1
2
2

三个指针指向的是同一个地址,但同一个地址的变量居然会有不同的值!

事实上,像这样修改解除const约束的变量的行为是未定义行为,因此其输出可能会因编译器、编译选项甚至运行时环境的不同而有所不同,严重时会导致程序崩溃。

也就是说,你的编程环境下写下相同的代码可能会有与我不相同的结果。在实际的编程中,我们不应该做出这样解除const约束的”暴行“。然而,当我们不小心做出这样的行为时,编译器并不会提醒你做错了。

为了更灵活地进行类型转换,C++ 11 引入了四种类型转换: static_cast、dynamic_cast、const_cast 和 reinterpret_cast。

static_cast 和 dynamic_cast

static_cast主要用于基本类型或无多态类类型的转换,并提供在编译时的合法性检查,因此它的类型转换是在编译阶段完成的。

例如,将上面short指针与int指针的C风格类型转换改成static_cast:

float f = 2.5;
float *pf = &f;
int *pa = static_cast<int*>(pf);  // 报错
std::cout << *pa << std::endl;

则上述第三行代码会在编译时报错。

同样的,当我们使用static_cast解除const约束时,它也会提醒你做出了错误的行为:

const int a = 1;
const int *pa = &a;
int *pb = static_cast<int*>(pa);  // 报错
*pb = 2;

利用这个特性,我们避免在C风格那样随意的类型转换,就可以在编译阶段就避免错误的行为,从而降低程序故障的风险。

前面提到,static_cast主要用于基本类型或无多态类类型的转换,对于Java程序员可能会有疑惑,如果两个类没有继承关系,怎么可能进行转换?
其实,在C++中是可以的,比如你可以重载强制转换运算符,让一个类类型可以强制转换为int:

class Decimal {
public:
    Decimal(int a) : _a(a) {}
    // 重载强制转换为int的运算符
    operator int() {
        return _a;
    }
private:
    int _a;
};

int main() {
    Decimal d(9);
    std::cout << (int) d << std::endl; 
    return 0;
}

输出:

9

对于存在派生类的类型转换,如将前面的示例代码由C风格类型转换修改为static_cast:

class Animal {};
class Dog : public Animal {};
class Cat : public Animal {};
int main(){
    Animal *pa = new Dog();
    Cat *pc = static_cast<Cat*>(pa);
    delete pa;
    return 0;
}

第6行的转换是错误的。即便编译器会通过编译,也可能导致未定义行为,引起程序崩溃。

当存在像上述AnimalDogCat这样有继承关系的情况,但又不清楚基类实际是哪个子类时,我们就可以使用dynamic_cast进行运行时类型识别(RTTI)。

void func(Animal *animal) {
    Cat *cat = dynamic_cast<Cat*>(animal);
    if(cat) {
        cat.xxx();
    }
}

上述代码中,第2行将Animal*动态转换为Cat*,如果转换失败,则返回0,于是可以像第3行代码那样用一个if判断是否转换成功。

不过,由于类型识别是在程序运行阶段进行的,所以它相比static_cast会多耗费一些性能。如果需要转换的类型是确定的,开发者能保证类型转换的安全性,则可以使用static_cast进行转换。否则,应该使用dynamic_cast以保证转换的安全性。

static_castdynamic_cast不会移除原始变量的常量性,比如:

Cat cat;
const Animal *animal = &cat;
Cat *pcat = static_cast<Cat*>(animal);  // 报错

上述代码将无法通过编译,第3行代码的类型转换会报错。为了消除报错,需要保证const不丢失:

Cat cat;
const Animal *animal = &cat;
const Cat *pcat = static_cast<const Cat*>(animal);  // 合法

这样一来,相比C风格的盲目转换可能导致的const丢失,static_castdynamic_cast更能保证const的正确传递,从而防止无意的修改const常量的未定义行为的发生。
因此,在现代C++编程中,应该使用这种新的强制类型转换方式,以降低错误率。

const_cast

const_cast可用于解除const限定符,比如:

int a = 1;  // 原始的 a 是可修改的
const int *pa = &a;  // 对 a 进行只读限定
// do something...
int *pb = const_cast<int*>(pa);
*pb = 2;

通过解除const限定,使一个常量变得可修改。但是有一个前提,就是原始的变量本身实际上就是可修改的,只是中间某个过程中被加上了const限定符。如果原始变量本身是不可修改的,那么修改一个常量就会导致未定义行为:

const int a = 1;  // 原始的 a 是只读的
const int *pa = &a;
// do something...
int *pb = const_cast<int*>(pa);
*pb = 2;
std::cout << a << std::endl;
std::cout << *pa << std::endl;
std::cout << *pb << std::endl;

输出:

1
2
2

const_cast也能给变量加上const限定,比如当你向一个形参是底层const指针或引用的函数传入一个无const限定的指针时,可以用它加上const限定:

void func(const int *pa);
int main() {
    int a = 1;
    int *pa = &a;
    func(const_cast<const int*>(pa));
    
    return 0;
}

reinterpret_cast

reinterpret_cast用于在不同类型之间进行低级别的转换,通常用于指针或引用之间的转换。比static_cast更加“强制”地进行类型转换,因此通常会被认为是危险的,因为它可能会导致不安全或不可预测的行为。

正如它的名字,它就是为了重新解释一个类型或指针,但不会改变改变指针指向的内存地址或其内容。

由于它不进行任何类型检查,它可能会导致类型错误或未定义行为。例如,将一个对象的指针转换为一个完全不同类型的指针,然后通过该指针访问对象可能会引发不可预见的错误。

reinterpret_cast主要用于非常特殊的情况,如底层系统编程、硬件接口、操作系统开发或与 C 代码的接口等。一般情况下,应该尽量避免使用它。

例如,将整数直接转换为指针:

int a = 10;
void* ptr = reinterpret_cast<void*>(a);

void*与其他类型指针的互转:

int a = 1;
int *pa = &a;
void* ptr = reinterpret_cast<void*>(pa);
// do something...
int *pb = reinterpret_cast<int*>(ptr);

使用智能指针时的类型转换

前面提到的指针转换都是在使用C风格的原始指针的情况下,当我们使用智能指针时,STL也为我们提供了相应的类型转换方法: std::static_pointer_caststd::dynamic_pointer_caststd::const_pointer_cast。它们与原始指针的用法类似,所以不再过多赘述,

这里以std::dynamic_pointer_cast举一个例子。

std::dynamic_pointer_cast接收一个shared_ptr或一个weak_ptr,并将其转换为另一个类型的shared_ptr,这种转换只能在具有继承关系的类之间进行‌。

std::dynamic_pointer_cast是在运行时进行动态类型检查的,类似于dynamic_cast,当转换失败时,它会返回一个空的shared_ptr

于是使用智能指针进行类型转换:

std::shared_ptr<Animal> pa(new Dog());

std::shared_ptr<Cat> pc = std::dynamic_pointer_cast<Cat>(pa);
std::shared_ptr<Dog> pd = std::dynamic_pointer_cast<Dog>(pa);
if (pc) {
    std::cout << "cat" << std::endl;
} else if (pd) {
    std::cout << "dog" << std::endl;
}

输出:

dog

std::reinterpret_pointer_cast是在C++ 17时才引入的,功能和使用方法同上。

0x05 字符串

历史遗留问题——char*-based字符串

这是一个常见(应该说在旧的代码中常见)的字符串赋值语句:

char* str = "abc";

可以看到,这里使用了一个指针指向了一个字符串字面常量(String Literal)。对于不知道它的人来说,可能会有修改它的欲望,但实际上修改它会出现不可预料的后果(不同编译器可能会有不同处理方式),因为我们在尝试修改一个常量!
可是,它不应该是一个变量吗?实际上,我们仔细一看,虽然赋值符号左边是变量,但右边是一个字符串字面常量,把一个字符串字面常量赋给一个指针,想必编译器一定要做一些“小动作”才能实现。
实际上,str指向的是一个常量,这个常量的类型是const char[],所以修改它会出现严重的错误(程序崩溃等),它应该这样写才严谨:

const char* str = "abc";

使用char*-base在现在是一个不提倡的做法,即便你经常能在旧的代码中看到它。
如今应该使用char[]-base字符串或string来替代它。

感兴趣的可以阅读这篇文章:从语句 char* p=”test” 说起

处理字符

标准库中提供了这样一些方法用于判断字符类型,在cctype头文件中:

  • isalpha(c): 是否为字母
  • isdigit(c): 是否为数字
  • isxdigit(c): 是否为十六进制数字
  • isalnum(c): 是否为字母或数字
  • islower(c): 是否为小写字母
  • isupper(c): 是否为大写字母
  • iscntrl(c): 是否为控制字符
  • isprint(c): 是否为可打印格式(可视形式)
  • isgraph(c): 是否为可打印格式且不为空格
  • ispunct(c): 是否为标点符号
  • isspace(c): 是否为空白(空格、横向制表符、纵向制表符、回车符、换行符、进纸符)
  • tolower(c): 如果c是大写字母,则输出对应小写字母,否则原样输出
  • toupper(c): 如果c是小写字母,则输出对应大写字母,否则原样输出

C语言也有相关的处理,在头文件ctype.h中,而C++则是cctype头文件,且将其放在了std命名空间中。
建议使用C++版本的C语言标准库文件,以便区分C++独有而C语言没有的函数,且防止命名冲突。

字符串字面常量

当我们使用双引号字符串字面值时,这个字符串常量会存储于内存的常量区中。如果同一个字符串在代码的不同位置出现了多次,它们事实上都指向内存常量区的同一个位置:

const char* getStr() {
    return "a string";
}

int main() {
    const char *str1 = "a string";
    const char *str2 = "a string";
    const char *str3 = getStr();
    
    std::cout << reinterpret_cast<const void*>(str1) << std::endl;
    std::cout << reinterpret_cast<const void*>(str2) << std::endl;
    std::cout << reinterpret_cast<const void*>(str3) << std::endl;
    
    return 0;
}

输出:

0x405008
0x405008
0x405008

这样也合理,因为这些字符串常量相同,所以没必要复制多份字符串,可以节省内存空间。

不过,这里要强调的是,既然字符串字面常量是在常量区的,于是如果对其中的字符进行修改,是一个很危险的行为,这也是为什么上面提到应该使用const char*代替char*来表示一个字符串常量。

如果使用std::string或者char[]-base字符串接受字符串字面常量,初始化时会将这个字符串字面常量拷贝一份到栈或堆中,于是就可以合法地修改拷贝后的字符串中的字符:

const char *str1 = "a string";
std::string str2(str1);
char str3[] = {"a string"};
std::cout << "str1地址: " << reinterpret_cast<const void*>(str1) << std::endl;
std::cout << "str2地址: " << reinterpret_cast<void*>(&str2[0]) << std::endl;
std::cout << "str3地址: " << reinterpret_cast<void*>(&str3) << std::endl;
str2[0] = 'b';  // 合法
str3[0] = 'b';  // 合法
std::cout << "修改后的str2地址: " << reinterpret_cast<void*>(&str2[0]) << std::endl;
std::cout << "修改后的str3地址: " << reinterpret_cast<void*>(&str3) << std::endl;

输出:

str1地址: 0x405008
str2地址: 0x61fd20
str3地址: 0x61fd07
修改后的str2地址: 0x61fd20
修改后的str3地址: 0x61fd07

可见,str1的地址与str2str3的地址相差比较大,可推断出前者与后两者是在内存中的不同区域的。

在C++ 11还引入了原始字符串的功能,该字符串不作任何转义,例如:

// 在双引号前加上【大写】的R,字符串内的圆括号不能漏掉
std::string raw = R"(aa\nbb"\\")";
std::cout << raw << std::endl;

输出:

aa\nbb”\“

可见,原始字符串内不会识别转义字符,且可以直接使用双引号。不过,字符串开头和结尾的圆括号不能缺省。

这样的字符串对于正则表达式、大字符串尤其是含有特殊字符的字符串提供了很大的方便。

将字符串字面常量嵌入到源代码中

有时候,一个字符串可能非常大,例如一个很复杂的SQL建表语句,如果将其直接放入源代码中会显得很不优雅。

如果,将这个语句放到另一个文件中,然后再读取这个文件,是否可行呢?

但是,若运行时才读取这个文件,一方面会有性能问题,另一方面这个文件可能被用户修改,从而造成了一定的安全隐患。

能否在编译期间就将其读入一个字符串中呢?

利用 #include

#include是一个预编译指令,它的作用是将另一个文件复制到#include的位置。

使用这个技巧,我们可以将 sql 文件的内容复制到源代码中。

由于 sql 文件中的内容可能存在特殊字符,我们使用C++ 11提供的原始字符串功能:

std::string sql = R"(
#include "create_table.sql"
)";
std::cout << sql << std::endl;

但是这有一个问题,看了输出就明白了:

 
#include “create_table.sql”  

它居然成为了原始字符串的一部分。

为解决这个问题,我们把原始字符串放在 sql 文件中:

R"(CREATE TABLE IF NOT EXISTS `table_name` (
  `id` INT, 
  `anything_else` TEXT 
)");
std::string sql =
#include "create_table.sql"
;

不过,由于需要修改 sql 文件,这种方法还是不够优雅,在实际工程项目中也不建议使用。

使用 CMake

另一种方案是使用诸如 CMake 这样的构建工具的字符串替换功能。

如果使用了上述#include方案测试,记得把 sql 文件改回原来的样子,即不在 sql 文件中加上原始字符串符号R"(...)";

例如,在CMakeLists.txt中:

cmake_minimum_required(VERSION 3.14)
project(StringReplacementExample)

set(CMAKE_CXX_STANDARD 11)

file(READ "create_table.sql" FILE_CONTENT)

# 将 embedded_text.cpp.in 文件中的占位符 @FILE_CONTENT@ 替换为文件内容
configure_file(embedded_text.cpp.in generated.cpp @ONLY)
configure_file(embedded_text.cpp.in generated.cpp @ONLY)

add_executable(StringReplacementExample main.cpp generated.cpp)

embedded_text.cpp.in:

const char *embedded_text = R"(@FILE_CONTENT@)";

CMake 会将该文件中的@FILE_CONTENT@进行替换为指定内容并生成为generated.cpp

由于我们将main.cppgenerated.cpp一同编译,因此main.cpp在同一个命名空间下可以使用extern使用同一个全局变量:

// main.cpp
extern const char *embedded_text;
int main() {
    std::cout << embedded_text << std::endl;

    return 0;
}

输出:

CREATE TABLE IF NOT EXISTS `table_name` (
`id` INT,
`anything_else` TEXT
)

这种方法无需修改 sql 文件,且不需要像#include方案那样使用奇怪的换行或缩进,是比较推荐的用法。

未来的方案: #embed

在未来的 C++ 26 或 C 23 可能会引入#embed预编译指令,以实现更方便、优雅的文件嵌入。

不过,目前(本文编写时间)还用不了它,可以期待一下。

char[]-base 字符串

在C语言中,并没有“字符串”这样的类型,字符串本质上是一个个字符串联起来,可以理解为一个字符数组。

那么,如果我们直接用一个char数组表示一个字符串并输出它:

char str[5] = {'h','e','l','l','o'};
std::cout << str << std::endl;

输出:

hello?????

可以发现,输出的字符串后面多出了几个随机的字符。

我们在前面学习过,C++的数组在函数中传递时会退化成指针,于是在传递一个数组时不一定知道数组的大小。
因此,我们规定,在逐字符遍历一个字符串时,如果遇到字符'\0',也就是整数0,即为字符串的结尾。

这样一来,一个字符数组表示的字符串就可以表示为:

char str[6] = {'h','e','l','l','o','\0'};
std::cout << str << std::endl;

输出:

hello

由于使用花括号初始化一个数组时,未指定的部分都会用0代替,所以你可以声明一个大的字符数组,用来存储长度可变但有最大长度限制的字符串:

char str[1024] = {'h','e','l','l','o'};
std::cout << str << std::endl;

上述代码中,数组只指定了前5个字符的值,于是剩余的其他字符都是字符串的结尾'\0'
这个数组的大小是1024,但它只能正确地表示1023个字符所组成的字符串,因为还需要至少一个字符'\0'放在数组的末尾用来表示字符串的结束。

如果一个字符数组中,提前遇到了'\0',也会被认为是字符串的结束,从而导致后面的内容被忽略:

char str[1024] = {'h','e','l','l','o','\0','w','o','r','l','d'};
std::cout << str << std::endl;

输出:

hello

当然,你也可以设计一个函数,用额外的一个整型记录数组大小,从而让数组最后一个元素也可以被利用上而不是使用一个结束符。不过因为多出了一个整型,实际并没有节省资源,反而需要更多的字节存储这个整型,因为一个char只占一个字节,而一个整型可能不只。

当使用双引号定义一个字面值时,它实际上自动包含了这个末尾标识,只是不需要手动写出来,且可以赋给字符数组。因此它的长度实际会比它看起来多一个字符:

char str[6] = "hello";

当把一个双引号字符串字面赋给一个数组时,需要保证数组能够容纳得下这个字面值,否则会报错:

char str[5] = "hello";  // 错误,至少要6个字符,因为有末尾自动添加的 \0
char str2[1024] = "hello";  // 合法

C风格的字符串处理

对于const char*char[]这样的C风格字符串,标准库中提供了一些方法来实现一些基本操作。

虽然可以通过这些方法处理字符串,但手动管理内存很容易出错,且不够灵活,通常不在C++中使用,后面会介绍别的方法。

在C语言中,使用这些方法需要引入<string.h>头文件,在C++中则是<cstring>头文件。

字符串拷贝

char str[6] = "hello";
char str2[6];
strcpy(str2, str);
std::cout << str2 << std::endl;

字符串拼接

char src[6] = "world";
char dest[50] = "hello ";
strcat(dest, src);
std::cout << str2 << std::endl;

输出:

hello world

dest中有效部分后的'\0'会被覆盖,而src末尾的则会保留。

需要保证dest还有足够的空间存储两个待连接的字符串,否则会出现溢出错误。

字符串查找

strchr

char src[50] = "hello world";
char *p = strchr(src, 'l');
int index = p - src;
std::cout << "字符'l'第一次出现的下标: " << index << std::endl;
std::cout << "其之后的内容是: " << p << std::endl;

输出:

字符’l’第一次出现的下标: 2
其之后的内容是: llo world

如果字符未找到,则以上代码会出问题:

char src[50] = "hello world";
char *p = strchr(src, 'z');
int index = p - src;
std::cout << "字符'z'第一次出现的下标:" << index << std::endl;
std::cout << p << std::endl;

输出:

字符’z’第一次出现的下标:-6421984
其之后的内容是:

为防止这种情况出现,需要分别对找到和没找到做不同处理:

char src[50] = "hello world";
    char *p = NULL;
    p = strchr(src, 'z');
    if (p != NULL) {
        int index = p - src;
        std::cout << "字符'z'第一次出现的下标:" << index << std::endl;
        std::cout << p << std::endl;
    } else {
        std::cout << "未找到字符'z'" << std::endl;
    }

输出:

未找到字符’z’

strstr

char src[50] = "hello world and C++!";
char *p = NULL;
p = strstr(src, "world");
if (p != NULL) {
    int index = p - src;
    std::cout << "字符串\"world\"第一次出现的下标: " << index << std::endl;
    std::cout << p << std::endl;
}

输出:

字符串”world”第一次出现的下标: 6
world and C++!

std::string

为了更方便地表示和处理字符串,C++标准库提供了string表示可变长的字符序列。

定义和初始化string对象

这里要再次提醒,要对C++的初始化有敬畏之心,特别是Java和Python程序员。

比如,声明一个字符串,看起来和声明一个int差不多,但它事实上会进行初始化:

std::string str;

与声明一个int但不初始化不同,以上写法会调用默认的初始化,创建一个空字符串,而不是像声明一个int但不初始化那样是一个随机的内容。

当我们使用等号将一个字符串字面值赋给字符串时,实际上是调用了string的一个构造函数,并将字符串字面值拷贝给它:

std::string str = "test";

以上代码中,“test”的类型是一个const char[5]的字面类型,也就是说它最后自带一个空字符’\0’。

直接初始化的方法:

std::string str("test");

要注意的是,如果是这样写,仍是拷贝初始化,因为它显式地创建了一个临时的对象,然后再拷贝给str:

std::string str = std::string("test");

我们一般不用这个方法,因为它做了不必要的拷贝,且可读性不如上述直接初始化的方法。

string还有一个构造函数,可以生成多个重复字符:

std::string str(5, '*');
std::cout << str << std::endl;

输出:


当我们用等号将一个string对象赋给另一个string对象时:

std::string s1("test");
std::string s2 = s1;

是将s1内容的副本替换掉s2的。

string的操作

string提供了许多有用的方法,包括一些重构了的运算符。

常用查询:

  • str.size(): 返回str中字符个数,不包括空字符’\0’,注意返回的std::string::size_type是unsigned的
  • str.length(): 同上
  • str.empty(): 字符串str是否为空,是返回true,否返回false
  • s1 == s2, s1 != s2: 判断两字符串的内容是否一致,大小写敏感
  • <, <=, >, >=: 利用字符在字典中的顺序进行比较,大小写敏感
  • str[i]: 返回str中第i个字符的引用,下标i从0开始,不会进行越界检查,直接返回数组中某位置的引用
  • str.at(i): 同上,但会进行越界检查
  • str.front(): 返回str中第一个字符的引用
  • str.back(): 返回str中最后一个字符的引用
  • str.find_first_of(): 返回str中第一次出现指定字符或字符串的下标,如果没有则返回-1
  • str.find_last_of(): 返回str中最后一次出现指定字符或字符串的下标,如果没有则返回-1
  • str.substr(int start, int count=-1): 返回从start开始的count个字符的子串,若count为-1或超过了字符串末尾,则得到的是到末尾的子串
  • str.c_str(): 返回C风格字符串的char指针

注意: 当使用str.size()(或str.legnth())进行比较大小时一定要小心,因为它返回的是unsigned类型,所以当你拿它与一个负数比较时,这个负数会被自动转换为一个很大(非常大!)的无符号类型,从而导致它(一个正数)几乎永远比这个负数小。

字符串连接:

  • str.append(xxx): 将某内容追加到字符串后,返回原字符串的引用
  • s1 += s2: 同上
  • s1 + s2: 将s2追加到s1后,返回追加后字符串的拷贝

注意: 使用加号连接多个字符串和字符序列字面值时,需保证每个加号两边至少有一个是string类型,以下写法是非法的:

std::string s1 = "test";
std::string str = "aaa" + "bbb" + s1;  // 错误: 前面两个字面值不能相加

但是以下写法是合法的:

std::string s1 = "test";
std::string str = s1 + "aaa" + "bbb";  // 合法

因为加号的顺序是从左到右,第一个string类型字符串与第一个字面值相加后得到一个string类型字符串,然后它再与第二个字面值相加。

切记诸如”test”这样的字面值并不是string类型的,而是const char[](或者说const char*)类型。

当需要连接一个字符串和一个数字时,需要使用std::to_string将数字转为一个字符串,否则会将数字视作一个字符:

int balance = 66;
std::string str1("余额: ");
std::string str2("余额: ");
str1 += balance;
str2 += std::to_string(balance);

输出:

余额: B
余额: 66

除此之外,std::string还有大量有用的方法,可以查看其文档并根据实际需求使用。

使用 range for 访问string中的字符

C++11 提供的 range for 语句可用于遍历序列,也能用来方便地遍历字符串中的字符:

std::string str = "test123";
for (auto c : str) {
    if (std::isdigit(c))
        std::cout << c << std::endl;
}

输出:

1
2
3

还记得auto会去除引用属性吗?如果需要遍历并通过引用修改字符串的内容,则需要给auto加上引用标志:

std::string str = "test123";
for (auto &c : str) {
    if (std::isdigit(c))
        c = 'x';
}
std::cout << str << std::endl;

输出:

testxxx

或者将auto改为decltype(auto),不过这是C++ 14的特性,需要保证编译器支持C++ 14。

将字符串转换为基本数据

思考这样一个问题,假设读取了一串字符串数据,想把它转为基本数据,如”123”、”3.14”,此时如何将这些字符串转换为我们想要的数字呢?

有人可能会想,进行强制类型转换是否可行呢?实际上是不可行的,因为它们在内存中的二进制数据完全不一样,例如”123”相当于:

char str[4] = {49, 50, 51, 0};
std::cout << str << std::endl;

输出:

123

也就是说,”123”是由4个字符(或数字)所组成的。

C语言方法: atoi 和 atof

char int_str[] = "123";
char double_str[] = "3.14";
int a = atoi(int_str);
double b = atof(double_str);

C++方法: std::stoi 和 std::stod

相比于C语言的方法,这两个函数可以检查输入的字符串是否是合法的数字,如果不合法则会抛出异常:

  • std::invalid_argument: 输入字符串非法
  • std::out_of_range: 数据超出范围
std::string int_str = "123";
std::string double_str = "3.14";
std::string invalid_example = "a123";
std::string out_of_range_example = "999999999999999999999999999999";

int a = std::stoi(int_str);
double b = std::stod(double_str);
try {
    int c = std::stoi(invalid_example);
} catch (std::invalid_argument &e) {
    std::cout << "数据不合法" << std::endl;
}
try {
    int d = std::stoi(out_of_range_example);
} catch (std::out_of_range &e) {
    std::cout << "数据超出范围" << std::endl;
}

将基本数据转为字符串

C语言中的方法: sprintf

int a = 123;
char num_str[4];
sprintf(num_str, "%d", a);
char str[100] = "变量a的值是: ";
strcat(str, num_str);
printf("%s", str);

输出:

变量a的值是: 123

std::to_string

std::to_string是C++ 11提供的方法,它可以将intdouble等转为字符串:

double d = 3.14;
std::string str(std::to_string(d));

std::stringstream

std::stringstream提供了一个类似于操作流的方式操作字符串的方法,使用它需要引入头文件<sstream>

在使用它连接多个字符串和基本数据时,它可以自动将基本数据转为字符串,而不会把它视作字符:

std::string username = "Lyzen";
    int balance = 66;
    std::stringstream ss;
    ss << "用户" << username << "的账户余额为" << balance;

    std::cout << ss.str() << std::endl;

输出:

用户Lyzen的账户余额为66

当需要处理大量的小字符串连接时,std::stringstream的性能会比直接使用std::string更好些。

0x06 函数

其实本章一些内容已经放在 函数的指针 这一节了,强引用与弱引用 这一节也涉及到一点编译器扩展提供的函数特性,但是还是有必要单独用一章讲解一下函数的其他特性。

整合回顾前面提到的细节

前面提过,对于一个函数来说,它不能返回一个数组或函数,但可以返回指向数组或函数的指针或引用(引用作为函数返回值)。

对于传入函数的实参,为了避免产生无意义的拷贝,可以将形参声明为指针,这样只有指针会被拷贝,指针所指向的对象不会发生拷贝。在C++中,建议使用引用作为形参来避免拷贝,这样连指针也不会发生拷贝了(左值引用和右值引用)。
不过,当一个形参被声明为非常量引用时,它能够接收的数据会有限制。因此如果函数不会改变引用形参的值,建议将其声明为常引用,这样一来常量和字面值也能够传入该函数中。

一个函数可以返回一个值或不返回值,不过有时候我们希望能够返回两个或多个值。虽然我们可以将多个值包装成一个对象(如结构体或类)再返回,但这样一来我们可能需要定义非常多的结构体或类。另一种方法就是利用引用形参,通过在函数中给多个引用赋值,来实现返回多个值的效果。

函数的声明

关于函数的声明,我有另一篇文章介绍C++声明函数有趣的特性,这里不再重复讲解。

静态函数(内部函数)与外部函数

当一个程序由多个文件构成时,有一些函数只会在它所在的文件中被调用,不会提供给其他文件使用,这时我们就可以把它声明为static:

static void func();

这样的函数被称为内部函数(也叫静态函数),由于它只能在该文件内被调用,你也无需担心在其他文件中有同名函数的重复定义问题。

相应的,在函数声明前加extern,代表它为外部函数,即该函数在其他文件中定义。函数声明默认为外部函数,所以这个extern可以省略。

此外,这种在文件域中的static特性已被标记为”不建议使用“。所以,你了解了在(非类里的)函数前加static有什么作用了,你能在某些C++代码中看到它并知道它的作用,但在现代C++编程中,你不使用它。

空形参函数

对于一个没有形参的函数,在大多数编程语言中都可以这样写:

void func() {
    // do something ...
}

不过,在C语言中,你必须在圆括号中添加一个void,表示这个函数没有形参:

void func(void) {
    // do something ...
}

在C++中,虽然可以不加上这个void,但为了兼容C,你可以选择把它加上。

匿名形参

在声明一个函数时,如果函数形参的名称没有意义,则可以不指定名称,例如:

// 声明函数
int min(int, int);

// 定义函数,即编写函数的实现
int min(int a, int b) {
    return a < b ? a : b;
}

这种做法通常用于声明时只关心函数的接口,而不关心具体实现。

在定义函数时,形参也可以是匿名的,但是这样一来该形参就无法被函数访问了:

int min(int, int b) {
    // 无法获取第一个参数,无法比较
}

虽然这样是合法的,但第一个形参是无意义的。在什么情况下函数需要一个形参但不访问它呢?

不过,在大部分情况下,都应该在声明函数时为形参指明有意义的名称,例如:

void join(std::string playername, std::string server);

假设上述代码改为:

void join(std::string, std::string);

则难以确定两个参数分别代表什么意义,即便在该函数的实现部分有指明名称,但对于调用该函数的用户来说,IDE提示的是声明的函数的形参。
比如,在join(std::string playername, std::string server)中,IDE会直接显示playernameserver,而join(std::string, std::string)则仅显示类型,没有给出任何提示,导致使用者必须依赖文档或者进一步查看函数的定义。

内联函数与 constexpr 函数

函数的调用是一个复杂的过程,例如某些机器中,调用一个函数需要保存当前寄存器的内容、将传入函数的参数压栈、将程序转向一个新的位置继续执行、执行完毕时将返回值压栈、恢复原来的寄存器内容、返回到调用该函数的位置继续执行等。具体过程可以阅读计算机组成原理相关资料。

对于一个很简单的函数,例如一个获取大的一个整型的引用的函数:

int& greater(int &a, int &b) {
    return a > b ? a : b;
}

假设这个函数需要被经常调用(例如在一个循环次数很多的循环中),多次地调用函数是比直接把函数体展开在代码中的。但是,为了程序的阅读性,我们又需要这样的函数,且该函数还能在别的代码中被重复利用。

内联函数

我们可以把该函数设定为内联函数,它可以在编译时自动在调用点展开:

inline int& greater(int &a, int &b) {
    return a > b ? a : b;
}

这样一来,我们既保证了程序的可阅读性与可复用性,又保证了性能。

不过,inline只是给编译器的一个提示,编译器是可以忽略这个提示的。在某些编译器中,即便没有inline,它也可能会自动优化,自动地展开简单的函数以提高性能。

如果一个函数很复杂,例如存在递归的情况,那么它就不应该被设置为内联函数。一般来说,内联函数只用于优化规模较小、流程直接、频繁调用的函数。

通常我们会把内联函数定义在头文件中,而不是在头文件中声明再在源代码文件中定义。

使用内联函数替代宏

在C中,我们常用宏去实现一些简单函数以提高性能,例如:

#define SQUARE(x) (x * x)

宏相当于字符串替换,效率是比较高的,但是它也有很多问题,比如当使用上述宏但输入的参数是表达式时:

int a = SQUARE(2 + 3);

于是宏展开后会变成:

int a = 2 + 3 * 2 + 3;

我们期望得到的是25,但结果却是11。

为了防止这种情况的出现,我们修改宏为:

#define SQUARE(x) ((x) * (x))

这样就可以正确处理表达式的情况了。

然而,它还是存在一系列的问题,比如宏并不支持命名空间,可能产生命名冲突。再比如它不支持类型检查,于是当类型错误时它的报错会在宏展开之后产生,增大了错误排查的难度。

在C++中,你更应该使用内联函数来替代它:

template<typename T>
inline
auto square(T x) -> decltype(x * x) {
    return x * x;
}

因为内联函数也会在调用处展开,避免了函数调用的额外性能消耗(函数跳转、参数及返回值的入栈出栈、现场保护/恢复等开销),且当投入一个不支持乘法的类型时也会及时得到编译器报错,比使用宏的方式更加安全。

constexpr 函数

常规 constexpr 函数

constexpr函数是指能用于常量表达式的函数,不过它的返回值类型是字面值类型,所有形参都是字面值类型或常量表达式。

在C++ 11,函数体中必须有且只有一条return语句。在C++ 14,函数体中可以有多条return语句。

constexpr函数被隐式地定义为内联函数,且该函数的运算结果会在编译阶段完成。因此,你不能朝一个constexpr函数投入一个变量作为参数。

constexpr double square(double a) {
    return a * a;
}

constexpr double distanceSquare(double x1, double x2, double y1, double y2) {
    return square(x1 - x2) + square(y1 - y2);
}

int main() {
    double distanceSquare1 = distanceSquare(5, 1, 4, 3);  // 合法

    constexpr double x1 = 5, x2 = 1, y1 = 4, y2 = 3;
    double distanceSquare2 = distanceSquare(x1, x2, y1, y2);  // 合法

    double x3, x4, y3, y4;
    std::cin >> x3 >> x4 >> y3 >> y4;
    double distanceSquare3 = distanceSquare(x3, x4, y3, y4);  // 非法

    return 0;
}

对于distanceSquare1distanceSquare2,它们都是常量表达式的计算,能在编译阶段就得到结果。
对于distanceSquare3,它不能在编译阶段就计算出结果,因此是非法的。但是,编译器可能会对其进行优化,同时生成这个函数的常量表达式和非常量表达式版本。使之在投入非常量表达式时,调用一个非constexpr版本的函数,从而同时支持编译时计算出结果和在运行时计算结果。不过要注意,不是所有的编译器都支持这样做。

此外,constexpr函数的函数体中不能调用非constexpr函数,且函数体中不能抛出异常。

constexpr函数也可以递归调用,不过要注意避免无限递归。通常编译器会有一个对constexpr递归深度的限制,例如GCC默认是512,可以在编译时使用参数-fconstexpr-depth修改。

通常我们会把constexpr函数定义在头文件中,而不是在头文件中声明再在源代码文件中定义。

为类支持 constexpr

为了让类对象可以赋给 constexpr 常量,可以给类的构造函数加上constexpr,其成员函数也可以加上constexpr:

class Complex {
public:
    constexpr Complex(double real=0, double imaginary=0) : _real(real), _imaginary(imaginary) {}

    constexpr double getReal() const { return _real; }
    constexpr double getImaginary() const { return _imaginary; }
    constexpr Complex operator*(const Complex& b) {
        return {this->_real * b._real - this->_imaginary * b._imaginary,
                this->_real * b._imaginary + this->_imaginary * b._real};
    }
private:
    double _real;
    double _imaginary;
};

constexpr Complex a(1.5, 2.5);
constexpr double real = a.getReal();
constexpr double imaginary = a.getImaginary();
constexpr Complex b(2.5, -1.5);
constexpr Complex c = a * b;

对于这样的类,如果有一个成员函数对类中的成员变量进行修改,例如:

class Complex {
public:
    constexpr Complex(double real=0, double imaginary=0) : _real(real), _imaginary(imaginary) {}

    constexpr void setReal(double real) { _real = real; }
    constexpr void setImaginary(double imaginary) { _imaginary = imaginary; }
private:
    double _real;
    double _imaginary;
};

则是在C++ 14之后才允许的。

返回值类型

常规左置返回类型

我们常在函数名的左边指明函数的返回值类型,例如:

int func();

则是返回值类型为int

对于一些比较复杂的情况,例如返回一个指向容量为10的int数组的指针,则是:

int arr[10];

int (*func())[10] {
    int (*parr)[10] = &arr;
    return parr;
}

可见,由于一些历史原因,使得这个函数的返回值类型难以辨认。

有没有办法,能让返回值类型指明为int(*)[10]这样比较容易阅读的形式呢?

使用 decltype

decltype是C++ 11引入的,前面的章节也有介绍过它的使用,它也可以用来代替上述情况:

int arr[10];

decltype(arr) *func() {
    int (*parr)[10] = &arr;
    return parr;
}

因为arr是个数组,我们希望得到的是指向数组的指针,于是在func前加上了星号*

尾置返回类型

C++ 11 提供了另一种指定返回值类型的方法: 尾置返回类型。

对于上述情况,返回值类型可写为:

auto func() -> int(*)[10];

这样一来,相比于上面的decltype(arr)还要去查看arr的类型,返回值类型显然更明显。

尾置返回类型与decltype并用,在模板编程中特别有用。

例如,不使用decltype时,用模板实现add函数:

template<typename T, typename V>
auto add(T t, V v) {  // C++ 14
    return t + v;
}

int main() {
    auto str1 = add(std::string("Hello"), "World");  // 合法
    auto str2 = add("Hello", "World");  // 非法,但报错不在这行
    
    return 0;
}

对于auto str2 = add("Hello", "World");,因为两个字符串字面常量不能直接相加,于是这肯定不能通过编译。但是,报错的最终位置居然不在这一行,而是在add函数的return t + v;这一行,特别是用户在使用IDE编写代码时,IDE就不会告诉用户调用add的这一行代码有误,而是没有任何报错,直到用户按下编译。这显然给错误的排查增加了困难。

使用decltype时:

template<typename T, typename V>
auto add(T t, V v) -> decltype(t + v) {
    return t + v;
}

int main() {
    auto i = add(1, 2);
    std::cout << typeid(i).name() << std::endl;
    auto d = add(3.14, 4);
    std::cout << typeid(d).name() << std::endl;
    auto str = add(std::string("Hello"), "World");
    std::cout << typeid(str).name() << std::endl;
    
    return 0;
}

输出:

i
d
NSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

其中,第一个输出i代表int;第二个d代表double;第三个一长串代表std::string,读者测试环境的不同可能会略有差异。

当用户尝试投入不合法的参数,即无法相加的参数时,报错的位置会在用户代码中:

auto str = add("Hello", "World");  // 报错: 找不到匹配的函数

也就是说,虽然add是个模板类型,但无法推断出"Hello" + "World"的类型(两个字符串字面常量不能直接相加),于是编译器不认为调用的是这个函数。
相比于不使用decltype的版本,新版本的一大进步是,当用户错误地调用我们的函数时,编译器或IDE的报错会指示用户的代码,而不是我们的add,这样一来错误就能够及时排查。

为了支持两个字符串字面常量的add,我们重载add函数,当第一个参数为字符串字面值时,转化为std::string,以便利用其重载的加号运算符:

template<typename T, typename V>
auto add(T t, V v) -> decltype(t + v) {
    return t + v;
}

template<typename V>
auto add(const std::string &t, V v) -> decltype(t + v) {
    return t + v;
}

int main() {
    auto str = add("Hello", "World");  // 合法
    return 0;
}

如果把decltype放前面,则会报错:

template<typename T, typename V>
decltype(t + v) add(T t, V v) {  // 报错: 找不到变量 t 和 v
    return t + v;
}

因为放左边时变量tv还未定义,这也是为什么需要把decltype(t + v)尾置。

重载调用函数运算符与函数对象

如果一个类重载了调用函数运算符,我们就可以像调用函数那样使用类:

MyClass m;
auto a = m(arg);

这样的对象被称为函数对象。因为这样的类同时也能存储状态,所以它更加灵活。

例如下面的这个函数对象类:

struct MyAdder {
    int operator()(int a, int b) {
        int res = a + b;
        overflow = (a > 0 && b > 0 && res < 0) || (a < 0 && b < 0 && res > 0);
        return res;
    }
    bool overflow;
};

于是就可以这样调用这个函数对象且判断是否溢出:

MyAdder f;
int res = f(1234567890, 987654321);
std::cout << "结果: " << res << " 是否溢出: " << f.overflow << std::endl;

输出:

结果: -2072745085 是否溢出: 1

含有可变形参的函数

省略符形参

省略符形参是C语言的内容,在C++中通常只用于处理C与C++通用的类型,通常无法处理类类型,因为大多数类类型的对象在传递给省略符形参时都无法正确拷贝。因此,省略符形参在C++中比较少用。

使用省略符形参需要引入<stdarg.h>,在C++中我们可以引入<cstdarg>,使用方法如下:

#include <cstdarg>
int sum(int size, ...) {
    va_list valist;  // 形参列表
    va_start(valist, size);  // 使 valist 指向参数 size 后的第一个参数
    int result = 0;
    for (int i = 0; i < size; i++) {
		result += va_arg(valist, int);  // 这里的int表示参数类型为int
	}
	va_end(valist);  // 释放 valist
    return result;
}

调用该函数时,需要在第一个形参输入参数的数量,例如,需要计算1+2+3,共三个参数,则:

sum(3, 1, 2, 3);

这是因为,虽然投入给函数的参数数量是可变的,但在调用该函数时,投入的参数会连续地存放在栈中。

如果第一个参数错误,则结果会出现错误。

  • 第一个参数少于实际参数数量时,例如sum(2, 1, 2, 3);,则第四个参数3会被忽略,即计算的是1+2
  • 第一个参数多于实际参数数量时,例如sum(4, 1, 2, 3);,则结果会出乎意料

va_arg用于获取下一个参数,在上面的代码中,后续的所有参数都是int类型,如果是不同的类型,则可以这样做:

int arg1 = va_arg(valist, int);
double arg2 = va_arg(valist, double);
T arg3 = va_arg(valist, T);

如果类型错误,结果也会产生错误。例如在上面的sum中投入一个double类型的参数,由于sum只接受int,投入的double会读取错误而不是发生转换,且后续参数都会产生错误(因为这里的double是8字节,而int是4字节,可以理解为发生的是指针的reinterpret_cast)。

当然,形参中的...前可以不只一个参数,只要va_start指向了...前的最后一个参数:

int func(double d, const char *str, int arg_size, ...) {
    va_list valist;
    va_start(valist, arg_size);
    // anything else ...
}

如果你尝试在...前不写任何形参,你会发现它是合法的:

int func(...){
    // anything else
}

这样一来,虽然函数func可以接受任意数量的实参,但func无法访问传入的参数,因此是无意义的。

可见,C语言的这种可变参数的功能用起来是比较麻烦的,而在C++中,我们有更好的方法使用可变参数。

std::initializer_list

C++ 11 引入了一种新的标准库容器std::initializer_list。由于它是模板类型,使用时需要指定一个类型,例如当可变参数都是int时:

int sum(std::initializer_list<int> list) {
    int result = 0;
    for (int arg : list) {
        result += arg;
    }
    return result;
}

因为std::initializer_list是个标准库容器,所以你即可以用迭代器访问其中的元素,也可以像上面的代码那样使用 for range 访问。

为了调用该函数,可以使用花括号框选住可变的参数列表:

sum({1, 2, 3});

也就是说,可以有多个不同类型的可变参数,例如:

double sum(std::initializer_list<int> int_list, std::initializer_list<double> double_list) {
    double result = 0;
    for (int arg : int_list) {
        result += arg;
    }
    for (double arg : double_list) {
        result += arg;
    }
    return result;
}

调用该函数:

sum({1, 2, 3}, {3.14, 1.7});

如果不需要投入double类型的参数,则可以使用空的花括号:

sum({1, 2, 3}, {});

对于使用花括号调用构造函数来初始化一个类对象时,std::initializer_list可能会劫持参数,可见初始化对象时的疑惑

但是,这样使用两个花括号实现不同参数的方法不太优雅,有没有更好的方法呢?

可变参数模板

C++ 11 还引入了可变参数模板,使用方法如下:

template<typename T>
T sum(T t) {
    return t;
}

template<typename T, typename... Args>
T sum(T t, Args... args) {
    return t + sum(args...);
}

int main() {
    std::cout << sum(3.14, 1, 2) << std::endl;
    return 0;
}

以上代码可能看起来云里雾里的,为什么要有个模板函数T sum(T t)返回它自己?

其实它利用了递归的特性,在调用sum(3.14, 1, 2)时,首先落入了第二个sum函数,第一个参数3.14落入参数t,而第二和第三个参数12落入args,并递归调用sum
在下一层sum中,参数1落入了参数t,参数2落入了参数args,由于args只剩一个参数,于是最后一次调用sum调用的是第一个返回T自己的sum函数,递归结束。

不过这段代码有一个问题,因为投入sum的第一个参数是double类型,于是后续参数都被推导为double类型。如果是这样调用:

sum(1, 2, 3.14);

由于第一个参数1是int类型,于是后续参数都被推导为int类型,包括参数3.14,最后得到结果6。如果我们希望,投入的参数中如果出现double值,无论它出现在哪,结果都是double值,又要怎么做呢?

为了修改这段代码,我们将第二个sum函数的返回类型修改为自动推导,但是使用decltype可能会让编译器推导时出现错误。另一种做法是利用<type_traits>库的std::common_type来实现推导:

#include <iostream>
#include <type_traits>  // 包含 std::common_type

// 基本情况:只有一个参数时返回该参数本身
template <typename T>
T sum(T t) {
    return t;
}

// 递归情况:处理第一个参数并递归处理剩余的参数
template <typename T, typename... Args>
auto sum(T t, Args... args) -> typename std::common_type<T, Args...>::type {
    return t + sum(args...);  // 递归调用 sum,处理剩下的参数
}

int main() {
    std::cout << sum(1, 2, 3.14) << std::endl;  // 输出 6.14
    return 0;
}

对于<type_traits>库的详解会在模板编程的章节中提到。

使用折叠表达式简化

C++ 17引入了折叠表达式,可以极大地简化可变参数模板的递归操作,于是以上代码可修改为:

template <typename... Args>
auto sum(Args... args) {
    return (args + ...);  // 折叠表达式:对所有参数求和
}

int main() {
    std::cout << sum(1, 2, 3.14) << std::endl;  // 输出 6.14
    return 0;
}

它的含义是将参数包args中的所有参数依次相加,相当于arg1 + (arg2 + (arg3 + ...))。这样一来,我们就不必使用上面递归的技巧,不仅简化了代码,也使代码变得更加容易阅读。不过要注意的是,折叠表达式是C++ 17才支持的用法。

使用 std::variant 解决不同类型参数问题

C++ 17引入了std::variant,它是一个类型安全的联合体(union),可以存储一组类型中的任意一种。使用std::variant可以解决处理不同类型可变长参数的问题,尤其是在需要处理多种类型且类型在运行时可能变化的情况下:

#include <iostream>
#include <variant>
#include <vector>

// 定义支持的类型的 variant
using VariantType = std::variant<int, double>;

// 递归求和函数
template <typename... Args>
auto sum(Args... args) {
    // 将参数包装到 variant 中
    std::vector<VariantType> variants = {args...};

    // 初始化结果为 double 类型,以支持整数和浮点数的混合运算
    double result = 0.0;

    // 遍历 variant 并求和
    for (const auto& v : variants) {
        // 使用 std:
        // :visit 访问 variant 的值
        // 这里使用到了 Lambda 表达式,如果对 Lambda 表达式不熟悉的可以先看后面章节
        std::visit([&result](auto&& arg) {
            result += arg;
        }, v);
    }

    return result;
}

int main() {
    // 测试
    std::cout << sum(1, 2, 3.14) << std::endl;   // 输出 6.14
    std::cout << sum(1.5, 2.5, 3) << std::endl;  // 输出 7.0
    return 0;
}

相比前面的方法,使用std::variant明确定义了支持的类型,这种显式定义使得代码的意图更加清晰,避免了模板推导中可能出现的意外行为。
此外,如果需要支持更多类型,只需修改variant的定义,例如添加floatlong:

using VariantType = std::variant<int, long, double, float>;

如果尝试访问variant中未存储的类型,std::variant会抛出异常std::bad_variant_access。这种机制使得错误处理更加明确和可控。

回调函数与函数的包装

回调函数基础

有些时候,我们需要利用回调函数来实现一定的功能。

举一个具体的例子,假设有某个系统,提供了实现监听器的接口,你可以为其提供一个函数或函数指针来实现当某个事件发生时,执行一定的代码:

void on_click(Button btn, User user) {
    open_url(user, "https://blog.lyzen.cn");
}

void initialize() {
    Button btn(Position(0, 0));
    btn.listeners.add(on_click);
}

一种实现回调函数的方法举例:

int run(int func(int, int), int a, int b) {
    return func(a, b);
}

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

int main() {
    std::cout << run(add, 1, 2) << std::endl;
    std::cout << run(multiply, 3, 4) << std::endl;
    return 0;
}

输出:

3
12

上述代码中run函数中的func参数是函数的声明,另一种方法是将run中的func参数设置为函数的指针:

int run(int (*func)(int, int), int a, int b);

实际上,前一种方法编译器会隐式地将回调函数转为指针,而后一种方法使用更加广泛。调用run时,两种方法投入的函数addmultiply均可加可不加取地址符&

Lambda 表达式

上面两种方法我们都需要定义函数addmultiply,或者on_click,将事件的处理与添加监听器的代码分离。

C++ 11 引入了 Lambda 表达式,利用 Lambda 表达式,我们就可以同时定义并传入函数:

void initialize() {
    Button btn(Position(0, 0));
    btn.listeners.add([](Button btn, User user) {
        open_url(user, "https://blog.lyzen.cn");
    });
}

可见 Lambda 表达式实际上一个匿名函数。
准确来说,Lambda 表达式是函数对象,它实际上创建了一个匿名类,在该类中含有一个重载的函数调用运算符。想想 Lambda 函数有捕获功能,为了保存一个捕获的变量,需要用到函数对象的特性

Lambda 表达式的格式是:
[capture-list] (parameters) mutable -> return-type { statement }
其中,

  • [capture-list]: 用来捕获上下文的变量供 Lambda 函数使用
  • (parameters): 是函数的参数列表,如果函数没有参数,可以省略(包括圆括号也能省略)
  • mutable: 默认情况下,Lambda 函数总是一个 const 函数,如果需要取消函数的常量性,可以加上lambda,不过加上它时参数列表不可省略
  • -> returntype: 指明函数的返回值类型,没有返回值时可以省略,有返回值时通常也可省略以让编译器自动推导

对于[capture-list]:

  • [var]:表示值传递方式捕获变量var
  • [&var]:表示引用传递方式捕获变量var
  • [this]:表示值传递方式捕获当前的this指针
  • [=]:表示值传递方式捕获所有父作用域中的变量(包括this)
  • [&]: 表示引用传递捕获所有父作用域中的变量(包括this)

于是,当一个函数需要传入一个回调函数,但我们又希望它什么都不做时,可以传入一个最简单的空的 Lambda 函数[]{}:

Button createButton(Position pos, void (*on_load)());

void initialize() {
    Button btn = createButton(Position(0, 0), []{});
}

为了使我们的run支持 Lambda 表达式,我们可以把它修改为接受函数指针的形式:

int run(int (*func)(int, int), int a, int b) {
    return func(a, b);
}

int main() {
    int res = run([](int a, int b) { return a / b; }, 10, 2);
    std::cout << res << std::endl;

    return 0;
}

输出:

5

如果func是函数声明的形式,则无法使用 Lambda 表达式。

不过,使用函数指针仍然有一个问题,就是无法捕获变量。为了能够捕获变量,我们可以采用模板:

template<typename Func>
int run(Func func, int a, int b) {
    return func(a, b);
}

int main() {
    int v = 10;
    int res = run([v](int a, int b) { return v / a + b; }, 2, 1);
    std::cout << res << std::endl;

    return 0;
}

如果我们需要在 Lambda 函数中对捕获的变量进行修改,那么就需要捕获这个变量的引用:

int main() {
    int v = 10;
    int res = run([&v](int a, int b) { v = v / a + b; return v; }, 2, 1);
    std::cout << res << std::endl;
    std::cout << v << std::endl;

    return 0;
}

输出:

6
6

你可能会好奇,上面说过加上mutable才会取消函数的const性,为什么这里不加呢?

实际上,使用引用捕获变量时,加不加mutable效果都是一样的,都可以修改外部v的值。

不加mutable时且不捕获引用时,捕获的vconst属性,也就是说不能修改v的值:

int v = 10;
int res = run([v](int a, int b) { v = v / a + b; return v; }, 2, 1);  // 非法,不能修改 v 的值,因为 v 是 const

而加上mutable但不捕获引用,虽然可以修改v了,但是修改的是 Lambda 函数内的v,外部v是不会变化的:

int v = 10;
int res = run([v](int a, int b) mutable { v = v / a + b; return v; }, 2, 1);
std::cout << res << std::endl;
std::cout << v << std::endl;

输出:

6
10

由于模板推导是在编译阶段进行的,因此如果投入run的第一个函数不是一个函数,例如run(1, 2, 3);,就无法通过编译。但是,编译器的报错会指示在模板函数的return func(a, b);这一行,而不是用户自己编写错误的run(1, 2, 3);,这给错误排查增加了一定的困难。

注意: 如果 Lambda 表达式捕获一个引用,要注意引用对象的生命周期在 Lambda 表达式被调用时可能已经结束了,从而成为一个悬空引用,此时使用这个引用可能会引起意外的结果,甚至程序的崩溃:

std::function<int(int)> getFunc() {
    int a = 10;
    return [&a](int b) { return a / b; };
}

int main() {
    auto func = getFunc();
    std::cout << func(2) << std::endl;

    return 0;
}

输出:

0 (随机)

对于这种需要传递一个函数的情况(特别是多线程中),为防止悬空引用,可以选择捕获值而不是捕获引用,或是采用动态分配但要注意资源释放。

std::function

当我们使用模板实现上述功能时,由于投入的类型不同,模板会实例化为多份。一种验证方法:

template<typename Func, typename T>
void run(Func func, T t) {
    func(t);
    
    static int a = 0;
    ++a;
    std::cout << a << std::endl;
    std::cout << &a << std::endl;
}

void foo(int s) {}

int main() {
    run(foo, 1);
    run(foo, 2);
    
    run([](int a) {}, 1);
    run([](int b) {}, 2);
    
    return 0;
}

输出:

1
0x4030b0
2
0x4030b0
1
0x407034
1
0x407038

可见,即便投入的三个函数实际上都是void(int)类型,编译器还是为模板函数run创建了三个实例,于是静态变量a就会有三份,分别属于这三个不同的实例。

为了防止不必要的实例化,我们可以使用std::function

std::function是 C++ 11 引入的一种包装器,使用它需要引入头文件<functional>,它的模板原型是:

template <typename Ret, typename... Args>
class function<Ret(Args...)>;

于是,为了包装一个返回值为void,形参是int的函数,我们就可以:

std::function<void(int)> = foo;

于是我们使用std::function对函数进行包装后,再传入模板类:

int main() {
    std::function<void(int)> func1 = foo;
    std::function<void(int)> func2 = [](int a) {};
    std::function<void(int)> func3 = [](int b) {};

    run(func1, 1);
    run(func1, 2);

    run(func2, 1);
    run(func3, 2);

    return 0;
}

输出:

1
0x4050b0
2
0x4050b0
3
0x4050b0
4
0x4050b0

这样一来,由于投入函数run的都是std::function<void(int)>这一种类型,于是编译器只会为这个模板创建一个实例。

为了让函数run的第一个参数只能接受函数,而且是包装后的函数,我们可以修改它的模板:

template<typename Ret, typename... Arg>
void run(std::function<Ret(Arg...)> func, Arg... arg) {
    func(arg...);

    static int a = 0;
    ++a;
    std::cout << a << std::endl;
    std::cout << &a << std::endl;
}

void foo(int s) {}

int main() {
    std::function<void(int)> func1 = foo;
    std::function<void(int)> func2 = [](int a) {};
    std::function<void(int)> func3 = [](int b) {};
    std::function<void(double)> func4 = [](double b) {};

    run(func1, 1);
    run(func1, 2);

    run(func2, 1);
    run(func3, 2);
    run(func4, 3.14);

    return 0;
}

输出:

1
0x4050c0
2
0x4050c0
3
0x4050c0
4
0x4050c0
1
0x4050b0 # 注意这里最后是 b0,和上面的 c0 不一样

这样一来,run强制要求投入一个包装过的函数,避免了投入相同类型函数时创建的多余的实例,只有投入不同类型的函数时,才会创建不同的实例。

此外,这样还有一个好处,当用户自己犯错误时,编译器给的报错会在用户自己写的代码上,而不在我们的模板函数上,例如:

std::function<void(int)> func = [](int b) {};
run(func, 1.5);  // 报错

以上代码用户定义了一个接受int参数的函数,但错误地投入了double类型,于是报错。

我们的run函数还支持投入不同长度形参、不同返回类型的函数,例如:

std::function<double(double, double)> func = [](double a, double b) { return a + b; };
run(func, 1.5, 2.1);

而如果用户编写出错误的代码,则编译器报错的位置仍会在用户自己的代码上,例如:

用户定义的函数错误:

std::function<double(double, double)> func = [](double a) { return a; };  // 报错
run(func, 1.5, 2.1);

用户定义的函数有一定数量的参数,但传入run的参数类型或数量错误:

std::function<double(double, double)> func = [](double a, double b) { return a + b; };
run(func, 1.5, 2.1, 3.6);  // 报错,参数数量错误
run(func, 1.5);  // 报错,参数数量错误
run(func, "1.5", "2.1");  // 报错,参数类型错误
run(func, 1.5, 2.1);  // 正确

这样一来,相比我们前面盲目地使用模板,我们新的run函数更灵活,性能更好,且出现错误时能够更清楚地指明错误的位置。

对于类中的函数来说,如果是静态函数,那么前面的方法仍然有效,而非静态成员函数则无法用前面的方法包装:

class Widget {
public:
    static int add(int a, int b) {
        return a + b;
    }

    int multiply(int a, int b) {
        return a * b;
    }
};

int main() {
    std::function<int(int, int)> func1 = Widget::add;  // 合法
    std::function<int(int, int)> func2 = Widget::multiply;  // 非法
    return 0;
}

对于类中的成员函数,需要在模板参数中添加类的类名,并使用取地址符获取函数,调用函数需投入类的实例对象:

std::function<int(Widget, int, int)> func1 = &Widget::multiply;
Widget w;
std::cout << func1(w, 3, 4) << std::endl;

std::function<int(Widget*, int, int)> func2 = &Widget::multiply;
Widget *pw = new Widget;
std::cout << func2(pw, 3, 4) << std::endl;

std::bind

我们有时候已有一个含有多个形参的函数,但我们需要一个新的函数,这个新函数的效果与已有的这个函数相同,但形参数量、顺序可能不同,例如:

int add(int a, int b, int c) {
    return a + b + c;
}
int add(int a, int b) {
    return add(a, b, 0);
}

std::bind可以用来实现这个功能,但它没有创造新的函数,只是通过包装来生成一个新的可调用对象:

int add(int a, int b, int c) {
    return a + b + c;
}

int main() {
    std::function<int(int, int)> func = std::bind(add, std::placeholders::_1, std::placeholders::_2, 0);
    int res = func(1, 2);
    
    return 0;
}

可见,std::bind首先接受一个原函数,然后用std::placeholders::_xx来表示可传入的参数,并在第四个参数(表示原函数第三个参数的位置)填入0。

我们可以通过修改std::placeholders::_xx的顺序来实现参数顺序的调整,例如:

bool greater(int a, int b) {
    return a > b;
}
int main() {
    std::function<bool(int, int)> fewer = std::bind(greater, std::placeholders::_2, std::placeholders::_1);
    std::cout << greater(2, 1) << std::endl;
    std::cout << fewer(2, 1) << std::endl;
    
    return 0;
}

输出:

1
0

std::function类似,当目标函数是类中的非静态成员函数时,则需要有一个参数来接受类的实例化对象:

class Widget {
public:
    int add(int a, int b, int c) {
        return a + b + c;
    }
};

int main() {
    Widget w;
    std::function<int(int, int)> func = std::bind(&Widget::add, w, std::placeholders::_1, std::placeholders::_2, 0);
    std::cout << func(1, 2) << std::endl;

    return 0;
}

函数标记

C++ 11引入了几个标记,可以给函数添加一些额外的信息,以让编译器为调用者给出一定的警告。

[[deprecated]] 标记

[[deprecated]]标记可以将一个变量、函数或类标记为“已废弃”。

它的作用是告诉编译器和开发者,该特性已不建议使用,可能会在未来的版本中被移除,有其他替代方法。

例如:

[[deprecated]] void oldFunc();

使用它仍可以通过编译,但是编译过程中编译器会给出警告。

在C++ 14,还可以指定一个字符串,用于提供额外的说明或替代建议:

[[deprecated("Use newFunc() instead.")]] void oldFunc();

[[nodiscard]] 标记

[[nodiscard]]标记用于告诉函数的调用者不要忽略函数的返回值,以确保编译器在某些情况下对函数的返回值进行检查,避免一些潜在的问题。

如果忽略了带[[nodiscard]]标记的函数返回的值,编译器会给出警告,

[[noreturn]] 标记

注意,千万不要望文生义,这个标记不是指函数没有返回值,而是指函数不会返回到调用点

例如,有些函数内部是一个无限循环,或者调用了std::terminate(),又或者会陷入永久的阻塞。如果调用了该函数,在该函数后的其他操作将是无意义的,因为它们永远不会被执行到。

这个属性的主要作用是帮助编译器进行代码优化和警告检查。同时,编译器也会给出警告,防止调用者在调用了该函数后还继续在后面添加其他代码。

0x07 类

定义一个类

我们常用class关键字声明或定义一个类,实际上,struct也能用来定义类。

二者的区别是,使用class定义类时,成员的默认访问权限是private;而使用struct时,成员的默认访问权限是public

对于明确地、在类一开始就规定了publicprotected或是private的类来说,使用structclass定义类是没有区别的。

publicprotectedprivate可以多次出现,其作用范围从它开始直到下一个权限关键字出现或类结束:

struct MyClass {
    int a;  // 默认权限,因为是 struct 所以是 public
private:
    int b;
public:
    int c;
private:
    void func();
};

类的循环依赖与前向声明

有时候我们可能有两个类分别依赖对方,例如类A依赖B,类B又依赖A,此时就会出现一个问题,当其中一个类引用另一个类时,另一个还未被定义,反之亦然:

class A {
    B b;  // 非法: 类B还未被定义
    void func(B b);  // 非法: 类B还未被定义
};

class B {
    A a;
};

我们在学习前面函数的时候也有遇到类似的情况,我们的解决方法是先声明再定义。对于类,也可以先声明有这样一个类,然后再定义类,这样是在说明这个名字对应的类型是个类类型,这被称为前向声明:

class A;
class B;

但是,即便类已经被提前声明了,由于类在被定义前仍不清楚它所需要占用的内存大小,于是仍然无法使用这个类对象:

class A;
class B;

class A {
    B b;  // 非法: 类B还未被定义
};

不过,因为指针本身的大小是在编译时就固定的,指针本身不会依赖于类的完整定义,所以使用一个指针就合法了。对于静态成员,也可以是一个不完整的类型。但是因为类B还未定义,所以B中的,所以不能通过直接定义在类A中的成员函数去访问B中的成员:

class A;
class B;

class A {
    B *b1;  // 合法
    static B *b2;  // 合法
    void func(B *b) {
        b->xxx();  // 非法
    }
};

class B {
public:
    void xxx();
};

因此,类A中的成员函数func(B *b)只能是声明,并在类的外部定义:

class A;
class B;

class A {
    B *b;  // 合法
    void func(B *b);  // 合法
};

class B {
public:
    void xxx();
};

void A::func(B *b) {
    b->xxx();  // 合法
}

不过在实际工程中,应尽量避免这样循环依赖的情况出现。
循环依赖会使得类之间的关系变得复杂,增加了理解和维护代码的难度。你需要小心处理前向声明,避免循环依赖导致的逻辑混乱。循环依赖可能导致代码在将来进行重构时变得难以修改。过多的相互依赖使得每次修改一个类时,必须小心地处理所有相关类的变化,否则可能会引入难以发现的错误。

类对象的构造

默认构造函数与初始化列表

当一个类没有构造函数时,编译器会自动为其创建一个默认构造函数(不接受任何参数的构造函数),并为成员变量进行默认初始化。

当一个类有一个带参数的构造函数时,就不会有默认构造函数,这意味着不能使用没有参数的构造函数:

class Widget {
public:
    Widget(std::string content) {
        this->_content = content;
    }
private:
    std::string _content;
};

int main() {
    Widget w1;  // 非法,没有默认构造函数
    Widget w2("abc");  // 合法

    return 0;
}

我们可以手动为其添加一个没有参数的构造函数,不过在C++ 11,可以用以下方法让编译器生成默认构造函数:

class Widget {
public:
    Widget() = default;
    Widget(std::string content) {
        this->_content = content;
    }
private:
    std::string _content;
};

int main() {
    Widget w1;  // 合法
    Widget w2("abc");  // 合法

    return 0;
}

不过对于第二个带参数的构造函数,它的初始化有一个问题,就是即便传入了一个参数用于初始化成员变量_content,这个成员变量也会先执行默认初始化(得到空字符串""),然后才被赋值,这个不必要的默认初始化显然会增加额外的性能消耗,特别是成员变量类型复杂或数量多的情况。

一个解决方法是使用初始化列表,可以直接初始化成员变量而不执行不必要的默认初始化:

class Widget {
public:
    Widget() = default;
    Widget(std::string content, std::string s) : _content(content), _s(s) {}
private:
    std::string _content;
    std::string _s;
};

这样一来,当执行第一个构造函数时,才会对成员变量进行默认初始化;而执行第二个构造函数时,则直接通过传值初始化成员变量。

不过要注意,如果成员变量不能被默认初始化(例如没有默认构造函数),则即便指定了让编译器生成默认构造函数,也无法使用该方法进行默认初始化:

class NoDefault {
public:
    NoDefault(int a) {}  // 没有默认构造函数
};

class Widget {
public:
    Widget() = default;  // 让编译器生成默认构造函数,但生成失败,相当于 = delete
    Widget(NoDefault noDefault) : _noDefault(noDefault) {}
private:
    NoDefault _noDefault;
};

int main() {
    Widget w;  // 非法,没有默认构造函数

    return 0;
}

以上代码中的Widget w会报错,因为无法调用其默认构造函数。

此外,如果需要在调用默认构造函数时,为成员变量分配非默认初始化的默认值,一种方法是使用类内初始化:

class Widget {
public:
    Widget() = default;
    Widget(NoDefault noDefault) : _noDefault(noDefault) {}
private:
    NoDefault _noDefault{1};
};

int main() {
    Widget w;  // 合法

    return 0;
}

可见,当使用类内初始值时,默认构造函数就可以正常生成了,从而使Widget w合法。而且初始化的具体值直接写在了成员变量的声明处,从而更加方便阅读。

不过这种设计有一个问题,一方面类内默认初始值可以被构造函数对其的初始化覆盖掉,这意味着可能会产生意外的值覆盖,这可能会导致程序员混淆,以为这个成员变量是固定的一个值而不知道其实已经被覆盖为其他值了,且覆盖前多余的默认初始化操作也会造成性能损失;另一方面,不是所有编译器都支持类内默认初始化,从而降低了代码的跨平台兼容性。不过,因为C++ 11在标准中引入了类内初始化,所以C++ 11后大多数编译器都支持了类内初始化。

所以另一种分配非默认初始化的默认值的方法,是在构造函数处指定其默认值:

class Widget {
public:
    Widget() : _noDefault(NoDefault{1});  // 手动指定默认值,不依赖于编译器自动生成构造函数
    // Widget() : Widget(NoDefault{1});  // 另一种方法,采用委托构造函数
    Widget(NoDefault noDefault) : _noDefault(noDefault) {}
private:
    NoDefault _noDefault;
};

此外,在Widget构造函数形参采用值传递的情况下,使用初始化列表会造成不必要的拷贝:

class MyClass {
public:
    MyClass() { std::cout << "普通构造函数" << std::endl; }
    MyClass(const MyClass &from) { std::cout << "拷贝构造函数" << std::endl; }
    MyClass(MyClass &&from) noexcept { std::cout << "移动构造函数" << std::endl; }
};

class Widget {
public:
    Widget(MyClass m) : _m(m) {}
private:
    MyClass _m;
};

int main() {
    MyClass m;
    Widget widget{m};

    return 0;
}

输出:

普通构造函数
拷贝构造函数
拷贝构造函数

为了避免不必要的拷贝,可以在初始化列表使用移动操作来避免拷贝:

// 对 Widget 的修改,省略其他代码
class Widget {
public:
    Widget(MyClass m) : _m(std::move(m)) {}  // 这里将 m 改为了 std::move(m)
private:
    MyClass _m;
};

输出:

普通构造函数
拷贝构造函数
移动构造函数

对于一些基本类型(int),在初始化列表中移动是没有效果的,因此可以不使用move

如果在上述main函数中,构造Widget的参数是右值:

int main() {
    Widget widget{MyClass()};

    return 0;
}

则会少一次拷贝构造,输出变为:

普通构造函数
移动构造函数

这是由于编译器优化造成的,称为拷贝省略

警惕类的构造函数形成的的隐式转换

当我们声明一个类的构造函数时,如果这个构造函数只有一个实参,那么同时也会定义这个实参转换为这个类类型的隐式转换:

class Widget {
public:
    Widget(int a);
};

void func(Widget w);

int main() {
    func(123);  // 合法

    return 0;
}

可见,func函数接受一个Widget类型的参数,但是居然可以给它传递一个int类型的值!这是因为Widget的构造函数同时定义了向它转换的规则。

不过,这样的隐式转换只允许一步的类型转换,例如:

class Widget {
public:
    Widget(std::string s);
};

void func(Widget w);

int main() {
    func("123");  // 非法
    func(std::string("123"));  // 合法

    return 0;
}

这是因为字面值"123"实际上属于const char *类型,如果能够进行隐式转换,需要先转换为std::string,再转换为Widget

如果我们不希望这种隐式转换,可以将构造函数声明为explicit:

class Widget {
public:
    explicit Widget(int a);
};

void func(Widget w);

int main() {
    func(123);  // 非法

    return 0;
}

此外,explicit必须放在类里面。如果类的构造函数是先声明再定义,则只需在声明的构造函数处加上explicit,定义处无需加上。

当构造函数有多个参数时,explicit也是有作用的,例如不使用它时:

class Widget {
public:
    Widget(int a, int b);
};

void func(Widget w);

int main() {
    Widget w = {1, 2};  // 合法
    func({3, 4});  // 合法

    return 0;
}

使用explicit时:

class Widget {
public:
    explicit Widget(int a, int b);
};

void func(Widget w);

int main() {
    Widget w1 = {1, 2};  // 拷贝初始化,非法
    Widget w2{1, 2};  // 直接初始化,合法
    func({3, 4});  // 非法
    
    return 0;
}

不过对于多个参数的情况,我们一般不会犯意外的隐性转换的错误,因为我们都已经特地使用花括号了。因此对于两个及以上参数的构造函数,可以不声明为explicit,以便我们可以使用花括号初始化。

要注意的是,使用花括号初始化可能被std::initializer_list劫持,这个我们在初始化对象时的疑惑提过了:

class Widget {
public:
    Widget(int a, int b);
    Widget(std::initializer_list<int> list);
};

void func(Widget w);  // 第一个func
void func(std::initializer_list list);  // 第二个func

int main() {
    func({3, 4});  // 调用的是第二个func
    
    return 0;
}

此外,即便explicit阻止了隐式转换,我们仍可以使用强制转换:

class Widget {
public:
    explicit Widget(int a);
};

void func(Widget w);

int main() {
    func(static_cast<Widget>(1));  // 强制转换,合法

    return 0;
}

拷贝控制

对于其他编程语言的程序员来说,C++对于类对象的构造是相当复杂的。除了上面提到的普通的构造函数(直接构造),C++的类还有:

  • 拷贝构造函数
  • 移动构造函数
  • 拷贝赋值运算符
  • 移动赋值运算符
  • 析构函数

这些操作被称为拷贝控制操作。如果一个类没有定义所有这些拷贝控制成员,那么编译器会自动为它定义缺失的操作。但是,正如前面的构造函数,并不是所有类都能依赖默认定义。就算能够让编译器自动定义,它定义的版本也可能不是我们想要的。

拷贝构造函数

如果一个构造函数的第一个参数是自身的引用(通常还是const),且任何额外参数都有默认值,则此构造函数为拷贝构造函数。

拷贝构造函数在大多数情况下都是隐式使用,所以通常不应该加上explicit

有一点需要注意,当拷贝构造函数被调用时,函数的第一个参数是从哪里复制,而拷贝构造函数内的成员则是拷贝后产生的新对象需要拷贝赋值的成员。
这也是为什么它是“构造函数”,即用来构造自己,以及为什么函数的第一个引用参数是const,一般拷贝不会修改原对象的内容:

class Widget {
public:
    // 省略其他函数
    // 拷贝构造函数(与默认生成的相同)
    Widget(const Widget &from) : str(from.str), num(from.num) {}
private:
    std::string str;
    int num;
};

当拷贝发生时,会对类的每个成员进行拷贝:

  • 对于类类型的成员,会使用其拷贝构造函数来拷贝
  • 对于内置成员(如int等),则直接拷贝
  • 对于数组,会逐一元素地拷贝每一个数组类型的成员

下面用几个例子来区分拷贝初始化和直接初始化:

std::string s1("a string");                   // 直接初始化

std::string s2(s1);                           // 拷贝初始化
std::string s3 = s1;                          // 拷贝初始化
std::string s4 = "another string";            // 拷贝初始化
std::string s5 = std::string("hello world");  // 拷贝初始化
std::string *s6 = new std::string(s1);        // 拷贝初始化

如果发生的是拷贝初始化,则会调用拷贝构造函数来完成。不过如果一个类有移动构造函数,则可能调用移动构造函数来完成,这里先假定没有移动构造函数。

以下情况也会发生拷贝初始化:

  • 将一个对象作为实参传递给一个非引用类型的形参
    void func(std::string s);
    int invoke() {
        std::string s;
        func(s);  // 传递过程发生拷贝初始化
    }
  • 从一个返回类型为非引用类型的函数返回一个对象
    std::string func();
    int invoke() {
        std::string s = func();  // 发生拷贝初始化
    }
    即便函数的返回类型是引用,但没有使用引用接收,也会发生拷贝
    std::string& func();
    int invoke() {
        std::string s = func();  // 发生拷贝初始化
        // 相当于
        std::string &rs = func();
        std::string ss = rs;  // 相当于使用等号赋给非引用而导致的拷贝
    }
  • 使用标准化容器时,使用不同的方法会导致不同的行为,例如insertpush通常是拷贝初始化,而emplace则是直接初始化

不过,编译器是可以绕过拷贝或移动构造函数的,例如:

std::string s = "a string";  // 拷贝构造

编译时改为

std::string s("a string");  // 直接构造

从而优化掉前者两次拷贝带来的额外消耗。
编译器不一定会进行这样的优化,具体取决于编译器本身以及相应的构造函数是否存在且能被访问。

拷贝赋值运算符

当使用等号赋值时,也可能产生拷贝,例如:

Widget w1;
Widget w2 = w1;  // 等号赋值拷贝

而我们可以定义等号拷贝的行为,它本质上是对等号运算符的重载,参数是所在类类型一致的引用,通常返回一个指向左侧运算对象的引用:

class Widget {
public:
    // 省略其他成员
    // 拷贝赋值运算符重载
    Widget& operator=(const Widget& from) {
        str = from.str;
        num = from.num;
        return *this;  // 需要返回 *this
    }
private:
    std::string str;
    int num;
};

如果一个类没有定义自己的拷贝赋值运算符,则编译器会自动为其生成一个。与拷贝构造函数一样,不是所有所有类都能依赖默认定义,默认生成的也不一定是我们想要的。

那么,既然编译器会为我们自动生成这些函数,是否意味着我们并不需要手动定义它们了?

其实,实际情况下我们可能会在除复制数据对象外再做其他事情,例如我们可能为某些类对象生成一个唯一标识码,于是在拷贝发生时我们需要为新的对象分配一个新的唯一标识码而不是复制原对象的。

而当涉及到内存动态分配时,手动定义拷贝构造和赋值操作几乎是必须的,详情请看浅拷贝与深拷贝

析构函数

析构函数用于在对象被释放时,释放对象所使用的资源,通常销毁非static数据成员。

析构函数没有返回值,不接受任何参数,且一个类里只能有唯一的一个析构函数,不能被重载。

如果一个类中的数据成员不是动态分配的,那么我们无需手动释放它们,因为它们会被自动释放。相当于这些数据成员是局部变量,当类对象(无论是临时对象还是用new动态分配的)被释放时,这个类的作用范围就结束了,一个局部变量会在作用范围结束时自动释放。

class MyClass {
public:
    MyClass() {
        num = 1;
    }
    ~MyClass() {}  // 析构函数,无需手动释放 num,因此可以省略该析构函数让编译器自动生成
private:
    int num;
};

而如果类中的数据成员是动态分配的(使用裸指针),那么我们就需要在析构函数中手动使用delete释放它。

class MyClass {
public:
    MyClass() {
        pnum = new int(1);
    }
    // 析构函数,需要手动释放 pnum
    ~MyClass() {
        delete pnum;
    }
private:
    int *pnum;
};

显式让编译器创建或删除默认函数

前面提到了可以使用= default让编译器显式创建默认函数。

C++ 11还提供了= delete来删除一个函数,例如可以在拷贝构造函数和赋值操作的函数参数列表后添加它来阻止拷贝。虽然在C++ 11之前可以把它们放在private之后来阻止使用,但= delete可以保证类自己和友元都不会使用它们,从而避免编写这个类时意外的拷贝。

不过private的方式也是有使用场景的,因为它只是限制了类外部对其的访问,并没有限制类内的函数和友元对私有成员的访问,例如你可能希望禁止拷贝,但这个类在自己的成员函数中或友元中允许拷贝。

我们一般不会删除析构函数,但删除析构函数是合法的。当一个析构函数被删除时,就无法定义该类的实例化对象:

class MyClass {
    ~MyClass() = delete;
};
int main() {
    MyClass m;  // 非法
}

但是,可以动态分配这种类型的对象,不过不能被删除。这种特性可以用来实现一种严格的单例模式:

class Singleton {
public:
    static Singleton& getInstance() {
        static auto *instance = new Singleton;
        return *instance;
    }
private:
    Singleton() {};  // 防止外部构造
    Singleton(const Singleton&) = delete;  // 防止拷贝
    Singleton& operator=(const Singleton&) = delete;  // 防止拷贝赋值
    ~Singleton() = delete;  // 防止删除
};

int main() {
    Singleton s = Singleton::getInstance();  // 错误: 拷贝被禁止
    Singleton &rs = Singleton::getInstance();  // 正确
    rs = *static_cast<Singleton*>(nullptr);  // 错误: 赋值操作是私有的
    Singleton newS;  // 错误: 构造函数是私有的
    Singleton *pNewS = new Singleton;  // 错误: 构造函数是私有的
    delete rs;  // 错误: 构造函数被删除

    return 0;
}

这样的单例模式可以保证它不会被意外删除,并在程序运行期间不被释放。
不过这样的单例模式并不常见,最常见的做法是将析构函数设为私有而不删除。通过私有化析构函数,类的外部无法直接销毁该对象,从而保证了对象的生命周期由单例管理。

浅拷贝与深拷贝

浅拷贝

如果一个类中存在动态分配内存的成员,并定义一个析构函数去释放资源,通常需要手动编写拷贝和赋值操作。

如果不定义拷贝构造函数和赋值操作,会发生什么呢?

以上面的MyClass为例,假设不定义拷贝和赋值操作,那么编译器生成的默认操作会是这样:

class MyClass {
public:
    MyClass(std::string s) : ptr(new std::string(s)) {}
    ~MyClass() { delete ptr; }
    // 以下两个函数是编译器自动生成的
    MyClass(const MyClass &from) : ptr(from.ptr) {}
    MyClass& operator=(const MyClass &from) {
        ptr = from.ptr;
    }
private:
    std::string *ptr;
};

void func(MyClass m);

int main() {
    MyClass m1;
    func(m1);  // 将会产生错误
    MyClass m2 = m1;  // 将会产生错误
    
    return 0; 
}

上述代码中,调用函数func并传入m1会使对象产生拷贝,而在拷贝构造函数中成员ptr被拷贝,于是m1与函数中的实参m的数据成员ptr虽然是两个不同的指针,但它们都指向同一个std::string对象,于是当函数func运行结束时,指针被释放,但m1的生命周期还未结束。当m1的生命周期结束时,又会释放这个指针,造成双重释放,从而导致程序崩溃。对于赋值操作的m2也同理。

这种只拷贝指针而不拷贝对象的方式被称为浅拷贝,如果需要使用浅拷贝,必须确保释放内存的责任只归属于一个对象,一个常见的做法是使用智能指针(如std::shared_ptr)来自动管理内存,避免手动管理内存导致的错误。

深拷贝

我们也可以编写拷贝构造函数和赋值操作,当拷贝发生时,将动态分配内存的数据成员也进行拷贝,而不是只拷贝指针:

class MyClass {
public:
    MyClass(std::string s) : ptr(new std::string(s)) {}
    ~MyClass() { delete ptr; }

    // 拷贝数据成员而不是只拷贝指针
    MyClass(const MyClass &from) : ptr(new std::string(from.ptr)) {}
    MyClass& operator=(const MyClass &from) {
        // 当涉及到动态内存分配时,对于赋值操作的重载,应该及时进行赋值前旧资源的释放,以避免内存泄露
        // 为什么这里要单独用一个第三方变量接收,再删除ptr,再把第三方变量赋给ptr
        // 而不直接删除ptr,然后给它赋新值
        // 会在后面进行讲解
        std::string *newPtr = new std::string(*from.ptr); 
        delete ptr;
        ptr = newPtr;
        return *this;
    }
private:
    std::string *ptr;
};

这样一来,当发生拷贝时,新对象会为动态分配的数据成员分配一片新的内存并拷贝数据,从而避免两个对象的数据成员指针指向同一片内存而造成的双重释放。
这种拷贝时为新对象分配独立的内存空间,而不是指向同一片内存的方式,被称为深拷贝

在实际的工程项目中,内存管理会非常复杂,而写出内存安全的程序是十分困难的。

例如,上面的深拷贝的代码可以保证类对象被释放时它所动态分配的内存也能被释放,但当这个类本身通过new分配内存时,可能因为某些异常而导致delete的代码没有被执行:

void this_func_will_throw_error(MyClass *ptr);
void func() {
    MyClass *ptr = new MyClass;
    this_func_will_throw_error(ptr);  // 这里抛出了错误,但没有进行处理
    delete ptr;
}

因为没有处理抛出的错误,所以最后的释放操作没有被执行到,从而造成内存泄露。

我们虽然可以采用 try-catch 的方式处理错误,并在出现错误时释放资源,但有时候代码的层级可能非常复杂,有时候我们并不清楚错误是在哪一层抛出的,我们可能也需要在捕获到错误时继续将错误往下一层级传递,以便下一层级它自己也能捕获错误并释放它自己分配的内存。但下一层级也可能用到了我们将要释放的对象,如果它也释放了这个对象,而我们也尝试释放它,就会造成双重释放。悲观的是,我们既不能保证下一层级或上一层级在出现错误有释放某个动态分配的内存,也不能保证我们自己手动释放时上级或下级没有释放。
而且,类对象本身在构造的时候也可能出现错误,例如拷贝构造时内存不足,或是拷贝构造需要进行其他操作(如创建一个新文件但是硬盘空间不足等),这些情况并不是好解决的。
这与Java等编程语言有很大区别,为了写出内存安全的C++代码,需要非常丰富的经验。
而有一些语言如Rust,会在语法层面强制你处理这些问题,从而让你在写出安全的代码后才能通过编译,提高了代码的安全性。
不过,C++正是因为有着许多内存不安全的技巧和其灵活性,才能够写出一些非常高性能的代码。

上面对于赋值操作的重载是不够安全的,而更安全的设计方式会在拷贝并交换中讲解。

拷贝并交换

在类里存在动态分配内存的成员变量时,另一种非常安全的设计赋值操作的方式是拷贝并交换:

class MyClass {
    friend void swap(MyClass &lhs, MyClass &rhs);
public:
    MyClass(const std::string &s = std::string()) : ps(new std::string(s)) {}
    ~MyClass() { delete ps; }
    MyClass(const MyClass &m) : ps(new std::string(*m.ps)) {}
    // 注: 这里的rhs是按值传递,即拷贝传递,不是引用
    MyClass& operator=(MyClass rhs);
private:
    std::string *ps;
};

inline void swap(MyClass &lhs, MyClass &rhs) {
    using std::swap;
    swap(lhs.ps, rhs.ps);  // 交换的是指针
}

MyClass& MyClass::operator=(MyClass rhs)  {
    swap(*this, rhs);
    return *this;
}

可以发现这个版本对等号的重载中,参数不是一个引用,而是按值传递,于是参数传递时会得到一个副本。
接着,交换本对象与副本的数据值。
然后,函数结束,rhs被销毁,因为本对象动态分配的值被换进了rhs里,于是rhs生命周期的结束使得其动态分配的内存得以释放。

你可能会问,这样做使MyClass产生了拷贝,进而让其中的动态分配的std::string也发生了拷贝,是否会对性能产生较大影响?而且这样绕来绕去的意义是什么?
其实,与不使用交换的版本比较,不交换时需要删除本对象的ps,然后再动态分配一个新的std::string给它,其实是一样的。

而使用交换版本时,之所以说它安全是因为它自动处理了自赋值的情况:

MyClass m{"a string"};
m = m;  // 自赋值情况

至于非交换版本的情况,当自赋值出现时,如果没有正确处理这种情况:

MyClass& operator=(const MyClass &from) {
    delete ps;
    ps = new std::string(*from.ps);
    return *this;
}

则会把自己的ps删除,然后再把自己的ps的值复制给自己。但是,此时的ps已经是悬空指针,试图去解引用一个悬空指针,可想而知会发生什么灾难。

这也是为什么前面使用第三方变量先暂存新值,然后再删除旧值:

MyClass& operator=(const MyClass &from) {
    std::string *newPtr = new std::string(*from.ps); 
    delete ps;
    ps = newPtr;
    return *this;
}

另外一种解决自赋值问题的方法是,使用if判断是否出现了自赋值,若是,则不进行任何操作:

MyClass& operator=(const MyClass &from) {
    if(&from == this)  // 防止自赋值
        return *this;
    
    delete ps;
    ps = new std::string(*from.ps);
    return *this;
}

但是这样是简单粗暴地禁止了自赋值,假设存在自赋值的需求(例如想要重新分配一遍对象自己的数据成员的内存),则什么都不做显然不是我们想要的。

使用交换版本还有一个好处,当MyClass拷贝构造并传给rhs时,如果拷贝构造中的new操作发生异常,会在进入拷贝赋值函数前发生,而这里的new发生报错时,不会为其分配内存,因此不会发生内存泄露。

这里举另一个例子说明这个情况,假设MyClass有两个动态分配内存的成员ab
对于非交换情况:

MyClass& operator=(const MyClass &from) {
    std::string *newA = new std::string(*from.a); 
    std::string *newB = new std::string(*from.b); 
    delete a;
    delete b;
    a = newA;
    b = newB;
    return *this;
}

假设newB成功分配了内存,但newB分配内存失败并抛出异常,由于下面的代码没有被继续执行,则newA没有赋给任何可被释放的变量,于是newA所分配的内存就无法被释放,从而造成内存泄露。

而对于交换的情况,假设MyClass的拷贝构造函数如下:

MyClass(const MyClass &from) {
    a = new std::string(*from.a);
    b = new std::string(*from.b);
}

MyClass析构函数有对这两个成员进行释放。
a的内存成功分配,而b的内存分配失败,拷贝构造函数并不会完全执行完毕。这意味着,在异常发生时,拷贝构造函数的剩余部分不会执行,也不会初始化b
接着,拷贝对象被销毁,并执行析构函数,释放a的内存。而b未被初始化,所以对bdelete不会有任何效果。
因此,对于交换版本来说,其内存安全性会更好。

描述类行为

行为像值的类

通过上面这些类的工具,我们可以设计我们的类使之像intstd::string那样表现为一个值。

对于行为像值的类,当我们拷贝它的对象时,副本与原对象是完全独立的,修改副本不会改变原对象的值,反之亦然。

例如:

class Complex {
public:
    double real;
    double imaginary;

    // 允许隐性转换,不加 explicit
    Complex(double r = 0, double i = 0) : real(r), imaginary(i) {};
    Complex(const Complex &from) = default;  // 允许拷贝构造
    Complex& operator=(const Complex& from) = default;
};

std::ostream& operator<<(std::ostream& stream, Complex &complex) {
    stream << "(" << complex.real << ", " << complex.imaginary << ")";
    return stream;
}

int main() {
    Complex zero;
    Complex onlyReal = 1;
    Complex complex = {1, 2};
    Complex copy = complex;
    copy.imaginary = 3;
    std::cout << complex << std::endl;
    std::cout << copy << std::endl;

    return 0;
}

输出:

(1, 2)
(1, 3)

如果在类内的数据成员使用了指针(特别是动态分配了内存的情况下),那么行为像值的类一般会对其进行深拷贝,以保证原对象与副本的独立性。

行为像指针的类

通过浅拷贝,并维护一个引用计数,我们可以设计出行为像指针的类。

一个典型例子就是shared_ptr,对于shared_ptr的模拟设计我们已经在智能指针-shared_ptr中提过了,这里不再赘述。

智能指针主要维护的是指针所指向的对象和一个引用计数。类似地,可以设计一个类,维护一个具体情景下的多个数据成员,当类对象发生拷贝时其数据成员采用浅拷贝,并通过引用计数来保证它们的正确释放。
对引用计数的维护要注意循环引用的情况,必要时可用智能指针shared_ptrweak_ptr代替。

再例如,一台打印机可以被多个对象共享,当对象被拷贝时,不应该对打印机的实例进行拷贝,因此只拷贝其指针,且不应该被一个类的生命周期结束自动释放,防止多重释放。为此,对打印机资源的管理需要特定的资源管理程序,例如一个资源池。

类内动态分配内存的意义?一个使用案例

既然非动态分配的成员会自动在类生命周期结束时自动释放,那在类中动态分配内存有什么意义呢?

动态分配内存在类中的意义是,它使得你可以在运行时根据需要分配内存,而不仅仅是依赖编译时大小已知的静态内存。
这在一些特定情况下是非常有用的。具体来说,动态内存分配使得你能够处理大小不固定或不确定的对象,节省内存,或处理复杂的数据结构。

举一个具体的使用案例,假设我们需要实现一个大小可变的数组,由于这个数组的大小不是可以在编译时确定的,因此需要进行动态内存分配:

template<typename T, std::size_t _Size>
class DynamicArray {
public:
    DynamicArray() : size(_Size), elements(new T[_Size]) {}
    ~DynamicArray() { delete[] elements; }
    std::size_t getSize() const { return this->size; }
    T& operator[](std::size_t index) { return elements[index]; }
    void enlarge(std::size_t num) {
        std::size_t newSize = size + num;
        T *newElements = new T[newSize];
        // 复制旧数组内容到新数组
        for (std::size_t i = 0; i < size; ++i) {
            // 拷贝构造
            newElements[i] = elements[i];
        }
        // 释放旧数组
        delete[] elements;
        size = newSize;
        elements = newElements;
    }
    // 为简化代码,先不考虑拷贝构造和赋值
    DynamicArray(const DynamicArray& other) = delete;
    DynamicArray& operator=(const DynamicArray& other) = delete;
private:
    std::size_t size;
    T *elements;
};

这个类设计得没有那么正确,不过为了简化代码,没有对某些情况进行处理。

不过这存在一个性能问题,当数组扩容时,创建一个新数组,然后将旧数组的内容拷贝过去后,删除了旧数组的内容,这样的对象拷贝后销毁是否是多余的呢?

以上是对于不确定大小的数组或容器的例子。

在实际的面向对象编程的过程中,可能出现多态和基类指针的情况,如果类的对象类型在编译时无法确定,那么可能需要通过动态分配内存来处理对象的创建:

struct Animal { virtual std::string what() { return "animal"; } };
struct Dog : public Animal { std::string what() override { return "dog"; } };
struct Cat : public Animal { std::string what() override { return "cat"; } };

int main() {
    Animal *ptr = new Dog;
    std::cout << ptr->what() << std::endl;  // 输出 dog
    
    return 0;
}

移动构造

我们在左值与右值已经介绍过了右值引用和移动语义,通过移动操作可以减少不必要的拷贝带来的额外性能消耗。

移动构造函数与移动赋值运算符

与拷贝构造函数不同,移动构造函数通常不分配新内存,而是接管给定的内存。

标准库中的容器,例如std::vector,可以自动进行处理,当错误出现时,std::vector可以释放新分配但未成功构造的内存并返回,而std::vector中原有的元素保持不变。
为了处理这些异常,它需要一些额外的操作进行检测。如果一个移动构造函数可能抛出异常,则std::vector会使用拷贝构造而不是移动构造。如果在执行移动构造时抛出异常,可能会导致容器的状态不一致,特别是容器扩容时,容器内部的元素可能已经发生了移动,导致丢失部分数据。
而移动操作通常不会抛出异常。当编写一个不抛出异常的移动操作时,应通知标准库该移动构造函数不会抛出异常,告诉它可以安全地使用移动构造函数(和移动赋值运算符)。

一种保证移动构造不会抛出异常的方法是为移动构造函数的参数列表后添加noexcept:

class MyClass {
public:
    // 移动构造函数
    MyClass(MyClass &&from) noexcept : _str(from._str) {}
private:
    std::string _str;
};

如果移动的数据中存在指针,通常我们还需要将原对象的数据指针设为nullptr,以免原对象在执行析构函数时释放了移动构造后的数据:

class MyClass {
public:
    MyClass(std::string s) : _ptr(new std::string(s)) {}
    ~MyClass() { delete _ptr; }
    MyClass(MyClass &&from) noexcept : _ptr(from._ptr) {
        // 避免原对象析构将新对象的数据删除
        from._ptr = nullptr;
    }
private:
    std::string *_ptr;
};

因为当一个指针为空指针时,delete一个空指针不会有任何效果。而如果没有上述将原对象中的数据指针设为空指针的话,移动构造后的_ptr将会变成一个悬空指针,从而在访问它时引起程序崩溃。

至于移动赋值运算符,与移动构造一样,加上noexcept,并注意自赋值问题:

MyClass& operator=(MyClass &&from) noexcept {
    if (this != &from) {
        // 释放已有的动态分配内存的成员
        delete _ptr;
        _ptr = from._ptr;
        from._ptr = nullptr;
    }
    return *this;
}

与拷贝构造不同,编译器并不会自动为某些类生成移动构造函数和移动赋值符,特别是当一个类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数时。如果一个类没有移动操作,就通过正常的函数匹配,使用相应的拷贝操作来代替移动操作。

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会自动为它生成移动构造函数或移动赋值运算符。

与拷贝构造函数不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成= default的移动操作,但并不是所有成员都可被移动,则编译器会将移动操作定义为删除的函数。

拷贝并交换赋值运算符与移动操作

我们在拷贝并交换一节介绍了使用交换来实现一个安全的拷贝构造函数和拷贝赋值方法。
如果我们为其添加一个移动构造函数,则已经定义的赋值运算符同时会拥有拷贝赋值和移动赋值的效果:

class MyClass {
public:
    // 移动构造函数
    MyClass(MyClass &&from) noexcept : ps(from.ps) {
        from.ps = nullptr;
    }
    // 使用值传递而不是引用传递(即不是左值引用也不是右值引用)
    // 既是拷贝赋值运算符,又是移动赋值运算符
    MyClass& operator=(MyClass rhs) {
        swap(*this, rhs);
        return *this;
    }
    // 省略其他成员
};

int main() {
    MyClass m1, m2, m3;  // 直接构造
    m2 = m1;  // 拷贝赋值
    m3 = std::move(m1);  // 移动赋值
    m2 = m2;  // 拷贝自赋值
    m3 = std::move(m3);  // 移动自赋值
    
    MyClass m4 = m1;  // 拷贝构造(虽然出现了等号但调用的是拷贝构造函数)
    
    return 0;
}

当赋值发生时,无论是拷贝赋值还是移动赋值,都能正确地、安全地被处理。

引用限定符

上一节我们定义了赋值操作,而赋值操作除了能给一个左值赋值,又能给一个右值赋值,例如对于std::string:

std::string s1 = "hello ", s2 = "world";
s1 + s2 = "a new string";  // 合法

可见,给一个右值赋值居然是合法的。但向右值赋值通常是没有意义的。

为了阻止为一个右值赋值,C++ 11引入了引用限定符,将其加在赋值运算符函数的参数列表之后,可以限制其只能为左值赋值:

MyClass& operator=(const MyClass &from) &;

引用限定符只能用于非static的成员函数,且必须同时出现在函数的声明和定义中。

一个函数可以同时被const和引用限定符限定,不过引用限定符必须在const之后:

class MyClass {
    void func1() const &;  // 合法
    void func2() & const;  // 非法
};

引用限定符可以是&&&,且可以用于重载函数:

class MyClass {
public:
    void func() & { std::cout << "调用了 func() &" << std::endl; }
    void func() const & { std::cout << "调用了 func() const &" << std::endl; }
    void func() && { std::cout << "调用了 func() &&" << std::endl; }
    void func() const && { std::cout << "调用了 func() const &&" << std::endl; }
};

int main() {
    MyClass m;
    m.func();  // 调用左值版本的 func()

    MyClass{}.func();  // 调用右值版本的 func()

    const MyClass m2;
    m2.func();  // 调用常量、左值版本的 func()

    std::move(m2).func();  // 调用常量、右值版本的 func()

    return 0;
}

输出:

调用了 func() &
调用了 func() &&
调用了 func() const &
调用了 func() const &&

此外,如果一个成员函数有引用限定符,那么名字相同且参数列表都相同的成员函数都要加上引用限定符:

class MyClass {
public:
    void func(int) &;
    void func(int) const;  // 错误
};

拷贝省略

执行以下代码:

#include <iostream>

class Widget {
public:
    Widget() { std::cout << "普通构造函数" << std::endl; }
    Widget(const Widget &from) { std::cout << "拷贝构造函数" << std::endl; }
    Widget(Widget &&from) { std::cout << "移动构造函数" << std::endl; }
};

Widget getWidget() {
    Widget w;
    std::cout << &w << std::endl;
    return w;
}

int main() {
    Widget w = getWidget();
    std::cout << &w << std::endl;

    return 0;
}

输出:

普通构造函数
0x61fe1f
0x61fe1f

可见,明明getWidget返回的是一个普通的对象而不是引用,在Widget w = getWidget()本应发生拷贝,但实际并没有调用拷贝构造函数,甚至两个对象的地址还是相同的。

这是因为编译器对其进行了优化,这种优化被称为返回值优化(Return Value Optimization, RVO),或者更具体些,具名的返回值优化(Named Return Value Optimization, NRVO)。

对于移动构造,也会优化掉不必的移动:

Widget w{Widget{}};

输出:

普通构造函数

可见,内层的Widget{}创建了一个右值对象,使用一个右值对象构造一个Widget,实际只调用了一次普通构造函数,而没有调用移动构造函数。

这也是编译器的优化,这些优化称为拷贝省略(Copy Elision)。

这些优化可以避免一些不必要的拷贝或移动所造成的开销。
在C++ 11,可以通过编译器参数-fno-elide-constructors来关闭这种优化。
不过到了C++ 17,这种优化成了标准的一部分,于是不能使用编译器参数来关闭它,保证了优化的发生。

为什么要特地用一节来介绍这个优化,是因为在平时我们可能会写出这样的优化专门用来测试拷贝构造或移动构造,或是有意地写出这样的代码去构造一个对象同时执行拷贝构造函数或移动构造函数的函数体中除资源初始化外的其他操作,但由于编译器优化导致这些操作并没有发生,从而违背我们代码的意图。当我们写出这样的代码时,要知道编译器会对其进行优化,从而应该采用其他方式实现我们的意图。

this 指针与 const

this 指针

当我们在一个类成员函数中访问另一个类成员变量或函数时,可以使用this指针:

class Widget {
public:
    void func() const {
        this->_var++;  // 显式使用this,非法
        _var++;  // 隐式使用this,非法
    }
private:
    int _var;
};

上述代码中的this->均可以省略。当它被省略时,可以认为是隐式地使用了this->this是一个常量指针(指针本身是常量),即Widget * const类型,因此不能修改this指针的指向。

const 对类成员函数的影响

当我们在类成员函数的参数列表后添加一个const时,表明这个函数不会修改类中的成员变量。为了保证这一点,在该函数中的this指针,无论是显式还是隐式使用,都会是底层const的类型,且无法在const成员函数中调用非const成员函数:

class Widget {
public:
    void func() const {
        this->_var++;  // 显式使用this,非法
        _var++;  // 隐式使用this,非法
        func2();  // 非法
    }
    void func2();  // 非 const 成员函数,可能修改成员变量的值
private:
    int _var;
};

注意当我们返回this指针的解引用形式时,为了防止拷贝应将返回值类型设置为引用。当然,记得使用引用接收:

class Widget {
public:
    Widget& func() {
        return *this;
    }
};

int main() {
    Widget w1;
    Widget w2 = w1.func();  // w2与w1是两个不同的对象,因为产生了拷贝
    Widget &rw1 = w1.func();  // rw1与w1是同一个对象
    return 0;
}

而当func参数列表右侧加上const时,它的this指针受底层const约束,因此返回的引用也应该加上const约束:

class Widget {
public:
    const Widget& func() const {
        return *this;
    }
};

成员函数可以有const与非const的重载版本,根据调用该函数的实例化对象是否为底层const而选择实际调用的函数:

class Widget {
public:
    void func() {
        std::cout << "非const" << std::endl;
    }
    void func() const {
        std::cout << "const" << std::endl;
    }
};

int main() {
    Widget w1;
    w1.func();
    const Widget w2;
    w2.func();
    const Widget *pw = &w1;  // 底层const指针指向非const变量w1
    pw->func();

    return 0;
}

输出:

非const
const
const

如果一个成员函数只有const版本,则无论实例化对象是否为底层const都可以调用:

class Widget {
public:
    void func() const;
};

int main() {
    Widget w1;
    w1.func();  // 合法
    const Widget w2;
    w2.func();  // 合法
    const Widget *pw = &w1;
    pw->func();  // 合法

    return 0;
}

但是如果一个成员函数只有非const版本,则实例化对象为底层const时无法调用非const成员函数:

class Widget {
public:
    void func();
};

int main() {
    Widget w1;
    w1.func();  // 合法
    const Widget w2;
    w2.func();  // 非法
    const Widget *pw = &w1;
    pw->func();  // 非法

    return 0;
}

此外,构造函数是没有const版本的,因为在实例化一个类对象时,通常需要对其成员变量进行初始化,必然需要为成员变量赋值。

可变数据成员

有些情况下,我们希望即便是const成员函数,也能修改类中的某些成员变量,此时我们可以将其声明为mutable:

class Widget {
public:
    Widget(int var) : _var(var) {} 
    void func() const {
        ++_var;
    }
private:
    mutable int _var;
};

这样一来,无论Widget实例化对象是否为const,都能修改_var的值。

正因如此,mutableconst不能同时出现在成员变量的声明中。

volatile成员函数

const修饰成员函数类似,volatile也可用于修饰成员函数,如果一个类对象是volatile的,那么它只能调用volatile成员函数:

struct Widget {
    void func() { std::cout << "non-const and non-volatile ver" << std::endl;}
    void func() const { std::cout << "const ver" << std::endl;}
    void func() volatile { std::cout << "volatile ver" << std::endl;}
    void func() const volatile { std::cout << "const volatile ver" << std::endl;}
};

int main() {
    Widget w1;
    const Widget w2;
    volatile Widget w3;
    const volatile Widget *w4 = &w1;  // 使用指针或引用来加上限定

    w1.func();
    w2.func();
    w3.func();
    w4->func();

    return 0;
}

const不同的是,如果一个类对象被声明为volatile的,它将不能使用自动生成的拷贝/移动构造函数及赋值运算符,因为不能将一个非volatile引用绑定到一个volatile对象上。
为此,可以自定义拷贝或移动操作,例如:

struct MyClass {
    MyClass(const volatile MyClass &other);  // 从一个volatile对象进行拷贝
    MyClass& operator=(volatile const MyClass &other);  // 将一个volatile对象赋值给一个非volatile对象
    MyClass& operator=(volatile const MyClass &other) volatile;  // 将一个volatile对象赋值给另一个volatile对象
};

尽管我们可以这样做,但貌似没有什么意义,什么样的程序会使用到一个volatile类对象呢?就算用了,拷贝volatile对象是否有意义呢?

成员函数返回值设计

在设计一个类的成员函数时,我们可能会需要考虑这样一个问题: 究竟应该返回一个什么样形式的对象?

例如,以下是一个三维向量类的设计(省略除叉乘外的成员函数):

class Vector3D {
public:
    Vector3D() : Vector3D(0, 0, 0) {}  // C++ 11的委托构造函数
    Vector3D(double x, double y, double z) : _x(x), _y(y), _z(z) {}

    Vector3D crossProduct(const Vector3D &other) const {
        Vector3D res{
                _y * other._z - _z * other._y,
                _z * other._x - _x * other._z,
                _x * other._y - _y * other._x
        };
        return res;
    }
private:
    double _x, _y, _z;
};

可见,该类实现叉乘的成员函数返回了一个新的Vector3D对象,且将成员函数声明为const,接收的参数也是const引用,意味着该函数不会修改参与运算的两个向量的成员变量。

另外一种设计:

class Vector3D {
public:
    Vector3D() : Vector3D(0, 0, 0) {}
    Vector3D(double x, double y, double z) : _x(x), _y(y), _z(z) {}

    Vector3D& crossProduct(const Vector3D &other) {
        double x = _y * other._z - _z * other._y;
        double y = _z * other._x - _x * other._z;
        double z = _x * other._y - _y * other._x;
        this->_x = x;
        this->_y = y;
        this->_z = z;
        return *this;
    }
private:
    double _x, _y, _z;
};

这种方式不会产生一个新的对象,而是对前一个参与运算的向量进行修改,使之成为叉乘的结果。

不过这样的设计可能会有一个问题,正因为它会修改原向量,于是可能会产生这样的问题:

Vector3D v1{1, 2, 3};
Vector3D v2{3, 2, 1};
Vector3D v3 = v1.crossProduct(v2);

这里用户原本希望得到v1v2叉乘的结果,则将结果赋给v3,但是因为crossProduct修改了v1原本的内容,于是v1v3实质上是相同的,但它们不是同一个对象(内存地址不同),因为v3没有使用引用去接收,于是产生了拷贝。但用户以为v1还是原来的向量,用户可能期望保留原始对象不变,而误用了这种设计,导致原对象被修改而未预料到,可能引发意外的逻辑错误。

而这个函数的设计者初衷可能是,让v1完成叉乘后仍可以在同一个语句中继续参与运算(链式调用)而无需换行,如:

v1.crossProduct(v2).dotProduct(v3);

但因错误的使用而出现了问题。一种解决方法是,让crossProduct的返回值类型是void以防止误解,或采用前一个设计方案。

对于大多数情况下,返回一个新对象(即第一种设计)是更安全且符合直觉的做法。它避免了修改原始对象,减少了意外错误的风险。

而如果必须进行原地修改并返回引用(第二种设计),则需要更加谨慎地设计函数,确保用户了解会修改原向量,或者通过更明确的命名和文档来避免误解。

还有一种选择是,两种方法都支持,并采用合适的命名区分,例如:

class Vector3D {
public:
    Vector3D() : Vector3D(0, 0, 0) {}
    Vector3D(double x, double y, double z) : _x(x), _y(y), _z(z) {}

    Vector3D getCrossProduct(const Vector3D &other) const {
        return { _y * other._z - _z * other._y,
                 _z * other._x - _x * other._z,
                 _x * other._y - _y * other._x };
    }
    
    Vector3D& toCrossProduct(const Vector3D &other) {
        double x = _y * other._z - _z * other._y;
        double y = _z * other._x - _x * other._z;
        double z = _x * other._y - _y * other._x;
        this->_x = x;
        this->_y = y;
        this->_z = z;
        return *this;
    }
    
    Vector3D operator*(const Vector3D &other) const {
        return getCrossProduct(other);
    }

    Vector3D& operator*=(const Vector3D &other) & {
        return toCrossProduct(other);
    }
private:
    double _x, _y, _z;
};

在实际的工程项目中,一个类可能相当复杂,因此对类的设计应当明确,以免产生误解。

友元

将外部非成员函数设置为友元

由于我们将部分成员变量设置成了私有,于是在类的外部我们将无法访问这些变量。但是,有时候我们希望有一些函数可以访问私有成员,此时就可以利用友元。

以上面提到的向量为例,假设把crossProduct方法变成一个普通的函数而不是类的成员函数,那么为了让这个函数能够访问类里的成员变量,需要将在类里面声明该函数为友元:

class Vector3D {
    friend Vector3D crossProduct(const Vector3D &v1, const Vector3D &v2);
public:
    Vector3D() : Vector3D(0, 0, 0) {}  // C++ 11的委托构造函数
    Vector3D(double x, double y, double z) : _x(x), _y(y), _z(z) {}
private:
    double _x, _y, _z;
};

Vector3D crossProduct(const Vector3D &v1, const Vector3D &v2) {
    return Vector3D{
            v1._y * v2._z - v1._z * v2._y,
            v1._z * v2._x - v1._x * v2._z,
            v1._x * v2._y - v1._y * v2._x
    };
}

于是虽然函数crossProduct不是类中的函数,但它也能够访问类中的私有成员。

一般我们会把多个友元函数集中放在类声明中的同一个地方,以便我们查看和增加友元。

注意,友元的声明只是指明了访问的权限,并非通常意义上的函数声明,因此如果采用先声明再定义函数的形式,不能省略函数的声明这一步。

注意区分友元函数和类成员函数的先声明再定义,虽然它们长得很像:

// 类的声明,通常放在头文件(如.h文件)中
class Vector3D {
friend Vector3D crossProduct(const Vector3D &v1, const Vector3D &v2);
public:
    Vector3D();
    Vector3D(double x, double y, double z);
    void add(double x, double y, double z);
private:
    double _x, _y, _z;
};

// 函数的声明,通常放在头文件(如.h文件)中
// 通过友元给予权限的外部函数,不属于类的成员
Vector3D crossProduct(const Vector3D&, const Vector3D&);

// =====================================================

// 函数的定义,通常放在源代码文件(如.cpp文件)中
// 通过友元给予权限的外部函数,不属于类的成员
Vector3D crossProduct(const Vector3D &v1, const Vector3D &v2) {
    return Vector3D{v1._y * v2._z - v1._z * v2._y, v1._z * v2._x - v1._x * v2._z, v1._x * v2._y - v1._y * v2._x};
}

// 在类的外面定义类中的成员函数,通常放在源代码文件(如.cpp文件)中
Vector3D::Vector3D() : Vector3D(0, 0, 0) {}
Vector3D::Vector3D(double x, double y, double z) : _x(x), _y(y), _z(z) {}
void Vector3D::add(double x, double y, double z){
    this->_x += x;
    this->_y += y;
    this->_z += z;
}

此外,如果直接在类的内部定义成员函数而不是先声明再在类的外部定义,它会被隐式地认为是一个内联函数,即便它没有inline。所以,通常我们只会在头文件中类的声明内部直接定义的成员函数都是简单的函数。
不过,当这个直接在类中定义的成员函数过于复杂时,编译器可能会不进行内联操作以防止代码膨胀,即便语法上它应该内联。
本文章为了方便阅读,大部分成员函数都直接定义在类的内部。

将类或类函数设置为友元

除了将外部的非成员函数设置为友元,还可以把另一个类或类函数设置为友元。

如果一个类将另一个类设置为了友元,则另一个类的成员函数可以访问前者的所有成员,包括私有成员:

class User {
    friend class UserManager;
private:
    int id;
};

class UserManager {
public:
    int getUserID(User user) {
        return user.id;  // 合法
    }
};

这段代码中类User中的友元UserManager前加上了class,表明友元名字UserManager是一个类类型,即便该类还没有被声明或定义。如果去掉这里的class,则需要保证UserManager已经被声明过了,否则编译器会因找不到UserManager而报错。

当然,有时候我们并不希望给友元类所有的权限,那么我们可以只给它部分权限,即将类成员函数设为友元:

class User;  // 前向声明

class UserManager {
public:
    int getUserID(User user);  // 必须提前声明
};

class User {
    friend int UserManager::getUserID(User user);
private:
    int id;
};

int UserManager::getUserID(User user) {
    return user.id;
}

有没有办法让友元只能访问部分成员?

友元允许友元函数或类访问类中的私有成员,但如果我们不希望所有成员都能被友元访问,应该如何做呢?

当然是采用其他设计方式而不是友元啦。

不过还是有技巧解决这个问题的,例如可以采用间接访问的方式来控制友元的权限,使用另一个类来暴露部分成员:

class MyClass {
    friend class MyClassWrapper;
private:
    int welcome;
    int dontReadMe;
};

class MyClassWrapper {
    friend void access(MyClass myclass);
public:
    MyClassWrapper(MyClass& myclass) : welcome(myclass.welcome) {}
private:
    int &welcome;
};

void access(MyClass myclass) {
    MyClassWrapper wrapper(myclass);
    wrapper.welcome = 1;
}

多态

对象切割

假设有一个类class Dog继承了另一个类class Animal,对于Java程序员来说可能写出这样的代码:

Animal animal;
animal = Dog();

可能原本的意图是,使用基类对象来抽象化地表示一个子类对象,但是实际上做的却令人大跌眼镜:

  • 首先创建一个Dog类型的对象
  • 调用DogAnimal的拷贝构造函数,因为没有这个拷贝构造函数,因此隐式转换成了Animal,再调用Animal的拷贝构造函数,即右值Dog()被拷贝给了animal
  • 右值Dog()被销毁
  • 变量animal是个Animal类型的对象,与Dog无关

也就是说,它其实是进行了类型转换和拷贝赋值,它类似于:

int a = 1;
short b = a;

如果Animal类没有向Dog转换的方法,它将无法再次转换为Dog:

Animal animal;
animal = Dog();
Dog dog = static_cast<Dog>(animal);  // 错误: Animal无法向Dog转换,无法通过编译
Dog *pdog = static_cast<Dog*>(&animal);  // 未定义行为,可以通过编译,但可能引起程序崩溃 

对于最后一行,由于animalAnimal类型的,实际上并没有Dog的内存布局,因此这会导致程序崩溃或其他不可预见的行为。它类似于:

int a = 1;  // 假设这里的 int 占用4字节
double *s = (double*) &a;  // 将其解释为了占用8字节的 double 的指针,显然是错误的,但可以通过编译

如果你认真地阅读了拷贝控制,应该很清楚这是为什么。虽然它长得与Java中实现基类对象指针=派生类对象的方式很像,但C++与Java有着很大的区别。

对于以下写法也是同理:

Dog dog;
Animal animal = dog;  // 拷贝赋值

Animal animal2 = Dog(); // 拷贝赋值

第二行将dog赋给animal,是将dog拷贝给animal,由于类型不同还会发生隐式转换,于是doganimal是两个完全不同的对象。对二者其中之一的修改并不会影响到另外一个对象。
第四行则是拷贝赋值,类似于

float f = int(1);

由于animalanimal2被声明为Animal类型,它只会包含Animal部分的成员,丢失了Dog中特有的成员和方法。这种现象被称为对象切割(Object Slicing)。

继承关系下的成员函数

虚函数

当一个类继承另一个类时,如果父类的方法没有用virtual声明,则父类和子类的相同方法将会是独立的:

struct Animal {
    std::string what() const { return "animal"; }
};
struct Dog : public Animal {
    std::string what() const { return "dog"; }
};

int main() {
    Animal *pa = new Dog();
    std::cout << "pa的类型: " << pa->what() << std::endl;
    delete pa;
    
    Dog *pd = new Dog();
    std::cout << "pd的类型: " << pd->what() << std::endl;
    std::cout << "pd调用Animal类的类型: " << pd->Animal::what() << std::endl;
    delete pd;
    
    return 0;
}

输出:

pa的类型: animal
pd的类型: dog
pd调用Animal类的类型: animal

相当于是Dog中的what()方法隐藏了Animal类的what()方法,两个方法同时存在,当指针是Animal类型时,调用的就是Animal类中的what()方法;当指针是Dog类型时,也可以指定调用父类的what()方法。
不过当指针是Animal类型时,即便它实际指向一个Dog类型的对象,但调用的也是Animal中的这个方法。

为了能调用实际对象的方法,可以将Animal中的what()方法声明为virtual函数,这样Dog就可以改写父类方法:

struct Animal {
    virtual std::string what() const { return "animal"; }
};

pa的类型: dog

虚析构函数

不过上述代码仍然有一定问题,当我们通过基类指针释放实际是子类的对象时,会出现内存泄露:

struct Animal {
    ~Animal() { std::cout << "Animal destructor" << std::endl; }
};
struct Dog : public Animal {
    Dog() : ptr(new std::string) {}
    ~Dog() {
        delete ptr;
        std::cout << "Dog destructor" << std::endl;
    }
private:
    std::string *ptr;
};

int main() {
    Animal *pa = new Dog();
    delete pa;
    
    return 0;
}

输出:

Animal destructor

我们可以发现一个问题,pa被声明为Animal指针但它实际上是Dog对象,释放pa时只调用了Animal的析构函数,而没有调用Dog的析构函数,这导致Dog中动态分配的ptr没有被释放。

为了解决这一点,我们应当将Animal也声明为virtual:

struct Animal {
    virtual ~Animal() { std::cout << "Animal destructor" << std::endl; }
};

当一个函数的析构函数为virtual时,不会只调用实际对象的析构函数,它也会调用父类的析构函数(在调用子类析构函数之后),于是上述代码修改后的输出是:

Dog destructor
Animal destructor

当一个类中的成员函数允许被改写时,可以将其声明为virtual。当一个类允许被继承时,最好将析构函数也声明为virtual,以免发生内存泄露。

此外,当一个成员函数在父级已经被声明为了virtual,那么在子类它仍然是一个虚函数,子类无需再声明该函数是virtual,也能继续被接下来的派生类改写,直到遇到final(下面介绍)。

注意: 如果一个类存在虚析构函数,即便显式地通过= default去生成移动操作,编译器也不会为我们生成。

使用 override 标记要改写的成员函数

当我们改写父类方法时,需要保证是函数签名一致(函数名一致、参数列表一致、限定符一致等),否则就是一个新函数而不是改写函数。

在实际项目中,一个成员函数可能非常复杂,所以在改写函数时可能意外地没有保证是同一个方法,而是写了一个新方法。当这种情况发生时,编译器不会报错,甚至运行过程也不会报错,只是结果会与预期不符。

为了防止这种情况的发生,C++ 11新增了一个关键字override,加在函数参数列表、尾置返回值类型、限定符(如const和引用限定符等)之后,用于通知编译器这个成员函数改写了父类的一个成员函数。
而当不小心新建了一个函数时,由于父类没有这个函数,于是与“改写父类成员函数”的目的冲突,编译器报错。
当父类某个函数不是虚函数时,即便子类的一个函数签名与父类的这个函数完全一致,也不会覆盖父类的这个函数,这两个函数将会是两个独立的函数,只是恰巧函数签名一样而已。但是当你的意图是改写父类函数时,你加上了override,编译器报错,于是你明白了你忘记给父类的这个函数加上virtual了,OK问题解决,可以少掉头发了。

在C++ 11及之后,改写父类函数时随手加上override是一个好的习惯,它可以尽量避免一些不经意的错误,从而减少debug的时间。并且,它显式地说明了这个函数改写了父类函数,增加了代码的可读性和可维护性。

使用 final 避免子类隐藏父类成员函数

有时候,我们不仅不希望子类改写父类函数,也不希望子类用一个签名一致的成员函数隐藏掉父类的成员函数,此时我们就可以为父类的这个成员函数加上final:

struct Animal {
    virtual ~Animal() {}
    virtual std::string getBaseType() const final { return "Animal"; }
};
struct Dog : public Animal {
    // 以下代码无法通过编译
    std::string getBaseType() const { return "trying to change base type"; }
};

此外,只有virtual成员函数才能标记为final

如果我们不希望一个类可以被继承,则可以在类名之后加上final,比如:

class Dog final : public Animal {};
class Collie : public Dog {};  // 非法: Dog 不能被继承

存在默认参数的情况

当成员函数的形参存在默认实参时,其实际的默认值取决于本次调用的静态类型。

例如,通过基类的指针或引用调用函数,则采用基类中的默认值,即便实际对象是子类的对象:

struct Animal {
    virtual ~Animal() {}
    virtual void func(std::string str = "default string") const {
        std::cout << "Animal: " << str << std::endl;
    }
};
struct Dog : public Animal {
    void func(std::string str = "another string") const override {
        std::cout << "Dog: " << str << std::endl;
    }
};

int main() {
    Animal *pa = new Dog();
    pa->func();
    delete pa;

    Dog *pd = new Dog();
    pd->func();
    delete pd;
    
    return 0;
}

输出:

Dog: default string
Dog: another string

这很容易产生误解,明明调用的是子类的函数,却得到的是基类的默认值,对于不清楚这个特性的程序员来说很容易陷入又一天的debug过程。

因此,如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

当子类省略了这个默认实参时,还是可以改写原方法的,但通过子类指针或引用调用这个函数时不能省略实参:

struct Animal {
    virtual ~Animal() {}
    virtual void func(std::string str = "default string") const {
        std::cout << "Animal: " << str << std::endl;
    }
};
struct Dog : public Animal {
    void func(std::string str) const override {
        std::cout << "Dog: " << str << std::endl;
    }
};

int main() {
    Animal *pa = new Dog();
    pa->func();  // 合法
    delete pa;

    Dog *pd = new Dog();
    pd->func();  // 非法,不能省略实参
    pd->Animal::func();  // 合法
    delete pd;
    
    return 0;
}

虽然说基类和派生类中定义的默认实参最好一致,不过这也引发了一个问题,当需要修改默认实参时,需要同时修改所有派生类或基类的默认值以保持一致。为了防止可能的错误,你可以使用一个常量去规定默认值:

struct Animal {
    static constexpr const char* DEFAULT_STR = "default string";
    virtual ~Animal() {}
    virtual void func(std::string str = DEFAULT_STR) const;
};

struct Dog : public Animal {
    void func(std::string str = Animal::DEFAULT_STR) const override;
};

纯虚函数

纯虚函数是一种特殊的虚函数,它没有函数体,只声明而不定义。纯虚函数的存在使得一个类成为抽象类,不能直接实例化。抽象类只能作为基类供派生类继承和实现。纯虚函数的主要作用是为派生类提供接口,而派生类必须提供该接口的具体实现。

struct Animal {
    virtual void makeSound() = 0;
};
struct Dog : public Animal {
    void makeSound() override {
        std::cout << "Bark!" << std::endl;
    }
};

int main() {
    Animal animal;  // 非法: 含有纯虚函数的类不能直接实例化
    Animal *ptr = new Dog();  // 合法
    ptr->makeSound();  // 输出 Bark!

    return 0;
}

对于派生类,需要实现所有父类的纯虚函数,才能被实例化。如果只有部分纯虚函数被实现,则该派生类仍然是一个抽象类。

如果某个成员函数在派生类仍未实现,可以不写出来,也可以显式地写出来:

struct Animal {
    virtual void makeSound() = 0;
};
struct Homotherm : public Animal {
    void makeSound() = 0;  // 也可以不写出来
};

并且,派生类中没有实现的纯虚函数也不需要加上virtual了,原因和虚函数一样。

对于一个派生链条,一个成员函数可以从纯虚函数到被实现,然后继续派生的过程又变成纯虚函数。

struct Animal {
    virtual void procreate() = 0;
};
struct Mammal : public Animal {
    void procreate() { std::cout << "胎生" << std::endl; }
};
struct SpecialReproductionMammal : public Mammal {
    virtual void procreate() = 0;
};
struct Ornithorhynchus : public SpecialReproductionMammal {
    void procreate() { std::cout << "卵生" << std::endl; }
};

虽然这个例子有牵强,但它可以说明“继续派生的过程又变成纯虚函数”的情况。
哺乳动物(Mammal)基本都是胎生的,但不是所有哺乳动物都是胎生,于是派生一个类专门处理非胎生的哺乳动物的情况(SpecialreproductionMammal),而具体的情况有哺乳动物但却是卵生的鸭嘴兽(Ornithorhynchus)。

此外,即便一个成员函数是纯虚函数,但它也是可以被实现的,只不过它的函数体不能定义在类内,而必须定义在类之外。
即便一个类自己实现了自己的纯虚函数,它也是不能被实例化的,如果它的派生类没有实现这个类,也不能被实例化。

struct Shape {
    virtual void draw() = 0;
};
void Shape::draw() { std::cout << "." << std::endl; }

struct Point : public Shape {
    void draw() override { Shape::draw(); }
};
struct Circle : public Shape {};

int main() {
    Point point;
    point.draw();  // 输出: .
    
    Circle circle;  // 非法: Circle没有实现draw(),是个抽象类,即便Shape有默认实现

    return 0;
}

多重继承

一个类可以继承多个类:

struct Shepherd : public Dog {};
struct BritishDog : public Dog {};
struct BorderCollie : public Shepherd, public BritishDog {};

注意,上面的BorderCollie所继承的两个基类ShepherdBritishDog都加上了public说明符。每个基类的访问说明符是独立的,也就是说如果省略掉BritishDogpublic,那么它将采用默认访问说明符private,而面的public只属于Shepherd而不与BritishDog共享。

构造一个多重继承的派生类对象需要同时构造并初始化它的所有基类子对象:

class Dog {};
struct Shepherd : public Dog {
    explicit Shepherd(std::string livestock);
};
struct BritishDog : public Dog {
    explicit BritishDog(std::string region);
};
struct BorderCollie : public Shepherd, public BritishDog {
    explicit BorderCollie(std::string livestock, std::string region) : 
             Shepherd(std::move(livestock)), 
             BritishDog(std::move(region)) {}
};

在C++ 11中,允许派生类从它的一个或几个基类中继承构造函数:

struct Base1 {
    Base1(int a);
};
struct Base2 {
    Base2(std::string s);
};
struct Derive : public Base1, public Base2 {
    using Base1::Base1;
    using Base2::Base2;
};

不过上述代码是非法的,因为Derive有两个构造函数,但如果采用其中一个基类的构造函数,另一个基类的构造函数就无法传入参数。
为此,你应该为基类添加默认构造函数,以便使用其中一个基类的构造函数时,另一个可以调用其默认构造函数:

struct Base1 {
    Base1() = default;
    Base1(int a);
};
struct Base2 {
    Base2() : Base2("default string"); 
    Base2(std::string s);
};
struct Derive : public Base1, public Base2 {
    using Base1::Base1;
    using Base2::Base2;
};

不过,如果两个基类有着相同形参列表的构造函数,而无法确定实际采用哪一个构造函数,此时你应该手动为派生类其添加构造函数而不是继承基类的。
例如,上面ShepherdBritishDog都有一个相同的接收std::string的构造函数,则无法采用直接继承基类构造函数的方法。

当多重继承存在成员函数冲突时,实例化的派生类对象如果没有这个成员函数,那么调用基类成员函数时需要指明调用的是哪一个基类的。如果是基类指针,则直接调用指针类型对应的成员函数:

class Dog {};
struct Shepherd : public Dog {
    explicit Shepherd(std::string livestock) {};
    virtual void func() { std::cout << "Shepherd" << std::endl; }
};
struct BritishDog : public Dog {
    explicit BritishDog(std::string region) {};
    virtual void func() { std::cout << "BritishDog" << std::endl; }
};
struct BorderCollie : public Shepherd, public BritishDog {
    explicit BorderCollie(std::string livestock, std::string region) :
             Shepherd(std::move(livestock)),
             BritishDog(std::move(region)) {}
};

int main() {
    BorderCollie bc("sheep", "Anglo-Scottish border");
    bc.func();  // 非法
    bc.Shepherd::func();  // 合法
    bc.BritishDog::func();  // 合法

    Shepherd *shepherd = new BorderCollie(bc);
    shepherd->func();  // 输出 Shepherd
    delete shepherd;

    return 0;
}

这是因为,对于派生类BorderCollie对象,它没有覆盖或隐藏基类成员func(),于是产生了二义性。

在实际工程项目中,多重继承的使用需要谨慎考虑,因为它会使类变得过于复杂,难以维护。
尽量使用接口(抽象类)而非具体类进行多重继承,接口定义了方法签名,而不包含具体实现。然后,通过继承多个接口来实现接口。
或者,采用多个类对象进行组合来实现具体功能,而不是多重继承。

虚继承

虽然一个类的派生列表中同一个类不能出现多次:

class SuperShepherd : Shepherd, Shephred {};  // 非法

但是派生类可以间接地通过两个直接基类多次继承同一个类,例如标准库中的istreamostream分别继承了base_ios,而iostream继承自istreamostream
不过,iostream如果真的包含了base_ios的两份拷贝:

struct MyBaseIOS {
    int resource;
    explicit MyBaseISO(int resource) : resource(resource) {}
};
struct MyIStream : public MyBaseIOS {
    using MyBaseISO::MyBaseISO;
};
struct MyOStream : public MyBaseIOS {
    using MyBaseISO::MyBaseISO;
};
struct MyIOStream : public MyIStream, public MyOStream {
    explicit MyIOStream(int r1, int r2) :
    MyIStream(r1), MyOStream(r2) {}
};

在这种情况下,MyBaseISO中的resource就有两份独立的数据,因为继承关系是:

MyBaseIOS -> MyOStream
              -> MyIOStream
MyBaseIOS -> MyOStream

为了共享基类MyBaseIOS,可以采用虚继承,不过派生类需要对其基类和基类的基类都进行初始化:

struct MyBaseISO {
    int resource;
    explicit MyBaseISO(int resource) : resource(resource) {}
};
// virtual 和 public 的顺序无影响
struct MyIStream : virtual public MyBaseISO {
    using MyBaseISO::MyBaseISO;
};
struct MyOStream : public virtual MyBaseISO {
    using MyBaseISO::MyBaseISO;
};
struct MyIOStream : public MyIStream, public MyOStream {
    explicit MyIOStream(int r) :
             MyIStream(r),
             MyOStream(r),
             MyBaseISO(r) {}
};

于是继承关系变成:

      -> MyOStream
MyBaseIOS        -> MyIOStream
      -> MyOStream

这样的继承关系被称为菱形继承

继承关系下构造函数和析构函数的执行顺序

  • 对于构造函数,按照依赖链从上往下构造(先构造父亲,再构造儿子),或者说,先构造基类,再构造派生类。
  • 对于析构函数,按照依赖链从下往上析构(先析构儿子,再析构父亲),或者说,先析构派生类,再析构基类。这和构造函数是相反的。

如果类里有成员变量,在初始化该类时会先完成对成员变量的初始化,再完成对本类的初始化。

例如:

struct AnotherClass {
    AnotherClass() { std::cout << "AnotherClass construction" << std::endl; }
    ~AnotherClass() { std::cout << "AnotherClass destruction" << std::endl; }
};
struct Base {
    Base() { std::cout << "Base construction" << std::endl; }
    ~Base() { std::cout << "Base destruction" << std::endl; }
private:
    AnotherClass member;
};
struct Derive : Base {
    Derive() { std::cout << "Derive construction" << std::endl; }
    ~Derive() { std::cout << "Derive destruction" << std::endl; }
};

int main() {
    Derive d;
    return 0;
}

输出:

AnotherClass construction
Base construction
Derive construction
Derive destruction
Base destruction
AnotherClass destruction

对于多重继承的情况,除了上述规则外,

  • 构造时,会按顺序先初始化第一个直接基类,再初始化第二个直接基类,以此类推
  • 析构时,顺序与构造时相反

例如:

struct AnotherClass {
    AnotherClass() { std::cout << "AnotherClass construction" << std::endl; }
    ~AnotherClass() { std::cout << "AnotherClass destruction" << std::endl; }
};
struct Base1 {
    Base1() { std::cout << "Base1 construction" << std::endl; }
    ~Base1() { std::cout << "Base1 destruction" << std::endl; }
private:
    AnotherClass member;
};
struct Base2 {
    Base2() { std::cout << "Base2 construction" << std::endl; }
    ~Base2() { std::cout << "Base2 destruction" << std::endl; }
};
struct Derive : Base1, Base2 {
    Derive() { std::cout << "Derive construction" << std::endl; }
    ~Derive() { std::cout << "Derive destruction" << std::endl; }
};

int main() {
    Derive d;
    return 0;
}

输出:

AnotherClass construction
Base1 construction
Base2 construction
Derive construction
Derive destruction
Base2 destruction
Base1 destruction
AnotherClass destruction

虚函数表与虚函数实现原理

如果一个类存在虚函数,那么编译器会为这个类生成一个指向虚函数表的指针。虚函数表中存放的是指向虚函数的指针。

虚函数表位于内存模型中的只读数据段(.rodata),即常量区。虚函数的代码存放于代码段(.text)。

虚函数表是在编译的时候被创建的,而虚函数表指针则是在对象实例化时创建(即运行时创建)。

当一个对象需要调用虚函数时,先通过对象的虚函数表指针找到该类对应的虚函数表,然后再查虚函数表找到虚函数指针,进而找到想要调用的虚函数,从而完成虚函数的调用。

虚基类表

当存在虚继承时,会创建一个虚基类表。

虚基类表是在编译时创建的,虚基类表指针在运行时创建。

此外,派生类还会包含指向虚基类的指针,这个指针指向的是唯一的虚基类实例,而不是每个派生类都持有一份独立的虚基类副本,避免了虚基类重复创建的问题。

引用动态绑定

除了能使用指针对多态的对象进行动态绑定,引用也可以,例如:

Dog dog;
Animal &animal = dog;

相比于指针的情况,由于引用不能为空,无需对其进行是否为空的判断。并且,引用不会出现指针重定向的问题,因为一个引用不能重新绑定另一个对象。于是使用引用进行动态绑定会更加安全。

而指针则是更加灵活,因为确实有些情况下我们需要空指针来表示一定的逻辑,我们可能需要对空指针或非空指针分别做不同的处理,而不是简单的阻止空指针。

因此,当需要保证对象地址不为空的情况,可以选用引用;而当可能出现空指针的情况则可以选用指针。

访问控制与继承

派生列表访问说明符

如果我们将派生列表访问说明符从public改成private,并不会影响派生类的成员函数对基类成员的访问权限:

class Base {
public:
    int basePublicMember;
protected:
    int baseProtectedMember;
private:
    int basePrivateMember;
};
class Derive : private Base {
    void func() {
        basePublicMember = 1;  // 合法,可以访问基类的公有成员
        baseProtectedMember = 2;  // 合法,可以访问基类的受保护成员
        basePrivateMember = 3;  // 非法,本来就不能访问基类的私有成员
    }
};

它的作用是,控制派生类的实例化对象对基类的访问权限:

Derive d;
d.basePublicMember = 1;  // 非法,Derive将基类的公有成员访问权限设置为了私有
d.baseProtectedMember = 2;  // 非法,本来就不能通过实例化对象访问受保护成员

Base base = d;  // 非法: 不能将派生类转换为被私有继承的基类
Base *ptr = &d;  // 非法: 不能将派生类指针转换为被私有继承的基类

只有当派生类公有地继承基类时,才能将派生类对象向基类转换。
不过,如果是在派生类的成员中,就可以进行转换:

class Derive : private Base {
    void func() {
        Base *bptr = this;  // 合法
        Base base = *this;  // 合法
    }
};

它也能控制继承自派生类的新派生类对基类的访问权限:

class NewDerive : public Derive {
    void func() {
        basePublicMember = 1;  // 非法,Derive将基类的公有成员访问权限设置为了私有
    }
};

友元对于基类的权限

如果一个类中声明了友元函数,则这个友元函数可以访问该类中的私有成员和受保护成员。不过它不能访问基类的私有成员,只能访问基类的公有成员和受保护成员:

class Base {
public:
    int basePublicMember;
protected:
    int baseProtectedMember;
private:
    int basePrivateMember;
};
class Derive : public Base {
    friend void func(Derive obj);
private:
    int deriveMember;
};
void func(Derive obj) {
    obj.deriveMember = 1;  // 合法
    obj.basePublicMember = 2;  // 合法
    obj.baseProtectedMember = 3;  // 合法
    obj.basePrivateMember = 4;  // 非法
}

友元关系也不能继承,即在Base中声明的友元关系,并不能被继承到Derive中。

改变个别成员的可访问性

我们也可改变个别成员的可访问性,比如:

class Base {
public:
    int basePublicMember;
};
class Derive : public Base {
private:
    using Base::basePublicMember;
};

int main() {
    Derive d;
    d.basePublicMember = 1;  // 非法,基类这个公有成员权限被修改为了私有

    Base *ptr = &d;
    ptr->basePublicMember = 1;  // 合法,可通过基类指针访问

    return 0;
}

当然,不能反过来将基类的私有成员在派生类中修改为公有,因为派生类本来就无法访问基类的私有成员。

运行时类型识别(RTTI)

dynamic_cast

在前面的章节我们已经介绍过了dynamic_cast

当对一个对象指针进行dynamic_cast时,若转换失败,则返回空指针。

除了对指针进行转换,它也可以对引用进行转换:

  • dynamic_cast<type&>(e): e必须是一个左值
  • dynamic_cast<type&&>(e): e不可以是左值

当对一个引用类型进行动态转换失败时,会抛出bad_cast异常。

一个使用技巧:

if (Cat *pcat = dynamic_cast<Cat*>(panimal)) {
    // do something with pcat
}

因为pcat的作用域只在if语句中,因此它不会在if语句外被使用,从而避免误用了空指针的pcat

此外,如果上述panimal是顶层const,则转换时如果不带上顶层const则会无法通过编译,从而保证了不会不小心去除了其顶层const属性。

typeid

typeid也可用于运行时类型识别,不过,typeid得到的是该指针静态编译时的类型,因此使用指针时:

Dog *pdog = new Dog("dog");
Animal *pa = pdog;
std::cout << (typeid(pa) == typeid(Dog*)) << std::endl;
std::cout << (typeid(pa) == typeid(pdog)) << std::endl;

输出:

0
0

为此,我们应将指针解引用后再与类型作比较:

Dog *pdog = new Dog("dog");
Animal *pa = pdog;
std::cout << (typeid(*pa) == typeid(Dog)) << std::endl;

输出:

1

不过,因为它得到的是静态编译时的类型,于是如果你想表示它是否派生于一个基类,就只能用dynamic_cast了:

Animal *pa = new Shepherd();
std::cout << (typeid(*pa) == typeid(Dog)) << std::endl;
if (Dog *pdog = dynamic_cast<Dog*>(pa)) {
    std::cout << "cast successfully" << std::endl;
}

输出:

0
cast successfully

0x08 标准库容器

std::array

std::array已经在数组替代方案 std::array中介绍过了,这里不再赘述。

std::vector

相比于数组大小固定,vector的大小是动态的,而且插入或删除元素都很方便。

初始化 vector

vector是模板,支持指定特定类型,不过不包括引用类型。
如果指定的模板类型也是vector,即vector嵌套:

std::vector<std::vector<int>> v;

在C++ 11之前,有些编译器可能需要在两个右尖括号间添加空格才能正常识别:

std::vector<std::vector<int> > v;

vector的默认初始化方法,即指定一个T类型:

std::vector<T> v;

从另一个vector中拷贝:

std::vector<T> v1;

// 以下两种方法等效
std::vector<T> v2(v1);
std::vector<T> v3 = v1;

注: v2或v3的元素,是v1元素的副本,而不是引用。

可以在初始化时让vector包含n个重复对象:

std::vector<std::string> v(3, "test");

v含有3个字符串”test”。
如果只传入n的值,如:

std::vector<std::string> v(3);

则vector包含的内容是3个指定类型初始化后的值。这里指定的类型是std::string,所以是3个空字符串。如果指定的类型是int,如:

std::vector<int> v(3);

则vector包含3个整型值0。
注意: 有些类型可能不支持默认初始化。

在C++ 11,也可以使用列表初始化,即在初始化时包含特定元素,如:

// 以下两种方式等效
std::vector<std::string> v1{"a", "bc", "efg"};
std::vector<std::string> v2 = {"a", "bc", "efg"};

注意,使用花括号时,如果能用列表初始化,会优先调用列表初始化:

std::vector<int> v1(10);  // v1有10个0
std::vector<int> v2{10};  // v2只有一个元素0

std::vector<int> v3(10, 1);  // v3有10个1
std::vector<int> v4{10, 1};  // v4有两个元素10和1

std::vector<std::string> v5{10};  // v5有10个空字符串

操作 vector

vector的大小是可以动态变化的,我们可以使用push_back向末尾添加元素:

std::vector<int> v{1, 2, 3};
v.push_back(4);  // 添加后v中的元素为1, 2, 3, 4

可以通过pop_back从末尾弹出元素,但它没有返回值,且vector为空时也不会报错。
如果你想用vector实现一个堆,可以通过back来获取最后一个元素,然后再弹出它:

if (!heap.empty()) {
    auto data = heap.back();
    heap.pop_back();
    do_something(data);
}

vector有许多与string的操作类似的操作:

  • v.empty(): v是否为空,即不含任何元素
  • v.size(): v中元素的个数
  • v[i]: v中第i个元素的引用,i从0开始,无越界检查
  • v.at(i): 同上但有越界检查
  • v1 == v2, v1 != v2: v1和v2两个vector是否相同,即元素个数相同且同一位置的元素值相同
  • <, <=, >, >=: 以字典顺序进行比较

注意,对于两个vector的比较操作,需要元素本身可比较(如int)或定义了比较运算符的类(如string)才能进行比较。

高效插入元素

在使用push_back往vector末尾插入元素时,可能会引起拷贝。为了防止拷贝带来的性能损失,可以将vector的类型指定为你想指定类型的指针或智能指针,这样拷贝的就只是指针而不是完整的对象。不过如果使用指针,需注意其中的对象的生命周期,避免成为悬空指针,或者使用智能指针。

如果不想使用指针,可以利用C++ 11提供的移动语义来避免拷贝。

对于push_back:

  • 如果你传入的是一个左值(即已经构造的对象的引用),那么push_back会创建该对象的一个副本。
  • 如果你传入的是一个右值(即临时对象或可以“移动”的对象),且该可移动对象在移动构造时不会抛出异常,则push_back会将该对象移动到容器中,从而避免了不必要的复制。

为了让push_back可以移动而避免拷贝,vector指定的类型需支持移动构造,且保证不会抛出异常,比如:

class MyClass {
public:
    MyClass(std::string s) : _s(s) {}
    MyClass(const MyClass &m) : _s(m._s) {
        std::cout << _s << "拷贝构造" << std::endl;
    }
    MyClass(MyClass &&m) noexcept : _s(m._s) {
        std::cout << _s << "移动构造" << std::endl;
    }
private:
    std::string _s;
};

int main() {
    std::vector<MyClass> vec;

    MyClass obj("abc");
    vec.push_back(obj);  // 拷贝构造
    vec.push_back(MyClass("def"));  // 移动构造

    return 0;
}

输出:

abc拷贝构造
def移动构造
abc移动构造

如果传入的右值对象的移动构造函数没有noexcept,则执行push_back时标准库容器仍然会选择拷贝构造。这是因为,如果在执行移动构造时抛出异常,可能会导致容器的状态不一致,特别是容器扩容时,容器内部的元素可能已经发生了移动,导致丢失部分数据。

不过有时候,我们的这个对象需要提前构造,如果可以放弃对象的所有权,则可以使用std::move:

void addToVector(std::vector<MyClass> &vec) {
    MyClass obj("ghi");
    // 毕竟 obj 出了这个函数作用域就被销毁了
    // 与其使用拷贝让更长生命周期的新对象加入到容器
    // 不如放弃临时变量的所有权并使用移动来延长生命周期
    vec.push_back(std::move(obj));
}

输出:

ghi移动构造

此外,C++ 11的vector还提供了另一个方法emplace_back,能在vector末尾原地构造对象,从而避免拷贝。这句话看起来有点懵,举一个例子就明白了:

class MyClass {
public:
    MyClass(int a, std::string b) {}
};

int main() {
    std::vector<MyClass> v;
    
    v.emplace_back(123, "abc");
    
    return 0;
}

也就是说,emplace_back传入的是用于构造MyClass的参数值,让它自己来构造而不是由我们构造完再传入,这样就可以避免拷贝时的两次构造。如果vector指定的类型不支持移动构造,那么使用emplace_back是个不错的选择。

vector的数组是动态扩容的,这意味着当扩容发生时,需要分配新的内存,并将在旧数组中的数据迁移到新数组中。
如果一个对象不支持移动构造,那么迁移就是拷贝:

int copyTimes;
class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass &m) { ++copyTimes; }
};

int main() {
    std::vector<MyClass> vec;
    for (int i = 0; i < 50; ++i) {
        MyClass obj;
        vec.push_back(obj);
    }
    std::cout << "拷贝次数: " << copyTimes << std::endl;

    return 0;
}

输出:

拷贝次数: 113

这里的拷贝次数会因为编译器的不同而不同,因为不同编译器采用不同的扩容策略,有些是扩容至原来的1.5倍,有些是2倍等。

当移动构造存在时,vector可将旧数据从旧数组移动到新数组中,避免了拷贝的开销:

int copyTimes;
int moveTimes;
class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass &m) { ++copyTimes; }
    MyClass(MyClass &&m) noexcept { ++moveTimes; }
};

int main() {
    std::vector<MyClass> vec;
    for (int i = 0; i < 50; ++i) {
        MyClass obj;
        vec.push_back(obj);
    }
    std::cout << "拷贝次数: " << copyTimes << std::endl;
    std::cout << "移动次数: " << copyTimes << std::endl;

    return 0;
}

拷贝次数: 50
移动次数: 50

此外,移动构造函数需要保证不会抛出异常,并加上noexcept关键字,才能被正常移动,原因可见类-移动构造。如果移动构造没有noexcept关键字,仍会选择拷贝构造的方式进行迁移,读者可自行去除这个关键字并运行查看效果。

遍历 vector

使用迭代器(iterator)遍历 vector

迭代器有点像指针,指向容器中的某个元素,但它与指针不同。

容器有两个方法 begin 和 end ,分别用来获取指向指向第一个元素的迭代器和指向最后一个元素下一位置的迭代器。
对于指向最后一个元素下一位置的迭代器,通常用来表示已经遍历完了容器中的所有元素,只是一个标记,通常被称为尾后迭代器。
如果一个容器为空,则 begin 和 end 返回的是同一个迭代器,即都是尾后迭代器。

迭代器的操作:

  • *iter: 返回迭代器所指元素的引用
  • iter->xxx: 获取迭代器所指元素的引用并获取该元素引用的成员,等价为(*iter).xxx
  • ++iter, iter++: 令迭代器指向下一个元素
  • --iter, iter--: 令迭代器指向上一个元素
  • iter1 == iter2, iter1 != iter2,判断两个迭代器是否指向同一个元素,通常用来判断是否指向容器的尾后迭代器
  • iter + n: 返回令迭代器往前移动若干位置的迭代器
  • iter - n: 返回令迭代器往后移动若干位置的迭代器
  • iter += n: 令迭代器往前移动若干位置
  • iter -= n: 令迭代器往后移动若干位置
  • iter1 - iter2: 两个迭代器相减得到它们之间的距离,两个迭代器必须属于同一个容器(有符号)
  • <, <=, >, >=: 比较两迭代器的位置

注意(*iter).xxx*iter.xxx的区别,后者是尝试解引用iter.xxx,即解引用的是迭代器iter的xxx成员,而不是iter所指元素的xxx成员。
为了简化,迭代器重载了箭头运算符,可以像使用指针那样访问迭代器所指元素的成员。

++iter, iter++的区别前面章节讲过的类似,

  • ++iter先递增迭代器,再返回递增后的迭代器
  • iter++先返回当前迭代器,再递增,返回的是递增前的迭代器

相比之下,++iter会更高效,因为它避免了临时对象的创建。对于--的情况也类似。

于是我们便可以使用迭代器遍历vector:

std::vector<std::string> v{"a", "bc", "def"};

for (auto iter = v.begin(); iter != v.end(); ++iter) {
    std::cout << *iter << std::endl;
}

标准库提供了两种迭代器(以vector为例): vector<T>::iteratorvector<T>::const_iterator,两种迭代器的区别是能否写,前者可读可写,而后者只能读不能写。如果vector或者T是const,就只能使用vector<T>::const_iterator。如果vector和T都不是const,则两种迭代器都可以使用。

如果无需写操作,建议使用const_iterator,因为这样可以避免意外将容器中的元素进行改动。对于非const的vector,可以使用v.cbegin()v.cend()来获取const_iterator。如果vector或T已经是const,则begin和cbegin、end和cend等效。不过v.cbegin()v.cend()能保证获取的迭代器是只读的,无论原vector或T是否为const。

因此上面遍历vector的代码建议改为:

for (auto iter = v.cbegin(); iter != v.cend(); ++iter) {
    std::cout << *iter << std::endl;
}

注意: 当我们在使用迭代器遍历vector过程中,不能向迭代器所属容器添加元素,否则会导致迭代器失效。

使用 for range 遍历 vector

和遍历string类似,在C++ 11可以使用 for range 方便地遍历vector:

std::vector<std::string> v{"a", "bc", "def"};

for (auto ele : v) {
    std::cout << ele << std::endl;
}

同样,基于auto的特性,为了避免拷贝,以及只允许写操作,可以:

for (const auto &ele : v) {
    std::cout << ele << std::endl;
}

可见,这种写法比用迭代器或下标简洁很多。

与使用迭代器遍历一样,在使用 for range 遍历vector时,不能向vector添加元素。

vector 的访问性能

因为std::vector容器的内存分布是连续的,因此通过下标对容器中的元素的随机访问的性能是很高的,因为目标元素的具体内存地址只需首地址加上下标乘上元素的大小即可。

但是,由于每次分配的连续内存大小是有限的,因此在不断往vector中添加元素的过程中,需要不断地扩容以容纳更多的元素,而扩容后可能涉及旧元素的拷贝或移动,这些都是要消耗额外的资源的。

如果需要在vector的中间插入一个元素,则需要将这个位置之后的元素往后挪动以“让出”一个位置给待插入和元素,如果后面的元素数量特别多,则往中间插入元素的消耗是很大的。

std::list

std::list是一个双向链表,它容器中的元素在内存中的分布不是连续的,每个结节通过一个指针指向下一个结节,通过一个个结节链接起来,形成一个链表。

list不支持通过下标访问元素,如果要在list中查找元素,需要从某个位置(如头结点)开始往后遍历,最坏情况下可能需要遍历整个列表才能找到,或者找不到目标元素。

不过链表的插入元素的效率很高,因为它只需改变前一个元素的next指针使之指向自己,再将自己的next指针指向下一个元素,无需移动其他元素,也无需重新分配容器大小。因为std::list是双向链表,因此插入元素还会改变prev指针。
虽说如此,通常在list中间插入元素之前,如果没有待插入位置的结点实例,你可能还是要遍历一遍列表以查找插入点的结点,然后再进行插入。不过,在链表头或链表尾插入数据的效率很高

那么,如果不是想找而是遍历的情况,从头到尾遍历list与遍历vector,效率是否是相同的呢?
从原理上看,因为从头到尾遍历list每次只需读取下一个指针,速度好像没什么区别。
但是,由于vector中的元素在内存中是连续分布的,处理器中普通有cache,当访问内存中的一个数据时,会将这个数据所在的一整个内存块都读入cache中。
cache的访问速度比内存快很多,而一个内存块通常能容纳多个内存地址连续的数据,所以遍历一个vector通常只需访问次数较少的内存,而大多数访问都在cache中进行,充分利用了空间局部性原理优化了访问性能。
list因为其元素的内存分布是不连续的,它们可能随机地分布在内存的任意位置,因此在遍历过程中基本无法利用空间局部性原理,最坏情况下所有元素都得访问内存才能获取。

鉴于list读性能不好而插入性能好的特性,对于头插或尾插多而读少的场景,或者从中间插入元素较多的场景,std::list是一个不错的选择。

为了更好性能的链表,C++ 11引入了std::forward_list,相比于std::list来说,它没有size操作,而且是一个单向链表,因为少维护了大小和prev指针,它的性能会更好些,更适合大量小数据的插入。
因为forward_list没有prev指针,所以添加或删除元素无法访问它的前驱,进而修改前驱的next指针。因此forward_list的添加或删除元素功能是比较特殊的。

std::deque

std::deque是一个双端队列,它融合了listvector的优点,其内存分布是分段连续的。因此它支持随机访问且访问速度很快。

对于插入元素,它的代价(可能)很高,不过没有vector高。如果是在deque的两端插入元素,它的速度几乎与list相当。

std::map

std::mapstd::unordered_map是关联容器,与vectorlist等顺序容器不同的是,关联容器需要通过键来访问值。

std::map

std::map使用了一个红黑树实现,因为红黑树是一个二叉排序树(也是一个平衡二叉树),因此它的键是有顺序的。

std::map的使用例子如下:

std::string word = "strawberry";
std::map<char, int> count;
for (auto &c : word) {
    count[c]++;
}
std::cout << "单词strawberry中字母r的数量: " << count['r'] << std::endl;

输出:

单词strawberry中字母r的数量: 3

遍历map:

std::string word = "strawberry";
std::map<char, int> count;
for (auto &c : word) {
    count[c]++;
}
count['z'];  // 访问一个不存在的键,合法
for (const auto &pair : count) {
    std::cout << pair.first << ": " << pair.second << std::endl;
}

输出:

a: 1
b: 1
e: 1
r: 3
s: 1
t: 1
w: 1
y: 1
z: 0

这里的pair类型为std::pair,具体来说是std::pair<const char, int>&,可以通过pair.first访问键,通过pair.second访问值。

可见,如果map中键不存在,访问这个键对应值会自动创建一个值并对值进行默认初始化,再插入到容器中。

除了通过方括号这种类似于下标方式访问map,也可以通过map.at(key)来访问。使用at访问map时,若键不存在,会抛出一个std::out_of_range异常。

在C++ 17,你可以用结构化绑定更方便地遍历map:

for (const auto& [k, v] : count) {
    std::cout << k << ": " << v << std::endl;
}

如果不希望元素不存在时插入不必要的元素,可以判断元素是否存在:

auto iter = count.find('z');
if (iter != count.end()) {
    std::cout << "键 z 存在,其值为 " << iter->second << std::endl;
} else {
    std::cout << "键 z 不存在" << std::endl;
}

在C++ 17,可以通过范围基if语句更好地实现以上操作:

if (auto iter = count.find('z'); iter != count.end()) {
    std::cout << "键 z 存在,其值为 " << iter->second << std::endl;
} else {
    std::cout << "键 z 不存在" << std::endl;
}

这样写一方面代码更紧凑,可读性好,另一方面它将声明的变量iter的可见范围限制在了ifelse语句块内,避免了if外可能对其的意外访问,更加安全。

std::map的键必须是唯一的。准确来说,像mapmultimapsetmulti_set这样的有序容器,唯一代表的是任意两个元素都不”小于等于“另一个。
展开来说,它们的红黑树对键的比较操作是严格弱序的,对键的比较操作可能很复杂,但比较操作必须具备以下性质:

  • 两个关键字不能同时”小于等于“另一个。这听起来有点矛盾,既然不能同时小于等于,那么直接是小于不就行了吗?实际上,键类型不一定是数字,数字可以定义为小于,但其他复杂的类型可以存在等于但不是等价的情况。
    如果k1”小于等于“k2,则k2绝不能”小于等于“k1
  • 如果k1”小于等于“k2k2”小于等于“k3,则k1必须”小于等于“k3
  • 如果存在两个键是等价的,那么任何一个都不”小于等于“另一个,容器会将它们视为相等来处理
    如果k1等价于k2k2等价于k3,那么k1也必须等价于k3

相比于std::map不允许键重复,std::multimap没有这个限制,允许键重复。
std::multimap不能通过方括号的形式访问,需要通过insert插入数据。

如果需要删除map中的某个键值对,可以使用erase函数:

std::map<char, int> count;
count['z'] = 0;
count['a'] = 1;
auto cnt = count.erase('z');

erase会返回一个数字,表示实际删除的键值对数量。

  • 对于键不能重复的容器,erase的返回值总是1或0。若返回值是0,表示要删除的键值对不在容器中
  • 对于键允许重复的容器,erase的返回值可能大于1

自定义 map 键值类型

自定义键类型

为了让std::map支持我们自定义的键类型,我们需要让红黑树知道怎么对我们加入的键进行调整排序,即需要有一个比较操作operator<或自定义比较器。这里的小于需要是上面提到过的严格弱序。

例如:

class Person {
public:
    Person(std::string name, int age) : _name(std::move(name)), _age(age) {}
    bool operator<(const Person &other) const {
        return _name < other._name;
    }
    std::string info() const {
        std::stringstream ss;
        ss << "{" << _name << "," << _age << "}";
        return ss.str();
    }
private:
    std::string _name;
    int _age;
};

int main() {
    std::map<Person, int> map;
    map[Person{"Bob", 25}] = 1;
    map[Person{"Alice", 18}] = 1;
    map[Person{"Bob", 31}] = 2;

    for (const auto& [k, v] : map) {
        std::cout << k.info() << ": " << v << std::endl;
    }

    return 0;
}

注意这里的operator<还需要有const标记,否则会报错。
输出:

{Alice,18}: 1
{Bob,25}: 2

可见,名字为BobPerson对象会被视为是等价的,当我们将另一个名字同为Bob但年龄不相同的Person对象作为键时,会改变原来键对应的值,但不会改变键(年龄仍为25)。

此外,遍历时也会按名字升序的顺序遍历。

自定义值类型

要想让值类型可用于map,它必须拥有一个无参的默认构造函数,否则将无法通过编译。

std::unordered_map

std::unordered_map是在C++ 11引入的,它的原理是哈希表(散列表),且对于碰撞的处理普遍采用的是拉链法。

由于哈希表“随机化”的特性,对一个哈希表进行遍历时是无序的。

相比于std::map,由于红黑树查找元素时需要进行多次比较,插入或删除需要对红黑树进行调整(时间复杂度均为$O(\log n)$),哈希表在理想情况下可以在常数时间($O(1)$)进行查找、插入和删除。因此在某些情况下,使用哈希表的版本性会更好。
不过,当哈希表的负载因子达到一定大小时,发生碰撞的概率不断增大,对于拉链法来说,最极端情况下所有键都落入同一个桶中(同一个链表),则查找效率会退化成O(n)

为此,std::unordered_map会在及负载因子超过一定值时进行扩容,增加更多的桶,并将旧的数据进行重新散列并分配到新的哈希表中。

自定义 unordered_map 键类型

std::map不同,因为std::unordered_map对键采用的是散列计算,于是当自定义类型需要作为unordered_map的键时必须提供对键的哈希函数。此外,为判断是否是同一个键,还要提供一个operator==

例如:

class Person {
    friend class std::hash<Person>;
public:
    Person(std::string name, int age) : _name(std::move(name)), _age(age) {}
    bool operator==(const Person &other) const {
        return this == &other;
    }
private:
    std::string _name;
    int _age;
};

namespace std {
    template <>
    struct hash<Person> {
        size_t operator()(const Person& p) const {
            // 使用名字和年龄组合来生成哈希值
            return hash<string>()(p._name) ^ hash<int>()(p._age);
        }
    };
}

这样设计的Person只有在内存地址相同时都会被视为是同一个。其哈希函数根据名字和年龄确定,当两个Person的名字和年龄完全相同时,会发生碰撞,也就是说两个对象会落入哈希表的同一个桶中,但不一定会被视为是同一个对象,因为我们规定了只有内存地址相同时才是同一个对象。

此外,std::unorder_multimap允许键重复。

std::set

相比于std::mapstd::set只有键没有值,是一个集合,同样拥有std::multisetstd::unordered_setstd::unordered_multiset

std::set通过insert插入元素,可以通过erase删除元素,通过findcount查找或计数元素。

std::tuple

std::tuple是在C++ 11引入的,它类似于std::pair,不同的是std::pair只有两个成员,而std::tuple可以有任意数量的成员。

例如,你想临时将一些数据组合成单一对象,但又不想特地定义一个结构体,又或者,想要在一个函数中返回多个值,此时就可以使用tuple

初始化一个 tuple

std::tuple<int, std::string, std::vector<double>> t(1, "some string", {"s1", "s2"});

当然,我们也可以使用tuple的默认构造函数,它会对每个成员进行默认初始化:

std::tuple<int, std::string, std::vector<double>> t;

当然,提前是所有成员都能被默认初始化。

由于std::tuple的构造函数是explicit的,因此以下这种初始化方法是非法的:

std::tuple<int, std::string, std::vector<double>> t = {1, "some string", {"s1", "s2"}};  // 非法

也可以用std::make_tuple来创建tuple,它会根据初始值的类型来推断tuple的类型:

auto item = std::make_tuple(1, "s", std::vector{"s1", "s2"});

访问 tuple 的成员

由于tuple的成员数量是不确定的所以不能像std::pair那样通过p.firstp.second来访问。

标准库中提供了一个模板函数std::get<T>(tuple)来访问,得到的是成员对象的引用,例如:

std::tuple<int, std::string, double> t;
int first = std::get<0>(t);
std::string second = std::get<1>(t);
std::get<2>(t) *= 1.5;

查看 tuple 的类型信息

通过以下方法,可以查看或利用tuple的类型信息:

// 将 trans 设定为 t 类型
typedef decltype(t) trans;
// 得到 tuple 中有几个成员
std::size_t size = std::tuple_size(trans)::value;
// 用 tuple 中第一个成员的类型作为 cnt 的类型并接收 tuple 中第一个成员的值
std::tuple_element<0, trans>::type cnt = std::get<0>(t);

使用 tuple 从函数返回多个值

tuple的一个常见用途是让函数能够返回多个值:

std::tuple<double, bool, std::string> safeDivide(double a, double b) {
    if (b == 0) {
        return {std::nan(""), false, "除数不能为0"};
    }
    if (std::isnan(a) || std::isnan(b)) {
        return {std::nan(""), false, "被除数或除数不能为NaN"};
    }
    if (std::isinf(a)) {
        return {std::nan(""), false, "被除数不能为无穷"};
    }
    return {a / b, true, ""};
}

int main() {
    auto res = safeDivide(1, 0);
    bool ok = std::get<1>(res);
    if (ok) {
        double result = std::get<0>(res);
        std::cout << "结果为: " << result << std::endl;
    } else {
        std::string err = std::get<2>(res);
        std::cout << "出现错误: " << err << std::endl;
    }

    return 0;
}

在C++ 17,可以使用结构化绑定更方便地获取tuple中的每个值:

auto [result, ok, err] = safeDivide(1, 0);
if (ok) {
    std::cout << "结果为: " << result << std::endl;
} else {
    std::cout << "出现错误: " << err << std::endl;
}

0x09 内存管理

C语言内存管理

malloc/free 与 new/delete 的区别

学习过C语言的可能知道,在C中使用malloc分配内存,使用free释放内存。而newdelete则是C++的关键字。

malloc 与 new

使用malloc分配内存时,需要指明分配的内存大小。而使用new则无需指定,可以自动计算所需的内存大小。
若在C++中使用malloc为类对象分配内存:

MyClass *ptr = (MyClass*) malloc(sizeof(*ptr));

这里进行了类型转换,因为malloc返回的指针类型是void*,需进行手动转换。

当内存分配失败时,malloc会返回NULL,而new会抛出std::bad_alloc异常。不过,在现代操作系统中,因为采用虚拟内存的技术,一般不会出现内存分配失败的情况,即便内存占用已经远远超过了可用内存容量。不过对于某些嵌入式系统,或某些特别极端的情况下,才会出现内存分配失败的情况。

在不同的系统系统中,malloc的行为可能有所不同。一般情况下,在linux操作系统中,使用malloc,当使用malloc分配的内存达到或超过一定值(如128KB)时,会在文件映射区进行分配,也就是通过mmap系统调用进行内存分配。
此外,malloc还维护了一个内存池,当申请小内存时,会先在内存池中进行分配,如果不能在内存池分配,则通过brk系统调用在堆上进行内存分配。
而使用new时会在内存的自由存储区(free store),也就是堆上进行内存分配。newallocator申请大、小内存的行为(调用哪个系统调用)与malloc基本一致。

C++的new运算符还可以被重载,当它被重载时,会先调用其重载函数(void* operator new()),然后再申请内存空间,接着调用构造函数并初始化成员变量。
malloc并不会对成员变量进行初始化。

当使用malloc时,其实会多分配额外的字节(如16字节)的空间,指定需要分配的内存空间接在这16字节空间之后。它返回的指针指向的是指定分配的内存空间的首地址,不会指向这多分配的16字节空间,虽然你可以通过移动指针访问它。
这16字节的空间储存的是分配的内存块的描述信息,它包含了分配的内存空间大小。

注意如果指定malloc分配的内存大小为0时,是未定义行为,其结果取决于编译器。

delete 与 free

C++的delete接受一个特定类型的指针,这是因为需要根据类型来计算需要释放的内存空间大小,以及调用其析构函数。如果对指针进行类型擦除(如转为void*),则无法正常释放。
free只需一个void*指针。

当进行delete时,会先调用析构函数,然后再调用operator delete,最后释放空间。

因为malloc会在所需空间前面多分配16个字节的内存空间,因此调用free时会自动向左移动16个字节,确保这些空间也能被正确释放。
因为这16个字节的描述信息包含了分配的内存空间大小,所以使用void*指针就能正确释放分配的内存空间。

若使用free释放的内存是从内存池或brk系统调用分配的,则释放的内存会回收到内存池中;如果是从mmap系统调用分配的,则操作系统会立刻释放这个内存。

注意free不会将指针设为NULL,为了防止操作悬空指针造成的灾难性后果,通常你需要在free后立刻将指针手动设为NULL

malloc 是否立刻得到物理内存

当使用malloc进行内存分配后,由于现代操作普遍采用了虚拟内存技术,于是得到的是虚拟内存。虚拟内存分配时会在页表中建立关系,但不会对数据进行初始化,于是该虚拟内存对应的物理内存内容是未定义的,可能包含垃圾数据。操作系统仅仅为虚拟内存提供了一个映射,但并没有将物理内存分配或初始化。

当通过页表访问物理内存时,会发生缺页中断,此时操作系统捕获并处理这个中断,才会将相应的虚拟内存页加载到物理内存中。然后,更新页表,将虚拟地址映射到实际的物理地址,程序就可以访问到实际的物理内存。

使用场景

因为malloc只分配原始的内存块,并不会进行初始化,因此在C++中不推荐使用它,除非是非常特殊的需求(例如与C代码交互时,或者在操作系统编程中需要使用原始内存分配)。

特别地,因为mallocnewfreedelete所做的具体操作是不同的,因此不能混用它们,例如将malloc分配的指针直接使用delete释放,是非常危险的行为。

事实上,在现代C++中,除非是某些特殊的项目(如底层开发),newdelete也不建议使用了,使用智能指针替代它能省下很多的头发。

calloc 与 realloc

calloc

相比于malloccalloc有两个参数numsize,表示申请num个大小为size的空间,并将分配的空间每个字节初始化为0。

realloc

正如它的名字 realloc ,它用于重新分配内存:

void *realloc(void *ptr, size_t size);

例如,对一片内存空间进行扩容,

  • 如果这片空间后面有足够大的空闲空间,则直接进行扩容,并返回原地址
  • 如果这片空间后面没有足够大的空闲空间,则分配一片新的空间,将旧空间中的数据移动到新分配的空间中,自动释放旧空间,并返回新分配空间的地址
  • 如果堆上没有足够的空闲空间进行分配,则分配失败,返回NULL,原有空间不会被释放

memcpy 与 memmove

memcpy将指定内存范围下的二进制数据复制到另一地址下,例如使用它进行数组拷贝:

int arr1[5] = {1, 1, 1, 1, 1};
int arr2[3] = {2, 2, 2};
memcpy(arr1, arr2, sizeof(int) * 3);
for (int i = 0; i < 5; ++i) {
    printf("%d", arr1[i]);
}

输出:

22211

上述是将一块内存中的数据复制到另一块不重叠的内存中,当内存重叠时,复制的结果可能就不是我们想要的:

int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
memcpy(arr+3, arr, sizeof(int)*5);
for (int i = 0; i < 10; ++i) {
    printf("%d", arr[i]);
}

输出:

01201230189

我们希望得到的是0120123489,为了得到正确的结果,我们可以将memcpy修改为memmove
(在你的环境中,可能使用memcpy也能得到正确的结果,这可能是编译器优化造成的)

memmove也是内存复制,但它允许内存重叠。

如果自己实现一遍memmove,像上述情况,因为目的地址大于源地址且范围产生重叠,从左往右复制会丢失数据,那从右往左复制不就行了?
事实上,它确实可以解决这种情况,但当目的地址小于源地址且范围产生重叠时,从右往左复制反而会产生数据丢失。

因此,如果待复制的内存地址存在重叠,

  • 当目的地址大于源地址时,采用从右往左复制
  • 当目的地址小于源地址时,采用从左往右复制

memcopymemmove可用于任何数据,因为它是逐字节复制的。

不过,在C++中,除了某些特殊的要求,我们不会使用它们。
例如,如果需要拷贝类对象,我们通常会重载拷贝、移动构造函数,而使用memcpymemmove并不会调用拷贝或移动构造函数,从而可能造成意外的结果。

memset

memset用于将指定范围内存块逐字节填充为指定值,比如:

int arr[5] = {1, 2, 3, 4, 5};
memset(arr, 0, sizeof(int)*5);
for (int i=0; i<5; ++i) {
    printf("%d", arr[i]);
}

输出:

0

注意,它是逐字节填充,而不是逐指定类型填充,因此当你想用它为整型数组直充为1时:

const int TARGET_NUM = 1;
int arr[5] = {1, 2, 3, 4, 5};
memset(arr, TARGET_NUM, sizeof(int)*5);
for (int i=0; i<5; ++i) {
    printf("%d ", arr[i]);
}

输出:

16843009 16843009 16843009 16843009 16843009

资源获取即初始化(RAII)

RAII 是 Resource Acquisition Is Initialization 的缩写,即“资源获取即初始化”。它是C++语言的一种管理资源、避免资源泄漏的惯用法。

例如,通过类的构造函数获取资源,通过析构函数释放资源,并通过对象的生命周期自动地管理类中资源的申请和释放,有效降低了内存泄露的风险。

我们前面讲过的智能指针便是 RAII 的一个代表。

具体如下:

  • 所有资源在构造函数中获取,例如分配内存、打开文件、获取锁、建立数据库连接等。如果无法完成则在构造函数中抛出异常。
  • 所有资源在析构函数中释放,例如:释放内存、关闭文件、释放锁、销毁数据库连接等;不应该抛出任何异常。

在实际的复杂的项目中,我们难以管理手动分配的资源,例如程序员的疏忽忘记释放资源、程序抛出异常导致释放资源的代码无法被执行、新增需求后在新增的分支中提前返回而没有释放资源等,最后一种情况尤为常见。

std::allocator

当我们使用new动态分配一个数组时,数组中的各元素会进行默认初始化。但我们在实际情况下不一定需要所有元素都进行初始化,也许我们希望先把每个元素留空然后需要的时候再将对象放入其中。例如,先构造一个数组,然后再按需初始化部分或所有元素,如果都先默认初始化,那么可能会导致两次的初始化,前一次的默认初始化并没有被用到。

特别地,如果一个类对象没有默认构造函数,那么由于它无法被默认初始化,它将无法动态分配数组。

对此,我们可以使用std::allocator来给对象分配内存,它所分配的内存是初始的、未构造的。使用std::allocator需要引入头文件<memory>

例如,分配nWidget大小的内存空间,它会根据给定的对象类型计算恰当的内存大小和对齐位置:

std::allocator<Widget> alloc;
Widget *p = alloc.allocate(n);

使用alloc.construct(p, args)来构造对象,p是一个指针,表明在哪个位置进行构造,args是传递给即将构造的类的构造函数参数。

当对象使用完毕时,使用alloc.destroy(p)来调用对象的析构函数。

最后,使用alloc.deallocate(p, n)释放分配的内存,p是分配内存的起始地址,必须是allocator分配内存时返回的指针,且n也必须是分配内存时指定的n

例如:

struct Widget {
    Widget(int a);
};

int main() {
    int size = 16;
    std::allocator<Widget> alloc;
    Widget *p = alloc.allocate(size);
    for (int i = 0; i < size; ++i) {
        alloc.construct(p, i);
    }
    // do something with p
    for (int i = 0; i < size; ++i) {
        alloc.destroy(p);
    }
    alloc.deallocate(p, size);

    return 0;
}

注意,由于alloc.allocate()分配的内存是未初始化的,所以在初始化之前访问其指向的对象是未定义行为。

当对象使用完毕后,使用alloc.destory()销毁对象后,内存仍未释放,我们可以继续利用这片内存分配新的对象,从而避免多次申请内存带来的开销。当这片内存不再需要被使用时,记得使用alloc.deallocate()释放它,以免造成内存泄露。

对于未初始化的内存,我们可以将这片内存中已初始化的部分对象拷贝到这片内存的另一个位置中:

  • alloc.unitialized_copy(begin, end, toBegin): 三个参数均为迭代器,将迭代器beginend范围的对象拷贝到迭代器toBegin开始的位置往后,toBegin指向的内存必须足够大,以容纳输入序列的拷贝
  • alloc.unitialized_copy_n(begin, n, toBegin): 与上一个相比,n为需要拷贝的数量
  • alloc.unitialized_fill(begin, end, t): 将迭代器beginend范围填充为对象t的拷贝
  • alloc.unitialized_fill_n(begin, n, t): 从迭代器begin开始填充n个对象t的拷贝

重载 new 与 delete

是什么

尽管我们说“重载newdelete运算符”,但它与重载其他运算符不太相同。

在大部分情况下,我们都没有重载newnew[]deletedelete[]的需求。

而对于某些程序,它对内存分配有着特殊的需求,于是可以通过重载这些运算符来自定义内存分配的细节。

当我们使用new动态分配一个对象时,会依次发生以下事情:

  1. 调用operator newoperator new[],它分配一个足够大的、原始的、未被初始化的内存空间
  2. 运行构造函数以构造这个对象,即进行初始化
  3. 对象被分配了空间并构造完成,返回一个指向该对象的指针

当我们使用delete释放一个动态分配的对象时,会依次发生以下事情:

  1. 执行析构函数
  2. 调用operator deleteoperator delete[]以释放内存空间

如果一个类没有重载这些运算符,则编译器会自动执行标准库中的版本。它们是:

// 这些版本可能抛出 std::bad_alloc 异常
void *operator new(std::size_t);
void *operator new[](std::size_t);
void *operator delete(void*) noexcept;
void *operator delete[](void*) noexcept;

// 这些版本承诺不会抛出异常
void *operator new(std::size_t, std::nothrow_t&) noexcept;
void *operator new[](std::size_t, std::nothrow_t&) noexcept;
void *operator delete(void*, std::nothrow_t&) noexcept;
void *operator delete[](void*, std::nothrow_t&) noexcept;

其中std::nothrow_t是定义在<new>头文件中的一个结构体,它不包括任何成员。
<new>头文件中还定义了一个名为nothrowconst对象,用户可以通过这个对象来请求new的非抛出版本

我们可以自定义上面函数版本中的任意一个。

怎么做

重载newdelete可以在全局作用域中,也可以在类作用域中。这句话是什么意思呢?

对于全局作用域,顾名思义是写在全局作用域中,而不是一个具体的类中。是的,C++的重载运算符可以是全局作用域,不仅仅只是newdelete,例如加法的重载可以在全局作用域:

Widget operator+(MyClass a, std::string b);

当编译器发现一条new表达式或delete表达式时,如果被分配或释放的对象是类类型,则首先在类或基类的作用域中查找是否有operator newoperator delete成员,若有,则调用这些成员。
若没有,则编译器会接着在全局作用域中寻找,也就是说我们可以一次性自定义所有对象的内存分配或释放,而无需给每个类都重载一次。
若全局使用域也没有,则编译器会使用标准库中定义的版本。

即便一个类中定义了operator newoperator delete成员,我们也可以忽略类中的版本而使用全局作用域中的版本,即通过::new::delete

operator newoperator delete的一种实现如下:

void *operator new(std::size_t size) {
    if (void *mem = malloc(size)) {
        return mem;
    } else {
        throw std::bad_alloc();
    }
}

void *operator delete(void *mem) noexcept {
    free(mem);
}

因为调用free(0)没有意义,所以delete一个空指针是合法的。

为 new 添加新的形参

事实上,我们可以为operator newoperator delete自定义新的形参。
前面讲到过,如果不希望new失败时抛出异常而是返回nullptr,可以这样做:

Widget *p = new(std::nothrow) Widget();

而当一个new有更多自定义形参时,实参的传递也是在这个地方,例如:

class MyClass {
public:
    MyClass(std::string s);
    void* operator new(std::size_t size, int a, double b, std::string c);
};

int main() {
    MyClass *obj = new(1, 3.14, "hello") MyClass("aaa");
    return 0;
}

这种形式的new被称为定位new

不过,以下函数不能被重载:

void *operator new(std::size_t, void*);

这种形式只能被标准库使用,不能被用户重新定义。

void *operator new(std::size_t, void*) 的作用

上面讲到,这个版本的new是我们无法自定义的版本,那它有什么用呢?

使用这个版本的new时,需要传入一个指针,它可以是malloc分配内存地址返回的指针。

这个版本的new不会分配内存,它只是简单地返回指针实参:

void *operator new(std::size_t, void* ptr) {
    return ptr;
}

接着,在这个地址上通过构造函数构造对象。也就是说,它可以让我们在一个特定的、预先分配的内存地址上构造对象。

此外,我们可以显式地调用析构函数:

ptr->~MyClass();

通过析构函数释放对象,但不释放内存,我们可以重新利用这个内存,如使用上面提到的传入一个指针的定位new版本。通过重复利用内存而不重新分配内存,可以节省因分配内存造成的开销。

这种方法是早期C++的做法,在新的C++标准中,我们可以使用前面提到的allocator来实现内存的重复利用。

删除 operator new

如果我们在类中删除了operator new,则直接使用new时编译器会报错:

class MyClass {
public:
    void* operator new(std::size_t size) = delete;
};

int main() {
    MyClass *obj = new MyClass;  // 非法
    return 0;
}

这是否意味着我们禁止了new呢?实际上,我们可以忽略类中的版本而选择全局作用域中的版本:

MyClass *obj = ::new MyClass;  // 合法

通过删除new,可以禁用在堆上分配内存,但实际上这只是一个提醒,开发者仍有其他方式来绕过这个限制,例如上面的全局::new或使用allocator

内存分配失败的处理

使用malloc分配内存失败时,会返回NULL,因此可以通过判断其是否为空来判断是否分配失败:

int *p = (int*) malloc(sizeof(*p) * 1024 * 1024 * 1024 * 1024);  // 请求非常大的内存
if (p == NULL) {
    // do something
    std::cout << "Memory allocation failed" << std::endl;
}

当使用newstd::allocator分配内存失败会抛出std::bad_alloc异常,如果异常没有被捕获,则程序会终止。

try {
    std::allocator<int> alloc;
    int* p = alloc.allocate(1024LL * 1024 * 1024 * 1024);  // 请求非常大的内存
    alloc.deallocate(p, 1024LL * 1024 * 1024 * 1024);
} catch (const std::bad_alloc& e) {
    std::cout << "Memory allocation failed: " << e.what() << std::endl;
}

早期的C++程序员会使用判断指针是否为空来判断内存是否申请成功:

Widget *p = new Widget();
if (p == NULL) {  // 这是错误的
    // do something
}

事实上,如果内存申请失败,会直接抛出异常,判空操作根本无法被执行到。

如果你不希望new在申请内存失败时抛出异常,则可以这样做:

Widget *p = new(std::nothrow) Widget();
if (!p) {
    std::cout << "Memory allocation failed" << std::endl;
}

不过这种方法不适用于std::allocator

由于在现代操作系统中,普遍采用了虚拟内存的技术,所以一般情况下内存分配失败是不太可能出现的,即便程序的内存占用已远远超出了实际的可用内存大小。

因此,对于绝大部分项目而言,分配失败是无需处理的,因为当内存真的分配失败时,系统应该已经陷入了非常极端的境界,就算你非要处理,能做些什么呢?一般只能任其崩溃,然后再做排查。

如果内存真的分配失败了,一般情况下应该做的是为设备增加更多的内存,或是排查是否出现了内存泄露。

真正需要对内存分配失败进行处理的,一般是某些不支持虚拟内存的嵌入式设备,或是一定要保证不能崩溃的程序(如银行系统)。

实际上,会发生内存分配失败的情况,大多是在分配大块内存的情况,例如构造一个超级大的对象或数组等。这种情况可以进行是否分配失败的判断,当内存分配失败时,进行资源释放。

例如,一个数据缓存系统,当内存不足时,释放掉一些旧的数据,以便容纳新的数据。当需要访问已经释放的数据时,再将其作为新的数据载入,若内存再次出现不足,继续释放旧数据。

0x0A 异常与调试

运行时异常识别

我们曾在C++基础中学习了throwtry-catch的异常处理机制,这里探究一下它们的细节。

栈展开

当程序从main函数入口开始,可能会进入多层函数,例如:

void func1();
void func2();
int main() {
    func1();
    // some code else
    return 0;
}
void func1() {
    func2();
    // some code else
}
void func2() {
    if (true) {
        throw "example exception";
    }
    // some code else
}

以上代码中main()调用了func1()func1()又调用了func2(),而在func2()中抛出了一个异常。

当执行到throw时,它后面的代码将不会被执行,并将异常往外传递(传递到func1())。
如果func1()调用func2()时,如果它嵌套在了一个try语句块中,则会检查有无相对应的catch,若有,执行其catch语句块。
若没有,因为一个try语句块还能嵌套在try语句块中,如果有外层try,则继续检查外层try
显然,func1()并没有处理该异常,因此会退出该函数继续在外层函数找,即func1()调用func2()之后的代码不会被执行,需要在外层函数也就是这里的main()函数寻找。
显然,main()函数也没有处理该异常,因为异常已经传递到了最外层,于是程序结束。

上述过程被称为栈展开,即沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch语句为止。如果直到主函数仍未找到,则程序会退出(调用标准库函数std::terminate())。

如果找到了对应的catch,则会执行其catch语句块,并在该catch语句块所在函数层继续执行(当然,catch语句块中也可能存在return或继续往外传递异常)。

有些时候,函数的调用链可能会很复杂,例如当存在递归或函数间相互调用时:

void func1(int a) {
    func2(a+1);
}
void func2(int a) {
    if (a >= 50) {
        throw "some exception";
    }
    func1(a+2);
}

这种情况下异常的处理可能会比较困难。

异常处理时的资源释放

在栈展开过程中,位于调用链中的语句块可能提前退出,在这些语句块中创建的局部变量会被自动释放。
但是动态分配的对象可能不会被释放,因为释放它的代码可能因语句块提前退出而没有被执行到。

对于类对象,其析构函数不应该抛出它自己处理不了的异常,因为它可能导致后面释放资源的代码没有被执行而造成内存泄露。
通常来讲析构函数都是在释放资源,释放资源一般不会抛出异常,若可能发生,则需要在析构函数中捕捉并处理。
如果在栈展开过程中析构函数抛出了它没有处理的异常,那么程序会直接结束,即便外部有try-catch:

struct ErrOnDeconstruction {
    ~ErrOnDeconstruction() { throw "some err"; }
};

int main() {
    try {
        ErrOnDeconstruction obj;
    } catch (...) {  // 表示捕获所有异常,但匿名,通常继续抛出
        std::cout << "尝试捕捉异常" << std::endl;
    }
    std::cout << "这段代码是否会被执行到" << std::endl;

    return 0;
}

输出:

terminate called after throwing an instance of ‘char const*’

可见,即便使用了一个try-catch捕获异常,由于析构函数抛出了异常,并不会进入catch语句块,程序直接结束。

对于catch(...),可以继续抛出:

try {
    mayThrow();
} catch (...) {
    // do something
    func(std::current_exception());  // 可以这样获取当前异常
    throw;  // 继续抛出
}

在一个类中,如果一个指针类型的成员变量没有被初始化,那么它将会被默认初始化为nullptr。虽然我们建议RAII的原则,在实际项目中,我们确实可能需要表示一种“没有”的状态,例如一条消息类有一个指针指向发送者,但它在被发送之前可能还未指定其发送者,于是用一个nullptr来表示这种“没有”的状态。
在析构函数中,你可以放心地delete一个nullptr,而不需判空。但一个对象指针可能在类析构前就被释放了,如果释放的同时没有将其设为nullptr,则析构函数对其的释放可能会造成双重释放。

如果类在构造函数中抛出异常,因为此时类对象还未构造完毕,因此它的析构函数不会被执行。
特别地,如果在构造函数中已经分配了部分资源,但构造到一半时出现异常,那么已经分配的资源将不会被释放:

struct MyClass {
    ~MyClass() { std::cout << "MyClass析构" << std::endl; }
};
struct MayThrowException {
    MayThrowException() { throw "err"; }
    ~MayThrowException() { std::cout << "MayThrowException析构" << std::endl; }
};
struct Widget {
    Widget() : ptr1(new MyClass), ptr2(new MayThrowException) {}
    ~Widget() {
        delete ptr1;
        delete ptr2;
        std::cout << "Widget析构" << std::endl;
    }
private:
    MyClass *ptr1;
    MayThrowException *ptr2;
};

int main() {
    Widget w;
    return 0;
}

输出:

terminate called after throwing an instance of ‘char const*’

为此,你可能需要在构造函数中捕捉异常并进行资源释放:

struct Widget {
    Widget() {
        try {
            ptr1 = new MyClass;
            ptr2 = new MayThrowException;
        } catch (...) {
            delete ptr1;
            delete ptr2;
            throw;  // 重新抛出异常
        }
    }
}

或者,使用智能指针自动管理资源的释放。

异常对象

我们使用throw抛出一个异常时,后面可以接任何类型,比如整数、字符串等。

在实际项目中,通常不推荐抛出一个非类对象,因为它缺乏明确的异常类型,从而无法区分不同类型的异常,这会导致代码的可维护性差。

如果抛出的异常对象是类类型,则相应的类必须有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。当异常处理完毕后,异常对象被销毁。

注意,如果在异常对象中有指向局部对象的指针,或异常对象本身是一个指向局部对象的指针,那么因为抛出异常时会退出当前函数,所以指针会成为悬空指针。
特别要注意,如果一条throw表达式解引用一个指向基类的指针,而该指针实际指向一个派生类的对象,则抛出的对象会发生对象切割,只有基类部分发被抛出。这是因为当我们抛出一个表达式时,该表达式的静态编译时类型决定了异常对象的类型。

catch子句

当我们无需访问catch子句异常声明中的异常对象时,其变量名可以忽略。

catch异常声明中的异常对象类型可以是完全类型,也可以是左值引用,但不能是右值引用。
通常建议使用左值引用,因为异常对象可能有继承关系,引用可以保持其多态性,如果使用完全类型则可能发生对象切割。

noexcept

不抛出声明

在C++ 11,可以通过在函数形参列表后加上noexcept关键字再说明该函数不会抛出异常。
它不仅能够说明函数不会抛出异常,还能让编译器根据这个信息来执行某些优化操作,而这些优化操作不适用于可能出错的代码。

noexcept声明必须同时出现在函数的声明和定义中,或者都不出现。

对于noexcept的具体位置,是在函数形参列表、const及引用限定符之后;在finaloverride、虚函数的=0之前。

如果一个函数声明了noexcept,但它仍抛出了异常,程序就会调用std::terminate()以确保不在运行时抛出异常的承诺。
也就是说,在声明了noexcept的函数中抛出异常是合法的,可以通过编译,不过部分编译器可能会给出警告。

早期C++可以通过在与noexcept相同的位置添加一个throw(xxx)来说明可能抛出的异常,比如:

void func() throw(std::exception);

但是这种方法由于不支持多态、没有反应出具体细节、难以维护等原因,已经在C++ 11被取消了。
例如,一个函数声明了一个throw(xxx),这个函数又调用了另一个函数b,由于b迭代过程中增加了新的异常抛出,而调用b的函数并不知道有新的异常,于是它的说明是过时的。

在实际项目中,应该通过文档来描述异常行为。

此外,如果throw()中没有说明异常,例如:

void func() throw();

则它表示不抛出异常,与noexcept等效。虽然这个特性未被移除,不过,在现代C++编程中,使用noexcept语义更明确。

是否抛出取决于某条件

noexcept还能接受一个可选的实参,该实参必须能够转化为bool类型,

  • 如果该实参为true,则说明该函数不会抛出异常
  • 如果该实参为false,则说明该函数可能抛出异常

再例如,如果一个模板函数的形参是整型则不会抛出异常,否则可能抛出异常,则可以这样写:

template<typename T>
void mayThrow(T t) noexcept(!std::is_integral<T>::value);

noexcept 运算拊

noexcept还可以作为一个一元运算符使用,判断一个函数是否会抛出异常,例如对上面那个模板函数进行判断:

std::cout << noexcept(mayThrow(1)) << std::endl;  // 输出 0
std::cout << noexcept(mayThrow(3.14)) << std::endl;  // 输出 1

如果一个函数是否可能抛出异常跟多个函数相关,则可以这样做:

void func() noexcept(noexcept(f()) || noexcept(g()));

这说明func()是否抛出异常,与f()g()有关。如果f()或者g()可能抛出异常,则func()也可能抛出异常。

若一个函数func()未作出是否抛出异常的说明,则noexcept(func())的结果为,当func()自己调用的所有函数都做了不抛出声明且func()本身不含throw语句时为true,否则为false

noexcept(xxx)运算符并不会真的调用函数,它的结果是在编译时确定的。

noexcept 函数指针说明

函数指针也能加上noexcept,如果一个函数指针加上了noexcept说明,则它只能指向不抛出异常的函数,例如:

void safeCall(void (*func)() noexcept) {
    func();
}
void noThrow() noexcept {}
void mayThrow() {}

int main() {
    safeCall(noThrow);  // 合法
    safeCall(mayThrow);  // 非法
    return 0;
}

如果一个函数指针没有声明noexcept,则它可以指向的函数没有这个限制,无论这个函数是否可能抛出异常。

虚函数的 noexcept

如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出相同的承诺。

如果基类的虚函数允许抛出异常,则派生类既可以允许抛出异常,也可以不允许抛出异常。

标准异常

标准库中的标准异常std::exception及它的一些派生异常:

  • std::bad_cast
  • std::bad_alloc
  • std::runtime_error
  • std::logic_error

其中std::exception除了拷贝构造函数、拷贝赋值运算符、虚析构函数外,只有一个名为what的虚成员函数,它返回一个const char*并确保不会抛出异常。
std::exceptionstd::bad_caststd::bad_alloc定义了默认构造函数,而std::runtime_errorstd::logic_error没有默认构造函数,但有一个可以接受C风格字符串或std::string实参的构造函数。

std::runtime_error还派生出了:

  • std::overflow_error
  • std::underflow_error
  • std::range_error

std::logic_error还派生出了:

  • std::domain_error
  • std::invalid_argument
  • std::out_of_range
  • std::length_error

对于以上一些异常,例如std::overflow_errorstd::underflow_error,标准库中一般不会抛出,而是我们在编写自己的程序时可以自己抛出这些异常而无需自己定义。
例如,当两个很大的整型相加而造成溢出时,会得到错误的结果而不是抛出std::overflow_error;当浮点数运算发生下溢时,会得到不精确的结果或者0,而不是抛出std::underflow_error

try-catch 的局限性

通过try-catch的形式识别并处理异常是一种错误处理的思路,Java、Python等编程语言也可以通过这种方式识别和处理异常。

但是,在一个非常复杂,特别是层级结构很复杂的程序,使用try-catch来处理异常是十分困难的。

有时候,我们希望在某一层捕捉异常后,释放资源以防止资源泄露,并继续将异常抛出以便外层也能捕捉异常并做出如释放资源等操作。

但是,我们并不能保证外层也捕捉异常并做出处理,特别是某些情况下,内层不能释放资源,而得给外层去释放。外层也不一定能处理该异常,所以需要继续往外抛出。你必须小心翼翼地处理异常,并保证资源的正确释放。

而如果一个异常抛出到了最外层仍未被捕获,程序就会崩溃。而对于C++,除非是在调试模式或者对异常做出保存信息等操作,否则程序会表现为闪退或在命令行中输出一行程序结束信息,并不会像Java等编程语言那样给出详细到报错出现在代码中哪一行、在哪些代码中传递等信息,因此C++的异常捕捉是十分难以排查的。

所以,编写一个异常安全的程序是十分困难的,即便是对于一个经验丰富的程序员,也会常常头疼于异常排查。

通过返回值传递异常

一种优秀的异常处理方式

另外一种异常处理思路是通过返回值传递异常,Go语言、Rust等编程语言就是这样做的。
例如,返回函数运行的结果和一个是否发生异常的标志。

虽然C或C++并不支持直接返回两个或多个值,但可以通过结构体或std::pair等方式实现。

例如:

template<typename T>
struct Result {
    T value;
    bool ok;
};

Result<double> safeDivide(double a, double b) {
    if (b == 0 || std::isnan(a) || std::isnan(b) || std::isinf(a)) {
        return {std::nan(""), false};
    }
    return {a / b, true};
}

int main() {
    Result<double> res = safeDivide(5, 0);
    if (res.ok) {
        std::cout << res.value << std::endl;
    } else {
        std::cout << "参与运算的数字非法" << std::endl;
    }
    return 0;
}

使用std::pair的方式:

std::pair<double, bool> safeDivide(double a, double b) {
    if (b == 0 || std::isnan(a) || std::isnan(b) || std::isinf(a)) {
        // 返回 NaN 来标识错误,并返回 false 表示失败
        return {std::nan(""), false};
    }
    return {a / b, true};
}

int main() {
    auto res = safeDivide(5, 0);
    if (res.second) {
        // 成功的情况
        std::cout << res.first << std::endl;
    } else {
        // 失败的情况
        std::cout << "参与运算的数字非法: " << std::endl;
    }
    return 0;
}

因为std::pair通过firstsecond访问其数据,使得语义不太明确,阅读性不如用一个Result
不过,C++ 17引入了结构化绑定的特性,使你可以这样做:

int main() {
    auto [res, ok] = safeDivide(5, 0);
    if (ok) {
        std::cout << res << std::endl;
    } else {
        std::cout << "参与运算的数字非法" << std::endl;
    }
    return 0;
}

这就有点像python或go语言那样返回两个变量的形式了,更加清晰且容易阅读。

携带异常具体信息: 利用 std::optional

当然,也可以把第二个结果bool ok改为std::string或其他类型来说明或标注异常类型,通过判断其是否为空字符串或其他方式以判断异常是否发生。

当然,判断是否为空字符串还是没有那么明确,能否直接判断是否为空对象?因为C++缺少None对象,如果使用空指针,也要考虑对象生命周期或资源释放问题。

为此,在C++ 17,你可以选择std::optional来说明是否存在异常,例如:

std::pair<double, std::optional<std::string>> safeDivide(double a, double b) {
    if (b == 0) {
        return {std::nan(""), "除数不能为0"};
    }
    if (std::isnan(a) || std::isnan(b)) {
        return {std::nan(""), "被除数或除数不能为NaN"};
    }
    if (std::isinf(a)) {
        return {std::nan(""), "被除数不能为无穷"};
    }
    return {a / b, std::nullopt};
}

int main() {
    auto [res, err] = safeDivide(5, 0);
    if (!err) {
        std::cout << res << std::endl;
    } else {
        std::cout << "参与运算的数字非法: " << err << std::endl;
    }
    return 0;
}

但是这样一来,函数safeDivide的返回值类型过于复杂,写起来很麻烦,所以可以像上面那样定义一个结构体来代替。
或者,采用别名模板:

template<typename T>
using Result = std::pair<T, std::optional<std::string>>;

这样函数safeDivide的返回值类型就能改为Result<double>

std::expected

虽然定义一个Result方便很多,但采用这种方式,程序员可能会随手定义一个Result,特别是在多人项目中,如果没有规范约束这种行为,这会造成代码中出现大量的Result,从而造成可能的命名冲突或资源浪费。

为此,C++ 23标准库提供了std::expected:

template<typename T>
std::expected<T, std::string> safeDivide(T a, T b) {
    if (b == 0) {
        return std::unexpected("Division by zero!");  // 错误信息
    }
    return a / b;  // 正常返回结果
}

int main() {
    auto result = safeDivide(10.0, 0.0);
    if (result.has_value()) {
        std::cout << "Result: " << result.value() << std::endl;
    } else {
        std::cout << "Error: " << result.error() << std::endl;
    }
    return 0;
}

与 try-catch 的对比

性能对比

  • 对于try-catch,异常处理涉及堆栈展开、内存分配、异常传播等操作,性能开销较大。在性能敏感的应用中,频繁的异常抛出和捕获可能会导致性能问题,尤其是当异常处理不频繁发生时,这个开销显得更加明显。
  • 对于返回异常,明显少了上述步骤,性能会更高。

控制流清晰度

  • try_catch异常机制允许将错误处理逻辑从正常业务逻辑中分离出来,使得代码更加简洁。它也可以传播异常,使得你可以处理跨多个函数和层次的复杂错误情况。但它可能使得程序的控制流变得难以理解,特别是在复杂的系统中,过多的异常可能让代码变得难以跟踪,影响可读性。
  • 对于返回值异常来说,它的形式更容易提醒程序员要处理异常。不过,它难以跨层级传播,也难以携带上下文信息,为了做到这些,代码可能变得很复杂。

调试用宏

assert

assert是一个宏,用来检查一些不能发生的条件,当条件成立时,assert输出信息并结束程序的运行。

使用assert需要引入头文件<cassert>,用法如下:

assert(expression);

expression为假时(即为0时),输出信息并结束程序的运行。

我们常常用宏来区分程序的发布版本和调试版本,例如:

#ifdef MY_DEBUG
std::cout << "debug: the value of a is " << a << std::endl;
#endif

如果宏MY_DEBUG被定义,则上述输出信息的代码会在编译时有效。当程序需要发布时,取消MY_DEBUG的定义,以使这段输出信息不会不必要地添加进发布版本中。

而对于assert来说,如果宏NDEBUG被定义,则assert会失效。我们也可以利用NDEBUG这个宏来编写我们的调试代码,例如:

int func(int a) {
#ifndef NDEBUG
    std::cout << "debug: the value of a is " << a << std::endl;
#endif
    assert(a > 0)  // 如果 a 不大于 0 程序就会结束
    
    return a;
}

几个有用的用于调试的宏

  • __func__: 当前调试的函数名
  • __FILE__: 当前文件名
  • __LINE__: 该行代码的行号
  • __TIME__: 文件编译的时间
  • __DATE__: 文件编译的日期

例如:

int func(int a) {
    if (a <= 0) {
        std::cerr << "Error in file " << __FILE__ << "\n"
                  << "  In function " << __func__ << "\n"
                  << "  Compiled on " << __DATE__ << " " << __TIME__ << "\n"
                  << "  At line " << __LINE__ << "\n"
                  << "  The parameter must be greater than 0.";
    }
    return a;
}

gdb

gdb 是 GNU 提供的一个调试工具,它是一个命令行形式的调试工具。

我们在使用诸如 Visual Studio 或 CLion 等 IDE 编写 C 或 C++ 代码时,它们也提供了图形化界面的调试工具,那 gdb 相比它们有什么优点呢?

有些时候,我们需要在线上进行程序的调试,例如 C 与 C++ 被大量用于无图形化界面的 linux 服务器、嵌入式系统中,而有时候一些bug无法在图形化的 IDE 界面中复现,有些程序则只能前面提到的环境的运行。于是在这些情况下,gdb就派上用场了。

gdb 的远程调试功能非常强大,特别适合调试嵌入式系统。它支持通过串行端口、TCP/IP 或其他协议连接到远程目标设备进行调试。

因为 gdb 是一个命令行程序,因此相对更轻量,消耗的系统资源较少,尤其是在资源受限的环境(如嵌入式系统)。而且,命令行程序也更容易编写自动化测试。

快速开始

假如需要调试以下代码:

#include <cstdio>

int main() {
    int arr[5];
    for (int i = 0; i < 5; ++i) {
        arr[i] = i;
        printf("%d", arr[i]);
    }
    return 0;
}

为了能够使用gdb进行调试,我们需要在编译时让其带上调试信息,例如:

g++ -g hello_gdb.cpp -o hello_gdb

然后,使用以下指令进入到 gdb 的调试中:

gdb hello_gdb

进入到 gdb 后,使用runr执行代码:

(gdb) r
0
1
2
3
4

在 gdb 界面中,使用quit即可退出 gdb 。

断点与单步执行

在 gdb 界面中,使用breakb即可打断点。

例如,在上面的main函数处打断点,可以:

(gdb) break main
Breakpoint 1 at 0x40155d: file hello_gdb.cpp, line 5.

也可以往具体行打断点,在此之前可以使用list查看代码:

(gdb) list
1  #include
2  
3  int main() {
4    int arr[5];
5    for (int i = 0; i < 5; ++i) {
6     arr[i] = i;
7     printf(“%d”, arr[i]);
8    }
9    return 0;
10 }

如果代码过长,list不会一次性显示所有代码,此时可以再执行一次list接着查看下面的代码。

给第7行打上断点:

(gdb) b 7
Breakpoint 2 at 0x401576: file main.cpp, line 7.

通过info b查看打了哪些断点:

(gdb) info b
Num   Type      Disp Enb  Address       What
1    breakpoint  keep y  0x000000000040155d in main() at hello_gdb.cpp:5
2    breakpoint  keep y  0x0000000000401576 in main() at hello_gdb.cpp:7

接着,运行:

(gdb) run
Thread 1 hit Breakpoint 1, main () at hello_gdb.cpp:5
5  for (int i = 0; i < 5; ++i) {

可见,在第一个断点处停了下来。

通过nextn可单步执行:

(gdb) next
6  arr[i] = i;
(gdb) n
Thread 1 hit Breakpoint 2, main () at hello_gdb.cpp:7
7  printf(“%d”, arr[i]);

如果不希望单步执行而是继续执行,直至下一个断点为止,可以使用continuec

输出表达式的值

通过printp可以输出表达式的值,例如:

(gdb) p arr[1]
$1 = 0
(gdb) p &arr[0]
$2 = (int *) 0x61fe00
(gdb) p &arr[1]
$3 = (int *) 0x61fe04

进入一个函数

假设代码如下:

#include <cstdio>

void func() {
    printf("hello");
}

int main() {
    func();
    return 0;
}

在执行了func()的代码处(第8行)打一个断点:

(gdb) b 8
Breakpoint 1 at 0x401578: file hello_gdb2.cpp, line 8.
(gdb) run
Thread 1 hit Breakpoint 1, main () at hello_gdb2.cpp:8
8  func();

通过step即可进入函数:

(gdb) step
func () at hello_gdb2.cpp:4
4  printf(“hello\n”);
(gdb) n
hello
5 }

如果不使用step而使用n,则函数func()内的所有代码的执行会被视为一步。

shell

在 gdb 中,你可以使用shell指令执行一个终端命令:

(gdb) shell ls
hello_gdb hello_gdb2 hello_gdb.cpp hello_gdb2.cpp

这个特性允许你在不退出 gdb 的时候执行一些操作,例如查看某个源代码文件,或者查看程序 IO 操作的结果等。

set logging on

通过set logging on,可以将 gdb 的输出记录到文件中。

(gdb) set logging on
Copying output to gdb.txt.

将调试过程记录到gdb.txt中,便于更好地排查问题,特别是在复杂的调试过程中。

在新版本中,这个指令被更新为set logging enabled on

watchpoint

你可以为一个变量设置一个 watchpoint,当它发生变化时暂停并给出它的旧值和新值。

(gdb) b 6
Breakpoint 1 at 0x401570: file hello_gdb.cpp, line 6.
(gdb) run
Thread 1 hit Breakpoint 1, func () at main.cpp:6
6  arr[i] = i;
(gdb) p &i
$1 = (int *) 0x61fe1c
(gdb) watch *0x61fe1c
Hardware watchpoint 2: *0x61fe1c
(gdb) continue
Continuing.
0
Thread 1 hit Hardware watchpoint 2: *0x61fe1c
Old value = 0
New value = 1
0x0000000000401591 in main () at hello_gdb.cpp:5
5  for (int i = 0; i < 5; ++i) {

调试已经上线的程序

对于一个正在运行的程序,当我们知道了它的进程号(如使用终端命令ps),可以通过gdb -p 进程号来调试它。

对于一个已经上线的程序,我们当然不会用-g编译它。因为它不携带调试信息,于是调试会变得比较困难,但你仍可以进行调试,gdb 会提供基于汇编的调试。

此外,可以在系统中开启 coredump 的功能,这样当程序崩溃时会生成一个 coredump 文件,可以使用 gdb 来访问它以得出崩溃的原因以及造成崩溃的代码。

其他

gdb 是一个强大的调试工具,除了以上基本的使用外,还有 catchpoint 等功能。

为节省篇幅,更多功能还请自行查阅资料。

0x0B 并发编程

多线程

什么是多线程

当我们在现代操作系统上运行一个程序时,操作系统会为这个程序创建一个进程,并为这个进程分配一个进程控制块PCB和一定的资源(内存、栈、堆、全局区、代码区等)。

每个进程中可以有多个线程,系统为每个进程分配一个线程控制块和一个栈,线程之间共享全局区和堆的数据。

在现代操作系统中,我们可以同时运行多个程序(进程),它们看起来是同时执行的,而不是执行完一个后才能执行另一个。
在单核单处理器计算机中,操作系统通过一定的调度策略,在多个程序中切换,由于切换的速度非常快,所以看起来像是多个程序在并行执行,但实际上某一瞬间只有一个程序在执行,也就是说它们是并发的。

事实上,操作系统的基本调度单位是线程而不是进程,因此在同一个进程中,也可以有多个执行过程(线程)交替执行。

而在多核、多处理器计算机中,不同的线程可以被调度到不同的处理器或处理器核心中,使得它们真正实现并行,即同一瞬间有多个线程在执行。

通过多线程的特性,可以让多个线程同时执行,从而提高效率。

此外,当一个线程进行IO操作时,由于IO操作需要等待IO设备,对CPU来说IO设备是非常非常慢的,此时可以先让线程暂时放弃CPU,让CPU资源让给其他线程,直到IO设备准备好数据后再唤醒线程。这样可以减少因线程占用CPU但却因为要等待IO而没有利用到CPU的时间浪费。

线程的调度是操作系统自动进行的,当然我们也可以手动干预线程的调度,以提高效率。

关于线程调度的细节和原理,可以阅读操作系统相关书籍。

如何实现多线程

在C++ 11之前,C++并没有在语言层面提供对多线程的直接支持,需要通过系统调用或第三方库等方式实现。

不同的操作系统提供了各自的多线程支持,例如在 Windows 中可以通过 WinAPI 中的多线程模块实现;在类 UNIX 系统上通过 POSIX 库(pthread)实现。

一些第三方库如 Boost.Thread 提供了跨平台的多线程支持,它帮我们封装好了多线程管理的接口,并在不同操作系统下帮实现了它们。

到了C++ 11,标准库中提供了标准的多线程支持,使得我们可以在不使用第三方库的情况下更方便地实现多线程。

本文不具体展开C++ 11之前多线程的实现方法,感兴趣的可以自行学习。

标准库 thread

在使用标准库多线程模块时,需要引入头文件<thread>

创建 thread

为了创建一个线程,我们需要创建一个线程类对象,通常采用这个构造函数来构造线程类对象:

thread(F&& f, Args&&... args);

其中,第一个参数F&& f表示线程需要执行的函数,这个函数可以是函数、函数对象、类成员函数、Lambda表达式等可调用对象。

  • 如果这个函数是函数、函数对象、类静态成员函数、Lambda表达式,则Arg&&... args是传递给要调用函数的实参。
  • 如果这个函数是类非静态成员函数,则Arg&&... args的第一个函数是用于调用这个成员函数的类对象地址,剩余的参数才是传递给成员函数的实参。

由于函数f的返回值被忽略,如果需要获取线程的返回值,需要通过引用参数或将返回值存储在类对象的数据成员中,或者使用后面会提到的promise

例如:

void add(int a, int b, int &res) { res = a + b; }
int main() {
    int res = 0;
    std::thread t(add, 1, 2, std::ref(res));
    std::cout << res << std::endl;
    return 0;
}

对于引用,需要使用std::ref()进行包装一下。
输出:

0
terminate called without an active exception

可见,res并没有得到我们想要的结果,这是由于线程t与当前的主线程并不是同步的,当线程t创建时,它什么时候被运行是不确定的,而主线程创建完t这个线程后,就立刻继续往下执行了。

你可以通过等待t的执行结果,例如自旋等待:

std::thread t(add, 1, 2, std::ref(res));
while (res == 0);
std::cout << res << std::endl;

但如果这样做的话,要是线程t迟迟得不到调度,而主线程会一直陷入自旋中,占用大量的CPU资源。

另一个方法是使用t.join():

std::thread t(add, 1, 2, std::ref(res));
t.join();
std::cout << res << std::endl;

t.join()可能会阻塞当前线程的执行,直到目标线程执行完毕。在目标线程执行完毕前,操作系统会进行线程调度,让当前线程让出CPU给其他线程执行,防止当前线程忙等待。

这里说可能会阻塞而不是一定会阻塞,是因为执行到t.join()时该线程可能已经执行完毕,此时t.join()会立刻返回,不会触发线程调度。例如:

std::vector<std::thread> workers;
for (int i = 0; i < 10; ++i) {
    workers.push_back(std::thread(func));
}
for (auto &worker : workers) {
    worker.join();
}

因为以上10个线程是异步执行的,它们之间的执行顺序是不一定的,因此第6行的worker.join()可能会阻塞第一个线程,而当它完成时,后续线程可能已经执行完毕,从而无需再次阻塞。
因此,以上代码的执行时间取决于最后一个完成的线程,而不是所有线程执行时间的总和。

注意,当使用一个函数对象时:

struct functor {
    void operator()(double a, double b) {
        result = a + b;
    }
    double result = 0;
};

int main() {
    functor f;
    std::thread t(f, 1, 2);
    t.join();
    std::cout << f.result << std::endl;

    return 0;
}

输出:

0

这是因为传入线程对象的函数对象发生了拷贝,从而使线程中修改的resultmain函数中的f.result不是同一个。

注意,只有joinable的线程才能调用join()进行合并,我们可以通过这种方式判断其是否为joinable:

if (t.joinable()) {
    t.join();
}

以下线程对象是不可合并的:

  • 使用默认构造函数初始化的线程对象,即没有给线程对象指定任何需要执行的函数
  • 已经被join()完成的线程对象
  • detach()后的线程对象
  • 被移动的线程对象

detach

除了thread.join()进行阻塞,还有一个选择是thread.detach(),与join的区别是它不会阻塞当前线程,当前线程可以继续向下执行。

不过,如果不使用detach,不也会继续往下执行而不阻塞吗?

看下面的例子:

void func() {
    std::thread t([]() {
        std::cout << "hello" << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "world" << std::endl;
    });
}
int main() {
    func();  // 启动一个线程
    // 让主线程暂停,确保上面创建的新线程执行完毕
    std::this_thread::sleep_for(std::chrono::seconds(3));
    return 0;
}

输出:

hello
terminate called without an active exception

这是由于,func中的线程对象是一个局部变量,当func结束时,新线程还没有执行完毕便结束了其生命周期,其资源被自动清理,清理一个未执行完毕的线程会抛出异常。

如果我们加上t.detach(),使线程从当前线程中”分离”,并在后台独立执行,这样它就不会因局部变量的生命周期结束而被提前销毁,线程资源(如堆栈等)会在其完成时自动清理。

线程对象的移动

std::thread线程对象只能被移动构造或移动赋值,而不能被拷贝构造或拷贝赋值,这样保证了每一个thread对象只能代表唯一的一个执行线程,即不能有多个线程对象代表同一个执行线程:

std::thread t1, t2;
std::thread t3(t1);  // 非法
std::thread t4 = t1;  // 非法
std::thread t5(std::move(t1));  // 合法
std::thread t6 = std::move(t2);  // 合法

其他

你可以使用标准库函数std::this_thread::get_id()来获取执行到这条指令的线程的id,即当前线程id。

通过std::this_thread::sleep_for(std::chrono::seconds(3));,可以让当前线程等待一段时间。
如果你觉得这种写法太长了,在C++ 14可以这样做:

#include<chrono>
using namespace std::chrono_literals;
int main() {
    std::this_thread::sleep_for(3.5s);
    return 0;
}

互斥

线程安全问题

即便大部分读者应该知道线程安全问题,这里还是再给可能的刚入门多线程的读者解释一下。

例如有以下两个函数分别被两个线程执行:

int a = 1;
void func1() {
    a++;
}
void func2() {
    a *= 2;
}
int main() {
    std::thread t1(func1);
    std::thread t2(func2);
    t1.join();
    t2.join();
    
    return 0;
}

也许你会想,func1func2的运行顺序不一定,所以a可能的结果是3或4。

实际上,无论是func1还是func2,都不是原子性的,具体来说,

  • func1详细操作是
  1. 读取a的值
  2. a的值增加1
  3. 将结果写回a
  • func2详细操作是
  1. 读取a的值
  2. a的值变为原本的2倍
  3. 将结果写回a

而它们的任意步骤的顺序是不确定的,可能会是这样:

  1. func1读取a的值1到寄存器r1
  2. func1将寄存器r1的值增加1,变为2(未写回)
  3. func2读取a的值1到寄存顺路r2
  4. func1将寄存器r1的值写回a,现在a的值是2
  5. func2将寄存器r2的值变为原来的2位,为2
  6. func2将寄存器r2的值写回a,现在a的值是2

所以,a的结果也有可能为2。

为了更明显地显现这个问题,以下代码是多个线程对一个变量的修改:

int variable = 0;

void func() {
    for (int i = 0; i < 100000; ++i) {
        variable++;
    }
}

int main() {
    std::vector<std::thread> vector;
    for (int i = 0; i < 10; ++i) {
        vector.push_back(std::thread(func));
    }
    for (auto& thread : vector) {
        thread.join();
    }

    std::cout << variable << std::endl;

    return 0;
}

输出:

201825

这个输出的结果每次都会是不一样的,但基本是错误的结果。这是因为多个线程对同一个变量进行修改,出现了数据竞争,因为对这个变量的访问不是原子性的,即修改一个变量实际有多个步骤(从内存中读取值、修改值、将修改后的值写回内存),而这个变量又没有互斥访问机制,因此会出错。

再例如,执行以下代码:

std::array<std::thread, 6> workers;
for (int i = 0; i < 6; ++i) {
    workers[i] = std::thread([i](){
        std::cout << "Hello from thread " << i << std::endl; 
    });
}
for (auto &worker : workers) {
    worker.join();
}

输出:

Hello from thread Hello from thread 05

Hello from thread Hello from thread Hello from thread Hello from thread 231
 
 
4

可见,输出基本是乱掉的,这是因为cout并不是线程安全的,不同线程之间的指令执行顺序是不能确定的。

此外,在C++这样没有GC的编程语言中处理线程安全问题是比较困难的,例如一个动态分配内存的资源可能被多个线程共享,而当它需要被释放时,需要考虑是否还有线程正在使用它。
一种解决方法是利用智能指针shared_ptr来自动管理对象的生命周期,shared_ptr对引用计数的操作是原子性的,但是要注意通过它对其管理的对象的访问不是线程安全的。

std::mutex

C++ 11提供了std::mutex来实现共享变量的互斥访问,通过对一个互斥量进行加锁和释放锁,可以保证在同一时刻只有一个线程在访问和处理共享资源。mutex有以下操作:

  • mutex.lock(): 获取锁。如果锁已经被其他线程占用,则会阻塞当前线程直到锁被释放
  • mutex.unlock(): 释放锁
  • mutex.try_lock(): 尝试获取锁,该函数会立刻返回,如果成功获取则返回true,否则返回false

例如对上面10个线程对一个变量的修改的代码中对variable的访问加上互斥操作:

void func() {
    static std::mutex mutex;
    for (int i = 0; i < 100000; ++i) {
        mutex.lock();
        variable++;
        mutex.unlock();
    }
}

输出:

1000000

可见,结果就是正确的了。

加锁和释放锁的操作是原子性的,因此不会出现两个或多个线程同时获得锁的情况。

我们一般将访问共享变量的过程“夹在”lockunlock之间,而在lockunlock之间的代码被称为临界区,表示多个线程会在这段代码中访问共享资源。

因为当一个线程占有锁时,其他线程会等待锁,因此我们不会在临界区放无关的代码,以减少其他线程等待锁的时间。一般情况下,在满足互斥要求的前提下,临界区越小越好。

在一个锁竞争十分激烈的情况下,例如双十一期间某个热门商品的抢购,由于抢锁的线程非常多,这就有可能造成某个线程长时间等待甚至一直得不到锁的情况,反馈给用户的结果就是程序卡住或一直转圈。
为此,可以使用try_lock不断抢锁,并在多次失败后放弃抢锁:

void purchase() {
    static std::mutex mutex;
    int attempt = 0;
    do {
        if (mutex.try_lock()) {
            std::cout << "成功购买" << std::endl;
            mutex.unlock();
            return;
        }
        attempt++;
    } while (attempt > 10);
    std::cout << "系统繁忙,请稍后再试" << std::endl;
}

int main() {
    std::vector<std::thread> vector;
    for (int i = 0; i < 100; ++i) {
        vector.push_back(std::thread(purchase));
    }
    for (auto& thread : vector) {
        thread.join();
    }

    return 0;
}

输出:

成功购买
成功购买
成功购买
系统繁忙,请稍后再试
成功购买

注意,请占有锁,锁就由谁释放。不能一个线程占有锁,而由另一个线程释放锁,由不占有锁的线程释放锁是未定义行为。

std::lock_guard

与管理动态分配内存一样,锁的管理也可能因为临界区出现异常而导致锁没有被释放,如果这种情况发生,那么锁会一直是被占用的状态,所有等待该锁的线程都将永远等待下去。

于是,我们可以将锁交给std::lock_guard管理,它与智能指针类似,是一种 RAII 的思想,其代码简化后如下:

template<typename Mutex>
class lock_guard {
public:
    explicit lock_guard(Mutex& m) : _mutex(m) {
        _mutex.lock();
    }
    lock_guard(Mutex& m, adopt_lock_t) noexcept : _mutex(m) {}
    ~lock_guard() {
        _mutex.unlock();
    }
    
    lock_guard(const lock_guard &) = delete;
    lock_guard& operator=(const lock_guard &) = delete;
private:
    Mutex _mutex;
};

通过把锁交给局部对象管理,可以保证在局部对象生命周期结束时锁可以被及时释放。
此外,lock_guard是不能被拷贝的,防止双重释放。

通过使用lock_guard,前面对于共享变量variable访问的代码就可以简化为:

void func() {
    static std::mutex mutex;
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard lock(mutex);
        variable++;
    }
}

因为lock_guard的生命周期持续到其定义的语句块范围,因此为了防止过长的锁占用,可以使用一个花括号限制其生命周期,例如:

static std::mutex a_mu, b_mu;
static int a, b;
{
    std::lock_guard lock(a_mu);
    a++;
}
// something else
// ...
{
    std::lock_guard lock(a_mu);
    std::lock_guard lock(b_mu);
    b = a + 1;
}

这样,我们便缩小了临界区的大小。
不过,在第一个花括号内对a修改过后,因为a_mu被释放了,所以在进入第二个花括号并再次获取a_mu之前,共享变量a是可能被其他线程修改的,因此需要仔细审视这种情况是否允许出现。

lock_guard的另外一个构造函数中的第二个参数adopt_lock_t,用于告诉lock_guard锁已经被占用了,无需再加锁,只需负责锁的释放:

static std::mutex mutex;
mutex.lock();
// do something
std::lock_guard lock(mutex, std::adopt_lock);

std::unique_lock

相比于lock_guardstd::unique_lock提供了更高的灵活度(当然是以性能为代价的)。它可以手动控制锁的加锁和解锁时机,支持延迟加锁和条件变量等高级特性。

类似于lock_guardadopt_lock_tunique_lock的构造也可以传入第二个参数std::adopt_lockstd::defer_lock来使其不在构造函数内加锁。相比之下,

  • std::adopt_lock: 假定当前线程已经获取了锁,不在构造函数内加锁
  • std::defer_lock: 假定当前线程未获取锁,不在构造函数内加锁

也就是说,std::adopt_lock相当于是在已经获取锁的状态下,将锁交给unique_lock管理。
std::defer_lock说明锁还没被获取,单纯先将锁交给unique_lock管理但不自动加上锁,稍后再手动通过mutex.lock()加锁。
可以通过unique_lock.owns_lock()来获取锁的状态。

std::timed_mutex

相比mutexstd::timed_mutex多了两个方法:

  • tm.try_lock_for(): 在指定时长内获取锁的所有权,若成功返回true,否则返回false
  • tm.try_lock_until(): 在指定时刻前获取锁的所有权,若成功返回true,否则返回false

timed_mutex可以结合unique_lock使用。

死锁

同一个线程获取同一把锁的死锁 与 std::recursive_mutex

如果对锁的使用不适当,可能会造成死锁,例如:

void func2();

std::mutex mutex;

void func1() {
    std::lock_guard lock(mutex);
    func2();
}
void func2() {
    std::lock_guard lock(mutex);
}

以上代码中,函数func1获取了锁mutex,并调用了函数func2,而func2也尝试去获取同一把锁mutex,而函数func1还未结束,仍持有锁,因此造成了永久的等待。

对于这种同一个线程反复获取同一把锁的情况,可以通过把std::mutex换成std::recursive_mutex解决,它允许同一个线程反复获取同一把锁,但不允许不同线程同时持有该锁。

recursive_mutex内部有一个计数器和线程ID,当一个线程获取锁时,会将线程ID设置为获取锁的线程ID,并将计数器自增。当同一个线程再次获取锁时,将计数器再次自增。但线程释放锁时,将计数器自减。当计数器为零时,真正地释放锁。

recursive_mutex是一个可重入锁,如果你学过 Java 就会发现它与ReentrantLock的行为基本一致。

类似于timed_mutex,递归锁也有std::recursive_timed_mutex

不同线程获取多个锁时的死锁

还有一种情况,有时候一个线程需要访问多个共享资源,例如,以下代码需要对变量a进行自增,并使用cout输出它的值,因为使用了两个共享资源,因此需要两个互斥量:

int a;
std::mutex a_mutex;
std::mutex cout_mutex;

void func1() {
    std::lock_guard a_lock(a_mutex);
    std::lock_guard cout_lock(cout_mutex);
    a++;
    std::cout << a << std::endl;
}

void func2() {
    std::lock_guard cout_lock(cout_mutex);
    std::lock_guard a_lock(a_mutex);
    a++;
    std::cout << a << std::endl;
}

int main() {
    std::vector<std::thread> vector;
    for (int i = 0; i < 1000; ++i) {
        vector.push_back(std::thread(func1));
        vector.push_back(std::thread(func2));
    }
    for (auto& thread : vector) {
        thread.join();
    }
    return 0;
}

上面的代码中,func1func2a_mutexcout_mutex的获取顺序不一样,于是可能会在某一时刻出现这种情况:

  1. func1获取锁a_mutex成功
  2. func2获取锁cout_mutex成功
  3. func1尝试获取锁cout_mutex,但它现在已经被占用了,等待其释放
  4. func2尝试获取锁a_mutex,但它现在已经被占用了,等待其释放

于是两线程互相等待对方释放锁,从而造成死锁。

破坏死锁条件

为了解决这个问题,可以破坏产生死锁的条件,例如,在获取一个资源后立刻释放锁,然后再获取另一个资源:

a_mutex.lock();
a++;
a_mutex.unlock();

cout_mutex.lock();
std::cout << a << std::endl;
cout_mutex.unlock();

但是这有一个问题,例如当前a为1,当a++后变成2释放锁,本来应该输出2这个数字,但在输出前有另一个线程获取到了a_mutex并将a修改为了3,此时前一个线程的输出结果可能就不是我们想要的了,例如:

0
1
3 <— 本应是2
3

事实上,输出的操作也使用到了变量a,虽然读操作可以不加锁,但不加锁的读操作可能保证不了强一致性。

为此,我们可以使用一个临时变量接收a自增后的结果,例如:

a_mutex.lock();
int after = ++a;
a_mutex.unlock();

cout_mutex.lock();
std::cout << after << std::endl;
cout_mutex.unlock();

虽然可以用这个方法避免死锁,不过,有时候我们确实需要同时占有两个锁,例如同时访问两个变量并同时修改它们,如果不同时获取两个锁的话就无法保证这个操作的原子性。

为此,可以使用前面介绍过的std::timed_mutex,当获取锁超时时,回滚操作并放弃所有锁,再重新获取锁直到所有锁都获取成功:

int a, b;
std::mutex a_mu, b_mu;

void func() {
    using namespace std::chrono_literals;
    while (true) {
        std::unique_lock a_lock(a_mu, std::defer_lock);
        std::unique_lock b_lock(b_mu, std::defer_lock);
        if (!a_lock.try_lock_for(100ms))
            continue;
        if (!b_lock.try_lock_for(100ms))
            continue;
        int temp = a;
        a = b;
        b = temp;
        break;
    }
}

对于以上代码,当死锁出现时,总会有一个线程超时并放弃已经占有的锁,从而使等待这个被占有锁的另一个线程可以获取到锁。

但是,这种设计又有一个问题,每当死锁发生时,都需要等待一个线程超时才能获取锁。如果并发量很大且需要同时占有的锁数量大的情况下,这种等待会经常发生,且每次发生时所有的线程都要进行等待,效率极其低下。

就算没有很大的并发量和很多需要同时占用的锁,设想这种极端的情况,线程1占有资源a,线程2占有资源b,它们互相获取另一资源超时失败后,放弃锁。然后,线程1重新占有资源a,线程2重新占有资源b,它们又开始等待对方的资源,如此重复。
我们可以很容易地模拟这种情况,例如有两个线程同时要获取两个资源并做耗时处理,但一个线程需要优先处理其中一个资源,而另一个线程需要优先处理另一个:

using namespace std::chrono_literals;

std::timed_mutex a_mu, b_mu;
void func1() {
    while (true) {
        std::unique_lock a_lock(a_mu, std::defer_lock);
        std::unique_lock b_lock(b_mu, std::defer_lock);
        // 优先获取a的锁
        if (!a_lock.try_lock_for(100ms))
            continue;
        // 模拟对资源a进行耗时处理
        std::this_thread::sleep_for(50ms);
        // 再获取b的锁
        if (!b_lock.try_lock_for(100ms))
            continue;
        break;
    }
}
void func2() {
    while (true) {
        std::unique_lock a_lock(a_mu, std::defer_lock);
        std::unique_lock b_lock(b_mu, std::defer_lock);
        // 优先获取b的锁
        if (!b_lock.try_lock_for(100ms))
            continue;
        // 模拟对资源b进行耗时处理
        std::this_thread::sleep_for(50ms);
        // 再获取a的锁
        if (!a_lock.try_lock_for(100ms))
            continue;
        break;
    }
}
int main() {
    std::cout << "任务开始" << std::endl;
    std::thread t1(func1);
    std::thread t2(func2);
    t1.join();
    t2.join();
    std::cout << "任务完成" << std::endl;

    return 0;
}

以上代码虽然只有两个线程和两个共享变量,且超时时间只有100毫秒,但如果你尝试运行这段代码,就会发现这个任务需要数十秒甚至数分钟才能完成。这种状态被称为活锁。

不过这种情况的解决方法也很简单,即便需要优先对b进行处理,也先获取a的锁,再获取b的锁。只要获取锁的顺序一致,则死锁不会发生。

使用 std::lock 来避免死锁

我们可以直接把多互斥量传给std::lock让它帮我们加锁,它使用了特殊的算法来保证加锁的顺序,例如:

std::mutex a_mu, b_mu;

void func1() {
    std::lock(a_mu, b_mu);
    std::lock_guard(a_mu, std::adopt_lock);
    std::lock_guard(b_mu, std::adopt_lock);
}

void func2() {
    std::lock(b_mu, a_mu);
    std::lock_guard(b_mu, std::adopt_lock);
    std::lock_guard(a_mu, std::adopt_lock);
}

以上代码中func1func2传给std::lock的互斥量顺序是不一样的,但它不会造成死锁。

std::lock 的原理

如果你对std::lock的原理感兴趣,可以接着看这一节。

假设两个线程需要获取资源A、B、C,但两个线程获取它们的顺序不一致,例如线程1的顺序是ABC,而线程2的顺序是BAC。

假设线程1先通过lock获取了A,然后线程2通过lock获取了B。接着线程1通过try_lock尝试获取B,因为B已经被线程2占用了,因此获取失败,此时线程1会放弃所有资源,然后再通过BCA的顺序重新开始。由于B已经被线程1占用,因此线程1通过lock获取B时会进入阻塞状态。

同理,它们会再次不断尝试获取锁,如果失败则会放弃所有锁并重新获取(从lock开始,后面用try_lock),直到获取所有锁。

std::scoped_lock

相比于使用std::lock然后再手动释放锁或将锁交给lock_guardunique_lock管理,使用std::scoped_lock可以在保证加锁顺序的同时自动释放锁,更加地方便。

比如,上一节的代码可以改成:

std::mutex a_mu, b_mu;

void func1() {
    std::scoped_lock(a_mu, b_mu);
}

void func2() {
    std::scoped_lock(b_mu, a_mu);
}

可见使用scoped_lock可以让代码更加简洁。

读写锁

当不存在写时,多个线程读取同一个变量是不存在数据竞争的,因为没有写操作,这个变量的值就不会变化,那么对于读操作,谁先谁后并不会影响到读取的结果。

因此,对于只有读的操作,并不需要对其进行加锁,因为加锁可能引起激烈的锁竞争,从而降低程序的性能。

但实际情况下,对一个共享变量的访问是可能存在写的,例如一个读多写少的场景下,大部分时候都在读,偶尔存在写,因为写操作的存在,数据竞争是可能的,我们既需要防止数据竞争带来的错误,又想提高读的性能,此时就可以使用读写锁。

读写锁的实现

为了实现读时不加锁而只有写时才加锁,我们可以:

class RWMutex {
public:
    void r_lock() {
        std::lock_guard(count_mu);
        // 当获取第一个读锁时,阻止写线程获取写锁
        if (r_count == 0) {
            rw_mu.lock();
        }
        count++;
    }
    void r_unlock() {
        std::lock_guard(count_mu);
        count--;
        // 当最后一个读锁释放时,允许写线程获取写锁
        if (r_count == 0) {
            rw_mu.unlock();
        }
    }
    void w_lock() {
        rw_mu.lock();
    }
    void w_unlock() {
        rw_mu.unlock();
    }
private:
    int        r_count;   // 用于记录当前读线程数量
    std::mutex count_mu;  // 用于保护 r_count 变量 
    std::mutex rw_mu;     // 用于保证读线程与写线程互斥访问 
};

使用以上的读写锁,我们可以保证只有一个或多个读线程获取到读写锁,或者一个写线程获取到读写锁。

但是这样的读写锁有一个缺陷,假设读操作十分地频繁,以至于一直有读线程存在,那么写线程就会陷入一直等待读写锁的状态,造成写线程的“饿死”。

为了解决这一问题,我们可以让写线程优先,即当存在写请求时,禁止后续新的读请求:

class RWMutex {
public:
    void r_lock() {
        std::lock_guard(w_mu);  // 在没有写请求时进入
        std::lock_guard(count_mu);
        // 当获取第一个读锁时,阻止写线程获取写锁
        if (r_count == 0) {
            rw_mu.lock();
        }
        count++;
    }
    void r_unlock() {
        std::lock_guard(count_mu);
        count--;
        // 当最后一个读锁释放时,允许写线程获取写锁
        if (r_count == 0) {
            rw_mu.unlock();
        }
    }
    void w_lock() {
        w_mu.lock();  // 在没有写请求时进入
        rw_mu.lock();
    }
    void w_unlock() {
        rw_mu.unlock();
        w_mu.lock();
    }
private:
    int        r_count;   // 用于记录当前读线程数量
    std::mutex count_mu;  // 用于保护 r_count 变量 
    std::mutex rw_mu;     // 用于保证读线程与写线程互斥访问 
    std::mutex w_mu;      // 用于实现写优先
};

std::shared_mutex 与 std::shared_lock

std::shared_mutex提供了两种模式:

  • 独占模式: locktry_lockunlock
  • 共享模式: lock_sharedtry_lock_sharedunlock_shared

这里的独占模式就相当于上面的写锁,共享模式相当于上面的读锁。

我们可以将shared_mutex交给lock_guardunique_lock管理,相当于自动进行lockunlock,或者将shared_mutex交给std::shared_lock管理,相当于自动进行lock_sharedunlock_shared

同样地,shared_mutex也有支持超时的版本std::shared_timed_mutex

shared_mutex的实现方式与我们上面写的读写锁略有不同,它是使用了条件变量来实现的,但原理大致相同。

volatile关键字对多线程有用吗

int a = 0;

void read() {
    while (true) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << a << std::endl;
    }
}
void write() {
    while (true) { ++a; }
}

int main() {
    std::thread t1{read};
    std::thread t2{write};
    t1.join();
    t2.join();

    return 0;
}

当你使构建DEBUG版本并运行时,输出结果(好像)是正常的:

-1137463580
1983730333
1049306810

如果指定以RELEASE版本构建并运行,结果(可能)就不正常了:

0
0
0

这是因为,在这段代码中,不同线程对变量a的访问出现了数据竞争,数据竞争是未定义行为。而编译器在编译时会假定没有出现未定义行为,它不并知道有多个线程在写变量a
于是,编译器便做出优化,将a的值0直接替换std::cout << a << std::endl中的a(或是其他优化方案),从而导致结果不符合预期。

我们学过,volatile关键字用于告知编译器不要对变量进行优化,通常是因为该变量可能会被外部因素(例如硬件、操作系统或多个线程)修改。
那给变量a加上volatile关键字是不是就能避免优化了?
确实,加上它之后运行结果(好像)就正常了。

但是,这不影响这段程序本身就是错的,因为这并没有解决数据竞争,还是出现了未定义行为。更糟糕的是,volatile因为避免了优化,而且对变量的访问都直接读取/写入内存而不使用Cache,从而降低了访问变量a的性能。

也就是说,volatile并不能处理线程同步问题。

虽然说,有时候volatile关键字与多线程有一点关系,但对一个volatile变量的访问并不是原子性的。

对于多线程出现数据竞争的情况,应当采取正确的解决方式,例如使用原子变量,或使用锁。

同步与线程通信

有时候,两个或多个线程间需要交换数据,但这个数据可能是在线程运行的中间产生的,其中一个线程可能需要等待另一个线程产生这个数据。

一个思路是使用全局变量并加锁,但这种设计显然不太安全且不易维护。

promise 与 future

std::promisestd::future是C++进行单向数据传递的一种方式。std::promise类似于数据的输入端,而std::future类似于数据的输出端。

promise是一个模板类,可以指定共享变量的类型,例如:

std::promise<double> promise;

通过promise.get_future()来获取这个promise用于获取共享数据的future
对于future,通过future.get()来获取数据,若数据仍未准备完毕,则会阻塞当前线程。

promise对象和future对象都不能被拷贝,但可以被移动。

例如,以下是一个生产者线程和消费者线程:

void producer(std::promise<int>& promise) {
    using namespace std::chrono_literals;
    int res = 0;
    // 假设 res 值的获取是一个耗时操作
    std::this_thread::sleep_for(1s);
    res = 1;
    
    promise.set_value(res);
    // something else...
}

void consumer(std::future<int>& future) {
    std::cout << future.get() << std::endl;
}

int main() {
    std::promise<int> promise;
    std::future<int> future = promise.get_future();
    std::thread producerThread(producer, std::ref(promise));
    std::thread consumerThread(consumer, std::ref(future));

    producerThread.join();
    consumerThread.join();

    return 0;
}

以上代码实际上不太安全,因为传递的promise引用和future引用的生命周期是有限的,假设它们放在一个局部代码块或另一个函数中,且没有使用join进行阻塞,那么当代码块结束时,promisefuture就被销毁了,如果使用了这个promisefuture的线程还未结束,就会造成悬空引用的使用,从而造成程序崩溃。

为此,可以使用移动语义将promisefuture的控制权交给它们的线程,而不是将其生命周期交给局部代码块管理:

void producer(std::promise<int>&& promise);
void consumer(std::future<int>&& future) {}
void func() {
    std::promise<int> promise;
    std::future<int> future = promise.get_future();
    std::thread producerThread(producer, std::move(promise));
    std::thread consumerThread(consumer, std::move(future));
}

此外,promise也可以设置异常:

void divide(std::promise<double> &&promise, double a, double b) {
    try {
        if (b == 0)
            throw std::runtime_error("除零异常");
        promise.set_value(a / b);
    } catch (...) {
        promise.set_exception(std::current_exception());
    }
}

void consumer(std::future<double> &&future) {
    try {
        std::cout << future.get() << std::endl;
    } catch (std::runtime_error& e) {
        std::cout << e.what() << std::endl;
    }
}

int main() {
    std::promise<double> promise;
    std::future<double> future = promise.get_future();
    std::thread producerThread(divide, std::move(promise), 1, 0);
    std::thread consumerThread(consumer, std::move(future));

    producerThread.join();
    consumerThread.join();

    return 0;
}

注意,promise只能设置值或者异常一次,如果超过一次,将会抛出std::future_error异常。
future也只能获取一次值。

future有一个方法future.valid(),表示共享状态是否有效,当调用了get方法后,该方法返回false

shared_future

如果需要在多个线程中获取future的值,则可以选择使用std::shared_future

有以下两种方式获得shared_future对象:

std::shared_future sf1(promise.get_future());
auto sf2 = future.share();

当获取了shared_future之后,原有的future就不能再使用.

future.wait

有时候我们不关心任务的结果,只关心任务的完成时间,此时就可以使用future.wait(),它会阻塞到任务完成时,此时你可以通过future.get()获取结果或者不获取。

对于某些任务,我们希望设置一个超时时间,如果超时了则进行另外的操作,此时可以使用future.wait_for()future.wait_until()。如果在规定时间内任务没有完成,则会返回std::future_status::timeout。例如:

using namespace std::chrono_literals;
if (future.wait_for(5s) == std::future_status::timeout) {
    std::cout << "Task timed out!" << std::endl;
} else {
    std::cout << "Task completed!" << std::endl;
}

对于future.wait_until():

using namespace std::chrono_literals;
auto deadline = std::chrono::steady_clock::now() + 5s;
if (future.wait_until(deadline) == std::future_status::timeout) {
    std::cout << "Task timed out!" << std::endl;
} else {
    std::cout << "Task completed!" << std::endl;
}

wait_forwait_until的返回结果是一个future_status的枚举类,它的定义如下:

enum class future_status {
	ready,
	timeout,
	deferred
};
  • ready: 表示数据管道中的结果已经准备好了
  • timeout: 表示数据管道中的数据仍不可用,且wait等待时间已经到达
  • deferred: 接口被延迟执行,也就是任务还未开始

wait_forwait_until并不会真的等待到指定时间,如果任务在指定时间之前完成,会停止阻塞。你可以写一个测试代码来证明这一点:

void producer(std::promise<void> &&promise) {
    promise.set_value();  // 说明任务已完成,无需传递数据(因为是void)
}

void consumer(std::future<void> &&future) {
    using namespace std::chrono_literals;
    future.wait_for(999s);
    std::cout << "任务结束" << std::endl;
}

int main() {
    // void 表示没有值需要传递,只传递任务完成的状态
    std::promise<void> promise;
    std::future<void> future = promise.get_future();
    std::thread producerThread(producer, std::move(promise));
    std::thread consumerThread(consumer, std::move(future));
    producerThread.join();
    consumerThread.join();
    return 0;
}

条件变量 std::condition_variable

条件变量也是一种通过线程通信实现同步的方式,接收者通过从条件变量中等待数据,直到发送方发送数据后唤醒并获取数据、

对于std::condition_variable:

  • 读线程通过waitwait_forwait_until等待并读取数据。
  • 写线程通过notify_onenotify_all通知读线程数据准备好了。

wait成员函数需要传入一个std::unique_lock<std::mutex>&,当前线程需要已经获得了传入的这个互斥量。wait方法会释放掉这个互斥量,并调用内部的一个_wait()方法进入阻塞等待状态。

当另一个线程使用notify_onenotify_all时,前面阻塞的线程就会结束阻塞并再次获得互斥量。

也就是说,wait是进入阻塞时释放锁,结束阻塞时获得锁。

例如:

class PC {
public:
    void produce() {
        std::cout << "写入数据" << data << std::endl;
        data = "some data";
        cv.notify_one();
    }

    void consume() {
        std::unique_lock lock(mu);
        std::cout << "等待数据" << std::endl;
        cv.wait(lock);
        std::cout << "读取数据" << data << std::endl;
        data.clear();
    }
private:
    std::mutex mu;
    std::condition_variable cv;
    std::string data;
};

int main() {
    PC pc;
    std::thread producer(&PC::produce, &pc);
    std::thread consumer(&PC::consume, &pc);
    producer.join();
    consumer.join();

    return 0;
}

然而,上述代码可能造成死锁,因为写线程可能先写入了数据,然后读线程再开始等待数据,然而写线程已经写完数据并退出了,读线程错过了这个数据,于是会一直等待。这种情况被称为丢失唤醒。

此外,条件变量还存在虚假唤醒的情况,即并没有满足条件但却唤醒。

为了应对这种情况,wait提供了另外一个重载版本,可以防止错过数据。另外,手动判断数据是否存在来确定是否虚假唤醒:

void consume() {
    std::unique_lock lock(mu);
    std::cout << "等待数据" << std::endl;
    cv.wait(lock, []{ return !data.empty(); });
    if (data.empty()) {
        std::cout << "虚假唤醒,无数据" << std::endl;
    } else {
        std::cout << "读取数据" << data << std::endl;
    }
    data.clear();
}

promise/future 与 条件变量 的区别

由上面两节可见 promise/future 与 条件变量 十分类似,都可以用于传递数据进行线程同步。

不同点是:

  • promise/future 可以在接收端进行异常处理,而条件变量不能
  • 条件变量可以进行多次数据传递,而 promise/future 只能一次
  • 条件变量存在虚假唤醒的情况,而 promise/future 不会
  • 条件变量存在唤醒丢失的情况,而 promise/future 不会
  • 条件变量有临界区,可以对数据进行更加灵活的处理

信号量 Semaphore

学过操作系统这门课的应该对信号量以及 PV 操作、wait/signal 操作比较熟悉。

前面学过的 mutex 锁用于控制对单个资源的并发访问互斥,不过有些资源可能不只一个,例如一台计算机可能连接多台打印机,而每台打印机只可能被一个进程/线程使用。
于是,我们为打印机设置一个计数器,当有一个进程/线程占有一个打印机时,将计数器减一。当打印机资源被释放时,计数器加一。如果计数器减为零,则继续申请打印机资源的进程/线程需要等待。

为了实现信号量,我们需要引入对资源访问的互斥控制、维护一个计数器,然后还需要一个互斥量保证对计数器访问的互斥等。

对于C++,在C++ 20才引入了std::counting_semaphore来方便地实现信号量。它是一个模板类型,需要在尖括号中输入一个数字来确定信号量的最大并发访问量。
此外,它还有一个特化版本std::binary_semaphore,其最大并发量为1。

counting_semaphore的方法:

  • release: 将计数值加1,是原子操作
  • acquire: 如果当前计数值大于1,则减1;如果为0,则阻塞,直到计数值大于1时结束阻塞,并将计数值减1后继续执行
  • try_acquire: 非阻塞版本,若成功则将计数值减1并返回true,否则返回false
  • try_acquire_for: 阻塞时最多等待指定时长
  • try_acquire_until: 阻塞时最多等待到指定时刻

例如,一个缓冲区最大可以存放100个数据资源(假设是string),资源生产者不断往其中添加数据,资源消费者不断从其中提取数据并做处理。
当缓冲区为空时,消费者需要阻塞等待直到缓冲区不为空;当缓冲区满时,生产者需要等待缓冲区有空位时才能继续往其中添加数据。
为了实现这个缓冲区,我们需要一个最大计数为100且初始量也为100的信号量empty用于表示缓冲区空位数量,用一个初始值为0的信号量full表示缓冲区数据的数量,此外还需要一个互斥锁mutex控制缓冲队列的互斥访问。
代码如下:

#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <semaphore>
#include <mutex>
#include <chrono>

const int BUFFER_SIZE = 100;  // 缓冲区大小
std::vector<std::string> buffer;  // 缓冲区
std::mutex buffer_mutex;  // 缓冲区互斥锁

std::counting_semaphore<BUFFER_SIZE> empty_slots(BUFFER_SIZE);  // 空位信号量,初始为缓冲区大小
std::counting_semaphore<0> filled_slots(0);  // 已填充的信号量,初始为 0,表示没有数据

// 生产者函数
void producer(int id) {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));  // 模拟生产延迟

        empty_slots.acquire();  // 等待有空位

        {
            std::lock_guard<std::mutex> lock(buffer_mutex);
            std::string data = "Data " + std::to_string(id) + "-" + std::to_string(i);
            buffer.push_back(data); // 生产数据并放入缓冲区
            std::cout << "Producer " << id << " produced: " << data << std::endl;
        }

        filled_slots.release();  // 增加已填充信号量,通知消费者有数据
    }
}

// 消费者函数
void consumer(int id) {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(150));  // 模拟消费延迟

        filled_slots.acquire();  // 等待有数据

        {
            std::lock_guard<std::mutex> lock(buffer_mutex);
            if (!buffer.empty()) {
                std::string data = buffer.back();
                buffer.pop_back();  // 从缓冲区取出数据
                std::cout << "Consumer " << id << " consumed: " << data << std::endl;
            }
        }

        empty_slots.release(); // 增加空位信号量,通知生产者有空位
    }
}

int main() {
    std::vector<std::thread> producers;
    std::vector<std::thread> consumers;

    // 创建多个生产者线程
    for (int i = 0; i < 3; ++i) {
        producers.emplace_back(producer, i + 1);
    }

    // 创建多个消费者线程
    for (int i = 0; i < 3; ++i) {
        consumers.emplace_back(consumer, i + 1);
    }

    // 等待所有线程完成
    for (auto& producer : producers) {
        producer.join();
    }

    for (auto& consumer : consumers) {
        consumer.join();
    }

    return 0;
}

binary_semaphore 与 mutex 对比

binary_semaphore作为最大并发量只有1的信号量,它相当于一个mutex锁,在很多情况下它们的作用是等价的。

不过,mutex主要用于同一个进程中的多线程同步/互斥访问控制,而信号量通过可以在多个进程中共享,可以被其他进程释放,因此更适合多进程环境下的同步需求‌。

不过,binary_semaphore通常不能直接进行进程间同步,需要搭配其他库使用,感兴趣的可以自行探索。

异步任务

异步任务与直接使用 thread 的区别

对于一个常规的顺序同步任务来说,一行代码或一个操作执行完之后才能进行下一步操作。

不过,有时候一组操作是可以并行执行的,例如你需要使用微波炉且吃饭,以及使用洗衣机、烘干机然后换上洗烘好的新衣服出门,你不应该等其中一个完成后才开始另一个。你可以让洗衣机和微波炉同时工作,当洗衣机完成工作后将衣服放入烘干机,当微波炉完成任务后吃饭,当吃饭和烘干机都完成工作后换上衣服出门。

为了能让不同子任务并行执行,你可以使用多个std::thread并行执行,因为std::thread不会直接返回结果所以你需要通过回调函数确保共享数据的传递,使用thread.join确保同步。

C++ 11提供了异步任务来更方便地实现并行任务,你可以直接创建一个异步任务并得到一个future,然后做其他事情,直到需要结果时才访问future
且C++提供的异步任务使用线程池来管理线程,相比于直接创建线程在某种程度上会更加节省资源。

std::async

std::async的函数模板定义如下:

template<class F, class... Args>
std::future<async_result_of<F, Args...>> async(std::launch policy, F&& f, Args&&... args);

template<class F, class... Args>
std::future<async_result_of<F, Args...>> async(F&& f, Args&&... args);

std::async返回一个std::future,其模板类型为函数的返回值类型。async接收一个可选的参数policy表示执行策略,f为需要异步调用的函数,args为传入要异步调用函数的参数。
第二个async没有policy参数,它所采用的执行策略为默认策略。

执行策略有:

  • (std::launch) 0: 表示默认策略,具体实现(异步还是推迟)取决于编译器,通常是异步
  • std::launch::async: 表示异步执行,通常采用这个方式
  • std::launch::deferred: 表示推迟调用,当该策略的异步任务被创建时,不会立即执行传入的函数,而是在你调用future.get时在当前线程同步执行并获得结果
  • std::launch::async | std::launch::deferred: 两种策略的组合,通常不被支持,你需要自己手动支持(例如使用threadcondition_variable)

上面最后一种策略采用的组合方式,是基于位运算实现的,其实现原理可以看这里

以上面使用微波炉、洗衣机等的例子,说明异步任务的使用:

struct food{};
struct clothes{};

// 为节省篇幅没有实现以下方法,你可以自行加上cout输出与sleep模拟等待
food microwave();  // 使用微波炉
void eat(food);  // 吃饭
void washer();  // 使用洗衣机
clothes dryer();  // 使用烘干机
void wear(clothes);  // 穿上洗烘好的衣服
void go_outside();  // 出门

// 洗衣服相关工作
clothes wash() {
    std::future<void> taskWasher = std::async(std::launch::deferred, washer);
    std::future<clothes> taskDryer = std::async(std::launch::deferred, dryer);
    taskWasher.get();
    return taskDryer.get();
}

// 吃饭相关工作
food dinner() {
    std::future<food> taskMicrowave = std::async(std::launch::deferred, microwave);
    return taskMicrowave.get();
}

int main() {
    std::future<clothes> taskWash = std::async(std::launch::async, wash);
    std::future<food> taskDinner = std::async(std::launch::async, dinner);
    food f = taskDinner.get();
    eat(f);
    clothes c = taskWash.get();
    wear(c);
    go_outside();
    return 0;
}

std::packaged_task

std::packaged_task的类模板与函数封装的类模板std::function的定义是一样的。

它与function的区别是,提供了一个成员函数get_future()用于获取一个future对象,调用所封装的函数,调用结果会储存在这个future对象中的共享状态中。

你也可以直接把它像执行function那样,使用其重载的函数调用运算符operator()调用函数,但这样做是同步执行,且没有返回值,其函数返回值结果会存储在future中。

它还有一个成员函数reset()用于重复使用packaged_task,当通过future对象获得函数的结果后,可以调用reset()进行重置,然后通过get_future获得一个新的future对象以再次获得结果。

这次我们有大量的衣服要洗,需要分两次洗,但只需穿第一批洗的衣服,因此上一节的代码可以改为:

clothes wash() {
    std::packaged_task<void()> taskWasher(washer);
    std::packaged_task<clothes()> taskDryer(dryer);
    taskWasher();
    std::future<clothes> result = taskDryer.get_future();
    taskDryer();

    clothes c = result.get();
    
    // 洗第二批衣服
    taskWasher.reset();
    std::thread t(std::move(taskWasher));
    t.detach();
    
    return c;
}

food dinner() {
    std::packaged_task<food()> taskMicrowave(microwave);
    auto result = taskMicrowave.get_future();
    taskMicrowave();
    return result.get();
}

int main() {
    std::packaged_task<clothes(void)> taskWash(wash);
    std::packaged_task<food(void)> taskDinner(dinner);
    auto resultWash = taskWash.get_future();
    auto resultDinner = taskDinner.get_future();
    std::thread t1(std::move(taskWash));
    std::thread t2(std::move(taskDinner));
    eat(resultDinner.get());
    wear(resultWash.get());
    go_outside();

    return 0;
}

因为一个打包任务需要显式调用才能开始执行,所以我们通常将任务交给一个线程去执行,并通过future.get()等待结果。

线程屏障

我们有时候需要同时异步执行多个任务,然后等待所有任务都完成后才继续下一步操作。

为了实现这点,我们需要维护一个计数器或信号量,阻塞当前线程并等待达到一定要求后唤醒当前线程。

C++ 20引入了线程屏障的新特性,可以更方便地协调多个线程进行同步。

std::latch

std::latch维护了一个计数器,通过传入其构造函数的值确定其初始值,其常用成员函数如下:

  • count_down(n=1): 减少计数器的值,可选参数为需要减少的值,默认为1,该函数为原子操作
  • wait(): 阻塞当前线程直到计数器为0
  • try_wait(): 检查计数器是否为0,返回true时计数器为0,但返回false时计数器不一定为0,极少数情况下计数器为0也会返回false
  • arrive_and_wait(n=1): 相当于同时执行count_downwait

latch是一个一次性对象,当计数器减为0时,它就不能再重复使用了。

std::barrier

std::barrierstd::latch的不同是,当它的计数值降为0后,它会重新将计数器设为初始值并开始一轮新的计数。

std::barrier的构造函数接收一个计数初始值和一个可调用对象,当每轮计数值为0时会调用一次这个可调用对象。

它的常用成员函数如下:

  • arrive(n=1): 将计数器减去指定值(默认为1)
  • wait(): 等待直到本阶段的计数器降为0
  • arrive_and_wait(n=1): 相当于arrivewait的结合使用
  • arrive_and_drop(n=1): 除了将本次计数值减去n,还将下一阶段的初始计数值减去n,不会调用wait阻塞当前线程

这里的arrive_and_drop说是会减少下一阶段初始计数值,但下下阶段的初始计数值受上一阶段影响,因此假设每一阶段都调用至少一次arrive_and_drop,那么初始值会不断下降直到为0。

无锁编程

无锁编程的优缺点

在多线程中,为了保证资源的互斥访问与线程同步,需要使用锁。

但是当线程数很多、并发量很大时,就会引起激烈的锁竞争,从而有大量的线程会等待锁。除此之外,因为阻塞而引起的上下文切换、CPU缓存失效等问题,会进一步降低程序的性能。

然而,在一些并发量大且时间敏感的场景中,锁竞争所带来的时间等待可能是无法被忍受的。而采用无锁编程,通常能获得两倍或数倍的性能提升。

但是,无锁编程在低并发的场景下性能提升并不明显,甚至可能不如有锁的情况。而且,无锁编程的设计十分困难,需要耗费比使用锁更多的时间用于编程,且也容易出现问题。

原子操作

我们前面不断提到过原子操作,这里再重新审视一下这个概念。

所谓原子操作,就是一个不能被分割或中断的操作,所有线程只能看到原子操作开始之前和结束之后的状态,而看不到它的中间状态或过程。
一个原子操作,可以由底层的硬件指令来保证操作的原子性,也有可能是更高层的同步原语来实现。

例如,将一个数据值从内存中读取到寄存器中,可以视为一个原子操作。

我们前面不同线程对一个共享变量进行自增的操作需要加锁,是因为自增不是一个原子操作,它包括了读取、修改、写入三个主要过程,因为另外一个线程的这三个过程可能与前一个线程重叠,导致结果的错误,因此需要通过加锁的方式来避免重叠,实现该组合操作的原子性。

不过通过加锁实现的原子操作存在锁竞争的问题,如果能够将三个过程在硬件的层面组合,变成一条指令而不是三条指令,便可以不通过加锁实现原子操作,自然也不存在锁竞争。

如果你熟悉数据库的事务,你会发现一个事务也是一个原子操作: 要么全部成功,要么全部失败,整个操作过程不被中断,外部不能获取中间状态。

原子类型 std::atomic_flag

std::atomic_flag是C++ 11引入的,它是标准库中唯一的能够保证无锁的原子操作,也就是说除了它以外的其他原子类型都可能在内部使用互斥量等锁机制来实现原子操作。

atomic_flag是一个布尔类型,它只有两个值truefalsetrue表示标志被设置,false表示标志被清除。

初始化 atomic_flag

可以使用宏来初始化atomic_flag:

std::atomic_flag flag = ATOMIC_FLAG_INIT;
// 相当于
// std::atomic_flag flag = {0};

在C++ 20之前,使用atomic_flag的默认构造函数,创建的值是未定义的,直到C++ 20之后,它才默认初始化为false

成员函数

在C++ 11,atomic_flag只提供以下两种操作

  • clear(): 用于清楚标志,也就是将标志设为false,是一个原子的写操作。它有一个参数std::memory_order用于指定内存的顺序,默认为std::memory_order_seq_cst,这个比较复杂,会在后面讲解
  • test_and_set(): 用于将标志设置为true,并返回原有的值,它是一个读改写(Read Modify Write, RMW)的原子操作

在C++ 20,atomic_falg提供了更多操作:

  • test(): 用于读取数据
  • wait(oldValue): 用于等待条件达成(可能虚假唤醒,但它不会因为虚假唤醒而结束等待)
  • notify_one(): 用于通知所有等待线程中的其中一个线程结束等待
  • notify_all(): 用于通知所有等待线程结束等待

C++ 20的这些新功能可以用于实现更高性能的condition_varible类似功能。

使用 atomic_flag 实现一个自旋锁

自旋锁是一种锁的实现,当锁被其他线程占有时,锁获取失败,此时会继续不断尝试获取锁,直到锁获取成功。

class SpinLock {
public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire));
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
private:
    std::atomic_flag flag{ATOMIC_FLAG_INIT};
};

当第一个线程调用lock时,test_and_set会将flag设为true,由于初始值被设为false,于是返回false,循环没有开始,获得锁。
当第二个线程调用lock时,test_and_set虽然也会将flag设为true,但因原有值已经是true了,所以返回true,进入循环。
当第一个线程通过unlockflag设为false后,第二个线程调用test_and_set返回false,循环结束,获取锁。

这种通过自旋方式实现的锁意味着,如果一个线程获取锁失败,它会持续不断占用CPU获取锁,如果锁没有被释放或者发生了死锁,那么这个线程就会陷入死循环中,使得它所在的CPU核心占用一直是100%。

但是,这种锁不是没有意义的,如果放弃CPU转而让该CPU核心去执行其他线程,那么会引起上下文的切换,包括保存旧线程在CPU寄存器的值,并将CPU寄存器的值设置为新线程的寄存器值等。而且,旧线程在CPU Cache中的值可能被新线程覆盖,引起缓存失效,当旧线程重新获得CPU时,不仅要再次重新切换上下文,还会因为缓存失效而需要重新从内存中读取数据,降低了Cache带来的性能提升。此外,系统操作需要将当前的状态从用户态转为内核态以执行一些系统调用,如进行一系列线程调度算法选择一个新线程代替旧线程,执行这个算法也需要一定的CPU时间。

对于一些轻量级的任务,比如将一个变量自增,如果一个线程获取锁失败,且当前锁竞争并不激烈,它可能只需浪费几个CPU周期自旋等待就能得到锁,而如果放弃CPU,可能就会浪费数百个CPU周期进行上下文切换和线程调度。

因此,我们可以这样改进自旋锁,允许自旋锁自旋指定次数(如10次),如果超过指定次数后仍得不到锁,才放弃CPU。

不过,在现代操作系统中,即便一个自旋锁陷入了死循环,它仍然可能会被调度出去,这是因为操作系统可能采用了时间片等调试策略,当一个线程占用CPU一定时间后,强制剥夺它占用的CPU,让其他线程也能得到CPU。特别是在单核处理器中,总不能因为一个死循环就直接让系统死机吧。

std::atomic 类模板

声明与定义

std::atomic的声明如下:

template<class T>
struct atomic;

template<class U>
struct atomic<U*>;

其中,类型T要求是可以平凡拷贝的。

在C++ 20,支持使用原子的智能指针:

template<class U>
struct atomic<std::shared_ptr<U>>;

template<class U>
struct atomic<std::weak_ptr<U>>;

虽然std::atomic没有显式地使用锁,但不意味着它一定不使用锁。例如,某些处理器平台上可能不支持相关的原子指令,此时atomic的实现就会使用锁。

我们可以用这种方式判断指定类型Tatomic类是否是无锁的:

std::atomic<T>{}.is_lock_free();

例如,在大多数现代计算机平台上,都会得到以下结果:

struct A { char a[8]; };
struct B { int a[100]; };

int main() {
    std::cout << "atomic<bool>: " << std::boolalpha << std::atomic<bool>{}.is_lock_free() << std::endl;
    std::cout << "atomic<char>: " << std::boolalpha << std::atomic<char>{}.is_lock_free() << std::endl;
    std::cout << "atomic<int>: " << std::boolalpha << std::atomic<int>{}.is_lock_free() << std::endl;
    std::cout << "atomic<double>: " << std::boolalpha << std::atomic<double>{}.is_lock_free() << std::endl;
    std::cout << "atomic<void*>: " << std::boolalpha << std::atomic<void*>{}.is_lock_free() << std::endl;
    std::cout << "atomic<A>: " << std::boolalpha << std::atomic<A>{}.is_lock_free() << std::endl;
    std::cout << "atomic<B>: " << std::boolalpha << std::atomic<B>{}.is_lock_free() << std::endl;
    return 0;
}

输出:

atomic<bool>: true
atomic<char>: true
atomic<int>: true
atomic<double>: true
atomic<void*>: true
atomic<A>: true
atomic<B>: false

基本类型的原子类型大多是无锁的,而自定义类型是否无锁与其大小和对齐有关。例如,对齐后大小小于最大对齐数(通常是16字节),就可以是无锁的。

加载与存储

原子模板有以下方式用于原子地加载变量值:

T load(std::memory_order order = std::memory_order_seq_cst) const noexcept;
operator T() const noexcept;

对于第二个类型重载运算符,没有内存序的参数,相当于使用默认的内存序。

原子地存储值:

void store(T desired, std::memory_order order = std::memory_order_seq_cst) noexcept;
T operator=(T desired) noexcept;

同样,赋值运算符使用的也是默认的内存序。

在C++ 20之后,原子类型还提供了通知和等待函数:

  • wait()
  • notify_one()
  • notify_all()

对于某些类型,也提供了以下数值运算函数:

整数 指针 浮点
fetch_add
fetch_sub
operator+=
operator-=
operator++
operator++(int)
operator--
operator--(int)
fetch_and
fetch_or
fetch_xor
operator&=
operator|=
operator^=

CAS 操作

无锁编程中最重要的操作之一是比较并交换(Compare And Set, CAS),在C++中该函数可通过以下两个函数实现:

bool compare_exchange_weak(T& expected, T desired, 
                           std::memory_order success, 
                           std::memory_order failure) noexcept;
bool compare_exchange_strong(T& expected, T desired, 
                             std::memory_order success, 
                             std::memory_order failure) noexcept;

compare_exchange的操作相当于:

template<class T>
class atomic {
public:
    bool compare_exchange_strong(T& expected, const T& newValue) {
        std::lock_guard<std::mutex> lock(mutex);
        if (this->value == expected) {
            this->value = newValue;
            return true;
        }
        expected = this->value;
        return false;
    }
private:
    T value;
    std::mutex mutex;
};

在实际的操作中,符合条件的情况下compare_exchange会使用CPU指令来实现。

compare_exchange_weak相比于strong版本而言,weak版本可能有虚假失败的可能。

无锁算法是经常使用CAS循环实现,例如,以下是使用CAS实现原子乘法的示例:

int fetch_multiply(std::atomic<int>& value, int multiplier) {
    int oldValue = value.load();
    int desired;
    do {
        desired = oldValue * multiplier;
    } while(!value.compare_exchange_strong(oldValue, desired));
    return oldValue;
}

对于第6行的compare_exchange,如果oldValueexpected相同,即第2行load时的值在经过几个CPU周期后,仍与原子变量的值相同,说明还没有其他并发的线程修改了它的值,于是将该原子变量的值修改为desired
如果oldValueexpected不相等,说明在第2行load了之后有其他线程修改了变量的值,于是更新失败,将oldValue的值更新为该原子变量的当前值,然后再重试。

对于这样的无锁方案,在读多写少的情况下,因为读本身不会修改变量的值,于是写失败的概率会比较低,且相比读写锁需要额外维护读线程数的值,CAS操作因为通常直接由硬件指令实现,开销会少很多。

在并发写频繁的情况下,写失败的概率会增加,并会不断触发重试。对于有锁方案,激烈的锁竞争可能触发线程调度,从而引起多次的上下文切换,前面讲过上下文切换的开销是比较大的,而CAS的重试不会直接引起上下文切换,且每次重试的成本较小,于是在高并发场景下的性能更好。

不过,如果并发写极其激烈(什么情况会出现呢?),重试的次数会不断增加,从而成为算法的瓶颈,降低性能。于是,如何更巧妙地优化无锁算法,也是一个难题。

内存模型与顺序

顺序一致性模型

前面不断提到内存一致性、内存序等,这里给出详细解释。

内存一致性模型用于处理多个线程中看到的代码执行顺序的问题。

看以下代码:

atomic<int> a{0}, b{0};
int x = 0, y = 0;

std::thread t1([&]{
   a.store(1);
   x = b;
});
std::thread t2([&]{
   b.store(1);
   y = a;
});

t1.join();
t2.join();

正常情况下,两个线程t1t2内的代码是顺序执行的,但两个线程可能交错执行,从而可能产生以下结果:

  • x=1, y=0
  • x=0, y=1
  • x=1, y=1

而有一种结果x=0, y=0是不会出现的,因为如果它出现,必须是x=bb.store(1)之前执行且y=aa.store(1)之前执行,在线程内顺序执行的情况下这不会发生。

这种模型被称为顺序一致性模型std::memory_order_seq_cst,也是默认的模型。

顺序一致性模型使用简单且不易出错,但它可能无法充分利用系统和硬件的优化。

从硬件角度出发

在现代的处理器中,为提高性能,普遍采用了多级缓存方案。当一个变量被读入Cache时,对它的修改并不一定会直接同步写到内存中,而是可能会在这个变量从Cache中移出时才写回内存。
在单线程的情况下,这不会有什么问题,但是在多线程的情况下,由于不同CPU核心的Cache是相互独立的,于是当两个线程都要访问同一个变量时,其中一个线程对其的修改可能还未同步到内存,就被另外一个线程读取了,导致另一个线程读取的是旧的值。

在现代的处理器中,指令可以被乱序执行,为什么要乱序执行呢?

一条CPU指针,大概可以被分为五个部分:

  • 取指令(IF): 从内存或Cache中获取下一条需要执行的指令,并写入指令寄存器中
  • 译码(ID): 将二进制形式的指令通过译码电路翻译成具体要执行的电信号
  • 执行(EX): 执行该指令
  • 访存(MEM): 如果该指令需要访问内存,则需要一定时间访存
  • 写回(WB): 如果有执行结果,将结果写回到内存或Cache中

由于每个部分都可以独立进行,如果每条指令都串行地执行,如:
|IF1|ID1|EX1|MEM1|WB1|IF2|ID2|EX2|MEM2|WB2|…|

那么每一部分完成后,都需要等整条指令执行完毕且下一条指令执行到这一部分时,才能继续利用这一部分的相关电路。

如果每条指令间的执行可以重叠,像流水线那样执行,就能减少每一部分电路的空闲时间,从而减少执行多条指令的总时间,例如:

时刻 1 2 3 4 5 6 7 8 9
指令1 IF ID EX MEM WB
指令2 IF ID EX MEM WB
指令3 IF ID EX MEM WB
指令4 IF ID EX MEM WB
指令5 IF ID EX MEM WB

但是这种形式有一个问题,如果指令1和指令2都访问同一个变量,且指令2需要访问指令1的结果,那么指令1的执行结果可能在写回内存或Cache之前,指令2就去读指令1的结果了,造成不同步的问题。

为解决这个问题,可以让指令2往后延迟几个周期执行,但这势必会造成一定的CPU时间浪费,因为后续指令都随着指令2的延迟而延迟。另一种方案是,如果指令3、4提前执行的结果不会影响到最终结果,那可以让指令3、4先执行,然后再执行指令2,这样不仅没有浪费CPU时间,还能保证数据的同步。

为此,如果指令可以被乱序执行,并利用指令流水线机制,可以提高性能。

指令的乱序执行主要是CPU硬件实现的,不过编译器也可以调整指令的顺序,从而更灵活的优化代码。

非顺序一致性模型

为此,std::memory_order还提供了以下选择:

  • std::memory_order_acquire
  • std::memory_order_release
  • std::memory_order_acq_rel

写入原子变量时通常使用release,可以理解为,将值写入Cache后,将Cache中的数据立即发布到内存中,这不仅包括正在修改的变量,也包括了在此之前修改的其他变量。
读取原子变量时通常使用acquire,可以理解为,将内存中的真实值更新到Cache中,再读取这个值,防止Cache中的值与内存中的真实值不同步。
例如:

x = a.load(std::memory_order_acquire);
a.store(1, std::memory_order_release);

这样的内存模型设定并不只针对这一行代码,而且能让编译器在编译和优化时,也遵照这样的约定,保证编译器在重排代码时符合内存一致性要求。

使用acquirerelease可以形成内存屏障。在使用std::mutex时,lock()相当于acquireunlock()相当于release:

a = 1;
mutex.lock();  // acquire
x = 1;
y = 1;
mutex.unlock();  // release
b = 1;

这段代码中lockunlock之间为临界区,在临界区中,x=1y=1是独立的没有关系的语句,于是这两条语句的顺序不会对最终结果有影响。
但是,由于临界区的限制,临界区内的代码是不能被移到临界区外的,因为临界区内的变量可能被共享,需要被保护。
而临界区上方的a=1虽然在临界区外,但是它是可以被移入临界区内的,即便会增加临界区的运行时间,从而降低性能。不过,a=1不能越过临界区,到达临界区的下方
也就是说,unlock之前的语句不能被移动到unlock之后。
同理,临界区下方的b=1是可以被移动到临界区内的,但不能越过临界区被移动到临界区的上方,即lock之后的语句不能被移动到lock之前。
换句话说,acquire之后的语句不能被移动到acquire之前,release之前的语句不能被移动到release之后。

而对于fetch_add这样需要先加载再修改的,由于是一个原子操作,所以需要使用std::memory_order_acq_rel,即先使用acquire从内存中读取,再使用release发布到内存,且这些操作是原子性的。

此外,还有:

  • std::memory_order_relaxed: 它是最宽松的内存顺序,表示没有同步要求,只保证原子操作本身的原子性。
  • std::memory_order_consume: 确保读取原子变量之后的所有依赖于该值的操作的顺序性。

在实际开发中,memory_order_relaxed经常用于优化性能,避免不必要的同步开销。
memory_order_consume则较少使用,因为它的行为在一些编译器实现中可能并不完全按照预期,常常会被优化成memory_order_acquire

前面使用的顺序一致性模型memory_order_seq_cst则是最强的内存顺序,它要求全局顺序一致性。也就是说,所有线程的内存访问必须按照程序中指定的顺序来执行。这个顺序保证了在多个处理器上运行的程序的可见性一致性,但这也可能影响CPU流水线的效率,因为它会要求严格的同步,限制了乱序执行的自由度。

利用原子操作实现线程安全的 shared_ptr

这里给出一个使用原子操作并采用正确内存序优化代码的shared_ptr的例子(注意,这里的线程安全指引用计数是线程安全的,对智能指针所指向的对象的线程安全不作保证)。

template<typename T>
class shared_ptr {
public:
    shared_ptr() : ptr(nullptr), ref_count(nullptr) {}
    explicit shared_ptr(T* p) : ptr(p), ref_count(p == nullptr ? nullptr : new std::atomic<std::size_t>(1)) {}
    ~shared_ptr() { release(); }
    // 拷贝构造
    shared_ptr(const shared_ptr<T>& other) : ptr(other.ptr), ref_count(other.ref_count) {
        if (ref_count != nullptr) {
            ref_count->fetch_add(1, std::memory_order_relaxed);
        }
    }
    // 拷贝赋值运算符
    shared_ptr<T>& operator=(const shared_ptr<T>& other) {
        if (this != &other) {
            release();
            ptr = other.ptr;
            ref_count = other.ref_count;
            if (ref_count != nullptr) {
                ref_count->fetch_add(1, std::memory_order_relaxed);
            }
        }
    }
    // 移动构造
    shared_ptr(shared_ptr<T>&& other) noexcept : ptr(other.ptr), ref_count(other.ref_count) {
        other.ptr = nullptr;
        other.ref_count = nullptr;
    }
    // 移动赋值运算符
    shared_ptr<T>& operator=(shared_ptr<T>&& other) noexcept {
        if (this != &other) {
            release();
            ptr = other.ptr;
            ref_count = other.ref_count;
            other.ptr = nullptr;
            other.ref_count = nullptr;
        }
        return *this;
    }
    // 解引用运算符
    T& operator*() const {
        return *ptr;
    }
    T* operator->() const {
        return ptr;
    }
    std::size_t use_count() const {
        return ref_count == nullptr ? 0 : ref_count->load(std::memory_order_acquire);
    }
    T* get() const {
        return ptr;
    }
    void reset(T* p = nullptr) {
        release();
        ptr = p;
        ref_count = p == nullptr ? nullptr : new std::atomic<std::size_t>(1);
    }
private:
    T* ptr;
    std::atomic<std::size_t>* ref_count;  // 引用计数

    void release() {
        if (ref_count != nullptr && ref_count->fetch_sub(1, std::memory_order_acq_rel) == 1) {
            delete ptr;
            delete ref_count;
        }
    }
};

在以上代码中,主要对原子变量ref_count做了三种操作:

  • ref_count->load(std::memory_order_acquire): 确保获取的是最新的值
  • ref_count->fetch_add(1, std::memory_order_relaxed): 增加后无需获取其值,因此对内存序没有要求,使用relaxed以优化程序
  • ref_count->fetch_sub(1, std::memory_order_acq_rel) == 1: 由于需要在引用计数为0时释放内存,因此需要获取修改前的值,当修改前的值为1时,说明修改后为0。因为同时有读写操作,于是需要acquire确保读的是最新的值,又需要release确保其他线程能够同步到修改,于是使用acq_rel

协程

什么是协程

前面学过的线程中,当一个线程阻塞时,这个线程会被挂起,然后触发线程调度,引起上下文切换,这个切换的过程开销是比较大的。

为了减少这样的开销,如果一个线程需要进入阻塞时不放弃CPU,而是切换到另一个任务继续进行,不就能够减少线程切换的开销了吗?

事实上,一个标准的线程我们称之为内核态线程,因为对该线程的调度需要操作系统参与,需要有从用户态向内核态切换的过程。

而协程相当于一个轻量级的线程,当需要切换任务时,仍使用当前线程继续工作,调度协程时无需切换到内核态,而是直接由用户态的程序自行调度。

对于单线程的协程,可以保证多个任务异步的同时,无需通过锁等机制就能实现一定的同步机制,因为在单线程下同一时刻只有一个任务在执行。

但单线程可能成为性能瓶颈,于是我们可以同时启动多个内核态的线程,并不断地获取任务执行,但一个任务执行完毕或陷入阻塞时,开始另一个任务而不是进行线程切换,这样能够进一步提高多线程程序的性能。

C++中的协程

由于协程在性能上的优越性,许多编程语言开始在语法层面上支持协程,特别是 python 和 javascript,很早就开始支持协程。

C++直到C++ 20才引入了语言层面的协程。不过,相比于 python 等语言,C++的协程比较原始(当然,因为这是C++),因此它使用起来比较复杂。

C++ 20引入了三个新的关键字co_awaitco_yieldco_return,一个C++函数中如果有其中任意一个关键字,就会将其视为一个协程函数。

对于一个协程函数,需要返回一个协程对象,我们可以自定义一个协程对象类,一个协程对象类通常需要内嵌一个promise_type的结构定义,也可以通过coroutine_traits<T>的特化来定义promise_type的类型。
在一个promise_type中,需要实现一定的函数,于是一个协程类大概长这样:

class MyCoroutine {
public:
    struct promise_type {
        MyCotoutine get_return_object() {
            return MyCoroutine();
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    }; 
};

为了使用这个协程,我们可以编写一个协程函数并执行它:

MyCoroutine func() {
    std::cout << "Hello World!" << std::endl;
    co_return;
}

int main() {
    MyCoroutine coroutine = func();
    return 0;
}

但是,如果运行这段程序,会发现并没有任何输出,这是因为这个协程在刚开始运行时就被挂起了,而且我们的协程对象并没有任何控制协程的功能。

为此,我们为协程类添加对协程的控制方法:

class MyCoroutine {
public:
    MyCoroutine(std::coroutine_handle<promise_type> handle) : _handle(handle) {}
    struct promise_type {
        MyCoroutine get_return_object() {
            return MyCoroutine(std::coroutine_handle<promise_type>::from_promise(*this));
        }
        // 省略其他方法
    }
    void resume() { handle.resume(); }
private:
    std::coroutine_handle<promise_type> _handle;
};

std::coroutine_handle模板类中封装了一个指向协程帧coroutine_frame的指针,并提供了对协程进行控制的函数。
由于我们添加了resume函数,于是我们便可以在主函数中恢复对协程的执行:

int main() {
    MyCoroutine coroutine = func();
    coroutine.resume();
    return 0;
}

于是这个协程函数便可以被正常运行,并获得输出:

Hello World!

为协程函数添加返回值

如果需要在一个协程函数中返回值,则需要进一步修改。例如,让一个协程函数返回一个整数:

MyCoroutine func() {
    co_return 1;
}

于是,我们的协程对象类就不能是return_void了,而应该使用return_value并返回一定的值,并将resume改为get并通过promise同步获取结果:

class MyCoroutine {
public:
    struct promise_type {
        void return_value(int value) { _value = value; }
        // 省略其他方法
    };
    int get() {
        handle.resume();
        return handle.promise().get();
    }
private:
    int _value;
};

接着,我们就能通过get同步获取协程的结果了:

int main() {
    MyCoroutine coroutine = func();
    int result = coroutine.get();
    std::cout << result << std::endl;
    return 0;
}

为协程函数添加 co_yield

co_yield有点像co_return,不过它的作用是向调用者返回一个值,并将协程挂起。

如果为一个协程函数添加了co_yield,则需要在协程对象类中的promise_type添加yield_value函数:

class MyCoroutine {
public:
    struct promise_type {
        auto yield_value(int value) { 
            _value = value;
            return std::suspend_always{};
        }
        // 省略其他方法
    };
    operator bool() {
        return !handle.done();
    }
private:
    int _value;
};

yield_value需要返回一个可等待对象awaitable,并使用co_wait对这个对象进行等待;而return_value则是协程的最后一次执行,不会再挂起。

于是,我们就可以这样设计一个协程函数:

MyCoroutine func() {
    for (int i = 0; i < 5; ++i) {
        co_yield i * i;
    }
    co_return 0;
}

由于协程对象重构了bool运算符,于是我们可以在一个while循环中方便地判断是否还可以yield一个值:

int main() {
    MyCoroutine coroutine = func();
    while (coroutine) {
        std::cout << coroutine.get() << std::endl;
    }
    return 0;
}

输出:

0
1
4
9
16
0

协程可等待对象

我们通过co_await 表达式来等待一个可等待对象,这个表达式的结果可以直接是一个可等待对象awaitable,也可以是某个重载了operator co_await()并返回了awaitable的对象。

一个可等待对象需要包括以下三个成员函数:

struct awaitable {
    bool await_ready();
    void await_suspend(std::coroutine_handle<> h);
    auto await_resume();
};

对于std::suspend_alwaysstd::suspend_never,它们大概是这样:

struct suspend_always {
    constexpr bool await_ready() const noexcept { return false; }
    constexpr void await_suspend(std::coroutine_handle<>) const noexcept {};
    constexpr auto await_resume() const noexcept {};
};
struct suspend_never {
    constexpr bool await_ready() const noexcept { return true; }
    constexpr void await_suspend(std::coroutine_handle<>) const noexcept {};
    constexpr auto await_resume() const noexcept {};
};

如果await_ready的返回值为false时,会执行await_suspend中的内容并挂起这个协程,当这个协程被继续执行时,会执行await_resume
如果await_ready的返回值为true时,则会直接执行await_resume

难用的协程

由上面的示例可见,C++的协程使用起来非常复杂,不像 python 或 go 那样方便和直观。

不过,C++这样提供极其原始的协程,有利于更灵活地根据需求定制你的协程。你可以自行制作一个协程库,将其变得方便使用,并加入更多灵活的操作。

目前(这个章节编写的时间),C++标准库中还缺少对协程的高级封装,所以用起来比较麻烦,如果不想自己手搓协程库的话,可以考虑使用一些第三方协程库。

指令级并行

什么是指令级并行

其实,我们前面提到的指令流水线技术、指令乱序执行中,就是指令级并行了。

所谓指令级并行,就是指在同一时间内执行多条指令的技术。

对于传统的处理器对数据的处理,一条指令可以访问并修改一个变量。我们可以通过多线程的方式,使用处理器的多个核心同时处理多个变量。不过,多线程通常需要考虑同步问题,且这些任务的完成时间是不一定的,取决于操作系统的调度算法。

不过,能不能这样,对于处理器的一个核心,同时设计多个加法运算单元,并与几个寄存器相连。使用一条指令同时将寄存器的值输入到加法运算单元中,并将结果输送到相应的寄存器中,这样不就能只用一条指令完成多个加法运算了吗?
当然可以,这种技术被称为单指令流多数据流(Single Instruction Multiple Data, SIMD)。

而同时处理多个数据实际上是很常见的,例如图形处理、科学计算、加密和视频处理等常常用到向量运算,向量的加法便是需要同时对多个值进行相加。

SIMD通常是通过单个CPU核心的硬件来实现的,而不是通过多个核心的并行处理。具体来说,SIMD通过向量处理单元(如加法器阵列)在一个时钟周期内同时对多个数据进行处理,从而提升数据处理效率。
于是,利用SIMD,我们便可以并行地让处理器同时操作多个元素,从而提高计算的性能。

OpenMP

OpenMP是一个常用的并行算法库,不过大部分编译器都内置了这个库,于是你大概率无需自行下载并安装它。

例如,$\pi$的计算公式
$$
\begin{equation}
\pi = \sum_{i=1}^n \cfrac{4}{1 + [(i+0.5) \times \Delta x]^2} \times \Delta x
\end{equation}
$$
这个计算公式常用于并行计算的示例。其代码如下:

double compute_pi(const long num_steps) {
    double step = 1.0 / num_steps;
    double sum = 0.0;
#pragma omp parallel for reduction(+ : sum)
    for (long i = 0; i < num_steprs; ++i) {
        double x = (i + 0.5) * step;
        sum += 4.0 / (1.0 + x * x);
    }
    return sum * step;
}

这段代码中使用了一个预处理指令#pragma,其中omp代表OpenMP,parallel for表示将for中的每次循环转化为并行计算,reduction(+ : sum)表示用于reduce计算的变量和运算符。reduce计算是指将并行计算的多个结果进行累计。这里的+表示reduce是加法算法,sum为共享变量,为加操作需要累计的结果。

为了让OpenMP生效,需要添加一个编译参数,例如在gcc中需要添加-fopenmp

除此之外,OpenMP还有很多指令和功能,且支持多种编程语言,感兴趣的可以自行学习。

STL 并行算法库

C++ 17增加了一些并行算法库,可以更简单地实现并行计算。

使用这些并行计算函数时,需要指定执行策略,这些策略有:

  • std::execution::seq: 顺序执行
  • std::execution::par: 多线程并行
  • std::execution::par_unseq: 多线程+向量化(例如利用SIMD)

例如,使用不同策略的std::for_each并输出值:

auto print = [](int v) { std::cout << v << std::endl; };
std::vector<int> vec{1, 2, 3, 4, 5};

std::cout << "sequenced policy: " << std::endl;
std::for_each(std::execution::seq, vec.begin(), vec.end(), print);
std::cout << "parallel policy: " << std::endl;
std::for_each(std::execution::par, vec.begin(), vec.end(), print);
std::cout << "parallel unsequenced policy: " << std::endl;
std::for_each(std::execution::par_seq, vec.begin(), vec.end(), print);

输出:

sequenced policy:
1
2
3
4
5
parallel policy:
1
234

5
parallel unsequenced policy:
1
345

2

由于没有对共享对象cout采取任何同步措施,于是可以看到并行模式下输出是乱掉的

如果需要在并行算法中访问共享资源,需要自行负责线程的同步。

CUDA编程

为了应对一些更大型的密集计算型任务,我们可以基于CUDA编程利用GPU的并行计算能力来加快运算速度。

例如,要实现一个很大的向量或矩阵的乘法,如果采用传统的CPU顺序单指令单数据模式,则一次矩阵乘法需要大量的CPU指令才能完成(回忆一下矩阵乘法,并想象一下两个32x32的矩阵乘法的计算量)。

但是,一个GPU会有大量的核心和矩阵运算阵列电路,能够在极短时间内完成一个矩阵的乘法运算。

于是,像大型3D游戏、图像视频处理、机器学习等需要大量使用矩阵运算的场景,将这样的运算交给GPU处理将会极大地节省时间。

CUDA是建立在NVIDIA推出的一个通用并行计算平台和编程模型,不过,CUDA编程的内容繁多,本文只给出一个引子,就不展开讲了,感兴趣的可以自行学习。

0x0C 文件操作

fstream

filesystem

C++在很长一段时间中都没有标准方法实现灵活的文件操作,直到C++ 17,才将 Boost 的 filesystem 引入到C++标准中。

参考资料

[1] Lippman, S. B., Lajoie, J., & Moo, B. E. (2013). C++ Primer (5th ed.). Objectwrite Inc. ISBN 978-0-321-71411-4.
[2] Meyers, S. (2005). Effective C++ (3rd ed.). Pearson Education Inc. ISBN 978-7-121-12332-0.
[3] Meyers, S. (1996). More Effective C++. Addison-Wesley. ISBN 978-0-201-63371-9.
[4] Meyers, S. (2015). Effective Modern C++. O’Reilly Media. ISBN 978-1-491-90399-5.
[5] Lippman, S. B. (1996). Inside the C++ Object Model. Addison-Wesley Professional. ISBN 978-0-201-83454-3.


[C++进阶]C++有趣细节与现代C++
https://blog.lyzen.cn/2022/08/16/CppAdvanced-Fundamental/
Author
Lyzen
Posted on
August 16, 2022
Licensed under