C++中的继承

从现在开始就要进入c++进阶的学习了,今天我们学习的是c++中三大特性之一:继承

继承的概念:

当我们想要描述一个学生时,我们需要知道他的姓名、年龄、地址、电话等等。当我们又想描述一个老师时我们又需要这些信息,在不同类中多次重复填写同样的信息会显得特别麻烦。所以c++中设计出了继承,让这些需要用到的信息给继承下来,就不需要自己再写一遍了:

这样设计的好处不仅继承下来我们需要的东西,又能在这些东西的基础在再添加我们所需要的信息。

class Person
{
public:
    void Print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "peter"; // 姓名
    int _age = 18;  //年龄
};

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;
}

student和teacher都继承了person的信息:

我们还可以发现student和teacher的对象都可以调用person的成员函数,所以继承不仅能够继承成员变量还能继承成员函数

定义格式:

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

这张表我们是不需要背诵的,我们需要的是理解:

1.基类的private成员在类外面不能使用,在派生类中也不能使用!现在我们将person的成员变量改成私有,再在student中使用,程序就运行不起来:

2.基类的protected成员不能在类外面使用,但可以在派生类中使用!这就是protected和private访问限定符的区别。

3.实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他

成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected

> private。

4.如果不写class默认的是私有继承,struct默认是公有继承。

因为是private继承,所以Person中的保护公有成员在派生类中变成了私有成员。所以Print不能在外面使用。

5.绝大多数情况下我们使用的都是公有继承

基类和派生类对象赋值转换

派生类的对象可以赋值给基类的对象/基类的引用/基类的指针。

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

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

int main()
{
    Person p;
    Student t;
    p = t;
    return 0;
}

这个代码是派生类赋值给基类的对象,对象的赋值示意图如下:是将派生类中包含基类的所有数据拷贝到基类中:

但这种赋值是天然的,不存在类型转换(赋值时不产生临时变量)

基类的引用和基类的指针赋值就不会像之前对象赋值那样拷贝数据给基类了,它们是将引用和指针分别指向派生类中基类的那部分数据:

所以基类和派生类对象赋值转换又称作是切割切片。是将派生类中基类的数据切割出来赋值给基类,因此赋值只能是派生类赋值给基类,而不能是基类赋值给派生类。这种赋值是从下往上的。

继承中的作用域:

在继承体系中,每个类的作用域都是互相独立的:

class Person
{
protected:
    string _name = "小李子"; // 姓名
    int _num = 111;// 身份证号
};
class Student : public Person
{
public:
    void Print()
    {
        cout << " 姓名:" << _name << endl;
        cout << "_num:" << _num << endl;
    }
protected:
    int _num = 999; // 学号
};
void Test()
{
    Student s1;
    s1.Print();
};

我们先来看运行Test()的结果是什么,不用说姓名肯定是小李子,但是_num的值是什么呢?

Person中有一个_num,student中也有一个_num,这两个成员变量的名字相同,如果子类和父类中有同名成员变量,它们构成隐藏/重定义。

隐藏以后,那么就默认使用就近原则,因此_num的值是999。

如果子类成员想访问父类成员,可以用::作用域限定符进行访问:

刚刚讲的是同名成员变量,那么同名成员函数呢?我们来看以下代码:

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);
}

图中两个类中的fun函数构成什么关系?

A.函数重载 B.重写 C.隐藏/重定义 D. 编译错误

答案:选C。不选A的理由是函数重载的前提必须是在同一个作用域内。

在这里我需要强调一下同名成员函数和同名成员变量一样,子类和父类中有同名成员函数(不用管参数类型,只要求名字一样),它们将构成隐藏/重定义。隐藏以后依旧使用就近原则,优先使用子类的成员。如果子类想访问父类的成员函数,也可以用::作用域限定符进行访问,这里就不演示了。

当我们把上面的代码在进行修改:(fun函数不传入任何的参数)

void Test()
{
    B b;
    b.fun();
}

这里运行的结果就是编译错误了,因为就近原则优先使用B类中的fun,而它的fun需要传参。

总结:

子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义,隐藏以后使用就近原则。(在子类成员函数中,可以使用 基类::基类成员 显示访问)

派生类的默认成员函数

首先我们先复习一下默认成员函数的特性:

派生类的默认成员函数其实和普通类相差不大,普通类的数据类型分为内置类型和自定义类型,而派生类相比于普通类就多了 一个基类的数据。因此调用派生类的时候,派生类中的基类部分调用基类对应函数完成初始化/清理/拷贝,自己的那部分调用自己的默认成员函数。

我们举例子来一个个说明派生类的默认成员函数:

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

    Person(const Person& p)
        : _name(p._name)
    {
    
    }

    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; // 姓名
};

首先我们先创建一个person类,类中的构造、拷贝、赋值、析构都已经全部实现好。然后我们再写一个它的派生类student:(内部什么都不实现)

我们直接调用Student:

代码运行的结果:

此时我们发现了即使派生类里面什么也没有实现,就会默认调用基类的默认构造函数。

那么如何初始化构造一个派生类呢?

我们用这种方法发现编译不过去。原因就是当我们想初始化基类中的成员变量时我们不能用派生类的构造函数进行初始化,必须用基类的构造函数,代码修改如下:

Student派生类的拷贝函数如下:

首先是必须先用基类的拷贝构造拷贝好派生类中基类的数据,然后再把派生类中剩余的数据构造完。

但是调用基类的拷贝构造必须传入的参数是Person类型的,这时对象的赋值转换就起到了作用。可以将Student派生类的对象传给Person基类的对象,然后进行切割将派生类中基类的数据拷贝到基类的对象里去。

Student派生类的赋值重载如下:

我们和之前一样先调用基类的赋值重载,然后再去实现除基类外剩余数据的赋值,但这里却显现出栈溢出的情况,这是什么原因呢?

原因就是调用基类的赋值重载重载出了问题,基类和派生类的赋值重载函数名构成隐藏/重定义,所以它会默认调用的是Student的赋值重载陷入死循环最后栈溢出。解决方法如下:(使用作用域限定符)

接下来是派生类的析构函数:

在派生类中直接调用基类的析构函数出现了调不动的情况,这就体现了派生类函数析构的第一怪

子类析构函数和父类析构函数构成隐藏关系。(由于多态关系需求,所有析构函数都会特殊处理成destructor函数名)

接下来我们用作用域限定符解决这个问题:

代码运行结果如下:

我们发现Person的析构函数调用了两次,如果释放同一块空间两次程序会崩溃,这就体现了派生类析构函数的第二怪

子类先析构父类再析构。子类析构函数不需要显示调用父类的析构函数。子类析构函数调用完之后会自动调用父类的析构函数。

构成第二怪的主要原因是因为我们的C++祖师爷追求了顺序的一致性,当我们按顺序创建了两个类分别为A、B,它们调用构造函数的顺序是:A、B,调用析构函数的顺序是B、A。父类和子类相同,按顺序创建了两个父子类分别为A、B,为了让子类B能够先被析构掉,所以规定了在子类的构造函数中不需要显示调用父类的析构函数,因此先会调用子类的析构,调用完之后会自动调用父类的析构。

继承和友元的关系

在这里我们需要记住一个点,那就是友元关系不能被继承:(用个代码来举个例子)

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;
}
void main()
{
 Person p;
 Student s;
 Display(p, s);
}

我们可以发现这段代码在编译期上是运行不起来的,原因就是Display是Person的友元函数不是Student的友元,所以它可以在外面访问Person中的成员变量而不能访问Student的。

可以用这样的方式解决这种问题:(让Display也成为Student的友元函数)

class Student : public Person
{
public:
    friend void Display(const Person& p, const Student& s);

protected:
    int _stuNum; // 学号
};

继承与静态成员

class Person
{
public:
    Person() { ++_count; }
    string _name; // 姓名
public:
    static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
    int _stuNum; // 学号
};

Person里面有一个_name,Student里面也有一个_name,它们两个的成员变量是相互独立的。

但是静态成员不同,静态成员属于整个类,也属于整个对象。同时也属于整个派生类及对象:

运行结果:

两个对象里面的_count地址相同,说明是同一个_count,这就验证了静态成员被所有类和对象共享,因为它是存放在静态区的。

接下来我为大家出一道题目:

class Person
{
public:
    void Print()
    {
        cout << this << endl;
    }
    Person() { ++_count; }
    string _name; // 姓名
public:
    static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
    int _stuNum; // 学号
};


int main()
{
    Person* ptr = nullptr;
    cout << ptr->_name << endl;    
    cout << ptr->_count << endl;    
    ptr->Print();          
    cout << (*ptr)._count << endl;    
    (*ptr).Print();   
    return 0;
}

main函数中哪些代码可以被运行哪些不可以被运行呢?

我们来看结果:

当我们看到->和*的时候需要考虑的是需不需要在对象里面找东西,如果需要找那就是解引用操作,如果不是,所以这里的->和*是用来传this指针的。ptr->_name运行不了的原因就是ptr需要在对象中解引用找到_name,因为类中只存储成员变量。至于_count,ptr不需要解引用,在静态区就可以找到。Print函数是存储在代码段的并不是存储在对象中,所以也不需要解引用。后几行代码用解引用的方式和前面说的一样。

复杂的菱形继承及菱形虚拟继承

单继承:

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

多继承:

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

菱形继承:

多继承中的一种特殊情况。

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

class Person
{
public:
    string _name; // 姓名
};
class Student : public Person
{
protected:
    int _num; //学号
};
class Teacher : public Person

{
protected:
    int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; // 主修课程
};

int main()
{
    Assistant a;
    a._name;
}

当我们访问Assistant中的名字时,我们继承下来的Teacher和Student中都有名字,编译器不知道我们访问的是哪个名字,这就造成了二义性的问题:

现在我们将代码修改一下,可以解决二义性问题:

因为每个类中都会有_name,并且每个_name的值都不同,这就造成了数据的冗余

菱形继承问题的解决方法

可以使用虚拟继承解决数据冗余和二义性。我们只需要在继承方式前面加上virtual关键字:

class Student : virtual public Person
{
protected :
 int _num ; //学号
};
class Teacher : virtual public Person
{
protected :
 int _id ; // 职工编号
};

但是加关键字的位置不是乱加的,必须是在菱形的腰部添加:

我们通过代码运行并调试一下:

我们发现我们可以直接调用_name不需要加作用域限定符了,并且通过监视窗口发现所有的_name全部变成了”阮老师“,这到底是什么原因呢?

虚拟继承解决数据冗余和二义性的原理

因为监视窗口是被编译器优化过的,我们可以借助内存窗口观察对象成员的模型。为了更方便的观察我们直接创建A、B、C、D四个类构成菱形继承:

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 = 1;
    d._c = 2;
    d._d= 3;
    d.B::_a = 4;
    d.C::_a = 5;
    return 0;
}

接下来我们通过内存窗口看看数据的分布:

接下来我们看看菱形虚拟继承的数据是如何分布的:

现在我们发现原来的B和C中存储的_a的值不存在了,变成了像指针一样的数值。_a的值到了最下面(红色框框里面),那我们继续深究,多开两个监视内存看看存储的值是不是指针:

我们发现B和C中存储的指针指向的数值是0,但是该数值的下面还存储了一个值M,我们_a的起始地址减去B和C中存储指针的地址发现正好等于M。

总结:B和C中分别储存了一个指针,这个指针指向了一个虚基表,这个续集表里存储的是距离虚基类对象的偏移量。

有人就会困惑了,为什么要存储距离虚基类对象的偏移量,这样虽然解决了二义性,但感觉数据更加冗余了。事实情况并非如此:

当我们使用菱形虚拟进程,我们类型B的内存也会随之改变并且是这样分布的:

你会发现B类对象和D类对象有一定的区别:

B类对象距离虚基类对象的偏移量相比于D类对象中的B类对象距离虚基类对象的偏移量要更短

这就体现了储距离虚基类对象的偏移量重要意义:

我们使用切割或者切片的时候,当我们需要找到虚基类对象时,因为在不同的对象中找虚基类对象的距离是各不相同的,这时就可以通过偏移量来找到:

它同时也解决了数据冗余的问题,为什么这么说?

虽然多存储了指向虚基表的指针,但这些消耗远远比不上原有的损耗。假如数据_a的大小为100个字节,如果不用虚拟继承,B和C中各有一份_a数据那就是200个字节。而使用了虚拟继承_a会统一保存在最下面(不同编译器存储在不同的位置),只消耗100个字节,节省了100字节。再减去两个指向虚基表的指针的消耗可以节省92个字节(默认指针的大小为4个字节)。

继承的总结和反思

继承和组合的概念:

继承和组合的区别:

1.继承的耦合度高,组合的耦合度低。例如:x中有100个成员,80个为保护,20个为公有,那么改变任何x中的成员可能都会影响到y。同样的对于M而言,只有20个公有成员能影响能影响到N。所以能够相互影响关联性越强它的耦合程度就越高。而在软件工程中,程序的设计追求的是高类聚低耦合

2.继承是白箱复用,组合是黑箱复用。白箱复用相当于把所有的实现细节暴露给子类,黑箱复用是对类以外的对象是无法获取实现细节的。

3.继承是is-a的关系,组合是has-a的关系。例如学生是一个人,使用的是继承关系。头上有一只眼睛,用的是组合关系。

4.当继承和组合都可以使用的时候,我们优先使用组合

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值