【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++中的继承
C++中的继承
8 0
|
2天前
|
安全 前端开发 Java
【C++】从零开始认识继承二)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
9 1
|
2天前
|
安全 程序员 编译器
【C++】从零开始认识继承(一)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
18 3
【C++】从零开始认识继承(一)
|
3天前
|
设计模式 算法 编译器
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
8 0
|
4天前
|
安全 程序员 编译器
【C++】继承(定义、菱形继承、虚拟继承)
【C++】继承(定义、菱形继承、虚拟继承)
12 1
安全 编译器 程序员
9 1
|
10天前
|
C++ 芯片
【期末不挂科-C++考前速过系列P4】大二C++实验作业-继承和派生(3道代码题)【解析,注释】
【期末不挂科-C++考前速过系列P4】大二C++实验作业-继承和派生(3道代码题)【解析,注释】
|
13天前
|
安全 Java 程序员
【C++笔记】从零开始认识继承
在编程中,继承是C++的核心特性,它允许类复用和扩展已有功能。继承自一个基类的派生类可以拥有基类的属性和方法,同时添加自己的特性。继承的起源是为了解决代码重复,提高模块化和可维护性。继承关系中的类形成层次结构,基类定义共性,派生类则根据需求添加特有功能。在继承时,需要注意成员函数的隐藏、作用域以及默认成员函数(的处理。此外,继承不支持友元关系的继承,静态成员在整个继承体系中是唯一的。虽然多继承和菱形继承可以提供复杂的设计,但它们可能导致二义性、数据冗余和性能问题,因此在实际编程中应谨慎使用。
16 1
【C++笔记】从零开始认识继承
|
15天前
|
设计模式 编译器 数据安全/隐私保护
C++ 多级继承与多重继承:代码组织与灵活性的平衡
C++的多级和多重继承允许类从多个基类继承,促进代码重用和组织。优点包括代码效率和灵活性,但复杂性、菱形继承问题(导致命名冲突和歧义)以及对基类修改的脆弱性是潜在缺点。建议使用接口继承或组合来避免菱形继承。访问控制规则遵循公有、私有和受保护继承的原则。在使用这些继承形式时,需谨慎权衡优缺点。
24 1
|
17天前
|
编译器 C++
【C++进阶(八)】C++继承深度剖析
【C++进阶(八)】C++继承深度剖析