Item32. 在将来时态下开发程序:
作为软件开发人员,我们也许知道得不够多,但我们知道万物都会变化。我们没必要知道什么将发生变化,这么变化又怎么发生,以什么时候发生,在哪里发生,但我们知道:万物总会变化。
好的软件能够适应变化。它提供新的特性,适应到新的平台,满足新的需求,处理新的输入。这种软件的灵活性、健壮性、可靠性不是来自于运气。它是那些遵照了现在的要求并关注了将来可能的开发人员设计和实现出来的。这样的优雅地各应变更的软件是那些在未来时态下开发程序的人写出来的。
新的函数将被加入到函数库中,新的重载将发生,于是要注意那些潜在的函数调用的二义性;新的类将会加入继承层次,现在的派生类将会是以后的基类;将会编制新的应用软件,函数将在新的运行环境下被调用,它们应该被写得在新平台上运行正确;程序的维护人员通常不是原来编写它们的人,因此应该被设计得易于被别人理解、维护和扩充。
一种好的做法是:用C++语言自己来表达设计上的约束条件,而不是用注释或文档。
例如,如果一个类被设计得不能被继承,不要只是在其头文件中加个注释,用C++的方法来阻止继承;Item M26显示了这个技巧。如果一个类需要其实例全部创建在堆中,不要只是对用户说了这么一句,用Item M27的方法来强迫这一点。如果拷贝构造和赋值对一个类是没有意义的,通过申明它们为私有来阻止这些操作(见Item E27)。C++提供了强大的功能、灵活度和表达力。用语言提供的这些特性来强迫程序符合设计。
避免按照需要随意更改虚函数的声明(“demand-paged”)。应该判断一个函数的含意,以及它被派生类重定义的话是否有意义。如果是有意义的,申明它为虚,即使没有人立即重定义它。如果不是的话,申明它为非虚,并且不要在以后为了便于某人而更改其声明;确保更改是对整个类的运行环境和类所表示的抽象是有意义的(见Item E36)。
处理每个类的赋值和拷贝构造函数,即使“从没人这样做过”。他们现在没有这么做并不意味着他们以后不这么做(见Item E18)。如果这些函数是难以实现的,那么申明它们为私有。这样,不会有人误调编译器提供的默认版本而做错事(这在默认赋值和拷贝构造函数上经常发生,见Item E11)。
努力提供这样的类,它们的操作和函数有自然的语法和直观的语义。和内建数据类型的行为保持一致:拿不定主意时,仿照int来做。
记住:只要是能被人做的,就有人这么做(WQ:莫菲法则)。他们会抛异常;会用自己给自己赋值;在没有赋初值前就使用对象;给对象赋了值而没有使用;会赋过大的值、过小的值或空值。一般而言,只要能编译通过,就有人会这么做。所以,要使得自己的类易于被正确使用而难以误用。要承认用户可能犯错误,所以要将你的类设计得可以防止、检测或修正这些错误(例子见Item M33和Item E46)。
努力于可移植的代码。写可移植的代码并不比不可移植的代码难太多,只有在性能极其重要时采用不可移植的结构才是可取的(见Item M16)。
将你的代码设计成:当需要变化时,影响是局部的。尽可能地封装;将实现细节申明为私有(例子见Item E20)。只要可能,使用无名的命名空间和文件内的静态对象或函数(见Item E31)。避免导致虚基类的设计,因为这种类需要每个派生类都直接初始化它--即使是那些间接派生类(见Item M4和Item E43)。避免需要RTTI的设计,它需要if...then...else型的瀑布结构(再次参见Item M31,然后看Item E39上的好方法)。每次,类的继承层次变了,每组if...then...else语句都需要更新,如果你忘掉了一个,你不会从编译器得到任何告警。
当然,现在时态的思维是必须的。未来时态的考虑只是简单地增加了一些额外约束:
* 提供完备的类(见Item E18),即使某些部分现在还没有被使用。如果有了新的需求,你不用回过头去改它们。
* 将你的接口设计得便于常见操作并防止常见错误(见Item E46)。使得类容易正确使用而不易用错。例如,阻止拷贝构造和赋值操作,如果它们对这个类没有意义的话(见Item E27)。防止部分赋值(见Item M33)。
* 如果没有限制你不能通用化你的代码,那么通用化它。例如,如果在写树的遍历算法,考虑将它通用得可以处理任何有向不循环图。
未来时态的考虑增加了你的代码的可重用性、可维护性、健壮性,已及在环境发生改变时易于修改。它必须与进行时态的约束条件进行取舍。太多的程序员们只关注于现在的需要,然而这么做牺牲的是软件的长期生存能力。要与众不同一点,做一个离经叛道者,在未来时态下开发程序。
Item33. 将非尾端类设计为抽象类:
假设你正在编写一个处理动物的软件项目。
这是它们的简化定义:
考虑下面的赋值运算:
Lizard liz1;
Lizard liz2;
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2;
这里有两个问题:第一,最后一行调用的是Animal类的赋值运算符,虽然相关对象的类型是Lizard。结果,只有liz1的Animal部分被修改。这是部分赋值。在赋值后,liz1的Animal成员有了来自于liz2的值,但其liz1的Lizard成员部分没被改变。
第二个问题是真的有程序员把代码写成这样。用指针来给对象赋值并不少见,特别是那些对C有丰富经验而转移到C++的程序员。所以,我们应该将赋值设计得更合理的。如Item M32指出的,我们的类应该容易被正确适用而不容易被用错,而上面这个类层次是容易被用错。
一个解决方法是将赋值运算申明为虚函数。如果Animal::operator=是虚函数,那句赋值语句将调用Lizard的赋值操作(应该被调用的版本)。然而,看一下申明它为虚后会发生什么:
基于C++语言新近作出的修改,被覆写的虚函数之间的返回值的类型不要求完全一致,但C++的规则强迫被覆写的虚函数之间声明完全一致相同的参数类型。这意味着Lizard类和Chicken类的赋值操作必须能够接受任意类型的Animal对象。也就是说,这意味着我们必须面对这样的事实:下面的代码是合法的:
Lizard liz;
Chicken chick;
Animal *pAnimal1 = &liz;
Animal *pAnimal2 = &chick;
...
*pAnimal1 = *pAnimal2; // assign a chicken to
// a lizard!
这是一个混合类型赋值:左边是一个Lizard,右边是一个Chicken。混合类型赋值在C++中通常不是问题,因为C++的强类型检验将判定它们为非法。然而,通过将Animal的赋值操作设为虚函数,我们打开了混合类型操作的门。
只能在运行期区分它们,因为将*pAnimal2赋给*pAnimal1有时是正确的,有时不是。
我们也可以使用dynamic_cast(见Item M2)来实现:在混合类型赋值时指出在operator=内部发生了错误,而类型相同时,我们期望按通常的方式完成赋值。下面是怎么实现Lizard的赋值操作:
这个函数只在rhs确实是Lizard类型时将它赋给*this。如果rhs不是Lizard类型,函数传递出dynamic_cast转换失败时抛的bad_cast类型的异常。(实际上,异常的类型是std::bad_cast,因为标准运行库的组成部分,包括它们抛出的异常,都位于命名空间std中。对于标准运行库的概述,见Item E49和Item M35)。
我们也可以在不增加复杂度也不花费dynamic_cast的情况下处理这种情况,只要在Lizard中增加一个通常形式的赋值操作:
实际上,给出了后面那个的operator=,也就简化了前者的实现:
现在这个函数试图将rhs转换为一个Lizard。如果转换成功,通常的赋值操作被调用;否则,一个bad_cast异常被抛出。
但是说实话,在运行期使用dynamic_cast进行类型检测,这令我很紧张。
最容易的方法是在Animal中将operator=置为private。于是,Lizard对象可以赋值给Lizard对象,Chicken对象可以赋值给Chicken对象,但部分或混合类型赋值被禁止:
不幸的是,Animal也是具体类,这个方法同时将Animal对象间的赋值判定为非法了:
Animal animal1, animal2;
...
animal1 = animal2; // error! attempt to call
// private Animal::operator=
而且,它也使得不可能正确实现Lizard和Chicken类的赋值操作,因为派生类的赋值操作函数有责任调用其基类的赋值操作函数:
后面这个问题可以通过将Animal::operator=申明为protected来解决,但“既要允许Animal对象间的赋值而同时又要阻止Lizard和Chicken对象通过Animal的指针进行部分赋值”的两难问题仍然存在。程序该怎么办?
最容易的事情是排除Animal对象间赋值的需求,其最容易的实现方法是将Animal设计为抽象类。作为抽象类,Animal不能被实例化,所以也就没有了Animal对象间赋值的需求了。当然,这导致了一个新问题,因为我们最初的设计表明Animal对象是必须的。有一个很容易的解决方法:不用将Animal设为抽象类,我们创造一个新类--叫AbstractAnimal--来包含Animal、Lizard、Chikcen的共有属性,并把它设为抽象类。然后将每个实体类从AbstractAnimal继承。修改后的继承体系是这样的:
要使得这一切工作,AbstractAnimal类必须是抽象类--它必须至少有一个纯虚函数。在极少见的情况下,你会发现需要创一个如AbstractAnimal这样的类,没有哪个成员函数可以很自然地声明为纯虚函数。此时,传统方法是将析构函数申明为纯虚函数。为了支持多态,基类总需要虚析构函数(见Item 14),将它声明为纯虚的唯一麻烦就是必须在类的定义之外实现它。
(申明一个函数为虚并不意味着它没有实现,它意味着:
* 当前类是抽象类
* 任何从此类派生的实体类必须将此函数申明为一个“普通”的虚函数(也就是说,不能带“= 0”)
)
实现纯虚函数一般不常见,但对纯虚析构函数,它不只是常见,它是必须。
面向对象设计的目的是:确认有用的抽象,并强迫它们(并且只有它们)成为一个抽象类。
我不知道怎么预知一个继承体系将来会如何使用,但我知道一件事:某个抽象在一个context被用到的可能只是凑巧,但多个context都被乃至的抽象通常是有意义的。
记住,只有在设计出的类能被将来的类从它继承而不需要它作任何修改时,你才能从抽象类中获得好处。(如果它需要被修改,你不得不重新编译所有使用继承类的代码,你将得不到任何好处。)
当你发现需要从一个具体类派生出另外一个具体类时,这并不是唯一一需要引入抽象类的地方。但是,需要通过公有继承将两个具体类联系起来,通常表示需要一个新的抽象类。
Item34. 理解如何在同一程序中混合使用C++和C:
在同一程序里混合使用C++和C之前,请确保你的C++编译器和C编译器兼容。
确认兼容后,还有四个要考虑的问题:名称改编(name mangling),静态变量初始化,内存动态分配,数据结构兼容性。
名称改编
即C++编译器给程序的每个函数换一个独一无二的名字。在C中,这个过程是不需要的,因为没有函数重载,但几乎所有C++程序都有函数重名(例如,流库就申明了几个版本的operator<<和operator>>)。重载不兼容于绝大部分链接程序,因为链接程序通常无法分辨同名的函数。名变换是对链接程序的妥协;链接程序通常坚持函数名必须独一无二。
如果只在C++范围内,名称改编不会影响你。如果你你有一个函数叫drawline而编译器将它变换为xyzzy,你总使用名字drawLine,不会注意到背后的obj文件引用的是xyzzy的。
如果drawLine位于C运行库中,那情况就不同了。你的C++源文件包含的头文件中申明为:
void drawLine(int x1, int y1, int x2, int y2);
代码体中通常也是调用drawLine。每个这样的调用都被编译器转换为调用名变换后的函数,所以写下的是
drawLine(a, b, c, d); // call to unmangled function name
obj文件中调用的是:
xyzzy(a, b, c, d); // call to mangled function mame
但如果drawLine是一个C函数,obj文件(或者是动态链接库之类的文件)中包含的编译后的drawLine函数仍然叫drawLine;没有名变换动作。当你试图将obj文件链接为程序时,将得到一个错误,因为链接程序在寻找一个叫xyzzy的函数,而没有这样的函数存在。
要解决这个问题,你需要一种方法来告诉C++编译器不要在这个函数上进行名变换。如果你调用一个名字为drawLine的C函数,它实际上就叫drawLine,你的obj文件应该包含这样的一个引用,而不是引用进行了名变换的版本。
要禁止名称改编,使用C++的extern "C"指示:
extern "C"可以对一组函数生效,只要将它们放入一对大括号中:
当用C++编译时,你应该加extern 'C',但用C编译时,不应该这样。
通过只在C++编译器下定义的宏__cplusplus,你可以将头文件组织得这样:
静态变量的初始化
对C++来说,在main执行前和执行后都可能有大量代码被执行。尤其是静态的类对象和定义在全局的、某个命名空间中的或文件体中的类对象的构造函数通常在main被执行前就被调用。这个过程称为静态初始化(参见Item E47)。这和我们对C++和C程序的通常认识相反,我们一直把main当作程序的入口。同样,通过静态初始化产生的对象也要在静态析构过程中调用其析构函数;这个过程通常发生在main结束运行之后。
为了解决main()应该首先被调用,而对象又需要在main()执行前被构造的两难问题,许多编译器在main()的最开始处插入了一个特别的函数,由它来负责静态初始化。同样地,编译器在main()结束处插入了一个函数来析构静态对象。产生的代码通常看起来象这样:
所以如果有可能,就尽量用C++来写main函数。只要将C写的main()改名为realMain(),然后用C++版本的main()调用realMain():
这么做时,最好加上注释来解释原因。
如果不能用C++写main(),那就有麻烦了,因为没有其它办法确保静态对象的构造和析构函数被调用了。一般都是由编译器来具体实现。
动态内存分配
C++部分使用new和delete(参见Item M8),C部分使用malloc(或其变形)和free。只要new分配的内存使用delete释放,malloc分配的内存用free释放,那么就没问题。用free释放new分配的内存或用delete释放malloc分配的内存,其行为没有定义。那么,唯一要记住的就是:将你的new和delete与mallco和free进行严格的隔离。
看一下这个粗糙(但很方便)的strdup函数,它并不在C和C++标准(运行库)中,却很常见:
char * strdup(const char *ps); // return a copy of the
// string pointed to by ps
要想没有内存泄漏,strdup的调用着必须释放在strdup()中分配的内存。但这内存怎么释放?用delete?用free?如果你调用的strdup来自于C函数库中,那么是后者。如果它是用C++写的,那么恐怕是前者。
所以要减少这种可移植性问题,就要尽可能避免调用那些既不在标准运行库中(参见Item E49和Item M35)也不在大多数计算机平台下有固定形式的函数。
数据结构的兼容性
C了解普通指针,所以想让你的C++和C编译器生产兼容的输出,两种语言间的函数可以安全地交换指向对象的指针和指向非成员的函数或静态成员函数的指针。自然地,struct和内建类型(如int、char等)的变量也可自由通过。
如果你在C++版本中增加了非虚函数,其内存结构没有改变,所以,只有非虚函数的结构(或类)的对象兼容于它们在C中的孪生版本(其定义只是去掉了这些成员函数的申明)。增加虚函数将打破这种兼容性,因为其对象将会使用一个不同的内存结构(参见Item M24)。从其它struct(或class)继承而来的struct,通常也改变其内存结构,所以有基类的struct也不能与C函数交互。
结论是:如果某个structure的定义既可以在C++中编译,又可以在C 中进行编译,在C++和C之间这样相互传递数据结构是安全的。在C++版本中增加非虚成员函数基本不影响兼容性,但几乎其它的改变都将影响兼容性。
总结
如果想在同一程序下混合C++与C编程,记住下面的指导原则:
①* 确保C++和C编译器产生兼容的obj文件。
②* 将在两种语言下都使用的函数申明为extern "C"。
③* 只要可能,用C++写main()。
④* 总用delete释放new分配的内存;总用free释放malloc分配的内存。
⑤* 将在两种语言间传递的东西限制在用C编译的数据结构的范围内;C++版本的struct可以包含非虚成员函数。
Item35. 让自己熟悉C++语言标准:
C++发生的主要变化:
* 增加了新的特性:RTTI、命名空间、bool,关键字mutable和explicit,对枚举的重载操作,已及在类的定义中初始化const static成员变量。
* 模板被扩展了:现在允许了成员模板,增加了强迫模板实例化的语法,模板函数允许无类型参数,模板类可以将它们自己作为模板参数。
* 异常处理被细化了:异常规格申明在编译期被进行更严格的检查,unexpected()函数现在可以抛一个bad_exception对象了。
* 内存分配函数被改良了:增加了operator new[]和operator delete[]函数,operator new/new[]在内存分配失败时将抛出一个异常,并有一个返回为0(不抛异常)的版本供选择。(见Effective C++ Item 7)
* 增加了新的类型转换形式:static_cast、dynamic_cast、const_cast,和reinterpret_cast。
* 语言规则进行了重定义:重定义一个虚函数时,其返回值不需要完全的匹配了
C++的这些变化在标准运行库的变化面前将黯然失色。
*支持标准C运行库。
* 支持string类型。
*支持本地化。
*支持I/O操作。
*支持数学运算。
*支持通用容器和运算。
在介绍STL前,必须先知道标准C++运行库的两个特性。
第一,在运行库中的几乎任何东西都是模板。第二,标准运行库将几乎所有内容都包含在命名空间std中。
STL基于三个基本概念:容器(container)、迭代器(iterator)和算法(algorithms)。
另外一点:STL是可扩充的。