【C++】深入浅出之多态(举例+详解,一文说懂)!

巴西世界杯梅西

🌟个人主页:第七序章

🌈专栏系列:C++

目录

❄️前言:

☀️一:多态概念的引入

☀️二:C++中多态的定义和实现

🌙2.1 多态的构成条件

🌙2.2 虚函数

🌙2.3 虚函数的重写

🌙2.4 虚函数重写的两个例外

🌙2.5 C++11 override 和 final

🌙2.6 重载、覆盖(重写)、隐藏(重定义)的对比

🌙2.7 相关面试题⭐

☀️三:抽象类

🌙3.1 抽象类的概念

🌙3.2 接口继承和实现继承

☀️四:多态的原理

🌙4.1 相关面试题⭐

🌙4.2虚函数表

🌙4.3 多态的原理

🌙4.3.1 多态是如何实现的

🌙4.4 动态绑定与静态绑定

🌻共勉:

❄️前言:

上一篇我们学习了C++中面向对象的第二大模块--继承,今天我们来学习一下面向对象的第三大模块--多态

☀️一:多态概念的引入

多态的概念:多态(polymorphism)是C++中面向对象编程的一个重要概念,它指的是同一种方法调用,在不同的对象上产生不同的行为。这种特性使得程序设计更加灵活,提高了代码的可扩展性和可维护性。(通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态)。

🍉例如,在现实生活当中,普通人买票是全价,学生买票是半价,而军人允许优先买票。不同身份的人去买票,所产生的行为是不同的,这就是所谓的多态。

☀️二:C++中多态的定义和实现

🌙2.1 多态的构成条件

🍉多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

🍉那么在继承中要构成多态还有两个条件 :

必须通过基类的指针或者引用调用虚函数被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

🌙2.2 虚函数

🍉虚函数:即被virtual修饰的类成员函数称为虚函数。具体如下

class Person {

public:

virtual void BuyTicket() { cout << "买票-全价" << endl;}

}

需要注意的是: 🍉只有类的非静态成员函数前可以加virtual,普通函数前不能加virtual。 虚函数这里的virtual和虚继承中的virtual是同一个关键字,但是它们之间没有任何关系。虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。

🌙2.3 虚函数的重写

🍉虚函数重写的三个必要条件:1. 函数名相同 2. 参数相同 3. 返回值类型相同(三同)

class Person {

public:

virtual void BuyTicket() { cout << "买票-全价" << endl; }

};

class Student : public Person {

public:

virtual void BuyTicket() { cout << "买票-半价" << endl; }

/*void BuyTicket() { cout << "买票-半价" << endl; }*/

};

🍉注意: 在重写基类虚函数时,派生类的虚函数不加virtual关键字也可以构成重写,主要原因是因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数属性(注意这个词哦,后面有道题这个坑)。但是这种写法不是很规范,因此建议在派生类的虚函数前也加上virtual关键字。

🌙2.4 虚函数重写的两个例外

协变(基类与派生类虚函数返回值类型不同)

🍉派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A{};

class B : public A {};

class Person {

public:

virtual A* f() {return new A;}

};

class Student : public Person {

public:

virtual B* f() {return new B;} // 返回值类型不同了属于协变

}

🍉例如,下列代码中基类Person当中的虚函数fun的返回值类型是基类A对象的指针,派生类Student当中的虚函数fun的返回值类型是派生类B对象的指针,此时也认为派生类Student的虚函数重写了基类Person的虚函数。

//基类

class A

{};

//子类

class B : public A

{};

//基类

class Person

{

public:

//返回基类A的指针

virtual A* fun()

{

cout << "A* Person::f()" << endl;

return new A;

}

};

//子类

class Student : public Person

{

public:

//返回子类B的指针

virtual B* fun()

{

cout << "B* Student::f()" << endl;

return new B;

}

};

🍉此时,我们通过父类Person的指针调用虚函数fun,父类指针若指向的是父类对象,则调用父类的虚函数,父类指针若指向的是子类对象,则调用子类的虚函数。

int main()

{

Person p;

Student st;

//父类指针指向父类对象

Person* ptr1 = &p;

//父类指针指向子类对象

Person* ptr2 = &st;

//父类指针ptr1指向的p是父类对象,调用父类的虚函数

ptr1->fun(); //A* Person::f()

//父类指针ptr2指向的st是子类对象,调用子类的虚函数

ptr2->fun(); //B* Student::f()

return 0;

}

2.析构函数的重写(基类与派生类析构函数的名字不同)

🍉如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor(原本构成隐藏)

注意:析构函数非常建议写成虚函数

class Person {

public:

virtual ~Person() {cout << "~Person()" << endl;}

};

class Student : public Person {

public: // 函数名不同

virtual ~Student() { cout << "~Student()" << endl; }

}

| 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。

int main()

{

Person* p1 = new Person;

Person* p2 = new Student;

delete p1;

delete p2;

return 0;

}

🍉在这种场景下,若是父类和子类的析构函数没有构成重写就会导致内存泄漏,因为此时delete p1和delete p2都是调用的父类的析构函数,派生类student的p指针并没有通过调用派生类的析构函数来释放空间/mark>,而我们所期望的是p1调用父类的析构函数,p2调用子类的析构函数,即我们期望的是一种多态行为。 🍉此时只有父类和子类的析构函数构成了重写,才能使得delete按照我们的预期进行析构函数的调用,才能实现多态。因此,为了避免出现这种情况,比较建议将父类的析构函数定义为虚函数。 🍉现在多态的第一个条件基类的指针或引用已经有了,还差一个重写。重写要求同名,同返回值,同参数列表,我们的析构函数只有同名不满足,所以编译器编译的时候会强制把所有析构函数的名字替换为destructort,所以基类的析构函数加了 vialtual修饰,派⽣类的析构函数就构成重写。 🍉知识扩展: 在继承当中,子类和的析构函数和父类的析构函数构成隐藏的原因就在这里,这里表面上看子类的析构函数和父类的析构函数的函数名不同,但是为了构成重写,编译后析构函数的名字会被统一处理成destructor();。

🌙2.5 C++11 override 和 final

🍉从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮 助用户检测是否重写。

🍉final:修饰虚函数,表示该虚函数不能再被重写

例如,父类Person的虚函数BuyTicket被final修饰后就不能再被重写了,子类若是重写了父类的BuyTicket函数则编译报错。

//父类

class Person

{

public:

//被final修饰,该虚函数不能再被重写

virtual void BuyTicket() final

{

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

}

};

//子类

class Student : public Person

{

public:

//重写,编译报错

virtual void BuyTicket()

{

cout << "买票-半价" << endl;

}

};

//子类

class Soldier : public Person

{

public:

//重写,编译报错

virtual void BuyTicket()

{

cout << "优先-买票" << endl;

}

};

🍉override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

例如,子类Student和Soldier的虚函数BuyTicket被override修饰,编译时就会检查子类的这两个BuyTicket函数是否重写了父类的虚函数,如果没有则会编译报错。

//父类

class Person

{

public:

virtual void BuyTicket()

{

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

}

};

//子类

class Student : public Person

{

public:

//子类完成了父类虚函数的重写,编译通过

virtual void BuyTicket() override

{

cout << "买票-半价" << endl;

}

};

//子类

class Soldier : public Person

{

public:

//子类没有完成了父类虚函数的重写,因为重写要求参数列表相同,编译报错

virtual void BuyTicket(int i) override

{

cout << "优先-买票" << endl;

}

};

🌙2.6 重载、覆盖(重写)、隐藏(重定义)的对比

🍉重写可以理解成特殊的隐藏

🌙2.7 相关面试题⭐

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;

}

1.这里派生类的指针去调用public继承下来的基类函数test, 那么test里面又去调用func, this(A*,j基类)指针调用func, 那么这里满足多态吗? 多态两个条件 🍉(1):要实现多态效果,第⼀必须是基类的指针或引用,因为只有基类的指针或引⽤才能既指向基类对象也可以指向派⽣类对象(切片);这里已经满足了(this指针(A*)基类指针调用虚函数) 🍉(2)第⼆派⽣类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派⽣类才能有不同的函数,多态的不同形态效果才能达到。这里也达到了把,func函数在基类和派生类的i函数名,返回值,形参列表完全一致,就是那个缺省参数值不一样。但是参数列表只要是缺省参数值类型和名字相同就行了,并没有规定缺省值相同, 🍉注意:在重写基类虚函数时,派⽣类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。 🍉这时候就会有的人就说,那肯定是选D了,那么这里的派生类指针去调用test传过去指向派生类对象,就去调用派生类的重写虚函数,那么应该是B->0呀,派生类里面的缺省值不是0吗,所以我们前面标注的重写相当于继承属性这个是什么属性,实际上就是把基类的虚函数的缺省值属性拿下来用,基类的虚函数缺省值是1。所以选B

注意:只有多态才是这样的结果,所以这道题也警告我们,重写虚函数构成多态的时候不要让重写虚函数的基类和派生类的缺省值不一样

🍉总结: p->test发现B里没有,就会去父类查找test, 此时调用func。注意;此时的this指针是A类里的 , 固为父类的指针指向func,func也实现了重写( 多态的两要素 )所以构成多态,特别注意的是,只有多态的时候,实现的是父类的接口(参数) + 派生类的实现, 而且继承的父类是public, 认为私有不重要仍然可以调用所以答案选择b

注意:多态不受访问限定符的影响, 继承才受,继承的重写是 “隐藏”

☀️三:抽象类

🌙3.1 抽象类的概念

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

#include

using namespace std;

//抽象类(接口类)

class Car

{

public:

//纯虚函数

virtual void Drive() = 0;

};

int main()

{

Car c; //抽象类不能实例化出对象,error

return 0;

}

🍉派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

#include

using namespace std;

//抽象类(接口类)

class Car

{

public:

//纯虚函数

virtual void Drive() = 0;

};

//派生类

class Benz : public Car

{

public:

//重写纯虚函数

virtual void Drive()

{

cout << "Benz-舒适" << endl;

}

};

//派生类

class BMV : public Car

{

public:

//重写纯虚函数

virtual void Drive()

{

cout << "BMV-操控" << endl;

}

};

int main()

{

//派生类重写了纯虚函数,可以实例化出对象

Benz b1;

BMV b2;

//不同对象用基类指针调用Drive函数,完成不同的行为

Car* p1 = &b1;

Car* p2 = &b2;

p1->Drive(); //Benz-舒适

p2->Drive(); //BMV-操控

return 0;

}

🍉那这时候就会有人说:抽象类既然不能实例化出对象,那抽象类存在的意义是什么?

抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

🌙3.2 接口继承和实现继承

实现继承:普通函数继承下来就是一个实现继承,派生类继承了基类函数的实现,可以使用该函数接口继承:虚函数的继承就是一种接口继承,派生类继承的是虚函数的接口,目的是为了完成重写,实现多态

☀️四:多态的原理

🌙4.1 相关面试题⭐

我们先做一道笔试题:Base类实例化出对象的大小是多少?

class Base

{

public:

virtual void Func1()

{

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

}

private:

int _b = 1;

};

通过观察测试,我们发现Base类实例化的对象b的大小是8个字节。(内存对齐)b对象当中除了_b成员外,实际上还有一个_vfptr放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。

对象中的这个指针叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少有一个虚表指针。

🍉那虚函数表中到底放的是什么?

下面Base类当中有三个成员函数,其中Func1和Func2是虚函数,Func3是普通成员函数,子类Derive当中仅对父类的Func1函数进行了重写。

#include

using namespace std;

//父类

class Base

{

public:

//虚函数

virtual void Func1()

{

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

}

//虚函数

virtual void Func2()

{

cout << "Base::Func2()" << endl;

}

//普通成员函数

void Func3()

{

cout << "Base::Func3()" << endl;

}

private:

int _b = 1;

};

//子类

class Derive : public Base

{

public:

//重写虚函数Func1

virtual void Func1()

{

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

}

private:

int _d = 2;

};

int main()

{

Base b;

Derive d;

return 0;

}

🍉通过调试可以看到,父类和子类对象都有一个虚表指针,分别指向属于自己的虚表。

🍉实际上虚表当中存储的就是虚函数的地址,因为父类当中的Func1和Func2都是虚函数,所以父类对象b的虚表当中存储的就是虚函数Func1和Func2的地址。(注意虚函数表只会存储虚函数的地址,普通函数的地址并不会存储) 🍉而子类继承了基类的3个函数,其中有两个虚函数Func1和Func2,但是子类对父类的虚函数Func1进行了重写,可以看到重写后的Func1在子类中的地址和在父类中Func1的地址是不一样的,而在子类中没有重写的Func2地址跟在父类中的Func2的地址一样,这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。

🍉总结一下,派生类的虚表生成步骤如下:

把基类中的虚函数表的虚函数copy一份放在子类的虚函数表中如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。

虚表是什么阶段初始化的?虚函数存在哪里?虚表存在哪里?

int j = 0;

int main()

{

Base b;

Base* p = &b;

printf("vfptr:%p\n", *((int*)p)); //000FDCAC

int i = 0;

printf("栈上地址:%p\n", &i); //005CFE24

printf("数据段地址:%p\n", &j); //0010038C

int* k = new int;

printf("堆上地址:%p\n", k); //00A6CA00

char* cp = "hello world";

printf("代码段地址:%p\n", cp); //000FDCB4

return 0;

}

代码当中打印了对象b当中的虚表指针,也就是虚表的地址,可以发现虚表地址与代码段的地址非常接近,由此我们可以得出虚表实际上是存在代码段的。

🥝 :理解 继承的重写 和 多态的重写!!

#include

#include

class A

{

public:

void virtual func(){ std::cout<<"A->" <

};

class B : public A

{

public:

void func(){ std::cout<<"B->" <

};

int main(int argc ,char* argv[])

{

B*p = new B;

p->func(); // 重写 隐藏

A* pa = new A;

pa->func();

p->A::func(); //继承显示调用

A* paa = new B;

paa->func(); // 多态

return 0;

}

🌙4.2虚函数表

| 这里常考一道笔试题:sizeof(Base)是多少?

class Base

{

public:

virtual void Func1()

{

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

}

private:

int _b = 1;

}

🍉通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?

我们接着往下分析:

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

我们增加一个派生类Derive去继承BaseDerive中重写Func1Base再增加一个虚函数Func2和一个普通函数Func3

class Base

{

public:

virtual void Func1()

{

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

}

virtual void Func2()

{

cout << "Base::Func2()" << endl;

}

void Func3()

{

cout << "Base::Func3()" << endl;

}

private:

int _b = 1;

};

class Derive : public Base

{

public:

virtual void Func1()

{

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

}

private:

int _d = 2;

};

int main()

{

Base b;

Derive d;

return 0;

}

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

🍉派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。 🍉基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。 🍉另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。 🍉虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。 🍉总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。 🍉这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家可以自己去验证?

🌙4.3 多态的原理

🍉看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的,也动态绑定与静态绑定 ,这就是多态的原理

🌙4.3.1 多态是如何实现的

🍉

从底层的角度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调Person::BuyTicket,ptr指向Student对象调⽤Student::BuyTicket的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调⽤对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调⽤基类的虚函数,指向派生类就调用派生类对应的虚函数。第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第⼆张图,ptr指向的Student对象,调用的是Student的虚函数。

class Person {

public:

virtual void BuyTicket() { cout << "买票-全价" << endl; }

};

class Student : public Person {

public:

virtual void BuyTicket() { cout << "买票-打折" << endl; }

};

class Soldier : public Person {

public:

virtual void BuyTicket() { cout << "买票-优先" << endl; }

};

void Func(Person* ptr)

{

// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket

// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。

ptr->BuyTicket();

}

int main()

{

// 其次多态不仅仅发⽣在派⽣类对象之间,多个派⽣类继承基类,重写虚函数后

// 多态也会发⽣在多个派⽣类之间。

Person ps;

Student st;

Soldier sr;

Func(&ps);

Func(&st);

Func(&sr);

return 0;

}

🌙4.4 动态绑定与静态绑定

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

🌻共勉:

以上就是本篇博客的所有内容,如果你觉得这篇博客对你有帮助的话,可以点赞收藏关注支持一波~~🥝

Thinkpad T470 内置电池问题
胡莱三国马超实力霸气,打造最强战力!