C++:多态

HZzzzzLu 2024-09-19 12:35:01 阅读 94

目录

一.多态的概念

二.多态的定义及其实现

1.虚函数

2.虚函数的重写/覆盖

3.实现多态的条件

 4.虚函数重写的例外

5.析构函数的重写

6.经典例题

7.C++11 override和final关键字

8.重载、重写/覆盖、隐藏的区别

三.抽象类

四.多态的原理

1.虚函数表指针

2.多态如何实现

3.动态绑定与静态绑定

4.虚函数表

5.有关虚函数和虚函数表的存储位置

五.常见问题


一.多态的概念

多态(polymorphism):简单来说,就是多种形态。

多态可以分为编译时多态(静态多态)运行时多态(动态多态)。

编译时多态主要就是函数重载函数模板,传递不同类型的参数调用不同的函数,通过参数不同达到多种形态。至于叫做编译时多态的原因,是它们实参传给形参的参数匹配是在编译时完成的。

我们主要介绍运行时多态。

运行时多态,就是具体完成某个行为(函数),传递不同的对象就会完成不同的行为,从而达到多种形态。例如买票这个行为,普通人买票就是全价;学生买票就是优惠价;军人买票就是优先买票。再或者,对于一个动物叫的行为(函数),传猫对象过去就是喵喵喵,传狗对象过去,就是汪汪汪。

二.多态的定义及其实现

1.虚函数

虚函数就是类成员函数前加virtual修饰。注意非成员函数不能加virtual修饰

<code>class Person

{

public:

virtual void Buyticket()

{

cout << "全价买票" << endl;

}

};

2.虚函数的重写/覆盖

虚函数的重写/覆盖:子类中有一个跟父类完全相同的虚函数,这里的”完全相同”指的是两个虚函数的返回类型、函数名、参数列表(参数类型与个数)完全相同,这样则称子类的虚函数重写了父类的虚函数。

注意:虚函数的重写是对函数实现部分的重写。

class Person

{

public:

virtual void BuyTicket()

{

cout << "全价买票" << endl;

}

};

class Student : public Person {

public:

virtual void BuyTicket()

{

cout << "打折买票" << endl;

}

};

子类Student的BuyTicket函数重写了父类Person的BuyTicket函数。

注意:在重写父类虚函数时,子类的虚函数可以不加virtual关键字,这样也构成重写。能这样做的原因在于父类的虚函数被子类继承下来后依旧保留了虚函数的属性,但是这种写法不规范,不建议写,不过考试会埋这样的坑。

3.实现多态的条件

多态的实现效果:

<code>class Person

{

public:

virtual void BuyTicket()

{

cout << "全价买票" << endl;

}

};

class Student : public Person {

public:

void BuyTicket()

{

cout << "打折买票" << endl;

}

};

void Func(Person* ptr)

{

ptr->BuyTicket();

}

int main()

{

Person ps;

Student st;

Func(&ps);

Func(&st);

return 0;

}

 

 想要实现多态必须满足两个条件。

必须是父类的指针或者引用。

因为只有是父类的指针或者引用才可以既能指向父类对象,又能指向子类对象

简单来说,在传递不同对象时,只有父类的指针或者引用才能接收子类对象或者父类对象。

而父类指针或者引用有这样的功能是通过子类切片去实现的。

子类必须对父类的虚函数进行重写/覆盖。

 4.虚函数重写的例外

子类重写父类虚函数时,子类虚函数返回类型可以与父类虚函数返回类型不同,称为协变。

这里类型不同指的是,父类虚函数返回父类对象的指针或者引用子类虚函数返回子类对象的指针或者引用

<code>class A {};

class B : public A{};

class Person

{

public:

virtual A* BuyTicket()

{

cout << "全价买票" << endl;

return nullptr;

}

};

class Student : public Person {

public:

virtual B* BuyTicket()

{

cout << "打折买票" << endl;

return nullptr;

}

};

void Func(Person* ptr)

{

ptr->BuyTicket();

}

int main()

{

Person ps;

Student st;

Func(&ps);

Func(&st);

return 0;

}

这里的A *可以替换成Person*,B*可以替换成Student*。

5.析构函数的重写

我们首先要了解析构函数的名字看起来不同,但实际上会被编译器统一处理成destructor,所以析构函数的名字实际上都是destructor

 当父类的析构函数为虚函数,此时子类析构函数只要定义,无论加不加virtual关键字,都与父类的析构函数构函数构成重写。原因就在于析构函数的名字实际上都是一样的。

我们来观察下面这段代码。

<code>class A

{

public:

virtual ~A()

{

cout << "~A" << endl;

}

};

class B : public A

{

public:

~B()

{

cout << "~B->delete:" << _ptr << endl;

delete[] _ptr;

}

protected:

int* _ptr = new int[20];

};

int main()

{

A* p1 = new A;

A* p2 = new B;

delete p1;

delete p2;

return 0;

}

(delete的原理类似于先调用各自的析构函数,再去free)

这里调用析构函数实现了多态的效果,只有实现多态,才能正确调用析构函数,父类A调用~A(),

子类B调用~B(),如果不正确调用析构函数,就会有资源泄漏的风险。

这里想要实现多态,就要对子类的析构函数进行重写,想要进行析构函数的重写,那析构函数的名字就必须被编译器统一处理成destructor来达成重写的条件。

这也回答了为什么父类的析构函数建议设计成虚函数,如果不设计成虚函数,那又谈何虚函数的重写,也就无法实现析构函数的多态。

6.经典例题

<code>class A

{

public:

virtual void func(int val = 1) { cout << "A->" << val <<endl; }

virtual void test() { func(); }

};

class B : public A

{

public:

void func(int val = 0) { cout << "B->" << val << endl; }

};

int main()

{

B* p = new B;

p->test();

return 0;

}

以上程序输出结果是什么()

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

这里子类B中的func函数重写了父类A中的func函数。

首先B*类型的指针p调用了继承父类B的test()函数,test()函数是有个A*类型隐式this指针,这里就是父类A的指针接收了子类B对象。test()函数又调用了func函数,func函数满足虚函数的重写,这里就达成了多态实现的条件,所以应该调用的是B类中的func函数,打印B->0。

但事实上,这是大错特错的,这道题坑就坑在虚函数的重写是重写函数的实现部分。子类B继承父类A的接口声明( virtual void func(int va1=1) ),然后重写函数的实现部分({ std::cout << "B->" << val << std::endl; }),重写后的func函数变成了下面这样,缺省值用的是1,打印B->1。

virtual void func(int val = 1)

{

cout << "B->" << val << endl;

}

打个不恰当的比喻,虚函数的重写就是把一个人的头(函数声明)接到了另一个人的身子上(函数实现)。 

7.C++11 override和final关键字

1.override

只能修饰子类的虚函数(放在函数参数列表的后面),用于检测该虚函数是否完成重写,如果没有,则报错(编译错误)。

2.final

如果不想让子类重写父类虚函数,则我们用final修饰。

final用于修饰父类的虚函数。

如果final修饰了一个类,那这个类就不能被其他类继承。

8.重载、重写/覆盖、隐藏的区别

三.抽象类

在虚函数后面加上 =0,这个虚函数就是纯虚函数。纯虚函数不需要定义只要需要声明就行了。

纯虚函数也是实现具体的函数部分,但是没有意义,因为纯虚函数实现部分会被重写,父类不能实例出对象,也无法调用纯虚函数。(说纯虚函数不能定义是错误的)

含有纯虚函数的类叫做抽象类,抽象类不能实例化出对象。当子类继承抽象类后不重写虚函数那么子类也是抽象类。所以纯虚函数在某种程度上强制要求子类重写虚函数,因为不重写子类实例不出具体的对象。

四.多态的原理

1.虚函数表指针

我们先来看一道题

下面编译为32位程序的运行结果是什么()

A. 编译报错 B. 运⾏报错 C. 8 D. 12

<code>class Base

{

public:

virtual void Func1()

{

cout << "Func1()" << endl;

}

protected:

int _b = 1;

char _ch = 'x';

};

int main()

{

Base b;

cout << sizeof(b) << endl;

return 0;

}

按照我们以前所学的知识,b的大小应该是8字节,但实际上程序运行的结果是12字节。

Base类除了有_b和_ch成员,还有一个虚函数表指针_vfptr放在前面(有些平台可能会放到后面,这跟平台有关)。这个_vfptr指针,v代表virtual,f代表function。所以结果应该是8+4=12字节。

一个含有虚函数的类中至少有一个虚函数表指针,一个类所有的虚函数的地址都要放到这个类对象的虚函数表中,虚函数表也叫做虚表。

2.多态如何实现

我们以刚才买票的例子来说明多态是如何实现的。

通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址而是运行时到指向的对象的虚函数表中确定对应的虚函数的地址,这样就是实现了父类指针或引用指向父类就调用父类的虚函数,指向子类就调用子类对应的虚函数。

由上图我们知道,虽然都是Person类型的指针Ptr在调用BuyTicket,但是跟Ptr的类型无关,调用的BuyTicket函数跟Ptr指向的类型有关,Ptr指向Person类对象调用Person的BuyTicket(全价买票),Ptr指向Student类对象调用Student的BuyTicket(打折买票)。

3.动态绑定与静态绑定

对不满足多态条件(指针+引用调用虚函数)的函数调用是在编译时绑定调用函数的地址,叫做静态绑定

满足多态条件的函数调用是在运行时绑定函数调用的地址,也就是运行时到指向对象的虚函数表中找到调用函数的地址,叫做动态绑定。

我们从汇编层面观察两者的区别。

动态绑定,编译在运行到ptr指向对象的虚函数表中确定函数地址。

静态绑定,编译器直接确定调用函数地址。

4.虚函数

下面我们来详细介绍虚函数表。

同一类类型的对象共享同一张虚函数表,不同类类型的对象的虚函数表则不一样,具体点就是不同类对象的虚函数表指针不一样。基类对象的虚函数表中存放基类所有的虚函数的地址。

首先派生类由两部分构成,继承下来的的基类和自己的成员,一般情况下,如果继承下来的基类有虚函数表指针,那派生类就不会生成虚函数表指针。但要注意继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立存在一样。派生类重写的基类的虚函数派生类的虚函数表中对应的虚函数被覆盖成派生类重写的虚函数地址。派生类的虚函数表包含,基类的虚函数地址、派生类重写的虚函数地址、派生类自己的虚函数地址三部分。虚函数表本质是一个函数指针数组,一般情况这个数组在后面放一个0x00000000作为结束标志。(不过这个C++规定这个,取决于各个编译器)

5.有关虚函数和虚函数表的存储位置

虚函数和普通函数一样,编译好后是一段指令,存在代码段(常量区),只是虚函数的地址又存在虚函数表中。

虚函数表的存储位置C++标准中没有规定,取决于不同编译器。在VS中,虚函数表是存储在常量区的。

下面这段代码可以验证下在VS中虚函数表是存储在常量区的。

<code>class Base {

public:

virtual void func1() { cout << "Base::func1" << endl; }

virtual void func2() { cout << "Base::func2" << endl; }

void func5() { cout << "Base::func5" << endl; }

protected:

int a = 1;

};

class Derive : public Base

{

public:

// 重写基类的func1

virtual void func1() { cout << "Derive::func1" << endl; }

virtual void func3() { cout << "Derive::func1" << endl; }

void func4() { cout << "Derive::func4" << endl; }

protected:

int b = 2;

};

int main()

{

int i = 0;

static int j = 1;

int* p1 = new int;

const char* p2 = "xxxxxxxx";

printf("栈:%p\n", &i);

printf("静态区:%p\n", &j);

printf("堆:%p\n", p1);

printf("常量区:%p\n", p2);

Base b;

Derive d;

Base* p3 = &b;

Derive* p4 = &d;

printf("Person虚表地址:%p\n", *(int*)p3);

printf("Student虚表地址:%p\n", *(int*)p4);

printf("虚函数地址:%p\n", &Base::func1);

printf("普通函数地址:%p\n", &Base::func5);

return 0;

}

        

五.常见问题

1. 什么是多态?

答:多态分为静态多态:函数重载;和动态多态:继承中的虚函数重写+基类指针引用。

2. 什么是重载、重写 ( 覆盖 ) 、重定义 ( 隐藏 ) ?

答:参考上述内容。

3. 多态的实现原理?

答:静态多态:函数名修饰规则;动态多态:虚函数表。

4. inline 函数可以是虚函数吗?

答:可以,不过编译器就忽略 inline 属性,这个函数就不再是inline,因为虚函数要放到虚表中去。

5. 静态成员可以是虚函数吗?

答:不能。静态成员函数属于类本身,而不是类的某个特定对象。它可以通过类名直接调用,不需要类的实例。虚函数是为了实现多态性,允许在运行时根据对象的实际类型调用相应的函数。虚函数需要依赖于对象的动态类型,而静态成员函数不依赖于任何对象的类型。两者的这种特性有所冲突,所以禁止将静态成员函数声明为虚函数。

6. 构造函数可以是虚函数吗?

答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

答:可以,并且最好把基类的析构函数定义成虚函数。参考上述内容。

8. 对象访问普通函数快还是虚函数更快?

答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

9. 虚函数表是在什么阶段生成的,存在哪的?

答:虚函数表是在编译阶段就生成的,一般情况下存在代码段( 常量区 ) 的。

10. C++ 菱形继承的问题?虚继承的原理?

答:参考继承。注意这里不要把虚函数表和虚基表搞混了。

11. 什么是抽象类?抽象类的作用?

答:参考上述内容。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。


拜拜,下期再见😏

摸鱼ing😴✨🎞

 



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。