【C++】封装、继承和多态

发布于:2024-04-27 ⋅ 阅读:(19) ⋅ 点赞:(0)

引言

在现代软件开发中,面向对象编程(Object Oriented Programming)已经成为一种广泛应用的编程范式。C++作为一种支持面向对象编程的语言,在封装、继承和多态方面提供了强大的特性。本文将介绍C++中的封装、继承和多态概念,并通过简单示例来说明它们的使用方法。

一、封装

  • 封装可以隐藏实现细节,使得代码模块化,使代码和功能独立
  • 封装是把函数和数据包围起来,对数据的访问只能通过可信任的对象和类进行访问,对不可信的进行信息隐藏。
  • 在面向对象编程上可理解为:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。

二、继承

继承的概念和定义

  • 继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。其继承的过程,就是从一般到特殊的过程。
  • 通过继承创建的新类称为“子类”或“派生类”。被继承的类称为“基类”、“父类”或“超类”。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。在C++当中,一个子类可以继承多个基类。

继承实现的三种方式

  • 继承概念的实现方式有三类:实现继承接口继承可视继承
  • 实现继承:指使用基类的属性和方法而无需额外编码的能力;
  • 接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力;
  • 可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力。

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

类成员/继承方式

public继承

protected继承 private继承
父类public成员 子类public成员 子类protected成员 子类private成员
父类protected成员 子类protected成员 子类protected成员 子类private成员
父类private成员 子类中不可见 子类中不可见 子类中不可见

由上表可知:

  • 基类private成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。但如果是派生类调用基类当中访问了private成员的函数,那么也相当于间接访问。
  • 基类成员在被继承的时候,在派生类当中的访问方式会取该成员在基类当中的访问限定符和继承方式当中权限较小的一个,public > protected > private。
  • class默认的继承方式是private,而struct的默认继承方式是public。但在实际运用过程当中一般是采用public继承。
  • public成员是被公开的,在类里或者类外都能被直接访问;protected成员是能在派生类当中可见,类外不可见;private成员只能在类里被访问,外界和派生类都不能访问。

赋值转换

派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。注:切片不存在类型转换时构造临时对象,而是直接赋值。

  • 子类对象可以赋值给父类对象、指针和引用。
  • 基类对象不能赋值给派生类对象。
  • 基类的指针可以通过强制类型转换赋值给派生类的指针。注:如果该基类指针本身是指向派生类对象,访问派生类成员时,不存在越界问题,但是如果该指针是指向基类对象,访问派生类成员会存在越界问题。

函数隐藏

  • 在继承体系中基类和派生类都有独立的作用域。所以即使基类和派生类存在同名函数,不会构成函数重载,因为函数重载要求在同一作用域。
  • 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏(优先访问子类同名成员),也叫重定义。可以加类域限定符,来指定访问的是父类还是子类中的同名成员。
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏

派生类默认成员函数

在派生类中,如果不自己写成员函数,那么编译器会自动生成默认成员函数。

  • 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  • 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  • 派生类的operator=必须要调用基类的operator=完成基类的赋值。
  • 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 派生类对象初始化先调用基类构造再调派生类构造。
  • 派生类对象析构清理先调用派生类析构再调基类的析构。
  • 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

注:子类成员会自动去调用父类的构造函数,不能自己去访问父类的私有成员进行初始化。拷贝构造也是如此,因为拷贝构造中是父类引用,就对应了赋值兼容规则,传过来子类对象,对其进行切片,得到父类那一部分的引用,从而进行拷贝构造。赋值也是如此。析构函数建议设置为虚函数让子类进行重写。

Student(const Student& s)
    : Person(s)
    , _num(s._num)f
    {}

Student& operator=( const Student& s)
{
    if (this != &s)
    {
        operator=(s);
        _num = s._num;
    }

    return *this;
}

但此处的operator=和父类中的operator=构成了隐藏,所以会自己调用自己,死循环,导致栈溢出。所以要加类域限制符,指定调用父类里面的=。

总结

  • 子类拷贝构造和赋值可以不写,会自动去调用父类中的拷贝构造和赋值,为了避免深拷贝的情况出现,才会有自己写子类的拷贝构造和赋值的过程。
  • 子类的析构函数不能显式去调用父类的析构函数,因为在子类对象中先构造父类部分再构造子类部分,为了保证子类部分先析构,父类部分后析构,所以不能显式调用,打破顺序。
  • 友元关系不能继承。
  • 静态成员变量即使被继承,也只存在这一个变量,但是子类和父类都可以访问。

实现一个不能被继承的类? 

把构造函数和析构函数私有化,那么子类就不能构造或者析构对象了。

菱形继承

菱形继承是多继承的一种特殊情况,由图可知,b类和c类继承了a类,而d类又同时继承了b类和c类,这种情况一般就被称为菱形继承。

数据冗余和二义性

b类和c类分别继承了一份a类中的成员,而d类又继承了b类和c类,那么d一定存在两份a类的成员,这就产生了数据冗余,因为根本不需要多一份的a类成员,同时,当对这个成员进行访问的时候,也会会产生到底是访问b类中的a类成员,还是c类中的a类成员,指向不明确,这就是二义性。

如何解决?——虚继承

所谓虚继承(virtual)就是子类中只有一份间接父类的数据。该技术用于解决多继承中的父类为非虚基类时出现的数据冗余问题,即菱形继承问题。

在继承时,在继承方式的前面加上一个virtual关键字即可。

由上图可知:在d类对象当中,只存在了一份a类成员,而在b类(8c cd aa 00)和c类(ac cb aa 00)对象的起始位置存放的是一个虚基表指针,而虚基表当中存放就是当前成员到a类成员的偏移量。

虚基表:每个虚继承的子类都有一个虚基表指针,虚基表里面存放偏移量,通过偏移量找到唯一的成员,从而解决了数据冗余和二义性。

对于菱形虚拟继承:先继承的类,先进行初始化,且被多继承的类,只会进行一次初始化。

继承和组合

继承是派生类继承子类,组合则是一个类中包含另一个类的对象。

  • 继承:is-a 学生是人 采用继承的方式
  • 组合:has-a 车有轮胎 采用组合的方式

实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。可以继承,可以组合,优先使用组合。

三、多态

多态的概念

  • 多态是同一个行为具有多个不同表现形式或形态的能力。
  • 多态就是同一个接口,使用不同的实例而执行不同操作
  • 允许派生类类型指针或引用赋值给基类类型的指针或引用

动态绑定和静态绑定

  • 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  • 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

多态的实现——虚函数

虚函数:被virtual修饰的类成员函数可称为虚函数。
虚函数的作用:允许在派生类中重新定义与基类同名的函数,并且可以通过基类指针或引用来访问基类和派生类中的同名函数。

虚函数的重写:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),在派生类当中重写了该同名函数,称为虚函数重写。(函数重写\覆盖)

特例:

  • 协变:允许虚函数的返回值不同,但即使不同,两个返回值类型的关系必须构成派生类和基类的关系。
  • 析构函数的重写:如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

纯虚函数

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

虚函数实现多态的条件

  1. 虚函数的重写:父类中的函数加了virtual,子类对该函数的重写就可以不加virtual。继承的是接口,即包括了函数当中的缺省参数,重写的只是函数实现。
  2. 通过基类的指针或者引用调用:传递派生类对象时,基类的指针或者引用可以进行切片,无论派生类对象还是基类对象,其处理过程都一样。

函数重载、覆盖、隐藏的对比

final和override

  • final:修饰虚函数,表示该虚函数不能被重写。
  • override:检查派生类虚函数是否重写了基类的虚函数,如果没有则编译报错。

虚函数表

每个包含了虚函数的类都包含一个虚表。 
我们知道,当一个类(A)继承另一个类(B)时,类A会继承类B的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

因为类A存在vfunc1和vfunc2两个虚函数,所以存在一个虚表,虚表里面存在两个虚函数指针。

虚表是一个指针数组,存放的是虚函数指针,并且数组最后一般放的nullptr。普通的成员函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通成员函数的函数指针。 
虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

虚表指针

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。 为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

以下代码能更好诠释通过虚函数来实现多态的过程:

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};
 
class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};
 
class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};

A类存在两个虚函数vfunc1和vfunc2,B类继承A类之后,对vfun1进行了重写,C类继承B类,对vfunc2进行了重写,所以会得到下面的关系:



总结一下派生类的虚表生成:

a.先将基类中的虚表内容拷贝一份到派生类虚表中

b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

注:在继承两个父类之后,子类中如果有虚函数存在,那么这个虚函数会放在第一个继承的父类的虚表指针当中,且子类对父类的虚函数重写之后,继承的两个虚表里面的虚函数都会被覆盖,但在监视窗口中,地址居然不一样。是因为调用的子类的成员函数,必须要this指针来调用,那么this指针的位置必须指向对象开头的位置,为了修正this指针,可能会多一些操作,ecx里面一般存放的就是this指针。

通过代码验证也可以得知,虚表放在虚基表的前面,为了方便找到虚表,虚基表中还存放了距离虚表的偏移量,例如,全f是-1,那fc就是-4,可以理解为是找到虚表位置。

虚函数相关问题

  • inline函数可以是虚函数吗?可以,但是编译器就会直接忽略掉inline属性,这个函数不再是inline,因为虚函数要放到虚表当中去。
  • 静态成员函数可以是虚函数吗?不可以,因为虚函数还是成员函数,需要通过this指针调用,但静态成员函数不需要this指针进行调用,所以静态成员函数无法放进虚表当中。
  • 构造函数可以是虚函数吗?不可以,对象中的虚表指针是在构造函数的初始化列表时被初始化的。
  • 对象访问普通函数快还是虚函数更快?如果是对象去调用,是一样快,但如果是指针或者引用去调用,则调用普通函数更快,因为构成了多态,运行时需要到虚表当中去查找。
  • 虚表存在哪儿?虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢? vs下是存在代码段的
  • 虚表是什么时候生成的?对象中虚表指针什么时候初始化?虚表在编译阶段生成,而虚表指针在构造函数的初始化列表进行初始化。