第一章 对象导言
作者从抽象的角度描述了程序设计语言的发展,汇编语言是对机器底层的抽象,过程语言是对汇编语言的抽象,这两种语言都是用来描述机器的,而必须由程序员负责建立起问题空间和解空间之间联系的桥梁。于是人们试图直接在机器上建立问题空间的模型,面向对象语言应运而生。面向对象语言用对象来模拟现实世界中的事物,于是它也就跟现实世界的事物一样,拥有了它需要的各种特性。
在这个大背景下,对象必然拥有自己的类别,于是引入了class的概念。对象要跟外界交互,它的接口便是类的函数。为了控制类的创建者和类的使用者之间的联系,提高模块的独立性,必要的封装是少不了的。
实现代码重用是编程界的重大问题。于是面向对象语言使用组合来重用类的实现,引入继承来重用类的接口。而为了重用类的客户代码,多态就必不可少了。
谈到对象,不免要谈到它的创建和销毁,以控制其生存期和存储区。C++的设计目标是效率第一,它灵活多变但比较复杂的内存管理机制便显得理所当然了。意外处理是实际项目中的大问题,标准C++在这方面做了大幅度的增强,在语言上保证错误将被处理并且可以恢复正常。
下面作者用很大的篇幅介绍了软件分析和设计的过程。现成的方法都是为了最复杂的情况设计的,我们只需采用一小部分,够用就行。总的来说,作者提倡尽快让程序运行起来,通过简要的分析,把最重要的部分和风险比较大的部分优先考虑,尽早拿出一个测试版本。当然不是直接开始编码,哪怕再简单的分析也比直接开始编码好得多。但不要过早陷入细节,分析不可能一步到位,总有些因素要到编码甚至测试阶段才能发现。分析应该做到什么程度呢,对于面向对象编程来说,就是要搞清有哪些对象,它们各有什么接口,你可能需要更多的说明信息,但绝不能再少了。整个过程大概可以分衣五个步骤:
0.制订计划。直接开做也是一种计划,但增加几个里程碑往往更能激励程序员,也多了庆祝的机会。这里用高度抽象的几句话概括整个系统即可,以后觉得不够准确可以改。
1.做什么。即需求分析和系统规范说明。这些文档通常要经过讨论,所以越精简越好。作者建议使用用例,一个用例揭示了系统的一个功能,包括它在各种情况下的反应。用例应该尽量简单,以免过早被一些细节所困扰。接下来,就该制定时间进度表了,尽可能忠实地估算时间,乘以2再加上10%,基本上就可以很好地完成任务了。
2.怎么做。作者建议使用CRC卡,用一张3乘5的卡片,记录一个类的名字、功能及其交互。卡片空间有限,以免过早陷入细节,它让你尽快对系统的全貌有一个初步的认识,也方便讨论。你也可以使用UML。对象的设计一般分为五个阶段:对象的发现、对象的组装、对象的构造、系统的扩充和对象的重用。每个阶段都可能出现新的类,所以不要奢望在这个阶段就提出所有类。对象的开发原则是:一个类只解决一个问题,系统设计的主要任务就是实现需要的类,不要强求一步到位,尽早开始编程,尽量简单。
3.创建内核。只实现让系统运行起来的必要部分和风险比较大的部分,以尽早看到结果。
4.迭代用例。一次迭代增加一个用例,逐步完善。
5.进化。尽善尽美,以备后用。
各种分析和设计方法中最突出的就是极限编程了,很多方法都受它影响,它最重要的两条是先写测试和结对编程。先写测试能强迫程序员给出完整清晰的类接口,还能在每次建立系统时自动测试。从检测的观点来看,程序设计语言的进步就是检测的进步,汇编语言只能检查到语法错误,过程设计语言还能检测一些语义上的错误,而面向对象编程语言对主义的检查更为严格。尽管如此,有些错误还是只有运行的时候才能发现,这就需要我们加入一些测试代码来保证程序的正确性。结对编程就是让一个人写代码,另一个人考虑全局,一旦编码无法进行下去,就可以交换过来,再不行还可以让大家一起讨论。
C++的成功主要得益于两点,从C到C++的转换成本较低和它的高效性。
第二章 对象的创建和使用
生成程序有两种方式:解释和编译。解释运行简单快速,不生成可执行文件。
编译器为了方便大规模程序的编写,通常分为两个阶段:编译和链接,以允许将大型程序分成多个独立的小模块单独编译,还可以引入一些现成的库。编译的过程这里只大略地分为分析和生成代码两步,中间还可以进行优化。编译器为每个文件生成目标代码,链接程序把各个模块连接起来,解释每个模块的外部引用,并链入一些系统库,最终生成可执行程序。为了检查源码语法是否正确,编译器要执行静态类型检查,但程序中使用的外部变量和函数编译器无从知晓,需要程序员在使用前声明。声明告诉编译器这个名字会在某处定义,它应该按声明的这样使用,而定义才会分配内存,定义同时也有声明的作用。链接程序只会链接含有你使用的函数或变量的模块。
int func()在C中表示可带任意参数,而在C++中表示不带参数。为了避免错误,尽量使用int func(void);
后面介绍了第一个C++程序及相关基础:IOSTREAM、String类和容器的基本使用方法。
第三章 C++中的C
本章相当于标准C的简介,我把一些有特色的概念摘录了出来。
sizeof是一个操作符,而不是函数,所以取变量大小时可不加括号。
标准C++支持and、or、not、xor等关键词。
#在预处理中可将参数当做字符串,用来打印表达式的值相当方便。
标准C根据NDEBUG宏来解释assert。
使用作者介绍的右-左-右原则来辨别复杂的函数指针非常方便。
第四章 数据抽象
本章首先介绍了一个用C语言写的函数库,找出它的缺点,然后通过把函数放到结构体内来解决名字冲突和头文件的问题,于是对象出现了。接下来,作者又讲述了头文件的正确使用方式。即:只包含声明;避免重定义;注意名字空间污染。
没有数据成员的对象的大小通常并不为0,为了避免两个对象无法分辨,通常会给它分配一块最小内存块。
作者在介绍一个语言元素前,首先详细解释了为什么要使用它。在我们的学习过程中,不但要知其然,还要知其所以然。本书之所以畅销,大半得益于此。
第五章 隐藏实现
为了让函数库更易于使用,应该明确地告诉用户什么是他需要知道的,什么是可以略过的,并且禁止用户使用不该使用的接口。为此,C++中引入了访问权限控制指示符。为了保持语言的灵活性,又引入了友员的概念。
不想让用户知道的东西,根本就不应该放在头文件中。为此,我们可以把类的定义放在实现文件中,头文件中另外创建一个类,它只包含一个对象的指针和公开的函数。这样就可以把实现完全隐藏起来,具有更好的模块独立性。
第六章 构造函数和析构函数
在C程序中,很大一部分问题都是因为忘记初始化或清理引起的。为此,C++中引入构造函数和析构函数来保证对象被正常地初始化和销毁。
第七章 函数重载和默认参数
为了更进一步解决名字冲突的问题,C++中引入了函数重载和默认参数机制。要实现函数重载,就必须用到名字修饰将函数的参数信息附加到名字上,之前的成员函数也是一样的方法。
联合也可以跟类一样拥有各种函数成员,它跟类的唯一区别是成员变量的分配位置,且因此使得它无法被继承。
第八章 常量
常数在编译时就可以得到它的值,因而可以用来声明数组维数、参加常数折叠等。常量默认使用内部链接,非不得已不分配内存,而是执行constant folding,所以应置于头文件中。复杂类型如数组编译器不方便维护其值,会引起内存分配,不能用来声明数组维数等。定义外部链接的常量时必须同时初始化,以区分定义和声明。
C语言中的常量只是不能改变其值的普通变量,因而默认使用外部链接,必然分配内存,不能用于常数折叠。
const int *u;int const *u;都表示指向常量的指针,int *const u;才表示常指针,const总是修饰最接近它的符号。
char *p = "abc";本来是非法的,但C语言中这种语句太多,因而C++不会警告。有些编译器会作特殊处理,让它能正常工作。
临时变量都有常量性,当它作为函数的参数时,只能通过传值或常量引用的方式。
类中常量必须在初始化列表中初始化,静态常量应该在定义的同时初始化。枚举常用来代替静态常量使用。
被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。
第九章 内联函数
宏主要有两个问题:1.当表达式有副作用时,有些问题是无法避免的,如min(++a, ++b);2.无法作为类的成员函数。
内联函数只是给编译器的建议,当函数过于复杂时或需要取函数地址时编译器会生成普通函数来满足需要。类中内联函数的定义在类的声明结束后才会解释。构造函数和析构函数因为要自动调用其它构造函数或析构函数,情况有些复杂,但有时也可以用内联函数。
只有需要使用#时才需要用到宏。
第十章 命名控制
很多人觉得static的作用太多,意义也各不相同。作者也把它归纳为两个方面:静态存储和静态链接。静态链接是控制名字的访问域,而静态存储是在变量超出范围后仍然保存变量的存储空间,所以大家觉得两者完全不一致。我想,如果我们反过来思考,静态变量不也是限制了访问域的全局变量吗。
静态对象在第一次进入函数时初始化,在退出程序时析构。调用exit也会执行析构函数,所以不能在它的析构函数中调用exit。调用abort退出不会执行析构函数。调用atexit可以注册在程序退出前执行的函数,这些函数会在析构函数之前执行。
未命名的名字空间可用来替代静态链接。
书上说静态变量的定义是内部链接,可以放在头文件中,害我奇怪了好久,后来发现是自己理解错了,它是说只有静态整型常量才是这样。局部类中不能包含静态变量。
因为静态成员在类的定义结束后才初始化,所以可以在类中定义一个它本身类型的静态变量,再把它的构造函数私有化可以使这个类只能存在这一个对象。
静态变量的初始化过程中若对其它文件中的静态变量有依赖关系,结果可能因它们初始化的顺序而异。这里介绍了两种解决方法:一是添加一个专门的初始化类来控制初始化只进行一次并按指定的顺序,这个方法建立在静态变量区域会被事先初始化为0的基础上;二是依据函数内的静态变量只会在第一次进入函数时初始化的事实,给静态变量加一个包装函数。
第十一章 引用和拷贝构造函数
尽量通过常引用来传递参数,这不仅可以提高效率,更能避免拷贝构造函数的问题。
当返回值太大而无法存储在寄存器中时,编译器会给函数增加一个指针参数来输出返回值。
下面是关于指向成员变量和成员函数的指针,语法看上去挺怪的。
class Data ...{
public:
int a, b, c;
int f(float) const { return 1; }
int (Data::*pFun)(float) const;
Data() {
pFun = &Data::f; //类的定义内也必须使用完全限定
(this->*pFun)(1.0);// 解引用时必须显式跟某个对象绑定
}
};
int (Data::*fp2)(float) const = &Data::f; // 这里&是必不可少的
int main() ...{
Data d, *dp = &d;
int Data::*pmInt = &Data::a;
dp->*pmInt = 47;
pmInt = &Data::b;
d.*pmInt = 48;
} /**////:~
第十二章 操作符重载
// Prefix; return incremented value
const Integer& operator++(Integer& a);
// Postfix; return the value before increment:
const Integer operator++(Integer& a, int);
赋值操作符只能使用成员函数的方式重载。重载赋值类操作符时需注意检查是否给它本身赋值。
return Integer(1);// 返回临时变量时编译器会进行优化,避免产生多余的局部变量。
->是个很特殊的操作符,它只能使用成员函数的方式重载,且必须返回一个带有解引用操作符的对象或引用或一个能能选择此操作符右操作数的指针。它是个一元操作符。常用于智能指针。
->*是个二元操作符,它必须返回一个重载了()操作符且能处理给出的参数的对象。
不允许重载的操作符:.和.*。
下表是作者推荐的操作符重载方式,非成员方式可以对两个操作数都采用隐藏转换,而成员方式只能对右操作数作转换,这可能是表中尽量采用非成员方式的原因吧。
Operator
Recommended use
All unary operators
member
= ( ) [ ] –> –>*
must be member
+= –= /= *= ^=
&= |= %= >>= <<=
member
All other binary operators
non-member
当类中成员包含指针时,可采用复制内存或引用计数的方式来解决问题。
拷贝构造函数和重载的类型转换操作符都可以用来进行自动类型转换,但前者作用于目标转换类型、可用explicit关键字阻止,后者作用于源转换类型。
第十三章 动态对象创建
当new操作符分配内存失败时,一个叫做new-handler的函数会被调用,我们可以调用set_new_handler函数来设置此函数。这个函数参数和返回值都为空。重载的new操作符不会调用此函数。
重载new操作符只能改变内存的分配,不能改变构造函数的调用过程,可以认为new和new[]是两个不同的操作符,重载一个不会影响另一个。标准C++中分配内存失败时不会返回NULL,而是throw一个bad_alloc异常,所以用new赋值的指针应该事先初始化,以免成为野指针。
定位new操作符可以拥有任意多个参数,如果重载时没有分配内存,可以显式调用析构函数来释放变量,显式调用析构函数也只应该在这种情况下使用。定位delete操作符只能在构造函数失败抛出异常时被调用。印象中C++Primer中介绍的定位new表达式只是用来在指定内存位置执行构造函数。
第十四章 继承和组合
构造函数初始化列表可以用来初始化基类、成员对象和普通成员变量。构造函数调用顺序只跟声明顺序有关。
子类中定义的函数会隐藏基类中所有同名函数。构造函数、析构函数和赋值操作符不会自动继承。在子类中public部分重新声明私有基类的成员可以公开部分接口。
第十五章 多态和虚函数
纯虚函数依然可以拥有定义,以供子类调用。
重写一个虚函数同样会隐藏基类中的同名函数,重写虚函数时不允许改变返回类型,除非改成原类型的子类指针或引用。
构造函数调用时其子类尚未构造好,所以构造函数内部调用虚函数也采用早期绑定。同理,构造函数不能为虚函数,而且考虑到它的额外工作,通常也不作为内联函数。而析构函数为了能在用基类指针或引用操作时也能正常释放,通常需要使用虚函数。还可以定义纯虚析构函数来使一个不存在其它虚函数的类成为一个抽象类。析构函数内部同样使用早期绑定。
重写重载的操作符时容易碰到两个参数都要向上转换的问题,虚函数只能确定一个参数的转换问题,这时需要用到一种叫multiple dispatching的技术,即在虚函数的实现中再调用另一个虚函数来确定另一个参数。
dynamic_cast 只能在含有虚函数的类层次结构中使用,它能安全地进行向下类型转换,当指针不是希望的类型时,它将返回NULL。但因为它会增加一些额外的开销,如果我们能通过其它信息确定对象的类型,使用static_cast效率更高。static_cast不允许超出类层次结构的转换。
第十六章 模板
模板定义通常放在头文件中,若要放到某个单独的CPP文件中,参见特定编译器的文档,这段感觉有点怪,头文件最终不是都得包含到实现文件中编译吗。
从某种意义上来说,模板就是把类型检查弱化,弱类型检查语言如Smalltalk、Python都不需要模板。
附录A 编码风格
我早就习惯了用比作者更严格的编码风格来写程序,(有兴趣的可以看看林锐博士的《高质量程序设计》,现在大部分公司的内部编码风格都是参照它来的。)对这部分没什么感觉,但有两点还是值得注意:一是头文件包含顺序应该是从特殊到一般,这点跟我们平时的使用方式恰恰相反;二是头文件中不能有任何对名字空间的污染。
附录B 编程指导
First make it work, then make it fast. 不要陷入分析的泥潭,要尽早让程序跑起来。分析过程中至少要给出类的公共接口,及它与其它类的关系。
Elegance always pays off.
分而治之。
尽量不要重写已有的C代码。一定要改的话,先把可以不改的部分分离出来。
尽量把类的创建者和使用者划分清楚。类的接口要尽量简单,其命名要能见文知意。类的功能要简单明确。
先写测试。每一步编程语言的改善都是在语言中加入更多的类型检查和意外处理。而且测试程序的编写过程中可以更一步明确类的特性和它需要的接口。
软件工程的基础:All software design problems can be simplified by introducing an extra level of conceptual indirection.
尽量避免多继承,不要使用私有继承。