ANSI/ISO C++ Professional Programmer's Handbook
5
面向对象的编程和设计
by Danny Kalev
- 简介
- 程序设计范例
- 面向对象程序设计的技巧
- 类和对象
- 设计类层次
- 私有数据成员优于保护成员
- 申明虚的基类的销毁器
- 虚成员函数
- 在派生类中扩展虚函数
- 使用派生代替类型域
- 穿过类的边界重载成员函数
- 继承还是包容
- Holds-a关系
- 空类
- 将structs作为一个公用类的简化版本使用
- 友元
- 非公有继承
- 通用根类
- 提前申明
- 局域类
- 多继承
- 总结
简介
在今天C++是广泛使用的面向对象的程序设计语言。C++的成功是使面向对象程序设计成为软件工业标准的显著因素。然而,与其他面向对象的程序设计语言不同(有些已使用了近30年了),C++不强迫使用面向对象的程序设计——它可以认为是一种基于对象的“更好的C”,或是一种泛型程序设计语言。这种前所未有的弹性使的C++适用与各种领域——实时系统、嵌入式系统、数据处理、数值计算、图形处理、人工智能或操作系统。
这章从介绍C++支持的多种程序设计风格开始。然后,集中于面向对象的编程和设计。
程序设计范例
程 序设计范例定义设计和实现软件的方法,包括用语言构件,数据结构和操作数据结构的方法的相互关系,程序结构,问题如何抽象,如何解决。一种程序设计语言提 供了语言上的手段(关键字、预处理标志、程序结构),以及扩展语言的能力——就是为了支持特定程序设计范例的标准库和程序设计环境。通常给定程序设计语言 是为了应用于特定的应用环境,例如字符串处理、数学应用程序、仿真、Web等等。但是C++不限定于特定应用环境。更恰当的说法,它支持许多有用的程序设 计范例。现在讨论C++支持的最常用的程序设计范例。
过程化的程序设计
C++是ISO C的超集。既然如此,它也能作为过程化的程序设计语言使用,虽然它有更严格的类型检查和许多改进设计和编码的扩展:引用变量、内联函数、默认参数和bool类型。过程化的程序设计基于函数和函数操作的数据分离。一般来说,函数依赖于他们操作数据的物理表示方法。这种依赖关系是维护和扩展过程化软件所面对的主要问题。
过程化程序设计容易受设计变化的影响
只要类型的定义变化(移植软件到另一个平台上,顾客需求的改变等等,都会导致这种结果),引用数据的函数就不得不更着改变。反过来,也是这样:当函数改变时,它的参数也受影响;例如,函数可能通过地址传递结构来代替值传递以取得最优的性能。考虑下面的例子:
struct Date //压缩结构中的数据包
{
char day;
char month;
short year;
};
bool isDateValid(Date d); //值传递
void getCurrentDate(Date * pdate); //改变它的参数,用地址传递
void initializeDate (Date* pdate); //改变它的参数,用地址传递
数据结构,比如Date,和用来初始化、读、测试它的一组函数在主要用C编程的软件工程中是很普通的。现在假设修改了设计,要求Date也存储当前的以秒记的时间戳。因此改变Date的定义如下:
struct Date
{
char day;
char month;
short year;
long seconds;
}; //现在比前面复杂了
所有使用了Date的函数不得不改变以适应Date的改变。另一个设计的变化又增加了一个存储微秒的域,以作为数据处理中唯一的时间戳。现在改变Date
struct Date
{
char day;
char month;
short year;
long seconds;
long millionths;
};
所有使用了Date的函数不得不再次改变。这一次,函数的接口都改变了,因为Date现在至少占用12字节。本来通过值传递Date的函数,现在改成接受Date的指针。
bool isDateValid(Date* pd); //为了效率传递指针
过程化程序设计的缺点
这个例子不是假想的。在几乎每一个软件工程里,设计改变是很平常的。预算和时间超支导致的结果是无法抗拒的;有时甚至导致工程的中止。为了避免——至少是减小——这种超支的尝试引出了新的程序设计范例。
过程化程序设计将代码重用限制在很少的形式内,函数调用或使用公用的用户定义数据结构。虽然如此,数据结构和使用函数之间有强耦合性,这大大限制了他们的可复用性。例如,计算double平方根的函数,不能适用用户定义的用struct表示的复数。一般的,过程化程序设计语言依赖于静态类检查,这有比动态类检查更好的性能——但是这也牺牲了软件的扩展
性。
过程化程序设计语言提供了一套封闭的、不能扩展的内建数据类型。用户定义类型不被支持或是“二等公民”。用户不能重新定义内建运算符来支持用户定义类型。另外,由于缺乏抽象和信息隐藏的机制,使得用户不得不暴露实现细节。考虑标准C函数atof()、atoi()和atol()(他们分别将C字符串转换成double、int和long)。他们不仅要求用户关注返回值的物理数据类型(现在在大多数机器上,int和long以同样的方式表示),而且他们禁止适用其他数据类型。
为什么过程化的程序设计仍然在使用?
尽 管有其显而易见的缺点,过程化程序设计在一些特殊的应用环境仍然是首选,比如在嵌入式和时间紧迫系统中。过程化程序设计在机器生成的代码中广泛使用,因为 此时代码的可重用性、可扩展性和维护费用没有任何意义。例如,许多SQL解释器将SQL语句翻译成过程化的C代码再编译。
在高级语言中,过程化的程序设计语言——如C、Pascal或Fortran——产生的机器代码是最有效率的。事实上,不愿意使用面向对象的开发团队常常将性能下降作为主要原因。
C+ +的演化在程序设计语言中是独特的。C++的创建者的工作可以有很多简便,可以从一个草案中设计它而不需要考虑与C的兼容性。然而兼容性是它的优点之一: 它使得组织和程序员可以不用废弃几亿行用C写的代码就能从C++获得好处。此外,C程序员在完全掌握面向对象程序设计之前就能容易的变成使用C++的生产 者。
基于对象的程序设计
过程化程序设计使得研究人员和开发者找到了更好的方法——分离实现细节和接口。面向对象的程序设计使他们可以创建象“一等公民”一样的用户定义类型。用户定义类型可以将数据和操作数据的方法捆绑的一个单独的实体class中。类也支持信息隐藏从而分离了类的实现细节和类的接口。类的用户允许访问其接口,但不能访问它的实现细节。分离实现细节——可能由于设计
变化、可移植性和效率等原因经常变化——和相对稳定的接口有实质意义。这种分离保证了设计上的变化仅限制在一个实体之内——类的实现;另一方面,类的用户不受影响。为了评定基于对象程序设计的重要,考虑一个简单的有代表性的Date
类:
class Date
{
private:
char day;
char month;
short year;
public:
bool isValid();
Date getCurrent();
void initialize();
};
基于对象的程序设计将变化限制在实现细节之内
现在假设你必需改变Date的定义以支持时间:
class Date
{
private:
char day;
char month;
short year;
long secs;
public:
bool isValid();
Date getCurrent();
void initialize ();
};
增加新的数据成员对Date的接口没有影响。Date的用户更本不知道增加了新的域;他们仍然象以前一样接受类的同样的服务。当然,Date的实现必须改变成员函数的代码来反映变化。因此,Date::initialize()必须初始化增加的域。尽管如此,变化仅仅限制在Date::initialize()的定义,因为用户不能访问Date接口之下的内容。但是在过程化程序设计中,用户可以直接访问Date数据成员。
抽象数据类型
象Date这样的类有时叫做concrete types(具体类型)或抽象数据类型(不要与抽象类混淆;参见本章稍后的“抽象对象VS抽象类”)。
这些类可能遇到与接口分离的清晰、易于维护的大量变量。C++以类的形式为数据抽象提供必要的机制,类将数据和处理数据的一整套操作捆在一起。通过private访问控制字来完成信息隐藏,private限制只有类的成员函数才能访问数据成员。
运算符重载
在基于对象的语言里,用户能扩展内建运算符的定义以支持用户自定义类型(运算符在第三章:“运算符重载”中讨论)。这种特性通过将用户定义类型当作内建类型来提供一种更高层的抽象。例如
class Date
{
private:
char day;
char month;
short year;
long secs;
public:
bool operator < (const Date& other);
bool operator == (const Date& other);
//...其他成员函数
};
基于对象程序设计的特性
从 某种意义来讲,基于对象的程序设计是面向对象程序设计的子集;也就是说,一些普遍的规则适用于两者。但是,与面向对象程序设计不使用继承。就是说,每一个 用户定义类型是一个自包含的实体,他们既不从更一般的类型派生,也不作为其他类的基类。这种范例缺乏继承不是巧合。基于对象的支持者认为:继承使设计复杂 化,并且基类的bug和不足可能会传给子类。此外,继承也意
味着多态,这是另一个使设计复杂化的源头。例如,接受基对象为参数的函数也必须知道如何处理任何一个基对象的公共派生对象。
基于对象程序设计的优点
基于对象的程序设计克服了过程化程序设计的大多数缺点。它限制了变化的影响,将接口与实现细节分开,也支持用户定义类型。标准库提供了丰富的抽象数据类型,包括string、complex和vector。设计这些类是为了为非常特定的用处提供抽象,例如,字符处理和复数运算。他们不是从更普通的类派生的,也不想派生其他类。
抽象数据类型VS抽象类
抽象对象类型和 抽象类是两种完全不同的概念,尽管因为历史原因两者都使用 abstract这个词。抽象数据类型(也叫 具体类型) 是用户定义的一种自包含类型,它将数据和相关的操作捆绑在一起。有些时候他象内建类型一样。但是,他不是可扩展的也不展示动态多态性。与之相反,抽象类可 以可以是除了抽象数据类型以外的任何东西。它不是数据类型(一般的,抽象类不包括任何数据成员),也不允许你产生它的实例。抽象类只是一种框架接口,它指 定了其他(非抽象)类实现的一套服务或操作。不幸的是,两种概念之间的区别经常被混淆。许多人在他们想表示抽象类的地方错误的使用抽象数据类型。
基于对象的程序设计的局限性
对 于特定的用途基于对象的程序设计是有利的。但是,它不能表现现实世界中对象的关系。例如,软盘和硬盘的共性就不能用基于对象的设计直接表示。硬盘和软盘都 能存储文件;他们都能包含目录和子目录等等。但是,基于对象的设计必须创建两个截然不同的独立的实体,不能共享两者的共有特性。
面向对象的程序设计
面向对象的程序设计为定义类层次提供了必要的构造,从而克服了基于对象程序设计的局限。类层次抓住了相似——不同也可以——类型之间的共有特点。例如,Mouse和Joystick是两个不同的实体,但是他们共有许多特性。这些共有特性可以用通用类PointingDevice,表示,这个类可作为两者的基类。面向对象程序设计的基础也是基于对象程序设计的基础:信息隐藏、抽象数据类型和封装。另外它还支持继承、多态和动态绑定。
面向对象程序设计的特性
面 向对象的程序设计有时与其他语言的有很大区别。例如,转到C++的Smalltalk程序员发现两种语言的差别有点令人害怕。当然,转到 Smalltalk或Eiffel的C++程序员也会有同样的想法。但是,面向对象的程序设计语言与非面向对象的语言相比有几个通用的特性。这些特性在下 面的章节中展示。
继承
继承使派生类可以重用基类的功能和接口。重用的优点是显著的:更快的开发时间、更简单的维护和更简单的可扩展性。类层次抓住存在的相关类的一般的共有的特性。更一般的操作在继承树中更高的类中实现。一般的,设计考虑的是特定应用环境。例如,书店的在线订购系统和大学语言系的计算机化图书馆的类Thesaurus和Dictionary有不同的处理方式。在书店的在线订购系统中,类Thesaurus和Dictionary可以从更普通的基类Item继承:
#include <string>
#include <list>
using namespace std;
class Review{/*...*/};
class Book
{
private:
string author;
string publisher;
string ISBN;
float list_price;
list<Review> readers_reviews;
public:
Book();
const string& getAuthor() const;
//...
};
类Dictionary和Thesaurus定义如下:
class Dictionary : public Book
{
private:
int languages; //双语,三种语言等等
//...
};
class Thesaurus: public Book
{
private:
int no_of_entries;
//...
};
然而,语言系的计算机化图书馆可能使用不同的继承层次:
class Library_item
{
private:
string Dewey_classification;
int copies;
bool in_store;
bool can_be_borrowed;
string author;
string publisher;
string ISBN;
public:
Library_item();
const string& getDewey_classification() const;
//...
};
class Dictionary : public Library_item
{
private:
int languages;
bool phonetic_transciption;
//...
};
class Thesaurus: public Library_item
{
private:
int entries;
int century; //语言的历史时代,例如莎士比亚时代
//...
};
两种层次看上去不一样,因为他们为不同的目标工作。但是,至关重要的一点是共有的功能和数据可以在基类中实现,然后由特定的类扩展。引如一个新类,例如Encyclopedia,到书店在线订购系统和语言系的计算机化图书馆中在面向对象环境中是很容易实现。因为不管它是什么,它的大部分功能在基类中以实现了。换句话说,在面向对象的环境中,每一个新类可以从草稿开始。
多态
多态是不同对象以不同方式对相同消息相应的能力。多态在自然语言中广泛使用。考虑动词关:它用于不同对象有不同的含义。关门,关闭银行帐号或关闭程序窗口多时不同的动作;他们的确切含义依赖与执行动作的对象。同样的在面向对象程序设计中多态意味着消息的解释依赖与接受消息的对象。C++有三种静态(编译期)多态:运算符重载、模板和函数重载。
运算符重载
例如,运算符+=用于int或string的含义依赖于这些对象不同的解释方式。但是,你可以直观的
预测结果,并且你可以发现两者的相似之处。支持运算符重载的面向对象的程序设计语言以一种限制的方式支持运算符重载,多态也一样。
模板
vector<int>和vector<string>有不同的响应;就是说,当他们接到一样消息时执行一套不同的指令。但是,你可能期望两者有相似的行为(模板在第九章“模板”中详细讨论)。考虑下面的例子:
vector < int > vi; vector < string > names;
string name("Bjarne");
vi.push_back( 5 ); //在vector尾增加一个整数
names.push_back (name); //在vector尾增加一个整数一个字符串
函数重载
函数重载是多态的第三种形式。为了重载函数,不同的重载版本有不同的形参表。例如,叫f() 的一套重载函数可能十分相
似,如下:
void f(char c, int i);
void f(int i, char c); //形参的顺序也是一个重要的因素
void f(string & s);
void f();
void f(int i);
void f(char c);
注意,在C++种仅仅返回值不同的重载函数是错误的:
int f(); //错误;与void f();只有返回值不同
int f(float f); //正确——独特的标志
动态绑定
动态 绑定进一步体现的多态的思想。在动态绑定中,消息的含义依赖于接受消息的对象;但是,对象的确定类型只有在运行期才决定。虚函数是一个很好的例子。虚函数的指定版本在编译期是不知道的。这时调用在运行期才决定,如下所示:
#include <iostream>
using namespace std;
class base
{
public: virtual void f() { cout<< "base"<<endl;}
};
class derived : public base
{
public: void f() { cout<< "derived"<<endl;} //overrides base::f
};
void identify(base & b) //参数可以是基类的实例
//也可以是派生类的实例
{
b.f(); //base::f or derived::f? resolution is delayed to runtime
}
//a separate translation unit
int main()
{
derived d;
identify; //参数是派生类对象
return 0;
}
函数identify可以接受任何从类base公共派生的任何对象——甚至在identify编译之后定义的
子对象。
动态绑定有很多优点。在这个例子中,它使用户可以不修改identify而扩展base的功能。在过程化和基于对象程序设计,这样的弹性基本上是不可能的。此外,动态绑定的底层机制是自动的。程序员不必为运行期查找和分派虚函数实现任何代码,程序员也不需要检查对象的动态类型。
面向对象程序设计的技巧
到现在为止,讨论的焦点在面向对象编程和设计的一般特性。这部分展示C++的特殊应用技术和面向对象程序设计的指导方式。
类设计
在C ++中类是主要的抽象单元。在面向对象软件系统的生存周期中,最重要的阶段可能就是在分析和设计阶段确定正确的类。确定类的一般性指导规则是,类必须表现 现实世界中的对象;另一个主张是自然语言中的名词必须描述类。在一定的范围内这一条是正确的,但是在特殊软件工程中常常有一些只在程序设计领域存在的类。 异常表示现实世界中的对象吗?函数对象(在第十章“STL和泛型程序设计”中讨论)和智能指针在程序设计环境之外有等价物吗?显然,现实实体和对象的关系 不是1:1。
确定类
确定正确类的方法来源于应用领域的功能需求。换句话说,当他适合应用的需求时,设计者就可以决定用类来表现这种概念(而不是在不同类中的成员函数或全局函数)。这通常由CRC(类,责任,协作)卡片或其他方法来完成。
设计类的一般错误
没 有两种面向对象程序设计语言是相似的。程序设计语言同样影响设计。就象你在第四章"特殊成员函:默认构造器,拷贝构造器,销毁器和分配运算符特殊成员函 数:默认构造器,拷贝构造器,销毁器和赋值运算符”学到的,C++在构造器和销毁器之间有明显的对称关系,而其他面向对象程序设计语言就没有。C++中的 对象能在自己“死亡”之后自动的作善后工作。C++也能使你创建的局域对象自动完成自己的数据存储。在有些语言中,对象只能从堆内存中创建。C++也是少 数几个提供多继承支持的语言之一。C++是有静态类型检测的强类型语言。设计大师们坚持将设计和实现的方法(这里指具体语言的行为)分离,同样的具体语言 的特性不会影响总体设计。但是,设计的错误当然也不是仅仅来源于其他语言的冲突。
面向对象不是万能药。一些一般性的缺陷能产生糟糕的应用程序,它需要经常维护,不能正常的工作,他们仅仅在最后或不能达到产品化。有些设计错误时很容易发现的。
巨类
没有标准的方法来衡量类的大小。但是,许多小的类比那些包含上百个成员函数和数据成员的“巨”类要好。但是巨类也不是没有用处。类std::string有超过100个成员函数的巨大的接口;显然,这是规则的例外,一般来说,许多人认为这是设计方法之间冲突的折中方案。虽然如此,普通程序很少用到所有的成员函数。不止一次我看见程序员用附加的成员函数和数据成员来扩展类
而不是用更加似是而非的面向对象技术,例如子对象。作为一条规则,比20-30个成员函数更有价值的类是不可靠的。
巨类是不可靠的,有至少三条原因:使用这种类的用户很少知道他们的确切用法;这种类的实现和接口需要很多修改和debug;他们也不适合重用,因为巨大的接口和复杂的实现细节仅能适用于很少的方面。某种意义上讲,巨类同大函数比较相似——他们是松散的并且难以维护。
暴露实现细节
申明所有的数据成员为public 是一个设计缺陷。虽然如此,许多流行的frameworks厂家采用这种被反对的程序设计风格。使用公用数据类型是诱人的,因为程序员可以省去写不需要的accessors和mutators工作(对应的getters和setters)。但是这种方法是不推荐的,因为它导致维护复杂并牺牲了一部分可靠性。这种类的用户趋向于依赖类的实现细节;即使他们通常避免这种依赖,他们也可能以为暴露实现细节意味着没有假定类会改变。有时没有其他选择——类的实现没有定
义存取类成员的任何方法。改变和扩展这种类成了维护的恶梦。基础组件,比如Date或string类可能在一个源文件中使用很多次。不难想象,有一打程序员,每一个编写了一打源文件,而你不得不检查源文件中所有使用了这些类的行时,你的感受。比如,臭名昭著的2000年问题就是这样。另一方面。如果数据成员申明为private,用户就不能直接存取它了。当类的实现细节改变时,仅仅是accessors和mutators需要改变,而代码的其他部分保持不变。
暴露实现细节还有一个危险。由于任意的访问数据成员和辅助函数,用户能不经意的损害对象的内部数据成员。他们可能释放内存(本来假定是由销毁器释放的),或者他们可能改变文件句柄的值造成可怕的后果,等等。因此,更好的设计选择总是隐藏对象的实现细
节。
“Resource Acquisition Is Initialization”
许多不同类型的对象有一个相同的特性:获得使用他们之前必须初始化;初始化之后才能使用,使用之后必须显式的释放。比如象File、CommunicationSocket、DatabaseCursor、DeviceContext、OperatingSystem以 及其他许多对象在使用之前必须申请、附加、初始化、构造或启动。当他们的工作完成时,他们必须刷新、分开、关闭、释放或注销。一个常见的设计错误是显式的 接受用户初始化和释放的要求。更好的选择是将所有的初始化工作放到构造器中,将所有的释放工作放到销毁器中。这种技术叫做resource acquisition is initialization(The C++ Programming Language第三版P365)。 优点是简化 了使用协议。用户创建了对象之后就可以开始使用它,不用为对象是否可用或有没有其他的初始化工作必须做而担心。此外,因为销毁器释放了所有资源,用户也可 以不用管这些事情了。请注意,这种技术通常需要适当地异常处理代码来处理构造对象的过程中可能抛出的异常。
类和对象
不像其他面向对象的程序设计语言,C++对于类和对象有明显得区别:类是用户定义的类型;对象是用户定义对象的实例。有许多特性用于操作类的状态,不是单个对象的。这些特性在下面章节讨论。
静态数据成员
静态数据成员被类的所有实例共享。因为这个原因,它有时被叫做类变量。静态数据成员在同步对象时十分有用。例如,文件锁可以用静态数据成员来实现。试图访问这个文件的对象首先检查文件是否被其他用户处理。如果文件可用,对象将标志改为开,这样用户就可以安全的处理文件。其他用户在标志恢复成原样之前不能访问这个文件。当处理文件的对象完成了,它关闭标志,其他对象就可以访问文件了。
class fileProc
{
private:
FILE *p;
static bool Locked;
public:
//...
bool isLocked () const;
//...
};
bool fileProc::Locked;
静态成员函数
类的静态成员函数只能访问类的静态数据成员。不同于普通成员函数,静态成员函数在没有类的实例时也可以调用。例如
class stat
{
private:
int num;
public:
stat(int n = 0) {num=n;}
static void print() {cout <<"static member function" <<endl;
};
int main()
{
stat::print(); //不需要累得实例
stat s(1);
s.print();//静态成员函数也能通过对象来调用
return 0;
}
在下列情况中使用静态成员函数:
当对象的其他数据成员也是静态时
- 当函数不依赖于其他对象成员时(print()就一例)
- 用作全局函数的包装时
成员指针不能引用静态成员函数
将静态成员函数的指针赋值给成员指针是非法的。但是,你可以将静态成员函数的指针当作普通函数指针来处理。例如
class A
{
public:
static void f();
};
int main()
{
void (*p) () = &A::f; //OK,普通函数指针
}
因为静态成员函数本质上就是普通函数,它不接受隐含的this参数。
定义类的常量
当你在类中需要常量整数时,最简单的方法就是使用const static整数成员;不像其他静态数据成员,这种成员能在类的体中初始化(参见第二章“标准简报:ANSI/ISO C++的最新附加部分”)。例如
class vector
{
private:
int v_size;
const static int MAX 1024; //MAX被所有vector对象共享
char *p;
public:
vector() {p = new char[MAX]; }
vector( int size)
{
if (size <= MAX)
p = new char[size] ;
else
p = new char[MAX];
}
};
设计类层次
在确定了应用程序需要的一套可能的类之后,重要的就是确定类之间的相互作用和关系:继承、包含、还是从属?类层次的设计不同于设计正确的类型,它需要另外的考虑,在下面的章节讨论。
私有数据成员优于保护成员
类的数据成员经常是类实现的一部分。类的内部实现改变时,数据成员可能被替代;因此需要向其他类隐藏他们。如果派生类需要访问数据成员,他们需要通过接口而不是直接访问基类的数据成员。因此当基类改变时,派生类就不需要改变。
这是一个例子:
class Date
{
private:
int d,m,y //数据如何表示是实现细节
public:
int Day() const {return d; }
};
class DateTime : public Date
{
private:
int hthiss;
int minutes;
int seconds;
public:
//...其他成员函数
};
现在假设类Date一般用于现实设备,所以它必须提供将d、m、y转换成可显示字符串得方法。为了提高性能,做了设计修改:代替三个整数,用一个string来表示数据。如果类DateTime依赖于
Date的内部实现细节,它将不得不也改变。但是因为它仅通过接口访问Date的数据成员,所需要的仅仅是小小的修改成员函数Date::Day()。请注意访问数据的成员函数一般申明为内联,这样可以避免额外的运行期开销。
申明虚的基类的销毁器
基类需要申明其销毁器为virtual。 这样做,你可以确保总是调用正确的销毁器,即使在下面的情况:
class Base
{
private:
char *p;
public:
Base() { p = new char [200]; }
~ Base () {delete [] p; } //不是虚销毁器,问题将出现
};
class Derived : public Base
{
private:
char *q;
public:
Derived() { q = new char[300]; }
~Derived() { delete [] q; }
//...
};
void destroy (Base & b)
{
delete &b;
}
int main()
{
Base *pb = new Derived(); //分配200 + 300字节
//...使用pb
destroy (*pb); //糟糕!只有Base的销毁器被调用了
//如果Base的销毁器是虚函数,就会调用正确的销毁器
return 0;
}
虚成员函数
虚函数使子类能扩展或覆盖基类的行为。决定类中那些成员是可以被派生类覆盖并不是微不足道的。覆盖虚函数的类只是忠实的秉承了被覆盖函数的原型——而不是实现。一个普遍地错误是将所有的成员函数申明为virtual“以防万一”。在这方面,C++对提供纯接口的抽象类和与之相反的提供实现也提供接口的基类有明显得区别。
在派生类中扩展虚函数
你想要派生类扩展而不是完全覆盖基类定义的虚函数时,有很多方法。你可以简单的以下面的方法实现:
class shape
{
//...
public:
virtual void draw();
virtual void resize(int x, int y) { clearscr(); /*...*/ }};
class rectangle: public shape
{
//...
public:
virtual void resize (int x, int y)
{
shape::resize(x, y); //显式的调用基类的虚函数
//增加功能
int size = x*y;
//...
}
};
派生类的替代版本必须以完整的名字调用基类的背代替的虚函数。
改变虚函数的访问限制
基类定义virtual成员函数的访问限制能被派生类改变。例如
class Base
{
public:
virtual void Say() { cout<<"Base";}
};
class Derived : public Base
{
private: //访问限制改变了;合法但不是一个好主意
void Say() {cout <<"Derived";} //覆盖Base::Say()
};
尽管这是合法的,当使用指针或引用时可能会产生意想不到的结果;任何Base的公共派生类都可以赋值给Base的指针和引用:
Derived d;
Base *p = &d;
p->Say(); //OK,调用Derived::Say()
因为虚成员函数的实际绑定推迟到了运行期,编译器不能检测到调用了非公有函数;编译器假设p是指向Base类型的指针,而在Base中Say()是公有函数。作为一条规则,不要在派生类中改变虚成员函数的访问限制。
虚成员函数不能是私有的
就像你在前面看到的,在派生类中扩展虚函数的习惯方法是先调用改函数的基类版本;在用附加功能扩展它。虚函数申明为private时这种方法就不能使用。
抽象类和接口
抽象类是至少包含一个纯虚成员函数的类。纯虚成员函数就是没有实现的一个占位符,需要派生类来实现。不能创建抽象类的实例,因为有意将他它当作派生具体类的设计骨架,而不是当作独立的对象。看下面的例子:
class File //抽象类;作为接口
{
public:
int virtual open() = 0; //纯虚
int virtual close() = 0; //纯虚
};
class diskFile: public File
{
private:
string filename;
//...
public:
int open() {/*...*/}
int close () {/*...*/}
};
使用派生代替类型域
假如你要实现一个国际化的帮助类,这个类要接受当前字处理软件支持的每一种自然语言作为参数。天真的一种实现方案是靠类型域来制定当前使用的语言种类(例如,在显示菜单中区分语言)。
class Fonts {/*...*/};
class Internationalization
{
private:
Lang lg; //type field
FontResthisce fonts
public:
enum Lang {English, Hebrew, Danish}
Internationalization(Lang lang) : lg(lang) {};
Loadfonts(Lang lang);
};
Internationalization的 每一个改变将影响它的所有用户,即使用户假定不被影响。当增加对新语言的支持已支持语言的用户不得不重新编译(或下载,这更糟)类的新版本。而且,随着时 间的推移以及要支持更多的语言,类变得越来越大而且更不容易维护,也有产生更多bug的趋势。更好的实现方法是用派生代替类型域。例如
class Internationalization //基类
{
private:
FontResthisce fonts
public:
Internationalization ();
virtual int Loadfonts();
virtual void SetDirectionality();
};
class English : public Internationalization
{
public:
English();
Loadfonts() { fonts = TimesNewRoman; }
SetDirectionality(){}//不作任何事;默认的:左边到右边
};
class Hebrew : public Internationalization
{
public:
Hebrew();
Loadfonts() { fonts = David; }
SetDirectionality() { directionality = right_to_left;}
};
派生简化了类结构而且可以将修改限制在于特定语言关联的类中而不影响其他。
穿过类的边界重载成员函数
类是一个命名空间。重载成员函数的范围限制在类中,但不包括派生类。有时需要在派生类中重载相同的函数就像在类中一样。
但是,在派生类中使用同样的名字只是隐藏了它,而不是重载。考虑现面的代码:
class B
{
public:
void func();
};
class D : public B
{
public:
void func(int n); //隐藏了B::f,而不是重载
};
D d;
d.func();//编译器错误。B::f在d中不可见;
d.func(1); //OK, D::func接受类型为int的参数
为了重载——而不是隐藏——基类的成员函数,基类的函数名必须通过using declaration显式的加入派生类的命名空间中。例如
class D : public B
{
using B::func; //将基类成员的名字加入到D范围中
public:
void func(int n); //现在D有两个func()的重载版本
};
D d;
d.func ( ); // OK
d.func ( 10 ); // OK
继承还是包容
当设计类层次时,你必须面对继承或is-a,和包容或has-a的关系。选择并不是总那么明显。假设你设计类Radio,而且你已经在一些库里实现一下几个类:Dial和ElectricAppliance。显然Radio派生于ElectricAppliance。但是,Radio也派生于Dial并 不是那么显然。在这种情况下,检查两者之间是不是总是1:1关系。是不是所有的radios(无线电通信装置)有且仅有一个dial(表盘)?答案是否。 一个radio可以没有一个dial——比如,以固定频率接受/发送的器件。此外,radios也可能有多个——FM和AM dials。因此,你的Radio类需要设计成有多个Dial而不是从Dial派生。注意到Radio和ElectricAppliance的关系是1:1,所以确定将
Radio设计成从ElectricAppliance派生。
Holds-a关系
所 有权定义了构造和销毁对象的责任。只有当对象既有构造也有销毁资源责任时,对象才是资源的拥有者。出于这种考虑,包含其他对象的对象也是所包含对象的所有 者,因为对象的构造器要为调用了内部对象地构造器负责。同样的。对象的销毁器也要为调用了内部对象的销毁器负责。这是众所周知的has-a关系。相似的关 系是holds-a。由于所有权,holds-a比has-a更突出。
一个类间接的包含——就是通过指针或引用——其他地独立构造和销毁的对象,这就叫拥有(hold)对象。这是一个例子:
class Phone {/*...*/};
class Dialer {/*...*/};
class Modem
{
private:
Phone* pline;
Dialer& dialer;
public:
Modem (Phone *pp, Dialer& d) : pline(pp), dialer {}
//Phone和Dialer对象
//独立于Modem构造和销毁
};
void f()
{
Phone phone;
Dialer dialer;
Modem modem(&phone, dialer);
//...使用modem
}
Modem使用Phone和Dialer。但是,Modem没有构造和销毁他们的责任。
空类
不包含数据成员和成员函数的类是空类。例如
class PlaceHolder {};
空类可以作为将要定义类的占位符。假象类和接口类作为其他类得基类,避免了等待一个完整的实现,可以在过渡期使用。另外,空类可用于迫使类的继承层次成为一颗严格的树。(这是顶层设计)最后,空类可以用作区别重载函数的不同版本的无用参数。事实上标准运算符new的一个版本(参见第十一章”内存管理“)使用了这种技术:
#include <new>
using namespace std;
int main()
{
try
{
int *p = new int[100]; //抛出异常的new
}
catch(bad_alloc & new_failure) {/*..*/}
int *p = new (nothrow) int [100]; //不抛出异常的版本
if (p)
{/*..*/}
return 0;
}
参数nothrow是nothrow_t类型,这本生就是一个空类。
将structs作为一个公用类的简化版本使用
传统的,structs作为数据的集合。但是,在C++中struct可以有构造器、销毁器和成员函数——完全是一个类。structs与类的基本区别是默认访问限制不同:默认得,类的成员和其派生类都是私有的,然而结构都是公有的。因此,结构有时可以用作成员都是public的类的简化。抽象类是所有成员都是公有的地一个很好的例子。
#include <cstdio>
using namespace std;
struct File //接口类。所有的成员隐含申明为公有
{
virtual int Read() = 0;
File(FILE *);
virtual ~File() = 0;
};
class TextFile: File //隐含公有继承;File是一个结构
{
private:
string path;
public:
int Flush();
int Read();
};
class UnicodeFile : TextFile //隐含私有继承
{
public:
wchar_t convert(char c);
};
友元
通 过申明外部类和函数为友元,类可以允许他们访问类的成员。友元有对允许者成员的完全访问权限,包括私有何保护成员。有时批评友元暴露了实现细节。但是,这 与申明成员为公有有明显区别,因为友元使类可以显式的申明那些客户可以访问的成员;与之对比,公有申明使得任何客户都能访问成员。这里是一个例子:
bool operator ==( const Date & d1, const Date&amp;amp;amp; d2);
{
return (d1.day == d2.day) &&
(d1.month == d2.month) &&
(d1.year == d2.year);
}
class Date
{
private:
int day, month, year;
public:
friend bool operator ==( const Date & d1, const Date&amp;amp;amp; d2);
};
记住友元是不会继承的,所以从Date继承的任何成员函数对于运算符==都是不可见的。
非公有继承
当派生类是非公有继承时,派生对象和非公有基类之间的is-a关系就不存在了。例如:
class Mem_Manager {/*..*/};
class List: private Mem_Manager {/*..*/};
void OS_Register( Mem_Manager& mm);
int main()
{
List li;
OS_Register( li ); //编译期错;将
//List & 转换成 Mem_Manager&的转化运算符是不可见
return 0;
}
类List有一个私有基类Mem_Manager,它为必要的内存申请负责。但是,List 不是Mem_Manager。因此,私有继承用于阻止上面这种滥用。私有继承与包容相似。事实上,将Mem_Manager作为List的一个成员可以取得相同的效果。Protected继承用于同样的目的。
通用根类
在许多frameworks和软件工程里,所有的类都必须是一个通用根类的派生类,这个根类一般叫Object。这种设计方针在其他OO语言里十分流行,比如Smalltalk和Java中所有的类隐含的派生自类Object。 但是在C++中模仿这种做法带来了许多安全隐患和潜在的bug。它在没有任何公共点的类之间人为创造了血缘关系。Bjarne Stroustrup有句名言:“现在一个微笑、我的CD-ROM唱机、一张《唐璜》的唱片、一行文本、我的医疗记录和一个实时时钟之间有什么共同点?当 他们共享的唯一属性是他们都是程序设计中的人造物(他们都是对象)时,将他们全部放到一个继承层次中没有什么确切含义,而且可能带来混乱。”(The C++ Programming Language第三版,732页)。
如果你想追求泛型,就是说,如果你需要适应任何数据类型的算法/容器/函数,模板是你更好的选择。此外,根类的设计方针也迫使你完全克制住使用多继承,因为从有相同基类的不同多个类派生的类就要面对可怕的菱形继承问题:它将基类嵌入了多次。最后,通用根类经常作为实现异常处理和RTTI的一种手段,现在这两者都是C++的完整的一部分了。
提前申明
考虑下面的类引用其他类的一般情况:
//file: bank.h
class Report
{
public:
void Output(const Account& account); //编译期错误;
// Account还没有申明
};
class Account
{
public:
void Show() {Report::Output(*this);}
};
试图编译这个头文件将产生编译期错误,因为当Report编译时编译器不认为标识符Account是一个类。即使你将类Account的申明放到Report的前面。你将遇到同样的问题:Report引用了Account。为了达到目的,需要提前申明。提前申明指示编译器在扫描完整个文件之后再报告错误。例如
//file: bank.h
class Acount; //提前申明
class Report
{
public:
void Output(const Account& account); //正确
};
class Account
{
private:
Report rep;
public:
void Show() {Report::Output(*this);}
};
源文件开始处的提前申明使类Report能引用类Account尽管没有见到它的定义。 注意,只有引用和指针能使用提前申明类。
局域类
类也可以在函数或块中申明。此时,类在其他地方不可见,而且它的实例只能在它申明的范围之内创建。当你需要隐藏不想让其他地方使用和访问的对象时,这一特性是十分有用的。例如
void f(const char *text)
{
class Display //局域helper类;只在f()中可见
{
const char *ps;
public:
Display(const char *t) : ps(t) {}
~Display() { cout<<ps; }
};
Display ucd(text); //类型为Display的局域类
}
局域类没有linkage。
多继承
多 继承在1989年引入C++。毫不夸张的说它是加入C++的最有争议的特性。多继承的反对者认为它给语言增加了不必要的复杂性,每一个使用了多继承的设计 模式都能用单继承模式,而且多继承使得编译器更复杂。这三点中只有一点是正确的。多继承是可选的。设计者认为可以不用多继承时,他就可以不用。将增加的复 杂性归结为使用多继承同样是站不住脚的,因为对于语言的其他特性也有同样的批评,模板、运算符重载、异常处理等等。
多继承使设计者能创建更贴近现实世界的对象。传真modem卡本质上讲是一个modem和传真的结合物。同样的 ,一个从fax和modem公有派生的fax_modem类比单一继承模式能更好的表示传真/modem的概念。例如,在Jave中实现Observer模式基本是不可能的,因为Jave缺乏多继承(Java vs. C++——A Critical Comparison," C++ Report, 一月 1997)。Observer不是唯一依赖多继承的模式——Adapter和Bridge都是。
使用多继承联合各种特性
使用多继承派生类可以联合许多基类的功能。使用单一继承达到同样的效果十分困难。例如
class Persistent //抽象基类
{
//所有支持持久化的对象
public:
virtual void WriteObject(void *pobj, size_t sz) = 0;
virtual void* ReadObject(Archive & ar) = 0;
};
class Date {/*...*/};
class PersistentDate: public Date, public Persistent
{ /*..*/} //能被存储和重建
虚继承
多继承可能导致著名的菱形继承问题(dreadful diamond of derivation),下面演示了这种情况:
class ElectricAppliance
{
private:
int voltage,
int Hertz ;
public:
//...构造器和其他方法
int getVoltage () const { return voltage; }
int getHertz() const {return Hertz; }
};
class Radio : public ElectricAppliance {/*...*/};
class Tape : public ElectricAppliance {/*...*/};
class RadioTape: public Radio, public Tape { /*...*/};
int main()
{
RadioTape rt;
//下面的语句将是编译期错误——不明确的调用。
//rt中有getVoltage()的两个拷贝:一个来自Radio
//另一个来自Tape。而且返回那一个福特值呢?
int voltage = rt.getVoltage();
return 0;
}
问题是很显然的:rt同时从两个基类派生,而两个基类都有ElecctricAppliance方法和数据的一份拷贝。结果,rt有ElectricAppliance的两份拷贝。这就是菱形继承。然而放弃多继承使得设计变复杂。不需要公有基类方法和数据的重复拷贝时,使用虚继承:
class Radio : virtual public ElectricAppliance {/*...*/};
class Tape : virtual public ElectricAppliance {/*...*/};
class RadioTape: public Radio, public Tape
{/*...*/};
现在类RadioTape只包含ElectricAppliance的一份拷贝,这份拷贝Radio和Tape共享;于是模糊不存在了也不需要放弃多继承。
int main()
{
RadioTape rt;
int voltage = rt.getVoltage(); //OK
return 0;
}
继承了多次之后,C++如何保证虚成员只有一份实例存在?这依赖于具体编译器的实现。但是,现在的实现都是使用附加层间接访问虚基类,一般是指针。
//注意:这是iostream类的简化描述
class ostream: virtual public ios { /*..*/ }
class istream: virtual public ios { /*..*/ }
class iostream : public istream, public ostream { /*..*/ }
换句话说,iostream继承层次中的每个对象都有一个指向共享的ios子对象的指针。附加的间接层会有很小的性能开销。也就是说本地的虚子对象在编译期是不知道的;因此在这种情况下可能需要RTTI来访问对象(在第七章“运行期类型识别”讨论)。
当使用多继承时,多继承对象的内存布局是依赖编译器的。编译器可以重新排列继承来的子对象来提高内存的使用效率。另外,虚基类也可能移到了不同的内存地址。因此使用多继承时,不要做关于对象内存布局的任何假设。
非虚多继承
虚继承用于避免在多继承的对象中有一个基类的的多个拷贝,就像你看到的。但是在有些情况下,派生类需要基类的多个拷贝。在这种情况下,有意的避免虚继承。例如,假设你有一个scrollbar(滚动条)类,这个类需要其他两个类的基类:
class Scrollbar
{
private:
int x;
int y;
public:
void Scroll(units n);
//...
};
class HorizontalScrollbar : public Scrollbar {/*..*/};
class VerticalScrollbar : public Scrollbar {/*..*/};
现在假设一个窗口既有水平滚动条也有垂直滚动条。它可以用下面的方法实现:
class MultiScrollWindow: public VerticalScrollbar,
public HorizontalScrollbar {/*..*/};
MultiScrollWindow msw;
msw.HorizontalScrollbar::Scroll(5); // scroll left
msw.VerticalScrollbar::Scroll(12); //...and up
用户即可以将窗口上下滚动也可以左右滚动。为了这个目的,窗口对象必须有两个不同的Scrollbar子对象。因此,有意避免多继承。
为成员函数选择不同名字
多继承中当两个以上类作为基类时,必须为每个成员函数选择不同的名字,不然就会出现歧义。考虑下面的具体例子:
class AudioStreamer //实时音频播放类
{
public:
void Play();
void Stop();
};
class VideoStreamer //实时视频播放类
{
public:
void Play();
void Stop();
};
class AudioVisual: public AudioStreamer, public VideoStreamer {/*...*/};
AudioVisual player;
player.play(); //错误:AudioStreamer::play()还是VideoStreamer::play()?
一个消除歧义的方法是用函数的全名调用:
Player.AudioStreamer::play(); //正确但是冗长
更好的解决方案是在基类中为函数使用不同的名字:
class AudioStreamer
{
public:
void au_Play(); };
class VideoStreamer
{
public:
void vd_Play();
};
Player.au_play(); //OK
总结
今天C++在各种各样的领域中使用嵌入式系统、数据库引擎、Web引擎、金融系统、人工智能等等。这种多功能性归结于C++程序设计风格的灵活性、对C的兼容性以及一个重要的事实——C++是现有的最有效率的面向对象程序设计语言。
作为一种过程化语言,C++提供比C更严格的类型检查。它也提供更好的内存管理、内联函数、默认参数和引用变量,这些使C++可以作为“更好的C”。
通过将数据和操作数据的一套函数封装到一个单独的实体中,基于对象的程序设计克服了过程化程序设计的许多明显得缺点。设计中将实现细节和接口分离将修改限制在很小的范围内,于是成生了功能更强更容易扩展的软件。但是,它不支持类的继承。
面向对象的程序设计依赖于封装、信息隐藏、多态、继承和动态绑定。这些特性使你能设计和实现类层次。面向对象程序设计与基于对象程序设计相比,面向对象有更快的开发速度、更容易的维护和更强的可扩展性。
C+ +支持面向对象程序设计的高级特性,如多继承、静态和动态多态和类和对象之间的明显区别。面向对象设计的首要任务就是确定类和类的相互关系:继承、包容还 是所有。构造器和销毁器的对称性是一些有用设计习惯的基础,比如“initialization is acquisition”和智能指针。
C++支持的另外一种程序设计范例——泛型程序设计与面向对象的程序设计没有直接关系。事实上,过程化的程序设计语言也能很好的实现泛型程序设计。虽然如此,面向对象程序设计和泛型程序设计的结合使得C++是一种强大的语言,第十章将让你充分体会到这一点。
![Contents](http://www.blogger.com/Button/contents.gif)
© Copyright 1999, Macmillan Computer Publishing. All rights reserved.
翻译:Sttony,2001.11.8