前言:
在之前的学习过程中,我们已经对继承进行了详细的学习和了解。今天,我将带领大家学习的是关于 多态 的基本知识。
目录
(一)多态的概念
1、概念
(二)多态的定义及实现
1、多态的构成条件
2、虚函数
1️⃣纯虚函数
2️⃣ 面试题:虚函数与纯虚函数的区别
3、虚函数的重写
1️⃣虚函数重写的两个例外:
2️⃣析构函数的重写(基类与派生类析构函数的名字不同)
4、C++11 override 和 final
5、重载、覆盖(重写)、隐藏(重定义)的对比(面试题)
(三)抽象类
1、概念
2、接口继承和实现继承
(四)多态的原理
1、虚函数表
2、多态的原理
3、动态绑定与静态绑定
总结
(一)多态的概念
1、概念
- 多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
【小结】
1、这些例子中,不同的对象根据自身的特性和行为对相同的消息做出了不同的响应,展现了多态的概念;
2、通过多态性,我们可以灵活地处理不同的对象,并针对每个对象的特点执行适当的操作,提高代码的可扩展性和复用性。
(二)多态的定义及实现
1、多态的构成条件
- 1. 必须通过基类的指针或者引用调用虚函数
- 2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
【小结】
要实现多态性,需要满足以下条件:
继承关系:存在一个继承关系的类层次结构,包括基类和派生类。派生类继承了基类的属性和方法。
方法重写:在派生类中重新定义(重写)与基类中相同名称的方法。子类通过重写基类方法来赋予自己独特的行为。
向上转型:将派生类的对象赋值给基类的引用变量。这样可以使得基类引用指向派生类的对象,从而可以调用派生类中重写的方法。
运行时绑定:在运行时确定调用哪个方法,实现动态绑定。由于基类引用指向的是派生类对象,因此根据实际的对象类型来决定调用哪个子类的方法。
当满足以上条件时,就实现了多态性。通过使用多态,可以提高代码的灵活性、可扩展性和可维护性,同时减少了代码的重复编写。
2、虚函数
【注意】
这里有一个大家容易混淆的点:那就是虚函数与之前学到的虚继承之间有什么联系吗?(强调:二者之间无联系,只是公用一个关键字而已)
C++中的虚函数和虚继承是两个不同的概念,它们在面向对象编程中发挥不同的作用。
- 下面是对这两个概念的解释以及它们之间的关系:
-
虚函数(Virtual Functions):
- 虚函数是用于实现动态多态性的机制。通过将基类中的函数声明为虚函数,可以在派生类中重写(override)该函数来实现不同的行为。
- 当通过基类指针或引用调用虚函数时,实际执行的函数取决于指针或引用指向的对象的类型,而不是指针或引用本身的类型。
- 通过使用虚函数,可以在运行时决定调用哪个函数,实现对象的多态行为。
-
虚继承(Virtual Inheritance):
- 虚继承是解决多继承带来的问题的一种机制。当一个类需要从多个基类继承时,如果其中的一些基类有共同的基类,那么在继承关系中就会产生多个对共同基类的实例。
- 通过使用虚继承,可以确保在继承关系中只有一个对共同基类的实例。这样可以避免派生类中对共同基类的成员访问和命名冲突。
- 虚继承会在派生类对象中引入虚基类指针(Virtual Base Pointer)和虚基类表(Virtual Base Table),用于管理共享基类的访问。
关系:
- 虚函数和虚继承是C++语言提供的两种不同的特性,它们在语法和作用上是独立的。
- 虚函数通过动态绑定实现运行时多态性,而虚继承通过调整继承关系来解决多继承带来的问题。
- 在某些情况下,我们可能需要同时使用虚函数和虚继承。例如,在存在多继承关系的类层次结构中,如果基类中有虚函数,并且派生类需要重写这些函数,那么可以通过虚继承来消除多个对共同基类的实例,同时实现动态多态性。
1️⃣纯虚函数
纯虚函数(Pure Virtual Function)是一种在基类中声明但不进行实现的虚函数。它通过在函数声明末尾添加 = 0
来标识,例如 virtual void func() = 0;
。
纯虚函数在基类中起到以下作用:
- 接口定义:纯虚函数可以被视为基类对于派生类的接口定义,定义了派生类必须实现的方法。
- 强制继承类实现:通过在基类中声明纯虚函数,强制要求派生类必须提供实现,确保每个派生类都具备相同的接口。
- 抽象类:包含一个或多个纯虚函数的类称为抽象类,无法实例化对象。抽象类通常用作基类,用于定义通用接口和行为。
派生类必须实现基类中的纯虚函数,如果未能实现,则派生类也成为了抽象类。只有当派生类实现了基类的所有纯虚函数时,才能实例化派生类的对象。
以下是一个展示纯虚函数的代码示例:
#include<iostream>
using namespace std;
// 抽象基类 Animal
class Animal
{
public:
// 纯虚函数,用于定义接口
virtual void makeSound() = 0;
};
// 派生类 Dog
class Dog : public Animal
{
public:
// 实现基类的纯虚函数
void makeSound() override
{
cout << "汪汪!" << endl;
}
};
// 派生类 Cat
class Cat : public Animal
{
public:
// 实现基类的纯虚函数
void makeSound() override
{
cout << "喵喵!" << endl;
}
};
int main()
{
Dog dog;
Cat cat;
dog.makeSound(); // 输出:汪汪!
cat.makeSound(); // 输出:喵喵!
return 0;
}
【说明】
- 在上面的代码中,
Animal
是一个抽象基类,其中声明了一个纯虚函数makeSound()
。而Dog
和Cat
是派生类,它们必须实现makeSound()
函数才能被实例化。 - 通过上述代码,我们可以看到通过纯虚函数的方式,我们定义了一个通用的接口
makeSound()
,并要求派生类提供它们特定的实现。在main()
函数中,我们创建了Dog
和Cat
对象,并调用它们的makeSound()
函数,分别输出对应的结果!!
总结来说,纯虚函数是一种没有具体实现的函数,用于定义基类的接口和要求派生类提供实现。
2️⃣ 面试题:虚函数与纯虚函数的区别
首先,给大家先抛出概念性的东西,大家有个认识:
- 1. 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
- 2. 虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。
- 3. 虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。
- 4. 虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。
- 5. 虚函数的定义形式: virtual{} ; 纯虚函数的定义形式: virtual { } = 0 ;
- 6. 在虚函数和纯虚函数 的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。
class A
{
public:
virtual void foo()
{
cout << "A::foo() is called" << endl;
}
};
class B :public A
{
public:
void foo()
{
cout << "B::foo() is called" << endl;
}
};
int main(void)
{
A* a = new B();
a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
return 0;
}
输出结果如下:
【说明】
- 这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时 刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以 被成为“虚”函数。
- 虚函数只能借助于指针或者引用来达到多态的效果。
virtual void funtion1()=0
3、虚函数的重写
概念:
- 是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重 写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数;
- 重写的基类中被重写的函数必须有virtual修饰。
接下来,我简单的用代码展示一下:
#include<iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person {
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
输出显示:
【注意】
- 在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因 为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
1️⃣虚函数重写的两个例外:
- 例如以下代码:
class a {};
class b : public a
{
};
class person
{
public:
virtual a* f()
{
cout << "new A" << endl;
return nullptr;
}
};
class student : public person
{
public:
virtual b* f()
{
cout << "new B" << endl;
return nullptr;
}
};
void Func(person* p)
{
p->f();
delete p;
}
int main()
{
Func(new person);
Func(new student);
return 0;
}
输出显示:
而当我们想返回的是对象的时候,此时编译器就会发生报错:
【说明】
- 基类
Person
的虚函数f()
返回类型是A*
,而派生类Student
的重写函数f()
的返回类型是B*
,这违反了上述规则,因为B*
不是A*
的派生类。 - 在C++中,如果要进行虚函数重写,返回类型必须是完全匹配的,或者是基类返回类型的派生类。在这种情况下,你可以将
B*
转换为A*
,然后在函数中返回一个派生类对象的指针。
2️⃣析构函数的重写(基类与派生类析构函数的名字不同)
首先,我们先看这样的场景:
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person p;
Student s;
return 0;
}
- 输出结果:
经过我们的分析,发现上述代码并没有问题。紧接着,我把代码改动一下,看最终的结果是什么!
- 具体如下:
【说明】
- 上述代码存在内存泄露问题。
- 在代码中,派生类
Student
是基类Person
的子类,并且在派生类中定义了析构函数。在main()
函数中,使用了动态内存分配来创建了两个对象p1
和p2
,分别指向Person
类型和Student
类型; - 然而,在释放这些动态分配的对象时,只调用了
delete
关键字,却没有使用虚析构函数。由于基类Person
的析构函数不是虚函数,因此在通过基类指针p2
删除指向派生类对象的指针时,将不会调用派生类Student
的析构函数,可能导致资源泄露。
为了解决这个问题:只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函 数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
- 修正后的代码如下所示:
【说明】
- 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同;
- 虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处 理,编译后析构函数的名称统一处理成destructor。
4、C++11 override 和 final
- final:修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
virtual void Drive() final
{}
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car {
public:
void Drive()
{}
};
class Benz :public Car {
public:
virtual void Drive() override
{
cout << "Benz-舒适" << endl;
}
};
int main()
{
Car cc;
Benz bb;
cc.Drive();
bb.Drive();
return 0;
}
输出显示:
因此,想要达到相应的效果,我们需要在基类中用虚函数实现:
class Car {
public:
virtual void Drive()
{}
};
class Benz :public Car {
public:
virtual void Drive() override
{
cout << "Benz-舒适" << endl;
}
};
int main()
{
Car cc;
Benz bb;
cc.Drive();
bb.Drive();
return 0;
}
5、重载、覆盖(重写)、隐藏(重定义)的对比(面试题)
上述对于重写我已经实现。接下来,我简单的实现一下剩下的两类:
- 我们在平时写代码中会用到几个函数但是他们的实现功能相同,但是有些细节却不同;
- 函数重载是 指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
// 重载函数
void print(int num) {
cout << "Integer: " << num << endl;
}
void print(float num) {
cout << "Float: " << num << endl;
}
void print(const char* str) {
cout << "String: " << str << endl;
}
int main() {
print(10); // 调用 print(int) 重载
print(3.14f); // 调用 print(float) 重载
print("Hello, World!"); // 调用 print(const char*) 重载
return 0;
}
输出展示:
💨 隐藏(重定义)
- 在C++中,函数隐藏(Function Hiding)也称为函数重定义(Function Redefinition),指的是派生类中的函数隐藏了基类中的同名函数;
- 这种情况下,无法使用基类指针或引用调用派生类中隐藏的函数。
//函数隐藏
class Base
{
public:
void print()
{
cout << "Base::print()" << endl;
}
};
class Derived : public Base
{
public:
void print()
{
cout << "Derived::print()" << endl;
}
};
int main()
{
Base base;
Derived derived;
base.print(); // 调用基类 Base 的 print()
derived.print(); // 调用派生类 Derived 的 print()
// 使用基类指针或引用调用派生类中隐藏的函数
Base* basePtr = &derived;
basePtr->print(); // 调用基类 Base 的 print(),派生类的函数被隐藏
return 0;
}
输出演示:
【说明】
- 在上述示例中,我们创建了一个基类
Base
和一个派生类Derived
。两个类中都定义了名为print()
的函数,其中派生类Derived
的print()
函数隐藏了基类Base
中的同名函数。 - 在
main()
函数中,我们分别创建了一个基类对象base
和派生类对象derived
。然后通过调用base.print()
和derived.print()
,可以分别看到基类和派生类中的print()
函数的输出结果。 - 接下来,我们使用基类指针
basePtr
指向派生类对象derived
,然后通过basePtr->print()
调用print()
函数。这时,会发现调用的是基类Base
中的print()
函数,而派生类中的函数被隐藏而不可访问。
(三)抽象类
1、概念
- 例如以下代码:
class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
输出显示:
2、接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
(四)多态的原理
1、虚函数表
首先,这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
char _ch;
};
int main()
{
Base b;
cout << sizeof(Base) << endl;
return 0;
}
输出显示:
【说明】
那么派生类中这个表放了些什么呢?我们接着往下分析:
- 1.我们增加一个派生类Derive去继承Base
- 2.Derive中重写Func1
- 3.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;
}
通过调试,我们可以得到以下这正图片:
【说明】
1. 派生类对象 d 中也有一个虚表指针, d 对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。2. 基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现 Func1 完成了重写,所以 d 的虚表 中存的是重写的 Derive::Func1 ,所以虚函数的重写也叫作覆盖 ,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。3. 另外 Func2 继承下来后是虚函数,所以放进了虚表, Func3 也继承下来了,但是不是虚函数,所以不会放进虚表。4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr 。5. 总结一下派生类的虚表生成:
- a.先将基类中的虚表内容拷贝一份到派生类虚表中
- b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
6. 这里还有一个童鞋们很容易混淆的问题: 虚函数存在哪的?虚表存在哪的?
- 虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。
- 但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。
- 另外对象中存的不是虚表,存的是虚表指针。
2、多态的原理
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person mike;
Func(mike);
Student john;
Func(john);
return 0;
}
接下来,我们对代码进行调试观察:
【说明】
- 1. 观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚 函数是Person::BuyTicket。
- 2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中 找到虚函数是Student::BuyTicket。
- 3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
- 4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调 用虚函数。反思一下为什么?
- 5. 再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行 起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
💨 其次,我在带大家看下底层的汇编指令,看多态状态和不是多态状态下的场景:
3、动态绑定与静态绑定
以下是一个示例,展示了如何在 C++ 中实现静态绑定:
class Base
{
public:
void display()
{
cout << "买票-全价" << endl;
}
};
class Derived : public Base
{
public:
void display()
{
cout << "买票-半价" << endl;
}
};
int main() {
Base baseObj;
Derived derivedObj;
baseObj.display(); // 静态绑定,调用 Base 类的 display() 函数
derivedObj.display(); // 静态绑定,调用 Derived 类的 display() 函数
return 0;
}
输出显示:
【说明】
- 由于函数调用使用的是静态绑定,编译器在编译时就知道要调用哪个函数。因此,
baseObj.display()
调用了Base
类的display()
函数,而derivedObj.display()
调用了Derived
类的display()
函数。
这就是 C++ 中如何实现静态绑定的方式。可以看出,在没有使用虚函数或基类指针/引用的情况下,默认使用的是静态绑定。
- 1. 虚函数。基类中必须有虚函数,在派生类中必须重写虚函数。
- 2. 通过基类类型的指针或引用来调用虚函数。
class Base {
public:
virtual void display() {
cout << "Base::display()" << endl;
}
};
class Derived : public Base {
public:
void display() override // 使用 override 关键字指明这是一个重写的虚函数
{
cout << "Derived::display()" << endl;
}
};
int main()
{
Base baseObj;
Derived derivedObj;
Base* ptr1 = &baseObj; // 基类指针指向基类对象
Base* ptr2 = &derivedObj; // 基类指针指向派生类对象
ptr1->display(); // 动态绑定,调用 Base 类的 display() 函数
ptr2->display(); // 动态绑定,调用 Derived 类的 display() 函数
return 0;
}
输出展示:
【说明】
- 由于函数调用使用的是动态绑定,当我们通过指针调用
display()
函数时,实际调用的函数版本根据指针指向的对象类型来确定; - 因此,
ptr1->display()
调用了基类Base
的display()
函数,而ptr2->display()
调用了派生类Derived
的display()
函数。
这就是在 C++ 中实现动态绑定的方式。通过使用虚函数和基类指针/引用,我们能够在运行时根据对象的实际类型确定要调用的函数版本。
最后给大家推荐一篇文章,帮助大家更好的理解:
- C++ 虚函数表解析
总结
到此,关于多态相关的知识便讲解结束了。感谢大家的观看与支持!!!