C++多态(2)

1. 抽象类

纯虚函数:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。

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

class Car
{
public:
	virtual void Drive() = 0;//纯虚函数
};

抽象类的纯虚函数一般只声明不实现,没有价值,因为它无法实例化出对象,那么也就没有对象能够调用它。

1.1 使用场景

一个类型如果在现实世界中,没有具体的对应实物就定义成抽象类比较好。

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。

虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

所以如果不实现多态,不要把函数定义成虚函数。

2. 多态的原理

2.1 虚函数表

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
	cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};
int main()
{
	Base b;
}

通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。

在这里插入图片描述

一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,其本质是一个函数指针数组。

注意:虚函数表里存的是虚函数的指针,并不是虚函数

如果增加一个虚函数Func2(),观察对象b,可以发现有两个数组元素,分别代表的是两个虚函数的指针:
在这里插入图片描述

如果删除Func2(),就只剩下func1()的指针:
在这里插入图片描述

但是即使Func2()是虚函数,如果这时候是对象调用的话,调用的时候也不是去虚表里面找,因为去虚表里面找虚函数调用的前提是要满足多态;而Func2()仅仅只是满足了虚函数,并不满足指针/引用对象调用,那么自然也就不构成多态。

所以说对象调用的话这里的虚函数Func2()是按照普通函数的方式调用的,在汇编层面也就是直接call这个函数的地址,这个过程在编译的时候实现,也就是产生汇编代码的时候。

但是如果改成对象指针/引用调用的话,即使子类中没有重写Func2(),那也是可以形成多态的!

也就是说,即使子类没有重写Func2(),此时的对象指针/引用调用Func2()的话,也是按照多态的方式调用Func2()(通过虚表)。

这种情况和重写了Func2()的区别就是:会不会产生不同的结果。如果你在子类中重写了Func2()并且更改了里面的函数输出内容,那么此时输出不同的结果;

反之你没有重写Func2(),依旧形成多态,但是始终输出父类的Func2()的内容,因为子类拷贝了父类的虚表但是没有更改里面的虚函数指针,所以始终调用父类的Func2()。(这个结论可以从汇编代码里调用Func2()的过程看出来,所以说并不是子类没有重写,就不构成多态,可以理解重写只是多态的一种场景,区别就是输出的结果会不会不同

而上述如果形成多态的话,比如Func1(),调用的过程就是在运行的时候实现,因为编译的过程并没有找到虚函数地址,自然也就call不了,所以是在运行的时候对应对象才会去到虚表找虚函数地址。

  • 代码理解

通过这段代码先理解一下虚表:

class Car
{
public:
	virtual void Drive() = 0;//纯虚函数
	void f(){}
};
int main()
{
	Car* p = nullptr;
	p->Drive();//崩溃
	p->f();//不崩溃
}

上述代码调用Drive()会崩溃,是因为虚函数的指针是放在虚表里的,要找到虚函数的地址,就要先拿到其指针,那么就要去该成员里找到虚表指针,再找到虚表,然后从虚表里面找到虚函数指针;

那么找到虚函数指针以后,就会通过解引用找到该函数,空指针解引用必然引发程序崩溃。

而调用f()不崩溃是因为并没有发生解引用,只是把p传给了this指针,直接到代码段找到该函数,而不会到该对象类里去找。

2.2 子类和虚函数表的关系

针对上面的代码我们做出以下改造

  1. 我们增加一个派生类Derive去继承Base
  2. Derive中重写Func1
  3. Base再增加一个虚函数Func2和一个普通函数Func3

class Base
{
public:
	virtual void Func1()
	{}
	virtual void Func2()
	{}
	void Func3()
	{}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

通过观察和测试,我们发现了以下几点问题:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
    在这里插入图片描述

  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖重写是语法的叫法,覆盖是原理层的叫法。 而对于没有重写的虚函数,继承的过程就是简单地将其内容拷贝下来子类的虚函数表里。

在这里插入图片描述

结合这个地方,来理解为什么实现多态必须得是指针/引用调用

我们对多态的理解就是不同的对象去做一样的事会产生不同的结果,那么对于买票的例子来说:

void(Person& p)
{
	p.buyticket();
}

①这里的买票函数Func()为什么一定要用Person类?

满足切片原则,如果这里固定是子类类型的话,就接收不了父类了,因为父类有的东西不可能比子类多;而如果固定是父类类型,父类对象既可以传,子类也可以通过切片传参。

②为什么一定要引用/指针传参才可以实现多态?

首先,父类对象存的就是父类对象的虚函数表指针,子类对象存的就是子类对象的虚函数表指针

那么对于传对象来说,不能实现多态的原因是它们之间是拷贝实现的,这期间子类里面的虚表指针不会被拷贝过去,而是父类自己新形成一份。

为什么这样?因为父类就该存父类对象的虚表指针,子类同理,否则的话父类对象里面还存了子类的虚表指针的话,那我们怎么能知道,某个父类对象里调用虚函数的时候,它是去父类找虚表还是子类虚表?这就乱了套了:一个父类的对象里面到底存的是父类的虚表指针,还是子类的虚表指针就无法确定。

所以说传对象是不合理的,那为什么其他两种可以?

通过传引用,会引发切片,切片的方式,我们就可以分别拿到父类、子类各自的虚表。而构成切片方法就是子类/父类对象给父类对象传引用/指针。

  • 用实例来演示:

还是上面的Base和Derive,但是分别实现三种赋值方式:

int main()
{
	Base b;
	Derive d;
	Base b2 = d;//传对象
	Base& b3 = d;//传引用
	Base* b4 = &d;//传指针
	return 0;
}

首先来看传对象:
在这里插入图片描述

很明显地看出:子类对象d继承了父类对象b的成员,还拷贝了一份虚表指针_vfptr,这个虚表指针指向对应的虚表,但是父类与子类的虚表指针_vfptr是不一样的,也就证明了:不构成多态的情况下,父类对象与子类对象的虚表是不一样的,不是同一份!

再有,既然是子类对象d赋值给父类对象b2,我们理解的应该是b2得到的成员将是切片后的d对象的里继承下来的父类成员,也就是含有地址为0x00257b34的虚表指针的那部分成员。

但是运行结果显示的是b2存有的却是父类原有的含有地址为0x00257b44虚表指针的那部分成员。

这就验证了最原始的买票行为,为什么传对象会无法实现多态而导致买的都是全价票,就是因为赋值的这个行为根本就没有把子类对象自己的虚表指针拷贝过去,而此时对于对象而言,它存的是什么类型的虚表也就只决定于它本身是什么类型,那么此时它是父类类型,那么它的虚表指针和父类一样,不就是理所当然?

再看传引用:

在这里插入图片描述

很清楚地看见b3中存的虚表完完全全就是子类自己的虚表指针0x00257b34,其实这个操作就相当于和子类一起,指向同一块虚表。

那么此时的对象b3的虚表就不像刚才的b2一样决定于本身是什么类型了(即使现在b3是父类),你给我赋值子类,那我就是子类的虚表;给我赋值父类,那我就是父类的虚表。

那么在进行真正调用虚函数的过程中,既然你给我的是子类对象d,那么我就按照你的虚表去找你的虚函数Func1(),而父类就去找父类的。此时此刻,不就是多态的场景?这就是买票场景:给我传递学生对象,我就打印半价票,给我传递普通市民,我就打印全价票。

传指针也是一样的情况:
在这里插入图片描述

③通过赋值为Person类以后发生了什么事?

如果是父类对象,就会进入到对象里面找到父类的虚函数表,进而找到对应的虚函数,就好比上面的Func1();而如果是子类对象,此时也会去找到自己的虚函数表,但是找的是子类对象中被切片下来的父类的那一部分,也就是属于子类中的继承成员的一部分,那么它也会去寻找该表里的虚函数指针,进而调用里面的Func1()。但是此时虚函数表里的Func1()已经被重写,所以就会呈现出不同的结果。

这也是多态的形象化表达:不同的人(父子类对象),做相同的事(找父类虚函数表),得到不同的结果(Func1()已经被重写,得到不同的运行结果)

  1. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
  2. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  3. 总结一下派生类的虚表生成:

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

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

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

  1. 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

答:虚函数存在虚表,虚表存在对象中。

这些回答是错的。 但是很多人都是这样认为的。

注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。

2.3 特殊情况

如果把子类重写的虚函数设置成私有:

class Base
{
public:
	virtual void Func1()
	{}
private:
	virtual void Func1(){}//私有
	int _b = 1;
};
void test(Person& p)
{
	p.Func1();
}
int main()
{
	Base b;
	b.test();
}

上述代码依旧可以形成多态,因为重写本质是接口继承,对于父类对象来说,当对象p去调用Func1()时,通过虚函数表,看到的是父类的公有函数Func1();而对于子类对象来说也是一样。也就是说有了虚表以后,实际上这个私有是没有起作用的,因为该函数的地址都放在虚表里了,即使你是私有,我只要能找到虚表地址,那我就是可以调用到这个Func()。

3. 补充

3.1

实际上虚表里面存的并不是虚函数真正的地址。

通俗地说:在汇编层面上,有一个jmp指令,虚表存了jmp指令的地址后跳到其对应的地方,而那个地方的地址才是真正的虚函数的地址。

3.2 动态绑定与静态绑定

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

接口继承用另一句话来概括接口继承就是:形成多态以后,子类重写了父类的虚函数,那么子类中该虚函数的接口将变成父类的,但是函数实现还是子类的。

用下列例子来演示:

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

上述代码输出B->1

可以看见B类继承了A类,并且B类重写了func函数

但是由于定义的指针p是B类类型,所以此时调用test()并不构成多态,这里仅仅是发生了一个p指针(B*)到this指针(A*)的切片行为,此时的this再去调用func,才形成了多态,从而调用子类中的func。

但是为什么参数不是0?

这里是因为虚函数的重写是接口继承,其实用到前面的一个性质,子类的虚函数加不加virtual无所谓,只需要满足重写、父类指针或者引用,即可形成多态。这里的多态形成的时候压根不会去看子类是否是虚函数,接口满足就可以形成多态了,因此这里的子类其实是把父类的func接口直接继承下来了,调用的时候会造成形参列表缺省值不变,依旧是1.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

久菜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值