C++Primer 通关
- 工程代码链接,求小星星,谢谢 ⭐
- 第一关:C++Primer 的了解
- 第二关:基本内置类型与变量
- 第三关:字符串、向量和数组
- 第四关:表达式
- 4.1 基础
- 4.3 逻辑与关系运算符
- 4.4 赋值运算符
- 4.5 递增和递减运算符
- 4.6 成员访问运算符
- 4.7 条件运算符
- 4.8 位运算符
- 4.9 sizeof 运算符
- 4.10 逗号运算符
- 4.11 类型转换
- 4.12 运算符优先级表
- 第五关:语句
- 第六关: 函数
- 第七关:类
- 第八关:IO库
- 第九关:顺序容器
- 第十关:泛型算法
- 第十一关:关联容器
- 第十二关: 动态内存
- 12.1 动态内存与智能指针
- 12.1.1 shared_ptr 类
- 12.1.2 直接管理内存
- 12.1.3 shared_ptr 与 new结合使用
- 12.1.4 智能指针与异常
- 12.1.5 unique_ptr
- 12.1.6 weak_ptr
- 12.2 动态数组
- 12.3 使用标准库 :文本查询系统
- 第十三关: 拷贝控制
- 13.1 拷贝,赋值与销毁
- 第十四关: 重载运算与类型转换
- 14.1 基本概念
- 14.2 输入输出运算符
- 14.3 算术和关系运算符
- 14.4 赋值运算符
- 14.5 下标运算符
- 14.6 递减与递增运算符
- 14.7 成员访问运算符
- 14.8 函数调用运算符
- 14.9 重载,类型转换与运算符
- 第十五关 . 面向对象程序设计
- 15.1 OOP:概述
- 15.2 定义基类和派生类
- 15.3 虚函数
- 15.4 抽象基类
- 15.5 访问控制与继承
- 15.6 继承中的 类作用域
- 15.7 构造函数与拷贝控制
- 15.8 容器与继承
- 15.9 文本查询程序再谈
- 第 十六关: 模板与泛型编程
- 16.1 定义模板
- 16.2 模板实参推断
- 16.2.1 类型转换与模板类型参数
- 16.2.2 函数模板显式实参
- 16.2.3 尾置返回类型于类型转换
- 16.2.4 函数指针和实参判断
- 16.2.5 模板实参推断于引用
- 16.2.6 理解 std::move
- 16.2.7 转发
- 16.3 重载与模板
- 16.4 可变参数模板
- 16.5 模板特例化
- 第 十七 关: 标准库特殊设施
- 第十八关: 用于大型程序的工具
- 第十九关: 特殊工具与技术
工程代码链接,求小星星,谢谢 ⭐
GitHub 仓库,求小星星,谢谢 ⭐
第一关:C++Primer 的了解
C++Primer是基于 C++11标准进行编写的书籍,以 3 位作者 Standley B. Lippman,Josee Lajoie ,Barbara E.Moo在C++语言发展历程中的经历,这本书的权威性自不容置疑的:既有编译器的开发和实践,又参与 C++标准的制定,再加上丰富的 C++ 教学经历。该书是一本由浅入深的教程,同时考虑到该书的全面性,我们也可以当其为教材,以备随时查阅。
第二关:基本内置类型与变量
2.1 基本内置类型
如何选择内置类型:
- 明确数值不可能为负时,选用无符号类型。
- 使用int (16位)进行整数运算,超出就使用 long long int(64位)
- 在算数表达式中不要使用 char 或者 bool
- 执行浮点数运算使用 double,float通常精度不够,双精度有时比单精度更快。
类型转换注意点:
- 赋予无符号类型一个超出它表示范围的数值,结果是初始值对无符号类型表示数值总数取模后的余数。
- 赋予带符号类型一个超出它表示范围的值时,结果是未定义的,此时程序可能继续工作,可能崩溃,也可能产生垃圾。
- 无符号与int 值进行运算时,int值会转换为无符号数,把int 转化为无符号的过程 与 把int直接赋给无符号变量一样。
2147483647 INT_MAX
-2147483648 INT_MIN
指定字面值的类型
当使用一个长整型字面值时,请使用大写字母 L 来标记,因为小写字母 1 和数字 1 太容易混淆。
例如: 42LL.
2.2 变量
谨言慎行:
初始化不是赋值,初始化的意思是指在创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而是以一个新值来替代。
列表初始化 11新标准
无论是初始化对象还是某些时候为对象赋予新值,都可以使用这一组花括号括起来的初始值。
例如:int num_val = {0};
重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错。
int a = {3.14 L}
默认初始化
- 定义于任何函数体之外的变量被初始化为 0 ,定义在函数体内部的内置类型变量将不被初始化,
一个未被初始化的内置类型变量的值是未定义的,试图拷贝或者访问将发生错误。 - 类的对象如果没有显式地初始化,则其值由类确定。
建议:初始化每一个内置类型的变量,虽然并非必须这么做,但是如果我们不能确保初始化后程序安全,那么这么做不失为一种简单可靠的方法。
标识符
- 必须以字母或者下划线开头。
- 用户自定义的标识符中不能连续出现两个下划线,也不能以下划线连接大写字母开头,此外,定义在函数体外的标识符不能以下划线开头
//变量要用描述性名称,不要节约空间,让别人理解你的代码更重要
const int kDaysInWeek = 7; //const 变量为k开头,后跟大写开头单词
int num_entries_; //变量命名:全小写,有意义的单词和下划线,类成员变量下划线结尾
int num_complated_connections_;
名字的作用域
- 定义在花括号外的名字拥有全局作用域,定义在花括号内的名字拥有块作用域。
建议:当你第一次使用变量时再定义它
一般来说,在对象第一次使用的地方附近定义他是一种好的选择,因为这样做有助于更容易地找到变量的定义。更重要的是,当变量的定义于它第一次被使用的地方很近时,我们也会赋予其一个比较合理的初始值。
- 内部作用域变量会覆盖掉外部作用域的同名变量(就近原则),如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量。
2.3 复合类型
复合类型(compound type)是基于其他类型定义的类型。 例如(指针与引用)。
2.3.1 引用
( rvalue reference ) 右值引用是 C++11新标准新增加的内容
当我们使用术语 “reference” 指的都是 “左值引用”( lvalue reference ),绑定另一种类型的符合类型。
- 定义引用时,程序会把引用与它的初始值绑定在一起,而不是将值拷贝。
- 引用必须被初始化,且无法令引用重新绑定到另外一个对象。
- 引用并非对象,相反的,它只是为一个已经存在的对象所起的另一个名字。
- 所有引用的类型都要和与之绑定的对象严格匹配,而且,引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果保存在一起(左值绑定)。
2.3.2 指针
指针是指向另一种类型的符合类型。与引用类似,指针也实现了对其他对象的间接访问。
指针与引用的不同之处:
- 指针本身就是对象,允许对指针进行赋值与拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。
- 指着无须在定义时赋与初始值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也拥有一个不确定的值。
- 与引用类似,除特殊情况外(后面会提到)指针的类型都要与它所指向的对象严格匹配。
指针值(即地址)应属于下列4中状态之一:
- 指向一个对象
- 指向紧邻对象所占空间的下一个位置
- 空指针,意味着指针没有指向任何对象
- 无效指针,也就是上述情况之外的其他值。
利用指针访问对象:
- 使用解引用符(操作符*)来访问
- 给解引用的结果赋值,实际上也就是给指针所指的对象赋值。
空指针:
得到空指针的方式:使用 字面值 nullptr (C++11新标准)初始化指针,nullptr是一种特殊类型的字面值,它可以被转化为任意其他的指针类型。
建议:初始化所有指针
使用未经初始化的指针是引发运行时错误的一大原因。
- 因此建议初始化所有的指针,并且尽量等定义了对象之后再定义指向它的指针。
- 如果实在不清楚指针应该指向何处,就应该把它初始化为 nullptr 或者 0,这样程序就能检测并指导它没有指向任何具体的对象了。
任何非 0 指针对应的条件值都是 true
void* 指针:
void*指针 是一种特殊的指针类型,可用于存放任意对象的地址,与其他指针不同的是,我们对该指针中到底是一个什么类型的对象并不了解。
复合类型的声明 :
int* p,p2; 其中 p是指针类型,p2是int类型,始终数据类型是 int,(* 或 &)是类型修饰符,并不是数据类型的一部分。
指向指针的指针 :
- 当有多个修饰符连写在一起时,按照其逻辑关系详加解释即可。
- 通过 * 的个数可以区别指针的级别,**是指向指针的指针,***表示指向指针的指针的指针。
- 解引用的规则与 指针级别符合
指向指针的引用 :
- 引用本身不是对象,因此不能定义指向引用的指针,但指针是对象,所以存在指针的引用。
指针引用声明: int *& i = p;
- 理解其变量的类型到底是什么,采用从右向左阅读 变量的定义,离变量名最近的符号对变量的类型有最直接的影响。
2.4 const 限定符
存在目的是为了防止程序不小心改变其值。
初始化和 const 介绍:
- const类型的对象上执行不改变其内容的操作,因此const 对象必须被初始化。
- 利用const 对象去初始化其他对象是无须担忧的,因为其中是利用了其值,而非对象本身
默认状态下,const 对象仅在文件内有效
当以编译时初始化的方式定义一个 const 对象时,编译器将在编译过程中把用到该变量的地方都替换成对应的值。
如果想在多个文件中共享该 const对象,必须在变量的定义之前添加 extern 关键字。
const 的引用
可以把引用绑定到const对象上,这样的引用我们称之为对常量的引用(非常量引用)。
- 与普通引用的区别就是,对常量的引用不能被用作修改它绑定的对象。
初始化对const 的引用
一般来说,引用的类型必须与其所引用对象的类型一致,但是有两种例外:
第一种例外就是:初始化常量的引用时允许用任意表达式作为初始值,只要该表达式的结果能转换引用的类型即可。
例如:可以使常量的引用绑定到 非常量的对象,字面值,表达式。
理解例外发生的原因:
double d_val = 3.14;
const int &ri = d_val;
此时为了确保 ri 绑定一个整数,编译器对其进行了如下操作:
const int temp = d_val;
const int &ri = temp;
ri 绑定了一个 临时量对象,来使其表达的结果可以进行转换为引用的类型,
但我们使用引用就是为了改变其对象的值,这时我们改变的是临时量,这种行为是非法行为。
对 const 的引用可能引用一个并非const 的对象
常量引用仅对引用可参与的操作做了限定,对于引用的对象的本身是不是一个常量未作限定,因为对象也可能是一个非常量,所以允许通过其他途径改变它的值。
指针和 const
- 指向常量的指针(可指向常量或非常量)不能用于改变其所指对象的值,要想存放常量对象的地址,只能使用指向常量的指针。
试试这样想:所谓指向常量的指针或引用,不过是指针或引用“自以为是”罢了,它们觉得自己指向了常量,所以自觉地不去改变所指对象的值。
const 指针
由于指针本身就是对象,也允许把指针本身定位常量。
- 常量指针必须被初始化,一旦初始化完成,(存放在指针中的那个地址)就不能被改变了。
- 书写: int *const cur_err = &err_numb; // const指针将一直指向 err_numb
- 常量指针并不意味着不能通过指针来修改其所指向对象的值,能否这样做完全依赖与所指对象的类型,只是自己不能改变自己的指向而已。
顶层 const
指针本身是不是常量以及指针所指的是不是一个常量就是两个互相独立的问题。
顶层const(top - level const)表示指针本身是一个常量
底层const (low-level const)表示指针所指的对象是一个常量。
当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换,一般来说,非常量能转换为常量,反之不行。
constexpr 和 常量表达式
常量表达式是指值不会改变并且在编译过程中就能得到计算结果的表达式。
- 字面值属于常量表达式,用常量初始化的const对象也是常量表达式。
- 直到程序运行才能获取到的的具体值不是常量表达式。
constexpr 变量
目的:为了解决一个初始值是不是常量表达式,因为在复杂系统中,很难分辨。
C++11新标准规定,允许将变量声明为 constexpr类型以便由编译器来验证变量的值是否是一个常量表达式,声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。
建议:一般来说,如果你认定变量是一个常量表达式,那就把它声明成 constexpr类型。
指针与constexpr
在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关。
constexptr会把它所定义的对象 设置为顶层 const
2.5 处理类型
程序越来越复杂,程序中用到的类型也越来越复杂,
- 类型难以“拼写”
- 搞不清需要的类型到底是什么
类型别名
类型别名是为了让复杂的类型名字变得清晰明了,利于理解与使用。
-
使用 typedef‘
typedef double wages; // wages是double的同义词
typedef wages base,p ; //base 是double的同义词,p 是 doule的同义词 -
新标准规定的新的方法,使用别名声明(alias declaration)来定义类型的别名:
using SI = SalesItem; // SI 是 SalesItem的同义词
关键字 using 作为别名声明的开始,紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。
指针,常量与类型别名
类型别名指代的是复合类型或常量,那么他的基本数据类型是指针。
typedef char *p_string; //p_string是 是数据类型(指针)
const p_string cstr 与 const char *cstr 是不同的
前者的数据类型是指针,因此前者的p_string是常量指针,后者的数据类型是 const char,*成为了声明符的一部分,因此后者的p_string是指向 常量字对象 的指针。
atuo 类型说明符
为了解决在声明变量的时候准确地知道变量的类型不那么容易,c++11新标准引入了 auto 类型说明符。
- auto 定义的变量必须有初始值,因为它需要靠初始值推断变量的类型。
复合类型,常量与 atuo之间的关系
- auto 一般会忽略掉顶层 const,同时底层 const会被保留下来。
const int ci = i;
auto e = &ci; //e是一个指向常量的整数指针
- 在一条语句中利用 定义多个变量时,符号& 和 * 只从属于某个声明符,而非基本数据类型的一部分,因此多个变量的初始值应该是同一中类型。
decltype 类型指示符
从表达式的类型来推断要定义的变量的类型,而不是用值来推断,使用 C++新标准引入的第二类型说明符 decltype。
- decltype处理顶层const 和引用的方式,与 auto方式不同,decltype()使用的如果是表达式或者变量,则decltype() 返回该变量的类型 (包括顶层const 和引用都在内)。
decltype 和 引用
- 当有些表达式将向 decltype 返回一个引用类型,意味着该表达式的结果对象能作为一条赋值语句的左值。
- 如果表达式的内容是解引用操作,则decltype得到引用类型。
- decltype ((variable)) 的结果永远是引用,因为编译器会把加了一层或多层括号的变量当作一个表达式,变量是一种可以作为赋值语句左值的特殊表达式,所以这样的 decltype 就会得到引用类型,
而decltype(variable)结果只有当 variable 本身就是一个引用时才是引用。
2.6 自定义数据结构
注意点:
- 类定义的结尾最后加上分号
- 最好不要把对象的定义和类的定义放在一起,对实体的定义混淆为一条语句中,这时不被推荐的。
类数据成员
- 我们的类只有数据成员,类的数据成员定义了类的对象的具体内容,每个对象都各自有自己的一份数据成员拷贝。
- C++11新标准,可以为数据成员提供一个类内初始值,创建对象时,类内初始值进行初始化该成员,没有初始值的成员将被默认初始化。
预处理概述:
由于头文件在程序中多次引用会造成 源文件重新编译获取更新过的声明,这时十分不安全以及不正常的。
确保头文件多次包含仍能安全工作的常用技术是 预处理器。
在C++中,我们用到的一项预处理功能是头文件保护符,头文件保护符依赖与预处理变量。
- 预处理变量有两种状态:已定义与未定义。
- #define指令把一个名字设定为预处理变量
另外两个指令则分别检查某个指定的预处理变量是否已经定义:
- #ifdef 当且仅当变量已经定义时为真,#ifndef 当且仅当变量未定义为真时为真。
一旦检查结果为真,则执行后续操作直至遇见 #endif 指令为止。
头文件保护符原理详细解释:
第一次包含 以下头文件时,#ifndef的检查结果为真,预处理器将顺序执行后面的操作直到遇见 #endif 为止,此时,预处理变量的 CPPPRIMER_SALEDATA_H_ 已经是已定义,如果再一次包含的话 #ifndef 的结果就为假,会忽略掉 #ifndef 到 #endif之间的部分。
#pragma once
//Copyright 2020 Handling
//License (BSD /GPL...)
//Author : Handling
//This is C++Primer
#ifndef CPPPRIMER_SALEDATA_H_
#define CPPPRIMER_SALEDATA_H_
#include <string>
#include <iostream>
/* 每一个限定符内,声明顺序如下
1.typedef 和 enums
2.常量
3.构造函数
4.析构函数
5.成员函数,含静态数据成员
6.成员变量,含静态成员变量
*/
struct SaleData {
std::string book_no_;
unsigned units_sold_;
double revenue_ = 0.0;
};
#endif // CPPPRIMER_SALEDATA_H
习惯地加上头文件保护符是一个明智的决定。
第三关:字符串、向量和数组
3.1 命名空间的using声明
访问库中名字的简单方法,使用作用域运算符 (:: )
std :: cin
另外一种安全的方法:使用 using 声明(using declaration)
使用 using namespace::name 之后就无须专门的前缀,也能使用所需的名字。
每个名字都需要独立的using声明
- 每个using声明引入命名空间中的一个成员,例如:可以把用到标准库的名字都以 using 声明的形式表现出来。
头文件不应该包含 using 声明
头文件的内容会拷贝到所有引用它的文件里去,如果头文件中某个using声明,那么每个使用了该头文件的文件就都会有这个声明,也许会造成始料未及的名字冲突。
3.2 标准库类型 string
直接初始化与拷贝初始化
- 如果使用 等号(=)初始化一个变量,实际上执行的是拷贝初始化,编译器把等号右侧的初始值拷贝到新创建的对象中去,与之相反,如果不使用等号,执行的是直接初始化。
读写 string 对象
在执行读取操作时,string 对象会自动忽略开头的空白(空格符,换行符,制表符)并从一个真正的字符开始读起,直到遇见下一处空白为止。
使用getline 读取一整行
getline 函数的参数是一个输入流和一个string对象,函数从给定的流中读入内容,直到遇见换行符为止,
(换行符也被读入),之后把所读的内容存入string对象中(不连换行符)。
string 的 empty 和size操作
empty 函数根据 string 对象是否返回空返回一个对应的布尔值。
size 函数返回 string 的长度,可以作为限制 string 对象的输出条件。
比较string 对象
- string 对象相等意味着他们的长度与字母全部相同
小于,大于的规则如下:
- 如果两个 string 对象的长度不同,而且较短 string 对象的每个字符都与较长 string 对象对应位置上的字符相同,就说较短 string 对象小于 较长 string 对象。
- 如果两个 string 对象在某些对应的位置上不一致,则string对象比较的结果其实是 string对象中第一对 相异字符比较的结果。
字面值 和 string 对象相加
-
因为标准库允许 字符字面值 和字符串字面值转换为 string 对象,所以在需要 string 对象的地方就可以使用这两种字面值来代替。
-
当string对象和字符字面值或者字符串字面值混在一条语句中使用时,必须确保 每个加法运算符的两侧的对象至少有一个是 string。
-
为了与 C兼容,C++语言中的字符串字面值并不是标准库类型的 string 对象,字符串字面值与 string是不同的类型。
处理 string 中的字符
cctype 头文件中定义了一组标准库函数处理这部分工作
isalnum(c) 当 c 是字母或数字时为真
isalpha(c) 当 c 是数字时为真
iscntrl(c) 当 c 是控制字符时为真
isdigit(c) 当 c 是数字时为真
isgraph(c) 当 c 不是空格但可以打印时为真
islower(c) 当 c 是小写字母时为真
isprint(c) 当 c 是可打印字符时为真 (即 c 是空格 或 c具有可视形式)
ispunct(c) 当 c 是标点符号时为真(不是控制字符,数字,字母,可打印空格)
isspace(c) 当 c 是空白时为真(c是空格,横向制表符,纵向制表符,回车符,换行符,进纸符)
isupper(c) 当 c 是大写字母时为真
isxdigit(c) 当 c 是十六进制数字时为真
tolower(c) 将大写字母变为小写
toupper(c) 将小写字母变大写
建议:使用 c++ 版本的 c 标准库文件
因为 c++版本的头文件中定义的名字从属于 命名空间 std,但是 c不是,所以尽量全部使用
c开头的头文件,而不是选择使用.h结尾的头文件。
处理每个字符?使用基于范围的 for 语句
C++ 11 新标准提供的 :范围 for语句,能遍历其序列的每一个元素,对值进行某种操作。
for (declaration : expression)
statement
例子:
for (auto c : str)
cout << c <<endl;
使用范围 for 语句改变字符串中的字符
- 如果想要改变 string 对象中 字符的值,必须把血循环变量定义成引用类型从。
使用下标 执行随机访问
使用下标时,必须检测其合法性,如果 索引或者下标越界 将会产生错误。
3.3 标准库类型 vector
vector 是模板而非类型,由vector 生成的类型必须包含 vector 中元素的类型,如 vector。
3.31列表初始化 vector 对象
C++11 新标准提供了为 vector 对象赋予初始值的方法,列表初始化。
c++ 语言提供了几种不同的初始化方法,在大多数情况下这些初始化方式能相互等价的使用,不过也并非一直如此。
- 使用拷贝初始化时,(即使用 = 时),只能提供一个初始值。
- 如果提供的是一个类内初始值,,则只能使用拷贝初始化或者花括号的形式初始化。
- 如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在 圆括号里。
创建指定数量的元素
可以用 vector 对象容纳的元素数量 和 所有元素的统一初始值来初始化 vector 对象。
vector num_vec(10,-1);
值初始化
如果只提供 vector 对象容纳的元素数量而忽略其初始值,库会创建值初始化的元素初值,这个初值由 vector对象中元素的类型决定。
对这种初始化的方式有两个特殊限制:
- 有些类要求必须明确地提供初始值,如果 vector 对象中元素的类型不支持默认初始化,我们就必须提供初始的元素值,对于这种类型的对象来说,只提供元素的数量不提供初始值就无法完成初始化工作。
- 如果只提供了元素的数量而没有设定初始值,只能使用直接初始化: 以()的方式。
列表初始值还是元素数量?
一方面情况:初始化的真实含义依赖于传递初始值时用的是花括号还是圆括号。
- 如果用的是圆括号,可以说提供的值是用来构造 vector 对象的
- 如果用的是花括号,可以表述为我们想列表初始化该 vector 对象的。
另一方面:如果初始化时使用了 花括号的形式但是提供的值不能来列表初始化,我们需要考虑用这样的值来构造 vector 对象了。
3.3.2 向 vector对象中添加元素
通过列表初始化的方式仅仅能对少量元素进行罗列,但是数量级的元素数量就不合理了,我们可以使用
push_back 向其中添加元素。
关键概念: vector 对象能高效增长
c++标准要求 vector 在运行时能快速地添加元素,因此在定义 vector 对象的时候设定其大小可能会导致性能更差,除了初始化的元素的值全部一样,建议设定空 vector 对象,运行时向其动态添加。
3.3.3 其他 vector 操作
vector<int> v;
v.empty() 判空
v.size() 返回v中元素的个数
v.push_back(elem) 添加元素
v[n] 索引第 n 个位置上的引用。
v1 = v2 用v2中元素的拷贝替换 v1中的元素
v1 = {a,b,c...} 用列表中元素的拷贝替换 v1 的元素
v1 == v2 v1 和 v2 相等当且仅当他们的元素的数量相等且对应位置的元素值都相同。
<,<=,>,>= 按照字典序比较
当元素的定义了自己的相等性运算符与关系运算符,vector对象 才能支持相等性判断与关系运算等操作。
不能用下标形式添加元素
vector<>对象 以及string 对象的下标 运算符可用于访问已存在的元素,而不能用于添加元素。
提升:只能对确知已存在的元素执行下标操作,如果对不存在的元素去访问将引发错误,(buffer overflow)
确保下标合法的一种有效手段就是尽可能使用 范围for语句。
3.4 迭代器介绍
并不是所有的容器都支持 下标运算,但是所有的容器都支持另一种间接访问元素的机制,迭代器。
3.4.1使用迭代器
- 与指针不同的是,获取迭代器不是使用取地址符,而是有迭代器的类型同时有着返回迭代器的成员。
- begin(开头迭代器) 与 end(尾后迭代器,指向容器本不存在的 ”尾后“元素),特殊情况下容器为空,begin 与 end 返回的是同一个迭代器,都是尾后迭代器。
迭代器运算符
*iter 返回迭代器 iter 所指元素的引用
iter->mem 解引用iter 并获取该元素的名为 mem 的成员,等价于 (*iter).mem
++iter 令 iter 指示容器中的下一个元素。
--iter 令 iter 指示容器的上一个元素
iter1 == iter2 判断两个迭代器是否相等(不相等),如果两个迭代器指示的是同一个元素或者他们是同一个容器的iter1 != iter2 尾后迭代器,则相等,否则不相等
解引用一个非法迭代器或者尾后迭代器都是未被定义的行为。
将迭代器从一个元素移动到另外一个元素
- 迭代器可使用递增 ++ 或递减 --运算符来进行从一个元素移动到另一个元素
- 不能对 end 迭代器进行解引用或者递增操作。
泛型编程的概念:
由于并非所有的标准库容器都定义了下标运算或者是 迭代器的操作符(<,>),因此我们要养成使用迭代器和 !=,这样就比较有通用性,不太在意用的是那种数据类型。
迭代器类型:
一般情况下,我们的迭代器类型有 iterator 与 const_iterator。
术语:迭代器类型与迭代器是不同的,一个指数据类型,一个指的是迭代器对象。
begin 与 end
- 如果对象只需要读操作而无需写操作的话最好使用常量类型迭代器(const_iterator)
为了专门得到常量迭代器类型的返回值,C++ 11 定义了两个新函数,分别是 cbegin() 和 cend();
结合解引用和成员访问的操作
为了简化 使用解引用符与下标点符获取该指向对象的元素(*iter).elem,C++定义了 箭头运算符(->)
iter ->elem 来将其操作结合在一起。
某些对vector对象的操作会使 迭代器失效
谨记:但凡是使用了 迭代器的循环体,都不要向迭代器所属的容器添加元素。
3.4.2 迭代器运算
所有的标准库容器都有支持递增运算的迭代器,类似的,也用 == 与 !=对任意的标准库容器进行比较操作。
string 和 vector 提供了额外的运算符(迭代器运算)
iter + n 迭代器加上整数仍得一个迭代器,向前移动 n 个元素
iter - n 迭代器减去整数得到一个迭代器,向后移动 n 个元素
iter += n 迭代器加法的复合赋值语句
iter -= n 迭代器减法的复合赋值语句
iter1 - iter2 迭代器相减的结果是他们之间的举例,参与运算的必须是同一个容器中的元素的迭代器,或者是尾元素的下一位置。
> ,>= ,< ,<= 迭代器的关系运算符,位置在前的迭代器小于位置在后的迭代器
3.5 数组
如果不清楚元素的确切个数,请使用 vector
3.5.1 定义与初始化内置数组
- 数组是一种复合类型,声明为: 数据类型 数组名+维度(必须大于0)
- 编译时数组的维度必须是已知的,维度必须是一个常量表达式。
- 不设置初始化列表的时候,数组的元素被默认初始化
- 与内置类型的变量一样,函数内部定义了内置类型的数组,那么默认初始化会令数组含有未定义的值。
- 不允许用auto 关键字由初始值的列表推断类型。另外和 vector 一样,数组的元素应为对象,因此不存在引用的数组。
显示初始化数组元素
- 指明维度的,则初始值的总数量不能超过维度,如果小于维度,则提供的初始值初始化靠前的元素,剩下的元素被初始化为默认值。
- 不指明维度,则按照提供的初始值数量设置维度。
字符数组的特殊性
字符数组可以直接使用字符串字面值对此类数组初始化,注意字符串字面值末尾会有一个 空字符’\0‘,
这个空字符也会被拷贝到字符数组中去。
但是vector 是不支持直接使用字符串字面值对其进行初始化的。
不允许赋值与拷贝
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值。
int a[] = {0, 1, 2};
int a2[] = a; //错误
a2 = a; //错误
一些编译器支持数组的赋值,但是这些非标准特性的程序很有可能在其他编译器上无法正常工作。
理解复杂的数组声明
因为数组是可以存放大多数类型的对象,同时本身也是对象,可以定义存放指针的数组,也可以定义指向数组的指针。
int ptrs[10];
//int &refs[10] = {};
int *ptr[10]; //ptr是存放了10个指针的数组
int (*parray)[10] = &ptrs; //parray 是指向数组的指针
int (&arr_ref)[10] = ptrs; //arr_ref对数组的引用
要想理解数组声明的含义,最好的办法就是从内向外,从右至左来分析。
int *(& arry) [10] = ptrs ; //从内看,arry是一个引用,从右至左(忽略到括号内)是一个指针数组,那么arry就是对指针数组的引用。
3.5.2 访问数组元素
- 数组下标通常定义为 size_t 类型,size_t 是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。在c++的 cstdef中定义了 size_t类型。
- 注意检查下标的值是否符合合法内存区域。
3.5.3 指针与数组
- 使用数组的时候编译器会把它转换成为指针。
- 对数组元素使用取地址符就能得到指向该元素的指针。
- 在很多用到数组名字的地方,编译器都会自动第将其替换为一个指向数组首元素的指针。
int ia[] = {0,1,2,3,4};
auto 推断数组名为 其数组类型指针 auto ia2(ia); ia2是指针
decltype() 推断数组名 为其数组 decltype(ia) ia2; ia2是数组
指针也是迭代器
利用指针也可以完成迭代器的操作,递增,指示等等。
标准库函数 begin 和 end
由于数组的尾后指针(并不存在的元素地址)获取会容易出错,为了让指针的使用更加简单安全,C++11新标准引入了 两个名为 begin 和 end 的函数。
begin(arr) :会得到arr首元素的指针
end(arr) :会得到 arr 尾元素的下一个位置的指针
指针运算
迭代器的所有运算,用在指针上意义完全一致。
- 两个指针相减的结果的类型是一种名为 ptrdiff_t 的标准库类型,它是一种带符号类型,定义在
cstddef 头文件。
下标与指针
内置的下标运算符所用的索引值并不是无符号类型,可以处理负数,这一点与 vector 和 string 不一样。
3.5.4 C风格字符串
C++支持 C风格字符串,但在 C++程序中最好还是不要使用他们,C风格字符串极易发生程序漏洞,是诸多安全问题的根本原因。
- 习惯书写的字符串存放在字符数组中并以 空字符结束。
C 标准库String函数(cstring)
strlen(p) 返回p的长度,空字符不计算入内。
strcmp(p1,p2) 比较p1与p2的相等性。如果p1 == p2,返回 0;如果 p1 > p2, 返回一个正值
如果 p1 < p2 ,返回一个负值。
strcat(p1,p2) 将 p2附加到 p1 之后,返回 p1;
strcpy(p1,p2) 将 p2 拷贝给 p1,返回 p1.
以上函数,均不会去验证字符串参数的正确性。
使用标准库 string 要比使用 C风格字符串更加安全,高效。
3.5.5 与旧代码的接口
混用 strng 对象与 C 风格的字符串
- 允许使用以空字符结束的字符数组来初始化 string 对象或为 string 对象赋值。
- 在 string 对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是);在string 对象的复合赋值运算中允许使用以空字符结束的字符数组作为右侧的运算对象。
- 如果程序需要的是 一个 C 风格字符串,那么 可以使用 string .c_str() 来将字符串转换为 C风格的字符数组。
注意点: c_str() 函数返回的数组在改变了字符串对象时会失去效用,我们最好将该数组拷贝一份。
使用数组初始化 vector
vector i_vec{begin(int_arr) , end(int_arr)};
建议:尽量使用 标准库类型而非数组类型,C 程序的底层操作容易引发一些繁琐细节有关的错误。
3.6 多维数组
- 要使用 范围 for语句处理多维数组,除了最内层的循环外i,其他所有循环的控制变量都应该是引用类型,因为外层的循环获取到的元素如果是数组的话,不使用引用类型的话,这些元素将会被认为是指针类型。
类型别名简化多维数组的指针
using int_array = int[4];
int ia[3][4];
for (int_array *p = ia; p != ia + 3; ++p) {
for (int *q = *p; q != *p + 4; ++q)
cout << *q << ends;
cout << endl;
}
第四关:表达式
- 表达式是由一个或多个运算对象组成,对表达式求值将得到一个结果。
- 字面值和变量是最简单的表达式,其结果就是字面值和变量的值。
- 把 运算符和一个或多个运算对象组合起来可以生成较为复杂的表达式
4.1 基础
4.3 逻辑与关系运算符
运算对象和求值结果全是右值
逻辑与与逻辑或运算符
左结合律
短路求值:
- 对于逻辑与运算符来说,当且仅当左侧运算对象为真时候才对右侧运算对象求值。
- 对于逻辑或运算符来说,当且仅当左侧运算符为假时才对右侧对象求值。
逻辑非运算符
右结合律;
逻辑非运算符将运算对象的值取反后返回,
关系运算符
左结合律,
满足即为真,不满足为假
相等性测试与布尔字面值
左结合律
- 如果向测试一个算术对象或指针对象的真值,最直接的办法就是使用if 语句条件测试
- 不要使用布尔字面值 true 和 false 作为运算对象。
4.4 赋值运算符
- 赋值运算符的左侧对象是可修改的左值,并且右侧运算对象是能够转换为左侧对象的值。
- 左侧运算对象是内置类型,使用列表初始化时,注意只能包含一个值。
- 赋值运算符满足右结合律
- 赋值运算符优先级比较低
因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值部分通常应该加上括号。
复合赋值运算符效率稍高。
4.5 递增和递减运算符
因为很多迭代器不支持算术运算,所以递增与递减运算符是必须的
建议:除非必须,否则不使用递增递减后置版本。
后置版本将原始值存储下来以便于返回这个未修改的内容,一般情况下我们不需要保留该值,这就会造成浪费。
在一条语句中混用解引用与递增运算符
建议: 简洁可以成为一种美德。
使用 * p++ :先将p指针加一,返回 p 未增加前的副本,之后解引用,并将指针向前移动一个位置
运算对象可按任意顺序求值
如果一条子表达式改变了某个运算对象的值,另一条子表达式又要用到该值的话,运算对象的求值顺序就很关键了,除非子表达式与另一条子表达式是相连的关系。
4.6 成员访问运算符
点运算符与箭头运算符都可用于 访问成员,点运算符获取类对象的一个成员,箭头运算符与点运算符有关,表达式 ptr-> mem 等价于 (*ptr).mem;
- 点运算符的优先级是低于点运算符的,所以执行解引用的子表达式两端必须加上括号。
- 箭头运算符作用域一个 指针类型的运算对象,结果是一个左值,而点运算符分为两种情况:
成员的所属对象是左值,则结果是左值,所属对象是右值,则结果是右值。
4.7 条件运算符
条件运算符 (?:)允许我们把简单的 if- else 逻辑嵌入到单个表达式中:
cond ? expr1 : expr2
条件运算符值对 expr1 与 expr2 中的一个求值。
当条件运算符的两个表达式都是左值或者能转换成同一左值类型时,运算结果是左值,否则是右值。
嵌套条件运算符
条件运算符满足右结合律,意味着运算对象从右至左的顺序进行顺序结合。
条件运算的嵌套最好别超过两到三层。
4.8 位运算符
位运算作用域整数类型的运算对象,并把 运算对象看成是 二进制位的集合。
位运算符提供检查和设置 二进制位的功能
位运算符(左结合律) |
---|
运算符 | 功能 | 用法 |
---|---|---|
~ | 位求反 | ~expr |
<< | 左移 | expr1 << expr2 |
>> | 右移 | expr1 >> expr2 |
& | 位与 | expr & expr |
^ | 位异或 | expr ^ expr |
| 是位或运算
注意:位运算符处理运算对象的 ”符号位“依赖于机器。而且此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。
强烈建议仅将位运算符用于处理无符号类型。
移位运算符
移位运算符是执行二进制位的移动操作,左侧对象按照右侧运算对象的值要求移动位数,然后将经过移动的左侧运算对象的拷贝作为求值结果。(右侧对象不能为负值)
- 二进制位移位后,移出边界的位就被舍弃掉。
- 左移运算符 << 在右侧插入值为 0 的二进制位,右移运算符的行为则依赖于其左侧运算对象的类型,如果是无符号类型,在左侧插入值位 0 的二进制位,如果该运算对象是带符号的,在左侧插入符号位的副本或值为 0 的二进制位。
位求反运算符
位求反运算符将运算对象逐位取反后生成一个新值,将1置为 0,0置为1.
位与,位或,位异或运算符
对于位于运算符 (&)来说,如果两个运算对象对应位置都是1,则运算结果中该位 为 1,否则为 0.
对于位或运算符(|),如果两个运算对象的对应位置上有一个是 1,则运算结果中该位置为1,否则为0.
对于异或运算符(^),如果两个运算对象的对应位置有且仅有一个为1,则该运算结果为1,否则为 0.
在移位运算时加上括号会帮助减少错误(优先级不高不低)
4.9 sizeof 运算符
sizeof返回的是表达式结果类型的大小。
sizeof 满足右结合律,并且与* 运算符的优先级一样。
- 在sizeof的运算对象中解引用一个无效指针仍然是一种 安全的行为,因为指针并没有被真正使用,sizeof不需要真的去解引用指针也能知道它所指对象的类型。
- 对 char 类型或者 char 的表达式执行 sizeof运算,结果为1.
- 对引用类型执行得到引用的其对象空间的大小
- 对指针执行sizeof 得到指针本身所占空间的大小
- 对解引用指针执行得到指针所指对象所占空间的大小
- 对数组执行得到整个数组所占空间的大小,(sizeof并不会把数组当成指针来处理)
- 对string对象或 vector 对象执行sizeof运算指挥返回该类型固定部分的大小,不会计算其占用了多少空间。
4.10 逗号运算符
都好运算符有两个运算对象,首先对左侧的表达式求值,然后把求值结果丢弃掉。
逗号运算符真正的结果是右侧表达式的值,如果右侧表达式的结果是左值,最终的求值结果也是左值。
4.11 类型转换
隐式转换:是自动执行的对运算对象进行的类型统一的过程。
何时会发生隐式转换:
在下面这些情况下,编译器就会自动地转换运算对象的类型:
- 在大多数表达式中,比 int 类型小的整型值首先提升为较大的整数类型
- 在条件中,非布尔值转换为布尔值
- 初始化过程中,初始化转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型。
- 如果算术运算符或关系运算的运算对象有多种类型,需要转换成同一类型。
- 函数调用时也会发生类型转换。
4.11.1 算术转换
算术转换的含义是把一种算术类型转换成另外一种算术类型。
整型提升
- 整型提升负责把小整数类型转换成较大的整数类型。(short ,char ->转换为 int)
- 较大的 char 类型提升称为 int,unsigned int,long 类型中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
4.11.2 其他隐式转换类型
-
数组转换为指针:在数组的表达式中,数组自动转换成指向数组首元素的指针
-
指针的转换:C++中还规定了其他的指针转换方式,常量整数 0 或者字面值 nullptr能转换成任意指针类型:指向任意非常量的指针能转换成 void* ;指向任意对象的指针能转换成 const void*.
-
转换为布尔类型:指针算术类型的值为 0,转换的结果是false,否则转换结果为 true;
-
转换成常量:允许将指向非常量类型的指针转换为指向相应常量类型的指针,对于引用也是这样。
-
类类型定义的转换:类类型能定义由编译器自动执行的转换,不过每次只能转环一次。
4.11.3 显示转换
命名的强制类型转换
一个命名的强制类型转换的格式如下:
cast-name<type>(expression)
static_cast:
- 任何具有明确定义的类型转换,只要不包含底层 const,都可以使用 static_cast
- 当需要把一个较大的算术类型赋值给较小的类型时,static_cast 是非常有用的,这是显式地告诉编译器,我们并不在乎潜在的精度损失。
- static_cast 对于编译器无法自动执行的类型转换也非常有用,但是我们必须确保转换后的类型能够复合左值使用。
const_cast
const_cast 只能改变运算对象的 底层 const
- 去掉const性质,一旦我们去掉了对象的 const 性质,编译器将不会阻止我们进行写操作。
- 只有 const_cast 能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误,也不能 使用 const_cast 改变表达式的类型
reinterpret_cast
reinterpret_cast 通常为运算对象的位模式提供较低层次上的重新解释。
reinterpret_cast 本质上依赖于机器。要想安全地使用 reinterpret_cast 必须对涉及的类型和编译器实现转换的过程都非常了解。
建议:避免强制类型转换
强制类型转换干扰了正常的类型检查,因此我们强烈建议 程序员避免使用强制类型转换。
4.12 运算符优先级表
运算符优先级 |
---|
结合律与运算符 | 功能 | 用法 |
---|---|---|
1 左 :: | 全局作用域 | ::name |
1左 :: | 类作用域 | class::name |
1左 :: | 命名空间作用域 | namespace::name |
2左 . | 成员选择 | object.member |
2左 -> | 成员选择 | pointer->member |
2左 [ ] | 下标 | expr[expr] |
2左 () | 函数调用 | name(expr_list) |
2左 () | 类型构造 | type(expr_list) |
3右 ++ | 后置递增运算 | lvalue++ |
3右 – | 后置递减运算符 | lvalue– |
3右 typeid | 类类型 ID | typeid(type) |
3右 typeid | 运行时类型 ID | typeid(expr) |
3右 explicit cast | 类型转换 | cast_name <type.> (expr) |
4右 ++ | 前置递增运算 | ++lvalue |
4右 – | 前置递减运算 | –lvalue |
4右 ~ | 位求反 | ~expr |
4右 ! | 逻辑非 | !expr |
4右 - | 一元负号 | -expr |
4右 + | 一元正号 | +expr |
4右 * | 解引用 | *expr |
4右 & | 取地址 | &lvalue |
4右 () | 类型转换 | (type)expr |
4右4sizeof | 对象的大小 | sizeof expr |
4右 4sizeof | 类型的大小 | sizeof(type) |
4右 Sizeof… | 参数包的大小 | sizeof…(name) |
4右 new | 创建对象 | new type |
4右 new[ ] | 创建数组 | new type[size] |
4右 delete | 释放对象 | delete expr |
4右 delete [ ] | 释放数组 | delete[ ] expr |
4右 noexcept | 能否抛出异常 | noexcept(expr) |
5左 ->* | 指向成员选择的指针 | ptr->*ptr_to_member |
5左 .* | 指向成员选择的指针 | obj.* ptr_to_member |
6左 * | 乘法 | expr * expr |
6左 / | 除法 | expr / expr |
6左 % | 取模 | expr % expr |
7左 + | 加法 | expr + expr |
7左 - | 减法 | expr - expr |
8左 << | 向左移位 | expr<<expr |
8左 >> | 向右移位 | expr>>expr |
9左 < = | 小于等于 | expr <= expr |
9左 > = | 大于等于 | expr >= expr |
10左 == | 相等 | expr == expr |
10左 != | 不相等 | expr!=expr |
11左 & | 位与 | expr & expr |
11左 | 位或 | |
11左 ^ | 位异或 | expr ^ expr |
12左 && | 逻辑与 | expr && expr |
12左 | ||
13右 ?: | 条件 | expr ? expr: expr |
14右 = | 赋值 | lvalue = expr |
15右 | 复合运算符 | |
16左 , | 逗号表达式 | expr, expr |
第五关:语句
C++ 提供了一组控制流语句以支持更复杂的执行路径。
5.1 简单语句
表达式末尾加上分号就成了表达式语句;
空语句
- 空语句是 只含有一个单独的分号;
- 使用空语句要加上注释,让其他人知道这句是有意省略的。
别漏写分号,也别多写分号
复合语句
- 复合语句是指用花括号括起来的(可能为空的)语句和声明的序列,复合语句也被称为块。
- 一个块就是一个作用域
- while 或者 for 的循环体只能跟一条语句,但我们可以使用复合语句(块)扩展循环体内做的事情。
- 块不以分号结尾,仅为对应块的右花括号结束为结束。
5.2 语句作用域
在 if,switch,while 和 for 语句中的控制结构内定义变量,定义在控制结构当中的变量只在对应语句的内部可见,语句结束,变量也会超出其作用范围。
因为控制结构定义的对象的值马上要由结构本身使用,所以这些局部变量需要初始化。
5.3 条件语句
悬垂 else
当 if 存在且 if else 语句也存在,这时 C++规定 else 与 离它最近的尚未匹配的 if 匹配,从而消除 程序的二义性。
case 关键字
switch‘ 语句括号内的表达式是可以转换为整数类型的表达式。
case 关键字与它对应的值一起被称为 case 标签,case 标签必须是整型常量表达式。
switch 内部的控制流
- 如果某个 case 标签匹配成功,将该标签开始往后顺序执行所有的 case 分支,除非程序显式地中断这个过程,不然switch的结尾处才会停下来。
- 不要省略 case 分支最后的 break 语句,如果没写 break 语句,要加一段注释说清楚程序的逻辑。
- 即使不准备在defalult 的标签下做任何工作,定义一个 default 标签也是有必要的。
switch 内部的变量定义
- 如果在某处一个带初始值的变量位于作用域之外,在另一处变量位于作用域之内,从前一处跳转到后一处的行为是非法行为。
- 把 变量定义在 switch 内部条件选择的块中,以确保后面的所有 case 标签都在变量的作用域之外。
5.4 迭代语句
- 在使用迭代器的 for 语句中,预存了 end() 的值,如果序列添加删除元素,那么 end函数的值将变得无效
- do while 语句应该在括号包围起来的条件后面用一个分号来表示语句结束。
- do while 条件中使用的变量必须定义在循环体之外。
- do while 来说先执行语句或者块,后判断条件,所以不允许在条件部分定义变量。
5.5 跳转语句
- break 语句 负责终止离它最近的 while,do while,for 或 switch语句,并从这些语句后的第一条语句继续执行。
- continue 语句终止最近的循环中的当前迭代并立即开始下一次迭代,并且当 switch 语句嵌套在迭代语句内部时,才能在 switch 中使用 continue;
5.6 try 语句块和异常处理。
异常是指存在于运行时的反常行为,这些行为都超出了函数正常功能的范围。
- throw 表达式,异常检测部分使用 throw 表达式来表示它遇到了无法处理的问题,我们说 throw 引发了异常。
- try 语句块,异常处理部分用 try 语句块处理异常。 try 语句块以关键字 try 开始,并以一个或多个 catch 子句结束。 try语句块中代码抛出的异常通常会被 某个 catch 子句处理。因为 catch 子句 “处理”异常,所以他们也被称作异常处理代码
- 一套异常类,用在throw 表达式和相关 catch子句之间传递异常的具体信息。
函数在寻找处理代码的过程中退出
当异常被抛出时,首先搜索抛出异常的函数,没有找到对应的 catch子句,终止该函数,并在外层调用该函数的函数继续搜索,。。。。沿着程序的执行路径逐层回退,直到找到适合类型的 catch 子句为止。
如果最终没有找到匹配的 catch,程序将转到名 为 terminate的标准库函数,(该函数会导致程序非正常退出)。
标准异常
C++ 标准库中定义了一组类,用于报告标准库函数遇到的问题,他们分别定义在 4个头文件中
- exception 头文件定义了最通用的异常类 exception。它只报告异常的发生,不提供任何额外信息
- stdexcept 头文件定义了集中常用的异常类
- new 头文件中定义了 bad_alloc异常类型
- type_info 头文件定义了 bad_cast 异常类型
stdexcept 定义的异常类
exception | 最常见的问题 |
---|---|
runtime_error | 只有在运行时才能被检测的问题 |
range_error | 运行错误:生成的结果超出了有意义的值域范围 |
overflow_error | 运行时错误;计算上溢 |
underflow_error | 运行时错误:计算下溢 |
logic_error | 程序逻辑错误 |
domain_error | 逻辑错误,参数对应的结果值不存在 |
invalid_argument | 逻辑错误:无效参数 |
length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 |
out_of_range | 逻辑错误:使用一个超出有效范围的值 |
- 异常类型只定义了一个 名为 what 的成员函数,该函数没有任何参数,返回值是一个 c风格字符串,提供了异常的文本信息。
- what 返回的 C风格字符串的内容于异常对象的类型有关,如果异常类型有一个字符串初始值,则返回该字符串,无则由编译器决定返回什么。
第六关: 函数
6.1 函数基础
函数的构成: 返回类型,函数名字,0或者多个形参组成的列表以及函数体。
调用运算符 :使用括号运算符作用于一个表达式(函数或者是函数指针),圆括号是用逗号分隔的参数列表,我们用实参初始化函数的形参,调用表达式的类型就是函数的返回类型。
调用函数:
调用函数完成了两项工作:
- 一是用实参初始化函数对应的形参,
- 二是将控制权转移给被调用函数,主调函数被暂时中断,被调函数开始执行。
return 语句的两项工作:
- 一是返回语句中的值
- 二是将控制权从被调函数转移回主调函数。
形参与实参
- 实参是形参的初始值
- 编译器能对任意可行的顺序对实参求值,C++并没有规定实参的求值顺序。
- 实参与形参类型要匹配,形参与实参数量一致,所以形参一定会被初始化。
- 必须提供的是可转换为形参类型的实参
函数的形参列表
- 任意两个形参不能重名,函数的局部变量也不能与形参的名字一致
- 即使形参的不被函数使用,也要为其提供一个实参。
函数返回类型
- 大多数类型都能作为函数的返回类型,一种特殊的返回类型是 void
- 函数不能返回数组或者函数类型,但可以返回指向数组或者函数的指针
6.1.1 局部对象
在C++中语言中,名字有作用域,对象有生命周期
- 形参和函数体内定义的变量统称为局部变量,仅在函数的作用域内可见。
- 局部变量还会隐藏在外层作用域中同名的其他所有声明中。
自动对象
只存在于块内执行期间的对象称为自动对象,函数执行结束后,创建的自动对象的值就变成未定义的 了。
对于局部变量对应的自动对象:
- 如果变量定义本身含有初始值,就用这个初始值进行初始化;否则如果变量定义不含初始值,执行默认初始化。
- 内置类型的未初始化局部变量将产生未定义的结果。
局部静态变量
局部静态对象 在程序的执行路径第一次经过该对象时定义语句并将其初始化,直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
6.1.2 函数声明
- 函数的名字也必须在使用之前声明,函数只能定义一次,但可以声明多次。
- 如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。
- 函数的三要素(返回类型,函数名,形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。
在头文件中进行函数声明:
- 建议变量在头文件中声明,在源文件中定义,函数也相同。
- 含义函数声明的头文件应该被包含到定义函数的源文件中
6.1.3 分离式编译
分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
编译和链接多个源文件
fact 函数 声明于 Chapter6.h 的头文件中,定义于 fact.cc
factMain.cc 文件中创建 main 函数,main函数调用 fact 函数。
其中如果要生成 可执行文件,我们需要告诉编译器我们的代码在哪,下面演示:
$ cc factMain.cc fact.cc ##generates .exe or a.out
$ cc factMain.CC fact.cc -o main # generates main or main.exe
cc 是编译器的名字, $ 是系统提示符,#是注释
分离式编译并链接
如果我们修改了其中一个源文件,那么我们只需要重新编译那个改动了的文件,大多数编译器都会提供分离式编译每个文件的机制,这一过程通常会产生一个 后缀名为 .obj 或者 .o 的文件,后缀名的含义是该文件包含该对象代码。
编译过程:
$ cc -c factMain.cc #generates factMain.o
$ cc -c fact.cc #generates fact.o
链接过程:
$ cc factMain.o fact.o # generates factMain.exe or a.out
$ cc factMain.o fact.o -o main #generates main or main.exe
6.2 参数传递
6.2.1 形参与实参
- 形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用。
- 当实参拷贝给实参,形参实参是相互独立的对象,我们说这样的实参被值传递或函数被传值调用。
- 熟悉 C 的程序员常常使用指针类型的形参访问函数外部的成员,C++语言则建议使用引用类型的形参代替指针
- 通过使用非常量引用形参,允许函数改变或多个实参的值。
- 存在多种类型(IO)不支持拷贝操作时,函数只能通过引用形参进行访问该对象
- 如果函数无须改变引用形参的值,最好将其声明为常量引用
- 当函数求得的返回值不能满足目标信息的数据个数,我们可以声明引用类型的形参去保存另外的信息。
6.2.2 const 形参与实参
- 用实参初始化形参时会忽略掉顶层 const,当形参有顶层const 时,传递给它常量对象或者非常量对象都是可以的。
- 在C++中,不同函数的形参列表应该有明显的不同,但是 顶层const被忽略掉了,它的参数列表就与 非具有顶层const 的参数列表相同,定义这两个参数的函数是相同的。
6.2.3 指针或引用参数与 const
- 非常量初始化一个底层 const 对象是合理的,但是底层const对象 初始化一个非常量对象 是不合理的
- 尽量使用常量引用形参,能极大的避免 实参为 底层const对象或者 非底层const对象 可能带来的一些错误
6.2.4 数组形参
- 不能拷贝数组以及使用数组时通常会将其转换为指针。
- 以数组作为实参的函数也必须确保使用数组不会越界。
管理指针形参
- 使用标记指定数组长度(c风格字符串以 空字符停止’\0’)
- 使用标准库规范,传递执行数组首元素和尾后元素的指针。
- 显式地传递一个表示数组大小的形参。
数组形参与 const
只有当函数确实要改变元素值的时候,我们才把形参定义成指向非常量的指针。
数组引用形参
我们可以将引用形参绑定到数组上,数组的引用。
void print(int (&arr)[ 10 ]);
传递多维数组
void print (int (*matrix)[10], int rowSize);
void print (int matrix[][10], int rowSize)
编译器会自动忽略第一个维度,因此请不要包含它到形参列表中
6.2.5 main:处理命令行选项
我们有时需要给 main 传递实参,一种常见的情况是用户通过设置一组选项来确定该函数执行的操作
prog -d -o ofile data0
这些命令行通过两个参数传递给main函数
int main(int argc,char **argv)
argc 代表了传递信息的数量,argv代表了命令行的字符串数组
- 当使用 argv 的实参时,注意一要从 角标 1开始,因为 argv[0]保存程序的名字,而非用户输入。
6.2.6 含有可变形参的函数
我们有时无法预知 向函数传递几个实参, 因此为了编写能处理不同数量实参的函数,
C++11新标准提供了两种主要的方法
- 实参类型相同,传递给 initializer_list 的标准库类型
- 实参类型不同,编写另外的一种特殊函数,可变参数模板
initializer_list
initializer_list<T> lst; 默认初始化:T类型元素的空列表
initializer_list<T> lst{a,b,c....} lst的元素数量与初始值一样多,lst的元素都是对应初始值的副本,
元素均为const
lst2(lst) :执行拷贝或者赋值对象,但不会拷贝列表中的元素,而是两个对象共享元素
lst2 = lst;
lst.size() :列表中元素数量
lst.begin() :返回 首元素指针
lst.end() : 返回尾后指针
省略符形参
省略符形参是为了便于 C++程序访问某些特殊的 C代码而设置的,这些代码使用了名为 varargs 的 C 标准库的功能。
- 省略符号形参应该仅仅用于 C 与 C++通用的类型,特别注意,大多数类型的对象在传递给 省略符形参时都无法正确拷贝。
- 省略符形参对应的实参无须类型检查,且形参声明后面的逗号是可选的
void foo(int a,…) = void foo(int a…)
void foo(…) {
}
6.3 返回类型和return 语句
6.3.1 无返回值
- 无返回值 返回类型为 void,且 return语句能显式地中断函数的进行,使控制流返回到调用函数的地方
6.3.2 有返回值
- 有返回值时,要确保一定会有返回值返回,循环与if嵌套有可能确保不了程序是否能正确返回。
- 返回一个值的方式与初始化一个变量或形参的方式相同。
不要返回局部变量的引用或指针
- 不要返回局部变量的引用或指针,因为函数完成后,它所占用的存储空间也被随之释放掉,函数终止意味着局部变量的引用将指向不再有效的内存区域。
- 保证返回值安全的方式:确保引用所引的是函数之前已经存在的对象。
返回类类型的函数和调用运算符
(.) 调用运算符的优先级与 点运算符和箭头运算符相同,且复合结合律,我们可以使用函数返回的结果直接调用其对象的成员。
引用返回左值
调用一个返回引用的函数得到左值,我们能为返回非常量的引用的对象赋值
列表初始化返回值
C++11新规定,函数可以返回花括号包围的值的列表(代表了返回的临时量进行初始化),如果列表为空,临时量指向值初始化,否则,由函数的返回类型决定。
如果返回的是内置类型,则花括号应该仅有一个值,且所占空间不应该大于目标类型的空间,如果返回的是类类型,则由类定义初始值如何使用。
主函数 main 的返回值
- main 函数没有 return 语句也可以直接结束,因为编译器会隐士地自动插入一条返回 0 的语句
- main 函数的返回值是状态指示器,返回0 代表成功,其他为失败,非 0 的值由机器而定,但是为了避免返回值与机器有关, cstdlib 头文件定义了两个预处理变量来表示成功与失败(EXIT_FALURE EXIT_SUCCESS)
递归
如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数。
- 在递归函数中,一定有某条路径是不包含递归调用的,否则函数会一直递归下去,直到栈空间耗尽。
6.3.3 返回数组指针
利用 类型别名返回数组指针或引用
using arrT = int【10】;
arrT * func(int i);
声明一个返回数组指针的函数
我们想定义一个返回数组指针的函数,则数组的维度必须紧跟在函数的名字之后
Type(*function(parameter_list))[dimension]
逐层理解:
- func(int i) 表示调用func函数我们需要一个int类型的实参。
- (*func(int i)) 意味着我们可以对函数调用的结果执行解引用操作。
- (*func(int i))[ 10 ] 表示解引用 func 的调用将得到一个大小是10的数组。
- int (*func(int i))[10] 表示数组中的元素是 int 类型
使用尾置返回类型
auto func(int i) -> int(*)[10]
为了表示函数真正的返回类型跟在形参列表之后,我们本应该出现返回类型的地方放置一个 atuo;
使用 decltype
利用 decltype()推断数组的类型,之后跟一个指针也可以
int odd[] = {1,3,4,5}
decltype(odd) *arrPtr(int i);
6.4 函数重载
如果同一作用域中的几个函数名字相同但形参列表不同,我们称之为重载函数
- main 函数不能重载
定义重载函数
对于重载的函数来说,他们应该在形参数量或形参类型上有所不同。
重载和 const 形参
- 编译器会忽略掉形参的 顶层 const,编译器对于有无顶层const的 形参是区分不开的
- 底层 const的形参只能通过 底层const 的实参传递,编译器能区分开来
建议:何时不应该重载函数
重载函数虽然一定程度上减轻我们为函数起名字的负担,但是最好重载那些确实非常相似的操作。
const_cast 和重载
利用 const_cast 能对底层const 与 普通非常量 进行转换
调用重载的函数
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配的错误信息
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择,此时也将发生错误,称为二义性调用。
6.4.1 重载与作用域
- 如果我们在内层作用域声明函数的名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。
- C++中,名字的查找发生在类型检测之前。
6.5 特殊用途语句特性
6.5.1 默认实参
在很多次函数的调用中,一些形参被赋予了同一个值,这时,我们将反复出现的值称为函数的默认实参。
调用该默认实参的函数可以包含实参也可以省略实参。
- 一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
using sz = string::size_type ;
string screen(sz ht = 24, sz width = 80, char background = ’ ')
使用了默认实参调用函数
- 函数调用时实参按照位置进行解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)
- 设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参放在后面。
默认实参声明
通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
- 在给定的作用域中一个形参只能被赋予一次默认实参,后续更改是不行的,且后续声明仅能为那些没有默认实参的形参进行声明。
using sz = std::string::size_type;
std::string screen(sz, sz, char = ' ');
//std::string screen(sz, sz, char = '*'); //不能修改char的默认实参
std::string screen(sz = 24, sz = 25, char); //不能修改char的默认实参
默认实参初始值
局部变量不能作为默认实参,除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。
局部变量隐藏了外层的 变量时,但是局部变量与传递给函数的默认实参没有关系。
6.5.2 内联函数和 constexpr 函数
调用函数比求等价表达式的值要慢一点
函数调用的工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。
内联函数可避免函数调用的开销
内联函数通常是将它在每个调用点上 ”内敛地“展开。
内联函数需要在 函数声明的最前面加入 ”inline“修饰符
- 内敛机制用于优化规模较小,流程直接,频繁调用的函数,许多编译器都不支持内敛递归函数。
- 内敛说明仅仅是向编译器发送的一个请求,编译器可选择忽略请求。
constexpr 函数
constexpr 函数是指能用于常量表达式的函数,定义 constexpr 函数的方法与其他函数类似,
不过要遵循几项约定:
- 函数的返回类型及所有形参的类型都带是字面值类型。
- 函数体必须有且仅有一条 return 语句。
把内联函数和 constexpr 函数放在头文件内
对于给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致,因此,内联函数和 constexpr函数通常定义在头文件中。
6.5.3 调试帮助
当应用程序准备发布时,要先屏蔽掉调试代码
这种方法用到两项预处理功能: assert 和 NDEBUG
assert 预处理宏
assrt( expr)
对expr求值,如果表达式为假,assert 输出信息并终止程序的执行,如果表达式为真,则assert什么都不做
NDEBUG 预处理变量
assert 的行为依赖于一个名为 NDEBUG 的预处理变量,如果定义了 NDEBUG 则 assert 什么都不做,
我们可以利用 NDEBUG编写自己的测试代码:
如果NDEBUG未定义,则执行 #ifndef 与 #endif之间的代码,这些代码将被忽略掉
void print(const int ia[], size_t size) {
#ifndef NDEBUG
cerr << __func__ << ": array size is " << size << endl;
#endif // !NDEBUG
}
预处理器定义了 4 个对于程序调试很有用的名字:
- _ _ FILE_ _ 存放文件名的字符串字面值;
- _ _ LINE_ _ 存放当前行号的整型字面值
- _ _ TIME_ _ 存放文件编译时间的字符串字面值
- _ _ DATE_ _ 存放文件编译日期的字符串字面值。
6.6 函数匹配
确定候选函数和可行函数
- 函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为 候选函数。
- 考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数。
可行函数的特点:
- 形参数量与本次调用提供的实参数量相等。
- 实参的类型与对应的形参类型相同,或者能转换。
- 如果函数中含有默认参数,则该函数虽然实参数量不够但也可能会是可行函数。
寻找最佳匹配
- 在可行函数中选择与本次调用最匹配的函数。(实参类型与形参类型越接近,匹配的越好)
含有多个形参的函数匹配·
编译器依次检测每一个实参以确定哪个函数是最佳匹配。如下条件:
- 该函数每个实参的匹配 都不劣于其他可行函数需要的匹配。
- 至少有一个实参的匹配优于其他可行函数提供的匹配。
如果检测到所有的函数都没有一个脱颖而出,编译器则报告二义性。
调用重载函数时应尽量避免强制类型转换,如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6.1 实参类型转换
为了完成精确匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下:
1.精确匹配
- 实参类型与形参类型相同
- 实参从数组类型或函数类型转换成对应的指针类型
- 向实参添加顶层 const 或者从实参中删除顶层 const
- 通过 const 转换实现的匹配
- 通过类型提升实现的匹配
- 通过算术类型转换或指针转换实现的匹配
- 通过类类型转换实现的匹配。
函数匹配与 const 实参
底层 const 形参会优先匹配 常量实参
非const 实参 只会匹配非常量实参。
6.7 函数指针
函数指针指向的是函数而非对象,与其他指针一样,函数指针指向某种特定类型,函数的类型由它的返回类型 和形参类型共同决定,与函数名无关。
bool lengthCompare(const string &,const string &);
声明一个指向该函数的该函数指针只需要将函数名换成指针就行了。
bool (*pf)(const string &,const string &);
使用函数指针
- 把函数名作为一个值使用时,该函数自动转化为指针,也可以将 函数的地址赋予指针,取地址符 & 是可选的。
( pf = lengthCompare; ) = ( pf = &lengthCompare;)
- 指向不同函数类型的指针之间不存在相互转换规则,但是和往常一样,我们可以为函数指针赋予一个 nullptr 或者 值为 0 的整型常量表达式。
重载函数的指针
编译器通过指针类型决定选择哪个函数。指针类型必须与重载函数中的某一个精准匹配。
函数指针形参
- 虽然不能定义函数类型的形参,但是形参可以是指向函数的指针,此时,形参看起来是函数类型,实际上却是当成指针使用。
返回指向函数的指针
- 类型别名将返回值定义为函数指针 using PF = int (*)(int,int);
- 尾后返回类型 auto f1(int) -> int (*)(int,int);
将 auto 和 decltype 用于函数指针类型
当我们将decltype作用于某个函数时,它返回 函数类型而非指针类型,因此,我们显式地加上 * 代表我们返回指针而非函数本身。
string::size_type sumLength(const string& , const string&);
decltype(sumLength) *getFcn(const string &);
第七关:类
类的基本思想是数据抽象(data abstraction) 与 封装(encapsulation),数据抽象是一种依赖于接口和实现分离的编程(以及设计)技术。
类的接口包括用户所能执行的操作;类的实现则包括类的数据成员,负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口与实现的分离,封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类想要实现数据抽象 和 封装,需要先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无需了解类型的工作细节。
7.1 定义抽象数据类型
我们来实现一个 SaleData类,它目前并不是一个抽象数据类型,它允许用户访问它的数据成员,并且由用户来编写操作。
我们需要定义一些操作以供类的用户使用,之后我们封装(隐藏)它的数据成员,保证接口与实现分离,逐渐地完成数据抽象与封装,实现一个抽象数据类型。
7.1.1 设计 SalesData 类
SalesData 的接口应该包含以下操作:
- 一个 isbn 成员函数,用于返回对象的 ISBN编号
- 一个 combine 成员函数,将一个 SalesData 对象加到另一个对象上
- 一个 名为 add 的函数,执行两个 SalesData 对象的加法。
- 一个 read 函数,将数据从 istream 读入到 SalesData 对象中
- 一个 print 函数,将 SalesData 对象的值输出到 ostream。
C++程序员无须刻意区分应用程序的用户以及类的用户。
在一些简单的应用程序中,类的用户和类的设计者常常是同一个人。尽管如此,还是最好把角色区分开来,当我们设计类的接口时,应该考虑如何才能使得类易于使用;当我们使用类时,不应该顾及类的实现机理。
要想开发一款成功的应用程序,其作者必须充分了解并实现用户的需求。同样,优秀的类设计者也应该密切关注那些有可能使用该类程序员的需求。作为一个设计良好的类,既要有直观且易于使用的接口,并且具备高效的实现过程。
7.1.2 定义改进的SalesData 类
#pragma once
#ifndef CPPPRIMER_SALESDATA_H_
#define CPPPRIMER_SALESDATA_H_
#include <string>
#include "goolestyle.h"
namespace mynamespace {
struct SalesData {
// 新成员: 关于 SalesData 对象的操作
std::string book_no() const { return book_no_; }
SalesData& Combine(const SalesData &);
double AvgPrice() const;
std::string book_no_;
unsigned units_sold_ = 0;
double revenue_ = 0.0;
DISALLOW_COPY_AND_ASSIGN(SalesData);
};
SalesData Add(const SalesData &, const SalesData &);
std::ostream& Print(std::ostream &, const SalesData &);
std::istream &Read(std::istream &, SalesData &);
}
#endif // !CPPPRIMER_SALESDATA_H_
- 非通用的函数应该属于类实现的一部分,而非接口的一部分
- 定义和声明成员函数的方式与普通函数差不多,成员函数的声明必须在类的内部,它的定义既可以在类的内部也可以在类的外部。
- 作为类的接口组成部分的非成员函数,add,read,print 他们的定义与声明都在类的外部。
定义成员函数
- 类的所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。
- 定义在类内部的函数是隐式的 inline 函数
引入 this
-
在成员函数内部,我们可以直接调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,是因为 成员函数通过一个名为 this 的隐式参数来访问调用 它的那个对象。
-
当我们调用一个成员函数时,用请求该函数的对象地址初始化 this。
引入 const 成员函数
std::string book_no() const { return book_no_; }
- isbn 函数在参数列表后紧跟着一个 const 关键字,这里,const 的作用是修改隐-式 this指针的类型。
- C++ 语言的做法是允许把 const 关键字放在成员函数的参数列表之后,此时紧跟在参数列表后面的const 表示 this 是一个指向 常量的指针。像这样使用 const 的成员函数被称作 常量成员函数。
- 常量对象,常量对象的引用或指针都只能调用常量成员函数。
- 常量成员函数内部只能读取对象的数据成员,但是不能写入新值。
类作用域和成员函数
- 编译器首先编译成员的声明,之后才轮到成员函数体(如果有),因此,成员函数体可以随意使用类中的其他成员,而无须在意这些成员出现的次序。
在类的外部定义成员函数
double mynamespace::SalesData::AvgPrice() const {
if (units_sold_)
return revenue_ / units_sold_;
return 0.0;
}
类外部定义的成员的名字必须包含它所属的类名。
函数名 SalesData::AvgPrice使用作用域运算符来说明该函数被声明在 SalesData的作用域中,函数体内的代码的成员是位于类的作用域内的就不会出错。
定义一个返回 this 对象的函数
SalesData& SalesData::Combine(const SalesData &rhs) {
units_sold_ += rhs.units_sold_;
revenue_ += rhs.revenue_;
return *this;
}
- 一般来说,当我们定义的函数类似于某个内置运算符时,应该令函数的行为尽量模仿这个运算符。
内置的赋值运算符把它的左侧运算对象当成左值返回。
7.1.3 定义类相关的非成员函数
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类都在同一个头文件内。
定义 Read函数与 Print 函数
- IO类属于不能被拷贝的类型,因此我们只能通过引用来传递他们。
- Print 函数不设置换行,将主动权尽量交给用户来执行。
std::ostream &Print(std::ostream &os, const SalesData &item) {
os << item.book_no() << " " << item.units_sold_ << " "
<< item.revenue_ << " " << item.AvgPrice();
return os;
}
std::istream& Read(std::istream &is, SalesData &item){
double price = 0;
is >> item.book_no_ >> item.units_sold_ >> price;
item.revenue_ = price * item.units_sold_;
return is;
}
定义 Add 函数
SalesData Add(const SalesData &lhs, const SalesData &rhs) {
SalesData sum = lhs;
sum.Combine(rhs);
return sum;
}
- 返回 sum (合并的副本)。
- 未加入 iostream 头文件的话 unsigned 一些变量的类型会识别为未定义重载运算符。
7.1.4 构造函数
类通过一个或多个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。
- 构造函数的任务是初始化类对象的数据成员,只要类对象被创建,就会执行构造函数
- 构造函数的名字与类名相同,构造函数没有返回类型
- 构造函数也有一个(可能为空的)参数列表与一个(可能为空的)函数体。
- 类可以包含多个构造函数,和其他重载函数差不多,不同构造函数之间必须在参数数量或参数类型上有所区别。
- 构造函数不能声明为 const,因为我们创建类的 const对象时,直到构造函数完成初始化过程,对象才能真正取得 ”常量“属性。
合成的默认构造函数
类可以通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。
如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐士地定义一个默认构造函数。
编译器创建的构造函数又被称为 合成的默认构造函数,它以以下规则初始化类的数据成员:
- 如果存在类内的初始值,让其初始化成员
- 否则,执行默认初始化该成员。
某些类不能依赖于合成的默认构造函数
原因有三:
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生产一个默认的构造函数
- 合成的默认构造函数可能执行错误的操作,如果类包含有内置类型或者复合类型的成员,则只有当这些成员全部被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
- 有时候编译器不能为某些类合成默认的构造函数,如果类内包含其他类类型成员,但这个类类型成员没有默认构造函数,那么编译器则无法初始化该成员。
定义SalesData 的构造函数
我们定义 4 个不同的构造函数
- 一个 istream&,从中读取一条交易信息。
- 一个 const string& ,表示 ISBN编号,一个unsigned,表示出售的图书数量;以及一个 double,表示图书的售出价格。
- 一个 const string&,表示ISBN编号,编译器将赋予其他成员默认值。
- 一个空参数列表(即默认构造函数)
#pragma once
//Copyright 2020
//License (BSD/GPL/...)
//Author: Handling
//This is CPPPrimer Study
#ifndef CPPPRIMER_SALESDATA_H_
#define CPPPRIMER_SALESDATA_H_
#include <string>
#include <iostream>
namespace mynamespace {
struct SalesData {
// 新成员: 关于 SalesData 对象的操作
/*1.typedef 和 enums
2.常量
3.构造函数
4.析构函数
5.成员函数,含静态数据成员
6.成员变量,含静态成员变量
*/
SalesData() = default;
SalesData(const std::string &s): book_no_(s) { }
SalesData(const std::string &book_no, unsigned unit_sold, double price) :
book_no_(book_no), units_sold_(unit_sold), revenue_(price * unit_sold) { }
SalesData(std::istream &);
std::string book_no() const { return book_no_; }
SalesData& Combine(const SalesData &);
double AvgPrice() const;
std::string book_no_;
unsigned units_sold_ = 0;
double revenue_ = 0.0;
DISALLOW_COPY_AND_ASSIGN(SalesData);
};
SalesData Add(const SalesData &, const SalesData &);
std::ostream& Print(std::ostream &, const SalesData &);
std::istream &Read(std::istream &, SalesData &);
}
#endif // !CPPPRIMER_SALESDATA_H_
- C++ 11 新标准中,我们需要默认的行为,就可以在参数列表后面写上 = default 来要求编译器生产构造函数。
- 其中 = default 如果出现在类的内部,代表默认构造函数是内联的。
- 上面的默认构造函数之所以对SalesData 有效,是因为我们为内置类型的数据成员提供了初始值,如果编译器不支持类内初始值,则需要使用 构造函数初始化列表来初始化每一个成员。
构造函数初始化列表
SalesData(const std::string &s): book_no_(s) { }
SalesData(const std::string &book_no, unsigned unit_sold, double price) :
book_no_(book_no), units_sold_(unit_sold), revenue_(price * unit_sold) { }
构造函数初始值是成员名字的一个列表,每个名字紧跟括号括起来的成员初始值,不同成员初始化通过逗号分隔开。
- 构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同,如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每一个内置类型的成员。
在类的外部定义构造函数
SalesData::SalesData(std::istream &is) {
Read(is,*this);
}
当作用域与函数命字相同时,说明该函数是构造函数。
7.1.5 拷贝,赋值,析构
类除了初始化外,类还需要 控制拷贝,赋值,销毁对象时发生的行为。
一般来说,编译器生成的版本将对对象的每一个成员执行拷贝,赋值和销毁操作。
某些类不能依赖于合成的版本
管理动态内存的类通常是不能依赖上述操作的合成版本,会造成内存问题。
7.2 访问控制与封装
我们已经为类定义了接口,但还没有机制强制用户使用这些接口,我们的类还没有封装,用户可以直达对象内部控制它的具体实现细节。
在C++中,我们使用 访问说明符 加强类的封装性。
- 定义在 public 说明符之后的成员在整个程序内可被访问, public 成员定义类的接口
- 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private 部分封装了(即隐藏了)类的具体实现细节。
- 每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者直到达类的结尾为止。
class SalesData {
// 新成员: 关于 SalesData 对象的操作
/*1.typedef 和 enums
2.常量
3.构造函数
4.析构函数
5.成员函数,含静态数据成员
6.成员变量,含静态成员变量
*/
public:
SalesData() = default;
SalesData(const std::string &s): book_no_(s) { }
SalesData(const std::string &book_no, unsigned unit_sold, double price) :
book_no_(book_no), units_sold_(unit_sold), revenue_(price * unit_sold) { }
SalesData(std::istream &);
std::string book_no() const { return book_no_; }
SalesData& Combine(const SalesData &);
private:
double AvgPrice() const
{ return units_sold_ ? revenue_/units_sold_ : 0; }
std::string book_no_;
unsigned units_sold_ = 0;
double revenue_ = 0.0;
DISALLOW_COPY_AND_ASSIGN(SalesData);
};
使用 struct 或者 class关键字
struct 和 class 唯一的区别就是 默认的访问权限不同。
如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public的,相反,如果我们使用的是 class 关键字,则这些成员是 private 的。
- 如果我们希望定义的类的所有成员是 public的时候,使用 struct;反之 如果我们希望成员是 private,使用 class;
7.2.1 友元
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元。
- 如果类想把一个函数作为它的友元,只需要增加一条以friend 关键字开始的函数声明语句即可。
- 友元不是类的成员也不受它所在区域访问说明符控制级别的约束,一般在类定义开始或结束前的位置集中声明友元。
#pragma once
//Copyright 2020
//License (BSD/GPL/...)
//Author: Handling
//This is CPPPrimer Study
#ifndef CPPPRIMER_SALESDATA_H_
#define CPPPRIMER_SALESDATA_H_
#include <string>
#include <iostream>
#include "goolestyle.h"
namespace mynamespace {
class SalesData {
// 新成员: 关于 SalesData 对象的操作
/*1.typedef 和 enums
2.常量
3.构造函数
4.析构函数
5.成员函数,含静态数据成员
6.成员变量,含静态成员变量
*/
friend SalesData Add(const SalesData &, const SalesData &);
friend std::ostream &Print(std::ostream &, const SalesData &);
friend std::istream &Read(std::istream &, SalesData &);
public:
SalesData() = default;
SalesData(const std::string &s): book_no_(s) { }
SalesData(const std::string &book_no, unsigned unit_sold, double price) :
book_no_(book_no), units_sold_(unit_sold), revenue_(price * unit_sold) { }
SalesData(std::istream &);
std::string book_no() const { return book_no_; }
SalesData& Combine(const SalesData &);
private:
double AvgPrice() const
{ return units_sold_ ? revenue_/units_sold_ : 0; }
std::string book_no_;
unsigned units_sold_ = 0;
double revenue_ = 0.0;
DISALLOW_COPY_AND_ASSIGN(SalesData);
};
SalesData Add(const SalesData &, const SalesData &);
std::ostream& Print(std::ostream &, const SalesData &);
std::istream &Read(std::istream &, SalesData &);
}
#endif // !CPPPRIMER_SALESDATA_H_
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态
- 被封装的类的具体实现细节可以随时改变,则无须调整用户级别的代码。
友元的声明
- 友元的声明仅仅指定了访问的权限,我们必须在友元声明之外在专门对函数进行一次声明。
7.3 类的其他特性
令成员作为内联函数
- 定义在类内部的成员函数是自动 inline 的
- 我们可以在类定内部把 inline 作为声明的一部分显式地声明成员函数,我们也能在 类的外部用 inline关键字修饰函数的定义。
- 我们最好只在类外部定义的地方说明 inline,这样可以使类更容易理解
- inline 成员函数也应该与相应的类定义在同一个文件中。
重载成员函数
可变数据成员
我们希望能修改类的某个数据成员,即使是在一个const 成员函数中,我们可以在变量的声明中 加入
mutable 关键字做到这一点
一个可变数据成员永远不会 是 const,即使它是 const 对象的成员。
类数据成员的初始值
在C++ 11 新标准中,最好的方式是把默认值声明为 类内初始值。
当我们提供一个类内初始值时,必须用花括号或者 = 表示。
7.3.2 返回 *this 的成员函数
- 返回 *this 的成员函数返回类型引用的话,则返回的是左值,是对象本身,反之是对象的副本。
*从 const 成员函数返回 this
一个 const 成员函数如果以引用的方式返回*this,那么他从的返回类型是常量引用。
基于const 的重载
判断是否该函数为常量成员函数:
- 常量对象调用非常量版本的函数是不可用的,因此我们只能在一个常量对象上调用 const 成员函数。
- 非常量对象调用常量函数与非常量函数,最佳匹配是非常量成员函数。
建议:对于公共代码使用私有功能函数。
公共代码定义成一个单独的函数,是为了在实践中,重复调用这些函数,完成一组一组其他函数的“ 实际”工作。
7.3.3 类类型
- 即使两个类的成员列表完全一致,他们也是不同的类型。对于一个类来说,他们的成员与其他任何类(其他作用域)的成员都不是一回事。
类的声明
- 在类定义前进行声明是前向声明,在声明之后定义之前是一个不完全的类型,只知道其是一个类类型,但不知道包含那些成员。
不完全类型的使用情形:
- 可以定义指向这种类类型的指针或引用,也可以声明(但不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,创建其对象之前必须被定义过,而不呢仅仅声明,不清楚其存储空间大小。
7.3.4 友元再探
类可以将其他的类定义成友元,也可以把其他类(已经定义过的)的成员函数定义成友元。
此外友元函数定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
- 如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非共有成员在内的所有成员。
- 友元不具有传递性,每个类只负责控制自己的友元类或友元函数。
令成员函数作为友元
假设 A 为 B 的 func函数提供友元访问权限。
- 首先定义 B类,其中声明 func 函数,但不能定义它。在 func使用 A类的成员之前必须先声明
A类。 - 定义 A 类,包括对于 func 的友元声明
- 最后定义 func 此时它才可以使用 Screen 的成员。
函数重载与友元
对重载函数声明友元,仍然需要每个单独声明。
友元声明和作用域
- 友元本身不一定真的声明在当且作用域中,就算在类的内部中定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。
- 有些编译器并不强制 上述的友元限定规则。
7.4 类的作用域
作用域和定义在类外部的成员
- 在类的外部,成员的名字被隐藏起来了,一旦遇到了类名,定义的剩余部分就在类的作用域中
- 函数的返回类型通常出现在函数名之前,定义在类外部的函数,返回类型在类的作用域之前。
- 如果想用类内部定义的返回类型作为在类外部定义的成员函数的返回类型,需要在返回类型前指明哪个类定义了它。
7.4.1 名字查找与类的作用域
名字查找(寻找与所用名字最匹配的声明的过程)
- 首先,在名字所在的块中寻找其声明语句,只考虑名字的使用之前的声明
- 如果没找到,继续查找外层作用域
- 如果最终没有找到匹配的声明,程序报错
类的定义分两步处理:
- 编译成员的声明
- 知道类全部可见才编译函数体。
类型名要特殊处理:
类型名的定义通常出现在类的开始处,这样能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
-
首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
-
如果在成员函数中没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
-
如果类内也没找到该名字的声明,在成员函数定义之前的作用域继续查找。
-
不建议将局部变量的名字与成员的名字重复
-
我们可以显式地使用 this 指针强制访问成员。
类作用域之后,在外围的作用域查找
尽管外层的对象被隐藏掉了,但我们可以用作用域运算符 (::)访问它。
cursor = width * (::height);
在文件中名字的出现处对其进行解析
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域继续查找。
7.5 构造函数再探
7.5.1 构造函数初始值列表
- 以块内赋值操作初始化 类内成员的构造函数 与 构造函数初始值列表初始化 的区别完全依赖于数据成员的类型。
构造函数的初始值有时必不可少
如果成员是 const,引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
建议使用构造函数初始值
当有的类含有需要构造函数初始值的成员时,使用构造函数初始值能避免意想不到的编译错误。
成员初始化的顺序
- 构造函数初始值列表只说明用于初始成员的值,而不限定初始化具体执行顺序。
- 成员的初始化顺序与他们在类定义的出现顺序一致,第一个成员先被初始化,然后第二个,依次类推。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
默认实参和构造函数
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
SalesData(const std::string &s = ""): book_no_(s) { }
7.5.2 委托构造函数
C++ 11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它的一些职责委托给了其他构造函数。
SalesData(const std::string &book_no, unsigned unit_sold, double price) :
book_no_(book_no), units_sold_(unit_sold), revenue_(price * unit_sold) { }
SalesData() : SalesData("",0,0) {}
SalesData(const std::string &s) : SalesData(s,0,0) {}
SalesData(std::istream &is) : SalesData()
{ Read(is,*this); }
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。
在SalesData类中,受委托的构造函数体恰好是空的,如果有代码的话,先执行受委托的函数体代码,再把控制权交还给委托者的函数体。
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生
- 当我们在块作用域内不使用任何初始值定义一个 非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化:
-
在数组初始化的过程如果我们提供的初始值数量小于数组的大小
-
我们不使用初始值定义一个局部静态变量时
-
当我们书写 T() 的表达式要求显式地进行值初始化时。
-
在实际开发中,如果定义了其他构造函数,最好也提供一个默认的构造函数。
使用默认构造函数
SaleData obj;//默认初始化,调用默认构造函数
SaleData obj() ;//声明了一个返回值为 SaleData 的 函数
7.5.4 隐式的类类型转换
如果一个类只接受一个实参,则它定义了转换为此类类型的隐士转换机制,我们把这种构造函数称作转换构造函数。
SalesData& Combine(const SalesData &lhs);
SalesData(const std::string &s) : SalesData(s, 0, 0) {}
string null_book = "9-9999";
item.Combine(null_book);
我们可以直接使用 一个实参的构造函数来隐式转换 为类类型。
只允许一步转换
item.Combine("9-9999"); //这是不对的
我们隐式地把 字面值转换为 常量字符串,之后再隐式地转换为类类型,这时不被允许的
类类型转换不是总有效
是否要从一个 构造函数实参类型转换为 类类型取决于用户使用该转换的看法,该隐式转换的数据可能不符合条件,也可能会正确!!!
抑制构造函数定义的隐式转换
-
关键字 explict 只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无须指定 explicit。
-
只能在类内声明构造函数时 使用 explicit 关键字,在类外部定义时不应重复。
-
当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的方式使用,拷贝初始化是不被允许的。而且,编译器将不会在自动转换过程中使用该构造函数。
explicit SalesData(const std::string &s) : SalesData(s, 0, 0) {}
explicit SalesData(std::istream &is) : SalesData()
{ Read(is,*this); }
为转换显式地使用构造函数
我们可以直接使用 类的单参数构造函数接收 单参数完成显式构造。
item.combine (Sales_data(null_book));
标准库中含有显式构造函数的类
- 接受单参数的 const char * 的string 构造函数不是 explicit
- 接受一个容量参数的 vector 构造函数是 explicit 的。
7.5.5 聚合类
聚合类 使得用户可以直接访问成员,并且具有特殊的初始化语法形式。
当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是 public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有 virtual函数
struct Data {
int ival;
std::string s;
};
Data val = {0, “abcd”};
- 聚合类的初始值的顺序必须与声明的顺序一致。
- 如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化,数量不能超过类的成员数量
显式地初始化类的对象的成员存在 3个明显的缺点:
- 要求类的成员是 public ,不符合封装
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘记某个初始值,或者提供一个不恰当的初始值,所以这样的初始化的过程乏味容易出错。
- 添加删除一个成员,所有的初始化语句都需要更新。
7.5.6 字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。
复合下面的要求也是字面值常量类:
- 数据成员全部是字面值类型
- 类至少含有一个 constexpr 构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;如果成员属于类类型,则初始值必须用自己的 constexpr 构造函数
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数
- constexpr构造函数 必须初始化所有数据成员,使用初始值或者 constexpr 的构造函数吗,或常量表达式
- constexpr 构造函数用于生产constexpr对象以及 constexpr函数的参数或返回类型。
class Debug {
public:
constexpr Debug(bool b = true): hw_(b), io_(b), other_(b){ }
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_;
bool other_;
};
7.6 类的静态成员
类需要它的一些成员与类本身有直接的关系,但不是与类定各个对象都保持联系。
这时候我们将其 声明为 类的静态成员
声明静态成员
- 我们通过在成员的声明之前加上关键字 static 使其与类关联在一起。静态成员可以是 public,private,数据类型可以是常量,引用,指针或者类类型。
- 类的静态成员存在于任何对象之间,对象中不包含任何于静态数据成员有关的数据。
- 静态成员函数不与任何对象绑定在一起,不包含 this指针,不能声明其const静态成员函数。
定义静态成员
- 与类成员一样,类外部的静态成员必须指明成员所属的类名,static 关键字则只出现在类内部的声明语句中。
- 我们必须在类的外部定义和初始化每个静态成员,且只能被定义一次,与非内联的函数定义放在同一文件内部。
- 静态数据成员定义在任何函数之外,因此它将一直存在于程序的整个生命周期中。
- 静态类数据成员也能访问类内定义的所有的数据成员。
静态成员的类内初始化
- 我们可以为静态成员提供 const 的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr。
- 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义以下该成员,目的是防止其值不能替换的场景。
static constexpr char period = '1';
constexpr char SalesData::period;
静态成员能用于某些场景,而普通成员不能
- 静态数据成员可以是 不完全类型。比如:自身类类型,但是非静态成员则受到限制,只能声明成它所属类的指针或引用。
- 非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法提供一个对象以便从中获取成员的值,最终引发错误。但是静态成员是可以作为默认实参的。
第八关:IO库
8.1 IO类
IO库了与头文件 | |
---|---|
头文件 | 类型 |
iostream | istream,wistream 从流中读取数据 |
ostream,wostream 向流写入数据 | |
iostream,wiostream 读写流 | |
fstream | ifstream,wifstream 从文件读取数据 |
ofstream,wofstream 向文件写入 数据 | |
fstream,wfstream 读写文件 | |
sstream | istringstream,wistringstream 从 string 读取数据 |
ostringstream,wostringstream 向string 写入数据 | |
stringstream,wstringstream读写string |
- 为了支持宽字符的语言,编制看定义了一组类型和对象来操纵 wcahr_t 类型的数据。
- 宽字符版本的类型和函数的名字以一个 w 开始。
IO 类型间的关系
标准库使我们能忽略这些不同类型的流的差异,这都是由继承机制实现的,
利用模板,我们可以使用具有继承关系的类,而不必了解继承机制如何工作的细节。
8.1.1 IO对象无拷贝或赋值
- 不能以形参或者返回类型设置为流类型,进行 IO 操作的函数通常以引用方式传递和返回流。
- 读写一个 IO 对象会改变其状态,因此传递和返回的引用不能是 const 的。
8.1.2 条件状态
IO 类所定义的一些函数和标志,可以帮助我们访问和操纵流的条件状态
IO库条件状态 | |
---|---|
stm::iostate | srm 是一种 IO类型,iostate 是一种机器相关的类型,提供了表达条件状态的完整功能 |
strm::badbit | strm::badbit 用来指出流已经崩溃 |
strm::failbit | strm::failbit 用来指出一个 IO 操作失败了 |
strm::eofbit | strm::eofbit 用来指出流已经到达了文件的结束 |
strm::goodbit | strm::goodbit 用来指出流未处于错误状态。此值保证为 零 |
s.eof() | 若流 s 的eofbit置位,则返回 true |
s.fail() | 若流 s 的failbit置位或 badbit置位,则返回 true |
s.bad() | 若流 s 的badbit置位,则返回 true |
s.good() | 若流 s 处于有效状态,则返回 true |
s.clear() | 将流 s 的所有条件状态位复位,将流的状态设置位有效,返回 void |
s.clear(flags) | 根据给顶的 falgs标志位,将流 s 中对应条件状态位复位。flags 的类型为 strm:;iostate.返回void |
s.setstate(flags) | 根据给定的 flags 标志位,将流 s 中对应条件状态位置位。flags 的类型位 strm::iostate.返回void。 |
s.rdstate() | 返回 流的当前条件状态,返回值位 strm::iostate |
- 确定一个流对象状态的简单办法就是将其当作一个条件来使用
if(strm >> word)
查询流的状态
stm::iostate 是与IO定义的与机器无关的类型,它提供了表达流状态的完整功能,它是一个位集合。
- 可以使用位运算符一起来使用来一次性检测或设置多个标志位。
- 如果 badbit failbit eofbit 任意一个被置位,则检测流状态的条件都会失败。
- 使用 good 或 fail 是确定流总体状态的正确方法。
管理流的状态
auto old_state = cin.rdstate(); //返回当前流的状态
cin.clear(); //清除所有错误标志位。调用 good 会返回true
cin.setstate(old_state); //设置流的状态
8.1.3 管理输出缓冲
每个输出流都管理着一个缓冲区,用来保存程序读写的数据,有了缓冲的机制,操作系统就可以将程序的多个输出操作组合成单一的系统级写操作,会带来很大的性能提升。
导致缓冲刷新(即,数据真正写到输出设备或文件),原因有很多:
- 程序正常结束,作为 main 函数的return操作的一部分,缓冲刷新被执行。
- 缓冲区满时,需要刷新缓冲,而后新的数据才能继续写入缓冲区。
- 我们可以使用操作符入 endl 来显式地刷新缓冲区
- 在每个输出操作之后,可以利用 unitbuf 设置流的内部状态,来清空缓冲区,默认情况
对 cerr 是设置 unitbuf 的,因此写到 cerr 的内容都是立即刷新的。 - 一个输出流可能被关联到另一个流。在这种情况下,当读写被关联的流时,关联到的流的缓冲区会被刷新,默认情况下,cin 和 cerr 都关联到 cout ,因此读写 cin 或 写 cerr 都会导致 cout 的缓冲区刷新。
刷新输出缓冲区
-
flush 操纵符 刷新缓冲区,但不输出任何的字符
cout << flush; -
ends 操作符 输出 一个空字符,刷新缓冲区
unitbuf 操纵符
使用 unitbuf 操作符会告诉接下来的每一次写操作之后都会进行一次 flush 操作。而 nounitbuf 操纵符会
重置流,使其恢复之前的缓冲机制。
cout << std::unitbuf;
cout << std::nounitbuf;
如果程序崩溃,输出缓冲区不会被刷新
当一个程序崩溃时,它输出的数据很可能停留在缓冲区中等待打印。
关联输入和输出流
tie函数能使 输入流关联到一个输出流,接受一个流的地址,返回值位 指向这个流的指针,如果对象未关联到流,则返回空指针
ostream *old_tie1 = cin.tie(); //返回当且关联的输出流指针
ostream *old_tie2 = cin.tie(nullptr); //cin不再与其他输出流关联,返回的是之前关联到 cin 的流
cin.tie(&cerr);//cin与cerr关联
cin.tie(old_tie2); //cin重新与cout进行关联
8.2 文件输入输出
除了继承 iostream 类型的行为外, fstream 可以读写给定文件,且增加了一些新的成员来管理 与流关连 的文件。
fstream fstrm; 创建一个未绑定的文件流,fstream是头文件 fstream中定义的一个类型
fstream fstrm(s); 创建一个 fstream,并打开名未 s 的文件。
s 可以是 string 类型。或者是一个指向C风格字符串的指针,这些构造函数都是
expllicit的,默认的文件模式 mode 依赖于 fstream 的类型
fstream fstrm(s,mode); 与之前的构造函数类似,按指定 mode 打开文件
fstrm.open(s) 打开名为 s 的文件,并将文件与 fstrm绑定。 s 可以是一个string或者是c风格字符串
默认文件 mode 依赖于 fstream 类型。返回 void
fstrm.close() 关闭与 fstrm 绑定的文件。返回 void
fstrm.is_open() 返回一个 bool 值,指出与 fstrm关联的文件是否成功打开且尚未关闭。
8.2.1 使用文件流对象
- 当我们读写一个文件时,定义一个文件流对,并将对象与文件关联起来。
- 文件流对象定义了一个 open 成员函数,完成一些系统的操作,视情况为读和写。
ifstream in(file); //构造ifstream并打开 file文件
成员函数 open 和 close
ofstream out;
out.open(file); //打开指定文件
- 如果 open 失败,failbit 会被置位,进行 open 是否成功的检测通常是一个好习惯。
- 为了文件流关联到另一个文件,必须先关闭已经关联的文件。
自动构造和析构
当一个fstream对象被销毁时,close 会自动被调用。
8.2.2 文件模式
流通过关联的文件模式来指出 如何使用文件。
文件模式
in 以读方式打开
out 以写方式打开
app 每次进行写操作之前均定位到文件末尾
ate 打开文件后立即定位到文件末尾
trunc 截断文件
binary 以二进制方式 进行 IO
指定文件模式有以下限制:
- 只可以对 ofstream或 fstream 对象设定 out 模式
- 只可以对 ifstream 或 fstream 对象设定 in 模式
- 只有当 out 也被设定时才可设定 trunc 模式。
- 只要 trunc 没被设定,就可以设定 app 模式。 在app模式下,即使没有显式指定 out 模式,文件也
总是以 out 输出方式打开。 - 默认情况下,即使我们没有指定 trunc,以out模式打开的文件也会被截断,为了保留 out 模式之前打开的文件的内容,我们必须指定 app 模式,在文件末尾进行追加。
- ate 和 binary 模式可用于 任何类型的文件流对象,且可以与其他任何文件模式组合使用。
默认文件模式:
- ifstream 默认以 in模式打开
- ofstream 默认以 out 模式打开
- fstream 关联的文件以 in 和 out 模式打开。
以 out 模式打开文件会丢弃已有数据
保留 被 ofstream 打开的文件中已有数据的唯一方式是显式指定 app 或 in模式。
每次调用 open 时都会确定文件模式
每次打开文件时,都要设置文件模式,可能是显式地设置,也可能是隐式地设置,当程序未指定模式时,就使用默认值。
8.3 string 流
sstream 头文件定义了三个类型来支持 内存 IO,可以向string读写数据,就像string 是一个 IO流。
stringstream 特有的操作
sstream strm; strm是一个未绑定的stringstream 对象。sstream是头文件 sstream 中定义的一个类型
sstream strm(s); strm 是一个 stringstream对象,保存 string s 的一个拷贝。此构造函数是 explicit
strm.str() 返回 strm所保存的string 拷贝
strm.str(s) 将string s 拷贝到 strm 中。返回 void
8.3.1 使用istringstream
- 如果是对整个文本进行整理或处理,而其他一些工作是处理行内的单个单词时,通常可以使用 istringstream。
istringstream record("sss s s s s ");
string word;
while (record >> word) {
cout << word << ends;
}
此循环从 string 而不是标准输入中读取数据,当string中的数据全部读取后,会触发 文件结束的信号,再次读取操作会失败。
8.3.2 使用 ostringstream
我们希望逐步构造输出,希望最后一起打印时,ostringstream是很有用的。
例如我们向诸葛验证电话号码并改变其格式,如果所有的号码都是有效的,我们输出它。
ostringstream formatted_people;
for (const string& nums : people_phones) {
formatted_people << nums << " ";
}
cout << formatted_peopl.str();
第九关:顺序容器
- 容器就是特定类型对象的集合。
- 顺序容器为程序员提供了控制元素存储和访问顺序的能力
9.1 顺序容器概述
所有的顺序容器都提供了快速顺序访问元素的能力。
但是,这些容器都在以下方面有不同的性能折中:
- 向容器添加或从容器中删除元素的代价
- 非顺序访问容器中元素的代价。
vector 可变大小数组,支持快速随机访问。在尾部之外的位置插入删除可能很慢
deque 双端队列。支持快速随机访问。在头尾位置插入/删除很快
list 双向链表,只支持双向顺序访问,在list任何位置进行插入删除都很快
forword_list 单向链表,只支持单向顺序访问,在链表任何位置进行插入/删除操作速度都很快
array 固定大小数组,支持快速随机访问,不能添加或者删除元素
string 与vector 相似的容器,但专门用来保存字符。随机访问速度块,在尾部插入/删除速度快。
确定使用哪种顺序容器
通常情况下,选用 vector 是最好的选择,除非你有很好的理由选择其他容器。
- 要求随机访问,选择 vector deque
- 程序要求在容器的中间插入或者删除,使用 list 或 forward_list.
- 程序需要在容器的头尾插入删除,而不会在中间插入删除选择 deque
- 如果程序只有在读取时才在容器中间位置插入元素,随后需要随机访问元素
则:
- 确定是否在中间位置添加元素,处理数据时 通常很容易在 vector 追加数据,sort排序避免中间位置插入数据
- 必须在中间位置插入数据,考虑输入阶段使用 list,一旦输入完成,将 list 的内容拷贝到vector 中
一般来说,应用中占主导地位的操作(执行的访问操作更多还是插入/删除更多)决定 了容器类型的选择。
建议:当我们不确定应该使用哪种容器,就只适用 vector 和 list就给够了。
9.2 容器库概览
- 某些操作是所有容器类型都提供的
- 另外一些操作仅仅针对于 顺序容器 ,关联容器,无序容器
- 还有一些操作只适合一小部分的容器。
当顺序容器构造函数接受一个 容器大小参数,它使用了元素类型的默认构造函数,没有默认构造函数的元素我们给与其初始化。
类型别名
iterator 此容器类型的迭代器类型
const_iterator 可以读取元素,但不能修改元素的迭代器类型
size_type 无符号整数类型,足够保存此种容器类型最大可能容器的大小
difference_type 带符号整数类型,足够保存两个迭代器之间的举例。
value_type 元素类型
reference 元素的左值类型,于 value_type& 含义相同
const_reference 元素的 const 左值类型,const value_type&
构造函数
C c; 默认构造函数,构造空容器
C c1(c2); 构造 c2 的拷贝 c1
C c(b,e); 构造 c,利用迭代器 b与 e 指定范围内的元素拷贝到 c
C c{a, b, c....} 列表初始化 c
赋值与swap
c1 = c2 将c1中的元素替换成 c2 的元素
a.swap(b) 交换 a 和 b 的元素
swap(a,b); 与上面等价
大小
c.size() c中元素数目 ,forward_list 不支持
c.max_size() c中可保存的最大元素数目
c.empty() 若 c 中存储了元素,返回 false,否则返回 true
添加/删除元素(不适用于 array)
//在不同容器中,这些操作的接口都不同
c.insert(args) 将args 的元素拷贝进 c
c.emplace(inits) 使用 inits 构造 c 中的一个元素
c.erase(args) 删除 args 中的指定元素
c.clear() 删除 c 的全部元素,返回 void
关系运算符
== != 所有容器都支持该运算符
<,<=,>,>= 关系运算符(无序容器不支持)
获取迭代器
c.begin(),c.end() 指向c首元素与尾元素之后位置的迭代器
c.cbegin() , c.cend() 返回 const_iterator
反向容器的额外成员 (不支持 forward_list)
reverse_iterator 按逆序寻址元素的迭代器
const_reverse_iterator 不能修改元素的逆序迭代器
c.rbegin(),c.rend() 指向c的尾元素与首元素之前位置的迭代器
c.crbegin(),c.crend() 返回 const_reverse_iterator
9.2.1 迭代器
- 如果一个迭代器提供某种操作,那么提供相同操作的迭代器对这个操作的实现方式是相同的
- 标准库容器类型上的所有迭代器都允许我们访问容器中的元素
- forward_list 迭代器不支持递减运算符
迭代器范围
迭代器返回的概念是标准库的基础
【begin,end)
- 左闭右开的区间是迭代器的元素范围
- 他们指向同一个容器的元素,或者是容器最后一个元素之后的位置
- 我们可以通过反复递增begin 来到达 end。换句话说,end 不在 begin 之前。
使用左闭右开范围蕴含的编程假定
3 种方便的性质
- 如果begin 与 end相等,范围为空 (利于条件判定)
- 如果 begin 与end 不等,则范围至少包含一个一个元素,且begin指向该范围种第一个元素
- 我们可以对 begin 递增若干次,使得 begin == end
9.2.2 容器类型成员
9.2.3 begin 和 end成员
begin 与 end就是指向容器第一个元素与最后一个元素的尾后指针,用途是形成左闭右开的容器元素范围。
- 当不需要写访问时,应使用 cbegin 和 cend
- 可以将 iterator 转换为 const_iterator,反之不行
9.2.4 容器定义和初始化
每个容器类型都定义了一个默认的构造函数,(除了array)其他容器的默认构造函数都会创建一个指定类型的空容器,且都可以接受指定容器大小与元素初始值的参数。
C c; 默认构造函数,构造空容器
C c1(c2); 构造 c2 的拷贝 c1
C c(b,e); 构造 c,利用迭代器 b与 e 指定范围内的元素拷贝到 c
C c{a, b, c....} 列表初始化 c
C seq(n) seq包含 n 个元素,都进行了值初始化,此构造函数是 explicit的
C seq(n,t) seq 包含 n 个初始化为值 t 的元素。
- 为了创建一个容器为另一个容器的拷贝,容器的类型与其元素类型必须匹配
- 当利用迭代器参数拷贝一个范围时,就不要求容器类型是相同的了。
- 新容器与原容器的元素类型也可以不同,只需要能拷贝的元素转换为初始化容器元素的类型。
列表初始化
使用列表初始化初始化容器,是显式地制定了其容器种每个元素的值。
与顺序容器大小相关的构造函数
C seq(n) seq包含 n 个元素,都进行了值初始化,此构造函数是 explicit的
C seq(n,t) seq 包含 n 个初始化为值 t 的元素。
-
如果元素类型是内置类型或具有默认构造函数的类类型,可以只为构造函数提供一个容器大小参数,标准库会创建一个值初始化器初始化他们
-
如果没有默认构造函数,除了大小参数外,再次指定一个显式的初始值。
9.2.5 赋值与 swap
- 赋值运算符将左边容器种的全部元素替换成右边容器种元素的拷贝。
容器赋值与swap操作
c1 = c2 将c1中的元素替换成 c2 的元素
a.swap(b) 交换 a 和 b 的元素
swap(a,b); 与上面等价
assign操作不适合关联容器和 array
seq.assign(b,e) 将 seq 种的元素替换成 b 和 e 所表示的范围种的元素。
迭代器 b 和 e 不能指向 seq 种的元素
seq.assign(il) 将 seq 种的元素替换成初始化列表 il种的元素
seq.assign(n,t) 将seq种的元素替换成 n 个值为 t 的元素。
注意: 赋值运算会导致左边容器内部的迭代器,引用,指针失效,而 swap 操作将容器内容交换并不与会导致指向容器的迭代器,引用和指针失效。(array 和 string 的情况除外)
使用assign (仅顺序容器)
seq.assign(il) 允许从 类型不同但是相容的类型赋值,由于旧元素被替换,因此传递给 assign 的迭代器不能指向调用 assign 的容器。
从 list names 元素 assign 给 vector<const char*> oldstyle 的元素是正确的。
使用 swap
- 调用swap 交换两个容器的操作很快 ----元素并未交换,swap 只是交换了 两个容器的内部数据结果。
- 除了 array ,swap 不对任何元素进行拷贝,删除或插入操作,因此可以保证常数时间完成。
元素不会移动的事实: 除了 string ,指向容器的迭代器,引用和指针在swap 操作之后不会失效,他们仍然指向swap操作前的那些元素,但是 swap 之后,这些元素已经属于不同的容器了
- 统一使用非成员版本的swap 是一个好习惯。
9.2.6 容器大小操作
除了一个forward_list 例外,每个容器都有三个相关的操作
成员函数
size()
emplty()
max_size() 返回一个等于等于该类型容器所能容纳的最大元素数的值。
9.27 关系运算符
-
每个容器类型都支持 相等运算符 = or !=
-
除了无序关联容器所有的容器都至此关系运算符(>,>=,<,<=),关系运算符两边的运算对象必须是相同类型的容器,且保存相同类型的元素。
比较两个容器:
- 如果两个容器具有相同大小且所有元素都两两对应相等,则这两个容器相等,否则两个容器不等。
- 如果两个容器大小不同,较小的容器与最大的容器的对应元素都相等,则较小容器小于较大容器。
- 如果两个容器都不是另一个容器的前缀子序列,则比较结果取决于不相等的元素的比较结果。
容器的关系运算符使用元素元素的关系运算符进行比较
只有器元素类型定义了相应 的比较运算符,我们才可以使用关系运算符来比较两个容器。
9.3 顺序容器操作
9.3.1 向顺序容器添加元素
所有标准库容器都提供灵活的内存管理,在运行时候可以动态添加或删除元素来改变容器的大小。
这些操作会改变容器的大小;array 不支持这些操作。
forward_list 有自己专属版本的 insert 和 emplace
forward_list 不支持 push_back 和 emplace_back;
vector 和 string 不支持 push_front 和 emplace_front;
向顺序容器添加元素的操作
c.push_back(t) 在 c 的尾部创建一个值未 t 或由 args创建的元素,返回void
c.emplace_back(args)
c.push_front(t) 在 c 的头部创建一个值未 t 或由 args创建的元素,返回void
c.emplace_front(args)
c.insert(p,t) 在 p 位置之前创建一个 值为 t或者由 args创建的元素,返回新添加的元素迭代器
c.emplace(p,args)
c.insert(p,n,t) 在p位置之前插入 n 个值为t的元素,返回指向新添加的第一个 元素 的迭代器,若 n 为 0,返回 p
c.insert(p,b,e) 将迭代器 b,e 范围内的元素插入到迭代器 p所指向的元素之前,
b和e不能指向 c 中的元素,返回指向新添加的第一个元素的迭代器
c.insert(p,il) il是花括号包围的元素值列表,将这些给定值插入到迭代器 p 指向的元素之前,若列表为空,返回p
向一个vector,string,deque 插入元素会使所有只想容器的迭代器,引用与指针失效。
- 在vector 和string 的尾部之外的任何位置,deque 首尾之外的任何位置添加元素,都需要移动元素。
- 移动元素添加元素可能引起 整个对象存储空间的重新分配,重新分配对象空间需要分配新内存,并且把元素从旧空间移动到新空间中。
概念: 容器元素是拷贝,而不是对象本身
使用 emplace 操作
新标准引入了 3个新成员 – emplace_front,emplace 和 emplace_back,这些操作构造而不是拷贝元素。
emplace 函数在容器中直接构造元素,传递给 emplace 函数的参数必须与元素类型的构造函数相匹配。
9.3.2 访问元素
在顺序容器中访问元素的操作
c.back() 返回尾元素的引用
c.front() 返回头元素的引用
c[n] 返回下标为 n 的元素引用,n是无符号整数,n大于 c.size(),函数行为未定义
c.at(n) 返回下标为 n 的元素的引用,如果下标越界,抛出 out_of_range异常
- 访问成员函数返回的是引用
- 如果我们确保下标是合法的,使用 at成员函数,越界后,会抛出异常。
9.3.3 删除元素
在顺序容器中删除元素
c.pop_back() 删除 c 的尾元素,c为空则函数行为未定义。函数返回void
c.pop_front() 删除 c 的首元素,若c为空,则函数行为未定义,函数返回void
c.erase(p) 删除迭代器 p 的元素,返回一个指向被删元素之后的元素的迭代器。
c.erase(b,e) 删除迭代器 b,e 的所指范围元素,指向最好一个被删除元素之后的迭代器
c.clear() 删除所有元素,返回void
- 删除 deque 中除首位位置之外的任何元素都会使所有迭代器,引用指针失效,指向vector或 string 中删除 点之后位置的迭代器,引用和指针都会失效。
- 删除元素的成员函数并不检查参数,在删除元素之前, 程序员需要确保他们是存在的
9.3.4 特殊的 forward_list 操作
9.3.5 改变容器大小
c.resize(n) 调整c的大小为 n 个元素,若 n<c.size(),则多出的元素被丢弃,若必须添加
新元素,对新元素进行值初始化。
c.resize(n,t) 调整 c 的大小为n个元素,任何新添加的元素都初始化为值 t
如果 resize 缩小容器,则指向被删除元素的迭代器,引用指针都会失效,对vector,string,deque进行resize可能导致迭代器,指针引用失效。
9.3.6 容器操作可能使迭代器失效
添加元素:
- 如果容器是vector 和 string,且存储空间重新分配,则指向改容器的迭代器,指针引用全部失效,
如果存储空间没有被分配,则插入位置之前的元素的迭代器仍然有效。 - deque,插入到除首位之外的任何位置都会导致迭代器,指针和引用失效。如果首尾添加元素,迭代器会失效,但是存在的元素的引用与指针不失效。
删除元素:
- deque,删除首尾之外的任何位置都会导致迭代器,指针和引用失效。如果首尾删除元素,迭代器会失效,但是存在的元素的引用与指针不失效。
- 对于 vector 与 string ,指向被删元素之前的迭代器与指针引用都有效。
注意:当我们删除元素,尾喉迭代器总会失效
建议: 我们在每次改变容器的操作之后都应该正确地重新定位迭代器。
不要保存 end 返回的迭代器
在 进行插入删除操作时,保存的局部 end 可能会失效,因此不应该保存,要重新获取
9.4 vector对象是如何增长的
vector 与 string的实现通常会分配比新的空间需求更大的内存空间,容器预留这些空间作为备用,可以保存更多的新元素。这样,就不需要每次添加新元素都重新分配容器的内存空间了。
管理容器的成员函数
容器大小管理操作
c.shrink_to_fit() 请将capacity()减少为 size() 相同大小,仅仅为请求
c.capacity() 不重新分配内存空间的化,c可以保存多少元素
c.reserve(n) 分配至少容纳n个元素的内存空间
vector 内存分配策略遵守规则:只有当迫不得已时才可以分配新的内存空间。
9.5 额外的 string 操作
string 剪切追加替换操作
substr操作:
s.substr(pos,n) 返回一个string,从 pos 开始n 个字符的拷贝,pos默认值为0,n默认值为 s.size() =pos
append操作
s.append(args) :将 args追加到 字符串尾部
replace 操作
s.replace(range,args) :将范围内的字符删除并替换为args的字符。
string 搜索操作
- string 搜索操作返回的是 string::sizetype值,该值是一个 unsigned类型
- 搜索失败,则返回一个名为 string::npos 的static 的静态成员。
s.find(args) 查找 s 中 args第一次出现的位置
s.rfind(args) 查找 s 中 args 最后一次出现的位置
s.find_first_of(args) s中查找 args 任何一个字符第一次出现的位置
s.find_last_of(args) s中查找 args 任何一个字符最后一次出现的位置
s.find_first_not_of(args) 在 s中查找第一个不在 args 中的字符
s.find_last_not_of(args) 在s中查找最后一个不在 args 中的字符
val 与 string 的相互转换
to_string(val) :得到字符串
stoi,stol,stof
第十关:泛型算法
10.1 概述
大多数算法都定义在 头文件 algorithm中,标准库还在头文件 numeric中定义了一组数值泛型算法
算法如何工作
算法并不依赖容器保存的元素类型,只要有一个迭代器能访问元素即可。
迭代器令算法不依赖容器,但算法依赖于元素类型的操作
大多数算法提供了使用元素类型的运算符来进行的比较,但我们将会看到,我们可以允许自定义的操作来 代替默认的运算符。
10.2 初识别泛型算法
10.2.1 读元素的算法:
读容器的 迭代器应该是常量迭代器类型
只接受单一迭代器来表示第二序列的算法,都假定第二个序列至少与第一个一样长
accumulate(b,e,默认值) numeric 返回容器的和
equal(b1,e1,b2) 两个容器进行对比
10.2.1 写元素的算法:
fill(b,e,设置值) 容器区间的值设置为设置值
fill_n(dest,n,val) 从dest开始的n个元素设置为val,算法不检查 n 是否合法
copy(b1,e1,des) 将容器范围内的元素,拷贝到des所指的目的序列,
传递给copy的目的序列至少要包含于 输入序列一样多的元素
介绍 back_inserter
保证算法有足够空间来容纳输出数据的方法是 使用插入迭代器。
通常我们通过一个迭代器向容器元素赋值时,值被赋予迭代器所指的元素,而当我们通过一个 插入迭代器进行赋值,则一个赋值号右侧相等的元素被添加到容器中。
vector<int> vec;
auto iter = back_inserter(vec);
*iter = 10; //向vec尾部插入 10
fill_n(back_inserter(vec),10,0);
10.2.3 重排容器的算法
sort: 会重排输入序列的元素,利用 元素之间的 < 运算符实现排序的
使用unique
unique 会将相邻的重复项都“”消除“”,返回一个指向不重复值范围末尾的迭代器。
10.3 定制操作
算法使用元素类型的 运算符来完成比较操作,标准库还为这些算法提供了额外的版本,允许我们自定义操作来替换默认运算符。
10.3.1 向算法传递函数
谓词
- 有一元于二元谓词之称,接受谓词参数的算法对输入序列中的元素调用谓词。
排序算法
stable_sort:可以使具有相同长度的元素按照字典序排列。
划分算法
标准库定义了 partition ,接受谓词,对容器内容进行划分,使得谓词等于 true 的 值会排在容器的前半部分,否排在容器后半部分,返回一个指向 最后一个使谓词为true 的元素之后的位置。
10.3.2 lambda 表达式
有时我们希望谓词能多接受一些参数
介绍lambda 表达式
我们可以向一个算法传递任意类别的可调用对象。
lambda 表达式表示一个可调用的代码单元,我们可以将其理解为一个未命名的内联函数
- lambda 具有一个返回类型,参数列表,以及函数体,但与函数不同的是,lambda 是可以定义在函数内部。
[ capture list](parameter list) -> return type { function body }
- capture list 捕捉列表是一个 lambda 所 在函数中定义的局部变量的列表
- lambda 表达式必须使用尾置返回
- lambda 可以忽略参数列表(空列表)和返回类型(void),但必须永远包含捕获列表和函数体
向lambda 传递参数
- 调用一个 lamada 时给顶的实参用来初始化 lamada 的形参,实参于形参的类型必须匹配
- lambda 不能有默认参数,实参形参数目须相等
- 空捕捉列表表明此 lambda 不使用 它所在函数中的任何局部变量
使用捕获列表
一个 lambda 表达式只有在捕获列表中捕获一个它所 在函数中的局部变量,才能使用。
[ sz ] (const string& s)
{ return a.size() > = sz; }
捕获列表只用于局部非 static变量, lambda 类直接使用 局部 static 变量和它所在函数之外声明的名字。
for_each 算法
利用该算法接受一个可调用对象,并对输入序列中每个元素调用此对象。
10.3.3 lambda 捕获与返回
当定义一个 lambda时,编译器生成一个与 lambda 对应的新的类类型。
- 当利用 lambda 传递参数时,传递的是由此类型生成 的类类型的未命名对象,当用 auto定义一个用lambda 初始化的变量时,定义了一个该对象。
- 默认情况下,lambda 生成的类都包含了一个对应该 lambda 所捕获的变量的数据成员,与类相同,该成员在 lambda对象 创建时被初始化。
值捕获:
size_t v1 = 42;
auto f = [v1] { return v1; };
v1 = 0;
由于 该捕获变量的值是 在 lambda 对象创建前拷贝,此后修改是不会影响到 lambda内对应的值。
引用捕获
我们以 &来进行引用捕捉,但必须保证lambda 执行时变量是存在的。
如果lambda 的返回值作为函数返回值,lambda不能返回局部变量的引用
建议:尽量保持 lambda 的变量捕捉简单化,减少其捕获的数据量,来避免潜在的捕获导致的问题,如果可能的话,避免捕获引用与指针
隐式捕获
我们可以让编译器根据 lambda 体内的代码来推断我们要使用那些变量。
为了指示编译器推断捕获列表,应该在捕获列表中写一个 & 或 =。告诉编译器采用的捕获方式。
size_t v1 = 42;
auto f = [=] { return v1; };
如果我们想混用隐式捕获和显式捕获的时候,有以下规则:
lambda捕获列表
[] 空捕获列表,lambda 不能使用所指函数中的变量。一个lambda 只有捕获变量后才能使用他们
[names] names是以逗号分隔的名字列表,这些名字都是lambda所指函数的局部变量,默认情况下拷贝捕获,如果前面加上了&,使用引用捕获
[&] 隐式捕获列表,采用引用捕获方式,lambda体中使用的来自函数的实体都采用引用方式
[=] 隐式捕获列表,采用拷贝捕获方式,lambda体中使用的来自函数的实体的值
[&,identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自 所在函数的变量,这些变量采用值捕获方式,而
任何隐式捕获的变量都采用引用方式捕获,identifier_list 的名字前面不能加上 &
[=,identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自 所在函数的变量,这些变量采用引用捕获方式,而
任何隐式捕获的变量都采用值方式捕获,identifier_list 的名字前面都必须加上 &,名字不能包括this
可变 lambda
我们希望以值拷贝的变量能够改变其被捕获的变量的值,要在参数列表前面加上 mutable。
指向lambda 返回类型
- 如果一个 lambda 体包含 return 之外的任何语句,则编译器都假定 lambda 返回 void,与其他返回 void的函数类似,被推断返回 void 的lambda 不能返回值
- 建议,始终为其定义 尾后返回类型
10.3.4 参数绑定
lambda 的适用场合: 仅有一两个地方使用的简单操作,我们定义其 lambda 表达式,其他,我们定义为函数。
标准库bind 函数
为了解决能使函数能替换 带捕获 的lambda 表达式,且形参列表数量不被改变,我们引入了 标准库
bind 函数。
- bind 定义在 functional 中,可以把 bind 函数看成一种 通用的函数适配器。
- bind 接受一个可调用对象,生成一个 新的可调用对象来 “适应” 原对象的参数列表。
auto newCallable = bind(callable, arg_list);
arg_list 中的参数包含了形如 _n的名字,其中 n 个整数。 这些参数都是占位符,表示 newCallable的参数。
举例子:
auto check6 = bind(check_size,_1,6);
其中,_1是占位符,代表了check_size_的第一个参数
使用 placeholders 名字
placeholders 命名空间也定义在 functional 头文件中
名字 _n 都定义在一个名字为 placeholders 的命名空间中,而此命名空间本身定义在 std 命名空间,为了使用该名字,两个命名空间都要写。
using std::placeholders::_1;
用 bind 重排参数顺序
当我们利用占位符 _1,_2无实参传入时,值得是 传入的函数的参数。
我们交换 _2,_1 的位置,能实现参数顺序重排
sort (words.begin(), words.end(), isShorter);
sort (words.begin(), word.end() ,bind(isShorter,_2,_1))
绑定引用参数
默认情况下, bind 的非占位符的参数被拷贝到 bind 的返回的可调用对象中,如果绑定参数为引用类型
我们应该在其参数前面加上(ref 或者 cref),函数 ref 与 cref均定义在头文件 functional中
ostream &Print(ostream &os, const string &s, char c) {
return os << s << c ;
}
int main(int argc,char **argv) {
vector<string> words;
std::for_each(words.cbegin(), words.cend(), std::bind(Print, ref(cout), _1,' '));
std::for_each(words.cbegin(), words.cend(), [&os,c](const string& s){ os << s << c;})
}
占位符占的是实际 谓词的参数。
其余均为 绑定值,可以说是捕捉值
新标准的 C++程序应该尽量使用 bind。
10.4 再探迭代器
标准库在头文件 iterator 中还定义了 额外的迭代器
- 插入迭代器: 这些迭代器被绑定到一个容器上,可以向容器插入元素
- 流迭代器 :这些迭代器被绑定到输入或者输出流上,可用来遍历所有关联 的 IO 流。
- 反向迭代器:这些迭代器向后而不是向前移动
- 移动迭代器:这些专用的迭代器不是拷贝其中的元素
10.4.1 插入迭代器
当我们通过一个插入迭代器进行赋值时,该迭代器调用容器操作来向给顶容器的指定位置插入一个元素。
it = t 在it指定的当且位置插入值 t。假定 c 是it绑定的容器,
依赖于插入迭代器的不同种类,此赋值分别调用 c.push_back(t),
push_front(t), c.insert(t,p);
*it,++t,it++ 这些操作虽然存在,但不会使it做任何事情,每个操作都返回 it
- back_inserter 创建一个使用 push_back 的迭代器
- front_inserter 创建一个使用 push_front 的迭代器
- inserter 创建一个使用 insert 的迭代器,此函数接受第二个参数,这个参数必须是一个指向给定容器
的迭代器。 元素将被插入 到给定迭代器所表示的元素之前。
vector<string> words;
auto inserter = std::back_inserter(words);
*inserter = "ss";
使用插入迭代器插入之后迭代器仍然指向原来的位置
10.4.2 iostream 迭代器
istream_iterator 读取输入流
ostream_iterator 向一个输出流写数据
这些迭代器将他们对应的流 当做一个特定类型的元素序列来处理。使用流迭代器,我们可以用泛型算法
从流对象读取数据 以及向其写入数据。
istream_iterator操作
- 读写的对象类型需要定义 >> 运算符
- 默认初始化流迭代器,创建一个 当作尾后值的迭代器
istream_iterator<int> in_it(cin); //从cin 读取
istream_iterator<int> int_eof;
ifstream in("afile");
istream_iterator<string> str_it(in) //从文件中读取字符
- 一个绑定到流的迭代器,在流遇到文件尾巴或者是 IO 错误,迭代器的值与尾后迭代器相等。
- 我们可以使用元素范围的流迭代器来构造容器,这两个迭代器 是 istream_iterator。
istream_iterator<int> in_it(cin), eof;
vector<int> ivec(in_iter,eof);
istream_iterator操作
istream_iterator<T> in(is) in从输入流 is 读取类型T的值
istream_iterator<T> end 尾后位置的读取 T 值的输入迭代器
in1 == in2 in1与in2 是相同类型,如果是尾后迭代器或绑定到相同的输入,则两者相等
in1 != in2
*in 返回从流中读取的值
in->mem 与(*in).mem含义相同
++in, in++ 使用元素类型所定义的 >> 运算符从输入流读取下一个值
istream_iterator 允许使用懒惰赋值
- 标准库并不保证迭代器立即从流中读取数据,只有我们使用迭代器时才阵阵读取。
- 这种方式在我们没有使用就销毁迭代器或者两个不同对象读取同一个流,这种方式显得很安全以及性能。
ostream_iterator
- 对含有输出运算符(<<)的类型可以定义 ostream_iterator
- 可以提供给 ostream_iterator的第二参数,其是可选的字符串类型,在输出每个元素后都会打印该字符串
- 必须将 ostream_iterator 绑定到一个指定的流,不允许空的或指向尾后位置
ostream_iterator 操作
ostream_iterator<T> out(os); out将类型为T 的值写入到输出流 os中
ostream_iterator<T> out(os,d) out将类型为 T 的值写入到输出流 os 中,每一个值后面都跟一个d
out = val 用 << 将val 写入到 out 绑定的流中
*out,++out, out++ out 不做任何事情
使用:
std::ostream_iterator<string> out(cout," ");
*out++ = "sss";
10.4.3 反向迭代器
反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。
-
反序迭代器递增递减的操作会颠倒过来
-
反向迭代器只能从 ++ 也支持 – 的迭代器来定义
-
我们能利用 reverse_iterator 的 base 成员来转换回一个普通正向迭代器,指向的是相邻位置而不是相同位置
反向迭代器的目的是表示元素范围,其不是对称的:当我们从普通迭代器初始化一个反向迭代器,或相反,结果迭代器与原迭代器指向的并不是相同的元素【左闭右开与左开右闭】
10.5 泛型算法结构(总结)
10.5.1 迭代器类别
输入迭代器 只读,不写,单遍扫描,只能递增
输出迭代器 只写,不读,单遍扫描,只能递增
前向迭代器 可读写;多遍扫描,只能递增
双向迭代器 可读写,多遍扫描,可递增递减
随机访问迭代器 可读写,多遍扫描,支持全部迭代器操作
当向一个算法提供错误类别的迭代器的问题,大多数不会给出警告。
输入迭代器:可以读取序列中的元素
- 用于比较两个迭代器的 相等于不等于运算符(==,!=)
- 用于推进迭代器的前置于后置运算符(++)
- 用于读取元素的解引用运算符(*),解引用只回出现在赋值运算符的 右侧。
- 箭头运算符(->),等价于 (*it).member,即解引用迭代器,并提取对象的成员
输入迭代器只支持顺序访问,对于一个输入迭代器 *it++ 保证是有效的,递增他可能导致所有的指向流的迭代器失效,不能保证输入迭代器的状态可以保存下来访问元素,因此只能单遍扫描算法
算法: find accumulate
输入迭代器: istream_iterator
输出迭代器:可以看作是输入迭代器的补集–只写而不读元素,额外支持
- 用于推进迭代器的前置和后置运算符(++)
- 解引用运算符,只出现在 等号左侧
单遍扫描
算法: copy 的第三个参数
输出迭代器: ostream_iterator
前向迭代器
- 沿着一个方向移动
- 支持所有的输入于输出迭代器的操作,可以多次读写同一个元素,我们可以保存前向迭代器的状态
多遍扫描
算法: replace
forward_list 上的迭代器
双向迭代器:可以正向反向读写序列中的元素
- 额外支持 递减运算符
算法 reverse 要求双向迭代器,除了forward_list 标准库都符合双向迭代器要求的迭代器
随机访问迭代器: 在常量时间内访问序列中任意元素的能力
支持所有双向迭代器的能力。
- 支持比较运算符 (<,<=,>,>=)
- 迭代器和整数的加减运算(+,+=,-,-=)
- 两个迭代器之间的减法运算符 (-)
- 下标运算符 (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)
向输出迭代器中写入数据的算法都假定目标空间足够容纳写入的数据。
- 如果 dest 是一个直接指向容器的迭代器,那么算法将输出数据写道容器中已存在的元素中
- dest 绑定一个插入迭代器或 ostream_iterator,插入迭代器会将新元素都添加到容器中,保证空间是足够的,ostream_iterator 会将数据写到一个输出流中。
copy(lst.begin(), lst.end(), inserter(lst3,lst3.begin()))
std::ofstream ofs("",std::ios::app);
if (ofs.good()) {
std::ostream_iterator<string> out_it(ofs);
vector<string> words;
std::copy(words.cbegin(), words.cend(), out_it);
}
10.5.3 算法命名规范
使用重载形式传递谓词的算法
unique(beg,end);
unique(beg,end, comp);
_if 版本的算法
接受一个元素值的算法通常有另一个不同命的版本,该版本接受一个谓词,并且算法名 有附加的 _if
find(beg, end, val);
find_if(beg, end, pred);
区分拷贝元素的版本和不拷贝的版本
写到额外的目的空间的算法都在后面附加一个 _copy
reverse(beg,end);
reverse(beg, end, dest);
一些算法同时提供了 _copy 和 _if 版本,这些版本接受一个 目的位置迭代器与一个谓词。
remove_if(v1.begin(), v1.end() , [ ] (int i) { return i%2; });
remove_if(v1.begin(), v1.end() , back_inserter(v2),[](int i ){ return i%2; });
10.6 特定容器算法
对于 list 与 forward_list,应该优先使用成员函数算法而不是通用算法
list与forward_list成员函数版本的算法
lst.merge(lst2) 将来自 lst2 的元素合并到 lst 中,lst与lst2都必须是有序的
lst.merge(lst2,comp) 元素将从 lst2 删除,合并后,lst2 边为空,第二个版本自定义
lst.remove(val) 调用 erase 删除指定 == 或 令一元谓词为真的每个元素
lst.remove_if(pred)
lst.reverse() 反转lst 中元素的顺序
lst.sort() 使用 < 或给顶操作排序元素
lst.sort(comp)
lst.unique() 调用 erase删除同一个值的连续拷贝
lst.unique(pred)
splice 成员
我们可以理解其是移动函数,将一个链表的元素移动到指定位置
lst.splice(args), flst.splice_after(args)
第十一关:关联容器
-
关联容器支持高效的关键字查找和访问
-
关联容器的主要类型是 map与 set
map 的元素是一些关键字(key-val)对,关键字起到索引的作用,值表示与索引相关的数据
set 中的元素只包含一个关键字,set 支持高效的关键字查找操作,查找一个关键字是否在 set中 -
无序容器定义在 unordered_set 与 unordered_map 头文件中
-
有序容器定义在 map和 set中
关联容器类型 |
---|
按关键字有序保存元素 | |
---|---|
map | 关联数组;保存关键字-值对 |
set | 关键字即值,即只保存关键字的容器 |
multimap | 关键字可重复出现的 map |
multiset | 关键字可重复出现的 set |
无序集合 | |
unordered_map | 用哈希函数组织的 map |
unordered_set | 用哈希函数组织的 set |
unordered_multimap | 用哈希组织是map,关键字可以重复出现 |
unordered_multiset | 用哈希组织的 set;关键字可以重复出现 |
11.1 使用关联容器
使用 map 与 set
- map 的元素均为 pair 类型对象是键值对象,pair的first成员保存关键字,second 保存值
- set 的元素仅仅是 数据类型的对象
map<string,size_t> word_map;
set word_set;
11.2 关联容器概述
- 关联容器不支持顺序容器位置相关的操作,因为关联容器是根据关键字进行存储的,关键字代表了索引,因此这些操作是没有意义的
- 关联容器不支持 构造函数或插入操作这些接受一个元素值 与数量值 的操作。
- 关联容器 的迭代器是双向的。
11.2.1 定义关联容器
每个关联容器都定义了一个默认构造函数,它创建了一个指定类型的空容器。
- 可以将关联容器初始化为另外同一个类型容器的拷贝,从是从一个值范围初始化关联容器(值可以进行所需类型转换即可)
- 新标准下,我们可以对关联容器进行列表值初始化:
std::map<string, size_t> word_count; //空容器
std::map<string, string> authors = { {"joyce", "jance"}, //列表初始化
{"joyce", "jance"}};
std::set<string> exclude = {"the", "but", "and"};
- 当初始化一个 map,{key, value} 关键字与值对包围在花括号中来指出它们一起构成了 map 中的元素
初始化 multimap 或 multiset
容器 multimap 与 multiset 没有对关键字的限制,可重复,就像一个特定的单词可能有多种词义。
- 我们可以用顺序容器来初始化 set , set(beg, end); 元素不可重复出现,但是 multiset可以
11.2.2 关键字类型的要求
- 对于 有序容器,关键字必须定义元素比较的方法,默认情况下,标准库使用关键字类型的 < 运算符来比较两个关键字
有序容器 的关键字类型
我们可以向算法提供我们自定义的比较操作,与之类似,也可以提供自己定义的操作来代替关键字的 < 运算符
其中所提供的操作必须在关键字类型上定义一个 严格弱序,可以将严格弱序看作 ”小于等于“。
其性质如下:
- 两个关键字不能同时 ”小于等于对方“;如果 k1 ”小于等于“ k2,那么 k2 绝不能 ”小于等于“ k1.
- 如果 k1 ”小于等于“ k2 ,且 k2 ”小于等于“ k3,那么 k1 必须 ”小于等于“ k3.
- 如果存在两个关键字,任何一个都不 ”小于等于“ 另一个,那么我们称这两个 关键字 是 ”等价的“
如果 k1 ”等价于“ k2,且 k2 等价于 'k3",那么 k1 必须 ”等价于“ k3.
如果 这两个关键字是等价的,那么容器视为他们是相等的,当用 map 的关键字时,只能选其一来访问对应的值 。
在实际编程中,如果一个类型定义了一个 ”行为正常“ 的 < 运算符,则可以用作关键字类型。
使用关键字类ing的比较函数
- 自定义的操作类型必须在尖括号中紧跟元素类型给出
- 比较操作的类型,应该是一种函数指针类型,利用该函数进行初始化 有序容器表示有序容器在添加元素时按此规则进行排序。
bool CompareIsbn(const SalesData &lhs, const SalesData &rhs) {
return lhs.book_no() < rhs.book_no();
}
int main(int argc,char **argv) {
std::multiset<SalesData, decltype(CompareIsbn)*>
book_store{CompareIsbn};
}
12.2.3 pair 类型
pair 标准库类型作为 键值对容器的元素类型,它定义在 utility 中。
pair 是用来生成特定类型的模板,我们需提供两个类型名(不要求相等)
- pair 的默认构造函数对数据执行值初始化
pair上的操作
pair<T1, T2> p; p是一个pair,两个类型都进行了值初始化
pair<T1, T2> p(v1,v2) p 的first 于 second 对象分别用 v1 v2 初始化
pair<T1, T2> p = {v1,v2} 等价于上面
make_pair(v1, v2) 返回一个 由 v1,v2进行初始化的pair,返回pair类型由 v1,v2类型推断
p.first 返回 p 的名为 first 的公有数据成员
p.second 返回 p 的名为 second 的公有数据成员
p1 relop p2 关系运算符(<,>,<=,>=)按照字典序定义
当 p1.first < p2.second 或者 !(p1.first < p2.first)&&
(p1.second < p1.second ),p1 < p2
关系运算符利用元素的 < 运算符实现
p1 == p2 当 first 和 second 成员分别相等时,两个 pair 相等
p1 != p2
创建 pair 对象的函数
返回值为 pair时,我们可以使用 列表初始化返回
return {p,first,p.second}’
11.3 关联容器操作
关联容器额外的类型别名 | |
---|---|
key_ype | 此容器类型的关键字类型 |
mapped_type | 每个关键字关联的类型,只使用于map |
value_type | 对于 set ,与key_type 相同,对于 map,为 pair<const key_type, mapped_type> |
11.3.1 关联容器迭代器
当解引用 关联容器迭代器时,我们会得到一个类型为容器的 value_type 的 值的引用。
- 只能改变 pair 的值,不能改变其关键字
- set 的迭代器是 const 的
遍历关联容器
获取 有序关联容器 begin 与尾后迭代器,可以对其进行遍历
11.3.2 添加元素
关联容器的 inert 成员向容器中添加一个元素或一个元素范围
- insert 有两个版本,分别接受一对迭代器,或是一个初始化器列表,这两个版本行为类似于对应的构造函数。
- 只有第一个带此关键字的元素会插入其中
vector<int> vec = {2, 4, 5, 5, 4,8};
std::set<int> set2;
set2.insert(vec.begin(), vec.end());
set2.insert({1,2,3,4});
向 map 添加元素
map.insert({1,2}); //列表初始化
map.insert(make_pair(1,2)); //make_pair
map.insert(map::value_type(1,2)); //显式构造
c. emplace(args) : 当关键字不在的时候构造一个元素。
检测 insert 的返回值
insert 或者 emplace 返回的值依赖于容器类型于参数,对于不包含重复关键字的容器
insert 返回一个 pair
- pair.first : 是一个迭代器,指向具有给定关键字的元素
- pair.second :是一个bool 值,指出元素是插入成功还是已经存在于容器中。
向 multiset 和 multimap 添加元素
- 对于允许重复关键字的容器,接受单个元素的insert 操作返回一个指向新元素的迭代器,无须返回bool值,因为 insert 总会添加一个新元素。
std::multimap<string, string> athors;
athors.insert({"hzj,zcy","c++"});
athors.insert({"hzj,zcy",""});
11.3.3 删除元素
c.erase(k) 从c中删除每个关键字为k的元素,返回一个 size_type值,指出删除的元素的总量
c.erase(p) 删除迭代器 p 指定的元素,返回一个指向 p 之后的元素的迭代器
c.erase(b, e) 删除迭代器对 b,e 所表示的范围,返回 e
11.3.4 map 的下标操作
- set类型不支持下标操作
- multimap 与 unordered_map不支持下标操作,因为这些容器中可能有多个值与一个关键字相关联。
- map 下标运算符 [ ] 接受一个 关键字的索引,获取到与此关键字相关联的值,与 其他不同的是,关键字不在 map 中时,map会插入一个元素到map中,其中 关联的值进行 值初始化。
- 我们只能对 非 const 的map使用下标操作。
map 和 unordered_map 的下标操作
c[ k ] : 返回关键字为 k 的元素,k不在c中,则添加一个关键字为 k 的元素,对其进行值初始化。
c.at (k) : 访问关键字为 k 的元素,带参数检查;若 k 不在 c中,抛出一个out_of_range异常。
- 当对一个 map 进行下标操作时,会获得一个 mapped_type对象,一个左值
- 但当对一个map 迭代器解引用时,得到的是 一个 value_type对象。
11.3.5 访问元素
在一个关联容器中查找元素操作
c.find(k) 返回一个迭代器,指向第一个关键字为 k 的元素,若k不在容器中,返回尾后迭代器
c.count(k) 返回关键字等于 k 的元素的数量
c.low_bound(k) 返回迭代器,指向第一个关键字不小于 k 的元素
c.upper_bound(k) 返回迭代器,指向第一个关键字大于 k 的元素
c.equal_range(k) 返回一个迭代器 pair,表示关键字等于 k 的元素的范围,若k不存在,
则pair的两个成员均等于 c.end()
- 我们只想知道该元素在不在 容器中,而不想改变 ma,则尽量使用find 代替 下标操作。
在multimap 和 multiset 查找元素
- 如果一个 multimap 或 multset 有多个元素具有给定关键字,则这些元素在容器中会相邻存储。
一种不同的,面向迭代器的解决办法。
c.low_bound(k) :会得到第一个关键字不小于 k 的元素,可以作为容器中k关键字的键值对的 begin迭代器
c.upper_bound(k) :会得到第一个关键字大于 k 的元素,可以作为容器中k关键字的键值对的 end 迭代器
该获取到的迭代器范围就是关键字等于 k 值的 pair序列范围
equlal_range 函数
equal_range(k) :会在容器中返回关键字等于 k 的【b,e)范围
11.3.6 一个单词转换的 map
我们以一个程序结束本节的内容,它将展示map 的创建搜素以及遍历.
给定一个 是string,使其转换为另一个 string
程序的输入 是两个文件:
- 第一个文件保存的是一些规则,用来转换第二个文本的文本
每个规则由一个单词,和用来替换的它的短语构成 - 第二个输入文件包含要转换的文本
void WordTransform(std::ifstream &rules_ifs, std::ifstream &text_ifs) {
std::map<string, string> word_transform_rule;
string line_rule;
string key;
while (rules_ifs >> key && std::getline(rules_ifs, line_rule)) {
if(line_rule.size() > 1)
word_transform_rule.insert(std::make_pair(key,line_rule.substr(0)));
else
throw std::runtime_error("no rule for" + key);
}
string line_text;
while (std::getline(text_ifs, line_text)) {
istringstream split_word_iss(line_text);
string word;
bool is_first_word = true;
while (split_word_iss >> word) {
if(is_first_word)
is_first_word = false;
else
cout << " ";
string final_word;
auto word_it = word_transform_rule.find(word);
if (word_it != word_transform_rule.cend())
final_word = word_transform_rule[word];
else
final_word = word;
cout << final_word;
}
cout << endl;
}
}
11.4 无序容器
c++11 新标准 定义了4个无序关联容器。
- 无序容器使用 哈希函数和关键字类型的 == 运算符来组织元素
- 在关键字中的元素没有明显的序关系时,无序容器是很有用的,在某些维护元素的序代价很高时,也尽量选择无序容器
- 使用无序容器通常更为简单也会有很好的性能。
建议: 如果关键字类型固有就是无序的,或者上性能测试发小问题可以用哈希技术解决,就可以选择使用无序容器。
管理桶
无序容器在存储组织上为一组桶,每个桶保存 0 个或多个元素,无需容器使用一个 哈希函数将元素映射到桶。为了访问一个元素,容器首先计算元素的哈希值,之后指出应该搜索哪一个桶。
- 容器将具有一个特定 哈希值的所有元素都保存在一个桶中,如果容器允许重复关键字,所有具有相同关键字的元素也会在同一个桶中。
- 无序容器的性能依赖于哈希函数的质量和桶的数量的大小。
- 桶内元素数量多少影响了查找速度
无序容器管理操作
桶接口
c.bucket_count() 正在使用桶的数量
c.max_bucket_cout() 容器能容纳的最多桶的数量
c.bucket_size(n) 第 n 个桶中有多少个元素
c.bucket(k) 关键字为 k 的元素在哪个桶里
桶迭代
local_iterator 可以用来访问桶中元素的迭代器类型
const_local_iterator 桶迭代器的const版本
c.begin(n),c.end(n) 桶 n 的首元素迭代器 和 尾后迭代器
c.cbegin(),c.cend() 与前两个函数类似,但返回 const_local_iterator
哈希策略
c.load_factor() 每个桶的平均元素数量,返回 float 值
c.max_load_factor() c 试图维护的平均桶大小,返回 float 值。c会在需要时添加新的桶,
以使得 load_factor <= max_load_factor
c.rehash(n) 重组存储, 使得 bucket_count >= n
且 bucket_count > size/max_load_factor
c.reserve(n) 重组存储,使得 c 可以保存 n 个元素且不必 rehash
无序容器对关键字类型的要求
- 默认情况下,无序容器使用关键字类型的 == 运算符来比较元素
- 无需容器使用 hash<key_type>类型的对象来生成每个元素的哈希值,标准库对内置类型还有一些标准库类型都定义了 hash 模板
我们可以利用重载关键字类型的默认比较函数 == 与哈希计算函数实现自定义类类型的无序容器:
size_t hasher(const SalesData &sd) { //重载hash计算函数
return hash<string>(sd.book_no());
}
bool eqOp(const SaleData &lhs, const SaleData &rhs) {
return lhs.book_no() == rhs.book_no();
}
using SD_multiset = unordered_multiset<SaleData,
decltype(hasher)*, decltype(eqOp)*>;
SD_multiset book_store(42, hasher, eqOp);
第十二关: 动态内存
- 静态内存用来保存局部 static对象,类static 数据成员,以及定义在任何函数之外的变量。
- 栈内存用来保存定义在函数内的非 static 对象。
- 分配在栈内存与静态内存的对象都会由编译器自动创建和销毁,
对于栈对象,仅在其定义的程序块运行时才存在,static 对象在使用之前分配,程序结束时销毁
除了静态内存和栈内存,每个程序还拥有一个内存池,这部分内存被称为 自由空间 或 堆。
- 程序用堆来进行存储动态分配的对象,即那些在程序运行时分配的对象。
- 动态内存的生存期由程序控制,也就是说,当动态对象不再使用时,我们的代码必须显式地去销毁他们。
12.1 动态内存与智能指针
C++ 中,动态内存的管理是通过 new(为对象分配动态内存并返回指向的指针,我们可以选择对对象进行初始化)与 delete 接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。
动态内存的使用很容易出现问题:
- 我们忘记释放内存:会导致内存泄露
- 在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。
为了更容易(更安全)地使用动态内存,新的标准库提供了两种 智能指针类型来管理对象。
智能指针的行为类似常规指针,重要的区别是它负责 自动释放所指向的对象。
新标准提供的智能指针的区别在于管理底层指针的方式:
- shared_ptr 允许多个指针指向同一个对象;
- unique_ptr 则”独占“所指的对象。
这些类型都定义在 memory 头文件中
shared_ptr 与 unique_ptr 都支持的操作
shared_ptr<T> sp 空智能指针,可以指向类型为 T 的对象
unqiue_ptr<T> up
p 将p用作一个条件判断,若p指向一个对象则为true
*p 解引用 p,获得它所指向的对象
p->mem 等价于 (*p).mem
p.get() 返回 p 中保存的指针,要小心使用,若智能指针释放了其对象,返回的指针
所指向的对象也就消失了。
swap(p,q) 交换 p 和 q 的指针
p.swap(q)
12.1.1 shared_ptr 类
shared_ptr 独有的操作
make_shared<T>(args) 返回一个 shared_ptr,指向一个动态分配的类型为 T 的对象。使用 args 初始化其对象
shared_ptr<T> p(q) p 是 shared_ptr q 的拷贝;此操作会递增q 中的计数器。q中的指针必须能转换为 T*
p = q p 和 q 都是 shared_ptr,所保存的指针必须能够相互转换。此操作会递减 p 的引用计数
递增 q 的引用计数;若 p 的引用计数为 0,则将其管理的原内存释放掉。
p.unique() 若 p.use_count() 为1,返回true,否则返回 0
p.use_count() 返回 p 的共享对象的智能指针数量;可能很慢,主要用于调试
make_shared 函数
- make_shared 标准库函数(memory)是十分安全的分配和使用动态内存,此函数在动态内存中分配一个对象并初始化它,返回此对象的 shared_ptr.
- make_shared 用其参数来构造给顶类型的对象
- 如果不传递其参数,则对象就会执行值初始化
shared_ptr<int> p3 = make_shared<int>(42); //指向一个值为 42 的int 的shared_ptr
shared_ptr<string> p4 = make_shared<string>(10,'9'); //p4 指向一个值为 “99999999999”的string
auto p5 = make_shared<int>(); //p5 指向一个值初始化的 int,值为 0
shared_ptr 的拷贝与赋值
auto p(q); //p和q指向同一对象,此对象 有两个引用者
- 每一个 shared_ptr 都有一个关联的引用计数,通常称其为 引用计数。
- 拷贝会使计数器增加
- 当我们利用shared_ptr 赋予一个新值 或是 shared_ptr 被销毁(局部的 shared_ptr离开其作用域)
计数器会递减,当计数器为 0 ,它会自动释放其管理的对象
shared_ptr 自动销毁所管理的对象和释放相关联的内存
-
当一个 shared_ptr 被销毁时,shared_ptr 会递减它所指向的对象的引用计数
引用计数为0时,shared_ptr 会调用对象的析构函数对其进行销毁并释放内存。 -
shared_ptr 在无用之后不应该被保留。
使用了动态生存期资源的类
程序使用动态内存处以下三种原因之一:
- 程序不知道自己需要多少对象
- 程序不知道所需对象的准确类型
- 程序需要在多个对象间共享数据。
容器类 是处于第一种原因而使用动态内存的典型例子。
而使用一个动态内存的常见原因是允许多个对象共享相同的状态。
定义一个多个对象共享相同状态的类
- 实现一个新的集合类型的最简单的办法是使用 标准库容器来管理元素,这种方法可以达到使用标准库类型来管理元素所使用的内存空间。
- 对类的外部用户操作异常要进行处理
- 将类内部函数使用的 重复操作 封装到一个 私有函数中
在一个类的内部初始化一个管理动态内存的指针时,拷贝赋值和销毁该对象都会使引用计数发生改变。
因为其 内存是被操作的其他对象与自身共享的。
12.1.2 直接管理内存
使用 new 动态分配和初始化对象
在自由空间分配的内存是无名的,因此 new 无法为其分配的对象命名,而是返回一个指向该对象的指针
int *p = new int;
int *pi = new int ();
-
分配内存,返回其指针
-
默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象将是未定义的,而类类型对象将使用 默认构造函数进行初始化
-
对于定义了自己的构造函数的类类型,要求值初始化是无意义的,不管什么形式,对象都会通过默认构造函数来初始化,但是对于内置类型,值初始化对象将有良好定义的值。
-
提供了一个括号包围的初始化器,就可以使用 auto 进行推断我们分配的对象类型,但智能有单一的初始化器。
auto p1 = new auto(obj);
动态分配的 const 对象
- 一个动态分配的 const 对象必须进行初始化。
- 对于一个定义了默认构造函数的类类型,其 const 动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。
- 由于分配的对象是 const 的,new 返回的指针是一个指向 const 的指针。
内存耗尽
当自由空间被耗尽的情况下,new表达式就会失败
默认情况下,如果 new 不能分配所要求的内存空间,它会抛出一个类型为 bad_alloc 的异常,
我们可以改变使用new的方式来阻止抛出异常。
int *p2 = new (std::nothrow) int();
这种形式的new 为 定位 new,定位 new 表达式允许我们向 new 传递额外的参数,这里我们传入
nothrow 代表不能抛出异常,这时 new 如果不能分配内存,会返回一个空指针。
bad_alloc 与 nothrow 都定义在头文件 new中。
释放动态内存
我们通过 delete表达式来将 动态内存归还给系统,delete 接受一个指针,指向我们想要释放的对象:
delete p
执行两个动作: 销毁给定的指针,释放对应的内存。
- 对于指针指向是静态或者是动态分配的对象,编译器不能分辨,也不能分辨一个指针指向的内存是否已经被释放了,因此 delete表达式充满了危险性。
动态对象的生存期直到被释放时为止
对于一个内置指针管理的动态对象,直到显式被释放之前它都是存在的
小心:动态内存的管理非常容易出错
使用 new 和 delete 管理动态内存存在三个常见问题:
- 忘记 delete 内存,会导致内存邪路问题
- 使用已经释放掉的对象,需要释放后将其设置为空
- 同一片内存释放两次,两个指针指向相同的动态分配对象时,可能发生这种错误。当第二次delete
该指针,自由空间就可能被破坏。
坚持只使用智能指针,就可以避免所有的问题,对于一块内存,只有在没有任何智能指针指向它的情况,智能指针才会自动释放它。
delete之后重置指针值
- 在delete 之后,指针就变成 空悬指针,即指向一块曾经保存数据对象但现在已经无效的内存的指针。
- 将 delete之后的指针置为空,可以表明 该指针不会指向任何对象
这只是提供了有限的保护
如果是 多个指针指向相同的内存,当释放掉该内存的话,所有的指针都是空悬指针,只置空一个是不合理的。
12.1.3 shared_ptr 与 new结合使用
- 接受指针参数的智能指针构造函数是 explicit的,因此我们不能将一个 内置指针隐式地转换为一个 智能指针,必须使用直接初始化形式来初始化一个智能指针。
shared_ptr<int> Clone(int p) {
return shared_ptr<int>(new int(p));
}
定义和改变 shared_ptr 的其他方法
shared_ptr<T> p(q) p管理内置指针 q 所指向的对象;q必须指向 new 分配的内存,
且能够转换为 T*类型
shared_ptr<T> p(u) p 从 unique_ptr接管了对象的使用权,将 u 置为空
shared_ptr<T> p(q, d) p 接管了内置类型 q 所指的对象的所有权,q必须能转换为 T*类型,p将
使用可调用对象来代替 delete
shared_ptr<T> p(p2, d) p 是 p2 的拷贝,唯一的区别就是 p将调用d来替换 delete
p.reset() 若 p 是唯一指向其对象的 shared_ptr,reset 会释放此对象,若传递了可选的参数
p.reset(q) 内置指针 q,会令 p 指向 q,否则会将 p 置为空。若传递了 d,调用 d 来替换
delete
p.reset(q, d)
不要混合使用普通指针与智能指针
shared_ptr 可以协调对象的析构,但仅 限于 自身的拷贝(也是 shared_ptr)之间,这也就是为什么我们推荐使用 make-shared 而不是 new 的原因。
- 如果 shared_ptr 是 利用普通指针初始化的,那么是非常不安全的,因为当智能指针所指内存被释放
后,普通指针是 空悬指针。
不要用 get 初始化另一个智能指针或为智能指针赋值
- get用来将指针的访问权限传递给代码, 只有确定代码不会 delete 指针的情况下,才能使用 get。
- 永远不要用get初始化另一个智能指针,因为当另一个智能指针释放内存后,我们使用给他初始化的智能指针会引发未定义的错误。
12.1.4 智能指针与异常
如果使用智能指针,即使程序块过早(异常或者正常处理结束)的结束,智能指针作为局部变量都会被销毁,并释放其内存。
使用普通指针,则必须在程序块出问题前显式地去释放该内存。
智能指针与哑类
在 语言中也有一些没有被 定义析构函数的类,哑类,他们要求用户显式地释放所使用的任何资源。
使用 shared_ptr 来保证 一个没有定义析构函数的类能够自动释放资源。
使用我们自己的释放操作
默认情况下,shared_ptr假设它们指向的是动态内存。因此,当一个 shared_ptr 被销毁时,它默认对他管理的指针进行 delete 操作。
如果用 shared_ptr 来管理一个 例如关闭连接的操作 ,我们需要定义一个函数 替换 delete,
这个删除器函数必须能够完成对 shared_ptr 中保存的指针进行释放的操作。
void f() {
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection); //p接管了 c 中的连接动态内存
}
删除器必须接受一个 T* 的参数。
void end_connection(connect *p) { disconnect(*p); }
这样能保证,即使发生了错误或者异常, 连接也能正常关闭。
智能指针的陷阱
智能指针可以提供对动态分配的内存安全又方便的管理,但这也建立在正确使用的前提下。
为了正确使用智能指针,我们必须坚持一些基本规范:
- 不使用相同的内置指针值初始化(或 reset)多个智能指针。
- 不delete get( ) 返回的指针
- 不使用 get( ) 初始化 或 reset 另一个智能指针,
- 如果你使用 get( ) 返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就无效了
- 如果你使用智能指针管理的资源不是 new 分配的内存,,记得给他传入一个 删除器。
12.1.5 unique_ptr
一个 unique_ptr ”拥有“ 它所指的对象,只能有一个 unique_ptr 指向一个给顶对象,当unique_ptr
被销毁时,该所指的对象也被销毁。
unique 操作
unique_ptr<T> u1 空 unique_ptr,指向类型T 的对象,调用delete释放它的指针
unique_ptr<T, D> u2 u2 使用一个类型 D 的可调用对象来释放它的指针
unique_ptr<T, D> u(d) 空 unique_ptr,指向类型为 T 的对象,用类型为D 的对象 d 代替 delete
u = nullptr 释放 u 指向的对象,将 u 置为空
u.release() u 放弃对指针的控制权,返回指针,并将 u 置为空
u.reset() 释放 u 指向的对象
u.reset(q) 如果提供了内置指针 q,令u 指向这对象;否则将 u 置为空
u.reset(nullptr)
注意的是:
- 我们不能拷贝赋值 unique_ptr,但我们可以通过 release 或 reset 将指针的所有权转移
- 在 release时,如果我们不用另一个智能指针来保存 release返回的指针,我们的程序就要负责资源的释放。
不能拷贝和赋值的例外:我们可以拷贝或赋值一个将要被销毁的 unique_ptr
向 unique_ptr 传递删除器
- unique_ptr 默认情况下用 delete 释放它所指向的对象。
- 重载一个 unique_ptr的删除器会直接影响到 unique_ptr 类型以及如何构造该类型的对象。
我们必须在 尖括号中指向类型之后提供删除器类型,在创建和 reset 一个这种 unique_ptr 类型的对象时
必须提供一个指定类型的可调用对象(删除器)。
unique_ptr<objT, delT> p(new objT, fcn);
p 指向一个类型为 objT 的对象,并使用一个类型为 delT 的对象释放 objT对象。
void f() {
connection c = connect(&d);
unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
}
12.1.6 weak_ptr
weak_ptr 是一种不控制 所指向对象生存期的智能指针,它指向一个 shared_ptr的对象
- 将 weak_ptr 绑定到 shared_ptr 不会改变 shared_ptr 的引用计数
- 一旦绑定的 shared_ptr 被销毁,对象会被释放,即使有 weak_ptr 指向对象,对象依然会被释放,因此 weak_ptr 的名字抓住了智能指针的 ”弱“ 共享对象的特点
weak_ptr:
weak_ptr<T> w 空 weak_ptr 可以指向 T 类型的对象
weak_ptr<T> w(sp) 将 weak_ptr 绑定到相同类型 的 shared_ptr所指的对象上
w = p p 可以是一个shared_ptr 或 一个 weak_ptr.赋值后 w 与 p 共享对象
w.reset() 将 w 置为空
w.use_count 与 w 共享对象的shared_ptr 的数量
w.expired() (已过期) 若 w.use_count = 0,返回true,否则返回 false
w.lock() 如果 expired 为 true,返回一个 空 shared_ptr;否则返回一个指向 w 的对象的
shared_ptr
- 由于是 弱共享, 创建 wp 不会改变 p 的引用计数; wp 指向的对象可能被释放掉。
- 由于对象可能不存在,因此我们需要检查 weak_ptr 指向的对象是否存在,如果存在,lock 将返回一个指向共享对象的 shared_ptr, 与其他shared_ptr 类似,只要 shared_ptr 存在,它所指的底层对象就会一致存在。
12.2 动态数组
我们需要一次为很多对象分配内存的功能。
例如当 vector 和 string 重新分配内存时,必须一次性为很多元素分配内存。
为了至此这种需求,C++ 和标准库提供了两种一次性分配一个对象数组的方法。
- C++ 定义了另一种 new 表达式语法,可以分配并初始化一个对象数组。
- 标准库包含一个 allocator 的类,允许我们将分配和初始化分离,使用 allocator 会有更好的性能与灵活的内存管理能力。
建议:大多数容器都应该使用标准库容器而不是动态分配的数组,使用容器更为简单,更不容易出现内存管理错误并且可能有更好的性能
12.2.1 new 和 数组
为了 new 分配一个对象数组,我们要在 类型名之后跟一对 方括号,在其中指明要分配的对象的数目。
int * p = new int[ size ]
- size 必须是整型,但不必是常量
- 分配一个数组会得到该元素类型的指针,(begin与 end 不能调用,因为这些函数利用数组的维度来返回 首尾之针,处于相同的原因, 范围 for 语句也不能使用)
动态数组并不是 数组类型,这点很关键
初始化动态分配对象的数组
默认情况下,new 分配的对象,不管是单个还是数组,全都是执行默认初始化。
当然我们可以利用在 大小后面加入一对空括号来对其中元素进行值初始化
int *pia = new int[10];
int *pia = new int[10]();
我们还可以利用初始化列表进行初始化:
- 当初始化列别内的元素小于 元素数量,剩余元素执行值初始化。
- 如果初始化器数目大于元素数目,则 new 表达式失败,不会分配任何内存,抛出异常
bad_array_new_lenth ,在头文件 new 中。
- 虽然我们能用空括号对数组元素进行值初始化,但我们不能在括号中给出 初始化器,这意味着 不能用
auto 分配数组
动态分配一个空数组是合法的
动态分配大小为 0 的数组,new 返回一个合法的非空指针,此指针保证与 new 返回的其他任何指针都不同,对于零长度的数组来说, 此指针就像尾后指针一样。
释放动态数组
为了释放动态数组,使用 delete的特殊形式 – 在指针前加上一个空括号对
delete []pa;
- 数组中的元素按照逆序销毁,最后一个先被销毁,然后是倒数第二个
- 空括号对是必须的,它指示编译器此指针指向一个对象数组的第一个元素。如果我们在 delete 一个指向数组的指针忽略了方括号,(或者在delete一个单一对象时使用了方括号),其行为是未定义的 。
智能指针与动态数组
标准库为 unique_ptr 来管理动态数组的版本,为了让unique_ptr管理动态数组,我们必须在对象类型后跟一对空方括号:
unique_ptr<int[]> up(new int[10]);
up.release(); //自动调用 delete[] 销毁此指针
- 我们不能使用点 与箭头成员运算符,因为 unique_ptr 指向的是数组而不是单一元素,该运算符无意义
我们使用下标运算符来访问数组中的元素。
指向数组的 unique_ptr
unique_ptr<T[]> u u 可以指向一个动态分配的数组,数组元素类型为 T
unique_ptr<T[]> u(p) u指向内置类型 p 所指向的动态分配的数组, p 必须能转换为类型 T*
u[i] 返回 u 拥有的数组中位置 i 处的对象,u必须指向一个数组
指向数组的 shared_ptr (不建议)
shared_ptr 不支持管理动态数组,如果希望使用 shared_ptr 管理动态数组的话,必须提供自定义的删除器。
shared_ptr<int[]> sp(new intt[10](), [](int *p) { delete []p;})
sp.reset()
shared_pt 未提供下标运算符,智能指针也不支持指针算术运算,为了访问数组元素,必须get 获取一个内置指针,用它来访问数组元素。
12.2.2 allocator 类
new 在使用上将 内存分配和对象构造组合在了一起
delete 将对象析构与内存释放组合在了一起
在实际使用动态数组时,我们往往不会令其等于相同的值,数组因为 new 在创建时执行力 构造,之后我们重复赋新值,会造成一些浪费,更重要的是没有默认构造函数的类不能动态分配数组。
引入需求:
当分配大块内存,我们希望将内存分配与对象构造分离,这意味着我们可以分配大块内存,在真正需要才真正执行对象创建操作(同时付出一定开销)
allocator 类
标准库 allocator 类定义在头文件 memory中,它帮助我们将内存分配和对象构造分离开。
- allocator 提供了一种类型感知的内存分配方法,它分配的内存是原始的,未构造的。
alloctor 支持的操作:
allocator<T> a 定义了一个名为 a 的allocator 对象,它可以为类型 T 的对象分配内存
a.allocate(n) 分配一段原始的,未构造的内存,保存 n 个类型为 T 的对象
a.deallocate(p, n) 释放从 T*指针p中地址开始的内存,这块内存保存了 n 个类型为 T 的对象
p必须是先前 allocate 返回的指针,且 n 必须是 p 创建时所要求的大小
在调用 deallocate之前,用户必须对每个这块内存中创建的对象调用 destroy
a.construct(p, args) p 是类型为 T*的指针,指向一片原始内存,args 被传递给 T 的构造函数,用来在
p 指向的内存构造一个对象
a.destroy(p) p 为 T* 类型的指针,此算法对 p 指向的对象执行析构函数
allocator 分配未构造的内存
我们需要在 allocator 分配的内存中构造对象。
- 在新标准中,construct 函数接受一个指针和零个多个额外参数,在给定的位置构造一个元素,额外参数用来初始化构造的对象。
- 在未构造对象之前,使用 alloc 分配的内存是错误的
- 我们用完对象要构造的元素调用 destroy来销毁他们,destroy 接受一个指针,对指向的对象执行析构函数。
- 我们只能对真正构造了的元素进行 destroy 操作
- 当销毁元素后,我们可以使用这部分内存来保存其他元素,也可以将其归还与系统,释放内存通过
deallocate 来完成
allocator<string> alloc;
string *p = alloc.allocate(10);
auto q = p;
alloc.construct(q++);
alloc.construct(q++,10,'c');
while (q!=p)
alloc.destroy(--q);
alloc.deallocate(p, 10);
拷贝与填充未初始化的算法
标准库还为 allocator 类定义了两个伴随算法,可以在未初始化内存中创建对象,以下算法都定义在
memory
allocator 算法
uninitialized_copy(b, e, b2) 从b,e指出的输入范围拷贝元素到迭代器 b2指定的为构造的原始内存中‘
b2 所指的内存应该足够大
uninitialized_copy_n(b,n,b2) 从迭代器 b 指向元素开始,拷贝n个元素到 b2 开始的内存中
uninitialized_fill(b,e,t) 在迭代器 b e指定的原始范围中创建对象,对象的值均为 t 的拷贝
uninitialized_fill_n(b, n, t) 从b开始的内存开始创建 n 个对象,b必须指向足够大的未构造的原始内存
能够容纳给定数量的对象
- 算法返回的迭代器都会返回 目的位置迭代器(指向最后一个构造的原始之后的位置)
12.3 使用标准库 :文本查询系统
开始一个程序的设计的的一个好方法是列出程序的操作。了解那些操作可以帮我们分析出需要 什么样的数据结构。从需求入手。
第十三关: 拷贝控制
当定义一个类时,我们会显式地隐式地指定在此类型的对象拷贝,移动,赋值和销毁时做什么,一个类通过定义五种 特殊的成员函数来控制这些操作,包括:
- 拷贝构造函数
- 拷贝赋值函数
- 移动构造函数
- 移动赋值函数
- 析构函数
warning : 在定义任何C++类时,拷贝控制操作都是必要部分, 对初学 C++ 程序员来说,必须定义对象
拷贝,移动,赋值或销毁时做什么。
如果我们不显式定义这些操作,编译器会自动帮我们定义,但编译器定义的版本的行为可能并非我们所想。
13.1 拷贝,赋值与销毁
13.1.1 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
- 拷贝构造函数的参数是const 的引用
- 拷贝函数通常不应该是 explict,因为在常用情况下 拷贝构造函数都会被隐式使用。
class Foo {
public:
Foo();
Foo(const Foo&); //拷贝构造函数
}
合成拷贝构造函数
-
即使我们定义了构造函数,编译器也会为我们合成一个拷贝构造函数。
-
合成拷贝构造函数会将给顶对象依次将每个非 static 成员拷贝到正在创建的对象中。
-
每个成员的类型决定了它如何被拷贝:
类类型的成员会使用拷贝构造函数来拷贝
内置类型的成员则直接拷贝(包括数组,对数组元素逐一拷贝)
数组元素是类类型,则调用每个元素的拷贝构造函数来进行拷贝
SalesData(const SalesData& orig);
SalesData::SalesData(const SalesData &orig)
: book_no_(orig.book_no_), units_sold_(orig.units_sold_), revenue_(orig.revenue_) {}
拷贝初始化
直接初始化: 要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数
拷贝初始化:我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还会进行类型转换
拷贝初始化不仅在我们用 = 定义变量的时候会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为 非引用类型的函数返回 一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
当我们初始化标准库容器或是调用 insert 或 push 成员,容器会对此元素进行拷贝初始化,与之对应
用 emplace 成员创建的元素都进行直接初始化。
参数与返回值
具有非引用类型的参数进行拷贝初始化
当函数返回非引用类型时,返回值会被用来初始化调用方的结果。
拷贝初始化被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。
:如果参数不是引用类型,初始化类类型对象,我们需要调用形参的拷贝构造函数,调用拷贝构造函数又会进行形参对自己的拷贝构造函数的传递,。。。。。无止境的传递建造 类类型对象形参。
拷贝初始化的限制
如果我们使用的初始化值要求一个 explict 的构造函数来进行类型转换,那么我们使用直接初始化与拷贝初始化就无关紧要了
编译器可以绕过拷贝构造函数
在拷贝初始化时,编译器可以(但不是必须) 跳过拷贝/移动构造函数,直接创建对象。
string null_book = "999-9-999" 拷贝初始化
string null_book("99999") 编译器略过了 拷贝构造函数
拷贝,移动构造函数必须是存在的且可访问的 (例如,不能是 private)
13.1.2 拷贝赋值运算符
重载赋值运算符
重载运算符本质上是函数,其名字有 operator 关键字后接表示定义的符号组成,因此
赋值运算符就是一个 名为 operator= 的函数,与其他函数一样,该函数有一个返回值与形参列表
- 重载运算符的参数表示为运算符的运算对象,某些运算符,包括赋值运算符,赋值运算符必须定义为成员函数
- 如果一个运算符是一个成员函数,其左侧运算对象就绑定在隐式的 this 参数,而 赋值是二元运算符,右侧对象作为显式的参数进行传递
class Foo{
public:
Foo& operator=(const Foo&); //赋值运算符
}
拷贝赋值运算符接受一个与其类类型相同的参数,作为右侧运算对象,为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
注意:标准库通常要求保存在容器中的类型要具有赋值运算符,其返回值是左侧运算对象的引用。
合成拷贝赋值运算符
利用编译器生成一个合成拷贝赋值运算符,它会将右侧运算对象的每个 非 static 成员赋值给左侧运算对象的对应对象。
13.1.3 析构函数
析构函数执行与构造函数相反的操作:
- 构造函数初始化对象的非 static 数据成员,还可能做一些其他工作
- 析构函数释放对象使用的资源,并销毁对象的 非 static数据成员。
了解析构函数:
析构函数是类的成员函数,名字由波浪号接类名构成,它没有返回值,也不接受参数:
class Foo {
public :
~ Foo()
}
由于析构函数不接受参数,因此它不能被重载,对于一个给定类,只会有唯一一个析构函数。
析构函数完成什么工作
一个构造函数,成员的初始化是在函数体执行之前完成的,且按照他们在类中出现的顺序进行初始化
析构函数,首先执行函数体,之后按照初始化顺序的逆序销毁成员。
- 在对象的最后一次使用之后,析构函数的函数体会执行类设计者希望执行的任何收尾工作。
通常,析构函数释放对象在生存期分配的所有资源。 - 析构函数中成员的销毁发生什么完全依赖于成员的类型。
注意: 隐式销毁一个内置指针类型,并不会delete 它所指向的对象,智能指针是一个类类型,所以会在析构阶段自动销毁
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数
- 变量在离开作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库还是数组)被销毁时,其原始被销毁
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁。
- 对于 临时对象,当创建 它的完整表达式结束时被销毁。
析构函数自动运行,我们无须担心何时释放我们分配的资源。
合成析构函数
析构函数体并不会直接销毁成员,成员是在析构函数体之后隐式析构的。
13.1.4 三/五法则
需要析构函数的类也需要拷贝和赋值操作
当我们决定是否要定义它自身版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。
通常情况下,析构函数的需求更加明显,如果一个类需要析构函数,我们可以几乎肯定它也需要一个拷贝构造函数和一个拷贝赋值函数。
需要拷贝操作的类也需要赋值操作,反之亦然
13.1.5 使用 = default
我们可以通过拷贝控制成员定义为 = default 来显式地要求编译器生成合成的版本。
~SalesData() = default;
- 只能对具有合成版本的成员函数使用 = default
- 在类内使用 = default 修饰变量的声明时,合成的函数将隐式地声明为内联,如果我们不希望其是内联,将 = default 在类外定义。
13.1.6 阻止拷贝
有些类拷贝与赋值没有合理的意义,这时,定义类时应该采用某种机制阻止拷贝或赋值。
不定义拷贝控制成员来阻止拷贝是无效的,编译器会自动合成
定义删除的函数
C++ 11新标准通过对拷贝构造函数和拷贝赋值运算符定义为 删除的函数 来阻止拷贝,
删除的函数:我们声明了他们,但不能以任何方式使用它,在参数列表后面加上 = delete来指出我们希望它定义为删除的。
struct NoCopy {
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const NoCopy&)= delete; //阻止赋值
}
- =delete 通知编译器,我们不希望定义这些成员。
- 与 = default 不同, = delete 必须出现在函数第一次声明的时候,因为 编译器需要在声明时刻就明白它是删除的,以便禁止试图使用 它的操作。
- 与 default 不同,= delete 是可以对任何函数都指定的,如果我们希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能被指定删除
如果一个类有某个成员的类型删除了析构函数,我们不能定义该类的变量或临时对象,因为成员无法销毁,如果一个成员无法销毁,则对象整体也就无法销毁。
合成的拷贝控制成员可能是删除的
本质上,当不可能拷贝,赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
- 如果类的某个成员的析构函数 删除的或 是不可访问的(private)则类的合成析构函数被定义为删除的
- 如果类的某个成员的拷贝构造函数或析构函数是删除的或是不可访问的,则类的合成拷贝构造函数是被定义为删除
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const 成员或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的成员的析构函数是删除的或是不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个 const 成员,他没有类内初始化器且类型未显式定义默认构造函数,则该类的默认构造函数被定义未删除的。
private: 拷贝控制
希望阻止拷贝的类应该使用 = delete来定义他们自己的拷贝构造函数和拷贝赋值运算符,而不应该将他们声明为 private。
#define DISALLOW_COPY_AND_ASSIGN(TypeName) \
TypeName(const TypeName&) = delete; \
TypeName& operator=(const TypeName&) = delete;
//仅在需要拷贝对象时使用拷贝构造函数,不需要拷贝时在使用DISALLOW_COPY_AND_ASSIGN宏
DISALLOW_COPY_AND_ASSIGN(GoogleStyle);
13.2 拷贝控制和资源管理
一般来说,对于管理类外资源的类的定义,我们需要明确器类型对象的拷贝语义。有两种选择:
可以定义拷贝操作,使类的行为看起来像一个值或者一个指针。
值的行为: 意味着它应该有它自己的状态,拷贝一个像值的对象时,副本与原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。
13.2.1 行为像值的类
提供像只的行为,对于类管理的资源,每个对象都应该拥有自己的一份拷贝,这意味着对于资源,每个类的对象都i有一份资源的拷贝:
为了实现类值行为,需要:
- 定义一个 拷贝构造函数,完成对资源的拷贝,而不是拷贝资源是指针
- 定义一个 析构函数来释放资源
- 定义一个拷贝赋值运算符来释放对象当且的 资源,并从右侧运算对象拷贝 一份资源。
class HasPtr {
public:
HasPtr(const std::string &s = std::string())
: ps_(new std::string(s)), i_(0){ }
HasPtr(const HasPtr &p)
: ps_(new std::string(*p.ps_)), i_(p.i_) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps_; }
private:
std::string *ps_;
int i_;
};
}
类值拷贝赋值运算符
赋值操作会销毁左侧运算对象的资源,并从右侧运算对象拷贝数据。
- 自赋值情况,如果我们先销毁自身资源,在进行拷贝数据,那么这就会造成异常
我们应该确保异常发生时左侧运算对象能为一个有意义的状态。
- 我们先拷贝右侧运算对象在临时量里,我们可以处理自赋值情况,并且能够保证在异常发生时代码也是安全的,在完成拷贝时,我们释放左侧运算对象的资源,并更新指针指向临时量。
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
auto newp = new string(*rhs.ps_);
delete ps_;
ps_ = newp;
i_ = rhs.i_;
}
编写赋值运算符建议:
- 如果将一个对象赋予它自身,赋值运算符必须能正常工作
- 大多数赋值运算符组合了析构函数与拷贝构造函数的工作。
13.2.2 定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的 资源。
- 定义析构函数来释放分配的资源,但是必须在所有共享指针都被销毁的情况下
- 拷贝构造函数与拷贝赋值函数都是拷贝 指针,而非资源
- 利用 shared_ptr 来管理行为很像指针的类的是非常适合的
- 直接管理资源而不使用 shared_ptr 的话我们使用引用计数即可
引用计数的作用:
- 除了初始化外,每个构造函数还有创建一个引用计数,用来记录有多数对象与正在创建对象共享内存状态,当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1.
- 拷贝构造函数不分配新的计数器,而是拷贝给定的对象是数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。计数器为 0 ,析构函数释放资源
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
计数器放在动态内存中,由共享对象共享
定义一个使用引用计数的类
class HasPtr {
public:
HasPtr(const std::string &s = std::string())
: ps_(new std::string(s)), i_(0), use_(new std::size_t(1)) { }
HasPtr(const HasPtr &p)
: ps_(p.ps_), i_(p.i_), use_(p.use_) { ++use_; }
HasPtr& operator=(const HasPtr &);
~HasPtr();
private:
std::string *ps_;
int i_;
std::size_t *use_;
};
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
++ *rhs.use_;
if (--use_ == 0) {
delete ps_;
delete use_;
}
ps_ = rhs.ps_;
i_ = rhs.i_;
use_ = rhs.use_;
return *this;
}
HasPtr::~HasPtr() {
if (-- * use_ == 0) {
delete use_;
delete ps_;
}
}
处理自赋值,首先递增 右侧对象引用计数,之后在进行递减 左侧引用计数,防止自赋值导致释放自身
13.3 交换操作
除了定义拷贝控制成员,管理资源的类通常还定义一个名为 swap 的函数,与重排元素顺序的算法一起使用的类,这类算法在交换两个元素时会调用 swap
编写我们自己的swap 函数
friend void swap(HasPtr&,HasPtr&);
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps_, rhs.ps_);
swap(lhs.i_, rhs.i_);
}
由于 swap 的存在就是为了优化代码,我们将其声明为 inline函数是更好的
对于分配了资源的类, 定义 swap 操作是一种很重要的优化手段。
swap 函数应该调用 swap,而不是 std::swap
标准库版本的 swap 仅限于内置类型
而如果我们有自己类型特定的 swap函数,则调用 std:::swap 就是错误的,当我们引用了 标准库版本的swap (using std::swap) ,我们进行swap 操作时就会优先匹配自己定义的类类型,不会引发错误
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps_, rhs.ps_);
swap(lhs.i_, rhs.i_);
swap(lhs.h, rhs.h) //假定h是类类型,会调用 类类型的 swap
}
在赋值运算符中使用 swap
定义 swap 的类通常用 swap 来定义他们的赋值运算符。这些运算符使用了拷贝并交换的技术。
这种技术是将左侧对象与右侧对象的一个副本进行交换
HasPtr& HasPtr::operator=(HasPar rhs){
swap(*this, rhs);
return *this;
}
这个技术的有趣之处就是它自动处理了自赋值且天然就是异常安全的,它在改变左侧对象的之前拷贝右侧运算对象保证了自赋值的正确。
13.4 拷贝控制示例 邮件处理应用
编写类程序时的步骤:
- 画图描述程序
- 分析图示,举出每一个类 class 应该具有的功能
- 根据功能找到适合的数据结构与算法
- 根据使用到的 标准库或者变量的函数参数,找到 最佳的成员变量。
- 找到类成员函数之间实现的公共部分,封装为私有函数供类内成员函数调用。
- 拷贝赋值运算符 通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在 private 的工具函数中完成。
- 注意 string set,vector 等等都会 在 标准库swap中收益,因此定义自身版本的 swap 有时是很有必要的
邮件处理应用:
Message: 电子邮件
Folder :消息目录
- message 可出现在多个 Folder,仅有一个副本:
说明每一个Folder都存在了一个指向该message指针
每个 message 也保存了它所在的 Folder 的指针
class message{
public:
save() 保存自己到其他 folder
remove() 移除自己到其他 folder
message(const message&) 拷贝初始化,将拷贝的元素的保存的folder都添加一个新拷贝的自己,且自己将folder都保存下来
~message() 从给定保存的folder删除自身。
private :
set<folder*>
string content; 实际消息数据
void add_to_Folders(const Message&) //功能的公共部分
void remove_from_Folders();
}
class Folder {
public:
void AddMsg(Message*);
void RemoveMsg(Message*);
private:
std::set<Message*> msgs;
};
13.5 动态内存管理类
某个类需要运行时分配可变大小的内存空间,这些类一般可以通过标准库容器来保存他们的数据
当然这一策略并不是对每个类都使用;某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存
实现StrVec 类的设计
- Vec 为了接受可接受的性能, vector预先分配足够的内存来保存可能需要的更多元素。
- vector 的每个添加元素的成员函数都会检查是否有空间容纳更多的元素,如果有,可以直接构造,如果没有可用空间,vector 就会重新分配空间,将已有的元素移动到新空间,释放旧空间,并添加元素。
包含的功能:
- reserve 额外分配更多内存
- Check 检测自身是否可容纳新的元素,空间是否足够
- allocate :分配新空间 construct :构造元素
- destroy 与 deallocate :销毁元素与 释放空间
包含的成员(猜测)
string* elements;
string* first_free;
string* cap;
static allocator
在重新分配内存的过程中移动而不是拷贝元素
- 为一个新的,更大的 数组分配内存
- 在内存空间的前一部分构造对象,保存现有元素
- 销毁原内存空间中的元素, 并释放这块内存
由于我们从旧空间到新空间完毕后,旧空间的元素立马会被销毁,显然使用拷贝是不划算的 ,如果我们能避开分配和释放 这些元素的额外开销, 我们的 Vec 就会i性能上好很多。
移动构造函数和 std::move
- 移动构造函数是将资源从给顶对象 “移动” 而不是拷贝到正在创建的对象,而且我们知道标准库保证“移后源” 旧string 仍然保持一个 有效的,可析构的状态。(可以假定移动构造函数对其进行了指针的拷贝而不是重新分配空间)
- std::move,定义在 utility(效用) 头文件中,它的技术点
- 当在新的内存中构造对象时,它必须调用 move 来表示希望使用 对象 的移动构造函数。如果漏掉了 move 调用,将会使用对象的拷贝构造函数。
- 我们通常不为move 提供一个 using 声明,当我们使用 move 时,直接调用 std::move 而不是 move
13.6 对象移动
新标准的一个主要特征是可以移动而非拷贝对象的能力,因为在很多情况下,对象拷贝之后就被销毁了,这些情况下,使用移动而非拷贝会大幅度提高性能,
- IO类与 unique_ptr 这样不能拷贝的类,但支持移动
- 新标准我们能保存不可拷贝的类型,只要他们能被移动
13.6.1 右值引用
所谓右值引用就是必须绑定到右值的引用。
- 我们通过使用 && 而不是 & 获取右值引用
- 右值引用性质:只能绑定到一个将要销毁的对象,因此,我们可以自由地将一个右值引用的资源,移动到另一个对象中。
- 我们可以将一个右值引用绑定到(要求转换的表达式,字面常量或者是 返回右值的表达式),但不能将一个右值引用直接绑定到一个左值上。
返回左值的函数,赋值,下标,解引用和前置递增,递减运算符,都是返回左值的表达式的例子,我们可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术,关系,位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们能将一个 const 的左值引用或一个右值引用绑定到这类表达式上。
左值持久,右值短暂
左值有持久的状态,而右值不是字母常量就是 表达式求值的临时变量:
- 所引用的对象即将被销毁。
- 该对象没有其他用户。
这两个特性: 使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值
变量是左值, 因此我们不能将一个右值引用绑定到一个变量上,即使这个变量是右值引用也不行,毕竟,变量是持久的,直到离开作用域才被销毁
int &&rr1 = 42; //正确
int &&rr2 = rr1; //错误
标准库 move 函数
我们可以将一个 左值显式地转换位对应的右值引用,我们还可以用一个 move 的新标准库函数来获得绑定到左值的 右值引用。
int &&rr3 = std::move(rr1);
move 告诉编译器,我们有一个左值,我们希望像一个右值一样处理它。
注意:调用 move 就意味着承诺,除了对 源对象进行赋值和销毁它之外,我们不能使用它。
13.6.2 移动构造函数 和移动赋值运算符
我们可以定义自己的类的移动操作,从给定对象中”窃取“资源而不是拷贝资源,会从中受益。
- 移动构造函数的第一个参数是该类型的右值引用,与拷贝构造函数一样,任何额外的参数都必须有默认实参。
- 除了资源移动,移动构造函数还必须确保移动后源对象处于—销毁无害的,一旦资源完成移动,源对象将不再指向被移动的资源 —这些资源已经转让使用权。
StrVec::StrVec(StrVec &&s) noexcept //移动构造函数不应该抛出异常
: elements_(s.elements_), first_free_(s.first_free_), cap(s.cap) {
s.elements_ = s.first_free_ = s.cap = nullptr;
}
noexcept :表示标准库我们的构造函数不抛出异常
移动操作,标准库容器与异常
由于 移动操作 ”窃取资源“ ,它通常不分配任何资源,因此移动操作不会排除异常,当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库,我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为我们移动时抛出异常,为了处理这一工作可能性做出额外的工作。
-
通知标准库我们的移动操作不抛出异常的方式是 指明 noexcept (C++11 新标准),我们在函数的参数列表后指定 noexcept,
-
不抛出异常的移动构造函数和移动赋值运算符必须标记位 noexcept
-
为了避免出现元素在容器重新分配内存后使用移动构造函数发生异常,容器如果在没有显式地指出元素类型的移动构造是安全的,会调用拷贝构造函数。
移动赋值运算符
移动赋值运算符完成于 析构函数和移动构造函数相同的工作,标记位 noexcept,且必须正确处理自赋值
StrVec &StrVec::operator=(StrVec &&s) noexcept {
if (&s != this) {
Free();
elements_ = s.elements_;
first_free_ = s.first_free_;
cap = s.cap;
s.elements_ = s.first_free_ = s.cap = nullptr;
}
return *this;
}
移动源对象必须可析构
在移动操作之后, 移动源对象必须保存 有效,可析构的状态,但是用户不能对其值进行任何假设。
合成的移动操作
只有当一个类没有定义自己的拷贝控制成员,且它的所i有数据成员是能够移动构造或是能移动赋值的话,编译器才会为它合成移动构造函数或移动赋值运算符。
原则:
- 于拷贝构造函数不同,移动构造函数被定义为删除函数的条件是: 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或是有类成员未定义自己的拷贝构造函数且编译器不能为其合成 移动构造函。移动赋值运算符的情况类似。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则其被定义为删除的
- 类似拷贝构造函数,如果类的析构函数被定义位 删除的或是不可访问的,则类的移动构造函数被定义位删除的
- 类似拷贝赋值运算符,如果有类成员是 const 或是引用,则类的移动赋值运算符被定义为删除的。
定义了移动构造函数或赋值运算符的类必须定义自己的拷贝操作,否则这些拷贝成员默认是被定义为删除的。
移动右值,拷贝左值,但如果没有移动构造函数,右值也会被拷贝
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,其对象是通过拷贝构造函数 来 ”移动“的。
拷贝并交换赋值运算符和移动操作
当我们定于i一个拷贝并交换的拷贝赋值运算符,那么如果我们为其添加一个移动构造函数,它实际上也会获得一个移动赋值运算符。
建议:(三五法则)
所有五个拷贝控制成员都应该看作是一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义五个操作。
某些类(含有动态内存):必须定义拷贝控制函数和析构函数才能正常工作,这些类通常都会有一个资源,而拷贝成员必须拷贝此资源。拷贝一个资源会导致一些额外的开销,在这种拷贝并非必须的情况下,定义了移动操作就可以避免该问题。
移动迭代器
新标准定义了一种移动迭代器适配器 ,通过改变给定迭代器的解引用运算符的行为来适配此迭代器。(移动迭代器解引用为右值引用)
我们通过调用标准库的 make_move_iterator函数来将一个普通迭代器转换为一个移动迭代器。
void StrVec::ReAllocate() {
auto new_capacity = Size() ? 2*Size() : 1;
auto new_data = alloc.allocate(new_capacity);
/* auto dest = new_data;
auto elem = elements_;
for (size_t i = 0; i != Size() ; ++i) {
alloc.construct(dest++, std::move(*elements_++));
}*/
auto last = std::uninitialized_copy(std::make_move_iterator(elements_), std::make_move_iterator(first_free_), new_data);
Free();
elements_ = new_data;
first_free_ = last;
cap = elements_ + new_capacity;
}
由于我们传递给 uninitialized_copy 的是移动迭代器,因此我们 construct将使用 移动构造函数来构造元素。
注意:
- 标准库不保证算法使用移动迭代器,那些不使用,由于移动一个对象可能销毁原对象,因此只有你确信算法是安全的,才能让移动迭代器传递给算法。
- 只有你确信需要移动操作且移动操作是安全的,才可以使用 std::move.
13.6.3 右值引用 和成员函数
如果一个成员函数同时提供拷贝和移动版本,那么它也能从中受益。
- 一个版本指向一个 const 的左值引用
- 一个指向非 const 的右值引用
一般来说,我们可用通过参数为 T& 类型的函数,当需要传递右值时,我们直接将实参 std::move 即可
但是,一般拷贝的值参数需要是常量,不允许修改,因此我们使用重载函数,一个拷贝版本一个移动版本。
当使用移动版本时,参数会精准匹配。
右值与左值引用成员函数
s1 + s2 = "wow!";
新标准仍然允许向右赋值,但是,我们可能希望在自己的类中阻止这种做法。我们希望强制左侧运算对象(this) 是一个左值。
Foo &operator=(const Foo&) &; 只能向可修改的左值赋值
-
在参数列表后放置一个 引用限定符 可用指出该返回的 this 是可用指向左值还是右值。
-
类似于 const 限定符,引用限定符只能用于 非 static 成员函数,并且必须同时出现在函数的声明和定义中。
-
一个成员函数 可用同时用于 const 与引用限定,引用限定符必须跟随在 const 限定之后。
重载与引用函数
引用限定符也可以区分重载版本。
Foo sorted() &&;
Foo sorted() const &;
一个一个成员函数有引用限定符,具有相同参数列表的重载版本都必须有引用限定符。
(变量是左值,临时量是右值)
第十四关: 重载运算与类型转换
当运算符被用于类类类型的对象时,C++语言允许我们为其指定新的含义,同时,我们也能定义类类型之间的转换规则,和内置类型的转换一样,类类型转换隐式地将一种类型的对象转换成另一种我们所需类型的对象。
14.1 基本概念
- 重载运算符是具有特殊名字的函数:它的名字由关键字 operator 和其后要定义的运算符号共同组成。
- 和其他函数一样,重载的运算符也包含 返回类型,参数列表以及函数体
- 重载运算符函数的参数数量与该运算符作用的运算对象一样多, 除了重载调用运算符 operaotr()
其他重载运算符不能含有默认实参。
- 当一个重载的运算符是成员函数时, this绑定到 左侧运算对象,成员运算符函数显式参数数量比运算对象的数量少一个
- 对于一个重载的运算符来说,其优先率和结合律与对应的内置运算符保持一致。
可以被重载的运算符
+ - * / % ^
& | ~ ! , =
< > <= >= ++ --
<< >> == != && ||
+= -= /= %= ^= &=
|= *= <<= >>= [] ()
-> ->* new new[] delete delete()
不能被重载的运算符
:: .= . ?:
直接调用一个重载的运算符函数
我们能像普通函数一样直接调用运算符函数,先指定名字,然后传入数量正确类型适当的实参
data1 + data2
operator+(data1, data2)
如果是像调用成员函数一样显式地调用成员运算符函数,具体做法,指定运行函数的对象的名字,使用点运算符(箭头运算符)访问调用的函数
data1 += data2
data1.operator+=(data2);
某些运算符不应该被重载
通常情况下,不应该重载 逻辑与和逻辑或运算符,因为会导致求值顺序无法保留
逗号运算符与取地址运算符有着特殊的含义,因为他们有着内置的函数,所以他们不应该被重载,否则他们的行为会非常怪异,导致不适合
使用与内置类型一致的含义
在设计类时明确这个类需要的操作,之后再思考是将其操作定义为普通函数还是重载运算符。
在逻辑上与运算符相关的类,他们适合定义成重载的运算符:
- 类执行 IO 操作,则定义移位运算符使其与内置类型的 IO 保持一致。
- 如果类的某个操作是检查相等性,则定义 operator==, 如果类有了 operator,那么它通常也会有 operator!=.
- 类包含一个单序比较操作,则定义 operator< ,如果类有了 operator<, 它应该也含有其他关系操作
- 重载运算符的返回类型通常情况下应该与内置版本的返回类型兼容:
- 逻辑运算符与关系运算符应该返回 bool
- 算术运算符应该返回一个类类型的值
- 赋值运算符和符合赋值运算符应该返回一个左侧对象的引用。
提升: 尽量明智地使用运算符重载
只有当操作的含义对于用户来说清晰明了时才使用运算符,如果用户对运算符有几种不同的理解,则使用这样的运算符将产生二义性。
赋值于复合赋值运算符
重载的符合赋值运算符,应该于内置保持一致
选择作为成员或非成员函数
- 赋值(=),下标(【】),调用(())和成员访问箭头 (->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者给定类型密切相关的运算符,如递增,递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如 算术,相等性,关系和位运算等,因此他们通常应该是普通的非成员函数。
- 如果我们提供含有类对象的混合类型表达式,运算符必须定义成非成员函数(double 与 int 求和)。
- 运算符定义成成员函数时, 它的左侧运算对象必须是运算符所属类的一个对象。
14.2 输入输出运算符
14.2.1 重载输出运算符 <<
- 输出运算符的第一个形参时非常量 ostream 的引用,因为向流写入会改变器状态
- 第二个参数是常量引用,(一般情况下是我们想要打印的类型)
- 返回值是 ostream 引用
std::ostream &operator<<(std::ostream &os, SalesData &item) {
os << item.book_no() << " " << item.units_sold_ << " "
<< item.revenue_ << " " << item.AvgPrice();
return os;
}
注意: 输出运算符尽量减少格式化操作
14.2.2 重载输入运算符 >>
- 输出运算符的第一个形参时非常量 istream 的引用,因为向流写入会改变器状态
- 第二个参数是引用,(一般情况下是我们想要打印的类型)
- 返回 给顶流的引用
std::istream &operator>>(std::istream &is, SalesData &item) {
double price = 0;
is >> item.book_no_ >> item.units_sold_ >> price;
if(is)
item.revenue_ = price * item.units_sold_;
else
item = SalesData();
return is;
}
- 输入运算符必须处理输入失败的情况,并且负责从错误中恢复,尽量去通过 IO 标准库 标识错误信息
通过将对象置为合法的状态,我们能保护使用者免于输入错误的影响,此时的对象处于可用状态,它的成员是被正确定义的,而且对象不会产生误导性的结果。
14.3 算术和关系运算符
算术和关系运算符定义成非成员函数以允许左侧或右侧运算对象进行转换,因为这些运算符一般不需要改变运算对象状态,形参均为常量引用。
- 如果定义了算术运算符,那么它一般也会定义一个对应的复合赋值运算符,最有效的是使用复合赋值运算符来定义算术运算符
SalesData&& operator+(const SalesData &lhs, const SalesData &rhs) {
SalesData sum = lhs;
sum += rhs;
return std::move(sum);
}
14.3.1 相等运算符
如果一个类在逻辑上有相等性的含义,则类应该定义 operator==,这样做可用使得用户更容易使用标准库算法来处理该类。
bool operator==(const StrBlobPtr &loo, const StrBlobPtr &roo) {
return (loo.weak_ptr_.lock() == roo.weak_ptr_.lock()) && (loo.curr == roo.curr);
}
bool operator!=(const StrBlobPtr &loo, const StrBlobPtr &roo) {
return !(loo == roo);
}
14.3.2 关系运算符
如果存在唯一一种逻辑可靠的 < 定义,则考虑应该为这个类定义 < 运算符,如果类同时还包含 == ,当且仅当 < 的定义和 == 产生的结果一致时才定义 < 运算符。
== 的规则与 < 的规则应该保持一致,否则不能算逻辑可靠
bool operator==(const StrBlobPtr &loo, const StrBlobPtr &roo) {
return (loo.weak_ptr_.lock() == roo.weak_ptr_.lock()) && (loo.curr == roo.curr);
}
bool operator!=(const StrBlobPtr &loo, const StrBlobPtr &roo) {
return !(loo == roo);
}
bool operator<(const StrBlobPtr &loo, const StrBlobPtr &roo) {
if(loo.weak_ptr_.lock() != roo.weak_ptr_.lock())
throw std::invalid_argument("argument is not match!");
return loo.curr < roo.curr;
}
14.4 赋值运算符
我们可用重载赋值运算符(参数不同)不管形参类型是什么,赋值运算符都必须定义为成员函数。
StrVec &StrVec::operator=(std::initializer_list<std::string> il) {
auto data = AllocNCopy(il.begin(), il.end());
Free();
elements_ = data.first;
first_free_ = cap = data.second;
return *this;
}
复合赋值运算符
复合赋值运算符与赋值运算符都是成员函数,这两类运算符都应该返回左侧运算对象的引用。
SalesData &SalesData::operator+=(const SalesData &rhs) {
units_sold_ += rhs.units_sold_;
revenue_ += rhs.revenue_;
return *this;
}
14.5 下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,一般会定义下标运算符 【】
- 下标运算符必须是成员函数
- 下标运算符以访问元素的引用作为返回值,常量对象返回常量引用
包含下标运算符的类,会定义两个版本(一个返回普通引用,另一个是类的常量成员且返回常量引用)
std::string &StrVec::operator[](size_t n) {
return *(elements_+n);
}
const std::string &StrVec::operator[](size_t n) const {
return *(elements_ + n);
}
14.6 递减与递增运算符
递增递减运算符一般在迭代器类中实现
- 因为他们改变了操作对象的状态,所以建议定义为成员函数
- 如果定义,那么前置与后置均要定义,且都是类成员
定义前置递增/递减运算符
- 前置运算符返回 其递增递减后对象的引用
- 运作机理,先检测迭代器是否可递增或递减,之后检查索引是否有效,之后返回结果对象引用
StrBlobPtr &StrBlobPtr::operator++() {
Check(curr,"increment past end of StrBlobPtr");
++curr;
return *this;
}
StrBlobPtr &StrBlobPtr::operator--() {
Check(--curr, "decrement past begin of StrBlobPtr");
return *this;
}
区分前置与后置运算符
由于前置后置使用同一种符号,且运算对象的数量与类型都相同,因此为了区分重载,
后置版本接受了一个额外的(不被使用的)int 类型的形参,显式调用要提供该实参。
StrBlobPtr operator++(int); //后置
StrBlobPtr operator--(int);
StrBlobPtr& operator++(); //前置
StrBlobPtr& operator--();
- 后置版本是先记录状态,再修改状态
StrBlobPtr StrBlobPtr::operator++(int) {
StrBlobPtr ret = *this;
++*this;
return ret;
}
StrBlobPtr StrBlobPtr::operator--(int) {
auto ret = *this;
--*this;
return ret;
}
14.7 成员访问运算符
在迭代器类及智能指针类会用到 解引用运算符(*) 与 箭头运算符(->)
std::string &StrBlobPtr::operator*() const {
return DeRef();
}
std::string *StrBlobPtr::operator->() const {
return & this->operator*();
}
- 箭头运算符与解引用运算符都应该是类的成员
- 解引用运算符首先应该检测当前的 索引是否在作用范围内,之后再返回引用,而箭头运算符是调用解引用符返回解引用结果的地址。
- 解引用与箭头运算符不会改变运算对象的状态,他们只能绑定非常量对象
对箭头运算符返回值的限定
箭头运算符不能定义获取成员额外的操作,只能用作成员访问
可以改变箭头从哪个对象中获取成员。
(*point).mem //point是一个内置指针类型
point.operator()->mem //point是类对象,执行的操作是从指向类中调用其成员函数
除此之外, point->mem 的执行操作:
- 如果 point 是指针,我们引用内置的箭头运算符,解引用该指针并从其对象中获取指定成员
- 如果 point 定义了 operator-> 的类的对象,我们使用 point.operator->() 的结果来获取 mem,其结果是个指针,则执行第一步,如果该结果本身也含有重载的 operator->(),则重复调用当前步骤,最终这一过程接受时程序返回所需内容或者返回程序错误信息。
注意: 重载箭头运算符必须返回类的指针或者自定义箭头运算符的某个类的对象。
14.8 函数调用运算符
我们 可以像使用函数一样使用重载了函数调用运算符的类的对象,因为这样的类也能存储状态。
struct absInt {
int operator() (int val) const {
return val < 0 ? -val : val;
}
};
absInt absObj;
int ui = absObj(i);
- 定义了调用运算符的类, 类的对象称为 函数对象
含有状态的函数对象类
可以设定数据成员来完成定制调用运算符中的操作
class PrintString {
public:
PrintString(ostream &o = cout, char c = ' ') : os(o), seq(c) {}
void operator()(const string &s) const { os << s << seq; }
private:
ostream &os;
char seq;
}
PrintString printer;
printer(s);
PrintString errors(cerr, '\n');
errors(s);
函数对象常常作为泛型算法的实参,满足其实参的类型(参数列表以及返回值)即可
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
14.8.1 lambda 是函数对象
lambda 会被编译器翻译成一个 未命名类的未命名对象,在lambda表达式产生的类中含有一个重载的函数调用运算符
stable_sort(words.begin(), words.end(),
[] (const string &a,const string &b) { return a.size() < b.size(); });
其行为类似以下类的未命名对象
class ShorterString {
public:
bool operator() (const string &s1, const string &s2) const
{ return s1.size() < s2.size() ; }
}
也可以说 lambda 表达式就是一个实际上就是一个重载了函数调用符的类
- 函数调用运算符的形参列表与函数体与 lambda 表达式完全一样。
- 默认 lambda 不能改变 它捕获的变量,因此默认情况下,lambda 产生的类中的函数调用运算符是一个const 成员函数, 如果lambda声明为可变大,则调用运算符就非const
表示 lambda 及相应捕获行为的类
- 当一个lambda 表达式通过引用捕获变量时,程序负责确保 lambda 执行时引用所引用的对象确实存在,因此,编译器可直接使用 该引用而无须在 lambda 产生的类中将其存储为数据成员。
- 通过值捕获的变量拷贝到 lambda中,这种 lambda 产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕捉的变量 初始化数据成员。
注意: lambda 表达式产生的类不含有默认构造函数,赋值运算符及默认析构函数,它是否含有默认的构造/移动构造函数则通常要视捕获的数据成员类型而定。
14.8.2 标准库定义的函数对象
标准库函数对象
算术 关系 逻辑
plus<T> equal_to<T> logical_and<T>
minus<T> not_equal_to<T> logical_or<T>
multiplies<T> greater<T> logical_not<T>
divides<T> greater_equal<T>
modulus<T> less<T>
negate<T> less_equal<T>
在算法中使用标准库函数对象
表示运算符的函数对象常用来替换算法中的默认运算符
sort(svec.begin(), svec.end(), greater<string>());
std::multiset<int, std::greater<int>> book_store;
book_store.insert(2);
book_store.insert(3);
cout << *book_store.begin();
标准库规定其函数对象对于指针同样适用。
14.8.3 可调用对象与 function
C++语言中有几种可调用的对象: 函数,函数指针,lambda 表达式,bind 创建的对象以及重载了调用运算符的类。
- 每个 lambda 有它自己的唯一的类类型
- 函数及函数指针的类型由返回值类型和实参类型决定。
不同类型的可调用对象却可能共享同一种调用形式,调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。
int (int, int)
不同类型可能具有相同的调用形式
对于共享相同调用形式的可调用对象,我们希望把他们看成具有相同的类型
int add(int i, int j) { return i+j; }
auto mod = [](int i, int j) { return i%j; }
struct divide {
int operator() (int denominator, int divisor)
return denominator / divisor;
}
调用形式:
int (int, int )
如果我们试图利用 调用形式 int(int,int) 来 存储这些可调用对象,就会产生不匹配的情况,(lambda 与 重载了可调用操作符的类与函数指针类型不符)
标准库 function 函数
我们可以使用 名为 function 的新的标准库函数来解决以上问题,定义在 functional 头文件中
function 的操作
functio<T> f; f是一个用来存储可调用对象的空 function,这些可调用对象的调用形式类型
应该与函数 T 相同(T 是 retType(args))
function<T> f(nullptr); 显式构造一个空 function
function<T> f(obj); 在 f 中存储一个 可调用对象 obj 的副本
f 将 f 作为条件:当 f 含有一个可调用对象为真,否则为假
f(args) 调用f中的对象,参数是 args
定义为 function<T> 的成员的类型
result_type 该function 类型的可调用对象返回的类型
argument_type 当T有一个或两个实参时定义的类型,如果T有一个实参,那 argument_type
与其是同义词,如果 T 有两个实参,则 first_argument_type 和
second_argument 分别代表两个实参的类型
first_argument_type
second_argument_type
- function是一个模板,我们需要提供额外的 function类型能表达的对象的调用形式
- 利用调用形式模板,我们能存储其所有能表达该调用形式的可调用对象。
function<int(int, int)>
重载的函数与function
- 我们不能将重载函数的名字存入 function 类型的对象中,因为会造成二义性,我们利用存储函数指针的方式,表现存储的重载函数参数与另外同名函数不同。
int add(int, int) {}
double add(double, double)
function<int(int,int)> f(add); //二义性
std::function<int(int, int)> f;
int (*fp)(int, int) = add;
f.operator=(fp);
cout<< f(1,2);
- 我们也能使用 lambda 来消除二义性
f.operator=([] (int a, int b) { return add(a, b); })
14.9 重载,类型转换与运算符
我们能通过构造函数隐式地将实参类型转换为类类型,也能同样定义对于类类型的类型转换
- 转换构造函数 与 类型转换运算符共同定义了 类类型的转换,也叫用户定义的类型转换
14.9.1 类型转换运算符
类型转换运算符 是类的特殊成员函数,它负责将一个类 类型的值转换为其他类型,形式如下
operator type() const;
- type 表示某种类型, 只要该类型能作为函数的返回类型(数组与函数类型除外),但可以转换为 指针与引用类型
- 类型转换运算符没有显式的返回类型,没有形参,必须定义为成员函数
- 类型转换运算符通常不改变其对象的内容, 类型转换运算符一般被定义成 const 成员。
note: 一个类型转换函数必须是成员函数,不能声明返回类型,形参必须为空,且应该是 const 成员
定义含有类型转换运算符的类
class SmallInt {
public:
SmallInt(int i = 0): val(i) {
if(i <0 || i > 255)
throw std::out_of_range ("");
}
operator int() const { return val; }
private:
std::size_t val;
}
使用类型转换运算符
SmallInt s1;
si = 4; //首先将 4 隐式转换为 SmallInt ,然后调用 operator=
si+3 //将 si 隐式转换为 int ,然后执行整数加法
注意:尽管编译器一次只能执行一个用户定义的类型转换,但隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后。
提示: 避免过度使用类型转换函数
明智地使用类型转换运算符能极大简化类设计者的工作, 同时使得使用类更加容易,然而如果在类类型和转换类型之间不存在明显的映射关系,这样的转换会具有误导性。
类型转换运算符可能产生意外结果
int i =42;
cin << i;
这时 cin会转换为 bool类型,bool类型转换为 0,1, 之后进行移位操作 将 0,1左移 42
显式的类型转换运算符
为了防止这样的异常情况发生, C++11 新标准引入了 显式的类型转换运算符(explict conversion operator):
explict operator int() const { return val; }
- 我们必须使用显式转换请求,才能让该类类型执行 类型转换运算符操作(static_cast(object))
- 如果表达式被用作条件,编译器会将显式的类型转换自动应用
- if ,whille 以及 do 语句的条件部分
- for 语句头的条件表达式
- 逻辑非 或 与 运算的 对象
- 条件运算符 (? :) 的条件表达式。
转换为 bool
像 bool 类型转换通常用于条件部分,因此 operator bool 一般定义为 explicit的
14.9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型只存在唯一的一种转换方式,否则我们编写的代码很可能有二义性。
- 两个类提供相同的类型转换:A类定义接受 B 类对象的转换构造函数,同时 B也定义了转换 目标是 A类的类型转换运算符, 他们提供了相同的类型转换。
struct B;
struct A {
A() = default;
A(const B&);
}
struct B {
operator A() const ;
}
A f(const A&);
B b;
A a = f(b); //二义性,不知道调用的是 转换构造函数 还是类型转换运算符
解决以上的问题,我们不得不显式调用 类型转换运算符 或者 转换构造函数
A a1 = f(b.operator A())
A a2 = f(A(b));
我们也无法利用强制类型转换解决二义性,因为强制转换类型本身也面临 二义性
- 如果定义了多个转换规则,且这些转换设计的类型本身可以通过其他类型联系在一起,会产生二义性,最好只定义一个与算术类型有关的转换规则。
struct A {
A(int = 0);
A(double);
operator int() const;
operator double() const;
}
void f2(long double);
A a;
f2(a) //不知道使用的是那一个类型转换运算符
long lg;
A a2(lg); //二义性
二义性的原因是因为他们所需的标准库类型转换级别一致。
当我们使用两个用户定义的类型转换时, 如果转换函数之前或之后存在标准库类型转换,则标准类类型转换将决定最佳匹配到底是哪一个。
提示: 类型转换与运算符
- 不要令两个类 执行相同的类型转换,如果 Foo 类 有一个接受 Bar对象的构造函数,则不要在Bar 类中再定义转换目标是 Foo类的类型转换运算符。
- 避免转换目标是内置算法类型的类型转换。 特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
- 不要再定义接受算术类型的类型转换,如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
- 不要定义转换到多种算术类型的类型转换, 让标准库转换完成向其他算术类型转换的工作。
一言则蔽之:除了显式地向 bool 类型的转换之外,我们应该避免定义类型转换函数,并尽可能限制那些 “显然正确” 的非显式构造函数。
重载函数与转换构造函数
如果 两个参数或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。
struct C{
C(int);
}
struct D{
D(int);
}
void manip(const C&);
void manip(const D&);
manip(10) 会产生二义性
我们可以通过显式地构造正确的类型消除二义性
manip( C(10))
如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,这说明我们程序设计存在不足。
重载函数与用户定义 类型转换
struct E{
E(dobule);
}
void manip(const E&);
void manip(const D&);
manip(10) 会产生二义性
因为调用重载函数所请求的用户定义的类型转换不止一个且彼此不同, 所以调用具有二义性。
即使其中一个调用需要额外的标准库类型转换而另外一个精准匹配,编译器也会标识错误。
14.9.3 函数匹配 与 重载运算符
重载的运算符也是重载的函数,因此,通用的函数匹配规则同样适用与判断给顶表达式中到底应该使用内置运算符还是重载的运算符。
a sym b
a.operatorsym(b) //a 有一个 operatorsym 成员函数
operatorsym(a, b ); // operaotrsym 是一个普通函数
我们不能通过调用的形式来区分当前调用是是成员函数还是非成员函数
表达式中运算符的候选函数既应该包括成员函数,也应该包括非成员函数 与内置类型函数。
class SmallInt {
friend SmallInt operator+(const SmallInt& , const SmallInt&);
public:
SmallInt(int = 0);
operator int() const { return val; }
private:
size_t val;
}
SmallInt s1,s2;
auto s3 = s1 +s2;
int i = s3 + 0; //二义性
如果我们对同一个类进行了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
第十五关 . 面向对象程序设计
15.1 OOP:概述
面向对象程序设计(object - oriented programing)的核心思想是 数据抽象,继承和动态绑定。
数据抽象: 可以实将 类接口和实现分离。
继承: 可以定义相似的类型并对其相似关系建模。
动态绑定: 可以在一定程度上忽略相似类型的区别, 并以统一的方式来使用他们的对象。
继承
- 通过继承连接起来的类构成一种层次关系
- 基类是层次关系的根部,其他类从其中直接,间接继承而来,继承得到的类型是派生类
- 基类负责定义在层次关系中所有类共同拥有的成员,每个派生类定义各自特有的成员。
- 派生类需要使用派生类列表明确指出它是从哪个基类继承而来,(类名后: 派生访问说明符 继承类名)
动态绑定
在C++语言中,当我们使用基类的引用(指针)调用一个虚函数时发生动态绑定。
15.2 定义基类和派生类
15.2.1 基类定义
class Quote {
public:
Quote() = default;
Quote(const std::string &book, double sales_price)
: book_no_(book), price_(sales_price) {}
virtual ~Quote() = default; //对析构函数进行动态绑定
std::string isbn() const { return book_no_; };
virtual double net_price(std::size_t n) const
{ return n * price_; }
private:
std::string book_no_;
protected:
double price_ = 0.0;
};
基类通常会定义一个虚析构函数,即使该函数不执行任何实际操作也要如此
成员函数与继承
派生类可以继承基类的成员,当需要虚函数时,派生类必须重新提供自己的新定义以覆盖(override)从基类继承而来的旧定义。
C++中,基类需要分开两种函数:
-
一种是基类希望派生类进行覆盖的函数,这类定义为虚函数,当我们使用指针或基类调用虚函数时,该调用将被动态绑定,根据引用或指针绑定的对象类型不同,该调用执行不同的版本
- 基类在成员函数的声明语句上加上关键字 virtual 使得函数执行动态绑定
- 任何构造函数之外的非静态函数都可以是虚函数
- 关键字 virtual 只能出现在类内部的声明语句中,而不能定义在类外部的函数定义
- 如果基类把一个函数声明为虚函数,则该类在派生类中隐式地也是虚函数
-
另一种是基类希望派生类直接继承而不要改变的函数。
访问控制与继承
- 派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问到基类继承来的成员
派生类能访问公有成员与保护成员,而不呢访问私有成员,用户仅能访问公有成员
15.2.2 定义派生类
派生类必须通过类派生列表明确指出它是从哪个基类继承而来。
形式:
(首先是冒号,后面紧跟以逗号分割的基类列表, 每个基类前面可以是三种访问说明符中的一个 :
public, protected 或者是 private。)
class Bulk_Quote : public Quote {
public:
Bulk_Quote() = default;
Bulk_Quote(const std::string &, double, std::size_t, double);
double net_price(std::size_t) const override;
private:
std::size_t min_qty_ = 0; //使用折扣的最低购买量
double discount = 0.0; //以小数表示的折扣额
};
派生类中的虚函数
- 派生类会经常覆盖它的继承虚函数, 如果没有覆盖,则该虚函数的行为是派生类继承其基类的版本
- 派生类可以在它覆盖的函数前面使用 virtual(不是必须)
- C++ 11 新标准允许派生类显式地标注它使用某个成员函数覆盖了它继承的虚函数,做法
{ 在形参列表后面,或是在 const 成员函数的const 关键字后面,或者在引用成员函数的引用限定符后面添加 override 关键字}
派生类对象及派生类向基类的类型转换
在一个对象中,继承自基类的部分和派生类定义的部分不一定是连续存储的。
- 因为在派生类对象中含有与基类相对应的组成部分, 所有我们能把派生类的对象当成基类对象来使用而且我们也能将基类的指针或引用绑定到派生类对象的基类部分上。
Quote item;
Bulk_Quote bulk;
Quote *p = &item; //p指向 基类对象
p = &bulk; // p指向 bulk 的基类部分
Quote &r = bulk; // r 绑定到 bulk 的基类部分
- 这种转换叫做派生类到基类的类型转换, 编译器会隐式地执行派生类到基类的转换
note : 在派生类对象中有基类组成部分, 这一事实是继承的关键所在。
派生类构造函数
每个类控制它自己的成员初始化过程,因此我们必须使用基类的构造函数来初始化派生类的基类组成部分。
- 派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。
- 派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类的构造函数的。
Bulk_Quote(const std::string &book, double p, std::size_t qty, double discount)
: Quote(book, p), min_qty_(qty), discount_(discount) {}
- 如果我们不利用 基类构造函数初始化基类部分, 那么基类部分会像数据成员一样执行默认初始化
- 使用其他基类构造函数,只需要指定其重载版本的参数,使得编译器进行匹配即可
note: 首先初始化基类的部分, 然后按声明的顺序依次初始化派生类的成员。
派生类使用基类的成员
派生类可以使用基类的 公有与 保护成员
double Bulk_Quote::net_price(std::size_t cnt) const {
if (cnt >= min_qty_)
return cnt * (1 - discount_) * price_;
return cnt * price_;
}
关键概念: 遵循基类的接口
必须明确: 每个类负责定义各自的接口, 要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
- 派生类不能直接初始化类的成员,(尽量可以直接初始化),但一定要遵循类的接口,调用基类的构造函数初始化那些从基类继承自自身的成员
继承与静态成员
静态成员数据在整个继承体系中只存在该成员的唯一定义。
静态成员遵循通用的访问控制规则, 如果基类中的成员是 private的,则派生类无权访问它。如果静态成员是可访问的,我们则通过基类或者派生类均可访问。
class Base{
public :
static void Statement();
}
class Derived : public Base{
void f (const Derived &obj){
Base::statement(); //类作用域访问
Derived::statement();
obj.statement(); //通过对象访问
statement(); //this 访问
}
}
派生类的声明
一条声明语句的目的是让程序知道某个名字的存在 以及 改名字表示一个什么样的实体,如一个类,一个函数,一个变量等,派生列表以及与定义有关的其他细节必须在类的主体一起出现
被用作基类的类
如果我们想让某个类作为基类,该类必须已经定义而非声明
原因: 派生类包含并且可以使用它从基类继承而来的成员,为了使用这些成员, 派生类必须知道他们是什么。 这层含义的隐含意思(一个类不能派生它本身)
- 每个类都会继承基类的所有成员,对于一个最终的派生类来说,它会继承其直接基类的成员,
因此最终派生类会包含它直接基类的字对象以及 每个间接基类的子对象。
防止继承的发生
C++ 11 新标准提供了防止继承发生的方法,在类名后添加一个 关键字 final:
class FinalClass final {} //该类不能作为基类
15.2.3 类型转换与继承
理解基类与派生类之间的类型转换是理解 C++语言面向对象编程的关键所在。
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:
当使用基类的指针或引用时,我们不清楚该绑定对象的真实类型,该对象可能是基类对象或派生类对象
note : 智能指针也支持派生类向基类的类型转换, 基类的智能指针能存储派生类对象的指针
静态类型与动态类型
静态类型: 在编译时总是已知的, 它是变量声明时的类型或是表达式生成的类型。
动态类型: 变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
note: 基类的指针或引用的静态类型可能与其动态内存不一致。
不存在从基类到派生类的隐式类型转换
因为一个基类的对象可能是派生类对象的一部分, 也可能不是; 所以不存在从基类向派生类的自动类型转换:
Quote Base;
Bulk_quote* bulkP = &base; //如果转换成功,我们会使用到 base本不存在的成员,是非法的
- 即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换
- 编译器只能通过检查指针或引用的静态类型推断转换是否合法, 如果我们已经知道基类向派生类的转换是安全的,我们可以使用 static_cast 来强制覆盖掉编译器的检查工作,进行显式强制转换。
Quote item;
Bulk_Quote bulk;
Quote *p = &item; //p指向 基类对象
p = &bulk; // p指向 bulk 的基类部分
Bulk_Quote *bq = static_cast<Bulk_Quote *>(p);
在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用类型有效, 在派生类类型和基类类型之间不存在这样的转换。
- 当我们给基类 传递一个派生类对象时,实际运行的是 基类定义的构造函数 或者 赋值运算符,该运算符只能处理 派生类中基类的成员。
Bulk_Quote bulk;
Quote item(bulk);
item = bulk;
代码中演示的操作会忽略掉 派生类的子成员,也可以说 派生类的部分被切掉了,仅传递 其中基类成员。
warning: 当我们用一个派生类对象作为一个基类对象初始化或赋值时, 只有该派生类对象中基类部分会被拷贝, 移动或赋值, 它的派生类部分将被忽略掉。
关键概念:存在继承关系的类型之间的转换规则
- 从派生类像基类的类型转换只对指针 或引用类型有效
- 基类向派生类不存在隐式类型转换
- 派生类向基类的类型转换也可能由于访问受限而变得不可行。
15.3 虚函数
- 当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,因为只有我们直到运行时才知道到底调用了哪个版本的虚函数
- 我们必须为每个虚函数都提供定义,不管它是否用到了,这时因为连编译器都不知道会使用哪个虚函数
对虚函数的调用可能在运行时解析
被调用的函数是与绑定到指针或引用上的对象的动态绑定相匹配的哪一个。
关键概念: C++ 的多态性
OOP 的核心思想是 多态性。
- 我们把具有继承关系的多个类型称为多态类型, 因为我们能使用这些类型而无须在意它们的差异。
- 引用或指针的静态类型与动态类型不同这一件事就是 C++ 语言支持多态性的根本所在
对非虚函数的调用在编译时进行绑定。 类似的,通过对象进行的函数调用也在编译时绑定,对象的类型是确定不变的, 都不可能使得对象的动态类型与静态类型不一致。
notae: 当且仅当通过指针或引用调用虚函数时,才会在运行时解析该调用, 也只有这种情况下对象的动态类型与静态类型可能会不同。
派生类中的虚函数
- 一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
- 一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须被它覆盖的基类函数完全一致。同样,其返回值也必须与基类函数匹配(有例外)
例外: 当类的虚函数返回类型是类本身的指针或引用时,规则无效,但返回类型要求从派生类本身指针或引用到 基类的指针或引用的类型转换是可访问的。
(D 是 B 派生类, 则 基类虚函数可以返回B* 而 D可以返回 D*)
final 与 override 说明符
派生类定义了一个函数与基类虚函数名字相同但形参列表不同,这仍然是合法行为。 编译器将认为新定义的函数与基类的虚函数是相互独立的,就编程习惯而言,这种声明意味着错误。
调试以上错误十分困难,因此 c++ 11 新标准中我们可以使用 override 关键字来说明派生类中的虚函数,好处是 使得程序员的意图更加清晰的同时让编译器为我们发现了一些错误,如果我们使用 override标记了某个函数, 但该函数没有覆盖已经存在的虚函数,此时编译器报错。
final 可以将某个函数指定为最后覆盖, 之后任何尝试覆盖该函数的操作都将引发错误。
note: final 与 override 说明符在形参列表 (包括 const 和引用修饰符) 以及尾置返回类型之后。
虚函数与默认实参
虚函数的默认实参(可提供),实参值由本次调用的静态类型决定。
如果我们使用基类引用或指针调用函数,则不管运行的是派生类的函数 均使用基类的默认实参
建议: 如果虚函数使用默认实参, 则基类和派生类定义的默认实参最好一致。
回避虚函数的机制
我们希望虚函数的调用不进行动态绑定, 而是强迫其执行虚函数的某个特定执行版本。
Quote item;
Bulk_Quote bulk;
Quote *p = &item; //p指向 基类对象
p = &bulk; // p指向 bulk 的基类部分
p->Quote::net_price(42);
该代码强行调用 Quote 的 net_price 函数, 而不管 baseP 实际指向的对象类型到底是什么。该调用将在编译时完成解析。
通常情况下,只有成员函数(友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
15.4 抽象基类
纯虚函数
当我们需要一种 通用概念的类时而非具体操作的类,这种类我们定义其 纯虚函数 来表示这种通用概念。
- 定义为纯虚函数可以清晰明了地告诉用户当且函数是没有实际意义的。
- 纯虚函数无须定义,我们在函数体的位置(声明语句的分号之前 )书写 = 0 就可以说明为纯虚函数, =0 只能出现类内部虚函数声明语句处。
class DiscQuote : public Quote{
public:
DiscQuote() = default;
DiscQuote(const std::string &book, double price, std::size_t qty, double disc): Quote(book, price), quantity_(qty), discount_(disc) {}
double net_price(std::size_t) const = 0;
protected:
std::size_t quantity_ = 0;
double discount_ = 0.0;
};
含有纯虚函数的类是抽象基类
含有(未经覆盖直接继承) 纯虚函数的类是抽象基类,抽象基类负责定义接口,而后续的其他类可以覆盖该接口。
- 抽象基类的派生类必须给出自己的 纯虚函数定义,否则它们仍然是抽象基类
- 抽象基类是接口的表现,我们不能创建其接口的对象。
派生类构造函数只出始化它的直接基类
派生类除了初始化它自身,也会初始化它的直接基类,之后它的直接基类会初始化它的直接基类。。
从最底层的基类开始初始化逐层到最后的派生类对象。
关键概念:重构
在基类的继承体系中加入 抽象基类实现了 重构。
重构负责重新设计类的体系以便将操作和 / 数据从一个类移动到另一个类。
注意点: 即使我们改变了整个继承体系, 那些使用了 基类与 派生类的代码无须任何改动,不过一旦类被重构, 我们必须重新编译含有这些类的代码
15.5 访问控制与继承
每个类还分别控制着成员对于派生类是否可访问
受保护的成员
- 受保护的成员对于类的用户是不可访问的,但对于派生类的成员和友元是可访问的
- 派生类的成员和友元只能通过派生类对象来访问基类的受保护的成员,派生类对于一个基类对象中的受保护的成员没有任何访问权限。
class Base {
protect:
int prot_mem;
}
class Sneay: pulbic Base {
friend void clobber(Sneaky&);
friend void clobber(Base &);
}
void clobbeer(Sneaky & s) { s.j = s.prot_mem = 0;} //正常访问
void clobber(Base &b) { b.prot_mem = 0; } //访问失败
派生类的成员和友元能访问派生类对象中基类部分的受保护成员,对于普通的基类对象的成员不具有特殊的访问控制。
公有,私有和受保护继承
一个类对其继承而来的成员的访问权限受到两个因素影响: 一个是基类中该成员的访问说明符,二是派生类在派生类列表中的访问说明符。
class Base{
public :
void pub_mem;
protected:
int prot_mem;
private:
char priv_mem;
}
struct Pub_Derv : public Base {
//正确,派生类能访问 protected成员
int f() { return prot_mem; }
//错误,派生类不能访问 private 成员
char g() { return priv_mem; }
}
struct Priv_Derv : private Base {
//private 不影响派生类的访问权限
int f1() const { return prot_mem; }
}
- 派生访问说明符对于派生类的成员(友元)能否访问其直接基类的成员没什么影响。 对基类成员的访问 权限只与基类中的访问说明符有关。
- 派生访问说明符的目的是控制派生类用户 (包括派生类的派生类在内) 对于基类成员的访问权限“
Pub_Derv d1;
Priv_Derv d2;
d1.pub_mem(); //正确
d2.pub_mem(); //错误, pub_mem 是在派生类中是私有的。
- 派生访问说明符还可以控制继承自派生类的新类的访问权限:
struct Derived_from_Public : public Pub_Derv {
int use_base() { return prot_mem; } //Base::prot_mem 在 Pub_Derv 仍然是 protected的
}
struct Derived_from_private: public Priv_Derv {
// 错误, ase::prot_mem 在 Priv_Derv 是 private 的
int use_base() { return prot_mem; }
}
- 对于继承自 使用私有派生说明符继承基类的的派生类 的 第二代派生类,它们对于它们 间接基类的成员是 私有访问控制的, protected 类似
- 派生访问修饰符是私有与保护时,也可以说是把在基类中派生类可访问成员都以 派生访问修饰符的权限 添加到该派生类中
派生类向基类转换的可访问性
派生类向基类的转换是否可访问由使用该转换的代码决定, 同时派生类的派生访问说明符也会有影响。
假定 D 继承自 B:
- 只有当 D 公有地继承 B 时, 用户代码才能使用派生类向基类的转换; 如果 D 继承 B 的方式是受保护的或者私有的, 则用户代码不能使用该转换。
- 不管D以什么方式继承 B ,D 的成员函数和友元都能使用派生类向基类的转换, 派生类向其直接基类的类型转换对于派生类的成员和友元来说永远都是可访问的
- 如果 D 继承B 的方式是公有的或者受保护的, 则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换,反之是私有的 则不行
总结: 对于代码中的某个给顶节点来说,如果基类的公有成员是可访问的, 则派生类向基类的类型转换也是可访问的,反之则不行。
关键概念: 类的设计和受保护的成员
- 假定类的用户有两种 : 普通用户和类的实现者。
普通用户: 编写 的代码使用类的对象,这部分代码只能访问类的 公有(接口)成员
实现者:则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的 私有(实现)部分
考虑继承的话会出现第三种用户 : 派生类
基类把它希望派生类能够使用的部分声明为受保护的。 普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问 私有成员
- 基类应该把接口成员声明为公有的, 同时将属于其实现的部分分成两组:
- 可供派生类访问 ,声明为受保护的,这样派生类就能实现自己的功能时使用基类的操作和数据
- 由基类及基类的友元访问, 声明为私有的。
友元与继承
不能继承友元,每个类负责各自成员的访问权限。
派生类的友元也不能随意访问基类的成员
改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用 using 声明
class Base {
public :
std::size_t size() const { return n; }
protected:
std::size_t n;
}
class Derived : private Base {
public :
using Base::size;
protected:
using Base::n;
}
- 通过在类的内部使用 using 声明语句,我们可以将该类的直接或间接中的任何可访问成员标记出来。
- using 声明语句中名字的访问权由该 using 声明语句之前的 访问说明符决定。
- 派生类只能为那些它可以访问的名字提供 using 声明。
默认的继承保护级别
struct 与 class 区别
默认成员访问说明符: struct public class 是 private
默认派生访问说明符: struct public class 是 private
- 尽量是显式地将 派生访问说明符与成员访问说明符声明出来。
15.6 继承中的 类作用域
- 派生类的作用域位于基类作用域之内,因此派生类才能像使用自己的成员一样使用基类的成员
Bulk_quote bulk;
cout << bulk.isbn();
- 现在调用成员的对象中找,如果找不到,向继承的类的作用域找,直到在基类找到为止,之后被解析为基类的 isbn()
在编译时进行名字查找
当我们进行调用成员时,是在编译时进行名字查找,只看当前的静态成员的作用域,如果里面没有,则报错
名字冲突与继承
派生类的成员将因此同名的基类成员
通过作用域运算符来使用隐藏的成员
我们可以使用作用域来使用被隐藏的基类成员:
strcut Derived: Base {
int get_base_mem() { return Base::mem; }
}
作用域运算符将覆盖原有的查找规则,提示编译器从 Base 类的作用域开始查找 mem
建议: 除了覆盖继承而来的虚函数之外, 派生类最好不要重用其他定义在基类中的名字。
关键概念: 名字查找与继承
假定我们调用 p->mem() (或者 obj.mem()),则依次执行以下 4 个步骤:
- 首先确定 p (或 obj) 的静态类型, 因为我们调用的是一个成员, 所以该类型必然是类类型。
- 在 p (或 obj) 的静态类型对应的类中查找 mem。 如果找不到,则依次在直接基类中不断找到直到继承链的顶端, 如果找遍了该类及其基类仍然找不到,则报错。
- 一旦找到了 mem,就进行常规的类型检测确认是否合法
- 调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
- 如果 mem 是虚函数且我们是通过引用或指针进行的调用, 则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据 是对象的动态类型。
- 反之,如果是对象调用,则编译器产生一个常规函数调用。
一如往常,名字查找先于类型检查
- 声明在内层作用域的函数并不会重载声明在外层作用域的函数,因此,定义在派生类的函数不会重载其基类的函数
- 如果派生类于基类的成员重名,则派生类将在其作用域内隐藏该基类成员,参数列表基类与派生类不同,基类成员也会被隐藏掉。
虚函数与作用域
因为名字查找先于类型检查,,我们在派生类中找到该名字后,就不会继续搜索基类的虚函数,如果基类与派生类的虚函数接受的实参不同,就会报错。
通过继承调用隐藏的虚函数
覆盖重载的函数
成员函数无论是否是虚函数都能被重载。 派生类可以覆盖重载函数的 0 个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的, 那么它需要覆盖所有的版本,或者一个也不覆盖。
如果我们不得不覆盖类i中 的每个版本的话,显然操作将极其繁琐。
-
一种好的解决办法是为重载的成员提供一条 using 声明语句,这样我们就无须覆盖基类中每一个重载版本了。
-
使用: using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就把所以重载实例添加到派生类作用域中。此时派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。
-
类内 using 声明的一般规则同样适用于重载函数的名字,基类函数的每个实例在派生类中都必须是可访问的
-
对派生类没有重新定义的重载版本访问实际上是对 using 声明点的访问。
15.7 构造函数与拷贝控制
因为 基类指针指向的可能是 派生类,(静态类型与动态类型不符),如果派生类中含有动态内存,当我们 delete 其指针,我们需要确定其执行的析构函数的正确性,所以需要定义虚析构函数,在对象调用时,进行动态绑定,安全地析构
- 如果基类的析构函数不是虚函数, 则delete 一个指向派生类对象的基类指针将产生未定义的行为。
- 三/五准则在 基类的析构函数中不遵循,因为一个基类总是需要一个虚析构函数,无法推断该基类还需要赋值运算符或拷贝构造函数
虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,那么即使它通过 = default 使用了合成的版本,编译器也不会为这个类合成移动操作。
15.7.2 合成拷贝控制与继承
派生类的合成的拷贝控制成员,析构函数除了自身的操作之外,还负责使用直接基类中对应的操作对一个对象的直接基类部分进行 初始化赋值,析构的操作
-
合成的Bulk_quote(第三代) 默认构造函数运行 Disc_quote 的默认构造函数,后者在执行 Quote(d第一代)的默认构造函数
-
类似的 拷贝控制也是相同
-
对于派生类的析构函数来说,他除了销毁自身,也负责销毁派生类的直接基类,直到顶端。
派生类中删除的拷贝控制与基类的关系
- 如果基类中的默认构造函数,拷贝控制,析构函数是删除的函数或者不可访问,则派生类中对应的成员是被删除的,因为编译器不能使用基类成员来执行对派生基类部分的 拷贝,析构操作
- 如果在基类中有一个不可访问或删除的析构函数,那么派生类中合成的默认和拷贝构造函数都将是删除的,因为编译器无法销毁派生类中对象的基类部分
- 编译器不会合成一个删除掉的移动操作,当我们使用 = default请求一个移动操作,原因是 派生类对象的基类部分不可移动
移动操作与继承
- 基类中会定义虚析构函数,因此默认情况下,基类不含有合成的移动操作,因此它的派生类中也没有合成的移动操作
- 因为基类没有移动操作会阻止派生类拥有自己的合成移动操作,所以我们确实需要执行移动操作时应该首先在基类中进行定义。
class Quote {
public:
Quote() = default;
Quote(const Quote &) = default;
Quote(Quote&&) = default;
Quote& operator=(const Quote &) = default;
Quote& operator=(Quote&&) = default;
virtual ~Quote() = default; //对析构函数进行动态绑定
Quote(const std::string &book, double sales_price)
: book_no_(book), price_(sales_price) {}
std::string isbn() const { return book_no_; };
virtual double net_price(std::size_t n) const
{ return n * price_; }
private:
std::string book_no_;
protected:
double price_ = 0.0;
};
显式定义后,除非该类的派生类含有排斥移动的成员,否则它将自动获得合成的移动操作
15.7.3 派生类的拷贝控制成员
- 派生类构造函数在其初始化阶段不但要初始化派生类自己的成员还有负责初始化派生类对象的基类部分
- 移动构造与拷贝构造,以及 赋值运算符也同样的规则
- 析构函数与其不同,它只负责销毁派生类自己分配的资源,因为对象的成员是被隐式销毁的,因此派生类对象的基类部分也是自动销毁的
定义派生类的拷贝和移动构造函数
class Base{}
class D: public Base {
public:
D(const D &d) :Base(d)
{}
D(D &&d) : Base(std::move(d)) {}
}
在默认情况下,基类的默认构造函数初始化派生类对象的基类部分,如果我们向(拷贝或移动)基类部分,也要在派生类的构造函数初始化列表中显式地使用 基类的拷贝(移动)构造函数
派生类赋值运算符
D &D::operator=(const D &rhs)
{
Base::operator=(rhs); //显式为基类部分赋值
// 按照过去的方式为派生类的成员赋值
// 酌情处理自赋值及释放已有资源等情况
return *this;
}
值得注意的是,无论基类的构造函数或赋值运算符是自定义的版本还是合成的,派生类的对应操作都能使用它们。 例如,对于 Base::operator= 的调用语句将执行Base的拷贝赋值运算符,至于该运算符是显式或编译器合成无关
派生类析构函数
对象销毁的顺序与创建的顺序相反:派生类析构函数首先执行,之后在进行基类的析构,依次向下到基类
class D:public Base{
public :
// Base::~被自动调用
~D(){}
}
在构造函数和析构函数中调用虚函数
如果构造函数或析构函数调用了某个虚函数, 则我们应该执行与 构造函数或析构函数所属类型相对应的虚函数版本
15.7.4 继承的构造函数
在C++新标准中,派生类能重用其直接基类定义的构造函数,提供一条著名了 (直接)基类名的 using 声明语句。
class Bulk_Quote : public DiscQuote {
public:
using DiscQuote::DiscQuote; //继承 DiscQuote的构造函数
double net_price(std::size_t) const override;
private:
std::size_t min_qty_ = 0; //使用折扣的最低购买量
double discount_ = 0.0; //以小数表示的折扣额
};
- using 作用域构造函数时,using声明语句将令 编译器产生代码。对于基类的每个构造函数,编译器都会在派生类中生成一个形参列表完全相同的构造函数。
derived(parms) : base(args){}
如果派生类还有自己的数据成员,则这些成员将被默认初始化。
继承的构造函数的特点
-
一个构造函数的 using 声明不会改变该构造函数的访问级别。
-
一个using 声明语句不能指定 explict 和 constexpr,如果基类含有这些,那么继承的构造函数也有相同的属性
-
当一个基类构造函数含有默认实参,这些实参不会被继承,而会生成多个继承的构造函数,一个生成不含默认实参的参数的列表,一个生成去掉带默认实参的参数的列表的构造函数。
-
如果派生类定义的构造函数与基类构造函数具有相同的参数列表,那么该构造函数不会被继承
-
默认,拷贝,移动构造函数不会被继承, 这些构造函数按照正常规则被合成,继承的构造函数不会作为用户定义的构造函数来使用,因此,如果一个类只含有 继承的构造函数,那么他也拥有一个合成的默认构造函数。
15.8 容器与继承
当派生类对象被赋值给基类对象时,其中的派生类部分被 “切掉”,因此容器和存在继承关系的类型无法兼容
在容器中存放(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,实际上存放的通常是基类的指针(最好选择智能指针)。
vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-201-82470-1",50));
basket.push_back(make_shared<Bulk_Quote>("0-201",50,10,0.25));
15.8.1 编写 Basket 类
//Copyright C++Primer 15
//License BSD
//Author: Handling
//This is OOP test
#ifndef CPPPRIMER_OOPTEST_H
#define CPPPRIMER_OOPTEST_H
#include <string>
#include <iostream>
#include <set>
#include <memory>
namespace mynamespace {
class Quote {
public:
Quote() = default;
Quote(const Quote &) = default;
Quote(Quote&&) = default;
Quote& operator=(const Quote &) = default;
Quote& operator=(Quote&&) = default;
virtual ~Quote() = default; //对析构函数进行动态绑定
Quote(const std::string &book, double sales_price)
: book_no_(book), price_(sales_price) {}
std::string isbn() const { return book_no_; };
virtual Quote* Clone() const & { return new Quote(*this); }
virtual Quote* Clone() && { return new Quote(std::move(*this)); }
virtual double net_price(std::size_t n) const
{ return n * price_; }
private:
std::string book_no_;
protected:
double price_ = 0.0;
};
class DiscQuote : public Quote{
public:
DiscQuote() = default;
DiscQuote(const std::string &book, double price, std::size_t qty, double disc): Quote(book, price), quantity_(qty), discount_(disc) {}
double net_price(std::size_t) const = 0;
protected:
std::size_t quantity_ = 0;
double discount_ = 0.0;
};
class Bulk_Quote : public DiscQuote {
public:
using DiscQuote::DiscQuote; //继承 DiscQuote的构造函数
double net_price(std::size_t) const override;
Bulk_Quote *Clone() & { return new Bulk_Quote(*this); }
Bulk_Quote *Clone() && { return new Bulk_Quote(std::move(*this)); }
private:
std::size_t min_qty_ = 0; //使用折扣的最低购买量
double discount_ = 0.0; //以小数表示的折扣额
};
class Basket {
public:
void AddItem(const Quote &sale) { items_.insert(std::shared_ptr<Quote>(sale.Clone())); }
void AddItem(Quote &&sale) { items_.insert(std::shared_ptr<Quote>(std::move(sale).Clone())); } //该sale本身也是左值变量,尽管它是右值引用
double TotalReceipt(std::ostream &) const;
private:
static bool Compare(const std::shared_ptr<Quote> &lhs, const std::shared_ptr<Quote> &rhs)
{ return lhs->isbn() < rhs->isbn(); }
// multset 保存多条报价,按照 compare 成员排序
std::multiset<std::shared_ptr<Quote>, decltype(Compare)*> items_{Compare};
};
double print_total(std::ostream &os, const Quote &item, size_t n) ;
}
#endif // !CPPPRIMER_OOPTEST_H
//Copyright C++Primer 15
//License BSD
//Author: Handling
//This is OOP test
#include "ooptest.h"
using std::endl;
namespace mynamespace {
double Bulk_Quote::net_price(std::size_t cnt) const {
if (cnt >= min_qty_)
return cnt * (1 - discount_) * price_;
return cnt * price_;
}
double print_total(std::ostream &os, const Quote &item, size_t n) {
double ret = item.net_price(n);
os << "ISBN:" << item.isbn() << " # sold: " << n << "total due: " << ret << endl;
return ret;
}
double Basket::TotalReceipt(std::ostream &os) const {
double sum = 0.0;
for (auto iter = items_.cbegin(); iter != items_.cend(); iter = items_.upper_bound(*iter)) {
sum += print_total(os, **iter, items_.count(*iter));
}
return sum;
}
}
15.9 文本查询程序再谈
#pragma once
//Copyright C++Primer
//license (BSD)
//Author : Handling
//This is TextQuery program
#ifndef CPPPRIMER_TEXTQUERY_H_
#define CPPPRIMER_TEXTQUERY_H_
#include <fstream>
#include <vector>
#include <map>
#include <set>
#include <memory>
#include <iostream>
#include <sstream>
#include <algorithm>
namespace mynamespace {
class QueryResult;
//文本查询
class TextQuery {
public:
using line_no = std::vector<std::string>::size_type;
TextQuery(std::ifstream &);
QueryResult Query(const std::string &) const;
private:
std::shared_ptr<std::vector<std::string>> file_data_; //所有的行内容
std::map<std::string, std::shared_ptr<std::set<line_no>>> wm; //单词-行号
};
//查询结果
class QueryResult {
friend std::ostream& print(std::ostream &, const QueryResult &);
public:
QueryResult(std::string s, std::shared_ptr<std::set<TextQuery::line_no>> p,std::shared_ptr<std::vector<std::string>> f)
: sought_(s), line_nos_(p), file_(f) { }
std::set<TextQuery::line_no>::iterator begin() const { return line_nos_->begin(); }
std::set<TextQuery::line_no>::iterator end() const { return line_nos_->end(); }
std::shared_ptr<std::vector<std::string>> file() const { return file_; }
private:
std::string sought_; //单词
std::shared_ptr<std::set<TextQuery::line_no>> line_nos_; //其行号
std::shared_ptr<std::vector<std::string>> file_; //文件内容
};
class QueryBase {
friend class Query;
protected:
using line_no = TextQuery::line_no;
virtual ~QueryBase() = default;
private:
//eval 返回放弃与 Query 匹配的 QueryResult
virtual QueryResult Eval(const TextQuery &) const = 0;
//查询string
virtual std::string Rep() const = 0;
};
class Query {
friend Query operator~(const Query &);
friend Query operator|(const Query &, const Query &);
friend Query operator&(const Query &, const Query &);
public:
Query(const std::string &);
QueryResult Eval(const TextQuery &t) const { return q_->Eval(t); }
std::string Rep() const { return q_->Rep(); }
private:
Query(std::shared_ptr<QueryBase> query) :q_(query){}
std::shared_ptr<QueryBase> q_;
};
//单词查询
class WordQuery: public QueryBase{
friend class Query;
WordQuery(const std::string &s): query_word_(s) {}
QueryResult Eval(const TextQuery &t) const override { return t.Query(query_word_); }
std::string Rep() const override { return query_word_; }
std::string query_word_;
};
//非条件查询
class NotQuery : public QueryBase {
friend Query operator~(const Query &);
NotQuery(const Query &q): query(q) { }
std::string Rep() const override { return "~("+ query.Rep() + ")"; }
QueryResult Eval(const TextQuery &) const override;
Query query;
};
class BindaryQuery: public QueryBase {
protected:
BindaryQuery(const Query &l, const Query &r, std::string s)
: lhs_(l), rhs_(r), oper_stym_(s) {}
std::string Rep() const override { return "(" + lhs_.Rep() + " " + oper_stym_ + " " +rhs_.Rep() + ")"; }
Query lhs_, rhs_;
std::string oper_stym_;
};
//并条件查询
class AndQuery: public BindaryQuery{
friend Query operator&(const Query &, const Query &);
AndQuery(const Query &left, const Query &right): BindaryQuery(left, right, "&") {}
QueryResult Eval(const TextQuery &) const override;
};
//或条件查询
class OrQuery : public BindaryQuery {
friend Query operator|(const Query &, const Query &);
OrQuery(const Query &left, const Query &right) : BindaryQuery(left, right, "|") {}
QueryResult Eval(const TextQuery &) const override;
};
std::ostream &operator<<(std::ostream &os, const Query &query);
std::ostream &print(std::ostream &os, const QueryResult &qr);
inline
Query::Query(const std::string &s) : q_(new WordQuery(s)) {
}
inline
Query operator~(const Query &operand) {
return std::shared_ptr<QueryBase>(new NotQuery(operand));
}
inline
Query operator&(const Query &lhs, const Query &rhs) {
return std::shared_ptr<QueryBase>(new AndQuery(lhs, rhs));
}
inline
Query operator|(const Query &lhs, const Query &rhs) {
return std::shared_ptr<QueryBase>(new OrQuery(lhs, rhs));
}
}
#endif // !CPPPRIMER_TEXTQUERY_H_
//Copyright C++Primer
//license (BSD)
//Author : Handling
//This is TextQuery program
#include "textquery.h"
using std::vector;
using std::string;
using std::istringstream;
using std::getline;
using std::make_shared;
using std::set;
using std::shared_ptr;
namespace mynamespace {
TextQuery::TextQuery(std::ifstream &is):file_data_(make_shared<vector<string>>()) {
string text;
while (getline(is, text)) {
file_data_->push_back(text);
int n = file_data_->size() -1;
istringstream line(text);
string word;
while (line >> word) {
auto &lines = wm[word];
if(!lines)
lines.reset(new set<line_no>);
lines->insert(n);
}
}
}
QueryResult TextQuery::Query(const std::string &sought) const {
static shared_ptr<set<line_no>> no_data(new set<line_no>);
auto loc = wm.find(sought);
if (loc != wm.end())
return QueryResult(sought, no_data, file_data_);
else
return QueryResult(sought, loc->second, file_data_);
}
std::ostream &operator<<(std::ostream &os, const Query &query) {
os << query.Rep();
return os;
}
std::ostream &print(std::ostream &os, const QueryResult &qr) {
os << qr.sought_ << " occurs " << qr.line_nos_->size()
<< (qr.line_nos_->size() <= 1 ? "time" : "times") << std::endl;
for (auto num : *qr.line_nos_)
os << "\t (line " << num + 1 << ") " << *(qr.file_->begin() + num) << std::endl;
return os;
}
QueryResult OrQuery::Eval(const TextQuery &text) const {
auto right = rhs_.Eval(text), left = lhs_.Eval(text);
auto ret_lines = make_shared<set<line_no>>(left.begin(), left.end());
ret_lines->insert(right.begin(), right.end());
return QueryResult(Rep(), ret_lines, left.file());
}
QueryResult AndQuery::Eval(const TextQuery &text) const {
auto right = rhs_.Eval(text), left = lhs_.Eval(text);
auto ret_lines = make_shared<set<line_no>>();
std::set_intersection(left.begin(), left.end(), right.begin(), right.end(), std::inserter(*ret_lines, ret_lines->begin()));
return QueryResult(Rep(), ret_lines, left.file());
}
QueryResult NotQuery::Eval(const TextQuery &text) const {
auto result = query.Eval(text);
auto ret_lines = make_shared<set<line_no>>();
auto beg = result.begin(), end = result.end();
auto sz = result.file()->size();
for (size_t n = 0; n != sz; ++n) {
if (beg != end && n == *beg) {
++ beg;
continue;
}
ret_lines->insert(n);
}
return QueryResult(Rep(), ret_lines, result.file());
}
}
第 十六关: 模板与泛型编程
面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况,不同之处在于:
- OOP 能处理类型在程序运行之前都未知的情况,
- 泛型编程中,编译时就能获知类型。
模板是C++ 泛型编程的基础,一个模板就是一个创建类与函数的蓝图或者说公式。
16.1 定义模板
int compare(const string &v1, const string &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
int compare(const double &v1, const double &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
对于以上仅仅差异是参数的类型,函数体的执行内容则完全一样,如果我们希望执行的是不同的类型,那么就需要定义完全一样的函数体,是非常繁琐的
16.1.1 函数模板
我们可以定义一个通用的 函数模板,一个函数模板就是一个公式,来生成针对特定类型的函数版本。
模板定义: 关键字 template开始, 后跟一个模板参数列表,这是一个逗号分割的一个或多个模板参数的列表, 用 (< >)包裹起来。
template <typename T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
- 模板参数列表不能为空
- 模板参数列表的作用是定义了若干特定类型的局部变量,但未指出如何初始化它们,当使用模板时,(隐式或显式地)指定模板实参,将其绑定到模板参数上。
- T 的实际类型在编译时 根据 其 模板函数的使用情况来确定。
实例化函数模板
- 编译器会根据 函数的实参 推断出的模板参数来为我们实例化一个特定版本的 函数(模板的实例)
vector<int> vec1{1, 2, 3};
vector<int> vec2{4, 5, 6};
//根据模板实例化出了 int compare(const vector<int> &v1, const vector<int> &v2) {
cout << compare(1, 0) << endl;
悟道: 小熊饼干是通过小熊模板实例化的,小熊饼干只需要通过 小熊模板的 实参(奶油味与 花生味的调理)生成对应的即可。
模板类型参数
模板类型参数(类型说明符):可以用来指定返回类型与函数的参数类型, 以及函数体内用于变量声明或类型转换。
- 类型参数需要在前面必须使用 关键字 class / typename,关键字含义相同,可以相互转换。
template <typename T>
T Foo(T *p) {
T temp = *p;
//...
return temp;
}
template <typename T, class U> calc(const T&, const U&);
悟道: class 与 typename 都可以用来定义类型参数,目前是主要以 typename为主。
非类型模板参数
可以在模板定义时 定义非类型参数。
- 非类型参数表示一个值而非一个类型, 通过特定的类型名而非 关键字 class 或 typename 来指定非类型参数。
- 当模板被实例化时, 非类型参数被 用户提供的值或编译器推断的值替换, 值必须是 常量表达式,从而允许编译器在编译时实例化模板。
- 绑定到指针或者引用非类型参数的实参 必须具有静态的生存期,不能用一个 普通(非 static)局部变量或动态对象作为指针或引用非类型模板参数的实参, 指针参数也可以用 nullptr 或值为 0 的常量表达式实例化
template <unsigned N, unsigned M>
int compare(const char(&p1)[N], const char(&p2)[M]) {
return strcmp(N,M);
}
int main(int argc,char **argv) {
compare("sss", "sss");
}
N = 4, M = 4
inline 和 constexpr 的函数模板
函数模板也可以声明为 inline 和 constexpr 的,如同非模板函数一样。 inline 或 constexpr 说明符应该放在模板参数之后, 返回类型之前。
template <unsigned N, unsigned M> inline
int compare(const char(&p1)[N], const char(&p2)[M]) {
return strcmp(N,M);
}
编写与类型无关的代码
编写泛型代码的两个重要原则:
-
模板的函数参数是 const 的引用
- 可以保证函数可以用于处理不能拷贝的类型,(unique_ptr 与IO )
- 处理大对象, 设计策略还能使函数运行 的更快
-
函数体中的条件判断仅仅使用 < 比较运算
- 只使用 < 可以降低对于 模板函数对于处理的类型要求
建议: 模板程序应该尽量减少对实参类型的要求。
模板编译
- 编译器遇到一个模板定义时, 它并不生成代码,而是当我们实例化出模板的一个特定版本,才会生成
- 为了生成一个实例化版本 ,编译器需要掌握函数模板或类模板成员函数的定义, 因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。
关键概念: 模板与头文件
模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。模板的用户必须包含模板的头文件, 以及用来实例化模板的任何类型的头文件。
大多数编译在实例化期间报告
模板到实例化时才生成代码,这一特性影响了我们何时才获知模板内代码的编译错误。
通常在三个阶段报告错误:
- 编译模板本身时, 在这个阶段,编译器可以检查语法错误, 例如忘记分号或者变量名拼错等。
- 在编译器遇到模板使用时, 对于函数模板调用,编译器会检查实参数目是否正确,参数类型是否匹配,对于类模板,编译器会检查用户是否提供了正确数目的模板实参
- 模板实例化时,只有这个阶段才能发现类型相关的错误。 依赖于编译器如何管理实例化,这类错误可能在链接时才报告。
注意点: 保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。
16.1.2 类模板
- 类模板 是用来生成类的蓝图的, 与函数模板不同之处是,编译器不能为类模板推断模板参数类型。
- 我们在使用类模板时, 我们必须在模板名后的尖括号中提供额外信息 ------- 用来代替模板参数的模板实参列表。
定义 类模板
- 类模板以关键字 template 开始,后跟 模板参数列表。
- 在类模板(及其成员)的定义中, 我们将模板参数当作替身, 代替使用模板时用户需要提供的类型或值:
template <typename T> class Blob {
public:
using value_type = T;
using size_type = typename std::vector<T>::size_type;
//构造函数
Blob();
Blob(std::initializer_list<T> il);
//Blob 的元素数量
size_type size() const { return data_->size(); }
bool empty() const { return data_->empty(); }
//添加与删除元素
void push_back(const T &t) { data_->push_back(t); }
void push_back(T &&t) { data_->push_back(std::move(t)); }
void pop_back();
// 元素访问
T& back();
T& operator[](size_type i);
private:
std::shared_ptr<std::vector<T>> data_;
void Check(size_type i, const std::string &msg) const;
};
实例化类模板
- 使用类模板,我们需要提供额外信息,这些额外信息就是 显式模板实参列表, 它们被绑定到模板参数,编译器使用这些模板实参来实例化特定的类。
- 一个类模板的每个实例都形成一个独立的类, 类型 Blob 与任何其他 Blob 类型都 没有关联,也不会对其他任何 Blob 类型的成员有特殊访问权限。
Blob <int> ia;
Blob <int> ia2 = {0,1,2,3,4};
在模板作用域中引用模板类型
为了阅读模板类代码, 应该记住类模板的名字不是一个类型名。类模板用来实例化类型,而一个实例化的类型总是包含模板参数的。
vector<shared_ptr<int>>
嵌套模板的实参应该是实例的类类型(通过类模板实例化出的)
类模板的成员函数
- 可以在类模板内部外部都定义成员函数,且定义在类模板内的成员被隐式声明为内联函数
- 类模板的每个实例都有其自己版本的成员函数,类模板的成员函数具有和模板相同的模板参数。定义在类模板之外的成员函数必须以关键字 template 开始,后接类模板参数列表。
ret-type StrBlob::member-name(parm-list)
对应Blob 成员是
template <typename T>
ret-type Blob<T>::member-name(parm-list)
示例:
template<typename T>
inline Blob<T>::Blob(std::initializer_list<T> il): data_(std::make_shared<std::vector<T>(il)>) {
}
template<typename T>
inline void Blob<T>::pop_back() {
Check(0, "pop back on empty Blob");
data_->pop_back();
}
template<typename T>
inline T &Blob<T>::back() {
Check(0, "back on empty Blob");
return data_->back();
}
template<typename T>
inline T &Blob<T>::operator[](size_type i) {
Check(i, "subscript out of range");
return (*data_)[i];
}
template<typename T>
inline void Blob<T>::Check(size_type i, const std::string &msg) const {
if (i >= data_->size())
throw std::out_of_range(msg);
}
类模板成员函数的实例化
- 一个类模板的成员函数只有当程序用到时才会进行实例化。
- 上面的特性使得 某种类型不能完全复合模板操作的要求, 我们仍能使用该类型实例化类。
Blob<int> squares = {0, 1, 2 ,3}; //仅仅实例化 Blob<int> 与其构造函数
for (size_t i = 0; i != squares.size(); ++i)
squares[i] = i*i; //实例化 size(),与 operator[]
在类代码内简化模板类名的使用
我们在使用类模板类型时必须提供模板实参, 但例外是,在类模板自己的作用域中, 我们可以直接使用 模板名而不提供实参:
template <typename T> class BlobPtr {
public:
BlobPtr(): curr_(0) {}
BlobPtr(Blob<T> &a, size_t sz = 0) : wptr_(a.data_), curr_(sz) { }
T& operator*() const { auto p = Check(curr_, "dereference past end"); return (*p)[curr_]; }
BlobPtr& operator++();
BlobPtr& operator--();
private:
std::shared_ptr<std::vector<T>> Check(std::size_t, const std::string &) const;
std::weak_ptr<std::vector<T>> wptr_;
std::size_t curr_;
};
当我们处于一个类模板的作用域中, 编译器处理模板自身引用时就好像我们已经提供了与模板参数匹配的实参一样。
在类模板外使用类模板名
template<typename T>
inline BlobPtr<T> &BlobPtr<T>::operator++() {
Check(curr_, "curr is end");
++ curr_;
return *this;
}
- 当返回类型在类的作用域之外,我们必须指出返回类型是一个实例化的 BlobPtr,它所用类型与类实例化所用类型一致。
- 在函数体内,我们已经进入类的作用域, 因此在使用 模板时无须重复指出模板实参。
类模板和友元
- 当一个类包含友元声明时, 类与友元各自是否是模板是相互无关的。
- 如果一个类模板包含一个非模板友元, 则友元被授权可以访问所有模板实例。
- 如果友元自身是模板, 类可以授权给所有友元模板实例,也可以只授予给特定实例。
一对一的友好关系
类模板 与另一个(类或函数)模板间 的友好关系的最常见的形式是建立对应实例及其友元间的友好关系。
template <typename> class BlobPtr;
template <typename> class Blob;
template <typename T>
bool operator==(const Blob<T> &, const Blob<T> &);
template <typename T> class Blob {
friend class BlobPtr<T>;
friend bool operator==<T>(const Blob<T> &, const Blob<T> &);
};
我们将 BlobPtr 与 operator==声明为自己的友元,对其对应实例都为自己的友元,而非相同 类型的实例则没有特殊访问权限
通用和特定的模板友好关系
一个类也可以将另一个模板的每一个实例都声明为自己的友元, 或者限定特定的实例为友元:
- 为了让所有实例称为友元, 友元声明中必须使用与类模板本身不同的模板参数。
template <typename T> class Pal;
class C {
friend class Pal<C>;
template <typename T> friend class Pal2; //Pal2 的所有实例都是 C的友元;这种情况无须前置声明
};
template <typename T> class C2 {
friend class Pal<T>; //C2 的每个实例将相同实例化的 Pal声明为友元,需要前置声明
template <typename X> friend class Pal2; // Pal2的所有实例都是 C2的每个实例的友元,不需要前置声明
friend class Pal3; //非模板类,是C2 所有实例的友元,不需要 Pal3的前置声明
};
令模板自己的类型参数称为友元
在新标准中,我们可以将模板类型参数声明为友元:
template <typename Type> class Bar {
friend type; //将访问权限授予用来实例化 Bar 的类型,也可以用内置类型实例化 Bar这样的类
};
模板类型别名
由于模板不是一个类型, 我们不能定义一个typedef 引用一个模板,。
新标准允许我们为类模板定义一个类型别名
template <typename T> using twin = pair<T, T>;
twin <string> authors;
由于pair 的成员类型是相同的,我们只需要一次指定,
我们也可以固定一个或多个模板参数
template <typename T> using partNo = pair<T, unsigned>;
类模板的 static成员
- 类模板的静态成员是 在指定类型的对象 共享的。类模板的每个实例都有一个独有的 static 对象,必须有定义且只能有一个。
- 为了通过类来直接访问 static 成员,我们需要引用一个实例
- 一个 static 成员函数只有在使用它时才会实例化。
template <typename T> class Foo {
public:
static std::size_t count() { return ctr; }
private:
static std::size_t ctr;
}
定义:
template <typename T> size_t Foo<T>::ctr = 0;
使用:
Foo<int> f1;
auto ct = Foo<int>::count();
ct = f1.count();
ct = Foo::count();
16.1.3 模板参数
一个模板参数的名字没有什么内在含义,我们将类型参数命名为T,但实际上我们可以使用任何名字:
template <typename Foo> Foo calc(const Foo &a, const Foo &b) {
Foo tmp = a;
return tmp;
}
模板参数与作用域
- 一个模板参数名的可用范围是其声明之后,至模板声明或定义结束之前。
- 模板参数会隐藏外层作用域中声明的相同名字。
- 模板额你不能重用模板参数名,一个模板参数名在一个特定模板参数列表中只能出现一次
typedef double A;
template <typename A, typpename B> void f(A a, B b){
A tmp = a; //tmp 的类型为模板参数 A 的类型,而非 double
double B; // 错误:重声明模板参数B
}
template <typename V, typename V> // 错误
模板声明
- 模板声明必须包含模板参数
- 声明中的模板参数的名字不必与定义中相同
- 一个给顶模板的每个声明和定义都必须有相同数量和种类 (类型或非类型)的参数。
template <typename T> int compare(const T &, const T &); //声明但不定义
template <typename T> class Blob;
//3个 calc都指向相同的函数模板
template <typename T> T calc(const T &, const T &); //声明
template <typename U> U calc(const U &, const U &); //声明
template <typename Type> Type calc(const Type &a, const Type &b) {
}
建议:一个特定文件所需的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。
使用类的类型成员
在非模板的代码中,编译器掌握类的定义,因此他知道通过作用域运算符访问的是名字是类型还是 static成员。
而在 模板代码中, 遇到 T::mem 这样的代码时,它不会知道 mem 是一个类型成员还是一个 static 数据成员,直到实例化才知道。为了处理模板,编译器必须直到名字是否表示一个类型。
默认情况下,C++语言假定通过作用域运算符访问的名字不是类型,因此我们希望使用一个模板类型参数的类型成员, 就必须显式告诉编译器该名字是一个类型。使用 typename 来实现这一点
template <typename T>
typename T::value_type top(const T &c){
if (!c.empty)
return c.back();
else
return typename T::value_type();
}
- 我们使用 typename 来通知编译器一个名字表示类型,不能使用 class
默认模板实参
在新标准中,我们可以为 函数和类模板提供默认实参。
template <typename T, typename F = less<T>>
int compare(const T &v1, const T &v2, F f = F()){
if(f(v1, v2)) return -1;
if(f(v2, v1)) return 1;
return 0;
}
bool j = compare(0, 42);
- 与函数默认实参一样,对于一个模板实参,只有当它右侧的所有参数都有默认实参时, 它才可以有默认实参。
模板默认实参与类模板
- 如果一个类模板其所有模板参数都提供了默认实参,我们希望使用默认实参,我们必须在模板名之后跟一个空尖括号对:
template <class T = int> class Number {
public:
Numbers(T v = 0): val(v) {}
private:
T Val;
}
Numbers<> average_precision;
16.1.4 成员模板
一个类(普通或者类模板)可用包含本身是模板的成员函数,这种成员被称为成员模板,成员模板不能是虚函数。
普通(非模板)类的成员模板
定义一个 普通(非模板)类的成员模板,用来删除动态内存并打印信息
class DebugDelete {
public:
DebugDelete(std::ostream &s = std::cerr): os(s){}
template <typename T>
void operator()(T *p) const {
os << "deleting unique_ptr "<< std::endl;
delete p;
}
private:
std::ostream &os;
};
使用:
double *p = new double;
DebugDelete d;
d(p);
将其对象作为 unique_ptr 的删除器
unique_ptr<int, DebugDelete> p(new int, DebugDelete());
unique_ptr 析构函数会调用 DebugDelete的调用运算符
类模板的成员模板
对于类模板,我们可用为其定义成员模板,在此情况下,类和成员各有自己的独立的模板参数。
- 当我们在类模板外定义一个成员模板时, 必须同时为类模板和成员模板提供模板参数列表。类模板的参数在前,后跟成员自己的模板参数列表。
template <typename T> class Blob {
template <typename It> Blob(It b, It e);
};
template <typename T>
template <typename It>
Blob<T>::Blob(It b, It e): data(std::make_shared<std::vector<T>>(b,e)) {}
实例化与成员模板
为了实例化一个类模板的成员模板, 我们必须同时提供类和函数模板的实参。
vector<long> vi = {0, 1, 2, 3, 4};
Blob<int> a1(vi.begin(), vi.end());
16.1.5 控制实例化
- 当模板被使用时才会进行实例化,意味着相同的实例可能出现在多个对象文件中。
- 当两个或者多个独立编译的源文件都使用了相同的模板, 并提供了相同的模板参数时,每个文件就都会有该模板的一个实例。
多文件实例化相同模板的开销是很严重的, 在新标准中,我们通过显式实例化来避免这种开销。
形式如下:
extern template declaration; //实例化声明
template declaration; //实例化定义
extern template class Blob<string> //声明
template int compare(const int &, const int &); //定义
- 当编译器遇到 extern 模板声明时,她不会在本文件中生成实例化代码。
- 将一个实例化声明为 extern 就表示承诺在程序的其他位置有该实例化的一个 非 extern 声明(定义)
- 对于 一个给定的实例化版本,可能有很多个 extern 声明,但必须只有一个定义。
编译器使用一个模板时自动对其实例化, 因此 extern 声明必须出现在任何使用该实例化版本的代码之前:
//Application.cpp
//这些模板需要在程序其他位置进行实例化
extern template class Blob<string>;
extern template int compare(const int &,const int &);
Blob<string> sa1, sa2; //实例化在其他位置
Blob<int> a1 = {0, 1, 2, 3, 4};
Blob<int> a2(a1); //拷贝构造函数在本文件实例化
int i = compare(a1[0], a2[0]);// 实例化在其他位置
//templateBuild.cpp
//实例化文件必须为每个在其他文件中声明为 extern 的类型和函数提供一个(非 extern)的定义
template int compare(const int &, const int &);
template class Blob<string>;
实例化定义会实例化所有成员
- 一个类模板的实例化定义会实例化该模板的所有成员, 包括内联的成员函数
- 我们来显式实例化定义一个类模板的类型,所用类型必须能用于模板的所有成员函数。
16.1.6 效率与灵活性
unqiue_ptr 在编译时绑定删除器,避免了间接调用删除器的运行开销
shared_ptr 在运行时绑定删除器, shared_ptr 使用户重载删除器更方便。
16.2 模板实参推断
从函数实参来确定模板实参的过程被称为 模板实参推断
16.2.1 类型转换与模板类型参数
将实参传递给带模板类型的函数形参时, 能狗自动转换的只有 const 转换及数组或函数到指针的转换。
其余编译器不会实参进行类型转换,而是生成一个新的模板实例。
//形参或是实参 的顶层 const 都会被忽略掉。算是转换与 派生类到基类的转换用户定义的转换都不能应用于函数模板
- const 转换:可用将一个 非 const 对象的引用(指针)传递给一个 const 的引用(或指针)形参。
- 数组或函数指针转换: 如果函数形参不是引用类型, 则可用对数组或函数类型的实参应用正常的指针转换。 一个数组实参可用转换为 一个指向首元素的指针。 类似的,一个函数实参可用转换为一个该函数类型的指针。
使用相同模板参数类型的函数形参
一个模板类型参数可以用于多个函数形参的类型,由于只允许有限的几种类型转换,因此传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,那么调用就是错误的。
long lng;
compare(lng, 1024); //错误,不能实例化出 compare(long, int)
如果希望允许函数实参进行正常的类型转换,将函数模板定义为多个类型参数
//实参可以不同,但是依赖于函数模板中的内容,需要兼顾
template <typename A, typename B>
int flexibleCompare(const A &v1, const B &v2) {
if(v1 < v2) return -1;
if(v2 <v1) return 1;
return 0;
}
long lng;
compare(lng, 1024); //正确
正常类型转换应用普通函数实参
如果函数参数类型不是模板参数, 则对实参进行正常的类型转换。
template <typename T> ostream &print(ostream &os, const T &obj){
return os << obj;
}
print(cout, 42);
16.2.2 函数模板显式实参
在某些情况下,编译器无法推断模板实参类型,希望允许用户控制模板实例化。
当函数返回类型于参数列表中任何类型都不相同时。
指定显式模板实参
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);
auto val3 = sum<long long>(i, lng); // long long sum(int, long)
- 没有任何函数实参来推断返回值类型, 因此每次调用都需要为 T1 提供显式模板实参。
- 显式模板实参按由左至右的顺序与对应的模板参数匹配, 第一个模板实参与第一个模板参数匹配,。。。依次类推,只有尾部参数的显式模板实参可用忽略,前提是它们可用从函数参数推断。
正常类型转换应用于显式指定的实参
- 对于模板类型参数已经显式地指定了的函数实参, 也进行正常的类型转换:
16.2.3 尾置返回类型于类型转换
当我们希望用户确定返回类型时,用显式模板实参表示其返回类型是很有效的。
而其他情况下,让用户去确定其返回类型是很痛苦的,且不准确。
- 在编译器遇到函数的参数列表之前, 参数列表中的参数都是不存在的,为了定义该函数,我们必须使用尾置返回类型,由于尾置返回类型出现在 参数列表之后, 它可以使用函数的参数。
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg){
return *beg;
}
进行类型转换的标准库模板类
为了获得 元素类型,我们可用使用 标准库的 类型转换模板。定义在 type_traits中。
template <typename It>
auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type
{
return *beg;
}
- type 是类的成员,依赖于模板参数,因此我们需要在返回类型的声明中使用 typename来告知编译器,type表示一个类型。
对 Mod,其中Mod是 | 若T 是 | 则 Mod::type是 |
---|---|---|
remove_reference | X& 或 X&& | X |
否则 | T | |
add_const | X&, const X或函数 | T |
否则 | const T | |
add_lvalue_reference | X& | T |
X&& | X& | |
否则 | T& | |
add_rvalue_reference | X& 或 X&& | T |
否则 | T&& | |
remove_pointer | X* | X |
否则 | T | |
add_pointer | X& 或 X&& | X* |
否则 | T* | |
make_signed | unsigned X | X |
否则 | T | |
make_unsigned | 带符号类型 | unsigned X |
否则 | T | |
remove_extent | X[n] | X |
否则 | T | |
remove_all_extents | X[n1][n2]… | X |
否则 | T |
- 每一个标准类型转换模板都有一个 名为 type 的public 成员,表示其类型
- 如果没有必要(不可能)转换模板参数,则type 成员就是模板参数自身
16.2.4 函数指针和实参判断
- 当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值, 编译器将使用指针的类型来推断模板实参
- 当参数是一个函数模板实例的地址时, 程序上下文必须满足:对于模板参数,能唯一确定其类型或值。
template <typename T> int compare(const T &, const T &);
int (*pf1)(const int &, const int &) = compare;
如果不能从函数指针的类型确定模板实参(二义性或其他),则发生错误。
void func(int (*)(const string &, const string &));
void func(int(*)(const int &, const int &));
func(compare) //二义性错误,编译失败
这时需要消除二义性,使用显式地模板实参即可。
func(compare<int>);
16.2.5 模板实参推断于引用
从左值引用函数参数推断类型
- 当函数参数是模板类型参数的 左值引用时,只能给其传递给一个左值
- template void func(T &); 实参是 const,则T 被推断为 const 类型
- template void func(const T &); 实参是 const是 非const 均可,到模板函数中就是 const
从右值引用函数参数推断类型
当一个函数参数是右值引用,正确绑定规则 于类型推断过程一样,都是需要我们传递给其右值。
template <typename T> void f3(T &&)
f3(42)
引用折叠和右值引用参数
通常我们不能将一个右值引用绑定到一个左值上。 但是,C++语言正常绑定规则之外定义了两个例外规则,允许这种绑定。这两个规则是 move 这种标准库设施正确工作的基础。
- 第一个例外影响了右值引用参数的推断如何进行, 当我们将一个左值(I)传递给函数的右值引用参数,且此右值引用指向模板参数(T&&),编译器推断模板类型参数为实参的左值引用类型。 因此,当我们调用 f3(i),编译器推断 T 的类型为 int& ,而非 int。
T& & = X&
using X = T&;
- 第二个例外: 如果我们间接创建了一个引用的引用,则这些引用形成了 “折叠”。在所有情况下,引用会折叠成一个普通的左值引用类型。在新标准中,折叠规则扩展到右值引用。只有一种规则引用会折叠成右值引用: 右值引用的右值引用
T&& && == X&&
using X = T&&;
- X& & , X& && 和 X&& & 都折叠成 X&
- 类型 X&& && 折叠成 X&&
引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数
template <typename T> void f3(T &&)
f3(ci); //实参是一个左值,模板参数T 是 int&
以上的f3(i)的实例化是这样推断的
void f3<int&>(int& &&); //当 T 是int&,函数参数为 int& &&
即使f3 的函数参数是 T&& 且 T 是int&, 因此 T&& 是 int& &&,会折叠为 int&
void f3<int&>(int&);
这两个规则导致了两个重要结果:
- 如果一个函数参数是一个指向模板类型参数的右值引用(T&&),则它可用被绑定到一个左值;且
- 如果实参是一个左值,则推断出的模板实参类型是一个左值引用,且函数参数将被实例化一个普通左值引用参数。
note:如果一个函数参数是指向模板参数类型的右值引用(T&&),则可用传递给它任意类型的实参。如果将一个左值传递给这样的参数, 则函数参数被实例化为一个普通的左值引用。
编写接受右值引用参数的模板类型
由于右值引用参数,模板类型会推断 其类型参数为 左值或右值,这在函数体中编写通用的正确的代码就
非常困难(remove_reference这样的类型转换类也许有帮助)
实际上右值引用通常用于两种情况: 模板转发其实参或模板被重载
使用右值引用的函数模板要进行下面的重载进行区分:
template <typename T> void f(T&&); //绑定到非const 的右值
template <typename T> void f(const T&); //左值与const 右值(字面值)
16.2.6 理解 std::move
标准库move 函数是使用 右值引用的模板的一个很好的例子。
- std::move 获得一个绑定到左值上的右值引用
- 由于 move 本质上可用接受任何类型的实参,它是一个函数模板
std::move 是如何定义的
//在返回类型和类型转换中也要用到 typename
template <typename T>
typename remove_reference<T>::type&& move(T&& t){
return static_cast<typename remove_reference<T>::type&& >(t);
}
由于 move 的函数参数 T&& 是指向模板类型参数的右值引用,根据折叠原则,它可用接受左值与右值均可。
std::move 是如何工作的
std::move(string(“bye”))!
- 推断的T类型为string
- 因此,remove_reference 的 type 是string
- move 的返回类型是 string&&
- move 的函数参数 t 的类型是 string&&
std::move(str)
- 推断出的 T 的类型为 string&
- 因此 remove_reference用string&进行实例化
- remove_reference<string&> 的type成员是 string
- move 的返回类型是 string&&
- move 的函数参数 t 实例化为 string& &&,会折叠为 string&
从一个左值 static_cast 到一个右值引用是允许的
右值引用特许规则: 可用用static_cast 显式地将一个左值转换为一个右值引用。
建议;: 统一地使用 std::move 使得我们在程序中查找潜在的截断左值的代码变得很容易。
16.2.7 转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数,在此情况下,我们需要保持被转发实参的所有性质, 包括实参类型是否是 const 的以及实参是左值还是右值。
//接受一个可调用对象 与两个参数的模板
//对 "翻转"的参数调用给顶的可调用对象
//flip1 是一个不完整的实现: 顶层const 和引用丢失了
template <typename F, typename T1, typename T2>
void flip1(F f, T1 t1, T2 t2){
f(t2, t1);
}
如果是这样的话,非引用反转,不会改变原值。
定义能保持类型信息的函数参数
如果一个函数参数是指向模板参数的右值引用,它对应的实参的const 属性与左值/右值属性都会得到支持。
template <typename F, typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2) {
f(t2, t1);
}
这个版本的 flip2 对于接受左值引用的函数工作的很好,但不能用于接受右值引用参数的函数。
void g(int &&i, int &j)
flip2(g, i, 42); 因为 t1,t2不管是右值引用还是左值引用,都是左值表达式,因此 当左值表达式作为实参传递给 右值引用形参是不对的。
在调用中使用 std::forward 保持类型信息
我们可用使用一个名为 forward 的新标准库设施来传递 flip2的参数, 它能保持原始实参的类型。
- forward 定义在 头文件 utility 中,需要通过显式模板实参来调用
- forward 返回该显式实参类型的右值引用,即 forward的返回类型是 T&&
template <typename Type> intermediary(Type && arg)
{
finalFcn(std::forward<Type>(arg));
}
与std::move相同,要使用 std::forward 而不使用 uisng 声明
16.3 重载与模板
函数模板的函数匹配规则:
- 对于一个调用, 其候选函数包括所有模板实参推断 成功的函数模板实例
- 候选的函数模板总是可行的, 因为模板实参推断会排除任何不可行的模板
- 与往常一样,可行函数(模板与非模板)按类型转换来排序,当然用于函数模板调用的类型转换是非常有限的。
- 如果恰有一个函数提供比其他函数都更好的函数匹配,则选择该函数。
但是,如果有多个函数提供同样好的匹配,则:
- 如果同样好的函数只有一个是非模板函数,则选择该函数
- 如果同样好的函数中没有非模板函数, 而有多个函数模板,则其中一个模板比其他模板更特例化,则选择该模板。
- 否则,此调用有歧义。
编写重载模板
template <typename T> string debug_rep(const T &t){
ostringstream ret;
ret << t;
return ret.str();
}
template <typename T> string debug_rep(T *p){
ostreamstring ret;
ret << "pointer:" << p;
if (p)
ret << " " <<debug_rep(*p);
else
ret << "null pointer";
return ret.str();
}
重载模板和类型转换
缺少声明可能导致程序行为异常
在定义任何函数之前,记得声明所有重载的函数版本。 这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。
16.4 可变参数模板
一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。 可变数目的参数称为参数包。
存在两种参数包: 模板参数包 与 函数参数包。
- 在一个模板参数列表中 ,class 或 typename…指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号,表示零个或多个给定类型的非类型参数的列表。
- 在函数参数列表中, 如果一个参数的类型是一个模板参数包,则该参数也是一个函数参数包。
template <typename T, typename... args>
void foo(const T &t, const args& ... rest);
void foo(const int &, const string &, const int &, const double &);
void foo(const int &, const string &);
int main(int argc,char **argv) {
foo(0, 42,1,3);
}
在每个实例中, T 的类型都是从第一个实参类型推断而来的。 剩下的实参提供函数额外实参的数目与类型。
sizeof…运算符
sizeof…() 返回一个常量表达式,不会对实参求值,解析包中元素数量
template <typename T, typename... args>
void foo(const T &t, const args & ... rest) {
cout << sizeof...(args) << endl;
cout << sizeof...(rest) << endl;
}
16.4.1 编写可变参数函数模板
当我们即不知道想要处理的实参的数目也不知道它们的类型时,可变参数函数是很有用的
16.4.2 包扩展
16.5 模板特例化
第 十七 关: 标准库特殊设施
17.1 tuple 类型
我们可用将 tuple 看成一个 “快速而随意” 的数据结构。
tuple 的用途: 当我们希望将一些数据组合成单一对象, 但又不想麻烦地定义一个新数据结构来表示这些数据时, tuple 是非常有用的。
tuple<T1,T2,T3.....Tn> t; t 使用了可变参数模板, 其所有成员值初始化
tuple<T1,T2,T3.....Tn> t(v1, v2, ....vn); 其成员固定初始值初始化,构造函数是 explicit
make_tuple(v1, v2, v3,....vn) 返回一个用给定初始值值初始化的 tuple,从初始值进行推断
t1 == t2; 成员相等且数量相等,则相同,否则不同
t1 != t2;
t1 relop t2 关系运算符使用字典序
get<i>(t) 索引地 i 个数据元素的引用,t是左值,则为左值引用,否则右值引用
tuple_size<tupleType>::value 获取其 tupleType 的成员数量,其value 是public constexpr static
tuple_element<i, tupleType>::type 给定tuple 类型中指定成员的类型
演示:
std::tuple<int, int, unsigned> t;
t = {1, 2, 3};
cout << std::get<1>(t); //输出索引 1 的元素值
std::tuple<int, int, unsigned> t2;
cout << (t < t2);
cout << std::tuple_size<decltype(t)>::value;
std::tuple_element<1, decltype(t)>::type;
- 由于在 tuple 中定义了 < 和 == 运算符,我们可用将tuple序列传递给算法, 并且可用在无序容器中将 tuple 作为关键字类型。
- 使用 tuple 返回多个值
typedef tuple<vector<SalesData>::size_type,
vector<SalesData>::const_iterator,
vector<SalesData>::const_iterator> matches;
vector<matches> FindBook(const vector<vector<SaleData>> &files, const string &book);
17.2 bitset 类型
bitset 类使得 位运算的使用更为容易, 并且能够处理超过最长整型类型大小的位集合。
bitset 定义在 头文件 bitset中
初始化 bitset
bitset<n> b;
b 有n 位,每一位都为 0 ,默认构造函数是 constexpr
bitset<n> b(u);
b是 ull 值 u 的低 n 位的拷贝,如果 n 大于 ull 的大小,
则 b 超出 ull 的高位 被置为0, 该构造函数也是 constexpr
bitset<n> b(s, pos, m, zero, one); //explicit
b 是string s 从pos 开始的 m 个字符的拷贝,
s 只能包含字符 zero,one,如果存在其他字符,
抛出 invalid_argument异常, pos 默认 0, m string::npos, zero默认为 ’0‘,one为 ’1‘
bitset<n> b(cp, m, zero, one) //explicit
cp是字符数组,未提供 m,cp必须指向一个 c风格字符串,提供了cp至少含义 m 个 zero或one
note: string 的下标编号习惯于 bitset 恰好相反, string 下标最大的字符初始化 bitset 中的低位。
bitset 操作
bitset操作定义了多个检测或者设置一个或多个二进制的方法。
b.any()
b 中是否存在置位的二进制位
b.all()
b中的所有位都置位了吗
b.none()
b中不存在置位的二进制吗
b.count()
b中置位的位数
b.size()
一个 constexpr函数,返回 b 中的位数
b.test(pos)
若 pos 位是置位的,返回true 否则返回 false
b.set(pos,v)
将 pos 位设置位 bool 值, v默认 true, 未传递实参,则b中所有位置位
b.set()
b.reset(pos) 将位置 pos 处的位复位,或全部复位
b.reset()
b.flip(pos) 改变位置 pos 处位的状态,或者改变 b 的每一位的状态
b.flip()
b[pos] 访问b中位置 pos 处的位,如果 b 是const,则位置位时 返回 true,否则 false
b.to_ulong() 返回一个 unsigned long 或一个 unsigned long long 的值,位模式与 b 相同,
b.to_ullong() 如果 b 中位模式不能放入指定结果类型,则抛出 overflow_error 异常
b.to_string(zero, one) 返回 string,表示 b 中位模式
os << b 将b 中二进制位打印位 字符 1或0,到 流中
is >> b 从is中读取字符存入 b。当下一个字符不是 1或0 或是已经读入 b.size()个位,
结束
使用 bitset
*
17.3 正则表达式
正则表达式是一种描述字符序列的方法,是一种及其强大的计算工具。
C++正则表达式库(RE):是新标准的一部分, RE库定义在头文件 regex中,它包含多个组件。
regex
表示有一个正则表达式的类
regex_match
将一个字符序列与一个正则表达式匹配,匹配返回 true
regex_search
寻找第一个与正则表达式匹配的子序列,匹配返回 true
regex_replace
使用给顶格式替换一个正则表达式
sregex_iterator
迭代器适配器,调用 regex_search 来遍历一个 string 中所有匹配的子串
smatch
容器类,保存在 string 中搜素的结果
ssub_match
string 中匹配的子表达式的结果
regex_search 和 regex_match 的参数
注意:这些操作返回 bool 值,指出是否找到匹配
(seq, m, r,mft)
(seq, r, mft)
1. 在字符序列seq中查找 regex 对象 r 中的正则表达式。
seq可用是一个 string,表示范围的一对迭代器或字符数组指针
2. m 是一个match对象, 用来保存匹配结果的相关细节。 m 和 seq 必须具有兼容的类型
3. mft 是一个可选的 regex_constants::match_flag_type 值,它们会影响撇皮过程
17.3.1 使用正则表达式库
使用步骤:
- 创建正则表达式
- 初始化 regex 对象,以及保存查找结果的 smatch
- 定义查找序列
- regex_searcg(str, result, regex)
- 默认情况下, regex 使用的正则表达式语言是 ECMAScript
指定 regex 对象的选项
定义一个 regex 或是 对 regex 调用 assgin 为其赋予新值时,可用指定标志位来影响 regex 如何操作
regex r(re) re是一个正则表达式,(string,字符数组指针,迭代器对,计数器或花括号列表,)
regex r(re, f) f 是指出对象如何处理的标准,默认未 ECMAScript
r1 = re; 将 r1 中正则表达式替换未 re
r1.assign(re, f) 与赋值运算符相同,
r.mark_count() r中子表达式的数目
r.flags() 返回 r 的标志集
注: 构造函数和赋值操作可能抛出 regex_error异常
定义regex时指定的标志
定义在 regex 和 regex_constants::synatax_option_type中
icase 在匹配过程中忽略大小写
nosubs 不保存匹配的子表达式
optimize 执行速度优于构造速度
ECMAScirpt 使用ECMA-262 指定的语法
basic 使用 POSIX基本的正则表达式语法
extended 使用 POSIX 扩展的正则表达式语法
awk 使用 POSIX 版本的awk语法
grep 使用 POSIX 版本的 grep 语法
egrep 使用 POSIX 版本的 egrep 语法
演示: 识别扩展名以及其他普通文件扩展名
- 注意:(字符 点. )匹配任意字符,我们使用反斜杠 \ 去除其含义, 但是反斜线也是 特殊含义,我们使用 \. ,来表示 .
指定或使用正则表达式的错误
- 一个正则表达式的语法是否正确 是在运行时解析的
- 正则表达式出现错误后,会在运行时标准库抛出一个类型 regex_error 的异常, what 成员描述发生了什么错误, code 成员 来返回错误类型对应的数值编码。
演示:
正则表达式的错误类型:
定义在 regex 和 regex_constants::error_type 中
error_collate 无效的元素校对请求
error_ctype 无效的字符类
error_escape 无效的转移字符或无效的尾置转义
error_backref 无效的向后引用
error_brack 不匹配的方括号([])
error_paren 不匹配的小括号 (())
error_brace 不匹配的花括号({})
error_badbrace {} 中无效的范围
error_range 无效的字符范围 【z-a】
error_space 内存不足,无法处理该正则表达式
error_badrepeat 重复字符(* ? + {)之前没有有效的正则表达式
error_complexity 要求匹配过于复杂
error_stack 栈空间不足,无法匹配
建议: 避免创建不必要的正则表达式,正则表达式的编译是非常慢的操作。
正则表达式类和输入序列类型
RE 为不同的输入序列类型定义了对应的类型,且 RE库必须与输入序列类型匹配。
不匹配的RE库与输入序列组合在一起就会引发错误,这里我们应该使用 cmatch 存储查找后的细节
输入序列类型 使用正则表达式类
string regex, smatch, ssub_match, sregex_iterator
const char* regex, cmatch, csub_match, cregex_iterator
wstring wregex, wsmatch, wssub_match, wsregex_iterator
const wchar* wregex, wcmatch, wcsub_match, wcregex_iterator
17.3.2 匹配与 Regex 迭代器类型
我们可以使用 Regex 的迭代器来获得输入序列的所有匹配
sregex_iterator 操作
操作适用于其他 RE库迭代器
sregex_iterator it(b, e, r)
一个迭代器,遍历迭代器 b,e 表示的 string,调用 sregex_search(b, e, r) 将 it 定位到输入中第一个匹配的位置
sergex_iterator end;
sergex_iterator 的尾后迭代器
*it 根据最后一个调用 regex_search 的结果,返回一个 smatch对象的引用或一个指向 smatch 对象的指针
it->
++it 从输入序列当前匹配位置开始再次调用 regex_match
it++
it1 == it2
it1 != it2
使用 sregex_iterator
使用 匹配的数据
it 解引用是一个 smatch 类型,我们可用使用其匹配的数据
m.ready()
已经通过调用regex_search 或 regex_match 设置了 m,则返回true,否则返回 false,且false 下 不能对m进行操作
m.size()
如果匹配失败,返回0,否则返回最近依次匹配的正则表达式中子表达式的数目
m.empty()
若 m.size() 为 0, 返回 true
m.prefix()
一个 ssub_match 对象,表示当前匹配之前的 序列
m.suffix()
一个 ssub_match 对象,表示当前匹配之后的 序列
m.format(。。。)
m.length(n)
第n 个匹配的子表达式的大小
m.position(n)
第 n 个子表达式据序列开始的举例
m.str(n)
第n个子表达式匹配的 string
m[n]
对应 第 n 个子表达式的 ssub_match 对象
m.begin(),m.end()
表示 m 中 sub_match 元素返回的迭代器,和往常一样,c系列表示const_iterator
m.cbegin(),m.cend()
17.3.3 使用子表达式
正则表达式中的模式通常包含一个 或 多个 子表达式,一个子表达式是模式的一部分,本身也具有意义。
正则表达式语法通常用括号表示子表达式, 子表达式从 1 索引开始, 0索引代表整个匹配到的表达式
子表达式用于数据验证
- 子表达式的常见用途是验证必须匹配特定格式的数据
下面的代码读取一个文件,并用此模式查找完整的电话号码模式匹配的数据,并调用 valid 函数来检查号码是否有效。
string phone = "(\\()?(\\d{3})(\\))?([-. ])?(\\d{3})([-. ]?)(\\d{4})";
std::regex r(phone);
std::smatch m;
string s;
while (std::getline(cin, s)) {
for (std::sregex_iterator it(s.begin(), s.end(), r), end;
it != end; ++it) {
if(valid(*it)) //校验其中的子序列的正确性
cout << "valid: " << it->str() << endl;
else
cout << "not valid" << it->str() << endl;
}
}
子匹配的操作
matched 一个public bool,指出此 ssub_match 是否匹配了
first public数据成员,指出匹配序列首元素与尾后位置的迭代器,如果未匹配,其相等
second
length() 匹配的大小,如果 matched 为fakse 返回 0
str() 返回一个包含输入匹配部分的string,如果 matched 为 false,返回空 string
s = ssub 将 ssub_match 转换为 string
17.3.4 使用 regex_replace
当我们希望在输入序列中查找并替换一个正则表达式时,可用调用 regex_replace.
string phone = "(\\()?(\\d{3})(\\))?([-. ])?";
std::regex r(phone);
std::smatch m;
string s;
string fmt = "$2.$5.$7"; //将号码格式改为 ddd.ddd.ddd
while (std::getline(cin, s))
cout << std::regex_replace(s, r, fmt) <<endl;
return 0;
用来控制匹配和格式的标志
17.4 随机数
定义在头文件 radom 中的随机数库通过一组协作的类来解决这些因为 (非均匀分布的数而导致引入了非随机性)的问题: 随机数引擎类 和 随机数分布类
引擎 类型,生成随机 unsigned 整数序列
分布 类型,使用引擎返回服从特定概率分布的随机数
建议: C++程序不应该使用 库函数 rand,而应该使用 default_random_engine 类和恰当的 分布类对象
17.4.1 随机数引擎和分布
引擎提供给分布进行随机
此外还有很多使得不同分布的概率随机不同的模板程序库
17.5 IO 库再探
本节,我们来学习 三个更特殊的 IO 特性:格式控制,未格式化 IO 和随机访问
17.5.1 格式化输入输出
- 标准库定义了一组操纵符来修改流的格式状态。
- 当操纵符改变流的格式时,通常改变后的状态对后续 IO 都生效
- 最号在不需要特殊格式时尽快将流回到默认状态
控制布尔值的格式
将 bool 类型 输出以 字母方式打印
指定整型值的进制
- 注意,这些操纵符会影响下一个和随后所有的整型输出,直到另一个操纵符改变了其状态
- 整型操纵符只影响整型运算对象,对浮点型没有影响
在输出中指定进制
利用 nouppercase/uppercase showbase/noshowbase dec 能改变输出的格式问题
控制浮点数格式
我们能控制浮点数输出三种格式:
- 以多高精度(多数数组)打印浮点值
- 数值是打印为 十六进制, 定点 十进制还是 科学计数法形式
- 对于没有小数部分的浮点值是否打印小数点
默认情况下,浮点数以六位数字精度打印,如果没有小数部分,则不打印小数点
标准库选择可读性更好的格式: 非常大或非常小的打印为 科学计数法形式, 其他值打印 定点十进制形式
指定打印精度
-
在打印浮点值时按 当前精度舍入而非 截断
-
通过 IO 对象的 precision 成员或使用 setprecision 操纵符来改变精度。
- os. precision() 返回当前精度值 ,os. precision(int) :设置精度为 int值
- cout << setprecision(int) 设置当前精度值为 int
-
操纵符 setprecision 和其他接受参数的操纵符都定义在 头文件 iomanio 中
指定浮点数计数法
建议:除非你需要控制浮点数的表示形式,不然由标准库选择计数法是最好的方式
- 操纵符 scientific 改变流的状态来使用 科学计数法,fixed 恢复其定点十进制
- 新标准使用 hexfloat 可用降至浮点数使用 十六进制格式,defaultfloat 使流恢复
打印小数点
showpoint操纵符强制打印小数点,noshowpoint恢复
输出留白
- setw 指定下一个数字或字符串值的最小空间
- left 表示左对齐输出
- right 表示右对齐输出,默认格式
- internal 控制负号的符号位置,它左对齐符号,右对齐值,用空格填满所有中间空间
- setfill 允许一个字符代替默认的空格补白输出
定义在 iomanip 的操纵符
setfill(ch) 用ch 填充空白
setprecision(n) 将浮点精度设置为n
setw(w) 读值或写值的宽度为 w个字符
setbase(b) 将整数输出为 b 进制
控制输入格式
默认情况下,输入运算符会忽略空白符(空格符, 制表符,换行符,换纸符和回车符)
操纵符 noskipws 会令输入运算符读取空白符, skipws 会恢复默认
17.5.2 未格式化的输入/输出操作
第十八关: 用于大型程序的工具
大规模编程对程序设计语言的要求更高。
大规模应用程序的特殊要求包括:
- 在独立开发的子系统之间协同处理错误的能力 异常处理
- 使用各种库(可能包含独立开发的 库) 进行协同开发的能力 命名空间
- 对比较复杂的应用概念建模的能力 多重继承
18.1 异常处理
异常处理 机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。
- 异常使得我们能够将问题的检测与解决过程分离开
- 要想有效地使用异常处理, 必须首先了解当抛出异常时发生了什么, 捕获异常时发生了什么, 以及用来传递错误的对象的意义。
18.1.1 抛出异常
在C++中,我们通过抛出一条表达式来引发一个异常, 被抛出的表达式的类型以及当前的调用链共同绝对零度哪段处理代码将被用来处理该异常。
- 当执行 throw,跟在 throw 后面的代码不被执行,程序的控制权转移到 catch。
- 沿着调用链的函数可能提早结束
- 一旦程序开始执行异常处理代码,将沿着调用链创建的对象被销毁
栈展开
- 栈展开过程沿着嵌套函数的调用链不断查找, 直到找到了与异常匹配的 catch 子句为止;或者也可能一直没找到匹配的catch, 则退出主函数查找过程终止。
- 如果一个异常没有被捕获,则它将终止当前的程序。
栈展开过程中对象被自动销毁
- 如果栈展开的过程中退出某个块, 编译器将负责确保在这个块中创建的对象被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。
- 如果在异常发生之前已经数组或标准库构造了一部分元素,则我们应该确保这部分元素被正确销毁。
析构函数与异常
- 在负责释放资源的代码之前发生了异常,则释放资源的代码不会执行
- 如果我们使用类来控制资源分配,就能确保无论函数是正常结束还是 异常,资源都能通过析构函数正确释放
- 如果析构函数需要执行某个可能抛出异常的操作, 则该操作应该被放置在一个 try 语句块中,并且在析构函数内部得到处理,处理不了就会抛出异常
异常对象
-异常对象是一种特殊的对象, 编译器使用异常抛出表达式对异常对象进行拷贝初始化。 throw 表达式必须拥有完全类型,如果是类类型,则类必须含有一个可访问的 析构函数,和可访问的拷贝或移动构造函数,如果表达式是数组或是函数类型,表达式会转换为指针类型
- 异常对象位于由编译器管理的空间中,编译器确保无论调用的是哪个 catch 子句都能访问该空间,当异常被处理,异常对象被销毁
- 抛出一个局部对象的指针是一种错误的行为
- 表达式的静态编译时类型决定了异常对象的类型,如果 throw 表达式解引用一个基类指针,而该指针指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
18.1.2 捕获异常
catch 子句的异常声明看起来像是只包含一个形参的函数形参列表。
- 声明的类型决定了的处理代码所能捕获的异常类型,这个类型必须是完全类型,可以是左值引用,但不能是右值引用。
- 进入 catch 语句中,通过异常对象初始化异常声明中的参数,如果 catch 参数类型是非引用,则以拷贝传递,否则以引用传递
- 我们能用派生类类型的异常对象对其基类类型异常对象初始化, 如果 catch 的参数是非引用类型,则异常对象被切掉一部分,如果 catch 是基类的引用,则参数以常规方式绑定到异常对象上
建议: 异常声明的静态类型将决定 catch 语句所能执行的操作,如果 catch 接受的异常与某个继承体系有关,最好将 catch 的参数定义为引用类型。
查找匹配的处理代码
- 当有继承关系的多个异常时,必须对 catch 语句顺序进行组织管理,要求派生类异常的处理代码出现在基类异常的处理代码之前。
异常和 catch 异常声明的匹配规则受到限制,只允许以下精准匹配:
- 允许从非常量到常量的转换
- 允许从派生类到基类的转换
- 数组被转换成指向数组元素的指针,函数被转换为指向函数的指针
重新抛出
有时一个单独的 catch 语句不能完整地处理某个异常, 在执行了某些校正操作之后,当前的 catch 可能会决定由调用链更上一层的函数接着处理异常。
-
一条 catch 语句通过重新抛出的操作将异常传递给另一个 catch 语句,这里的重新抛出仍然是一个 throw 语句,只不过不包含任何表达式:
throw;
-
只有当 catch 异常声明是引用类型我们对参数的改变才会保留并传递。
catch (my_error &eobj) {
eObj.status = errCodes::severeErr;
throw;
} catch (other_error eObj) {
eObj.status = errCodes::badErr;
throw;
}
捕获所有异常的处理代码
为了一次性捕获所有异常, 我们使用省略号作为异常声明, 这样的处理代码称为 捕获所有异常的处理代码。
- 如果 catch(…) 与其他 catch 语句一起出现,则catch(…) 必须出现在最后的为止。
s
try {
} catch (...) {
}
18.1.3 函数 try 语句块与构造函数
构造函数在进入函数体之前首先执行初始值列表。 因为在初始值列表抛出异常时构造函数体内的 try 语句块还未生效, 所以构造函数体内的 catch 无法处理构造函数初始值列表抛出的异常。
将构造函数写成 函数try语句块 (函数测试块)的形式可用处理(唯一办法)
- 函数try语句块 使得一组 catch 语句既能处理构造函数体(析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。
template<typename T>
inline Blob<T>::Blob(std::initializer_list<T> il)
try: data_(std::make_shared<std::vector<T>(il)>) {
} catch(const std::bad_alloc &e) {
//处理内存异常
}
18.1.4 noexcept 异常说明
对于用户和编译器来说, 预先知道某个函数不会抛出异常显然 大有脾益
- 有助于简化调用该函数的代码
- 有助于编写优化操作
在 C++11 新标准中, 我们提供 noexcept 说明指定某个函数不会抛出异常,其形式是关键字 noexcept紧跟在函数的参数列表后面,用以标识该函数不会抛出异常:
- 出现在声明语句和定义语句中
- 说明在尾置返回类型之前
- 在函数指针的声明和定义中指定 noexcept
- 在 typedef 与类型别名中不能出现 noexcept
- noexcept 在成员函数中跟在 const 及 引用限定符之后,在 final override 或虚函数的 =0之前。
void recoup(int) noexcept; //不会抛出异常
void alloc(int); //可能会抛出异常
违反异常说明
一旦一个 noexcept 函数抛出了异常,程序就会调用 terminate 以确保遵守不在运行时抛出异常的承诺。
上述过程对是否执行栈展开未作约定。
因此:我们可用在两种情况下使用 noexcept
- 一 我们确认函数不会抛出异常
- 二 我们根本不知道如何处理异常
异常说明的实参
noexcept 说明符接受一个可选的实参, 该实参必须能转换为 bool 类型 :
- 实参为 true,则函数不会抛出异常
- 实参为 false,函数可能抛出异常
noexcept 运算符
noexcept 说明符的实参与noexcept运算符混合使用。
- noexcept 运算符是一元运算符,返回值是 bool 类型的右值常量表达式,用于表示给定的表达式是否抛出异常, noexcept 也不会求其运算对象的值
void f() noexcept(noexcept(g())) ; f 与 g 的异常说明一致
noexcept(e) 如果 e 本身不含有 throw 语句,为 true,否则false
note: noexcept 有两层含义:跟在函数参数列表之后它是异常说明符,当作 noexcept 异常说明的 bool 实参出现,是一个运算符
异常说明与指针,虚函数和拷贝控制
- 函数指针及其指针所指的函数必须具有一致的异常说明。
- 如果为指针做了不跑出异常的声明,该指针只能指向不抛出异常的函数,
- 显式隐式说明了指针可能抛出异常,那该指针可以指向任何函数
-
如果一个虚函数承诺了它不会抛出异常, 则后续派生处的虚函数也需要做同样的承诺,如果基类的虚函数允许抛出异常,派生类的对应函数可抛出异常也可不抛出异常
-
当编译器合成拷贝控制成员时, 同时也生成异常声明,如果对所有成员和基类的所有操作都承诺了不抛出异常,合成的成员是 noexcept的,如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是 noexcept(false)
18.1.5 异常类层次
实际的应用程序通常会自定义 exception(或者 exception的标准库派生类)的派生类以扩展继承体系
这些面向应用的异常类表示了与应用相关的异常条件。
class out_of_stock : public std::runtime_error {
public:
explicit out_of_stock(const std::string &s): std::runtime_error(s) {}
};
class isbn_mismatch : public std::logic_error { //逻辑错误
public:
explicit isbn_mismatch(const std::string &s): std::logic_error(s) {}
isbn_mismatch(const std::string &s, const std::string &lhs, const std::string &rhs)
: std::logic_error(s), left(lhs), right(rhs) {}
const std::string left, right;
};
SalesData &SalesData::operator+=(const SalesData &rhs) {
if (book_no() != rhs.book_no())
throw isbn_mismatch("wrong isbns", book_no(), rhs.book_no());
units_sold_ += rhs.units_sold_;
revenue_ += rhs.revenue_;
return *this;
}
SalesData item1, item2, sum;
while (cin >> item1 >> item2) {
try {
sum = item1 + item2;
} catch (const mynamespace::isbn_mismatch &e) {
cerr << e.what();
}
}
18.2 命名空间
多个库将名字放置在全局命名空间中会引发 命名空间污染
命名空间 为放置名字冲突提供了更加可控的机制,命名空间分割了全局命名空间,其中每个命名空间是一个作用域。通过在某个命名空间中定义库的名字,库的作者可用避免全局名字固有 的限制。
18.2.1 命名空间定义
关键字 namespace 与 命名空间的名字 加上 花括号括起来的声明和定义 构成了命名空间
- 命名空间的名字必须定于i在它的作用域内保证唯一,可以定义在全局作用域中,或者定义在其他命名空间中,但不能定义在函数和类的内部
每个命名空间都是一个作用域
- 命名空间的每个名字都必须保证是该空间的唯一实体,所以 不同命名空间内可以有相同名字的成员‘
命名空间可以是不连续的
namespace nsp {
}
namespace nsp {
}
- 如果之前没有名为 nsp 的命名空间定义, 则上述代码创建一个新的命名空间;否则,上述代码打开已经存在的命名空间定义并为其添加一些新成员的声明。
- 命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于我们管理自定义类及函数的方式:
- 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象, 则这些成员应该值于头文件中, 头文件将被包含在使用了这些成员的文件中。
- 命名空间成员的定义部分则置于另外的源文件中。
建议: 定义多个类型不想关的命名空间应该使用单独的文件表示每个类型(关联类型构成的集合)
定义命名空间
注意: 不能把 #include 放在命名空间内部,如果我们这么做了,隐含的意思是把头文件中所有名字定义成该命名空间的成员。
定义命名空间成员
- 假定定义的语句的代码在同一命名空间下,可以简写,否则 所属名字的声明和定义都需要指出所属的命名空间
- 应该保证命名空间的定义在与其相关的作用域
CPlusPlusPrimer& operator+(const CPlusPlusPrimer &rhs);
CPlusPlusPrimer &CPlusPlusPrimer::operator+(const CPlusPlusPrimer &rhs) {
return *this;
}
如果不在同一命名空间下:
mynamespace::CPlusPlusPrimer& mynamespace::CPlusPlusPrimer::operator+=(const CPlusPlusPrimer &rhs){
return *this;
}
模板特例化
模板特例化必须定义在原始模板所属的命名空间中,和其他命名空间的名字类似,只要我们在命名空间中声明了特例化,就可以在命名空间外部定义他了:
全局命名空间
全局作用域中定义的名字也就是定义在全局命名空间中。
:: member_name 能显式地表示全局命名空间中的一个成员。
嵌套的命名空间
namespace mynamespace {
namespace handling {
const std::string kNameStr;
}
}
std::cout << mynamespace::handling::kNameStr;
内联命名空间
C++ 11 新标准引入了新的嵌套命名空间,内联命名空间,在关键字 namespace 前加入 inline 即可。
- 内联命名空间中的名字可以被外层命名空间直接使用,像是展开在 其所在命名空间中一样
未命名的命名空间
namespace {
int vec_count;
}
未命名的命名空间是指关键字 namespace 后紧跟花括号括起来的一系列声明语句。
- 未命名的命名空间中定义的变量拥有静态声明周期:它们在第一次使用前被创建,程序结束销毁。
- 未命名的命名空间仅在特定的文件内部有效, 其作用范围不会横跨多个不同的文件。
- 如果 未命名的命名空间嵌套在外层命名空间中,需要使用外层作用域访问符来访问嵌套在里面的名字。
建议: 在文件中进行静态声明的做法已经被 C++ 标准取消了,现在的做法是使用未命名的命名空间
18.2.2 使用命名空间成员
我们可以调用作用域访问符来访问其成员,但有时候显得篇幅太长,这节我们讨论其他的解决办法
命名空间的别名
命名空间的别名可以使得我们为命名空间的名字设定一个短的多的同义词
namespace mynp = mynamespace;
using 声明: 扼要概述
一条 using 声明语句一次只引入命名空间的一个成员, 他使得我们可以清楚地知道程序用的到底是哪个名字。
using std::string;
- 作用域规则: 它的有效范围从 using 声明的地方开始, 一直到 using 声明所在的作用域结束为止。
using 指示与作用域
using 指示使得某个特定的命名空间中所有名字可见,这样我们无须为其添加任何前缀限定。
using namespace mynamespace;
- 使用的命名空间名字需要是已经定义好的名字,且如果我们提供了 std 等命名空间指示,而未做特殊控制的话,将重新引入由于使用了多个库而造成的名字冲突问题。
提示: 避免使用 using 指示
由于 using 指示使用后对应用程序的控制变得十分简单,命名空间污染的问题就会出现,我们可以在命名空间本身的实现文件中使用 using 指示。
18.2.3 类,命名空间与作用域
- 对命名空间内部的查找遵循常规的查找规则: 即由内向外依次查找每个外层作用域
- 只有位于开放的块中且在使用点之前声明的名字才被考虑:
note :可以从函数的限定名推断出查找名字检查作用域的次序,(相反)
int A::C1::F3() 从 c1 开始 -> A ->全局作用域
实参相关的查找与类类型形参
std::string s;
std::cin >> s;
operator >> (std::cin, s);
- 当我们给函数传递一个类类型的对象时, 除了在常规的作用域查找外还会查找实参类所属的命名空间。
- 查找规则的这个例外允许概念上作为类接口的一部分非成员函数无须单独的 using 声明就能被程序使用。
查找与 std::move 和 std::forward
由于 传递右值引用形参 会导致传递任何类型,都被匹配。
因此为了避免这种冲突,我们使用 std::move 让我们明确直到使用的是函数标准库版本。
友元声明与实参相关的查找
对于友元与 接口函数查找规则相同
18.2.4 重载与命名空间
在命名空间重载的单一调用会默认为 全部重载函数
18.3 多重继承
多重继承 指从多个直接基类产生派生类的能力,多重继承的派生类继承了所有父类的属性。
18.3.1 多重继承
class A : public B, public C {}
在给顶的派生列表中,同一个基类出现依次
多重继承的派生类从每个基类中继承状态
在多重继承关系中,派生类对象包含有每个基类的子对象
派生类构造函数初始化所有基类
派生类的构造函数初始化列表将实参分别传递给每个基类,其中基类的构造顺序与派生类列表中基类的出现顺序保持一致。(根据每一个派生类的构造函数列表进行初始化)
继承的构造函数与多重继承
在C++11 中,允许派生类从它的一个或几个基类中继承 构造函数, 但是如果从多个基类中继承了相同的构造函数(形参列表完全相同)。
建议; 最好使用构造函数定义自己的版本
析构函数与多重继承
析构函数的调用顺序与构造函数相反
多重继承的派生类的拷贝与移动操作
与 一个基类的继承相符。
18.3.2 类型转换与多个基类
-
我们可以令某个可访问基类的指针或引用直接指向一个派生类对象
-
编译器不会在派生类向基类的几种转换中进行比较与选择,因为在它看来转换到任意一种基类都一样好,则在 选择 参数是派生类的重载函数时,会引发二义性错误。
基于指针或引用类型的查找
对象,指针和引用的静态类型决定了我们使用哪些成员
18.3.3 多重继承下的类作用域
- 在多重继承情况下,相同的查找过程在所有基类中同时进行,沿着继承体系自底而上,直到找到所需的名字。
- 派生类的名字隐藏基类的同名成员,
- 当一个类拥有多个基类时,可能出现派生类从两个或更多基类中继承了同名成员的情况,此时,不加前缀限定符直接使用该名字将引发二义性(即使一个是私有一个是公有)
- 解决二义性办法是 自身定义一个版本覆盖掉基类的这些版本。
18.3.4 虚继承
在默认情况下,派生类含有继承链上每个类对应的子部分, 如果某个类在派生过程中出现了多次,那么派生类将包含该类的两份拷贝,这明显会造成一些问题。
- 在C++ 语言中我们通过虚继承 的机制来解决以上问题。 虚继承的目的是令某个类做出声明, 承诺愿意共享它的基类。
- 共享的基类子对象称为虚基类,不论虚基类在继承体系中出现了多少次, 在派生类中都包含唯一一个共享的虚基类子对象
note: 虚派生只影响从指定了虚基类的派生类中进一步派生出来的类, 它不会影响派生类本身,
使用虚基类
指定虚基类的方式是在派生类列表中添加关键字 vittual
- virtual 代表了一种愿望,即后续的派生类当中共享虚基类的同一份实例。
支持向基类的常规类型转换
虚基类成员的可见性
因为 在每个共享的虚基类中只有唯一一个共享的子对象, 所以该基类的成员可以被直接访问,并且不会产生二义性。
解决二义性的问题最好的方法是在派生类中为成员定义新的实例。
18.3.5 构造函数与虚继承
只要我们能创建虚基类的派生类对象, 该派生类的构造函数就必须初始化它的虚基类。
虚继承的对象的构造方式
含有虚基类对象的构造顺序与一般的顺序稍有区别: 首先提供给最底层派生类的构造函数初始值初始化该对象的虚基类子部分,再按照直接基类在派生列表出现的次序依次对其初始化。
构造函数与析构函数的次序
对象的销毁顺序与构造顺序相反。
第十九关: 特殊工具与技术
在语言基本要素之外, C++还定义了 一些非常特殊的性质,对于很多程序员来说,它们一般很少会用到本章介绍的内容。
19.1 控制内存分配
我们希望自定义内存分配的细节,这时我们会使用关键字 new 将对象放置在 特定的内存空间中,为了实现这个目的, 应用程序需要重载 new 运算符和 delete 运算符以控制内存分配的过程。
19.1.1 重载 new 和 delete
new 表达式的三个步骤:
- new 调用了 名为 operator new 的标准库函数,该函数分配一块足够大,原始的,未命名的内存空间以便存储特定类型的对象(或对象的数组)。
- 编译器运行相应的构造函数以构造这些对象, 并为其传入初始值。
- 对象被分配了空间并构造完成,返回一个指向该对象的指针。
delete 表达式的 两个步骤
- 对 指针所指的对象或数组的原始执行对应的析构函数
- 调用 operator delete 的标准库函数释放内存空间。
当我们自定义 全局 operator new 与 operator delete 函数后,我们担负起控制动态内存的责任,这两个函数必须是正确的
编译器对于自定义 运算符重载 的响应
- 当编译器发现一条 new 表达式或 delete ,在程序中查找可供调用的 operator 函数。
- 如果被分配的对象是类类型, 则编译器首先在类及基类的作用域查找,如果发现 重载函数则相应的表达式调用该成员
- 非类类型则编译器在全局作用域查找匹配的函数,如果编译器找到了用户自定义的版本,则调用该版本,否则,使用标准库定义的版本。
使用作用域运算符可以了令 new 表达式与 delete 表达式忽略掉在类中的函数直接执行全局作用域的版本。
:: new Strlob();
operator new 接口与 operator delete接口
标准库定义了 8 个重载版本,其中前4个版本可能抛出 bad_alloc 异常,其他 4个版本不会抛出异常
void * operator new(size_t);
void * operator new[](size_t);
void *operator delete(void*) noexcept;
void *operaotr delete[](void*) noexcept
//以下版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept;
void *operator delete(size_t, nothrow_t&) noexcept;
void *operator new[](size_t, nothrow_t&) noexcept;
void *operator delete[](size_t, nothrow_t&) noexcept;
- new 头文件定义了名为 nothrow的const 对象,可以通过这个对象请求 new 不抛出异常, delete 表达式不允许抛出异常,我们使用 noexcept 异常说明符来说明这一点。
- 当我们自定义其 运算符函数时,它们是隐式静态的
- 因为 operator new 与 operator delete 用在对象创建以及对象销毁之后,其必须是静态的,且不能操纵类的任何成员
如果我们向自定义 operator new 函数,则可以为其提供额外的形参,用到这些自定义函数的 new 表达式必须使用 new 的定位形式,将实参传递给新增的形参。
auto p = new (nothrow)int;
auto p = new (para) int;
注意: void operator new(size_t, void); //不能被重载,只供标准库使用
operator delete 函数版本由该对象的动态类型决定
malloc 函数与 free 函数
初衷只是使用特殊定制的 内存分配器,但这两个函数也应该同时满足测试的目的,即检验其分配内存的方式与常规方式类似。
我们使用 malloc 和 free 函数来完成简单的定制内存分配器 。它们定义在 cstdlib 中
- malloc 函数接受一个表达待分配字节数的 size_t ,返回指向分配空间的指针,或者 返回 0代表分配失败
- free 函数接受有一个 void*,将内存归还给系统
void *operator new (size_t sz){
if (void *mem = malloc(sz))
return mem;
else
throw std::bad_alloc();
}
void operator delete(void *mem) noexcept {
free(mem);
}
19.1.2 定义 new 表达式
operator new 分配的内存空间使用 定位 new 形式构造对象, new 的这种形式为分配函数提供了额外的信息。
new (place _address) type;
new (place _address) type(initializers);
new (place _address) type [size];
new (place _address) type [size] { braced initializer list};
其中 place_address 必须是一个指针,同时在 initializers提供一个(可能为空的)以逗号分隔的初始值列表,该列表用于构造一个新分配的对象。
- 当只传入指针类型的实参时, 定位 new 表达式构造对象但是不分配内存,它的内存靠 operator new(size_t, void *) 分配。
显式的析构函数调用
string *sp = new string("a value");
sp -> ~string();
显式调用析构函数会销毁对象,但是不会释放内存,释放内存由 operator delete 释放
19.2 运行时类型识别
运行时类型识别(run_time type indentification RTTI) 的功能由两个运算符实现:
- typeid 运算符,用于返回表达式的类型
- dynamic_cast 运算符,用于将基类的指针或引用安全地转换为派生类的指针或引用
当我们将两个运算符用于某个类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。
使用情况:
我们想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数
注意: 使用 RTTI 必须加倍小心, 在可能情况下,最好定义虚函数而非直接接管类型管理的重任。
19.2.1 dynamic_cast 运算符
使用形式:
dynamic_cast<type*>(e)
dynamic_cast<type&>(e)
dynamic_cast<type&&>(e)
-
type 必须是一个类类型,且通常情况下含有 虚函数
-
e 的类型必须符合以下三个条件中的任意一个:
- e 的类型是目标 type的公有派生类,e 的类型是目标 type 的公有基类或者 e 的类型就是目标 type 的类型,如果符合,则类型转换成功,否则转换失败。
- 如果一条 dynamic_cast 语句的转换目标是指针类型并且失败了,则指针为 0, 如果是引用类型,则抛出异常 bad_cast
指针类型的 dynamic_cast
if (Derived *dp = dynamic_cast<Derived*>(bp))
{
//转换成功
} else {
//转换失败
}
- 对一个空指针执行 dynamic_cast ,结果是所需类型的空指针
- 在条件部分执行 dynamic_cast 操作可以确保类型转换和结果检查在同一条表达式中完成
引用类型的 dynamic_cast
void f(const Base &b){
try {
const Derived &d = dynamic_cast<const Derived &>(b);
//使用 b 引用的Derived
}
catch (bad_cast){
}
}
bad_cast 异常定义在 type_info标准库头文件中
19.2.2 typeid 运算符
为 RTTI 提供的第二个运算符是 typeid 运算符,它允许程序向表达式提问: 你的对象是什么类型?
typeid(e)
-
e 可以是任意表达式或类型的名字,
-
typeid 操作的结果是一个常量对象的引用,该对象的类型是标准库类型 type _info 或者 type_info 的公有派生类型。
-
typeid 运算符可以用作任何类型的表达式,顶层const 被忽略,如果表达式是引用类型,则返回引用所引对象的类型,作用与指针或函数,并不会执行向指针的标准类型转换
-
当运算对象不属于类类型或者不包含虚函数的类,所求的类型是运算对象的静态类型,否则,typeid 的结果到允许时才会求得。
使用typeid 运算符
通常情况, typeid 用来比较两条表达式的类型是否相等,或一条表达式与指定类型是否相等。
- typeid 作用于指针类型,返回的是指针类型结果的静态编译类型
Derved *dp = new Derived;
Base *bp = dp;
if (typeid(*bq) == typeid(*dp))
if (typeid(*bq) == typeid(Derived))
19.2.3 使用 RTTI
当我们想为具有继承关系的类实现相等运算符时, RTTI 是非常有用的
步骤;
- 定义的相等运算符的形参是基类的引用,然后用 typeid 检查两个运算对象类型是否一致
- 如果不一致。直接返回 false,类型一致时调用 equal函数(虚函数)
- 每个类定义的 equal 负责比较自己的成员,接受 base & 成员,在比较之前转换 运算符所属的类类型。
class Base {
friend bool operator==(const Base &lhs, const Base &rhs);
public:
//Base 的接口成员
protected:
virtual bool Equal(const Base &) const;
};
class Derived: public Base {
protected:
bool Equal(const Base &base) const override;
};
bool Derived::Equal(const Base &base) const {
auto r = dynamic_cast<const Derived&>(base);
return *this == r;
}
bool Base::Equal(const Base &rhs) const {
return *this == rhs;
}
bool operator==(const Base &lhs, const Base &rhs) {
return typeid(lhs) == typeid(rhs) && rhs.Equal(rhs);
}
19.2.4 type_info 类
type_info 类的精确定义随着编译器的不同略有差异,编译器规定 type_info类必须定义在 typeinfo头文件中,并提供操作
typeid 是type_info 的 公有派生类型
t1 == t2 表示同一类型相等
t1 != t2
t.name() 返回 C风格字符串, 表示类型名字的可打印形式,因系统而异
t1.before(t2) 返回bool指, t1 是否位于 t2 前
19.3 枚举类型
枚举类型使得我们可以将一组整型变量组织在一起。和类一样,枚举定义了一种新类型,枚举属于字面值常量类型
C++ 包含的枚举有两种: 限定作用域的和不限定作用域的。
限定作用域枚举 与 非限定作用域枚举:
限定作用域枚举 :关键字 enum class + 枚举类型名字 + 花括号括起来以逗号分割的 枚举成员列表,最后是一个分号
enum class open_modes{ input, output, append };
不限定作用域枚举 : 省略掉 关键字 class,如果enum 是没有名字的,则只能在定义该 enum 时定义其对象,
enum color { red, yellow, green};
enum {floatPrec = 6, doublePrec = 10 };
枚举成员
- 在限定作用域枚举,枚举成员的你名字遵循常规的作用域准则,在枚举类型的作用域之外不可访问。
在不限定作用域的枚举类型中,枚举成员的作用域与枚举类型本身的作用域相同 - 默认情况下我们没有显式地提供初始值,枚举值从0 开始,依次加1。
- 枚举成员是const ,因此初始值必须是 常量表达式
和类一样,枚举也定义 新的类型
只能使用 枚举类型或类型的对象来初始化 enum 对象
指定 enum 的大小
在 c++ 11新标准中,我们可以在 enum 的名字后加上冒号,表示我们想在 enum 中使用的类型
enum intValues :unsigned long long {
charTyp = 255,
};
- 如果我们没有指定 enum 的潜在类型,则默认情况下限定作用域的 enum 成员类型是 int,对于 不限定作用域的枚举类型不存在默认类型。
枚举类型的前置声明
C++ 新标准中,是我们可以提前声明 enum, enum 的前置声明(隐式显式)必须指定其成员的大小
enum intValue: unsigned long long;
enum class open_modes;
形参匹配与枚举类型
要想初始化一个 enum 对象,必须使用该 enum 类型的另一个对象或者它的一个枚举成员(即使只相等也不能作为 enum实参使用)
19.4 类成员指针
用来指向类的成员的指针
19.5 嵌套类
嵌套在外层类中的类,主要描述 关系十分密切的类
19.6 union:一种节省空间的类
联合是一种特殊的类,通常嵌套在其他类的内部
- 一个union可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值
- 当我们给 union 的某个成员赋值之后,该union 的其他成员就变成未定义的了,分配给 union 对象的存储空间至少要容纳它的最大的数据成员,和其他类一样,一个 union 定义了新类型
- union 不能含有引用类型,但可以有 类类型,但不能继承其他类,也不能当成基类使用,union不能含有虚函数
- union 的访问修饰与 struct 相同
定义 union
union Token {
char cval;
int ival;
double dval;
}
使用 union 类型
- 默认情况下 union是未初始化的,可以显式地初始化 聚合类一样初始化 union
- 为 union 的一个数据成员赋值会零其他数据成员变成未定义的状态。
Token t = {‘a’}; //只能初始化一个成员
19.7 局部类
定义在函数的内部,局部类的所有成员都i必须定义在类内,局部类不能含有静态数据成员
19.8 固有的不可移值的特性。
19.8.1 位域
19.8.2 volatile 限定符
关键字 volatile 告诉编译器不应该对这样的对象进行优化,使得程序更容易访问硬件。
19.8.3 链接指示: extern “c”
使得程序更容易访问其他语言编写的代码。