单继承和多继承

定义一个派生类的过程:

  1. 吸收基类的成员
  2. 添加新的成员(非必须)
  3. 隐藏基类的成员(非必须)

如果定义一个派生类只写了继承关系,没有写任何的自己的内容,那么也会吸收基类的成员,这个情况叫做空派生类(其目的是在特定的场景建立继承关系,为将来的拓展留出空间)

继承访问权限.png

继承关系的局限性:

  • 创建、销毁的方式不能被继承 —— 构造、析构
  • 复制控制的方式不能被继承 —— 拷贝构造、赋值运算符函数
  • 空间分配的方式不能被继承 —— operator new 、 operator delete
  • 友元不能被继承(友元破坏了封装性,为了降低影响,不允许继承)

单继承

创建派生类对象时调用基类构造的机制

  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
class Base {
public:
Base(long base):_base(base){
cout << "Base(long)" << endl;
}
~Base() {
cout << "~Base()" << endl;
}
private:
long _base;
};

class Derived : public Base
{
public:
Derived(long base, long derived)
: Base(base) // 显示 调用基类构造函数
,_derived(derived)
{
cout << "Derived(long)" << endl;
}
~Derived() {
cout << "~Derived()" << endl;
}
private:
long _derived;
};

派生类对象的内存布局:

单继承1.png

构造函数的调用顺序:

创建派生类对象会马上调用派生类的构造函数,但在初始化列表的最开始调用基类的构造函数。等到基类构造完成,派生类对象才接着完成。

派生类对象的销毁

当派生类析构函数执行完毕之后,会自动调用基类析构函数,完成基类部分所需要的销毁(回收数据成员申请的堆空间资源)。

这和前面继承中构造函数还有所不同,这里的析构是派生类析构彻底完成,才会去调用基类析构函数。前面的构造函数是派生类先调用构造函数,在此期间会调用基类的构造函数,等到基类的构造函数完成之后,才接着完成自己的构造函数。

当派生类对象中包含对象成员

在派生类的构造函数中,初始化列表里调用基类的构造,写的是类名。初始化列表中调用对象成员的构造函数,写的是对象成员的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Demo;
class Base;
class Derived
: public Base
{
public:
Derived(long base, int num,long derived)
: Base(base) // 基类的初始化
, _derived(derived)
, demo(num) // 类的对象成员的初始化
{
cout << "Derived(long)" << endl;
}
~Derived() {
cout << "~Derived()" << endl;
}
private:
long _derived;
Demo demo;
};

内存布局如下:

单继承2.png

如果再给派生类中加上一个基类的对象成员,派生类的构造函数应该怎么写呢?

就是和 类的对象成员的初始化 方式一样。

单继承3.png

创建一个派生类对象时,会马上调用自己的构造函数,在此过程中,还是会先调用基类的构造函数创建基类子对象,然后根据对象成员的声明顺序去调用对象成员的构造函数,创建出成员子对象;

一个派生类对象销毁时,调用自己的析构函数,析构函数执行完后,按照对象成员的声明顺序的逆序去调用对象成员的析构函数,最后调用基类的析构函数(基类子对象调用)。

对基类成员的隐藏

基类数据成员的隐藏

派生类中声明了和基类的数据成员同名的数据成员,就会对基类的这个数据成员形成隐藏。

派生类对象无法通过数据成员的名字直接访问基类的这个数据成员。

如果一定要访问基类的这个数据成员,需要加上作用域,但是这种写法不符合面向对象的原则,不推荐实际使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
int x;

Base() : x(10) {}
};

class Derived : public Base {
public:
int x; // 子类成员隐藏了基类的成员

Derived() : x(20) {}

void print() {
std::cout << "Derived x: " << x << std::endl; // 打印子类的 x
std::cout << "Base x: " << Base::x << std::endl; // 显式访问基类的 x
}
};

基类成员函数的隐藏

当派生类声明了与基类同名的成员函数时,只要名字相同,即使参数列表不同,也只能看到派生类部分的,无法通过派生类对象直接调用基类的同名函数。

如果一定要调用基类的这个成员函数,需要加上作用域,但是这种写法不符合面向对象的原则,不推荐实际使用。

多继承

多继承继承的时候容易犯下面这个错误:

1
2
3
4
5
6
7
class D
: public A,B,C
{
public:
D(){ cout << "D()" << endl; }
~D(){ cout << "~D()" << endl; }
};

你可能误以为是 D public 继承 A 、B 、C,实际情况是D public 继承 A ,private 继承B 、C。

正确的做法见下:

1
2
3
4
5
6
7
8
9
class D
: public A
, public B
, public C
{
public:
D(){ cout << "D()" << endl; }
~D(){ cout << "~D()" << endl; }
};

多重继承的派生类对象的构造和析构

基于前面的结构,我们讨论:

创建 D 类对象时,这四个类的构造函数调用顺序如何?

马上调用 D 类的构造函数,在此过程中会根据继承的声明顺序,依次调用 A/B/C 的构造函数,创建出这三个类的基类子对象。

D 类对象销毁时,这四个类的析构函数调用顺序如何?

马上调用 D 类的析构函数,析构函数执行完后,按照继承的声明顺序的逆序,依次调用 A/B/C 的析构函数。

单继承4.png

多重继承可能引发的问题

成员名访问冲突的二义性

当多个基类中包含同名的成员(函数、变量等),会引发命名冲突问题。编译器无法确定应该使用哪个基类的成员,导致二义性错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A {
public:
void func() { std::cout << "Class A" << std::endl; }
};

class B {
public:
void func() { std::cout << "Class B" << std::endl; }
};

class C : public A, public B {
public:
void func() { std::cout << "Class C" << std::endl; }
};

int main() {
C c;
c.func(); // 编译错误:命名冲突
}

解决办法:可以通过 作用域解析符 来指定调用哪个基类的函数。但我们不推荐这样做。

1
2
c.A::func();  // 调用 A 的 func
c.B::func(); // 调用 B 的 func

存储二义性的问题(重要)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A {
public:
void show() { std::cout << "Class A" << std::endl; }
};

class B : public A {
public:
void show() { std::cout << "Class B" << std::endl; }
};

class C : public A {
public:
void show() { std::cout << "Class C" << std::endl; }
};

class D : public B, public C {
public:
void show() { std::cout << "Class D" << std::endl; }
};

这是经典的菱形继承问题,通过给中间类(类 B 和 类 C)加上 virtual 关键字就可以解决这个问题。

如下图,左边是未解决问题前的二义性,右边就是加上 virtual 关键字解决二义性。

菱形继承.png

使用虚继承,即加上 virtual:

1
2
3
4
5
6
7
8
9
class B : virtual public A {
public:
void show() { std::cout << "Class B" << std::endl; }
};

class C : virtual public A {
public:
void show() { std::cout << "Class C" << std::endl; }
};

使用虚继承后,类 A 只会有一个共享的实例。

采用虚拟继承的方式处理菱形继承问题,实际上改变了派生类的内存布局。B 类 和 C 类对象的内存布局中多出一个虚基类指针,位于所占内存空间的起始位置,同时继承自A类的内容被放在了这片空间的最后位置。D类对象中只会有一份A类的基类子对象。

虚拟继承.png

基类与派生类之间的转换

一般情况下,基类对象占据的空间小于派生类对象。(空继承时,有可能相等)

回答下面几个问题:

可否用一个基类对象给一个派生类对象赋值?可否用一个派生类对象给一个基类对象赋值?

用基类对象给派生类对象赋值:

  • 不可以。这是因为基类对象无法包含派生类中的额外成员或方法。派生类扩展了基类,因此将基类对象赋给派生类对象会导致丢失派生类中的信息。

用派生类对象给基类对象赋值:

  • 可以。派生类对象可以被当作基类对象来处理,派生类对象包含了基类的成员,因此派生类对象可以赋值给基类对象。这种情况会进行对象切割(object slicing),即基类部分会被复制,派生类部分会被丢弃。

 

可否将一个基类指针指向一个派生类对象?可否将一个派生类指针指向一个基类对象?

将基类指针指向派生类对象:

  • 可以。这就是 C++ 多态机制的基础之一。派生类对象可以赋给基类指针,基类指针可以指向派生类对象,但如果没有虚函数,基类指针只能访问基类的成员。

将派生类指针指向基类对象:

  • 不可以。这是因为派生类指针假设它指向的是派生类类型的对象,基类对象不包含派生类的成员。将派生类指针指向基类对象是类型不匹配的。

 

可否将一个基类引用绑定一个派生类对象?可否将一个派生类引用绑定一个基类对象?

将基类引用绑定派生类对象:

  • 可以。基类引用可以绑定派生类对象,这也是 C++ 中的多态机制之一。你可以通过基类引用来访问派生类的成员(前提是有虚函数时,能够调用派生类重写的方法)。

将派生类引用绑定基类对象:

  • 不可以。派生类引用期望绑定派生类对象,绑定基类对象是不符合类型要求的。这种情况下会发生编译错误。

 

为什么向上转型可以直接进行,而向下转型不能呢?

因为向下转型有风险 —— 以指针为例

转换危险.png

Base 类的指针指向 Derived 类的对象,d1 对象中存在一个 Base 类的基类子对象,这个 Base 类指针所能操纵只有继承自 Base 类的部分。

Derived 类的指针指向 Base 对象,除了操纵 Base 对象的空间,还需要操纵一片空间,只能是非法空间,所以会报错。

 

C++ 的 dynamic_cast 进行转换,含有检查功能

1
2
3
4
5
6
7
8
9
10
11
Derived d1(2,5);
Base * pbase = &d1;

//向下转型
Derived * pd = dynamic_cast<Derived*>(pbase);
if(pd){
cout << "转换成功" << endl;
pd->display();
}else{
cout << "转换失败" << endl;
}

如果是不合理的转换,dynamic_cast 会返回一个空指针。

这里可以转换成功,因为 pbase 本身就是指向一个 Derived 对象。

派生类对象间的复制控制

复制控制函数就是 拷贝构造函数、赋值运算符函数

原则:基类部分与派生类部分要单独处理

(1)当派生类中没有显式定义复制控制函数时,就会自动完成基类部分的复制控制操作;

(2)当派生类中有显式定义复制控制函数时,不会再自动完成基类部分的复制控制操作,需要显式地调用;

单继承5.png

对于拷贝构造,如果显式定义了派生类的拷贝构造,在其中不去显式调用基类的拷贝构造,那么无法通过复制初始化基类的部分,只能尝试用Base无参构造初始化基类的部分。如果Base没有无参构造,编译器就会报错。

对于赋值运算符函数,如果显式定义了派生类的赋值运算符函数,在其中不去显式调用基类的赋值运算符函数,那么基类的部分没有完成赋值操作。

拷贝构造函数

如果给 Derived 类中添加一个char * 成员,依然不显式定义 Derived 的复制控制函数。

那么进行派生类对象的复制时,基类的部分会完成正确的复制,派生类的部分只能完成浅拷贝。

若派生类合理地定义了析构函数,那么对象在销毁时会出现 double free 问题(三合成原则)

1
2
Derived d1("hello","world");
Derived d2 = d1;

单继承6.png

如果接下来给 Derived 类显式定义了拷贝构造,但是没有在这个拷贝构造中显式调用基类的拷贝构造(没有写任何的基类子对象的创建语句),会直接报错。

(—— 在派生类的构造函数的初始化列表中没有显式调用基类的任何的构造函数,编译器会自动调用基类的无参构造,此时基类没有无参构造,所以报错)

因为无法对 d2 对象的基类子对象进行初始化,需要在 derived 的拷贝构造函数中显式调用 Base 的拷贝构造。

单继承7.png

赋值运算符函数

如果接下来给 Derived 显式定义赋值运算符函数,但是没有在其中显式调用基类的赋值运算符函数。

1
2
3
4
5
Derived d1("hello","world");
Derived d2 = d1;
Derived d3("beijing","shanghai");

d2 = d3; //派生类对象的部分完成了复制,但是基类部分没有完成复制

单继承8.png

基类的部分不会自动完成赋值,需要在 Derived 的赋值运算符函数中显式调用 Base 的赋值运算符函数,才能完成正确的赋值。

单继承9.png

派生类对象间的移动控制

即指 移动构造和移动赋值,它们照样遵循前面的准则。

当派生类中定义了显式的移动控制函数时,需要手动调用基类的相应移动操作,以确保基类部分也能正确地移动资源。

移动构造函数BaseDerived 类中都定义了移动构造函数。Derived 的移动构造函数显式调用了 Base 的移动构造函数 Base(std::move(other))

移动赋值运算符Derived 的移动赋值运算符中,显式调用了基类的移动赋值运算符 Base::operator=(std::move(other)),并重置了移动后的对象。