【C++】继承 —— 切片 | 隐藏 | 子类的默认成员函数 | 菱形继承

简介: 继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段。它允许程序员在保持原有类(基类)特性的基础上进行扩展,增加功能,产生新的类,称派生类。

反爬链接
正文开始

在此之前,我们接触的复用都是函数复用,那么继承就是类设计层次的复用

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段。它允许程序员在保持原有类(基类)特性的基础上进行扩展,增加功能,产生新的类,称派生类。

在如下代码中,子类会继承(public)父类的所有成员,包括成员变量 & 成员函数。这样,子类就可以使用父类成员 ——

#include<iostream>
#include<string>

using namespace std;

// 父类(基类)
class Person
{
public:
    void Print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "you-know-who"; //姓名
    int _age = 20; //年龄
};

//子类(派生类)
class Student : public Person
{
protected:
    int _stuid; //学号
};

class Teacher : public Person
{
protected:
    int _jobid; //工号
};

int main()
{
    Student s;
    Teacher t;
    s.Print();
    t.Print();
    return 0;
}

1. 继承的规则

1.1 继承的格式

<img src=" title="">

1.2 访问限定符 & 继承方式

在类和对象中,protected和private没有区别。它们的区别体现在继承中:在这一层没有区别,下一层private无法再继承下去(事实上,在继承体系中,很少用private)。

<img src=" title="">

这样组合下来,就一共有3*3 = 9种继承情况 ——

类成员/继承方式 public继承 protected继承 private继承
父类的public成员 子类的public成员 子类的protected成员 子类的private成员
父类的protected成员 子类的protected成员 子类的protected成员 子类的private成员
父类的private成员 子类不可见 子类不可见 子类不可见

但其实我们只要把握住两点,抓住重点,它并不复杂,请看下一小节。

1.3 继承父类的成员访问方式变化

:purple_heart: 1. 父类的private成员在子类中都是不可见的。不可见是指父类的私有成员被子类继承了,但是在语法上限制子类对象在类内&类外都不可以访问。这里需要区分private和不可见,区别主要在于在类里面是否可以访问,private类内可类外不可;不可见类内外都不可。父类不想给子类用的可以给私有。事实上,我们很少定义private成员。

:purple_heart: 2. 子类中继承下来的成员的访问权限 = min(成员在子类中的访问修饰限定符继承方式),总之就是取权限小的。权限大小关系:public > protected > private.

事实证明,C++的设计过于复杂。实际上一般使用都是public继承,几乎很少使用protected/private继承(也不推荐使用保护和私有继承,因为保护和私有继承下来的成员只能子类的类内部使用,扩展维护性不强)。

注:对于访问修饰限定符,如果不写,默认struct中成员默认是public;class默认是private. 对于继承也一样,struct中默认的继承方式是public;class默认的是private,但是最好显式写出。

:heart: 总结 - 我们删繁就简,常见的使用就是 ——

  • 父类成员:公有和保护
  • 子类的继承方式:公有继承

这样原本复杂的3*3表格,就精简到了左上角 ——

<img src=" title="">

2. 赋值兼容规则 - 切片

class Person
{
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age;     // 年龄
};

class Student : public Person
{
public:
    int _id; // 学号
};

子类和父类的赋值转换。它在它的作用我们在4.子类的默认成员函数来感受。

<img src=" title="">

:purple_heart: 1. 子类对象可以赋值父类的对象/父类的指针/父类的引用(引用的底层也是指针)。形象的说法叫切片或者切割。即把子类中父类那部分切来赋值过去。

​ 这里不存在类型转换,是语法天然支持的行为。众所周知,同类型可以相互赋值(自定义类型会调用的赋值重载);不同类型之间也可以相互赋值,相关类型隐式类型转换,不相关类型显式强制类型转换。这里显然不存在类型转换,因为众所周知,类型转换中间会产生临时变量,临时变量具有常属性,必须加const,这儿不加也不报错~

<img src=" title="">

    Person p;
    Student s;

    //1.父类 = 子类
    p = s;
    Person* ptr = &s;
    Person& ref = s;

注意:这仅限于公有继承,私有和保护都不行。这是因为存在权限转换问题,因为只有在公有方式继承下,父子类中的成员访问权限才会统一;其他方式继承都可能引起访问权限缩小,这时再切片回去后,访问权限被放大,这是不合理的。

举个具体的反例,比如Person中有公有成员,如果以私有方式继承下来,在子类Student看来都是私有的,子类切片给父类,作指针和引用,父类却把它看作是公有的。那么我继承下来是私有的,你去用反而是公有的了,这不合理。

:purple_heart: 2. 父类对象不能赋值给子类对象。因为子类通常比父类成员多,那_id拿什么去给呢?

但是指针和引用经过强转后可以,但最好不要用,因为有越界风险。(注:那怎样才能安全呢?这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换(以后详谈).)

<img src=" title="">

    //2.子类 = 父类
    //s = p; //强制类型转换也不行
    Student* pptr = (Student*)&p;
    Student& rref = (Student&)p;

    //很危险,存在越界访问的风险
    //pptr->_id = 1201021318; //崩了

3. 继承中的作用域 - 隐藏

子类和父类出现同名成员,称为隐藏/重定义

:purple_heart: 1. 在继承体系中,父类和子类具有独立的作用域。因此,同名成员可以同时存在。

:purple_heart: 2. 若子类与父类有同名成员,由局部优先原则,子类会屏蔽父类,优先访问自己类中的成员;若想访问父类成员,需要指定作用域。这种情况就叫隐藏/重定义。

注:在继承体系中,不推荐定义同名的成员(变量+函数)

#include<iostream>
#include<string>

using namespace std;

// Student的_num和Person的_num构成隐藏关系(这样代码虽然能跑,但是非常容易混淆)
class Person
{
protected:
    string _name = "you-know-who"; // 姓名
    int _num = 23010620; //身份证号
};

class Student : public Person
{
public:
    void Print()
    {
        cout << "姓名:" << _name << endl;
        cout << "学号:" << _num << endl; //就近原则
        cout << "身份证号:" << Person::_num << endl; //需要指定作用域
    }
protected:
    int _num = 1201021318; //学号
};

int main()
{
    Student s1;
    s1.Print();
    return 0;
}

运行结果 ——

<img src=" title="">

来一道小题 ——

class A 
{
public:
    void fun()
    {
        cout << "func()" << endl;
    }
};

class B : public A 
{
public:
    void fun(int i)
    {
        cout << "func(int i)->" << i << endl;
    }
};

思考如下两种情况 ——

<img src=" title="">

请选择:
a. A、B的func构成函数重载
b. 编译报错
c. 运行报错    
d. A、B的func构成函数隐藏

它们不可能构成函数重载,函数重载要求在同一作用域中。那么1.便是函数隐藏,选d;2编译报错,因为被隐藏了根本调不到,需要指定作用域b.A::func();. 于是我们又总结出 ——

:purple_heart: 3. 对于成员函数的隐藏,只要函数名相同就构成隐藏,参数随意。

4. 派生类的默认成员函数

关于子类的默认成员函数,我们一共要探讨两个问题 ——

  • 派生类的4个重点默认成员函数,我们不写,编译器默认会做什么?
  • 如果我们要写,要写些什么?什么情况下必须自己写?

:purple_heart: 1. 派生类的4个重点默认成员函数,我们不写,编译器默认会做什么?

#include<iostream>
#include<string>

using namespace std;

class Person
{
public:
    Person(const char* name = "peter")
        : _name(name)
    {
        cout << "Person()" << endl;
    }

    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }

    Person& operator=(const Person& p)
    {
        cout << "Person operator=(const Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }

    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name; //姓名
};

class Student : public Person
{
public:

protected:
    string _id; //学号
};

int main()
{
    Student s1;
    Student s2(s1);
    return 0;
}

<img src=" title="">
由上述运行结果可以看出 ——

:star:1.1 我们不写默认生成的子类的构造&析构

  • a. 父类继承下来的:调用父类的默认构造&析构
  • b. 自己的:和普通类一样(内置类型不处理;自定义类型调用自己的默认构造&析构)

再来看拷贝构造和赋值重载 ——

<img src=" title="">

:star:1.2 我们不写默认生成的子类的拷贝构造&赋值重载operator=?同上

  • a. 父类继承下来的:调用父类的默认构造&析构
  • b. 自己的:和普通类一样(内置类型不处理;自定义类型调用自己的默认构造&析构)

:heart: 总结:父类成员调用父类的来处理,自己的成员按普通类处理。

:purple_heart: 2. 如果我们要写,要写些什么?什么情况下必须自己写?

:star: 2.1 when?

  • 父类没有默认构造函数,需要我们自己写,显式的调用构造。不能自己处理
  • 析构函数呢?如果子类有资源需要释放,就需要自己写析构。父类的不用管,会调用父类的完成(父类就那一个析构,不存在调不到问题)。比如int ptr = new int[10];
  • 如果子类存在浅拷贝问题,就需要自己实现拷贝构造和赋值重载来深拷贝

:star: 2.2 how?

如果需要自己写,父类成员调用父类对应的构造、拷贝构造、operator=和析构来处理;自己的成员按照普通类来处理:该浅拷贝的浅拷贝,该深拷的深拷贝。

请仔细阅读代码中注释!

#include<iostream>
#include<string>

using namespace std;

class Person
{
public:
    Person(const char* name) //父类没有默认的构造函数
        : _name(name)
    {
        cout << "Person()" << endl;
    }

    Person(const Person& p) /*切片:引用父类的那部分*/
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }

    Person& operator=(const Person& p) /*切片:引用父类的那部分*/
    {
        cout << "Person operator=(const Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }

    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name; //姓名
};

class Student : public Person
{
public:
    Student(const char* name = "you-know-who",int num = 2003000)
        : Person(name) /*显式调用父类的构造函数*/ /*_name(name)这样不行(非法的成员初始化:“_name”不是基或成员)*/
        , _num(num)
    {}

    // s2(s1)
    Student(const Student& s)
        : Person(s) /*显式调用父类的赋值 —— 切片:把s1父类的部分切给s2父类的那部分*/
        , _num(s._num)
    {
        // 一些自己的深拷贝... 
    }

    // s3 = s1
    Student& operator=(const Student& s)
    {
        if (this != &s)
        {
            // 显式调用父类的赋值:需要指定作用域,否则会隐藏父类的,优先调用子类(会Stack overflow)。
            Person::operator=(s); /*切片*/
            _num = s._num;
            // 一些自己的深拷贝... 
        }
        return *this;
    }

    ~Student()
    {
        //Person::~Person();
        // 清理自己的资源... /*delete[] ptr;*/ 
    }
protected:
    int _num = 2003001; //班号
};

int main()
{
    Student s1;
    Student s2(s1);
    Student s3;
    s3 = s1;
    return 0;
}

关于析构,注释中写不下了 ——

<img src=" title="">

析构时,会发现调不动,诶?!这不合理呀~ 这是因为析构函数的名字会被统一处理成destructor(),那么子类和父类的析构函数构成隐藏,需要指定作用域 (至于为什么会统一处理,下一篇文章多态详谈)。调整之后,却发现析构调用了两次,我这现在是偷懒了没写清理资源的代码,写了不就崩了?!

<img src=" title="">

难道说不需要我们显式调用父类的析构函数?!是的。子类析构函数结束时,会自动调用父类的析构函数,这样才能保证先析构子类成员,后析构父类成员。

在这里插入图片描述

因此,我们实现子类析构函数时,不需要显式调用父类的析构函数。

5. 继承与友元

友元关系不能继承。父类的友元,不是子类的友元。

#include<iostream>
#include<string>

using namespace std;

class Student;
class Person
{
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name; // 姓名
};


class Student : public Person
{
protected:
    int _stuNum; // 学号
};

void Display(const Person& p, const Student& s) 
{
    cout << p._name << endl;
    //cout << s._stuNum << endl; //nope~
}

int main()
{
    Person p;
    Student s;
    Display(p, s);
    return 0;
}

6. 继承与静态成员

父类定义了static成员,则整个继承体系只有这一个公有的成员。这可以统计Person以及Person的派生类共创建了多少个对象。

#include<iostream>
#include<string>

using namespace std;

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; // 研究科目
};

int main()
{
    Person p;
    Student s;
    Graduate g;
    // 用来统计父类及其派生类创建对象的个数
    cout << Person::_count << endl;   //3
    cout << Student::_count << endl;  //3
    cout << Graduate::_count << endl; //3

    cout << &Person::_count << endl;
    cout << &Student::_count << endl;
    cout << &Graduate::_count << endl;
    return 0;
}

运行结果 ——

<img src=" title="">

7. 菱形继承 & 菱形虚拟继承

7.1 菱形继承

:yellow_heart: 单继承:一个子类只有一个直接父亲

<img src=" title="">

:yellow_heart: 多继承:一个子类有两个及两个以上的直接父亲

<img src=" title="">

多继承看起来很合理,一个类继承多个类,但其实它就是一个坑( Java等语言直接没有多继承,避开这个坑),它带来的菱形继承,一个研究生助教对象中有两份Person,会有数据冗余和二义性的问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iNyE45Sq-1650453760472)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220419192535710.png)]

二义性还可以通过指定作用域勉强解决 ——

#include<string>

using namespace std;

class Person
{
public:
    string _name; //姓名
};

class Student : public Person
{
protected:
    int _stuid; //学号
};

class Teacher : public Person
{
protected:
    int _teacherid; //工号
};

class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; //主修课程
};

int main()
{
    //二义性:对_name的访问不明确
    Assistant a;
    //a._name = "peter"; //nope~ 错误示范,请勿模仿

    //需要显示指定访问哪个父类的成员
    a.Student::_name = "文彬"; 
    a.Teacher::_name = "文教"; //嘘~文教超级nb,超级好!
    return 0;
}

那数据冗余呢?如果我在祖先类Person中添加一个int a[10000]数组,再那就白白浪费了4万字节。

为了解决解决菱形继承的二义性和数据冗余的问题,C++付出了极大的代价,虚继承。

7.2 菱形虚拟继承

注意是在腰儿上虚继承,不要在其他地方使用virtual关键字。

class Person
{
public:
    string _name; //姓名
};

class Student : virtual public Person
{
protected:
    int _stuid; //学号
};

class Teacher : virtual public Person
{
protected:
    int _teacherid; //工号
};

class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; //主修课程
};

int main()
{
    Assistant a;
    // 访问的是同一个
    a.Student::_name = "文彬"; 
    a.Teacher::_name = "文教"; 
    a._name = "文教yyds!"; //无需指定
    return 0;
}

7.3 菱形虚拟继承的原理

为了研究虚拟继承的原理,我们写一段简单的继承关系,配合内存窗口观察。

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;
    
    //d._a = 0; //不存在二义性,可以直接找
    return 0;
}

先来观察一下不采用虚拟继承时,是有数据冗余 & 二义性问题的(且先继承的在前,后继承的在后) ——

<img src=" title="">

:purple_heart: 再观察虚拟继承时,可以观察到,这样A成员的确只存储了一份,在对象的最底下——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CRDjnTAe-1650453760474)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220419170958311.png)]

但是B和C中那是啥?推测是地址,众所周知,当前机器采取的是小端存储(低位存低地址,高位存高地址),我们再打开内存窗口来看这地址存的什么 ——
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XJHO4Hqw-1650453760475)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220419201648293.png)]
D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A(虚基类)呢?就是通过了B和C的两个指针虚基表指针),指向的虚基表(找虚基类的表)。虚基表中存的偏移量,通过偏移量可以找到下面的A。

Person关系菱形虚拟继承的原理 ——

在这里插入图片描述

A一般叫做虚基类,在D中,A放到一个公共位置,有时B需要找A、C需要找A,就要通过虚基表中的偏移量来计算。那为什么要找呢?考虑以下场景

    D d;
    B b = d; //切片,要找_a
    C c = d;

    B* pb = &d;
    pb->_a = 10;

注意:有公共祖先类就会构成菱形继承,那么virtual加在哪里呢?

<img src=" title="">

虚继承加在B、C上,而不是D、C上。是因为要解决的是A的数据冗余和二义性的问题。若是D、C,你把谁放在最下面?

8. 总结

很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。多继承可以认为是C++的缺陷之一(另一个是没有垃圾回收器),很多后来的OOP语言都没有多继承,如Java。

:purple_heart: 继承和组合

继承和组合都是一种复用,只不过访问的方式有所不同。

//继承 - 白箱复用(white-box reuse)
class A
{
    int _a;
};

class B: public class A
{
    int _b;
}
//组合 - 黑箱复用(black-box reuse)
class C
{
    int _c;
}

class D
{
    C _obj;
    int _d;
}

public继承是一种is-a的关系;组合是一种has-a的关系。

继承是白箱复用(white-box reuse),父类成员除了私有的不可见,公有和保护对子类都是透明的可以直接访问(事实上,继承中要复用很少定义私有),因此白箱复用在一定程度上破坏了封装组合是黑箱复用(black-box reuse),D只能访问C的公有,不能访问保护,和在类外的对象一样。

另外我们希望,类、模块之间的关系最好是低耦合高内聚,方便维护。继承(A和B),耦合度高,依赖性强,任意一个成员的改变都会对我有影响;组合(C和D),耦合度低,依赖关系较弱。

结论:完全符合is-a,就用继承;完全符合has-a,就用组合;既是is-a,又是has-a,优先用组合而不是继承。

持持续更新@边通书

过两三天我估计要暂停更新了,要备考嘞哎呦,各种实验考试大作业压上来,我现在还是悠悠闲闲的,觉睡得很足~ anyway~ 最近起了一点小心思,未来慢慢靠近吧!不是少女小心思of course哈哈:) 然后会更一波儿学校的笔记大作业,因为要分儿啊,懒得开小号了,求求大家别看别点赞了,查完作业我大概率就删了,丢死人了~

相关文章
|
1月前
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
85 11
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
54 1
|
1月前
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
39 1
|
1月前
|
安全 编译器 C++
【C++篇】C++类与对象深度解析(三):类的默认成员函数详解
【C++篇】C++类与对象深度解析(三):类的默认成员函数详解
20 3
|
1月前
|
存储 编译器 C++
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作(二)
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作
|
1月前
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
20 0
|
1月前
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
31 0
|
1月前
|
程序员 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
33 0
|
1月前
|
存储 编译器 C++
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作(三)
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作
|
1月前
|
存储 编译器 C++
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作(一)
【C++】掌握C++类的六个默认成员函数:实现高效内存管理与对象操作