类继承(C++)

一个简单的类

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。

tabetnn.h

#pragma once
#include<string>
using std::string;
class TableTennisPlayer
{
private:
	string firstname;
	string lastname;
	bool hasTable;
public:
	TableTennisPlayer(const string &fn = "none", const string &ln = "none", bool ht = false);
	~TableTennisPlayer();
	void Name() const;
	bool HasTable()const { return hasTable; };
	void ResetTable(bool v) { hasTable = v; };
	
};

tabten.cpp

#include "TableTennisPlayer.h"
#include<iostream>


TableTennisPlayer::TableTennisPlayer(const string &fn = "none", const string &ln = "none", bool ht = false) :firstname(fn), lastname(ln), hasTable(ht)
{
}


TableTennisPlayer::~TableTennisPlayer()
{
}

void TableTennisPlayer::Name() const {
	std::cout << lastname << ", " << firstname;
}

派生一个类

class RatedPlayer :public TableTennisPlayer {};

冒号指出RatedPlayer类的基类是TableTennisPlayer类。上述特殊的声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。

RatedPlayer对象将具有以下特征:

  • 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)。

因此,RatedPlayer对象可以存储运动员的姓名及其是否有球桌。另外,RatedPlayer对象还可以使用TableTennisPlayer类的Name()、hasTable()和ResetTable()方法。

需要在继承特征中添加什么?

  • 派生类需要自己的构造函数。
  • 派生类可以根据需要添加额外的数据成员和成员函数。
class RatedPlayer :public TableTennisPlayer {
private:
	unsigned int rating;
public:
	RatedPlayer(unsigned int r = 0, const string &fn = "none", const string &ln = "none", bool ht = false);
	RatedPlayer(unsigned int r, const TableTennisPlayer &tp);
	unsigned int Rating()const { return rating; }
	void ResetRating(unsigned int r) { rating = r; }
};

构造函数必须给新成员和继承的成员提供数据。

构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。例如,RatedPlayer构造函数不能直接设置继承的成员(firstname、lastname和hasTable)。而必须使用基类的公有方法来访问私有的基类成员。具体地说,派生类构造函数必须使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。从概念上,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表语法来完成这种工作:

RatedPlayer::RatedPlayer(unsigned int r = 0, const string &fn = "none", const string &ln = "none", bool ht = false) :TableTennisPlayer(fn,ln,ht){
	rating = r;
}

其中:TableTennisPlayer(fn,ln,ht)是成员初始化列表。

如果省略成员初始化列表,情况将如何:

  • 必须首先创建基类对象,如果不调用基类构造函数,程序将使用默认的基类构造函数。
  • 除非要使用默认构造函数,否则应显式调用正确的基类构造函数。

有关派生类构造函数的要点如下:

  • 首先创建基类对象;
  • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
  • 派生类构造函数应初始化派生类新增的数据成员。

释放对象的顺序与创建对象的顺序相反,即首先执行派生类的析构函数,如何自动调用基类的析构函数。

使用派生类

要使用派生类,程序必须要能够访问基类声明。可以将两种类的声明置于同一个头文件中,也可以将每个类放在独立的头文件中。两个类相关时,把其类声明放在一起更合适。

派生类和基类之间的特殊关系

派生类与基类之间有一些特殊关系:

  • 派生类对象可以使用基类的方法,条件是方法不是私有的;
  • 基类指针可以在不进行显式类型转换的情况下指向派生类对象;
  • 基类引用可以在不进行显式类型转换的情况下引用派生类对象。
    然而,基类指针或引用只能用于调用基类方法。
    通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。然而,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。

继承:is-a关系

派生类和基类之间的特殊关系是基于C++继承的底层模型的。
C++的3种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。新类将继承原始类的所有数据成员。因为派生类可以添加特性,所以,将这种关系称为is-a-kind-of关系可能更准确,但是通常使用术语is-a。

多态公有继承

RatedPlayer继承示例很简单。派生类对象使用基类的方法,而未做任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类和基类中的行为是不同的。换句话说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异。有两种重要的机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法。
  • 使用虚方法。

开发Brass类和BrassPlus类:

brass.h

#pragma once
#include<string>
class Brass
{
private:
	std::string fullName;
	long acctNum;
	double balance;
public:
	Brass(const std::string &s = "Nullbody", long an = -1, double bal=0.0);
	void Deposit(double amt);
	virtual void Withdraw(double amt);
	double Balance()const;
	virtual void ViewAcct() const;
	virtual ~Brass() {};
};

class BrassPlus : public Brass{
private:
	double maxLoan;
	double rate;
	double owesBank;
public:
	BrassPlus(const std::string &s = "Nullbody", long an = -1, double bal = 0.0, double ml = 500, double r = 0.11125);
	BrassPlus(const Brass &ba, double ml = 500, double r = 0.11125);
	virtual void ViewAcct()const;
	virtual void Withdraw(double amt);
	void ResetMax(double m) { maxLoan = m; }
	void ResetRate(double r) { rate = r; }
	void ResetOwes() { owesBank = 0; }
};

其中:

  • BrassPlus类在Brass类的基础上添加了3个私有数据成员和3个公有成员函数;
  • Brass类和BrassPlus类都声明了ViewAcct()和Withdraw()方法,但BrassPlus和Brass对象的这些方法的行为是不同的;
  • Brass类在声明ViewAcct()和Withdraw()时使用了新关键字virtual。这些方法被称为虚方法;
  • Brass类还声明了一个虚析构函数,虽然该析构函数不执行任何操作。

假设要同时管理Brass和BrassPlus账户,可以创建指向Brass的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此Brass指针既可以指向Brass对象,也可以指向BrassPlus对象。这就是多态性。

当指针数组指向Brass对象时,则调用Brass::ViewAcct(),如果指向BrassPlus对象时,将会调用BrassPlus::ViewAcct()。

为何需要虚析构函数:
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。因此,对于Brass指针,虽然有指向BrassPlus对象,但只有Brass的析构函数被调用。因此,使用虚析构函数可以确保正确的析构函数序列被调用。

静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在C++中,由于函数重载的缘故,编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译过程完成这种联编。在编译中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。

指针和引用类型的兼容性

在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。公有继承建立is-a关系的一种方法是如何处理指向对象的指针和引用。通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。
然而,指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。

将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a关系的一部分。
相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转化是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。

对于使用基类引用或指针作为参数的函数调用,将进行向上转换:

void fr(Brass &rb);	//use rb.ViewAcct()
void fp(Brass *pb); //use pb->ViewAcct()
void fv(Brass b);	//use b.ViewAcct()
int main() {
	Brass b("Billy Bee", 123432, 10000.0);
	BrassPlus bp("Betty Beep", 232313, 12345.0);
	fr(b);	//use Brass::ViewAcct()
	fr(bp);	//use BrassPlus::ViewAcct()
	fp(b);	//use Brass::ViewAcct()
	fp(bp);	//use BrassPlus::ViewAcct()
	fv(b);	//use Brass::ViewAcct()
	fv(bp);	//use Brass::ViewAcct()
}

按值传递导致只将BrassPlus对象的Brass部分传递给函数fv()。但随引用和指针发生的隐式向上转换导致函数fr()和fp()分别为Brass对象和BrassPlus对象使用Brass::ViewAcct()和BrassPlus::ViewAcct()。
隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用需成员函数来满足这种需求。

虚成员函数和动态联编

回顾使用引用或指针调用方法的过程:

BrassPlus ophelia;
Brass *bp;
bp = &ophelia;
bp->ViewAcct();

如果在基类中没有将ViewAcct()声明为虚的,则bp->ViewAcct()将根据指针类型调用Brass::ViewAcct()。指针类型在编译时已知,因此编译器在编译时,可以将ViewAcct()关联到Brass::ViewAcct()。总之,编译器对非虚方法使用静态联编。

然而,如果在基类中将ViewAcct()声明为虚的,则bp->ViewAcct()根据对象类型调用BrassPlus::ViewAcct()。在这个例子中,对象类型为BrassPlus,但通常只有在运行程序是才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将ViewAcct()关联到Brass::ViewAcct()或BrassPlus::ViewAcct()。总之,编译器对虚方法使用动态联编。

在大多数情况下,动态联编很好,因为它让程序能够选择为特定类型设计的方法。那么:

  • 为什么有两种类型的联编?

  • 既然动态联编如此之好,为什么不将它设置成默认的?

  • 动态联编是如何工作的?

  • 为什么有两种类型的联编以及为什么默认为静态联编
    如果动态联编让您能够重新定义类方法,而静态联编在这方面很差,为何不摒弃静态联编呢?原因有两个——效率和概念模拟。

    首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。例如,如果类不会用作基类,则不需要动态联编。同样,如果派生类不重新定义基类的任何定义,也不需要使用动态联编。在这些情况下,使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。C++的指导原则之一是,不要为不使用的特性付出代价(内存或者处理时间)。仅当程序设计确实需要虚函数时,才使用它们。

    接下来看概念模型。在设计类时,可能包含一些不在派生类重新定义的成员函数。例如,Brass::Balance()函数返回账户结余,不应该重新定义。不将该函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的方法声明为虚的。

  • 虚函数的工作原理
    通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中。注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已。

    调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中的第三个元素的函数。

总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间;
  • 对于每个类,编译器都创建一个虚函数地址表(数组)
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。

有关虚函数注意事项:

虚函数的一些要点:

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。
  • 如果使用指向对象的引用或指针来调用虚函数,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
  • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。

对于虚方法,还需要了解其他一些知识:

  1. 构造函数
    构造函数不能是虚函数。创建派生类对象时将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。

  2. 析构函数
    析构函数应当是虚函数,除非类不用做基类。例如,假设Employee是基类,Singer是派生类,并添加一个char *成员,该成员指向由new分配的内存。当Singer对象过期时,必须调用~Singer()析构函数来释放内存:

    Employee *pe = new Singer;
    ...
    delete pe;
    

    如果使用默认的静态联编,delete语句将调用 ~ Employee()析构函数。这将释放由Singer对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则上述代码将先调用~ Singer()析构函数释放由Singer组建指向的内存,然后,调用~ Employee()析构函数来释放由Employee组件指向的内存。
    这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作。

    virtual ~BaseClass(){};
    

顺便说一下,给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

  1. 友元
    友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。

  2. 没有重新定义
    如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。

  3. 重新定义将隐藏方法

    假设创建了如下所示的代码:

    class Dwelling {
    public:
    	virtual void showperks(int a)const;
    };
    
    class Hovel:public Dwelling{
    public:
    	virtual void showperks()const;
    }
    

    新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。总之,重新定义继承的方法并不是重载。如果重新定义派生类中的函数,将不只是使用相同的函数参数列表覆盖基类声明,无论参数列表是否相同,该操作将隐藏所有同名基类方法。
    这引出了两条经验规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(convariance of return type),因为允许返回类型随类类型的变化而变化。这种例外只适用于返回值,而不适用于参数。
    第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外的版本将被隐藏,派生类对象将无法使用它们。

访问控制:protected

关键字protected与private相似,在类外只能用公有类成员来访问protected部分中的类成员。private和protected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
例如,假如Brass类将balance成员声明为保护的:

class  Brass {
protected:
	double balance;
};

在这种情况下,BrassPlus类可以直接访问balance,而不需要使用Brass方法。
使用保护数据成员可以简化代码的编写工作,但存在设计缺陷,例如,继续以BrassPlus为例,如果balance是受保护的,则可以按下面的方式编写代码:

void BrassPlus::Reset(double amt) {
	balance = amt;
}

Brass类被设计成只能通过Deposit()和Withdraw()才能修改balance。但对于BrassPlus对象,Reset()方法将忽略Withdraw()中的保护措施,实际上使balance成为公有变量。
然而,对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。

抽象基类

至此,介绍了简单继承和较复杂的多态继承。接下来更为复杂的是抽象基类(abstract base class,ABC)。

有时候,使用is-a规则并不是看上去的那样简单。例如,假设您正在开发一个图形程序,该程序会显示圆和椭圆等。圆是椭圆的一个特殊情况——长轴和短轴等长的的椭圆。因此,所有的圆都是椭圆,可以从Ellipse类派生出Circle类。但涉及到细节时,将发现很多问题。
虽然圆是一种椭圆,但是从椭圆派生出圆是笨拙的。例如圆只需要一个值(半径)就可以描述大小和形状,并不需要有长半轴和短半轴。Circle构造函数可以通过将同一个值赋给长半轴和短半轴来照顾这种情况,但将导致信息冗余。angel参数和Rotate()的方法对圆来说没有任何实际意义。可以使用一些技巧来修正这些问题,例如在Circle类中的私有部分包含重新定义Rotate()方法,使Rotate()不能以公有方式用于圆。但总的来说,不使用继承,直接定义Circle类更简单。
但这种解决方法的效率也不高。Circle和Ellipse类有很多共同点,将它们分别定义则忽略了这一事实。

还有一种办法,即从Ellipse和Circle类中抽象出它们的共性,将这些特性放到一个ABC中。然后从该ABC派生出Circle和Ellipse类。这样,便可以使用基类指针数组同时管理Circle和Ellipse对象(即可以使用多态方法)。但Area()方法的参数不同,因此,不能再ABC中实现Area()方法,因为它没有包含必要的数据成员。C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0。

class BaseEllipse {
private:
	double x;
	double y;
public:
	BaseEllipse(double x0 = 0, double y0 = 0) :x(x0), y(y0) {};
	virtual ~BaseEllipse();
	void Move(int nx, int ny) { x = nx; y = ny; }
	virtual double Area() const = 0;
};

当类声明中包含纯虚函数时,则不能创建该类的对象。这里的理念是,包含纯虚函数的类只用作基类。要成为真正的ABC,必须至少包含一个纯虚函数。原型中的=0使虚函数成为纯虚函数。这里的方法Area()没有定义,但C++甚至允许纯虚函数有定义。例如,也许所有的基类方法都与Move()一样,可以在基类中进行定义,但您仍需要将这个类声明为抽象的。在这种情况下,可以将原型声明为虚的:

void Move(int nx, ny)= 0;

这将使基类成为抽象的,但您仍可以在实现文件中提供方法的定义。
总之,在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。

使用这些类的程序将能够创建Ellipse对象和Circle对象,但是不能创建BaseEllipse对象。由于Circle和Ellipse对象的基类相同,因此可以用BaseEllipse指针数组同时管理这两种对象。像Circle和Ellipse这样的类有时被称为具体类,这表示可以创建这些类型的对象。
总之,ABC描述的是至少使用一个纯虚函数的接口,从ABC派生出的类将根据派生类的具体特征,使用常规虚函数来实现这种接口。

ABC理念:
在处理继承的问题上,RatedPlayer示例使用的方法比较随意,而ABC方法比它更具系统性、更规范,设计ABC之前,首先应开发一个模型——指出编程问题所需的类以及它们之间相互关系。
可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于组建的编程模式中很常见,在这种情况下,使用ABC使得组建设计人员能够制定“接口约定”,这样确保了从ABC派生的所有组建都至少支持ABC指定的功能。

声明:以上整理自个人理解和Stephen Prata 著的《C++ Primer Plus》

猜你喜欢

转载自blog.csdn.net/MoooLi/article/details/82860821