详细介绍:C++----多态
1.定义与条件
多态(Polymorphism) 指的是“同一操作作用于不同的对象,可以产生不同的行为”。
换句话说:在编译或运行时,同一个函数调用可能会绑定到不同的函数实现上。
在 C++ 中,多态主要分为两类:
编译时多态(静态多态)
通过 函数重载、运算符重载、模板 实现
在编译阶段决定调用哪个函数
运行时多态(动态多态)
通过 虚函数(virtual function) 实现
依赖 继承 + 虚函数 + 指针或引用调用
在运行时由虚函数表(vtable)决定调用哪个函数
实现运行时多态的条件
要实现 动态多态,C++ 有三个必要条件:
继承(Inheritance)
子类必须继承父类(基类),否则没有“重写”的语境。
虚函数(Virtual Function)
基类的函数必须用
virtual
修饰,才能支持运行时绑定。派生类最好也写上否则只会发生静态绑定(编译期决定调用哪个函数)。
指针或引用调用(Base 或 Base&)*
必须通过 基类指针或引用 来调用虚函数。
如果直接用对象调用,即使函数是虚函数,依然是静态绑定。
函数必须返回值、名字、参数都相同,也有例外。比如说协变
示例代码
#include
using namespace std;
class Animal {
public:
virtual void speak() { // 虚函数
cout speak(); // 动态多态:调用 Dog::speak()
delete p;
return 0;
}
输出:
Dog barks
这里满足了 继承 + 虚函数 + 基类指针调用,所以发生了动态多态。
总结
静态多态:编译期决定(重载、模板、inline 展开)
动态多态:运行期决定(继承 + 虚函数 + 基类指针/引用调用)
2.协变
一、协变的定义
协变主要应用在 虚函数的返回类型 上:
当派生类重写(override)基类的虚函数时,
如果返回的是 指针 或 引用,
派生类的返回类型允许是 基类返回类型的派生类。
这就是 协变返回类型(covariant return type)。
二、示例代码
#include
using namespace std;
class Animal {
public:
virtual Animal* clone() { // 返回基类指针
cout clone(); // 实际调用 Dog::clone()
delete a;
delete b;
}
三、输出结果
Clone a Dog
说明:
Animal::clone()
返回Animal*
。Dog::clone()
返回Dog*
。在 C++ 中,这是合法的,因为 Dog 是 Animal 的派生类,返回类型满足协变规则。
四、作用
更加类型安全 —— 调用
Dog::clone()
得到的就是Dog*
,不需要额外强制转换。更贴合面向对象语义 —— 克隆一条狗,当然应该得到狗,而不是“泛化成 Animal”。
⚠️ 限制:
协变只适用于 指针或引用类型 的返回值。
不能用于值类型。
甚至:
class A {};
class B : public A {}; // A 和 B 是父子关系
class X {
public:
virtual A* foo()
{
cout << "x";
A a;
return &a;
}
};
class Y : public X {
public:
B* foo() override
{
cout << "y";
B b;
return &b;
}
// ❌ 错误:返回类型和 X::foo 的 A* 没有“通过 X/Y 的继承关系”挂钩
};
void fun(X& x)
{
x.foo();
}
int main()
{
X _x;
Y _y;
fun(_x);
fun(_y);
}
//打印xy
也是可以的,不在虚函数所在的父子类也可以使用。
3.析构函数的重写
先看一段代码:
class Person {
public:
virtual void Buyticket()
{
cout Buyticket();
}
int main()
{
Person* p1 = new Person;
System(p1);
Person* p2 = new Student;
System(p2);
delete p1;
delete p2;
}
运行结果为
全价
半价
~Person()
~Person()
这就与我们的期望不符合,因为没有出现~Student()!按道理应该调用Student的析构函数释放资源。这就引出了析构函数的重写。为什么没有调用student的析构函数?因为我们知道delete p2其实是两个动作:p2->destructor() + operator delete(p2),p2因为要满足多态所以此时为Person*,所以他会去调用Person的析构函数,并且执行 operator delete(p2)将资源交给系统。这时就必须重写析构函数了,从而使得Person*可以调用自己student的析构函数。为什么析构函数可以被重写?因为析构函数的名字会在编译时被统一处理成destructor。
class Person {
public:
virtual void Buyticket()
{
cout << "全价" << endl;
}
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual void Buyticket()
{
cout << "半价" << endl;
}
virtual ~Student() { cout << "~Student()" << endl; }
};
打印:
全价
半价
~Person()
~Student()
~Person()
4.多态的原理
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
char a;
};
int main()
{
cout << sizeof(Base);
}
这段代码应该打印什么?可能会说1,因为函数不存在类中而是代码段。但是事实却相反,打印出了8,为什么呢?我们打开vs下的监视窗口:发现有一个_vfptr,大小为4个字节,再加上内存对齐,所以大小为8,这里也引出了关于多态的原理。接下来看一下写了继承类后,继承类中对象的内存分布是什么样的:
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.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
虚函数表存在了哪里?
我们知道,对象中只存在虚函数表的指针,而虚函数在代码段,那么虚函数表被存在了哪里?众说纷纭,我们来简单的做个实验检验一下:
int main()
{
int a = 0;
printf("栈:%p\n", &a);
static int s = 1;
printf("静态区:%p\n", &s);
int* c = new int;
printf("堆:%p\n", c);
const char* r = "hello world";
printf("常量区:%p\n", r);
Base b;
printf("基类虚函数表地址:%p\n", *((int*)&b));
Derive d;
printf("派生类类虚函数表地址:%p", *((int*)&d));
//这种方法只适合vs下,因为在该编译器下,对象内存中的前四个字节是虚函数表的地址
}
打印结果为:
可以发现,基类对象和派生类对象的虚函数表地址都与存储在常量区中的r相离的特别近,那么为这个程序分配的常量区内存也就那么些,所以我们有理由认为,虚函数表有很大的可能在常量区。
那么多态的原理究竟是什么呢?
class Person {
public:
virtual void Buyticket()
{
cout Buyticket();
}
int main()
{
Person* p1 = new Person;
System(p1);
Person* p2 = new Student;
System(p2);
delete p1;
delete p2;
}
观察下图的红色箭头我们看到,p是指向p1对象时,p->BuyTicket在p1的虚表中找到虚函数是Person::Buyticket。 2. 观察下图的蓝色箭头我们看到,p是指向p2对象时,p->BuyTicket在p2的虚表中找到虚函数是Student::Buyticket。 3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。在这里还要说一件事,就是同一个类的所有对象,共同使用一个虚函数表。
为什么多态的条件中,不能使用基类对象呢?这是因为当把派生类对象赋值给基类对象时,基类对象不会拷贝派生类对象虚函数表的指针!他仍然存基类虚函数地址,因此在运行时无法找到派生类的虚函数表,更不可能找到覆盖的虚函数!
原理:虚函数表(vtable)+ 虚指针(vptr)
虚函数表(vtable)
编译器为包含虚函数的类生成一张“函数地址表”。
表里存放该类所有虚函数的地址。
虚指针(vptr)
每个含虚函数的对象,内部都会有一个隐藏的指针,指向它所属类的虚函数表。
构造对象时,编译器会自动设置 vptr 指向正确的 vtable。
调用过程
当指针或引用调用虚函数时:
程序先通过对象里的 vptr 找到对应的 vtable。
在 vtable 中找到
speak
的函数地址。跳转执行。
这样就实现了 运行时动态绑定(不同对象表现出不同的行为)。
附:多继承下,派生类会产生基类个数个虚函数表,而他自己的虚函数(新的,基类中没有的)会添到第一个虚函数表中。(vs下),如果两个基类有相同的虚函数,而派生类又正好重写了该虚函数,那么,派生类中的两个虚函数表中,对应位置的虚函数均被覆盖。
5.抽象类
1. 抽象类的定义
抽象类(Abstract Class):包含至少一个 纯虚函数 的类。
纯虚函数:在类中只声明、不实现,语法是:
virtual 返回类型 函数名(参数) = 0;
一旦类里有纯虚函数,这个类就 不能直接实例化对象,除非重写其中的纯虚函数。
示例
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
virtual double area() = 0; // 纯虚函数
};
这里 Shape
就是一个 抽象类。
2. 抽象类的作用
提供统一接口(规范)
抽象类定义了“子类必须实现什么功能”。
就像一个“合同”或“模板”,所有子类都要遵守。
实现多态(接口多态)
父类指针/引用可以指向不同子类对象,调用时根据实际对象执行对应实现。
这就是面向对象编程的“多态”核心。
代码复用
抽象类不仅能定义纯虚函数,还能提供普通成员函数。
子类自动继承这些通用实现,只需要实现自己特有的部分。
3. 使用示例
#include
#include
using namespace std;
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
virtual double area() = 0; // 纯虚函数
virtual ~Shape() {} // 虚析构函数,保证多态删除安全
};
class Circle : public Shape {
double r;
public:
Circle(double radius): r(radius) {}
void draw() override { cout shapes;
shapes.push_back(new Circle(5));
shapes.push_back(new Rectangle(3, 4));
for (auto s : shapes) {
s->draw(); // 多态调用
cout area() << endl;
delete s; // 通过虚析构安全释放
}
}