C++基础:多态性


https://github.com/jxingm/Interview-Notes/blob/master/c%2B%2B/多态 虚函数 虚函数表.pdf

介绍下多态

C++的多态分为静态多态(编译时多态)和动态多态(运行时多态)两大类。静态多态通过 函数重载函数模板和类模板 来实现;动态多态是通过 虚函数 来体现。

什么是虚函数?

在类的定义中被 virtual 关键字修饰的成员函数都是虚函数:

virtual returntype func(parameter);

引入虚函数的目的是为了动态绑定。

什么是纯虚函数?为什么需要纯虚函数?

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”。引入纯虚函数的目的在于为派生类提供了一套统一的接口,但具体实现可以各不相同。

virtual returntype func(parameter)=0

原因:

  1. 为了方便使用多态特性,我们常常需要在基类中定义虚函数。
  2. 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

每个类有一个vtable,每个对象有一个vptr指向vtable,new多个对象的时候,进程地址空间只会保留一个vtable,所有对象共享一个vtable

虚函数和纯虚函数的区别

基类为什么需要虚析构函数?

防止内存泄漏。定义了虚析构函数后,可以借助父类指针去销毁子类对象。假如没有虚析构函数,释放一个由基类指针指向的派生类对象时,不会触发动态绑定,则只会调用基类的析构函数,而派生类中申请的空间则得不到释放导致内存泄漏。

一般情况下类的析构函数里面都是释放内存资源,而析构函数不被调用的话就会造成内存泄漏。这样做是为了当用一个基类的指针删除一个派生类的对象时,派生类的析构函数会被调用。

虚函数实现多态的原理

当一个类声明了虚函数或者继承了虚函数时,不论是否实现,这个类就会创建自己的虚函数表vtbl,实质是一个函数指针数组或链表,其中每一个元素(即每一个函数指针)都指向该类的一个虚函数。这个虚函数表是属于类的,所有该类的实例化对象中都会有一个虚函数表指针去指向该类的虚函数表。虚函数按照其声明顺序放于虚函数表中, 如果子类覆盖了父类的虚函数,则用派生的虚函数指针替换原父类虚函数指针的位置。在多继承情况下,有多少个含有虚函数的基类就有多少个虚函数表指针。

(1)先将基类中的虚表内容拷贝一份到派生类虚表中;
(2)如果派生类重写了基类中的某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
(3)派生类自己新增加的虚函数按其声明次序增加到虚表的最后。
(4)通过虚函数表指针,父类指针即可以查表方式调用该虚函数表中所有的虚函数。

在这里插入图片描述

示例:

https://zhuanlan.zhihu.com/p/98776075

class Aclass Bclass C

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

class B : public A {
public:
    virtual void vfunc1();
            void func2();
private:
    int m_data3;
};

class C : public B {
public:
    virtual void vfunc1();
            void func2();
private:
    int m_data1, m_data4;
};

在这里插入图片描述

为什么 C++里访问虚函数比访问普通函数慢?

**总述:**单继承时性能差不多,多继承的时候会慢

  • 调用性能方面: 从前面虚函数的调用过程可知。当调用虚函数时过程如下(引自More Effective C++):

    1. 调用第一步通过对象的虚函数表指针找到类的虚函数表。这是一个简单的操作,代价只是一个偏移调整(以得到 vptr)和一个指针的间接寻址(以得到 vtbl)。
    2. 调用第二步找到的的指针所指向的函数。在单继承的情况下,调用虚函数所需的代价基本上和非虚函数效率一样,在大多数计算机上它多执行了很少的一些指令,所以有很多人一概而论说虚函数性能不行是不太科学的。在多继承的情况下,由于会根据多个父类生成多个vptr,在对象里为寻找 vptr 而进行的偏移量计算会变得复杂一些,但这些并不是虚函数的性能瓶颈。 虚函数运行时所需的代价主要是虚函数不能是内联函。这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”但虚函数的运行时多态特性就是要在运行时才知道具体调用哪个虚函数,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。
  • 占用空间方面: 在上面的虚函数实现原理部分,可以看到为了实现运行时多态机制,编译器会给每一个包含虚函数或继承了虚函数的类自动建立一个虚函数表,所以虚函数的一个代价就是会增加类的体积。在虚函数接口较少的类中这个代价并不明显,虚函数表vtbl的体积相当于几个函数指针的体积,如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。但这并不是最主要的代价,主要的代价是发生在类的继承过程中,在上面的分析中,可以看到,当子类继承父类的虚函数时,子类会有自己的vtbl,如果子类只覆盖父类的一两个虚函数接口,子类vtbl的其余部分内容会与父类重复。这在如果存在大量的子类继承,且重写父类的虚函数接口只占总数的一小部分的情况下,会造成大量地址空间浪费。在一些GUI库上这种大量子类继承自同一父类且只覆盖其中一两个虚函数的情况是经常有的,这样就导致UI库的占用内存明显变大。 由于虚函数指针vptr的存在,虚函数也会增加该类的每个对象的体积。相比在单继承或没有继承的情况下,类的每个对象会多一个vptr指针的体积,也就是4个字节;在多继承的情况下,类的每个对象会多N个(N=包含虚函数的父类个数)vptr的体积,也就是4N个字节。当一个类的对象体积较大时,这个代价不是很明显,但当一个类的对象很轻量的时候,如成员变量只有4个字节,那么再加上4(或4N)个字节的vptr,对象的体积相当于翻了1(或N)倍,这个代价是非常大的。

构造函数、内联函数、静态成员函数可以是虚函数吗?

constructorinlinestatic 三种函数都不能带有virtual关键字。虚函数的实现基础是能够访问虚函数表。

inline是编译时展开,必须有实体;

static属于class自己的,也必须有实体,没有this指针,只能使用类型::成员函数的调用方式无法访问虚函数表;

new一个对象需要①开辟内存空间 ②编译器调用构造函数进行初始化,也就是实例化。因此constructor函数在被调用的时候,对象所处的内存没有实例化,而如果构造函数是虚的,就是说通过vtable来调用构造函数,但是此时是一片raw memory,根本找不到对象的vptr来指向vtable,所以这才是构造函数不能是虚的原因。

虚函数实际上不能被内联: 虚函数运行时所需的代价主要是虚函数不能是内联函。这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”但虚函数的运行时多态特性就是要在运行时才知道具体调用哪个虚函数,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。

构造函数不能是虚函数。而且,在构造函数中调用虚函数,实际执行的是父类的对应函数,因为自己还没有构造好, 多态是被disable的。

静态的对象是属于整个类的,不对某一个对象而言,同时其函数的指针存放也不同于一般的成员函数,其无法成为一个对象的虚函数的指针以实现由此带来的动态机制。

为什么需要虚继承?虚继承实现原理是什么?

虚继承是多重继承中特有的概念。虚基类是为解决多重继承而出现的。
如:类D继承自类B1、B2,而类B1、B2都继 承自类A,因此在类D中两次出现类A中的变量和函数。为了节省内存空间,可以将B1、B2对A的继承定义为虚拟继承,而A就成了虚基类,虚拟继承在一般的应用中很少用到,所以也往往被忽视,这也主要是因为在C++中,多重继承是不推荐的,也并不常用,而一旦离开了多重继承,虚继承就完全失去了存在的必要因为这样只会降低效率和占用更多的空间。

虚继承的特点是,在任何派生类中的virtual基类总用同一个(共享)对象表示,

总结

  1. 虚函数是动态绑定的,也就是说,使用虚函数的指针和引用能够正确找到实际类的对应函数,而不是执行定义类的函数。这是虚函数的基本功能,就不再解释了。
  2. 构造函数不能是虚函数。而且,在构造函数中调用虚函数,实际执行的是父类的对应函数,因为自己还没有构造好, 多态是被disable的。
  3. 析构函数可以是虚函数,而且,在一个复杂类结构中,这往往是必须的。
  4. 将一个函数定义为纯虚函数,实际上是将这个类定义为抽象类,不能实例化对象。
  5. 纯虚函数通常没有定义体,但也完全可以拥有。
  6. 析构函数可以是纯虚的,但纯虚析构函数必须有定义体,因为析构函数的调用是在子类中隐含的。
  7. 非纯的虚函数必须有定义体,不然是一个错误。
  8. 派生类的override虚函数定义必须和父类完全一致。除了一个特例,如果父类中返回值是一个指针或引用,子类override时可以返回这个指针(或引用)的派生。例如,在上面的例子中,在Base中定义了 virtual Base* clone(); 在Derived中可以定义为 virtual Derived* clone()。可以看到,这种放松对于Clone模式是非常有用的。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值