c++学习记录六:继承

(本文来自c++简明教程)
类最突出的一个特性就是“子类化”(subclassing),也就是一个类从以前定义好的一个类继承它的成员。处于几方面的原因,这个特性显得非常重要。
第一个原因是子类化处理允许你定制现有的软件。你可以拿到别人创建的一个类,在上面添加自己独有的新功能。还可以覆盖类的任何或者全部功能。这样一来,类就具有了扩展性和重用性(我保证这是你在本章见到的最后两个专业术语)。
听起来似乎很不错……有时的确如此!但是,考虑到本章要讨论的一些技术原因,你的工作并非总是那么轻松。最起码,你必须修订所有构造函数。
使用一个继承层次结构和创建接口时,也需要用到“子类化”技术,详情将在下一章讨论。


子类化

子类是具有另一个类(称为基类)的所有成员,而且允许你添加新的成员。下面是声明一个子类时的语法:

1
2
3
class 类名:public基类 {
声明
};

类名是指你要声明的一个新类的名称。它继承了基类的所有成员(构造函数除外——详情在后文讲述)。

声明是指数据成员、成员函数或两者的组合——这与标准的类声明是一样的。声明代表在现有成员(已经从基类继承)的基础上新增的成员。

在声明中,你也可以指定一个或多个已经在基类中声明的成员。这样一来,现有基类成员的声明就会被覆盖,所以会在子类中忽略。

通常,为避免混淆,你只应将“覆盖”技术用于现有的函数提供新的定义。“覆盖”是一项重要的变成技术,它是下一章要讨论的主题。
下例将在Fraction类的基础上创建一个新类。

1
2
3
4
class FloatFraction : public Fraction {
public:
double get_float();
};

上述代码展示了一个简单的概念:它将FloatFraction类声明为现有的Fraction类的一个子类。结构就是,每个FloatFraction对象都拥有Fraction类中声明的所有成员。除此之外,每个FloatFraction对象都新增了一个名为get_float的成员函数。
声明好这个类之后,你可以像使用其他任何类那样,把它作为一个类型名称来使用:

1
2
3
4
FloatFraction f1;
f1.set(1, 4);
cout << "The decimal representation os "
<< f1.get_float();

假定get_float函数已经正确定义,上述代码会打印结果“0.25”。
下面是基于String类来创建一个新类的例子。这个例子稍微复杂一些。

1
2
3
4
5
6
7
8
9
10
class ExtString : public String {
private:
int nullTerminated;
public:
int length;
int isNUllTerminated()
{
return nullTerminated != 0;
}
};

上述代码将ExtString声明为String类的一个子类,所以,它包含了String类的成员。除此之外,它还包含 私有数据成员nullTerminated。由于这个成员是私有的,所以在类的外部定义的代码不能引用它,但外部的代码能够访问两个公共成员,即length(一个数据成员)和isNullTerminated(一个函数)。

下面是从Fraction类继承的另一个例子,但这一次是通过一个中间类(FloatFraction)来继承的:

1
2
3
4
5
6
class ProperFraction : public FloatFraction {
public:
int get_whole();
int get_num(); //重载函数
void pr_proper(ostream &os);
};

这里的基类是FloatFraction。换言之,ProperFraction(真分数)在这里相当于“孙辈”的一个类,它除了包含FloatFraction类的所有成员,还包含Fraction类的所有成员(因为FloatFraction本身又是Fraction的子类)。

这些声明创建了一个层次结构。在这个层次结构中,ProperFraction是Fraction的一个间接子类。

作为最后一个例子,以下是直接从Fraction类继承的另一个类:

1
2
3
4
class FractionUnits : public Fraction {
public:
String units;
};

在FractionUnits的声明中虽然涉及到String类,但String类即不是一个基类,也不是一个子类。你可以说每个FractionUnits对象都有“具有”一个String对象,而且它是一种Fraction。换言之,它是一种更具体的Fraction对象。
你可以这样想:当A是B的一个子类时,你可以说A是一种更具体的B。“狗”是“哺乳动物”的一个子类,而“哺乳动物”是“动物”的一个子类,以此类推。与此同时,“狗”类包含了“牙齿”类、“尾巴”类,等等。

1
2
3
4
5
6
class Dog : public Mammal {
public:
Teeth dog_teeth;
Tail dog_tail;
// ...
};

小插曲:为什么要声明public基类?

在前面给出的声明子类的语法中,你应该注意到其中使用了public关键字来限定基类。在这种情况下,该关键字规定了“基类访问级别”。

1
2
3
class 类名 : public 基类 {
声明
};

从技术上说,你可以进行一个直接的声明,在其中省略public关键字:

1
2
3
class FloatFraction : Fraction {
// ......
};

和类的成员声明一样,这里的问题在于,默认基类访问级别是private。但是,private访问级别在这种情况下通常是不适宜的。尤其是,如果使用private基类访问级别,那么从基类继承的所有成员都会在新类中成为private成员。例如,get_num函数虽然会继承,但它会成为private成员函数——所以不能在类的代码外部访问。

1
2
FloatFraction aFract;
cout << aFract.get_num(); //错误:假如get_num为private成员,则为非法

相反,如果使用public基类访问级别,那么所有基类成员将按照原来的样子进行继承。这几乎一点是你所希望的,所以最好养成习惯,在基类名称前添加一个public
除了public之外,很少需要使用其他基类访问级别。这是c++语法设计得不好的一个例子。这种设计对于大多数人来说都是没有用处的,而且它的来源已经无从考证。但幸运的是,解决方案也非常简单,你只需记住在基类名称前加一个public


示例1:FloatFraction类

下一个例子标志着本书的程序开发水平将迈上一个新的台阶。我们不再将全部内容都放在一个文件中。相反,我假定源代码存储在两个单独的文件中,本例假定是Fract.h和Fract.cpp。为了方便起见,后文将列出两个文件的内容。

首先展示的是实现文件的代码,它负责对FloatFraction类本身进行测试。注意,它用一个**#include预编译命令添加基类Fraction**的类型信息。
FloatFract1.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include "Fract.h"
using namespace std;

class FloatFraction : public Fraction {
public:
double get_float() {
return static_cast<double> (get_num())/get_den();}
};
int main() {
FloatFraction fract1;

fract1.set(1, 2);
cout << "Value of 1/2 is " << fract1.get_float() << endl;
fract1.set(3, 5);
cout << "Value of 3/5 is " << fract1.get_float() << endl;
return 0;
}

这是一个很短的文件,因为它只包含了子类FloatFraction所需的代码。这个类其实涉及到大量c++代码,只是那些代码已经包含在基类Fraction中。换言之,这里只是在FloatFraction类中重用那些代码:
Fraction类的声明包含在文件Fract.h中。
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
#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); //新增
private:
void normalize(); //将分数转换为标准写法
int gcf(int a,int b); //gcf代表最大公因数
int lcm(int a,int b); //lcm代表最小公倍数
};

用于实现Fraction类成员函数(内联的函数除外)的代码则放在文件Fract.cpp中。注意,这个文件必须添加到当前项目中。请查看你的开发环境的Project(项目)和File(文件)菜单,并查找一个“Add New Item”命令。
Fract.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
62
#include "Fract.h"
// -----------------------------------------------------
// Fraction类的成员函数

// Normalize(正规化):将分数转换成标准形式,
// 从数学意义上来看,每个不同的值都对应一个不同的分数
//

void Fraction::normalize()
{
//处理0的情况
if( den == 0 || num == 0) {
num = 0;
den = 1;
}
//确保只有分子才能为负数
if(den < 0) {
num *= -1;
den *= -1;
}

//分解出gcf
int n = gcf(num, den);
num = num / n;
den = den / n;
}
//最大公因数
int Fraction::gcf(int a,int b)
{
if(a % b ==0)
return abs(b);
else
return gcf(b,a % b);
}
//最小公倍数
//
int Fraction::lcm(int a, int b) {
return (a / gcf(a, b)) * b;
}
Fraction Fraction::add(const Fraction &other) {
Fraction fract;
int lcd = lcm(den, other.den);
int quot1 = lcd / den;
int quot2 = lcd / other.den;
fract.set(num * quot1 + other.num * quot2, lcd);
return fract;
}
Fraction Fraction::mult(const Fraction &other) {
Fraction fract;
fract.set(num * other.num, den * other.den);
return fract;
}

int Fraction::operator==(const Fraction &other) {
return (num == other.num && den == other.den);
}
// ---------------------------------------
// Fraction类的友元函数
ostream &operator<<(ostream &os, Fraction &fr) {
os << fr.num << "/" <<fr.den;
return os;
}

注意:如果你正在使用Microsoft Visual Studio,请记住每个.cpp文件都需要以#include “stdafx.h”开头。一定要在Fract.cpp以及其他每个.cpp文件的开头插入这个预编译指令。


幕后玄机

这个例子中,最引人注意的一点是FloatFraction1.cpp中的代码相当段!
在子类FloatFraction中,我们只添加了一个函数,即get_float。由于函数定义很短,所以这里对它进行了内联。在函数定义中,我们利用了一个小技巧:在执行除法运算之前,先对get_num表达式的数据类型进行强制转换。这样一来,程序最后执行的就是浮点除法,所以生成的是一个浮点结果:

1
2
double get_float() {
return static_cast<double> (get_num())/get_den();}

查看函数定义时,你或许会觉得奇怪:代码为什么用get_num和get_den函数来分别获得num(分子)和den(分母)的值?它们不是成员吗?既然是成员,为什么不能直接访问它们的值呢?

为Fraction类本身写代码时,上述说法是成立的。但是,这里并不是为Fraction类本身写代码,我们写的是FloatFraction这个子类的代码。由于num和den是私有成员,所以它们不能直接由其他类来访问——Fraction的子类也不例外!因此,FloatFraction的代码必须使用get_num和get_den来获取这些成员的值。

在main中,我们使用了FloatFraction子类新增的成员函数(get_float)和继承的成员函数(set):

1
2
3
4
fract1.set(1, 2);
cout << "Value of 1/2 is " << fract1.get_float() << endl;
fract1.set(3, 5);
cout << "Value of 3/5 is " << fract1.get_float() << endl;

练习

  1. 修改示例1,在FloatFraction类中新增一个set_float成员函数。函数应该获取一个double类型的参数,并用它设置numden的值。一个办法是:
    1. 用100乘以参数值,取整;
  2. 将num设置成这个值;
  3. 将den设为100;
  4. 调用normalize。

注意:如果set函数来进行步骤2和步骤3,那么步骤4能够自动进行。提示:取整时,请使用到int的一次强制类型转换:
new_value = static_cast(value * 100.00)

2.为FloatFraction类写一个构造函数,它能接受单个double类型的参数。在完成了练习1之后,这个工作会非常简单。


FloatFraction类的问题

子类的创建几乎肯定能像上一节展示的那样轻松地完成。但遗憾的是,在你试验了几次FloatFraction对象之后,很快就会发现那个类存在的局限。
例如,以下代码完全能按你预想的那样工作(假定定义了一个完整的Fraction类):

1
2
3
4
Fraction f1(1, 2);
f1 = f1 + Fraction(1, 3);
if(f1 == Fraction(5, 6))
cout << "1/2 + 1/3 = 5/6";

但是,如果你换用FloatFraction类(根据前面所说的,它应对支持Fraction类支持的一切),那么以下每一个语句都会造成错误:

1
2
3
4
FloatFraction f1(1, 2);             //错误
f1 = f1 + FloatFraction(1, 3); //错误
if (f1 == FloatFraction(5, 6)) //错误
cout << "1/2 + 1/3 = 5/6";

对于当前版本的FloatFraction类来说,存在两个需要解决的问题(幸好每个问题都很容易解决):

  • 一个问题是子类没有继承构造函数。
  • 另一个问题是Fraction类的许多函数都返回Fraction类型的对象,或者获取Fraction类型的参数,而不是FloatFraction类型。

现在让我们依次研究这两个问题。构造函数的问题最严重,所以值得为它列出一条基本规则。

基本规则:子类不会继承构造函数,所以不要依赖于基类的构造函数。

c++不允许继承构造函数,这看似不公平,但c++的这种设计是有道理的。假如子类在基类的继承上添加了一个或多个数据成员,那么会发生什么?例如:

1
2
3
4
5
class FloatFraction2 : public Fraction {
public:
double float_amt;
int whole;
};

Fraction的构造函数在设置num和den的值是,会调用set函数,而set函数会同时调用normalize函数来相应地调整上述两个值。但在上面的例子中,新的数据成员float_amt和whole还能享受到这一切吗?

c++的一个设计理念在于,你应当总是写自己的构造函数——即使是在创建一个子类的时候。除此之外,你应该初始化每个成员。由于一个基类构造函数可能不够用(因为它可能无法初始化新的成员),所以c++认为你应该为每个子类写一套全新的构造函数。

不过,这个规则也有例外的时候。以前讲过,如果类的作者没有提供,那么编译器会自动提供3个特殊的成员函数。对于子类来说,情况将变得更加复杂,但类似的规则任然适用。其中每个函数最终都要使用基类。


子类的默认构造函数

如果类的作者没有为一个类写任何构造函数,编译器会自动提供一个默认构造函数。由于FloatFraction类的当前版本不包含它自己的构造函数声明,编译器会提供一个默认构造函数。

将子类考虑在内,自动提供的默认构造函数的一般规则是:它首先调用基类的默认构造函数,然后,它对子类中的每个新的数据成员进行清零处理。

注意:假如一个成员本身就是一个对象,就会调用那个对象自己的默认构造函数。


子类的拷贝构造函数

以前讲过,假如类代码本身没有声明一个拷贝构造函数,编译器就会自动提供一个默认拷贝构造函数。编译器提供的版本首先调用基类的拷贝构造函数。然后,它对每个新的数据成员执行直接的、逐个成员的拷贝操作。


子类的赋值函数

你或许已经猜到:自动赋值函数(如果你不自己写一个,编译器就会提供一个)首先调用基类赋值函数。然后,它对每个新的数据成员执行一次直接的、逐个成员的拷贝操作。


添加遗漏的构造函数

遗漏的FloatFraction类的构造函数很容易提供。它们所做的事情要比Fraction的构造函数做的多一些。

1
2
3
4
5
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());}

为了生成上述代码,可以拷贝并粘贴Fraction的构造函数代码,将其中的每一个“Fraction”替换成“FloatFraction”——使用字处理软件或者文本编辑器,可以很容易地完成这个操作。

除了简单地进行替换,还对一个地方进行了修改。你或许想要保持拷贝构造函数的简单定义,使其变成下面这:

1
2
FloatFraction(const FloatFraction &src) 
{set(src.num, src.den);}

问题在于,num和den都是Fraction类的私有成员。这意味着不能从其他类访问这些成员,即使是从Fraction的子类中(本章稍后还会讲到这个问题)!所以,在FloatFraction的代码中,你需要使用公共函数get_num和get_den。


解决与基类的类型冲突

如果仔细测试一下FloatFraction类,会发现它的大多数运算都无法工作。set函数是一个重要的例外,它几乎是继承之后惟一能工作的函数。
但是,c++的这个设计并不是没有道理的。例如,我们来看Fraction::add函数是如何声明的:

1
Fraction add(const Fraction &other);

该函数在FloatFraction类中得到了完全的继承和支持。但看看该函数所做的事情:它获取Fraction类型(而不是FloatFraction类型)的一个参数,并返回Fraction类型的一个值(同样地,不是FloatFraction类型)。
结果就是,你可以在下面这样的语句中使用add函数:

1
2
3
Fraction f1, f2;
FloatFraction ff;
f1 = ff.add(f2);

上述代码正确使用了所有类型,因为它不厌其烦地使用Fraction类来传递和接收值。但是,这样做的用处毕竟是有限的。
你真正想做的——但目前还不能——是创建混合了多个FloatFraction对象的表达式:

1
2
FloatFraction f0, f1, f2;
f1 = f0.add(f2);

幸运的是,有一个简单的方案可以解决这种问题。只需添加一个新的构造函数,它能执行从Fraction类的转换:

1
2
FloatFraction(Fraction fract)
{ set(fract.get_num(), fract.get_den()); }

现在,一切问题都能迎刃而解。add函数返回Fraction类型的一个对象,但那个值能够轻松地转换成FloatFraction类型。这样做之所以有效,是因为FloatFraction不包含新的数据成员。所以,不需要额外做什么,就能使来自Fraction类的运算正常进行。但是,正如本章后面的示例2所示,如果涉及新的数据成员,你就必须覆盖某些Fraction成员函数。


示例2:最终的FloatFraction类

为了使FloatFraction类按照我们希望的那样工作,有必要提供所有必要的构造函数,同时提供FloatFraction(Fraction)类型的一个构造函数。下面是完整的代码。
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
#include <iostream>
#include "Fract.h"
using namespace std;

class FloatFraction : public Fraction {
public:
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 static_cast<double> (get_num())/get_den();}
};
int main() {
FloatFraction f1(1, 2), f2(1, 3), f3;

f3 = f1 + f2;

cout<< "Value of f2 is "<< f3 << endl;
cout << "Float value of f3 is " << f3.get_float() << endl;
return 0;
}

幕后玄机

本例应该很容易理解。在修订后的FloatFraction声明中,包含所有必须的构造函数。所以像下面这样的声明是完全支持的:

1
FloatFraction f1(1, 2), f2(1, 3), f3;

类还提供了从Fraction转换成FloatFraction的构造函数:

1
2
FloatFraction(const Fraction &src)
{ set(src.get_num(), src.get_den()); }

由于存在上述构造函数,所以Fraction类中声明的所有数学运算都能应用与FloatFraction类——例如,operator+函数返回一个Fraction对象不再成为一个问题,因为对象的类型会根据需要自动转换成FloatFraction。所以,以下语句能够正确执行:
f3 = f1 + f2;

练习

  1. 修改示例2的main部分,证实测试相等性操作符(==),加法和乘法操作符能够正确工作。
  2. 回答下列问题:有了这个最终版本的FloatFraction,是否能在任何情况下自由地混合FloatFraction和Fraction对象?

示例3:ProperFraction类

本节展示了如何创建一个子类的子类,从而对继承进行扩展。并非一定要采取这种方式来编写类,你可以让所有子类都直接从同一个基类继承(换言之,创建一个“平板”式的类层次结构)。但是,你仍然有必要知道一个子类可以成为另一些子类的基类。

每次创建一个子类,并构成层次结构的另外一个级别时,都会添加更多的功能/容量来存储数据。正如前面讲述的那样(使用狗/哺乳动物/动物的例子),你最好将一个子类视为基类的一个更具体的版本。请注意以下基本规则。

基本规则:通常,你应该创建一个现有类的子类,以便为那个类添加更多具体的功能或者容量。

同样地,假如A是B的一个子类,A就应该被视为一种B,就像够应被视为一种哺乳动物一样。

但老实说,这只是面向对象编程的一个理论性框架。有的时候,你之所以要创建子类,完全是因为用它能最快地完成工作。假定你已经声明了FloatFraction类,现在希望它的子类ProperFraction(真分数)中添加更多的函数。本例展示了如何生成FloatFraction的子类(注意,FloatFraction本身又是Fraction的子类)。
PropFract1.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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include "Fract.h"
using namespace std;

class FloatFraction : public Fraction {
public:
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 static_cast<double> (get_num()) / get_den();
}
};

class ProperFraction : public FloatFraction {
public:
ProperFraction() { set(0, 1); }
ProperFraction(int n, int d) { set(n, d); }
ProperFraction(int n) { set(n, 1); }

ProperFraction(const ProperFraction &src)
{set(src.Fraction::get_num(), src.get_den());}
ProperFraction(const FloatFraction &src)
{
set(src.Fraction::get_num(), src.get_den());
}

ProperFraction(const Fraction &src)
{
set(src.Fraction::get_num(), src.get_den());
}

void pr_proper(ostream &os);
int get_whole();
int get_num();
};

int main() {
ProperFraction f1(1, 2), f2(5, 6), f3;

f3 = f1 + f2;

cout << "Value of f3 is ";
f3.pr_proper(cout);
cout << endl;
cout << "Float value of f3 is " << f3.get_float() << endl;
getchar();
return 0;
}

// 和真分数有关的函数
// -----------------------------------------
// 该函数打印真舒服;
// 使用指定的输出流(os),以“1 1/2”这样的形式
// 来打印一个对象
void ProperFraction::pr_proper(ostream &os) {
if(get_whole() != 0) {
os << get_whole() << " ";
}
os << get_num() << "/" << get_den();
}

// 该函数用于获取整数
// 使用整数除法返回整数部分
int ProperFraction::get_whole() {
int n = Fraction::get_num();
return n / get_den();
}
// 该函数用于获取分子(已覆盖)
// 返回一个真分数的分子,它
// 使用了取模(余数)除法
int ProperFraction::get_num() {
int n = Fraction::get_num();
return n % get_den();
}

幕后玄机

对于本例,你需要注意两个问题:

  • get_num函数是Fraction类的最常用的函数之一,它在ProperFraction类中被覆盖。这使问题复杂化了,你必须指定要使用哪一个版本的get_num。
  • 继承方案也有点赋值。为了确保所有类都能正常工作,你需要支持从Fraction类的转换,以及间接基类FloatFraction的转换。

get_num函数在这里进行了覆盖,因为该函数应该具有不同的行为。我们希望类像这样工作:假定一个对象存储了值5/2,对应的真分数应该是2 1/2。对于Fraction对象来说,以下代码应该打印”5/2”。

1
2
3
Fraction fract(5, 2);
cout << fract.get_num()<< "/";
cout << fract.get_den();

但是,对于一个ProperFraction对象来说,相同的代码应该打印“1/2”,因为上述代码变成了以下语句的一部分。以下语句应该打印“2 1/2”:

1
2
3
4
ProperFraction fract(5, 2);
cout << fract.get_whole() << "";
cout << fract.get_num()<< "/";
cout << fract.get_den();

在一个真分数中,由于整数部分是隔离出来的,所以分子应该输出1,而不是5。
ProperFraction对象内部存储数据的方式和Fraction对象一样。假定你用相同的方式来设置它们:

1
2
Fraction fract1(5, 2);
ProperFraction fract(5, 2);

无论ProperFraction对象,还是Fraction对象,都会为num存储值5,为den存储值2。这些对象的区别在于,当对象的用户获得一个值时,它们具有不同的行为。如果你调用get_num函数,Fraction对象会返回5,而ProperFraction对象会返回1。所以,get_num函数需要具有不同的行为。

为了实现get_num函数的ProperFraction版本,你需要一种方式来直接获取num值,这时情况变得更加复杂。但是,为了直接获取num的值,惟一的办法就是调用get_num的原始版本,而它已经被覆盖了。

幸运的是,c++提供了一个简单的解决方案:即使你覆盖了一个成员,也总是能使用作用域操作符(::)来引用基类的版本。使用这种语法,就能调用get_num的原始版本:

1
2
3
4
int ProperFraction::get_num() {
int n = Fraction::get_num();
return n % get_den();
}

即使Fraction是ProperFraction的一个间接基类,上述语法也是适用的。
其余代码则相当容易理解。你需要为ProperFraction类提供构造函数,其中包括从间接基类FloatFraction及其基类Fraction转换的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ProperFraction() { set(0, 1); }
ProperFraction(int n, int d) { set(n, d); }
ProperFraction(int n) { set(n, 1); }

ProperFraction(const ProperFraction &src)
{set(src.Fraction::get_num(), src.get_den());}
ProperFraction(const FloatFraction &src)
{
set(src.Fraction::get_num(), src.get_den());
}

ProperFraction(const Fraction &src)
{
set(src.Fraction::get_num(), src.get_den());
}

同样地,需要使用作用域操作符(::)来指定get_num的基类版本。

练习

  1. 写一个operator<<函数,它能和ProperFraction类配合,允许你直接打印到cout(以及其他输出流)。如有必要,请复习前几章的编程技术。记住,这个函数必须是一个声明为friend的全局函数:
    1
    2
    3
    ProperFraction pf;
    // ...
    cout << pf << endl;
  2. 为ProperFraction类写一个构造函数,它获取3个int参数,即w,n和d(分别代表真分数的整数部分、分子和分母)。例如,你可以输入4,2和3,将一个对象初始化为4 2/3。ProperFraction pf(4, 2 ,3);

private成员和protected成员

在本章中,访问num和den的问题使问题变得更加复杂。子类FloatFraction和ProperFraction确实继承了两个数据成员,即num和den。然而,由于这些成员是private的,所以不能在代码中访问它们(当然,除了从Fraction类自身的代码中访问)。即使子类的代码也不能引用private成员。那些成员虽然存在,但相当于“隐形”的。

所以,在子类函数的定义代码中,你必须借助get_num和get_den函数来获取num和den的值。如前所述,一旦其中的一个函数被重载(get_num),情况会变得更复杂。假如子类能直接引用num和den,事情就简单了。在这种情况下,以下函数:

1
2
3
4
int ProperFraction::get_num() {
int n = Fraction::get_num();
return n % get_den();
}

可以改写成:

1
2
3
int ProperFraction::get_num() {
return num /den;
}

然后,它可以通过内联来进一步缩短:
int get_num() {return num/den;}
看起来,确实有必要让子类也能引用num和den。最理想的结果是确保子类的代码能够访问私有数据,同时禁止除了Fraction类及其所有子类之外的其他代码访问私有数据。

确实有办法做到这一点。c++提供了第三种访问基本,即protected(受保护的)。该基本介于private和public之间。假如num和den被赋予了这个访问级别(通过使用protected关键字),那么所有子类都能直接引用它们。

1
2
3
4
5
class Fraction {
protected:
int num, den; //分子,分母
// ...
};

值得注意的是,使用protected声明的成员仍然要对访问进行限制。只有Fraction类本身和它的(直接或间接)子类才能引用受保护的成员。

那么,能不能像这样更改Fraction类的代码呢?答案是“也许”。但在某些情况下,这是不可能的。假如Fraction类已经编译,并由另一个程序员将编译后的版本交给你,就不能更改类。在这种情况下,你只能尽量克服私有数据的限制。

另一个更加深入的问题是:需要将类成员声明为protected(而不是private)吗?一方面,你也许很快就能决定是不是应该声明为public,但另一方面,一旦需要在protected和private之间做出选择,局面就不那么明朗了。如果声明为private,表面你不希望任何人在写一个子类时更改它或者注意它。例如,让lcd和gcf这两个函数成为private函数也许时有意义的,因为任何人都不应该改动它。它们只由normalize调用,后者确实是惟一应该使用它的函数。

其他成员则也许应该设置成protected成员。normalize函数本身就是一个很好的候选者,因为该函数具有常规性的用处。除此之外,就像前面展示的那样,数据成员num和den在设置成protected成员之后,子类的作者可以很方便地引用它们。

但是,假如一个子类的作者试图直接设置num和den,而不是依赖set函数。就可能出问题。所以,取决于你对子类作者的信任程度,也许有必要使num和den保持private状态。
下面总结了c++的3个访问级别:


示例4:包含的成员(FractionUnits)

在本章的最后,我准备再开发一个子类(从Fraction继承),它包含一个新的数据成员,即units。子类添加的新数据成员可以具有任何类型,这里使用的是一种对象类型,换言之,是另一个类。如前所述,这种额外的类型——String——虽然不是当前的类层次结构的一部分,但包含在使用它的类中。

就像本章前两个例子做的那样,我假定基类Fraction的所有代码都放在文件Fract.h和Fract.cpp中,你需要将这两个文件添加到项目中。在此,我假定String类才采取类似的方式来处理,它应该由文件StringClass.h和StringClass.cpp来支持。为了运行下一个例子,首先根据上一章的代码来创建这些文件。除此之外,你还可以使用c++内建的string类(参见代码之后的“注意”)。
FractionUnits1.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
#include "Fract.h"
#include<string>
#include<iostream>
#include<string.h>
using namespace std;

class FractionUnits : public Fraction {
public:
string Units;

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

FractionUnits(const FractionUnits &src)
{set(src.get_num(), src.get_den()); units = src.units;}


// 重载的函数
FractionUnits add(const FractionUnits &other);
FractionUnits operator+(const FractionUnits &other)
{return ad(other);}

int operator==(const FractionUnits &other);

friend ostream &operator<<(ostream &os, FractionUnits &fr);
};

int main() {
FractionUnits f1(1, 2), f2(4, 3);
f1.units = "feet";
f2.units = "feet";
FractionUnits f3 = f1 + f2;
cout << "The length of the item is " << f3 << endl;
return 0;
}

// FRACTIONUNIT函数
// ----------------------------------------------
FractionUnits FractionUnits::add(const FractionUnits &other) {
FractionUnits fr = Fraction::add(other);
if(units == other.units)
fr.units = units;
return fr;
}

int FractionUnits::operator==(const FractionUnits &other) {
return Fraction::operator==(other) && units == other.units;
}

ostream &operator<<(ostream &os, FractionUnits &fr) {
os << fr.get_num() << "/" <<fr.get_den();
if(fr.units.size() > 0)
os << " " << fr.units;
return os;
}

注意:如果你的编译器支持新的string类,就可以运行这个例子,具体做法是:(1)将#include”StringClass.h”替换成#include ;(2)string类而不是String来表明units;(3)在operator<<函数中,通过使用fr.unit.size()(而不是strlen(fr.units))来计算字符串的长度。

幕后玄机

和本章的其他例子一样,FractionUnits类声明了它自己的构造函数(记住,构造函数不会继承):

1
2
3
4
5
6
7
FractionUnits() { set(0, 1); }
FractionUnits(int n, int d) { set(n, d); }
FractionUnits(int n) { set(n, 1); }
FractionUnits(const FloatFraction &src)
{ set(src.get_num(), src.get_den());}
FractionUnits(const FractionUnits &src)
{set(src.get_num(), src.get_den()); units = src.units;}

除此之外,这个类还覆盖了几个函数。FractionUnits类的作用是允许类的用户存储某种形式的计量单位的名称(比如“feet”,“inches”,“pounds”)。它声明了一个新的数据成员units,并使其成为public成员,使FractionUnits对象的用户可以直接设置这个成员。

这个类实现了特定的行为,目的是简化单位处理(这反映了面向对象编程的一个基本概念:数据结构和行为应该一起设计和构建)。

  • 包含相同单位的两个对象相加时,单位应该予以保留。例如,1/2 inches 加 1/3 inches的结果应该是5/6 inches。所以,add和operator+函数需要覆盖。
  • 比较两个FractionUnits对象时,只有在两者具有相同的单位时,才应被视为相等。例如,1/4 feet等于 1/4 feet,但不等于1/4 miles。所以,operator==函数需要重载。
  • 将一个FractionUnits对象打印到控制台时,应该打印单位(如果有的话)。例如:
    1
    2
    3
    FractionUnits fr(1, 2);
    fr.units = "miles";
    cout << fr;
    总之,类需要覆盖add,operator==以及operator+函数,而且要支持一个新版本的operator<<函数。
    1
    2
    3
    4
    FractionUnits add(const FractionUnits &other);
    FractionUnits operator+(const FractionUnits &other)
    {return add(other);}
    int operator==(const FractionUnits &other);
    这里的函数定义尽量使用了基类的代码,因为根据面向对象的设计原则,应该尽可能地“重用”。例如,覆盖的add函数调用基类版本(Fraction::add)来完成它的大部分工作。
    1
    2
    3
    4
    5
    6
    FractionUnits FractionUnits::add(const FractionUnits &other) {
    FractionUnits fr = Fraction::add(other);
    if(units == other.units)
    fr.units = units;
    return fr;
    }
    在这里,operator+函数是一个很有趣的例子。虽然内部定义所做的事情似乎与Fraction类的版本相同,但事实上不是这样的。两个版本都调用了add函数,但是,它们各自调用的都是在它自己的类中定义的add函数版本。假如这个函数没有覆盖,operator+就会调用Fraction::add,而不是FractionUnits::add。
    1
    2
    3
    FractionUnits operator+(const FractionUnits &other) {
    return add(other);
    }
    最后,虽然operator<<函数从技术上说没有被覆盖(因为它不是一个成员函数),但是代码提供了该函数的一个版本,它与FractionUnits类进行交互。由于函数重载,这样做不会与operator<<的Fraction类的版本发生冲突。
    friend ostream &operator<<(ostream &os, FractionUnits &fr);

练习

通过覆盖mult和operator函数,为Fraction实现乘法。单位的任意组合都应该是允许的。如果一个对象包含的单位具有字符串s,而另一个是空字符串,那么单位应该设置成s。如果一个包含字符串s1,另一个包含字符串s2,结果单位就应该是s1s2。例如,1/2 feet乘以3/4 sec的结果是3/8 feet*sec。


小结

下面总结了本章的要点:

  • 为了声明一个现有的子类,要使用以下语法:
    1
    2
    3
    class 类名 : public 基类 {
    声明
    };
  • 这个上下文中的public关键字并不是不可缺少的,但强烈建议你添加它(尤其是在你刚开始学习c++时)。假如基类具有private访问级别,那么基类中的所有成员在由子类继承时,都会成为private。
  • 基类的所有成员都由子类继承(构造函数除外)。
  • 子类中的声明可以指定新成员,这些新成员和继承的成员一道,都会成为子类的成员。另外,还可以在子类中覆盖基类现有的成员。通常,只有在需要提供不同的行为的前提下,才需要覆盖成员函数。
  • 默认情况下,在引用一个类成员名称时,引用的是那个特定的类中的版本。例如,假定foo是一个被覆盖的函数,在引用foo时,引用的就是新版本,而不是基类的版本。然而,你仍然可以使用作用域操作符(::)来引用基类版本。例如,在以下的定义中,ProperFraction类中定义的get_num函数调用的是Fraction(基类)中定义的版本:
    1
    2
    3
    4
    int ProperFraction::get_num() {
    int n = Fraction::get_num();
    return n % get_den();
    }
  • 记住,构造函数不会由子类继承。每个子类必须声明它自己的构造函数。
  • 和以前一样,编译器会自动提供一个默认构造函数、拷贝构造函数以及operator=函数。对于子类,编译器提供的上述每一个函数都会调用基类版本。例如,默认构造函数将调用基类的默认构造函数,然后将子类的每个新成员设置成零或null值(但要注意:和以前一样,只有在你没有写任何一个构造函数的前提下,编译器才会提供默认构造函数)。
  • 你通常都需要写一个构造函数,将基类的一个对象转换成子类的一个对象。在继承了成员函数的时候,这有助于解决类型冲突:
    1
    2
    3
    4
    5
    6
    class FloatFraction : public Fraction {
    public:
    // ....
    FloatFraction(const Fraction &src)
    {set(ser.get_num(), src.get_den());}
    };
  • 基类的私有成员虽然由一个子类继承,但不能在子类代码中访问。为了声明能由子类访问,但不能由类继承层次结果外部的代码访问的成员,你需要将它们声明为protected(这些成员也能由继承层次结构下方任何级别的间接子类访问)。
    1
    2
    protected:
    int num, den;
  • 类(子类和普通类)可以包含对象成员。换言之,一个类可以包含另一个类的示例。
作者

bd160jbgm

发布于

2018-01-28

更新于

2021-05-08

许可协议