Stream
stream是我们编程时与用户和文件系统进行交互的渠道,stream是source和destination之间的通道,使source将格式化的数据传到destination。
标准输入输出流
cout
cout(short for character output)
是一个典型的输出流,从程序接收数据,将数据传送到控制台窗口,<<
是流插入符号(stream insertion operator)
,是c++中用于将数据输入到流当中的符号,在这个例子中我们将字符串输入到cout流当中后,我们又输入了一个特殊的对象endl
,下一次的控制台输出出现在新的一行。我们可以对cout流输入任意类型的数据。
1 |
|
cin
cin(short for character input)
是输入流,cin以键盘输入为source,对应地有流提取运算符(strean extraction operator)
>>
。
1 | cout<<"please enter an integer."<<endl; |
cin也是可以链式输入的,每一个输入之间输入一个空格符或者enter,但是空格一般推荐只在链式输入的单个数据内没有空格时使用,比如假如说myString
的字符串值是hello world
,那么中间的空格符就会被误认为是间隔符而产生错误输入,enter输入就不会有这种情况。
1 | string myString; |
cin不会对输入进行安全检查,如果输入非法数据cin的输入就会产生异常。
文件读写流
c++中的fstream
头文件引入了ifstream
和ofstream
分别为输入文件流和输出文件流这两种类型,还有fstream
可以同时做到输入和输出两种功能。cin和cout是实打实的流对象,但是ifstream和ofstream是两种类型,在对文件进行读写操作前,我们要先创建类型为ifstream
或ofstream
的对象。
ifstream
1 |
|
上面提供了两种文件打开方式,这两种方式是等价的。
当我们试图打开一个文件,我们可能会由于权限或文件不存在导致文件打开失败,所以我们每一次打开文件都要进行检查文件是否成功打开。is_open()
是文件流对象的成员函数,cerr
是一个输出流,专门用于输出错误信息
ofstream
1 | ofstream myStream("example.txt");//输入流,将输入内容写入example.txt |
如果写入的文件不存在,那么输出流会自动创建一个新的同名空文件,如果已经存在,那么输入流就会清空原有的数据,重新写入该文件(注意备份)。
如果一个文件名储存在c++字符串内,那么应该转换为c字符串才能打开该文件,.c_str()
方法可以返回一个和c++字符串一样的c字符串,不会改变c++字符串的类型。
当离开文件流的作用域,c++会自动关闭文件以便其他进程可以读写文件,c++中手动进行文件关闭,可以利用.close()
方法。调用了该方法后,对调用该方法的文件流的读写都会失败。
stream manipulator(流操控器)
流操纵器是一个可以插入流中的对象,并起到改变流的性质的作用。endl
是最常见的流操纵器,
setw
用于输出固定长度,left和right决定不同的对齐方向
1 | cout<<'['<<left<<setw(20)<<"hello world"<<']'<<endl;//[hello world ] |
setfill
用于替换setw
所填充用的符号,并且替换一次后,后续setw
的填充符号就一直是setfill
填充的符号,如果不再次设置就不会改变。
1 | cout<<'['<<setfill('*')<<left<<setw(20)<<"hello world"<<']'<<endl; |
boolalpaha
转换cout对于布尔值的整数输出为布尔输出,noboolalpha
是逆过程
hex(base 16)
,oct(base 8)
,decimal(base 10)
,输出进制转换
1 | cout<<dec<<10<<endl;//10,转化的过程,x的储存数字方式也变化了 |
流的错误读取
由于流的操作经常将一个数据转化为另一种数据,所以很容易出现错误,比如当一个整数从一个输入流中读取一个浮点数时,便会出现错误,出现错误后,后续的输入不会再进行,程序也不会报错。当我们读取文件的时候,每一次读取之后我们都对文件输出流进行一次检查,当文件输出流状态为fail的时候,要么是文件出现了格式错误,要么是文件读取完成,没有更多的数据可以读取。
.fail()
可以检查流是否处于错误状态。
1 | ifstream myStream("example.txt"); |
上述代码的while语句也可以简单的写为
1 | while(myStream>>myInteger>>myDouble){ |
可以这样写的原因是,每一个流的操作,如果成功的话都会返回一个非0的值,而如果失败了就会返回0。
getline
我们可以用getline直接完成对每一行的读取而不用担心出现输入错误
1 | getline(cin,s);//getline(<istream>,<string>) |
getline的输入流既可以是标准输入流cin,也可以是文件输入流。同样的getline读取成功后会返回非0值,可以将这一点用于在getline读取文件时,在while循环进行判断。
stringstream
<sstream>
。和标准输入输出流,文件流一样,字符串流也是一种流对象,字符串流暂时储存字符串信息。
通过.str()
方法,我们可以通过字符串流得到字符串。字符串流既可以通过流操作符输入,也可以通过流操作符输出。
1 | int getInteget(){ |
上述函数实现了一个整数的安全输入。
多文件程序
C++编译模型
C++是一门编译型语言,C++执行前要经过编译器转换为机器码。
编译的步骤可以大致分为以下几步:
- 预处理,代码段被切分和插入
- 编译,将代码转换为目标代码(.o文件)
- 链接,编译后的代码被连接成整体,成为可执行代码
一个简单的模块
头文件:声明引用的各种文件和类
实现文件:函数和类的具体实现部分
#define
define 将第一个空格视为语句和替换部分的分割。
const
定义常数更好
1 | const int myInteger = 200-100; |
条件编译
#if
预处理指令只能操作已定义的常数和逻辑运算符,defined
可以用来判断某一个名称是否被定义。
1 |
|
#ifdef
(short for if defined),#ifndef
(short for if not defined)
1 |
|
这样有条件编译包含的头文件可以避免预处理时二次定义。
macros(宏)
宏定义的格式如下
define macroname(parameter1,parameter2,…,parameterN) macro-body
宏定义没有返回值,而是将参数传入宏体后,用宏体代码段替换掉程序内部引用的宏名。
宏定义由于是直接插入代码段,形成行内函数,效率高于函数调用。
特殊的预处理器值
__DATE__
和__TIME__
,代表程序编译的日期和时间
字符串操作函数
#
,字符串化操作符
1 |
##
,连接字符串操作符
1 |
X Macro Trick
1 |
可以利用X Macro Trick来简写多行高度相似的代码段(每一行不相同的代码段就作为宏的参数)。
STL
stl(short for standard template library),标准模板库。
stl由六个部分组成,每一个部分都和其他部分相互依赖:
- Containers
- Iterators
- Algorithms
- Adapters
- Functors
- Allocators
vector
在使用vector之前,必须包含<vector>
文件,对其他标准库的模板的使用也要包含对应的文件
vector是一个可以装任意数据类型(可以是自己声明的数据类型,比如结构体类型)的容器,甚至可以是一个装vector的容器,这样就形成了一个二维容器。
在创建vector之前要声明vector所包含的数据类型
1 | vector<int> myVector; |
<>
内包含的是模板参数(template argument)。
push_back()
:添加一个新的元素在vector的尾部。对应的pop_back()
删除尾元素,pop_back
函数类型是void。
.size()
得到vector储存的元素的数量。
vector作为参数传入一个函数时也要声明这个vector的包含的数据的类型是什么。
size_t
类型得到的是一个类似于int型的数据,但有所不同的是size_t
不允许出现负数,专门用于表示某些数据的size。
.insert()
:在v中position为n的地方插入值为e的元素,对应的erase()
去除某一个位置上的元素
1 | v.insert(v.begin()+n,e); |
创建一定初始大小的vector的时候,如果是int或double类型,那么元素初始化默认为0,如果类型是c++字符串,那么元素初始化为空字符串。
1 | vector<int> myIntVector(10); |
初始化某个初始值的vector
1 | vector<double> myDoubleVector(10,137.0); |
.reseize()
:对已有的vector的大小进行变化
1 | myVector.resize(new_size,new_element); |
将vector的大小调整成new_size,如果new_size比原来的size小,那么就裁剪成new_size的大小,如果大,那么多的元素用new_element填充,new_element默认为0。
.clear()
:清空vector
deque
1 |
deque is short for double-ended queue.
所有vector支持的操作,deque都支持。deque多支持两种操作push_front()
和pop_front()
.deque和vector的不同之处在于vector往往是连续储存的内存空间,而deque的内存空间往往是不连续的,这也使得deque的效率比vector一般低一些。deque的在队列头插入速度很快,而vector很慢。如果有很多插入和删除操作,那么我们一般采用deque,其他默认的情况下我们一般用vector。
set
1 |
|
当我们考虑一些元素的关系,而这些元素的次序不那么重要时,我们就会使用set
容器。
set的内部实现是通过平衡二叉树。
set
代表的是任意的无序集合,并且不允许元素重复,同一个集合内的元素类型要一样,储存的元素一定是可比较的(自己定义的结构体在没有经过操作符重载的前提下是不能添加到set内的)
具有以下操作:
- 向其中添加元素
- 移除其中的某一个元素
- 判断某一个元素是否在其中
.count()
:检查一个元素是否在集合中,如果在就返回1
,不在就返回0
。处于安全起见,我们将其返回值视为布尔值。
.insert()
:向集合中插入某一个元素,集合的插入不需要像vector那样声明插入的地方。如果插入的元素在原集合中已经包含,那么插入操作不会对集合产生改变。
.erase()
:在集合中去除特定的某一个元素。
.clear(),.size()
:与vector相同。
iterator
vector和deque这样的容器,是线性的,并且元素是按顺序排列的,我们可以直接通过简单的for循环实现遍历。对于set这样没有次序而言的容器,我们就要引入一个新的概念——iterator(迭代器)。所有的利用迭代器遍历容器的语法都是一样的。
1 | vector<int> myVector = /*some initialization*/; |
同样地,对于set的迭代器如下
1 | set<int> mySet = /*some initialization*/; |
set<int>::iterator itr = set.begin()
是对迭代器的初始化,声明迭代器的类型,迭代开始的位置。
itr != set.end()
:set.end()
指向的是一个没有元素储存的位置,当迭代器指向该位置就表示内部元素已经全部迭代过了。
++itr
:迭代器指向下一个位置。
*itr
是迭代器的解引用(dereference)。迭代器的解引用操作使得迭代器不仅能读取元素,也能修改元素的能力,
迭代器对于set
的迭代是从最小元素逐渐递增到最大元素。
迭代器的->
调用成员函数
1 | set<string> myString; |
define ranges with iterator
set
有.lower_bound()
和.upper_bound()
,这两个方法,接受一个值,返回一个迭代器,同时使用就能形成一个迭代范围
1 | set<int> mySet = /*some initialization*/; |
summary of set
pair
1 |
|
pair
是由两个域组成的容器,分别为first
和second
。这两个域的类型可以不同也可以相同。
make_pair()
:直接同时传入两个参数的方法。
1 | pair<int,string> myPair = make_pair(137,"hello world!"); |
map
map
储存的是keys
和values
这两个集合之间的关系。map中储存的数据都是以pair的形式储存的,并且是无序的。和set一样,map的底层实现也是通过平衡二叉树,所以keys
必须是可比较的类型,但values
不必须是可比较的。
map的初始化声明如下
1 | map<int,string> myMap; |
map的常见操作有以下几种:
- 插入一个新的键值对
- 检查某一个特定的键是否存在
- 查询给定键的对应值
- 移除一个已经存在的键值对
插入新的键值对
直接赋值,[]
既可以用来查询某一个键的值,也可以直接插入键值对
1 | map<string,int> myMap; |
利用insert()
插入
1 | map<string,int> myMap; |
直接赋值和insert函数的区别
直接赋值,如果前后对相同的键赋值,那么会实现对值的重写,但是insert插入一个键值对后,再插入键相同的键值对无法实现值的重写,因为map的insert操作和set类似,检查键是否已经存在,如果不存在就插入,存在就不插入,避免出现多个相同的键出现。map的insert操作会返回一个pair<iterator,bool>
,其中的bool
代表的是插入操作是否成功实现,iterator
在插入成功的情况下会返回插入后的键值对位置,在插入失败的情况下会返回已有的键值对位置。为了确保insert能重写值,我们可以这样修改。
1 | pair<map<string,int>::iterator,bool> result = myMap.insert(make_pair("STL",137)); |
find
如果对一个不存在的键进行查询,那么map会自动创建一个新的键,键的值是默认值。如果想避免在查询map的键的时候错误的创建新的键,那么可以用find()
。当找到时,find()
返回这一点的迭代器,没找到时返回map.end()
1 | map<string,int>::iterator itr = myMap.find("xyz"); |
map的迭代器解引用过程和前面说到的不太一样,对于map<KeyType,ValueType>
的迭代器来说,解引用得到的是pair<const KeyType,ValueType>
。const KeyType
表示的是键是不能改变的常值,而值是可以改变的。
erase(KeyName)
去除某一个键值对
1 | myMap.erase("Key"); |
clear(),size()
用法和其他容器一样。
map summary
multicontainers
1 | multimap<string,int> myMap; |
insert
能插入形成一个键对应多个值。
equal_range
可以返回一个pair
包含两个迭代器,这两个迭代器包含的是输入的特定键对应的所有值。
1 | pair<multimap<string,int>::iterator,mutimap<string,int>::iterator> range = myMultiMap.equal_range("STL"); |
STL Algorithmsr
求和
1 |
|
前两个参数是迭代器,确定求和范围,第三个参数是初始值。
_if
后缀的,一般都是,传入一个函数和参数范围,如果这个参数带入传入的函数内,返回的是真值,那么就执行_if
前的操作。比如count
和count_if
1 | bool IsEven(int value){ |
_n
为后缀的,一般都是将一个操作重复多次,以fill算法为例
1 | vector<int> myVec; |
迭代器种类
输出迭代器
只能进行被赋值和++操作,不呢从中读取值,不能使用+=或者-操作符
输入迭代器
只能进行赋值操作,不能改变迭代器的值
向前迭代器
只能进行向前迭代的操作(++),可以读出也可以写入,不能向后迭代(—)
双向迭代器
可以向两个方向迭代,但是不能使用+或+=
随机访问迭代器
可以进行任意操作,+和+=都是允许的
排序算法
sort
可以自己传入自定义的比较函数进行比较,前两个参数是两个迭代器,确定排序的范围,第三个参数是一个函数,如果不写,就是按照数据的默认情况进行比较,如果数据类型不能比较那就必须写入该函数,对于可以比较的数据类型,在第三个参数的位置写入函数名能重新定义比较方式。
1 | sort(v.begin(),v.end(),CompareFunction); |
打乱算法
random_shuffle
传入两个迭代器,随机打乱这两个迭代器里面的数据的顺序。在使用这个算法之前,要提前使用srand函数。和sort一样,传入的迭代器必须是随机访问的,所以set和map同样不适用这个算法。
1 | random_shuffle(v.begin(),v.end()); |
旋转
1 | rotate(v.begin(),v.begin()+2,v.end()); |
搜索算法
set和map支持find
进行查找,而vector和deque没有这个函数,但是STL算法能解决这个问题。
find
函数对容器进行查找,find函数接受两个迭代器参数和一个值,返回第一个找到的对应值的迭代器,如果没有找到,那么就返回第二个参数作为一个信号。
1 | if(find(myVector.begin(),myVector.end(),value) != myVector.end()) |
binary_search
对已经排序过的vector或deque进行查找,语法和find
一样。如果vector经过sort排序时,使用了特定的比较函数,那么这个函数应该作为参数传到二分查找函数里面。
binary_search
不会返回迭代器,只是检查某一个特定的值是否在容器内.
迭代器适配器
copy
接受三个参数,两个输入的是迭代器,定义copy的数据的范围,第三个迭代器定义输出数据要写入的位置
1 | copy(v.begin(),v.end(),new_v); |
为保证copy的容器的内存足够,我们要用到迭代器适配器。迭代器适配器和迭代器很像,也可以解引用,可以进行++运算前移,
1 |
|
ostream_iterator<int> myItr(cout," ");
声明了int
型的数据将写入流,在参数里,我们输入了一个流cout
,这就是数据要写入的流,其次我们写入了一个字符串,这个字符串在每次赋值后也被输入到流当中。
1 | copy(v.begin(),v.end(),ostream_iterator<int>(cout," ")); |
为了避免copy函数运行时出现内存不足的错误,标准库函数提供了一系列的迭代器适配器称为插入迭代器
,这些是输出迭代器,当使用insert,push_back,push_front函数对容器插入值时使用。
以back_insert_iterator
为例
1 | vector<int> v;//创建空vector |
back_insert_iterator
可以简写
1 | back_inser_iterator<vector<int> > itr(v) ==(等价) back_insert(v) |
reverse_copy
,这个函数的输入和copy一样,但是是将输入范围内的数据按倒序的方式复制到输入的destination当中。
1 | 0 1 2 3 4---(reverse_copy)--->4 3 2 1 0 |
1 | vector<int> original = /*………………*/; |
insert_iterator
可以运用在map和set上面。insert_iterator
的实际运用的例子是set_union,set_itersection,set_difference
.
set_union
有五个参数,前四个是确定两个集合的范围的迭代器,第五个是inserter(result,result.begin())
,这是一个插入迭代器,将迭代器内的元素插入到集合result
内
istream_iterator
能将数据像输入一样提供给特定的STL算法,正如其名istream_iterator
能被用来从流中读取数据,就类似于一个装载数据的容器。下面是一个例子
1 | copy(istream_iterator<int>(input),istream_iterator<int>(),inserter(result,result.begin())); |
istream_iterator<int>()
是所有istream_iterator
的终止符号。input
是一个装载数据类型为int
的ifstream
。这两个istream_iterator
定义了输入流的迭代范围。
summary
删除算法 removal algorithm
抽象和类
the wall of Abstraction
就像工具的使用者和工具的制造者一样,同样的工具在不同的两方眼中的复杂度是不一样的,在编程中,我们对于工具的使用者我们称为client
,对于实现者称为implementer
。
接口 iterface
接口是一个在设备上可执行的命令和访问的集合,也就是用户和对象交互的方式一个接口可能让用户了解到对象有哪些性质已经用户能够让对象进行怎么样的工作。在工程领域,一个接口往往是对象性质的集合以及对象能进行的操作的集合的并集。
一个对象的接口就是对这个对象的抽象概念的具体描述。
封装 encapsulation
反对breaking the wall of abstraction,一方面保护了client和implementer,另一方面使得系统更容易更改。
如果一个抽象不允许用户访问具体的实现,那么就称其为封装的-encapsulated
接下来,以一个收音机的类实现来了解类在c++里面的具体用法
类 class
类是实现和接口的配对,类的结构分为两部分,一部分是public interface
声明如何和这个类交互,令一部分是private interface
用来声明public
里面的函数的具体实现。
定义一个public interface
为了保证用户只能读取数据,不能对类的数据进行修改,我们将数据查询的方式写成函数,这样当对类进行修改时,只需要修改这些函数不需要对所有代码里查询过类的地方进行修改。
1 | class FMRadio{ |
public
关键字声明的叫做成员函数 member function,成员函数的作用域只在类当中,
类的具体实现
类是包含接口和实现的集合,在我们声明了接口之后,我们开始类的具体实现。
1 | class FMRadio{ |
首先我们在private
内部定义我们后续会用到的变量,由于有private
标记,所有试图访问private
内部的语句都是非法的,只能通过public
内声明的接口进行交互。
注意到函数实现时的声明functiontype classname::functionname()
,其中的::
称为范围解析运算符,告诉编译器,我们要实现的函数具体在哪个类里面,如果没有范围解析运算符,那么编译器就会认为这个函数是普通的函数实现,与类无关。
注意到getFrequency
函数的具体实现,在所有的成员函数内,data member都可以直接使用。
init
定义私有函数init是由于类在初始化基本类型的变量时,变量的值是随机不可控的,在需要的情况下可以设置init函数对成员变量进行初始化赋值。但是每一次都要手动调用。
class constructor
constructor
是一个特殊的成员函数,专门初始化对象。constructor
和init
很像,但是不一样的是,在每一次对象被创建的时候,constructor
都会被自动执行。
constructor
是一个和类名的名字相同的函数,比如string
的constructor
的名字就是string::string
,并且没有返回类型,不能自己手动调用。
1 | class FMRadio{ |
classes without nullary constructor
如果一个函数没有参数,那么这个函数就是零元的(nullary),如果你创建了一个类,但是没有给这个类提供构造器,那么c++会自动给你提供一个默认的零元构造器(nullary constructor),但是实际上这个构造器什么也不会做。我们可以对零元构造器进行改写,使其进行一定的初始化,也可以改写为含参数的构造器。如果改写为了含参数的构造器,那么初始就必须写入参数,不能进行默认的随机初始化。
一个类里面能定义多个构造器,每一个构造器的名字都是一样的,不一样的地方在于要求传入的参数不一样,每一个构造器的声明只要有对应的构造器实现,那么类就能对应地进行初始化
1 | class FMRadio{ |
private member function
我们已经知道private
关键字包含的内容是封装好的,用户无法接触的内容,我们不仅能建立私有的成员数据,我们也能建立私有的成员函数,和其他公有的成员函数一样,私有成员函数可以读取类里的数据。但是私有成员函数只能在类的实现里被调用,私有成员函数不是类的接口,仅仅是为了简化类的实现。
声明私有成员函数只需要将函数声明写在private
关键字内就可以了。
1 | class FMRadio{ |
simplifying constructor with private functions
在上面提到的多构造器的类的实现里,私有成员函数能简化这一过程。
1 | class FMRadio{ |
上面是一个FMRadio类的多构造器利用私有成员函数简化的方法。
partitioning classes acrosss files
除了将具体实现和接口进行封装外,我们往往还会将类分为头文件和实现文件进行封装。
一个头文件包含的是类的声明,实现文件包含的是具体的类函数实现,通常来说,实现文件一般和类名的名字相同。
Refining Abstraction
parameterizing class with template
使用模板参数化类
对于初学者来说,最重要的一课是如何分解问题,如何将问题分解成很小的部分,然后分开解决。分解的核心在于,代码不能只专注于一个特殊的问题,代码应该足够健壮来适应不同的情况。以STL为例,里面的各种容器,不是只适用于一个特殊单一的类型,作者将容器参数化,使得比如像vector的代码,能被用于几乎所有预先储存的类型。
STL是极佳的健壮的,灵活的,强大的C++模板例子。C++中,模板(template)是一种可以被实例化的代码模式。
类模板 class template
C++中的类模板是像vector和map这样的类,对一定数量的类型进行参数化。从某种意义上来说,类模板就像一个带有一个洞的类,每一次用户使用这个模板,就会补上这个洞,并产生一个完整的类型,比如说不能创建vector或map类,但是能创建vector\
类模板一般用于表示某种特定的数据结构的类型,比如说vector模板是一种动态分配数组的线性结构实现。
通常来说,大多数的类都不必写成类模板。
定义类模板 Defining Class Template
如果我们要写的类能被任意的类型参数化(也就是类的具体实现和数据类型关系不大),那么我们就可以定义类模板,并声明那些类是参数化的。示例如下
1 | template <typename FirstType,typename SecondType>struct MyPair{ |
template <typename FirstType,typename SecondType>
定义了一个模板类型,其中有两个参数,类型名为别为FirstType
和SecondType
。类型名具体是什么并不重要。
上述的typename
也可以换为class
,但是这里的class
并不代表定义的类型是一个类,反过来是非法的,c++中常规的类声明不能用typename替换。
上面这个例子定义的是一个模板结构,下面我们定义一个模板类
1 | template <typename FirstType,typename SecondType> class Mypair{ |
定义了模板类型后,我们就可以像正常类型那样使用模板类型,但是类模板的函数实现和普通的类的函数实现不太一样
1 | template <typename FirstType,typename SecondType> |
我们先是声明了这是一个使用了哪些模板类型的模板函数,然后我们在正常的类函数声明中加入了类的模板类型。
如果在模板类的public
关键字内写函数实现的话就不用写模板声明template <typename FirstType,typename SecondType>
。
注意
当写一个模板类的时候,如果将类的声明和实现分别放在头文件和cpp文件内,将会造成链接错误,这是因为,当实例化一个模板类的时候,c++会将所有引用到模板类型的地方进行替换,如果模板类的声明和实现是放在两个文件内的,那么当实例化时,头文件内的模板声明的模板类型被换成了实例类型,但是实现文件内并没有替换,这就会造成编译器找不到对应函数声明的实现,造成链接时错误。
当声明的类型是嵌套在一个模板类型当中的时候,我们必须在这个新的类型前面加上typename
.
1 | template <typename T> class Stack{ |
template <typename T>
表示的是要实现的函数是一个模板类的函数,typename deque<T>::iterator
是这个函数的返回值类型,Stack<T>::begin()
是成员函数的名称。
用const声明接口
const variable
以集合的迭代器为例,如果我们想访问集合当中属于区间[a,b]的元素,我们利用for循环进行遍历,对于上界的选择,我们有三种方法,第一种是在循环内计算itr != mySet.upper_bound(b)
,第二种是先定义set<type>::iterator stop = mySet.upper_bound(b)
,然后在for循环中比较stop,第三种就是利用const
关键字对第二种方法进行优化,对stop进行const修饰,避免stop在程序中出现被错误修改的情况。
1 | const set<int>::iterator stop = mySet.upper_bound(b); |
如此声明得到的变量成为const local variable,作用域就和普通声明得到的变量是一样的。
const object
以字符串常量为例,当我们进行如下声明时
1 | const string myString = "This is a constant string" |
我们得到了一个字符串常量,当我们调用这个字符串常量对象的成员函数时,我们就要思考一个问题,我们调用的成员函数会不会对字符串进行修改,如果会,那么我们就不能调用这个成员函数,反之,我们就可以调用,那么我们该如何区分成员函数是否会对对象进行修改呢,示例如下
1 | class string{ |
常量对象只能调用经过const修饰的函数,const修饰表明该函数不会对对象的值进行修改。为了保证不会修改对象的值,const修饰的函数只能调用同样被const修饰的函数。
const reference
在前面的学习中我们了解到引用传参在传递vector或map类型的大型变量时能提高效率,但是我们很难知道函数对传入的参数做了什么操作,不知道参数是否被修改过。我们可以通过常值(const reference)引用来解决这个问题。
1 | void PrintVector(const vector<int>& vec); |
上面这个例子中,传入的vector是通过常值引用传入,保证了函数无法改变vector内部的值。虽然常值引用时传入的vector是常值,但是这并不会影响原来的vector变量。
注意
函数声明是常值引用的函数可以传入非常值的变量,函数声明是非常值变量的函数不能传入常值变量。
const and pointer
c++中要区分两种相似的说法:指向常量(pointer-to-const)的指针和常数(const-pointer)指针。前者是指向一个常量的指针,指针指向的内容是一个不可变的常量,但是指针所指的内容是可变的。
1 | const type* myPointer//指针所指向的内容是常量 |
而后者,常量指针是一个不能修改指针变量所指向的位置的指针,但是指向的位置上的内容是可以修改的。
1 | type* const myConstPointer;//常量指针 |
两者是可以同时实用的,以下面这个表示c语言的常量字符串为例
1 | const char* const kGlobalString = "This is a string"; |
const_iterator
假设我们现在有一个print函数,这个函数使用了常量引用vector\
我们希望迭代器能不改变迭代内容,同时自身能进行迭代,const_iterator
能实现这种要求,所有定义了iterator
的容器都定义了const_iterator
。
我们注意到一个问题,当我们对迭代器进行普通声明和const声明后,都能从begin()函数返回一个正确的迭代器值,那么库函数是如何做到这样的效果的呢,实际上,库函数的begin()的实现时使用了const重载,同样的begin()函数通过普通迭代器声明写了一次,又通过const 声明再写了一次。
limitness of const
有一些操作可以骗过const,达到修改const修饰的常量的效果,像下面这段代码。
1 | class Vector{ |
这个函数经过const修饰,但是却能正确运行修改对象的值,这是因为对象的数据成员是一个指针,这个函数修改的是指针指向的内容,而不是指针本身,所以可以得到编译运行。在c++中,编译器对常量的判定是基于比特位上的,如果变量的比特位没有变化,那么就是常量,但是像刚刚这个例子,我们要考虑的常量是语义上的常量(semantically const)。
再比如下面这个例子
1 | int* Vector::rawElems() const{ |
在这个例子当中,我们用指针间接地对数据内容进行了修改,破坏了常值引用保证的引用变量不会被改变的规则,为了避免这种情况的发生,我们在任何返回常值指针的函数都应该加上const声明,使得返回的常值指针指向的也是常值,常值函数必须返回指向常值的指针
1 | const int* Vector::rawElems() const{ |
mutable
经过mutable
修饰的类的成员变量就可以在const函数内被修改。以下面这一个列表为例
1 | class groceryList{ |
一旦数据成员被mutable修饰,那么所有函数都可以修改这个数据成员,最重要的是,不要随便用mutable去解决const修饰的函数的一些更改数据的问题。
什么是 const-correctness
笼统地说,const-correctness指的是代码明确了哪些变量和函数是不能改变程序的状态的,更具体地来说,const-correctness要求const的使用是普遍的,const-correct的代码一般会这样使用const:
- 永远不对对象进行值传参,任何一个值传参的对象都应该被const引用传参代替
- 不改变程序状态的成员函数要标注const,不标注const的成员函数一定会改变程序状态
- 声明了但是不会修改的变量要标记为const
object are never passed by value
c++中又三种参数传递的方法,值传参,引用传参,指针传参。值传参要求c++先做一份被传入的参数的拷贝,而后两者都是通过指向参数的一个指针来进行初始化。如果不涉及到对传入的数据进行更改,引用传参和指针传参都最好进行const修饰。值传参的好处在于,传入的参数是一个新的可以随意修改和操作的对象,而后两者只能进行数据读取。
member fubctions which do not change state are const
确保const的修饰能为函数的分类和后续对程序的阅读产生极大的帮助,同时在开发过程中也会帮助开发者对函数的使用更加规范。
variables which are set but never changed are const
对不会进行修改的变量进行const修饰是很重要的,这能完全保证不会出现错误的修改操作,减少了debug浪费的时间。
使用成员初始化列表优化构造
Optimizing Construction with Member Initializer Lists
通常来说,当你创建一个类的时候,构造器就会初始化成员数据,但是有的时候,我们可能需要先初始化实例变量然后再执行构造函数。比如给一个常量对象分配值,或者想创建一个实例,但是不使用默认的构造器。这一部分将介绍member initializer list
。
c++是如何创建对象的
第一步:为对象分配内存空间,此时各个变量储存的都是随机值(junk)
第二步:每一个实例变量调用默认的构造器进行初始化,原始类型的变量保持随机值不变
第三步:调用对象的构造器,通过构造器对每一个变量进行再一次的初始化
在上面这个过程中,可以看到实例变量在第二步和第三步一共进行了两次初始化,为简化这一过程,
c++有一个叫做初始化列表(initializer list)的特性。一个初始化列表是在初始化实例变量时c++会使用的一系列的值替代初始的默认值。
为了使用初始化列表,我们只需要在构造器后面加冒号说明哪些变量初始化为哪些值就可以了
1 | class SimpleClass{ |
初始化列表会在构造器被调用之前先实现变量的初始化。有了初始化列表,上述的第二步就变成了调用初始化列表内的值进行初始化,在这个例子中由于我们在构造器内部没有写入初始化的值,所以第三步调用构造器内部不会产生具体的影响。
初始化列表里的参数
Parameters in Initializer Lists
在上面这个例子里,我们在初始化列表内放入的都是常数,实际上,初始化列表的参数也可以是表达式。
1 | class RationalNumber{ |
numerator(numerator)
这是以传入的参数numerator为值初始化numerator变量,内部的numerator只是取决于参数如何取名字,而外部的numerator取决于对象内部成员变量的名字。
其中public的函数声明使用到了默认参数值,如果一个参数使用了默认参数,那么它后面的所有参数都要使用默认参数,同时在函数声明里写到了默认参数,函数定义里面就不能再写默认参数了。如果没有函数声明的话,才把默认参数写到函数定义里面去。
When Initializer Lists are Mandatory
当初始化的值含有常数的时候,我们必须使用初始化列表,因为构造器也不能做到改变常数。
1 | class Counter{ |
对于一个常量的数据成员,如果不使用初始化列表的话,编译器会报错。
另一种情况是当对象没有合法的或有意义的默认构造器时,具体地可以以一个set类的实现时,传入一个比较函数,从而能对set内部数据进行比较这一个例子看出。
1 | class SetWrapperClass{ |
上述定义的集合是一个有序集合,必须有比较函数才能初始化,而customeT是我们自己定义的数据类型,不能被直接放入集合,需要MyCallback
这个比较函数函数在初始化列表中传入我们初始化的集合对象。
多构造器
如果你写的类有多个构造器,那么每一个构造器都要写相应的初始化列表,当一个构造器被调用的时候,其他的构造器的初始化列表是不会被调用的。
Sharing Information With STATIC
假设有多个类为window
的对象,每一个对象有一个方法为drawWindow
,作用是将该对象代表的window
显示在屏幕上,同时我们有一个成员对象为palette
,具有图像表示的基本操作,我们如何做到多个window
对象共同使用同一个paletter
呢。这引入了一个新的c++语言特性静态数据成员(static data members)
Static Data Members
静态数据成员是整个类而不是这个类的某一个实例的数据成员。整体情况下,静态数据成员和常规的数据成员表现差不多,但是静态数据成员的特殊之处在于他的唯一性,如果一个类的实例对静态数据成员进行了修改,那么这个修改会影响到所有的实例。
静态数据成员的声明和实现如下
1 | class window{ |
和函数一样静态数据成员也要同时进行声明和定义。我们在定义静态数据成员的时候一定要写出类的所属关系,同时不会再重复写static标识。只有在对类进行定义的时候才能允许直接使用类的数据成员。静态数据成员的初始化在定义部分完成。
1 | //省略类myClass的声明 |
我们还可以通过静态数据成员实现对类的实例的计数
1 | class window{ |
Static Member Functions
在类的成员函数当中,有一个隐形的参数,是一个对象指针this
,这也就是为什么类函数能准确地识别是哪一个实例进行了这个函数的调用的原因。比如下面这个例子
1 | class Point{ |
所以每一个类函数都含有一个隐藏的实例指针参数,这一个隐藏的指针参数有时候会导致一些问题。比如说,如果我们给上面这个Point类定义一个比较函数,这个函数接受两个Point类的实例作为参数,如果将这个比较函数传入sort函数内,对一个基于Point类型的vector进行比较时就会出现问题,因为sort接受的比较函数只能是接受两个参数并且返回一个布尔值,但是由于类函数的隐式指针this存在,sort会报错。
如果想在一个类里面定义比较函数或谓词函数(做is_even这类的判断,返回布尔值的函数),我们可以用static
关键字创立静态成员函数。静态成员函数没有this指针的隐式存在,所以不能改变任何一个实例的属性,同时只能对自身的参数和类的静态成员数据进行操作。基于上面这个Point类的例子,我们进行静态成员函数的补写
1 | class Point{ |
调用静态成员函数时既可以指定某一个实例来进行调用,也可以直接书写\<类::函数名>这样的函数全称来调用
1 | //假设我们给window类添加了一个静态成员函数getRemainingInstances来获取window类目前剩余的实例数量 |
const and static
对于const和static两个关键字,这两个之间的关系比较微妙。const修饰的函数内部是可以修改static修饰的变量的,而static修饰的函数不能被const修饰。前者是因为改变static修饰的变量没有改变this指针内的变量值,后者是因为static修饰的函数没有this指针。
Integral Class Constants
整形类常量,是一个在同一个类内共享的,所以它是不可变(immutable)的且静态(static)的,我们有如下的声明方式
1 | class ClassConstExample{ |
对于声明整形的类常量,c++有内置的简写(只针对int和char类型存在)
1 | class ClassConstExample{ |
Implicit Conversions
为了定义隐式转换,c++用到了转换构造器,转换构造器接受一个单独的参数,并且初始化一个由参数复制而来的对象。
implicit conversion指的是从一个类型转换到另一个类型而不需要显式的类型转换。下面是一个int到double的隐式转换例子
1 | double myDouble = 137 + 2.71828;//int---->double |
c++执行隐式类型转换的时候实际上进行的过程是,先创建一个要转换到的类型的临时对象,其值为被转换的变量的值初始化,然后再用临时对象进行计算。
1 | double temp = double(myInt); |
如果定义一个有构造器的类,并且这个构造器能用一个参数调用,那么c++就能将这个构造器当作转换构造器,并有下面这几种等价情况
1 | RationalNumber myNumber = 137; |
实际上就是type variable = value
语句和type variable(value)
语句等价。
事实上,当你写的构造器只有一个参数时,c++很容易会产生像上面第一行这样的难以理解但是语义正确的句子,这样的情况对代码的可读性会产生很大的影响,我们应该尽量避免。下面这个Vector的例子更能体现这个问题。
1 | template <typename ElemType> class Vector{ |
所以可以看出,写只含有一个参数的构造器很容易被c++误以为是转换构造器,产生一些误解。
explicit
为了避免上述的问题,c++提供了explicit
关键字来指明某一个构造器不能被看作转换构造器,如果一个构造器被explicit
修饰,那么这个构造器就不能被用于隐式转换。这个时候上述的例子就会报错:没有从当前类型转换到目标类型隐式构造器的隐式转换。
1 | template <typename ElemType> class Vector{ |
如果一个类的单参数构造器不是用来当作转换构造器的时候,都应该标注exolicit
来避免隐式转换错误。
操作符重载
operator overloading
操作符重载在c++当中随处可见,善用操作符重载能使代码更简洁,可读性更高,更适合模板化。
操作符重载总体来说有两个主要目的:
- 操作符重载可以使自定义的类像基本类一样进行运算
- 操作符重载使代码和模板库代码正确地交互
A Word of Warning
操作符重载是一把双刃剑,正确地使用将带来直观,优雅的代码,但是,不正确的使用会带来很多难以察觉的bug。
The Principle of Least Astonishment: A function’s name should communicate its behavior and should be consistent with other naming conventions and idioms
当使用操作符重载的时候,一定要坚持最小惊讶原则。不要对操作符定义一些反直觉的操作,这样只会让代码质量很低。并且重载一定要完备,如果重载了”+”操作符,理应也要重载”+=”操作符。
Defining Overloaded Operators
以下面这段代码为例
1 | vector<string> myVector(kNumStrings); |
编译器实际上看到的是
1 | vector<srting> myVector(kNumStrings); |
编译器会将操作符重翻译为一个叫做operator
的特殊函数的调用,itr != myVector.end()
被翻译为operator!=(itr,myVector.end())
,++itr
被翻译为itr.operator++()
,等等。尽管这些函数名字看上去很奇怪,但实际上就是普通的函数。操作符重载只是一个syntax sugar,用不同的语法重写一个操作的方式。操作符重载的独特之处仅仅在于能调用内置的操作符,而不是显式的函数调用。
上面这个例子中,一些操作符是调用的成员函数(++和*),而另外一些是调用的自由函数(+=)。
每一个c++的内置操作符都有一个特定数目的操作数(operand),当定义一个重载操作符的函数时,要确保这个操作符的每一个操作数都有对应的参数。
以一个有理数类的加法为例子,当我们用成员函数实现时
1 | class RationalNumber { |
实际上编译器读入的内容是
1 | RationalNumber one, two; |
接受的对象是操作符左边的值,传入对象是操作符右边的值,这不是一个巧合,c++会保证这样的相对顺序。
当我们用自由函数实现时
1 | class RationalNumber { |
编译器读入的指令是
1 | RationalNumber one, two; |
同样的这里的相对顺序也是编译器会保证的,one+two翻译为(one,two)的参数顺序。
对于-
这样的多意性操作符,我们根据参数的数量不同来对操作符的具体含义进行判断。
减法
1 | //free function version |
负号
1 | //free function |
c++的符号重载不允许自己定义新的操作符像#@这类的,也不允许重载以下的符号
Lvalues and Rvalues
左值和右值
左值和右值的命名来源于在一个赋值语句里面他们能出现的位置,具体来说,左值是可以位于等号的左边的值,右值是只能在等号的右边的值。左值既可以被赋值也可以被读取,右值只能被读取,不能赋值。将右值放在赋值号的左边是非法的,而将左值放在赋值号的右边是合法的。当进行操作符重载的时候,左值和右值的区别是很重要的,因为操作符重载让我们定义了内置的操作符应用到某一个类型上的操作,我们要确保操作符正确地返回相应的左值和右值。
我们可以根据函数的返回类型来定义返回的是左值还是右值,要让一个函数返回左值,就让这个函数返回一个非常量的引用
1 | string& LValueFunction(); |
因为引用就是变量或内存位置的别名,这个函数返回一个值的引用,能起到能修改这个返回值,也能起到读取这个返回值,所以是一个左值。
而要返回右值,我们就让函数返回一个const修饰的对象值
1 | const string RValueFunction(); |
Overloading the Element Selection Operator
对选择操作符进行重载
为了写一个对自定义的元素类型的选择操作符的重载,我们要写的函数名称是operator[]
。这个操作符函数的类型可以是任意的,但是函数的参数只能为1个,对于选择操作符而言,返回值应该是左值。
1 | class string{ |
虽然operator[]返回的是左值,但是我们一般会配对地写一个const修饰的函数声明,返回const引用来应对对象被const修饰的这种情况。const operation[]的实现部分和非const版本的实现基本一样。
对于选择操作符来说,我们只能重载一层选择操作符,对于含有多层的选择操作符,我么不能重载,比如矩阵的[][]操作符。
Overloading Compound Assignment Operators
重载复合赋值操作符(+=)
复合赋值操作符是形式类似于op=
的操作符,更新一个对象的值但不重写这个对象。复合赋值操作符的重载一般都是作为一个成员函数进行声明,格式如下。
1 | MyClass& operator += (const parameterType& para) |
假设我们有一个三维向量的类
1 | class Vector3D { |
operator+=
返回值是*this
,一个对当前对象的引用,返回的是一个左值。
如果我们实现了*=
和+=
那么对应地,\=
和-=
我们可以对称地运用这两个符号得到(-=还需要额外定义负号)
Overloading Mathematical Operators
内置的数学操作符返回的是右值,所以我们重载的操作符的返回值应该被const修饰,对于加法和乘法,我们会遇到两种不一样的情况,加法两边的变量类型都是同一类型,而对于乘法来说,我们实现的数乘运算往往会导致乘法两边的变量类型的顺序很重要,所以对于加法,我么利用成员函数解决,对于乘法,我们利用两个自由函数解决。
1 | class Vector3D{ |
资源管理
Resouce Management
c++中对于初始化和赋值这两个操作分别对应的是copy构造器和赋值操作符。
copy构造器是一个单参数的构造器,参数是通过const引用传参传入的另一个同类的实例,返回的是一个新构造的实例对象。
赋值操作符是一个操作符重载函数,接受一个const修饰的实例,然后返回一个non-const的实例引用。
c++类默认的的构造器和copy构造器是调用所有数据成员的copy构造器和赋值操作符,对于大多数初级的数据类型和结构,这是没有问题的,但是对于含有指针的数据,copy只会进行表层的指针值的复制,而不是深层次的创建一个指针,然后复制指针指向的值。t
the rule of three:当一个类含有解构器,copy构造器,赋值操作符的任意一个,那么另外两个也必须含有。
tips:同属于一个类的实例可以互相访问对方的私有部分,这叫做sibling access
智能指针
auto_ptr
,超出范围后自动调用delete
引用计数
我们设计一个智能指针类叫做SmartPointers
,用reference counting来防止内存泄漏。
假设我们有一个智能指针指向一块内存区域,另一个指针也指向这块区域,但是这两个指针之间互相不知道对方的存在,那么任意一方释放了这块区域内的值,都会导致另一方指向一块未定义的区域,造成错误。解决方法是我们通过一个intermediary对指向该内存的指针进行管理。
- 当创建一个智能指针管理一块新分配的内存,先创建一个intemediary对象并且让这个intermediary对象指向内存,然后,将智能指针绑定到intermediary并且将引用数升到1。
- 创建一个新的智能指针指向同一块内存,确保新的智能指针指向原智能指针的intemediary,并且递增引用次数。
- 要移除一个智能指针,递减引用次数,如果引用次数到了0,那么销毁这块内存区域。
错误处理
exception handling
,c++的一个特性,紧急情况下实现程序控制流的重定向。
exception handling 分为三部分,try
,catch
,throw
。
try
包含的代码块是运行时错误可能发生的区域
1 | try{ |
throw
接受一个单参数的异常,并终止try代码的运行。
catch
,接受throw掷出的异常,并进行错误处理。
<stdexcept>
提供了几个错误处理的类供throw使用,其中有invalid _argument
。
一般在我们用if语句进行异常判断,满足条件就进行throw,catch会接受throw掷出的异常,并进行异常处理,往往我们掷出的invalid_argument
会有一个内置的方法what
,能显示throw的异常。
由于throw会中断原来的代码的运行,这可能会导致一些很重要的部分不能被执行到(比如说释放内存空间)。
catch(...)
会捕获所有异常,在catch内部再书写一个throw
能进行重新抛出,将底层的错误向上传播。
对象内存管理和RAII
<memory>
提供了auto_ptr
类型,接受一个指向动态分配内存的指针到构造器内,这是一个模板类,它的模板参数决定了auto_ptr
指向的数据的类型
对于auto_ptr
的赋值操作,一旦进行赋值,新赋值的auto_ptr
会包含原来的指针的所有值,而原来的auto_ptr
会自动销毁。
异常和智能指针
当为一个指针分配内存,如果遇到给内存地址赋值的过程中发生异常抛出,那么这个指针很容易丢失,造成内存丢失,我们可以通过智能指针解决这个问题
1 | nodeT* GetNewCell() { |
如果发生了返回值没有被接收,那么我们可以修改成下面这样,以免内存泄漏
1 | auto_ptr<nodeT> GetNewCell() { |
断言
assert用于排查逻辑错误的发生,由<cassert>
引入。assert的条件如果没有满足,会直接终止程序,在大型程序当中,只在可以被中断的节点使用assert。
Functor
functor是一个c++的类,functor的调用语法和函数很像,和普通函数一样能产生值和接收参数,但是functor可接触的数据比普通函数多了一个类的数据成员。functor最重要的就是这一点,能任意储存数据在functor类里面,不受参数数量的限制。
在创建functor之前,我们要先对函数调用操作符()
进行重载。
用模板解决传参问题
1 | template <typename UnaryFunction> |
STL Algorithms Revisited
1 | template <typename InputIterator, typename Type, typename BinaryFn> |
Higher-Order Programming
Adaptable Functions
<functional>
只能对特定结构的函数进行修饰,这种函数叫做adaptable function
。
<functional>
包含了实现转化的一些父类。
unary_function
:
声明
1 | template <typename ParameterType, typename ReturnType> |
实例:将普通的myFunction这一个单参的functor转换为adaptable functions
1 | class MyFunction : public unary_function<string, bool> { |
binary_function
:
声明
1 | template <typename Param1Type, typename Param2Type, typename ResultType> |
实例
1 | class MyOtherFunction: public binary_function<string, int, bool> { |
ptr_fun
:可以直接通过这一个函数实现到adaptable function的转化
tips:不能用ptr_fun接收参数是常量引用(reference-to-const)的函数,
如果想实现对成员函数的转化,可以使用mem_fun
或mem_fun_red
,
Binding Parameters
在了解了如何实现adptable function的转化之后,来看看如何在实践中运用这项技能
在这一章的一开始,我们介绍了参数绑定(通过锁定一个参数的值将一个有两个参数的函数转化为一个参数的函数)。为了实现绑定参数,STL函数式编程库引入了两个函数bind1st
和bind2nd
,这两个函数接受一个为adaptable function的参数和一个绑定的值并且返回和原来的函数一样的绑定了输入的值的新的函数。
1 | bool LengthIsLessThan(string str, int threshold) { |
bind2nd(ptr_fun(LengthIsLessThan), 5)
先使用了ptr_fun
来产生一个LengthIsLessThan的adaptable function,然后用bind2nd
来锁定参数5
,返回的结果是一个新的一元函数,这个一元函数接受一个string类型的变量,并且比较这个输入的字符串的长度是否小于5。
在STL函数式编程库内,参数绑定限制在二元函数,因此不能对三元及以上的函数进行参数绑定来产生新的函数,也不能对一元函数绑定变成零元函数。对于不能用参数绑定的函数,我们就只能写这个函数的functor然后进行转化。
Negating
否定操作是构建一个新的函数,这个新的函数的返回值和输入函数的返回值相反,not1
和not2
是两个STL否定函数,否定一个一元的或二元的谓词函数,接收的和返回的函数都是adaptable function。
1 | count_if(container.begin(), container.end(), |
Operator Function
STL函数式编程库提供了大量的二元操作符的可适应函数类模板。
1 | transform(container.begin(), container.end(), container.begin(), |
1 | plus minus multiplies divides modulus negate |
下面是一个将vector里所有的元素转化为倒数的函数
1 | transform(v.begin(), v.end(), v.begin(), bind1st(divides<double>(), 1.0)); |
Unifying Functions and Functors
inheritance
当我们有一系列的对象,他们有相似的功能,但是我们不知道用户会使用哪一个对象,我们可以用继承和虚拟函数来解决。
任何的问题都可以通过添加足够多的间接层来实现
根据这个理论,我们可以知道,虽然我们无法以多态方式处理函子和函数指针,但我们可以创建一个新的类层次结构,然后以多态的方式处理该类。
下面是一个可能的基类
1 | class IntFunction { |
InFunction
引入了一个execute
函数,这个函数接受一个int
返回一个int
。这个函数被标记为纯虚拟函数,因为我们还不知道这个函数要做什么。因为我们要存储一个函数,所以对execute没有明确定义的默认行为
从这个基类继承的类,通过对execute
的改写,实现不同的功能,然后将在其他地方可以对类进行多态的处理。
我们可以通过写一个IntFunction
的子类,这个子类接受一个函数指针,然后execute
中执行这个函数指针,
这样能减少子类的书写。
1 | class ActualFunction: public IntFunction { |
这样我们就能调用所有的满足条件的函数。
如果我们有一个函子类MyFunctor
,并且我们想在其他类中使用这个函子,那么我们可以书写以下这个子类
1 | class MyFunctorFunction: public IntFunction { |
我们假定MyFunctor
有一个一元的构造器
Templates
紧接着上面的例子,我们可以发现函数和函子这两种子类,实际上只是参数类型不同,这个相似性不是巧合,所有的可调用的函数或者函子都会要求有这个结构的子类。
我们使用模板,可以实现对上面两种方式的融合
1 | template <typename UnaryFunction> class SpecificFunction: public IntFunction { |
One More Abstrsction
目前,我们已经构建了一个类层次结构,其中包含了一个基类和一个用于创建所需数量的子类的模板。如果我们能以某种方式将所有必要的机制封装到一个类当中,我们就能重复的使用以构建好的内容。
我们将window
类里负责储存函数的代码放进一个Function
类当中
1 | class Function { |
我们将Fuction
的构造器当作一个隐式转化构造器,这样我们就能实现从可调用的函数指针到Function
函子之间的转化,我们如下进行实现
1 | /* Constructor accepts an IntFunction and stores it. */ |
在Function
类的实现下,我们可以这样写代码
1 | Function myFunction = new SpecificFunction<int (*)(int)>(ClampTo100Pixels); |
接下来实现Function
的拷贝功能
1 | class Function { |
现在由于Function
类只包含一个单独的数据成员IntFunction
指针,为了实现对Function
的深层拷贝,我们只需要对IntFunction
进行深层拷贝。IntFunction
是一个抽象类,在编译时我们不能分辨函数指针指向的具体的对象的类别。我们在IntFunction
基类当中引入一个新的虚函数,返回接收对象的深层拷贝。由于此函数复制了一个现有对象,因此我们将其称为clone
。
1 | class IntFunction { |
tips
=0
代表该虚函数为纯虚函数,在子类当中必须实现。
我们在模板类Specification
中实现clone
1 | template <typename UnaryFunction> class SpecificFunction: public IntFunction { |
这里的clone
返回的是一个新的通过将接收对象的复制构造器得到的SpecificFunction
对象。实际上我们并没有显式地定义SpecificFunction
的复制构造器,这里使用的是c++的自动生成的复制函数。
下面实现Function
的复制构造器,赋值操作符,析构器
1 | Function::~Function() { |
Hiding SpecificFunction
现在Function
类能够进行深层复制了,SpecificFunction
能储存各种类型的可调用函数。但是,所有想储存在Function
类的函数都必须显式地经过SpecificFunction
包装。这会导致封装的性质被破坏,以及代码会显得很冗长。
我们可以通过重写Function
的构造器,将其写成一个模板函数,该模板函数根据传递给他的参数类型进行参数化,然后为Function
构造相应的SpecificFunction
。由于c++会自动推断模板函数的参数类型,这意味着Function
的客户端永远不需要知道他们储存的内容的类型—编译器会完成这项工作。
如果我们最终将Function
构造函数设为模板,我们还应该移动IntFunction
和SpecificFucntion
类,使他们成为Function
的内部类,因为他们特定于Function
的实现,外部环境不需要它们。
更新后的Function
1 | class Function { |
函数调用实例
1 | Function fn = ClampTo100Pixels; |
最后一步是将只能储存int到int的Function
转化为可以储存任意类型的Function
,IntFunction
被重命名为ArbitraryFunction
。
1 | template <typename ArgType, typename ReturnType> class Function { |
外部多态性
上面开发的这个Function
十分巧妙,我们可以将任何可调用的一元函数转化为Function
对象,在编写需要使用某种一元函数的代码时,我们可以让该代码使用Function
而不是特定的函数类型,这种将提供行为的特定类型抽象为表示该行为的对象
的技术叫做外部多态性(external polymorphism)。与内部多态性(我们明确定义一组包含虚拟函数的类)相反,外部多态性将一组虚拟函数嫁接到支持所需行为的任何类型上。
由于需要查找虚函数表,因此虚函数可能开销比常规函数要大。外部多态性是使用继承实现的,因此也会产生开销,但是开销比常规继承略大。参照Function
的实现,调用Function::operator()
需要执行以下操作
- 按照
Function
类中的AbitrayFunction
指针找到其虚函数表 - 调用虚函数表指示的函数,该函数对应于所指向的特定的
SpecificFunction
- 调用存储在
SpecificFunction
中的实际函数对象
这比常规虚拟函数的调用要复杂一些,并说明了与外部多态性相关的成本。也就是说,在某些情况下(比如这里的Function),成本被外部多态性的灵活性抵消。
实现<functional>
库
现在,我们已经从客户端的角度了解了 < functional > 库的工作原理,让我们讨论一下如何组合该库。自适应函数有什么特别之处?ptr_fun 如何将常规函数转换为自适应函数?bind2nd 和 not1 等函数如何工作?本次讨论将非常技术化,并将突破您对模板的了解极限,但当您完成后,您应该对模板库的组装方式有很好的把握。此外,这里使用的技术不仅适用于 < functional > 库,而且几乎肯定会在您以后的编程生涯中派上用场。
让我们首先看看可适应函数到底是什么。回想一下,可适应函数是从 unary_function 或 binary_function 继承的函子。这两个模板类都不是特别复杂;以下是 unary_function 的完整定义:
1 | template <typename ArgType, typename RetType> class unary_function { |
此类不包含任何数据成员和成员函数。相反,它导出两个 typedef - 一个将 ArgType重命名argument_type,另一个将 RetType重命名为result_type。当您创建继承自 unary_function 的可适应函数时,您的类将获得这些 typedef。例如,如果我们编写以下可适应函数:
1 | class IsPositive: public unary_function<double, bool> { |
public unary_function
argument_type 和 return_type,分别等于 double 和 bool。现在可能还不清楚这些类型有什么用处,但随着我们开始实现 < functional > 库的其他部分,它将变得更加明显。
实现 not1
在开始我们对 < functional > 库的幕后探索之前,让我们看看如何实现 not1函数。回想一下,not1 接受一元适应谓词函数作为参数,然后返回一个新的自适应函数,该函数产生与原始函数相反的值。例如,not1(IsPositive()) 将返回一个函数,该函数返回一个值是否不是正数。
实现 not1 需要两个步骤。首先,我们将创建一个模板函子类,该类通过要取反的可适应函数的类型进行参数化。该函子的构造函数将以适当类型的可适应函数作为参数,并将其存储以供以后使用。然后,我们将实现其 operator() 函数,以便它调用存储的函数并返回结果的取反
一旦我们设计了这个函子,我们就会让 not1 接受一个可适应函数,将其包装在我们的否定函子中,然后将结果对象返回给调用者。这意味着 not1 的返回值是一个可适应的一元谓词函数,它返回其参数的相反值,这正是我们想要的。
让我们首先编写模板函子类,我们将其称为 unary_negate(这是由 < functional > 库的 not1 函数生成的函子类的名称)。我们知道这个函子应该通过它否定的可适应函数的类型进行参数化,因此我们可以从编写以下内容开始:
1 | template <typename UnaryPredicate> class unary_negate { |
这里,构造函数接受一个 UnaryPredicate 类型的对象,然后将其存储在数据成员 p 中。现在,让我们实现 operator() 函数,您会记得,该函数应该接受一个参数,将其输入到存储的函数 p 中,然后返回逆结果。此函数的代码如下所示:
1 | template <typename UnaryPredicate> class unary_negate { |
我们几乎已经完成了 unary_negate 类的编写,但我们遇到了一个小问题——operator() 的参数类型是什么?这就是可适应函数的用武之地。因为 UnaryPredicate 是可适应的,所以它必须导出与其参数类型相对应的名为argument_type 的类型。因此,我们可以定义我们的 operator() 函数来接受 typename UnaryPredicate::argume nt_type 类型的参数,以保证它具有与 UnaryPredicate 类相同的参数类型。
unary_negate 的更新代码如下所示:
1 | template <typename UnaryPredicate> class unary_negate { |
这看起来有点复杂,但这正是我们正在寻找的解决方案。如果不是因为 UnaryPredicate 是一个可适应函数,我们就无法确定 operator() 成员函数的参数类型,这样的代码也是不可能的。
完成这个函子类还剩一步,那就是让函子从适当的 unary_function 继承,使其成为一个可适应的函数。由于函子的参数类型是 typename UnaryPredicate::argument_type,其返回类型是 bool,我们将从 unary_function
1 | template <typename UnaryPredicate> |
现在,我们已经编写完了用于执行否定的函子类,剩下要做的就是编写 not1。not1 比 unary_negate 简单得多,因为它只需接受一个参数并将其包装在unary_negate 函子中。如下所示:
1 | template <typename UnaryPredicate> |
C++0x
自动类型推断
Automatic Type Inference
移动语义
Move Semantics
Lambda 表达式
Lambda Expressions
可变参数模板
Variadic Templates
继承
学习 C++ 或任何其他面向对象语言时,不可能不遇到继承,继承是一种让不同类共享实现和接口设计的机制。然而,继承自首次引入 C++ 以来已经发生了巨大的变化,因此 C++ 支持几种不同的继承方案。本章介绍并激发继承,然后讨论继承如何与其他语言特性交互。