剖析C++中的多态
文章目录
前言
C++的多态性是面向对象编程的精髓之一,它允许我们通过基类的指针或引用,来操作不同派生类的对象,从而使得同一操作可以作用于不同的对象上,产生不同的效果。这种能力不仅增强了代码的可重用性,也为设计灵活且易于扩展的系统提供了可能。在本文中,我们将探索C++多态性的深层次概念,包括虚函数、继承、final
和override
关键字,以及虚函数表和虚表指针。通过具体的代码示例,我们将揭示这些概念背后的工作原理,并展示它们如何在实际编程中发挥作用。
注意:本文章全程环境选定 VS2022编译器 Debug x86模式
一、理解多态性
在C++中,多态性是一种允许不同类的对象对同一消息做出不同响应的能力。它使得我们可以使用统一的接口来操作不同的对象,并且在运行时确定具体调用哪个类的哪个方法。C++中的多态性主要有两种类型:编译时多态和运行时多态。
- 编译时多态:也称为静态多态,主要通过函数重载和运算符重载实现。这种多态性在编译时就已经确定,不会在运行时改变。
- 运行时多态:也称为动态多态,是通过虚函数实现的。虚函数允许派生类重写基类中定义的方法。当通过基类的指针或引用调用虚函数时,会根据对象的实际类型来调用相应的函数,这个过程称为动态绑定或晚绑定。
以下是虚函数实现运行时多态的一个简单示例:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void display() {
cout << "Display Base class" << endl;
}
};
class Derived : public Base
{
public:
void display() override {
cout << "Display Derived class" << endl;
}
};
void function(Base* base)
{
base->display(); // 运行时多态
}
int main()
{
Base b;
Derived d;
function(&b); // 输出 "Display Base class"
function(&d); // 输出 "Display Derived class"
return 0;
}
在这个例子中,Base
类有一个虚函数 display
,而 Derived
类重写了这个函数。在 main
函数中,我们通过基类指针调用 display
函数。由于 display
是虚函数,所以调用哪个版本的函数取决于指针实际指向的对象类型,这就是运行时多态的体现。
二、虚函数与继承
虚函数重写
在C++中,虚函数是实现运行时多态的关键。当一个函数在基类中被声明为虚函数时,它可以在派生类中被重写,以提供特定于派生类的行为。
虚函数覆盖:当派生类提供一个与基类中同名的虚函数时,这个函数就覆盖了基类中的版本。这意味着,当通过基类的引用或指针指向派生类对象且调用这个函数时,实际调用的是派生类中的函数。
namespace test1 // 简单多态案例,多态的必要条件
{
class Person
{
public:
virtual void BuyTicket() { cout << "Person: 买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "Student: 买票-半价" << endl; }
};
// 多态
void Func(Person& p)
{
p.BuyTicket();
}
void test()
{
Person* p1 = new Person;
Person* p2 = new Student;
Func(*p1); // 调用Person::BuyTicket()
Func(*p2); // 调用Student::BuyTicket()
delete p1;
delete p2;
}
}
通过上面的代码案例,我们不难总结出形成多态的必要条件:
- 条件1:虚函数的重写 -> 父子类中两个虚函数满足三同(
函数名
、参数
、返回
)
- 【特别注意】
- 这里的三同都满足才能形成多态
- 二同返回值不同也是一种特殊多态(称之为
协变
) -> (详见:何为C++中的协变)- 析构函数函数名不同也能构成多态条件2:父类指针或引用去调用虚函数
对象切片
详见:剖析C++中的继承
虚函数和多态
当父类中有虚函数时,父类会形成一个虚表指针,指向虚表,虚表中存放的是多态调用时被调函数的地址。
下面给出一段用于测试虚表及虚表指针的代码:
namespace test2 // 对比多态调用和普通调用
{
class Person {
public:
//~Person()
virtual ~Person()
{
cout << "~Person()" << endl;
}
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
// 重写实现
class Student : public Person {
public:
~Student()
{
cout << "~Student()" << endl;
}
void BuyTicket() { cout << "买票-半价" << endl; }
};
void test()
{
Person* p1 = new Person;
Person* p2 = new Student;
cout << "sizeof(Person) = " << sizeof(Person) << endl; // 4
cout << "sizeof(Student) = " << sizeof(Student) << endl; // 4
delete p1; // p1->destructor() + operator delete(p1)
delete p2; // p2->destructor() + operator delete(p2)
}
可以看到子类中重写了父类的成员函数 BuyTicket() 与析构函数,这两处都满足多态的形成条件。
打开调试窗口观察 p1、p2 指向对象内部的虚表及虚表指针:
如果子类继承并重写了父类中的虚函数,那么子类所含虚表指针指向的虚表内容会相对父类中的发生变化,即虚表中对应存放的不是同一个函数地址。
然而,如果子类中未能重写父类中的虚函数,子类对象会也产生一个虚表和虚表指针,只不过虚表的内容和父类虚表的内容是一致的。
而且,子类继承到父类的虚表中,没有重写父类虚函数,那么子类虚表指针指向的虚表中,对应的父类虚函数地址与子类中读到的相一致。
三、多态性实践
多态调用与普通调用
示例类的定义上面 namespace test2 已经给出,下面给出测试案例代码:
void test()
{
Person* p1 = new Person;
Person* p2 = new Student;
// 普通调用 看指针或者引用或对象的类型
Student s;
s.BuyTicket();
s.Person::BuyTicket();
// 多态调用 看指针或者引用指向的对象
p1->BuyTicket();
p2->BuyTicket();
delete p1;
delete p2;
}
运行结果:
多态与析构函数
下面给出两个基础类,用于测试不同情况下析构函数的调用情况:
class A
{
public:
A()
{
a = new int[10];
}
~A()
{
cout << "A 析构" << endl;
delete a;
a = nullptr;
}
int* a;
};
class B: public A
{
public:
B() :A()
{
b = new char[10];
}
~B()
{
cout << "B 析构" << endl;
delete b;
b = nullptr;
}
char* b;
};
测试上面类的基本功能:
void test()
{
A* p = new B;
delete p;
p = nullptr;
}
为了观察是否存在析构函数调用异常引起的内存泄漏,调用
vld
库即可方便查看是否有内存直到程序运行结束还未被释放。包含头文件 “vld.h”
运行结果:
所以我们得出结论,当两个类存在继承关系时,如果父类不将析构函数声明为虚函数,那么当父类指针指向子类对象的案例出现时,释放父类指针指向的内存虽然成功,但是仅调用了父类的析构函数,并没有调用子类析构函数来释放子类对象内部堆区成员变量的内存。
正确地,需要将父类析构函数声明为虚析构:
// ~A()
virtual ~A()
{
cout << "A 析构" << endl;
delete a;
a = nullptr;
}
运行结果:
四、高级多态性概念
final 关键字
实现一个类,这个类不能被继承
- 方法1:父类构造函数私有化,派生实例化不出对象
class A
{
protected:
int _a;
private:
A(){}
};
class B : public A
{};
简单的类对象实例化代码:
void test1()
{
B b;
}
编译报错:
- 方法2:C++11,final修饰的类为最终类,不能被继承
class A final // 添加了final关键字
{
protected:
int _a;
/*private:
A(){}*/
};
class B : public A
{};
语法报错,编译报错:
override 关键字
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
在以下情况下,子类没有成功重写父类的虚函数,override关键字会在编译阶段进行检测并给出警告或错误:
- **函数签名不匹配:**如果子类中的函数签名(包括函数名、参数列表和返回类型)与父类中的虚函数不完全匹配,编译器将无法识别子类的函数是对父类函数的重写。(override 允许协变通过)
- **父类函数不是虚函数:**如果父类函数没有声明为虚函数,而子类使用override关键字尝试重写该函数,编译器将给出错误。
- **函数修饰符不匹配:**如果父类的虚函数使用了const或引用修饰符,而子类中的重写函数没有相同的修饰符,编译器将会给出警告或错误。
class Car
{
public:
virtual Car* Drive() { return new Car; }
};
class Benz :public Car
{
public:
virtual Benz* Drive() override { return new Benz; }
};
子类中继承的虚函数用 override 关键字修饰但未正确重写父类虚函数,利用测试案例:
void test2()
{
Benz b;
b.Drive();
}
运行未报错,说明override指定的父类与子类对应函数的函数签名需一致,但构成协变的函数名不同在override关键字修饰下也不会报错。
五、虚表和虚表指针
为了便于分析叙述多态下父子类各自的虚表和虚表指针特性,给出下面简单的示例代码:
class Base
{
public:
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;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 2;
};
void test()
{
Base b;
Derive d;
cout << "sizeof(Base):" << sizeof(b) << endl;
cout << "sizeof(Derive):" << sizeof(d) << endl;
}
我们注意到代码中,父类中的虚函数Func2()没有在子类中被重写,父类中的Func3()不是虚函数,子类中有自身额外定义的Func4()函数。
接着我们打开监视窗口,观察父类对象和子类对象各自的虚表指针与虚表中所含的函数地址:
基类(Base)的虚表:
- 包含指向
Base::Func1()
和Base::Func2()
的指针。- 不包含非虚函数
Base::Func3()
,因为它不支持多态。派生类(Derive)的虚表:
- 包含指向
Derive::Func1()
的指针,这是对基类虚函数的重写,具有多态特性。- 继承了基类的
Base::Func2()
,因此虚表中也包含指向该函数的指针。- 包含一个新的虚函数
Derive::Func4()
,这在派生类的虚表中是新增的。
六、常见陷阱与最佳实践
常见陷阱 – 切片问题
**对象切片:**当派生类对象赋值给基类对象时,派生类特有的成员会被“切掉”,这可能会导致数据丢失或错误的行为。
详见:基类与派生类之间赋值转换 (具体位于第二章)
切片问题通常发生在对象赋值的情况下,特别是当派生类对象被赋值给基类对象时。在这种情况下,由于基类对象只能容纳基类部分,派生类特有的属性和方法就会被 “切掉”,这就是所谓的 “对象切片”。
最佳实践 – 使用引用或指针传递多态类对象
示例代码:
class A
{
public:
int m_aa = 1;
};
class B : public A
{
public:
int m_bb = 2;
};
void func(A* tmp) // 没有发生切片
{
tmp->m_aa = 10;
// 内部访问不到 B::m_bb
}
void test()
{
B b;
cout << "b.m_bb = " << b.m_bb << endl;
func(&b);
}
此处func
函数接受一个指向 A
类的指针,并且传递了一个指向 B
类对象的指针。由于 B
是从 A
继承的,这是多态的正常使用,不会导致切片。
为什么派生类对象赋值给基类指针不会发生切片?
派生类对象赋值给基类指针时不会发生切片,因为指针只是存储了对象的内存地址。当我们使用基类指针指向派生类对象时,指针本身并不包含对象的数据,它只是指向对象所在的内存位置。因此,无论指针的类型如何,对象本身都不会改变。
当我们使用指针或引用时,我们并不是在复制整个对象,而是让指针指向已经存在的对象。这样,即使是基类的指针,也能够通过它来访问派生类对象的基类部分,而不会影响到对象的完整性。这也是多态的基础,允许我们用基类的指针或引用来操作不同派生类的对象。
怎么做到指针指向子类对象但是父类指针仅能访问到父类成员?
在C++中,当我们使用父类指针指向子类对象时,父类指针默认只能访问父类中定义的成员。这是因为编译器只能识别指针类型所对应的成员。这个机制是C++的静态类型绑定
,也就是说,成员的访问是根据指针的类型在编译时就确定下来的。
总结
我们已经深入探讨了C++中多态性的各个方面。从基本的虚函数使用,到final
和override
关键字的高级应用,再到虚函数表的内部机制,这些都是构建强大C++应用程序的基石。正确理解和运用多态性,可以让我们的代码更加灵活、高效,同时也更易于维护和扩展。