【C++】最通俗的多态、虚表、虚指针讲解

从C++使用者角度看多态

1 即使没有虚函数也能重写方法

多态需要两个特性
(1)方法重写(override):父类与子类具有函数签名完全相同的方法。
(2)向上类型转换(upcasting):用一个父类指针指向子类对象的时候,假如调用的是虚函数,会自动暂时将该指针转换为子类类型的指针。

虚函数的存在就是为了类型转换,即使没有虚函数也能重写方法。虚函数并不是为了解决函数重写问题的。假如你去在父类和子类中都写上函数签名相同的方法,同样也能重写函数。

例如 我们先写一个没有虚函数的例子

#include <iostream>
using namespace std;
class Base
{
public:
    void func(){cout<<"Base method!\n";};
};

class Derived: public Base
{
public:
    void func(){cout<<"Derived method!\n";};
};
int main()
{
    Base obj_base;
    Derived obj_derived;
    obj_base.func();
    obj_derived.func();
}

结果为

Base method!
Derived method!

是完全没问题的!

是的,即使不加virtual,也能实现方法重写!

2 即使不用虚函数,子类对象也能调用父类同名方法

假如我用子类对象调用父类同名的方法,能行吗?

也是能的!(注意这种很特别的写法)

int main()
{
    // Base obj_base;
    Derived obj_derived;
    // obj_base.func();
    obj_derived.Base::func();
}

打印结果

Base method!

所以,完全不需要虚函数,也能用子类对象调用父类方法!

(当然,父类中不同名的方法,不需要增加作用域符号Base::就能直接调用)

3 没有虚函数的基类指针还是基类指针,不会类型转换

我们给定一个指针p,这个p是基类指针,指向的是子类对象。

int main()
{
    Base *p = new Derived();
    p->func();
}

结果是

Base method!

也就是说,假如没有虚函数,基类的指针还是指向基类的。基类的指针不知道子类对象。

这一点我们还可以进一步证明。

当我们在子类中增加一个子类特有的函数

void derived_unique(){cout<<"Derived_unique method!\n";};

然后调用该函数的时候

int main()
{
    Base *p = new Derived();
    p->derived_unique();
}

就会报错

error: 'class Base' has no member named 'derived_unique'
     p->derived_unique();

所以不用virtual关键字,基类指针还是基类指针,即使它指向的是子类对象,它还是只知道基类的方法。

4 虚函数:只是做了指针类型转换

我们接下来只是增加virtual关键字。(和前面代码的唯一区别就是加了virtual)

#include <iostream>
using namespace std;
class Base
{
public:
    virtual void func(){cout<<"Base method!\n";};
};

class Derived: public Base
{
public:
    virtual void func(){cout<<"Derived method!\n";};
};
int main()
{
    Base *p = new Derived();
    p->func();
}

结果是

Derived method!

也就是说,virtual只是自动做了指针的类型转换。原本是基类的指针,因为有了virtual,现在变成了子类的指针。

注意,这个类型转换只是暂时的。而且是只有在调用虚函数的时候才出现的。也就是说,当编译器看到你的指针调用的是virtual方法的时候,自动的给你暂时转换为子类方法。而调用完毕,再自动转换回来。

这点很容易证明:
假如基类中有个基类才有的方法

void base_unique(){cout<<"base_unique method!\n";};

调用该方法

int main()
{
    Base *p = new Derived();
    p->base_unique();
}

那么完全没有问题

打印结果

base_unique method!

也就是说,这个类型转换只是暂时的。

总结

至此,我们总结一下。

多态具有两个特性:
(1)方法重写(override):父类与子类具有函数签名完全相同的方法。
(2)向上类型转换(upcasting):用一个父类指针指向子类对象的时候,假如调用的是虚函数,会自动暂时将该指针转换为子类类型的指针。

这两个特性完全是分开的。特性1即使不用任何virtual关键字也是能实现的。特性2才是借助了virtual关键字。所以,对于使用者来说:虚函数其实就是向上类型转换。而且这个类型转换是暂时的,只有调用虚函数的时候才暂时转换过去。


从C++设计者(内存)角度看多态

以上是从C++的使用者角度来看的,即使不知道虚表和虚指针,也完全不影响我们的使用。 现在我们从C++设计者角度(或从内存的角度)来看虚函数。通过从设计者的角度来看,我们就知道增加虚函数对我们的性能影响如何。

多态的向上转型真的是一种类型转换吗?

在使用者的眼中,向上类型转换是如此神奇的一个东西:你使用虚函数的时候,它就自动转型成具体的子类指针了,使用完了,再转换回去。你大概会以为:
他先做了一个

(Derived *)p

再做了一个

(Base*)p

但是,真的是这样吗?

答案是否定的。C++实现的方法是,通过对每个对象存入一个指针。这个指针从功能上讲是个二级指针。它被对象的指针(对外部调用者来说就是我们的p指针,对对象内部来说就是this指针)所指。它指向一个数组。

这个二级指针就是虚指针。

这个数组就是虚表。该数组中的每个元素都是指针。这些指针所存储的就是虚函数的地址。

请添加图片描述

从内存角度看虚指针

首先我们要记住:虚表和虚指针是为了多态性而存在的,假如类中没有虚函数(比如只有普通成员函数),那么就没有虚表和虚指针。

虚表是什么:只是一个数组,里面的每个元素存放的是虚函数的地址。
虚指针是什么:只是一个指针,指向虚表。

需要特别注意的是:虚表是对于类而言的,虚指针则是针对对象而言的。

也就是说,可以认为虚表内存存在于类内存中(代码区),每一个类只需要一份就可以了。虚指针则存在于对象的内存(堆栈区)里,每一个对象就有一个虚指针。假如某个类实例化了10000个对象,那么虚指针就要占用10000*8字节(假设每个指针占用8字节),而虚表的占用内存则完全不变。

因此:使用虚函数的开销就是每个对象增加了一个指针的内存开销。

假如只有普通成员函数

我们先来看看只有普通成员函数的对象内存布局。这时候,是没有虚表和虚指针的。

假如类A只有成员函数

class A
{
    void func1();
    void func2();
};

这时候去实例化A,其对象内存占用是1(任何空类的占用内存都是1)。

原因在于:普通成员函数的函数指针是不存放在对象内存内部的。可以理解为:从对象模型上讲,普通成员函数和全局函数没有什么区别。

我们要去调用某个对象a的成员函数的时候:

a.func1()

其实是把这个对象a的地址(也就是this指针)传递给func1,func1通过this指针就可以找到任何该对象的数据了。

虚函数的真正实现方法:虚指针和虚表

C++通过虚指针,是怎么实现多态性的呢?

我们只要当p指向父类对象的时候,指向的是父类的vptr;当p指向父类对象的时候,指向的是子类的vptr,

在这里插入图片描述

根据这个指针是子类指针还是父类指针,就会分别指向子类和父类的虚表。

这样,就实现了多态。

从抽象角度看多态:为什么要有多态?

1 继承是种属关系,指针是代词

我们先要理解,继承关系不是爸爸儿子的关系(这也是名字造成的通常的一个误解)。父类与子类之间是种属关系。例如狗和动物的关系就是种属关系。狗一定是一种动物。动物具有的特性狗都有。也就是说:父类与子类是抽象与具体的关系。这才叫做继承。

正因为狗也是一种动物,所以我们用一个代词指代狗的时候,既可以说:“那只狗。”
也可以说:“那只动物。”

但是没有人把这种关系反过来指代。我们不能指代任何一种动物为”那只狗“。因为显然动物不只有狗这一种。你只能用抽象的东西指代具体的东西,但是不能反过来用具体的东西指代抽象的东西。

那个代词,就是我们所用的指针。

所以,我们完全可以用”那只动物“这种抽象的代词来指代一只狗。也就是说,我们可以用基类指针来指代基类对象或者子类对象。

2 指代不清的问题

假如我们把多态的两个特性结合起来,就会出现一个问题:

既然我们的指针既能指向父类对象,也能指向子类对象,那编译器怎么知道该调用哪个方法?

我们先来看看为什么会有这个问题:

假如我们不用指针up casting的时候,完全就没有这个顾虑。也就是在第3节中的代码:

    A a;
    ASon aSon;
    a.func1();
    aSon.func1();

编译器完全能够分辨。因为我们直接用的是对象a和对象aSon。对象a一定是父类的对象,对象aSon一定是子类的对象。在不用代词,直呼其名的时候,是没有指代的混淆的。

例如我家的狗叫做小白。当我称呼小白的时候,它一定就是那条狗。用对象的名字称呼对象,没有任何不清楚的地方。

但是假如用代词称呼对象,那么就会出现指代不清的问题。比如我有一只猫,一只狗。我统一用”那只动物“来称呼他们。那就没法分辨指的是谁了。

也就是说:用指针去指代对象的时候,就会出现指代不清的问题。直接用对象名就没有这个问题。

所以,假如不用指针,直接用对象名,不会出现多态的问题。

  • 30
    点赞
  • 90
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值