* [《C++标准程序库》]()
* [《Effective C++》]()
* - 《Linux 多线程服务器编程》
Processes and Threads (Windows)
https://docs.microsoft.com/en-us/windows/win32/procthread/processes-and-threads?redirectedfrom=MSDN
Synchronization (Windows)
https://docs.microsoft.com/en-us/windows/win32/sync/synchronization?redirectedfrom=MSDN
- 《UNIX 环境高级编程》
- 《UNIX 网络编程》
- 《现代操作系统》
- 《c++标准库》
- 《c++ concurrency in action》
* 《C++ Concurrency in Action》
* [Synchronization(windows)](https://docs.microsoft.com/en-us/windows/win32/sync/synchronization?redirectedfrom=MSDN)
* [Processes and Threads(windows)](https://docs.microsoft.com/en-us/windows/win32/procthread/processes-and-threads?redirectedfrom=MSDN)
* [Google C++ Style Guide](https://google.github.io/styleguide/cppguide.html)
c++11 c++的蜕变 GNU编译器
本书的结构 第一部分和第二部分中介绍了C++语言和标准库的内容。这两部分包含的内容足够你编写出有意义的程序,而不是只能写一些玩具程序。大部分程序员基本上都需要掌握本书这两部分所包含的所有内容。
第一章 开始
主要内容: 大部分基础内容, 类型 变量 表达式和函数. 学习完你将具备编写 编译 及运行简单程序的能力
1.1 编写一个简单的C++程序
int main(){
return 0;
}
第一部分 C++基础
第二章 变量和基本类型
2.1 基本内置类型
- 算术类型
arithmetic type
字符、整型、布尔值、浮点型 - 空类型
void
函数不返回值时返回空类型
2.1.1 算术类型
分为整型(integral type, 包括字符和布尔类型)和浮点型
算术类型的尺寸(所占比特数)在不同机器上有差别
带符号类型和无符号类型
signed
正数、负数、0unsigned
大于0的数
int
、 short
、 long
和 long long
都是带符号,通过添加 unsigned
就可以得到无符号类型。 unsighed int
unsigned short
字符类型与其他不同,有三种 char
signed char
unsigned char
,但最终的类型还是只有:带符号的和无符号的两种。 char
和 signed char
并不相等, char
具体表现为哪种类型由编译器的实现决定。
无符号类型中的所有比特都用来存储值,例如,8比特的 unsigned char
表示0-255区间内的值
C++语言规定一个int至少和一个short一样大,一个long至少和一个int一样大,一个long long至少和一个long一样大。long long是C++11中定义的
2.1.2 类型转换
- 非布尔的算数值赋值给布尔: 初始值为0时结果为false,否则结果为true
- 布尔值赋值给非布尔值:初始值为false结果为0, 初始值为true结果为1
- 浮点值赋值给整数:取小数点前面整数
- 整数赋值给浮点数:小数部分记为0, 若整数所占空间超过浮点类型的容量,精度可能有损失
- 赋值给无符号类型一个超出它范围的值时候:结果是对无符号类型数值总数取模后的余数。
- 赋值给有符号类型一个超出它表示范围的值时候:结果为undefined。此时程序可能继续工作,可能崩溃,也可能生成垃圾数据。
建议:避免未知和依赖于实现环境的行为
含有无符号类型的表达式会将结果转化为无符号类型。
提示:不要混用带符号类型和无符号类型
2.1.3 字面值常量
整型和浮点字面值
20 // 十进制
024 // 八进制
0x14 // 十六进制
3.14159
3.14159E0
0.
0e0
.001
字符和字符串字面值
'a' // 字符字面值
"hello wolrd" // 字符串字面值
字符串字面值实际上是由常量字符构成的数组(array), 编译器会在每个字符串的结尾处加上一个空字符 '\0'
。因此。字符串字面值的实际长度要比它的内容多1。
'A' // 表示 A
"A" // 表示 A\0
如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则它们实际上是一个整体
std::cout<< "hello" "world"
"in C++"
转义序列
有两类字符程序员不能直接使用:
1 不可打印字符,如退格,因为它们没有可视的图符。
2 特殊含义字符(单引号,双引号,问号,反斜线),需要用转义序列
cout << '\n'; // 转到新一行
cout << '\tHi!\n' // 输出一个制表符,输出“Hi!”,转到新一行
泛化的转义序列,形式是\x后紧跟着1个或多个十六进制的数字,或者紧跟着1个或多个八进制的数字
cout << "Hi \x4dO\115!\n" // 输出 Hi MOM!,转到新一行
指定字面值的类型
布尔字面值和指针字面值 true和false是布尔类型的字面值
bool test=false
nullptr
是指针的字面值
2.2 变量
2.2.1 变量定义
类型说明符(type speccifier),随后紧跟一个或者多个变量名组成的列表,变量名以逗号分隔,最后以分号结束。
int sum = 0 , value ,
units_sold=0;
Sales_item item;
std::string book("I am a Book");
<<C++Primer>>
书中的变量和对象通常指的都是同一个东西->变量
初始值
当变量在创建时获得了一个特定的值,我们说这个变量被初始化了(initailized)
在C++中初始化是一个复杂的问题,在C++中赋值和初始化是两个完全不同的操作,然而在很多编程语言中二者的区别几乎可以忽略不计。
初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把变量的当前值擦除,用一个新值来替换。
列表初始化 C++语言定义了初始化的好几种不同的形式。
// 定义一个名为unit_sold的int变量并初始化为0
int unit_sold = 0;
int unit_sold = {0};
int unit_sold{0};
int unit_sold(0);
使用花括号来初始化变量是c++11标准的一部分,被称为列表初始化。当用于内置类型的变量时,这种初始化形式有个重要的特点:使用列表初始化如果存在丢失信息的风险时,会在编译时报错。
long double id=3.1415926536;
int a{id}, b={id} // 编译报错,转换未执行,long double 转int会丢失精度。
int c(id), d=id // 不报错,自动转换,会丢失精度
默认初始化 如果变量没有指定初始值,则被默认初始化(default initialized)
内置类型的变量的默认初始化,它的值由定义的位置决定
- 函数体外的变量初始化为0
- 函数体内部的内置类型变量将 不被初始化(uninitialized), 即未定义的,此时试图拷贝或者以其他形式访问此类值将会发生错误
类的初始化方式由自己决定,是否初始化和如何初始化
绝大多数类都支持无须显示初始化而定义对象,这样的类提供了一个合适的默认值。
定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显式初始化,则其值由类确定。
2.2.2 变量的声明和定义的关系
C++支持分离式编译(separate compilation), 该机制允许将程序分割为若干哥文件,每个文件可被独立编译。
为了支持分离式编译,C++语言将声明和定义区分开来
- 声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明
- 而定义(definition)负责创建与名字关联的实体
变量声明规定变量的类型和名字 定义规定变量的类型和名字, 还申请存储空间,也可能回味变量赋一个初始值
使用extern声明一个变量
extern int i; // 声明而非定义i
int i; // 声明并定义i
extern语句如果包含初始化的值就不是声明了,而是定义
extern int i=1; // 定义
extern double pi=3.1415926; // 定义
变量只能被定义一次,但是可以被多次声明
C++是静态类型(static typed)语言,会在编译阶段检查类型。其中检查类型的过程称为类型检查(type checking)
2.2.3 标识符
C++的标识符(identifier)由字母数字和下划线组成,其中必须以字母或下划线开头。长度没有限制,但对字母大小写敏感。
变量命名规范
- 标识符要能体现实际含义
- 变量名一般用小写字母
- 用户定义的类名一般用大写字母开头
- 如果标识符由多个单词构成,则单词间应该有区分,驼峰或pascal
2.2.4 名字的作用域
C++中的作用域大多数以花括号分隔。
#include <iostream>
int main(){
int sum=0;
for(int val=1;val<=10;++val){
sum+=val;
std::out << "Sum of 1 to 10 inclusive is"
<< sum << std::endl;
return 0;
}
}
/*
这段程序定义了3个名字,main,sum 和val,
同时使用了命名空间名字std,
该空间提供了2个名字cout和cin供程序使用
*/
名字 main
定义于所有花括号之外,它和其他大多数定义在函数体之外的名字一样拥有全局作用域,在声明后,全局作用域变量在整个程序范围内都可以使用。
名字 sum
定义于 main
函数所限定的作用域之内,从声明到 main
函数结束都可以使用,拥有块级作用域。
名字 val
的块级作用域在for循环之内
在变量第一次使用的地方之前附近定义它是一种比较好的代码习惯
嵌套的作用域 作用域彼此包含,根据包含关系分为
- 内层作用域
- 外层作用域
外层作用域中的变量,在其内层作用域中都可以访问,同时允许内层作用域重新定义外层作用域已有的名字
#include <iostream>
// 该程序仅用于说明,函数内部不宜定义与全局变量同名的新变量
int reused = 42;
int main(){
int unique = 0; // unique拥有块级作用域
/* 使用全局变量reused,输出:42 0 */
std::cout << reused << " " << unique << std::endl;
int reused = 0; //新建局部变量reused,覆盖了全局变量
/* 使用局部变量reused, 输出:0 0 */
std::cout << reused << " " << unique << std::endl;
/* 显示地访问全局变量,输出:42 0 */
std::cout << ::reused << " " << unique << std::endl;
return 0;
}
如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量
- block scope 块级作用域
- Function parameter scope 函数参数作用域
- Namespace scope namespace作用域
- Class scope 类作用域
- Enumeration scope 枚举作用域
- Template parameter scope 模板参数作用域
- Point of declaration 声明处
2.3 复合类型
复合类型指基于其他类型定义的类型,C++语言有好几种复合类型,本章介绍:引用和指针
一条声明语句由一个基本数据类型 base type和一个紧随其后的一个声明符 declarator列表组成。
2.3.1 引用
C++11新增的右值引用在13.6.1介绍,通常说的引用指的是本章的左值引用。
引用 reference 为变量起了另一个名字,引用类型引用 refer to另外一种类型。
int ival = 1024;
int &refVal = ival; // refVal指向ival(是ival的另一个名字)
int &refVal2; // 报错,引用必须被初始化
在初始化变量时,初始值会被拷贝到新建的变量中。而在定义引用时,程序把引用和它的初始值绑定 bind在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用会和其绑定的变量一直绑定在一起。因为无法重新进行绑定,所以引用必须初始化。
引用即别名 定义了一个引用后,对其进行的所有操作都是在与之绑定的对象上进行的。
refVal = 2; // 把2赋给refVal引用的变量,即ival
int ii = refVal; // 把refVal引用的变量的值赋给ii,等价于 int ii=ival
/* refVal3绑定到refVal绑定的变量,即绑定到ival上 */
int &refVal3=refVal;
/* i被初始化为refVal绑定的变量的值,即ival */
int i=refVal;
引用本身不是一个对象,所以没有办法定义引用的引用(因为最终会绑定到引用所绑定的变量上)
引用的定义
/* 允许在一条语句中定义多个引用,其中每个引用标识符都必须以&开头 */
int i=1024, i2=2048; // i和i2都是int
int &r=i, r2=i2; // r是一个引用,与i绑定在一起,r2是int
int i3=1024, &ri=i3; // i3是一个int,ri是一个引用,与i3绑定
int &r3=i3, &r4=i2; // r3和r4都是引用
引用只能绑定在对象上,而不能直接与某个字面值或者某个表达式的计算结果绑定在一起。基本上所有引用类型都要与绑定的变量的类型严格匹配。
int &refVal4 = 10; // 错误,引用类型的初始值必须是一个变量
double dval = 3.14;
int &refVal5=dval; // 错误,这个引用类型的初始值必须是int类型
2.3.2 指针
指针是 指向refer to
另外一种类型的符合类型。与引用类型类似,指针也实现了对其他变量的间接访问。不同的是:
- 指针是一个变量,允许对指针赋值和拷贝,而且在指针的生命周期中可以先后指向不同的变量
- 指针无须在定义时赋值,和其他类型一样,没有被初始化的指针也将拥有一个不确定的值
定义指针类型的方法将声明符写成 *d
的形式,其中d是变量名。
int *ip, *ip2; // ip1和ip2都是指向int类型对象的指针
double dp, *dp2; // dp2是指向double型对象的指针,dp是double型对象
获取变量的地址
指针存放某个变量的地址,使用取地址符 &
取变量的内存地址
int val = 0;
int &refVal = val; // 声明并初始化引用
int *pVal = &val; // 声明并初始化指针
int *pVal2 = &refVal; // 当取某个引用的地址时候,实际上取到的refVal引用的变量,即&refVal 等价于 &val
和引用一样,大部分情况下指针的类型和它指向的变量类型应该保持一致。
指针值
指针的值有四种情况:
- 指向一个变量
- 指向紧邻变量所占空间的下一个位置
- 空指针,指针没有指向任何变量
- 无效指针,除上面3种的任何情况
试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器不会检查此类错误,和使用为初始化的变量一样都不会检查。访问无效指针的结果无法预计,因此程序员必须清楚任意给定的指针是否有效。
虽然第2种和第3种形式的指针是有效的,但是这些指针没有指向任何具体对象,所以试图访问此类指针的行为不被允许。如果这样做了,后果也无法预计。
利用指针访问变量
如果指针指向了一个变量,则允许使用解引用符 *
来访问变量。
int ival=42;
int *p=&ival;
cout << *p; // 42
cout << p; // p的内存地址
对指针解引用会的出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的变量赋值。
*p = 0; // 由符号*得到指针p所指的对象,即可经由p为变量ival赋值
cout << *p; // 输出0
cout << ival; // 输出0
解引用操作仅适用于那些确实指向了某个对象的有效指针
某些符号有多重含义,像
*
和&
这样的符号,既能用作表达式中的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的定义:
int i = 42;
int &r = i; // &紧随类型名出现,因此是声明的一部分,r是一个引用
int *p; // *紧随类型名出现,因此是声明的一部分,p是一个指针
p = &i; // &在表达式中出现,是一个取地址符,指针p指向变量i的地址
*p = i; // *出现在表达式中,是一个解引用符,指针p的地址赋值为i的值
int &r2 = *p; // &是声明的一部分,*是一个解引用符,用rs绑定到*p解引用的结果i上,等价于 int &r2=i;
bool rs = bool(*p == i); // true
cout << r2 << " " << rs;
空指针
空指针 null pointer
不指向任何变量,在试图使用一个指针之前可以首先检查它是否为空。一下列出几个生成空指针的方法:
int *p1 = nullptr;
int *p2 = 0;
/* 需要include cstdlib */
int *p3 = NULL;
C++11中使用 nullptr
来初始化空指针,它会转换成对应类型的空指针。(int则为0)
直接将指针的值初始化为0来生成空指针。
赋值为预处理变量 NULL
来生产空指针,这个变量定义在头文件 cstdlib
中,它的值就是0。
把int变量直接赋值给指针是错误的,(这样也不会将指针初始化为空指针),即使这个变量的值刚好等于0也不行。
int zero = 0;
int *pi = zero; // 编译错误:不能将int变量直接赋值给指针
初始化所有指针,使用未初始化的指针是引发运行时错误的一大原因。和其他变量一样,访问未经初始化的指针所引发的后果也是无法预计的。这一行为会导致程序崩溃,且很难定位到出错的位置。大多数编译器会把未初始化的指针所占空间看作一个内存地址,访问该指针相当于访问一个本不存在的位置上本不存在的变量。更糟糕的是,如果指针所占内存空间中恰好有内容,而这些内容又被当作了某个地址,我们就很难分清楚它是合法还是非法的了。
因此建议初始化所有指针,尽量等定义了变量后在定义指向它的指针,或者把它初始化为努力nullptr或者0,这样程序就可以正确的检测它指向的变量或者知道它没有指向任何变量。
赋值和指针
指针和引用都可以提供对其他变量的间接访问,不同点是
- 引用本身不是一个变量,一旦定义了引用,就无法令其再绑定到另外的对象,之后的每次使用这个引用都是访问它最初绑定的那个变量
- 指针和它存放的地址就没有这种限制了。和其他变量一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象
int i = 42;
int *pi = 0; // 指针pi被初始化为空指针
int *pi2 = &i; // 指针pi2被初始化,存放变量i的地址
int *pi3; // 指针pi3未被初始化,如果pi3定义与块内则pi3的值是无法确定的
pi3 = pi2; // pi3和pi2指向通一个变量i了
pi2 = 0; // pi2现在是一个空指针了,pi3还是指向i
有时候想搞清楚一条赋值语句到底是改变了指针的值还是改变了指针所指的对象不太容易,最好的方法是记住赋值永远改变的是等号左侧的变量。
int ival = 1;
int *pi = nullptr; // 指针pi初始化为空指针
pi = &ival; // 指针pi指向ival, pi的值改变为ival的内存地址
*pi = 0; // 解引用取到指针pi所指的变量ival,ival的值改变为0;
// *pi == ival; true
// pi == &ival; true
其他指针操作
任何非0(空)指针对应的条件值都是true
int ival = 0;
int *pi = nullptr;
int *pi2 = &ival;
if(pi) // false
if(pi2) // true
两个同类型指针可以用相等操作符来比较 ==
!=
, 结果也是布尔值。如果两个指针内存地址相同返回 true
, 否则 false
。
指针的比较和判断都需要使用合法指针,使用非法指针的结果无法预计。
void* 指针
void*
是一种特殊的指针类型,可用于存放任意变量的地址。一个 void*
指针存放着一个地址,这与其他类型指针一致,不同的是,所指地址中变量的类型是未知的。
double obj = 3.14, *pd = &obj;
void *pd2 = &obj // 正确,void*能存放任意类型的变量
pd2 = pd; // 也可以存放任意类型的指针
void*
指针能做的事情比较有限,不能直接去指针所指操作变量的, 因为无法确定指向变量的类型,也就无法确定能做什么操作。(仅可用于条件、比较、作为函数的输入输出和赋值给另一个void*指针)
以 void*
指针来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象。
2.3.3 理解复合类型的声明
一条定义语句可以定义出不同类型的变量(数据类型相同,变量类型不同)
int i = 1024, *p = &i, &r = i;
/* i是一个int类型的数字,p是一个int型指针,r是一个int型引用 */
基本数据类型和类型修饰符都是声明符的一部分
定义多个变量
经常有一种误解认为类型修饰符 *
和 &
会作用与本次定义的全部变量,其实并不是,类型修饰符只作用于其后紧跟的一个变量。
int* p1; // 合法但是容易产生误导
int *p1, p2; // pi是int型指针,p2是一个int型变量
本书采用类型修饰符和变量紧跟,而不是和类型声明紧跟。
int *i = nullptr;
指向指针的指针
一般来说,声明符中的修饰符个数没有限制。当有多个修饰符连写在一起时,按照其逻辑关系详加解释即可。
以指针为例,指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址再存放到另一个指针中。
通过声明中 *
的个数可以区分指针的级别,也就是说, **
表示指向指针的指针, ***
表示指向指针的指针的指针。(禁止套娃…)
int ival = 1024;
int *pi = &ival; // pi指向一个int型的整数
int **pi2 = π // pi2指向一个int型的指针
/* 注意区分 */
int *pi3 = pi; // 指针pi3和指针pi指向同一个变量ival
解引用int型指针会得到一个int型的数,同样解引用指向指针的指针会得到一个指针。此时为了访问最原始的那个对象,需要对指针的指针做两次引用。
cout << ival // 1024
<< *pi // 1024
<< **pi2 // 1024
<< *pi2 // 指针pi的内存地址
<< pi // ival的内存地址
指向指针的引用
引用不是一个变量,因此定义不了指向引用的指针(指针最终会指向引用绑定的变量)
而指针是一个变量,所以存在对指针的引用。
int i= 42;
int *p; // p是一个int型指针
int *&r = p; // r是一个对指针p的引用
r = &i; // r引用了一个指针,因此给r赋值就是令p指向i
*r = 0; // 对*r 等价于 *p,此处将i赋值为0
理解 int *&r = p;
, 离变量名最近的 &
对r由最直接的影响,因此r是一个引用,符号 *
说明r引用的是一个指针,最后声明的基本数据类型部分指出r引用的一个int指针。
对于一条复杂的声明语句,从右往左阅读有助于弄清楚它的真实含义
2.4 const
限定符
有时我们希望定义这样一种变量,它的值不能被改变。
const int bufSize = 512;
bufSize = 1024; // 错误,试图向const对象写值
用const把bufSize定义成了一个常量,任何试图为bufSize赋值的行为都将引发错误。初始值可以是任意的复杂表达式:
const int i = get_size();
const int j = 42;
const int k =;
初始化和const
const类型可以进行除开改变自身值之外的所有操作,比如赋值给其他变量,初始化其他变量,参与算术运算,转换成布尔值等。
默认状态下,const对象只在文件内有效
编译器在编译const变量时,会把用到该变量的地方都替换成对应的值。
const int bufSize = 512;
/* 编译时会把用到bufSize的地方都替换为512 */
const默认只在定义的文件内有效,如果有多个文件,则需要在每个文件中定义const或者使用extern关键字来声明const变量
如果想在多个文件中共享
const
对象,必须在变量的定义之前添加extern
关键字
2.4.1 const 的引用
把引用绑定到const变量上,和绑定到其他变量一样,这种叫做对常量的引用。对常量的引用不能去修改其绑定的变量的值
const int ci = 1024;
const int &ri = ci; // 正确: 引用及对应的变量都是常量
ri = 42; // 错误: ri是对常量的引用,不能改值
int &r2 = ci; // 错误: 试图将一个非常量引用指向一个常量变量
初始化和对const的引用
引用那节提到,引用的类型必须与所引用的变量一致,但是有两个例外:
- 在初始化常量引用时,允许用任意引用任意类型的变量,且可以使用任意表达式或值来初始化引用
double dval = 3.14;
const int &ri = dval;
/* 编译器编译时,实际上的运行 */
double dval = 3.14;
const int temp = dval;
const int &ri = temp;
在这种情况下,ri绑定了一个临时量 temporary变量(即编译器为暂存表达式求值结果临时创建的一个未命名的变量)
对const的引用可能引用一个并非const的对象
常量引用仅对引用自身可进行的操作进行限定,对于引用的对象是否是常量不做限制。
int i = 42;
int &r1 = i; // 引用r1绑定对象i
const int &r2 = i; // r2也绑定对象i,但是不允许通过r2修改i的值
r1 = 0; // r1并非常量,i的值修改为0
r2 = 0; // 错误,r2是一个常量引用
r2可以绑定非常量变量i,但是不能通过引用修改i的值。i的值可以通过其他操作来改变,比如直接通过i改变,用&r3引用i, 改r3
2.4.2 指针和const
指向常量的指针
- 指针也可以指向常量和非常量
- 指向常量的指针不能用于改变其所指变量的值
- 常量变量的地址只能用指向常量的指针存放
const double pi = 3.14; // pi 是常量
double *ptr = π // 错误,不能用非指向常量的指针针存放常量变量的地址
const double *ptr2 = π // 正确,指向常量的指针可以存放常量变量的地址
*ptr2 = 3.04 // 错误,不能操作常量指针去改变其所指变量的值
和常量引用一样,指向常量的指针只规定不能操作常量指针来改变指向的非常量的值,不限制其他途径来改变这个非常量的值。
试试这样想:常量的指针和引用,不过是指针或引用的“自以为是”,它们觉得自己指向的是一个常量,所以自觉地不去改变所指对象的值。
const指针 常量指针
常量指针必须初始化,一旦初始化常量指针的所指地址不能改变。
int i = 0;
int i2 = 0;
int *const constPtr = &i; // 常量指针constPtr,将一直指向i
const int ptrToConst = &i; // 常量指针constPtr
const int constPtrToConst = &i; // 指向常量的常量指针,既不可以改变所指变量的值,也不可改变所指地址
constPtr = &i2; // 错误,常量指针不可改变所指变量
ptrToConst = &i2; // 正确,指向常量的指针可以改变所指变量
2.4.3 顶层const
- 顶层const
- 底层const
What are top-level const qualifiers? You should look at top and low level const through references and pointers, because this is where they are relevant.
int i = 0; // 普通变量
int *p = &i; // 普通指针
int *const cp = &i; // 普通变量
const int *pc = &i;
const int *const cpc = &i;
In the code above, there are 4 different pointer declarations. Let’s go through each of these,
int *p
: Normal Pointer can be used for making changes to the underlying object and can be reassigned.
int *const cp
(top-level const): Const Pointer can be used for making changes to the underlying object but cannot be reassigned. (Cannot change it to point to another object.)
const int *pc
(low-level const): Pointer to Const cannot be used for making changes to the underlying object but can itself be reassigned.
const int *const cpc
(both top and low-level const): Const Pointer to a Const can neither be used for making changes to the underlying object nor can itself be reassigned.
Also, top-level const is always ignored when assigned to another object, whereas low-level const isn’t ignored.
int i = 0;
const int *pc = &i;
int *const cp = &i;
int *p1 = cp; // allowed
int *p2 = pc; // error, pc has type const int*
C++ Primer has a lot of information about the same!!!
(顶层还是底层const与标识符位置无关,与是否能改变变量的值有关,能改自身,但不能改指向的变量是底层(指向常量的指针),不能能改自身,但能改指向的变量是顶层(普通const类型、常量指针),既不能改自身,又不能改指向的变量既是顶层也是底层)
对于指针来说,顶层const int *const pit = &i;
表示指针本身是一个常量,而底层const const int ptr = &i;
表示指针所指的变量是一个常量。
一般的,顶层const可以表示任意的变量是常量,对于任何数据类型都适用,如算术类型、类、指针等 底层const则与指针的和引用等复合类型的基本类型部分有关。特殊的是指针类型既可以是顶层const也可以是底层const
int i = 0;
int *const p1 = &i; // 不能改变p1的值,这是一个顶层const
const int ci = 42; // 不能改变ci的值,这是一个顶层const
const int *p2 = &ci; // 允许改变p2的值这是一个底层const
2.4.4 constptr 和常量表达式
常量表达式 const expression是指值不会改变并且在编译过程就能得到计算结果的表达式。
字面值、用常量表达式初始化的const变量都是常量表达式。
一个变量是不是常量表达式,由它的数据类型和初始值共同决定。
const int max_files = 20; // 是常量表达式
const int limit = max_files + 1; // 是常量表达式
int size = 27; // 不是是常量表达式
const int width = getWitdh(); // 不是是常量表达式
/* 尽管size的初始值是个字面值常量,但是不是用const声明,所以不是常量表达式*/
/* 尽管width使用const声明,但它的具体值在运行时决定,所以它不是常量表达式 */
常量表达式:
- 使用const声明
- 值在编译时候能够确定
C++语言中有几种情况需要用到常量表达式:
constptr 变量
在复杂工程中,很难分辨(几乎不能)一个初始值到底是不是常量表达式。
C++11中使用constexpr来验证变量的值是否是一个常量表达式。当变量声明的表达式是一个常量表达式时,constexpr声明才是一个正确的声明。
constexpr int mf = 20; // 20是常量表达式
constexpr int limit = mf + 1; // mf+1是常量表达式
constexpr int sz = size(); // 只有当size是一个constptr函数时才是一条正确的声明语句
- 不能使用普通函数作为constexpr变量的初始值
- C++新标准允许使用一种特殊的constexpr函数,这种函数足够简单以至于在编译时就可以计算其结果,可所以可以用来初始化constexpr变量
字面值类型
常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须由所限制。这些类型一般比较简单,值也显而易见,容易得到,就把它们称为“字面值类型”
- 目前接触过的算术类型、引用和指针都属于字面值类型
- 指针定义的constexpr,初始值必须是nullptr、0或者有固定地址的变量
函数体内定义的变量一般来说不是存放在固定地址中的,而定义于函数体外的变量的地址固定不变。另外6.1.1介绍了在函数中定义有效范围超出函数本身的变量,这种变量也有固定的地址
- 引用也只能绑定到由固定地址的变量
- 自定义类Sales_Item()、IO库、string类型则不属于字面值类型,也就不能被定义成
constexpr
- 其他的一些字面值在7.5.6节介绍
指针和constexpr
在 consrexpr
声明中如果定义了一个指针,限定符 constexpr
仅对指针有效,与指针所指的对象无关
const int *p = nullptr; // p是一个指向整型常量的指针
const int *q = nullptr; // q是一个指向整型常数的常量指针
p和q的类型相差甚远,p是一个指向常量的指针,q是一个常量指针,其中constexpr把定义的变量置为了顶层const
与其他常量指针类似,constexpr指针既可以指向常量也可以组织向一个非常量
constexpr int *np = nullptr; // np是一个指向整数的常量指针,其值为空
int j = 0;
constexpr int i = 42; // i是一个整型的常量
constexpr const int *p = &i; // p是一个指向整型常量的常量指针
constexpr int *p1 = &j; // p1是一个指向整型变量的常量指针
2.5 处理类型
程序越复杂,用到的类型也随之复杂。
- 类型名难于”拼写”,难记且易写错,无法明确体现变量的真实目的及含义
- 有时候搞不清楚到底需要什么类型,需要回头从程序上下文中寻找
2.5.1 类型别名
类型别名 type alias是一个名字,它是某种类型的同义词。
使用类型别名的好处:
- 使复杂的名字简单明了
- 易于理解和使用
- 有助于程序员清楚该类型的真实目的
使用类型别名的两种方式:
- 关键字
typedef
- 别名声明(alias declaration)
using
// myDouble是double的别名
typedef double myDouble;
// yourDouble是double的别名, *myPtrDouble是double * 的别名
typedef myDouble, yourDouble, *myPtrDouble;
using myInt = int; // myInt是int的别名
使用别名
myInt n = 1;
myDouble m = 3.14;
指针、常量和类型别名
如果某哥类型别名指代的是复合类型或常量,那么它用到的声明语句里就会产生意想不到的后果(误解)。
typedef char *pstring; // *pstring 是 char*的别名
const pstring cstr = 0; // cstr是指向char的常量指针
const pstring *ps; // ps是一个指针,指向一个指向char的常量指针
const pstring 是指向char的常量指针,而非指向常量字符的指针 遇到一条使用了类型别名的声明语句时,人们往往会错误地尝试把类型别名替换成它本来的样子,以理解该语句的含义。
const char *cstr = 0; // 这才是一个指向常量字符的指针
记住:const 是对给定类型的修饰
2.5.2 auto类型说明符
编程时常常需要吧表达式的值赋给变量,这就要求在声明变量的时候清楚的知道表达式的类型。然而要做到这一点并不容易,有时甚至根本做不到。为了解决这个问题,c++11新标准引入 auto
类型说明符,使用它可以让编译器替我们分析表达式的类型
auto
类型让编译器通过初始值来推算变量类型,显然 auto
定义的变量必须要有初始值。
auto item = val1 + val2;
// item 初始化为val1 和 val2相加的结果
// 编译器通过val1 和 val2相加的结果可以推算出item的类型
如果val1 和 val2都是int,则item的类型就是int 如果val1 和 val2都是double,则item的类型就是double
使用auto也可以在一条语句中声明多个变量,由于一条声明语句只能有一个基本数据类型,所以一条语句中的基本数据类型必须一致。
auto i = 0, *p = &i; // 正确,i是整型数字,p是整型指针
auto sz = 0, pi = 3.14; // 错误: sz和pi的类型不一致
复合类型、常量和auto
有时编译器推断出的auto类型和初始值的类型并不一样,因为编译器会根据初始化的规则做一些自动的转换
比如使用引用其实是使用引用(绑定)的变量,特别是在引用被用做初始值时,真正参与初始化的是引用(绑定)的变量。此时编译器使用引用(绑定)的变量的类型作为 auto
的类型
int i = 0, &r = i;
auto a = r; // a是一个整型变量
其次auto一般会忽略顶层const,且保留底层const.
const int ci = i, &cr = ci;
auto b = ci; // b是一个整型变量(ci的顶层const特性被忽略掉了)
auto c = cr; // c是一个整型变量(cr是ci的别名,ci的顶层const特性被忽略掉了)
auto d = &i; // d是一个整型指针
auto e = &ci; // e是一个指向整型常量的指针(对常量取地址是一种底层const)
如果希望推断出的auto是一个顶层const, 需要明确指出
const auto f = ci;
// ci的推演类型是一个int, f是const int
// f是一个整型常量
还可以将引用的类型设为auto,原来的(引用)初始化规则仍然适用。
auto &g = ci; // g是一个整型常量引用,绑定到ci
auto &H = 42; // 错误,不能为非常量引用绑定字面值
const auto &j = 42; // 正确,可以位常量引用绑定字面值
声明一个auto类型的引用,初始值中的顶层常量属性仍然保留
/* 再次提醒,一条声明中的*和&只从属于紧跟的那个声明符号 */
/* 且一条声明中的类型必须一致 */
auto k = ci, &l =i; // k是整型,l是整型引用
auto &m = ci, *p = &ci; // m是对整型的引用,p是指向整型常量的指针
auto &n = i, *p2 = &ci; // 错误,i是的类型int, &ci的类型是const int
2.5.3 decltype类型指示符
有时会遇到这种情况:希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。
为了满足这个需求,c++11引入 decltype
。它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值。
decltype(f()) sum = x; // sum的类型就是函数f的返回值类型
编译器并不实际调用函数f,而是使用当调用发生时的f的返回值类型作为sum的类型
如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)
const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int &, y绑定到变量x
decltype(cj) z; // 错误, z是一个引用,必须初始化
需要指出的是, 引用从来都作为变量的同义词出现,只有用在decltype处是一个例外
decltype和引用
如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。
// decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r+0) b; // 正确,加法的结果是int,因此b是一个(未初始化的)int
decltype(*p) c; // 错误,c是int&,必须初始化
r是一个引用, 所以decltype(r)的结果会是引用类型。 而decltype(r+0)的结果是int,因为r作为表达式的一部分,r+0的结果显然是一个int。
对于decltype(*p)中, *p是对指针的解引用,得到的是指针所指的变量,还可以以此操作此变量(给变量赋值),相当于引用,所以是int&。
decltype的结果类型与表达式形式密切相关,有一种需要特别注意就是变量名加括号的形式,加与不加括号的类型不同:
// decltype的表达式如果是加上括号,结果类型将是引用
decltype ((i)) d; // 错误: d是int&,必须初始化
decltype (i) e; // 正确: e是一个(未初始化的)int
decltype((variable))(注意是双层括号)的结果永远是引用,而decltype(variable)结果只有当variable本身就是一个引用时才是引用
2.6 自定义数据结构
从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法
C++语言允许用户以类的形式自定义数据类型,而标准库类型string, istream、ostream等也都是以类的形式定义的。
C++对类的支持甚多
2.6.1 定义Sales_item类型
先实现一个简单的类,给用户提供一些可以访问的数据元素和一些基本的操作。
由于是简单的实现,不妨将他叫做 Sales_data
struct Sales_data {
std::string bookno;
unsigned unit_sold = 0;
double revenue = 0.0;
}
这个类以关键字 struct
开始,紧跟着类名和类体(其中类体部分可以为空)。
类体由花括号包围形成了一个新的作用域,类内部定义的名字必须唯一,但是可以与类外部定义的名字重复。
类体右侧的表示结束的花括号后必须写一个分号(因为类体后面可以紧跟变量名以示对该类型的定义,所以分号必不可少)
struct Sales_data { /* ... */} accum,trans,*salesptr;
struct Sales_data { /* ... */}; // 和上一条语句等价,但可读性更好一些
Sales_data accum,trans,*salesptr;
分号表示声明符的结束,一般来说最好不要把变量的定义和类的定义放在一起。
struct MyClass{
int cnt;
double sum = 0.0;
};
记住在类的定义之后加上分号
类数据成员
类体定义类的成员,我们上面定义的类只有数据成员 data member。
类的数据成员定义了类的实例的具体内容,每个实例有自己的一份数据拷贝。修改一个实例的数据成员,不会影响其他实例。
定义数据成员的方法和普通变量一样:一个基本类型后面紧跟一个或者多个声明符。
我们上面定义的类 Sales_data
有三个数据成员, 每个 Sales_data
的实例都将包含这3个数据成员
C++11标准规定,可以为类数据成员提供一个类内初始值 in-class initializer。创建实例时,类内初始值将用于初始化数据成员。没有初始化的成员将会被默认初始化(2.2.1 默认初始化)
当定义Sales_data的实例时,unit_sold和revenue都将初始化为0, bookno会被初始化为空字符串。
对类内变量的初始化限制与2.2.1介绍的类似,初始值置于花括号内或者等号右侧,但区别是不能使用圆括号来初始化。
7.2节将会介绍使用C++提供的另一个关键字 class
来定义自己的数据结构。到时也会说明 struct
和 class
定义类的区别
2.6.2 使用Sales_data类
我们自定义的Sales_data类(内部)没有提供任何操作,想要执行任何操作就要自己动手实现。
struct Sales_data {
std::string bookno;
unsigned unit_sold = 0;
double revenue = 0.0;
}
我们将写一段简易的求两次交易相加结果的功能。 程序的输入是两条交易记录:
0-201-78345-x 3 20.0
0-201-78345-x 3 25.0
# ISBN编号 售出数量 售出单价
已同步到cppStart项目
#include <iostream>
#include <string>
// #include <Sales_data.h> /* 若Sales_data定以在另一个头文件Sales_data.h内 */
struct Sales_data {
std::string bookNo;
unsigned unit_sold = 0;
double revenue = 0.0;
};
struct Sales_rs {
std::string bookNo;
unsigned totalCnt = 0;
double totalRevenue = 0.0;
};
void getSales_data(Sales_data &d) {
using namespace std;
double price = 0.0;
cin >> d.bookNo >> d.unit_sold >> price;
d.revenue = d.unit_sold * price;
}
int computeSales_data(Sales_data &d1, Sales_data &d2, Sales_rs &rs) {
using namespace std;
if (d1.bookNo != d2.bookNo) {
return -1;
}
rs.bookNo = d1.bookNo;
rs.totalCnt = d1.unit_sold + d2.unit_sold;
rs.totalRevenue = d1.revenue + d2.revenue;
cout << "ISBN:" << rs.bookNo << " 总销售量:" << rs.totalCnt << " 总销售额"
<< rs.totalRevenue << endl;
if (rs.totalCnt != 0) {
cout << "平均价格" << rs.totalRevenue / rs.totalCnt << endl;
} else {
cout << "no sales" << endl;
}
return 0;
}
int main() {
using namespace std;
cout << "Start \n";
/* 声明两个Sales_data实例 */
Sales_data d1, d2;
/* 读入两条数据 */
getSales_data(d1);
getSales_data(d2);
/* 输出两条数据的和 */
Sales_rs rs;
int hasError = computeSales_data(d1, d2, rs);
if (hasError != 0) {
cout << "计算出错" << endl;
return -1;
}
// cout << d1.revenue << " " << d2.revenue << endl;
cout << "End \n";
return 0;
}
2.6.3 编写自己的头文件
尽管之前讲到的大多数是在函数内部定义类,但是这样定义的类(作用范围,复用性)毕竟受到了一些限制。
常规操作的类都不会定义在函数内部,而是(为了复用)通常被提取到一个头文件中,而且头文件的名字应该与类的名字一样。例如标准库类型string的头文件名也是string,Sales_data头文件名也应对应为Sales_data.h。
头文件通常包含那些只能被定义一次的实体,如类,const和constptr变量等。
头文件中也可能会用到其他头文件。如Sales_data中会用到标准库string,所以Sales_data.h中需要包含string.h头文件。
而其他用到Sales_data的文件也有可能使用string.h, 此时需要在书写头文件时做适当的处理预处理器,使其遇到多次包含的情况也能安全和正常的工作。
头文件一但改变,相关的源文件需要重新编译来获取更新过的声明。
预处理器概述
通常用于确保多次包含头文件仍能安全工作,是C++从C语言中继承的特性。
预处理器是在编译之前执行的一段程序,可以部分的改变我们所写的程序。如已经使用过的 #include
,预处理器看到 #include
标记时就会用指定的头文件内容代替 #include
C++中另一个常用的预处理功能是头文件保护符 header guard,头文件保护符依赖于预处理变量。
预处理变量有两种状态: 已定义和未定义
#define
指令把一个名字设定预处理变量
另外两个指令则分别检查某个指定的预处理变量是否已经定义
#ifdef
当且仅当变量已经定义时为真#ifndef
当且仅当变量未定义时为真- 一旦命中某个条件则一直执行到
#endif
指令为止
/*
Sales_data.h 通过预处理变量判断后
不管被多少个文件使用,都只会定义一次Sales_data
标准库string肯定也做了类似的处理
*/
#ifndef SALES_DATA_H
#define SALES_DATA_H /* 定义一个预处理变量 */
#include <string>
struct Sales_data {
std::string bookno;
unsigned unit_sold = 0;
double revenue = 0.0;
};
#endif
第一次使用, #ifndef
为真,预处理器将顺序执行到 #endif
为止,此时预处理变量 SALES_DATA_H
的值将变为已定义,而 Sales_data.h
也会被拷贝到程序中来。
后面如果再次包含 Sales_data.h
,则 #ifndef
为假,编译器将忽略 #ifndef
到 #endif
的部分。
预处理变量无视C++语言中关于作用域的规则
整个程序中的预处理变量和头文件保护符都必须唯一,通常基于头文件名或者头文件中类名来命名头文件保护符,以确保其唯一性。
为了避免和程序中其他实体名字发生冲突,一般把预处理变量全部大写。
不管程序需不需要,头文件保护符应该习惯性的加上。加头文件保护符很简单,也提高了代码健壮性
第三章 字符串、向量和数组
除了第二章介绍的内置类型之外,C++语言还定义了一个内容丰富的抽象数据类型库,比如string和vector是两种最重要的标准库类型,前者支持可变长字符串,后者则表示可变长集合。 还有一种标准库类型是迭代器,它是string和vector的配套类型,常被用于访问string中的字符或vector中的元素
内置的数组是一种更基础的类型,string和vector都是对它的某种抽象。
第二章介绍的内置类型是由C++语言直接定义的,这些类型,比如数字和字符,提现大多数计算机硬件本身具备的能力。
标准库定义了另外一组具有更高级性质的类型,它们尚未直接实现到计算机硬件中。
内置数组类型和其他内置类型一样,数组的实现与硬件密切相关,因此相较于标准库类型string、vector等在灵活性上稍显不足
3.1 命名空间的using命名
目前为止我们用到的库函数基本上都属于命名空间std,程序中也显示地表示了,比如 std::cin
表示从标准输入中读取内容,此处使用的作用域操作符 ::
的含义是:编译器应从操作符左侧名字所示的作用域中寻找右侧那个名字。
std::cin
表示使用 std
中的 cin
另外一种更简单也更安全的方法是使用 using 声明
using namespace::name;
using std::cin;
using std::cout; using std::endl;
#include <iostream>
/* using声明,当我们使用名字cin时候,从命名空间std中获取它 */
using std::cin;
int main() {
int i;
cin >> i; // 正确:cin和std::cin含义相同
cout << i; // 错误:没有对应的using声明,必须使用完整的名字
std::cin >> i; // 正确
std::cout << i; // 正确,显式地从std中使用cout
return 0;
}
每个名字都需要独立的using声明
/* 每个名字都需要有自己的声明语句,且每个声明按逗号分隔 */
using std::cin;
using std::cout; using std::endl;
/*
或者使用
using namespace std;
*/
头文件不应包含using声明
位于头文件的代码,一般来说不应该使用using声明。
这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件中有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。
由于不经意间包含了一些名字,所以容易产生无法预料的名字冲突。
3.2 标准库类型string
标准库类型 string
表示可变长的字符序列
使用 string
类型必须引入 string头文件
作为标准库的一部分, string
定义在命名空间 std
中
#include <string>
using std::string;
3.2.1 定义和初始化string对象
常用的一些方式:
string s1; // 默认初始化,s1是一个空字符串
string s2 = s1; // s2是s1的副本
string s3 = "hiya"; // s3是该字符串字面值的副本
string s4(5, 'c'); // s4的内容是 ccccc
/*
注意“”是字符串,''是单个字符
在C++语言中是有区别的
*/
直接初始化和拷贝初始化
string s5 = "hiya"; // 拷贝初始化,内容是hiya
string s6("hiya"); // 直接初始化,内容是hiya
string s7(5, 'c'); // 直接初始化,内容是ccccc
拷贝初始化,编译器把等号右侧的初始值拷贝到新创建的变量上去
3.2.2 string对象上的操作
string的主要操作:
读写string对象
可以使用IO操作符读写string对象
string s; // 空字符串
cin >> s; // 将string对象读入s,遇到空白为止
cout << s << endl; // 输出s
cin写入string类型时候,string类型会自动忽略开头的空白(空格、换行符、制表符等),从第一个真正的字符开始读起,直到遇见下一个空白为止。
程序输入的” hello “,则输出的将是”hello”,输出结果中不包含任何空格
和内置的输入输出操作一样,string对象的此类操作也是返回运算符左侧的运算对象作为结果。因此多个输入输出可以连写在一起。
string s1, s2;
cin >> s1 >> s2; // 把第一个输入读到s1中,第二个读到s2中
cout << s1 << s2 << endl; // 输出两个string对象
读取未知数量的string对象
int main() {
string word;
while(cin >> word){ // 反复读取,直到到达文件末尾(或者Ctrl-D for unix/linux, Ctrl-Z for windows )
cout << word << endl; // 逐个输出,每个输出后换行
}
return 0;
}
使用getline读取一整行
如果希望在最终得到的字符串中保留输入中的字符串,则应该用 getline
函数而不是用 >>
操作符。
getling
函数的参数是一个输入流和一个string对象,
函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了)
然后把读取到的内容存入到string对象中(注意不存换行符)。
getline
只要一遇到换行符就结束读取操作并返回结果,哪怕输入一开始就是换行符也是如此。(一开始就是换行符的情况,所得的结果string是个空string)
和输入运算符 >>
一样,getline也会返回它的流参数。
所以可以作为判断的条件:
int main() {
string line;
while (getline(cin, line)) {
cout << line << endl; // Line中不包含换行符,所以我们手动加上换行符
}
return 0;
}
string的empty和size操作
empty
根据 string
对象是否为空返回一个对应的布尔值
string line;
while(getline(cin, line)){
if(!line.empty()){
cout << line << endl; // 只输出非空的行
}
}
!
逻辑非运算符,它返回与其运算对象相反的结果
size
函数返回string对象的长度(即string对象中字符的个数)
string line;
while(getline(cin, line)){
if(line.size() > 80){
cout << line << endl; // 只输出超过80个字符的行
}
}
string::size_type类型
size
函数返回的就是 string::size_type
的值
string
类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type即是其中的一种。
string::size_type
是一个无符号类型的值,而且能够存放下任何string对象的大小。
在cpp11中可以使用auto或者decltype来推断变量的类型
auto len = line.size(); // len的类型是string::size_type
size
函数返回的是一个无符号整型数,因此切记注意在表达式中尽量不要混用带符号数和无符号数字
如果一条表达式中已经有了size()就不要使用int了,这样可以避免混用int和unsigned可能带来的问题
比较string对象
比较运算符逐一比较string对象中的字符,并且对大小写敏感
相等性运算符( ==
和 !=
), 校验两个string对象相等或者不等,相等意味着长度和包含的字符完全相同
关系运算符( <
、 <=
、 >
、 >=
)的校验都依照(大小写敏感的)字典顺序:
- 长度不同比较长度
- 长度相同,字符位置不同,比较第一对相异字符的比较结果
string a = "abc";
string b = "abcd";
string c = "abc";
string d = "agc";
a<b // true
c<d // true 比较第一个相异字符顺序 区分大小写
为string对象赋值
在设计标准库类型时在易用性上都力求向内置类型看齐,因此大多数标准库都支持赋值操作
对于string类而言,允许把一个string对象赋值给另一个对象
string s1(10,'c'), s2;
s1 = s2; // 将s2的值赋给s1
// 此时s1,s2都是空字符串
两个string对象相加
string对象相加就是把string对象串接起来,结果是一个新的string对象(+=也是等价的)
string s1 = "hello, " , s2 = " world\n";
string s3 = s1 + s2; // s3 :"hello, world\n"
s1 += s2; // s1 :"hello, world\n"
字面值和string对象相加
注意:字面值不能直接相加,只能是字面值+string对象
string s1 = "hello, " , s2 = " world\n";
string s3 = s1 + "\n" + s2 + "end\n"; // s3 :"hello, world\n"
string s4= s1 + "\n" + "end\n"; // 错误,字面值不能直接相加
string s5= "\n" + "end\n"; // 错误,字面值不能直接相加
string s6= "\n" + "end\n" + s1; // 错误,字面值不能直接相加
因此某些历史原因,也为了和C兼容,所以C++语言中的字符串字面量不是标准库类型string的对象(而是c-string)。
切记字符串字面值与string是不同的类型
3.2.3 处理string对象中的字符
我们经常需要单独处理string对象中的字符,比如检查一个string对象是否包含空白,或者把string对象中的字母改成小写,再或者查看某个特定的字符是否出现等等。 处理这些问题常常要涉及到语言和库的很多方面
在cctype头文件中定义了一组标准库函数来处理这部分工作
建议使用C++版本的C标准库头文件
C++标准库中除了定义C++语言特有的功能外,也兼容了C语言的标准库。如C语言中的name.h,在C++中被命名位cname(去掉.h后缀,在文件名name之前添加了字母c,表示这是属于C语言的标准库)。在cname里面的名字从属于std,而定义在name.h中的文件则不是。
所以在C++中使用C语言的标准库时,最好使用cname的头文件而不是name.h的
处理每个字符?使用基于范围的for语句
对字符串中每个字符的操作,最好使用C++11提供的 范围for range for
语句。
范围for range for
语句遍历:
for( declaraion: expression){
statement;
}
expression
是被遍历的序列对象
declaraion
定义一个循环变量,用于访问序列中的元素
每次迭代 declaraion
会被初始化为 expression
的下一个元素
string str("some str");
for(auto s : str){
cout << c << endl;
// 对于str中的每个字符,输出当前字符和一个换行符
}
for循环把变量c和str联系了起来,其中定义循环变量的方式和定义任意一个普通变量是一样。上例中使用auto关键字让编译器来决定变量c的类型(上例子中c的类型是char)。
// 使用范围for和ispunct函数来统计string对象中标点符号的个数
#include <iostream>
#include <string>
#include <cctype>
int main() {
using std::cout;
using std::string;
using std::ispunct;
string s = ",'Hello World!!!";
decltype(s.size()) puncCount = 0;
for (auto c : s) {
if (ispunct(c)) {
puncCount++;
}
}
cout << puncCount << " punc in" << s;
return 0;
}
代码中使用decltype来使编译器决定puncCount的类型,即s.size函数返回值的类型,也就是string::size_type
使用范围for语句改变字符串中的字符
如果想要操作范围for中的循环变量,则需要把循环变量定义为引用类型。 这样循环变量实际上被依次绑定到循环序列的每个元素上,我们就可以使用这个引用来操作对应的字符。
#include <iostream>
#include <string>
#include <cctype>
int main() {
using std::cout;
using std::endl;
using std::string;
using std::toupper;
// cout << "Hello Wolrd\n";
string s = "Hello Wolrd";
for (auto &c : s) {
c = toupper(c);
}
cout << s << endl;
return 0;
}
只处理一部分字符
要想访问string对象中单个字符有两种方式:
- 使用下标运算符
[]
- 使用迭代器(3.4节和第九章介绍)
这里只介绍下标运算符 []
- 接收的参数是
string::size_type
类型的值,表示要访问的字符的位置 - 返回值是该位置上字符的引用
- string对象的下标从0开始(s[s.size()-1]取最后一个字符)
string对象的下标参数必须大于等于0, 且小于s.size()
超出此范围的下标将会引发不可预知的结果,以此推断,使用下标访问空string也会引发不可预知的结果
下标的值称作下标或者索引,任何表达式只要是整型就能作为索引,不过带符号类型的值将自动转换成string::size_type(无符号类型)
// 输出string的第一个字符
if(!s.empty()){ // 确保string非空
cout << s[0] << endl;
}
只要字符串不是常量,就可以直接使用下标来操作字符
string s("some string");
if(!s.empty()){
s[0]=toupper(s[0]); // 将s的第一个字符改为大写
}
cout << s << endl; // 输出Some string
使用下标执行迭代
string s("some string");
for( decltype(s.size()) idx=0;
index != s.size() && !isspace(s[idx]);
++idx;
){
s[idx] = toupper(s[idx]);
}
// 输出 SOME string
for循环中使用了逻辑与运算符号( &&
)
下标必须大于等于0小于等于字符串的size()。简单的做法是总是设置下标的类型为string::size_type, 因此类型是无符号数,可以确保下标不会小于0, 我们只需要保证下标小于size()就可以了
C++标准库并不要求检测下标是否合法,所以不能在编译阶段检查出下标越界的情况,一旦在程序运行时候使用了一个超出范围的下标就会产生不可预知的结果
使用下标执行随机访问
之前只使用下标进行从前往后的顺序访问,其实下标也可以不按顺序,随机访问
/*
把0到15之间的十进制数字转换成对应的十六进制数字
*/
#include <iostream>
#include <string>
int main(){
using std::string;
using std::cout;
using std::cin;
using std::endl;
const string hexDigits = "0123456789ABCDEF"; // 可能的十六进制数字(0-15)
cout << "Enter a series of numbers between 0 and 15"
<< " seperated by space. Hit ENTER when finished:"
<< endl;
string hexRes; // 用于保存转换的十六进制结果
string::size_type n; // 用于保存从输出流读取的数
while(cin >> n){
if(n<hexDigits.size()){
hexRes += hexDigits[n];
}
}
cout << "Your hex number is:" << hexRes << endl;
return 0;
}
把 hexDigits
声明为常量是因为在后面的程序中不会再去修改它的值(相当于一个数字转16进制的映射表)
无论何时用到字符串的下标,都应该注意检查其合法性
3.3 标准库类型vector
标准库类型 vector
表示对象的集合,其中所有对象的类型都相同。
vector
存储的每个对象都有个 索引
,用于访问对象
因为 vector
容纳着其他对象,所以也经常被称为容器
使用 vector
需要包含头文件 #include <vector>
, 且名称在 std
中
#include <vector>
using std::vector;
C++语言既有类模板 class template
,也有函数模板。
vector是一个类模板
只有对C++有了相当深入的了解才能写出模板,在16章才开始学习模板。幸运的是,即使不会创建模板,也可以使用它
模板本身不是类或者函数,可以将模板看做用于编译器生成类或者函数的一份说明。编译器会根据模板创建类或者函数,这个过程称为 实例化
,当使用模板时候,需要指出编译器应把类或函数实例化成什么类型。
对于类模板,比如 vector
,需要使用尖括号来指定实例的类型
vector<int> myVec; // myVec保存int类型元素
vector<double> myDoubleVec; // myVec保存double类型元素
vector<custom_item> myCustomVec; // myVec保存自定义的类型元素
vector<vector<string>> myVecVec; // myVec保存vector类型 的 类型元素,类似于二维数组
[
["abc"],
["a","b"],
]
vector是模板,而非类型,使用vector必须指定元素类型,比如
vector<string>
。由vector生成的元素必须是vector尖括号指定的类型。
vector可以容纳绝大多数类型的变量,不过由于引用不是变量,所以不存在包含引用的vector。
早期的C++版本(C++11以前)或者某些不常见的编译器中,嵌套的vector需要一个空格如 vector<vector<int> >
3.3.1 定义和初始化vector对象
默认初始化创建的是一个指定类型的空vector:
vector<string> strvec; // 默认初始化,strvec不包含任何元素
最常见的方式就是先定一个空vector,然后在需要的时候��里面添加元素。
也可以把一个vector,直接赋值给另一个vector,不过注意类型必须一致
vector<int> ivec;
// 向ivec添加一些元素
vector<int> ivec2(ivec); // 把ivec的元素拷贝给ivec2
vector<int> ivec3= ivec; // 把ivec的元素拷贝给ivec3
vector<string> svec(ivec2); // 错误,类型不一致
列表初始化vector对象
cpp11
vector<string> articles = {"a", "an", "the"};
vector<string> articles{"a", "an", "the"};
vector<string> articles("a", "an", "the"); // 错误,圆括号
创建指定数量的元素
使用统一的初始化来初始化vector对象
vector<int> ivec(10, -1) // 10个int类型的元素,每个都被初始化为-1
vector<int> ivec(10, "hi") // 10个int类型的元素,每个都被初始化为"hi"
值初始化
vector初始化时可以只指定数量,而不指定值,值就初始化为指定的类型的对应初始值
vector<int> ivec(10); // ivec初始化为10个0
vector<string> ivec(10); // ivec初始化为10个空字符串
vector<int> ivec = 10; // 错误,这样赋值只能将一个vector传给另一个vector,或者使用列表初始化
这样的初始化有些限制:有些类型要求必须提供初始值,就必须提供初始值的对象
列表初始值还是数量?
注意区分花括号 {}
和圆括号 ()
初始化的不同
- 圆括号
(数量,值)
- 花括号
{元素,元素}
- 如果花括号提供的元素不符合指定的元素,则编译器会尝试使用圆括号来执行这些值
- 所以最好是避免混用和指定对类型
3.3.2 向vector对象中添加元素
vector
成员函数 push_back
,负责把一个值推到一个
vector
的尾部
向 vector
推入100个元素:
vector<int> myVec;
for (int i = 0; i != 100; ++i) {
myVec.push_back(i);
}
另一个例子,在运行时才确定向 vector
推入多少个元素:
vector<string> strVec;
string tmp;
cout << "请输入一些需要存放的文字,以空格分隔,ctrl+D结束"
<< endl;
while(cin >> tmp){
strVec.push_back(tmp);
}
C++标准要求vector可以高效的添加元素(C语言中的数组需要初始大小,不能动态增加元素)。所以给vector初始化时候设置其大小也就没什么必要了,反而会降低效率(除非所有元素的值都一样,初始时指定大小才不会降低vector性能)。第9.4将介绍vector提供的进一步提升动态添加元素性能的方法。
向vector对象添加元素蕴含的编程假定
由于可以高效便捷地向vector中添加元素,许多编程工作都被简化了。 不过这样就得保证所写的循环正确无误,特别是在需要改变vector的元素数量的时候
随着对vector更多的了解,我们可以知道如果需要在循环中改变vector元素数量,则不能使用 范围for
范围for
语句内不应该改变所遍历的序列的大小
3.3.3 其他vector操作
其他vector操作大多数与string类似,下面列举的常用部分:
访问vector中元素的方法和访问string中字符的方法差不多
vector<int> v{1,2,3,4,5,6,7,8,9};
for(auto &i : v){
i *= i; // v中每个元素取平方
}
for(auto i : v){
cout << i << " "; // 输出v中每个元素
}
cout << endl;
vector
的成员函数 empty
和 size
与 string
完全一致, empty
返回一个布尔值,表示 vector
是否包含元素, size
返回值的类型是由 vector
定义的 size_type
类型。相等运算符和关系运算符号也与 string
的相应运算符功能一致。
vector
中元素只有其类型的值可以比较时才可以进行比较。比如自己定义的 Sales_item
类不支持相等运算和关系运算,则 vector<Sales_item>
就不可进行比较。
计算vector内对象的索引
(和 string
类似)下标也是从0开始,下标类型是对应的 size_type
, 只要 vector
不是个常量,就可以通过下标直接操作对应的元素。
using std::cin;
using std::cout;
using std::endl;
using std::vector;
/* 以10分为一个区间统计每一个区间的成绩数量 */
vector<unsigned> scoreCnt(10, 0); // 初始化每个区间的分数计数为0
unsigned input = 0;
while (cin >> input) { // 输入成绩
if(input<=100){ // 只统计有效成绩
++scoreCnt[input/10] // 将对应区间成绩计数+1
}
}
cout << "Hello Wolrd\n";
return 0;
++scoreCnt[input/10]
// 等价于
int tmp = input/10;
scoreCnt[tmp] = scoreCnt[tmp] + 1;
不能使用下标形式添加元素
vector<int> v; // 空vector对象
v[0] = 1; // 错误
for(decltype(v.size()) idx=0; idx!=10; ++idx){
v[idx] = idx; // 错误
}
要向 vector
添加元素,正确的做法是使用 push_back
vector<int> v; // 空vector对象
for(decltype(v.size()) idx=0; idx!=10; ++idx){
v.push_back(idx); // 错误
}
vector对象的下标可以用于访问已存在的元素,不能用于添加元素
提示,只能对确定以及存在的元素执行下标操作,用不存在的下标访问元素不会被编译器发现,而在运行时会取到一个不可预知的值。(缓冲区溢出问题)
确保下标合法的一种有效手段是尽可能使用范围for语句
3.4 迭代器介绍
我们已经知道可以使用下标运算符来访问 string
和 vector
对象的元素,还有另外一种更通用的机制也可以实现同样的机制,就是迭代器
除了 vector
以外,标准库中还定义了其他几种容器,所有标准库容器都可以使用迭代器,不过只有少数几种才同时支持下标运算符。
严格来说 string
不属于容器类型,但是 string
支持很多与容器类似的操作,比如支持 下标运算符
和 迭代器
迭代器类似于指针类型,提供了对对象的间接访问。
迭代器可以访问容器的某个元素,也可以从一个元素移动到另外一个元素,迭代器有有效和无效之分。
有效的迭代器指向某个元素或者尾元素的下一个位置,其他情况都属于无效。
3.4.1 使用迭代器
和指针不同的是,获取迭代器不是用的 取地址符号
,支持迭代器的类型都有 begin
和 end
方法, begin
指向迭代器第一个元素
vector<int> intV = {1,2,3,4,5};
string s = "12345";
auto front = intV.begin(), end = intV.end(); // front指向第一个元素1,end指向intV尾元素的下一个位置
auto front2 = s.begin(), end2 = s.end(); // front2指向第一个字符’1‘,end2的指向尾字符的下一个位置'5'
end指向尾元素的下一个位置,通常被称为 尾后迭代器 off-the-end iterator
, 尾迭代器。
若容器为空,则 begin
和 end
返回的是同一个迭代器,都是尾后迭代器( v.begin() == v.end()
)
一般来说我们并不知道(也不在意)迭代器的准确类型是什么,所以一般使用 auto
由编译器判断就可以了
迭代器运算符
使用 ==
和 !=
来比较两个合法的迭代器是否相等
标准容器迭代器的运算符:
和指针类似,也可以通过解引用来获取它所指的元素。 执行解引用的迭代器必须合法,并指向其某个元素。 试图解引用一个非法迭代器,或者尾后迭代器都是未被定义的行为。
使用迭代器把字符串中第一个个字符改为大写
string s = "some string";
cout << "origin: " << s << endl;
if (s.begin() != s.end()) {
auto oneChar = s.begin();
*oneChar = toupper(*oneChar);
}
cout << "result: " << s << endl;
将迭代器从一个元素移动到另外一个元素
迭代器使用 ++
运算符来从一个元素移动到下一个元素。
整数的递增是将整数的数值加1, 迭代器的递增是将迭代器向前移动一个位置
把string对象中第一个单词(中的所有字符)转换成大写:
/* 当遇到空格或者到迭代器尾部时候循环停止 */
for (auto el = s.begin(); el !=s.end() && !isspace(*el) ; ++el) {
*el = toupper(*el);
}
cout << "result: " << s << endl;
原来使用C或者Java的程序员在转而使用C++语言后,会对for循环中使用!=而非<进行判断有点奇怪。C++程序员习惯性的使用!=,原因是这个判断在标准库提供的所有容器都有效。
所有标准库容器都实现了迭代器、==和!=,而许多标准库并未实现
下标操作符
和比较操作符<、>等。
所以习惯性的使用迭代器、==和!=,可以不用关心标准库容器实现,因为都实现了迭代器、==和!=
迭代器类型
拥有迭代器的标准库使用 iterator
和 const_iterator
来表示迭代器的类型。
vector<int>::iterator intV; // intV可以读写vector<int>的元素
strnig::iterator s; // s可以读写strnig对象的元素
vector<int>::const_iterator intV2; // intV2只能读元素,不能写元素
strnig::const_iterator s2; // s2只能读元素,不能写元素
const_iterator和常量指针差不多,可读不可写。 如果vector和string对象是一个常量,那么只能使用const_iterator 如果vector和string对象不是一个常量,那么既能使用const_iterator,也能使用iterator
迭代器这个词有三种不同的含义:可能是迭代器本身,也可能是指容器定义的迭代器类型,还可能是指某个迭代器对象
我们认定某个类型是迭代器,当且仅仅当它支持一套操作,这套操作使得我们能访问容器的元素或者从某个元素移动到另一个元素。 每个容器定义了一个名为iterator的类型,该类型支持迭代器概念所规定的一套操作
begin和end运算符
- 对象是常量,begin和end返回
const_iterator
类型 - 对象不是常量,begin和end返回
iterator
类型
vector<int> v1;
const vector<int> v2;
auto iter1 = v1.begin(); // v1的类型是 `iterator` 类型
auto iter2 = v2.begin(); // v2的类型是 `const_iterator` 类型
有时候这种默认的行为并非我们所要的,所以C++11新增了 cbegin
和 cend
来获取 const_iterator
类型,
auto iter3 = v.cbegin() // iter3的类型是vector<int>::const_iterator
cbegin
和 cend
不论 vector
对象是否是常量,返回值都是 const_iterator
结合解引用和成员访问操作
解引用迭代可获得迭代器所指的对象,如果该对象恰好是类,就有可能希望进一步访问它的成员。
例如一个string类型的迭代器:
(*it).empty()
通过解引用来访问到string对象,在调用string对象的empty()方法判断是否是一个空字符串。
注意 (*it).empty()
中的圆括号不可少,具体原因在4.1.2节介绍。
如果不加括号, .
点运算符将作用在 it
上,而不是 *it
所指的对象上
(*it).empty() // 解引用it,然后调用结果对象的empty成员
*it.empty() // 错误: 试图访问it的名为empty的成员,但it是个迭代器,没有empty成员
为了简化 (*it).empty()
这种解引用并访问成员的操作,C++语言定义了箭头运算符 ->
/* 两种操作等价 */
it->item
(*it).item
使用箭头运算符:
vector<string> text = {
"Contrary to popular belief, Lorem Ipsum is not simply random text. It "
"has roots in a piece of classical Latin literature from 45 BC, making "
"it over 2000 years old. ",
"The standard chunk of Lorem Ipsum used since the 1500s is reproduced "
"below for those interested. "};
/* 输出text中的每段文字 */
cout << "输出text的第一段文字:" << endl;
for (auto el = text.cbegin(); el != text.cend() && !el->empty(); ++el) {
cout << *el << endl;
}
对vector的某些操作会使迭代器失效
虽然vector可以动态的增长,但是有一些副作用。
-
在范围for中不能向vector增加元素
-
另外任何一种可以改变vector容量的操作,比如
push_back
, 都会使vector
对象的迭代器失效
但凡是使用了迭代器的循环体,都不要向迭代器所属的容器中添加元素
3.4.2 迭代器运算
迭代器的递增操作使迭代器每次移动一个元素,所有标准库容器都支持迭代器的递增操作 +
。(也都支持 ==
和 !=
)
string
和 vector
的迭代器提供了更多的运算符
迭代器的算术运算
可以使迭代器和一个整数值相加减,得到的结果是向前(或者后)移动了相应位置的迭代器
/* 计算得到vi迭代器中间位置的迭代器 */
auto mid = vi.begin() + vi.size() / 2;
/* 即:如果vi有20个元素,以上操作得到第11个元素的迭代器:vi[10] */
还可以使用关系运算符( < > = >= <=
)来比较迭代器的位置, 参与比较的两个迭代器必须合法且指向同一个容器的元素(或者尾元素的下一个位置)
if(it<mid){
/* 处理vi前半部分的元素 */
...
}
两个迭代器相减的结果是两个迭代器的距离 (参与比较的两个迭代器必须合法且指向同一个容器的元素(或者尾元素的下一个位置))
距离值的类型是名为 difference_type
的带符号型整数(可正可负), string和vector都定义了这个类型。
使用迭代器运算
使用迭代器运算的一个经典算法是二分搜索,二分搜索从某个有序序列中寻找某个给定的值。
/* text为一个有序序列 */
string text = {"abcdefghijklmnopqrstuvwxyz"};
char target = 'y';
/* beg为迭代器的头, end为迭代器的尾 */
auto beg = text.begin(), end = text.end();
/* mid为迭代器的中间位置 */
auto mid = beg + (end - beg) / 2;
while (target != *mid && beg != end) {
cout << target << " vs " << *mid << endl;
if (target > *mid) {
beg = mid;
} else {
end = mid - 1;
}
mid = beg + (end - beg) / 2;
}
cout << "rs:" << target << " vs " << *mid << endl;
return 0;
3.5 数组
数组是一个类似于标准库类型vector的数据结构,但是在性能和灵活性上的权衡与vector不同。
相似的地方:
- 都是存放相同类型对象的容器,这些对象没有名字,通过位置来访问
区别:
- 数组的大小确定不变,不能随意增加元素
- 大部分情况下性能更好,但是灵活性一般
3.5.1 定义和初始化内置数组
数组是一种复合类型(参考2.3节)
数组的声明型如 a[d]
, 其中a是数组的名字,d是数组的维度。
维度说明数组中元素的个数,因此必须大于0,编译的时候维度应该是已知的,也就是说维度必须是一个常量表达式。
unsigned cnt = 42; // 非常量表达式
constexpr unsigned sz = 42; // 常量表达式,关于constexpr,参见2.4.4节
int arr[10]; // 含有10个整数的数组
int *parr[sz]; // 含有42个整型指针的数组
string bad[cnt] // 错误,cnt非常量表达式
string bad[getSize()] // 当getSize时constexpr时正确,否则错误
默认情况下,数组的元素被默认初始化(参考2.2.1节)。
和内置类型的变量一样,如果在函数内部定义了某种类型的数组,那么默认初始化会使数组含有未定义的值
定义数组的时候,必须指定确定的类型,不能使用auto关键字。 另外和vector一样,数组的元素必须为对象,所以不存在引用的数组
显示初始化数组的元素
可以对数组进行列表初始化(参考3.3.1节),此时允许忽略数组的维度。
数组初始化时,若没有指定维度,那么编译器会根据初始值的数量推算出数组的维度。 若指定了维度,那么初始值的数量不能超过维度值,可以小于维度值,维度值超过初始值数量的部分将被默认初始化。
const unsigned sz = 3;
int intEl[sz] = {0, 1, 2}; // 含有三个元素的数组,元素值分别是0,1,2
int a2[] = {0, 1, 2}; // 维度是3的数组
int a3[5] = {0, 1, 2}; // 等价于a3[]={0,1,2,0,0}
string a4[3] = {"hi", "bye"}; // 等价于a3[]={"hi", "bye",""}
int a5[2] = {0, 1, 2}; // 错误,初始值超过维度
字符数组的特殊性
字符数组可以使用字符串字面值进行初始化。
字符串字面值在字符串结尾处会有个空字符 \0
,这个空字符也会被拷贝到数组里面去
char a1[] = {'c', '+', '+'}; // 列表初始化,没有空字符
char a2[] = {'c', '+', '+', '\0'}; // 列表初始化,含有显示的空字符
char a3[] = "c++"; // 使用字符串字面值初始化,自动添加表示字符串结尾的空字符
char a4[3] = "c++"; // 错误,没有多余的空间可以存放空字符
不允许拷贝和赋值
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值
int a[] = {0, 1, 2}; // 初始化一个含有3个整数的数组
int a2[] = a; // 错误,不允许使用一个数组初始化另一个数组
a2 = a; // 错误,不能把一个数组直接赋值给另一个数组
某些编译器支持数组的赋值,这就是所谓的编译器扩展 不过一般来说,最好避免使用非标准的特性,以保持代码的可移植性
理解复杂的数组声明
和 vector
一样,数组能存放大多数类型的对象。例如,可以定义一个存放指针的数组,还可以定义对数组的引用及指向数组的指针。
定义存放指针的数组比较简单和直接,定义指向数组的指针或者数组的引用就稍微复杂一些了:
int * ptrs[10]; // ptrs是含有10个整型指针的数组
int & refs[10] = /* ? */; // 错误不存在存放引用的数组
int(*parray)[10] = &arr; // parray指向一个含有10个整数的数组
int(&refarray)[10] = arr; // parray引用一个含有10个整数的数组
int *(&array)[10] = ptrs; // array是数组的引用,该数组含有10个指针
理解数组声明的含义,最好的方法是从数组的名字开始按照由内到外的顺序阅读
3.5.2 访问数组元素
和vector和string一样,数组的元素也能使用范围for语句或者下标运算符号来访问。
数组的索引从0开始,以一个包含10个元素的数组为例,它的索引是从0到9
在使用数组下标时候,通常将他定义为size_t类型。size_t是一种机器相关的无符号类型,它被设计的足够大以便能表示内存中任意对象的大小。
在 cstddef
头文件中定义了 size_t
类型,是c标准库文件 stddef.h
的C++版本
数组除了大小固定这一特点外,其他用法和vector基本类似。 例如可以使用数组来记录各分数段的成绩个数
/* 统计0-9, 10-20...90-99,100 11个分数段 */
unsigned scores[11] = {}; // 11个分数段,全部初始化为0
unsigned grade;
while (cin >> grade) {
if (grade <= 100) {
++scores[grade / 10];
}
}
cout << endl << "统计中..." << endl;
for(int i : scores){
cout << i << " ";
}
cout << endl;
与3.3.3节中vector的实现相比,一个不太明显的区别是本例子中使用的下标运算是C++语言直接定义的,而vector的下标运算是库模板vector定义的只能用于vector。
与vector和string一样,遍历操作最好使用范围for语句 因为维度是数组类型的一部分,所以数组的唯独是已知的,使用范围for可以减轻认为控制遍历的负担。
检查下标的值
和vector和string一样,数组的下标是否在合理的范围之内由程序员负责检查(下标应该大于等于0且小于数组的大小)。
要想防止下标越界,需要对代码进行细致的检查 c++ lint
3.5.3 指针和数组
在c++语言中,指针和数组有非常密切的联系。比如即将介绍的,使用数组的时候,编译器一般会把它编译为指针。
通常情况下,使用取地址符来获取指向某个对象的指针,取地址符可以用于任何对象。
数组元素也是对象,数组下标可以取到该位置的元素。因此可以使用取地址符取到数组元素的地址。
string nums[]={"one","two","three"}; // 数组的元素是string对象
string *p = &nums[0]; // 指针p 指向数组的第一个元素
通常使用数组类型是使用一个指向数组第一个元素的指针。
所以一些数组的操作,其实是指针的操作。 其中一层意思是,当使用数组作为auto变量的初始值时,推断得到的类型是指针而非数组
int arr[]={0,1,2,3,4,5}; // arr是一个含有6个整数的数组
auto parr(arr); // parr是一个指针,指向arr的第一个元素
parr=42; // 错误,parr是一个指针,不能用int值赋给指针
尽管arr是由10个整数构成的数组,但当arr作为初始值时,编译器实际执行到初始化过程类似于下面的形式
auto parr(&arr[0]); // 显然parr的类型是int *
必须注意的是,当使用decltype关键字时,上述转换不会发生
decltype(arr) arr2 = {0,1,2,3,4} // arr2是一个有5个元素的数组
int *p;
arr2 = p; // 错误,不能将整型指针赋值给指针
arr2[3] = 1; // 正确,将1赋值给arr2的第4个元素
指针也是迭代器
与2.3.2节指针介绍的内容相比,指向数组的指针拥有更多功能。
vector和string的迭代器支持的运算,数组的指针全都支持。
比如,使用递增运算符将指向数组元素的指针移动位置
int arr = {0,1,2,3,4}; // 一个有5个整数元素的数组
int *p = arr; // p指向arr的第一个元素 a[0]
++p; // p指向arr的第二个元素 a[1]
和使用迭代器遍历vector一样,使用指针也可以遍历数组
通过数组名字或者数组首元素的地址都能得到指向首元素的指针。 获取尾后指针需要用到数组的一个特殊性质,我们设法获取数组尾元素之后的那个并不存在的元素的地址:
int arr = {0,1,2,3,4}; // 一个有5个整数元素的数组
int *e = &arr[5]; // 指向arr尾元素(第六个元素)的下一个位置的指针
这里使用的下标显然是一个不存在的元素,这个不存在的元素的唯一用处就是提供其地址用于初始化尾指针e,就像尾后迭代器一样,尾后指针也不指向具体的元素。因此不能对尾后指针执行解引用或者递增操作。
用指针重写上面的范围for,目标同样是输出数组中所有元素:
/* 统计0-9, 10-20...90-99,100 11个分数段 */
unsigned scores[11] = {}; // 11个分数段,全部初始化为0
unsigned grade;
while (cin >> grade) {
if (grade <= 100) {
++scores[grade / 10];
}
}
cout << endl << "统计中..." << endl;
cout << "范围for:" << endl;
for (int i : scores) {
cout << i << " ";
}
cout << endl << "指针:" << endl;
unsigned *pend = &scores[11]; // scores[11]取的是第12个元素
for (unsigned *pa = &scores[0]; pa != pend; ++pa) {
cout << *pa << " ";
}
标准库函数begin和end
尽管可以计算得到数组的尾后指针,但这种用法极容易出错。
为了使指针的使用更简单、安全,C++11新标准引入了两个名为begin和end的函数。
这两个函数与容器中的同名函数成员函数功能类似(3.4.1节)。
int arr[] = {0,1,2,3,4,5,6,7,8,9}; // arr是一个有10个整数的数组
int *beg = begin(arr); // 指向arr数组首元素的指针
int *last = end(arr); // 指向arr数组尾元素的下一个位置的指针
begin函数返回指向数组首元素的指针
end函数返回指向数组尾元素的下一个位置的指针
这两个函数定义在 iterator
头文件中。
cout << "Find the first < 0 element in a array..." << endl << endl;
int arr[] = {0, 1, 2, 3, 4, -1, -2, -3, -4}; //
int *abeg = begin(arr), *aend = end(arr);
int pos = 0;
while (abeg != aend && *abeg >= 0) {
++abeg;
++pos;
}
if (abeg != aend) {
cout << "Value:" << *abeg << ",Pos:" << pos << endl;
}else{
cout << "Not Found." << endl;
}
一个指针如果指向了某种内置类型数组的尾元素的“下一位置”,则其具备与vector的end函数返回的与迭代器类似的功能。特别要注意尾后指针不能执行解引用和递增操作
指针运算
指向数组的指针可以进行指针的所有运算
#include <cstddef>
...
using std::size_t;
constexpr size_t sz = 5;
int arr[sz] = {1,2,3,4,5};
int *p = arr; // 等价于int *p = &arr[0]
int *p2 = ip + 4; // ip2指向指针的尾元素arr[4]
给指针加上一个整数,得到的新指针扔需指向同一个数组的其他元素,或者指向同一数组的尾元素的下一个位置。(简单的说,加上的整数后指针位置不能超过元素的最大位置)
int *p = arr + sz; // p指向arr首元素+5的位置,即尾元素的下一个位置
int *p2 = arr + 10; // 错误,arr只有5个元素,超出了数组的元素位置,p2为未定义
两个指针相减的结果是它们之间的距离,参与运算的两个指针必须指向同一数组当中的元素:
auto n = end(arr) - begin(arr); // n的值是5,也就是arr中元素的数量
相减的结果类型是一种名为 ptrdiff_t
的标准库类型,和 size_t
一样也是定义在 cstddef
中与机器相关的类型。差值可能为负数,所以 ptrdiff_t
是一种带符号类型。
只要两个指针指向同一个数组的元素,或者尾元素的下一位置,就可以(用关系运算符)进行比较。
int *b = arr, *e = arr + sz;
/* 位置的比较 */
while(b<e){
// 使用*b
++b;
}
如果两个指针分别指向不同的数组,则不可以比较
int i = 0, sz = 42;
int *p = &i, *e = &sz;
/* 未定义的:p和e无关,因此比较毫无意义 */
/* 可能与指针所指变量定义的先后顺序有关,(先定义分配的内存地址较小) */
while(p<e){
}
解引用和指针运算的交互
指针加上一个整数的结果还是指针,如果结果指针还是指向一个元素,允许解引用该结果指针。
int arr[] = {0,1,2,3,4,5}; // 一个6个元素的数组
int last = *(arr + 4); // 正确,把last初始化为第5五元素,即4
注意圆括号必不可少,不然含义就变了,例如:
int last = *arr + 4; // 把last初始化为第一个元素加上4的结果值,即0+4=4
下标和指针
如之前所述,在很多情况下使用数组的名字,其实用的是一个指向数组首元素的指针。
典型的例子是,在使用下标操作时,编译器会自动执行上述转换操作。
int arr[] = {0,1,2,3,4,5};
int i = arr[2]; // arr转换成指向数组首元素的指针
// arr[2]等价于*(arr+2)
int *p = arr; // p指向arr的首元素
i = *(p+2) // 等价于*(arr+2),等价于arr[2]
只要指针的结果是指向数组中的元素(或者尾元素的下一个位置),都可以执行下标运算,数组中的下标可以为负数(注意区别标准库中(vector,string等)的下标必须为正,无符号类型):
int *p = &arr[2]; // p指向索引为2的元素
int j = p[1]; // p[1]等价于*(p+1),等价于arr[3]
int k = p[-2]; // p[-2]即arr[0]
3.5.4 C风格字符串
尽管C++支持C风格字符串,但在C++程序中最好还是不要使用。因为用起来不太方便,而且极易引发程序漏洞。
C风格字符串不是一种类型,而是为了表达和使用字符串而形成的一种约定俗成的写法:字符串放在数组中并一空字符结束 \0
C标准库string函数
cstring
是C语言头文件 string.h
的C++版本, cstring
提供的一些函数
传入此类函数的指针必须指向以空字符作为结束的数组(而上表所列出的这些函数不负责验证其字符串参数)
char ca[]={'C','+','+'}; // 没有以空字符结束
cout << strlen(ca) << endl; // 严重错误,ca没有以空字符结束
注意字符 ''
是单引号,C++字符串字面值是 ""
双引号。
上述代码运行不会报错,但是可能会出现错误的结果。 strlen
函数将有可能沿着ca在内存中的位置不断向前寻找,知道遇到空字符才停下来。(如果另一个字符对象的内存位置刚好紧邻ca那就会计算出错误的长度)
比较字符串
TODO 4.1-4.4:
第四章 表达式
4.1 基础
4.1.1 优先级与结合律
组合运算符和运算对象
运算对象转换
重载运算符
左值和右值
4.1.2 优先级与结合律
4.1.3 求值顺序
4.2 算术运算符
布尔值不应该参与运算
溢出和其他算术异常
4.3 逻辑和关系运算符
4.4 赋值运算符
赋值运算符的左侧运算对象必须是一个左值
4.5 递增和递减运算符
除非必须,否则不使用 递增/减运算符的后置版本,性能/编译器优化
4.6 成员访问运算符
a->member 等价于 (*a).member
4.7 条件运算运算符
三元运算符 ? :
4.8 位运算符
4.9 sizeof运算符
sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof运算符满足右结合律
sizeof运算的结果取决于其作用的类型
- 对char或者类型为char的表达式,结果为1
- 对引用类型的运算,结果为被引用对象所占空间的大小
- 对指针的运算,结果为指针本身所占空间大小
- 对解引用指针的运算,结果为指针所指向对象所占的空间大小
- 对数组的运算,结果为对数组所有元素的sizeof运算之和。注意sizeof运算不会把数组转换位指针处理。
- 对string或者vector的运算,结果为该类型固定部分的大小,不会计算对象中的元素占用了多少空间(vector是一个class类型,sizeof实际计算的是vector类的大小)
因为sizeof可以返回整个数组的大小,所以可以使用数组的大小除以单个元素得到数组中元素的个数:
constexpr size_t arrsz= sizeof(a)/sizeof(*a);
int arr[sz]; // 正确,sizeof的返回值是一个常量表达式,参考2.4.4节
因为sizeof的返回值是一个常量表达式,所以可以用这个值声明数组的维度。
4.10 逗号运算符
逗号运算符从左向右的顺序依次执行
int count=50;
for(int size=0; size<10 ; ++size,--count){
...
}
4.11 类型转换
在C++语言中,某些类型之间存在关联。
如果两种类型存在关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或者值来替代。 即,如果两种类型可以相互转换,那么它们就是关联的
int ival = 3.541 + 3; // 编译器可能会警告该运算损失了精度
C++语言不会直接将两个不同类型的值相加,而是先根据类型转换规则将对象的类型统一后再求值。(这种转换是自动进行的,被称为隐式转换)
算术类型之间的转换被设计的尽可能避免损失精度。比如表达式中既有整型又有浮点型的运算对象,整型会转换为浮点型再相加。
相加完成后是将结果初始化给左面的对象ival,由于被初始化的对象类型不可以改变,所以初始值被转换成该对象的类型。
何时发生隐式类型转换
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型
- 在条件中,非布尔值转换成布尔值
- 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型
- 如果算术运算或者关系运算的对象有多种类型,需要转换成同一种类型
- 如第六章介绍的,函数调用时也会发生类型转换
4.11.1 算术转换
算术转换的含义是把一种算术类型转换成另一种算术类型
其中运算符的运算对象将转换成最宽的类型。如一个运算对象的类型是long double,哪么其他的运算对象不管是什么类型都会转换为long double(因为long double已经是C++中最宽的类型了)
整型提升
bool
char
signed char
unsigned char
short
unsigned short等类型
只要它们的值可能会存于int内,那么就会提升为int类型,否则提升为unsigned int类型。 比如布尔值false提升为0,true提升为1。
较大的char类型
wchar_t
char16_t
char32_t
提升成
int
unsigned int
long
unsigned long
long long
unsigned long long
中最小的一种,前提是: 转换后的类型一定要能容纳原类型所有可能的值
无符号类型的运算对象
如果某个运算符的运算对象类型不一致,这些运算对象将转换成同一类型。
但是如果某个运算对象的类型是无符号类型,那么转换结果就依赖于机器中各个整数类型的相对大小了。(相对大小指的是,无符号类型超过其范围时候又从 0开始,|大小|-类型大小)
- 无符号类型 > 带符号类型,带符号类型转换为无符号类型
即如果int类型为负值的时候,将以2.1.2节的方式转换,并带有该节所介绍的副作用
- 无符号类型 < 带符号类型, 此时的转换依赖与机器。如果无符号类型的所有值都可以存在带符号类型中,则无符号类型转成带符号类型。如果不能,则带符号类型转成无符号类型。
理解算术转换
bool flag;
char cval;
short sval;
unsigned short usval;
int ival;
unsigned int uival;
long lval;
unsigned long ulval;
float fval;
double dval;
3.14159L + 'a'; // 'a'提升成int,然后该int值转换成long double
dval + ival; // ival转换成double
dval + fval; // fval转换成double
ival = dval; // dval转换成(切除小数部分后)int
flag = dval; // 如果dval是0,则flag是false,否则flag是true
cval + fval; // cval提升成int,然后该int值转换成float
ival + ulval; // ival转换成usigned long
usval + ival; // 根据unsigned short 和int所占空间大小进行提升
uival + lval; // 根据unsigned int 和 long 所占空间大小进行提升
4.11.2 其他类型隐式转换
数组转换成指针
:在大多数用到数组的表达式中,数组自动转换成指向数组元素的指针
int ia[10]; // 含有10个整数的数组
int *ip = ia; // ia转换成指向数组首元素的指针
- 当数组被用作decltype关键字的参数,或者作为取地址符
&
、sizeof、及typeid(第19.2.2节介绍)等运算符的运算对象时,上述转换不会发生。 - 如果用一个引用来初始化数组,上述转换也不会发生
- 在6.7节将看到,当表达式中使用函数类型时会发生类似的指针转换
指针的转换
:C++还规定了几种其它的指针转换方式
- 常量整数值0或者字面值nullptr能转换成任意指针类型
- 指向任意非常量的指针能转换成void*
- 指向任意对象的指针能转换成const void*
- 15.2.2节将介绍,在有继承关系的类型间还有另一种指针的转换方式
转换成布尔类型
:
- 如果指针或者算术类型的值为0, 转换的结果是false,否则为true
转换成常量
:允许将指向非常量的指针转换成指向相应的常量类型的指针,引用类型也是这样。
- 如果T是一种类型,我们就可以将指向T的指针或引用分别转换成指向const T的指针或者引用(2.4.1节,2.4.2节)
int i;
const int &j = i; // 允许非const int转换成const &int的引用
const int *p = &i; // 允许非const int转换成const &int的指针
int &r = j, *q = p; // 错误,不允许底层const转换成非const
类类型定义的转换
:类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。在7.5.4节中我们将看到一个例子,如果同时提出多个转换请求,这些请求将被拒绝。
我们之前已经使用过标准库类型string的定义的转换:
string s, t = "a value"; // 字符串字面值转换成string类型
while( cin >> s) // while的条件部分把cin转换成布尔值
4.11.3 显式转换
有时我们希望显式地将对象强制转换成另一种类型。例如,在下面的代码中想执行浮点数除法:
int i=3, j=2;
double slope = i / j; // 如果不做强制类型转换
// 结果将会是3/2=1.5取整为1,
// 再初始化转为double类型1.00给slope
// 而我们的期望值是1.50
这时就需要将 i
、 j
显示地转换成double,这种方法称作强制类型转换 cast
虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的
命名的强制类型转换
一个命名的强制类型转换有如下形式:
cast-name<type> (expression);
其中type是转换的目标类型而expression是要转换的值。
如果type是引用类型,则结果是左值
cast-name
有: static_cast
、 dynamic_cast
、 const_cast
和 reinterpret_cast
dynamic_cast
支持运行时类型识别,我们将在19.2节做更详细的介绍
static_cast
任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。
double slope = static_cast<double>(j) / i;
当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不在乎潜在的精度损失。
一般来说,如果编译器发现一个较大的类型试图赋值给较小的类型,就会给出警告信息。现在我们执行了显示的类型转换后,警告信息就会被关闭了。
static_cast对于编译器无法自动执行的类型转换也非常有用。 例如我们可以使static_cast找回存在于void*的指针中的值(参见2.3.2节):
// d是一个double类型
void *p = &d; // 正确,任何非常量对象的地址都可以存入void*
double *dp = static_cast<double *>(p); // 正确,将void*转换为正确的指针类型
强制转换指针类型时,应该与指针的原类型一致,否则会出现未定义的结果。
const_cast
const_cast将常量对象转换为非常量对象,即去除const。
const_cast只能改变对象的底层const
如果对象本身不是常量,使用const_cast转换后可以获得写的权限 如果对象本身是常量,使用const_cast执行写的操作就会产生未定义的结果
const char *pc;
char *pc = const_cast<char *>(pc); // 正确;但是通过p写值会产生未定义的结果
只有 const_cast
能改变表达式的常量属性,使用其他形式的转换都将引发编译器错误。
而使用 const_cast
也不能改变数据类型。
reinerpret_cast
使用reinerpret_cast非常危险,对类型的上帝操作
旧式的强制类型转换
在早期的C++版本中,显式的进行强制类型转换包含两种形式:
type (expr); // 函数形式的强制类型转换
(type) expr; // C语言风格的强制类型转换
根据所涉及的类型不同,旧式的强制类型转换分别具有与const_cast、static_cast或者reinerpret_cast相似的功能
char *pc = (char*) ip; // ip是指向整数的指针
与命名的强制类型转换相比,旧式的强制类型转换从表现形式上不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。
4.12 运算符优先级表
TODO: 5.1-5.3:
第五章 语句
5.1 简单语句
5.2 语句作用域
C++中大多数语句都以分号结束,一个表达式,(比如ival+5),末尾加上分号,就变成了表达式语句,表达式语句的作用是执行表达式并丢弃掉求值结果。
ival + 5; // 一条没有什么作用的表达式语句
cout << ival; // 一条有用的表达式语句
// 第一条语句没有什么用处,虽然执行了加法,但是相加的结果没有被使用。
// 比较普遍的情况是,表达式中的表达式在求值时附带有其他效果
// 比如变量赋了新值或者输出了结果
空语句
; // 一个空语句
// 重复读入数据,直至文件末尾或某次输入的值等于sought
while(cin >> s && s != sought)
; // 空语句
别漏写分号,也别多写分号
复合语句(块)
复合语句是指用花括号括起来的(可能为空的)语句和声明的序列。
复合语句也被称为块,一个块就是一个作用域(参见2.2.4节 43页)
while(val <=10){
// 作用域
}
{
// 新的作用域
}
{} // 空块
- “块级作用域”,if、switch、while和for语句的控制结构内定义的变量,作用域只在结构内部
5.3 条件语句
5.3.1 if语句
5.3.2 switch语句
case必须是常量表达式(2.4.4节)
5.4 迭代语句
5.4.1 while语句
while(condition){
statement;
}
5.4.2 传统的for语句
initializer可以是声明语句、表达式或者空语句
for(initializer;condition;expression){
statement
}
- 1循环开始时执行initializer
- 2接下来判断condition,true执行循环体,false循环终止
- 3循环体执行完,执行expression
- 循环2-3直到终止
5.4.3 范围for语句
for(declaration: expression){
statement
}
expression必须是一个序列,比如{1, 2, 3}, string或者vector declaration要执行写操作,必须为引用类型
vector<int> v={1,2,3};
// r只能进行读操作
for(auto r: v){
statement
}
// r可以进行读写操作
for(auto &r: v){
statement
}
5.4.4 do while语句
do while语句和while十分类似,区别是不管条件是否为真,都会至少执行一次循环
do{
statement
}
while (condition);
注意while后需要有个分号
condition使用的变量不能定义在do循环体内
5.5 跳转语句
break, continue, goto, return
5.5.1 break 语句
break负责终止离它最近的一个while, do while, for或者switch 只能出现在这些语句的结构内部
5.5.2 continue 语句
break负责终止离它最近的一个循环并立即开始下一次循环
5.5.3 goto 语句
goto语句的作用是从goto语句无条件跳转到同一函数的另一条语句
goto label;
label: xxxx;
不要在程序中使用goto,因为它使得程序既难理解又难修改
5.6 try 语句块和异常处理
throw
表达式: 抛出异常try
catch
语句块: try尝试捕获异常 catch处理异常- 异常类,用于在throw表达式和相关的catch字句之间传递异常的具体信息
5.6.1 throw 表达式
if(...){
throw runtime_error("xxxxxxxx is wrong");
}
runtime_error
是标准异常类型的一种,定义在 stdexcept
头文件中。
更多标准异常类型在5.6.3节介绍。
5.6.2 try 语句块
try{
program-statements
} catch( exception-declaration ){
program-statements
} catch( exception-declaration ){
program-statements
}
- 同样拥有块级作用域,特别是try语句块里面的声明,在catch里面也无法访问
try{
// 异常时抛出一个runtime_error
throw runtime_error("xxxxxxxx is wrong");
} catch( runtime_error &err ){
cout << err.what() << "ERROR!" << endl;
}
如果一段程序没有try语句且发生了异常,系统会调用terminate函数并终止当前程序的执行
5.6.3 标准异常
- exception头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息
- stdexcept头文件定义了几种常用异常类,见下图
- new头文件定义了bad_alloc异常类型,这种类型在12.1.2节介绍
- type_info头文件定义了bad_cast异常类型,这种类型在19.2节介绍
exception、bad_alloc、bad_cast 异常类型只能默认初始化(括号加字符串字面值),不能被赋与值
try{
// 异常时抛出一个runtime_error
throw runtime_error("xxxxxxxx is wrong");
} catch( runtime_error &err ){
// 取runtime_error的引用,原因参考https://stackoverflow.com/questions/6755991/catching-stdexception-by-reference
cout << err.what() << " ERROR!" << endl;
// xxxxxxxx is wrong ERROR!
}
异常类型只有一个成员函数 what()
,返回一个指向c风格字符串的const char*, 提供关于异常的文本信息
第六章 函数
函数的定义、声明,传入参数、返回结果 重载函数 函数指针
6.1 函数基础
编写函数
函数的组成
- 返回类型 return type
- 函数名
- 参数列表: 0个或者多个形参,以逗号隔开
- 函数体
int funcName(int a, int &b){
return a+(b++);
}
调用函数
函数的调用
- 调用运算符来执行函数:
funcName()
, - 括号内传入调用函数实际使用的参数,即实参。
funcName(a,b)
- 返回类型即函数定义的返回类型,
int b=2; int rs = funcName(1,b);
函数的调用完成两项工作:1是用实参初始化函数对应的形参,隐式的定义并初始化它的形参。2是将控制权转移给被调用函数,此时主调用函数的执行被暂时中断,被调函数开始执行。
当遇到return时结束函数的调用,return语句也完成两个工作:1是返回return语句中的值(如果有的话)。2是将控制权从被调用函数转移回主调函数
形参和实参
C++中规定,形参和实参数量应该一致,所以函数定义了多少个参数,调用时候就必须传多少个参数(除非函数重载)
函数的形参列表
void f1(){} // 隐式地定义空形参列表
void f2(void){} // 显式地定义空形参列表
/* 必须为每个形参单独指定类型 */
void f2(int v1, v2){} // 错误
void f2(int v1, int v2){} // 正确
- 必须为每个形参单独指定类型
- 任意两个形参不能同名
- 外层作用域的变量不能和形参同名
函数返回类型
大多数类型都可以作为函数返回类型。
一种特殊的返回类型是void,表示函数不返回任何值。
函数的返回类型不能是数组类型或者函数类型,但是可以是指向数组或者函数的指针。
6.3.3介绍如何定义一种特殊的函数,它的返回值是指向数组的指针 6.7介绍如何返回指向函数的指针
6.1.1 局部对象
在C++中,名字有作用域,对象有生命周期,理解这两个概念非常重要
- 名字的作用域是程序文本的一部分,名字在其中可见
- 对象的生命周期是程序执行过程中该对象存在的一段时间
函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。
形参和函数体内部定义的变量统称为局部变量,它们对函数而言是局部的,仅在函数的作用域内可见(可用),对外部隐藏。
局部变量的生命周期依赖于定义的方式(见下方)
自动对象
普通声明的变量,在块作用域中执行时候被初始化,块执行完后销毁。
比如函数的形参、for循环的initializer
#include<iostream>
using namespace std;
// A function with default arguments, it can be called with
// 2 arguments or 3 arguments or 4 arguments.
int sum(int x, int y, int z=0, int w=0)
{
return (x + y + z + w);
}
/* Driver program to test above function*/
int main()
{
cout << sum(10, 15) << endl;
cout << sum(10, 15, 25) << endl;
cout << sum(10, 15, 25, 30) << endl;
return 0;
}
内置类型的未初始化局部变量的值将无法预测
局部静态对象
局部静态对象只在第一次执行该语句时初始化,并且直到程序终止才被销毁
下面的函数统计它被调用了多少次:
size_t callCount() {
static size_t cnt = 0;
return ++cnt;
}
int main() {
using std::cin;
using std::cout;
using std::endl;
callCount();
callCount();
size_t cnt = callCount();
cout << "cnt:" << cnt << endl;
return 0;
}
6.1.2 函数声明
函数必须在使用前声明。
函数声明也叫函数原型。
函数声明可以省略函数体
函数声明可以省略形参名
void funcName(int, int&, string, double, int*);
/* 函数声明 */
void funcName(int a);
void funcName(int a);
/* 函数定义 */
void funcName(int a){
return a;
}
- 函数可以声明多次,但只能定义一次
- 如果函数不使用的话,可以只有声明,没有定义。
在头文件中进行函数声明
我们之前建议变量在头文件中声明(2.6.3节),在源文件中定义。函数也应该如此。
将函数声明放在头文件中,可以确保同一函数的所有声明保持一致。而且如果我们想要改变函数的接口,只需改变一条声明即可。
定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。
含有函数声明的头文件应该被包含到定义函数的源文件中
6.1.3 分离式编译
C++支持分离式编译,允许我们把程序分隔到几个文件中去,每个文件单独编译。
- 源文件 .cpp
- 头文件 .h
编译和链接多个源文件
我们有个 compute
函数,声明在 compute.h
中,定义在 compute.cpp
中并引用头文件 compute.h
主函数 main
在 main.cpp
中, main
函数中将调用 compute
函数。
define C++ functions inside header files?
- 头文件
compute.h
#ifndef FUNCTIONS_H_INCLUDED
#define FUNCTIONS_H_INCLUDED
// 函数定义(函数原型)
int add(int a, int b);
#endif
- 功能函数
compute.cpp
#include "compute.h"
// 函数声明
int add(int a, int b){
return a + b;
}
- 主函数
main.cpp
#include <iostream>
#include "compute.h"
// 程序入口
int main()
{
std::cout << "add(1, 2) = " << add(1, 2) << '\n';
}
要生成可执行文件,必须告诉编译器我们用到的代码在哪里:
g++ main.cpp compute.cpp
g++ main.cpp compute.cpp -o main
6.2 参数传递
如之前所述,每次调用函数都会重新创建它的形参,并用传入的实参对形参进行初始化
形参的初始化和变量的初始化一样
形参的类型决定了形参和实参交互的方式:
- 如果形参是引用类型,它将绑定到对应的实参上,
引用传递,传址
- 否则,将实参的值拷贝后赋给形参,
值传递,传值
6.2.1 传值参数
非引用传参,实参的值拷贝给形参,修改形参不会对实参产生影响。
指针形参
指针的行为和其他非引用类型是一样,不过拷贝的是指针的值,而非指针指向对象的值。
拷贝后,实参和形参的指针是两个不同的指针,但都指向同一个对象,所以可以解引用来操作所指的对象。
int funcName(int *p2){
*p2=0; // 将a的值改变为0
p2=0 // 将形参p2指向0,不会影响实参p仍指向a
}
int a=1;
int *p = &a;
funcName(p);
// funcName(&a);
// 相当于
/*
int a=1;
int *p = &a;
// 初始化形参
int *p2 = p;
*p2=0;
p2=0
*/
6.2.2 传引用参数
/* 接收一个对int对象的引用*/
int funcName(int &r2){
r2=0; // 将a的值改变为0
}
int a=1;
funcName(a);
// 相当于
/*
int a=1;
// 初始化形参
int &r = a;
r=0;
*/
使用引用避免拷贝
拷贝大的类型对象或者容器对象比较低效,甚至有些类类型不支持拷贝操作(比如IO类型)
避免拷贝传入的参数,直接使用引用类型定义形参。
如比较两个(比较长的)字符串:
bool isStrShorter(string &str1,string &str2){
}
使用引用形参返回额外信息
在原有函数上增加功能时,可以考虑使用形参多传一些参数来记录额外的信息
6.2.3 const形参和实参
当形参是const时,必须要注意2.4.3节关于顶层const的讨论。
和其他初始化一样,用实参初始化形参时会忽略掉顶层const(即形参的顶层const被忽略,形参有顶层const时,可以传常量也可以传非常量)
void fcn(const int i){ /* i可以传常量,也可以传非常量 */
/* 函数体内i只读,不可写 */
}
void fcn(const int i){}
void fcn(int i){} /* 错误,重复定义了fcn(int) */
C++中可以定义多个同名的函数,前提是参数不同。 上面的例子由于顶层const被忽略,所以参数是一样的,因此报错。
指针或引用形参 与const
尽量使用常量引用
在不会改变引用形参的函数中,尽量使用常量引用
6.2.4 数组形参
数组的两个性质:1不允许拷贝数组 2使用数组时(通常)会将其转换为指针
当我们为函数传递一个数组时,实际上传递的是指针数组首元素的指针
/*
尽管形式不同,三个print是等价的
每个函数都是const int*的形参(数组转换为指针)
*/
void print(const int*){}
void print(const int[]){}
void print(const int[10]){} // 这里维度没有作用
因为数组是以指针的形式传递给函数的,所以函数不知道数组的确切尺寸。 有三种常用的方式:
使用标记指定数组长度
C风格字符串,以 \0
结尾
使用标准库规范
传入begin和end的指针
void print(int *beg, int *end){
while(begin != end){
/* statement */
}
}
显示传递一个表示数组大小的形参
void print(const int ia[],size_t size){
}
数组形参和const
6.2.3的讨论同样适用
数组引用形参
C++允许将变量定义成数组的引用3.5.1,同样的形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上。
/* 形参是数组的引用,维度是类型的一部分 */
void print(int (&arr)[10]){
}
void print(int &arr[10]) // 错误,声明为了元素类型为引用的数组
传递多维数组
C++中没有真正的多维数组,所谓的多维数组是数组的数组
当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。 因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组的第二维(及后面所有维度)的大小都是数组类型的一部分,不能省略
// matrix指向数组的首元素该数组的元素是由10个整数构成的数组
void print(int (*matrix)[10], int rowSize){}
matrix两端的括号必不可少, int (*matrix)[10] 否则就是元素类型为指针的数组
6.2.5 main处理命令行选项
我们之前定义的main函数都只有空形参列表,main函数也可以传递参数的。
一种常见的情况是通过设置一组选项来确定函数要执行的操作。 例如:
/* 假定main函数位于可执行文件prod之内 */
prod -d -o outfile data0
/* 这些命令行选项通过两个(可选的)形参传递给main函数 */
int main(int argc, char *argv[]){
}
- 第二个参数argv是一个数组,它的元素是指向c风格字符串的指针,表示命令行命令的字符串
- 第一个参数argc表示数组中字符串的数量
- 当实餐传给main函数之后,argv的第一个元素指向程序名字或者一个空字符串,后面的参数依次传递命令行的参数,命令最后一个指针元素的值保证为0
prod -d -o outfile data0
/*
argc为5
argv[0] = "prod"; // 或者argv[0]也可以指向一个空字符串
argv[0] = "-d";
argv[0] = "-o";
argv[0] = "ofile";
argv[0] = "data0";
argv[0] = 0;
*/
当使用argv时候,一定要记住从argv[1]开始。argv[0]保存的是程序的名字,而非(用户输入)命令参数
6.2.6 含有可变形参的函数
有时我们无法提前预知应该向函数传递几个实参。
C++11提供两种主要的方式处理不同数量的函数参数
- 如果所有参数的类型相同,可以传递一个initializer_list的标准库类型。
- 如果参数类型不相同, 可以编写可变参数模板,在16.4节介绍
- 还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参,不过这个用法一般只用于与C函数交互的接口程序
initializer_list形参
initializer_list是一种标准库类型,用于表示某种特定类型的值的数组
- initializer_list和vector一样也是一种模板类型
- 区别是initializer_list中的元素永远是常量值,无法操作initializer_list的元素
- 有成员函数
begin
end
- 调用时使用花括号
{}
- 含有initializer_list的函数也可以同时拥有其他形参
void printError(initializer_list<string> err, ErrCode e){
for(
auto beg = err.begin();
err.begin() != err.end();
++beg
){
cout << *beg << endl;
}
cout << endl;
}
string s = "error";
string s2 = " !";
printError({“here is a ”, s , s2}, ErrCode(0))
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。
通常省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs。
省略符形参应该仅仅用于C和C++通用的类型。特别注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎一下两种:
void foo(params,params2,...);
void foo(...);
6.3 返回类型和return语句
return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方: return语句有两种形式:
return ;
return expression;
6.3.1 无返回值函数
没有返回值的函数只能用在 void
类型的函数中。
也可以省略return,因为此类函数最后一句后面会隐式的执行return
函数中间位置提前退出也可以用return
void函数中也可以使用 return expression;
的形式,不过表达式的值需要时void
void函数return了其他类型会产生编译错误。
6.3.2 有返回值的函数
只要函数不是void,则每条return语句必须返回一个值。 return的类型必须和函数类型一致,或者能隐式转换为函数类型。
编译器会判断return结果类型的正确性
在含有return语句的循环后面也应该有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。
值是如何被返回的
返回一个值得方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
string parseString(size_t sSize,const string &word,const string &ending){
return (sSize>1) ? word + ending : word;
}
判断数值大于1, 返回单词的复数形式,否则返回单数形式。
由于返回类型为string,(非指针或者引用)所以意味着返回值将拷贝到 调用点
是返回值的一个副本
如果函数返回类型为引用,则不会进行拷贝操作,返回的引用是它所引对象的一个别名:
const string &shorterString(const string &s1, const string &s2){
return s1.size() > s2.size() ? s1 : s2;
}
其中形参和返回类型都是const string的引用,所以不管是调用函数还是返回函数都不会真正拷贝string对象。
不要返回局部对象的引用或指针
函数执行完后,它所占用的内存空间也随之被释放掉。因此函数内部定义的变量将不会在内存当中(局部变量的引用将指向不再有效的内存区域)
const string &manip(){
string ret;
//
if(!ret.empty()){
return ret; // 错误:返回局部对象的引用
}else{
return "empty" // 错误:“empty”是一个局部临时量
}
}
字符串字面值会转换成一个局部临时string对象。 对于上面的函数来说,“empty”和ret都是局部的。当函数结束的时候,临时对象占用的空间就随之释放掉了,所以两条return语句都指向了不再可用的内存空间。
要确保返回值的安全,看看return的值到底是不是局部的
指针也是同样的,将函数内定义的局部的指针return的也是错误的。函数执行完成,局部的指针将指向不存在的对象。
返回类类型的函数和调用运算符
调用运算符号的优先级与点运算符和箭头运算符相同,并且也符合左结合律。
所以如果函数返回指针、引用或类的对象,我们就可以使用函数调用的结果访问其成员。
string &shorterString(const string &s1, const string &s2);
auto ss = shorterString("123", "1234").size(); // ss=4
引用返回左值
返回值是引用类型的函数得到左值,其他返回类型得到右值
返回类型是非常量引用类型时,可以操作返回结果引用的对象(常量引用类型时,不可以可以操作返回结果引用的对象)
char &getVal(string &str, string::size_type idx) {
return str[idx]; // getVal假定idx是有效的
}
getVal("hello",0)='i'; // iello
列表初始化返回值
C++11标准规定,函数可以返回花括号包围的值得列表。
若列表为空,则进行默认初始化 返回值类型由函数类型决定
vector<string> process(){
if(expected.empty()){
return {} // 返回一个空的vector对象
}else if(expected== actual){
return {"functionX", "Okay"}; // 返回列表初始化的vector
}else{
return {"functionX", expected, actual }; // 返回列表初始化的vector
}
}
如果函数返回类型是内置类型,则花括号包围的列表最多包含一个值,且所占空间不应该大于目标类型的空间。
如果返回的是类类型,则由类来定义初始值如何使用。
主函数main的返回值
主函数main允许没有return,编译器会隐式地插入一条return 0
递归
函数直接或者间接调用本身
int factorial(int val){
if(val > 1){
return factorial(val-1) * val;
}
retutn 1;
}
6.3.3 返回数组指针
因为数组不能拷贝,所以函数不能返回数组。
不过函数可以返回数组的指针或者引用。
一个返回数组的指针或者引用的定义比较繁琐,有一些方法可以简化这些定义:
使用类型别名:
typedef int arrT[10];
using arrT = int[10];
/* 上面两种定义等价,arrT是一个类型别名,它表示的类型是含有10个整数的数组 */
arrT* func(int i); // func返回一个指向含有10个整数的指针
声明一个返回数组指针的函数
不实用类型别名的话,声明一个返回数组指针的函数:
int (*func(int i))[10];
可以按照一下顺序来逐层理解该声明的含义:
func(int i)
表示调用func需要一个int类型的实参(*func(int i))
意味着我们可以对函数调用的结果执行解引用操作(*func(int i))[10]
表示解引用操作得到一个大小是10的数组int (*func(int i))[10]
表示数组中元素类型是int
使用尾置返回类型
auto func(int i) -> int(*)[10];
使用decltype
如果知道函数返回的指针指向哪个数组,就可以使用decltype
int odd[] = {1,3,5,7,9};
int even[] = {2,4,6,8,10};
decltype(odd) *arrPtr(int i){
return (i % 2) ? &odd : &even;
}
注意decltype不会将数组名转换成指针,需要在函数声明处添加一个*表示函数返回的是一个指针。
6.4 函数重载
在同一个作用域的几个形参列表不同的同名函数,称为重载函数(overloaded)
在6.2.4中我们定义了几个名为print的函数:
void print(const char * cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);
调用函数时,编译器会根据传递的实参个数、类型推断调用的哪个函数
int j[2] = {0,1};
print("Hello world"); // 调用print(const char * cp);
print(j, end(j)-begin(j)); // 调用print(const int ia[], size_t size);
print(begin(j), end(j)); // 调用print(const int *beg, const int *end);
main函数不能重载
定义重载函数
有一种典型的数据库应用,创建一个函数分别根据名字、电话、账户号码等信息查找记录。
/* Record、Account、Phone、Name 为自定义的数据类型,或者别名 */
Record lookup (const Account&); // 根据Account查找记录
Record lookup (const Phone&); // 根据Phone查找记录
Record lookup (const Name&); // 根据Name查找记录
Account act;
Phone phone;
Record r1 = lookup(act); // 调用lookup (const Account&);
Record r1 = lookup(phone); // 调用lookup (const Phone&);
重载函数的形参应该是:数量不同或者数据类型不同,否则编译错误:二义性调用
判断两个形参是否相异
有时两个形参看起来不一样,但实际上是相同的。
Record lookup(const Account& acct);
Record lookup(const Account& ); // 省略形参的名字
typedef Phone Telno;
Record lookup(const Phone& );
Record lookup(const Telno& ); // Phone和Telno类型相同
重载和const形参
如6.2.3介绍的,顶层const(2.4.3节)不影响传入函数的对象。
有顶层const的形参和同类型的无顶层const的形参无法用于区分函数重载
Record lookup(Phone);
Record lookup(const Phone); // 顶层const被忽略,重复声明了Record lookup
Record lookup(Phone*);
Record lookup(Phone* const); // 指针的顶层const,重复声明了Record lookup
指针
Phone* const 顶层const
指针本身是常量const Phone* 底层const
指向常量的指针
引用
Phone& const 顶层const
引用本身是常量const Phone& 底层const
引用了常量的引用
Record lookup(Phone&);
Record lookup(const Phone&); // 底层const,未重复,函数为lookup的另个一重载
Record lookup(Phone*);
Record lookup(const Phone*); // 底层const,未重复,函数为lookup的另个一重载
调用时,只能const实参只能调用const形参。
相反的,因为非常量可以转换为const,所以上面四个函数都可以作用于非常量对象,或者指向非常量对象的指针。而编译器会优先选用非常量的版本。
合理使用函数重载,功能差异大的函数还是使用不同的函数名可读性更好
void move(int, int); void moveAbs(int, int); void slide(int, int);
const_cast和重载
在4.11.3节中我们说过,const_cast在重载函数的情景中最有用。
const string &shorterString(const string &s1, const string &s2){
retutn s1.size() <= s2.size() ? s1 : s2;
}
string &shorterString(string &s1, string &s2){
auto &r = shorterString(
const_cast<const string&> s1,const_cast<const string&> s2);
return const_cast<string &> r;
}
/*
在这个版本的重载中,首先将它的实参强制转换成对const的引用,然后调用了shorterString函数的const版本。const版本返回对const string的引用,这个引用事实上绑定在某个初始的非常量实参上。因此我们可以再将其转换回一个普通的string&,这显然是安全的
*/
调用重载的函数
定义了一组重载函数后,我们需要以合理的实参调用它们。调用时候进行函数匹配/重载确定,编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
调用重载函数有三种可能的结果:
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,编译器发出无匹配的错误
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时编译器发出二义性调用的错误
6.4.1 重载与作用域
重载对作用域的一般性质没有什么改变:
- 在内层作用域中声明的名字,它将隐藏外层作用域中声明的同名实体。
- 在不同的作用域中无法重载函数名
string read();
void print(const string &);
void print(double); // 重载print函数
void fooBar(int ival){
bool read = false; // 新作用域:隐藏了外层的read
string s = read(); // 错误,read是一个布尔值,不是一个函数
/*
不好的习惯:通常来说,在局部作用域中声明函数是不好的
*/
void print(int); // 新作用域,隐藏了之前的所有print
print("Value: "); // 错误,print(const string &)被隐藏掉了
print(1); // 正确,当前print(int)可见
print(3.14); // 正确,调用的是print(int),实参的double隐式转换为int,print(double)被隐藏掉了
}
在调用print函数时,编译器首先寻找对该函数名的声明,找到的是接受int值的那个局部声明。一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。剩下的工作就是检查函数调用是否有效了。
在C++中名字查找发生在类型检查之前
TODO: 6.5-6.7
6.5 特殊用途语言特性
默认实参、内联函数、constexpr函数及在调试过程中常用的一些功能
6.5.1 默认实参
使用默认实参调用函数
默认实参声明
默认实参初始值
6.5.2 内联函数和constexpr函数
内联函数可以避免函数调用的开销
constexpr函数
把内联函数和constexpr函数放在头文件内
6.5.2 调试帮助
assert预处理宏
NDEBUG预处理变量
6.6 函数匹配
确定候选函数和可行函数
寻找最佳匹配
含有多个形参的函数匹配
6.6.1 实参类型转换
需要类型提升和算术类型转换的匹配
函数匹配和const实参
6.7 函数指针
声明一个指向函数的指针,只需用指针替换函数名即可:
/*
pf指向一个函数,该函数的参数是两个const string的引用,
返回值是bool类型
*/
bool (*pf)(const string &, const string &)
使用函数指针
使用函数时,函数名会自动转换成指针。
函数指针可以赋值给另一个指针(前提是函数的形参类型和返回类型需要与被赋值的指针的数据类型一直)。
重载函数的指针
当使用指针指向重载函数时,上下文必须清晰地界定到底应该选用哪个函数。编译器根据指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配。
void ff(int *);
void ff(unsigned int);
void (*pf)(unsigned int) = ff; // pf指向ff(unsigned int)
void (*pf2)(int) = ff; // 错误,形参列表不匹配,没有任何一个ff与该形参列表匹配
double (*pf3)(int *) = ff // 错误,返回类型不匹配
函数指针(作为)形参
和数组类似(参见6.2.4节),虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。
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 &));
/*
调用时候也是可以直接使用函数名,它将自动转换成指向该函数的指针
*/
useBigger(s1,s2,lengthCompare);
类型别名和decltype可以让我们简化使用函数指针的代码。
bool lengthCompare(const string &, const string &);
/* 这两种声明等价, Func和Func2是函数类型 */
typedef bool Func(const string &, const string &);
typedef decltype(lengthCompare) Func2;
/* 这两种声明等价, FuncP和FuncP2是指向函数的指针类型 */
typedef bool (*FuncP)(const string &, const string &);
typedef decltype(lengthCompare) FuncP2;
/* 等价的声明 */
void useBigger(
const string &s1,
const string &s2,
Func)
void useBigger(
const string &s1,
const string &s2,
Func2)
void useBigger(
const string &s1,
const string &s2,
FunP)
void useBigger(
const string &s1,
const string &s2,
FuncP2)
返回指向函数的指针
和数组类似(6.3.3节),虽然不能返回一个函数,但是能返回指向函数类型的指针。
我们必须把返回类型写成指针形式。 简化的写法是使用类型别名。
using F = int (int *, int); // F是函数类型,不是指针
using PF = int(*) (int *, int); // PF是指向函数的指针
需要注意的是返回类型不会(像作为形参的函数一样)自动转换成指针,必须显示地将返回类型定义为指针
PF f1(int); // 正确:PF是指向函数的指针,f1返回指向函数的指针
F f1(int); // 错误,F是函数类型,不能返回一个函数类型
F* f1(int); // 正确,返回一个指向F类型的函数的指针
/*
直接声明
*/
int (*f1(int))(int *, int);
// f1形参是int
// 返回是指向函数类型为int (*)(int *, int)的一个指针
将auto和decltype用于函数指针类型
如果明确知道返回的函数是哪一个,就可以使用decltype来简化返回指针的函数的声明。
需要注意的是使用decltype时,返回的是函数类型,而非指向函数的指针,所以我们需要显式地加上*以表明我们需要返回的是指针,而非函数本身
(同样的,decltype也不会将数组名转换成指针,需要在函数声明处添加一个*表示函数返回的是一个指针,参考4.11.2节,6.3.3节)
auto f1(int) -> int (*)(int*, int);
string::size_type sumLength(
const string &s1,
const string &s2);
string::size_type largerLength(
const string &s1,
const string &s2);
/* getFunc内部的返回值是sumLength、largerLength其中一种
因为两种的数据类型相同,所以选其一decltype(largerLength)即可
*/
decltype(largerLength) *getFunc(const string&);
第七章 类
在C++中,我们使用类定义自己的数据类型。 通过定义新的类型来反映待解决的问题中的各种概念,可以使我们更容易编写、调试和修改程序
本章是第二章关于类的话题的延续,主要关注数据抽象的重要性。 数据抽象能帮助我们将对象的具体实现与对象能执行的操作分离开来。
第13章讨论如何控制对象拷贝、移动、赋值和销毁等问题 第14章中我们将学习如何自定义运算符
类的基本思想是数据抽象和封装。 数据抽象是一种依赖于接口和实现分离的编程技术
类的接口包括用户所能执行的操作 类的实现包括类的 数据成员、负责接口实现的函数体以及定义类所需的各种私有函数
封装使得类的接口和实现分离,封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型
7.1 定义抽象数据类型
Sales_item是一个抽象数据类型,Sales_item类有一个名为isbn的成员函数,并且支持+、=、+=、<<和>>运算符。
我们在14章学习如何自定义运算符。现在我们将这些运算定义为普通函数形式:定义一个Sales_data
- 一个isbn成员函数,返回ISBN编号
- 一个combine成员函数,用于将一个Sales_data对象加到另一个Sales_data对象上
- 一个add函数,执行两个Sales_data对象的加法
- 一个read函数,将数据从istrearm读入到Sales_data对象中
- 一个print函数,将Sales_data对象的值输出到ostrearm
使用改进的Sales_data类
在考虑如何实现我们的类之前,首先来看看应该(想要)如何使用这些接口函数
Sales_data total;
if (read(cin, total)) {
Sales_data trans;
while (read(cin, trans)) {
if(total.isbn() == trans.isbn()){
total.combine(trans);
}else{
print(cout,total) << endl;
total = trans;
}
}
print(cout, total) << endl;
} else{
cerr << "No data!?" << endl;
}
7.1.2 定义改进的Sales_data类
-
ISBN编号: string bookNo
-
本书的销量:unsigned unit_sold
-
本书的总销售收入:double revenue
-
成员函数:combine 将两个Sales_data对象相加
-
成员函数:isbn 取Sales_data对象的ISBN编号
-
成员函数:avg_price 返回售出书籍的平均价格
-
一个add函数,执行两个Sales_data对象的加法
-
一个read函数,将数据从istrearm读入到Sales_data对象中
-
一个print函数,将Sales_data对象的值输出到ostrearm
定义(6.1节)和声明(6.1.2节)成员函数的方式与普通函数差不多。 成员函数的声明必须在类的内部,定义可以在类的内部也可以在类的外部 作为接口的非成员函数(add, read, print),定义和声明都在类的外部
struct Sales_data {
string bookNo;
unsigned unit_sold;
double revenue;
/*
const member function
https://www.tutorialspoint.com/const-member-functions-in-cplusplus#:~:text=The%20const%20member%20functions%20are,by%20any%20type%20of%20object.
*/
string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data&);
double avg_price() const;
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream &, const Sales_data&);
std::istream &read(std::istream &, const Sales_data&);
定义在类内部的函数是隐式的inline函数
定义成员函数
成员必须函数定义在类内部,但是既可以声明在类内部,也可以声明在类外部
我们来看看成员函数isbn,块中只有一条return语句,用于返回Sales_data对象的bookNo数据成员。那么它是如何获得bookNo成员所依赖的对象的呢?
引入this
先观察isbn成员函数的调用
total.isbn()
在这里我们使用了点运算符 .
来访问total对象的isbn成员函数然后调用它。
7.6节将介绍一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。
isbn使用了Sales_data的成员(例如bookNo),实际上是隐式的指向了调用isbn的对象的成员(即total.isbn()调用时,return的bookNo就是total.bookNo)
成员函数通过一个隐式参数 this
来访问调用它的对象。
某个类实例对象调用类的成员函数时, this
会初始化为该实例对象的地址
当调用 total.isbn()
时,编译器把total的地址传递给isbn的隐式形参 this
,等价于:
/* 伪代码,仅用于说明调用成员函数的执行过程 */
Sales_data::isbn(&total);
调用isbn时,传入了total的地址给函数内部的this指针
任何对类成员的直接访问都被看做是this的隐式调用
string isbn() const { return bookNo; }
等价于
string isbn() const { return this->bookNo; }
this总是指向当前对象,所以this是一个常量指针,我们不允许改变this中保存的地址
引入const成员函数
isbn函数的另一个关键是参数列表之后的 const
关键字,这里的const的作用是修改隐式this指针的类型。
默认情况下,this的类型是指向类类型非常量版本的常量指针(指向非常量的常量指针)。
例如在Sales_data中,this的类型是Sales_data *const。
尽管 this
是隐式的,它也得遵循初始化规则,意味着我们不能把this绑定到一个常量对象上(常量变量的地址只能用指向常量的指针存放,参考2.4.2节),进而常量对象也就不能调用(非常量的)普通的成员函数。
所以需要把isbn()的this指针设置为指向常量指针,从而提高函数的灵活性。 而this是隐式的没有地方作声明,所以C++允许把const关键字放在成员函数的参数列表之后,表示this是一个指向常量的(常量)指针(this指针本身就是一个常量指针,const声明表示this指向了常量)
/* 伪代码,仅为了表示const对于this的作用 */
string isbn() const;
string isbn(const Sales_data *const this);
string isbn();
string isbn(Sales_data *const this);
因为this是指向常量的常量指针,所以常量成员函数不能改变调用它的对象的成员内容。 isbn可以读取实例对象的数据成员,但不能改变和写入新值
常量对象、以及常量对象的引用或指针都只能调用常量成员函数
类作用域和成员函数
类本身就是一个作用域(2.6.1节)。
类的成员函数的定义嵌套在类的作用域之内,因此isbn中用到的名字bookNo其实就是定义在Sales_data内的数据成员
值得注意的是即使bookNo定义在isbn之后,isbn也还是能够使用bookNo。 7.4.1会介绍,对于类的成员,编译器会分两部处理:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。
因此在成员函数体内可以随意使用类中的成员,不需要注意声明的次序。
在类外部定义函数成员
在类外部定义函数成员时,成员函数的定义和声明必须匹配,
- 即返回类型、参数列表和函数名都得与类内部的声明保持一致。
- 同时,类外部定义的成员函数的名字必须包含它所属的类名
double Sales_data::avg_price() const {
if(unit_sold){
return revenue / unit_sold;
} else {
return 0;
}
}
定义一个返回this对象的函数
Sales_data & Sales_data::combine(const Sales_data &item){
unit_sold += item.unit_sold; // 把item的成员加到this对象的成员上
revenue += item.revenue // 把item的成员加到this对象的成员上
return *this;
}
调用时:
total.combine(trans) // 更新变量total当前的值
值得关注的一个部分是它的返回类型和返回语句。 一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。
内置的赋值运算符把他的左侧运算对象当成左值返回(参考4.4节),因此为了与它保持一致,combine函数必须返回引用类型(6.3.2节)。因为此时的左侧运算对象是一个Sales_data对象,所以返回类型应该是Sales_data&。
其中return语句解引用this指针得到调用该函数的对象,上面的例子调用即返回total这个对象。
7.1.3 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add,read和print等。 这些函数应该属于类的接口的组成部分,不属于类本身。
定义非成员函数的方式和定义其他函数一样,通常把函数的声明和定义分离开来(参见6.1.2节)。
如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。这样,使用接口的任何部分都只需引入一个文件。
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件中。
定义read和print函数
下面的read和print函数与(2.6.2节)中的代码作用一样,而且代码本身也非常相似:
istream &read(istream &is, Sales_data &item){
double price = 0;
is >> item.bookNo >> item.unit_sold >> price;
item.revenue = price * item.unit_sold;
return is;
}
ostream &print(ostream &os, Sales_data &item){
os << item.isbn() << " " << item.unit_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
定义add函数
add函数接受两个Sales_data对象作为其参数,返回值是一个新的Sales_data, 用于表示前两个对象的和。
Sales_data add(const Sales_data &lsd,const Sales_data &rsd){
Sales_data sum = lsd;
sum.combine(rhs);
return sum;
}
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数
构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数
构造函数是一个比较复杂的问题,我们还会在7.5节、15.7节、18.1.3节和第13章介绍更多的关于构造函数的知识。
- 构造函数的名字和类名相同。
- 构造函数没有返回类型
- 其他类似于普通函数
- 类可以有多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数列表或者参数类型上有所区别
- 构造函数不能被声明为const的(参见7.1.2),当我们创建一个类的const实例时候,直到构造函数执行完后,该实例对象才能真正取得其常量属性
合成的默认构造函数
我们的类没有定义任何构造函数,
类有个默认的构造函数,默认的构造函数无需任何实参
编译器创建的构造函数又被称为合成的默认构造函数,对于大多数类来说,这个默认构造函数将按照以下规则初始化类的数据成员:
- 如果存在类的初始值(参见2.6.1节),用它来初始化成员
- 否则默认初始化该成员
某些类不能依赖于合成的默认构造函数
默认的构造函数只适用于非常简单的类
一个类普通的类,必须定义它的构造函数 三个原因:
- 只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数
- 编译器合成的默认构造函数可能导致执行错误的操作(错误的初始化复合类型数组、指针等)
- 编译器有时无法为某些生成默认的构造函数(类中有其他类类型的成员,且其没有默认的构造函数)
定义Sales_data的构造函数
struct Sales_data{
// 构造函数部分
Sales_data() = default;
Sales_data(const std::string &s) : booNo(s){} // 构造函数将成员bookNo初始化为输入值s
Sales_data(const std::string &s, unsigned n, double p) : booNo(s), unit_sold(n), revenue(P*n){}
Sales_data(std::istream &);
// 类成员
std:string isbn() const { return bookNo; }
Sales_data & combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned unit_sold = 0;
double revenue = 0.0;
}
=default的含义
当我们声明了类的一些构造函数,又需要默认的行为,就需要使用 = default
。
在C++11标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default
来要求编译器生成构造函数,这个函数的行为完全等同于编译器合成的默认构造函数
= default
既可以定义在类的内部,也可以定义在类的外部。
如果编译器不支持类内初始值,那么你的默认构造函数应该使用构造函数的初始值列表(马上就会介绍)。
构造函数初始值列表
上面的类中,除了 = default
还定义了两个构造函数。
Sales_data(const std::string &s) : booNo(s){} // 构造函数将成员bookNo初始化为输入值s
Sales_data(const std::string &s, unsigned n, double p) : booNo(s), unit_sold(n), revenue(P*n){}
冒号和花括号之间的代码,被称为构造函数的初始值列表。作用是在使用该构造函数初始化实例对象时,为这几个数据成员赋初值。不同的数据成员通过逗号分隔。初始化的形参可以使用构造函数的形参列表。
如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员
Sales_data(const std::string &s):
bookNo(s),unit_sold(0),revenue(0){}
构造函数不应该轻易覆盖类内的初始值。如果编译器不支持使用类内初始值,则所有构造函数都应该显式的初始化每个内置类型的数据成员
还有一点需要注意的是,上面的两个构造函数中的函数体都是空的,是因为这些构造函数的唯一目的就是为数据成员赋初始化的值,没有其他什么操作,函数体就为空了。
在类的外部定义构造函数
Sales_data::Sales_data(std::istream &is){
read(is, *this); // read函数的作用是从is中读取一条交易信息,然后存入this对象中
}
类外部定义构造函数时,必须指明该构造函数是属于哪个类的 Sales_data::Sales_data
,名字和类名一样。
这个构造函数没有声明初始值列表(见上一节),尽管这样还是可以正常的初始化,是因为执行了构造函数体内的read()按照目标进行了初始化。
没有出现在构造函数初始值列表中的成员将通过类内初始值初始化或者默认的初始化。 上面的构造函数意味着,bookNo初始化为空string,unit_sold和revenue初始化为0,接着执行函数体通过read()函数给这些成员赋值。
7.1.5 拷贝、赋值和析构
类之间的拷贝、赋值和销毁对象如果没有在类内自己定义的话,编译器会替我们自动合成这些操作。 例如,当一个Sales_data赋值给另一个Sales_data时候:
Sales_data total;
Sales_data trans;
total = trans;
由于我们的Sales_data类中没有定义赋值操作,编译器会帮我们合成赋值操作,它的行为与下面的代码相同:
total.bookNo = trans.bookNo;
total.unit_sold = trans.unit_sold;
total.revenue = trans.revenue;
我们将在13章介绍如何自定义上述操作。
某些类不能依赖于合成的版本
尽管编译器可以为我们生成拷贝、赋值、销毁的操作,但是必须要搞清楚的是,某些情况下编译器自己生成的版本无法正常工作(无法满足要求)。特别是,当类需要分配类对象之外的资源时候。
一个例子是第12章中介绍的C++程序是如何分配动态内存的。在13.1.4节我们将会看到,管理动态内存的类通常不能依赖上述操作的合成版本(编译器生成的版本)
值得注意的是,很多需要动态内存的类能(且应该使用)vector对象或者string对象来管理必要的内存。使用vector和string的类可以避免分配和释放内存带来的复杂性。
进一步讲,如果类包含vector和string成员,则其拷贝、赋值和销毁的操作的合成版本可以正常工作。
在学习第13章关于如何定义自定义操作的知识之前,类中所有分配的资源都应该直接以类的数据成员的形式存储
7.2 访问控制与封装
我们已经为类定义了接口,但并没有任何 机制限制用户如何使用这些接口,我们的类还没有封装。也就是说用户可以直达Sales_data对象内部并且控制它的具体实现细节。在C++中我们使用访问说明符加强类的封装性:
- public说明符的成员在整个程序内可被访问,public成员即类的接口
- private说明符的成员只可以被类的成员函数访问,不能被该类的实例对象访问,private封装(即隐藏)了类的实现细节
class Sales_data{
public:
// 构造函数部分
Sales_data() = default;
Sales_data(const std::string &s) : booNo(s){} // 构造函数将成员bookNo初始化为输入值s
Sales_data(const std::string &s, unsigned n, double p) : booNo(s), unit_sold(n), revenue(P*n){}
Sales_data(std::istream &);
private:
// 类成员
std:string isbn() const { return bookNo; }
Sales_data & combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned unit_sold = 0;
double revenue = 0.0;
}
构造函数和一些提供出的成员函数应该在public,需要隐藏实现细节的私有数据成员和成员函数声明在private内。
类中的 public
和 private
说明符不限定声明的次数和顺序,可以多次使用,不过最好是放在一起。
每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。
使用class或struct关键字
在上面的类的定义中我们使用了class关键字而非struct,这种变化仅仅是形式上有所不同,唯一的区别是struct和class的默认访问权限不一样:
- 如果使用struct,则在第一个访问说明符之前的成员是public
- 如果使用class,则在第一个访问说明符之前的成员是private
出于统一编程风格的考虑
- 当我们希望定义的所有成员是public时,使用struct
- 当有成员为private时,使用class
7.2.1 友元
当我们将Sales_data中的数据成员声明为了private,则add、print和add函数就无法正常编译了。 因为这几个函数不是类的成员函数(类相关的操作函数,即类的接口的一部分),无法访问类的私有成员
类可以允许其他类或者函数访问它的私有成员,方法就是使得这个类或者函数成为它的友元 friend
声明一个函数作为类的友元,只需要在类内部增加一条以friend关键字开始的函数声明语句即可:
class Sales_data{
friend Sales_data add(const Sales_data &, const Sales_data &);
friend std::istream &read(std::istream, Sales_data &);
friend std::ostream &read(std::ostream, const Sales_data &);
public:
private:
}
Sales_data add(const Sales_data &, const Sales_data &){
...
}
std::istream &read(std::istream, Sales_data &){
...
}
std::ostream &read(std::ostream, const Sales_data &){
...
}
友元声明只能出现在类定义的内部,但是在类出现的具体位置不限,友元不是类的成员,也不受它所在区域访问控制级别的约束。我们将在7.3.4介绍更多关于友元的知识。
封装的益处:
- 确保用户代码不会无意间破坏对象的状态
- 被封装的类的具体实现细节可以随时改变,而无需调整用户级别的代码
尽管当类的定义发生改变时无需更改用户代码,但是使用了该类的源文件必须重新编译
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么陈了友元声明还需要专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明和类本身放置在同一个头文件中(类的外部)
许多编译器并未限制使用之前需要在类的外部声明。
一些编译器允许友元函数只有友元声明也可以调用,但是最好还是提供一个额外的函数声明。(这样即使编译器不支持这种,也无需改变代码)
7.3 类的其他特性
类型成员、类的成员的类内初始值、可变数据成员、内联函数成员、从成员函数返回*this、如何定义和使用类类型及友元类的更多知识
7.3.1 类成员再探
定义一个类型成员
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。 由类定义的类型名字和其他成员一样存在访问限制,可以是public和private中的一种
class Screen {
public:
typedef std::string::size_type pos;
using pos2 = std::string::size_type;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string content;
}
类型成员必须先声明,后使用,这一点与普通成员有所区别,具体原因将在7.4.1节解释。 因此类型成员通常出现在类开始的地方。
使用typedef和using声明是等价的
Screen类的成员函数
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default; // 因为Screen有另一个构造函数
// 所以这个声明是必须的
Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht*wd,c){}
/* 读取光标处的字符 */
char get() const{
return content[cursor];
} // 隐式内联函数
inline char get(pos ht, pos wd) const; // 显式内联函数
Screen &move(pos r, pos c); // 能在之后被设为内联函数
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
}
- 因为我们已经提供了一个构造函数,所以编译器不会再自动生成默认的构造函数。如果需要默认构造函数,则应该显式的声明。在此例子中,我们使用=default显式的告诉编译器为我们合成默认的构造函数
- 第二个构造函数为cursor成员隐式的使用了类内初始值来初始化(参见7.1.4)。
令成员作为内联函数
在类中,一些规模较小的函数适合被声明为内联函数。
如(6.5.2节介绍的)定义在类内部的成员函数是自动inline的。 我们也可以在类内部使用inline关键字,进行显式的定义为inline。
在类的外部定义内联函数则必须使用inline关键字,显式声明:
我们无须在声明和定义的地方同时说明inline。最好只在类的外部定义函数时使用inline,或者直接将函数定义在类内部。
inline成员函数也应该与其相关的类定义在同一个头文件中
重载成员函数
和非成员函数一样,成员函数也可以重载(参见6.4节),成员函数的重载与非成员函数的重载非常类似(参见6.4节)
例如,我们重载了Screen的成员get函数:
Screen mys;
char ch = mys.get();
ch = mys.get(0,0); // 根据参数个数和类型分别调用不同的get版本
可变数据成员
mutable
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。
可以通过 mutable
关键字做到这点
一个可变数据成员 mutable永远不会是const,即使它是const对象的成员。
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr;
};
void Screen::some_member() const{
++access_ctr; // 保存一个计数量,用于记录成员函数被调用的次数
// ...该成员函数需要完成的其他工作
}
access_ctr是一个可变成员mutable,所以尽管在一个const成员函数some_member中也可以去改变access_ctr的值。(如果access_ctr不是mutable,在const成员函数中是不能改变它的值的)
类数据成员的初始值
在定义好Sreen类后,我们将继续定义一个窗口管理类。我们将用它来管理显示器上的一组Screen。
我们希望Windows_mgr类开始时总是拥有一个默认初始化的Screen。
在C++11中,最好的做法是吧这个默认值声明成一个类内初始值(参见2.6.1节)
class Window_mgr{
private:
// 默认情况下,Window_mgr包含一个标准尺寸的空白Screen
std::vector<Screen> screens(Screen(24,80, ''))
}
当我们提供一个类内初始值的时候,必须以符号=或者花括号来表示
7.3.2 返回*this的成员函数
接下来我们继续添加一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符。
class Screen {
public:
Screen &set(char);
Screen &set(pos,pos,char);
// 其他成员和之前的版本一致
};
inline Screen &Screen::set(char c){
contents[cursor] = c; // 将当前的光标所在位置设为新值
return *this; // 将this对象作为左值返回
}
inline Screen &Screen::set(pos row, pos col, char c){
contents[row*width + col] = c; // 将当前的光标所在位置设为新值
return *this; // 将this对象作为左值返回
}
Screen myscreen;
myscreen.move(4,0).set('#');
/*
等价于:
myscreen.move(4,0);
myscreen.set('#');
*/
由于move、set函数都是返回this指针指向对象的引用Screen&(而非拷贝),所以函数调用的结果即Screen实例对象本身,可以进行链式调用。
(如果move和set返回的是Screen而非Screen&,那么结果值将是值的拷贝,返回的是当前Screen实例对象的一个拷贝)
(leoy: 其实也可以直接返回一个指针,return this,不过这样结果值处就需要使用解引用符号*,没有使用引用来的简洁)
从const成员函数返回*this
(const成员函数的隐式this指向该实例对象的常量版本,参考7.1.2 引入const成员函数)
一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
我们增加一个display成员函数用于显示Screen的内容,它是一个const成员函数,并返回*this
Screen myScreen;
// display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*')
即使mySreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,我们无权操作常量引用类型的返回结果对象(参见6.3.2, 引用返回左值)返回类型是非常量引用类型时,可以操作返回结果引用的对象(常量引用类型时,不可以可以操作返回结果引用的对象)
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载。
- 因为非常量版本的函数对于常量对象是不可用的
- 而虽然可以在非常量对象上调用常量版本或者非常量版本,但显然非常量版本是一个更好的匹配
在下面的例子中我们将定义一个do_display()私有成员函数,负责打印内容的实例工作。所有(常量和非常量)版本的display都将调用这个私有成员函数。
class Screen {
public:
Screen &display(std::ostream &os){
do_display(os);
return *this;
}
const Screen &display(std::ostream &os) const{
do_display(os);
return *this;
}
private:
char contents;
void do_display(std::ostream &os) const{
os << contents;
}
};
Screen mySreen(5,3);
const Screen mySreen2(5,3);
mySreen.set('#').display(cout) // 调用非常量版本
mySreen2.display(cout) // 调用常量版本
当我们在某个对象上调用display时,该对象是否是const决定了应该调用display的哪个版本
建议:对于公共代码使用私有功能函数 有些读者可能会奇怪我们为什么要费力定义一个do_display的私有函数
- 可以避免在多处重新编写重复的代码
- 这个额外的函数不会增加任何开销,因为定义在类的内部,它会被隐式的被声明为内联函数,调用则不会带来任何额外的运行时开销
在实践中,设计良好的c++代码常常包含大量类似于do_display的小函数,通过调用这些函数,可以完成一组其他函数的“实际”工作
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也还是两个不同的类型:
struct First{
int memi;
int getNum();
}
struct Second{
int memi;
int getNum();
}
First obj1;
Second obj2 = obj1; // 错误,obj1和obj2类型不同
即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿
我们可以直接把类名作为类型名字来使用,也可以显示使用class或者struct
Sales_data item; // 默认初始化item为Sales_data的实例对象
class Sales_data item1; // 等价的声明
类的声明
可以和函数一样,把类的声明和定义分离开来(参见6.1.2节),可以仅仅声明类而暂时先不使用它。
class Screen; // Screen类的声明
这种声明有时候被称为前向声明,它向程序中引入了名字Screen并且指明Screen是一种类类型。
对于Screen来说,它在声明之后定义之前是一个不完全类型,也就是说我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情况下使用
- 可以指向这种类型的指针或者引用
- 可以声明(但不能定义)以不完全类型作为参数或者返回类型的函数
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。
类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员
在7.6节中我们将描述一种例外的情况:知道类被定义后数据成员才能被声明成这种类类型。
一个类的成员类型不能是该类自己, 而一旦一个类的名字出现之后,就被认为是声明过了,因此类允许包含指向它自身类型的引用或者指针:
class Link_screen{
Screen window;
//Link_screen wrong; 错误的声明
Link_screen *next;
Link_screen *prev;
Link_screen &ref;
}
7.3.4 友元再探
我们的Sales_data类中将三个普通函数定义成了友元(参见7.2.1节)
我们还可以将其他类定义为友元、将其他类的成员函数定义为友元。 友元函数还可以定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
把Windows_mgr定义成Screen类的友元:
class Window_mgr{
public:
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的Screen重置为空白
void clear(ScreenIndex)
private:
std::vector<Screen> screens(Screen(24,80,''));
};
class Screen{
// Windows_mgr的成员可以访问Screen的私有部分
friend class Windows_mgr;
}
void Window_mgr::clear(ScreenIndex i){
// s是一个引用,指向我们想要清空的那个屏幕
Screen &s = screens[i];
// 将选定的Screen重置为空白
s.contents = string(s.height*s.width,' ')
// 此处,由于Windows_mgr是Screen类的友元,所以可以访问到其私有成员
// contents、height、width
}
如果clear不是Screen的友元,上面的代码将无法通过编译。
由于已经把Windows_mgr定义成了Screen类的友元,Screen的所有成员对于Windows_mgr的所有成员都是可见的(包括clear)
注意,友元关系不存在传递性。
- 如果即使Windows_mgr有自己的友元,这些友元也不能访问到Screen的所有成员。
- 只有Screen中声明的友元才可以访问到Screen的所有成员。
- 每个类单独控制自己的友元,无传递性。
令成员函数作为友元
除了令整个Windows_mgr作为友元外,Screen还可以只为Windows_mgr的某个成员函数提供访问权限。
当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类。
想要某个成员函数作为友元,我们还必须仔细组织程序的结构,以满足声明和定义的彼此依赖关系:
- 首先定义 Windows_mgr 类,其中声明clear函数,但是不定义它。在clear使用Screen的成员之前必须先声明Screen.
- 接下来定义Screen,包括对clear的友元声明
- 最后定义clear,此时它才可以使用Screen的成员
class Window_mgr{
public:
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的Screen重置为空白
void clear(ScreenIndex)
private:
std::vector<Screen> screens(Screen(24,80,''));
};
class Screen{
// Windows_mgr的成员可以访问Screen的私有部分
friend void Windows_mgr::clear(ScreenIndex);
}
void Window_mgr::clear(ScreenIndex i){
// s是一个引用,指向我们想要清空的那个屏幕
Screen &s = screens[i];
// 将选定的Screen重置为空白
s.contents = string(s.height*s.width,' ')
// 此处,由于Windows_mgr是Screen类的友元,所以可以访问到其私有成员
// contents、height、width
}
函数重载和友元
尽管重载函数的名字相同,但是它们仍是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这一组函数的每一个分别声明(没有声明的就不是它的友元)
友元声明和作用域
类和非成员函数的声明,并不必须在它们的友元声明之前。
当一个名字第一次出现在友元声明中时,我们假定该名字在当前作用域中是可见的。 然而,友元本身不一定真的声明在当前作用域中(参见7.2.1节)
所以在我们调用友元函数时,需要确保它是被声明过(除友元声明外声明过的)的
struct X{
friend void f(){/* 友元函数可以定义在类的内部 */}
X(){ f(); } // 错误: f还没有被声明
}
void X::g(){ return f(); } // 错误: f还没有被声明
void f(); // 声明作为友元的函数f
void X::h(){ return f(); } // 正确: 现在f的声明在作用域中了
这段代码帮助理解友元声明是影响访问权限,它本身并非普通意义上的函数声明。
请注意,有的编译器并不强制执行上诉限制规则(参见7.2.1节)
7.4 类的作用域
每个类都会定义它自己的作用域。
在类的作用域之外,普通的数据和函数成员只能由实例对象、引用或者指针使用成员访问运算符(参见4.6节)->
来访问
类类型成员则使用作用域运算符::
访问。
不论那种情况,跟在运算符之后的名字都必须是对应类的成员:
Screen::pos ht = 24, wd = 80; // 使用Screen定义的pos类型
Screen scr(ht,wd,' ');
Screen *p = &scr;
char c = scr.get(); // 访问scr实例对象的get成员
c = p->get; // 访问p指针所指实例对象的get成员
作用域和定义在类外部的成员
一个类就是一个作用域。
这可以很好的解释为什么我们在类的外部定义成员函数必须同时提供类名和函数名(参见7.1.2节)。在类的外部,成员的名字被隐藏起来了。
一旦遇见类名,定义或者声明的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。所以在剩余的定义和声明部分里面,我们可以直接使用类的成员,而无需再显式使用作用域运算符:
了
void Window_mgr::clear(ScreenIndex i){
Screen &s = screen[i];
s.contents = string(s.height * s.width, ' ');
}
另外声明在类外部的成员函数的返回类型如果用到了类的类型,也需要使用作用域运算符:
来声明。
Window_mgr::sometype Window_mgr::somefunc(){
}
7.4.1 名字查找与类的作用域
到目前为止,我们编写的程序中,名字查找 name lookup(寻找与所用名字最匹配的声明过程)的过程比较直接了当
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明
- 如果没找到,继续寻找外层作用域
- 如果最终没找到匹配的声明,则程序报错
对于定义在类的内部的成员函数,名字的解析有些区别:
- 首先编译所有成员的声明
- 知道类全部可见后才编译函数体
编译器处理完类中的全部声明后才会处理成员函数的定义
类的这两个阶段可以减缓类代码的组织形式(因为函数体重不用考虑声明顺序,可以直接使用类的所有名字)
用于类成员声明的名字查找
类的两个阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须确保在使用前是可见的(已经声明过了的)
如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找:
typedef double Money;
string bal;
class Account{
public:
Money balance() { return bal;}
private:
Money bal;
}
当编译器执行到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑在使用Money之前出现的声明,因为没有找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。此例子中,编译器会找到Money的typedef语句。
另外,由于函数体在整个类声明处理完后才被处理,因此这个balance函数的语句中的bal使用的是Account类的数据成员bal,而非string bal;
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。
然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字
typedef double Money;
class Account{
public:
Money balance(){ return bal;} // 使用外层作用域的Money
private:
typedef double Money; // 错误,已经使用了外层作用域的Money,不能重新定义
Money bal;
}
需要特别注意的是,即使Account中定义的Money与外层作用域一致,上述代码仍然是错误的
而尽管重新定义类型名字是错误的,但是编译器并不为此负责。一些编译器仍可以顺利通过这样的代码,而忽略代码有错的事实
类型名的定义通常出现在类的开始处,这样就可以确保所有使用该类型的成员都出现在类名的定义之后
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按如下方式解析:
- 首先,在成员函数内查找改名字的声明。和之前一样,只有在使用之前出现的声明才被考虑
- 如果在成员函数中没有找到,则在类内继续查找,此时类的所有成员都可以被考虑
- 如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续寻找
一般来说,不建议使用其他成员的名字作为某个成员函数的参数。 为了更好的解释名字解析的过程,我们不妨在dummy_fcn函数中暂时违反一下这个约定:
int height;
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height; //
}
private:
pos cursor = 0;
pos height = 0, width = 0;
}
当编译器处理dummy_fcn中的乘法表达式时,它首先在函数作用域内查找,函数的参数位于函数作用域内,因此dummy_fcn中用到的名字height是参数声明的height。
参数height隐藏了同名的成员height,如果本例子中想要使用的是成员height,我们应该显示地通过类名调用或者使用this指针调用:
void dummy_fcn(pos height) {
cursor = width * this->height;
// cursor = width * (*this).height; 成员height
// cursor = width * Screen::height; 成员height
}
更好的方法是,取不同的名字(参数不要和成员名字一样):
void dummy_fcn(pos ht) {
cursor = width * height; // 成员height
}
此时的height在函数作用域中查找不到,会在类作用域中查找,由于Screen类有个height成员,所以此时的height就是类成员height(若类没有height成员,则用的是外层作用域中的int height,若外层作用域也没有height,则编译错误:使用未声明的变量)
类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。(即上面的height查找过程)
我们可以通过作用域运算符显式地访问外层作用域的变量:
int height=0;
void Screen::dummy_fcn(pos height){
cursor = width * ::height; // 使用的是全局作用域的height
}
在文件中名字的出现出对其进行解析
当成员出现在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明
int height;
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0;
}
Screen::pos verify(Screen::pos){
};
void setHeight(pos ht){
height = verify(ht);
}
需要注意的是,全局函数verify在编译器处理类的定义之前是不可见的。 本例子中,由于verify的声明位于setHeight的定义之前,所以可以被正常使用。
7.5 构造函数再探
7.5.1 构造函数初始值列表
当我们定义变量时,习惯于立即对其进行初始化,而非先定义、再赋值
string foo = "Hello World"; // 定义并初始化
string bar; // 默认初始化为空string对象
bar = "Hello World"; // 为bar赋一个新值
类的数据成员的初始化和赋值也是类似,如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化:
Sales_data::Sales_data(const string &s, unsigned cnt, double price){
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
这段代码和我们在7.1.4节 237页的定义的构造函数效果是一样,区别在于这个版本的数据成员是先执行了默认初始化,再进行了赋值。之前的版本是用构造函数的参数进行初始化。
初始化和赋值之间的区别完全取决于数据成员的类型。
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。
类似的,当成员属于某种类型且该类没有定义默认构造函数时,也必须将这个成员初始化:
class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
}
和其他常量对象或者引用一样,ci和ri都必须被初始化。因此如果我们没有为它们提供构造函数的话将引发错误:
// 错误: ci和ri必须被初始化
ConstRef::ConstRef(int ii){
i = ii; // 正确
ci = ii; // 错误:不能给const赋值
ri = i; // 错误:ri没被初始化
}
// 正确:显式地初始化引用和const成员
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i){}
如果成员是const、引用或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表或者类内初始值为成员提供初始值
成员初始化的顺序
在构造函数中的初始值列表的成员顺序不代表成员的初始化顺序,成员的初始化顺序与它们在类中定义的顺序一致
一般来说,成员的初始化顺序没什么特别的要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了
class X{
int i;
int j;
public:
// 未定义的,i在j之前被初始化(按类中的定义顺序)
X(int val): j(val), i(j){}
}
有些编译器具备一些友好的功能,即当构造函数初始值列表顺序和类中定义的顺序不一致时会产生一条警告信息
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员
默认实参和构造函数
class Sales_data{
public:
// Sales_data() = default
// 该构造函数不传参数时候,和default的行为一致
Sales_data(string s = ""): bookNo(s){}
}
如果一个构造函数为所有的参数都提供了默认实参,则它实际上也定义了默认构造函数
7.5.2 委托构造函数
C++11使得我们可以定义委托构造函数,一个委托构造函数是使用它所属累的其他构造函数执行它的初始化过程,或者说将它的一些(或者全部)职责委托给了其他构造函数
class Sales_data {
public:
// 非委托构造函数使用对应的实参初始化成员
Sales_data (std::string s, unsigned cnt, double price):
bookNo(s), unit_sold(cnt), revenue(cnt*price){}
Sales_data(): Sales_data("",0,0) {}
Sales_data(string s): Sales_data(s,0,0) {}
Sales_data(std::istream &is): Sales_data() {
read(is, *this);
}
}
除了第一个构造函数外,其余的构造函数都委托了它们的工作。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认的构造函数。
默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量(参见2.2.1节)或者数组(参见3.5.1节)时
- 当一个类本身含有类类型的成员且使用合成的默认的构造函数时(参见7.1.4节)
- 当类类型的成员没有在构造函数初始值列表显式初始化时
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时(参见3.5.1节)
- 当我们不使用初始值定义一个局部静态变量时(参见6.1.1节)
- 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名(vector有一个构造函数只接收一个参数用于初始化vector的大小(参见3.3.1节),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)
在实际中,如果定义了其他的构造函数,那么最好也提供一个默认构造函数
使用默认构造函数
下面的obj声明可以正常编译通过,但当我们试图使用obj时候编译器将报错:
Sales_data obj() // 正确:定义了一个函数而非对象
if(obj.isbn() == primer_5th_ed.isbn()) // 错误:obj是一个函数
Sales_data obj; // 正确:obj是一个对象而非函数
7.5.4 隐式的类型转换
如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种函数成为转换构造函数
只允许一步类类型转换
类类型转换不是总有效
抑制构造函数定义的隐式转换
通过将构造函数声明为explicit来阻止隐式转换
explicit构造函数只能用于直接初始化
为转换显式地使用构造函数
虽然explicit声明的构造函数不会进行隐式转换,但我们仍可以使用这个构造函数进行显式的转换。
标准库中含有显式构造函数的类
- 接受一个单参数的const char*的string的构造函数(参见3.2.1节)不是explicit的
- 接受一个容量参数的vector的构造函数(参见3.3.1节)是explicit的
7.5.5 聚合类
聚合类使用户可以直接访问其成员,并且具有特殊的初始化语法形式。
当一个类满足一下条件时,我们说它是聚合的:
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual函数,关于这部分我们将在第15章详细介绍
struct Data{
int ival;
string a;
}
我们可以使用花括号来初始化聚合类的数据成员,其中顺序和数量需要和类中声明的一致:
Data vall = { 0 , "Anna"};
缺点:
- 要求类的所有成员都是public的
- 将正确初始化类的成员的重任交给了类的用户
- 添加、删除一个成员后,所有的初始化语句都需要更新
7.5.6 字面值常量类
在6.5.2节中我们提到过constexpr函数的参数和返回值必须是字面值类型。
除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的(参见7.1.2节)
数据成员都是字面值类型的聚合类(参见7.5.5节)是字面值常量类。如果一个类不是聚合类,但满足一下要求,则它也是一个字面值常量类:
- 数据成员都是字面值类型
- 类必须至少含有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员初始值必须是一条常量表达式(参见2.4.4节)或者如果成员属于某种类类型,则初始值必须使用成员自己的consrexpr构造函数
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象(参见7.1.5节)
constexpr构造函数
尽管构造函数不能是const的(参见7.1.4),但是字面值常量类的构造函数可以是constexpr(参见6.5.2),且一个 字面值常量类必须至少提供一个constexpr构造函数
constexpr构造函数可以声明成=default(参见7.1.4节)的形式(或者是删除函数的形式,我们在13.1.6节介绍)否则constexpr函数就必须既符合构造函数的要求,又符合constexpr函数的要求(意味着它拥有的唯一的可执行的语句就是返回语句(参见6.5.2节))。综合这两点可知,constexpr构造函数体一般来说应该是空的。
class Debug {
public:
constexpr Debug(bool b = true): hw(b), io(b), other(b) {}
constexpr Debug(bool h, bool i, bool o ): hw(h), io(i), other(o) {}
constexpr bool any() { return hw || io || other; }
void set_io()(bool b) { io = b }
void set_hw()(bool b) { hw = b }
void set_other()(bool b) { other = b }
private:
bool hw; // 硬件错误
bool io; // IO错误
bool other; // 其他错误
}
constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式
constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或者返回类型:
constexpr Debug io_sub(false,true,false); // 调试IO
if(io_sub.any()){
cerr >> "print appropriate error msg" << endl;
}
constexpr Debug prod(false); // prod无调试
if(prod.any()){
cerr >> "print an error msg" << endl;
}
7.6 类的静态成员
有时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率。在此例中,我们希望利率与类关联,而非与类的每个对象关联。从实现效率的角度来看,没必要每个对象都存储利率信息。而且更加重要的是,一旦利率浮动,我们希望所有的对象都能使用新值
声明静态成员
我们使用static关键字使得其与类关联在一起。
和其他成员一样,静态成员可以是public的或private的。 静态数据成员可以是常量、引用、指针、类类型等
class Account {
public:
void calculate() { amount += amount * interestRate}
static double rate() { return interestRate}
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
}
- 类的静态成员不存储在任何实例对象中,实例对象中不包含任何与静态数据成员有关的数据。
- 静态成员函数不会与任何实例对象绑定在一起,它们不包含this���针,也不能在静态函数体内使用this指针,也不能直接使用非static的数据成员
- 作为结果,静态成员函数不能声明成const的
使用类的静态成员
我们使用作用域运算符直接访问静态成员
double r;
r = Account::rate(); // 使用作用域运算符访问静态成员
虽然类的静态成员不属于类的某个实例对象,我们仍然可以使用类的实例对象的引用、指针来访问静态成员。
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate(); // 通过Account的对象或引用
r = ac->rate(); // 通过Account的对象或引用
类的成员函数可以像使用其他类成员一样使用静态成员:
class Account {
public:
void calculate() {
amount += amount * i;
}
private:
static double i
}
定义静态成员
和其他成员函数一样,我们既可以在类内部也可以在类外部定义静态成员函数。
在类外部定义静态成员函数时,不能重复static关键字。该关键字只能出现在类内部的声明语句:
void Account::rate(double newRate){
interestRate = newRate;
}
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。
而且一般来说,我们不能在类的内部初始化静态成员。 必须在类的外部定义和初始化每个静态成员。和其他变量一样,一个静态数据成员只能定义一次
类似于全局变量(参见6.1.1节),静态数据成员定义在任何函数之外。因此一旦它被定义,就将一致存在与程序的整个生命周期中。
我们定义静态数据成员的方式和在类外部定义成员函数差不多。
// 定义并初始化一个静态成员
double Account::interestRate = initRate();
这条定义从类名开始的剩余部分就都位于类的作用域内了,因此我们可以直接使用initRate函数。
要想确保对象只定义一次,最好的办法是吧静态数据成员的定义和其他非内联函数的定义放在同一个头文件。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。
然而我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的consrexpr(参见7.5.6节)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。
静态成员能用于某些场景,而普通成员不能
- 静态成员作为默认实参
- 静态成员可以是不完全类型(和指针成员一样,比如指向类自身的指针)
第二部分 C++标准库
新的C++标准中有三分之二的文本用于描述标准库
有些核心库是每个C++程序员都应该熟练掌握的
第八章介绍IO库,使用标准库读写与控制台窗口相关的流,读写命名文件及完成到string对象的内存IO操作
容器类和泛型算法,可以帮助我们编写简洁高效的程序。让标准库去处理如内存管理等问题,可以让我们将更多的精力投入到需要求解的问题中
第九章中介绍更多关于vector的内容,也会涉及其他顺序容器。会介绍更多的string类型的操作,可以将string看作一种只包含字符元素的特殊容器
第10章介绍泛型算法。这类算法通常在顺序容器中进行操作。算法库为各种经典算法提供了高效的实现,如排序、搜索等。还有一些其他常见的操作,例如copy完成一个序列到另一个序列的拷贝。find实现给定元素的查找。 泛型算法的通用性体现在两个方面:1、可用于不同类型的序列。2 对序列中元素的类型限制小,大多数类型都是允许的
标准库还提供了一些关联容器,第十一章介绍,关联容器的元素通过关键字访问。(key-value)
第12章介绍动态内存管理相关内容,智能指针相关内容,使用智能指针可以大幅度提高动态使用动态内存代码的鲁棒性。
第八章 IO库
C++语言不直接处理输入输出,而是通过一族定义在标准库中的类型来处理IO。支持向设备读写数据的IO操作,设备可以是文件、控制台窗口等。
还有一些类型允许内存IO,即从string读取数据,向string写数据。
IO库定义了读写内置类型值的操作。此外,一些类如string,通常也会定义类似的IO操作,来读写自己的对象。
本章介绍IO的基本内容。十四章介绍如何编写自己的输入输出函数。十七章介绍如何控制输入输出格式以及如何对文件进行随机访问。
我们在1.2节介绍了大部分IO设施
- istream
- ostream
- ostream
- cin
- cout
- cerr
>>
运算符,用来从一个istream读取输入数据<<
运算符,用来从一个ostream读取输入数据- getline函数(参见3.3.2节),从给定istream读取一行数据,存入一个给定的string对象中
8.1 IO类
目前为止我们使用的IO类型和对象都是操纵char数据的。
默认情况下,这些对象都是关联到用户的控制台窗口。
IO操作还支持读写命名文件,读写string的字符,还可能读写需要宽字符支持的语言。
为了支持宽字符的语言,标准库定义了一组类型和对象来操作wchar_t类型的数据(参见2.1.1节)
宽字符函数版本以一个w开始
IO类型之间的关系
概念上,设备类型和字符大小都不会影响我们要执行的IO操作。
例如我们使用>>
读取数据时,不用关心是从控制台窗口、磁盘文件还是strng中读取,也不用关系读取的字符能存入一个char还是wchar_t
标准库使我们可以忽略这些不同类型之间的差异,是通过继承机制实现的。
利用模板(参见3.3节)我们可以使用具有继承关系的类,而不必了解继承机制如何工作的细节。我们将在第15章和第18.3节介绍C++是如何支持继承机制的。
简单地说,继承机制使我们可以声明一个特定的类继承自另一个类
ifstream
和istringstream
都继承自istream,因此我们可以像istream一样来使用ifstream和istringstream。比如我们可以对一个ifstream
或者istringstream
的实例对象调用getline
,也可以使用>>
从一个ifstream
或者istringstream
的实例对象读取数据。
类似的ofstream
和ostringstream
都继承自ostream
本节剩下部分所介绍的标准库流特性都可以无差别地应用于普通流、文件流和string流,以及char或宽字符版本
8.1.1 IO对象无拷贝或赋值
如7.1.3节,我们不能拷贝或者对IO对象赋值:
ofstream out1, out2;
out1 = out2; // 错误:不能对流对象赋值
ofstream print(ofstream); // 错误:不能初始化ofstream
out2 = print(ofstream); // 错误:不能拷贝流对象
由于不能拷贝IO对象,因此我们也不能将形参或者返回类型设置为流类型(参见6.2.1节)。
进行IO操作的函数通常以引用方式传递和返回流。 读写一个IO对象会改变其状态,因此传递和返回的引用不能是const的
8.1.2 条件状态
IO操作一个与生俱来的问题就是可能发生错误。一些错误是可以恢复的,而一些错误则发生在系统深处,已经超出了应用程序可以修正的返回。
下面列出了一些函数和标识可以帮助我们访问和操纵流的条件状态:
下面是一个IO错误的例子:
int ival;
cin >> ival;
如果我们在输入boo,读操作就会失败。因为代码中期待一个int,却得到了一个字符b。
查询流的状态
将流作为条件使用,只能告诉我们流是否有效,而无法告诉我们具体发生了什么。
IO库定义了一些类型来表示当前IO的状态
- iostate提供表达流状态的完整功能,使用方式与4.8节137页中使用quizl的方式一样。
IO定义了4个iostate类型的consrexpr值(参见2.4.4节),表示特定的位模式。这些值用来表示特定类型的IO条件,可以与位运算符一起使用来一次检测或设置多个标志位。
- badbit 表示系统级错误,如不可恢复的读写错误。通常情况下badbit被置位,流就无法再使用了
- failbit 表示可恢复错误,如期望读取数值时读取到了字符类型。这种问题是可以修复的,流还可以继续使用
- eofbit 表示文件结束位置。到达文件结束时eofbit和failbit都会被置位。
- gootbit 为0表示流未发生错误
badbit、failbit、eofbit其中任何一个被置位,则检测流的状态的条件都会失效。
标准库还定义了一组函数来查询这些状态:
- s.good() 流没有发生任何错误时为true
- s.bad() badbit置位时返回true
- s.fail() badbit和failbit置位时返回true
- s.eof() eofbit置位时返回true
管理条件状态
流对象的rdstate成员返回一个iostate值,对应当前流状态。
setstate操作将给定条件位置位,表示发生了对于错误 clear是流对象的重载过的成员,它可以接受多种参数版本
- clear不带参数的版本清除(复位)所有错误标识位。使用clear后,调用good会返回true
- 带参数的clear接收一个iostate值,表示流的新状态,将流置为新的状态
auto old_state = cin.rdstate(); // 保存当前流的状态
cin.clear(); // 使cin有效
process_input(cin); // 使用cin
cin.setstate(old_state); // 将cin置为原有状态
// 复位failbit和badbit,保持其他标志位不变
cin.clear(cin.rdstate() & ~cin.failbit & ~cin.badbit);
8.1.3 管理输出缓存
每个输出流都管理一个缓冲区,用来保存程序读写的数据。
os << "please enter a value";
如果执行一下代码,文本串可能立即打印出来,但也有可能被操作系统保存在缓冲区中,随后再打印。缓冲机制使得操作系统可以把程序的多个输出操作合并成一个系统级写操作。由于设备的写操作可能很耗时,所以允许将多个输出组合成一个输出可以带来很大的性能提升。
导出缓冲刷新(即将数据真正的输出到设备或文件)的原因:
- 程序正常结束,作为main函数的return操作的一部分,缓冲区被刷新
- 缓冲区满时,需要刷新缓冲,而后新的数据才可以写入缓冲区
- 使用操作符如endl(参见1.2节 6页)来显式刷新缓冲区
- 在每个输出操作之后,可以使用unitbuf设置流的内部状态,来清空缓冲区。默认情况下,对cerr是设置unitbuff的,因此写到cerr的内容都是立即刷新的
- 一个输出流可能被关联到另一个流。当读写到被关联的流时,关联到的流会被刷新。例如默认情况下:cin和cerr都关联到cout。因此读cin或写cerr都会导致cout的缓冲区被刷新
刷新输出缓冲区
cout << "hi!" << endl; // 换行并刷新缓冲区
cout << "hi!" << flush; // 刷新缓冲区无其他字符
cout << "hi!" << ends; // 空字符和刷新缓冲区
unitbuf操纵符
cout << unitbuf; // 之后输出都立即刷新缓冲区
/*
这里的所有输出都立即刷新缓冲
*/
cout >> nounitbuf // 回到正常的缓冲方式
程序异常终止,输出缓冲区是不会自动刷新的 调试一个崩溃的程序时,需要确认哪些你以为的输出的缓冲区是刷新了的,不然会浪费大量时间追踪代码为何没有输出。实际上只是因为缓冲区没有刷新,输出被挂起没有输出到设备而已。
关联输入和输出流
当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。
标准库将cout和cin关联在一起,因此
cin >> ival;
会导致刷新cout的缓冲区
交互式系统通常应该关联输出和输出流。意味着所有输出,特别是用户输入前的提示,都会在(用户输入)读操作前被打印出来。
cin.tie(&cout);
ostream *old_tie = cin.tie(nullptr);
cin.tie(&cerr);
cin.tie(old_tie);
8.2 文件输入和输出
头文件<fstream>
中定义了三个类型来支持文件IO:
- ifstream 从一个给定文件读取数据
- ofstream 向一个给定文件写入数据
- fstream 可以读写给定文件
在17.5.3中我们将介绍如何对一个文件既读又写
这些类型提供的操作与我们之前已经使用过的对象cin和cout的操作一样。
特别是,我们可以使用IO运算符(>>
和<<
)来读写文件,用getline
从一个ifstream
读取数据,因为fstream
是继承自iostream
的
8.2.1 使用文件流对象
当我们想要读写一个文件的时,可以定义一个文件流对象,并将对象与文件关联起来。
每个文件流都定义了一个名为open的成员函数,它完成一些系统相关的操作,来定位给定的文件,并视情况打开为读或写模式。
ifstream in("./readme.md"); // 构造一个ifstream并打开相应文件
ofstream out; // 构造一个ofstream,但并未关联到任何文件
在新的C++标准中,文件名既可以是库类型string对象,也可以是C风格字符串数组。
使用fstream代替iostream&
在8.1节已经提到过,在要求使用基类型对象的地方,我们同样可以使用继承自基类型的对象来进行相关操作。
所以我们可以使用fstream来重写之前的例子来实现输入输出到文件。
ifstream input(argv[1]); // 打开需要处理的文件
ofstream output(argv[2]); // 打开结果输出的文件
Sales_data total; // 保存销售总额的变量
if (read(input, total)) { //
Sales_data trans;
while(read(input, total)){
if(total.isbn() == trans.isbn()){
total.combine(trans);
}else{
print(output, total) << endl;
total = trans;
}
}
print(output, total) << endl;
}else{
cerr << "No, data?!" << endl;
}
成员函数open和close
如果我们定义了一个空文件流对象,可以随后调用open来将它与文件关联起来。
ifstream in(ifile);
ofstream out;
out.open(ifile+".copy");
open失败时,failbit会被置位 open成功时,则open会设置流的状态,使得good()为true
if(out){ // 检查open是否成功
// open成功我们可以使用文件了
}
一旦一个文件打开,它就保持与对应文件的关联。 对一个已经打开的文件调用open会失败,且failbit被置位,且随后所有对文件的操作都会失败。
要将文件流关联到另一个文件,必须先关闭当前关联的文件。成功关闭后可以打开新的文件。
in.close();
in.open(ifile+"2");
自动构造和析构
考虑这样一个程序,它的main函数接受一个要处理的文件列表(参见6.2.5节)
// 对每个传递给程序的文件执行循环操作
for (auto = argv+1; p!=argv + argc; ++p){
ifstream input(*p); // 创建输出流并打开文件
if(input){ // 如果文件打开成功,处理此文件
process(input);
} else{
cerr << "counld`t open: " + string(*p)
}
}
// 因为input是while循环的局部变量每次循环input都会重新被创建和被销毁(参见5.1.4节)
// 每次循环input都会离开作用域,因此会被销毁
当一个ifstream对象被销毁时,close会被自动调用
8.2.2 文件模式
每个流都有一个关联的文件模式 file mode,用来指出如何使用文件。
无论使用那种方式打开文件,我们都可以指定文件模式。 调用open还是用一个文件名初始化流来隐式打开文件都可以。 指定文件模式有如下限制:
- 只可以对ofstream或fstream设置out模式
- 只可以对ofstream或fstream设置in模式
- 只有当out也被设定时才可以设定trunc模式
- 只要没有设置trunc,就可以设置为app模式。在app模式下默认是out方式打开
- 以out模式打开的文件模式是trunc模式。out模式追加:1 out+app 2 out + in
- ate和binary可以用于任何类型文件流对象,且可以与其他任何文件模式组合
ofstream out("file",ofstream::app | ofstream::out)
ofstream out("file",ofstream::in | ofstream::out)
ofstream out("file",ofstream::out | ofstream::trunc)
ofstream out("file",ofstream::out)
ofstream out("file")
以out模式打开文件会丢弃已有数据
每次调用open都可以指定文件模式
8.3 string流
头文件sstream
8.3.1 使用istringstream
string line, word;
/* 创建流 */
istringstream record;
istringstream record(line);
/* 将字符存入流 */
istringstream record(line);
istringstream record;
record.str(line);
/* 取流内容 */
record.str(); // 返回值为当前流的内容
>>
getline
等操作符与cin
一致
8.3.2 使用ostringstream
当逐步构造输出,最后再一起打印时,ostringstream非常有用。
int cnt = 0;
while (cnt < 3) {
sout << "hello" << cnt << endl;
++cnt;
}
cout << "sout ends :" << endl << sout.str() << endl;
第九章 顺序容器 vector string array deque list..
本章完后,对标准库中顺序容器的知识就完整了。
顺序容器:数组、字符串…(元素存储、访问顺序) 关联容器在第十一章介绍。
所有容器类都共享公共的接口,不同容器按不同方式进行扩展。每种容器提供了不同的性能和功能的权衡。
容器适配器
9.1 顺序容器概述
所有顺序容器都提供快速顺序访问元素的能力。 不同容器在两个方面有不同的性能折中:
- 向容器添加或从容器中删除元素的代价
- 非顺序访问容器中元素的代价
容器保存元素的存储策略对容器操作效率有固有的影响,有时甚至是重大的影响。
比如string和vector将元素保存在连续的内存空间中。由于元素的连续存储的,所以用下标来计算其地址是非常快速的。但是在容器中间位置添加或删除元素就会非常耗时间,因为在每一次添加或删除操作后,需要移动该位置后的所有元素来保持连续存储,而且添加元素有时还要分配一个额外的存储空间,这种情况下每个元素都必须移动到新的存储空间。
list和forward_list则是令容器任何位置的添加和删除操作都很快速。作为代价,这两个容器不支持元素的随机访问,只能遍历容器。而且和vector、deque和array相比,这两个容器的内存额外开销更大。
deque是一个更为复杂的数据结构,和string和vector类似,deque支持快速的随机访问。 和string和vector一样在容器中间位置添加或删除元素的代价很高,但是在两端添加删除元素都是很快的,与list和forward_list相当
forward_list和array是C++新标准增加的类型。与内置数组相比,array更安全、更易使用(类似的是大小也是固定的)。 forward_list的设计目标是达到最好的手写的单向链表数据结构相当的性能(因此forward_list没有size操作,因为保存或计算其size会比手写单向链表多出额外的开销)。
对于除forward_list以外的所有容器,size会保证是一个快速的常量时间的操作。
新标准库的容器比旧版本快的多,原因我们将在13.6节解释 新标准库容器的性能几乎肯定与最精心优化过的同类数据结构一样好(通常会更好)。 所以现代C++程序应该使用标准库容器,而不是更原始的数据结构,如内置数组int a[]={1,2,3}(别用这个了,用容器)
确定使用哪种顺序容器
一些基本的原则:
- 除非有很好的理由使用其他容器,否则用
vector
- 如果你的程序有很多小的元素,且(内存)空间的开销很重要,则不要使用
list
和forward_list
- 如果程序要求随机访问元素,则应该使用
vector
或deque
- 如果程序要求在容器中间的位置插入或删除元素,则应该使用
list
或forward_list
- 如果程序要求在容器头尾的位置插入或删除元素,但不会在中间的位置插入或删除元素,使用
deque
- 如果程序只有在读取输入的时候才需要在容器中间位置插入元素,随后需要随机访问元素:
- 首先确定是否真的需要在容器中间位置添加元素。(通常可以直接向vector追加元素,然后调用标准库的sort函数(将在10.2.3节介绍)来重排元素,从而避免在中间位置添加元素(而导致的每次添加都移动其后所有元素位置。))
- 如果必须在中间位置插入元素,考虑在输入阶段使用list,一旦输入完成,将list中的内容拷贝到一个vector中
如果程序既要随机访问元素,又需要在中间位置插入元素,则应该根据在程序中执行访问操作更多还是插入/删除更多来决定选择什么容器。
如果不确定使用那种容器,那么可以在程序中只使用vector和list公共的操作:使用迭代器,不使用下标,避免随机访问。 这样在必要时,切换成vector还是list都很方便
9.2 容器库概预览
- 某些操作是所有容器类型都提供了的(参见表9.2 295页)
- 另外一些操作仅针对顺序容器(参见表9.3 299页)、关联容器(参见表11.7 388页) 无序容器(参见表11.8 395页)
- 还有一些操作只使用于一小部分容器
一般来说每个容器都定义在一个头文件中,文件名和类型名相同。 容器均定义为模板类(参见3.3节)。 声明时我们需要提供额外信息来生成特定的容器类型。
list<Sales_data> // 保存Sales_data对象的list
deque<double> // 保存double的deque
对容器可以保存的元素的限制
顺序容器几乎可以保存任意类型的元素。我们还可以定义一个容器其元素类型是另一个容器。
vector<vector<string>> lines; // vector的vector
较旧的编译器可能需要在两个尖括号之间加个空格,如
vector<vector<string> >
某些容器操作对元素类型有特殊要求。例如顺序容器的构造函数操作,对于没有默认构造函数的数据类型是不能使用的。
// 假定noDefault是一个没有默认构造函数的类型
vector<noDefault> v1(10,init) // 正确,提供了元素初始化器
vector<noDefault> v1(10) // 正确,提供了元素初始化器
9.2.1 迭代器
和容器一样,迭代器有公共的接口。如果一个迭代器提供某个操作,那么所有迭代器都是一样的
迭代器范围
一个迭代器范围由一对迭代器表示,两个迭代器分别指向同一容器中的元素或者是尾元素之后的位置。
- begin 初始位置为容器第一个元素
- end 初始位置为容器最后一个元素之后的位置
[begin,end)
左闭合区间
begin和end可以指向相同的位置,但不能指向begin之前的位置
编译器不会强制检查begin和end是否指向正常,这些是程序员的职责。
使用左闭合范围蕴含的编程假定
标准库使用左闭合范围是因为这种范围有三种方便的性质:
- 如果begin和end相等,则范围为空
- 如果begin和end不等,则范围至少包含一个元素,且begin指向该范围中的第一个元素
- 可以对begin递归若干次,使得begin==end
while( begin != end){
*begin = val; // 将迭代器中每个元素,都修改为val
++begin; // 移动迭代器,获取下一个元素
}
9.2.2 容器类型成员
每个容器类都定义了多个类型,如9.2节。我们已经用过的三种容器类型:size_type
(参见3.2.2节 79页)iterator
和const_iterator
(参见3.4.1节 97页)
除了已经使用过的迭代器类型,大多数容器还提供反向迭代器(即一种反向遍历容器的迭代器,我们将在10.4.3节 363页介绍更多关于反向迭代器的内容)
- 如果需要元素类型,可以使用容器的value_type
- 如果需要元素类型的一个引用,可以使用reference或者const_reference
这些元素相关的类型别名在泛型编程中非常有用,我们将在16章中介绍相关内容。
使用这些类型需要显式地使用其类名:
// iter是通过list<string>定义的一个迭代器类型
list<string>::iterator iter;
// count是通过list<int>定义的一个difference_type类型
vector<int>::difference_type count;
这些声明使用了作用域运算符(参见1.2节 7页)
9.2.3 begin和end成员
begin和end操作(参见3.4.1 95页)生成指向容器第一个元素和尾元素之后位置的迭代器。
如表9.2(弟295页),begin和end有多个版本
- 普通版本
- 以r开头的的版本返回反向迭代器
- 以c开头的版本返回const迭代器
list<string> a = {"Milton", "Shakespeare", "Austen"};
auto it1 = a.begin(); // list<string>::iterator
auto it2 = a.rbegin(); // list<string>::reverse_iterator
auto it3 = a.cbegin(); // list<string>::const_iterator
auto it4 = a.cend(); // list<string>::const_reverse_iterator
当不需要写操作时候,应该使用cbegin和cend
9.2.4 容器定义和初始化
每个容器类型都定义了一个默认构造函数(参见7.1.4节 236页) 除了array之外,其它容器的默认构造函数都会创建一个指定类型的空容器,且都可以接受指定容器大小和元素初始值的参数。
将一个容器初始化为另一个容器的拷贝
- 直接拷贝整个容器
- (array除外)拷贝由一个迭代器对指定元素的范围
容器之间拷贝的前提:两个容器的类型,及其元素类型必须匹配
传递迭代器参数拷贝时:两个容器的类型可以不同,元素类型也可以不同(前提是要能将元素类型转换为被初始化的容器的元素类型,参见4.11节 141页)
list<string> arthors = {"Milton", "Shakespeare", "Austen"};
vector<char *> articles = {"a", "an", "the"};
list<string> list2(arthors) // 正确,类型匹配
deque<string> list3(arthors) // 错误,类型不匹配
vector<string> list4(articles) // 错误,元素类型不匹配
forward_list<string> list5(articles.begin(), articles.end()); // 正确,char *可以转换为string
假设it指向authors中某个元素:
deque<string> list6(arthors.begin(), it) // 拷贝元素,直到(但不包括)it指向的元素
列表初始化
在新标准中,我们可以对一个容器进行列表初始化(参见3.3.1节 88页)
list<string> arthors = {"Milton", "Shakespeare", "Austen"};
vector<char *> articles = {"a", "an", "the"};
显式地指定了容器中每个元素的值。 对于除了array外的容器类型,初始化列表还隐含地指定了容器的大小(容器将包含和列表中一样多的元素)
与顺序容器大小相关的构造函数
顺序容器(除array外)还提供另一个构造函数,接受
- 一个容器大小和
- 一个(可选的)元素初始值,如果不提供元素初始值则标准库会创建一个值初始化器(参见3.3.1节 88页),如果类型没有默认构造函数,那么元素初始值也是必须传的。
vector<int> a0(10,1); // 10个int元素,每个初始化为-1
list<string> a1(10,"hi!"); // 10个string元素,每个初始化为"hi!"
forward_list<int> a2(10); // 10个int元素,每个初始化为0
deque<string> a3(10); // 10个int元素,每个初始化为空string
标准库array具有固定大小
和内置数组一样,标准库array的大小也是类型的一部分。
定义一个array时,除了指定元素类型,还要指定容器大小。
array<int, 42>; // 保存42个int的数组
array<string, 42>; // 保存42个string的数组
凡是使用array,必须指定元素类型和大小:
array<int, 42>::size_type i; // 正确
array<int>::size_type j; // 错误,必须指定元素类型和大小,array<int>不是一个正确的类型
由于array的大小是array类型的一部分,所以array不支持普通的容器构造函数
- array的大小是固定的
- 一个默认构造函数的array是非空的:它包含了与其大小一样多的元素,这些元素都被默认初始化(参见2.2.1节 40页),就像内置数组一样(参见3.5.1节 102页)
- array的列表初始化,初始值的大小必须小于等于array的大小,(列表初始值数量小于array大小时,剩余元素默认初始化)
后两种初始化的情况下,如果元素的类型是类的话,那么该类必须要有个默认构造函数,以使值初始化能够进行。
array<int,10> ia1; // 10个默认初始化的int
array<int,3> ia2 = {1,2,3}; // 列表初始化
array<int,3> ia3 = {42}; // ia3[0]为42,其余都是0
和内置数组相比,array可以进行拷贝和对象赋值操作
int digs[3] = {1,2,3};
int cpy = digs; // 错误,内置数组不支持拷贝或者赋值
array<int, 3> digits = {1,2,3};
array<int, 3> copy = digits; // 正确,只要数组类型匹配,元素数量一样即合法
9.2.5 赋值和swap
下表所列的与赋值相关的运算符可以用于所有容器。 赋值运算符将其左边容器中的全部元素替换为右边容器中的元素的拷贝:
c1 = c2; // 将c1中的内容替换为c2中元素的拷贝
c1 = {"a", "b", "c"};
array容器的赋值要求元素类型和容器大小都相同
w
使用assign(仅顺序容器)
赋值运算符=
要求左边和右边的运算对象具有相同的类型。将右边运算对象中所有元素拷贝到左边运算对象中。
顺序容器中定义了一个名为assign的成员,允许我们从一个不同,但相容的类型赋值,或者从容器的一个子序列赋值。
assign用参数中指定迭代器范围的元素的(拷贝)替换左边容器的所有元素。
list<string> names;
vector<const char*> oldstyle;
names = oldstyle; // 错误,容器类型不匹配
names.assign(oldstyle.cbegin(),oldstyle.cend()); // 正确,可以将char * 转换为string
由于其旧元素被替换,因此传递给assign的迭代器不能指向调用assign的容器 (容器调用assign的参数不能是容器自己的迭代器)
list<string> slist1(1); // 1个元素,为空string
slist.assign(10,"hiya!"); // 10个元素,每个都是“hiya!”
/* 等价于 */
list<string> slist1(1);
slist1.clear();
slist1.insert(slist1.begin(),10,"hiya!");
使用swap
swap交换两个相同类型容器的内容。
调用swap后,两个容器中的元素将会交换。
vector<string> svec0(10); // 10个元素的vector
vector<string> svec1(23); // 24个元素的vector
swap(svec0,svec1);
- swap两个array会真正交换它们的元素
- swap两个string会使得引用和指针失效
- 其他容器的swap操作,仅仅是交换了容器,不会对元素进行任何拷贝、删除或插入操作,因此可以在常数时间完成。
新标准库中,容器既提供成员函数swap,也提供非成员函数的swap。 非成员函数的swap在泛型编程中非常重要,优先使用非成员函数的swap是一个好习惯。
9.2.6 容器大小的操作
- 成员函数 size() (参见3.2.2节 78页)返回容器中元素的数目
- 成员函数 empty() 当size为0时返回布尔值true,否则返回false
- 成员函数 max_size() 返回一个大于等于该类型容器所能容纳的最大元素的值
除了一个例外forward_list只支持max_size和empty,但不支持size,原因我们将在下一节介绍
9.2.7 关系运算符
容器之间的关系运算符:
- 每个容器都支持相等运算符
==
和!=
- 除了无序容器外的所有容器都支持关系运算符
>
、>=
、<
、<=
- 关系运算符左右两边的运算对象必须是相同类型的容器,且必须保存相同类型的元素(即我们只能将一个vector
<int>
与另一个vector<int>
进行比较,而不能将vector<int>
与一个vector<double>
进行比较)
比较两个容器实际上是将每个容器进行逐一比较。这些运算符的工作方式与string的关系运算符类似
- 如果两个容器大小相等,且所有元素都两两对应相等,则两容器相等,否则两容器不等
- 如果两个容器大小不等,但小容器的每个元素都等于较大容器中的对应元素,则小容器小于大容器
- 如果两个容器都不是另一个容器的前缀子序列,则它们的比较结果取决于第一个不相等的元素的比较结果
容器的关系运算符使用元素的关系运算符完成比较
只有当其元素也定义了相应的比较运算符时,我们才可以使用关系预算符来比较两个容器
容器的相等预算符实际上是使用元素的==
实现比较的,而其他运算符是使用元素的<
运算符。
如果元素类型不支持所需运算符,那么保存这种元素的容器就不能用相应的关系运算
例如我们在第7章的Sales_data类型没有定义==
和<
运算,因此就不能比较两个保存Sales_data
元素的容器。
vector<Sales_data> storeA, storeB;
if(storeA<storeB){ // 错误,Sales_data没有<运算符
}
9.3 顺序容器操作
上一节介绍的是所有容器都支持的操作(表9.2 295页) 本章剩余部分将介绍顺序容器所特有的操作
9.3.1 向顺序容器添加元素
使用push_back()
除了array和forward_list外,每个顺序容器(包括string)都支持push_back()
string word;
while( cin >> word){
container.push_back(word);
}
- push_back在container的尾部创建了一个新的元素,该元素的值是word的拷贝
- container的size增大了1
- container的类型可以是list、vector、deque、string等
string word= "hell"
word.push_back('o'); // 等价于 word += 's'
使用push_front()
- list、forward_list 和 deque支持
list<int> ilist;
for(size_t ix = 0; ix != 4; ++ix){
ilist.push_front(ix);
}
deque保证在容器首尾插入元素都是常数时间。和vector一样在deque首尾之外的地方插入元素会很耗时。
在容器特中的特定位置添加元素
insert成员函数允许我们在容器中任意位置插入0个或者多个元素。
- vector、deque、list和string都支持insert成员
- forward_list提供了特殊版本的insert成员(我们将在9.3.4节 312页介绍)
每个insert函数都接受一个迭代器作为其第一个参数。
- 迭代器可以指向容器中任何位置,包括容器尾部之后的下一个位置
- insert将元素插入到迭代器所指定的位置之前
slist.insert(iter, "hello"); // 将"hello"添加到iter之前的位置
虽然某些容器不支持push_front,但它们对于insert操作并无类似的限制(即可以插入到开始位置)。
vector<string> svec;
list<string> slist;
slist.insert(slist.begin(),"hello!"); // 等价于slist.push_front("hello")
svec.insert(svec.begin(),"hello!"); // vector不支持push_front操作,但是不限制insert
将元素插入到vector、deque和string中的任何位置都是合法的。然而这样做可能会很耗时
插入范围内元素
insert函数还可以接受更多的参数,其中一个版本将指定数量的元素添加到指定位置之前,这些元素都按定值初始化:
svec.insert(svec.end(),10,"Anna"); //向svec末尾插入10个string,并将所有元素初始化为"Anna"
slist.insert(slist.end(),{"these","words","will"});
// 运行时错误:迭代器要拷贝的范围,不能指向与目标容器位置相同的容器(表示insert要拷贝的范围迭代器,不能是调用insert的容器自己中的迭代器)
slist.insert(slist.begin(),slist.begin(),slist.end()); // 错误
使用insert的返回值
通过insert的返回值,可以在容器中一个特定的位置反复插入元素
- insert的返回值是向容器新添加的n个元素的第一个元素的迭代器
list<string> lst;
auto iter = lst.begin();
while(cin >> word){
iter = lst.insert(iter,word); // 等价于调用push_front
}
// 每次循环更新iter为插入元素的第一个,本例中即每次循环更新iter为lst.begin()
使用emplace操作
emplace_front
、emplace
、emplace_back
,这几个操作是构造元素,而非拷贝元素。
即调用emplace
时,是将参数传递到元素类型的构造函数,emplace成员使用这些参数在容器管理的内存空间中直接构造函数。
对应的
push_front
、push
、push_back
这几个函数,是将对象拷贝到容器中。
// e是一个Sales_data类型的容器,直接使用Sales_data的三个参数的构造函数构造元素
e.emplace_back("978-0567889",25,15.99);
// 等价于
Sales_data item("978-0567889",25,15.99);
e.push_back(item);
9.3.2 访问元素
访问成员函数返回的是引用
如果容器是const的,则返回是const的引用
如果容器不是const的,则返回是普通的引用,我们可以用来改变元素的值
下标操作和安全的随机访问
使用越界的下标是一种严重的设计错误,而且编译器不检查这种错误
如果我们希望确保下标是合法的,可以使用at成员函数。 at成员函数如果下标越界,会抛出一个out_of_range异常(参见5.6节 173页)
vector<string> svec // 空vector
cout << svec[0] // 运行时错误: svec中没有元素
cout << svec.at(0) // 抛出一个out_of_range异常
9.3.3 删除元素
pop_front和pop_back成员函数
while(!ilist.empty()){
ilist.pop_front(); // 删除首元素,直到容器为空
}
从容器内部删除一个元素
成员函数erase
从容器指定位置删除元素。
删除容器中所有为奇数的元素
list<int> lst = {0,1,2,3,4,5,6,7,8,9};
auto it = lst.begin();
while(it != lst.end){
if(*it % 2){ // 若元素为奇数
it = lst.erase(it); // 删除此元素
}
else{
++it;
}
}
删除多个元素
slist.clear(); // 删除容器中所有元素
slist.erase(slist.begin(), slist.end()); // 等价调用
9.3.4 特殊的forward_list操作
当添加或者删除一个元素时,删除或添加的元素之前的那个元素的后继会发生变化。为了添加或删除一个元素,我们需要访问其前驱,以改变前驱的链接。
但是forward_list是一个单向链表。在一个单向链表中没有简单方法来获取一个元素的前驱。
所以在一个forward_list中添加和删除元素是通过改变元素之后的元素来实现的。
9.3.5 改变容器大小
我们可以用成员函数resize()来增大或者缩小容器(array不支持resize)
9.3.6 容器操作可能使迭代器失效
向容器中添加元素和从容器中删除元素的操作可能会使指向容器元素的指针、引用或迭代器失效。
一个失效的指针、引用或者迭代器将不再表示任何元素。 使用失效的指针、引用或迭代器是一种严重的程序设计错误,很可能引起与使用未初始化的指针一样的问题(参加2.3.2 49页)
在向容器添加元素后:
-
如果容器是vector或者string,且存储空间被重新分配,则指向容器的迭代器、指针和引用都会失效。如果存储空间没有被重新分配,指向插入位置之前的元素的迭代器、指针和引用仍有效,插入位置之后的元素的迭代器、指针、引用都失效
-
对于deque,插入到首尾位置之外的任何位置都会导致迭代器、指针、引用失效。若是在首尾位置添加元素,迭代器会失效,但指向存在的元素的引用和指针不会失效。
-
对于list和forward_list,指向容器的迭代器(包括尾后迭代器和首前迭代器)、指针和引用仍有效
当我们从一个容器删除元素之后,指向被删除元素的迭代器、指针和引用都会失效。:
- 对于list和forward_list,指向容器的迭代器(包括尾后迭代器和首前迭代器)、指针和引用仍有效
编写改变容器的循环容器
添加/删除vector、string或deque元素的循环必须考虑迭代器、引用和指针可能失效的问题。
如果要进行添加删除等操作,程序必须保证每个循环步中都更新了迭代器、指针或引用。
(如果循环中调用的是insert或erase,那么更新迭代器很容易。因为这些操作都返回迭代器,可以用来更新)
// 删除偶数元素,复制奇数元素
vector<int> vi = {0,1,2,3,4,5,6,7,8,9};
auto iter = vi.begin(); // 使用begin(),而不是cbegin(),因为我们要改变vi
while(iter != vi.end()){
if(*iter % 2){
iter = vi.insert(iter, *iter);
iter += 2; // 移动迭代器到插入的元素之后
}else{
iter = vi.erase(iter); // 删除偶数元素
// erase之后返回的迭代器即指向删除元素之后的元素
}
}
我们在调用insert和erase后都要更新迭代器,因为两者都会使迭代器失效。
不要保存end返回的迭代器
由于在执行改变容器的操作后,尾后迭代器(end()
)总是会失效。
所以如果使用一个变量保存了end返回的尾后迭代器,又没有再改变了容器后及时更新它,就会使用到失效的尾后迭代器。
最好的做法是,添加/删除元素的程序,每次都去调用end()来取尾后迭代器。(而不是在循环之前保存end返回的迭代器,一直当做容器的尾后迭代器使用) C++标准的实现中end()操作都很快,部分就是因为这个原因。
// 灾难,此循环的行为是未定义的
auto begin = v.begin(), end = v.end();
while(begin!=end){
++begin; // 我们想在此元素之后插入,所以向前移动
begin = v.insert(begin,42); // 插入数值 42
++begin; // 移动到插入的元素之后
}
这段代码的原意是实现在容器每个元素之后都插入一个42, 要正常运行应该改写为:
// 每次循环调用.end()来判断
while(begin != v.end()){
}
9.4 vector对象是如何增长的
为了支持快速随机访问,vector将元素连续存储—每一个元素紧挨着前一个元素存储。
通常情况下,我们不必关心一个标准库类型是如何实现的,而只需关心它如何使用。 然而,对于vector和string,其部分实现渗透到了接口中
假定容器中的元素是连续存储的,且容器的大小是可变的,考虑向vector或string中添加元素会发生什么:
- 如果没有空间容纳新元素,容器不可能简单地将它添加到内存中其他位置——因为元素必须连续存储。容器必须分配新的内存空间来保存已有元素和新元素,将已有元素从旧位置移动到新空间中,然后添加新元素,释放旧存储空间。
- 如果我们每添加一个新元素,vector就执行一次这样的内存分配和释放操作,性能会慢到不可接受。
为了避免这种代价,标准库实现者采用了可以减少容器空间重新分配次数的策略:
- 当在不得不获取新的内存空间时,vector和string的实现通常会分配比新的空间需求更大的内存空间。容器预留的这些空间作为备用,这样就避免了每次添加新元素都重新分配容器的内存空间了。
- 这个策略使得vector和string在扩张元素通常比list和deque要快
管理容量的成员函数
调用shrink_to_fit只是一个请求,标准库并不保证退换内存 一般来说vector增长的策略是是在每次需要的时候,将当前容量翻倍的
capcity和size
- size()返回已经保存的元素的数量
- capcity返回不分配新的内存空间的前提下,最多可以保存的元素数量
每个vector都可以有自己的内存分配策略。但是必须遵守的一条原则是:只有当迫不得已时才可以分配新的内存空间
9.5 额外的string操作
除了顺序容器的共同的操作之外,string类型还提供了一些额外的操作。
(这些操作大部分要么是提供string类和C风格字符串数组之间的转换,要么是增加了允许我们使用下标操作迭代器的版本)
本章介绍的这些string的函数的使用都是似曾相识的。 由于函数较多,可以快速浏览,在需要的时候回过头来仔细阅读。
9.5.1 构造string的其它方法
三种构造函数
substr操作
s.substr(pos,n);
9.5.2 改变string的其他方法
assign、insert、erase
append和replace函数
改变string的多种重载函数
assign、insert、append、replace函数都有多个重载版本,幸运的是这些函数有共同的接口(即它们的一些调用方式都是类似的)。
9.5.3 string搜索操作
搜索操作成功都返回:string::size_type 搜索操作失败返回:string::npos 的static成员(参见7.6节 268页),
标准库将string::npos定义为一个string::size_type类型,并初始化为-1。由于npos是一个unsigned类型(string::size_type),此初始值意味着npos等于任何string最大的可能大小(参见2.1.2节 32页)
string s("annabelle");
string::size_type pos = s.find("Anna"); // pos == npos
auto pos = s.find("Anna");
这段代码会将pos置为npos,因为Anna和anna不匹配
指定从哪里开始搜索
逆向搜索
9.5.4 compare函数
9.5.5 数值转换
如果string不能转换为一个数值,这些函数会抛出一个invalid_argument异常(参见5.6节 173页)。如果转换得到的数值无法用任何类型来表示,则抛出一个out_of_range异常
9.6 容器适配器
除了顺序容器外,标准库还定义了三个顺序容器的适配器:
- stack
- queue
- priority_queue
适配器是标准库中的一个通用概念。容器、迭代器和函数都有适配器。 本质上适配器是一种机制,可以使一种容器类型的行为看起来像另外一种不同的类型
例如stack适配器接收一个顺序容器(除array或者forward_list外),并使其操作起来像一个stack一样
定义一个适配器
每个适配器都定义两个构造函数:
- 默认构造函数创建一个空对象,接受一个容器的构造函数拷贝该容器来初始化适配器
stack<int> stk(deq) // 从dep拷贝元素到stk
默认情况下,stack和queue是基于deque实现的,priority_queue是在vector上实现的。 我们可以在创建适配器时,提供额外一个顺序容器的参数来重载默认的实现
// (默认)在deque上实现的空栈
stack<string> str_stk;
// 在vector上实现的空栈
stack<string, vector<string>> str_stk2;
// 在vector上实现的栈,初始化时保存svec的的拷贝
stack<string, vector<string>> str_stk3(svec);
- stack要求容器具有添加、删除和访问尾元素,所以不能使用vector和forward_list
- queue需要back、push_back、front、push_front,所以只能使用list、deque,不能使用vector
栈适配器 stack
头文件<stack>
中
stack<int> intStack;
intStack.push(0);
intStack.push(1);
intStack.top(); // 1 后进先出
intStack.pop();
intStack.pop();
适配器只能使用适配器定义的操作,而不能去调用底层容器的操作:
stack<int> intStack;
intStack.push(1);
intStack.push_back(1); // 错误,不能调用底层实现容器deque的push_back
// 只能调用stack的push成员函数
队列适配器 queue
头文件<queue>
中
- (constructor)
- Construct queue (public member function )
- empty
- Test whether container is empty (public member function )
- size
- Return size (public member function )
- front
- Access next element (public member function )
- back
- Access last element (public member function )
- push
- Insert element (public member function )
- emplace
- Construct and insert element (public member function )
- pop
- Remove next element (public member function )
- swap
- Swap contents (public member function )
第十章 泛型算法
标准库没有为每个容器添加大量操作,而是提供了一组泛型算法。
泛型算法实现一些经典算法的公共接口,称为泛型是因为可以用于不同类型的元素和多种容器类型
10.1 概述
大多数算法定义在头文件<algorithm>
中
还在头文件numeric
中定义了一组数值算法
一般情况下,这些算法并不直接操作操作容器,而是遍历由两个迭代器指定的一个元素范围(参见9.2.1 296页)来进行操作。
例如,假定我们有一个int的vector,希望知道vector中是否包含一个特定值。最方便的方式是使用标准库算法find
int target = 42;
vector<int> v = { 1,52,43,78,42,31,24,36 };
auto result = find(v.begin(), v.end(), target);
cout << "The result is " << (result == v.end() ? "false" : "true") << endl;
cout << result - v.begin() << endl;
cout << v[result - v.begin()] << endl;
算法如何工作
我们更近地观察一下find,find的工作是在一个未排序的元素序列中查找一个特定元素:
- 依次访问序列中的所有元素
- 若和目标值匹配,则find返回当前元素的迭代器
- 若无任何元素与目标值匹配则find到达末尾时停止,返回序列的尾后迭代器
这些步骤,不限制容器的元素类型,只需要支持迭代器(甚至序列可以不是容器)
迭代器令算法不依赖于容器......
......但算法依赖于元素类型的操作
虽然算法使用迭代器并不限制容器的类型,但是进行比较时,大多数算法都使用了元素类型上的操作。
比如find会使用=
操作符来完成元素与给定值的比较。
其他算法可能使用<
,则要求元素支持相应的操作符号
不过,大多数算法提供方式允许我们使用自定义的操作来替代默认的运算符
泛型算法永远不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作。 泛型算法的这个特效,带来了一个令人惊讶但非常必要的编程假定: 泛型算法永远不会改变底层容器的大小(泛型算法可以改变元素的值、移动元素,却不会添加或删除元素)
10.2 初识泛型算法
标准库提供了超过100个算法,幸运的是,与容器类似,这些算法有一致的结构。
理解这个结构可以让我们更容易学习和使用这些算法,而不需要死记硬背:
- 除了少数例外,标准库算法都对一个范围内的元素进行操作。我们将此范围成为输入范围
- 接收输入范围的算法总是使用前两个参数接收范围,两个迭代器一个表示开始,一个表示结束。
- 泛型算法接收参数的方式很相似,不过对元素进行的处理方式不同(读取元素、改变元素、重排元素)
10.2.1 只读算法
只读取元素,从不改变元素(find就是这样的算法,10.1节 337页的count也是如此)
<numeric>
头文件中的accumulate
也是只读算法,用于求序列的累加和:
// 以初始值0开始,对vec中的所有元素求和
int rs = accumulate(vec.begin(), vec.end(), 0);
// accumulate的第三个参数决定了函数中使用哪个加法运算符,以及返回值
// 将vec中的每个字符传元素拼接起来
using std::literals::string_literals::operator""s;
vector<string> sv = { "hello","cpp","nice!" };
string rs = accumulate(sv.begin(), sv.end(), ""s ); // c++ 14 string literal
// hellocppnice!
string rs = accumulate(sv.begin(), sv.end(), string("") ); // c++ string
// 错误const char* 上没有定义+运算符
string rs = accumulate(sv.begin(), sv.end(), "" );
算法和元素类型
accumulate将第三个参数作为求和起点,这蕴含着一个编程假定:将元素类型加到和的类型上的操作必须是可行的(即元素类型需要与第三个参数类型匹配,或者能自动隐式转换)
操作两个序列的算法
只读算法equal
,用于确定两个序列是否保存了相同的值:将一个序列的元素和另一个序列的元素进行比较,所有元素对应且相等返回true,否则返回false
// roaster2中的元素数量至少与roaster1一样多
equal(roaster1.cbegin(),roaster1.cend(),roaster2.cbegin())
操作两个序列的、第二个参数只接受一个迭代器的泛型算法,都假定第二个序列至少和第一个序列一样长 如果第二个序列长度小于第一个序列,则程序会产生一个严重错误,算法会试图访问第二个序列中末尾之后(不存在)的元素
10.2.2 写容器元素的算法
由于泛型算法不会执行容器操作(即不能改变容器的大小),所以我们需要确保写入元素后,容器的大小不会超过它的原大小(即向未满的容器插入元素,不能超过其所支持的大小)。
一些算法会自己向输入范围内写入元素,它们并不危险,它们最多写入与给定序列一样多的元素。
fill(vec.begin(),vec.end(),0); // 将每个元素重置为0
fill(vec.begin(),vec.begin() + vec.size()/2 , 10 ); // 将前一半元素重置为10
fill中只要传入的范围是有效的,写入操作就是安全的
算法不检查写操作
fill_n
将从指定迭代器开始,用新值赋给接下来n个元素
fill_n
假定dest指向一个元素,其从dest开始的序列至少包含n个元素
vector<int> vec; // 空vector
fill_n(vec.begin(), vec.size(), 0); // 将所有元素重置为0
fill_n(dest, n , val);
这条语句的结果是未定义的:
vector<int> vec; // 空vector
fill_n(vec.begin(), 10, 0); // 错误,修改vec中10个(不存在的)元素
介绍back_inserter
一种保证算法有足够元素空间来容纳输出数据的方法是使用:插入迭代器(insert iterator)
插入迭代器是一种向容器中添加元素的迭代器。
vector<int> vec;
auto it = back_inserter(vec);
*it = 42; // 向vec的末尾插入了一个元素,42
使用插入迭代器可以解决上一节的fill_n的问题:
vector<int> vec; // 空向量
fill_n(back_inserter(vec),10,0); // 添加10个元素到vec
由于传递给fill_n的迭代器是back_inserter返回的迭代器,因此每次向vec赋值时,都会在vec上调用push_back。 所以成功向vec中添加了10个元素,值都是0。
拷贝算法
copy
拷贝算法将一个序列中指定范围的元素拷贝到另一个序列中。
接收三个迭代器:前两个表示一个序列中指定范围的迭代器,第三个参数表示要拷贝到目标序列的起始位置
int a1[] = {0,1,2,3,4,5,6,7,8,9};
int a2[sizeof(a1)/sizeof(*a1)]; // a2初始为a1大小一样的数组
auto rs = copy(begin(a1),end(a2),a2); // 将a1的内容拷贝到a2
10.2.3 重排容器元素的算法
重排容器中元素的顺序,比如sort。
调用sort会重排输入序列的元素,使之有序,它利用元素类型的<
运算来实现排序
消除重复单词
使用unique
使用容器操作删除元素
sort(vec.begin(),vec.end())
// unique重排输入范围,使得每个单词只出现一次
// 结果排列在范围的前部,返回指向不重复区域之后一个位置的迭代器
auto end_unique = unique(vec.begin(),vec.end());
// 删除从end_unique到words.end()的元素
words.erase(end_unique, words.end());
10.3 定制操作
定制泛型算法中的比较操作,即重载泛型算法中比较操作的默认行为。
10.3.1 向算法传递操作
谓词
谓词是一个可以调用的表达式,其返回结果是一个能用作条件的值。
标准库中的谓词分为两类:
- 一元谓词,接收单一参数
- 二元谓词,有两个参数
bool isShorter(const string &s1, const string &s2){
return s1.size() < s2.size();
}
sort(words.begin(), words.end(), isShorter);
排序算法
elimDumps(words); // 将words按字典序重排,并消除重复单词
stable_sort(words.begin(), words.end(), isShorter); // 按字符串长度排序
for(const auto &s : words){ // 无须拷贝字符串
cout << s << " "; // 打印每个元素,以空格分隔
}
cout << endl;
10.3.2 lambda表达式
谓词必须严格接收一个或两个参数,但是当我们希望进行操作需要更多的参数时,超出了算法对谓词的限制 此时有两种方案
- 调整参数,使其通过谓词进行表达
- 使用lambda表达式
介绍lambda
我们可以向一个算法传递任何类别的可调用对象(callable object)。
即可以使用()
调用运算符(参见1.5.2节 21页)
目前为止我们使用过的两种可调用对象:函数和函数指针(参见6.7节 221页) 还有两种:重载了函数调用运算符的类(14.8节 506页)、lambda表达式
lambda表达式:
- 表示一个可调用的代码单元, 可以理解为一个未命名的内联函数
- 与任何函数类似,一个lambda拥有返回类型、参数列表、函数体
- 可以定义在函数内部
[caputure list] (parameter list) -> return type { function body }
// caputure list: lambda所在函数中定义的局部变量的列表(通常为空)
// parameter list、return type、function body 和普通函数一样
lambda 必须使用尾置返回(参见6.3.3节 206页)
我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体
// 定义了一个可调用对象f,它不接受参数,返回值是42
auto f = [] {return 42};
cout << f << endl; // 输出42
- 省略参数列表相当于空的参数列表(不传参)
- 省略返回类型,lambda会根据函数体的代码推断出返回类型
- 如果lambda的函数体除了return语句还有其他内容,且未制定返回类型,则返回void
向lambda传递参数
与isShorter功能一致的lambda函数:
auto f= [](const string &a, const string &b){
return a.size() < b.size();
}
改写之前的泛型算法参数:
statble_sort(words.begin(), words.end(),
[](const string &a, const string &b){
return a.size() < b.size();
}
)
使用捕获列表
通过捕获列表,使得lambda可以使用其所在函数的局部变量。
// sz为lambda所在函数的一个局部变量
[sz] (const string &a){
return a.size() >= sz;
}
// 错误,sz未捕获
[] (const string &a){
return a.size() >= sz;
}
调用find_if
vector<string> words = { "ab","abcd","abcde","abc","a" };
int sz = 2;
sort(words.begin(), words.end());
auto wc = find_if(words.begin(), words.end(), [sz](const string &a) {
return a.size() > sz;
});
auto count = words.end() - wc;
cout << count << endl;
return 0;
for_each算法
捕获列表只用于局部非static变量,lambda可以直接使用局部static变量以及lambda所在的函数之外声明的名字。
完整的biggies(代码)
void biggies(vector<string>& words,
vector<string>::size_type sz) {
elimDumps(word); // 将单词按字典顺序排序,删除重复单词
// 按单词长度排序
statble_sort(word.begin(), words.end(),
[](const string& a, const string& b) {
return a.size() < b.size();
});
// 获取一个迭代器,指向第一个满足size>=sz的元素
auto wc = find_if(words.begin(), words.end(),
[sz](const string& a) {
return a.size() >= sz;
});
// 计算满足size >= sz的元素的数目
auto count = words.end() - wc;
cout << count << " " << make_plural(count, "word", "s")
<< "of length" << sz << " or longger" << endl;
// 打印长度大于等于给定值的单词,每个单词后面接一个空格
for_each(wc, word.end(), [](const string& s) {
cout << s << "";
});
cout << endl;
}
10.3.3 lambda捕获和返回
当定义一个lambda时候,编译器生成一个与lambda对应的新的(未命名)的类类型(将在14.8.1节 507页介绍)。
目前可以理解为:
值捕获
[sz](const string &a){
return s.size() >= sz;
}
引用捕获
[&os, c](const string &a){
os << s << c;
}
隐式捕获
编译器根据lambda函数体代码自动推断捕获的变量
// 隐式值捕获
[=](const string &a){
return s.size() >= sz;
}
// 隐式引用捕获
[&](const string &a){
return s.size() >= sz;
}
可变lambda
- lambda捕获的值拷贝变量,默认是不可改变其值的,要改变值拷贝的捕获变量,需要制定mutable
void f(){
size_t v1 = 42;
auto f = [v1] () mutable{return ++v;};
v1 = 0;
auto j = f(); // j为43
}
- 引用拷贝不受此限制,而是和以前一样由引用指向的数据类型是否为const决定(底层const)。
指定lambda返回类型
lambda中如果不止有一条return的话,就必须制定一个返回类型。否则会产生编译错误。
transform(vi.begin(), vi.end(),
[](int i) -> {
if(i<0){
return -i;
}else{
return i;
}
}
)
10.3.4 参数绑定 bind
用于写一个普通函数来替代lambda��数
标准库bind函数
头文件functional
auto newCallable = bind(callble, arg_list);
确定check_size的sz参数
auto wc = find_if(words.begin(),words.end(),
bind(check_size, _1 ,sz)
)
使用placeholder名字
头文件functional
using std::placeholder::_1;
名字_n都定义在一个名为placeholder的命名空间中,在std中
bind的参数
// g是一个有两个参数的可调用对象
auto g = bind(func, a, b, _2, c, _1);
// 调用g
g(p1,p2);
// 会映射为
func(a, b, p2, c, p1);
使用bind重排参数排序
// 按单词长度由短至长排序
sort(words.begin(), words.end(), isShorter);
// 调用isShorter(A,B)
// 按单词长度由长至短排序
sort(words.begin(), words.end(), bind(isShorter, _2, _1));
// 调用isShorter(B,A)
绑定引用参数
bind不能直接使用捕获变量,需要使用标准库ref函数
for_each(words.begin(), words.end(),
[&os, c](const string &s){
os << s << c;
}
)
void print(ostream &os, const string &s, char c){
return os << s << c;
}
for_each(words.begin(), words.end(),
bind(print, ref(os), _1, ' ')
)
10.4 再探构造器
- 插入迭代器: 迭代器被绑定在容器上,可用于向容器插入元素
- 流迭代器:迭代器绑定在输入或者输出流上,可用于遍历所关联的IO流
- 反向迭代器: 迭代器被绑定在容器上,迭代器向相反方向移动,除了forward_list之外的标准库都有反向迭代器。
- 移动迭代器:迭代器被绑定在容器上,可用于移动容器的元素
10.4.1 插入迭代器
- back_inserter (参见10.2.2节 241页)创建一个使用push_back的迭代器
- front_inserter 创建一个使用push_front的迭代器
- inserter 创建一个使用insert的迭代器,insert(item,contariner);
auto *it = inserter(item, container.begin());
*it = val;
// 等价于
it = c.insert(it, val);
++it;
list<int> l = {1, 2, 3, 4};
list<int> l2, l3;
copy(l.cbegin(), l.cend(), front_inserter(l2));
// 拷贝完成后,l3包含4 3 2 1
copy(l.cbegin(), l.cend(), front_inserter(l3, l3.begin()));
// 拷贝完成后,l3包含1 2 3 4
10.4.2 iostream迭代器
头文件iterator
- istream_iterator读取输入流
- ostream_iterator读取输出流
istream_iterator操作
- 创建一个istream_iterator可以将它绑定到一个流
- 默认初始化即创建一个尾后迭代器
istream_iterator<int> int_it(cin); // 从cin读取int
istream_iterator<int> int_eof; // 尾后迭代器
ifsream in("afile");
istream_iterator<string> str_it("int"); // 从afile读取字符串
用istream_iterator从标准输入读取数据,存入一个vector:
istream_iterator<int> in_iter(cin); // 从cin读取int
istream_iterator<int> eof; // istream尾后迭代器
while(in_iter != eof){ // 当有数据读取时
// 后置递增运算符读取流,返回迭代器的旧值
// 解引用迭代器,获得从流读取的前一个值
vec.push_back(*in_iter++);
}
// 等价于
isteam_iterator<int> in_iter(cin), eof; // 从cin读取int
vector<int> vec(in_iter, eof); // 从迭代器范围构造vec
使用算法操作流迭代器
istream_iterator<int> in(cin), eof;
cout << accumulate(in, eof, 0) << endl;
// 累加输入流中的所有数字,初始值为0
istream_iterator允许使用懒惰求值
当我们将一个istream_iterator
绑定到一个流时,标准库并不保证立即从流中读取数据。
标准库的实现保证的是在我们第一次解引用迭代器之前,从流中读取数据的操作已经完成了。
这样设计的目的是为了一下两种情况:
- 创建了一个
istream_iterator
,没有使用就销毁了 - 正在从两个不同的对象同步读取同一个流
ostream_iterator操作
可以为所有支持<<
操作符(输出运算符)的类型定义ostream_iterator
。
可以用ostream_iterator来输出值的序列:
ostream_iterator<int> out_iter(cout, " ");
for(auto e : vec){
*out_iter++ = e; // 赋值语句实际上将元素写到cout
}
cout << endl;
// 等价,第一种形式更容易理解
// 运算符*和++实际上对osream_iterator对象不做任何事情
for(auto e : vec){
out_iter = e; // 赋值语句实际上将元素写到cout
}
cout << endl;
可以调用copy来打印vec中的元素,比写个循环要简单些:
ostream_iterator<int> out_iter(cout, ",");
copy(vec.begin(), vec.end(), out_iter);
cout << endl;
// 逐个打印vec,每个元素后加个英文逗号,分隔
使用流迭代器处理类类型
可以为所有支持>>
操作符(输入运算符)的类型定义istream_iterator
对象。
之前写的Sales_item既有输入也有输出运算符,所以可以这样重写它:
istream_iterator<Sales_item> item_iter(cin), eof;
ostream_iterator<Sales_item> out_iter(cout,"\n");
Sales_item sum = *item_iter++;
while(item_iter != eof){
// 若当前交易记录有相同的ISBN号
if(item_iter -> isbn() == sum.isbn()){
sum += *item_iter++; // 加到sum中并读取下一条记录
}else{
out_iter = sum; // 输出这一组ISBN的sum当前值
sum = *item_iter; // 读取下一条记录
}
out_iter = sum; // 打印最后一组记录的和
}
10.4.3 反向迭代器
在容器中从尾元素向首元素移动的迭代器
++向左移动,—向右移动
vector<int> vec = { 0, 1, 2, 3, 4, 5};
for(auto rev_iter = vec.crbegin();
rev_iter!=vec.crend();
++rev_iter){
cout << *rev_iter << endl;
}
sort(vec.begin(),vec.end()); // 正向排序
sort(vec.rbegin(),vec.rend()); // 反向排序
反向迭代器需要递减运算符
反向迭代器和其他迭代器间的关系
// 查找第一个逗号
auto comma = find(line.cbegin(), line.cend(), ',');
// 打印第一个逗号前的元素
cout << string(line.cbegin(), comma) << endl;
// 输入 first,middle,last
// 输出 first
// 反向查找第一个逗号
auto rcomma = find(line.crbegin(), line.crend(), ',');
// 反向打印第一个逗号前的元素
cout << string(line.crbegin(), comma) << endl;
// 输入 first,middle,last
// 输出 tsal
10.5 泛型算法结构
10.5.1 5类迭代器
迭代器类别
输入迭代器 只读,不写;单遍扫描,只能递增
==
!=
++
- 解引用
*
,只在赋值的左侧 - 取成员
->
, 等价于*it.merber
输出迭代器 只写,不读;单遍扫描,只能递增
++
- 解引用
*
,只在赋值的右侧
前向迭代器 可读写;多遍扫描,只能递增
- 支持所有输入输出迭代器的操作
双向迭代器 可读写;多遍扫描,可递增递减
- 支持所有输入输出迭代器的操作
--
随机访问迭代器 可读写;多遍扫描,支持全部迭代器运算
- 支持双向迭代器的所有操作
>
、>=
、<
、<=
+=
、+
、-
、-=
-
- 下标运算
iter[n]
,等价于*(iter[n])
10.5.2 算法形参模式
alg(beg, end, other_args);
alg(beg, end, dest, other_args);
alg(beg, end, beg2, other_args);
alg(beg, end, beg2, end2, other_args);
alg是算法的名字 beg和end表示算法所操作的输入范围 dest、beg2、end2都是迭代器参数,分别表示指定的位置和第二个迭代器范围。
接受单个目标迭代器的算法
dest
接受第二个输入序列的算法
beg2 end2
10.5.3 算法命名规范
一些算法使用重载形式传递一个谓词
unique(beg, end); // 默认使用 == 比较元素
unique(beg, end, comp); // 使用comp来替代默认的比较函数
_if版本的算法
find(beg, end, val); // 查找输入范围中val第一次出现的位置
find_if(beg, end, pred); // 查找输入范围中第一个令pred为true的元素
区分拷贝元素的版本和不拷贝的版本
reverse(beg, end); // 反转输入范围中的元素顺序
reverse_copy(beg, end, dest); // 将元素按逆序拷贝到dest
// 从v1中删除奇数元素
remove_if(v1.begin(), v1.end(), [](int i){ return %2;})
// 删除v1副本的奇数元素并将结果拷贝到v1,不改变v1
remove_copy_if(v1.begin(),v1.end(), back_inserter(v2), [](int i){ return i%2;});
10.6 特定容器算法
与其它容器不同,链表类型list和forward_list定义了几个成员函数形式的算法。
对于list和forward_list都应该优先使用成员函数版本算法,而不是通用版本算法
splice成员
链表数据结构特有的splice算法,因此不需要通用版本
链表特有的操作会改变容器
多数链表特有的算法都与其通用版本很相似,但不完全相同。链表特有版本的与通用版本间的一个至关重要的区别是链表版本会改变底层的容器。
例如: remove的链表版本会删除制定的元素。 unique的链表版本会删除第二个和后继的重复元素 merge和splice会销毁其参数
第十一章 关联容器
关联容器和顺序容器有着根本的不同:关联容器中的元素是按照关键字来保存和访问的。
虽然关联容器的很多行为与顺序容器相同,但其不同之处反映了关键字的作用。
两个主要的关联容器map
和set
map: key-value键值对,关键字key起到索引的作用,值则表示与索引相关的数据
set: key的集合,key是唯一的。
标准库提供8个关联容器:
头文件<map>
、<set>
、<unordered_map>
、<unordered_set>
11.1 使用关联容器
map类型通常被称为关联数组
使用map
map<string, size_t> word_count; // string到size_t的空map
string word;
while( cin >> word){
++ word_count[word]; // 提取word的计数器并将其加1
}
for(const auto &w : word_count){
cout << w.first << "occurs" << w.second
<< (w.second>1) ? "times" : "time" << endl;
}
读取输入,输出每个单词出现了多少次
使用set
// 只对不在set中的单词计数
map<string, size_t> word_count; // string到size_t的空map
set<string> exclude={"The", "But", "And", "Or", "An", "A",
"the", "but", "and", "or", "an", "a"
};
string word;
while(cin >> word){
if(exclude.find(word) == exclude.end()){
// 不在exclude则可以统计
++word[word];
}
}
11.2 关联容器概述
关联容器不支持顺序容器位置相关的操作 关联容器不支持构造函数或像插入操作这样接收一个元素值和一个数量的操作
关联容器支持一些顺序容器不支持的操作(11.7表 388页)和类型别名(11.3节 381页) 无序容器提供一些用来调整哈希性能的操作(11.4节 394页) 关联容器的迭代器都是双向的
11.2.1 定义关联容器
// 空容器
map<string, size_t> word_count;
// 列表初始化
set<string> exclude={"The", "But", "And", "Or", "An", "A",
"the", "but", "and", "or", "an", "a"
};
// 三个元素,string到string的映射
map<string, string> author = {
{"Joyce", "James"},
{"Austen", "Jane"},
{"Dickens", "Charles"},
};
列表初始化一个map时,必须提供关键字类型和值类型
{key, value}
构成map中的一个元素
初始化multimap或multiset
map和set中的关键字必须是唯一的,而multimap或multiset没有此限制,它们允许多个元素拥有相同的key
11.2.2 关键字类型的要求
严格弱序
有序容器的关键字类型
使用关键字类型的比较函数
使用自定义的比较操作,自定义的比较操作类型应该是一种函数指针类型
bool compareIsbn(const Sales_data &lhs, const Sales_data &rhs){
return lhs.isbn() < rhs.isbn();
}
// 自定义操作,提供一个指向compareIsbn的指针
multiset<Sales_data decltype(compareIsbn)*> bookstore(compareIsbn)
当使用decltype来获得一个函数指针类型时,必须加上一个*来指出我们要使用一个给定函数类型的指针(6.7节 223页)
用compareIsbn来初始化bookstore对象,这表示当我们向bookstore添加元素时,通过调用compareIsbn来为这些元素排序(即bookstore中的元素将按它们的ISBN成员值排序)
11.2.3 pair类型
头文件utility
中
- 一个pair保存两个数据成员,类似容器
- pair用于生成特定类型的模板
pair<string, string> anon; // 保存两个string
pair<string, size_t> word_count; // 保存一个string一个size_t
pair<string, vector<int>> line; // 保存string和vector<int>
- pair中用对应类型的默认构造函数对数据成员进行值初始化
// 也可以为每个成员提供初始化器,列表初始化
pair<string, string> author{"James","Joyce"};
- 与其他标准库不同,pair的数据成员是public的,first和second
创建pair对象的函数
想象有一个函数需要返回一个pair:
pair<string, int> process(vector<string> &v){
if(!e.empty){
return {v.back(),v.back().size}; // 列表初始化
}else{
return pair<string, int>(); // 隐式构造函数返回值
}
}
// 早期C++版本
return pair<string, int>(v.back(), v.back().size());
// make_pair
return make_pair(v.back(), v.back().size());
11.3 关联容器操作
11.3.1 关联容器迭代器
// word_count是一个map
auto map_iter = word_count.begin();
// *map_iter是指向一个pair<const string, size_t>对象的引用
cout << map_iter-> first; // 打印此元素的key
cout << " "<< map_iter-> second; // 打印此元素的value
map_iter-> first = "new key"; // 错误关键字是const的
++map_iter-> second; // 正确,可以通过迭代器改变元素
set的迭代器是const的
可读不可写
set<int> iset = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
set<int>::iterator set_it = iset.begin();
if(set_it != iset.end()){
*set_it = 42; // 错误:set中的关键字是只读的
cout << *set_it << endl; // 正确: 可以读取关键字
}
遍历关联容器
auto map_it = word_count.cbegin();
while(map_it != word_count.cend()){
cout << map_it -> first << " occurs "
<< map_it -> second << " times " << endl;
++map_it;
}
关联容器和算法
我们通常不对关联容器使用泛型算法(参见第10章)
11.3.5节 388页 关联容器定义一个find成员,这个比直接使用泛型算法中的find快许多。
实际情况下,如果我们真要对一个关联容器使用算法,要么是将它做一个源序列,要么当做一个目的位置。(例如,可以用copy算法将元素从关联容器拷贝到一个序列。可以调用insert将一个插入器绑定(10.4.1节 358页)到一个关联容器。通过使用inserter,我们可以将关联容器当做一个目的位置来调用一个算法)
11.3.2 添加元素
关联容器的insert成员(384页)向容器中添加一个元素或一个元素范围。
由于map和set(以及对应的无序类型)包含不重复的关键字,因此插入一个已存在的元素对容器没有任何影响
vector<int> ivec = {2,4,6,8,2,4,6,8}; // ivec有8个元素
set<int> set2;
set2.insert(ivec.begin(), ivec.end()); // set2有4个元素
set2.insert({1,3,5,7,1,3,5,7}); // set2有8个元素
向map添加元素
向map添加元素时,必须记住元素类型是pair
word_count.insert({word,1});
word_count.insert(make_pair(word,1));
word_count.insert(pair<string, size_t>(word, 1));
word_count.insert(map<string, size_t>::value_type(word, 1));
检查insert的返回值
// 更繁琐的一种方法统计每个单词在输入中出现的次数
map(string,size_t) word_count; // 从string到size_t的空map
string word;
while(cin >> word){
// insert的返回值也是pair,
// pair::first 指向插入的元素
// pair::second true表示插入成功 false表示元素已存在没有进行插入
auto res = word_count.insert({word,1});
if(!res.second){ // word已经在word_count中
++res.first->second; // 递增计数器
}
}
展开递增语句
上面这个例子中的递增语句不是很好理解,让我们展开来理解一下:
(看代码注释)
向multiset或者multimap添加元素
有时我们希望添加具有相同关键字的多个元素,比如可能建立到相同作者到他所著书籍题目的映射。
multimap<string, string> authors;
// 插入第一个元素,关键字为Barth, John
authors.insert({"Barth, John", "Sot-weed Factor"});
// 插入第二个元素,关键字也是Barth, John
authors.insert({"Barth, John", "Lost in the future"});
multimap由于总是可以插入的,所以返回值是一个指向插入的元素的迭代器
11.3.3 删除元素
对于不重复的关联容器,erase的返回值总是0或1。 对于重复的关联容器,erase的返回值可能大于1。
if(word_count.erase(removal_word)){
cout << "ok: " << removal_word << "removed!";
}else{
cout << "wrong: " << removal_word << "not found in this map!";
}
// 可以重复的关联容器,删除元素的数量可能大于1
auto cnt = authors.erase("Barth, John");
11.3.4 map的下标操作
map、unordered_map等map容器提供了下标运算和at函数运算。 set等相关容器不支持下标,因为没有关键字对应的值。
multimap、unordered_multimap也不能进行下标操作,因为一个key可能对应多个值。
map<string, size_t> word_count;
word_count["Anna"] = 1;
// 在word_count中查找Anna,未找到
// 插入一个新的pair,key为Anna,值按size_t默认初始化,即初始化为0
// 将1赋值给Anna
使用下标操作的返回值
cout << word_count["Anna"];
++word_count["Anna"]; // 提取元素将其增加1
cout << word_count["Anna"]; // 提取元素并打印
如果关键字还未在map中,下标运算会添加一个新元素。
11.3.5 访问元素
set<int> iset = {0,1,2,3,4,5,6,7,8,9};
iset.find(1); // 返回一个迭代器,指向key==1的元素
iset.find(11); // 返回一个迭代器,其值等于iset.end()
iset.count(1); // 返回1
iset.count(11); // 返回0
对map和find代替下标操作
if(word_count.find("foobar") == word_count.end()){
cout << "foobar is not in the map" << endl;
}
在multimap和multiset中查找元素
multimap和multiset中对个元素对应一个关键字,则这些元素都会相邻存储。
multimap和multiset查询一个key对应的所有元素:
使用find和count:
string search_item("Alian de Button");
auto entries = author.count(search_item); // key中有几个元素
auto iter = author.find(search_item); // key对应的第一个元素
while(entries){
cout << iter -> sencond << endl; // 打印每个元素
++iter;
--entries;
}
一种不同的,面向迭代器的解决方法
lower_bound返回的迭代器指向第一个具有给定关键字的元素,
upper_bound返回的迭代器指向最后一个具有给定关键字的元素,
for(auto beg = author.lower_bound(search_item),
end = author.upper_bound(search_item);
beg != end;
++beg;
)
equal_range函数
第三种方式:
equal_range返回一个pair,
pair::first指向第一个与关键字匹配的位置
pair::second指向最后一个与关键字匹配的位置
for(auto pos = authors.equal_range(search_item);
pos.first != pos.second;
++pos.first
){
cout << pos.first->second << endl;
}
11.3.6 一个单词转换的map
map<string, string> wordmap={
{"brb","be right back"},
{"k","okay?"},
{"y","why"},
{"r","are"},
{"u","you"},
{"pic","picture"},
{"thk","thanks!"},
{"l8r","later"},
};
单词转换程序
建立转换映射
生成转换文本
/* 单词转换主函数 */
void word_transform(ifstream &map_file, ifstream &input){
auto trans_map = buildMap(map_file);
string text;
while(getline(input, text)){
istringstream stream(text);
string word;
bool firstword = true;
while(stream >> word){
if(firstword){
firstword = false;
}else{
cout << " ";
}
cout << transform(word, trans_map); // 打印输出
}
cout << endl;
}
}
/* 建立转换映射 */
map<string, string> buildMap(ifstream &map_file){
map<string, string> trans_map; // 保存转换规则
string key; // 要转换的单词
string value;
while(map_file >> key && getline(map_file, value)){
if(value.size() > 1){
trans_map[key] = value.substr(1);
}else{
throw runtime_error("no rule for " + key);
}
}
return trans_map;
}
/* 生成转换文本 */
const string &
transform(const string &s, const map<string,string &m){
auto map_it = m.find(s);
if(map_if != m.cend()){
return map_it -> second;
}else{
return s;
}
}
11.4 无序容器
标准库中有4个无序关联容器,这些容器不使用比较运算符来组织元素,而是使用一个哈希函数和关键字类型的==运算符。
在关键字类型的元素没有明显的序的关系的情况下,无序容器是非常有用。 在某些应用中,维护元素的序代价非常高昂,此时无序容器也很有用。
使用无序容器
unordered_map<string, size_t> word_count;
string word;
while (cin >> word){
++word_count[word];
}
for(const auto &w : word_count){
cout << w.first << " occurs " << w.second
<< ((w.second > 1) ? " times " : " time ")<< endl;
}
管理桶
无序容器在存储上组织为一组桶,每个桶保存零个或多个元素。
无序容器对关键字类型的要求
默认情况下,无序容器使用关键字类型的==运算符来比较元素,
它们还使用一个hash<key_type>类型的对象来生成每个元素的哈希值。
标准库为内置类型(包括指针)提供了hash模板
标准库还为一些标准库类型,包括string和智能指针类型定义了hash模板
但是我们不能直接定义 自定义的关键字类型的无序容器,因为必须提供我们自己的hash模板函数,而不能直接使用hash模板。(16.5节 626页)
size_t hasher(const Sales_data &sd){
return hash<string>()( sn.isbn() );
}
bool eqOp(const Sales_data &lhs, const Sales_data &rhs){
// return hash<string>()( sn.isbn() );
return lhs.isbn() == rhs.isbn();
}
// 我们使用这些函数爱定义一个unordered_multiset
// 该hash建立在string类型上
// eqop函数通过比较ISBN号来比较两个Sales_data
using SD_multiset = unordered_multiset<Sales_data,
decltype(hasher)*, decltype(eqOp)*
>
SD_multiset bookstore(42, hasher, eqOp);
// 用haser重载了hash生成的函数
// 用eqOp函数重载了==比较
// 只重载了hash生成的函数
unordered_multiset<Foo,
decltype(FooHash)*> fooSet(10, FooHash);
第十二章 动态内存
到目前为止,我们编写的程序中使用的所有对象都有严格定义的生存期:
- 全局对象在程序启动时分配,在程序结束时销毁
- 对于局部自动对象,我们进入其定义所在的程序块时被创建,在离开块时被销毁
- 局部static对象在第一次使用前时分配,在程序结束时被销毁
除了自动(普通)对象、static对象,C++还支持动态分配对象。 (即: 除了自动(普通)变量、static变量,C++还支持动态分配变量(内存)。 )
动态分配的对象的生存期与它们在哪里被创建是无关的,只有被显式地被释放时,这些对象才会销毁。
动态对象的正确释放被证明是编程中极其容易出错的地方。为了更安全地使用动态对象,标准库定义了两个智能指针类型来管理动态分配的对象。当一个对象应该被释放时,指向它的智能指针可以确保自动地释放它
我们的程序到目前为止只使用过静态内存或栈内存。
- 静态内存用来保存局部static对象(参见6.6.1节 185页)、类static数据成员(参见7.6节 268页)已经定义在任何函数之外的变量
- 栈内存用来保存定义在函数内的非static对象。
分配在静态内存了或者栈内存中的对象由编译器自动创建和销毁。
- 对于栈对象,仅在其定义的程序块运行时才存在
- static对象在使用之前分配,在程序结束时销毁
除了静态内存和栈内存,每个程序还拥有一个内存池。堆内存 ,这个部分被称作自由空间 free stroe或者堆 heap
程序用堆来存储动态分配 dynamically allocate的对象 ( 即那些在程序运行时分配的对象,动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们 )
虽然使用动态内存有时是必要的,但众所周知,正确地管理动态内存是非常棘手的。
12.1 动态内存与智能指针
在C++中,动态内存的管理是通过一对运算符来完成的:
new
在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化delete
接受一个动态对象的指针,销毁该对象,并释放与之关联的内存
为了更容易和安全的使用动态内存,标准库提供了两种智能指针来管理内存(动态对象),智能指针行为类似于常规指针,区别是智能指针可以自动释放所指向的对象。
shared_ptr
允许多个指针指向同一个对象unique_ptr
独占所指向的对象
还有一种弱引用:
week_ptr
指向shared_ptr
所管理的对象
这三种类型都定义在memory
头文件中
12.1.1 shared_ptr类
类似于vector
,智能指针也是模板(3.3节 86页)
shared_ptr<string> p1; // shared_ptr可以指向string
shared_ptr<list<int>> p2; // shared_ptr可以指向int的list
默认初始化的智能指针保存着一个空指针(2.3.2节 48页) 后面会介绍初始化智能指针的其他方法(12.1.3节 412页) 智能指针的使用方式与普通指针类似
if(p1 && p1 -> empty()){
*p1 = "hi"; // 如果p1指向一个空string 解引用p1,将一个新值赋予string
}
智能指针的操作:401页 表12.1
make_shared函数
头文件memory
中
最安全的分配和使用动态内存的方法是调用一个名为make_shared
的标准库函数:
shared_ptr<int> p3 = make_shared<int> 42;
shared_ptr<int> p4 = make_shared<string> (10, '9');
shared_ptr<int> p5 = make_shared<int> ();
通常我们使用auto来保存make_shared
的结果:
auto p6 = make_shared<vector<string>>;
share_ptr的拷贝和赋值
当进行拷贝或者赋值操作时候,每个shared_ptr
都会记录有多少个其他shared_ptr
指向相同的对象:
auto p = make_shared<int> (42); // p指向的对象只有p一个引用者
auto q(p) // p和q指向相同的对象,此对象有两个引用者
我们可以认为每个shared_ptr
都有一个关联的计数器,通常称其为引用计数
auto r = make_shared<int> 42; // r指向的int只有一个引用者
r = q; // 给r赋值,令它指向另一个地址
// 递增q指向的对象的引用
// 递减r原来指向的对象的引用技术
// r原来指向的对象已经没有引用者,会自动释放
share_ptr自动销毁所管理的对象
当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr会自动销毁此对象。
它通过一个特殊的成员函数——析构函数(desructor)完成销毁工作的。
类似与构造函数,每个类都有一个析构函数, 构造函数控制初始化做什么操作 析构函数控制销毁做什么操作
析构函数一般用来释放对象所分配的资源: 例如,string的构造函数会分配内存来保存构成string的字符串 string的析构函数就负责释放这些内存 vector的若干操作都会分配内存来保存其元素,vector的析构函数就负责销毁这些元素,并释放它们占用的内存
shared_ptr的析构函数会递减它所指向对象的引用计数 如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它占用的内存。
share_ptr还会自动释放相关联的内存
当动态对象不再被使用时,share_ptr类会自动释放动态对象,这一特性使得动态内存使用变得非常容易。
// factory返回一个shared_ptr,指向一个动态分配的对象
shared_ptr<Foo> factory(T arg){
// 恰当的处理arg
// shared_ptr负责释放内存
return make_shared<Foo> (arg);
}
// 由于factory返回一个shared_ptr,所以我们可以确保它分配的对象会在恰当的时刻被释放
void use_factory(T arg){
shared_ptr<Foo> p = factory(arg);
// 使用p
}
// p离开了作用域,它指向的内存会被自动释放掉
// 由于p是use_factory的局部变量,
// 在use_factory结束时,它将会被销毁(6.1.1节 184页)
// 当p被销毁时,将递减其引用计数,并检查它是否为0。
// 在此例中,p是唯一引用factory返回的内存的对象。
// p销毁后,p所指向的对象也会被销毁,所占用的内存会被释放。
但如果有其他shared_ptr也指向这块内存,它就不会被释放掉:
void use_facory(T arg){
shared_ptr<Foo> p = factory(arg);
return p; // 当return p时,引用计数进行了递增操作
}
// p离开了作用域,但它指向的内存不会被销毁
在此版本中,use_factory中的return语句向此函数的调用者返回一个p的拷贝。 拷贝一个shared_ptr会增加所管理对象的引用计数值。 现在当p被销毁时,它所指向的内存还有其它使用者。 对于这一块内存,shared_ptr类保证只要有任何shared_ptr对象引用它,它就不会被释放掉。
由于在最后一个shared_ptr销毁前内存都不会被释放,所以需要销毁程序不再需要的shared_ptr,否则会浪费内存。
如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不在需要的那些元素
使用了动态生存期的资源的类
程序使用动态内存出于以下三种原因:
1 程序不知道自己需要使用多少对象
2 程序不知道所需对象的具体类型
3 程序需要在多个对象间共享数据
容器类是出于第一种原因而使用动态内存的典型例子,我们将在15章看到出于第二种原因而使用动态内存的例子 本节我们将定义一个类,它使用动态内存是为了让多个内存能共享相同的底层数据。
到目前为止,我们使用过的类中,分配的资源都与对应对象生存期一致。 例如:每个vector拥有其自己的元素。当我们拷贝一个vector时,原vector和副本vector中的元素是相互分离的。
vector<string> v1; // 空vector
{ // 新作用域
vector<string> v2 = {"a" , "an", "the"};
v1 = v2; // 从v2拷贝元素到v1中
}
// v2被销毁,其中的元素也被销毁
// v1有三个元素,是原来v2中元素的拷贝
假设我们希望定义一个名为blob的类,保存一组元素。
与容器不同,我们希望Blob
对象的不同拷贝之间共享相同的元素。
即,当我们拷贝一个Blob
时候,原Blob
及其拷贝应该引用相同的底层元素。
Blob<string> b1; // 空vector
{ // 新作用域
Blob<string> b2 = {"a" , "an", "the"};
b1 = b2; // b1和b2共享相同的元素
}
// b2被销毁,但其中的元素不会被销毁,因为引用计数还不为0
// b1指向b2最初创建的元素,引用计数为1
实现Blob底层就可以使用shared_ptr
class StrBlob {
public:
// 略
private:
shared_ptr<vector<string>> data;
}
以下章节将如何具体实现
定义StrBlob类
StrBlob构造函数
元素访问成员函数
StrBlob的拷贝、赋值和销毁
12.1.2 直接管理内存
使用new动态分配和初始化对象
int *p1 = new int; // p1是一个指向动态分配的、未初始化的无名对象
// 默认情况下,动态分配的对象是默认初始化的
// 这意味着内置类型或组合类型的对象的值将是未定义的
// 而类类型对象将用默认构造函数进行初始化
string *ps = new string; // 初始化为空string
int *pi = new int; // pi指向一个未初始化的int
// 我们可以使用直接初始化的方式(3.2.1节)来初始化一个动态分配的对象
// 也可以使用列表初始化
int *pi = new int(1024); // pi指向的对象的值为1024
string *ps = new string(3, '9'); // *ps 为"999"
vector<int> *pv = new vector<int> {0,1,2,3,4,5};
// 也可以对动态分配的对象进行值初始化
string *ps1 = new string; // 默认初始化为空string
string *ps1 = new string(); // 值初始化为空string
int *pi1 = new int; // 默认初始化, *pi1的值未定义
int *pi2 = new int(); // 值初始化为0 *pi2的值为0
// 还可以使用auto来推断我们想要分配的类型
auto p1 = new auto(obj); // p指向一个与obj类型相同的对象
auto p2 = new auto{a,b,c}; // 错误:括号中只能有单个初始化器
动态分配的const对象
和任何const对象一样,动态分配的const的对象也必须进行初始化
// 分配并初始化一个const int
const int *pci = new const int(1024);
// 分配并默认初始化一个const的空string
const string *pcs = new const string;
内存耗尽
虽然现代计算机通常配备大容量内存,但是自由空间被耗尽的情况还是有可能发生。
一旦一个程序用光了它所有可用内存,new
表达式就会失败
如果new不能分配所要求的内存空间,它会抛出一个类型为bad_alloc的异常。不过我们可以改变使用new的方式来阻止它抛出异常:
//
int *p1 = new int; // 如果分配失败, new抛出std::bad_alloc
int *p2 = new (nothrow) int; // 如果分配失败, new返回一个空指针,这种为定位new
释放动态内存
为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。
我们通过delete表达式,来将动态内存归还给系统。
delete表达式接收一个指针,指向我们想要释放的对象
delete p; // p必须指向一个动态分配的对象,或者是一个空指针
和new类型类似,delete表达式也执行两个动作:
- 销毁给定的指针指向的对象
- 释放对应的内存
指针值和delete
- delete 的对象必须指向动态分配的内存,或是一个空指针(参见2.3.2 48页)
- 释放一块非new分配的内存,或者将相同的指针释放多次,其行为是未定义的
int i, *pi1 = &i, *pi2 = nullptr;
double *pd = new double(33), *pd2 =pd;
delete i; // 错误i不是一个指针
delete pi1; // 未定义,pi1指向一个局部变量
delete pd; // 正确
delete pd2; // 未定义:pd2指向的内存已经被释放了
delete pi2; // 正确:释放一个 空指针总是没有错误的
动态对象的生存期直到被释放为止
如12.1.1节所述,由shared_ptr
管理的(智能指针)指针内存在最后一个shared_ptr
销毁时会被自动释放(引用技术)。
但是直接使用(内置指针)指针来管理内存时,只有显示释放内存,才会将指针管理的动态对象(内存)释放。
返回指针动态内存的指针(而不是智能指针)的函数给其调用者增加了一个额外负担——调用者必须记得(显式地)释放内存
Foo* factory(T arg){
// 视情况处理arg
return new Foo(arg); // 调用者负责释放此内存
}
void use_factory(T arg){
Foo *p = factory(arg);
// 使用了p,但没有delete它
}
// p离开了它的作用域,变量p被销毁了,但它所指向的内存没有被释放
内置类型(int,指针,引用类型等)的变量被销毁时,什么也不会发生(不类类型一样,会进行析构的操作)。 特别是当一个内置指针离开其作用域时,它所指的对象什么也不会发生。所以如果这个内置指针指向的是动态内存(堆内存 new创建),那么内存将不会被自动释放。
由内置指针(而不是智能指针)管理的动态内存在被显式释放前一直都会存在
void use_factory(T arg){
Foo *p = factory(arg);
// 使用了p,记得delete它
delete p;
}
Foo* use_factory(T arg){
Foo *p = factory(arg);
// 其他地方需要修改p,让调用者负责释放内存
return p;
}
使用new和delete管理动态内存存在三个常见问题: 1 忘记delete内存 (内存泄露,忘记释放内存永远不可能被归还给自由空间了) 2 使用已经释放掉的对象 (通过在将内存置为空,有时可以检测出这种错误) 3 同一块内存释放两次 (当有两个指针向相同的动态内存对象时,可能发生这种错误,如果对其中一个指针进行了delete操作,对象的内存就被归还给自由空间了,如果我们随后又delete第二个指针,自由空间就可能被破坏)
相对于查找和修复这些错误来说,制造出这些错误要简单的多 坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何只能指针指向它的情况下,智能指针才会自动释放它。
delete之后重置指针值......
当我们delete
一个指针后指针指向的值就变为无效了。
虽然指针已经无效,但在很多机器上的实现是,释放了的指针仍然保存着(已经释放了的)动态内存的地址。 此时这种指针就变成了人们所说的空悬指针(daling pointer),即指向一块曾经保存数据对象但现在已经无效的内存的指针。
未初始化的指针的所有缺点空悬指针也都有。 避免产生空悬指针的方式:
- 不需要保留指针的情况:在指针销毁前(离开其作用域之前)释放掉它所关联的内存
- 如果需要保留指针,则确保在动态内存delete之后,将指针重置为nullptr
但是还是会有一些问题,接下一节
......这只是提供了有限的保护
如果有多个指针指向相同的内存,在delete
内存之后重置指针,只会对当前指针有效,其他和改指针指向同一个内存的指针都会变成空悬指针
int *p(new int(42)); // p指向动态内存
auto q = p; // p和q指向相同的内存
delete p; // p和q指向无效内存
p = nullptr; // 将p重置为nullptr
// 此时的q成了一个空悬指针
在实际的程序中,查找指向相同内存的所有指针是异常困难的
12.1.3 shared_ptr 和 new结合使用
如前所述,如果我们不初始化一个智能指针,它就会被初始化为一个空指针。 我们可以用new返回的指针来初始化智能指针:
shared_ptr<double> p1; // shared_ptr可以指向一个double
shared_ptr<double> p2(new int(42)); // p2指向一个值为42的int
接收指针参数的智能指针构造函数是explicit的(参见7.5.4节)。因此,我们不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个智能指针。
shared_ptr<int> p1 = new int(42) // 错误,必须使用直接初始化,不能使用赋值初始化
shared_ptr<int> p2(new int(42)) // 正确,使了用直接初始化形式
// p1的初始化隐式地要求编译器用一个new返回的int*来创建一个shared_ptr
// 由于我们不能进行内置指针到智能指针之间的隐式转换,因此这条初始化语句是错误的
// 同样的,一个返回shared_ptr的函数不能在其返回语句中隐式转换一个普通指针
shared_ptr<int> clone(int p){
return new int(p); // 错误,不能将内置指针隐式转换为 shared_ptr
}
// 必须将shared_ptr显式地绑定到一个想返回的指针上
shared_ptr<int> clone(int p){
// 显式地使用int*创建shared_prt<int>
return shared_prt<int>(new int (p));
}
- 默认情况下,用来初始化智能指针的内置指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象
- 我们也可以将智能指针绑定到一个指向非动态内存的指针,不过这样我们就需要定义一个相应的delete操作,将在(12.1.4节介绍)
不要混合使用普通指针和智能指针
我们推荐使用make_shared而不是new,
因为shared_ptr可以协调对象的析构,但仅限于其自身的拷贝(shared_ptr)之间
使用make_shared可以在分配对象的同时就将shared_ptr与之绑定,从而避免无意中将同一块内存绑定到多个独立创建的shared_ptr上。
void process(shared_ptr<int> ptr){
// 使用ptr
}
// ptr离开作用域,被销毁
// process的参数是按传值方式传递的,因此实参会被拷贝到ptr中
// 拷贝一个shared_ptr会递增其引用计数
// 因此在process运行中,引用计数至少为2,process执行完后,引用计数至少为1
shared_ptr<int> p(new int(42)); // 引用计数为1
process(p); // 引用计数为2
int i = *p; // 解引用p,将p所指向的值赋值给i, i=42
// 引用计数为1
// 不能向process传递一个内置指针(原因参见之前一节)
// 可以向process传递一个临时的shared_ptr,但是很容易导致错误
int *x (new int(1024));// x是一个普通指针,不是智能指针
process(x); //错误,内置指针不能隐式转换为智能指针
process(shared_ptr<int>(x));// 合法的,但是内存会被释放,执行过程中引用计数至少为1,执行完毕引用计数为0,内存被释放
// 但此时x仍然指向已经被释放的内存,从而变成一个空悬指针
// 此时使用指针x的结果是未定义的
int j = *x; // 未定义,x是一个空悬指针
当将一个shared_ptr绑定到一个内置指针时,我们就将内存管理的责任交给了这个shared_ptr,所以我们就不应该再使用这个内置指针来访问交给shared_ptr的内存。
......也不要使用get初始化另一个智能指针或为智能指针赋值
智能指针类型定义了一个get
函数,它返回一个指向智能指针指向的对象的内置指针。
此函数的作用在于:
- 需要向不能使用智能指针的代码传递一个内置指针时的情况
注意不能使用delete去释放get返回的指针,让智能指针自己去管理内存。
shared_ptr<int> p(new int(42)); // 引用计数为1
int *q = p.get(); // 正确,需要注意不能将管理的指针(q)被释放掉(delete)
{// 新程序块(作用域)
// 未定义,两个独立的shared_ptr指向相同的内存
shared_ptr<int>(q);
}
// 离开作用域,q被销毁,它所指向的内存被释放
int foo = *p; // 未定义: p指向的内存已经被释放了
在本例子中,p和q指向相同的内存。 由于它们是相互独立创建的,因此各自的引用计数都是1。 当q所在的程序块结束时,q被销毁,(该程序块中的临时智能指针引用计数变为0),导致q指向的内存被释放,从而p变成一个空悬指针。这意味着当我们试图使用p时,将发生未定义的行为。而且,当p被销毁时,这块内存会被第二次delete。
get用来将指针的访问权限传给代码。你只有在确定代码不会delete指针的情况下,才能使用get。特别是,永远不要用get初始化另一个智能指针或者为另一个智能指针赋值。
Understanding C++ std::shared_ptr when used with temporary objects:
- scope 块作用域
- temp variable 临时变量
- shared_ptr 引用计数
shared_ptr<int>(p)
shared_ptr创建临时变量
life-time of anonymous (unnamed) variables
https://www.zhihu.com/question/21515496/answer/119296924
其他shared_ptr操作
可以用reset来将一个新的指针赋给一个shared_ptr
p = new int(1024); // 错误:不能直接将一个指针赋给shared_ptr
p.reset(new int(1024)); // 正确:shared_ptr指向一个新对象
reset
可以更新引用计数,如果需要的话也可以释放p所指向的对象。
reset
经常和unique
一起使用
if(!p.unique()){
// 我们不是p的唯一的使用者:分配新的拷贝
p.reset(new string(*p));
}
*p += newVal; // 现在是p唯一的使用者了,可以改变对象的值
12.1.4 智能指针和异常
5.6.2节中介绍了使用异常处理的程序能在异常发生后令程序流程继续。 这种程序需要确保在异常发生后资源能正确地被释放。 一个简单的确保资源被释放的方法是使用之智能指针。
void f(){
shared_ptr<int> sp(new int(42)); // 分配一个新对象
// 这段代码抛出一个异常,且在f中未被捕获
}
// 在函数结束时候,shared_ptr会自动释放内存
函数退出有两种情况: 正确退出 或者 发生了异常。 无论那种情况,局部对象都会被销毁。 (所以上面例子中,sp是一个局部对象,sp被销毁引用计数归0,内存也随之正确的被释放)
而使用内置指针管理的内存则需要我们手动去释放管理的内存:
void f(){
int *ip = new int(42); // 动态分配一个新对象
// 这段代码抛出一个异常,且在f中未被捕获
delete ip; // 由于中间抛出异常,所以delete ip不会执行
// 内存无法被释放
}
智能指针和哑类
标准库中很多C++类都定义了析构函数(参见12.1.1 402页),负责清理对象使用的资源。
但是不是所有类都是这样良好的定义,特别是为C和C++两种语言设计的类,通常都要求用户显式的释放所使用的任何资源。
struct destination; // 表示我们正在连接什么
struct connection; // 使用连接所需的信息
connection connect(destination*); // 打开连接
void disconnect(connection); // 关闭给定的连接
void f(destination &d /* 其它参数 */){
// 获得一个连接,记住使用完后要关闭它
connection c = connect(&d);
// 使用连接
// 如果我们在退出前忘记调用disconnect,就无法关闭c了
}
如果connection有一个析构函数,就可以在f结束时由析构函数自动关闭连接。但是connection没有析构函数,如果不调用disconnect,就会产生内存泄露(只连接,不会关闭,connection一直在内存中)。
使用shared_ptr来保证connection正确关闭,已经被证明是一种有效的方法。
使用我们自己的释放操作
struct destination; // 表示我们正在连接什么
struct connection; // 使用连接所需的信息
connection connect(destination*); // 打开连接
void disconnect(connection); // 关闭给定的连接
void end_connection(connection *p){disconnect(*p);}
void f(destination &d /* 其它参数 */){
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
// 使用连接
// 当f退出时(即使是由于异常退出),connection会被正确关闭
}
当p被销毁时,引用计数归0,会执行自定义的释放操作,即end_connection中调用disconnect,从而确保连接被关闭。
智能指针陷阱 智能指针可以提供对动态分配的内存安全又方便的管理。 但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:
- 不使用相同的内置指针初始化(或reset)多个智能指针
- 不delete get()返回的指针
- 不使用get() 初始化或reset另一个指针
- 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了
- 如果你使用的智能指针管理的资源不是new分配的内存,记住给他传递一个删除器(如数据库连接等,参考12.1.4节 12.1.5节 415页 416页)
12.1.5 unique_ptr
一个unique_ptr”拥有”它所指向的对象。与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。 当unique_ptr被销毁时,它所指向的对象也被销毁。
unique_ptr<double> p1; // 可以指向一个double的unique_ptr
unique_ptr<double> p2(new int(42)); // p2指向一个值为42的int
// 由于一个unique_ptr拥有它所指的对象,因此unique_ptr不支持普通的拷贝或赋值操作
unique_ptr<string> p1(new string("SteveJobs"));
unique_ptr<string> p2(p1) // 错误 unique_ptr不支持拷贝
unique_ptr<string> p3;
p3 = p2; // 错误:unique_ptr不支持赋值
// 虽然不能拷贝和赋值,但是可以通过调用release或者reset将指针的所有权从一个(非const)unique_ptr转移给另一个unique:
unique_ptr<string> p2(p1.release()); // 将p1转移给p2
unique_ptr<string> p3(new string("Texas")); //
p2.reset(p3.release()); // 将p3转移给p2
// 释放掉p1,将p1转移给p2
// 释放掉p2,释放掉p3,将p3转移给p2
unique_ptr<string> u(new string("unique"));
u = nullptr; // 释放u指向的对象,将u置为空
u.release() // u放弃对指针的控制权,返回指针,将u置为空
调用release
会切断unique_ptr和它原来管理的对象间的联系。
release返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。
p2.release(); // 错误:p2切断了与指针之间的联系,但并不会释放内存,所以我们丢失了指针
auto p3 = p2.release(); // 正确, 但我们必须记得delete(p3)
传递unique_ptr参数和返回unuque_ptr
不能拷贝unique_ptr的规则有一个例外:
我们可以拷贝或赋值一个将要被销毁的unique_ptr。
最常见的例子是从函数返回一个unique_ptr:
unique_ptr<int> clone(int p){
// 正确:从int*创建一个unique_ptr<int>
return unique_ptr<int>(new int(p))
}
// 还可以返回一个局部对象的拷贝
unique_ptr<int> clone(int p){
unique_ptr<int> ret(new int(p))
// ...
return ret;
}
对于这两段代码,编译器都知道要返回的对象将要被销毁。 在此情况下,编译器执行一种特殊的“拷贝”,我们将在(13.6.2节 473页)中介绍它
向unique_ptr传递删除器
类似于shared_ptr
, unique_ptr
默认情况下用delete释放它指向的对象。
我们也可以重载一个unique_ptr
中默认的删除器(参见12.1.4节 415页)。
unique_ptr
管理删除器的方式和shared_ptr不同,其原因我们将在(16.1.6节介绍 599页)
重载一个unique_ptr
中的删除器会影响到unique_ptr
ptr类型以及如何构造(或reset)该类型的对象。
与重载关联容器的比较操作类型,我们必须在尖括号中unique_ptr
指向的类型之后提供删除器类型。在创建或reset一个这种unique_ptr
类型的对象时,必须提供一个制定类型的可调用对象(删除器)
// p指向一个类型为objT的对象,并使用一个类型为delT的对象释放objT对象
// 它会调用一个名为fcn的delT类型对象
unique_ptr<objT, delT> p (new objT, fcn);
重写连接程序,用unique_ptr
来替代shared_ptr
:
void f(destination &d /* 其他需要的参数 */){
connection c = connect(&d); // 打开连接
unique_ptr<connection, decltype(end_connection) *>
p(&c, end_connection);
// 使用连接
// 当f退出时(即使是异常退出),connection会被正常关闭
}
12.1.6 weak_ptr
weak_ptr是一种不控制所指对象生存期的智能指针,它指向一个shared_ptr管理的对象。
-
将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。
-
一旦最后一个指向shared_ptr被销毁,对象就会释放。即使有weak_ptr指向对象,对象还是会被释放。
-
当我们创建一个weak_ptr时,要用一个shared_ptr来初始化它
auto p = make_shared<int> (42);
weak_ptr<int> wp(p);
- wp和p指向相同的对象。由于是弱共享,创建wp不会改变p的引用计数。
- wp指向的对象可能被释放掉
- 由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock检查weak_ptr所指向的对象是否存在。
if(shared_ptr<int> np = wp.lock()){
// 只有当lock调用返回true时,我们才会进入if语句体
// 在if中,使用np访问共享对象是安全的
}
核查指针类
通过使用weak_ptr
来检查shared_ptr
所指的对象。
用weak_ptr
来检查StrBlobPtr
:
class StrBlobPtr{
public:
StrBlobPtr: curr(0){}
StrBlobPtr(StrBlob &a, size_t sz=0): wptr(a.data), curr(sz){}
std::string &deref const;
StrBlobPtr & incr(); // 前缀递增
private:
// 若检查成功,check返回一个指向vector的shared_ptr
shared_ptr<vector<string>> check(size_t, const string &) const;
// 保存一个weak_ptr,意味着底层的vector可能会被销毁
weak_ptr<vector<string>> wptr;
size_t curr;
}
shared_ptr<vector<string>> StrBlobPtr::check(size_t i, const string &msg) const{
auto ret = wptr.lock(); // 检查指向的vector是否存在
if(!ret)
throw std::runtime_error("unbound StrBlobPtr");
if(i >= ret -> size())
throw std::out_of_range(msg)
return res; // 无异常,返回指向vector的shared_ptr
}
指针操作
我们将在14章学习如何定义自己的运算符。现在,我们定义名为deref和incr的函数分别用来引用和递增StrBlobPtr
string & StrBlobPtr::deref() const {
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p)是对象所指的vector
}
string & StrBlobPtr::incr(){
// 如果curr已经指向容器的尾后位置,就不能递增它
check(curr, "increment past end of StrBlobPtr");
++curr; // 推进当前位置
return *this;
}
为了访问StrBlob的data成员,我们的指针类还必须声明为StrBlob的friend
友元。
我们还为StrBlob类定义begin和end操作,返回一个指向它自身的StrBlobPtr:
class StrBlobPtr; // 对于StrBlob的友元声明来说,此前置声明是必要的
class StrBlob{
friend class StrBlobPtr;
// 其成员与12.1.1节 405页中的声明相同
// 返回指向首元素和尾后元素的StrBlobPtr
StrBlobPtr begin(){ return StrBlobPtr(*this);}
StrBlobPtr end(){
auto ret = StrBlobPtr(*this, data->size());
return ret;
}
}
12.2 动态数组
new
和delete
运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。
例如vector
和string
都是在连续内存中保存它们的元素,因此当容器需要重新分配内存时(参见9.4节 317页),必须一次为很多元素分配内存
C++语言提供了两种一次分配一个对象数组的方法。
- C++定义了另一种new表达式语法,可以分配初始化一个对象数组。
标准库中包含一个名为allocator类,允许我们将分配和初始化分离。使用allocator类通常会提供更好的性能和更灵活的内存管理能力。(原因我们在12.2.2节 427页介绍)
- 使用vector或者其他标准库容器(大多数应用都没有直接访问动态数组的需求,所以使用vector或者其他标准库容器几乎总是更简单、快速和安全的(
shared_ptr<vector<int>> sptr
)。而且在13.6节 我们将会看到使用标准库容器的优势在新的C++标准中比老版本C++标准更加快速)
大多数应用应该使用标准库容器而不是动态内存分配的数组。使用容器更简单,更不容易出现内存管理错误并且有可能有更好的性能
如前所述,使用容器的类可以使用默认版本的拷贝、赋值和析构操作(参见7.1.5节 239页)。
分配动态内存数组的类则必须定义自己版本的操作,在拷贝、复制以及销毁对象时管理所关联的内存。
直到学完第十三章,不要在类内的代码中分配动态内存
12.2.1 new和数组
为了让new分配一个对象数组,我们要在类型名之后根一对方括号,在其中指明要分配的对象的数目:
// 在下例中 new分配要求数量的对象,并(假定分配成功)返回指向第一个对象的指针
int *pia = new int[get_size()]; // pia指向第一个int
// 也可以用一个类型别名来分配
typedef int arrT[42]; // arrT表示42个int的数组类型
int *p = new arrT; // 分配一个42个int的数组,p指向第一个int
分配一个数组会得到一个元素类型的指针
当我们分配一个数组时,我们得到的是一个指向数组元素类型的指针。
要记住我们所说的动态数组并不是数组类型,这很重要
初始化动态分配对象的数组
int *pia = new int[10]; // 10个未初始化的int
int *pia2 = new int[10](); // 10个值初始化为0的int
string *psa = new string[10]; // 10个空string
string *psa2 = new string[10](); // 10个空string
int *pia3 = new int[3]{1,2,3}; // 3个int分别用列表中对应的初始化器初始化
string *psa3 = new string[10]{"a","an","the",string(3,'x')}; // 10个string,前4个用给定的初始化器初始化,剩余的进行值初始化
- 如果列表数量大于分配的元素数目,则new表达式失败,不会分配任何内存
动态分配一个空数组是合法的
可以用任意表达式来确定要分配的对象的数目
size_t n = get_size(); // get_size()返回需要的元素的数目
int *p = new int[n] // 分配数组保存元素
for(int *q=p; q!=p+n; ++q){
/* 处理数组 */
}
// 虽然我们不能创建一个大小为0的静态对象,但当n等于0,调用new[n]是合法的
char arr[0]; //错误不能定义长度为0的数组
char *cp = new char[0] // 正确: 但cp不能解引用
// 当我们用new分配一个大小为0的数组时候,new返回一个合法的非空指针。
// 此指针保证与new返回的其他指针都不相同
// 对于零长度的指针,此指针就像一个尾后指针一样(参见3.5.3 106页)
// 我们可以像使用尾后指针一样使用这个指针
释放动态数组
delete p; // p指向一个动态分配的对象或为空指针
delete [] pa; // pa指向一个动态分配的数组或为空指针
- 第二条语句销毁pa指向的数组中的元素,并释放对应的内存。
- 数组按逆序销毁(从最后一个元素开始销毁)
typedef int arrT[42];
int *p = new arrT;
delere [] p;
p指向的是一个动态分配的数组,所以必须使用delere [] p
来释放。
智能指针和动态数组
标准库提供了一个可以管理new
的分配的数组的unique_ptr
版本。
为了用一个unique_ptr
管理动态数组,我们必须在对象类型后面跟一对空方括号
:
unique_ptr<int[]> up(new int[10]);
up.release(); // 自动调用delete [] 销毁其指针
- 类型说明符中
<int[]>
,指出up指向一个int数组而不是一个int。 - 所以在销毁up时候,会自动使用
delete []
指向数组的unique_ptr
提供的操作与之前(12.1.5节 417页)中使用的那些操作有一些不同:
- 不能使用点和箭头成员操作符号
.
、->
- 可以使用下标运算符访问数组的元素
for(size_t i =0; i!=10; ++i){
up[i] = i; // 为up中每个元素赋予一个新值
}
shared_ptr
不能直接管理动态分配的数组,必须制定一个自己定义的删除器:
shared_ptr<int> sp(new int[10], [](int *p){delete [] p;})
sp.reset(); // 使用我们提供的lambda来释放数组,使用delete[]
for(size_t i=0; i!=10; ++i){
*(sp.get() + i) = ; // 使用get获取一个内置指针
}
shared_ptr
未定义下标运算符,而且智能指针类型不支持算术运算。
因此为了访问数组中的元素,必须使用get获取一个内置指针,然后用它来访问数组元素。
12.2.2 allocator类
new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。 delete将对象析构和内存释放组合在了一起
而我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起。 因为这样,我们几乎肯定知道对象应该有什么值。
当分配一大块内存时,我们通常计划在这块内存上按需构造对象。 在此情况下,我们希望将内存分配和对象构造分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作。
一般情况下,将内存分配和对象构造组合在一起可能会导致不必要的浪费,如下:
string const p = new string[n]; // 构造n个空string
string s;
string *q = p; // q指向第一个string
while(cin >> s && q != p + n){
*q++ = s; // 赋予*q一个新值
}
const size_t size = q -p; // 记住我们取了多少个string
delete [] p;
// new分配了n个string。
// 但是我们用不到n个string,我们创建了一些永远也不需要的对象
// 而且对于需要用到的对象,我们赋值了两次,一次在默认初始化时,随后是在赋值时
// 更重要的是,那些没有默认构造函数的类就不能使用动态数组了
所以需要使用allocator
allocator类
、allocator分配未构造的内存
标准库allocator
定义在头文件memory
中,用于将内存分配和对象构造分离开来:
allocator<string> alloc; // 可以分配string的allocator对象
auto const p = alloc.allocate(n); // 分配n个未初始化的string
// 这个allocate调用为n个string分配了内存(注意是未初始化的、未构造的内存对象)
auto q = p; // q指向最后构造元素之后的位置,此时即指向第一个元素(因为都未构造)
alloc.construct(q++); // *q为空字符
alloc.construct(q++, 10, 'c'); // *q为ccccc
alloc.construct(q++, "hi!"); // *q为hi!
拷贝和填充未初始化的内存的算法
标准库还为allocator类定义了两个伴随算法,可以在未初始化内存中创建对象。
// 分配比vi元素中所占空间大一倍的动态内存
auto p = alloc.allocate(vi.size()*2);
// 通过拷贝vi中的元素来构造从p开始的元素
auto q = unintiallized_copy(vi.begin(), vi.end(), p);
// 将剩余元素初始化为42
uinitialized_fill_n(q,vi.size(),42);
第三部分 类设计者的工具
C++中,我们通过定义构造函数来控制在类类型的对象初始化时做什么,类还可以控制在对象拷贝、赋值、移动和销毁时做什么。
其他很少有语言有控制这些操作的能力。
13章 拷贝控制、重载运算符、继承和模板、右值引用和移动操作 14章 运算符重载 15章 继承和动态绑定 16章 函数模板和类模板
第十三章 拷贝控制
- 拷贝构造函数
- 拷贝赋值函数
- 移动构造函数
- 移动赋值函数
- 析构函数
13.1 拷贝、赋值与销毁
13.1.1 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
}
拷贝构造函数的第一个参数必须是一个引用类型,而且此参数几乎都是一个const的引用。拷贝函数在几种情况下都会被隐式地使用,因此拷贝构造函数不应该是explicit的(即不会禁用参数的隐式类型转换)
合成拷贝函数 无论有没有为一个类定义拷贝构造函数,编译器都会为我们定义一个合成(默认)拷贝函数。
class Sales_data{
public:
// 与合成的拷贝构造函数等价的声明
Sales_data(const Sales_data &);
private:
string bookno;
int unit_sold=0;
double revenue = 0.0;
}
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // 使用string的拷贝构造函数
units_sold(orig.unit_sold), // 拷贝orig.unit_sold
revenue(orig.revenue), // 拷贝orig.revenue
{} // 空函数体
拷贝初始化
string dots(10,'.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-99999-9"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
拷贝初始化不仅在我们用=
定义变量时会发生,在下列情况也会发生:
- 将一个对象作为实参,传递给一个非引用类型的实参
- 从一个返回类型为非引用类型的函数,返回一个对象
- 用花括号列表初始化一个数组中的元素或者一个聚合类(7.5.5节)中的成员
参数和返回值
- 函数调用过程中,具有非引用类型的参数要进行拷贝初始化(6.2.1节 188页)
- 函数返回值,非引用类型的返回值也会进行拷贝初始化
拷贝构造函数被用来初始化非引用类型参数,这一特性解释了拷贝构造函数自己的参数必须是引用类型。如果不是引用类型,则调用永远不会成功了(为了调用拷贝构造函数,我们必须拷贝它的实参,但是为了拷贝实参我们需要调用拷贝构造函数,先有鸡还是先有蛋的问题,循环引用)。
拷贝初始化的限制
explicit
vector<int> v1(10); // 正确,直接初始化
vector<int> v2 = 10; // 错误:接受大小参数的构造函数是explitcit的
void f(vector<int>); // f的参数进行拷贝初始化
f(10); // 错误:不能用一个explitcit的构造函数拷贝一个实参,(这里是因为vector的构造函数(参见3.3.1节)是explicit的)
f(vector<int> (10)); // 正确: 从一个int直接构造一个临时vector
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。
即,编译器允许将下面的代码的拷贝初始化编译为直接初始化:
string null_book = "999-99" // 拷贝初始化
// 编译器可以略过拷贝构造函数,编译为直接初始化
string null_book("999-99")
13.1.2 拷贝赋值运算符
类不只可以控制其对象如何初始化,也可以控制其对象如何赋值。
Sales_data trans, accum;
trans = accum; // 使用Sales_data的拷贝赋值运算符
和拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会自己合成一个(默认的)
重载赋值运算符
了解合成赋值运算符之前,我们先了解一些重载运算符的知识,(详细的在14章 操作符重载与类型转换)
重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符。 重载运算符的函数本质上也是一个函数,有返回值类型,参数列表等。
class Foo{
public:
// 拷贝赋值运算符 接受一个与其所在类相同类型的参数
Foo& operator=(const Foo&); // 赋值运算符
// 为了与内置类型的赋值保持一致(参见4.4节 129页)
// 赋值运算符通常返回一个指向其左侧运算对象的引用
// 另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用
}
合成拷贝赋值运算符(默认的拷贝赋值运算符)
如果没有定义自己的拷贝赋值运算符
,编译器会默认生成一个合成拷贝赋值运算符 synthesized copy-assignment operator
。
类似于拷贝构造函数
(某些类默认禁止拷贝构造),某些类的合成拷贝赋值运算符
是用于禁止赋值的。
Sales_data& Sales_data::operator=(const Sales_data &rhs){
bookNo = rhs.bookNo; // 调用string::oprator=
unit_sold = rhs.units_sold; // 使用内置的int赋值
revenue = rhs.revenue; // 使用内置的double赋值
return *this; // 返回一个此对象的引用
}
13.1.3 析构函数
析构函数执行与构造函数相反的操作
-
构造函数初始化对象的非static成员和一些其他操作
-
析构函数释放对象所使用的资源,并销毁对象的非static成员
-
析构函数是类的一个成员函数,名字由波浪号接类名构成。
-
它没有返回值,也不接收任何参数
-
由于析构函数不接受参数,因此它不能被重载,对于一个给定的类,只会有唯一一个析构函数。
class Foo{
public:
~Foo(); // Foo的析构函数
}
析构函数完成什么工作
构造函数有:一个初始化部分 和 一个函数体
析构函数有:一个函数体 和 一个析构部分
在构造函数中,成员的初始化是在函数体执行前完成的,且按照出现的顺序进行初始化。 在析构函数中,首先执行函数体,然后销毁成员,成员按初始化的顺序的逆序销毁
在对象最后一次使用之后,析构函数的函数体可以执行类设计者希望执行的任何收尾工作。 通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数的初始化列表的东西来控制成员如何销毁,析构部分是隐式的。 成员销毁时发生什么完全依赖于成员的类型。 销毁类类型的成员需要执行该成员自己的析构函数。 内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员,不会delete它所指向的对象
与普通指针不同,智能指针(参见12.1.1节 402页)是类类型,具有析构函数。 与普通指针不同,智能指针成员在析构阶段自动销毁。
什么时候会调用析构函数
无论何时一个对象被销毁时,就会自定调用其析构函数:
- 变量在离开其作用域时被销毁
- 当一个对象(实例)被销毁时,其成员被销毁
- 容器(无论是标准容器还是数组)被销毁时,其元素被销毁
- 对于动态分配(堆内存)的变量,只有对指向其的指针进行delete运算符操作时,才会被销毁
- 对于临时变量,当创建它的完整表达式结束时被销毁
{ // 新作用域
// p和p2指向动态内存(堆内存,动态分配)
Sales_data *p = new Sales_data; // p是一个内置指针
auto p2 = make_shared<Sales_data>(); // p2是一个Shared ptr
Sales_data item(*p); // 拷贝构造函数将*p拷贝到item中
vector<Sales_data> vec; // 局部对象
vec.push_back(*p2); // 拷贝p2指向的对象
delete p; // 对p指向的对象执行析构函数
}
// 退出作用域; item、p2、和vec进行析构
// 销毁p2会递减其引用计数,如果引用计数变为0,对象被释放
// 销毁vec会销毁它的元素(vec中的元素p2销毁时候,引用计数为0,对象被释放)
当指向一个对象的引用或者(普通)指针离开作用域的时,析构函数不会执行
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个默认的合成析构函数
类似于拷贝构造函数和拷贝赋值运算符,某些类的合成析构函数
用于阻止该类型对象实例的销毁(参见13.1.6 450页)。
如果不是这种(阻止的)情况,合成析构函数的函数体就为空。
class Sales_data{
public:
// 成员会自动销毁,除此之外不需要做任何事情
~Sales_data();
}
在(空)析构函数体执行完成后,成员会被自动销毁。(对于Sales_data的bookNo成员,string的析构函数会被调用,它将释放bookNo所用的内存)
- 认识到析构函数体自身是不销毁成员是很重要的。
- 成员是在析构函数体执行完成后隐式析构的。
- 析构函数体是作为成员销毁步骤之前的另一部分进行的
13.1.4 三/五法则
如前所述,有三个基本操作可以控制类的拷贝操作: 拷贝构造函数,拷贝赋值运算符和析构函数。
新标准中,一个类还有移动构造函数
和移动赋值运算符
,将在(13.6节 470页)介绍
C++中不需要定义所有的操作,某些可以由编译器默认合成,可以根据需要定义这些操作。 但是这些操作应该被看做一个整体。通常,只需要其中一个操作,而不需要定义所有的操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制
成员时,一个基本的原则是首先确定这个类是否需要一个析构函数
如果一个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s), i(0)){}
~HasPtr() { delete ps;}
// 错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
}
在这个版本的类定义中,构造函数中分配的内存将在HasPtr对象销毁时候被释放。
但不幸的是,我们引入了一个严重的错误!这个版本的类使用了合成的拷贝构造函数和拷贝赋值运算符。
这些函数简单拷贝指针成员,这意味着多个HasPtr对象可能指向相同的内存
需要拷贝操作的类也需要赋值操作
13.1.5 使用=default
将拷贝控制成员定义为=default
,来显式要求编译器生成合成版本
class Sales_data{
public:
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data & operator = (const Sales_data&);
~Sales_data() = default;
}
Sales_data& Sales_data::operator = (const Sales_data&) = default;
我们只能对具有合成版本的成员函数使用
=default
(即,默认构造函数或拷贝控制成员)
13.1.6 阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式的还是显式的
定义删除的函数
析构函数不能是删除的成员
第十五章 面向对象程序设计
面向对象程序设计基于三个基本概念: 数据抽象、继承和动态绑定。
继承和动态绑定对程序的编写有两方面的影响:一是我们可以更容易地定义与其他类相似但不完全相同的新类。二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略掉它们的区别
例如,书店中不同书籍的定价策略可能不同:有的按原价销售,有的则打折销售,有时我们给那些购买书籍超过一定数量的顾客打折,有时则只对前多少本销售的书籍打折之后就调回原价。面向对象程序设计(OOP)适合这类应用
15.1 OOP概述
核心思想:数据抽象、继承和动态绑定
数据抽象使类的接口和实现分离(见第七章)
使用继承可以定义相似的类型并对其相似关系建模 使用动态绑定可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的实例对象。
继承
通过继承联系在一起的类构成一种层次关系,通常在层次关系的根部有个基类,其他直接或者间接从基类继承而来的类称为派生类
在C++语言中,基类将类型相关的函数
与派生类不做改变直接继承的函数
区分对待。
基类希望它的派生类
各自定义适合自身的版本,此时基类就将这些函数声明成虚函数
(virtual function)
class Quate{
public:
std::string is_bn() const;
virtual double net_price(std::size_t n) const;
};
派生类(子类)必须通过使用类派生列表
(class derivation list)明确指出它是从哪个(哪些)基类继承而来的
class Bulk_quote : public Quote { // Bulk_quote继承了Quote
double net_price(std::size_t) const override;
};
- Bulk_quote在派生类列表中使用public关键字,因此我们可以完全把Bulk_quote的实例对象当做Quote的对象来使用
- 派生类必须在其内部对所有重新定义的虚函数进行声明
- 在这样的函数之前加上
virtual关键字
- 派生类在该函数的形参列表之后增加一个关键字
override
,来显式的注明它将使用哪个成员函数改写基类的虚函数
- 在这样的函数之前加上
动态绑定
有称为 运行时绑定
通过使用动态绑定,我们能用同一段代码分别处理Quote和Bulk_quote的对象
15.2 定义基类和派生类
15.2.1 定义基类
派生类可以集成其基类的成员,然而当遇到一些情况时,派生类必须对基类的成员进行重新定义。即:派生类提供自己的新定义覆盖基类的旧定义。
C++ 中,基类必须将它的两种成员函数区分开来:
- 基类希望其派生类进行覆盖的函数 通常定义为虚函数
- 基类希望派生类直接继承而不要改变的函数
当我们使用指针或引用调用虚函数时,改调用将被动态绑定。 根据引用或指针所绑定的对象类型不同,改调用可能执行基类的棒棒,也可能执行某个派生类的版本。
任何构造函数之外的非静态函数都可以使虚函数,在声明钱加上关键字 virtual 使得改函数执行动态绑定。
成员函数如果没有被声明为虚函数,则其解析过程发生在编译时而非运行时。
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。
- 派生类可以访问基类公有成员,不能访问基类私有成员
- 受保护的
protected
访问运算符来申明 派生类可以访问这个成员,同时禁止其他用户访问(不继承他就不能访问他的成员)
Q: 什么是虚成员? A: 基类希望派生类重新定义的成员,使用 virtual 关键字声明。
Q: protected vs private 两个的区别? A: private 只能自己访问,实例和其派生类都不可访问。 protected 只能自己和派生类访问,实例和非派生类都不可访问。
Q: 定义你的 Quote 类 和 print_total 函数 A: TODO
15.2.2 定义派生类
派生类列表:一个冒号,后面紧跟以逗号分隔的基类列表。 其中每个基类前面都可以有一下三种访问说明符的一个 public protected 或 private
访问控制符的作用是控制派生类从基类继承而来的成员是否对派生类的用户(实例)可见。
如果一个派生是共有的,则基类的共有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上。
大多数类都只继承自一个类,这种形式的继承被称作“单继承”。 派生列表的多个基类的情况为,多继承。
派生类中的虚函数。 如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类到基类的类型转换
派生类的构造函数
继承和静态成员
第十六章 模板与泛型编程
面向对象和泛型编程都能处理在编写程序时不知道类型的情况。
不同之处在于:
- OOP在程序运行之前都未知类型
- 泛型编程,在编译时获知类型
模板是泛型编程的基础