C++ Primer Plus----第十三章--类继承

本章内容包括:is-a关系的继承;如何以公有方式从一个类派生出另一个类;保护访问;构造函数成员初始化列表;向上和向下强制转换;虚成员函数;早期(静态)联编与晚期(动态)联编;抽象基类;纯虚函数;何时及如何使用公有函数。

         C++提供了比修改代码更好的方法来扩展和修改类。这种方法叫作类继承,它能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法。

  1. 可以在已有类的基础上添加功能。例如,对于数组类,可以添加数学运算。
  2. 可以给类添加数据。例如,对于字符串类,可以派生出一个类,并添加指定字符串显示颜色的数据成员。
  3. 可以修改类方法的行为。(虚函数、纯虚函数)

         继承机制甚至可以不访问源代码就可以派生出类。

13.1 一个简单的基类

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

code_c++/C++ Primer Plus(第6版)/Chapter 13 · Kite/C和C++ - 码云 - 开源中国 (gitee.com)

13.1.1 派生一个类

class RatedPlayer : public TableTennisPlayer

  1. 冒号指出RatedPlayer类(派生类)的基类是TableTennisplayer类。
  2. public表明TableTennisPlayer是一个公有基类,这被称为公有派生。
  3. 派生类对象包含基类对象。
  4. 使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
  5. 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
  6. 派生类对象可以使用基类的方法(派生类继承了基类的接口)。
  7. 派生类需要自己的构造函数。构造函数必须给新成员(如果有的话)和继承的成员提供数据。
  8. 派生类可以根据需要添加额外的数据成员和成员函数。
// simple derived class
class RatedPlayer : public TableTennisPlayer
{
private:
    unsigned int rating; // add a data member
public:
    RatedPlayer (unsigned int r = 0, const string & fn = "none",
                 const string & ln = "none", bool ht = false);	// 构造函数1
    RatedPlayer(unsigned int r, const TableTennisPlayer & tp);	// 构造函数2
    unsigned int Rating() const { return rating; } 		// add a method
    void ResetRating (unsigned int r) {rating = r;} 	// add a method
};

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

        派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。必须使用基类的公有方法来访问私有的基类成员。派生类构造函数必须使用基类构造函数。

        创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数前被创建。

//成员初始化列表
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
						 const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht)	// 调用基类默认构造函数
{
    rating = r;
}

//如果省略成员初始化列表
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
						 const string & ln, bool ht) 	// 调用基类复制构造函数
{
	rating = r;
}
//必须首先创建基类对象,如果不调用基类构造函数,那就要使用默认的基类构造函数
//所以上述代码与下述代码等效
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
						 const string & ln, bool ht) :TableTennisPlayer() 	
{
	rating = r;
}
//第二个构造函数
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
		 :TableTennisPlayer(tp) 	
{
	rating = r;
}
//这里也会将TableTennisPlayer的信息传递给了TableTennisPlayer构造函数:
 //TableTennisPlayer(tp) 

         如果愿意,也可以对派生类成员使用成员初始化列表语法。在这种情况下,应在列表中使用成员名,而不是类名。所以第二个构造函数可以按照下述方式编写:   

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
		 :TableTennisPlayer(tp),rating(r) 	
{
	
}

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

1.首先创建基类对象;

2.派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;

3.派生类构造函数应初始化派生类新增的数据成员。

  1. 创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。
  2. 基类构造函数负责初始化继承的数据成员;
  3. 派生类构造函数主要用于初始化新增的数据成员。
  4. 派生类的构造函数总是调用一个基类构造函数。
  5. 可以使用初始化器列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。
  6. 派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
  7. 实际上,派生类的默认构造函数总是要进行一些操作:执行自身的代码后调用基类析构函数。
 13.1.3 使用派生类

      code_c++/C++ Primer Plus(第6版)/Chapter 13 · Kite/C和C++ - 码云 - 开源中国 (gitee.com)

        运行输出结果: 

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

        派生类对象可以使用基类的方法,条件是方法不是私有的。

RatedPlayer rplayer(1140, "Mallory", "Duck", true);
rplayer1.Name();

        基类指针(或引用)可以在不进行显式类型转换的情况下指向(或引用)派生类对象;

RatedPlayer rplayer(1140, "Mallory", "Duck", true);
TableTennisPlayer & rt = rplayer;
TableTennisPlayer * pt = &rplayer;
rplayer.Name(); // derived object uses base method
rt.Name(); 		// invoke Name() with reference
pt->Name(); 	// invoke Name() with pointer


         基类指针或引用只能用于调用基类方法。

        通常,c++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外的。然而,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用或指针:

TableTennisPlayer player("Betsy", "Bloop", true);
RatedPlayer &rr = player;//not
RatedPlayer *pr = player;//not

        这里以我自己的理解总结一下:为什么可以将派生类对象和地址赋给基类引用和指针?因为当你使用基类引用的时候为派生类调用基类的方法是可行的,因为派生类就是从基类来的,这里可以认为是派生类调用基类的方法;为什么不可以基类对象和地址赋给派生类引用和指针?因为当你使用派生类引用的时候为基类调用派生类的方法是不可行的,基类并不知道派生类有什么方法,这里可以认为是基类调用派生类的方法

        上述不一定严谨,主要是方便理解。

        从包括方面可以理解为,派生类包含了基类。

如果基类引用和指针可以指向派生类对象,将出现一些很有趣的现象。其中之一就是基类引用定义的函数或者指针参数可用于基类对象和派生类对象。

  1. 对于形参为指向基类的指针的函数,可以使用基类对象的地址或派生类对象的地址作为实参。
  2. 可以将基类对象初始化为派生类对象,也可以将派生对象赋给基类对象。
//引用兼容性属性
// 将基类对象初始化为派生类对象
RatedPlayer olaf1(1840, "Olaf", "Loaf", true);
TableTennisPlayer olaf2(olaf1);

//要初始化olaf2,匹配的构造函数原型如下:
TableTennisPlayer(const RatedPlayer &);

//但是类定义里没有这样的构造函数,但存在隐式复制构造函数:
TableTennisPlayer(const TableTennisPlayer &);

//形参是基类引用,因此它可以引用派生类。

//同样,也可以将派生类对象赋给基类对象:
RatedPlayer olaf1(1840, "Olaf", "Loaf", true);
TableTennisPlayer winner;
winner = olaf1; // 可以将派生对象赋给基类对象

//在这种情况,程序将使用隐式重载赋值运算符:
TableTennisPlayer  & operator = (const TableTennisPlayer  &) const;
//基类引用指向的也是派生类对象,因此olaf1的基类部分被复制给winner。

13.2 继承 is-a关系

  1. C++有3种继承方式:公有继承、保护继承和私有继承。
  2. 公有继承是最常用的方式,它建立一种is-a(is-a-kind-of(是一种的意思),只是通常用is-a表示)关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。
  3. 公有继承不建立 has-a关系。
  4. 公有继承不能建立is-like-a关系,也就是说,它不采用明喻。继承可以在基类的基础上添加属性,但不能删除基类的属性。在有些情况下,可以设计一个包含共有特征的类,然后以is-a或has-a关系,在这个基础上定义相关联的类。
  5. 公有继承不建立is-implemented-as-a(作为……来实现)关系。
  6. 公有继承不建立uses-a关系。

 针对上述概念列举几个例子帮助理解:

5. 可以使用数组来实现栈,但是从Array类派生出Stack类是不合适的,因为栈不是数组。

6. 计算机可以使用激光打印机,但从计算机类派生出打印机类或者反过来都是没有任何意义的。这里可以利用友元函数或者类来处理计算机对象和打印机对象之间的通信。

13.3 多态公有继承

        上面介绍的TableTennisPlayer类(基类)和RatedPlayer类(派生类),派生类调用基类的方法很简单,而未做任何修改。但是我们可能遇到这种情况,我们希望同一个方法在派生类和基类的行为是不同的。换句话说就是,方法的行为取决于调用该方法的对象。这种较复杂的行为称为多态----具有多种形态,即同一个方法的行为随上下文而异。

        有两种重要的机制可用于实现多态公有继承:

               · 在派生类中重新定义基类的方法;

               ·使用虚方法。

         这里有个注意点就是,is-a关系通常是不可逆的。例如水果不是香蕉。

13.3.1 开发Brass类和BrassPlus类

        程序清单13.7,brass.h;        

方法使用了关键字virtual,则该方法被称为虚方法。如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。

        如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。

        总结为,如果是虚的,看引用或指针后面对象的类型;如果不是虚的,就看引用或指针前面的类型。 虚看后,不虚看前。

// 如果ViewAcct( )是虚的
virtual void ViewAcct() const;

Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct(); 	// use Brass::ViewAcct()
b2_ref.ViewAcct(); 	// use BrassPlus::ViewAcct()



// 如果ViewAcct( )不是虚的

void ViewAcct() const;

Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct(); 	// use Brass::ViewAcct()
b2_ref.ViewAcct(); 	// use Brass::ViewAcct()

        基类中声明了一个虚析构函数,这样做是为了确保释放派生类对象时,按正确的顺序调用析构函数。 

下面说明程序清单13.8,brass.cpp;

        这里再次强调,派生类并不能直接访问基类的私有数据,而必须使用基类的公有方法才能访问这些数据。

        派生类构造函数在初始化基类私有数据时,采用的是成员初始化列表。

        程序清单13.9,usebrass1.cpp运行输出结果;

         现在想要使用同一个数组来保存Brass和BrassPlus对象,但是这不可能的。数组中所有元素的类型必须相同。

        可以这么做,创建指向brass的指针数组。这样,每个元素的类型都相同。但由于使用的是公有继承类型,因此Brass指针既可指向Brass对象,也可以指向BrassPlus类型。因此,可以使用一个数组来表示多种类型的对象。这就是多态性

        以下是移除usebrass1.cpp,添加usebrass2.cpp到项目里运行后的输出;

多态性是由下述代码提供的;

   for (int i = 0; i < CLIENTS; i++)
   {
       p_clients[i]->ViewAcct();
       cout << endl;
   }

        上面也提到过Brass中定义的有一个虚析构函数,为什么是虚的呢?如果不是虚的,则将只调用对应于指针类型的析构函数。对于上面的程序则是只调用Brass的析构函数,即使指针指向的是BrassPlus对象。

  1. 在派生类中重新定义基类的方法,需要使用虚方法。然后,程序将根据对象类型而不是引用(或指针)的类型来选择方法版本。
  2. 方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。
  3. 关键字virtual只用于类声明的方法原型中,而没有用于方法定义中
  4. 如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。
  5. 如果析构函数是虚的,将调用相应对象类型的析构函数。
  6. 如果派生类包含一个执行某些操作的析构函数,则基类必须有一个虚析构函数,即使该析构函数不执行任何操作。
  7. 可以创建指向 Brass的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此Brass指针既可以指向Brass对象,也可以指向 BrassPlus对象。因此,可以使用一个数组来表示多种类型的对象。这就是多态性。
  8. 如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

13.4 静态联编和动态联编

         将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。

        在编译过程中进行联编被称为静态联编,又称为早起联编。

        编译器生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编,又称为晚期联编。

13.4.1 指针和引用类型的兼容性
  1. 将派生类引用或指针转换为基类引用或指针——称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。
  2. 将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转换是不允许的。
  3. 隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种需求。

        对于使用基类引用或指针作为参数的函数调用,将进行向上转换。假如都调用虚方法ViewAcct()。

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("Dominic Banker", 11224, 4183.45);
    BrassPlus bp("Dorothy Banker", 12118, 2592.00);
    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()
}

         fv()按值传递导致只将BrassPlus对象的Brass部分传递给函数fv()。但随引用和指针发生的隐式向上转换导致函数fr()和fp()分别为两个不同类型对象使用了对应的ViewAcct()。

13.4.2 虚成员函数和动态联编

1、为什么有两种类型的联编?为什么默认为静态联编?

  1. 虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
  2. 效率:由于静态联编的效率更高,因此被设置为C++的默认选择。Strousstrup说,C++的指导原则之一是,不要为不使用的特性付出代价(内存或者处理时间)
  3. 模型:仅将那些预期将被重新定义的方法声明为虚的。 如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

2、虚函数的工作原理?

        编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。

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

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

 13.4.3 有关虚函数注意事项
  1. 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。
  2. 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
  3. 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
  4. **构造函数不能是虚函数。**创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。
  5. **析构函数应当是虚函数,除非类不用做基类。**即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作:virtual ~Brass() {}
  6. 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。
  7. 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
  8. 重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
    1. 如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而变化。
    2. 如果基类虚函数声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。

13.5 访问控制:protected

  1. 关键字protected与private相似:在类外只能用公有类成员来访问protected部分中的类成员。
  2. private和protected之间的区别:派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。

        最好对类数据成员采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类数据。

        然而,对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。

13.6 抽象基类(abstract base class,ABC)

        书上举了一个例子,圆和椭圆,圆是一个特殊的椭圆。

        从Ellipse类派生出Circle类,会出现一些奇怪的问题。

class Ellipse
{
private:
    double x;
    double y;//椭圆的坐标
    double a;
    double b;//椭圆的长半轴和短半轴
    double angle;//方向角(水平坐标轴和长轴之间的角度)


public:

    void Move(int nx, int ny) { x = nx; y = ny;}//移动椭圆
    virtual double Area() const { return 3.14159 * a * b;}//椭圆面积
    virtual void Rotate (double nang) { angle += nang;}//旋转椭圆
    virtual void Scale (double sa, double sb) {a *= sa; b *= sb;}//缩放长短半轴
};

        现在假设 从Ellipse类派生出Circle类:

class Circle : public Ellipse
{
    ……
};

        可以发现,有的变量不需要,有的函数也是没有意义的,继承反而不如直接定义来的更简单。但是显然这样效率也不高。

    例如:从Ellipse和Circle类中抽象出它们的共性,将这些特性放到一个ABC中。然后从该ABC派生出Circle和Ellipse类(具体类)。这样,便可以使用基类指针数组同时管理Circle和Ellipse对象,即可以使用多态方法。

        C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0。virtual double Area() = 0;在ABC中可以不定义该函数。

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, ny) {x = nx; y = ny;}
    virtual double Area() const = 0;


}
  1. 要成为真正的ABC,必须至少包含一个纯虚函数。
  2. 当类声明中包含纯虚函数时,则不能创建该类的对象(不能实例化)。
  3. 包含纯虚函数的类只用作基类。

        经过上面的描述,我们就可以从BaseEllipse类派生出 Circle类和Ellipse类,我们可以创建Circle对象和Ellipse对象,但是不能创建BaseEllipse对象。Circle对象和Ellipse对象的基类相同,因此可以用BaseEllipse指针数组同时管理这两种对象。像Circle和Ellipse这样的类有时被称为具体类,这表示可以创建这些类型的对象。

        总之,ABC描述的是至少使用一个纯虚函数的接口,从ABC派生出的类将根据派生类的具体特征 ,使用常规虚函数来实现这种接口。

13.6.1 应用ABC概念

        例子:首先定义一个名为AcctABC的ABC。这个类包含Brass和BrassPlus类共有的所有方法和数据成员,而那些在BrassPlus类和Brass类中的行为不同的方法应被声明为虚函数。

        这里涉及到程序清单13.11----acctabc.h,程序清单13.12----acctABC.cpp,程序清单13.13----usebrass3.cpp。

        useacctabc.cpp里可以创建指向AcctABC的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此AcctABC指针既可以指向Brass对象,也可以指向BrassPlus对象。 因此,可以使用一个数组来表示多种类型的对象。这就是多态性。

13.6.2 ABC理念

         可以将ABC看作一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数----迫使派生类遵循ABC设置的接口规则。这种模型在基于组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持ABC指定的功能。

13.7 继承和动态内存分布

        如果基类使用动态内存分配,并重新定义赋值和复制构造函数,同样派生类也使用动态内存分配,那么就需要学习几个新的小技巧。

13.7.1 第一种情况:派生类不使用new

        假设基类使用了动态内存分配,派生类不需执行任何特殊操作,不需要为派生类定义显式析构函数、复制构造函数和赋值运算符。

  1. 复制类成员或继承的类组件时,则是使用该类的复制构造函数完成的。(lacksDMA类的默认复制构造函数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部分。)
  2. 类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。
  3. 派生类对象的这些属性也适用于本身是对象的类成员。
13.7.2 第二种情况:派生类使用new

        第二种情况:假设基类和派生类都使用了new,在这种情况下,必须为派生类定义显式析构函数、复制构造函数和赋值运算符。

  1. 派生类析构函数自动调用基类的析构函数,而自身析构函数是对派生类新增指针成员指向内存的释放。
  2. 派生类复制构造函数在初始化成员列表中调用基类的复制构造函数,如果不这样做,将自动调用基类的默认构造函数。基类复制构造函数有一个基类引用参数,而基类引用可以指向派生类型。(baseDMA复制构造函数将使用hasDMA参数的baseDMA部分来构造新对象的baseDMA部分)。
  3. 派生类的显式赋值运算符可以通过作用域解析运算符显式调用基类赋值运算符,完成所有继承的基类对象的赋值。
  • 7
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值