C++ 继承和多态

继承


为什么会有继承?

  • 在 C++ 中,我们通常会定义一个类来描述对象,每个类中都有各自的成员变量和成员函数,用来描述对象的属性和方法。
  • 比如说,我们要定义一个Teacher类和Student类;我们先不考虑成员函数,只考虑成员变量,对于老师这个对象来说,他有name,sex,age,teacId等信息;对于学生这个对象来说,他有name,sex,age,stuId等信息;我们可以看到,这两个类的成员变量有重复项。
  • 事实上,和人有关的对象基本上都具备有name,sex,age这三个属性,那么在实际应用中我们需要定义这么几类人的类来描述他们,我们是要分别给他们建一个类吗?
  • 这当然不行,这样我们所做的大多都是重复性的工作,这是会大大降低我们的工作效率的。
  • 所以在 C++ 中,我们有这样的一种处理方式,我们可以抽象出一个Person类,它的成员变量有:name,sex,age等所有人类共有的信息,对于学生类,它可以复用Person类,然后定义属于自己的stuId成员变量;对于Teacher类,它可以复用Person类,然后定义自己的teacId成员变量,这样代码的可重用性就大大提高了,由此我们引出继承的概念。

继承基础概念

  • 继承:面向对象复用的重要手段;继承是类型之间关系的建模
    继承关系描述
  • 通过继承定义一个类,父类的所有东西都会变成子类的一部分
  • 子类由两部分构成:父类的成员和子类的成员
  • 我们可以通过代码验证一下上述两条论述:
    这里写图片描述
  • 我们可以通过上图观察到:我们定义的子类它既包含父类的三个成员变量,也有自己的成员变量。那么,对于子类来说,它的内存中包含的是整个父类吗?
  • 对于子类来说,它的内存中只有成员变量(自己的和父类的),并不是包含整个父类
成员访问限定符&继承关系
  • 从上小节图一中我们看到了继承关系有public(公有),其实还有protected(保护)和private(私有)
  • private:私有继承,父类的非私有成员都会成为子类的私有成员,父类的私有成员在子类中不可见
  • protected:保护继承,父类的非私有成员都会成为子类的保护成员,父类的私有成员在子类中不可见
  • public:公有继承,父类的非私有成员在子类的访问属性都不变,父类的私有成员在子类中不可见
  • 对于上述论述,我们可以验证一下:
    这里写图片描述
  • 私有继承将父类的所有非私有成员变成了自己的私有成员,私有成员在类外是无法直接访问的,那么”不可见”是什么意思呢?我们同样可根据代码来验证一下:
    这里写图片描述
  • 我们可以看到对子类对象求得的大小为8个字节,这说明父类的私有成员变量在子类中是存在的
    这里写图片描述
  • 私有成员变量在类外是无法直接进行访问的,但是它又在子类中存在,所以我们说私有成员变量无论是在哪种继承关系下都是”不可见”的,这种”不可见”的意思就是存在但不能用
    总结:
  • public继承(接口继承):is-a ,每个父类可用的成员子类也可用,因为每个子类对象也是一个父类对象
  • protected/private继承(实现继承):has-a,父类的部分成员并未完全成为子类接口的一部分
  • 使用关键字class时默认的继承方式是private,使用关键字private时默认的继承方式时公有继承,为了方便最好显示声明
  • 在实际应用中一般都会用public继承,只有很少的情况下才会用到protected/private继承

赋值兼容规则(public继承)

切片/切割
  • 首先演示第一种情况——子类对象赋值给父类对象
    这里写图片描述
  • 子类对象赋值给父类,实际上是把子类对象从父类中继承过来的成员变量的值赋给了父类,子类自身的成员变量并未发生变化,我们把这种现象叫做切片/分割——子类将自己切成两部分(从父类继承的部分,自己的部分),如果发生子类对象给父类对象赋值的操作,则将从父类继承过来的部分赋值给父类

  • 注意:父类对象是无法赋值给子类的,因为它没办法切出子类自有的那一部分,强制类型转换也不可以,编译是无法通过的
    这里写图片描述

  • 下面演示第二种情况——父类的指针指向子类对象
    这里写图片描述

  • 父类的引用指向子类对象
    这里写图片描述
  • 父类通过指针和引用可以指向子类对象从父类继承过来的那一部分,其他不变
  • 下面验证第三种情况——子类的指针/引用不能指向父类对象
    这里写图片描述

这里写图片描述
- 我们可以强制类型转换
这里写图片描述

这里写图片描述
- 我们发现通过强制类型转换是可以让子类指针/引用指向父类对象的,但是因为将父类对象强制类型转换为子类对象会在内存中多开空间,因为这部分空间不是我们的,访问会越界

隐藏/重定义
变量
  • 我们先来看一段代码:
class Person
{
public:
    int _id;
}
class Student:public Person
{
public:
    int _id;
}
  • 我们可以看到上述代码中,父类和子类中各自定义了一个成员变量_id,因为这个成员变量分属于不同的类,所以我们认为这种定义方式时正确的,但是在访问的时候会出现什么情况呢?
    这里写图片描述
  • 我们可以发现,我们用子类定义的对象来访问_id,发现只有子类_id发生了改变,而父类的_id并没有变化;这就是我们C语言中所说的就近访问原则,若父类、子类定义了同名变量,若使用子类对象去访问该同名变量,则子类会对父类的同名变量进行隐藏(不可见),那么我们如何通过子类对象去访问父类呢?
  • 我们可以通过指定作用域的方式来进行访问:
    这里写图片描述
函数
class Person
{
public:
    void Display()
    {
        cout << "父类:Display()" << endl;
    }
public:
    int _id;
};
class Student :public Person
{
public:
    void Display()
    {
        cout << "子类:Display()" << endl;
    }
public:
    int _id;
};

这里写图片描述
- 我们可以看到函数的隐藏和变量是类似的,但是我们说的隐藏是因为父、子类成员同名,函数可以同名,但可以传不同的参数,这样还会隐藏吗?
- 我们给构造子类成员函数为:Display(int a)
这里写图片描述

  • 可以看到我们调用了无参的成员函数,编译器却报错了。这是因为Display(int a)隐藏父类的Display(),所以我们只能调到子类的成员函数,但是子类有没有无参的成员函数,所以导致编译器编译不通过
  • 由此我们可以总结:不论是变量还是函数,在继承关系中,子类都会隐藏父类的同名成员,使得父类的这些成员不可见,如果需要通过子类对象访问,则必须指定作用域
  • 注意:实际中在继承体系⾥⾯最好不要定义同名的成员

派生类的默认成员函数

  • 6个默认成员函数:构造函数、拷贝构造函数、赋值运算符重载、析构函数、取地址运算符重载、const修饰的取地址运算符重载;本小节我们只讨论前4种
  • 在继承关系⾥⾯,在派⽣类中如果没有显⽰定义这六个成员函数,编译系统则会默
    认合成这六个默认的成员函数。
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, int num)
: Person(name )
, _num(num )
{
cout<<"Student()" <<endl;
}
Student(const Student& s)
: Person(s)
, _num(s ._num)
{
cout<<"Student(const Student& s)" <<endl ;
}
Student& operator = (const Student& s )
{
cout<<"Student& operator= (const Student& s)"<< endl;
if (this != &s)
{
Person::operator =(s);
_num = s ._num;
}
return *this ;
}
~Student()
{
//~Person();
Person::~Person();
cout<<"~Student()" <<endl;
}
private :
int _num ; //学号
};
void Test ()
{
Student s1 ("xiaoming", 1);
Student s2 (s1);
Student s3 ("rose", 17);
s1 = s3 ;
}
构造函数

这里写图片描述

  • 如果子类没有显示的声明构造函数,则编译器会自动调用父类的构造函数(无参),如果父类的构造函数是带参数的,则子类对象构造失败
    这里写图片描述
  • 子类的构造函数:先调用父类的构造函数来初始化从父类继承过来的变量,然后再初始化自己的成员变量
拷贝构造函数
  • 拷贝构造函数同构造函数类似,也是通过调用父类的拷贝构造来初始化从父类继承过来的变量,然后在初始化自己的成员变量
    这里写图片描述
赋值运算符重载

这里写图片描述

析构函数
  • 按照上述三种默认成员函数的构造方法,我们认为析构函数应该类似,子类的析构函数通过调用父类的析构函数来回收公有成员变量资源,子类自己的由自己回收,那么事实是不是这样呢?我们继续来验证一下:
    这里写图片描述

  • 子类调用父类的析构函数竟然出错了,这是为什么呢?编译器告诉我们没有匹配的Person::Person函数可以调用

  • 其实回答这个问题,就用到了我们上小节所讲到的隐藏,实际上是因为~Student()隐藏了~Person(),那么很多人就要问了:两个函数并不同名,怎能构成隐藏呢?
  • 实际上,这是因为编译器对其做了特殊处理,析构函数经过编译器的编译后,并不是原来的析构函数名,而是distructor(),因为每个析构函数对于编译器来说都是这个名字,所以自然构成隐藏
  • 所以,如果我们想在子类的析构函数中调用父类,则必须显示的指明作用域,我们再来观察一下运行的结果吧
    这里写图片描述
  • 发生了什么?析构子类对象竟然调用了两次父类的析构函数,这与我们之前的规则不相符。其实,编译器对于子类调用析构函数做了特殊处理,子类不用去显示的调用父类的析构函数,因为析构函数遵从”后定义的先析构”,所以这样就可以保证”后进先出”,子类在完成自己的析构后会自动的去调用父亲的,结果如下:
    这里写图片描述

单继承&&多继承

  • 单继承:一个子类只有一个父类
  • 多继承:一个子类有两个或两个以上的父类
菱形继承
  • 生活中我们可能会遇到这样一种人,比如说在学校,会有一些研究生学长学姐拥有助教的身份,他们在他们的导师面前是学生,在我们面前是老师,但是不论是作为学生还是老师,他们都是同一个人
  • 于是就有了一种这样的继承模型,这个助教他同时拥有老师和学生的相关属性,而老师和学生的部分属性是从Person类中继承过来的,下面是这种关系图示:
    这里写图片描述
  • 我们称这种继承为菱形继承
  • 我们可以思考一下,在学生类中,每个学生都有自己的名字,学号;在教师类中,每个教师都有自己的名字,工号,但是这两个名字表示的应该是同一个人,但是它们分属于不同的类,那么在内存中是否为这两个名字各自开辟了一份空间?
  • 我们可以看一下菱形继承的对象模型来思考这个问题:
    这里写图片描述
  • 这是菱形继承存在的二义性和数据冗余问题,本小节我们将来探讨编译器是如何解决菱形继承的二义性和数据冗余问题的
解决二义性和数据冗余
  • 我们猜想,既然成员变量_name是学生类和教师类各有一份的,那么直接用Assistant对象去访问编译器肯定是会报错的,这个大家可以自己验证,但是如果我们指定作用域对成员变量进行访问,会出现什么情况呢?
    这里写图片描述
  • 指定了作用域后,对于我们要访问的是哪个类的_name我们是明确的,解决了二义性,但是数据冗余并没有解决,”小明”和”小花”是一个人,但是他确有两个名字
  • 那么,编译器是如何解决数据冗余的呢?在这里我们要引入虚继承的概念
  • 虚继承:用来解决菱形继承的数据冗余和二义性问题
  • 为了方便演示,我们定义下面几个类:
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;
}
  • 首先观察未定义虚继承时内存的变化:
    这里写图片描述
  • 我们定义了虚继承,通过监视窗口来看看发生了什么情况
    这里写图片描述
    这里写图片描述
  • 观察上面两幅图,我们发现:我们指定了作用域,B和C类共同继承的A类成员变量_a变成了同一个,我们对其中一个修改则另外一个也会修改,这就很好的解决了菱形继承的数据冗余问题,那么编译其实如何解决的呢?我们需要来看一下内存窗口发生了什么:
  • 我们取到对象d的地址
    这里写图片描述
  • 内存有了如下的变化,_a=2,_b=3,_c=4,_d=5,内存中,_a是在最下边,_b和_c前面都有一个类似于地址的东西,这个是什么呢?会不会和_a有关呢?
  • 我们可以访问这块地址
    这里写图片描述
  • 这里的12和20我们可以猜想是现对于当前位置的偏移量,事实上也确实如此:
    这里写图片描述
  • 还有我们观察到0x00F2DF1C指向的内存,除了存有偏移量,还有一个0,但是这个0并不属于我们今天所讲的范畴,它是为虚表指针的偏移量预留的,这部分内容我会在后面更新的博客中讲到
  • 我们称0x00F2DF1C所指向的内容为虚基表(偏移量表),通过这个表我们可以找到公共的虚基类(譬如本小节例子中的A)
  • 通过上述论述,我想大家应该对虚继承有了一定的仍认识了吧,虚继承通过虚基表找到公共的虚基类然后对它进行访问就不会产生二义性和数据冗余了
  • 那么我们还有一个问题:有虚继承和没有虚继承对sizeof(d)的大小有没有什么影响?
  • 经过验证我们得到结论:无虚继承,sizeof(d)=20;有虚继承,sizeof(d)=24,大家可以自行验证。我在这里只解释一下为什么有了虚继承,d的大小反而增加了呢?这是因为我们节省了_a的4个字节,但是在B、C处又多了8个字节(因为虚基表指针),所以20-4+8=24
  • 这里可能大家又会有疑问,虚继承不是解决了数据冗余吗,为什么对象没变小反而变大了,事实上这是和_a的类型有关的,因为虚继承我们节省的空间的大小为(sizeof(_a)-8),_a越大,我们通过虚继承节省的空间就越大

多态(一)


概念铺垫

  • 虚函数:类的成员函数前加virtual关键字构成虚函数,上篇博客中我们讲到虚继承也使用到了virtual关键字,虽然关键字一样,但是注意:这里的虚函数和虚继承并无任何关系
  • 虚函数重写(覆盖):当在子类中定义了一个与父类完全相同的虚函数时,则称子类的这个函数重写了父类的虚函数

在此,我们顺便回忆一下,和重写名字容易混淆的另外两个概念

  • 重定义(隐藏):子类和父类定义了同名函数或者变量,则子类的函数和变量重定义了父类的函数和变量,子类对其访问时遵循就近访问原则,重定义即表名父类的同名函数和变量不可见(存在,但是不能访问)
  • 重载:函数名相同,参数列表不同的得函数互相之间构成重载

什么是多态

  • 多态是C++面向对象的三大特性之一
  • 所谓多态,其实就是“一个接口,多种方法”,程序在运行时才能通过基类指针指向的对象的类型来决定调用哪个函数。
  • 多态的两个重要条件:(1)虚函数的重写;(2)父类的指针和引用调用重写的虚函数
  • 多态:当基类的指针或引用调用重写的虚函数时,当指向父类,调用的就是父类的虚函数,当指向子类,调用的就是子类的虚函数

我们以一个简单的例子来理解一下多态的概念:

  • 生活中我们经常会遇到买票事件,比如我们买火车票,学生买票享受五折优惠,成人买票无优惠。对于买火车票这件事,对于学生这个对象,他买的是半价票;对于成人来说,他买的是全价票,对象不一样,执行的买票事件就不一样,我们通过图示来更深入的理解一下:
    这里写图片描述

虚函数的重写

本小节我们将论述序红函数的重写是怎样影响多态的构成的

  • 虚函数重写(覆盖):当在子类中定义了一个与父类完全相同的虚函数时,则称子类的这个函数重写了父类的虚函数
  • 父类与子类完全相同的范畴是什么?函数名、参数列表、返回值

我们来验证一下:
- 分别去掉父类函数的virtual关键字和子类函数的virtual关键字:
这里写图片描述
这里写图片描述

  • 我们可以观察到,去掉父类virtual,不构成多态;去掉子类virtual,仍然构成多态,所以子类的virtual是可以省略不写的,不过为了代码的可读性更高,我们一般建议不要省略virtual关键字

  • 验证返回值对虚函数返回值对构成多态的影响
    这里写图片描述

  • 类型不同(不满足虚函数的重写),编译器报错了,我们发现了一个新的概念——协变
  • 协变:构成多态的虚函数的返回值可以不同,但是返回值必须是父子关系(继承关系)的指针和引用,对象也不可以
    这里写图片描述
    这里写图片描述
  • 构成多态的虚函数参数列表相同和函数名相同是必须的

至此,我们可以总结关于虚函数对构成多态的影响有以下几点:

  • 派⽣类重写基类的虚函数实现多态,要求函数名、参数列表、返回值完全相
    同。(协变除外)
  • 基类中定义了虚函数,在派⽣类中该函数始终保持虚函数的特性(不论派生类该函数有没有virtual关键字)
  • 对于构成多态来说,调用的时候是和类型无关的,只和对象有关,哪个对象,就调用哪个对象虚函数
  • 如果不构成多态,则函数调用的时候是和类型有关的,函数重载就是这样的
    这里写图片描述

继承体系同名函数的关系

这里写图片描述

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页