【C++】从0到1讲继承|复杂的菱形继承

简介: 【C++】从0到1讲继承|复杂的菱形继承

前言

本文主要讲述的是继承的概念,以及基类和派生类和衍生出的各种东西,还有多继承,菱形继承等,从0到1讲解继承。


一、什么是继承?

与日常生活中的人的继承相关,你可以继承你父亲的财富,继承你父亲的房产等等。

二、继承的语法表示

1.基类和派生类

基类也叫父类,派生类也叫子类,子类通过继承方式,继承父类。

2.继承的方式

继承方式有三种:public,protected,private。

不同的继承对应着不同的访问方式的变化,变化如下:

其中,我们最经常使用的是公有继承。

有需要注意的点:

1.基类的private一旦被继承,就不可见。这里的不可见是在派生类中无法被访问,而不是没有继承。

2.只推荐使用公有继承,其他的继承方式不推荐使用。

3.class默认的继承方式是私有继承,struct默认的继承方式是公有继承,但是推荐显式写出继承方式。

三、基类和派生类的对象赋值转换

  • 1.子类对象可以直接赋值给基类对象/基类的指针/基类的引用但是基类对象不能赋值给子类。因为编译器认为基类的对象类型不完全包含子类。在这里也叫做切片。
  • 2.基类对象本身不能赋值给子类对象。
  • 3.基类的指针/引用可以赋值通过强制类型转换赋值给子类的指针/引用,但必须是基类的指针指向子类才安全。(了解即可)

四、继承中的新概念——隐藏(重定义)

  • 1.在子类继承父类中,子类的作用域和父类的作用域是独立的。
  • 2.如果子类和父类有同名成员变量,子类成员会将父类的同名成员变量隐藏起来,可以理解成父类的成员变量被揣进裤兜里了。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected :
    string _name = "小李子"; // 姓名
    int _num = 111;  // 身份证号
};
class Student : public Person
{
public:
    void Print()
    {
        cout<<" 姓名:"<<_name<< endl;
        cout<<" 身份证号:"<<Person::_num<< endl;
        cout<<" 学号:"<<_num<<endl;
    }
protected:
    int _num = 999; // 学号
};
void Test()
{
    Student s1;
    s1.Print();
};
  • 上面代码的情况就符合隐藏,虽然代码能跑,不过不容易进行区分。
  • 3.如果子类和父类有同名的成员函数,同样也会隐藏起来,这个也叫做重定义。
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
    void fun()
    {
        cout << "func()" << endl;
    }
};
class B : public A
{
public:
    void fun(int i)
    {
        A::fun();
        cout << "func(int i)->" <<i<<endl;
    }
};
void Test()
{
    B b;
    b.fun(10);
};
  • 对于类成员函数来说,只要同名就构成隐藏。
  • 4.实际中最好不要定义重名成员。

五、派生类的默认成员函数

  • 1.构造函数

在子类的构造函数中会调用父类的构造函数。所以如果父类中没有默认构造函数,则在子类的构造函数的初始化列表中必须显式地调用父类的构造函数来完成父类那部分成员的初始化。

  • 并且在子类的构造函数的初始化列表中,会按照声明出现的顺序依次初始化,所以应该先调用父类的构造函数,再初始化子类的成员。
  • 总结:构造保证先付猴子
  • 2.拷贝构造

在子类的拷贝构造中,必须显式地调用父类的拷贝构造,否则编译器会自动调用父类的默认构造,而不是调用父类的拷贝构造。

拷贝构造也是构造,最后作用域结束会调用父类的析构对父类成员进行释放。

  • 3.赋值

在子类的赋值运算符重载同样需要显式地调用父类的赋值运算符重载。

  • 4.析构

析构函数就不同了,不能显式调用父类的析构函数。

原因如下:

1.在构造函数中是先构造父类再构造子类,析构的顺序应该是先析构子类再析构父类。如果显式调用就会改变顺序,不合理。

2.有可能在子类会使用父类的成员,如果父类先析构,可能会造成非法访问。

六、继承和友元

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

举个简单的例子:我父亲的朋友不是我的朋友。

如果想要父类的友元也变成子类的友元,则需要在子类中声明该函数为友元。

七、继承和静态成员

在继承中,你可以认为静态成员继承了,也可以认为没有继承。

因为对于静态成员,子类只继承了使用权。

在整个继承体系中,静态成员只有一份,子类和父类都可以共同使用。

用下面一段代码可以证明:

 

class Person
{
public :
    Person () {++ _count ;}
protected :
    string _name ; // 姓名
public :
    static int _count; // 统计人的个数。
};
int Person :: _count = 0;
class Student : public Person
{
protected :
    int _stuNum ; // 学号
};
class Graduate : public Student
{
protected :
    string _seminarCourse ; // 研究科目
};
void TestPerson()
{
    Student s1 ;
    Student s2 ;
    Student s3 ;
    Graduate s4 ;
    cout <<" 人数 :"<< Person ::_count << endl;
    Student ::_count = 0;
    cout <<" 人数 :"<< Person ::_count << endl;
}

这段代码计算整个继承体系一共创建了多少个类对象,包括父类和子类。

八、菱形继承和菱形虚拟继承

继承可以分为单继承和多继承.

下面这样的情况就是多继承。

 


而菱形继承就是多继承的一种特例。

8.1 菱形继承的问题

对于菱形继承来说,真正出问题的是上图的Assistant。

1.在它的成员中有两份重复的Person的成员,出现了数据冗余的情况。

2.如果想在Assistant中调用Person的成员变量/成员函数,编译器就无法确定到底该调用Teacher继承下来的还是调用Student继承下来的。

在上面的继承体系中,内存关系如下图:

按照各个类声明出现的顺序依次继承,内存从上到下放置。

为了解决菱形继承的问题,我们可以使用菱形虚拟继承来解决。

我们在菱形继承体系的腰部加上两个virtual,让Student和Teacher继承Person是虚拟继承。

用虚拟继承可以解决菱形继承的原因:

 

我们重新定义一个菱形继承:

 

class A
{
public:
  int _a;
};
// class B : public A
class B : virtual public A
{
public:
  int _b;
};
// class C : public A
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;
}

在这个菱形继承中,我们通过调试观察可以发现,D对象的内存地址如下:

可以看到,前两个地址是B对象的地址,接着的是C对象的地址,再下来的地址是D对象中的成员_d的地址,而最后一个地址,其实是A对象中的成员_a的地址。

使用了菱形虚拟继承后,继承的对象在内存中会放到内存的最下面,在B和C对象的第一个地址中,存的是一个虚基表的地址,在虚基表中存的又是该对象相对于A对象的偏移量。

所以菱形虚拟继承可以通过查找到对象对应的虚基表的偏移量来获取对象A的地址,进而访问对象A的成员。这样就不用再在每个继承对象中都存一份A对象,并且在D子类中只有一份A对象,解决了数据冗余和二义性的问题。

A对象越大,越能够节省空间,因为在B和C对象中,只存了一个指针,指向虚基表,只有4字节。如果是存一个很大的数组,则需要花费巨大的空间。

总结:菱形虚拟继承是在对象中存一个指针,该指针指向一个虚基表,在虚基表中存着该对象相对于父类对象的偏移量,而父类在内存中是存储在整个继承体系内存的下面,能够通过偏移量找到父类对象的地址,进而访问父类对象的成员。

九、继承和组合

继承:白盒测试,每一部分细节都展示,需要测试每一部分代码的功能。

继承中每一个子类对象都是一个父类对象。

组合:黑盒测试,隐藏了细节,只暴露接口,用接口进行测试。

组合中每一个子类对象都有一个父类对象。

十、常见笔试面试题

1. 什么是菱形继承?菱形继承的问题是什么?

2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的

3. 继承和组合的区别?什么时候用继承?什么时候用组合?

1.菱形继承是多继承中的一种,假如有一个父类对象A,子类对象B继承A,C也继承A,同时有一个子类对象D同时继承了B和C,这样的继承关系就是菱形继承。菱形继承的问题是在B类和C类中都有一份A类的成员,造成数据冗余,并且如果用D类对象访问A类的成员时,会出现二义性,也就是不知道该访问谁。

2、在B类继承A类和C类继承A类时加上一个virtual,就是菱形虚拟继承。菱形虚拟继承是在B类和C类中存储一个指针,该指针指向一个叫做虚基表的表,表中存着该对象和父类对象的地址偏移量,可以通过自己相对父类的偏移量找到父类的地址,进而访问父类成员。在上述的菱形继承案例中,A类的成员在整个继承体系中只有一份,就解决了二义性问题。而在B类和C类中只存储一个虚基表指针,可以解决数据冗余的问题。

3.继承是子类继承父类,可以使用父类的所有属性和方法,组合是将已存在的类作为新的类的成员,两者无上下级的关系。当我们只需要用一个类的接口函数时,用组合;其他情况用继承。

总结

本文讲解了C++继承的众多概念。

相关文章
|
1月前
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
77 11
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
51 1
|
1月前
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
36 1
|
27天前
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
15 0
|
1月前
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
27 0
|
1月前
|
程序员 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
31 0
|
2月前
|
C++
C++(二十)继承
本文介绍了C++中的继承特性,包括公有、保护和私有继承,并解释了虚继承的作用。通过示例展示了派生类如何从基类继承属性和方法,并保持自身的独特性。此外,还详细说明了派生类构造函数的语法格式及构造顺序,提供了具体的代码示例帮助理解。
|
2月前
|
C++
c++继承层次结构实践
这篇文章通过多个示例代码,讲解了C++中继承层次结构的实践应用,包括多态、抽象类引用、基类调用派生类函数,以及基类指针引用派生类对象的情况,并提供了相关的参考链接。
|
3月前
|
安全 Java 编译器
|
21天前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
21 4