📌 个人主页: 孙同学_
🔧 文章专栏: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.2.3 虚函数表
1. 多态的概念
多态(polymorphism)的概念:通俗来说就是指多种形态,多态分为编译时多态(静态多态)和运行时多态(动态多态)。编译时多态(静态多态)主要是前面的函数重载和函数模板,它们传不同类型的参数就可以调用不同类型的函数,通过参数不同达到多种形态,之所以叫做编译时多态,是因为它们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),传不同的对象就可以完成不同的行为,就达到多种形态。就如买票这个行为,当普通人买票时是全价买票,当学生买票时是学生票,军人买票时是优先买票。再比如,同样是动物叫的一个行为,传“猫”过去就是“喵喵”,传“狗”过去就是“汪汪”。
2. 多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系下的类对象,去调用同一函数产生了不同行为。比如Student
继承了Person
。Person
对象买票全价,Student
对象优惠买票。
2.1.1实现多态还有两个必须重要条件:
- 必须是基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:要实现多态的效果,第一必须是基类的指针或者引用,因为只有基类的指针或引用才能既指向基类的对象,又能指向派生类的对象。第二派生类必须对基类的虚函数完成重写/覆盖,重写/覆盖了基类和派生类之间才能有不同的函数,多态的不同形态效果才能达成。
2.1.2 虚函数
类成员函数前面加virtual
修饰,那么这个成员函数被称为虚函数。❗️注意:非成员函数不能加virtual
修饰。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型,函数名字,参数列表完全相同),成派生类的虚函数重写了基类的虚函数。
注意:再重写基类虚函数时,派生类的虚函数在不加
virtual
关键字时,虽然也可以构成重写,因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数的属性),但是该种写法不是很规范,不建议这样使用。在考试题中会经常埋这样一个坑,让我们判断是否构成多态。
//指针
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : 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;
Func(&ps);
Func(&st);
return 0;
}
//引用
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : 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;
Func(ps);
Func(st);
return 0;
}
class Animal
{
public:
virtual void talk() const
{}
};
class Dog : public Animal
{
public:
virtual void talk() const
{
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void talk() const
{
std::cout << "(>^ω^<)喵" << std::endl;
}
};
void letsHear(const Animal& animal)
{
animal.talk();
}
int main()
{
Cat cat;
Dog dog;
letsHear(cat);
letsHear(dog);
return 0;
}
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(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
这里由this
调用func
,构成多态必须是父类的指针或者引用,这里是否构成多态呢?这里的this
是A*
,所以它是一个父类的指针。
继承它会把父类的成员拿下来是一种形象的说法,其实不会把它拿下来,它的复用是先去B
里面去找有没有test
,再去A
里面找,如果A
里面还找不到就会报错。复用的本质先去B
里面找,再去A
里面找。包括成员变量,如果A
里面有一个_a
,B
里面有一个_b
,它不是把A
里面的拷贝下来在B
里面也生成一份,是B
对象生成的时候会先生成一个A
对象的编译器,在这个对象模型在内存里面放的时候再放B
的成员,不会说是把分类的成员函数和成员对象都拷贝一份下来。
所以说第一个条件是满足的。
第二个条件是虚函数的重写,也就是func
,func
的函数名,参数类型,返回值都相同,它是父类的重写,所以也满足是虚函数。
所以是满足多态的,满足多态是指向谁调用谁,p
传给了this
,p
是指向一个派生类的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
,所以基类的析构函数加了virtual
修饰,派生类的析构函数就构成了重写。
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;
}
2.1.6 override和final关键字
C++对虚函数的重写要求比较严格,但是有些情况写由于疏忽,比如函数名写错或者是参数写错导致无法构成重写,而这种错误是在编译期间是不会报错的,只有在程序运行时没有得到预期结果,再进行找错误就会得不偿失,因此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
,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义,因为要被派生类重写,但语法上可以实现),只要声明即可。包含纯虚函数的类称为抽象类,抽象类不能实例化出对象,如果派生类继承后不重写虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类必须重写虚函数,因为不重写实例化不出对象。
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;
}
4.2 多态的原理
4.2.1 多态是如何实现的
从底层的角度Func
函数中ptr->BuyTicket()
,是如何作为ptr
指向Person
对象调用Person::BuyTicket
,ptr
指向Student
对象调用Student::BuyTicket
的呢?通过下图我们可以看到满足多态条件后,底层不再是编译时,通过调用函数对象确定函数地址,而是运行时到指向对象的虚表中,确定对应的虚函数的地址。
第一张图,ptr
指向Person
对象,调用的是Person
的虚函数;第二张图ptr
指向的是Student
对象,调用的是Student
的虚函数。
多态:指向谁调用谁的虚函数
指向父类,运行时到指向父类对象的虚函数表中找到对应的虚函数进行调用
指向子类,运行时到指向子类对象切片出的父类的虚函数表中找到对应的虚函数进行调用。
4.2.2 动态绑定与静态绑定
- 对不满足多态条件(指针或者引用 + 调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
- 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中,找到调用函数的地址,叫做动态绑定。
// ptr是指针+BuyTicket是虚函数满⾜多态条件。
// 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址
ptr->BuyTicket();
00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax
// BuyTicket不是虚函数,不满⾜多态条件。
// 这⾥就是静态绑定,编译器直接确定调⽤函数地址
ptr->BuyTicket();
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student (0EA153Ch)
4.2.3 虚函数表
- 基类对象的虚函数表中存放,同类型的对象公用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类各自有独立的虚表。
- 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不再生成虚函数表指针。但是要注意的是这里继承下来的虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员,和派生类继承下来的基类对象的成员也是独立的。
- 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
- 派生类的虚函数表中包括:(1)基类的虚函数地址(2)派生类重写的虚函数地址完成覆盖(3)派生类自己的虚函数地址
- 虚函数表本质上是一个存储虚函数指针的指针数组,一般情况下这个指针数组最后面放了一个
0x00000000
标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后⾯放个0x00000000标记,g++系列编译不会放) - 虚函数存在哪的?虚函数和普通函数一样,编译好是一段指令,都是存放在代码段的,只是虚函数的地址又存在虚表中。
- 虚函数表存放在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证⼀下。vs下是存在代码段(常量区)
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("Base虚表地址:%p\n", *(int*)p3);
printf("Derive虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
👍 如果对你有帮助,欢迎:
- 点赞 ⭐️
- 收藏 📌
- 关注 🔔