C++ Primer Part1 - C++基础

第一章:开始

main函数的返回值


在大多数系统中,main的返回值被用来指示状态,返回值0表明成功,非0的返回值的含义由系统定义,通常用来指出错误类型。

那么我们如何拿到main的返回值呢?
访问main的返回值的方法依赖于系统。在UNIX和Windows系统中,执行完一个程序后,都可以通过echo命令获得其返回值。
UNIX: echo $?
Windows: echo %ERRORLEVEL%


编译器


编写好程序后,我们就需要编译它。如何编译程序依赖于你使用的操作系统和编译器。

文件命名约定

命令行运行编译器

GNU

1
2
3
4
5
g++ -o prog1 prog1.cc
-o prog1 是编译器参数,指定了可执行文件的文件名

g++ --std=c++11 ch01.cpp -o main
有时候根据使用的GNU编译器的版本,可能需要指定-std=c++11参数来打开对C++11的支持

MSVC

1
2
cl /EHsc prog1.cpp
/EHsc 是编译器选项,用来打开标准异常处理

输入输出


C++语言并未定义任何输入输出(IO)语句,取而代之,包含了一个全面的标准库(STL)来提供IO机制(以及很多其他设施)。

标准输入输出对象

iostream库包含两个基础类型istreamostream,分别表示输入流和输出流。
一个流就是一个字符序列,是从IO设备读出或写入IO设备的。

标准库定义了4个IO对象

  • cin istream类型对象 标准输入
  • cout ostream类型对象 标准输出
  • cerr ostream类型对象 标准错误
  • clog ostream类型对方 输出程序运行时的一般性信息

向流读写数据

写入数据

1
std::cout << "Hello, World" << endl;

<< 运算符接受两个运算对象:左侧的运算对象必须是一个ostream对象,右侧的运算对象是要打印的值。此运算符将给定的值写到给定的的ostream对象中。输出运算符的计算结果就是其左侧运算对象。即计算结果就是我们写入给定值的那个ostream对象。

所以第一个运算符的结果成为了第二个运算符的左侧运算对象。这样,我们就可以将输出请求连接起来。

endl:这是一个被称为操纵符(manipulator)的特殊值,效果是结束当前行,并将设备关联的缓冲区(buffer)中的内容刷到设备中。

读取数据

1
std::cin >> v1 >> v2;

>> 运算符与输出运算符类似,它接受一个istream对象作为其左侧运算对象,接受一个对象作为其右侧运算对象。它从给定的的istream对象中读入数据,并存入给定对象当中。与输出运算符类似,输入运算符返回其左侧运算对象作为其运算结果。同理我们也可以将一系列输入请求合并到单一语句中。


注释


C++有两种注释:单行注释和界定符对注释。

  • 单行注释: // 当前行双斜线右侧的所有内容都会被编译器忽略,这种注释可以包含任何文本,包括额外的双斜线
  • 界定符注释: /* …. */ 编译器将落在区间内的所有内容都当作注释,包括换行符。

注释界定符不能嵌套
/*
注释对/ */ 不能嵌套
*/
此例中,“不能嵌套”会被当成源码处理,报错。

调试的时候,我们注释的代码可能包含界定符注释,因此最好的方式是采取单行注释方式注释掉代码的每一行(实际上IDE中的选中多行注释,也是采取单行注释)


控制流


while循环与for循环

看一个经典的程序,统计1到10的和

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// while循环写法
int sum = 0, val = 1;
while (val <= 10)
{
sum += val;
val++;
}

// for循环写法
int sum = 0;
for (int val = 1; val <= 10; val++)
{
sum + = val;
}

两种形式的优缺点和应用
在循环次数已知的情况下,for循环的形式更为简洁。

而循环次数无法预知的时候,用while循环实现显然更适合。用特定条件控制循环是否执行,循环体中执行的语句可能导致循环判定条件发生变化。


读取数量不定的输入数据

看一个经典的程序,对用户输入的一组数求和

1
2
3
4
5
6
7
8
9
10
//while循环写法
int sum = 0, val = 0;
while (std::cin >> value)
{
sum += val;
}

//for循环写法
int sum = 0;
for (int val; std::cin >> val; sum += val);

cin >> value 作为条件的意义
当我们使用一个istream对象作为条件时,其效果是检测流的状态,如果流是有效的,即流未遇到错误,那么检测成功。当遇到文件结束符(end-of-file),或遇到一个无效输入时(例如读入的值在上例中不是一个整数),istream对象的状态会变为无效。处于无效状态的istream对象会使条件变为假。因此while循环会一直执行直至遇到eof或输入错误。

文件结束符
对于如何指出文件结束,不同操作系统有不同的约定。

  • Windows: ctrl + z
  • UNIX: ctrl + d

编译错误

  • 语法错误(syntax error)
  • 类型错误(type error)
  • 声明错误(declaration error)

按照报告的顺序来逐个修正错误,是个好习惯,因为一个单个错误常常会具有传递效应,导致编译器在其后报告比实际数量多得多的错误信息。

另一个好习惯是在每修正一个错误后就立即更新编译代码,或者是最多修正了一部分明显的错误后就重新编译。这就是所谓的”编辑-编译-调试”(edit-compile-debug)周期。



头文件
类的类型一般存储在头文件中,标准库的头文件使用<>,非标准库的头文件使用””。申明写在.h文件,定义实现写在.cpp文件。

文件重定向

1
2
//从infile中读取数据,并将输出结果写入outfile中,两个文件都位于当前目录
addItem <infile >outfile

成员函数

1
2
// item1:对象, isbn:成员函数   . 点运算符    () 调用运算符
item1.isbn()

重点习题

1.1, 1.2
1.8
1.23
习题答案可以参考github上的答案


第二章:变量和基本类型

基本内置类型

C++支持广泛的数据类型,其包含了基本的内置类型,标准库中更加复杂的数据类型,同时也为程序员提供了自定义数据类型的的机制。

基本内置类型包括算术类型和空类型。


算术类型


算术类型分为两类:整型(包括字符和布尔类型)和浮点型。
算术类型的尺寸在不同机器上有所差别,这里列出了C++标准规定的尺寸的最小值,同时允许编译器赋予这些类型更大的尺寸。

类型 含义 最小尺寸
bool 布尔类型 8bits
char 字符 8bits
wchar_t 宽字符 16bits
char16_t Unicode字符 16bits
char32_t Unicode字符 32bits
short 短整型 16bits
int 整型 16bits (在32位机器中是32bits)
long 长整型 32bits
long long 长整型 64bits (是在C++11中新定义的)
float 单精度浮点数 6位有效数字
double 双精度浮点数 10位有效数字
long double 扩展精度浮点数 10位有效数字

然后这里附上实际64位机器的算术类型的尺寸。

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

int main() {
cout << "size of bool:" << sizeof(bool) << endl;
cout << "size of char:" << sizeof(char) << endl;
cout << "size of wchar_t:" << sizeof(wchar_t) << endl;
cout << "size of char16_t:" << sizeof(char16_t) << endl;
cout << "size of char32_t:" << sizeof(char32_t) << endl;
cout << "size of short:" << sizeof(short) << endl;
cout << "size of int:" << sizeof(int) << endl;
cout << "size of long:" << sizeof(long) << endl;
cout << "size of long long:" << sizeof(long long) << endl;
cout << "size of float:" << sizeof(float) << endl;
cout << "size of double:" << sizeof(double) << endl;
cout << "size of long double:" << sizeof(long double) << endl;
}


//output
size of bool:1
size of char:1
size of wchar_t:2
size of char16_t:2
size of char32_t:4
size of short:2
size of int:4
size of long:4
size of long long:8
size of float:4
size of double:8
size of long double:8

布尔和字符类型

bool类型的取值是true或false
一个char的大小和一个机器字节一样,确保可以存放机器基本字符集中任意字符对应的数字值。
其他字符类型用于扩展字符集,其中wchar_t确保可以存放机器最大扩展字符集中的任意一个字符。char16_t和char32_t则为Unicode字符集服务(Unicode是用于表示所有自然语言中字符的标准)。

整形大小关系
在整型类型大小方面,C++规定short ≤ int ≤ long ≤ long long(long long是C++11定义的类型)。

字节和字
字节byte:可寻址的最小内存块,大多数机器的字节由8比特构成
字word:存储的基本单元,4或8字节

浮点型
浮点型可表示单精度(single-precision)、双精度(double-precision)和扩展精度(extended-precision)值,分别对应float、double和long double类型。

带符号类型与无符号类型
除去布尔型和扩展字符型,其他整型可以分为带符号(signed)和无符号(unsigned)两种。带符号类型可以表示正数、负数和0,无符号类型只能表示大于等于0的数值。类型int、short、long和long long都是带符号的,在类型名前面添加unsigned可以得到对应的无符号类型,如unsigned int。

字符型分为char、signed char和unsigned char三种,但是表现形式只有带符号和无符号两种。类型char和signed char并不一样, char的具体形式由编译器(compiler)决定。

如何选择类型

  • 当明确知晓数值不可能为负时,应该使用无符号类型。
  • 使用int执行整数运算,如果数值超过了int的表示范围,应该使用long long类型。
  • 在算数表达式中不要使用char和bool类型。如果需要使用一个不大的整数,应该明确指定它的类型是signed char还是unsigned char。
  • 执行浮点数运算时建议使用double类型。

类型转换


进行类型转换时,类型所能表示的值的范围决定了转换的过程。

  • 把非布尔类型的算术值赋给布尔类型时,初始值为0则结果为false,否则结果为true。
  • 把布尔值赋给非布尔类型时,初始值为false则结果为0,初始值为true则结果为1。
  • 把浮点数赋给整数类型时,进行近似处理,结果值仅保留浮点数中的整数部分。
  • 把整数值赋给浮点类型时,小数部分记为0。如果该整数所占的空间超过了浮点类型的容量,精度可能有损失。
  • 赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数(8比特大小的unsigned char能表示的数值总数是256)取模后的余数。
  • 赋给带符号类型一个超出它表示范围的值时,结果是未定义的(undefined)。

切勿混用带符号类型和无符号类型
如果表达式里既有带符号类型又有无符号类型,带符号数会自动转换成无符号数。
当带符号类型取值为负时会出现异常结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int a = -1;
unsigned int b = 1;
cout << a*b << endl;

// output
此时输出就不是-1,而是4294967295

// 无符号数不会小于0的,影响循环
// u永远不会小于0
for (unsigned u = 10; u >= 0; --u)
std::cout << u << std::endl;

//解决办法:改变循环的范围,10...0 变成 11...1 再减去1
unsigned u = 11;
while (u > 0)
{
--u;
std::cout << u << std::endl;
}

字面值常量


一个形如42的值被称为字面值常量,每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。

整型和浮点型字面值
整形字面值可以写作多种进制数的形式。以0开头的整数代表八进制(octal)数,以0x或0X开头的整数代表十六进制(hexadecimal)数。在C++14中,0b或0B开头的整数代表二进制(binary)数。

C++14新增了单引号’形式的数字分隔符。数字分隔符不会影响数字的值,但可以通过分隔符将数字分组,使数值读写更容易。

1
2
3
// 按照书写形式,每3位分为一组
std::cout << 0B1'101; // 输出"13"
std::cout << 1'100'000; // 输出"1100000"

浮点型字面值表现为一个小数或以科学计数法表示的指数,其中指数部分用E或e标识:

  • 3.14159
  • 3.14159E0
  • 0.0
  • 0e0
  • .001

浮点型字面值默认是一个double。

字符和字符串字面值
由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符称为字符串字面值。

字符串字面值的类型是由常量字符构成的数组(array)。编译器在每个字符串的结尾处添加一个空字符’\0’,因此字符串字面值的实际长度要比它的内容多一位。

1
2
'A'  一个字符 
"A" 两个字符 一个A字符一个空字符

如果两个字符串字面值位置紧邻且仅有空格,缩进,换行符分隔,则它们实际上是一个整体。因此当字符串字面值比较长,就可以采取分行书写的方式:

1
2
std::cout << "a really long string literal"
"that spans two lines" << endl;

转义序列
有两类字符程序员不能直接使用:一类是不可打印的字符,如退格或其他控制字符,另一类是在C++语言中含有特殊含义的字符(单引号,双引号,问号,反斜线)。所以我们需要用到转义序列,以\开头。

含义 转义字符
newline \n
horizontal tab \t
alert (bell) \a
vertical tab \v
backspace \b
double quote \“
backslash \|
question mark \?
single quote \‘
carriage return \r
formfeed \f

八进制数和十六进制数可以拿来转义字符,可称为八进制字符和十六进制字符。
泛化转义序列的形式是\x后紧跟1个或多个十六进制数字,或者\后紧跟1个、2个或3个八进制数字,其中数字部分表示字符对应的数值。如果\后面跟着的八进制数字超过3个,则只有前3个数字与\构成转义序列。相反,\x要用到后面跟着的所有数字。

1
2
3
"\1234"  //两个字符 八进制123对应的字符(八进制字符)和字符4
"\x1234" //一个字符 四个十六进制数所对应的比特唯一确定(十六进制字符)
"1234" //四个char字符

指定字面值的类型

布尔字面值和和指针字面值

  • true和false和布尔类型的字面值
  • nullptr是指针类型的字面值

变量

变量提供一个具名的、可供程序操作的存储空间。 C++中变量和对象一般可以互换使用。


变量定义


定义形式: 类型说明符(type specifier) + 一个或多个变量名组成的列表,变量名以逗号分隔,最后以分号结束。如int sum = 0, value, units_sold = 0;

初始化::初始化不等于赋值(assignment)。初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,再用一个新值来替代。

列表初始化
用花括号初始化变量称为列表初始化(list initialization)。当用于内置类型的变量时,如果使用了列表初始化并且初始值存在丢失信息的风险,则编译器会报错。

1
2
3
long double ld = 3.1415926536;
int a{ld}, b = {ld}; // error: narrowing conversion required
int c(ld), d = ld; // ok: but value will be truncated

默认初始化
如果定义变量时没有指定初值,则变量被默认初始化(default initialized)。如果指定了初始值又叫显示初始化。

如果是内置类型未被显式初始化,定义于任何函数体之外的变量被初始化为0,函数体内部的变量将不被初始化(uninitialized)。
如果是非内置类型,每个类各自决定其初始化对象的方式,绝大多数类都支持默认初始化而定义对象。函数内外定义无区别

定义于函数体内的内置类型对象如果没有初始化,则其值未定义,使用该类值是一种错误的编程行为且很难调试。类的对象如果没有显式初始化,则其值由类确定。

建议初始化每一个内置类型的变量。


变量声明vs变量定义


为了允许把程序拆分成多个逻辑部分来写,C++语言支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可以被独立编译。

为了支持分离式编译,C++将声明和定义区分开。声明使得名字为程序所知。定义负责创建与名字关联的实体。

如果想声明一个变量而不定义它,就在变量名前添加关键字extern,并且不要显式地初始化变量。因为任何包含了显式初始化的声明即为定义。

1
2
3
extern int i;      //声明i而非定义i
int j; //声明并定义j
extern int k = 3; //定义

变量能且只能被定义一次,但是可以被声明多次。

如果要在多个文件中使用同一个变量,就必须将声明和定义分开。此时变量的定义必须出现且只能出现在一个文件中,其他使用该变量的文件必须对其进行声明,但绝对不能重复定义。


标识符


C++的标识符由字母、数字和下划线组成,其中必须以字母或下划线开头。标识符的长度没有限制,但是对大小写字母敏感。C++为标准库保留了一些名字。用户自定义的标识符不能连续出现两个下划线,也不能以下划线紧连大写字母开头。此外,定义在函数体外的标识符不能以下划线开头。

C++保留了关键字和操作符替代名供本身使用,这些名字不能用作标识符。


名字的作用域


定义在函数体之外的名字拥有全局作用域(global scope)。声明之后,该名字在整个程序范围内都可使用。

最好在第一次使用变量时再去定义它。这样做更容易找到变量的定义位置,并且也可以赋给它一个比较合理的初始值。

作用域中一旦声明了某个名字,在它所嵌套着的所有作用域中都能访问该名字。同时,允许在内层作用域中重新定义外层作用域已有的名字,此时内层作用域中新定义的名字将屏蔽外层作用域的名字。

可以用作用域操作符::来覆盖默认的作用域规则。因为全局作用域本身并没有名字,所以当作用域操作符的左侧为空时,会向全局作用域发出请求获取作用域操作符右侧名字对应的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
// Program for illustration purposes only: It is bad style for a function
// to use a global variable and also define a local variable with the same name
int reused = 42; // reused has global scope
int main()
{
int unique = 0; // unique has block scope
// output #1: uses global reused; prints 42 0
std::cout << reused << " " << unique << std::endl;
int reused = 0; // new, local object named reused hides global reused
// output #2: uses local reused; prints 0 0
std::cout << reused << " " << unique << std::endl;
// output #3: explicitly requests the global reused; prints 42 0
std::cout << ::reused << " " << unique << std::endl;
return 0;
}

如果函数有可能用到某个全局变量,则不宜再定义一个同名的局部变量。


复合类型

复合类型是指基于其他类型定义的类型,C++语言中有几种复合类型,其中用的最多的是引用和指针。
一条声明语句由一个基本数据类型和紧随其后的的一个声明符列表组成,每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。

引用


C++11新增了右值引用的概念,一般说的引用是指的左值引用

引用为对象起了另外一个名字,引用类型引用(refers to)另外一种类型。通过将声明符写成&d的形式来定义引用类型,其中d是变量名称。

1
2
3
int ival = 1024;
int &refVal = ival; // refVal refers to (is another name for) ival
int &refVal2; // error: a reference must be initialized

定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,将无法再令引用重新绑定到另一个对象,因此引用必须初始化

引用不是对象,它只是为一个已经存在的对象所起的另外一个名字。定义一个引用后,对其进行的所有操作都是在与之绑定的对象上进行的。

因为引用本身不是一个对象,所以不能定义引用的引用。

声明语句中引用的类型实际上被用于指定它所绑定的对象类型。大部分情况下,引用的类型要和与之绑定的对象严格匹配。

引用(非常量引用)只能绑定在对象上,不能与字面值或某个表达式的计算结果绑定在一起

引用的读法

1
2
3
4
int a = 1;
// 读法1:b是对int对象a的引用
// 读法2:b是int引用,绑定了a对象
int &b = a

指针


指针是“指向point to”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。

  • 指针本身就是一个对象,允许对指针赋值和拷贝,而且在生命周期内它可以先后指向不同的对象。
  • 指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

通过将声明符写成&d的形式来定义指针类型,其中d是变量名称。如果在一条语句中定义了多个指针变量,则每个变量前都必须有符号*。

1
2
int *ip1, *ip2;     // both ip1 and ip2 are pointers to int
double dp, *dp2; // dp2 is a pointer to double; dp is a double

指针存放某个对象的地址,要想获取对象的地址,需要使用取地址符&。

1
2
int ival = 42;
int *p = &ival; // p holds the address of ival; p is a pointer to ival

因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。

与引用类似,声明语句中指针的类型实际上被用于指定它所指向的对象类型。大部分情况下,指针的类型要和它指向的对象严格匹配。

指针值
指针的值(即地址)应属于下列状态之一:

  1. 指向一个对象。
  2. 指向紧邻对象所占空间的下一个位置。
  3. 空指针,即指针没有指向任何对象。
  4. 无效指针,即上述情况之外的其他值。

试图拷贝或以其他方式访问无效指针的值都会引发错误。
第二种和第三种形式的指针是有效的,但是没有指向任何具体对象,试图访问此类指针指向的对象的行为是不被允许的。

指针访问对象
如果指针指向一个对象,可以使用解引用(dereference)符*来访问该对象。

1
2
3
int ival = 42;
int *p = &ival; // p holds the address of ival; p is a pointer to ival
cout << *p; // * yields the object to which p points; prints 42

给解引用的结果赋值就是给指针所指向的对象赋值。解引用操作仅适用于那些确实指向了某个对象的有效指针。

&,*既可以作为表达式里的运算符,也可以用于声明符中的类型修饰符,用于组成复合类型。

空指针
空指针(null pointer)不指向任何对象,在试图使用一个指针前代码可以先检查它是否为空。得到空指针最直接的办法是用字面值nullptr来初始化指针。这里有三种方法。

1
2
3
4
int *p1 = nullptr;  // equivalent to int *p1 = 0;
int *p2 = 0; // directly initializes p2 from the literal constant 0
// must #include cstdlib
int *p3 = NULL; // equivalent to int *p3 = 0;

即使int变量的值恰好等于0,也不能拿来赋值给指针,我们提到过指针的类型要和对象严格匹配。

建议初始化所有指针。

赋值和指针
给指针赋值就是令它存放一个新的地址,从而指向一个新的对象
如果指针值为0,条件取false,任何非0指针对应的条件值都是true。

void*指针
void是一种特殊的指针类型,可以存放任意对象的地址,但不能直接操作void指针所指的对象。

引用的读法

1
2
3
4
int a = 1;
// 读法1:int指针b,指向对象a
// 读法2:b指针,指向int对象a
int *b = &a

复合类型的声明


指向指针的指针(Pointers to Pointers):

1
2
3
int ival = 1024;
int *pi = &ival; // pi points to an int
int **ppi = &pi; // ppi points to a pointer to an int

对指针的引用(References to Pointers):

1
2
3
4
5
int i = 42;
int *p; // p is a pointer to int
int *&r = p; // r is a reference to the pointer p
r = &i; // r refers to a pointer; assigning &i to r makes p point to i
*r = 0; // dereferencing r yields i, the object to which p points; changes i to 0

面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清它的真实含义。&取址符可以理解为指向,会更容易分析。

1
2
int **ppi = &pi; //从右往左,指针ppi,指向int指针    初始化:指向pi
int *&r = p; //从右往左,引用r,引用了int指针 初始化:绑定p

const限定符

在变量类型前添加关键字const可以创建值不能被改变的对象。const变量必须被初始化。

1
2
const int bufSize = 512;    // input buffer size
bufSize = 512; // error: attempt to write to const object

当以编译时初始化的方式定义一个const对象时,编译器将在编译过程中把用到该变量的地方都替换成对应的值。为了执行这个替换,编译器必须知道变量的初始值,如果程序包含多个文件,那么每个用了这个const对象的文件都必须得能访问到它的初始值才行(即每个文件都要定义const对象)。为了避免对同一变量的重复定义,默认情况下,const对象被设定成仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。

1
2
3
4
5
6
7
8
9
/*
* 下面是合法的, 不存在变量i重复定义问题
*/

// foo.cpp
const int i = 10;

// bar.cpp
const int i = 5;

如果想在多个文件之间共享const对象,那么必须在变量的定义之前添加extern关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* 下面是合法的, main.cpp和foo.cpp中的const int对象是同一个
*/

// foo.cpp
extern const int i = 10;

// main.cpp
#include <iostream>

int main(void) {
extern int i;
std::cout << "i:" << i << std::endl;
}

const的引用


把引用绑定在const对象上即为对常量的引用(reference to const)。对常量的引用不能被用作修改它所绑定的对象。

1
2
3
4
const int ci = 1024;
const int &r1 = ci; // ok: both reference and underlying object are const
r1 = 42; // error: r1 is a reference to const
int &r2 = ci; // error: non const reference to a const object

论述一下术语

1
2
3
4
5
const int a = 3;
//常量和常量对象
//一般说常量指的都是字面值常量,也就是这里的3
//常量对象指的是const对象,也就是这里的a
//对const的引用,通常可以称为常量引用

一般而言,引用的类型必须与其所引用对象的类型一致,但是有两个例外:

  • 初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用类型即可,允许为一个常量引用绑定非常量的对象、字面值甚至是一个一般表达式(如下)

  • 可以将基类的指针或引用绑定到派生类对象上(后续面向对象章节再探讨)

    1
    2
    3
    4
    5
    int i = 10;

    const int &ri1 = i; // 合法: 绑定到非常量对象
    const int &ri2 = 100; // 合法: 绑定到字面值
    const int &ri3 = 1 + 1; // 合法: 绑定到一般表达式

临时量(temporary)对象:当编译器需要一个空间来暂存表达式的求值结果时,临时创建的一个未命名的对象。对临时量的引用(非常量引用)是非法行为,常量引用可以绑定临时对象。


指针和const


指向常量的指针

与引用一样,指针也可以指向常量或非常量,指向常量的指针(pointer to const)不能用于修改其所指向的对象。要想获得常量对象的地址,只能使用指向常量的指针。

前面提过,指针的类型必须与所指向对象的类型一致,但有两个例外,第一种例外就是允许令一个指向常量的指针指向一个非常量对象。

1
2
3
4
5
6
const double pi = 3.14;     // pi is const; its value may not be changed
double *ptr = &pi; // error: ptr is a plain pointer
const double *cptr = &pi; // ok: cptr may point to a double that is const
*cptr = 42; // error: cannot assign to *cptr
double dval = 3.14; // dval is a double; its value can be changed
cptr = &dval; // ok: but can't change dval through cptr

常量指针(const指针)

定义语句中把*放在const之前用来说明指针本身是一个常量,常量指针(const pointer)必须初始化,指针值不能改变(指向不能改变,类似于引用,指针固定指向该对象,不能更改了),但是常量指针并不代表不能通过指针修改其所指向的对象的值,能否这样做完全依赖于其指向对象的类型。如果常量指针指向常量,指针指向的对象不能修改,但是如果常量指针指向非常量,那么此时就可以通过指针取修改对象的值。

1
2
3
4
5
6
7
int errNumb = 0;
int *const curErr = &errNumb; // curErr will always point to errNumb
const double pi = 3.14159;
const double *const pip = &pi; // pip is a const pointer to a const object

*pip = 2.72 ; //error: pip is a pointer to a const object
*curErr = 0; //ok: curErr is a pointer to a non-const object

顶层const与底层const


指针本身是一个对象,因此指针本身是不是常量与指针所指对象是不是常量是两个独立的问题,前者被称为顶层const,后者被称为底层const。

Tips:指针类型既可以是顶层const也可以是底层const,其他类型要么是顶层常量要么是底层常量。

顶层const用于表示任意的对象是常量,包括算数类型、类和指针等,底层const用于表示引用和指针等复合类型的基本类型部分是否是常量。

1
2
3
4
5
6
7
int i = 10;

int *const p1 = &i; // 顶层const: 不能改变p1的值
const int *p2 = &i; // 底层const: 不能通过p2改变i的值
const int *const p3 = &i; // 底层const + 顶层const

const int &r1 = i; // 底层const: 不能通过r1改变i的值

constexpr和常量表达式


常量表达式(constant expressions)指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。

一个对象是否为常量表达式由它的数据类型和初始值共同决定。

1
2
3
4
const int max_files = 20;           // max_files is a constant expression
const int limit = max_files + 1; // limit is a constant expression
int staff_size = 27; // staff_size is not a constant expression
const int sz = get_size(); // sz is not a constant expression

C++11允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。

1
2
3
constexpr int mf = 20;          // 20 is a constant expression
constexpr int limit = mf + 1; // mf + 1 is a constant expression
constexpr int sz = size(); // ok only if size is a constexpr function

指针和引用都能定义成constexpr,但是初始值受到严格限制。constexpr指针的初始值必须是0、nullptr或者是存储在某个固定地址中的对象。

函数体内定义的普通变量一般并非存放在固定地址中,因此constexpr指针不能指向这样的变量。相反,函数体外定义的变量地址固定不变,可以用来初始化constexpr指针。

在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针本身有效,与指针所指的对象无关。constexpr把它所定义的对象置为了顶层const。

1
2
3
constexpr int *p = nullptr;     // p是指向int的const指针
constexpr int i = 0;
constexpr const int *cp = &i; // cp是指向const int的const指针

const和constexpr限定的值都是常量。但constexpr对象的值必须在编译期间确定,而const对象的值可以延迟到运行期间确定。

建议使用constexpr修饰表示数组大小的对象,因为数组的大小必须在编译期间确定且不能改变。


处理类型

类型别名(Type Aliases)


类型别名是某种类型的同义词,传统方法是使用关键字typedef定义类型别名。

1
2
typedef double wages;   // wages is a synonym for double
typedef wages base, *p; // base is a synonym for double, p for double*

C++11使用关键字using进行别名声明(alias declaration),作用是把等号左侧的名字规定成等号右侧类型的别名。

1
using SI = Sales_item; // SI is a synonym for Sales_item

如果声明语句中有const,类型别名的声明语句并不是简单的替换,顶层const修饰的还是必须为顶层const。

1
2
3
4
5
6
typedef char *pstring;
//不能理解为 const char *cstr = 0,这样理解const从顶层const转变为了底层const
//所以还是要保留顶层const,正确的理解 char *const pstring = 0;
//复习一下:顶层const修饰的是对象,对于指针来说就是指针本身,底层const修饰的是指针指向的对象
const pstring cstr = 0;
const pstring *ps;

auto类型说明符(The auto Type Specifier)


C++11新增auto类型说明符,能让编译器自动分析表达式所属的类型。auto定义的变量必须有初始值。

1
2
// the type of item is deduced from the type of the result of adding val1 and val2
auto item = val1 + val2; // item initialized to the result of val1 + val2

编译器推断出来的auto类型有时和初始值的类型并不完全一样。

  • 当引用被用作初始值时,编译器以引用对象的类型作为auto的类型。

    1
    2
    int i = 0, &r = i;
    auto a = r; // a is an int (r is an alias for i, which has type int)
  • auto一般会忽略顶层const。

    1
    2
    3
    4
    5
    const int ci = i, &cr = ci;
    auto b = ci; // b is an int (top-level const in ci is dropped)
    auto c = cr; // c is an int (cr is an alias for ci whose const is top-level)
    auto d = &i; // d is an int*(& of an int object is int*)
    auto e = &ci; // e is const int*(& of a const object is low-level const)

如果希望推断出的auto类型是一个顶层const,需要显式指定const auto。

1
const auto f = ci;  // deduced type of ci is int; f has type const int

设置类型为auto的引用时,原来的初始化规则仍然适用,初始值中的顶层常量属性仍然保留。

1
2
3
auto &g = ci;   // g is a const int& that is bound to ci
auto &h = 42; // error: we can't bind a plain reference to a literal
const auto &j = 42; // ok: we can bind a const reference to a literal

decltype类型指示符(The decltype Type Specifier)


C++11新增decltype类型指示符,作用是选择并返回操作数的数据类型,此过程中编译器不实际计算表达式的值。

1
decltype(f()) sum = x;  // sum has whatever type f returns

decltype处理顶层const和引用的方式与auto有些不同,如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用)。

1
2
3
4
const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x has type const int
decltype(cj) y = x; // y has type const int& and is bound to x
decltype(cj) z; // error: z is a reference and must be initialized

decltype的结果类型与表达式密切相关,如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。
例如如果表达式的内容是解引用操作,则decltype将得到引用类型。如果decltype使用的是一个不加括号的变量,则得到的结果就是该变量的类型;
如果给变量加上了一层或多层括号,则decltype会得到引用类型,因为变量是一种可以作为赋值语句左值的特殊表达式。

decltype((var))的结果永远是引用,而decltype(var)的结果只有当var本身是一个引用时才会是引用。


自定义数据结构

类以关键字struct开始,紧跟着类名和类体(类体部分可以为空)。
一般来说最好不要把对象的定义和类的定义放在一起。

1
2
3
//类和对象分开定义
struct Sales_data {......};
Sales_data accum, trans, *salesptr;

C++11规定可以为类的数据成员(data member)提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员,没有初始值的成员将被默认初始化。

头文件(header file)通常包含那些只能被定义一次的实体,如类、const和constexpr变量。

头文件一旦改变,相关的源文件必须重新编译以获取更新之后的声明。

一个程序包含多次头文件会报错,所以需要对头文件做一些处理。确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),#include就是一项预处理功能,当预处理器看到#include标记时,就会用指定的的头文件的内容代替#include。

头文件保护符(header guard)依赖于预处理变量(preprocessor variable)。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量。#ifdef指令当且仅当变量已定义时为真,#ifndef指令当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。

1
2
3
4
5
6
7
8
9
10
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
struct Sales_data
{
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
#endif

在高级版本的IDE环境中,可以直接使用#pragma once命令来防止头文件的重复包含。

预处理变量无视C++语言中关于作用域的规则。

整个程序中的预处理变量,包括头文件保护符必须唯一。预处理变量的名字一般均为大写。

头文件即使目前还没有被包含在任何其他头文件中,也应该设置保护符。


第三章:字符串,向量和数组

内置类型是由C++语言直接定义的,这些类型体现了大多数计算机硬件本身具备的能力。标准库库定义了一组具有更高级性质的类型,它们尚未直接实现到计算机硬件中。

命名空间using的声明

using声明引入命名空间的成员,最好每个成员都使用独立的using声明

头文件不应包含using声明
如果头文件里有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。


标准库类型string

定义和初始化string对象


初始化string对象的方式:

如果使用等号初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。如果不使用等号,则执行的是直接初始化(direct initialization).


string对象上的操作


string的操作:

在执行读取操作时,string对象会自动忽略开头的空白(空格符、换行符、制表符等)并从第一个真正的字符开始读取,直到遇见下一处空白为止。

使用getline函数可以读取一整行字符。
该函数只要遇到换行符就结束读取并返回结果,如果输入的开始就是一个换行符,则得到空string。触发getline函数返回的那个换行符实际上被丢弃掉了,得到的string对象中并不包含该换行符。

size函数返回string对象的长度,返回值是string::size_type类型,这是一种无符号类型。需要注意混用int和unsigned可能带来的问题。

string对象的比较运算符
string里重载后的比较运算符逐一比较string对象中的字符,并且对大小写敏感

  • 如果两个string对象的长度不同,而且较短的string对象的每个字符都与较长string对象对应位置上的字符相同,则较短string对象 < 较长string对象。
  • 如果两个string对象在某些对应的位置上不一致,则string对象比较的结果其实是string对象中第一对相异字符比较的结果。
    1
    2
    3
    4
    // a < b < c
    string a = "Hello";
    string b = "Hello World";
    string c = "Hiya"

string对象的+运算符
当把string对象和字符字面值及字符串字面值混合在一条语句中使用时,必须确保每个加法运算符两侧的运算对象中至少有一个是string。

1
2
3
string s4 = s1 + ", ";          // ok: adding a string and a literal
string s5 = "hello" + ", "; // error: no string operand
string s6 = s1 + ", " + "world"; // ok: each + has a string operand

为了与C兼容,C++语言中的字符串字面值并不是标准库string的对象。


处理string对象中的字符


cctype头文件中定义了一组标准库函数处理string对象中的字符。

cctype vs ctype.h

range for
C++11提供了范围for(range for)语句,可以遍历给定序列中的每个元素并执行某种操作。

1
2
for (declaration : expression)
statement

expression部分是一个对象,用于表示一个序列。declaration部分负责定义一个变量,该变量被用于访问序列中的基础元素。每次迭代,declaration部分的变量都会被初始化为expression部分的下一个元素值。

1
2
3
4
string str("some string");
// print the characters in str one character to a line
for (auto c : str) // for every char in str
cout << c << endl; // print the current character followed by a newline

如果想在范围for语句中改变string对象中字符的值,必须把循环变量定义成引用类型。


要想访问string对象中的单个字符有两种方式;一种是使用下标,另外一种是使用迭代器。

[]下标运算符接收的输入参数是string::size_type类型的值,表示要访问字符的位置,返回值是该位置上字符的引用。

下标的值称为索引,任何表达式只要它的值是一个整型值就能作为索引。例如给出int整型值也会自动转化为string::size_type类型值。不过要注意,如果某个索引是带符号类型的值,它将自动转化成由string::size_type表达的无符号类型。

下标数值从0记起,范围是0至size - 1。使用超出范围的下标将引发不可预知的后果。所以使用下标访问空string也会引发不可预知的后果。


标准库类型vector

基本概念

  • 标准库类型vector表示对象的集合,也叫做容器(container),定义在头文件vector中。vector中所有对象的类型都相同,每个对象都有一个索引与之对应并用于访问该对象。
  • vector是模板(template)而非类型,由vector生成的类型必须包含vector中元素的类型,如vector
  • 类模板:本身不是类,但可以实例化instantiation出一个类,通过将类型放在类模板名称后面的尖括号中来指定类型,如vector ivec。
  • 因为引用不是对象,所以不存在包含引用的vector
  • 在早期的C++标准中,如果vector的元素还是vector,定义时必须在外层vector对象的右尖括号和其元素类型之间添加一个空格,如vector<vector >。但是在C++11标准中,可以直接写成vector<vector>,不需要添加空格。

定义和初始化vector对象


初始化vector对象的方法


初始化方式比较

  • 初始化vector对象时如果使用圆括号,可以说提供的值是用来构造(construct)vector对象的,一个初始值指示容量,两个初始值指示容量和初始值

  • 如果使用的是花括号,则是在尽可能地列表初始化(list initialize)该vector对象,如果提供的值无法列表初始化,编译器会尝试构造初始化
    eg:

    1
    2
    3
    4
    vector<string> v5{"hi"}       //列表初始化 一个元素"hi"
    vector<string> v6("hi") //错误:不能使用字符串字面值构造初始化
    vector<string> v7{10} //值无法列表初始化,自动构造初始化 10个元素
    vector<string> v8{10, "hi"} //值无法列表初始化,自动构造初始化 10个元素"hi"
  • 可以只提供vector对象容纳的元素数量而省略初始值,此时会创建一个值初始化(value-initialized)的元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中的元素类型决定。


向vector对象中添加元素


C++标准要求vector应该能在运行时高效快速地添加元素,所以常用的方式是先定义一个空的vector,然后向其添加具体值。

push_back函数可以把一个值添加到vector的尾端。

1
2
3
4
vector<int> v2;         // empty vector
for (int i = 0; i != 100; ++i)
v2.push_back(i); // append sequential integers to v2
// at end of loop v2 has 100 elements, values 0 . . . 99

范围for语句体内不应该改变其所遍历序列的大小。


其他vector操作



  • size函数返回vector对象中元素的个数,返回值是由vector定义的size_type类型。vector对象的类型包含其中元素的类型.
  • vector和string对象的下标运算符只能用来访问已经存在的元素,而不能用来添加元素.
  • 试图用下标的形式访问一个不存在的元素将引发错误,不过这种错误不会被编译器发现,而是在运行时产生一个不可预知的值。
  • 确保下标合法的一种有效手段就是尽可能地使用范围for语句。
1
2
3
4
5
6
7
8
9
vector<int>::size_type  // ok
vector::size_type // error

vector<int> ivec; // empty vector
for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
{
ivec[ix] = ix; // disaster: ivec has no elements
ivec.push_back(ix); // ok: adds a new element with value ix
}

迭代器iterator

  • 迭代器的作用和下标类似,但是更加通用。所有标准库容器都可以使用迭代器,但是其中只有少数几种同时支持下标运算符。
  • 类似于指针类型,迭代器也提供了对对象的间接访问,有效的迭代器指向某个元素或者指向容器中尾元素的下一个位置,其余所有情况都属于无效。

使用迭代器


  • vector<int>::iterator iter

  • auto b = v.begin();begin函数返回指向第一个元素的迭代器.

  • auto e = v.end();end函数返回指向容器“尾元素的下一位置(one past the end)”的迭代器,通常被称作尾后迭代器(off-the-end iterator)或者简称为尾迭代器(end iterator)

  • 如果容器为空, begin()end()返回的是同一个迭代器,都是尾后迭代器。

  • 养成使用迭代器和!=的习惯(泛型编程)。

  • const_iterator:只能读取容器内元素不能改变。

    • begin和end返回的迭代器具体类型由对象是否是常量决定,如果对象是常量,则返回const_iterator;如果对象不是常量,则返回iterator。
    • C++11新增了cbegin和cend函数,不论vector或string对象是否为常量,都返回const_iterator迭代器。
  • 箭头运算符: 解引用 + 成员访问,it->mem等价于 (*it).mem

  • 容器:可以包含其他对象;但所有的对象必须类型相同。

  • 迭代器(iterator):每种标准容器都有自己的迭代器。C++倾向于用迭代器而不是下标遍历元素。

  • 谨记:任何可能改变容器对象容量的操作,都会使该对象的迭代器失效。但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素

标准容器迭代器的运算符:

运算符 解释
*iter 返回迭代器iter所指向的元素的引用
iter->mem 等价于(*iter).mem
++iter iter指示容器中的下一个元素
--iter iter指示容器中的上一个元素
iter1 == iter2 判断两个迭代器是否相等
iter1 != iter2 不是指向同一元素,也不是同一个容器的尾后迭代器,不相等

迭代器运算


vectorstring迭代器支持的运算:

运算符 解释
iter + n 迭代器加上一个整数值仍得到一个迭代器,迭代器指示的新位置和原来相比向前移动了若干个元素。结果迭代器或者指示容器内的一个元素,或者指示容器尾元素的下一位置。
iter - n 迭代器减去一个整数仍得到一个迭代器,迭代器指示的新位置比原来向后移动了若干个元素。结果迭代器或者指向容器内的一个元素,或者指示容器尾元素的下一位置。
iter1 += n 迭代器加法的复合赋值语句,将iter1加n的结果赋给iter1
iter1 -= n 迭代器减法的复合赋值语句,将iter2减n的加过赋给iter1
iter1 - iter2 两个迭代器相减的结果是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后得到左侧的迭代器。参与运算的两个迭代器必须指向的是同一个容器中的元素或者尾元素的下一位置。
>>=<<= 迭代器的关系运算符
  • difference_type:保证足够大以存储任何两个迭代器对象间的距离,可正可负。
  • 注意迭代器之间只有减法,没有其余运算

数组

数组类似vector,但数组的大小确定不变,不能随意向数组中添加元素。
如果不清楚元素的确切个数,应该使用vector。


定义和初始化内置数组


数组是一种复合类型,声明形式为a[d],其中a是数组名称,d是数组维度(dimension)。维度必须是一个常量表达式

默认情况下,数组的元素被默认初始化。

定义数组的时候必须指定数组的类型,不允许用auto关键字由初始值列表推断类型。

如果定义数组时提供了元素的初始化列表,则允许省略数组维度,编译器会根据初始值的数量计算维度。但如果显式指明了维度,那么初始值的数量不能超过指定的大小。如果维度比初始值的数量大,则用提供的值初始化数组中靠前的元素,剩下的元素被默认初始化。

1
2
3
4
5
6
const unsigned sz = 3;
int ia1[sz] = {0,1,2}; // array of three ints with values 0, 1, 2
int a2[] = {0, 1, 2}; // an array of dimension 3
int a3[5] = {0, 1, 2}; // equivalent to a3[] = {0, 1, 2, 0, 0}
string a4[3] = {"hi", "bye"}; // same as a4[] = {"hi", "bye", ""}
int a5[2] = {0,1,2}; // error: too many initializers

可以用字符串字面值初始化字符数组,但字符串字面值结尾处的空字符也会一起被拷贝到字符数组中。

1
2
3
4
char a1[] = {'C', '+', '+'};        // list initialization, no null
char a2[] = {'C', '+', '+', '\0'}; // list initialization, explicit null
char a3[] = "C++"; // null terminator added automatically
const char a4[6] = "Daniel"; // error: no space for the null!

不能用一个数组初始化或直接赋值给另一个数组。

不同于默认的从右向左理解类型修饰,从数组的名字开始由内向外阅读有助于理解复杂数组声明的含义。

1
2
3
4
int *ptrs[10];              // ptrs is an array of ten pointers to int
int &refs[10] = /* ? */; // error: no arrays of references
int (*Parray)[10] = &arr; // Parray points to an array of ten ints
int (&arrRef)[10] = arr; // arrRef refers to an array of ten ints

访问数组元素


数组下标通常被定义成size_t类型,这是一种机器相关的无符号类型,可以表示内存中任意对象的大小。size_t定义在头文件cstddef中。

大多数常见的安全问题都源于缓冲区溢出错误。当数组或其他类似数据结构的下标越界并试图访问非法内存区域时,就会产生此类错误。


指针和数组


在大多数表达式中,使用数组类型的对象其实是在使用一个指向该数组首元素的指针。

1
2
3
string nums[] = {"one", "two", "three"};    // array of strings
string *p = &nums[0]; // p points to the first element in nums
string *p2 = nums; // equivalent to p2 = &nums[0]

当使用数组作为一个auto变量的初始值时,推断得到的类型是指针而非数组。但decltype关键字不会发生这种转换,直接返回数组类型。

1
2
3
4
5
6
7
8
int ia[] = {0,1,2,3,4,5,6,7,8,9};   // ia is an array of ten ints
auto ia2(ia); // ia2 is an int* that points to the first element in ia
ia2 = 42; // error: ia2 is a pointer, and we can't assign an int to a pointer
auto ia2(&ia[0]); // now it's clear that ia2 has type int*
// ia3 is an array of ten ints
decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9};
ia3 = p; // error: can't assign an int* to an array
ia3[4] = i; // ok: assigns the value of i to an element in ia3

C++11在头文件iterator中定义了两个名为begin和end的函数,功能与容器中的两个同名成员函数类似,参数是一个数组。

1
2
3
int ia[] = {0,1,2,3,4,5,6,7,8,9};   // ia is an array of ten ints
int *beg = begin(ia); // pointer to the first element in ia
int *last = end(ia); // pointer one past the last element in ia

标准库类型限定使用的下标必须是无符号类型,而内置的下标运算无此要求。


C风格字符串


  • C风格字符串是将字符串存放在字符数组中,并以空字符结束(null terminated)。这不是一种类型,而是一种为了表达和使用字符串而形成的书写方法。


  • C++标准支持C风格字符串,但是最好不要在C++程序中使用它们。对大多数程序来说,使用标准库string要比使用C风格字符串更加安全和高效。


  • C风格字符串的函数:

函数 介绍
strlen(p) 返回p的长度,空字符不计算在内
strcmp(p1, p2) 比较p1p2的相等性。如果p1==p2,返回0;如果p1>p2,返回一个正值;如果p1<p2,返回一个负值。
strcat(p1, p2) p2附加到p1之后,返回p1
strcpy(p1, p2) p2拷贝给p1,返回p1

  • C风格字符串函数不负责验证其参数的正确性,传入此类函数的指针必须指向以空字符作为结尾的数组。

    1
    2
    char ca[] = {'C', '+', '+'};  //不以空字符结束
    cout << strlen(ca) << endl; //严重错误: ca没有以空字符结束
  • C风格字符串和标准库string对象的同类操作差别很大,包括比较,连接,拷贝。


  • 对大多数应用来说,使用标准库 string比使用C风格字符串更安全、更高效。


与旧代码的接口


任何出现字符串字面值的地方都可以用以空字符结束的字符数组来代替:

  • 允许使用以空字符结束的字符数组来初始化string对象或为string对象赋值。

  • 在string对象的加法运算中,允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是)。

  • 在string对象的复合赋值运算中,允许使用以空字符结束的字符数组作为右侧运算对象。

不能用string对象直接初始化指向字符的指针。为了实现该功能,string提供了一个名为c_str的成员函数,返回const char*类型的指针,指向一个以空字符结束的字符数组,数组的数据和string对象一样。

1
2
3
string s("Hello World");    // s holds Hello World
char *str = s; // error: can't initialize a char* from a string
const char *str = s.c_str(); // ok

针对string对象的后续操作有可能会让c_str函数之前返回的数组失去作用,如果程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

可以使用数组来初始化vector对象,但是需要指明要拷贝区域的首元素地址和尾后地址。

1
2
3
int int_arr[] = {0, 1, 2, 3, 4, 5};
// ivec has six elements; each is a copy of the corresponding element in int_arr
vector<int> ivec(begin(int_arr), end(int_arr));

在新版本的C++程序中应该尽量使用vector、string和迭代器,避免使用内置数组、C风格字符串和指针。


多维数组


C++中的多维数组其实就是数组的数组。当一个数组的元素仍然是数组时,通常需要用两个维度定义它:一个维度表示数组本身的大小,另一个维度表示其元素(也是数组)的大小。通常把二维数组的第一个维度称作行,第二个维度称作列。

多维数组初始化的几种方式:

1
2
3
4
5
6
7
8
9
10
11
12
int ia[3][4] =
{ // three elements; each element is an array of size 4
{0, 1, 2, 3}, // initializers for the row indexed by 0
{4, 5, 6, 7}, // initializers for the row indexed by 1
{8, 9, 10, 11} // initializers for the row indexed by 2
};
// equivalent initialization without the optional nested braces for each row
int ib[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
// explicitly initialize only element 0 in each row
int ic[3][4] = {{ 0 }, { 4 }, { 8 }};
// explicitly initialize row 0; the remaining elements are value initialized
int id[3][4] = {0, 3, 6, 9};

可以使用下标访问多维数组的元素,数组的每个维度对应一个下标运算符。如果表达式中下标运算符的数量和数组维度一样多,则表达式的结果是给定类型的元素。如果下标运算符数量比数组维度小,则表达式的结果是给定索引处的一个内层数组。

1
2
3
// assigns the first element of arr to the last element in the last row of ia
ia[2][3] = arr[0][0][0];
int (&row)[4] = ia[1]; // binds row to the second four-element array in ia

使用范围for语句处理多维数组时,为了避免数组被自动转换成指针,语句中的外层循环控制变量必须声明成引用类型。

1
2
3
for (const auto &row : ia)  // for every element in the outer array
for (auto col : row) // for every element in the inner array
cout << col << endl;

如果row不是引用类型,编译器初始化row时会自动将数组形式的元素转换成指向该数组内首元素的指针,这样得到的row就是int类型,而之后的内层循环则试图在一个int内遍历,程序将无法通过编译。

1
2
for (auto row : ia)
for (auto col : row)

使用范围for语句处理多维数组时,除了最内层的循环,其他所有外层循环的控制变量都应该定义成引用类型。

因为多维数组实际上是数组的数组,所以由多维数组名称转换得到的指针指向第一个内层数组。

1
2
3
int ia[3][4];       // array of size 3; each element is an array of ints of size 4
int (*p)[4] = ia; // p points to an array of four ints
p = &ia[2]; // p now points to the last element in ia

声明指向数组类型的指针时,必须带有圆括号。

int *ip[4]; // array of pointers to int
int (*ip)[4]; // pointer to an array of four ints

使用auto和decltype能省略复杂的指针定义。

1
2
3
4
5
6
7
8
9
// print the value of each element in ia, with each inner array on its own line
// p points to an array of four ints
for (auto p = ia; p != ia + 3; ++p)
{
// q points to the first element of an array of four ints; that is, q points to an int
for (auto q = *p; q != *p + 4; ++q)
cout << *q << ' ';
cout << endl;
}

第四章:表达式

表达式(expression)由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式,其结果就是字面值和变量的值。

基础


运算符: C++定义了一元运算符(unary operator)和二元运算符(binary operator)。除此之外,还有一个作用于三个运算对象的三元运算符。函数调用也是一种特殊的运算符,它对运算对象的数量没有限制。

表达式理解:要想理解一个表达式,关注三个元素,优先级(precedence),结合律(associativity),求值顺序(order of evaluation).

运算对象转换: 表达式求值过程中,小整数类型(如bool、char、short等)通常会被提升(promoted)为较大的整数类型,主要是int

重载运算符: C++定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用于类类型的运算对象时,用户可以自定义其含义,这被称作运算符重载(overloaded operator)。

左值和右值:
C++中的表达式要么是左值要么是右值,在C语言中可以位于赋值语句左侧的是左值,不能的被称为右值。但是在C++中两者的区别没有那么明显,但是可以简单归纳为:

  • 左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象

  • 当一个对象被用作右值的时候,用的是对象的值(内容);当一个对象被用作左值的时候,用的是对象的身份(在内存中的位置)

不同的运算符对运算对象的要求不尽相同:有的需要左值运算对象,有的需要右值运算对象;有的返回值得到左值结果,有的得到右值结果。

Tips:一个重要的原则是:在需要右值的地方可以用左值代替,但是不能把右值当做左值(也就是位置)来使用。

下面列举几种需要用到左值的常用运算符:

  • 赋值运算符:需要一个非常量左值作为左侧运算对象,返回一个左值

  • 取地址符:作用于一个左值运算对象,返回一个指向该运算对象的指针右值

  • 内置解引用运算符、下标运算符、迭代器解引用运算符、vector和string的下标运算符:求值结果都是左值

  • 内置类型和迭代器的递增递减运算符:作用于左值运算对象,其前置版本所得的结果也是左值

优先级与结合律:
复合表达式(compound expression)指含有两个或多个运算符的表达式。优先级与结合律决定了运算对象的组合方式。
括号无视优先级与结合律,表达式中括号括起来的部分被当成一个单元来求值,然后再与其他部分一起按照优先级组合。

求值顺序:
并不是所有的运算符都规定了明确的求值顺序,只有&& || , ?: 这四种运算符规定了明确的求值顺序。

对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。

1
2
3
4
int i = 0;
cout << i << " " << ++i << endl; // 编译器可能先求++i, 也可能先求i

int i = f1() + f2() //f1和f2的计算先后不确定

处理复合表达式时建议遵循以下两点:

  • 不确定求值顺序时,使用括号来强制让表达式的组合关系符合程序逻辑的要求。

  • 如果表达式改变了某个运算对象的值,则在表达式的其他位置不要再使用这个运算对象。

当改变运算对象的子表达式本身就是另一个子表达式的运算对象时,第二条规则无效。如*++iter,递增运算符改变了iter的值,而改变后的iter又是解引用运算符的运算对象。类似情况下,求值的顺序不会成为问题。(总结其实这里就是在当前位置继续使用,不会造成问题)


算术运算符


运算符 功能 用法
+ 一元正号 + expr
- 一元负号 - expr
* 乘法 expr * expr
/ 除法 expr / expr
% 求余 expr % expr
+ 加法 expr + expr
- 减法 expr - expr

1. 优先级和结合律

一元运算符的优先级最高,然后是乘法和除法,优先级最低的是加法和减法。上面所有的运算符都满足左结合律,意味着优先级相同时满足从左到右的顺序进行组合。

2. 溢出

当计算结果超出该类型所能表示的范围时可能产生溢出,比如最大的short型数值为32767,这时候+1可能输出-32768(这是因为符号位从0变为1,从而变成负值)。当然在别的系统程序的行为可能不同甚至崩溃。

3. 除法与负号

Tips:C++语言的早期版本允许结果为负值的商向上或向下取整,C++11新标准规定商一律向0取整(即直接切除小数部分)。如果两个运算对象的符号相同则商为正,否则商为负。

1
2
3
4
21 / 6;    // 3
-21 / -6; // 3
21 / -6; // -3
-21 / 6; // -3

4. 取余与负号

Tips:如果m%n不等于0,那么运算结果的符号和m相同。

1
2
3
4
21 % 6;    // 3
21 % 7; // 0
-21 % -8; // -5
21 % -5; // 1

逻辑和关系运算符


结合律 运算符 功能 用法
! 逻辑非 !expr
< 小于 expr < expr
<= 小于等于 expr <= expr
> 大于 expr > expr
>= 大于等于 expr >= expr
== 相等 expr == expr
!= 不等 expr != expr
&& 逻辑与 expr && expr
|| 逻辑或 expr || expr

1. 逻辑与和逻辑或的短路求值

逻辑与&&和逻辑或||都是先求左侧对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果才会计算右侧运算对象的值,这种策略被称为短路求值。基于短路求值的特点,我们可以通过左侧运算对象来确保右侧运算对象求值的正确性和安全性:

1
2
// 只能左侧运算对象为真则右侧运算对象才安全
index != s.size() && !isspace(s[index])

2. 不要连写关系运算符

因为关系运算符的求值结果是布尔值,所以将几个关系运算符连写在一起会产生意想不到的结果:

1
2
3
4
5
// 错误写法: 用i < j的布尔值结果与k比较
if (i < j < k)

// 正确写法: 使用&&或者||连接
if (i < j && j < k)

赋值运算符


1. 运算对象与返回结果

赋值运算符的左侧运算对象必须是一个可修改的左值,返回的结果是它的左侧运算对象(仍然是左值)。

Tips:注意赋值不等于初始化,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦去,并用一个新值替代它。

1
2
3
4
5
6
int i = 0, j = 0, k = 0;  // 初始化而非赋值
const int ci = i; // 初始化而非赋值, 因此左侧运算对象可以是常量

1024 = k; // 错误: 字面值是右值
i + j = k; // 错误: 算数表达式是右值
ci = k; // 错误: ci是常量, 是不可修改的左值

2. 初始化列表赋值

C++11新标准允许使用初始化列表赋值:

1
2
3
4
5
6
// 1) 编译器warning提示窄化转换: narrowing conversion of ‘3.1499999999999999e+0’ from ‘double’ to ‘int’ inside { }
int k;
k = {3.14};

// 2) 无论左侧运算对象类型是什么, 初始值列表都可以为空, 此时编译器创造一个值初始化的临时量并将其赋给左侧运算对象
int i = {}; // i值为0

3. 赋值运算符满足右结合律

在下面的例子中,先执行j = 0,返回左侧运算对象,再执行i = j,因此执行结束后两个变量都被赋值为0。

1
2
int i, j;
i = j = 0;

4. 赋值运算符优先级较低

由于赋值运算符的优先级低于关系运算符的优先级,因此在条件语句中,赋值部分通常应该加上括号:

1
2
3
4
5
6
int i;

// 如果i = get_value()左右两侧不加括号的话, 含义就截然不同
while ((i = get_value()) != 10 ) {
// do something...
}

递增和递减运算符


1. 前置版本和后置版本

前置版本会将运算对象加1(或减1),然后将改变后的对象作为求值结果。后置版本也会将运算对象加1(或减1),但是求值结果是运算对象改变之前值的副本。这两种运算符必须作用于左侧运算对象,其中前置版本将对象本身作为左值返回,后置版本将对象原始值的副本的作为右值返回。

Tips:除非必须,否则不用递增递减运算符的后置版本。前置版本的递增运算将值加1之后直接返回该运算对象,但是后置版本需要将原始值存储下来以便于返回这个未修改的内容,如果我们不需要修改前的值的话就是一种性能上的浪费。对于整数和指针类型而言,编译器可能对这种额外的工作进行优化,但是如果是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本习惯,这样不仅不需要担心性能问题,而且不会引入非预期的错误。

1
2
3
int i = 0, j;
j = ++i; // j = 1, i = 1: 前置版本得到递增之后的值
j = i++; // j = 1, i = 2:后置版本得到递增之前的值

2. 后置版本的可能使用场景

后置版本最常用的场景就是在一条语句中混用解引用和递增运算符的后置版本:

1
2
3
4
auto pbeg = v.begin();
// 输出元素直到遇到第一个负值
while (pbeg != v.end() && *pbeg >= 0)
cout << *pebg++ << endl; // 输出当前值并将pbeg向前移动一个元素

*pbeg++这种写法非常普遍,会先把pbeg的值加1,然后返回pbeg的初始值的副本作为其求解结果,此时解引用的运算对象是pbeg未增加之前的值。


成员访问运算符


点运算符和箭头运算符都可用于访问成员,ptr->mem等价于(*ptr).mem。需要注意的是解引用运算符优先级低于点运算符,所以必须加上括号。


条件运算符


条件运算符满足右结合律,意味着运算对象一般按照从右往左的顺序组合,因此我们使用嵌套条件运算符:

1
2
finalgrade = (grade > 90) ? "high pass"
: (grade < 60) ? "fail" : "pass"

注意条件运算符的优先级非常低,所以一条长表达式中嵌套了条件运算子表达式时,通常需要在两端加上括号:

1
cout << ((grade < 60) ? "fail" : "pass"); // 输出pass或者fail

位运算符


用于检查和设置二进制位的功能。

  • 位运算符是作用于整数类型的运算对象。
  • 二进制位向左移(<<)或者向右移(>>),移出边界外的位就被舍弃掉了。
  • 位取反(~)(逐位求反)、与(&)、或(|)、异或(^

有符号数负值可能移位后变号,所以强烈建议位运算符仅用于无符号数

应用:

1
2
3
4
5
unsigned long quiz1 = 0;    // 每一位代表一个学生是否通过考试
1UL << 12; // 代表第12个学生通过
quiz1 |= (1UL << 12); // 将第12个学生置为已通过
quiz1 &= ~(1UL << 12); // 将第12个学生修改为未通过
bool stu12 = quiz1 & (1UL << 12); // 判断第12个学生是否通过

位运算符使用较少,但是重载cout、cin大家都用过

位运算符满足左结合律,优先级介于中间,使用时尽量加括号。


sizeof运算符


sizeof运算符返回一条表达式或者一个类型名字所占的字节数,所得的值是一个size_t类型(一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小)。当传入一条表达式时,sizeof运算符并不实际计算其运算对象的值。

1. 不同类型的sizeof运算结果

  • char或者类型为char的表达式执行sizeof,返回1
  • 对引用类型执行sizeof运算得到被引用对象所占空间的大小
  • 对指针执行sizeof得到指针本身所占空间的大小
  • 对解引用指针执行sizeof运算得到指针你指向的对象所占空间的大小,指针本身不需要有效
  • 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof运算并将所得结果求和
  • string对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间

2. sizeof返回常量表达式

因为sizeof的返回值是一个常量表达式,因此我们可以用sizeof的结果声明数组的维度。

3. sizeof中解引用指针

由于sizeof满足右结合律并且与*运算符的优先级一样,因此sizeof *p等价于sizeof (*p)。另外由于sizeof不会实际求运算对象的值,所以在sizeof的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正地使用。


逗号运算符


逗号运算符含有两个运算对象,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值,如果右侧运算对象是左值,那么最终的求值结果也是左值。

逗号运算符通常被用在for循环中:

1
2
3
4
5
6
vector<int>::size_type cnt = ivec.size();

// 把从size到1的值依次赋给ivec的元素
for (vector<int>::size_type ix = 0; ix != ivec.size(); ++ix, --cnt) {
ivec[ix] = cnt;
}

类型转换


在C++语言中,如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或值代替。

1
2
3
// C++不会直接将两个不同类型的值相加, 会先将3的int类型隐式转换为double类型, 再执行两个double类型相加
// 初始化i的过程中由于i的类型无法改变, 因此加法运算得到的double类型的结果会被转换为int类型的值用于初始化i
int i = 3.14 + 3;

隐式转换

1. 隐式转换发生的场景

在下面这些场景中,编译器会自动地转换运算对象的类型:

  • 比int类型小的整型值首先提升为比较大的整数类型
  • 在条件中,非布尔值转换为布尔类型(指针或者算术类型的值为0,则自动转换为false)
  • 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型
  • 如果算数运算或关系运算的运算对象有多种类型,需要转换成同一种类型
  • 函数调用时也会发生类型转换
  • 大多数用到数组的表达式会将数组自动转换为指向数组首元素的指针(数组作为decltype关键字参数、取地址符&、sizeof和typeid等运算符的运算对象时,上述自动转换不会发生)
  • 常量整数值0或者字面值nullptr能转换成任意指针类型,指向任意非常量的指针能转换为void*,指向任意对象的指针能转换为const void*
  • 转换成常量:允许指向非常量类型的指针/引用转换为指向相应的常量类型的指针/引用

2. 算数转换

算数转换的含义是指把一种算数类型转换为另一种算数类型,有如下一些规则:

  • 运算符的运算对象将转换成最宽的类型:例如一个对象是long double类型,那么无论另一个对象是什么类型都会转化为long double类型;表达式中既有浮点类型又有整数类型时,整数值将转换为对应的浮点类型
  • 如果运算对象是无符号对象和有符号对象,且无符号类型不小于带符号类型,那么带符号的类型会转化为无符号类型:例如两个类型分别是unsigned int和int,那么int类型的运算对象会转换为unsigned int,此时如果int运算对象为负值,那么存在后面提到的副作用

显式转换

1. static_cast

任何具有明确定义的类型转换,只要不包含底层const就可以使用static_cast。例如将int运算对象强制转换为double类型就可以使表达式执行浮点数除法:

1
2
int i, j;
double slope = static_cast<double>(j) / i;

static_cast也常用于编译器无法自动执行的类型转换,例如我们可以使用static_cast找回存在于void*的指针:

1
2
3
double d;
void *p = &d;
double *dp = static_cast<double*>(p);

2. const_cast

const_cast只能用于改变运算对象的底层const,用于将常量对象改成非常量对象。一旦我们去掉了某个对象的const性质,编译器就不会再组织我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为,如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果。

只有const_cast能改变表达式的常量属性,使用其他形式的强制类型转换改变表达式的常量属性都将引发编译错误。同样地,也不能用const_cast改变表达式的类型:

1
2
3
4
const char *cp;
char *q = static_cast<char*>(cp); // 错误: static_cast不能去掉const性质
static_cast<string>(cp); // 正确: 字符串字面量转换为string类型
const_cast<string>(cp); // 错误: const_cast只能改变常量属性

Tips:const_cast最常用于重载函数的情景。

1
2
3
4
5
6
7
8
9
10
11
// 常量引用的函数版本
const string &shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}

// 非常量引用的函数版本复用常量引用的函数版本
string &shorterString(string &s1, string &s2) {
const string &r = shorterString(const_cast<const string&>(s1),
const_cast<const string&>(s2));
return const_cast<string &>(r);
}

3. reinterpret_cast

reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释,例如:

1
2
int *ip;
char *cp = reinterpret_cast<char*>(ip);

我们必须牢记pc所指的真实对象是一个int而非char,如果把pc当成普通字符指针使用就可能在运行时发生错误。

4. dynamic_cast

dynamic_cast主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。

5. 旧式的强制类型转换

在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:

1
2
type(expr);  // 函数形式的强制类型转换
(type)expr; // C语言风格的强制类型转换

根据所涉及的类型不同,旧式的强制类型转换分别具有与const_caststatic_castreinterpret_cast相似的行为,例如转换后不合法,则旧式强制类型转换与reinterpret_cast具有相似的功能:

1
2
int *ip;
char *cp = (char*)ip; // 等价于使用reinterpret_cast

运算符优先级表


优先级 结合律 运算符 功能 用法
1 :: 全局作用域 ::name
1 :: 类作用域 class::name
1 :: 命名空间作用域 namespace::name
2 . 成员选择 object.member
2 -> 成员选择 pointer->member
2 [] 下标 expr[expr]
2 () 函数调用 name(epxr_list)
2 () 类型构造 type(expr_list)
3 ++ 后置递增运算 lvalue++
3 -- 后置递减运算 lvalue--
3 typeid 类型ID typeid(type)
3 typeid 运行时类型ID typeid(expr)
3 explicit cast 类型转换 cast_name<type>(expr)
4 ++ 前置递增运算 ++lvalue
4 -- 前置递减运算 --lvalue
4 ~ 位求反 ~expr
4 ! 逻辑非 !expr
4 - 一元负号 -expr
4 + 一元正号 +expr
4 * 解引用 *expr
4 & 取地址 &lvalue
4 () 类型转换 (type)expr
4 sizeof 对象的大小 sizeof expr
4 sizeof 类型的大小 sizeof(type)
4 sizeof... 参数包的大小 sizeof...(name)
4 new 创建对象 new type
4 new[] 创建数组 new type[size]
4 delete 释放对象 delete expr
4 delete[] 释放数组 delete []expr
4 noexcept 能否抛出异常 noexcept(expr)
5 ->* 指向成员选择的指针 ptr->*ptr_to_member
5 .* 指向成员选择的指针 obj.*ptr_to_member
6 * 乘法 expr * expr
6 / 除法 expr / expr
6 % 取模(取余) expr % expr
7 + 加法 expr + expr
7 - 减法 expr - expr
8 << 向左移位 expr << expr
8 >> 向右移位 expr >> expr
9 < 小于 expr < expr
9 <= 小于等于 expr <= expr
9 > 大于 expr > expr
9 >= 大于等于 expr >= expr
10 == 相等 expr == expr
10 != 不相等 expr != expr
11 & 位与 expr & expr
12 ^ 位异或 expr ^ expr
13 ` ` 位或
14 && 逻辑与 expr && expr
15 ` `
16 ? : 条件 expr ? expr : expr
16 = 赋值 lvalue = expr
16 += 复合赋值 lvalue += expr
17 throw 抛出异常 throw expr
18 , 逗号 expr, expr

第五章:语句

简单语句

  • 表达式语句:一个表达式末尾加上分号,就变成了表达式语句。
  • 空语句:只有一个单独的分号。
  • 复合语句(块):用花括号 {}包裹起来的语句和声明的序列。一个块就是一个作用域。

语句作用域

可以在if、switch、while和for语句的控制结构内定义变量,这些变量只在相应语句的内部可见,一旦语句结束,变量也就超出了其作用范围。


条件语句

C++语言提供了两种按照条件执行的语句:

  • if语句:根据条件决定控制流
  • switch语句:计算整型表达式的值,并根据这个值从几条执行路径中选择一条

if 语句


1
2
3
4
5
6
7
8
9
// 普通的if语句
if (condition)
statement

// if-else语句
if (condition)
statement
else
statement2

悬垂else(dangling else):用来描述在嵌套的if else语句中,如果ifelse多时如何处理的问题。C++使用的方法是else匹配最近没有配对的if

switch 语句


1. 例子:统计每个元音字母的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0;
char ch;
while (cin >> ch) {
switch (ch) {
case 'a':
++aCnt;
break;
case 'e':
++eCnt;
break;
case 'i':
++iCnt;
break;
case 'o':
++oCnt;
break;
case 'u':
++uCnt;
break;
}
}

2. case标签

  • case标签必须是整型常量表达式
  • 任意两个case标签的值不能相同
  • 如果某个case标签匹配成功,将从该标签开始往后顺序执行所有case分支,直到遇到break语句
  • 可以把多个case标签写在同一行内,强调这些case表示的是某个范围内的值
1
2
3
4
5
6
7
8
9
10
11
// 统计元音字母出现次数
unsigned vowelCnt = 0;
char ch;

while (cin >> ch) {
switch (ch) {
case 'a': case 'e': case 'i': case 'o': case 'u':
++vowelCnt;
break;
}
}

3. break

一般不要省略case分支最后的break语句,如果没写break语句,最好加一段注释说明程序的逻辑,否则可能带来非预期的结果。

4. default标签

Tips:标签不应该孤零零存在,它后面必须跟上一条语句或者另外一个case标签。如果switch结构以一个空的default标签作为接受,则该default标签后面必须跟上一条空语句或者一个空块。

如果没有任何一个case标签能匹配上switch表达式的值,那么程序将执行紧跟在default标签后面的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 统计元音字母和非元音字母出现次数
unsigned vowelCnt = 0;
unsigned otherCnt = 0;
char ch;

while (cin >> ch) {
switch (ch) {
case 'a': case 'e': case 'i': case 'o': case 'u':
++vowelCnt;
break;
default:
++otherCnt;
break;
}
}

4. switch内部的变量定义

switch的执行流程中可能会跨过某些case标签,如果需要为某个case分支定义并初始化一个变量,我们应该把变量定义在块内,从而保证后面所有case标签都在变量的作用域之外:

1
2
3
4
5
6
7
8
case true:
{
string file_name = get_file_name();
}
break;
case false:
// 编译报错: file_name不在作用域之内
if (file_name.empty())

迭代语句

while语句


1
2
while (condition)
statement
  • 只要condition的求值结果为true,就一直执行statement(通常是一个块)。condition不能为空,如果condition第一次求值就是false,statement一次都不会执行。

  • 定义在while条件部分或者循环体内的变量每次迭代都经历从创建到销毁的过程。

  • 在不确定迭代次数,或者想在循环结束后访问循环控制变量时,使用while比较合适。

do-while语句


1
2
3
do
statement
while (condition);
  • 计算condition的值之前会先执行一次statement,condition不能为空。如果condition的值为false,循环终止,否则重复执行statement。

  • 因为do-while语句先执行语句或块,再判断条件,所以不允许在条件部分定义变量

传统的for语句


for语句的语法形式是:

1
2
for (initializer; condition; expression)
statement
  • initializer:初始化值
  • condition:循环控制条件,为真就会执行一次statement
  • expression:修改initializer初始化的变量

initializer中可以定义多个对象,但是只能有一条声明语句,因此所有变量的基础类型必须相同:

1
2
3
4
5
// 通过for循环把vector的元素拷贝一份添加到原来的元素后面
vector<int> v;
for (decltype(v.size()) i = 0, sz = v.size(); i != sz; ++i) {
v.push_back(v[i]);
}

范围for语句


C++11新标准引入了范围for语句:

1
2
for (declaration : expression)
statement

其中expression表示的是一个序列,比如用花括号括起来的初始值列表、数组或者vector等类型的对象,这些类型的特点是拥有能返回迭代器begin和end成员。declaration定义一个变量,序列中每个元素都能转换成该变量的类型。确保类型相容最简单的办法是使用auto类型说明符。

注意事项: 在范围for循环中预存了end()的值,一旦在序列中添加(删除)元素,end函数的值就可能变得无效。


跳转语句

break 语句


break语句只能出现在迭代语句或者switch语句的内部,负责终止离它最近的while、do-while、for或者switch语句,并从这些语句之后的第一条语句开始执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
string buf;
while (cin << buf && !buf.empty()) {
switch(buf[0]) {
case '-':
for (auto if = buf.begin()+1; it != buf.end(); ++it) {
if (*it == '') {
break; // 第一个break: 离开for循环
}
}
break; // 第二个break: 离开switch
case '+':
// do something...
}
}

continue 语句


continue语句只能出现在迭代语句的内部,负责终止离它最近的循环的当前一次迭代并立即开始下一次迭代。和break语句不同的是,只有当switch语句嵌套在迭代语句内部时,才能在switch中使用continue。

continue语句中断当前迭代后,具体操作视迭代语句类型而定:

  • 对于while和do-while语句来说,继续判断条件的值。

  • 对于传统的for语句来说,继续执行for语句头中的第三部分,之后判断条件的值。

  • 对于范围for语句来说,是用序列中的下一个元素初始化循环变量。


try语句块和异常处理

异常(exception)是指程序运行时的反常行为,这些行为超出了函数正常功能的范围。当程序的某一部分检测到一个它无法处理的问题时,需要使用异常处理(exception handling)。

异常处理机制包括throw表达式(throw expression)、try语句块(try block)和异常类(exception class)。

  • 异常检测部分使用throw表达式表示它遇到了无法处理的问题(throw引发了异常)。

  • 异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理,catch子句也被称作异常处理代码(exception handler)。

  • 异常类用于在throw表达式和相关的catch子句之间传递异常的具体信息。

throw表达式


throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。

1
2
3
#include <stdexcept>

throw runtime_error("tomocat");

try语句块


try语句块的通用形式:

1
2
3
4
5
6
7
8
9
10
11
12
try
{
program-statements
}
catch (exception-declaration)
{
handler-statements
}
catch (exception-declaration)
{
handler-statements
} // . . .

try语句块中的program-statements组成程序的正常逻辑,其内部声明的变量在块外无法访问,即使在catch子句中也不行。catch子句包含关键字catch、括号内一个对象的声明(异常声明,exception declaration)和一个块。当选中了某个catch子句处理异常后,执行与之对应的块。catch一旦完成,程序会跳过剩余的所有catch子句,继续执行后面的语句。

如果最终没能找到与异常相匹配的catch子句,程序会执行名为terminate的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。类似的,如果一段程序没有try语句块且发生了异常,系统也会调用terminate函数并终止当前程序的执行。

标准异常


异常类分别定义在4个头文件中:

  • 头文件exception定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。

  • 头文件stdexcept定义了几种常用的异常类。
    stdexcept头文件中定义的异常类如下:

异常类 含义
exception 最常见的问题
runtime_error 只有在运行时才能检测出的问题
range_error 运行时错误:生成的结果超出了有意义的值域范围
overflow_error 运行时错误:计算上溢
underflow_error 运行时错误:计算下溢
logic_error 程序逻辑错误
domian_error 逻辑错误:参数对应的结果值不存在
invalid_argument 逻辑错误:无效参数
length_error 逻辑错误:试图创建一个超出该类型最大长度的对象
out_of_range 逻辑错误:使用一个超出有效范围的值
  • 头文件new定义了bad_alloc异常类。

  • 头文件type_info定义了bad_cast异常类。

  • 异常类只定义了一个名为what的成员函数,返回一个提供错误信息的C风格字符串

  • 如果异常类型有一个字符串初始值,那么what方法返回该字符串;对于其他无初始值的异常类型来说,what返回的内容由编译器决定


第六章:函数

函数基础

  • 函数定义:包括返回类型、函数名字和0个或者多个形参(parameter)组成的列表和函数体。
  • 调用运算符:调用运算符的形式是一对圆括号 (),作用于一个表达式,该表达式是函数或者指向函数的指针。
  • 圆括号内是用逗号隔开的实参(argument)列表。
  • 函数调用过程:
    • 1.主调函数(calling function)的执行被中断。
    • 2.被调函数(called function)开始执行。
  • 形参和实参:形参和实参的个数类型必须匹配上。
  • 返回类型void表示函数不返回任何值。函数的返回类型不能是数组类型或者函数类型,但可以是指向数组或者函数的指针。
  • 名字:名字的作用于是程序文本的一部分,名字在其中可见。

局部对象


  • 生命周期:对象的生命周期是程序执行过程中该对象存在的一段时间。
  • 局部变量(local variable):形参和函数体内部定义的变量统称为局部变量。它对函数而言是局部的,对函数外部而言是隐藏的。
  • 自动对象:只存在于块执行期间的对象。当块的执行结束后,它的值就变成未定义的了。
  • 局部静态对象static类型的局部变量,生命周期贯穿函数调用前后。

函数声明


  • 函数声明:函数的声明和定义唯一的区别是声明无需函数体,用一个分号替代。函数声明主要用于描述函数的接口,也称函数原型
  • 在头文件中进行函数声明:建议变量在头文件中声明;在源文件中定义。
  • 分离编译CC a.cc b.cc直接编译生成可执行文件;CC -c a.cc b.cc编译生成对象代码a.o b.oCC a.o b.o编译生成可执行文件。

参数传递

形参初始化的机理与变量初始化一样,形参的类型决定了形参和实参交互的方式:

  • 当形参是引用类型时,它对应的实参被引用传递(passed by reference),函数被传引用调用(called by reference)。引用形参是它对应实参的别名。

  • 当形参不是引用类型时,形参和实参是两个相互独立的对象,实参的值会被拷贝给形参(值传递,passed by value),函数被传值调用(called by value)。

传值参数


如果形参不是引用类型,则函数对形参做的所有操作都不会影响实参。

1
2
3
4
5
6
// function that takes a pointer and sets the pointed-to value to zero
void reset(int *ip)
{
*ip = 0; // changes the value of the object to which ip points
ip = 0; // changes only the local copy of ip; the argument is unchanged
}

使用指针类型的形参可以访问或修改函数外部的对象,建议使用引用形参代替指针形参。

传引用参数


通过使用引用形参,函数可以改变实参的值。

1
2
3
4
5
// function that takes a reference to an int and sets the given object to zero
void reset(int &i) // i is just another name for the object passed to reset
{
i = 0; // changes the value of the object to which i refers
}
  • 使用引用形参可以避免拷贝操作,拷贝大的类类型对象或容器对象比较低效。另外有的类类型(如IO类型)根本就不支持拷贝操作,这时只能通过引用形参访问该类型的对象。

  • 除了内置类型、函数对象和标准库迭代器外,其他类型的参数建议以引用方式传递。

  • 如果函数无须改变引用形参的值,最好将其声明为常量引用。

  • 一个函数只能返回一个值,但利用引用形参可以使函数返回额外信息。

const形参和实参


  • 当形参有顶层const时,传递给它常量对象或非常量对象都是可以的。因为实参初始化形参时会忽略掉顶层const

  • 通用的初始化规则:可以使用非常量对象初始化一个底层const形参,但是反过来不行。同时一个普通的引用必须用同类型的对象初始化

  • 把函数不会改变的形参定义成普通引用会极大地限制函数所能接受的实参类型,同时也会给别人一种误导,即函数可以修改实参的值。

数组形参


1. 传递数组形参

数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响:

  • 不允许拷贝数组:无法以值传递的方式使用数组参数
  • 使用数组时会将其转换成指针:当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针

尽管不能以值传递的方式传递数组,但是我们把形参写成类似数组的形式:

1
2
3
4
// 下面三个函数等价, 都接受const int*类型的形参
void print(const int*);
void print(const int[]);
void print(const int[10]); // 这里的维度表示我们期望数组含有多少个元素, 实际上不一定

2. 传递数组形参大小

由于数组是以指针的形式传递给函数的,所以函数并不知道数组的确切尺寸,调用者一般需要提供一些额外的信息。管理数组形参通常有三种技术:

2.1 数组中包含结束标记(一般只有C风格字符串)

第一种方法要求数组本身包含一个结束标记,最典型的例子是C风格字符串,接受C风格字符串的函数在遇到空字符时就会停止:

1
2
3
4
5
6
7
void print(const char *cp) {
if (cp) { // cp不是空指针
while (*cp) { // 指针所指字符不是空字符
cout << *cp++; // 输出当前字符并将指针向前移动一个位置
}
}
}
2.2 使用标准库规范

Tips:标准库begin和end函数可以返回数组的首元素指针和尾后元素指针。

第二种方式是传递指向数组首元素和尾后元素的指针:

1
2
3
4
5
6
7
8
void print(const int *beg, const int *end) {
while (beg != end) {
cout << *beg++ << endl;
}
}

int j[2] = {0, 1};
print(begin(j), end(j));
2.3 显式传递一个表示数组大小的形参

第三种方法是专门定义一个表示数组大小的形参:

1
2
3
4
5
6
7
8
9
10
// const int ia[]等价于const int *ia
// size表示数组的大小
void print(const int ia[], size_t size) {
for (size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}

int j[] = {0, 1};
print(j, end(j) - begin(j));

3. 数组形参与const

当函数不需要对数组元素执行写操作时,数组形参应该是指向const的指针。只有当函数确实要改变元素值的时候,才把形参定义成指向常量的指针。

4. 数组引用形参

Tips:当形参是数组的引用时,维度也是类型的一部分。

C++语言允许将变量定义为数组的引用:

1
2
3
4
5
6
// 形参是数组的引用, 维度是类型的一部分
void print(int (&arr)[10]) {
for (auto elem : arr) {
cout << elem << endl;
}
}

注意arr两边的括号是必不可少的:

1
2
f(int &arr[10]);    // 错误: 将arr声明成了引用的数组
f(int (&arr)[10]); // 正确: arr是具有10个整数的整型数组的引用

由于数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内我们可以放心地使用数组。但是这一用法也无形中限制了print函数的可用性,我们只能将函数作用于维度为10的数组。

5. 传递多维数组

前面我们提到过C++中并没有真正的多维数组,所谓的数组其实是数组的数组。和所有的数组一样,当我们把多维数组传递给函数时,实际上传递的是指向数组首元素的指针,即一个指向数组的指针。

Tips:由于数组第二维以及后面的维度的大小都是数组类型的一部分,因此传递多维数组时不能省略。

1
2
3
4
5
6
// matrix是指向含有10个整数的数组的指针
void print(int (*matrix)[10], int rowSize);

// 等价定义
// 由于编译器会忽略掉第一个维度, 因此最好不要把它包含在形参列表内
void print(int matrix[][10], int rowSize);

main函数处理命令行选项


假设我们的可执行文件名为prog,我们可以向程序传递如下选项:

1
prog -d -o ofile data0

这些选项会通过两个可选的形参传递给main函数:

1
2
3
4
int main(int argc, char *argv[]);

// 等价
int main(int argc, char** argv);

其中第二个形参argv是一个数组,它的元素是指向C风格字符串的指针,第一个形参argc表示数组中字符串的数量。

当实参传递给main函数之后,argv第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0。

在前面的例子中,argc等于5,argv指向的类型如下:

1
2
3
4
5
6
argv[0] = "prog";  // 或者一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;

可变形参


initializer_list提供的操作(C++11):

操作 解释
initializer_list<T> lst; 默认初始化;T类型元素的空列表
initializer_list<T> lst{a,b,c...}; lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const
lst2(lst) 拷贝或赋值一个initializer_list对象不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素。
lst2 = lst 同上
lst.size() 列表中的元素数量
lst.begin() 返回指向lst中首元素的指针
lst.end() 返回指向lst中微元素下一位置的指针

initializer_list使用demo:

1
2
3
4
5
6
7
8
void err_msg(ErrCode e, initializer_list<string> il){
cout << e.msg << endl;
for (auto bed = il.begin(); beg != il.end(); ++ beg)
cout << *beg << " ";
cout << endl;
}

err_msg(ErrCode(0), {"functionX", "okay});
  • 所有实参类型相同,可以使用 initializer_list的标准库类型。
  • 实参类型不同,可以使用可变参数模板
  • 省略形参符: ...,便于C++访问某些C代码,这些C代码使用了 varargs的C标准功能。

返回类型和return语句

无返回值函数


没有返回值的 return语句只能用在返回类型是 void的函数中,返回 void的函数不要求非得有 return语句。

有返回值函数


  • return语句的返回值的类型必须和函数的返回类型相同,或者能够隐式地转换成函数的返回类型。
  • 值的返回:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
  • 不要返回局部对象的引用或指针
  • 引用返回左值:函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值;其他返回类型得到右值。
  • 列表初始化返回值:函数可以返回花括号包围的值的列表。(C++11
  • 主函数main的返回值:如果结尾没有return,编译器将隐式地插入一条返回0的return语句。返回0代表执行成功。

不要返回局部对象的引用或指针


函数完成后它所占用的存储空间也会被释放掉,因此局部变量的引用将指向不再有效的内存区域:

1
2
3
4
5
6
7
8
9
// 严重错误: 试图返回局部对象的引用
const string &foo() {
string ret;
if (!ret.empty()) {
return ret; // 错误: 返回局部对象的引用
} else {
return "Empty"; // 错误: 返回局部对象的引用
}
}

列表初始化返回值


C++11新标准规定,函数可以通过列表初始化来对函数返回的临时量进行初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string>
#include <vector>

std::vector<std::string> foo(int i) {
if (i < 5) {
return {}; // 返回一个空vector对象
}
return {"tomo", "cat", "tomocat"}; // 返回列表初始化的vector对象
}

int main() {
foo(10);
}

main函数返回值


main函数的返回值可以看成是状态指示器,返回0表示成功,返回其他值表示失败。cstdlib头文件定义了两个预处理变量,分别表示成功和失败:

1
2
3
4
5
6
7
int main() {
if (some_failure) {
return EXIT_FAILURE;
} else {
return EXIT_SUCCESS:
}
}

返回函数指针


由于数组不能拷贝,因此函数不能返回数组,不过可以返回数组的指针或者引用。想要定义一个返回数组的引用或者指针的函数比较繁琐,不过我们可以使用类型别名来简化这一任务:

1
2
3
4
5
6
// arrT: 包含10个整型元素数组的类型别名
typedef int arrT[10];
// arrT的等价声明
using arrT = int[10];

arrT* func(int i); // 返回指向10个整数的数组的指针

如果不使用类型别名,那么相同的函数我们需要写成:

1
int (*func(int i))[10];

C++11允许我们使用尾置返回类型:

1
auto func(int i) -> int(*)[10];

还有一种情况是如果我们直到函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型:

1
2
3
4
5
6
7
int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};

// 根据i指向的不同返回两个已知数组中的一个
decltype(odd) *arrPtr(int i) {
return (i % 2) ? &odd : &even;
}

尾置返回类型


编码规范:只有在常规写法(返回类型前置)不便于书写或者不便于阅读时才使用返回类型后置语法。

C++现在允许两种不同的函数声明方式,以往的写法是将返回类型置于函数名之前:

1
int foo(int x);

C++11新标准引入了尾置返回类型,可以在函数名前使用auto关键字,在参数列表之后后置返回类型,例如:

Tips:尾置返回类型是显式地指定Lambda表达式返回值的唯一方式,当返回类型依赖模板参数时也可以使用使用尾置返回类型。

1
2
3
4
5
6
7
8
// 普通函数
auto foo(int x) -> int;

// lambda表达式
auto f = []() -> int { return 42; };

// 模型函数
template <class T, class U> auto add(T t, U u) -> decltype(t + u);

函数重载

如果同一作用域内几个函数名字相同但形参列表不同,我们称之为重载(overload)函数。

main函数不能重载。

### 重载和const形参

1. 顶层const

一个有顶层const的形参和没有它的函数无法区分。Record lookup(Phone* const)Record lookup(Phone*)无法区分。

1
2
3
4
5
6
7
// 错误: 重复声明foo(string)函数
void foo(string);
void foo(const string);

// 错误: 重复声明foo(string*)函数
void bar(string*);
void bar(string* const);

2. 底层const

相反,是否有某个底层const形参可以区分。 Record lookup(Account*)Record lookup(const Account*)可以区分。

1
2
3
4
5
6
7
// 正确
void foo(string&);
void foo(const string&);

// 正确
void bar(string*);
void bar(const string*);

const_cast与重载

const_cast最常用于重载函数的情景。

1
2
3
4
5
6
7
8
9
10
11
// 常量引用的函数版本
const string &shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}

// 非常量引用的函数版本复用常量引用的函数版本
string &shorterString(string &s1, string &s2) {
const string &r = shorterString(const_cast<const string&>(s1),
const_cast<const string&>(s2));
return const_cast<string &>(r);
}

重载和作用域


在不同的作用域中无法重载函数名。一旦在当前作用域内找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。

1
2
3
4
5
6
7
8
9
10
11
12
13
string read();
void print(const string &);
void print(double); // overloads the print function
void fooBar(int ival)
{
bool read = false; // new scope: hides the outer declaration of read
string s = read(); // error: read is a bool variable, not a function
// bad practice: usually it's a bad idea to declare functions at local scope
void print(int); // new scope: hides previous instances of print
print("Value: "); // error: print(const string &) is hidden
print(ival); // ok: print(int) is visible
print(3.14); // ok: calls print(int); print(double) is hidden
}

在C++中,名字查找发生在类型检查之前。


特殊用途语言特性

默认实参


  • string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');
  • 一旦某个形参被赋予了默认值,那么它之后的形参都必须要有默认值。

内联(inline)函数


  • 普通函数的缺点:调用函数比求解等价表达式要慢得多。
  • inline函数可以避免函数调用的开销,可以让编译器在编译时内联地展开该函数。
  • inline函数应该在头文件中定义。

constexpr函数


  • 指能用于常量表达式的函数。
  • constexpr int new_sz() {return 42;}
  • 函数的返回类型及所有形参类型都要是字面值类型。
  • constexpr函数应该在头文件中定义。

调试帮助


  • assert预处理宏(preprocessor macro):assert(expr);

开关调试状态:

CC -D NDEBUG main.c可以定义这个变量NDEBUG

1
2
3
4
5
void print(){
#ifndef NDEBUG
cerr << __func__ << "..." << endl;
#endif
}

函数匹配

  • 重载函数匹配的三个步骤:1.候选函数;2.可行函数;3.寻找最佳匹配。
  • 候选函数:选定本次调用对应的重载函数集,集合中的函数称为候选函数(candidate function)。
  • 可行函数:考察本次调用提供的实参,选出可以被这组实参调用的函数,新选出的函数称为可行函数(viable function)。
  • 寻找最佳匹配:基本思想:实参类型和形参类型越接近,它们匹配地越好。
  • const重载:顶层const会忽略,所以不可以函数重载,底层const虽然非常量对象既可以初始化const形参和非const形参,但是编译器为了避免类型转换,会选择非常量版本的函数,所以底层const可以重载。
    1
    2
    3
    4
    5
    6
    7
    Record lookup(Account&);    // function that takes a reference to Account
    Record lookup(const Account&); // new function that takes a const reference

    const Account a;
    Account b;
    lookup(a); // calls lookup(const Account&)
    lookup(b); // calls lookup(Account&)

调用重载函数
调用重载函数时有三种可能的结果:

  • 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码

  • 找不到任何一个函数与调用的实参匹配,这时候编译器发出无匹配的错误信息

  • 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择,此时会发生二义性调用的错误


函数指针

函数指针指向的是函数而非对象,与其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。

1
2
3
bool lengthCompare(const string &, const string &);
// pf是一个未初始化的函数指针: 参数是两个const string的引用, 返回值是bool类型
bool (*pf) (const string &, const string &);

把函数名作为一个值时, 该函数自动转换成指针:

1
2
3
4
// pf指向名为lengthCompare的函数
pf = lengthCompare;
// 等价写法
pf = &lengthCompare;

我们可以使用函数指针调用该函数:

1
2
3
4
// 等价的三种写法
bool b1 = pf("tomo", "cat");
bool b2 = (*pf)("tomo", "cat");
bool b3 = lengthCompare("tomo", "cat");

函数指针形参


虽然不能定义函数类型的形参,但是形参可以是指向函数的指针:

1
2
3
4
5
6
// 第三个参数是函数类型, 它会自动转换成函数指针
void useBigger(const string &s1, const string &s2,
bool pf(const string &, const string &));
// 等价声明: 显式将形参定义成函数指针
void useBigger(const string &s1, const string &s2,
bool (*pf) (const string &, const string &));

我们可以使用类型别名和decltype来简化使用了函数指针的代码:

Tips:decltype返回函数类型,此时不会将函数类型自动转换成指针类型,只有在结果前面加上*才能得到函数指针。

1
2
3
4
5
6
7
8
9
10
11
// Func1和Func2是函数类型
typedef bool Func1(const string&, const string&);
typedef decltype(lengthCompare) Func2;
// FuncP1和FuncP2是函数指针类型
typedef bool(*FuncP1)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2;
// 使用类型别名简化useBigger函数的声明
void useBigger(const string&, const string&, Func1);
void useBigger(const string&, const string&, Func2);
void useBigger(const string&, const string&, FuncP1);
void useBigger(const string&, const string&, FuncP2);

返回指向函数的指针


1. 类型别名using简化返回函数指针的函数声明

一般情况下直接声明返回函数指针的函数比较复杂:

1
2
// foo参数为int, 返回值是int(*)(int*, int)的函数指针
int (*foo(int))(int*, int);

新标准下我们可以使用using关键字定义类型别名:

1
2
3
4
// F是函数类型而非函数指针类型
using F = int(int*, int);
// PF是函数指针类型
using PF = int(*)(int*, int);

有了类型别名我们可以将foo函数重新声明为:

1
2
3
4
// foo接收int类型作为参数, 返回PF的函数指针
PF foo(int);
// 等价写法
F *foo(int);

2. 尾置返回类型

前面提到的foo函数还有另外一种声明方式:

1
auto foo(int) -> int(*)(int* int);

第七章: 类

定义抽象数据类型

  • 类背后的基本思想数据抽象(data abstraction)和封装(encapsulation)。
  • 数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程技术。

类成员


  • 必须在类的内部声明,不能在其他地方增加成员。
  • 成员可以是数据,函数,类型别名。

    Tips:用来定义类型的成员和普通成员不同,必须先定义后使用,因此类型成员通常出现在类开始的地方。

1
2
3
4
5
6
7
8
9
class Screen {
public:
// 类型别名, 等价于: using pos = std::string::size_type
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};

成员函数


1. 声明与定义

  • 成员函数的声明必须在类的内部。
  • 成员函数的定义既可以在类的内部也可以在外部。

2. this指针

  • 每个成员函数都有一个额外的,隐含的形参this
  • this总是指向当前对象,因此this是一个常量指针。
  • 形参表后面的const,改变了隐含的this形参的类型,如 bool same_isbn(const Sales_item &rhs) const,这种函数称为“常量成员函数”(this指向的当前对象是常量)。
  • return *this;可以让成员函数连续调用。
  • 普通的非const成员函数:this是指向类类型的const指针(可以改变this所指向的值,不能改变this保存的地址)。
  • const成员函数:this是指向const类类型的const指针(既不能改变this所指向的值,也不能改变this保存的地址)。

3. const限定符

  • 默认情况下,this的类型是指向类类型非常量版本的常量指针。this也遵循初始化规则,所以默认不能把this绑定到一个常量对象上,即不能在常量对象上调用普通的成员函数。

  • C++允许在成员函数的参数列表后面添加关键字const,表示this是一个指向常量的指针。使用关键字const的成员函数被称作常量成员函数

  • 常量对象和指向常量对象的引用或指针都只能调用常量成员函数。


非成员函数


类的作者通常会定义一些辅助函数,尽管这些函数从概念上来说属于类接口的组成部分,但实际上它们并不属于类本身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// input transactions contain ISBN, number of copies sold, and sales price
istream &read(istream &is, Sales_data &item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}

ostream &print(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}

如果非成员函数是类接口的组成部分,则这些函数的声明应该与类放在同一个头文件中。

一般来说,执行输出任务的函数应该尽量减少对格式的控制。


构造函数


  • 类通过一个或几个特殊的成员函数来控制其对象的初始化操作,这些函数被称作构造函数。只要类的对象被创建,就会执行构造函数。

  • 构造函数的名字和类名相同,没有返回类型,且不能被声明为const函数。构造函数在const对象的构造过程中可以向其写值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Sales_data
    {
    // constructors added
    Sales_data() = default;
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(std::istream &);
    // other members as before
    };

默认构造函数

在C++11中,如果类需要默认的函数行为,可以通过在参数列表后面添加=default来要求编译器生成构造函数。其中=default既可以和函数声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的。

1
Sales_data() = default;

构造函数初始值列表

构造函数初始值列表(constructor initializer list)负责为新创建对象的一个或几个数据成员赋初始值。形式是每个成员名字后面紧跟括号括起来的(或者在花括号内的)成员初始值,不同成员的初始值通过逗号分隔。

1
2
3
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }

当某个数据成员被构造函数初始值列表忽略时,它会以与合成默认构造函数相同的方式隐式初始化。

1
2
3
// has the same behavior as the original constructor defined above
Sales_data(const std::string &s):
bookNo(s), units_sold(0), revenue(0) { }

构造函数不应该轻易覆盖掉类内初始值,除非新值与原值不同。如果编译器不支持类内初始值,则所有构造函数都应该显式初始化每个内置类型的成员。


拷贝、赋值和析构(Copy、Assignment,and Destruction)


编译器能合成拷贝、赋值和析构函数,但是对于某些类来说合成的版本无法正常工作。特别是当类需要分配类对象之外的资源时,合成的版本通常会失效。


访问控制与封装


C++ Primer Part1 - C++基础
https://vendestine.com/c++-primer-p1
Author
Wenzhe Li
Posted on
April 7, 2023
Licensed under