C++面向对象整理(6)之继承(多态与虚函数)
注:整理一些突然学到的C++知识,随时mark一下
例如:忘记的关键字用法,新关键字,新数据结构
C++ 的 类的多态
提示:本文为 C++ 中 多态、虚函数 的写法和举例
一、继承的多态
1、多态的目的
多态性(Polymorphism)是面向对象编程的三大特性之一,多态允许我们使用父类类型的指针或引用来调用在子类中重新定义或覆盖的虚函数(virtual修饰的函数)。多态的目的就是让该子类的对象调用子类的那个同名函数,因为不使用虚函数的话,是父类的指针就会默认调用父类的成员函数
2、满足多态的条件
要实现多态,通常需要满足以下条件:
有虚函数:在基类中声明为virtual的成员函数为虚函数,且必须在派生类中被重写(override)。重写的函数可以在后方加上关键字override,也可以不加。
有继承:通过继承关系,派生类可以继承基类的虚函数,并提供自己的实现。
基类的指针或引用指向子类的对象:通过基类类型的指针或引用来操作派生类对象,实现运行时多态。
下面是一个简单的C++多态示例:
// 基类
class Shape {
public:
virtual void draw() const { // 虚函数
cout << "Drawing a generic shape." << endl;
}
};
// 派生类1
class Circle : public Shape {
public:
void draw() const override { // 重写虚函数
cout << "Drawing a circle." << endl;
}
};
// 派生类2
class Rectangle : public Shape {
public:
void draw() const override { // 重写虚函数
cout << "Drawing a rectangle." << endl;
}
};
int main() {
Shape* shapePtr; // 基类指针
Circle circle; // 派生类1对象
Rectangle rectangle; // 派生类2对象
shapePtr = &circle; // 指向派生类1对象
shapePtr->draw(); // 输出 "Drawing a circle."
shapePtr = &rectangle; // 指向派生类2对象
shapePtr->draw(); // 输出 "Drawing a rectangle."
return 0;
}
在这个例子中,Shape类
有一个虚函数draw(),Circle
和Rectangle类
都继承自Shape类
并重写了draw()
函数。在main()函数中,我们使用Shape*
类型的指针shapePtr
来指向Circle和Rectangle
对象,并调用它们的draw()
函数。由于draw()
函数在Shape类
中被声明为virtual
,因此当通过shapePtr
调用draw()
函数时,会根据shapePtr
实际指向的对象类型(Circle或Rectangle
)来调用相应的draw()
函数实现,从而实现多态。
3、多态的原理
多态的原理主要基于虚函数表和指针(或引用)的动态绑定。
虚函数表:对于包含虚函数的类,编译器会为其生成一个虚函数表,该表中存放了类中所有虚函数的地址。每一个有虚函数的类的对象在自己的内存的首地址处都会再存一个虚函数表的指针
动态绑定:当使用父类类型的指针或引用来调用虚函数时,会在运行时根据对象的实际类型来确定调用子类还是父类的虚函数(子类对象的话即那个子类)实现。
4、虚函数表与表指针
虚函数表
(Virtual Function Table,简称vtable)是C++实现多态机制的关键部分。在C++中,当一个类包含至少一个虚函数时,编译器会为该类生成一个虚函数表。这个表是一个存储虚函数地址的数组,按照虚函数在类中声明的顺序进行排列。
虚函数表指针(通常称为vptr)是一个指向虚函数表的指针,它存储在类的对象中。vptr的存在使得对象在运行时能够确定应该调用哪个虚函数的实现。vptr通常位于对象内存布局的最前面,即对象的地址与vptr的地址是相同的。这样做可以方便地在运行时通过对象的地址来访问虚函数表,在运行阶段绑定地址,地址(函数的地址)是晚绑定,这也称之为动态联编。所以函数重写后是动态绑定,而函数重载的时候是静态绑定,即地址再编译时绑定。
需要注意的是,vptr是在对象创建时由编译器自动初始化的,它指向的是该类对应的虚函数表。对于基类对象,vptr指向基类的虚函数表;对于派生类对象,如果派生类没有重写任何虚函数,则vptr仍然指向基类的虚函数表;如果派生类重写了基类的虚函数,则编译器会为派生类生成一个新的虚函数表,并将派生类对象的vptr指向这个新的虚函数表。
虚函数表指针的存在使得多态性得以实现。当我们使用基类指针或引用来操作派生类对象时,通过vptr可以找到正确的虚函数表,从而调用派生类中重写的虚函数实现。这种机制使得程序在运行时能够根据对象的实际类型来确定行为,实现了动态绑定和多态性。
二、纯虚函数与抽象类
纯虚函数
纯虚函数是一个在基类中声明为虚函数,但没有定义(即只有声明没有实现)的特殊虚成员函数。纯虚函数在声明时以= 0结尾。由于纯虚函数没有定义,所以包含纯虚函数的类不能创建对象。纯虚函数的主要目的是为派生类提供一个接口,要求派生类必须实现这个函数。
示例:
class AbstractClass {
public:
virtual void pureVirtualFunction() = 0; // 纯虚函数声明
};
抽象类
一个包含至少一个纯虚函数的类被称为抽象类。抽象类不能被实例化,即不能创建抽象类的对象。抽象类通常用作基类,为派生类提供一个公共的接口。派生类必须为所有的纯虚函数提供具体的实现,才能创建派生类的对象。
示例:
class Shape { // 抽象类
public:
virtual void draw() const = 0; // 纯虚函数
virtual ~Shape() {} // 虚析构函数,确保派生类的析构函数能被正确调用
};
class Circle : public Shape { // 派生类
public:
void draw() const override { // 实现纯虚函数
cout << "Drawing a circle." << endl;
}
};
int main() {
Shape* shape; // 可以声明指向抽象类的指针或引用
Circle circle; // 创建派生类对象
shape = &circle; // 指向派生类对象
shape->draw(); // 调用派生类的draw函数
return 0;
}
在上面的例子中,Shape
类是一个抽象类,因为它包含一个纯虚函数draw()
。Circle
类继承自Shape
类,并实现了draw()
函数。因此,我们可以创建Circle
类的对象,但不能创建Shape
类的对象。
三、虚析构函数的必要场景
如果子类(派生类)中含有指向堆区(动态分配的内存)的属性变量,并且你计划通过基类指针来删除派生类对象,那么必须为基类提供一个虚析构函数。这主要是为了确保在删除对象时,派生类的析构函数以及基类的析构函数都能被正确调用,从而释放所有动态分配的资源,防止内存泄漏。
下面是一个简单的例子来说明这个问题:
class Base {
public:
// 如果Base类有指向堆区的成员变量,或者派生类有,并且你计划通过Base指针来删除派生类对象,
// 则这里需要声明为虚析构函数
virtual ~Base() {} // 虚析构函数
};
class Derived : public Base {
public:
int* ptr = new int(5); // 指向堆区的成员变量
~Derived() {
delete ptr; // 释放堆区内存
}
};
int main() {
Base* basePtr = new Derived(); // 通过基类指针指向派生类对象
delete basePtr; // 删除对象
return 0;
}
在上面的例子中,Derived类有一个指向堆区的int类型指针ptr。如果Base类的析构函数不是虚函数,那么当通过Base类的指针basePtr来删除Derived类对象时,只有Base类的析构函数会被调用,而Derived类的析构函数不会被调用。这会导致ptr所指向的内存不会被释放,从而造成内存泄漏。
通过将Base类的析构函数声明为虚函数,我们确保了当通过基类指针删除对象时,首先会调用派生类的析构函数(如果有的话),然后再调用基类的析构函数。这样就保证了所有资源都能被正确释放。
因此,当子类中有指向堆区的属性变量,并且你计划通过基类指针来管理这些对象时,必须使用虚析构函数。
四、override关键字
使用override关键字的好处在于,如果基类中的虚函数签名发生更改,而派生类中的函数没有更新以匹配新的签名,编译器会发出错误。这有助于在编译时捕获潜在的错误,提高代码的安全性。
如果不加override关键字,当基类的虚函数签名发生更改(例如参数类型或数量发生更改),而派生类中的函数没有相应地更新,只要函数名相同的话编译器就不会发出错误,这可能导致未定义的行为。