Essential C++学习笔记
[TOC]
学习目的和内容简介
总觉得我对C++的知识不够牢固,所以从基础的C++开始,系统地把C++重新学习一边。
本书以四个面向来表现C++的本质:procedural(面向过程的)、generic(泛型的)、object-based(基于对象的)、object-oriented(面向对象的)。之后还有两章有关于模板编程和异常处理。
一、C++编程基础
1.1 如何撰写C++程序
- main并非是程序语言定义的关键字。但是,执行我们这个C++程序的编译系统,会假设程序中定义有main()函数。如果我们没有定义,程序将无法执行。
- class机制,赋予了我们“增加程序内之类型抽象化层次”的能力。
- 所谓命名空间(namespace)是一种将库名称封装起来的方法。通过这种方法,可以避免和应用程序发生命名冲突的问题(所谓命名冲突是指在应用程序内两个不同的实体〔entity〕具有相同名称,导致程序无法区分两者。命名冲突发生时,程序必须等到该命名冲突获得解析〔resolve〕之后,才得以继续执行)。
1.2 对象的定义和初始化
- 一般来说,将每个对象初始化,是个好主意——即使初值只用来表示该对象尚未具有真正有意义的值。
- 如果对象需要多个初值,这种方式
string name = "aaa";
就没有办法完成任务了。以标准库中的复数(complex number)类为例,它就需要两个初值,一为实部,一为虚部。于是便引入了用来处理“多值初始化”的构造函数初始化语法(constructor initialization syntax)complex<double> purei(0, 7);
- 被定义为const的对象,在获得初值之后,无法再有任何变动。如果你企图为const对象指定新值,会产生编译期错误。
1.3 撰写表达式
- 递增运算符和递减运算符都有前置(prefix)和后置(postfix)两种形式。前置形式中,原值先递增(或递减),之后才被拿来使用:
++iter
- 至于后置形式写法,对象原值会先供给表达式进行运算,然后才递增(或递减):
iter++
- 我将截至目前介绍过的运算符优先级简列于下。位置在上者的优先级高于位置在下者。同一行的各种运算符具有相同的优先级,其求值次序取决于它在该表达式中的位置(由左至右)。
逻辑运算符NOT
算术运算符(*,/,%)
算术运算符(+,-)
关系运算符(<,>,<=,>=)
关系运算符(==,!=)
逻辑运算符AND
逻辑运算符OR
赋值(assignment)运算符 - 逻辑运算符AND和OR的求值次序,是由它们在表达式中的位置决定的。AND运算符的优先级高于OR运算符。如果表达式中有多个AND运算符,它们的求值次序由左至右。如果表达式中有多个OR运算符,它们的求值次序也由左至右。但是,如果OR运算符的左侧表达式已经为真,那么OR运算符的右侧表达式将不会被求值。如果有多个NOT运算符,它们的求值次序由右至左。
1.4 条件语句和循环语句
- 当某个标签和switch的表达式值吻合时,该case标签之后的所有case标签也都会被执行,除非我们明确使用break来结束执行。
1.5 如何运用Array和Vector
array的大小必须是个常量表达式(constant expression),也就是一个不需要在运行时求值的表达式。
可以使用一个已经初始化的array来初始化一个vector
1
2
3
4
5
6const size_t size = 18;
int elem_vals[size] = {
1,2,3,4,5,6,7,8,9,
11,12,13,14,15,16,17,18,19
};
vector<int> elem_sqe(elem_vals, elem_vals+size);
1.6 指针带来弹性
- 指针为程序引入了一层间接性。我们可以操作指针(代表某特定内存地址),而不再直接操作对象。
1.7 文件的读写
- 声明outfile的同时,如果指定的文件并不存在,便会有一个文件被产生出来并打开供输出使用。如果指定的文件已经存在,这个文件会被打开用于输出,而文件中原有的数据会被丢弃。如果文件已经存在,但我们并不希望丢弃其原有内容,而是希望将新数据增加到该文件中,那么我们必须以追加模式(append mode)打开这个文件。为此,我们提供第二个参数
ios_base::app
给ofstream对象。
二、面向过程的编程风格
将函数独立出来的做法可带来三个主要好处:
- 第一,以一连串函数调用操作,取代重复编写相同的程序代码,可使程序更容易读懂。
- 第二,我们可以在不同的程序中使用这些函数。
- 第三,我们可以更容易地将工作分配给协作开发团队。
2.1 如何编写函数
- 每一个函数必须定义以下四个部分:返回类型、函数名、函数体、参数列表;
2.2 调用函数
- 当我们调用一个函数时,会在内存中建立起一块特殊区域,称为“程序堆栈(program stack)”。这块特殊区域提供了每个函数参数的储存空间。它也提供了函数所定义的每个对象的内存空间—我们将这些对象称为local object(局部对象)。一旦函数完成,这块内存就会被释放掉,或者说是从程序堆栈中被pop出
- 当我们以by reference方式将对象作为函数参数传入时,对象本身并不会复制出另一份—复制的是对象的地址。函数中对该对象进行的任何操作,都相当于是对传入的对象进行间接操作。
- 将参数声明为reference的理由之一是,希望得以直接对所传入的对象进行修改。
- 将参数声明为reference的第二个理由是,降低复制大型对象的额外负担。
- 当我们提领pointer时,一定要先确定其值并非0。至于reference,则必定会代表某个对象,所以不需要做此检查。
- 内置类型的对象,如果定义在file scope之内,必定被初始化为0。但如果它们被定义于local scope之内,那么除非程序员指定其初值,否则不会被初始化。默认情形下,由heap分配而来的对象,皆未经过初始化。
- C++没有提供任何语法让我们得以从heap分配数组的同时为其元素设定初值。
- 如果要删除数组中的所有对象,必须在数组指针和delete 表达式之间,加上一个空的下标运算符:
delete [] pia;
注意,无须检验pi是否非零, 译器会自动进行这项检查。如果因为某种原因,程序员不想使用delete表达式,由heap分配而来的对象就永远不会被释放。这称为memory leak(内存泄漏)。
2.3 提供默认参数值
- 默认值的解析(resolve)操作由最右边开始进行。如果我们为某个参数提供了默认值,那么这一参数右侧的所有参数都必须也具有默认参数值才行。
- 第二个规则是,默认值只能够指定一次,可以在函数声明处,亦可以在函数定义处,但不能够在两个地方都指定。通常,函数声明会被放在头文件,每个打算使用该函数的文件,都会包含对应的头文件。还记得吗,我们之所以包含cstdlib头文件,正是为了要包含exit()函数声明。函数的定义通常被放在程序代码文件,该文件只被编译一次,当我们想要使用该函数时,会将它链接(link)到我们的程序来。也就是说,头文件可为函数带来更高的可见性(visiblity)
2.4 使用局部静态对象
- 为了节省函数间的通信问题而将对象定义于file scope内,永远都是一种冒险。通常,file scope对象会打乱不同函数间的独立性,使它们难以理解。
2.5 声明inline函数
- 将函数声明为inline,表示要求编译器在每个函数调用点上,将函数的内容展开。面对一个inline函数,编译器可将该函数的调用操作改为以一份函数代码副本代替。
- 将函数指定为inline,只是对编译器提出的一种要求。编译器是否执行这项请求,需视编译器而定。(如果你想了解为什么inline仅仅是一种请求而没有强制性,请参考[STROUSTRUP97]的7.1.1节。)
- 一般而言,最适合声明为inline的函数:体积小,常被调用,所从事的计算并不复杂。inline函数的定义,常常被放在头文件中。由于编译器必须在它被调用的时候加以展开,所以这个时候其定义必须是有效的。
2.6 提供重载函数
- 参数列表(parameter list)不相同(可能是参数类型不同,可能是参数个数不同)的两个或多个函数,可以拥有相同的函数名称。
- 编译器无法根据函数返回类型来区分两个具有相同名称的函数。
2.7 定义并使用模板函数
- function template 将参数列表中指定的全部(或部分)参数的类型信息抽离了出来。
- function template以关键字template开场,其后紧接着以成对尖括号(< >)包围起来的一个或多个标识符。这些标识符用以表示我们希望推迟决定的数据类型。
- 关键字typename表示,elemType在display_message()函数中是一个暂时放置类型的占位符。
- function template同时也可以是重载函数
1 | template <typename elemType> |
2.8 函数指针
- 所谓函数指针(pointer to function),其形式相当复杂。它必须指明其所指函数的返回类型及参数列表
- 为了让seq_ptr被视为一个指针,我们必须以小括号改变运算优先级:
const vector<int>* (*seq_ptr)(int);
2.9 设定头文件
- 函数的定义只能有一份。不过倒是可以有许多份声明。我们不把函数的定义放入头文件,因为同一个程序的多个代码文件可能都会包含这个头文件。“只定义一份”的规则有个例外:inline函数的定义。为了能够扩展inline函数的内容,在每个调用点上,编译器都得取得其定义。这意味着我们必须将inline 函数的定义放在头文件中,而不是把它放在各个不同的程序代码文件中。
- 在file scope内定义的对象,如果可能被多个文件访问,就应该被声明于头文件中。const object的定义只要一出文件之外便不可见。这意味着我们可以在多个程序代码文件中加以定义,不会导致任何错误。
- 如果此文件被认定为标准的或项目专属的头文件,我们便以尖括号将文件名括住;编译器搜索此文件时,会先在某些默认的磁盘目录中寻找。如果文件名由成对的双引号括住,此文件便被认为是一个用户提供的头文件;搜索此文件时,会由要包含此文件的文件所在的磁盘目录开始找起。
三、泛型编程风格
Standard Template Library(STL)主要由两种组件构成:一是容器(container),包括vector、list、set、map等类;另一种组件是用以操作这些容器的所谓泛型算法(generic algorithm),包括find()、sort()、replace()、merge()等。
3.1 指针的算数运算
- 当数组被传给函数,或是由函数中返回,仅有第一个元素的地址会被传递。
- 下标操作就是将 array 的起始地址加上索引值,产生出某个元素的地址,然后该地址再被提领(dereference)以返回元素值。
- 在指针算术运算中,会把“指针所指的类型”的大小考虑进去。
3.2 泛型指针
- 每个标准容器都提供有一个名为begin()的操作函数,可返回一个iterator,指向第一个元素。另一个名为end()的操作函数会返回一个iterator,指向最后一个元素的下一位置。
- const_iterator允许我们读取vector的元素,但不允许任何写入操作。欲通过iterator取得元素值,我们可以采用一般指针的提领方式:
3.3 所有容器共通的操作
下列为所有容器类(以及string类)的共通操作:
- equality(==)和inequality(!=)运算符,返回true或false。
- assignment(=)运算符,将某个容器复制给另一个容器。
- empty()会在容器无任何元素时返回true,否则返回false。
- size()返回容器内目前持有的元素个数。●clear()删除所有元素。
每个容器都提供了 begin()和 end()两个函数,分别返回指向容器的第一个元素和最后一个元素的下一位置的iterator:
- begin()返回一个iterator,指向容器的第一个元素。
- end()返回一个iterator,指向容器的最后一个元素的下一位置。
通常我们在容器身上进行的迭代操作都是始于begin()而终于end()。所有容器都提供insert()用以插入元素,以及erase()用以删除元素。
- insert()将单一或某个范围内的元素插入容器内。
- erase()将容器内的单一元素或某个范围内的元素删除。
3.4 顺序性容器
- vector以一块连续内存来存放元素。对vector进行随机访问(random access)有效率。vector 内的每个元素都被储存在距离起始点的固定偏移位置上。如果将元素插入任意位置,而此位置不是vector的末端,那么效率将很低,因为插入位置右端的每个元素,都必须被复制一份,依次向右移动。同样道理,删除vector内最后一个元素以外的任意元素,同样缺乏效率。
- list系以双向链接(double-linked)而非连续内存来储存内容,因此可以执行前进或后退操作。list中的每个元素都包含三个字段:value、back指针(指向前一个元素)、front指针(指向下一个元素)。在list的任意位置进行元素的插入或删除操作,都颇具效率,因为list本身只需适当设定back指针和front指针即可。但是如果要对list进行随机访问操作,则效率不好。
- 第三种顺序性容器是所谓的deque(读作deck)。deque的行为和vector颇为相似——都以连续内存储存元素。和 vector 不同的是,deque 对于最前端元素的插入和删除操作,效率更高;末端元素亦同。
- 产生特定大小的容器并且为每个元素设定初始值:
1
vector<int> vec(10, 1);
- 通过一对iterator产生容器。这对iterator用来标示一整组作为初值的元素的范围:
1
2int ia[10] = {0,1,2,3,4,5,6,7,8,9};
vector<int> vec(ia, ia+10);
3.5 泛型算法
- find()用于搜索无序(unordered)集合中是否存在某值。搜索范围由iterator [first,last)标出。如果找到目标,find()会返回一个iterator指向该值,否则返回一个iterator指向last。
- binary_search()用于有序(sorted)集合的搜索。如果搜索到目标,就返回true;否则返回false。binary_search()比find()更有效率。(因为find()属于linear search,效率较binary search差。)
- count()返回数值相符的元素数目。
- search()比对某个容器内是否存在某个子序列。例如给定序列{1,3,5,7,2,9},如果搜索子序列{5,7,2},则search()会返回一个iterator指向子序列起始处。如果子序列不存在,就返回一个iterator指向容器末尾。
- 泛型算法max_element()。将一对 iterator 传给max_element(),它会返回该范围内的最大值。以下便是修订后的is_elem():
- binary_search()要求,其作用对象必须经过排序(sorted),这一责任由程序员承担。
- copy()接受两个iterator,标示出复制范围。第三个iterator指向复制行为的目的地(也是个容器)的第一元素,后续元素会被依次填入。
3.6 如何设计一个泛型算法
- 所谓function object,是某种class的实例对象,这类class对function call运算符做了重载操作,如此一来可使function object被当成一般函数来使用。
- 标准库事先定义了一组function object,分为算术运算(arithmetic)、关系运算(relational)和逻辑运算(logical)三大类。
以下列表中的type在实际使用时会被替换为内置类型或class类型:
- 六个算术运算:plus<type>,minus<type>,negate<type>,multiplies<type>,divides<type>,modules<type>。
- 六个关系运算:less<type>,less_equal<type>,greater<type>,greater_equal<type>,equal_to<type>,not_equal_to<type>。●
- 三个逻辑运算,分别对应于&&、||、!运算符:logical_and<type>,logical_or<type>,logical_not<type>。
- 我们必须传给transform():(1) 一对iterator,标示出欲转换的元素范围,(2)一个iterator,所指元素将应用于转换操作上,(3) 一个iterator,所指位置(及其后面的空间)用来存放转换结果,(4) 一个function object,表现出我们想要应用的转换操作。以下便是将Pell数列加到Fibonacci数列的写法:
1
2
3
4transform(fib.begin(), fib.end(), // 欲转换的元素范围
pell.begin(), // 所指元素将应用于转换操作上
fib_plus_pell.begin(), // 所指位置(及其后面的空间)用来存放转换结果
plus<int>()); // 一个function object,表现出我们想要应用的转换操作 - function object adapter会对function object进行修改操作。所谓binder adapter(绑定适配器)会将function object的参数绑定至某特定值,使binary(二元)function object转化为unary(一元)function object。
- 标准库提供了两个binder adapter:bind1st会将指定值绑定至第一操作数,bind2nd则将指定值绑定至第二操作数。
- 另一种adapter是所谓的negator,它会对function object的真伪值取反。not1可对unary function object的真伪值取反,not2可对binary function object的真伪值取反。
3.7 使用Map
- map是一种关联式容器(associative container),其内的元素是成对出现的。第一个元素称为key,第二个元素称为value。map内的元素是按key的大小顺序排列的。
- map查询法是利用map的find()函数。如果key已放在其中,find()会返回一个iterator,指向key/value形成的一个pair(pair class是标准库的一员)。反之则返回end()。
- 任何一个 key 值在 map 内最多只会有一份。如果我们需要储存多份相同的 key 值,就必须使用multimap。
3.8 使用Set
- 对于任何key值,set只能储存一份。(如果要储存多份相同的key值,必须使用multiset。)
- 默认情形下,set元素皆依据其所属类型默认的less-than运算符进行排列。
- 如果要为set加入单一元素,可使用单一参数的insert();如果要为set加入某个范围的元素,可使用双参数的insert();
3.9 如何使用Iterator Inserter
- 标准库提供了三个所谓的insertion adapter,这些adapter让我们得以避免使用容器的 assignment运算符。然而这些adapter并不能用在array上。array并不支持元素插入操作。
- back_inserter()会以容器的push_back()函数取代assignment运算符。对vector来说,这是比较适合的inserter。传入back_inserter的参数,应该就是容器本身:
1
2vector<int> res_vec;
unique_copy(vec.begin(), vec.end(), back_inserter(res_vec)); - inserter()会以容器的insert()函数取代assignment运算符。inserter()接受两个参数:一个是容器,另一个是iterator,指向容器内的插入操作起点。以vector而言,我们会这么写:
1
2vector<int> res_vec;
unique_copy(vec.begin(), vec.end(), inserter(res_vec, res_vec.begin())); - front_inserter()会以容器的push_front()函数取代assignment运算符。这个inserter只适用于list和deque:
1
2list<int> res_list;
unique_copy(vec.begin(), vec.end(), front_inserter(res_list));
第四章 基于对象的编程风格
4.1 如何实现一个 Class
- 前置声明使我们得以进行类指针(class pointer)的定义,或以此class作为数据类型
- Class 定义由两部分组成:class 的声明,以及紧接在声明之后的主体。主体部分由一对大括号括住,并以分号结尾。主体内的两个关键字public和private,用来标示每个块的“member访问权限”。Public member可以在程序的任何地方被访问,private member只能在member function或是class friend内被访问。
- 所有member function都必须在class主体内进行声明。至于是否要同时进行定义,可自由决定。如果要在 class 主体内定义,这个 member function 会自动地被视为inline 函数。
- 要在class主体之外定义member function,必须使用特殊的语法,目的在于分辨该函数究竟属于哪一个class。如果希望该函数为inline,应该在最前面指定关键字inline
- non-inline member function应该在程序代码文件中定义,该文件通常和class同名,其后接着扩展名.C、.cc、.cpp或.cxx(x代表横放的+)。
4.2 什么是构造函数和析构函数
- Constructor的函数名称必须与class名称相同。语法规定,constructor不应指定返回类型,亦不用返回任何值。它可以被重载(overloaded)。
- class object定义出来后,编译器便自动根据获得的参数,挑选出应被调用的constructor。
- Constructor定义的第二种初始化语法,是所谓的member initialization list(成员初始化列表)
- Member initialization list紧接在参数列表最后的冒号后面,是个以逗号分隔的列表。其中,欲赋值给member的数值被放在member名称后面的小括号中;这使它们看起来像是在调用constructor。
- Destructor主要用来释放在constructor中或对象生命周期中分配的资源。
- Destructor的名称有严格规定:class名称再加上’~’前缀。它绝对不会有返回值,也没有任何参数。由于其参数列表是空的,所以也绝不可能被重载(overloaded)。C++编程的最难部分之一,便是了解何时需要定义destructor而何时不需要。
- 当我们设计class时,必须问问自己,在此class之上进行“成员逐一初始化”的行为模式是否适当?如果答案肯定,我们就不需要另外提供copy constructor。但如果答案是否定的,我们就必须另行定义copy constructor,并在其中编写正确的初始化操作。如果有必要为某个class编写copy constructor,那么同样有必要为它编写copy assignment operator
4.3 何谓mutable(可变)和 const(不变)
- const修饰符紧接于函数参数列表之后。凡是在class主体以外定义者,如果它是一个const member function,那就必须同时在声明与定义中指定const。
- 虽然编译器不会为每个函数进行分析,决定它究竟是 const还是non-const,但它会检查每个声明为const的member function,看看它们是否真的没有更改class object内容。
- 改变某个变量的值,从意义上来说,不能视为改变class object的状态,或者说不算是破坏了对象的常量性(constness)。关键字mutable可以让我们做出这样的声明。只要将变量标示为mutable,我们就可以宣称:对变量所做的改变并不会破坏class object的常量性。
4.4 什么是this指针?
- this指针系在member function内用来指向其调用者(一个对象),编译器自动将this指针加到每一个member function的参数列表。
- 欲以一个对象复制出另一个对象,先确定两个对象是否相同是个好习惯。这必须再次运用this指针。
4.5 静态类成员
- static(静态)data member用来表示唯一的、可共享的member。它可以在同一类的所有对象中被访问。
- static member function便可以在这种“与任何对象都无瓜葛”的情形之下被调用。注意,member function只有在“不访问任何non-static member”的条件下才能够被声明为static,声明方式是在声明之前加上关键字static
4.6 打造一个iterator class
- 不可以引入新的运算符。除了.、.*、::、?:四个运算符,其他的运算符皆可被重载。
- 运算符的操作数(operand)个数不可改变。
- 运算符的优先级(precedence)不可改变。
- 运算符函数的参数列表中,必须至少有一个参数为 class 类型。也就是说,我们无法为诸如指针之类的non-class类型,重新定义其原已存在的运算符,当然更无法为它引进新运算符。
- 提供increment(递增)运算符的前置(++trian)和后置(trian++)两个版本。前置版的参数列表是空的:后置版的参数列表原本也应该是空的,然而重载规则要求,参数列表必须独一无二。因此,C++语言想出一个变通办法,要求后置版得有一个int参数:
1
2
3
4
5inline Triangular_iterator& Triangular_iterator::operator++()
{
++_index;
return *this;
}increment(递增)或decrement(递减)运算符的前置及后置版本都可直接作用于其class object。令人生疑的是,对后置版而言,其唯一的那个int参数从何发生,又到哪里去了呢?事实的真相是,编译器会自动为后置版产生一个int参数(其值必为0)。1
2
3
4
5
6inline Triangular_iterator Triangular_iterator::operator++(int)
{
Triangular_iterator tmp = *this;
++_index;
return tmp;
} - typedef可以为某个类型设定另一个不同的名称。其通用形式为:
1
typedef existing_type new_name;
4.7 友元函数 友元类
- friend,具备了与class member function相同的访问权限,可以访问class的private member。
- 只要在某个函数的原型(prototype)前加上关键字friend,就可以将它声明为某个class的friend。这份声明可以出现在class定义的任意位置上,不受private或public的影响。如果你希望将数个重载函数都声明为某个class的friend,你必须明确地为每个函数加上关键字friend。
1
2
3
4
5class A {
friend int test_friend_func();
friend class B;
// ...
};
4.8 实现拷贝构造操作
- 拷贝构造函数是一种特殊的构造函数,它在创建对象时,是使用同一类中之前创建的对象来初始化新创建的对象。拷贝构造函数通常用于:
- 通过使用另一个同类型的对象来初始化新创建的对象。
- 复制对象把它作为参数传递给函数。
- 复制对象,并从函数返回这个对象。
- 如果在类中没有定义拷贝构造函数,编译器会自行定义一个。如果类带有指针变量,并有动态内存分配,则它必须有一个拷贝构造函数。拷贝构造函数的最常见形式如下:
1
2
3class_A (const class_A &obj) {
// 函数处理
}
4.9 实现一个function object
- function object乃是一种“提供有function call运算符”的class。如果lt是个class object,编译器便会在内部将此语句转换为:
1
lt.operator(ival); //内部转换结果
- function call运算符可接受任意个数的参数。将function call运算符应用于对象身上,便可以调用function call运算符。通常我们会把function object当作参数传给泛型算法。定义和使用如下:
1
2
3
4inline bool LessThan::operator() (int value) const {return value < val_; }
LessThan lt;
lt(vec[ix]);
4.10 重载iostream运算符
- 我们常常会希望对某个class object进行读取和写入操作。如果我们想要显示trian对象的内容,可能会希望这样写:
cout << trian << endl;
为了支持上述形式,我们必须另外提供一份重载的output运算符:1
2
3
4
5
6
7
8
9
10ostream& operator << (ostream &os, const Triangular &rhs) {
os << "( " << rhs.member() << "," << rhs.length() << ")" ;
rhs.display(rhs.length(), os);
return os;
}
istream& operator >> (istream &is, Triangular &rhs) {
is >> aaa >> bbb;
......
return is;
} - 传入函数的ostream对象又被原封不动地返回。如此一来我们便得以串接多个output运算符.
- 参数列表中的两个对象皆以传址(by reference)方式传入。其中的ostream对象并未声明为const,因为每个output操作都会更改ostream对象的内部状态。
- 至于rhs这种将被输出的对象,就会被声明为const—因为这里之所以使用传址方式,是基于效率考虑而非为了修改其对象内容。
4.11 指针,指向类的成员函数
- 如果我们忘了在两个>之间加上空白,像下面这样:
static vector<vector<int>> seq;
就无法编译成功。这是基于所谓的 maximal munch 编译规则。此规则要求,每个符号序列(symbol sequence)总是以“合法符号序列”中最长的那个解释。因为>>是个合法的运算符序列,因此如果两个>之间没有空白,这两个符号必定会被合在一起看待。同样道理,如果我们写下a+++p,在maximal munch规则之下,它必定会被解释为a++ + p
五、面向对象的编程风格
5.1 面向对象编程概念
- 面向对象编程概念的两项最主要特质是:继承(inheritance)和多态(polymorphism)。
- 继承使我们得以将一群相关的类组织起来,并让我们得以分享其间的共通数据和操作行为;继承机制定义了父子(parent/child)关系。父类(parent)定义了所有子类(children)共通的公有接口(public interface)和私有实现(private implementation)。每个子类都可以增加或覆盖(override)继承而来的东西,以实现其自身独特的行为。
- 多态让我们在这些类之上进行编程时,可以如同操控单一个体,而非相互独立的类,并赋予我们更多弹性来加入或移除任何特定类。
- 静态绑定(Static Binding)是在编译时决定函数调用的绑定方式。在编译期间,编译器根据对象的类型决定调用哪个函数。这意味着函数的具体实现是在编译时就确定了。绑定在编译时完成。通常用于非虚函数。更高的性能,因为在运行时没有额外的开销。
- 动态绑定(Dynamic Binding)是在运行时决定函数调用的绑定方式。编译器无法在编译时确定实际调用的函数,而是在程序运行时,根据对象的实际类型进行绑定。绑定在运行时完成。通常用于虚函数(virtual function)。提供了多态性,但会带来一定的运行时开销。
5.2 面向对象编程思维
- 默认情形下,member function的解析(resolution)皆在编译时静态地进行。若要令其在运行时动态进行,我们就得在它的声明前加上关键字virtual。
5.4 定义一个抽象基类
- 定义抽象类的第一个步骤就是找出所有子类共通的操作行为。
- 设计抽象基类的下一步,便是设法找出哪些操作行为与类型相关。
- 设计抽象基类的第三步,便是试着找出每个操作行为的访问层级。
- 将虚函数赋值为0,意思便是令它为一个纯虚函数:
virtual void gen_elems(int pos) = 0;
任何类如果声明有一个(或多个)纯虚函数,那么,由于其接口的不完整性(纯虚函数没有函数定义,是谓不完整),程序无法为它产生任何对象。
5.5 定义一个派生类
- 派生类的名称之后紧跟着冒号、关键字public,以及基类的名称。唯一的规则是,类进行继承声明之前,其基类的定义必须已经存在(这也就是必须先行包含含有 num_sequence类定义头文件的原因)。
- 派生类必须为从基类继承而来的每个纯虚函数提供对应的实现。
- 一般来说,继承而来的public成员和protected成员,不论在继承体系中的深度如何,都可被视为派生类自身拥有的成员。基类的public member在派生类中同样也是public,同样开放给派生类的用户使用。基类的protected member在派生类中同样也是protected,同样只能给后续的派生类使用,无法给目前这个派生类的用户使用。至于基类的private member,则完全无法让派生类使用(遑论派生类的用户)。
- 每当派生类有某个member与其基类的member同名,便会遮掩住基类的那份member。也就是说,派生类内对该名称的任何使用,都会被解析为该派生类自身的那份member,而非继承来的那份member。这种情况下,如果要在派生类内使用继承来的那份member,必须利用class scope运算符加以限定。
- 所谓设计,必须来来回回地借着程序员的经验和用户的反馈演进。
- 将所有派生类共有的实现内容剥离出来,移至基类内。
5.8 初始化、析构、复制
- 派生类的constructor,不仅必须为派生类的data member进行初始化操作,还需要为其基类的data member提供适当的值。
- 如果派生类的constructor未能明确指出调用基类的哪一个constructor,编译器便会自动调用基类的default constructor。
- 如果派生类的拷贝构造函数未能明确指出调用基类的哪一个拷贝构造函数,编译器便会自动调用基类的拷贝构造函数。
- 基类的destructor会在派生类的destructor结束之后被自动调用。
5.9 在派生类中定义一个虚函数
- 当我们定义派生类时,我们必须决定,究竟要将基类中的虚函数覆盖掉,还是原封不动地加以继承。如果我们继承了纯虚函数(pure virtual function),那么这个派生类也会被视为抽象类,也就无法为它定义任何对象。
- 如果我们决定覆盖基类所提供的虚函数,那么派生类提供的新定义,其函数原型必须完全符合基类所声明的函数原型,包括:参数列表、返回类型、常量性(const-ness)。
- “返回类型必须完全吻合”这一规则有个例外—当基类的虚函数返回某个基类形式(通常是pointer或reference)时,派生类中的同名函数便可以返回该基类所派生出来的类型。
1
2
3
4
5
6
7
8
9class Base {
public:
virtual Base *func() = 0;
};
class Derivation {
public:
Derivation *func() {return new Derivation(); }
}; - 有两种情况,虚函数机制不会出现预期行为:(1) 基类的constructor和destructor内,(2) 当我们使用的是基类的对象,而非基类对象的pointer或reference时。
- 当我们构造派生类对象时,基类的constructor会先被调用。如果在基类的construtor中调用某个虚函数,会发生什么事?调用的应该是派生类所定义的那一份吗?问题出在此刻派生类中的 data member 尚未初始化。如果此时调用派生类的那一份虚函数,它便有可能访问未经初始化的data member,这可不是一件好事。基于这个原因,在基类的 constructor中,派生类的虚函数绝对不会被调用。
- 为了能够“在单一对象中展现多种类型”,多态(polymorphism)需要一层间接性。在C++中,唯有用基类的pointer和reference才能够支持面向对象编程概念。
5.10 运行时的类型鉴定机制
- typeid 运算符,这是所谓运行时类型鉴定机制(Run-Time Type Identification,RTTI)的一部分,由程序语言支持。使用 typeid运算符之前,必须先包含头文件 typeinfo。typeid运算符会返回一个 type_info对象,其中储存着与类型相关的种种信息。每一个多态类(polymorphic class),都对应一个type_info对象,该对象的name()函数会返回一个const char*,用以表示类名。
- static_cast其实有潜在危险,因为编译器无法确认我们所进行的转换操作是否完全正确。
- dynamic_cast 也是一个 RTTI 运算符,它会进行运行时检验操作,检验 ps 所指对象是否属于Fibonacci类。如果是,转换操作便会发生,于是pf便指向该Fibonacci对象。如果不是,dynamic_cast运算符返回0。
六、以template进行编程
6.1 class template的定义
- template机制帮助我们将类定义中“与类型相关(type-dependent)”和“独立于类型之外”的两部分分离开来。
- 定义使用:
1
2BinaryTree<elemType>:: //在class定义范围之外
BinaryTree() //在class定义范围之内
6.2 class template的参数处理
- 建议将所有的template类型参数视为“class类型”来处理。这意味着我们会把它声明为一个const reference,而非以by value方式传递。减少性能开销。
- 这两个代码,如果是系统内置类型没有太大区别,但如果是class类型时初始化成员列表性能更好。constructor函数体内对_val的赋值操作可分解为两个步骤:(1) 函数体执行前,class 的 default constructor 会先作用于_val 身上;(2) 函数体内会以 copy assignment opreator将 val复制给_val。但如果我们采用第一种方法,在 constructor的 member initialization list中将_val初始化,那么只需一个步骤就能完成工作:以copy constructor将val复制给_val。
1
2
3
4
5
6
7
8
9
10
11
12
13template <typename valType>
inline BTnode<valType>::BTnode(const valType &val)
{
_val = val;
_cnt = 1;
_lchild = _rchild = 0;
}
template <typename valType>
inline BTnode<valType>::BTnode(const valType &val)
: _val(val), _cnt(1), _lchild(0), _rchild(0) {}
{
}
6.4 实现一个class template
- new表达式可分解为两个操作:(1) 向程序的空闲空间(free store)请求内存。如果分配到足够的空间,就返回一个指针,指向新对象。如果空间不足,会抛出 bad_alloc异常。(2) 如果第一步成功,并且外界指定了一个初值,这个新对象便会以最适当的方式被初始化。
6.5 使用函数模板实现output运算符
- 把output表达式定义为一个函数模板就避免了为每一个类型生成一个表达式函数。
1
2
3
4template <typename elemType>
class BinaryTree {
friend ostream& operator <<(ostream&, const BinaryTree<elemType>&);
};
6.6 常量表达式与默认参数值
模板的参数不一定是一种类型,也可以是常量表达式。好处是通过编译期间计算,减少了运行时的计算负担。同时编译器可以检测类型,可以在保证类型安全。如下所示:
1 | template<int len, int begin_pos = 1> |
七、异常处理
7.1 抛出异常
- 异常出现之后,正常程序的执行便被暂停(suspended)。异常被处理完毕之后,程序的执行便会继续(resume),从异常处理点接着执行下去。
- C++通过throw表达式产生异常:
1
2
3
4
5
6
7
8if (_index > _max_elems) {
throw iterator_overflow(_index, _max_elems);
}
if (_index > _max_elems) {
iterator_overflow ex(_index, _max_elems);
throw ex;
}
7.2 捕获异常
- 我们可以利用单条或一连串的catch子句来捕获(catch)被抛出的异常对象。catch子句由三部分组成:关键字catch、小括号内的一个类型或对象、大括号内的一组语句(用以处理异常)。
catch(int error) {
log_message(err_messages[errno]);
status = false;
}
catch(const char *str) {
log_message(str);
status = false;
}
catch(iterator_overflow &iof) {
iof.what_happend(log_file);
status = false;
}
return status;
1 | 3. 异常对象的类型会被拿来逐一地和每个catch子句比对。如果类型符合,那么该catch子句的内容便会被执行。 |
- 如果我们想要捕获任何类型的异常,可以使用一网打尽(catch-all)的方式。只需在异常声明部分指定省略号(…)即可
1
2
3catch(...) {
}
7.3 提炼异常
- catch子句应该和 try块相应而生。try块是以关键字 try作为开始,然后是大括号内的一连串程序语句。catch子句放在try块的末尾,这表示如果try块内有任何异常发生,便由接下来的catch子句加以处理。
- 如果“函数调用链”不断地被解开,一直回到了main()还是找不到合适的catch子句,会发生什么事?C++规定,每个异常都应该被处理,因此,如果在main()内还是找不到合适的处理程序,便调用标准库提供的terminate()——其默认行为是中断整个程序的执行。
- 初学者常犯的错误是,将C++异常和segmentation fault或是bus error这类硬件异常混淆在一起。面对任何一个被抛出的 C++异常,你都可以在程序某处找到一个相应的 throw 表达式。
7.4 局部资源管理
- 对对象而言,初始化操作发生于constructor内,资源的请求亦应在constructor内完成。资源的释放则应该在destructor内完成。
- 在异常处理机制终结某个函数之前,C++保证,函数中的所有局部对象的destructor都会被调用。
7.5 标准异常
- 如果new表达式无法从程序的空闲空间(free store)分配到足够的内存,它会抛出bad_alloc异常对象。
- 标准库定义了一套异常类体系(exception class hierarchy),其根部是名为exception的抽象基类。exception声明有一个what()虚函数,会返回一个const char *,用以表示被抛出异常的文字描述。bad_alloc派生自exception基类,它有自己的what()。Visual C++所提供的bad_alloc,其what()函数会产生“bad allocation”这样的信息。
catch(const exception &ex)
{
cerr <<ex.what() << endl;
}
4. ostringstream class提供“内存内的输出操作”,输出到一个string对象上。当我们需要将多笔不同类型的数据格式化为字符串时,它尤其有用。