目录
一、前言
二、多态是什么?
三、多态的定义及实现
🔥 多态的构成条件🔥
🔥 虚函数的重写🔥
🔥虚函数重写的两个例外 🔥
🍍 协变返回类型
🥝 析构函数的重写
🔥C++11 的 override 和 final 🔥
🍑override
🍉final
🔥重载、覆盖(重写)、隐藏(重定义)的对比 🔥
💢重载(Overloading)
💢覆盖(重写)(Overriding)
💢重定义(隐藏)(Hiding)
四、抽象类
五、多态的原理
💢多态性的基本原理
💧虚函数表指针💧
💧虚函数表💧
💧虚函数表和虚指针相互关联构成 --- 多态💧
💢 动态绑定和静态绑定
六、多态的常考面试题
七、共勉
一、前言
多态
---- 是 面向对象 三大基本特征中的最后一个,多态
可以实现 “一个接口,多种方法”,比如 父类 和 子类 中的同名方法,在增加了多态后,调用同名函数时,可以根据不同的对象(父类对象或子类对象)调用属于自己的函数,实现不同的方法,因此多态
的实现依赖于继承
如果大家还不太了解 继承 可以先看看这篇文章:C++继承详解
二、多态是什么?
多态(Polymorphism)这个词来源于 希腊语,意思是“多种形态”。在编程中,多态允许你 使用相同的接口(方法或函数)来 处理不同的 数据类型 或 对象。这使得代码更加灵活和可扩展。
通俗一点:不同对象 ----- 完成同一件事情 ----- 产生不同的结果
举例1:
比如 春节回家买票 (看作一件事情),当普通人买票时,是全价买票 ;学生买票时,半价买票;军人买票时是优先买票。(不同的对象,产生了不同的结果)
举例2:
为了争夺在线支付市场,支付宝年底经常会做诱人的 扫红包-支付-给奖励金 的活动。
那么大家想想为什么有人扫的红包又大又新鲜 8块、10 块...而有人扫的红包都是1毛,5...。其实这背后也是一个多态行为。
总结一下:同样是扫码动作(看作一件事情),不同的用户扫得到的不-样的红包(不同的对象,产生了不同的结果),这也是一种多态行为。
代码举例:引出 本篇文章所要学习的 关于多态的 十万个 为什么?
假设你在家里有一只宠物。宠物可以是 猫、狗 或 鸟。每种宠物都会发出叫声,但每种宠物的叫声不同。我们可以定义一个基类
Pet
,然后让Cat
、Dog
和Bird
继承自这个基类。
class Pet {
public:virtual void makeSound() {cout << "Some generic pet sound" << endl;}
};class Cat : public Pet {
public:void makeSound() override {cout << "Meow" << endl;}
};class Dog : public Pet {
public:void makeSound() override {cout << "Woof" << endl;}
};class Bird : public Pet {
public:void makeSound() override {cout << "Tweet" << endl;}
};
使用 --- 多态 的性质
现在假设你有一个 Pet
类型的指针,它可以指向 任何一种 具体的宠物 对象:
Pet* myPet = new Dog();
myPet->makeSound(); // 输出 "Woof"
尽管 myPet
是 Pet
类型的指针,但它指向的是一个 Dog
对象。因此,当你调用 makeSound()
方法时,会调用 Dog
类的 makeSound()
方法,而不是 Pet
类的。(这就是多态)
如果你把
myPet
指向一个Cat
对象:
myPet = new Cat();
myPet->makeSound(); // 输出 "Meow"
我们先给出 总结:
多态
1️⃣:允许你用 基类指针或引用 来 操作不同的派生类对象。2️⃣:当调用虚函数时,程序会 根据实际对象的类型来调用对应的方法,而不是根据指针的类型。
3️⃣:这使得程序更加灵活,易于扩展和维护。
此时大家肯定 会对上面的 代码 和 解释 有不同程度 的看不懂,和不理解!!
1️⃣:构成多态需要那些条件 ?
2️⃣:什么是虚函数? 代码中关键字 virtual 是干什么的 ?
3️⃣:为什么 派生类中 可以出现 和 基类 同样的函数,这样会不会构成 继承中的 隐藏呢 ?
4️⃣:为什么 指向基类(Pet)的指针可以指向派生类(Dog 、Cat)的对象呢 ?
5️⃣:代码 派生类 中出现的关键字 override 是干什么呢 ?
这些疑问,下面将会给大家详细的讲解 !! 当大家搞懂上面这些问题后,在回头看上面的这个例子就会 恍然大悟 有一种升华的感觉!!
三、多态的定义及实现
实现多态需要借助虚表(虚函数表),而构成虚表又需要虚函数,即
virtual
修饰的函数,除此之外还需要使用虚表指针来进行函数定位、调用 。
🔥 多态的构成条件🔥
1️⃣:继承关系(Inheritance)
多态 依赖于类之间的 继承 关系。至少需要一个基类和一个或多个派生类。派生类从基类继承属性和方法,并可以覆盖基类的方法。
2️⃣:虚函数(Virtual Functions)
基类中必须有一个或多个虚函数。这些虚函数是用 virtual
关键字声明的。虚函数允许派生类重写它们,从而实现多态性。
// 基类
class Base {
public:virtual void display() // 虚函数{cout << "Display from Base" << endl;}
};// 派生类
class Derived : public Base {
public:void display() override {cout << "Display from Derived" << endl;}
};
3️⃣:必须通过 基类 的 指针 或 引用 来调用 虚函数(Base Class Pointer or Reference)
使用 基类指针 或 引用 来 引用派生类对象。这是实现多态性的关键,因为通过基类指针或引用调用虚函数时,程序 会根据实际指向的对象类型调用对应的函数。
Base* b = new Derived();
b->display(); // 输出 "Display from Derived"
4️⃣:虚函数表(Virtual Table, VTable)
虚函数表是编译器在处理虚函数时创建的一个数据结构。它包含类的虚函数的指针。当通过基类指针调用虚函数时,程序会查找虚函数表并调用实际的函数实现。(这个知识点大家点先了解一下,文章在讲解 多态原理的时候,会详细讲解!!)
5️⃣:基类中
virtual
修饰后的函数形成虚函数,与派生类中的 override 修饰函数形成 重写(三同:返回值、函数名、参数均相同)
代码案例熟悉:
我们可以按照上面 5条 多态的构成条件,例子帮助大家熟悉熟悉:
#include <iostream>
using namespace std;// 基类
class Animal {
public:// 虚函数virtual void makeSound() {cout << "Some generic animal sound" << endl;}
};// 派生类
class Dog : public Animal {
public:// 函数重写void makeSound() override { cout << "Woof" << endl;}
};class Cat : public Animal {
public:// 函数重写void makeSound() override {cout << "Meow" << endl;}
};int main() {// 基类指针指向派生类对象Animal* animal1 = new Dog();Animal* animal2 = new Cat();// 调用虚函数,根据实际对象类型调用对应的方法animal1->makeSound(); // 输出 "Woof"animal2->makeSound(); // 输出 "Meow"delete animal1;delete animal2;return 0;
}
详细解释:
- 继承关系:
Animal
是基类,Dog
和Cat
是从Animal
继承的派生类。 - 虚函数:基类
Animal
中的makeSound
函数被声明为虚函数。 - 基类指针:在
main
函数中,Animal* animal1
和Animal* animal2
分别指向Dog
和Cat
对象。 - 虚函数表:编译器为
Animal
类创建一个虚函数表,其中包含makeSound
函数的指针。Dog
和Cat
类覆盖了makeSound
函数,因此它们各自的虚函数表会包含指向相应重写函数的指针。 - 虚函数重写:
Dog
和Cat
中的makeSound
函数均被 override 修饰 ,并且 返回值、函数名、参数均相同,形成了虚函数重写
通过这些条件,多态性使得可以通过基类指针调用派生类的重写方法,从而实现灵活和可扩展的代码设计。这是面向对象编程的重要特性之一,广泛用于实际开发中。
🔥 虚函数的重写🔥
在面向对象编程中,虚函数重写(覆盖)是指派生类重新定义基类中的虚函数(返回值、函数名、参数列表,均相同)。当通过基类指针或引用调用虚函数时,程序会根据实际对象类型调用对应的重写函数,而不是基类中的函数。
具体实现步骤
- 定义基类和虚函数
- 定义派生类并重写虚函数
- 通过基类指针或引用调用虚函数
详细示例:
基类和虚函数
首先,我们定义一个基类 Base
,其中包含一个虚函数 display
:
#include <iostream>
using namespace std;class Base {
public:virtual void display() {cout << "Display from Base" << endl;}
};
virtual
关键字用于声明display
是一个虚函数。- 这样,派生类可以选择性地重写这个函数。
派生类和函数重写
然后,我们定义一个派生类 Derived
,它继承自 Base
并重写 display
函数:
class Derived : public Base {
public:void display() override {cout << "Display from Derived" << endl;}
};
override
关键字表明这个函数是 重写基类中的虚函数。- 虽然
override
是可选的,但推荐使用它来确保函数签名正确匹配基类中的虚函数。
使用基类指针调用重写的虚函数
在 main
函数中,我们使用基类指针来调用虚函数:
int main() {Base* b = new Derived();b->display(); // 输出 "Display from Derived"delete b;return 0;
}
Base* b = new Derived();
创建一个基类指针b
,指向一个Derived
对象。b->display();
调用display
函数,由于b
实际上指向的是Derived
对象,因此会调用Derived
类中的display
函数。
虚函数表解析
虚函数表(VTable)是编译器用来实现运行时多态性的机制。
- 创建 VTable:编译器为每个包含虚函数的类创建一个虚函数表。
- 填充 VTable:虚函数表中包含指向该类的虚函数实现的指针。
- 对象指向 VTable:每个对象都有一个隐藏的指针,指向它所属类的虚函数表。
示例中的 VTable 工作原理
Base
类的虚函数表包含指向Base::display
的指针。Derived
类的虚函数表包含指向Derived::display
的指针。
当创建一个 Derived
对象时,它的隐藏指针指向 Derived
类的虚函数表。因此,通过基类指针调用虚函数时,程序会查找虚函数表,并调用 Derived::display
。
通过这个例子,我们可以看到虚函数重写如何实现运行时多态性,使得基类指针能够根据实际对象类型调用正确的函数实现。
🔥虚函数重写的两个例外 🔥
🍍 协变返回类型
协变返回类型:指的是在 派生类中重写基类虚函数时,返回类型可以是基类返回类型的派生类。例如,如果是基类的虚函数返回一个基类指针或引用,派生类可以重写这个函数并返回派生类的指针或引用。
详细示例 :
定义基类和派生类
首先,我们定义一个基类 Base
和一个从 Base
继承的派生类 Derived
。
#include <iostream>
using namespace std;class Base {
public:virtual Base* clone() const {return new Base(*this);}virtual void print() const {cout << "This is Base" << endl;}
};class Derived : public Base {
public:Derived* clone() const override {return new Derived(*this);}void print() const override {cout << "This is Derived" << endl;}
};
在这个例子中:
Base
类定义了一个虚函数clone
,返回一个Base*
类型。Derived
类重写了clone
函数,但返回类型是Derived*
。
使用协变返回类型
在 main
函数中,我们可以使用协变返回类型来创建对象的副本。
int main() {Base* b = new Derived();Base* b_clone = b->clone(); // 调用的是 Derived::clone(),返回 Derived*,但可以赋值给 Base*b->print(); // 输出 "This is Derived"b_clone->print(); // 输出 "This is Derived"delete b;delete b_clone;return 0;
}
解释:
- 基类定义:基类
Base
中的虚函数clone
返回一个Base*
类型的对象。 - 派生类重写:派生类
Derived
重写了clone
函数,并返回一个Derived*
类型的对象。这是合法的,因为Derived*
是Base*
的派生类指针。 - 多态性:通过基类指针调用
clone
函数时,会实际调用Derived
类的clone
函数,并返回一个Derived*
。尽管返回的是Derived*
,但它可以被赋值给Base*
类型的指针,这是C++的类型兼容性特性。
使用协变返回类型的优点
协变返回类型(Covariant Return Type)的出现带来了许多实际作用和好处,尤其是在面向对象编程中。以下是协变返回类型的一些主要作用:
1️⃣:提高代码的类型安全性
协变返回类型允许派生类在重写基类的虚函数时,返回派生类的类型。这避免了在外部进行类型转换,提高了类型安全性。
class Base {
public:virtual Base* clone() const {return new Base(*this);}
};class Derived : public Base {
public:Derived* clone() const override {return new Derived(*this);}
};int main() {Base* basePtr = new Derived();// 强制类型转换Derived* derivedClone = static_cast<Derived*>(basePtr->clone());delete basePtr;delete derivedClone;return 0;
}
通过协变返回类型,可以避免在上例中使用的类型转换:
int main() {Base* basePtr = new Derived();// 协变类型转换 Derived* derivedClone = basePtr->clone();delete basePtr;delete derivedClone;return 0;
}
2️⃣:提高代码的可读性和可维护性
使用协变返回类型可以使代码更清晰,避免不必要的类型转换,从而提高代码的可读性和可维护性。
3️⃣:支持面向对象编程中的深拷贝
协变返回类型在实现深拷贝时尤为有用。在基类中定义一个虚函数 clone
并在派生类中重写,返回派生类对象,可以确保拷贝的对象具有正确的动态类型。
class Shape {
public:virtual Shape* clone() const {return new Shape(*this);}virtual void draw() const {cout << "Drawing a shape" << endl;}
};class Circle : public Shape {
public:Circle* clone() const override {return new Circle(*this);}void draw() const override {cout << "Drawing a circle" << endl;}
};int main() {Shape* shape = new Circle();Shape* clonedShape = shape->clone();shape->draw(); // 输出 "Drawing a circle"clonedShape->draw(); // 输出 "Drawing a circle"delete shape;delete clonedShape;return 0;
}
代码解析:
基类 Shape
class Shape {
public:virtual Shape* clone() const {return new Shape(*this);}virtual void draw() const {cout << "Drawing a shape" << endl;}
};
Shape
是一个基类,包含两个虚函数:
clone
:返回一个指向Shape
的指针,用于创建对象的副本。该函数通过new Shape(*this)
创建当前对象的拷贝。draw
:输出 "Drawing a shape",表示绘制一个通用的形状。
派生类 Circle
class Circle : public Shape {
public:Circle* clone() const override {return new Circle(*this);}void draw() const override {cout << "Drawing a circle" << endl;}
};
Circle
继承自 Shape
,并重写了两个虚函数:
clone
:返回一个指向Circle
的指针,用于创建Circle
对象的副本。通过new Circle(*this)
创建当前Circle
对象的拷贝。draw
:输出 "Drawing a circle",表示绘制一个圆形。
主函数 main
int main() {Shape* shape = new Circle();Shape* clonedShape = shape->clone();shape->draw(); // 输出 "Drawing a circle"clonedShape->draw(); // 输出 "Drawing a circle"delete shape;delete clonedShape;return 0;
}
Shape* shape = new Circle();
:创建一个指向Circle
对象的基类指针shape
。通过基类指针指向派生类对象,这里体现了多态性。Shape* clonedShape = shape->clone();
:调用shape
指向的对象的clone
方法,返回一个指向新创建的Circle
对象的指针clonedShape
。由于Circle
重写了clone
方法并返回Circle*
类型,因此实际调用的是Circle::clone
,返回的是Circle*
类型,但赋值给了Shape*
指针clonedShape
。这就是协变返回类型的作用。shape->draw();
:调用shape
指向对象的draw
方法,实际调用的是Circle::draw
,输出 "Drawing a circle"。clonedShape->draw();
:调用clonedShape
指向对象的draw
方法,实际调用的是Circle::draw
,输出 "Drawing a circle"。delete shape;
和delete clonedShape;
:删除shape
和clonedShape
对象,释放内存。
关键点解析
虚函数与多态性:
- 基类
Shape
的clone
和draw
方法被声明为虚函数,使得派生类Circle
可以重写它们。 - 通过基类指针
shape
调用虚函数clone
和draw
时,实际调用的是Circle
类中的实现。这是多态性在起作用。
协变返回类型:
Circle
类的clone
方法返回Circle*
类型,而不是基类的Shape*
类型。这是合法的,因为Circle*
是Shape*
的派生类指针。- 通过返回派生类类型的指针,可以避免外部进行类型转换,提高代码的类型安全性和可读性。
深拷贝:
clone
方法的实现通过new Circle(*this)
创建当前对象的副本,保证了深拷贝的实现。- 在
main
函数中,shape->clone()
返回的是一个新的Circle
对象的指针clonedShape
,确保了深拷贝。
总结
协变返回类型的主要作用包括:
- 提高代码的类型安全性
- 提高代码的可读性和可维护性
- 支持更好的面向对象设计和深拷贝
- 提供更灵活的接口设计
🥝 析构函数的重写
在C++中,析构函数是一个特殊的成员函数,用于在对象被销毁时执行清理操作。关于析构函数的重写(override),实际上并不是一个准确的术语,因为析构函数在派生类中并不能像普通的虚函数那样被重写。然而,我们可以通过让基类的析构函数成为虚函数,从而实现多态性和正确的资源释放。
为什么析构函数需要重写?
当使用基类指针或引用指向派生类对象时,如果基类的析构函数不是虚函数,那么在删除对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能会导致资源泄漏,因为派生类的清理工作未被执行。
例子
没有重写析构函数的情况
#include <iostream>
using namespace std;class Base {
public:~Base() {cout << "Base destructor called" << endl;}
};class Derived : public Base {
public:~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Base* b = new Derived();delete b; // 只会调用 Base 的析构函数return 0;
}
输出:
Base destructor called
在这个例子中,由于 Base
的析构函数不是虚函数,删除 b
时只调用了 Base
的析构函数,而没有调用 Derived
的析构函数。
使用重写析构函数
#include <iostream>
using namespace std;class Base {
public:virtual ~Base() {cout << "Base destructor called" << endl;}
};class Derived : public Base {
public:~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Base* b = new Derived();delete b; // 会调用 Derived 和 Base 的析构函数return 0;
}
输出:
Derived destructor called
Base destructor called
在这个例子中,由于 Base
的析构函数是虚函数,删除 b
时先调用了 Derived
的析构函数,然后调用了 Base
的析构函数。
重写析构函数的作用
虚析构函数确保了在使用基类指针或引用时,派生类的析构函数会被正确调用,从而防止资源泄漏和其他潜在问题。
总结
- 重写析构函数的必要性:当使用 基类指针或引用指向 派生类对象时,必须将基类的析构函数声明为虚函数,以确保正确调用派生类的析构函数。
- 实现方式:在基类中使用
virtual
关键字声明析构函数。 - 作用:确保在销毁对象时,调用正确的析构函数,防止资源泄漏和其他问题。
🔥C++11 的 override 和 final 🔥
🍑override
override
是C++11引入的一种功能,用来明确表示派生类中的函数是覆盖基类中的虚函数。它有以下几个好处:
- 增加代码可读性:明确表明这个函数是用来覆盖基类的虚函数的,方便代码阅读。
- 编译器检查:编译器会检查这个函数是否确实覆盖了基类中的虚函数。如果没有(例如函数签名不匹配),编译器会报错。这可以帮助我们捕捉错误。
代码案例:
class Pet {
public:virtual void makeSound() {cout << "Some generic pet sound" << endl;}
};class Cat : public Pet {
public:void makeSound() override {cout << "Meow" << endl;}
};
- 在
Cat
类中,makeSound()
函数后面的override
关键字告诉编译器这是对基类Pet
中makeSound()
函数的覆盖。如果我们不小心拼写错误或参数列表不同,编译器会报错。
为什么只需要在基类中声明虚函数 ?
在C++中,只有基类需要声明虚函数,因为这是多态性工作的基础。虚函数表(Virtual Table)是由基类维护的,当派生类重写这个虚函数时,它会自动被记录在虚函数表中。
-
基类声明虚函数:当你在基类中声明一个函数为虚函数时,编译器会在对象的虚函数表(VTable)中为这个函数创建一个入口。虚函数表是一个指针数组,每个对象都有一个指向它所属类的虚函数表的指针。
-
派生类覆盖虚函数:当派生类覆盖这个虚函数时,它会在虚函数表中替换基类的函数指针。因此,通过基类指针调用虚函数时,实际调用的是派生类的函数。
-
调用虚函数:当你通过基类指针调用虚函数时,程序会查找虚函数表,并调用实际指向的函数。这就是多态性的工作原理。
派生类不需要显式声明函数为虚函数,因为它继承了基类的虚函数机制。
代码案例
#include <iostream>
using namespace std;class Pet {
public:virtual void makeSound() {cout << "Some generic pet sound" << endl;}
};class Cat : public Pet {
public:void makeSound() override {cout << "Meow" << endl;}
};class Dog : public Pet {
public:void makeSound() override {cout << "Woof" << endl;}
};int main() {Pet* myPet = new Dog();myPet->makeSound(); // 输出 "Woof"myPet = new Cat();myPet->makeSound(); // 输出 "Meow"delete myPet;return 0;
}
在这个例子中:
Pet
是基类,其中makeSound
被声明为虚函数。Cat
和Dog
是派生类,覆盖了makeSound
函数,并使用override
关键字来确保正确的覆盖。- 在
main
函数中,通过Pet
类型的指针调用makeSound
函数,根据指针实际指向的对象类型,调用相应的派生类函数。
🍉final
final
关键字在C++11引入,用于防止继承和重写,确保类或虚函数不会被进一步派生或重写。这在设计某些类层次结构时非常有用,可以增加代码的安全性和明确性。
final
关键字的作用
- 防止类被继承:将
final
关键字放在类声明之后,表示该类不能被继承。 - 防止虚函数被重写:将
final
关键字放在虚函数声明之后,表示该虚函数不能在派生类中被重写。
示例解析
防止类被继承
class Base final {
public:void display() const {cout << "Base display" << endl;}
};// 下面的代码将导致编译错误,因为 Base 类被标记为 final,不能被继承
class Derived : public Base {
};int main() {Base b;b.display();return 0;
}
在这个例子中,Base
类被标记为 final
,因此 Derived
类无法从 Base
类继承。如果尝试继承,会导致编译错误。
防止虚函数被重写
class Base {
public:virtual void display() const {cout << "Base display" << endl;}
};class Derived : public Base {
public:void display() const final {cout << "Derived display" << endl;}
};// 下面的代码将导致编译错误,因为 Derived::display 被标记为 final,不能被进一步重写
class MoreDerived : public Derived {
public:void display() const override {cout << "MoreDerived display" << endl;}
};int main() {Base b;b.display();Derived d;d.display();return 0;
}
在这个例子中,Derived
类中的 display
函数被标记为 final
,因此 MoreDerived
类无法重写该函数。如果尝试重写,会导致编译错误。
综合示例
结合之前的多态示例,这里是一个使用 final
关键字的综合示例:
#include <iostream>
using namespace std;class Shape {
public:virtual Shape* clone() const {return new Shape(*this);}virtual void draw() const {cout << "Drawing a shape" << endl;}
};class Circle final : public Shape {
public:Circle* clone() const override {return new Circle(*this);}void draw() const override final {cout << "Drawing a circle" << endl;}
};// 下面的代码将导致编译错误,因为 Circle 被标记为 final,不能被继承
class DerivedCircle : public Circle {
public:void draw() const override {cout << "Drawing a derived circle" << endl;}
};int main() {Shape* shape = new Circle();Shape* clonedShape = shape->clone();shape->draw(); // 输出 "Drawing a circle"clonedShape->draw(); // 输出 "Drawing a circle"delete shape;delete clonedShape;return 0;
}
在这个示例中:
Circle
类被标记为final
,因此不能被继承。Circle
类中的draw
方法被标记为final
,因此不能被重写。
总结
final
关键字用于防止类被继承或虚函数被重写,增加代码的安全性和明确性。- 防止类被继承:将
final
放在类声明之后。 - 防止虚函数被重写:将
final
放在虚函数声明之后。 - 适用于设计稳定接口、提高代码安全性、优化性能等场景。
🔥重载、覆盖(重写)、隐藏(重定义)的对比 🔥
重载(Overloading)、覆盖(Overriding)、重定义(隐藏)(Hiding)在C++中是三个不同的概念,它们各自有不同的语法和应用场景。下面我将详细解释这些概念,并举例说明它们的区别。
💢重载(Overloading)
重载是指在同一个作用域中定义多个同名函数,但这些函数的参数列表(参数的数量、类型或顺序)不同。重载主要用于提高代码的灵活性和可读性。
#include <iostream>
using namespace std;class Printer {
public:void print(int i) {cout << "Printing int: " << i << endl;}void print(double d) {cout << "Printing double: " << d << endl;}void print(const string& s) {cout << "Printing string: " << s << endl;}
};int main() {Printer p;p.print(42); // 调用 print(int)p.print(3.14); // 调用 print(double)p.print("Hello"); // 调用 print(const string&)return 0;
}
在这个例子中,print
函数被重载了三次,每个重载版本的参数列表不同。
💢覆盖(重写)(Overriding)
覆盖是指派生类重新定义基类中已经存在的虚函数,以实现特定的行为。覆盖是多态性的重要机制,允许派生类根据需要修改基类的行为。覆盖函数必须与被覆盖函数具有相同的参数列表和返回类型。
#include <iostream>
using namespace std;class Base {
public:virtual void show() {cout << "Base show" << endl;}
};class Derived : public Base {
public:void show() override {cout << "Derived show" << endl;}
};int main() {Base* b = new Derived();b->show(); // 调用 Derived 的 show()delete b;return 0;
}
在这个例子中,Derived
类中的 show
函数覆盖了 Base
类中的虚函数 show
。
💢重定义(隐藏)(Hiding)
重定义是指在派生类中定义一个与基类中同名但参数列表不同的函数。重定义不会影响基类中的函数。重定义通常是无意的,并且可以通过使用
using
关键字在派生类中引入基类的同名函数来避免。
#include <iostream>
using namespace std;class Base {
public:void display() {cout << "Base display" << endl;}void display(int i) {cout << "Base display with int: " << i << endl;}
};class Derived : public Base {
public:void display() {cout << "Derived display" << endl;}
};int main() {Derived d;d.display(); // 调用 Derived 的 display()// d.display(42); // 错误:Derived 中没有匹配的函数d.Base::display(42); // 调用 Base 的 display(int)return 0;
}
在这个例子中,Derived
类中的 display
函数隐藏了 Base
类中的所有同名函数。如果在 Derived
类中想要使用基类的其他 display
函数,可以通过 using
关键字引入基类函数:
class Derived : public Base {
public:using Base::display; // 引入基类的 display 函数void display() {cout << "Derived display" << endl;}
};
这样可以在 Derived
类中使用 Base
类中的 display(int)
函数。
总结
- 重载(Overloading):同一个作用域内同名函数的参数列表不同,构成重载。
- 覆盖(重写)(Overriding):派生类重新定义基类中的虚函数,函数签名必须相同,构成覆盖。
- 重定义(隐藏)(Hiding):派生类中定义一个与基类中同名但参数列表不同的函数,构成重定义或隐藏。
四、抽象类
抽象类(Abstract Class)是面向对象编程中的一个重要概念,尤其在实现多态性方面非常有用。抽象类通常作为基类,定义接口供派生类实现,而不能实例化。
抽象类的特点
- 包含纯虚函数:一个类至少包含一个纯虚函数(pure virtual function)就是抽象类。纯虚函数在声明时使用
= 0
来标识。 - 不能实例化:抽象类不能创建对象。只能通过派生类来实例化对象。
- 提供接口:抽象类通常用来定义接口,而具体的实现由派生类提供。
纯虚函数
纯虚函数是在基类中声明但没有定义的函数,需要派生类提供具体实现。纯虚函数的语法如下:
virtual void functionName() = 0;
示例
以下是一个关于抽象类和纯虚函数的示例:
定义抽象类 Animal
#include <iostream>
using namespace std;class Animal {
public:// 纯虚函数,表示动物发出的声音virtual void makeSound() const = 0;// 虚析构函数,保证正确删除派生类对象virtual ~Animal() {}
};
派生类 Dog
和 Cat
class Dog : public Animal {
public:void makeSound() const override {cout << "Woof!" << endl;}
};class Cat : public Animal {
public:void makeSound() const override {cout << "Meow!" << endl;}
};
使用抽象类和派生类
int main() {Animal* animals[2];animals[0] = new Dog();animals[1] = new Cat();for (int i = 0; i < 2; ++i) {animals[i]->makeSound(); // 调用派生类的 makeSound 方法}for (int i = 0; i < 2; ++i) {delete animals[i]; // 删除对象,调用虚析构函数}return 0;
}
解析
抽象类 Animal
Animal
类是一个抽象类,因为它包含一个纯虚函数makeSound
。- 纯虚函数
makeSound
定义了一个接口,要求所有派生类必须实现这个函数。 - 虚析构函数确保通过基类指针删除派生类对象时,正确调用派生类的析构函数。
派生类 Dog
和 Cat
Dog
类和Cat
类从Animal
类继承,并实现了纯虚函数makeSound
。Dog
类的makeSound
方法输出 "Woof!",表示狗叫的声音。Cat
类的makeSound
方法输出 "Meow!",表示猫叫的声音。
main
函数
- 在
main
函数中,创建了一个Animal
类指针数组,其中包含Dog
和Cat
对象。 - 通过基类指针调用了派生类的
makeSound
方法,展示了多态性。 - 最后,通过基类指针删除了派生类对象,确保正确调用派生类的析构函数。
运行结果
Woof!
Meow!
这表明:
animals[0]
指向一个Dog
对象,并正确调用了Dog::makeSound
方法,输出 "Woof!"。animals[1]
指向一个Cat
对象,并正确调用了Cat::makeSound
方法,输出 "Meow!"。
关键点
- 抽象类定义接口:抽象类
Animal
定义了动物发出声音的方法接口。 - 派生类实现接口:
Dog
和Cat
类实现了这些接口,并提供了具体的行为。 - 多态性:通过基类指针调用派生类的方法,实现了多态性。
- 虚析构函数:保证了通过基类指针删除派生类对象时,正确调用派生类的析构函数。
抽象类的用途
抽象类适合用于描述无法拥有实体的类,比如 人、动物、植物,毕竟这些都是不能直接使用的,需要经过 继承 赋予特殊属性后,才能作为一个独立存在的个体(对象)
五、多态的原理
💢多态性的基本原理
在C++中,多态性是通过虚函数来实现的,而虚函数的机制依赖于虚函数表(vtable)和虚指针(vptr)。以下是这两个概念及其相互关系的详细解释。
- 虚函数(Virtual Function):在基类中声明为
virtual
的函数,允许派生类重写这些函数。 - 虚函数表(Virtual Table, vtable):编译器为每个含有虚函数的类生成一个虚函数表,其中存储了该类的虚函数指针。
实现机制
-
虚函数表(vtable):虚函数表是编译器为每个包含虚函数的类生成的一个隐藏的表。这个表中存储了该类的所有虚函数的指针。
- 每个含有虚函数的类都有一个虚函数表,表中存储了该类的所有虚函数的指针。
- 虚函数表存储了类的虚函数的地址。对于基类中的虚函数,如果在派生类中被重写,那么虚函数表中相应的条目会指向派生类的实现。
-
虚指针(vptr):虚指针是编译器在每个对象实例中添加的一个隐藏指针。这个指针指向该对象所属类的虚函数表。
- 每个对象实例都有一个隐藏的指针,指向该类的虚函数表 ------ vptr。
- 虚指针在对象实例化时被初始化,指向正确的虚函数表。
- 当通过基类指针或引用调用虚函数时,程序通过
vptr
找到对应的vtable
,然后从vtable
中找到实际调用的函数。
虚函数表和虚指针的关系
- 虚指针指向虚函数表。
- 当通过基类指针或引用调用虚函数时,程序会通过虚指针找到虚函数表,然后从虚函数表中找到实际需要调用的函数。
💧虚函数表指针💧
这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;char ch = 'A';
};int main()
{Base bb;cout << sizeof(Base) << endl;return 0;
}
- 可能刚看到这个题目的时候,都会觉得答案是 8 个字节,但是我们在打印后却发现是 16 个字节;这是为什么呢?
- 因为有了虚函数,这个对象里面就多了一个成员,虚函数表指针__vfptr。
为什么会有这个东西? --------- _vfptr
- 是因为我们的 Base类里面有虚函数,含 有虚函数的类里面就会有虚函数表指针。
- 那这里的 虚函数表指针 指向 一张表,即虚函数表(简称虚表),虚函数表里面存的是虚函数的地址。
- 因为Base里面现在只有一个虚函数,所以我们看到它里面现在只有一个元素,就是虚函数Func1的地址。
- 另外我也能知道虚函数表其实就是一个虚函数指针数组,存放虚函数的地址,所以虚函数指针
_vfptr
其实就是一个数组指针(函数指针数组的指针)
💧虚函数表💧
虚函数表是编译器为每个包含虚函数的类生成的一个隐藏的表。这个表中存储了该类的所有虚函数的指针。
针对上面的代码我们做出以下改造:
- 我们增加一个派生类Derive去继承Base
- Derive中重写Func1
- Base再增加一个虚函数Func2和一个普通函数Func3
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。
总结一下派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中;
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
💧虚函数表和虚指针相互关联构成 --- 多态💧
为什么父类指针指向不同的对象就能实现多态呢?
#include <iostream>
using namespace std;
//父类
class Person
{
public:virtual void BuyTicket(){cout << "买票-全价" << endl;}
private:int _b;
};
//子类
class Student : public Person
{
public:virtual void BuyTicket(){cout << "买票-半价" << endl;}
private:int _d;
};
int main()
{Person Mike;Student Johnson;Person* p1 = &Mike;Person* p2 = &Johnson;p1->BuyTicket(); //买票-全价p2->BuyTicket(); //买票-半价return 0;
}
通过监视窗口我们得到以下关系:
两个父类指针分别指向对应的 Mike 与 Johnson 对象,找到对应的虚表,调用对应的函数,即:
- 父类指针p1指向Mike对象,p1->BuyTicket 在 Mike 的虚表中找到的虚函数就是Person::BuyTicket。
- 父类指针p2指向Johnson对象,p2>BuyTicket在Johnson的虚表中找到的虚函数就是Student::BuyTicket。
总结
- 虚函数表(vtable)存储了类的虚函数的指针,每个含有虚函数的类都有自己的虚函数表。
- 虚指针(vptr)是每个对象实例中的隐藏指针,指向对象所属类的虚函数表。
- 当通过基类指针或引用调用虚函数时,程序通过虚指针找到虚函数表,然后从虚函数表中找到实际需要调用的函数,实现多态性。
💢 动态绑定和静态绑定
多态(Polymorphism)是面向对象编程(OOP)中的一个重要概念,它使得对象能够以多种形式出现。多态性主要分为两种类型:
-
编译时多态性(静态多态性):
- 函数重载(Function Overloading)
- 运算符重载(Operator Overloading)
-
运行时多态性(动态多态性):
- 虚函数(Virtual Functions)和覆盖(Override)
- 抽象类(Abstract Classes)和纯虚函数(Pure Virtual Functions)
- 接口(Interfaces)
六、多态的常考面试题
1. 什么是多态?
解答思路:
- 多态性是面向对象编程(OOP)的一个核心概念。
- 在C++中,多态性允许同一个接口调用不同的实现。
- 多态性分为编译时多态性(如函数重载和模板)和运行时多态性(通过虚函数实现)。
2. 解释虚函数和纯虚函数的区别。
解答思路:
- 虚函数(virtual function):在基类中使用
virtual
关键字声明,可以在派生类中重写。通过基类指针或引用调用时,会调用实际对象的函数实现。 - 纯虚函数(pure virtual function):在基类中声明但不提供实现,语法为
virtual void func() = 0;
。包含纯虚函数的类是抽象类,不能实例化,必须在派生类中实现纯虚函数。
3. 什么是抽象类?如何使用抽象类?
解答思路:
- 抽象类是包含至少一个纯虚函数的类。
- 抽象类不能实例化,但可以用作基类。
- 派生类必须实现抽象类中的纯虚函数才能实例化。
4. 解释什么是虚函数表(vtable)和虚指针(vptr)。
解答思路:
- 虚函数表是编译器为每个包含虚函数的类生成的表,存储类的虚函数的指针。
- 虚指针是每个对象实例中的隐藏指针,指向该对象所属类的虚函数表。
- 通过虚指针找到虚函数表,从而调用实际对象的函数实现。
5. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
6. 为什么需要虚析构函数?
- 如果基类指针指向派生类对象,通过基类指针删除对象时,需要虚析构函数来确保调用派生类的析构函数,防止资源泄漏。
- 基类中的析构函数应声明为虚函数。
7. 什么是协变返回类型?
解答思路:
- 协变返回类型允许派生类中的重写函数返回基类函数返回类型的派生类。
- 使得重写函数返回更具体的类型,提高灵活性和类型安全性。
8. 解释重载、重写和隐藏的区别。
解答思路:
- 重载(Overloading):同一作用域内,函数名相同但参数列表不同(参数类型、数量或顺序不同)。
- 重写(Overriding):派生类重新定义基类的虚函数,函数签名必须相同。
- 隐藏(Hiding):派生类定义了与基类同名但参数列表不同的函数或属性,基类成员被隐藏。
9. 讲解构造函数和析构函数在多态中的作用。
解答思路:
- 构造函数不能是虚函数,因为对象的类型在构造时已确定。
- 析构函数应声明为虚函数,以确保通过基类指针删除对象时调用正确的析构函数。
10. 你能解释一下 final 关键字在多态中的作用吗?
解答思路:
final
关键字用于防止类被继承或防止虚函数被重写。- 类名后使用
final
禁止类被继承;虚函数声明后使用final
禁止重写。
七、共勉
以下就是我对 【C++】多态 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对 【C++/STL】set 的理解,请持续关注我哦!!!