重学C++笔记之(十一)类继承


面向对象的主要目的之一是提供可重用的代码。类继承能够从已有的类派生出新的类,而派生类继承了原有类(基类)的特征。正如继承了一笔财产要比自己白手起家容易一样,通过集成派生出的类通常比设计新类要容易得多。可以通过继承类完成一些工作。

  • 可以在已有类的基础上添加功能。例如,对于数组类,可以添加数学运算。
  • 可以给类添加数据。例如,对于字符串类,可以派生出一个类,并添加指定字符串显示颜色的数据。
  • 可以修改类方法的行为。

因此,如果购买的类库只提供了类方法的头文件和编译后代码,仍可以使用库中的类派生出新的类。

1. 一个简单基类

以下是标记某个人是否有球桌的基类。

my_class.h代码

#ifndef MY_CLASS_H
#define MY_CLASS_H
#include<string>
using namespace std;

class TableTennisPlayer
{
    
    
private:
    string firstName;
    string lastName;
    bool hasTable;
public:
    TableTennisPlayer(const string& fn = "none",
                      const string& ln = "none", bool ht = false);
    void ShowName()const;
    bool HasTable()const{
    
    return hasTable;};
    void ResetTable(bool v){
    
    hasTable = v;};
};
#endif // MY_CLASS_H

my_class.cpp代码

#include"my_class.h"
#include<iostream>
TableTennisPlayer::TableTennisPlayer(const string& fn,
                                     const string&ln,bool ht):firstName(fn),lastName(ln),
    hasTable(ht){
    
    }
void TableTennisPlayer::ShowName()const
{
    
    
    std::cout<<lastName<<", "<<firstName;
}

main函数代码:

#include<iostream>
#include"my_class.h"
int main()
{
    
    
    TableTennisPlayer player1("Chuck", "Blizzard", true);
    player1.ShowName();
    if(player1.HasTable())
        cout<<": has a table.\n";
    else
        cout<<": hasn't a table.\n";
}

输出:

Blizzard, Chuck: has a table.

1.1 派生一个类

我们这里举一个公有派生的例子。公有派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。创建派生类对象时,程序首先创建基类对象。

我们创建一个公有派生类:

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;};
};

对第一个派生类构造函数进行实现:

//先基类对象TableTennisPlayer,后RatedPlayer的对象创建
RatedPlayer::RatedPlayer(unsigned int r, const string &fn, const string &ln, bool ht)
    :TableTennisPlayer(fn, ln, ht)
{
    
    
    rating = r;
}

以上先进行TableTennisPlayer(fn, ln, ht)的基类构造函数调用,如果省去TableTennisplayer也将默认调用基类的默认构造函数(因为必须先调用基类的构造函数!)。

下面继续实现第二个构造函数:

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

RatedPlayer的第二个构造函数,也是先调用TableTennisPlayer(tp),然后再进入函数体,进行rating = r;操作。

总结派生类构造函数的要点如下:

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

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

1.2 使用派生类

分别将上述的派生类和定义实现放入基类的头文件和cpp文件。然后在将main修改为:

#include<iostream>
#include"my_class.h"
int main()
{
    
    
    RatedPlayer rPlayer1(1140,"Mallory", "Duck", true);
    rPlayer1.ShowName();
    if(rPlayer1.HasTable())
        cout<<": has a table.\n";
    else
        cout<<": hasn't a table.\n";
}

输出:

Duck, Mallory: has a table.

我们可以看到派生类也可以访问基类中的方法。

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

  1. 派生类对象可以使用基类的方法,条件是方法不是私有的。
RatedPlayer rPlayer1(1140,"Mallory", "Duck", true);
rPlayer1.ShowName();//使用基类的方法
  1. 基类指针可以在不进行显示类型转换的情况下指向派生类对象
RatedPlayer rPlayer1(1140,"Mallory", "Duck", true);//派生类对象
TableTennisPlayer * pt = &rplayer;//基类对象
pt->ShowName();//指针方法调用
  1. 基类引用可以在不进行显示类型转换的情况下引用派生类对象。
RatedPlayer rPlayer1(1140,"Mallory", "Duck", true);//派生类对象
TableTennisPlayer& rt = rplayer;//基类对象
rt.ShowName();//引用方法调用
  • 但是基类的指针和引用只能调用基类的方法,而不能调用派生类的任何成员。
  • 通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对于继承类型来说是例外。
  • 但是这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。

引用兼容性属性也可以使得基类对象初始化为派生类对象:

RatedPlayer olaf1(1840,"Olaf", "Loaf", true);//派生类
TableTennisPlayer olaf2(olaf1);

要初始化olaf2,匹配的构造函数的原型如下:

TableTennisPlayer(const RatedPlayer &);//这种实现是没有的!!!

这个完全匹配的构造函数是没有的!但是存在隐式复制构造函数:

TableTennisPlayer(const TableTennisPlayer &);

同样也可以将派生类对象赋给基类对象:

RatedPlayer olaf1(1840,"Olaf", "Loaf", true);//派生类
TableTennisPlayer olaf2;
olaf2 = olaf1;

这种情况下,程序将使用隐式重载复制运算符:

TableTennisPlayer& operator= (const TableTennisPlayer &) const;

2. 继承:is-a关系

公有继承建立了一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。

3. 多态公有继承

如果在派生类和基类中都有同一个方法,但是表现出来的行为不一样,即同一个方法的行为随上下文而异。有两种重要的机制可实现多态公有继承:

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

多态注意的情况

使用多态公有继承注意几点:

第一:程序将使用对象类型来确定使用哪个版本:

Brass dom("Dominic Banker",11224, 4183.45);//基类
BrassPlus dot("Dorothy Banker", 12118,2592.00);//派生类
dom.ViewAcct();//调用基类方法
dot.ViewAcct();//调用派生类方法

第二:如果方法是通过引用或指针而不是对象调用。

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

如果ViewAcct()不是虚的,使用指针或引用类型都是下面的情况:

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();//使用基类方法
b2_ref.ViewAcct();//使用基类方法

如果ViewAcct()是虚的,则指针或引用调用方法如下:

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();//使用基类方法
b2_ref.ViewAcct();//使用派生类方法!!!

第三:基类中使用虚析构函数。如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。那么Brass的析构函数被调用,即使指针指向一个BrassPlus对象。如果析构函数是虚的,将调用相应对象类型的析构函数。因此指针指向BrassPlus对象,将调用BrassPlus的析构函数,然后自动调用基类的析构函数。因此,使用虚构函数可以确保正确的析构函数序列被调用。

4. 静态联编和动态联编

程序调用函数时,编译器决定执行哪个代码块。将源代码中的函数调用解释为执行特定的函数代码块被称为函数联编(binging)。

  • 静态联编(static binging):又称为早期联编(early binding),在编译过程中进行联编。
  • 动态联编(dynamic binging):又称为晚期联编(late bingding),虚函数使得在编译过程不能决定用户将选择哪种类型对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码。

4.1 指针和引用类型的兼容性

在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。通常C++不允许将一种类型的地址赋给另一种指针,引用也是这样:

double x = 2.5int * pi = &x;//不允许
long & r1 = x;//不允许

但是,我们可以看到,指向基类的指针或引用可以引用派生类对象,而不必进行显式类型转换。例如:

BrassPlus dilly("Annie Dill", 493222, 2000);
Brass * pb = &dilly;
Brass& rb = dilly;

这种形式称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。而且这种操作是可传递的,比如BrassPlus继续派生出BrassPlusPlus类,那么指向Brass的指针或引用同样可以引用BrassPlusPlus类对象。

4.2 虚成员函数和动态联编

我们先来回顾一下引用或指针调用方法的过程。

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

如果ViewAcct()没有声明为虚的,则调用Brass的成员函数ViewAcct()。这是因为指针类型在编译时已知,因此编译器在编译时,可以将ViewAcct()关联到Brass::ViewAcct()。总之,编译器对非虚函数使用静态联编。

如果ViewAcct声明为虚的。则根据指向的对象调用成员函数,编译器对虚函数使用动态联编。

(1) 为什么有两种类型联编

如果动态联编让你能够重新定义类方法,而静态联编这方面很差,那为什么不舍弃静态联编,而且还把静态联编当做默认联编方式呢?原因有两个效率和概念模型

首先,静态联编效率更高,因为如果想要使得程序在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。而且如果没有不重新定义基类,则不需要动态联编。所以C++默认选择静态联编。

接下来看概念模型。并不是所有的类方法都需要进行重新实现,所以不是所有的方法都需要设置虚函数。

(2)虚函数的工作原理

C++规定了虚函数的行为,但将实现方法留给了编译器作者。不需要知道实现方法就可以使用虚函数,但了解虚函数的工作原理有助于更好地理解概念。

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

具体可查看C++Primer 章节13.4.2

4.3 有关虚函数注意事项

  • 构造函数不能是虚函数
    创建派生类时,将调用派生类的构造函数,而不是基类的构造函数,然后派生类的构造函数将使用基类的一个构造函数。

  • 析构函数应该是虚函数
    保证正确的析构顺序。顺便说一下,即使类不作为基类,给类定义一个虚析构函数并非错误,只是效率方面的问题。

  • 友元不能是虚函数。因为友元不是类成员,而只有成员才能是虚函数。

  • 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本

  • 重新定义继承的方法并不是重载,而是隐藏了基类的函数版本。

例如基类中的函数是带参数的虚函数,而派生类中是不带参数的虚函数。

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);//无效,隐藏了,不可以这样

所以一旦基类声明被重载,则应在派生类中重新定义所有的基类版本!否则虚函数的方式将隐藏所有的基类方法。

当然也有例外,就是返回类型为基类引用或指针,将不会被隐藏(但是参数也要保持一致)。这种特性称为返回值协变(covariance of return type),因为允许返回类型随类类型的变化而变化。

class Dwelling
{
    
    
	public:
		virtual Dwelling& showperks(int a)const;
}
class Hovel : public Dwelling
{
    
    
	public:
		virtual Hovel& showperks(int a)const;//参数一致!!!
}

5. 访问控制:protected

在类中protected和private是一致的,都是只能通过公有成员函数来访问它们。

protected和private的区别主要在于派生类中体现。派生类的成员可以直接访问基类的protected成员,但不能直接访问基类的私有成员。

使用保护数据成员可以简化代码的编写工作,但存在设计缺陷,例如保护成员对于基类是受保护的,和private成员一样,但是在派生类中,可以轻易的修改它。

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

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

6. 抽象基类

C++中通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0.

virtual double Area() const = 0;//纯虚函数

当类声明中包含纯虚函数时,则不能创建该类的对象。也就是说包含纯虚函数的类只能用作基类,也就是抽象基类(abstract base class, ABC)。

如果从抽象基类中派生出的类方法功能都一样,则我们也可以在抽象基类中,将纯虚函数进行定义:

void Move(int nx, int ny) = 0;//原型
void BaseEllipse::move(int nx, ny){
    
    x = nx; y = ny;}//定义

7. 继承和动态内存分配

继承是怎样与动态内存分配(使用new和delete)的呢?例如,如果基类使用动态内存分配,并重新定义赋值和复制构造函数,这将怎么影响派生类呢?这个问题取决于派生类的属性。下面来看看两种情况。

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

这种情况类似于string类,string当做一般变量对待就可以。对于派生类,不需要显式析构函数、复制构造函数和重载赋值运算符。

7.2 第二种情况:派生类使用new

派生类使用了new:

class hasDMA:public baseDMA
{
    
    
	private:
		char * style;
	public:
		...
}

这种情况必须为派生类定义显式析构函数、复制构造函数和赋值运算符。


总览目录
上一篇:(十)类和动态内存分配


文章参考:《C++ Primer Plus第六版》

猜你喜欢

转载自blog.csdn.net/QLeelq/article/details/111059260