[C++进阶]C++细节与现代C++之-基础篇
目录
0x00 前言
这篇文章适合那些已经掌握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;
}
你可能不知道的作用域
我们都知道,一个局部变量只在当前块作用域内有效,在当前块作用域内嵌套的其他块中也有效。允许在嵌套的内作用域中重新定义外作用域已有的名字:
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可以为模板元编程提供更灵活的别名设计。
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
这个标准输出流以外,还有cerr
和clog
这两个标准错误流,用来输出错误或警告信息。
cerr
与clog
的区别是,cerr
不经过缓冲区,会直接写入到输出设备上。这是为了在紧急情况下(比如内存已满,没空间用来做缓冲区了,或者程序崩溃时),提供一个输出信息的可能性。
输出流的重定向
cout
、cerr
、clog
都可以被重定向,比如将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便会运行其析构函数,此时out
如果需要让“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进制前缀时0x
变0X
,输出科学记数法时e
变E
。默认情况为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.234
,12.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
设为科学计数法输出。
对于布尔值,默认情况下输出true
和false
时会显示为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的错误并清空数据流,前面已经提到过了,这里不再复述。
为什么要先声明函数再定义?为什么要写一个头文件用来存放声明?
我们在定义函数时,一般要先在前面声明函数,然后再定义函数,如:
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,而引入者并不知道引入了一个命名空间,从而导致可能的命名冲突。
调试用宏
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;
}
编译和链接
编译和链接是大家一定会用到但很少重视的步骤,这是因为大部分的集成开发环境(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.o
和main.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的详细内容,可以查阅:
- GNU make
- GNU make 中译版 由ZS_Wang_Blogs翻译。
- 跟我一起写Makefile - 陈皓 该链接为coofucoo整理的目录。
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
跨平台开发
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;
这段代码中,b
与a
有关系,c
与a
有关系,e
与d
有关系。由于a
,b
,c
这一组变量与d
,e
这一组变量没有关系,于是在一些CPU可并行执行指令的平台下,编译器可能会进行指令级的优化,使这两组指令并行执行,于是本来需要5个指令周期的这段代码可以缩减到3个指令周期完成。
有些时候,我们希望利用这样的优化,以提高我们代码的性能。
但是,这样的优化有时候可能并不是我们想要的,例如在异步或多线程情况下,这些变量可能被其他代码段共享,于是这种优化可能带来线程安全问题。
这也是为什么,有时候你能在一些底层代码发现一些莫名其妙的写法:
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需要丢弃错误分支上的计算结果,并重新执行正确的分支。而且,这个特性还能用来恶意攻击,如幽灵和熔断漏洞,感兴趣的可以自行上网搜索。
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等)的程序可以互相调用,从而实现跨语言协作。
养成良好的编程习惯
我们为什么需要编码规范
对于项目而言,通常维护一个项目的成本远大于开发它的成本,特别是对于一个大型项目与团队项目而言。
编码规范确保所有团队成员在编写代码时遵循一致的风格,减少由于个人风格差异带来的混乱。这有助于其他开发人员(包括未来的开发人员)快速理解代码,从而减少理解上的障碍。而且,可读性高的代码更容易进行修改和扩展。规范化的代码可以帮助开发者快速找到问题并有效地修改和改进代码,减少了意外错误的发生。
良好的编程习惯不应该因为功能简单就可以暂时放飞自我,随着一个项目的不断发展和迭代,即便是一个最简单的模块也可能因不断增加的需求变得越来越复杂,从而难以维护和增加新功能。
良好的编程习惯不只是让代码变得好看和提高效率那么简单。许多编码规范不仅仅关心代码的格式,也关注代码的结构和逻辑。这些规范有助于开发者避免常见的编码错误,减少潜在安全漏洞,增强安全性。
例如,许多函数即便功能很简单,却用了更多的代码只是用来检查输入参数的合法性,而这些检查是有必要的,特别是当输入是由用户提供的时候。忽视输入合法性的检查可能导致许多错误甚至程序崩溃,即便是一个很小的错误也可能引起重大损失,例如: b站 2021.07.13 崩溃原因
常见编码规范
不同的项目可能有不同的规范要求,需要根据实际情况选用,如果只是个人项目,可以选择一个自己喜欢的,但不应该在同一个项目中混用不同的规范。
命名
- 命名选用: 少用缩写,除非一些特定的广为人知的缩写,避免单字母命名、
temp
、拼音命名等 - 文件命名: 文件名全部要小写,使用下划线
_
分隔单词,尽量让文件名更明确,比如http_server_logs
比logs
更明确 - 变量命名: (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代表什么,应该使用变量代替。为了避免这样做影响性能,可以将该变量设置为inline
、constexpr
等:
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的占用,转而让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 long
是LL
,而不是L
,java程序员请小心不要弄混。
对于浮点类型,大部分情况下double就已经够用了。long double也是一个不稳定的类型,可能是10字节,可能是12字节,也有可能是16字节,用它来计算开销也会比较大。float的精度太小,不建议使用,而且目前的计算机大部分都对双精度浮点数的计算做了优化,运算速度甚至比单精度还快。
long long是C++11标准新增的,long double是C++99标准新增的。
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_t
、uint16_t
、int32_t
、uint32_t
、int64_t
、uint64_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;
};
左值与右值
左值可看作内存中有明确地址的数据,它是可寻址的。
右值是可提供数据值的数据,不一定可寻址。
比如以下代码
int x = 1;
x是左值,它是可寻址的,生命周期较长,我们可以对它进行其他操作;1是右值,它是一个临时的常量,不可寻址,生命周期较短。
左值可以作为右值使用,如:
int a, b;
a = b = 1;
上面的b = 1
中,1是右值,b是左值,而在a = b
中,b又作为了右值,此时a为左值。
左值引用和右值引用
左值引用(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
可见,因为str1
和str2
属于main
函数,在main
函数调用longer
函数,投入的引用str1
和str2
的生命周期不会因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”的生命周期,于是这个值可以重复利用,避免复制一份新的再销毁旧的。
查看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被销毁,于是变量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);
为了检查a
与b
的类型,可以使用<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。
内存对齐: 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了吗?
为此,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;
result
为0b00000101
,可以理解为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
的值不变。
若bitPattern
与mask
作运算,则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
,就会出现问题,因为它的前缀与用0b100
的c
重复了。当即将读入的数据为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)。由于负数是补码表示的,所以上述代码可以得出正确的结果,具体分析可以阅读计算机组成原理相关书籍。
只用位运算实现乘法
假设某处理器不支持乘法运算(现实存在这种处理器),也是可以通过加法实现乘法的。对于加法,上面已经提过如何使用位运算实现了。
一种思路是,乘法可以转化为多次相加,但是当乘数相当大时,就需要很多次循环,效率很低。
另一种思路是,回忆一下我们是怎么手算两个数乘法的,我们把被乘数每一位先依次与乘数的个位数相乘,注意进位,然后再将被乘数与乘数的十位数相乘,相乘的结果左移一位对齐,然后百位计算后再相对左移一位对齐、千位,以此类推。最后钭几个对齐后相加,即可得到乘法的结果。
上面的十进制相乘的方法需要记住九九乘法表,且需要处理相乘过程中的进位。好在在二进制时,位与位相乘只有1或0相乘,所以可以将按位相乘转化为与运算(只有两个都为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个字节的位置,也会出问题。
如何避免野指针的出现?
- 初始化指针。我们最好在指针被声明时就初始化它,或者初始化为nullptr。
- 注意不要让指针越界。
- 当指针指向的对象被释放时,将指针设为nullptr。
- 确保字符数组有”\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()];
这里通过变量size
和get_size()
获取数组的大小,而不是一个字面值,这样写是合法的吗?
实际上,数组的维度应该要在编译时是确定的,因此size
和get_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;
rarr
是arr
的引用,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提供了两个函数begin
和end
,可以获取指向数组第一个元素的指针和指向最后一个元素的下一个位置的指针:
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::array
或std::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型变量aint *a;
:int型指针变量aint a[5];
:长度为5的数组a,每个元素是指向int,即
但当变量类型复杂起来时,就没那么好认了:
int *a[5];
:长度为5的数组a,每个元素是指向int的指针,即
int (*a)[5];
:一个指针,指向长度为5的数组,数组的每个元素是指向int,即
这两个的区别,一个是指针的数组,另一个是数组的指针。如果我们对他们分别做自增操作,会怎么样呢?这个到下面再讲。
再来看点更复杂的:
int (*f[5])();
:长度为5的数组a,每个元素是指向函数的指针,函数返回值类型是int,即
int (*(*f)[5])();
:一个指针f,指向长度为5的数组,数组的每个元素是指向函数的指针,函数返回值类型为int,即
int* (*f[5])();
:长度为5的数组f,每个元素是指向函数的指针,函数返回值类型是int*,即
int* (*(*f)[5])();
:一个指针,指向长度为5的数组,数组的每个元素是指向函数的指针,函数返回值类型为int*,即
如果用上二级指针,情况可能就没那么好对付了。比如: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); // 合法
}
又或者,通过修改拷贝构造函数,当发生拷贝时,进行所有权转移,比如下面的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_ptr
与 weak_ptr
由于auto_ptr
在所有权转移、不支持数组、不适用于STL容器及缺乏移动语义等方面存在缺陷,在C++ 11标准中auto_ptr
被标记为了废弃,并在C++ 17标准中被移除。即便某些编译器为了兼容旧代码还支持auto_ptr
,在C++ 11之后你不应该继续使用auto_ptr
。
为此,C++ 11 提供了三种新的智能指针: unique_ptr
, shared_ptr
与 weak_ptr
。
如果你的程序必须使用C++ 11之前的版本,但想要使用C++ 11的智能指针,你可以选择使用Boost库的
scoped_ptr
、shared_ptr
和weak_ptr
。
Boost库社区建立的初衷之一就是为C++的标准化工作提供可供参考的实现,C++ 11提供的智能指针就是Boost中的智能指针实现的。
unique_ptr
unique_ptr
通过禁止拷贝的方式解决智能指针拷贝问题,禁止拷贝的方法我们已经在上面提到过了,这里就不再赘述。
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;
}
上述代码首先n1
和n2
两个指针创建时本身各有一次引用,而n1->next = n2
使用等号拷贝了一次指针,从而使n2
的引用加一,n2->prev = n1
同理。于是它们的引用数分别都为2。
而当n1
和n2
的生命周期结束时,两者的引用次数分别减一,但动态分配的两个Node
中各自引用了对方,n1
最后一次计数释放的条件是n2
解除对n1
的引用,而n2
最后一次计数释放的条件是n1
解除对n2
的引用,显然这造成了一个循环,导致两者均无法被释放。
为了防止引用循环导致指针无法被释放,C++提供了weak_ptr
用来指向一个shared_ptr
,weak_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
,它还需要维护一个引用计数值,于是其性能会比裸指针差一点。
类型转换: 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行的转换是错误的。即便编译器会通过编译,也可能导致未定义行为,引起程序崩溃。
当存在像上述Animal
、Dog
、Cat
这样有继承关系的情况,但又不清楚基类实际是哪个子类时,我们就可以使用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_cast
和dynamic_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_cast
和dynamic_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_cast
、std::dynamic_pointer_cast
和std::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
的地址与str2
、str3
的地址相差比较大,可推断出前者与后两者是在内存中的不同区域的。
char[]-base 字符串
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,否返回falses1 == s2
,s1 != s2
: 判断两字符串的内容是否一致,大小写敏感<
,<=
,>
,>=
: 利用字符在字典中的顺序进行比较,大小写敏感str[i]
: 返回str中第i个字符的引用,下标i从0开始,不会进行越界检查,直接返回数组中某位置的引用str.at(i)
: 同上,但会进行越界检查str.front()
: 返回str中第一个字符的引用str.back()
: 返回str中最后一个字符的引用
注意: 当使用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*
)类型。
使用 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”,此时如何将这些字符串转换为我们想要的数字呢?
有人可能会想,进行强制类型转换是否可行呢?实际上是不可行的。
std::stoi
std::stringstream
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会直接显示playername
和server
,而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
函数是指能用于常量表达式的函数,不过它的返回值类型是字面值类型,所有形参都是字面值类型或常量表达式,且函数体中必须有且只有一条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;
}
对于distanceSquare1
和distanceSquare2
,它们都是常量表达式的计算,能在编译阶段就得到结果。
对于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) {
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;
}
因为放左边时变量t
和v
还未定义,这也是为什么需要把decltype(t + v)
尾置。
含有可变形参的函数
省略符形参
省略符形参是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
,而第二和第三个参数1
和2
落入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
的定义,例如添加float
或long
:
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
时,两种方法投入的函数add
或multiply
均可加可不加取地址符&
。
Lambda 表达式
上面两种方法我们都需要定义函数add
和multiply
,或者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 表达式的格式是:[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
时且不捕获引用时,捕获的v
是const
属性,也就是说不能修改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);
,这给错误排查增加了一定的困难。
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;
}
0x07 类
定义一个类
我们常用class
关键字声明或定义一个类,实际上,struct
也能用来定义类。
二者的区别是,使用class
定义类时,成员的默认访问权限是private
;而使用struct
时,成员的默认访问权限是public
。
对于明确地、在类一开始就规定了public
、protected
或是private
的类来说,使用struct
或class
定义类是没有区别的。
public
、protected
或private
可以多次出现,其作用范围从它开始直到下一个权限关键字出现或类结束:
struct MyClass {
int a; // 默认权限,因为是 struct 所以是 public
private:
int b;
public:
int c;
private:
void func();
};
类对象的构造
默认构造函数与初始化列表
当一个类没有构造函数时,编译器会自动为其创建一个默认构造函数(不接受任何参数的构造函数),并为成员变量进行默认初始化。
当一个类有一个带参数的构造函数时,就不会有默认构造函数,这意味着不能使用没有参数的构造函数:
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;
};
警惕类的构造函数形成的的隐式转换
当我们声明一个类的构造函数时,如果这个构造函数只有一个实参,那么同时也会定义这个实参转换为这个类类型的隐式转换:
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; // 相当于使用等号赋给非引用而导致的拷贝 }
- 使用标准化容器时,使用不同的方法会导致不同的行为,例如
insert
、push
通常是拷贝初始化,而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
有两个动态分配内存的成员a
和b
。
对于非交换情况:
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
未被初始化,所以对b
的delete
不会有任何效果。
因此,对于交换版本来说,其内存安全性会更好。
描述类行为
行为像值的类
通过上面这些类的工具,我们可以设计我们的类使之像int
或std::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中提过了,这里不再赘述。
智能指针主要维护的是指针所指向的对象和一个引用计数。类似地,可以设计一个类,维护一个具体情景下的多个数据成员,当类对象发生拷贝时其数据成员采用浅拷贝,并通过引用计数来保证它们的正确释放。
类内动态分配内存的意义?一个使用案例
既然非动态分配的成员会自动在类生命周期结束时自动释放,那在类中动态分配内存有什么意义呢?
动态分配内存在类中的意义是,它使得你可以在运行时根据需要分配内存,而不仅仅是依赖编译时大小已知的静态内存。
这在一些特定情况下是非常有用的。具体来说,动态内存分配使得你能够处理大小不固定或不确定的对象,节省内存,或处理复杂的数据结构。
举一个具体的使用案例,假设我们需要实现一个大小可变的数组,由于这个数组的大小不是可以在编译时确定的,因此需要进行动态内存分配:
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
的值。
正因如此,mutable
和const
不能同时出现在成员变量的声明中。
返回新对象或拷贝,还是返回指针或引用?
在设计一个类的成员函数时,我们可能会需要考虑这样一个问题: 究竟应该返回一个什么样形式的对象?
例如,以下是一个三维向量类的设计(省略除叉乘外的成员函数):
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);
这里用户原本希望得到v1
与v2
叉乘的结果,则将结果赋给v3
,但是因为crossProduct
修改了v1
原本的内容,于是v1
与v3
实质上是相同的,但它们不是同一个对象(内存地址不同),因为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;
}
类的循环依赖与前向声明
有时候我们可能有两个类分别依赖对方,例如类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 Dog
继承了另一个类class Animal
,对于Java程序员来说可能写出这样的代码:
Animal animal;
animal = Dog();
可能原本的意图是,使用基类对象来抽象化地表示一个子类对象,但是实际上做的却令人大跌眼镜:
- 首先创建一个
Dog
类型的对象 - 调用
Dog
到Animal
的拷贝构造函数,因为没有这个拷贝构造函数,因此隐式转换成了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); // 未定义行为,可以通过编译,但可能引起程序崩溃
对于最后一行,由于animal
是Animal
类型的,实际上并没有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
,由于类型不同还会发生隐式转换,于是dog
与animal
是两个完全不同的对象。对二者其中之一的修改并不会影响到另外一个对象。
第四行则是拷贝赋值,类似于
float f = int(1);
由于animal
和animal2
被声明为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
(下面介绍)。
使用 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
。
存在默认参数的情况
当成员函数的形参存在默认实参时,其实际的默认值取决于本次调用的静态类型。
例如,通过基类的指针或引用调用函数,则采用基类中的默认值,即便实际对象是子类的对象:
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;
}
引用动态绑定
除了能使用指针对多态的对象进行动态绑定,引用也可以,例如:
Dog dog;
Animal &animal = dog;
相比于指针的情况,由于引用不能为空,无需对其进行是否为空的判断。并且,引用不会出现指针重定向的问题,因为一个引用不能重新绑定另一个对象。于是使用引用进行动态绑定会更加安全。
而指针则是更加灵活,因为确实有些情况下我们需要空指针来表示一定的逻辑,我们可能需要对空指针或非空指针分别做不同的处理,而不是简单的阻止空指针。
因此,当需要保证对象地址不为空的情况,可以选用引用;而当可能出现空指针的情况则可以选用指针。
访问控制
0x08 标准库容器
数组很难用?想要更复杂的容器?C++标准库提供了许多不错的选择。
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
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 std::string &s) : _s(s) {}
MyClass(std::string &&s) noexcept : _s(s) {} // 移动构造
private:
std::string _s;
};
int main() {
std::vector<MyClass> vec;
MyClass obj("abc");
vec.push_back(obj); // 复制构造
vec.push_back(MyClass("def")); // 移动构造
return 0;
}
如果传入的右值对象的移动构造函数没有noexcept
,则执行push_back
时标准库容器仍然会选择拷贝构造。这是因为,如果在执行移动构造时抛出异常,可能会导致容器的状态不一致,特别是容器扩容时,容器内部的元素可能已经发生了移动,导致丢失部分数据。
不过有时候,我们的这个对象需要提前构造,如果可以放弃对象的所有权,则可以使用std::move
:
void addToVector(std::vector<MyClass> &vec) {
MyClass obj("ghi");
// 毕竟 obj 出了这个函数作用域就被销毁了
// 与其使用拷贝让更长生命周期的新对象加入到容器
// 不如放弃临时变量的所有权并使用移动来延长生命周期
vec.push_back(std::move(obj));
}
此外,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
使用迭代器(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>::iterator
和vector<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添加元素。
0x09 内存管理
C语言内存管理
malloc 与 new
memset
memcpy
memmove
资源获取即初始化(RAII)
std::allocator
0x0A 异常
参考资料
[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.