我们几乎不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
class <派生类名>:<继承方式1><基类名1>,<继承方式2><基类名2>,…
{
<派生类类体>
};
#include
using namespace std;
// 基类 Shape
class Shape
{
public:
void setWidth(int w)
{
width = w;
}
void setHeight(int h)
{
height = h;
}
protected:
int width;
int height;
};
// 基类 PaintCost
class PaintCost
{
public:
int getCost(int area)
{
return area * 70;
}
};
// 派生类
class Rectangle: public Shape, public PaintCost
{
public:
int getArea()
{
return (width * height);
}
};
int main(void)
{
Rectangle Rect;
int area;
Rect.setWidth(5);
Rect.setHeight(7);
area = Rect.getArea();
// 输出对象的面积
cout << "Total area: " << Rect.getArea() << endl;
// 输出总花费
cout << "Total paint cost: $" << Rect.getCost(area) << endl;
return 0;
}
派生类不能直接访问基类的私有成员,必须通过基类方法进行访问,具体地说,派生类构造函数必须使用基类的构造函数。
创建派生类对象时,程序首先创建基类对象,从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建,C++使用成员初始化列表语法来完成这种工作。
Rectangle::Rectangle():shape(20,30){
}
如果我们省略成员初始化列表会怎样呢?
Rectangle::Rectangle(){}
首先必须创建基类对象,如果不调用基类构造函数,程序将使用默认的基类都早函数,因此上面的代码和下面等效。
Rectangle::Rectangle():shape(){}
有关于派生类的构造函数要点如下
首先创建基类对象
派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数
派生类构造函数应初始化派生类新增的数据成员。
派生类与基类之间有一些特殊关系。
派生类对象可以使用基类的方法,条件是方法不是私有的。
基类指针可以在不进行显式类型转换的情况下指向派生类对象
基类引用可以在不进行显式类型转换的情况下引用派生类对象:
基类指针或者引用却只能调用基类的方法。通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。然而,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。
可能会遇到这样的情况,即希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异。有两种重要的机制可用于实现多态公有继承;
在派生类中重新定义基类的方法
使用虚方法
虚函数 是在基类中使用关键字 virtual 声明的函数。在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。
我们想要的是在程序中任意点可以根据所调用的对象类型来选择调用的函数,这种操作被称为动态链接,或后期绑定。
您可能想要在基类中定义虚函数,以便在派生类中重新定义该函数更好地适用于对象,但是您在基类中又不能对虚函数给出有意义的实现,这个时候就会用到纯虚函数。
class Shape {
protected:
int width, height;
public:
Shape( int a=0, int b=0)
{
width = a;
height = b;
}
// pure virtual function
virtual int area() = 0;
};
= 0 告诉编译器,函数没有主体,上面的虚函数是纯虚函数。
纯虚函数只能被继承,纯虚函数用来规范派生类的行为,即接口。包含纯虚函数的类是抽象类,抽象类不能定义实例,但可以声明指向实现该抽象类的具体类的指针或引用。
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C+中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项正作变得更困难。使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。
在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。公有继承建立is-a关系一种方法是如何处理指向对象的指针和引用。通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型,然而,正如您看到的,指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。下面两种写法都是可以的。
BrassPlus dilly("Annie Dill");
Brass *pb = &dilly;
Brass &rb = dilly;
将派生类引用或者指针转换为基类引用或者指针成为向上强制转换,这使得共有继承不需要进行显式的类型转换,该规则是is-a关系的一部分,BrassPlus对象都是Brass对象,因为他继承了Brass对象所带有的数据成员和成员函数,所以,可以对Brass对象执行的任何操作都适用于BrassPlus对象。向上强制转换是可以传递的,也就是说Brass指针或引用可以引用Brass对象,BrassPlus对象或BrassPlusPlus对象。
相反的过程——将基类指针或引用转换为派生类指针或引用称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。
隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种需求。
看下面代码
BrassPlus ope;
Brass *bp;
bp = &ope;
bp->View();
如果在基类中没有将View()方法设置成虚方法,则bp->View();将根据指针类型(Brass *)调用Brass::View()。指针类型在编译时已知,因此编译器在编译时,可以将View()关联到Brass::View()。总之编译器对非虚方法使用静态联编。
然而,如果在基类中将View()声明为虚方法,则bp->View()根据对象类型调用BrassPlus::View(),在这个例子中对象类型为BrassPlus,但通常只有在运行程序时才确定对象的类型,总是编译器对虚方法使用动态联编。
为什么有两种联编方式以及为什么默认为静态联编
首先是效率,为了能使得程序在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的开销。可以说静态联编的效率更高一些。C++的指导原则之一就是不要为了不使用的特性付出代价。
其次是概念,在设计类时,可能包含一些不需要在派生类中重新定义的成员函数,不将这种函数设置为虚函数一方面是效率更高,另一方面是指出不需要重新定义该方法。如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。
虚函数的工作原理
C++规定了虚函数的行为,但将实现方法留给了编译器作者。不需要知道实现方法就可以使用虚函数,但了解虚函数的工作原理有助于更好地理解概念,因此,这里对其进行介绍。
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址,如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中。注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加一个地址成员,只是表的大小不同。
调用虚函数时,程序将查看存储在对象中的.vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
总之,使用虚函数时,在内存和执行速度方面都有一定的成本。
在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚的
如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指钱类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
构造函数
构造函数不能是虚函数,创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制,因此派生类不能继承基类的构造函数,所以将构造函数申明为虚的没有什么意义。
析构函数
析构函数应当是虚函数,除非类不用做基类,
假设Employee是基类,Singer是派生类,并添加一个char*成员,该成员指向由new分配的内存,当Singer对象过期时,必须调用~Singer()析构函数来释放内存。
请看下面的代码
Employee *pe = new Singer;
delete pe;
如果使用默认的静态联编,delete语句将调用Employee()析构函数。这将释放由Singer对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则上述代码将先调用Singer析构函数释放由Singer组件指向的内存,然后,调用~Employee( )析构函数来释放由Employee组件指向的内存。
这意味着即使基类不需要显示析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使他不执行任何操作。
顺便说一句,给类定义一个虚析构函数并非错误,即使这个类不用做基类,这只是一个效率问题,通常应当给基类提供一个虚析构函数,即使他不需要析构函数。
友元
友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。
没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本公如果派生类位于派生链中,则将使用最新的虚函数版本。
重新定义隐藏方法
假设创建了下面的代码
class Dwelling{
public:
virtual void showperks(int a) const;
}
class Hovel: public Dwelling {
public:
virtual void showperks() const;
}
这将导致问题,编译器可能也会警告
Hovel trump;
trump.showperks();
trump.showperks(5); // 错误
新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。总之,重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
这引出了两条经验规则,第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而变化。
当类声明中包含纯虚函数时,则不能创建该类的对象,这里的理念是,包含纯虚函数的类只能用作基类。
在处理继承的问题上,ABC方法更具系统性、更规范。设计ABC 之前,首先应开发一个模型指出编程问题所需的类以及它们之间相互关系。一种学院派的想法认为,如果要设计类继承层次,则只能将那些不会被用作基类的类设计为具体的类。这种方法的设计更清晰,复杂程度更低。
可以将ABC看作是一种必须实施的接口。ABC 要求具体派生类覆盖其纯虚函数,迫使派生类遵循ABC 设置的接口规则。这种模型在基于组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持ABC指定的功能。
class baseDMA{
private:
char* label;
int rating;
public:
baseDMA(const char* l = "null";int r = 0);
baseDMA(const baseDMA & rs);
virtual ~baseDMA();
baseDMA & operator=(const baseDMA & rs);
};
声明中包含了构造函数使用new时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。
class lackDMA:public baseDMA {
private:
char color[40];
public:
}
是否需要为lackDMA类定义显式析构函数、复制构造函数和赋值运算符呢?不需要。
首先,来看是否需要析构函数。如果没有定义析构函数,编译器将定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造函数总是要进行一些操作,执行自身的代码后调用基类析构函数。因为我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数是合适的。
接着来看复制构造函数。默认复制构造函数执行成员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA成员来说是合适的。因此只需考虑继承的baseDMA对象。要知道,成员复制将根据数据类型采用相应的复制方式,因此,将long 复制到 long中是通过使用常规赋值完成的,但复制类成员或继承的类组件时,则是使用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函数使用显式baseDMA复制构造函数来复制lacksDMA对象的 baseDMA部分。因此,默认复制构造函数对于新的 lacksDMA成员来说是合适的,同时对于继承的 baseDMA对象来说也是合适的。
对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。因此,默认赋值运算符也是合适的。
class hasDMA: public baseDMA {
private:
char *style;
public:
}
在这种情况下,必须为派生类定义显式析构函数、复制构造函数和赋值运算符。下面依次考虑这些方法。
派生类析构函数自动调用基类的析构函数,故其自身的职责是对派生类构造函数执行工作的进行清理。因此,hasDMA 析构函数必须释放指针style管理的内存,并依赖于baseDMA的析构函数来释放指针label管理的内存。
baseDMA::~baseDMA(){
delete [] label;
}
hasDMA::~hasDMA(){
delete [] style;
}
接下来看复制构造函数,BaseDMA的复制构造函数遵循用于char数组的常规模式,即使用strlen( )来获悉存储C风格字符串所需的空间、分配足够的内存(字符数加上存储空字符所需的1字节)并使用函数strcpy()将原始字符串复制到目的地。
baseDMA::baseDMA(const baseDMA & rs) {
label = new char[std::strlen(rs.label)+1];
std::strcpy(label,rs.label);
rating = rs.rating;
}
hasDMA复制构造函数只能访问hasDMA 的数据,因此它必须调用baseDMA复制构造函数来处理共享的baseDMA数据:
hasDMA::hasDMA(const hasDMA &hs):baseDMA(hs){
style = new char[std::strlen(rs.style)+1];
std::strcpy(style,hs.style);
}
接下来看赋值运算符,赋值运算符遵循下述常规模式
baseDMA & baseDMA::operator=(const baseDMA & rs) {
if (this == &rs) return *this;
delete [] label;
label = new char[std::strlen(rs.style)+1];
std::strcpy(style,rs.style);
rating = rs.rating;
return *this;
}
由于 hasDMA也使用动态内存分配,所以它也需要一个显式赋值运算符。作为 hasDMA 的万法,它只能直接访问hasDMA的数据。然而,派生类的显式赋值运算符必须负责所有继承的baseDMA 基类对象的赋值,可以通过显式调用基类赋值运算符来完成这项工作,如下所示:
hasDMA & hasDMA::operator=(const hasDMA & hs) {
if (this == &hs) return *this;
baseDMA::operator=(hs);
delete [] label;
style = new char[std::strlen(ha.style)+1];
std::strcpy(style,hs.style);
return *this;
}
这条语句可能看起来有些奇怪
baseDMA::operator=(hs);
但通过使用函数表示法,而不是运算符表示法,可以使用作用域解析运算符。实际上,该语句的含义如下:
*this = hs;
总之,当基类和派生类都采用动态内存分配时,派生类的析构函数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的复制构造函数来完成的:如果不这样做,将自动调用基类的默认构造函数。对于赋值运算符,这是通过使用作用域解析运算符显式的调用基类的赋值运算符完成的。