[C++进阶]容易被忽视的细节之-基础篇
目录
0x00 前言
由于是进阶内容,默认读者已经学会了C++的基础知识。
这篇文章是写给想要深入了解C++或是在学习C++时对某些东西有疑惑的人,这些知识点可能并不实用或过于深入,至少不适合用于期末考试,因此可以选取感兴趣的部分阅读。
虽然叫基础篇,但这个所谓基础是相对于例如C++模板元编程高级技巧、深入了解C++对象模型等内容而言的。本文章主要对C++的基础知识进行适当的深入探究。
由于内容都相对比较新,如果先前学习的C++教材比较老旧的话,这篇文章可能大部分内容读者都没见过,且许多章节可能学习起来会有困难。
此外,本文章不适合有C基础但没有C++基础的人学习。
由于C++的细节过于繁多,且想要深入几乎可以无限深入,所以部分章节只给出简要说明,对于想要进一步深入的知识点可以自行上网搜索。
为了便于理解,某些表述上不会太严谨或不太专业。
0x01 从头开始
在初学C++时,由于学到的东西还少,所以写的一些东西暂时视作固定写法。这里回过头来看看初学内容的细节。
命名空间
标准命名空间
在初学C++时,我们在文件的顶部加上这些代码:
这样写会导入std中的所有内容,但如果我们只用std里面的一部分内容,又担心命名冲突,此时我们可以去掉using namespace std;
,改用std::xxx
的形式来使用std中的内容:
命名空间的存在是为了避免不经意间的命名冲突,如果感觉这样写很麻烦,而且只需用到std的一部分内容(比如cout和endl),可以这样写:
到了C++17,两个using可改为using std::cout, std::endl;
。
此外,我们应避免在头文件中使用using,因为当这个头文件被其他文件引入时,也会引入这个using,而引入者并不知道引入了一个命名空间,从而导致可能的命名冲突。
对于一个大型项目的某个大的代码文件,应当避免导入一个或多个命名空间的所有内容,因为这样可能会引起命名冲突,导致一些令人头疼的bug。
为了代码简洁,本文章的示例代码可能会省略include和using。对于部分代码片段,会省略主函数,因此如果需要执行这些代码片段需要将其放在主函数中才能执行。
你可能不知道的命名空间
using
并不是只能在全局范围内使用:
于是当你只需在一个函数中使用大量的cout但又不想写std::cout
又担心全局引入容易导致代码的其他地方出现命名冲突时,这是一个不错的选择。
命名空间可以嵌套定义:
命名空间可以起别名
(这是C++11后的特性)
你可能不知道的作用域
我们都知道,一个局部变量只在当前块作用域内有效,在当前块作用域内嵌套的其他块中也有效。允许在嵌套的内作用域中重新定义外作用域已有的名字:
当然,不建议这样给变量起名,因为这样容易造成混乱,导致代码维护困难。
using的其他作用
C++11后,using可以用来代替typedef给某个类型起别名:
对于函数式编程,定义一个可以指向某一类函数的函数指针:
这段代码指的是,变量f是指向某一类返回类型为double,有两个类型为double的形参的函数指针。
为了能做到符合直觉的func f;
,func为函数,f为变量名,使用typedef时:
使用using时:
相比于使用typedef时函数名放在了奇怪的位置,使用using时符合关键字 变量名 = 值
的格式,更符合直觉。当然直觉因人而异,你可以选择合适的方式。
这一部分也有很多坑,比如漏括号、漏星号等,都会有不同的效果,都会让你在debug时看着谜语人般的报错时叫苦连天。感兴趣的可以阅读我的另一篇文章:你真的会声明C++的函数吗?。
using和typedef都不会创建新的数据类型,他们仅创建现有类型的同义词(synonyms),或者说给现有类型“起别名”。
using和typedef的区别是,typedef不能给模板类起别名,而using可以。
标准IO的缓冲区
在写下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
重定向到文件:
当然,你也可以直接写入文件而不重定向:
提示:如果使用IDE中的“运行”按钮,你可能找不到test.txt这个文件在哪里,其实它会生成在当前工作目录下,不一定是当前代码的目录,一般是可执行文件(如.exe文件)所在目录,可以在项目的可执行文件构建目标目录中找到它。
重定向是有作用范围限制的,比如:
运行以上代码,你会发现test.txt
中只有“1”没有“2”。
这是因为,out的生命周期仅限于if中,当if语句块运行结束时,out便会运行其构析函数,此时out
如果需要让“2”也能输出到文件里,需要把out写在if的外面:
那么你可能会想,如果out是动态分配的,那么重定向后是否会一直有效,直到另一次重定向或out被手动结束生命周期?
运行这段代码,可见“1”和“2”都被写入到了文件中,但“3”并没有输出到控制台中,而是报错了。
这是因为释放out后,它的文件流对象已被销毁,而标准输出流尝试使用这个已被销毁的文件流,导致了未定义行为的发生。
所以,如果是重定向到动态分配的out,在out释放后应恢复或重定向标准输出流。
重定向之后如何恢复呢:
运行以上代码,可以发现out.txt
中有test1
,屏幕上输出test2
。
如果既要输出到屏幕,又要写入文件中呢?比如想要在控制台中输出信息以便监控,同时生成对应的日志文件。这其实算是两次输出了。虽然你可以自定义一个cout来实现它,但不建议再折腾cout了。你可以自己写一个日志框架,或者使用第三方日志框架,比如log4cpp。
cout中也许有用的内容
我们可以修改输出整数的形式:以十六进制(hex)、十进制(dec)、八进制(oct)形式输出。
比如十六进制:
在此之后输出的整数就是十六进制形式了。同理,把上面的hex改为dec、oct就可以改为十进制、八进制形式。
对于整数,可以使用std::showbase
来显示进制的前缀,使用std::noshowbase
不显示(默认),如:
输出:0xf, 010
使用std::uppercase
,可以在输出16进制时a-f
变为A-F
,输出16进制前缀时0x
变0X
,输出科学记数法时e
变E
。默认情况为std::nouppercase
。
对于浮点数,需要控制输出的精度(包括整数位),可以使用std::setprecision(n)
,需要引入头文件iomanip
,如:
输出:3.14
这个包括整数位是什么意思呢?比如setprecision(4)
,那么1.23456
-> 1.234
,12.3456
-> 12.34
。
也可以使用std::cout.precision(n)
来设置精度。精度默认为6。
对于浮点数,如果刚好是整数,又想输出后面的.0
时,可以使用std::showpoint
:
输出:4, 4.0
对于浮点数,可以使用std::fixed
设置小数部分自动补0,直到符合精度:
输出: 3.1400
想要取消掉std::fixed
等设定,可以使用std::cout.unsetf(s);
。
对于浮点数,使用std::scientific
设为科学计数法输出。
对于布尔值,默认情况下输出true
和false
时会显示为1和0。为了能够输出true
或者false
,你可以使用std::boolalpha
:
如果需要输出指定长度的内容,不足时补齐,可以引入头文件iomanip
并使用std::setfill(char)
和std::setw(int)
,其中setw
是暂时性的,只会对下一次输出起作用:
输出:
–123
456
这个特性可以用来制表,用于在输出时对齐数据。
标准输入流
用户输入错误数据
我们常用标准输入流cin
来让用户输入数字,一般都是这样写:
如果用户输入的不是数字呢?
如果输入的是abc
,得到的是0
。
如果输入的是123abc
,得到的是123
。
我们可以这样判断输入的是否是数字:
不过,如果输入的是123abc
,那么输出会是输入了123
。
如果程序中有多个cin,当输入的数据错误时(比如上面的应输入整数却输入了字符串,或是输入的整数超过int最大限制等),会导致后面的cin都失效:
运行以上代码,并输入abc
并回车,会发现并没有让我们第二次输入,且输出结果是:
请输入a:abc
请输入b:
0
0
这是因为错误的输入让cin处于错误状态,必须消除它的错误状态才行:
std::cin
重载了运算符!
,即!std::cin
返回std::cin.fail()
。
于是你便可以在用户输入错误时不断让用户输入新数据:
单字符读取和getline
如果用户输入的不止一个字符,比如abcd
,会是怎样呢?
答案是用户只需输入一次,输出为:
a
b
cin中有一个储存字节的流,类似于缓冲区,每当给字符赋值时,会从中拿出最前面的一个字符,直到它被清空。在它被清空之前,不会再让用户输入。
我们同样可以使用std::cin.get()
来从中读取字符,即上述代码可改为:
利用这个特性,我们可以写一个求和程序:
输入: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')
可以获取一行文本(字符串):
该函数的三个参数分别是:需要赋值的字符串对应的字符指针,字符串长度,结束标识符(默认为换行\n
)。
同样,在cin中的数据被清空之前,不会再让用户输入:
比如将小写字母z
作为结束符:
输入:abcxdefyghi
输出:
abc
efh
clear()和sync()
std::cin.clear()
第一眼看起来像是清空cin的数据流,但实际上并不是,它的作用是去除cin中的错误。
输入:123a456
输出:a456
如果去掉上上述代码中的std::cin.clear()
,再输入123a456
,将不会有任何输出。
std::cin.sync()
的作用才是清空数据流。clear()
和sync()
一起使用,就可以去除cin的错误并清空数据流,前面已经提到过了,这里不再复述。
为什么要先声明函数再定义?为什么要写一个头文件用来存放声明?
我们在定义函数时,一般要先在前面声明函数,然后再定义函数,如:
这是因为,函数的作用域是从它被定义时开始的,虽然你也可以把定义写到前面去,但是这样不太美观。为了能把主函数放到上面,因此先声明,然后是主函数,然后再定义前面声明的函数。
另外,如果函数间存在互相调用,比如:
那么无论哪个在前哪个在后,上述代码都会报错。因此先声明再定义,可以让你免去函数互相调用时调整函数位置的痛苦。
另外,当多个文件需要调用某个函数时,在每一个文件都定义一遍显然是不智慧的选择,为了能把函数的定义只写在一个文件里,你可以在调用它的地方声明这个函数就行了:
当这样做还是有点小问题,因为它并不能直接看出func是哪来的。为了能做到“导入一个模块”的效果,我们可以把func写到头文件中,然后在需要用到它的地方包含这个头文件:
而#include的作用,是将被include的文件中的所有内容复制到该文件中。
先声明再定义还有一个好处是,对于一个大型合作项目,你可以先声明一系列函数,然后用注释标注这些函数有什么用,接着你就可以以整体的思想去构思整个项目,而不必先一个个实现他们,或者将这个头文件交给合作者,让他们去编写函数的实现,而你就可以直接调用这些函数来实现具体的内容了,即便现在他们还没有定义。比如,你想编写一个把大象装进冰箱的程序,需要三步:打开冰箱门,把大象放进冰箱,关闭冰箱门。于是需要三个函数,你可以先声明这些函数,然后调用这三个函数,完成整体的内容而不必关心这三个操作是怎么实现的。然后再编写细节,即这三个函数的具体实现。
对于某些企业的项目或者一些商业项目,需要提供一些函数。但这些函数可能涉及到机密,不能直接把这些函数的具体实现给到对方,此时你就可以只把这些函数的声明写在头文件中,然后把头文件给对方,这样对方就可以知道有哪些函数供他们使用,但无法看到函数的具体实现。但只给头文件无法运行测试,则你可以给对方提供编译后的库文件,可以使用一些工具对其进行混淆以增加反编译难度,并以动态链接的方式链接与执行,在后面的章节会介绍动态链接的方法。
避免头文件被重复包含
看这个例子:
h1.h
:
h2.h
:
main.cpp
:
main.cpp中需要用到h1和h2两个头文件,但编译时报错:void func()
重复定义。
这是因为h2中已经包含了头文件h1了,所以在main.cpp中同时包含h1和h2势必造成h1被重复包含。
为了防止这一问题的发生,可以在头文件中加上:
或
第一个方法是标准做法,可以兼容任何平台,而第二种做法不受一些老编译器支持,但对于大型项目,它不会像第一种方法那样因为要处理宏定义而降低编译速度。
此外,我们应避免在头文件中使用using,因为当这个头文件被其他文件引入时,也会引入这个using,而引入者并不知道引入了一个命名空间,从而导致可能的命名冲突。
编译和链接
编译和链接是大家一定会用到但很少重视的步骤,这是因为大部分的集成开发环境(IDE)已经把它们封装好了,帮我们隐藏掉了这些细节。如果离开IDE,要怎么对代码进行编译呢?
这里以Linux环境为例,测试环境为ubuntu 20.04,编译器为MinGW 9.3.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的格式是:
前面例子的makefile就是:
执行指令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
文件,都需要在目标文件后面的所需文件和指令中写两遍,麻烦且容易出错,此时我们就可以使用变量:
实际上,gnu的make还可以自动推导,如果没有特殊需求,make可以推导出指令,而且,假设需要构建main.o,它也会把main.cpp自动加入依赖。假设main还需要abc.h和xyz.h,那么makefile就可以简化成这样:
没错,main.o不需要加上main.cpp,而且lib.o的依赖干脆就不需要写了。
甚至你还能:
当然,奇技淫巧是要慎用的,上面的代码很偷懒,但容易出错误,而且也不容易看清依赖关系。
另外,每一个makefile都建议加一个clean用来清理.o文件和构建出的可执行文件,而且最好放在最后面,以便清理并重新构建。而且,clean建议这样写:
.PHONY
表示clean是一个“伪目标”,因为clean没有依赖文件,而且也不需要生成一个文件clean。rm $(objects)
前还加了一个减号-
表示忽略某些文件的错误。
使用变量,你还可以:
makefile实际上像是一个脚本语言,它可以做很多复杂的事情。除了最基本的构建,你还能实现提交代码、备份代码、自动化部署程序等功能。
如果你还想知道makefile的详细内容,可以查阅:
- GNU make
- GNU make 中译版 由ZS_Wang_Blogs翻译。
- 跟我一起写Makefile - 陈皓 该链接为coofucoo整理的目录。
CMake
CMake是一个开源的,跨平台的构建工具。大部分IDE都支持CMake。
首先需要创建一个CMakeLists.txt,对于一个最简单的项目,CMakeList只需三行:
第1行指定了所需的CMake最低版本,第2行指定项目的名称,第三行指定可执行文件的名称和它由那些文件编译而成。
在项目所在目录下创建新目录build,并把cmake生成的文件放在这里:
运行以上指令后,便会在build下生成相关文件和Makefile,接着只需输入make
并回车,即可生成可执行文件target。
对于多文件工程,则需修改第3行代码,如:
一个个添加太麻烦了,那么我们可以:
${CMAKE_PROJECT_NAME}
指的是project()
中定义的项目名,我们通过file()
将项目目录下所有以.h
和.cpp
结尾的文件都添加到变量SRC_FILES
中,并在add_executable()
使用它。
我们也可以使用set()
来设置cmake或自定义的变量:
第一行表示构建用的C++的版本为C++11,第二行是我们的自定义变量。
当我们使用了第三方库时,需要链接库:
你还可以使用CMake生成Doxygen文档、自动化测试、自动部署等,甚至能当成一个编程语言使用,知乎上甚至有人用它写了个光追。
CMake建议边用边学,因为你很难不结合项目一起使用。这里列举几个学习资源:
静态链接与动态链接
静态链接
以前面多文件编译为例,有两个文件:
分别编译它们,生成main.o和lib.o两个文件,在main.o中,函数add的地址会暂时设定为0,在与lib.o链接的过程再把它重定位到add的正确地址。
强引用与弱引用
上面的代码中,add是一个强引用。如果链接过程中add没有被定义,则链接会报错。
我们也可以创建一个弱引用,它与强引用类似,但如果它没有被定义,则链接不会报错。
attribute并不是只能放在开头:
在现代编译器中,使用weakref还需要指定别名:
如果函数未被定义,那么它的地址将会是0,我们可以这样判断函数是否被定义了:
这样一来,只有该函数被定义了,才会执行它。
变量也可以是弱引用:
不过要注意的是,__attribute__
是给编译器看的,像#define那样。且并非所有编译器都支持weakref的特性。
弱引用可以给程序模块化提供方便。我们可以将扩展模块设定为弱引用,当扩展模块和主程序链接在一起时,就可以用到扩展模块的内容。如果没有扩展模块,就只使用主程序的部分功能。
动态链接
很多程序都需要用到iostream等内容,如果每个程序都把它们的实现代码都打包到最终的可执行文件中,就会导致每个程序都有一段重复的内容,势必会造成空间的浪费。
同样,我们在使用或给其他用户提供库时(特别是很大的库),也不希望它们被重复打包。如果能把它分离开来,保存成一个单独的文件,让每一个要用到的程序都去共享这个文件,不就可以避免重复了吗?
动态链接库很好的解决了这个问题。除了节省硬盘空间,由于它可以被多个程序共享,所以只需读取一份到内存中,减少了内存的浪费。因为动态链接库是一个单独的文件。所以我们也可以很方便地安装和更新他们。假设你发行了一个1GB甚至更大的程序,当程序需要更新时,也可以只下载被修改的动态链接库文件,避免了重新下载整个程序。另外,你也可以利用动态链接库的特性让你的程序支持插件功能。
动态链接库一般在Windows下是.dll后缀,在Linux下是.so后缀。
Windows下,使用动态链接库时,会先在当前目录下寻找,然后再搜索Windows/System和Windows/System32目录。
而Linux是,先搜索/lib、/lib64等,然后再搜索/usr/lib等,最后再搜索ld.so.conf配置下的路径。默认情况下不会搜索当前路径。
为了解决这个问题,你可以把库拷贝到/lib下,或者修改ld.so.conf的配置,再或者临时指定:
第一个”$”指命令提示符。该命令只在当前terminal的会话中有效。
创建一个简单的动态链接库并调用(Linux环境):
编译和链接:
0x02 变量
基本数据类型
变量的声明类型
我们可以用一个类型声明多个变量,比如:
这里,基本数据类型是int。
当我们需要指针时:
这里的pa就是int型指针。但你可能会把b也错看成一个int指针,但实际上b只是一个int。pa和b的基本数据类型都是int,*只修饰pa。这也是为什么声明指针型变量时常常把星号和变量名连接在一起。同时声明两个指针的写法是:
尝试解释以下变量的含义:
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。
对于字面常量,你可以用字母标注:
既可以用小写字母,也可以用大写字母,但不建议使用小写字母“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++是弱类型语言,因此以下代码是合法的:
当然,为了让这个类型好看一些,你可以使用typedef:
或者使用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。比如:
以上代码将会造成无限循环。
尽管我们不会故意给无符号赋负值,但我们依然可能在无意中写出这样的代码:
输出: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字符集中的内容。
由于char只能表示8bits的内容,所以如果我们想表示中文或其他不在ASCII范围内符号时,char就无能为力了。
wchar_t用来表示宽字符,但一般Windows普通下它是16位(2字节),Linux下是32位(4字节),是不稳定的。
用wchar_t
来表示宽字符或字符串时,需要在前面加上L
,如:
对于宽字符,cout无法直接输出,对此iostream提供了wcout来输出它。但大部分情况下直接输出它会发现什么都没有输出:
(啥都没有的)输出:
为此,你需要设置一下locale,让它能够支持中文环境:
输出:无语
或者…(还是啥都没有的)输出:
为什么会这样呢?这是因为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字符:
以及说明字符串是以UTF-8格式编码(只能用于字符串字面常量):
除此之外还有前缀u(Unicode 16位字符,对应char16_t)、U(Unicode 32位字符,对应char32_t)。
左值与右值
左值可看作内存中有明确地址的数据,它是可寻址的。
右值是可提供数据值的数据,不一定可寻址。
比如以下代码
x是左值,它是可寻址的,生命周期较长,我们可以对它进行其他操作;1是右值,它是一个临时的常量,不可寻址,生命周期较短。
左值可以作为右值使用,如:
上面的b = 1
中,1是右值,b是左值,而在a = b
中,b又作为了右值,此时a为左值。
左值引用和右值引用
左值引用(lvalue-reference)
左值引用是我们最熟悉的给变量起别名:
输出:2
在许多C++入门教科书中,只提到了引用是给变量起别名,这听起来好像没有什么用对吧?为什么要引入一个这样的功能呢?接着往下看。
左值引用必须指向一个可寻址的值,下面的代码是非法的:
这是因为1是右值,没有指向它的内存地址。
这便是常见报错:“非常量引用的初始值必须是左值”的原因。
如果需要将常量赋给左值引用,可以把左值声明为const,下面的代码是合法的:
编译器会创建一个隐藏的变量储存这个右值的字面常量,然后再把这个隐藏的变量与引用绑定。
引用必须初始化,以下代码是错误的:
不能定义引用的引用,也不能定义指向引用的指针(即不存在int &*pra;
,但是存在int *& rpa;
,即指向指针的引用)。
由于函数使用引用形参时,传值不会复制一个新的对象,可以节省开销。但为了既能够传入变量也能够传入常量,且告诉用户不会修改实参的值,那么形参的声明就可以这样写:
再来看这个例子:
第1行代码函数形参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
,则以下调用均合法:
注:const放在星号*左边表示变量指向的数据不可修改,即指向的数据是常量;const放在星号*右边表示指针的指向不能修改,即指针本身是常量。所以上面要把const放*右边。
右值引用(rvalue-reference)
C++11引入了右值引用,用&&
表示,表示一个临时变量即右值,而且可以修改这个临时变量的值:
不能将左值赋给右值引用,以下代码是非法的:
如果需要将一个左值赋给右值引用,需要先将这个左值转为右值。STL提供了一个方法std::move()
来实现:
需要注意的是,当你将一个左值移动给另一个左值时,原先的左值便放弃了它的内容:
输出:
a的内容:
因此,std::move
实质上是把变量a的值移动给了b,这个值只有一份。a和b有着不同的内存地址,如果不复制的话,a和b这两个不同的变量无法拥有相同的值。
常量引用
常量引用是指对const的引用。
我们可以用一个常量引用与常量或非常量绑定,但不将用一个非常量引用与一个常量绑定:
当用一个常量引用与普通变量绑定时,我们便可对这个变量作限制,阻止该变量被修改。
引用作为函数返回值
对于一个值类型的函数返回值,它返回的是一个右值:
当引用作为函数的返回值时,你不能返回一个函数内的临时变量或者右值,因为的生命周期只在该函数的作用域内,出了函数就被销毁了:
不过你有没有想过,既然临时变量的生命周期只在作用域内,那么当函数的返回值类型不是引用时,返回函数中的一个局部变量时,这个变量不会被清理掉吗?
事实上,a确实会被清理掉,不过在返回时,会将该对象复制一份,b得到的,其实是另外一个对象:
运行结果:
address a: 0x61fc0c
address b: 0x61fccc
从上面的结果可以发现,a和b的地址并不相同,表明它们并不是同一个对象。
而使用引用作为函数返回值,可以避免复制对象产生的额外开销。
回到刚刚的问题,对于以引用为返回值的函数,既然不能返回函数内的临时变量作为返回值,那返回一个全局变量不就行了?
不使用引用作为返回值时:
输出:
0x7ff6b8078040
0xaede1ff7ec
可见即便返回的是globalVar,变量a仍然是一个新的值,与globalVar有着不同的地址。如果返回的globalVar是个很大的字符串,那么复制这样一个字符串将会消耗大量的资源,除非你希望复制,不然不必要的复制是应该避免的。
使用引用作为返回值:
输出:
globalVar地址: 0x7ff77a438040
a地址: 0x1b22bff814
b地址: 0x7ff77a438040
我们可以看到,func返回的是引用,可以理解为返回的是左值。
main中的a是一个普通变量,声明一个普通的变量会在内存中开辟一个新的地址,然后才把func得到的值赋给它,相当于string a = globalVar;
。
main中的b是一个引用,func返回的是左值,是可寻址的,所以可以赋给b,而且它指向globalVar,b是globalVar的别名,相当于string& b = globalVar;
。
因为func返回的是左值,于是我们可以给他赋值:
输出:2
另外,不应该去返回函数内由new分配的变量的引用,这是不好的习惯,因为如果返回的变量作为临时变量而不是赋给实际变量时,就会导致申请的内存无法被释放,造成内存泄露。
比如:
这个func看着人畜无害的样子,但实际上它返回的是函数内使用new分配的值。用户在不看源码或文档的情况下,怎么知道它分配了内存呢?
此时因为将其赋给了非引用的result,因此func()的值被复制,变为了右值,但func返回的是一个由new创建的变量,于是该返回值无法释放。
引用作为类成员函数返回值
上面说道不要在函数中返回局部变量的引用(指针也不能),但在类里呢?
比如这个例子,类Widget中有一个变量name,它是私有的,因此不能在类的外部修改它,但我们希望用户可以读取它:
getName()
返回的是name的一个拷贝,不过这样产生了复制,假如name是更复杂且巨大的数据类型,拷贝这样一个巨大的对象是可能会对性能有毁灭性的影响。
还记得引用的作用吗?为了不产生复制,我们可以返回一个引用。但是,既然是引用,会不会导致用户取得了name的所有权呢?这样将name设为私有不就没意义了吗?
其实,你可以返回一个常引用,这样,返回的引用就是只读的:
当然,因为返回的是常引用,所以变量s的前面也得加上const。
别忘了用一个引用接收返回的常引用,否则还是会产生复制。
实际上,忘记用引用接收是常见的,特别是当你把你写好的接口交给别人使用时。而且,不会有报错,甚至连警告都没有。
不过,换个思路,你也可以返回一个常指针:
返回一个常指针,也可以保证变量不被修改,而且如果用户将返回值赋给变量时,编译器也会强制用户使用一个常指针去接收。
即便它会拷贝一个指针,但拷贝指针的消耗相比拷贝一个巨大的对象来说显然微不足道。
右值引用的作用
右值引用的概念看起来很迷惑,它有什么用呢?其实它大有用处。有了前面的铺垫,这里详细介绍右值引用的作用。
用左值引用和右值引用重构函数
如果你希望对传入的左值和右值做不同的操作,那么你可以这样重构函数:
前面提到,以引用作为返回值时不能返回一个右值,但你可以返回一个右值引用:
于是就有
输出:
rvalue
lvalue
这是因为在func(func(1));
中,func(1)中的1是右值,故调用int& func(int &&a)
,而该函数又返回了一个左值,故func(返回的左值);
时调用了int func(const int &a)
。
但这又有什么用呢?以上内容请反复熟读,确保已经可以辨析各种值是左值还是右值,接下来的内容会比较难,判断不出来是左值还是右值会对阅读造成障碍。
移动语义
思考这样一个问题:如何将一头大象装进冰箱中。我们可以自然地想到,打开冰箱门,放入大象,关闭冰箱门。
不幸的是,在C++11之前是这样做的:复制一头大象,装入冰箱,删除冰外的大象:
main中,"Elephant"
会复制一份传入Refrigerator的构造函数,这复制操作明显增加了开销。
为了解决这一问题,我们使用左值引用不就好了吗:
可是,为了传入参数,我们需要先声明一个变量,再传入,多了一步,一点也不优雅。
有了右值引用,我们就可以:
这样,临时的"Elephant"
就可以废物利用,避免了复制大象的额外开销。
上述代码投入的是右值。对于左值,因为构造函数形参为左值和右值这两种情况都可以分别被重载,所以只需再加一个Refrigerator(const std::string &s) : content(s){}
。
或者,使用std::move
将左值转为右值:
这样,由于变成了右值,就可以调用移动构造,从而避免了拷贝。
再来看下面的代码:
输出:
移动后的s:
123
这里的a = std::move(s);
便是移动语义,将临时变量s的值移动到了变量a,于是避免了复制。当变量s生命周期结束后,并不会影响到a的值,因为此时变量s的值已经被移动了。
另外,我们可以发现,输出移动后的s的值是空的,即便此时它的生命周期还未结束。这意味着,当我们使用std::move
时,我们便放弃了该变量的所有权。
我们可以说,std::move
延长了变量s对应的值”123”的生命周期,于是这个值可以重复利用,避免复制一份新的再销毁旧的。
查看std::move()
的源码,可以发现它其实是static_cast<T&&>()
的简单封装。
一个错误的做法:
输出:
0x61fcd0
0x61fcd0
????
函数func内创建了一个临时变量s,通过std::move
返回一个右值引用,可见返回的右值引用与s虽然有相同的地址,但这并不能阻止变量s被销毁,于是变量a的值便是不确定的。
万能引用
当我们使用模板时,如:
这里的T&&
便是万能引用。对于万能引用,即可以给它赋右值,也可以给它赋左值:
我们发现,T&& r2 = a
是合法的,而int&& r3 = a
却是非法的。
这是由于,当一个万能引用被赋一个左值时,它就会发生引用折叠,变成一个T&
。
于是,在模板编程中,我们便可以只用一个T&&
同时接收左值和右值,这也是为什么它”万能“。
完美转发
有时候我们需要根据传入的是左值还是右值做不同的操作,如下:
输出: 左值
可以发现,明明输入的是右值,但输出的信息居然显示的是左值。
难道,变量T&& x
虽然是个右值引用,但变量本身是个左值。
为了解决这个问题,可以使用std::forward
进行完美转发:
输出: 右值
因此,当你在一个函数模板中接收到参数并将其转发给另一个函数时,你希望保留原始的左值/右值特性,此时便可以使用完美转发。
你可能不知道的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
当我们使用一个指针指向一个常量时,这个指针也必须声明为“指向的是常量”:
C++对于常量或者const的要求十分严格,这与Java等语言相比有很大的差距,不仅仅只是像Java的final那样声明不允许改那么简单。
再例如,一个函数返回一个指向常量的指针,那么也必须用指向常量的指针去接收:
当一个函数返回一个常引用时:
虽然以上pa和pb都合法,但它其实是func()返回值的拷贝,也就是说放弃了访问func返回引用的权限。
而如果用引用去接收,就必须用一个常引用去接收。
也就是说,func()返回给你一个引用,但对这个引用作了限制。
对于类来说,当返回类成员的引用时,最好是const引用。这样可以避免在无意的情况下破坏该类的成员。
如果一个函数的一个形参是引用,且没有const约束,那么你将不能传入一个const变量:
因为对于函数func,a没有const约束,这意味着func有权限修改引用a对应的值,但传入的值有const约束,造成了矛盾。
所以,如果func的一个形参是引用,但它不会修改a的值的话,应该将这个形参声明为const,这样它就可以接收const参数了。
除此之外,const在其他地方还有很多的约束,对于const的讲解会贯穿这个文章。
实现常量的方式
const的作用是实现一个语义约束,让一个对象不可改动。
除了const,也有其他实现常量的方式。
宏定义是其中一种方法,但非常不建议使用这种方法,因为宏定义的常量并不一定会被编译器发现,而且一旦出现错误,你将会收获匪夷所思的错误:
抛出的错误:Error:1.463
你会发现,抛出的错误是1.463,而不是MY_CONSTANT
,更不是error_is_here.h
,你就会疑惑这个1.463是哪来的,从而难以定位到错误的位置。如果这个头文件是你写的还好,但如果它是由别人写的,你就更难知道这个变量是哪来的了。
Effective C++[2]给出的建议是,当需要声明一个全局常量时,使用const、enum、inline代替#define。
常量表达式
constexpr关键字
常量表达式是指值不会改变,且在编译时就能得到计算结果的表达式:
对于一些情况,我们希望使用一个复杂的表达式,而这个表达式的值是确定的且不会变,我们希望在编译时就能得到这个值,而不必在运行时再计算。
例如,一个常量的值是sin 1 * ln 2
,我们当然可以手动算出来然后再把值代进去,但这样不优雅。
在C++ 11,我们便可以使用constexpr
这个关键字:
在常量表达式使用函数时,该函数也必须是constexpr函数,也就是在编译期间能得到结果的而不是运行期间。
此外,上述的sin和ln函数只是示例,注意math库中的sin和log没有constexpr声明。
把运行期间的运算提前到编译期间,可以减少运行期间不必要的计算,从而优化程序。
constexpr与指针
首先要区分constexpr与const指针。当constexpr单独与指针使用时,代表指针本身是常量:
constexpr常量指针的使用:
当constexpr指针指向一个const常量或一个constexpr常量时,这个指针也要标注const:
练习: 解释 constexpr const int * const
的含义和作用。
解答:
对于
这里的pa指向的不是一个常量,也就是说我们可以通过pa去修改a的值:
如果我们把pa声明为 constexpr const int * const
,则无法使用这个指针修改a的值:
register变量
在C语言中,用register修饰的变量可以建议编译器将该变量储存在寄存器中,因为寄存器在CPU里,所以寄存器的速度远远比内存要快。
因此,对于一个经常使用的变量,比如某个循环中会访问上亿次的变量,可以将其修饰为register:
然而,这只是一个建议,编译器会不会真的将其放入寄存器中,得看心情。
如果你有学过计算机组成原理,你就会清楚,你不应该在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访问数据过程感兴趣的,可以阅读计算机组成原理相关教材。
利用这个特性,我们可以对不同长度的数据进行对齐,从而提高性能。
C++ 11提供了可以查询和修改对齐方式的关键字: alignof和alignas。
不过,在一般的程序编写中我们很少会去手动对齐,只有对性能要求极高的程序,或者像驱动程序这样的底层程序,又或者某些嵌入式程序的特定要求,才会要求我们使用对齐特性。
alignof
alignof用于查询一个数据类型的对齐要求,返回一个std::size_t
类型的整数,表示该类型所需的最小对齐字节数。
输出:
alignof(char) = 1
alignof(int) = 4
alignof(A) = 4
alignof(B) = 8
alignas
alignas用于指定某个类型或变量的对齐要求。它允许你手动指定内存对齐,而不仅仅依赖于编译器默认的对齐方式。它可以用于结构体、类或者任何数据类型。
例如,假设Cache行的大小为64B,那么我们可以让一个容量为64的byte数组按64字节对齐,这样一来这个数组就能只用一次访存就全部存入Cache中:
auto
在C++ 11中,引入了一个关键字auto,它可以替我们为一个变量自动推导一个表达式的类型,不过推导过程是在编译时进行的,所以auto修饰的变量必须有初始值。
当变量类型很复杂时,auto能帮我们节省很多精力,例如:
又或者,一个函数返回了一个复杂的类型:
此外,auto一般会忽略掉顶层const(即指针本身是常量),保留底层const(即被指物是常量)。如果希望推导出的auto类型是顶层const,需明确指出:
我们也可以用auto获取特定类型的指针或引用:
设置一个auto类型的引用时,顶层const属性是会保留的:
decltype类型指示符
decltype的基本使用
decltype也是一种在编译时期进行类型推导的关键字,在C++ 11中引入。它可以获取表达式的类型,而不会对表达式进行求值。
它可以指定变量的类型为另一某变量的类型:
也可以获取一个表达式的类型:
这里可以将c的类型设定为a+b的类型,即double,且无需计算出a+b的值。
你可能会好奇,我们明明可以直接看出一个int加上一个double,应该得到一个double类型,那为什么还要费劲写上decltype(a+b)
呢?
其实,这种情况它更适合用于模板编程,当用在模板编程时,两个变量的类型是不确定的,因此需要使用这个功能:
decltype也可以获取某个函数的返回类型,而无需真正地调用该函数:
以上代码的func函数,可以将返回值类型设为auto并使用一个decltype推导返回值类型
decltype与引用
看以下代码:
为什么会报错呢?对一个int指针取值,得到的类型不应该是int类型吗?其实得到的是int&
,即引用,因为我们可以通过*pa = 2
的方式修改a的值,如果*pa
不是一个引用则无法实现。因此b是一个引用,引用必须初始化。这里的b没有初始化,因此出现了报错。
前面提到,我们可以用decltype获取一个变量的类型:
如果给被获取类型的变量加上两个括号,它就会变成表达式,从而变成获取表达式的类型。由于变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会变成引用类型:
相比于decltype(a)
:
decltype((a))
的结果永远是引用;decltype(a)
的结果只有当a本身是一个引用时才是引用。
decltype(auto)
decltype(auto)
是C++ 14引入的,可以根据表达式的实际类型推导变量的类型:
相比于auto,它可以精确推导类型,保留表达式中的引用、常量等修饰符,而auto一般会去掉引用和常量。
decltype(auto)
在一些需要精确类型推导的场景(如返回引用的函数、模板函数)特别有用,而auto则适合大多数普通场景,尤其是当你不关心引用或常量时。
0x03 运算
运算符
i++ 与 ++i
i++
和++i
单独出现时的作用都是自增。但i++
返回的是i自增前的数值,而++i
返回自增后的值,如:
输出:
1
2
由于i++
的特性,便可以这样复制字符串:
同时,++i
可以理解为返回i自增后i的引用,所以你可以给他赋值:
输出:5
输出:5
在性能上,++i
会比i++
效率高,但大多数编译器都会对代码进行优化,当他们单独出现时(普通变量),是没有什么区别的。但对于某些类对象,obj++
会产生一个新的临时对象,故此时++obj
性能要高一些。
运算符优先级
在计算正整数乘法时,如果乘数是2的N次幂,可以使用左移运算符(<<
),左移的位数为N。
比如,3 * 16
可表示为3 << 4
。
实际开发中,运算的表达式可能会比较复杂,比如,在上面的乘法之后再加一个数:3 << 4 + 1
,在这个例子中,期望得到的结果是49,但实际上得到的是96,这是因为左移运算符(<<
)的优先级比加号(+
)要低,所以会先计算4 + 1
,然后再3 << 5
。因为乘号(*
)的优先级比加号(+
)高,所以在我们利用上述乘法技巧,把乘号改为左移符号时就容易犯这种错误。加上括号即可解决该问题:(3 << 4) + 1
。
对于难以分辨的运算符优先级,建议加上括号,这样不仅不容易出问题,也能更容易看出运算的前后顺序,提高可阅读性。
表达式优先级?
阅读以下代码
结果会输出abc
还是cba
呢?换句话说,f(a(), b(), c());
中f内的三个函数运行的优先级是什么呢?
当你尝试验证时,你已经上当了。
事实上,在C++标准中(C++ 11前)并没有对此作出规定。不同于Java、Python等语言,C++的编译器有很多种,对于C++标准没有规定的内容,不同编译器可以有自己的解释,这就造成了以上不同的编译器会有不同的结果。
在C++11及以后版本中,C++标准明确规定了从右到左的求值顺序。所以在C++11之后,输出的是cba
。
“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:
而在C++中初始化是很复杂的过程。初始化和赋值是两个不同的操作,初始化不是赋值,初始化是在创建变量时赋予一个初始值,而赋值则是把当前值擦除,然后用一个新的值代替。
对于声明和初始化,C++与Java、Python等很多高级语言有很大的区别,所以对于一个熟练Java或Python的程序员,一定不要把对Java或Python对于变量声明和初始化的认知代入到C++中。本文章会反复提醒这点,以便读者能够重视C++初始化的特性和与其他语言的区别。
在C++中,假设有个类Widget,并初始化一个obj:
Widget类中有两个构造函数,调用第二个构造函数时给形参传入值“123”,这没什么问题吧?但当我们调用第一个无参构造函数时,就有可能写下这样的代码:
看似创建了一个对象w,但实际上居然声明了一个函数,返回值类型是Widget。实际上你应该这样写:
但第一个看起来又像是声明了一个变量,不像是初始化的样子,第二个也不太美观。
实际上,C++的初始化相当的混乱不堪,你可以用等号“=”和括号“()”初始化对象:
于是,C++11提供了一种统一的初始化方式,即大括号初始化:
而且,如果存在信息丢失的风险,例如将一个int赋给了short,则会报错:
这有利于提醒你注意信息丢失风险,而不会因为编译器偷偷摸摸的转换而头疼。
大括号初始化对列表初始化也有作用,这个到后面再讲。
大括号初始化也不是没有缺点,比如当类的构造函数的形参中有std::initializer_list
时,就容易被劫持:
对于Widget w{1, 2};
,调用的是Widget(std::initializer_list<int>);
这个构造函数。
但我想要调用Widget(int, int);
这个函数!
此时使用圆括号就没有问题:Widget w(1, 2);
。
要想用圆括号格式调用initializer_list的构造函数,则需要:Widget w({1, 2});
另外一种情况,如果类中既有无参构造函数,也有initializer_list,那么用空大括号初始化对象时,调用哪一个构造函数呢?
答案是Widget();
,如果需要调用含initializer_list的构造函数,则需要这样初始化:Widget w({});
。
位运算
位运算替代名
除了使用符号&
、|
等来进行位运算,C++还提供了保留字用来代替他们,他们在C++98就已经存在了:
- compl:~
- bitand:&
- bitor:|
- xor:^
- and_eq:&=
- or_eq:|=
- xor_eq:^=
注意和逻辑与和逻辑或区分:
- and:&&
- or:||
- not:!
- not_eq !=
0x04 指针
空指针 NULL和nullptr
在C++11之前,我们常用NULL表示空指针。但NULL只是一个宏,而且C和C++中对此的定义均不相同。
C:
C++:
可见在C++中,NULL是整数0。
如果int* p = NULL;
,由于C++中当一个指针为0时,视作空指针,所以这是没问题的。
但如果有这样的情况:
则会报错,因为并不知道这里的NULL究竟指的是数值0,还是空指针,因此产生了二义性(ambiguity)。
为了解决这一问题,C++11引进了nullptr,它是一个特殊的字面常量,类型为std::nullptr_t,用于代表空指针,可以转换为其他不同类型的指针。
如果把上面的代码中的NULL改为nullptr,则调用的函数原型为void func(int*)
。
野指针和悬空指针
野指针(Wild Pointer)
野指针是指指向一个未初始化的内存地址的指针,是一种指向不明确的、随机的指针。
当我们声明一个局部变量时,它会在内存中寻找一个空闲的位置。这个位置上可能原先是有内容的,当这个内容不再被使用后,它并不会被清理掉。
所以如果我们声明一个变量后没有去初始化它,那么它的值将会是随机的(事实上不应该叫随机,而是内存中未被清理的脏数据):
输出:32759(随机)
我们知道,指针存放的内容是一个地址,那么当一个局部指针被声明却没被初始化时,恰巧这片空间中有内容,那么此时这个指针就会指向一个随机的地址。
因为野指针的指向是不明确的,所以当我们操作这个指针时,就会产生各种意料之外的事情(去修改内存中随机位置的数据的值?)。
当指针越界访问时,也会变成野指针。比如我们操作数组时,不小心操作过头了,C++并不会像其他语言那些报数组越界错误,而是去访问越界后对应内存的数据:
还有一种情况,指针一般指向某个对象的第一个字节的地址。假设这个对象有4个字节,而指针却指向了这个对象的第2个字节的位置,也会出问题。
如何避免野指针的出现?
- 初始化指针。我们最好在指针被声明时就初始化它,或者初始化为nullptr。
- 注意不要让指针越界。
- 当指针指向的对象被释放时,将指针设为nullptr。
- 确保字符数组有”\0”结尾。
悬空指针(Dangling Pointer)
悬空指针指的是指向已经被释放的内存的指针。换句话说,悬空指针在内存释放(例如通过 delete 或 delete[])之后,仍然指向原来已经被释放的内存区域。
当我们释放一个对象时,可能不经意间又使用了它。访问已经被释放的内存会导致未定义行为,从而引起程序崩溃或数据损坏。
为了防止不小心使用了悬空指针,建议在使用delete或delete[]后,将该指针设为空指针:
当指针指向的临时变量被释放时,也会成为悬空指针:
在函数func中,a的作用域只在函数func中,当func运行结束时,a被释放,a所在的内存空间变为空闲空间,随时可能被其他数据占领。此时在main函数中的指针p,指向的就是未知的内容,成为了悬空指针。同样,如果p指向使用new或malloc分配的空间,当这个空间被释放后,p也会成为悬空指针。
也许你会想,这种低级错误自己是不会犯的。
实际上,悬空指针非常容易出现,在不经意之间就会犯错误,看下面这个例子:
由于cur的生命周期只在while内,所以result此时成了悬空指针。
此外,区别野指针与悬空指令的概念,悬空指针是指向已释放内存的指针,而野指针是指向未初始化的内存地址的指针,它们的本质不同。因此,它们不应混为一谈,尽管在某些情况下,两者的后果可能类似。
另一个避免野指令和悬空指令的方法是使用智能指针,会在后面的章节中进行讲解。
数组与指针
定义数组
在定义数组时,假设我们这样做:
则会得到乱七八糟的输出:
-281020096 70 1435572696 32758 -1203169216 487 0 1
这是因为,声明int arr[8]
时,分配的空闲内存中的这一片区域的数据未被清理,仅仅是被标注为了“空闲”。不清理是因为当它再次被利用时可以直接覆盖掉原来的数据,省略掉清理这一步可以提高性能。
为了解决这一问题,你可以这样做:
因为使用大括号初始化数组的数据时,没有指定的数据会被初始化为0,故一个空的大括号可以让数组内的所有数据都初始化为0。
不过,如果数组作为全局变量,则无需这样做:
有时候,我们需要根据实际情况确定一个数组的大小,这个数组大小可能不是固定的。这里有一个小细节,比如:
这里通过变量size
和get_size()
获取数组的大小,而不是一个字面值,这样写是合法的吗?
实际上,数组的维度应该要在编译时是确定的,因此size
和get_size()
必须是常量表达式,即constexpr
,才是合法的。
如果数组大小需要动态变化,可以使用vector,在后面的章节介绍。
数组不支持使用等号将一个数组拷贝给另一个数组:
即便有些编译器可能支持,但不建议使用非标准特性。
我们可以定义存放指针的数组,不过不存在存放引用的数组。
字符数组
如果一个数组是字符(char)类型,则在使用它会发生一些奇妙的事情,详情可见后面的“字符串”章节。
在函数间传递数组
上面讲过,返回函数内一个临时对象时,会返回它的一份拷贝。
但C/C++里有个很怪的规定,就是当你返回一个数组(T[])时,会将其视为T*,而不是T[]:
因为arr退化为了指针,所以这里的操作,是在返回一个指针,返回一个临时对象的指针,这意味着什么?因为arr出了函数,它的生命周期就结束了,所以你返回的是一个悬空指针。
当然,你可以将数组用结构体或类封装起来再返回,或者使用std::array
,就可以避免这个问题,不过,你也可以使用函数的参数来传递数组(推荐):
数组的指针
存放数组的指针?指向数组的指针?
一个数组即可存放对象,也可存放对象的指针:
像这样的定义,就是一个容量为5的数组,可以存放5个int型指针。
当我们想要定义一个指针,这个指针本身指向一个数组时,则需要这样写:
这是一个指针,指向的是一个容量为5的数组,数组本身存放的是整型数字。
同理,我们可以定义一个数组的引用,即数组的别名:
注意不存在存放引用的数组。
将其进行组合:
rarr
是arr
的引用,arr
存储5个int*
。
使用指针访问数组
我们可以直接使用一个等号将数组中第一个元素的指针赋给一个指针:
也就是说,int *pa = arr;
等效于int *pa = &arr[0];
。
根据前面提过的初始化方式,我们也可以这样对pa
初始化:
如果使用取地址符号(&),则取的是指向数组本身的指针:
分别输出 原数组、指向数组第一个元素的指针、指向数组的指针 的对应地址,及解引用后的值:
输出:
地址
0x61fdf0
0x61fdf0
0x61fdf0
解引用
1
1
0x61fdf0
可见,三者的地址当然都相同;而直接解引用数组本身、解引用指向数组第一个元素的指针,得到的都是第一个元素的数值 1 ,但解引用指向数组的指针得到的却是数组。
不过既然parr
解引用后是地址,那么如果我们对其解两次引用,不就能得到它指向的值了吗?
输出:
1
事实上,我们对parr
解一次引用得到的是int*
,与pa
等效。
如果我们对它们分别进行自增操作(对数组a不能直接进行自增操作):
输出:
原地址
0x61fdf0
修改后地址
0x61fdf4
0x61fe04
修改后地址解引用对应的值
2
0
可见,对指向数组第一个元素的指针pa
进行自增,会使之指向下一个元素;而对指向数组的指针parr
自增,则它会指向最后一个元素的下一个元素(即超出了数组范围)的地址。
所以如果我们对parr
自增,然后解引用后自减,便是指向数组最后一个元素的指针:
输出:
5
另外,就算pa
是个指针,我们仍可以使用下标:
输出:
3
3
3
可见,对于指针来说,pa[2]
等效于*(pa + 2)
,且pa + 2
并不是指其地址加2,而是往后移动两个指针大小的值,比如测试用机是64位系统和64位编译器,所以指针大小是8个字节,移动两个指针大小就是16个字节,对应数组第三个元素。
那么,如果是指向一个普通变量的指针,而不是指向数组中某一个元素的指针,是否也可以用pa[i]
或pa + i
的形式呢?答案是这种操作是合法的:
但是这种做法没什么意义,因为它将一个指针指向了一个不确定的位置,如果尝试修改这个指针对应位置的值可能引起程序崩溃。
注意区分*(pa + 2)
和*pa + 2
,前者是指针pa
往后移动两个元素的位置后解引用,后者是pa
本身解引用后得到的值再加上数字2。
既然指针也可以做运算,那如果将两个指针相减,又会发生什么呢?
事实上,两个指针相减会得到一个std::ptrdiff_t
的类型,是个有符号数,表示两个指针之间的距离。这个距离是指两个指针间相隔了多少个元素的位置,而不是相隔了多少字节地址。
输出:
2
由于相减得到的类型是有符号的,它有可能会是一个负数,取决于哪个在前哪个在后(哪个是被减数哪个是减数)。
此外,数组或者指针的下标可以是负数。
不过我们一般不会访问数组下标为负数的元素,因为它超出了数组的范围。
但我们可以访问下标为负数的指针,它表示指针往负方向移动后的指针:
使用 for range 遍历数组
for range 是C++ 11引入的特性,可以让我们方便地遍历数组:
输出:
1 2 3 4 5
但是,如果是指向数组第一个元素的指针,或指向数组的指针,则无法使用该特性。
数组作为函数形参
前面的章节中有一个获取数组长度的写法:
这确实是一种有效的获取数组长度的方法,但是当数组arr作为函数形参时,这段代码会发生什么呢?
输出: 2
可以看到,输出的内容与预期不符,这是因为当arr作为函数形参时,它实际上是一个指针(即数组会退化成指针),于是sizeof(arr)
得到的便是一个指针的长度。这里测试用机是64位,因此指针长度为8,于是输出结果为2。
为此,当使用方括号形式的数组作为函数形参时,一般还需要一个参数来确定数组长度:
对于其他高级语言的程序员来说,C++并没有像arr.length
这样方便地获取数组长度的功能。因为C++是个更接近于底层的编程语言,在汇编语言中,想要实现数组就要分配一个连续区域,然后一个个去访问他们,至于数组长度,则需要自行分配另一个内存空间去存储。
不过,STL提供了封装的数组数据结构,比这样原始的数组更方便一些,会在后面介绍。
当数组arr作为函数的形参时,它会退化成一个指针:
此时,虽然arr
的声明长得像一个数组int[]
,但它实际上是一个指针int*
,因此 for range 就失效了。
不过因为指针仍可以使用诸如arr[2]
这种用下标的方式,于是能够使用arr
+下标来访问数组元素。
此外,数组退化成指针也是当数组作为形参时,无法使用sizeof(arr) / sizeof(int)
获取数组长度的原因,因此需要在函数中多加一个参数用于传递数组的长度:
这里把第二个参数的类型是std::size_t
而不使用int
,这是因为std::size_t
是一种无符号类型,它被设计得足够大以便能表示内存容量可以表示的所有可能的长度。
数组引用作为函数参数
思考这样一个有意思的问题: 如果数组引用作为函数参数,又会发生什么呢?
对于第一个函数,因为是一个指定大小数组的引用,于是它并不会退化成一个指针,也就是说我们可以使用sizeof(arr) / sizeof(int)
和 for range 了。
不过,因为数组的大小是指定的,所以似乎不需要用sizeof
的方法获取数组长度,而且调用这个函数时传入的数组的长度是被限定的。
前面学到引用的时候知道,修改一个引用可以保证修改的是这个引用对应的原数据,但对于一个数组来说,就算不是引用,在函数中修改这个数组也能影响到原数组,因为它会被退化成一个指针,所以传入一个数组实际上没有发生对数组的拷贝。
而对于第二个函数,它并没有指定大小,且因为它是一个数组的引用,所以它也不会退化成指针。那么sizeof
获取长度的方法和 for range 可以用吗?事实上并不能用,因为这个数组没有指定大小,那么在编译阶段它的大小是不确定的,而sizeof
和 for range 都需要和编译时就知道数组的大小,显然并不可以使用。于是,对于第二个函数,你仍需要再传递一个参数才能知道数组的长度。
看起来好像没什么用的样子,这个特性究竟有什么用呢?
对于数组引用作为函数参数的特性,它的作用是用于接收一个数组引用而不退化成指针,避免了指针语义的限制,能保留数组本身的性质。
所以,最重要的是它能能保留数组本身的性质,特别在模板编程中有重要的作用。
比如,STL提供了两个函数begin
和end
,可以获取指向数组第一个元素和指向最后一个元素的下一个位置的指针:
输出:
1
5
于是我们便可以这样遍历数组:
输出:
1 2 3 4 5
但是我们也没给它传入数组大小啊,它是怎么知道最后一个元素的位置的?
看一下它的源码(为方便阅读经过了修改):
当我们往函数中传递arr
这个数组时,我们往其中传入了一个int[5]
参数,函数用数组引用接收,模板类型是T和N,而传入的参数的int和5落入了这个模板,从而函数得到的是int[5]
的引用,于是便知道了数组的大小。所以,使用模板也能获取数组的长度。
C++的模板功能非常强大,但要正确使用它需要对于C++有很深的理解,本文章将逐步加深读者对C++的理解,可作为使用模板元编程的前置知识。
此外,尾指针end
因为指向的是最后一个元素的下一个位置,超出了数组的范围,所以不能对它进行解引用或继续递增。
函数的指针
静态函数(内部函数)与外部函数
当一个程序由多个文件构成时,有一些函数只会在它所在的文件中被调用,不会提供给其他文件使用,这时我们就可以把它声明为static:
这样的函数被称为内部函数(也叫静态函数),由于它只能在该文件内被调用,你也无需担心在其他文件中有同名函数的重复定义问题。
相应的,在函数声明前加extern,代表它为外部函数,即该函数在其他文件中定义。函数声明默认为外部函数,所以这个extern可以省略。
此外,这种在文件域中的static声明不建议使用。
当函数指针与数组相遇
对于常见的变量类型,你一定能一眼认出来他们: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*
,返回值为函数指针的函数。
智能指针
指针类型转换
C++ 11引入了四种类型转换: static_cast、reinterpret_cast、dynamic_cast 和 const_cast
https://zhuanlan.zhihu.com/p/679500619
0x05 字符串
历史遗留问题——char*-based字符串
这是一个常见(应该说在旧的代码中常见)的字符串赋值语句:
可以看到,这里使用了一个指针指向了一个字符串字面常量(String Literal)。对于不知道它的人来说,可能会有修改它的欲望,但实际上修改它会出现不可预料的后果(不同编译器可能会有不同处理方式),因为我们在尝试修改一个常量!
可是,它不应该是一个变量吗?实际上,我们仔细一看,虽然赋值符号左边是变量,但右边是一个字符串字面常量,把一个字符串字面常量赋给一个指针,想必编译器一定要做一些“小动作”才能实现。
实际上,str指向的是一个常量,这个常量的类型是const char[]
,所以修改它会出现严重的错误(程序崩溃等),它应该这样写才严谨:
使用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语言没有的函数,且防止命名冲突。
std::string
为了更方便地表示和处理字符串,C++标准库提供了string表示可变长的字符序列。
定义和初始化string对象
这里要再次提醒,要对C++的初始化有敬畏之心,特别是Java和Python程序员。
比如,声明一个字符串,看起来和声明一个int差不多,但它事实上会进行初始化:
与声明一个int但不初始化不同,以上写法会调用默认的初始化,创建一个空字符串,而不是像声明一个int但不初始化那样是一个随机的内容。
当我们使用等号将一个字符串字面值赋给字符串时,实际上是调用了string的一个构造函数,并将字符串字面值拷贝给它:
以上代码中,“test”的类型是一个const char[5]
的字面类型,也就是说它最后自带一个空字符’\0’。
直接初始化的方法:
要注意的是,如果是这样写,仍是拷贝初始化,因为它显式地创建了一个临时的对象,然后再拷贝给str:
我们一般不用这个方法,因为它做了不必要的拷贝,且可读性不如上述直接初始化的方法。
string还有一个构造函数,可以生成多个重复字符:
输出:
当我们用等号将一个string对象赋给另一个string对象时:
是将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类型,以下写法是非法的:
但是以下写法是合法的:
因为加号的顺序是从左到右,第一个string类型字符串与第一个字面值相加后得到一个string类型字符串,然后它再与第二个字面值相加。
切记诸如”test”这样的字面值并不是string类型的,而是const char[]
(或者说const char*
)类型。
使用 range for 访问string中的字符
C++11 提供的 range for 语句可用于遍历序列,也能用来方便地遍历字符串中的字符:
输出:
1
2
3
还记得auto会去除引用属性吗?如果需要遍历并通过引用修改字符串的内容,则需要给auto加上引用标志:
输出:
testxxx
或者将auto改为decltype(auto),不过这是C++ 14的特性,需要保证编译器支持C++ 14。
0x06 标准库容器
数组很难用?想要更复杂的窗口?C++标准库提供了许多不错的选择。
std::vector
相比于数组大小固定,vector的大小是动态的,而且插入或删除元素都很方便。
初始化 vector
vector是模板,支持指定特定类型,不过不包括引用类型。
如果指定的模板类型也是vector,即vector嵌套:
在C++ 11之前,有些编译器可能需要在两个右尖括号间添加空格才能正常识别:
vector的默认初始化方法,即指定一个T类型:
从另一个vector中拷贝:
注: v2或v3的元素,是v1元素的副本,而不是引用。
可以在初始化时让vector包含n个重复对象:
v含有3个字符串”test”。
如果只传入n的值,如:
则vector包含的内容是3个指定类型初始化后的值。这里指定的类型是std::string,所以是3个空字符串。如果指定的类型是int,如:
则vector包含3个整型值0。
注意: 有些类型可能不支持默认初始化。
在C++ 11,也可以使用在初始化时包含特定元素,如:
注意,使用花括号时,如果能用列表初始化,会优先调用列表初始化:
操作 vector
vector的大小是可以动态变化的,我们可以使用push_back
向末尾添加元素:
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)才能进行比较。
高效的 vector 使用
在使用push_back
往vector末尾插入元素时,可能会引起拷贝。为了防止拷贝带来的性能损失,可以将vector的类型指定为你想指定类型的指针或智能指针,这样拷贝的就只是指针而不是完整的对象。不过如果使用指针,需注意其中的对象的生命周期,避免成为悬空指针,或者使用智能指针。
如果不想使用指针,可以利用C++ 11提供的移动语义来避免拷贝。
对于push_back
:
- 如果你传入的是一个左值(即已经构造的对象的引用),那么push_back会创建该对象的一个副本。
- 如果你传入的是一个右值(即临时对象或可以“移动”的对象),则push_back会将该对象移动到容器中,从而避免了不必要的复制。
为了让push_back
可以移动而避免拷贝,vector指定的类型需支持移动构造,比如:
此外,C++ 11的vector还提供了另一个方法emplace_back
,能在vector末尾原地构造对象,从而避免拷贝。这句话看起来有点懵,举一个例子就明白了:
也就是说,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:
标准库提供了两种迭代器(以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的代码建议改为:
注意: 当我们在使用迭代器遍历vector过程中,不能向迭代器所属容器添加元素,否则会导致迭代器失效。
使用 for range 遍历 vector
和遍历string类似,在C++ 11可以使用 for range 方便地遍历vector:
同样,基于auto的特性,为了避免拷贝,以及只允许写操作,可以:
可见,这种写法比用迭代器或下标简洁很多。
与使用迭代器遍历一样,在使用 for range 遍历vector时,不能向vector添加元素。
参考资料
- [1]C++ Primer Fifth Edition by Stanley B. Lippman, Josée Lajoie and Barbara E. Moo. Copyright © 2013 Objectwrite Inc., Josée Lajoie and Barbara E. Moo, 978-0-321-71411-4.
- [2]Effective C++ Third Edition by Scott Meyers. Copyright © 2005 Pearson Education Inc, 978-7-121-12332-0.