CPP-12-类继承

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。被称为 is-a(is-a-kind-of)关系

首先声明一个基类 tabtenn0.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef TABTENN0_H_
#define TABTENN0_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 Name() const;
bool HashTable() const {return hasTable;};
void ResetTable(bool v) {hasTable = v;};
};
#endif

接着实现基类 tabtenn0.cpp

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include "tabtenn0.h"

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

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

构造函数使用了成员初始化列表语法,也可以使用普通格式

1
2
3
4
5
6
7
TableTennisPlayer::TableTennisPlayer(const string & fn, 
const string & ln, bool ht)
{
firstname = fn;
lastname = ln;
hasTable = ht;
}

接着使用这个类 usett0.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include "tabtenn0.h"
using namespace std;

int main(void)
{
TableTennisPlayer player1("Chuck", "Blizzard", true);
TableTennisPlayer player2("Tara", "Boomdea", false);
player1.Name();
if(player1.HashTable()) cout << ": has a table.\n";
else cout << ": hasn't a table.\n";
player2.Name();
if(player2.HashTable()) cout << ": has a table.\n";
else cout << ": hasn't a table.\n";
return 0;
}

运行结果是

1
2
Blizzard, Chuck: has a table.
Boomdea, Tara: hasn't a table.

接着声明一个派生类 tabtenn1.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include "tabtenn0.h"

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

特殊的声明头 public TableTennisPlayer 表明TableTennisPlayer是一个公有基类,这被称为公有派生

派生类对象包含基类对象。

  • 使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问

  • 派生类对象可以使用基类的方法

  • 派生类需要自己的构造函数

  • 派生类可以根据需要添加额外的数据成员和成员函数

现在实现派生类 tabtenn1.cpp

1
2
3
4
5
6
7
8
#include <iostream>
#include "tabtenn1.h"
using namespace std;

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

:TableTennisPlayer(fn,ln,ht)是成员初始化列表。它是可执行的代码,调用TableTennisPlayer构造函数

如果不调用基类的构造函数呢?

1
2
3
4
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht){
{
rating = r;
}

必须首先创建基类对象,如果不调用基类构造函数,程序将使用默认的基类构造函数,所以等效于

1
2
3
4
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht) :TableTennisPlayer()
{
rating = r;
}

也可以显示的调用基类构造函数

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

注意这里使用了TableTennisPlayer(tp) 但是并没有定义,这是因为 如果需要使用复制构造函数但又没有定义,编译器将自动生成一个

也可以对派生类使用成员初始化列表语法

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

总结一下派生类的构造函数

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

多态

希望同一个方法在派生类和基类中的行为是不同的,即同一个方法的行为随上下文而异。有两种重要的机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法
  • 使用虚方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// brass.h  -- bank account classes
#ifndef BRASS_H_
#define BRASS_H_
#include <string>
// Brass Account Class
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() {}
};

//Brass Plus Account Class
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; }
};

#endif

使用delete释放由new分配的对象的代码说明了为何基类应包含虚析构函数(虽然有时好像并不需要析构函数)

  • 如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。意味着只有Brass的析构函数被调用,即使指针指向的是一个BrassPlus对象。
  • 如果析构函数是虚的,将调用相应对象类型的析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的析构函数,然后自动调用基类的析构函数

因此,使用虚析构函数可以确保正确的析构函数序列被调用,如果 BrassPlus包含一个执行某些操作的析构函数,则Brass必须有一个虚析构函数,即使该析构函数不执行任何操作

虚函数

首先,虚函数纯虚函数 是不同的

虚函数是为了允许用基类的指针来调用子类的这个函数,不代表函数为不被实现的函数

纯虚函数是为了,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数,代表函数没有被实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A
{
public:
    virtual void foo()
    {
        cout<<"A::foo() is called"<<endl;
    }
};
class B:public A
{
public:
    void foo()
    {
        cout<<"B::foo() is called"<<endl;
    }
};
int main(void)
{
    A *a = new B();
    a->foo();   // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
    return 0;
}

一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,即动态联编,所以被成为"虚"函数

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0

1
virtual void funtion1()=0

这里就衍生出了另外一个概念 抽象类(接口)带有纯虚函数的类为抽象类

  • 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出
  • 如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类
  • 如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类
  • 抽象类是不能定义对象的

虚函数原理

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

例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到 vtbl 中。注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已

Snipaste_2023-08-09_00-23-32

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

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

虚函数需要注意的

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。

  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为 动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。

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

  • 析构函数应当是虚函数,除非类不用做基类

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

  • 如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修 改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(covariance of return type)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Dwelling
    {
    public:
    virtual Dwelling & build(int n);
    };

    class Hovel : public Dwelling
    {
    public:
    virtual Hovel & build(int n);
    }

    这种只适用于返回值,而不适用于参数

  • 如果基类声明被重载了,则应在派生类中重新定义所有的基类版本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Dwelling
    {
    public:
    virtual void showperks(int a) const;
    virtual void showperks(double x) const;
    virtual void showperks() const;
    };

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

    如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义可只调用基类版本

派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),使公有继承不需要进行显式类型转换

将基类指针或引用转换为派生类指针或引用称为向下强制转(downcasting)。如果不使用显式类型转换,则向下强 制转换是不允许的

在继承中还有一个 protected 需要注意

在类外只能用公有类成员来访问protected部分中的类成员。privateprotected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的protected成员,但不能直接访问基类的private私有成员

参考链接

  1. 《C++ Primer Plus》