公有继承的考虑因素
通常,在程序中使用继承时,有很多问题需要注意。下面来看其中的一些问题。
-
is-a 关系
要遵循 is-a 关系。如果派生类不是一种特殊的基类,则不要使用公有派生。例如,不应从 Brain 类派生出 Programmer 类。如果要指出程序员有大脑,应将 Brain 类对象作为 Programmer 类成员。
在某些情况下,最好的方法可能是创建包含纯虚函数的抽象数据类,并从它派生出其他的类。
请记住,表示 is-a 关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。另外,反过来是行不通的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象。这种显式类型转换(向下强制转换)可能有意义,也可能没有,这取决于类声明。 -
什么不能被继承
构造函数是不能继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数。然而,派生类构造函数通常使用成员初始化列表语法来调用基类构造函数,以创建派生类对象的基类部分。如果派生类构造函数没有使用成员初始化列表语法显式调用基类构造函数,将使用基类的默认构造函数。在继承链中,每个类都可以使用成员初始化列表将信息传递给相邻的基类。C++11 新增了一种让您能够继承构造函数的机制,但默认仍不继承构造函数。析构函数也是不能继承的。然而,在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。如果基类有默认析构函数,编译器将为派生类生成默认析构函数。通常,对于基类,其析构函数应设置为虚的。
赋值运算符是不能继承的,原因很简单。派生类继承的方法的特征标与基类完全相同。但赋值运算符的特征标随类而异,这是因为它包含一个类型为其所属类的形参。赋值运算符确实有一些有趣的特征,下面介绍它们。
-
赋值运算符
如果编译器发现程序将一个对象赋给同一个类的另一个对象,它将自动为这个类提供一个赋值运算符。这个运算符的默认或隐式版本采用成员赋值,即将原对象的相应成员赋给目标对象的每个成员。然而,如果对象属于派生类,编译器将使用其基类赋值运算符来处理派生对象中基类部分的赋值。如果显式地为基类提供了赋值运算符,将使用该运算符。与此相似,如果成员是另一个类的对象,将使用其所属类的赋值运算符。
正如多次提到的,如果类构造函数使用 new 来初始化指针,则需要提供一个显式赋值运算符。因为对于派生对象的基类部分,C++ 将使用基类的赋值运算符,所以不需要为派生类重新定义赋值运算符,除非它添加了需要特别留意的数据成员。例如,baseDMA 类显式地定义了赋值,但派生类 lackDMA 使用为它生成的隐式赋值运算符。
然而,如果派生类使用了new,则必须提供显式赋值运算符。必须给类的每个成员提供赋值运算符,而不仅仅是新成员。HasDMA 类演示了如何完成这项工作:hasDMA & hasDMA::operator=(const hasDMA & hs){ if(this == &hs){ return *this; } baseDMA::operator=(hs); // copy base portion delete [] style; style = new char[std::strlen(hs.style)+1]; std::strcpy(style, hs.style); return * this; }
将派生类对象赋给基类对象将会如何呢?(注意,这不同于将基类引用初始化为派生类对象。)请看下面的例子:
Brass blips; // base class BrassPlus snips("Rafe Plosh", 91191, 3993.19, 600.0, 0.12); // derived class blips = sinps; // assign derived object to base object
这将使用哪个赋值运算符呢?赋值语句将被转换成左边的对象调用的一个方法:
blips.operator=(snips);
其中左边的对象是Brass对象,因此它将调用Brass::operator=(const Brass &) 函数。is-a 关系允许 Brass 引用指向派生类对象,如 Snips。赋值运算符只处理基类成员,所以上述赋值操作将忽略 Snips 的 masLoan 成员和其他 BrassPlus 成员。总之,可以将派生类赋给基类对象,但这只涉及基类的成员。
相反的操作将如何呢?即可以将基类对象赋给派生类对象吗?请看下面的例子:Brass gp("Griff Hexbait“, 21234, 1200); // base class BrassPlus temp; // temp class temp = gp; // possible?
上述赋值语句将被转换为如下所示:
temp.operator(op);
左边的对象是 BrassPlus 对象,所以它调用 BrassPlus::operator=(const BrassPlus &) 函数。然而,派生类引用不能自动引用基类对象,因此上述代码不能运行,除非有下面的转换构造函数:
BrassPlus(const Brass &);
与 BrassPlus 类的情况相似,转换构造函数可以接受一个类型为基类的参数和其他参数,条件是其他参数有默认值:
BrassPlus(const Brass & ba, double m. = 500, double r = 0.1);
如果有转换构造函数,程序将通过它根据gp来创建一个临时 BrassPlus 对象,然后将它用作赋值运算符的参数。
另一种方法是,定义一个用于将基类赋给派生类的赋值运算符:BrassPlus & BrassPlus::operator=(const Brass &) { ... }
该赋值运算符的类型与赋值语句完全匹配,因此无需进行类型转换。
总之,问题”是否可以将基类对象赋给派生对象?” 的答案是“也许”。如果派生类包含了这样的构造函数,即对将基类对象转换为派生类对象进行了定义,则可以将基类对象赋给派生对象。如果派生类定义了用于将基类对象赋给派生对象的赋值运算符,则也可以这样做。如果上述两个条件都不满足,则不能这样做,除非使用显式强制类型转换。 -
私有成员与保护成员
对派生类而言,保护成员类似于公有成员;但对于外部而言,保护成员与私有成员类似。派生类可以直接访问基类的保护成员,但只能通过基类的成员函数来访问私有成员。因此,将基类成员设置为私有的可以提高安全性,而将它们设置为保护成员则可简化代码的编写工作,并提高访问速度。Stroustrup 在其《The Design and Evolution of C++》一书中指出,使用私有数据成员比使用保护数据成员更好,但保护方法很有用。
-
虚方法
设计基类时,必须确定是否将类方法声明为虚的。如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的,这样可以启用晚期联编(动态联编);如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了这样的意思:您不希望它被重新定义。
请注意,不适当的代码将阻止动态联编。例如,请看下面的两个函数:
void show(const Brass & rba){ rba.ViewAcct(); cout << endl; } void inadequate(Brass ba){ ba.ViewAcct(); cout << endl; }
第一个函数按引用传递对象,第二个按值传递对象。
现在,假设将派生类参数传递给上述两个函数:BrassPlus buzz("Buzz Parsec", 00001111, 4300); show(buzz); inadequate(buzz);
show() 函数使 rba 参数成为 BrassPlus 对象 buzz 的引用,因此,rba.ViewAcct() 被解释为 BrassPlus 版本,正如应该的那样。但在 inadequate() 函数中(它是按值传递对象的),ba 是 Brass(const Brass &) 构造函数创建的一个 Brass 对象(自动向上强制转换使得构造函数参数可以引用一个 BrassPlus 对象)。因此,在 inadequate() 中,ba.ViewAcct() 是 Brass 版本,所以只有 buzz 的Brass部分被显示。
-
析构函数
正如前面介绍的,基类的析构函数应当是虚的。这样,当通过指向对象的基类指针或引用来删除派生对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅是调用基类的析构函数。 -
友元函数
由于友元函数并非类成员,因此不能继承。然而,您可能希望派生类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针或引用来调用基类的友元函数:ostream & operater<<(ostream & os, const hasDMA & hs){ // type cast to match operator<<(ostream &, const baseDMA &) os << (const baseDMA &) hs; os << "Style: " << hs.style << endl; return os; }
也可以使用第15章将讨论的运算符 dynamic_cast<> 来进行强制类型转换:
os << dynamic_cast<const baseDMA &> (hs);
鉴于第15章将讨论的原因,这是更佳的强制类型转换方式。
-
有关使用基类方法的说明
以公有方式派生的类的对象可以通过多种方式来使用基类的方法。- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法。
- 派生类的析构函数调用基类的析构函数。
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他基类构造函数。
- 派生类构造函数显式地调用成员初始化列表中指定地基类构造函数。
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法。
- 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。
类函数小结
C++ 类函数有很多不同的变体,其中有些可以继承,有些不可以。有些运算符函数即可以是成员函数,也可以是友元,而有些运算符函数只能是成员函数。下表总结了这些特征,其中op=表示诸如+=,*=格式的赋值运算符。注意,op=运算符的特征与“其他运算符”类别并没有区别。单独列出 op= 旨在指出这些运算符的行为是不同的。
函数 | 能否继承 | 成员还是友元 | 默认能否生成 | 能否为虚函数 | 是否可以有返回类型 |
---|---|---|---|---|---|
构造函数 | 否 | 成员 | 能 | 否 | 否 |
析构函数 | 否 | 成员 | 能 | 能 | 否 |
= | 否 | 成员 | 能 | 能 | 能 |
& | 能 | 任意 | 能 | 能 | 能 |
转换函数 | 能 | 成员 | 否 | 能 | 否 |
() | 能 | 成员 | 否 | 能 | 能 |
[] | 能 | 成员 | 否 | 能 | 能 |
-> | 能 | 成员 | 否 | 能 | 能 |
op= | 能 | 任意 | 否 | 能 | 能 |
new | 能 | 静态成员 | 否 | 否 | void* |
delete | 能 | 静态成员 | 否 | 否 | void |
其他运算符 | 能 | 任意 | 否 | 能 | 能 |
其他成员 | 能 | 成员 | 否 | 能 | 能 |
友元 | 否 | 友元 | 否 | 否 | 能 |
总结
继承通过使用已有的类(基类)定义新的类(派生类),使得能够根据需要修改编程代码。公有继承建立 is-a 关系,这意味着派生类对象也应该是某种基类对象。作为 is-a 模型的一部分,派生类继承基类的数据成员和大部分方法,但不继承基类的构造函数、析构函数和赋值运算符。派生类可以直接访问基类的公有成员和保护成员,并能够通过基类的公有方法和保护方法访问基类的私有成员。可以在派生类中新增数据成员和方法,还可以将派生类作为基类,来做进一步的开发。每个派生类都必须有自己的构造函数。程序创建派生类对象时,将首先调用基类的构造函数,然后调用派生类的构造函数;程序删除对象时,将首先调用派生类的析构函数,然后调用基类的析构函数。
如果要将类用作基类,则可以将成员声明为保护的,而不是私有的,这样,派生类将可以直接访问这些成员。然而,使用私有成员通常可以减少出现编程问题的可能性。如果希望派生类可以重新定义基类的方法,则可以使用关键字virtual将它声明为虚的。这样对于通过指针或引用访问的对象,能够根据对象类型来处理,而不是根据引用或指针的类型来处理。具体地说,基类的析构函数通常应当是虚的。
可以考虑定义一个ABC:只定义接口,而不涉及实现。例如,可以定义抽象类 Shape,然后使用它派生出具体的形状类,如 Circle 和 Square。ABC 必须至少包含一个纯虚方法,可以在声明中的分号前面加上=0 来声明纯虚方法。
virtual double area() const = 0;
不一定非得定义纯虚方法。对于包含纯虚成员的类,不能使用它来创建对象。纯虚方法用于定义派生类的通用接口。