C++类的继承

公有继承

实现公有继承

1
2
3
4
class RatedPlayer: public TableTennisPlayer
{
// ...
};

上面的代码实现了一个公有继承。派生类继承了基类的私有部分,但是只能通过派生类的公有方法来访问。

构造派生类

派生类不能访问基类的私有成员,所以要通过基类公有的构造函数来完成基类部分的构造。可以在派生类的构造函数后使用成员初始化列表语法来完成这个操作:

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

这样就把参数传递给TableTennisPlayer,并基类的构造函数来构造对象。

如果省略了这个成员初始化列表,那么编译器会使用默认的基类构造函数,即一个不带参数的构造函数。还可以使用下面这个语法来构造派生类的对象:

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

因为tp的类型为TableTennisPlayer,所以会调用基类的复制构造函数。这里基类没有复制构造函数,所以会调用默认复制构造函数,由于基类中并没有使用动态内存分配,所以这样的构造是可行的。

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

派生类可以调用基类的非私有方法

如果基类的方法是虚函数,并且派生类覆盖,那么调用的就是自己的方法。

基类的指针/引用可以指向派生类的对象

在函数中,函数的参数、返回值也都可以被赋为派生类的对象。比较特殊的情况是下面这种情况:

1
2
3
4
RatedPlayer pa(1840,"Olaf","Loaf",true);
TableTennisPlayer pb(pa); // 1.call copy constructor
TableTennisPlayer pc;
pc = pa; // 2.call assign operator

第1处,由于pa可以被转化为指向基类的引用,所以pa会先被转化为基类引用,然后调用基类的复制构造函数。

第2出,同理,pa会先被转化为指向基类的引用,然后pc会调用复制重载函数。

虚函数

在默认情况下,如果基类的指针/引用调用方法时,会直接调用基类的方法,而不是派生类的方法,这不利于实现多态特性,大大降低的代码的重复利用率。虚函数是实现多态特性的一个重要特性,在通过基类的指针/引用调用方法时,程序会通过判断指向对象的类型来判断应该调用哪个函数。

通过在函数前添加virtual关键字,我们可以在类中声明一个虚函数。

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
43
44
45
46
class Animal
{
public:
Animal(const string& name):
m_name{name}
{}

const string& getName() const
{
return m_name;
}

virtual string speak() const
{
return "???";
}

private:
string m_name;
};

class Cat : public Animal
{
public:
Cat(const string& name):
Animal(name)
{}

virtual string speak() const
{
return "Meow";
}
};

class Dog : public Animal
{
public:
Dog(const string& name):
Animal(name)
{}

virtual string speak() const
{
return "Woof";
}
};

override标识符

在试图重写基类的虚方法时,必须保证派生类的方法签名与基类的方法签名是一样的,否则就会变成重载方法,而不是**重写(覆盖)**方法。在派生类的方法中添加override关键字后,当我们写的方法与基类不对时,编译器就会报错,很好地将运行时的错误转化为编译时的错误,易于被发现。

虚函数的实现

首先要区别动态联编静态联编的概念,静态联编是指在编译时就就能确定函数的地址,而动态联编是指程序调用的函数不能在编译时确定,而必须根据指针所指向的对象动态地决定调用的地址。

虚函数的实现是通过虚函数表来完成的,每个对象中都有一个隐藏的虚函数表成员,存储了为类对象进行声明的虚函数的地址,派生类会保存一个新的虚函数表,如果派生类提供了新的虚函数的定义,那么就会提供新函数的地址,否则将保存原来版本的函数地址。也正是由于虚函数表的存在(会占用额外的内存),C++没有将虚函数作为默认的选项,而是需要手动地将函数声明为虚的。

虚析构函数

还有一些特殊的函数,我们可以考虑一下是否将他们声明为虚的。首先是构造函数,显然构造函数不应该为虚的,因为在派生类中会使用到基类的构造函数。

接下来是虚析构函数,如果不把析构函数声明为虚的,那么派生类的析构函数只会执行它所拥有的代码块,不会执行其他步骤。而虚的析构函数除了会执行自己的代码块之外,还会在完成代码块后执行基类的析构函数。也就是说,如果希望派生类在结束生命周期的时候将派生类和基类的内存全部都释放,那么就应该声明一个虚的析构函数。

《C++ Primer Plus》中如此提到:

通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

抽象基类

抽象基类就是一个不能被创建对象的类(对象没有意义),但是可以被继承,可以创建指向它的指针和引用(用于实现多态)。声明抽象基类没有特定的语法,但是需要在其中声明一个纯虚函数

1
2
3
4
class Animal
{
virtual void Cry() = 0;
};

当然,可以在声明时将这个方法声明为纯虚函数,与此同时在实现文件中提供该方法的定义,这样以一来就提供了一个含有“有定义的纯虚方法”的抽象基类。

继承和动态内存分配

如果基类使用动态内存分配来声明成员变量,那么派生类是否需要重新定义析构函数、复制构造函数、赋值运算,需要取决于派生类是否使用新的动态内存分配。

派生类不使用new的情况

先考虑析构函数,由于默认析构函数在执行完之后会自动调用基类的析构函数(这里参考上一节,我们通常都会给基类提供一个虚的析构函数),所以派生类可以不重新定义析构函数。

其次是复制构造函数和赋值运算,结论是也不需要。因为在派生类中的成员变量都可以直接赋值,不需要进行内存的拷贝,所以使用基类的复制构造函数就可以将基类的动态成员复制给新的对象,而派生类部分的会由默认复制构造函数来完成。赋值运算符重载同理。

派生类使用new的情况

正如前几节所说,如果派生类使用了动态内存分配,则派生类必须重新定义析构函数来完成派生类内存的释放,而派生类的析构函数调用完之后,会自动调用基类的析构函数来释放基类的内存。

复制构造函数

派生类的复制构造函数只能访问派生类的数据,所以如果希望将基类的数据也进行拷贝,则需要如下所示调用基类的复制构造函数:

1
2
3
4
hasDMA::hasDMA(const hasDMA & hs):baseDMA(hs)
{
// ...
}

成员初始化列表将一个hasDMA的引用传递给baseDMA的复制构造函数,这里运用到“基类的引用可以指向派生类型”这个特性。

赋值运算符

先直接给出实例代码:

1
2
3
4
5
6
7
8
9
10
hasDMA &hasDMA::operator=(const hasDMA &hs)
{
if (this==&hs)
return *this;
baseDMA::operator=(hs); // attention
delete[] style;
style = new char[strlen(style)+1];
strcpy(style,hs.style);
return *this;
}

这里和复制构造函数的思路非常相似,由于派生类无法访问基类的数据,所以需要显示地调用基类的赋值运算符函数,上述代码的第五行就执行了这个步骤。这行代码可能比较难以理解,但是我个人认为可以理解为与下面同理(没有实验过,不知道对不对):

1
(baseDMA)*this = hs;

将派生类转化为基类之后,隐式地调用赋值运算符来完成这个操作。那为什么不是*this=hs呢?这是因为当前就在派生类的赋值运算符函数中,使用这样的写法会发生递归调用。

多重继承

C++支持多重继承,这个特性比较富有争议,多重继承对于一些特殊的工程来说是必不可少的,但是在很多情况下多重继承会带来很多麻烦。一个比较典型的情况就是菱形继承,即两个类继承了同一个基类,而一个新的派生类又通过多重继承来派生自这两个类,这就会引出许多问题。这一节便是关注如何解决这样的问题。

菱形继承

问题1:有多少个基类

如果我们设计了上图所示的类继承关系,那么编译器实际上会在SingerWaiter的对象中包含两个Worker对象,于是下面的代码将出现二义性:

1
2
SingerWaiter ed;
Worker* pw = &ed;

因为编译器并不知道应该将SingerWaiter对象的地址转化为哪个Worker类。可以使用下面的代码来消除二义性:

1
2
Worker* pw1 = (Singer *)&ed;
Worker* pw2 = (Waiter *)&ed;

但是实际上很少这么做,因为大多数情况下我们不希望类中包含两个以上的相同对象。于是引出下文的解决方法。

解决1:虚基类

通过关键字virtual可以将是的基类变为虚基类,在上面的例子中,如果worker在两个类中都被声明为虚基类,那么SingerWaiter中就只会包含一个Worker对象。

于是,派生类中存在多个子对象的问题就解决了。但是这又会引发一个新的问题,那就是构造函数的问题。如果在SingerWaiter类的构造函数中调用WaiterSinger类的构造函数,那么他们会同时尝试调用Worker类的构造函数,这会出现问题,于是编译器会使用默认构造函数来构造基类Worker如果不希望调用默认构造函数,就应该显式地调用基类的构造函数。

1
2
SingerWaiter(const Worker & wk, int p=0, int v=Singer::ohter)
: Worker(wk), Waiter(wk,p), Singer(wk,v){}

问题2:调用哪个方法

如果继承的两个类中有相同签名的方法,则在通过派生类调用时又会产生二义性。

解决2:作用域解析运算符

此时则需要使用作用域解析运算符::来告诉编译器应该调用哪个对象的方法,在上面的例子中,假设SingerWaiter都实现了Show方法,那么在派生类的实现就应该像下面这样:

1
2
3
4
5
SingerWaiter::Show()
{
Singer::Show();
Worker::Show();
}

C++类的继承
http://zhf-xdu.top/2023/11/05/C-类的继承/
作者
周洪锋
发布于
2023年11月5日
许可协议