c++学习记录七:多态性:对象独立性(抽象类,虚函数)

(本文来自c++简明教程)
对象要想真正有用,就必须具有独立性。每个对象都应该相当于一台微型计算机,能发送并响应消息。只要一个新的对象类型使用了恰当的接口,你就应该能够把它“插接”到现有的软件上。
从字面意义上看多态性意味着“多种形式”。在最基本的级别上,它意味着以多种方式实现相同的操作。它最重要的意义在于,无需更改使用了一个对象的代码,就能将对象替换成另一个对象类型,而程序仍能正常工作。
打个比方。你的子处理软件能和你购买的任何打印机正确地交互。你甚至可以在5年之后,拔除用旧的打字机,接上一台当前尚未问世的打字机。今天写字处理软件的人不需要知道所有规格的打印机的详细信息。
这正是“多态性”存在的理由:使用所有软件对象都能像硬件组件那样工作,它们能协同工作,并根据需要进行置换。旧的软件能顺利连接较新的软件······那自是“重用性”的含义。


FloatFraction类的另一个思路

想理解多态性,首先要理解两个问题:virtual(虚)函数是什么:在什么情况下需要这种函数。让我们先来探讨一个编程问题,它演示了virtual函数的简单应用。

前一章的FloatFraction类“动态”生成一个浮点(double)值——换言之,对象的用户通过调用get_float来获取浮点值时,就计算出那个值。

这是一种行之有效的技术,而且一般情况下已经够用。但是,计算浮点值需要耗用宝贵的处理器时间。你或许更想用一个持久性的浮点值来避免执行上述计算。假如客户端代码(也就是使用了类的代码)每秒钟都要大量引用浮点值,而且你希望那些引用变得更有效率,就适合采取这种方法。
为了实现这种技术,你需要采取以下操作:

  • 声明一个新的数据成员,即float_val,它负责存储类的浮点值。这就使浮点值具有了持久性,所以若无必要,就不需要重新计算这个值。
  • 覆盖normalize函数,使它除了执行其他任务之外,还要计算浮点量。当然,前提是你有访问normalize函数的权限。所以,让我们假定normalize函数一开始就使用protected关键字来声明。

这一策略的好处在于简单。覆盖normalize函数是保证float_val(数据成员)只在必要时才进行计算的一种妥善的方式。

Fraction类以及所有子类的代码总是调用set成员函数为数据成员赋值。而set成员函数在设置了num和den的值之后,要调用normalize。所以,只需覆盖normalize,就能保证在遇到新值的时候重新计算float_val。
下面是normalize的覆盖后的版本:

1
2
3
4
void FloatFraction::normalize() {
Fraction::normalize();
float_val = static_cast<double> (get_num() / get_den());
}

这就是在函数定义中惟一要做的事情。注意,没有必要完全重新normalize。大多数工作都可以通过调用它的基类版本(Fraction::normalize)来完成。你只需新增一行用于设置数据成员float_val的值的代码。

但遗憾的是,这种做法存在一个问题。现在有两个版本的normalize,也就是Fraction::normalize和FloatFraction::normalize。set函数调用的是哪个版本?为了方便讨论,下面列出了set在Fraction类中的定义:

1
2
3
void set(int n, int d) {
num = n; den = d; normalize();
}

你或许以为,在调用normalize的时候,会调用当前对象所属的那个类中定义的版本(所谓“当前对象”,就是通过它来调用函数的那个对象)。但是,这个想法是错误的。
c++编译器一旦遇到set在Fraction类中的定义,就必须立即(在编译时)决定如何调用函数。为此,它必须修正函数地址。set函数可能像这样写:

1
2
voiod set(int n, int d){
num = n; den = d; Fraction::normalize();}

这样一来,set函数永远不会调用normalize的已覆盖的版本。如果你有覆盖set函数本身的想法,请再仔细思考一下。set函数需要访问两个private变量,即num和den;而子类不具有访问它们的权限。所以,子类不能成功地覆盖set函数。

我们希望的是一种灵活的函数调用方式——调用的函数的地址要到运行时再决定(这称为晚期绑定)。在那种情况下,调用normalize就相当于:“调用这个对象的normalize的实现”。然后,对normalize的调用将自动地采取正确的操作。在FractionUnits这样的子类中,set函数将调用normalize的新版本,而不是调用旧版本。

这是调用一个函数时最灵活的方式——调用一个事先没有确定的函数地址——用正式的术语来说,你可以说它调用的是一个virtual(虚)函数。


虚函数

所以,我们的解决方案是将normalize函数变成一个virtual函数。你可以使用virtual关键字来做这件事情。

但是,不一定肯定能达到我们希望的目标。为了保证一切都正常工作,你需要回过头去更改一下Fraction类的代码。在Fraction类的代码中,normalize函数必须是protected函数,同时还要使用virtual来声明。

如果你能重写Fraction类,那么很容易做到这一点。你只需在函数声明之前添加protected,然后将virtual关键字放到函数声明的开头。

1
2
3
4
5
6
class Fraction {
// ...
protected:
virtual void normalize(); //转变成标准形式
// ...
};

除了上一节描述的修订之外,你只需进行上述代码的更改。将函数声明为虚函数之后,它在所有情况下(以及在所有子类中)都是“虚”的。以后永远不需要再次为函数使用virtual关键字。

更妙的是,调用一个虚函数时,采取的方式和调用其他任何函数是一样的。在幕后,c++必须通过一个特殊的过程来调用虚函数。但是,这在c++源代码中是不可见的。

不过请注意几个限制:

  • 你只能使成员函数成为虚函数。
  • 内联函数不允许是虚函数。
  • 构造函数不允许是虚函数,但析构函数可以是虚函数。

那么,应该将哪些函数设置成虚函数呢?为什么不干脆让符合条件的所有函数都成为“虚”的呢?事实上,在调用一个虚函数的时候,性能会受到一定的影响——虽然影响微乎其微。所以,你应该遵循以下基本规则。

基本规则:可能由一个子类覆盖的任何成员函数都应该声明为虚函数。

子类可以覆盖一个不是virtual的函数。这虽然是合法的,但假如你这样做,可能造成在特定的情况下,无法调用正确的函数(就像上一节讲过的normalize函数)。如果你认为一个成员函数可能被覆盖,就使它成为虚函数。

小插曲:virtual会招致什么惩罚?

虽然没有必要知道virtual函数具体如何由c++实现,但你有必要理解可能要为它付出的一些代价。有得必有失,virtual函数虽然变得更灵活,但必须受到一定的惩罚。如果你能保证一个特定的函数永远都不会被覆盖,就没必要使它成为virtual函数,尤其是在希望程序高效率运行的前提下。
然而,这个惩罚很小,尤其是在当今计算机的速度和内存普遍都绰绰有余的情况下。实际上有两个方面的惩罚,它们是密切相关的:性能上的惩罚和空间上的惩罚。
c++程序执行一个标准的函数调用时,首先,它将程序控制临时转移带一个特定地地址,并在函数完成后返回。如下图所示:

但是,执行virtual函数时要考虑更多的事情。每个对象都包含一个隐藏的“vtable”指针,它指向一个表格,其中列出了该对象所属的那个特定的类的全部virtual函数。例如,FloatFraction类的所有对象都包含一个指向表格的指针,表格中包含了FloatFraction的所有virtual函数(假如一个类没有virtual函数,它的对象就不需要有一个vtable指针,从而省出一定空间)。
调用一个virtual函数时,程序会使用vtable指针来发出一个间接函数调用,这要求在运行时检索正确的函数地址(记住,这个操作是在幕后发生的,你在c++源代码中看不到丝毫迹象)。如下图所示:

由于每个对象都包含一个vtable指针,所以就像本章开头描述的那样,你可以说:“对象自身内置了和如何采取一个行动有关的信息”。vtable指针允许每个单独的对象都掌握这方面的“信息”,因为它指向它自己的类特有的函数实现。
虽然要付出一些代价,但这些代价是非常小的。之所以要付出性能上的代价,是因为现在发出的是一个间接函数调用,而不是直接函数调用(虽然两者的差别可能只是以微秒计)。之所以要付出空间上的代价,是因为vtable指针和表本身要占用一定的空间(虽然这一点点空间对于当今的内存容量来说是微不足道的)。记住:在函数可能被覆盖的情况下,就使其成为virtual函数,为此付出的代价比较小。


示例1:修订的FloatFraction类

下一个例子演示了FloatFraction类。本节的代码假定Fraction函数定义代码包含在文件Fract.cpp(必须将这个文件添加到使用了Fraction类的任何项目中)。注意,下面没有列出Fract.cpp的内容,但列出修改后的Fract.h的内容,表面需要对Fraction类声明进行的更改。
这是一个新版本的FloatFraction,它包含一个新成员函数float_val,同时覆盖了normalize函数。
FloatFract3.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include "Fract.h"
using namespace std;

class FloatFraction : public Fraction {
public:
double float_val; //新增

FloatFraction() {set(0, 1);}
FloatFraction(int n, int d) {set(n, d);}
FloatFraction(int n) {set(n, 1);}
FloatFraction(const FloatFraction &src)
{ set(src.get_num(), src.get_den()); }
FloatFraction(const Fraction &src)
{ set(src.get_num(), src.get_den()); }

void normalize(); //已覆盖
};
void FloatFraction::normalize() {
Fraction::normalize();
float_val = static_cast<double> (get_num()) / get_den();
}
int main() {
FloatFraction f1(1, 4), f2(1, 2);

FloatFraction f3 = f1 + f2;

cout << "1/4 + 1/2 = "<<f3<<endl;
cout << "Fraction pt value is "<< f3.float_val << endl;
return 0;
}

下面是Fract.h文件的修订后的版本,其中包含Fraction类声明。
Fract.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
using namespace std;
class Fraction {
private:
int num, den; //分子和分母
public:
Fraction() {set(0, 1);}
Fraction(int n, int d) {set(n, d);}
Fraction(int n) {set(n, 1);}
Fraction(const Fraction &src)
{set(src.num, src.den);}

void set(int n, int d) {num = n; den = d; normalize();}
int get_num() const {return num;}
int get_den() const {return den;}

Fraction add(const Fraction &other);
Fraction mult(const Fraction &other);

Fraction operator+(const Fraction &other) {
return add(other);
}
Fraction operator*(const Fraction &other) {
return mult(other);
}
int operator==(const Fraction &other);
friend ostream &operator<<(ostream &os, Fraction &fr);
// 新增
protected:
virtual void normalize(); //将分数转换为标准形式
private:
int gcf(int a,int b); //gcf代表最大公因数
int lcm(int a,int b); //lcm代表最小公倍数
};

幕后玄机

这个程序采用了前面描述的策略。浮点值将具有持久性,只在需要时才重新计算。

1
2
3
4
5
class FloatFraction : public Fraction {
public:
double flaot_val;
// ...
};

这个例子的关键在于被覆盖的normalize函数。这个新版本的normalize重新计算float_val的值,确保在对象发生更改之后,总是更新浮点值。

1
2
3
4
void FloatFraction::normalize() {
Fraction::normalize();
float_val = static_cast<double> (get_num()) / get_den();
}

set函数从基类(Fraction)继承,它发出对normalize的调用。

1
void set(int n, int d) { num = n; den = d; normalize();}

该函数能正确调用normalize的新版本(即FloatFraction::normalize),即使它是Fraction的一个成员函数,根本不知道任何子类的情况!之所以能这样,是因为normalize在Fraction类中是使用virtual关键字来声明的。该关键字的意思是说“到运行时再决定调用这个函数的哪个版本。”

1
2
protected:
virtual void normalize(); //将分数转换成标准形式

注意,如果该函数一开始就声明为protectedvirtual,现在就不需要更改或重新编译它。

为了测试FloatFraction类,main函数声明了FloatFraction对象,然后访问新成员float_val。

1
2
3
4
5
6
FloatFraction f1(1, 4), f2(1, 2);

FloatFraction f3 = f1 + f2;

cout << "1/4 + 1/2 = "<<f3<<endl;
cout << "Fraction pt value is "<< f3.float_val << endl;

改进代码

虽然我将float_val设为一个公共成员,但这不一定是最好的方案。将float_val设置成公共成员,就像将num/den设置成公共成员一样,也有不足。让类的用户直接获取float_val的值,这虽然能带来不少方便,但假如允许用户更改值,就可能造成错误。在这种情况下,你需要对数据访问进行控制,而这正是成员函数最基本的用途之一。

同时,你可以考虑在获取值的同时,不产生函数调用的开销。解决方案就是将函数内联(就像get_num和get_den函数那样)。
下面展示了FloatFraction的完整修订版本。
FloatFract2.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include "Fract.h"
using namespace std;

class FloatFraction : public Fraction {
private:
double float_val; //新增

FloatFraction() {set(0, 1);}
FloatFraction(int n, int d) {set(n, d);}
FloatFraction(int n) {set(n, 1);}
FloatFraction(const FloatFraction &src)
{ set(src.get_num(), src.get_den()); }
FloatFraction(const Fraction &src)
{ set(src.get_num(), src.get_den()); }

double get_float() {return float_val;} //新增
void normalize(); //已覆盖
};
void FloatFraction::normalize() {
Fraction::normalize();
float_val = static_cast<double> (get_num()) / get_den();
}
int main() {
FloatFraction f1(1, 4), f2(1, 2);

FloatFraction f3 = f1 + f2;

cout << "1/4 + 1/2 = "<<f3<<endl;
cout << "Fraction pt value is "<< f3.get_float()<< endl;
return 0;
}

练习

在normalize的覆盖版本中(FloatFraction::normalize),请添加以下语句:
cout << "I am now in FloatFraction::normalize!" << endl;
看到这条消息,表面执行的是normalize的重载版本,而不是基类版本。请重新生成和运行程序,注意该消息打印了多少次。然后,请修改Fract.h,从声明中移除virtual关键字。你应该注意到上述消息没有打印(此外,还应该发现float_val成员包含的是垃圾值)。这样一来,就证实了要想执行正确的(实现)版本,virtual关键字是必不可少的。


“纯virtual”和其他难点

我一直都在强调virtual函数的重要性。你的设计应该满足这一要求,即使函数被重载,也总是能执行正确版本的实现。
记住,如果需要,可以选择执行一个函数的基类版本,具体做法是在代码中限制它的作用域:
Fraction::normalize();
但是,假如不进行这个限制,执行的就是当前对象自己的函数版本,而不是基类的版本。在这个时候,virtual函数就能派上用场了。我们认为最理想的一种能力应该是:即使基类不知道一个子类具体如何实现一个函数,函数调用也能“做正确的事情”。
这种能力潜在的意义可能超出你的想象。继承层次结构是许多开发系统的根本,比如配合Visual C++使用的Microsoft Foundation Class以及Java库等等(Java虽然和C++不同,但却是一种基于C++概念的语言,两者的许多语法都是共通的)。

virtual函数则又是所有这些继承层次结构的关键。在Visual C++、Java以及Visual Basic这样的系统中,你经常需要根据一个泛化的Form、Windows或Document类来创建子类,从而创建你自己的窗体、窗口或文档。操纵系统调用你的对象来执行特定的任务,比如Repaint,Resize和Move等等。这些行动全部以virtual函数的形式来实现(虽然在Visual Basic中,这一点并不明显)。正是因为使用了virtual函数,才保证最终调用的是你自己的函数,使用的是你自己的代码。

在所有这一切的中心,就是接口的概念。接口已经明确成为Java语言的一部分,在C++中则通过类的继承来实现。

在讨论接口之前,我需要先讲讲一个奇怪的概念:纯virtual函数。纯virtual函数是(至少在基类中)没有任何实现的一个函数。为了指定一个纯virtual函数,你需要使用“= 0”表示法。例如,一个类可以像下面这样定义normalize:

1
2
3
4
class Number {
protected:
virtual void normalize() = 0;
};

这里的normalize就是纯virtual的。声明中没有函数定义。
像这样的声明有什么意义?嗯,它和下面要讨论的抽象类有关。


抽象类和接口

抽象类是包含一个或多个纯virtual函数(没有具体实现的virtual函数)的类。上一节的Number类就是抽象类。

一个重要的规则是:抽象类不能实例化。所谓“实例化”,其实就是声明类的对象的过程。

1
2
3
Number a, b, c;     // 错误!Number是抽象类
// 它有一个纯virtual函数,所以,
// a, b 和c不能创建

抽象类真正的用处是作为它的子类的一个常规模式来使用。假定你拥有一个进行Windows开发的继承层次结构,其中包含一个名为Form(窗体)的抽象类,那么可以创建它的子类,从而生成单独的、具体的窗体。

使用一个子类来实例化(创建)对象之前,它必须为所有纯virtual函数准备好函数定义。只要类中留下一个未实现的函数,它就是抽象类,所以不能用于示例化对象。

在抽象类和纯virtual函数的帮助下,你可以指定并强制一系列常规服务(我们可以将它们统称为“接口”,即使当前并不是Java语言)。关于接口,你需要遵守几个重要的基本规则:

  • 每个子类都可以安装自己希望的方式,自由的实现所有这些服务(也就是纯virtual函数)。
  • 每个服务都要实现,否则类不能实例化。
  • 每个类都必须严格遵守类型信息——包括返回类型和每个参数的类型。这样才能建立一个有序的继承层次结构,使那些明显错误的行动(比如传递类型不正确的数据)能及时被编译器发现。

子类的作者知道自己必须实现接口中定义的服务(比如Repaint,Move和Load),但除此之外,它们是自由的。另外,由于所有这些函数都是virtual的,所以总是能够执行正确的实现,不过具体如何访问一个对象。
在后文中,我们将用一个具体的例子来展示它的用处。


cout为什么不是真正多态的

现在让我们重提cout和流操作符(<<)的话题。这是一个近于(但不完全是)多态性的例子。
使用cout来打印输出(和C的printf函数不同)的优点在于,这一特性可以适用于任何类。例如,请回忆一下如何使cout与Fraction类配合使用。下面是运行这一行为的函数定义:

1
2
3
4
ostream &operator<<(ostream &os, Fraction &fr) {
os << fr.num << "/" << fr.den;
return os;
}

所以,从理论上说,对于任何给定的对象,以下语句似乎都能正常工作(不管是什么类型):
cout << "The value of the object is " << an_object;
然而,我刚才的说法隐藏着一个重要的前提条件。cout函数要想和一个对象配合使用,必须能在程序编译时知道对象的类型。这意味着客户端代码必须清楚地知道对象的类型。

但是,这种说法肯定正确吗?有没有可能引用一个类型没有完全定义的对象呢?

实际上,完全可以引用一个类型没有完全定义的对象。一个办法是使用一个void指针,这种指针执行任何类型的一个对象。但假如你使用这样的一个指针,并提领它(使用*),那么cout不知道如何打印对象。

1
2
void *p = &an_object;
cout << *p; // 错误!*p不能打印

理想情况下,你应该能这样做:使用一个提领的对象指针(也就是像*p 这样的一个表达式),就能以正确的格式来打印对象。另一种说法是:如何打印对象的有关信息是对象本身内置的。

这是非常重要的一个能力,因为你拿到的一个指针可能通过Internet,指向一个新的对象类型,或者指向来自另一个程序的新的对象类型。你希望,只要对象所属的类是遵循恰当的接口而设计的,就能以正确的格式来打印它。

为此,你可以声明一个名为Printable的抽象类,类中声明了一个名为print_me的纯virtual函数。在下一个例子中,我将展示如何创建Printable的子类,并实现上述函数,使子类的对象能够由cout(或者其他任何ostream类)正确地打印——即使只提供了一个常规的流指针。

换言之,我们要保证下面这样的语句能够正常工作——虽然除了an_object所属的类是Printable类的一个子类之外,我们不了解有关an_object的一切东西。

1
2
3
Printable *p = &an_object;
cout << *p; // 该语句能根据an_object所属的类的定义
// 以正确的格式打印对象

为了使上述代码能够正常工作,有一条重要的规则必须遵守。在能够传递一个基类对象的地方,都能传递子类类型的一个对象。另外,指向一个子类类型的对象的指针可以传递给基类类型的一个指针。或者更简单地说:

基本规则:更具体的东西(一个子类对象或者指针)肯定可以传递给更常规的东西(一个基类对象或指针)。


示例2:真正多态的printable类

下一个例子演示了如何与真正多态的cout(以及其他ostream)协作。通过遵循一个常规的接口(这里就是抽象类Printable),你可以正确地打印任何类型的对象——即使编译时不知道那个对象的确切类型。
从表面上看,这是不可能实现的,但实际则不然。之所以能在不知道对象的类型的前提下打印该对象,是因为你(也就是客户端)根本不需要知道如何打印一个对象。与如何正确打印有关的信息是对象自身内置的。你惟一需要知道就是对象实现了恰当的接口。
Printme.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
using namespace std;

class Printable {
virtual void print_me(ostream &os) = 0;

friend ostream &operator<<(ostream &os, Printable &pr);
};

// operator<<函数:
// 惟一要做的就是调用virtual函数print_me
//
ostream &operator<<(ostream &os, Printable &pr) {
pr.print_me(os);
return os;
}

// 从Printable类派生的子类
//-------------------------------------------

class P_int : public Printable {
public:
int n;

P_int() {};
P_int(int new_n) { n = new_n;}
void print_me(ostream &os);
};

class P_dbl : public Printable {
public:
double val;

P_dbl() {};
P_dbl(double new_val) {val = new_val;}
void print_me(ostream &os);
};
// print_me函数实现
//--------------------------------------
void P_int::print_me(ostream &os) {
os << n;
}

void P_dbl::print_me(ostream &os) {
os <<" "<< val;
}

// main函数
//------------------------------------
int main() {
Printable *p;
P_int num1(5);
P_dbl num2(6.25);

p = &num1;
cout << "Here is a number: "<< *p <<endl;

p = &num2;
cout << "here is anotherL " << *p <<endl;
return 0;
}

幕后玄机

本例的代码包括3个主要部分:

  • 抽象类Printable
  • 子类P_int和P_dbl,它们分别包含一个整数值和一个浮点值,并决定具体如何打印对象。
  • main函数,它对所有这些类进行测试。

Printable类是一个抽象类,你也可以把它视为定义了单个服务的一个接口,这个服务就是virtual函数print_me。

1
2
3
4
5
class Printable {
virtual void print_me(ostream &os) = 0;

friend ostream &operator<<(ostream &os, Printable &pr);
};

类的基本概念是相当简单的:Printable的子类实现了函数print_me,定义它们如何将数据发送给异构输出流(换言之,发送给ostream类的任何对象)。

Printable类还定义了一个全局的友元函数。该函数将以下表达式:
cout << an_object
转换成对对象自己的print_me函数的一个调用:
an_object.print_me(cout)
由于print_me是virtual函数,所以总是能调用print_me的正确版本——不管具体如何访问对象。正如这个例子的代码演示的那样,你可以使用指向一个对象的常规指针:

1
2
3
Printable *p = &an_other;
// ...
cout << *p;

结果就是,指向的对象将采用和那个对象的类相符的正确格式来打印——即使在编译时不知道具体是什么类。这样一来,便实现了真正的多态性。

在这个特定的例子中,print_me实际的实现并没有做多少事情,但那并不重要。整数和浮点值很容易打印。我这里对它们进行了少许区分——为浮点实现打印两个额外的空格——这样一来,你就能注意到调用的是不同版本的print_me。

1
2
3
4
5
6
7
void P_int::print_me(ostream &os) {
os << n;
}

void P_dbl::print_me(ostream &os) {
os <<" "<< val;
}

其他类的print_me函数实现可以更有趣。例如,下面是为Fraction类实现的print_me:

1
2
3
void Fraction::print_me(ostream &os) {
os << get_num() "/" << get_den();
}

练习

修订Fraction类,使它成为Printable的子类,并实现print_me函数。然后,使用如下代码来打印一个Fraction对象,从而对结果进行测试:

1
2
3
4
Fraction fract1(3, 4);
// ...
Printable *p = &fract1;
cout << "The value is " << *p;

如果一切正常,Fraction对象会采用正确的格式来打印。提示:为了确保Printable类的声明不被编译两次,你需要将Printable的声明移至Fract.h文件的开头,然后包容Fract.h。另外,在子类化时,记住使用public关键字。
另外,你可以为Fraction类创建一个“wrapper”(包装器)类,就像为整数和浮点数准备的wrapper类那样(Pr_int和Pr_dbl)。


关于面向对象

我上个世纪90年代刚开始学习面向对象编程时,整理出了这样的一个概念:面向对象编程(OOP)其实就是创建单独的、自包容的的实体,这些实体通过相互发生消息来进行通信。

即使现在,为了帮助自己理解一些重要的编程思想,上述模型仍然有用。单独的、自包容的实体倾向于将它们的内容包含起来,所以,它们具有了“封装性”——使自己的数据保持私有的一种能力。

然后,我不确定这个模型如何反映出“继承”的概念,虽然你可以自己完善它。如果将每个单独的对象都比喻成一个微处理器或者芯片(让我们全部用硬件术语来描述它),那么在理想情况下,你应该能拔出一个芯片,改进一番之后,再把它插回去。

总之,“相互发生消息的独立实体”模型是帮助你理解多态性和virtual函数的一种很好的方式。
我在示例2中说过解释过Printable接口的问题。这里用一条基本规则来进一步解释它:

基本规则:使用一个对象时,你无需知道它的确切类型,也无需知道它的函数代码的位置,因为具体应该如何提供服务,相关的信息已经在对象自身中内置,而不是由对象的用户来提供。

这个基本规则与“通过发生消息来进行通信的单独的对象”的概念是一致的。对象的用户不需要指示对象如何完成它的工作。另一个对象中包含的内容是保密的。你只需发生一条消息,对象就会恰当地做出响应。

从本质上说,这相当于对象(独立的代码及数据实体)从对其他对象的内部结构的盲目依赖中解放出来。

但这样做的结果并不是制造更多的混乱。面向对象编程系统要求进行严格的类型检查。如果你想支持一个接口,就必须实现接口的所有服务(也就是virtual函数),而且你必须与参数列表中的类型严格匹配。接口(不同的类和对象进行交互的地方)在面向对象编程中是受到严格控制的。

但是,只要你遵循了正确的接口,就能更加灵活地实现一个函数。编写客户端代码时,你不需要实际地写好了这个函数。例如,以下代码总是能正确地工作——不需要修订或重新编译——即使an_object的具体类型发生了改变。

1
2
Printable = &an_object;
cout << *p;

更令人高兴的是,p可以指向由系统的另一部分提供的一个对象。打印操作(cout<<*p)总是能正确地完成,即使程序根本不知道p指向哪里——它只知道指向的对象自己实现了Printable接口。


写在最后

但是,所有这一切有什么意义呢?多态性为什么如此重要呢?是因为它有助于代码的重用吗?嗯,是的。但我可以肯定地说,那并不是全部。

面向对象编程针对的是系统······图形系统、网络系统以及我们每天越来越依赖的其他系统。在一个图形用户界面(或者在网络中),各个组件确实都像独立的对象那样,通过相互发生消息来进行交互。

传统编程技术则是为一个不同的世界开发的,在这个世界中,只需提交一叠穿孔卡,看到程序成功开始、运行和结束,中途没有出现“打哆嗦”的情况,开发人员就可以开始欢呼雀跃了。在这个世界中,你假定自己能控制一切。这个世界非常有限,但它更简单。

但是,今天的软件变得越来越复杂,涉及面更广。例如,Microsoft Windows的成功,要部分归功于它丰富的组件集。同时,如果使用传统编程技术,那么很难实现组件模型。构造这样的系统时,要求现有的软件能够与将来出现的新组件挂钩。多态性和独立对象正是为了满足这一需求而设计的。

毕竟,这种看问题的方式更接近大千世界的本来面目。面向对象的一个令人兴奋不已的观点时:“它能更紧密地对真实世界进行建模。”当然,这是一个有点儿夸大的观点,但不是没有无可取之处。我们确实生活在一个复杂的世界。我们确实要同相互独立的事物与人进行交互。如果某种知识/技能是我们不曾拥有的,那么我们会选择信任拥有这种特殊知识/技能的人。老实说,“面向对象编程”真正的含义是不要将数据结构视为纯粹的对象(事实上,你应该将它们视为独立的“代理”)。

假如我们能将软件对象解放出来,为它们赋予一定程度的独立性,让它们能自由地去做它们知道如何做得最好的事情,就相当于解放了我们自己以及其他程序员。


小结

下面总结了第17章的要点:

  • 多态性是值对象自身内置了“如何提供一个服务”的相关知识,而不是由客户端代码(也就是使用对象的代码)来操心如何提供服务。这样做的结果就是,针对同一个函数调用或者同一个操作,可能出现数量不受限制的,形形色色的解释。
  • 多态性是通过virtual函数来实现的。
  • virtual函数的地址要等到运行时才能解析出来(这也称为晚期绑定)。这样一来,对象所属的类(运行时才能确定是哪一个类)就是负责决定具体执行virtual函数的哪一个实现。
  • 为了声明一个virtual函数,需要为它附加一个virtual关键字。例如:
    1
    2
    protected:
    virtual void normalize();
  • 一旦将函数声明为virtual,它在所有子类和所有上下文中都是virtual的。只需为每个函数使用一次virtual关键字。
  • 不能使构造函数或内联函数成为virtual函数。
  • 然而,析构函数可以是virtual函数。
  • 函数成为virtual函数之后,会在性能和空间上受到少量惩罚。然而,由于当今计算机速度普遍较快,所以这一点执行效率的损失是可以忽略不计的。
  • 通常,任何可能被覆盖的成员函数都应声明为virtual函数。
  • virtual函数是指在声明它的类中没有实现的函数。为了声明一个纯virtual函数,你需要使用”= 0”表示法。例如:
    virtual void print_me() = 0;
  • 只要类包含了一个纯virtual函数,它就是一个抽象类。不能用这样的类来实例化对象。换言之,不能用它声明对象。
    Number a, b, c; //错误
  • 虽然不能实例化具体的对象,但我们可以使用抽象类——创建一个常规接口——也就是一个服务列表,必须由子类来实现这些服务(通过实现所有virtual函数)。
  • 根据我们最后的分析,多态性的作用是将对象从盲目的相互依赖中解放出来(因为有关具体提供一个服务,这方面的知识已在每个单独的对象中内置了)。正是因为这一特性的存在,才使“面向对象编程”(OOP)具有了独特的魅力,使其最终面向的是对象,而不是面向类。

c++学习记录七:多态性:对象独立性(抽象类,虚函数)

http://example.com/2018/01/30/cplusplus7/

作者

bd160jbgm

发布于

2018-01-30

更新于

2021-05-08

许可协议