C++继承

C++
2.6k words

继承

继承的概念

继承是C++面向对象的三大特性之一,他设计的初衷是为了提高代码的复用性

例如,对于重复的功能代码,我们可以提炼抽象成函数,减少代码量,提高复用性

而对于重复或者相似的类,我们也可以提炼出他们共同的部分,称之为基类,那提炼之前的类,我们可以把它想象成是基类派生出的类,称之为派生类,还有一种更形象化的叫法,父类与子类

那我们在这里就能明白了,继承是类的设计层面的复用

例如,我们想要从人派生出学生和教师两个子类,这里要重点理解两个方向,一是由下而上的集成学生和教师共同点产生的基类,二是由上而下的从人继承出学生和教师产生的子类

1
2
3
4
5
6
7
8
9
10
11
12
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "Morty";
int _age = 14;
};

这里就是基类,在我们继承之后,Person的成员都会变成子类的一部分,我们可以在监视窗口中看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Student :public Person
{
protected:
string _StuId;
};

class Teacher :public Person
{
protected:
string _JobId;
};

int main()
{
Student stu;
Teacher tch;
stu.print();
tch.print();
return 0;
}

屏幕截图 2024-03-02 112853.png

继承的定义

继承的格式

我们在上面的演示中已经能看到继承的格式了,他的含义如下

屏幕截图 2024-03-02 113225.png

继承方式

我们在之前的类和对象基础中讲到过访问限定符,在学继承之前,我们可以认为protected和private是一样的,都是在类内可以使用,在类外不能使用

在这里就有所区别了,尤其是在继承方式这里,也有不同

public表示类内外都可以使用,可以继承访问

protected表示类内可以使用,类外不可以使用,可以继承访问

private表示类内可以使用,类外不可以使用,继承后在派生类中不可见(不能使用或访问)

这里可以使用一张表来表示其中的关系,表中内容表示在子类中表示什么成员

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 public成员 protected成员 private成员
基类的protected成员 protected成员 protected成员 private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

这里我们可以发现,子类的成员继承后是取父类成员和继承方式之中权限较小的那一个的

说明:

  1. 父类private成员无论使用什么方式派生给子类,子类都是不可见的,这里的不可见指的是,继承到子类中,但限制子类内外都不能访问

  2. 父类protected成员,在类外不能访问,但是可以在继承的子类中访问

  3. 继承方式可以省略,class默认为private继承,struct默认为public继承,推荐写继承方式

  4. 常用public继承,另外两种继承方式很少用,因为本身就是为了提高复用性,如果再保护起来就只能在类中使用,实际维护性不强

  5. private继承实际上限制的是子类的继承不可见,在C++11中新增了final关键字,可以达到类似的效果,表示不可被继承,否则会报错

    1
    2
    3
    4
    5
    class Teacher final :public Person
    {
    protected:
    string _JobId;
    };

基类和派生类的赋值转换

这里的赋值转换指的是从派生类赋值给基类的对象、指针、引用,有一个形象的说法叫做切片,含义是将子类中父类的部分切割之后赋值过去

需要注意的是父类不能赋值给子类

对于父类的指针或引用可以通过强制类型转换赋值给子类的指针或者引用,但必须是父类的指针指向子类对象时才安全

如果这里的父类是多态类型,可以使用RTT(Run-Time Type Information)的dynamic_cast识别后进行安全转换,这里后面会讲解

继承的作用域

这里主要说明的是同名成员的访问问题

首先,父类和子类分别具有独立的作用域,因此我们在子类中直接使用的成员就是子类的成员

其次,在子类中不能直接对父类成员访问,称之为隐藏,也叫做重定义,但并不构成重载(因为重载要求在相同的作用域下)

在子类中如果想使用父类成员,我们可以使用作用域限定符,例如 基类:: 基类成员

在实际的继承体系中,最好不要定义同名的成员

子类的默认成员函数

我们在之前类和对象基础中讲过六个默认成员函数,在继承中子类的默认成员函数生成规则如下

  1. 默认构造函数:子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员,如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表中显式调用,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class Person
    {
    public:
    Person(const char* name = "Morty", int age = 14)
    :_name(name)
    , _age(age)
    {}
    protected:
    string _name;
    int _age;
    };

    class Student :public Person
    {
    public:
    Student(const char* name, int age, const char* StuId)
    :Person(name, age)
    ,_StuId(StuId)
    {}
    protected:
    string _StuId;
    };
  2. 子类的拷贝构造函数必须调用父类的拷贝构造,完成基类部分的拷贝初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // Person 类中
    Person(const Person& p)
    :_name(p._name)
    ,_age(p._age)
    {}

    // Student 类中
    Student(const Student& s)
    :Person(s)
    ,_StuId(s._StuId)
    {}
  3. 子类的赋值重载必须要调用父类的赋值重载完成父类的赋值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // Person 类中
    Person& operator=(const Person& p)
    {
    if(this!=&p)
    {
    _name = p._name;
    _age = p._age;
    }
    return *this;
    }

    // Student 类中
    Student& operator=(const Student& s)
    {
    if(this!=&s)
    {
    Person::operator=(s);
    _StuId = s._StuId;
    }
    return *this;
    }
  4. 子类的析构函数在被调用完成后会自动调用父类的析构函数,因为要确保先清理子类的成员,再清理父类成员的顺序\

  5. 子类对象初始化先调用父类构造再调用子类构造

  6. 子类对象析构先清理子类的析构再调用父类的析构

  7. 在后续学习的一些场景中析构函数需要构成重写,重写的条件之一是函数名相同,那么编译器会对析构函数名进行特殊处理,变成destructor(),因此父类析构函数不加virtual的情况下,子类析构和父类析构构成隐藏关系

继承与友元

友元关系不能继承,也就是说基类的友元不能访问子类的私有和保护成员

继承与静态成员

基类的static成员即便在继承后也只有一个static成员实例,我们可以通过内存窗口观察到

多继承与菱形继承

一个子类只有一个直接父类称为单继承

屏幕截图 2024-03-02 130820.png

一个子类有多个直接父类称之为多继承

屏幕截图 2024-03-02 131129.png

菱形继承是如下一种特殊情况

屏幕截图 2024-03-02 131329.png

菱形继承会产生数据冗余和二义性的问题

一来person在assistant中出现了两次,会造成空间浪费,其次在assistant中会有两个部分的person的成员,在进行修改和使用时,无法确保使用的是哪一部分的,当然我们可以使用访问限定符来解决二义性问题,但是数据冗余仍无法解决

为了解决这个问题,我们引入虚拟继承,使用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
class A
{
public:
int _a;
};

class B : public A
{
public:
int _b;
};

class C : public A
{
public:
int _c;
};

class D : public B, public C
{
public:
int _d;
};


int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}

屏幕截图 2024-03-02 133056.png

通过内存我们可以观察到内存冗余现象

如果使用虚拟继承,要注意虚拟继承的位置是在菱形继承的腰部

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
class A
{
public:
int _a;
};

class B : virtual public A
{
public:
int _b;
};

class C : virtual public A
{
public:
int _c;
};

class D : public B, public C
{
public:
int _d;
};


int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}

屏幕截图 2024-03-02 134320.png

在使用虚拟继承之后,我们发现A始终是在同一个位置,而且是整个类的最底部,1被2覆盖,其次在虚拟继承的B和C多出来一些数据,我们猜测这是地址,通过内存可以找到

屏幕截图 2024-03-02 134721.png

查看对应内存的数据再对比可以发现,这实际上是虚拟继承到父类数据位置的偏移量,例如从B到A的偏移量是20(16进制下为14),从C到A的偏移量是12(16进制下为c)

对于蓝色框之外的部分,在后面会介绍到,其次虚拟继承的实现方式有很多种,这里只表示其中一种,使用偏移量的一个明显好处就是,在对象进行大量实例化之后,仍然只需查这一个偏移量的表

关于继承的说明

继承算是C++复杂语法的一部分,就是因为菱形继承和他的底层原因,因此一般不建议设计多继承,更不要设计菱形继承,否则在复杂度和性能上都会出问题

继承和对象的组合有一定的相似之处,但是组合中,原先的类中的保护和私有在新的类中是无法使用的

在实际运用中是优先使用组合关系的,因为他更符合我们设计中高内聚低耦合的要求

Comments