C++从入门到起飞之——多态 全方位剖析!
🌈个人主页:秋风起,再归来~
🔥系列专栏:C++从入门到起飞
🔖克心守己,律己则安
目录
1. 多态的概念
2. 多态的定义及实现
2.1 多态的构成条件
2.1.1 实现多态还有两个必须重要条件:
2.1.2 虚函数
2.1.3 虚函数的重写/覆盖
2.1.4 多态场景的⼀个选择题
2.1.5 虚函数重写的⼀些其他问题
2.1.6 override和final关键字
2.1.7 重载/重写/隐藏的对⽐
3. 纯虚函数和抽象类
4. 多态的原理
4.1 虚函数表指针
4.2 多态的原理
4.2.1 多态是如何实现的
4.2.2 动态绑定与静态绑定
4.3 虚函数表
5、完结散花
1. 多态的概念
多态(polymorphism)的概念:通俗来说,就是多种形态。
多态分为编译时多态(静态多态)和运⾏时多 态(动态多态)。
编译时多态(静态多态)主要就是我们前⾯讲的函数重载和函数模板,他们传不同类型的参数就可以调⽤不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运⾏时归为动态。
运⾏时多态,具体点就是去完成某个⾏为(函数),可以传不同的对象就会完成不同的⾏为,就达到多种形态。⽐如买票这个⾏为,当普通⼈买票时,是全价买票;学⽣买票时,是优惠买票(5折或75折);军 ⼈买票时是优先买票。再⽐如,同样是动物叫的⼀个⾏为(函数),传猫对象过去,就是”喵喵“,传狗对象过去,就是"汪汪"。
2. 多态的定义及实现
2.1 多态的构成条件
多态是⼀个继承关系的下的类对象,去调⽤同⼀函数,产⽣了不同的⾏为。⽐如Student继承了 Person。Person对象买票全价,Student对象优惠买票。
2.1.1 实现多态还有两个必须重要条件:
• 必须基类指针或者引⽤调⽤虚函数
• 被调⽤的函数必须是虚函数。
说明:要实现多态效果,第⼀必须是基类的指针或引⽤调用虚函数,因为只有基类的指针或引⽤才能既指向基类对象,又指向派⽣类对象;第⼆派⽣类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派⽣类才能有不同的函数,多 态的不同形态效果才能达到。
这里我先用代码来感受一下运行时多态:
//父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "普通人买票全价!" << endl;
}
protected:
};
//学生类(子类)
class Student:public Person
{
public:
virtual void BuyTicket()
{
cout << "学生买票半价!" << endl;
}
protected:
};
void fun( Person& p)
{
p.BuyTicket();
}
//void fun(Person* p)
//{
// p->BuyTicket();
//}
int main()
{
Person p1;
Student s1;
fun(p1);
fun(s1);
return 0;
}
我们可以看到上面这段程序,我们通过传递不同的类型的对象,调用同一个函数(执行同一种行为),但是他们所表现出来的结果却是不同的。这就是动态多态(运行时多态)!
2.1.2 虚函数
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修 饰。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
注意:这里用关键字virtual修饰成员函数和之前讲的继承时用virtual修饰从而达到虚继承这两者是没有任何关联的!
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派⽣类中有⼀个跟基类完全相同的虚函数(即派⽣类虚函数与基类虚函数的返回值 类型、函数名字、参数列表完全相同),称派⽣类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派⽣类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样 使⽤,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
2.1.4 多态场景的⼀个选择题
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E:编译出错 F:以上都不正确
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()
{
B* p = new B;
p->test();
return 0;
}
详解: 首先我们来思考一下,p在调用test函数时有没有构成多态。我们先来看看在继承体系下,有没有实现多态的前提条件,即子类是否完成了父类虚函数的重写!虽然子类的函数没有用virtual修饰,但这并不重要,重要的是子类里面的缺省值和父类并不相同,那这构不构成重写呢?我们还是回到重写的定义三同(返回值类型相同,函数名相同,参数列表相同)注意,我们所说的参数列表形同是指形参的类型、顺序和个数相同!与形参的缺省值是否相同并没有关系,甚至和形参名是否相同也没有关系!
所以这里得出结论,这里具有实现多态的前提条件。
那我们再来看看,以上程序是否使用了基类的指针或引用调用虚函数!
我们new了一个子类对象,然后再用子类的指针去调用test函数,我们知道test函数是被子类从父类继承下来的。之前我们学了继承都知道,(子类继承父类后,会把父类的所有成员继承下来)这是形象的说法,实际上子类真的会把父类的成员函数拷贝一份放到子类当中吗?当然不会!我们在调用子类继承下来的成员函数时,实际上是去父类当中调用相应的函数(如果没有构成隐藏)。
而父类的test函数中隐藏了形参(A* this)是父类的指针!this->fun();即满足了基类的指针或者引用调用虚函数这个条件!
综上:p在调用test函数时有构成多态!
那这个题目的答案是不是B->0呢? 我们运行一下看看结果:
然而结果却出乎我们的意料!怎么回事呢?
这里我就要再讲一下重写的本质了: 重写的本质是对父类虚函数的函数体的重写!
说明:在继承体系下,子类的成员函数一旦对父类的虚函数构成重写(满足三同),那么子类的成员函数除函数体外其他内容(子类缺省值失效)被父类完全覆盖!这也说明了为什么重写(重写函数体)又叫做覆盖!这也解释了为什么在子类中成员函数不用加virtual依然构成重写!因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性。
再思考一下以下程序的结果:
int main()
{
A* p = new B;
p->test();
return 0;
}
p调用的不应该是A类的func吗,即使构成多态,不应该是不同类型的对象调用不同的函数吗?为什么这里还是调到了B类重写的函数。这和继承当中讲的父类和派生类间的转换有关:当我们把子类对象赋值给父类的指针或者引用时,这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切出来,父类的指针或者引用指向的是子类中切出来的基类的那部分。(这也进一步解释了:要实现多态效果,第⼀必须是基类的指针或引⽤调用虚函数,因为只有基类的指针或引⽤才能既指向基类对象,又指向派⽣类对象)
所以这时A*类型的p还是指向B类型的对象,指向谁调用谁,所以我们还是调用到了B!
2.1.5 虚函数重写的⼀些其他问题
• 协变(了解)
派⽣类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引 ⽤,派⽣类虚函数返回派⽣类对象的指针或者引⽤时,称为协变。协变的实际意义并不⼤,所以我们 了解⼀下即可。
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;
}
• 析构函数的重写
基类的析构函数为虚函数,此时派⽣类析构函数只要定义,⽆论是否加virtual关键字,都与基类的析 构函数构成重写,虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析 构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派⽣类的析构函数就构成重写。
注意:这个问题⾯试中经常考察,⼤家⼀定要结合类似下⾯的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
// 只有派⽣类Student的析构函数重写了Person的析构函数,下⾯的delete对象调⽤析构函数,才能
//构成多态,才能保证p1和p2指向的对象正确的调⽤析构函数。
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
如果我们在父类当中的析构函数中不加比virtual,则子类和父类的析构函数不构成重写!所以我们在析构p2时并不会发生多态,从而导致B部分无法完成析构!如果B中有资源的申请,还会发生内存泄漏的问题!
2.1.6 override和final关键字
从上⾯可以看出,C++对函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错参数写 错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的,只有在程序运⾏时没有得到预期结果 才来debug会得不偿失,因此C++11提供了override,可以帮助⽤⼾检测是否重写。如果我们不想让派 ⽣类重写这个虚函数,那么可以⽤final去修饰。
// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法
class Car {
public:
virtual void Dirve()
{}
};
class Benz :public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{
return 0;
}
// error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{
return 0;
}
2.1.7 重载/重写/隐藏的对⽐
注意:这个概念对⽐经常考,⼤家得理解记忆⼀下
3. 纯虚函数和抽象类
在虚函数的后⾯写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被 派⽣类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例 化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派⽣类重写虚函数,因为不重写实例化不出对象。
class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
int main()
{
// 编译报错:error C2259: “Car”: ⽆法实例化抽象类
Car car;
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
4. 多态的原理
4.1 虚函数表指针
下⾯编译为32位程序的运⾏结果是什么()
A.编译报错 B.运⾏报错 C.8 D.12
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;
}
上⾯题⽬运⾏结果12bytes,除了_b和_ch成员,还多⼀个__vfptr放在对象的前⾯(注意有些平台可能 会放到对象的最后⾯,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代 表function)。⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针(简称虚表指针),因为⼀个类所有虚函数的地址要 被放到这个类对象的虚函数表中,虚函数表也简称虚表。
虚函数表实际上就是存储虚函数地址的一个数组,也就是函数指针数组!
4.2 多态的原理
4.2.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.2.2 动态绑定与静态绑定
• 对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。
• 满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。
4.3 虚函数表
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()
{
Base b;
Derive d;
return 0;
}
如下所有演示都以上面代码显示:
• 基类对象的虚函数表中存放基类所有虚函数的地址。
• 派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴ 的。
• 派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。
• 派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地址三个部分。
• 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标 记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000 标记,g++系列编译不会放)
• 虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中。
• 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以 对⽐验证⼀下。vs下是存在代码段(常量区)
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", (void*)*(int*)p3);
printf("Student虚表地址:%p\n", (void*)*(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n",&Base::func5);
return 0;
}
下面我们再来验证一下在Linux下虚函数表在哪里?
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",*(double*)p3);
printf("Student虚表地址:%p\n", *(double*)p4);
printf("虚函数地址:%p\n", (void*)&Base::func1);
printf("普通函数地址:%p\n",(void*)&Base::func5);
return 0;
}
注意:Linux下默认是64位,指针大小为8个字节,所以我们要把int换位double取8个字节!
我们观察可以看到Linux(Ubuntu)下的虚表地址是在堆区上的!
5、完结散花
好了,这期的分享到这里就结束了~
如果这篇博客对你有帮助的话,可以用你们的小手指点一个免费的赞并收藏起来哟~
如果期待博主下期内容的话,可以点点关注,避免找不到我了呢~
我们下期不见不散~~