【C++】多态

1.多态的概念

概念:通俗来说就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生处不同的状态。

注意:virtual关键字可以修饰原函数,为了完成虚函数的重写同时满足多态的条件之一。

也可以在菱形继承中,去完成虚继承的操作,解决数据冗余和二义性。

但是尽管使用了同一个关键字,他们之间是没有任何关联的。

满足多态的两个条件:

  • 虚函数的重写:基类中的成员函数必须声明为virtual,派生类中可以重写(覆盖)基类的虚函数,提供具体实现。
  • 通过基类指针或引用去调用虚函数:使用基类的指针或引用来指向派生类的对象,并通过它们调用虚函数,以实现动态绑定。

多态的注意事项!

满足多态时:跟指向对象的类型无关,指向哪个对象调用的就是他的虚函数。

不满足多态时:跟调用对象的类型有关,类型是什么调用的就是谁的虚函数。

  • **构造函数不能是虚函数!**在对象构造过程中,虚表(vtable)尚未完全建立,无法实现多态。
  • **析构函数应当是虚函数。**确保通过基类指针删除派生类对象是,派生类的析构函数被正确调用,避免资源泄露。
  • 友元不能是虚函数:友元关系不具备继承性,无法通过友元函数实现多态。
  • 在派生类中重写虚函数时,建议使用 override 关键字,以确保函数确实覆盖了基类的虚函数。
  • 内联函数可以是虚构函数,不过编译器可能会忽略了inline属性,因为虚函数调用需要通过虚表进行动态绑定,不适合内敛优化
  • 静态函数不能是静态指针:因为虚函数需要一个this指针,而静态函数不属于任何对象实例没有this指针,无法放到虚函数表。
  • 虚函数表是在编译阶段生成的,一般情况下存在代码段(常量区)。

2.虚函数的重写

下面引入一个小例子:

class people {
public:
	virtual void BuyTisk() {
		cout << "people: BuyTisk()" << endl;
	}
};
class student : public people {
public:
	virtual void BuyTisk() {
		cout << "student: BuyTisk()" << endl;
	}
};
//void Func(people* ps){
//    ps->BuyTisk();
//}
void Func(people& ps) {
	ps.BuyTisk();
}
void Test01() {
	people ps;
	student st;

	Func(ps);
	Func(st);
}
// 输出
/*
people: BuyTisk()
student: BuyTisk()
*/

注意

  • 派生类/派生列在重写时可以不使用virtual也能构成重写(但是不建议)
  • 如果基类中的方法不使用 virtual,即使派生类中有同名方法,调用时仍会执行基类的方法,不构成多态。
  • 使用 override 关键字可以帮助编译器检查派生类方法是否正确重写了基类的虚函数,避免错误。

此外Func中如果调用的是派生类引用/指针,将无法传递基类对象,后面会做解释.

虚函数的重写:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数的返回值类型、函数名、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

示例

#include<stdio.h>
class CParent {
public: 
    virtual void Intro() {
		printf("I'm a Parent, "); 
    	Hobby();
	}
    virtual void Hobby() {
		printf("I like football!");
	}
};
class CChild : public CParent {
public: 
    void Intro() override {	// 重写 Intro
		printf("I'm a Child, "); 
        Hobby();
	}
	void Hobby() override {	// 重写 Hobby
      	printf("I like basketball!\n");
	}
};
int main(void) {
	CChild* pChild = new CChild();
	//CParent* pParent = (CParent*)pChild;
	CParent* pParent = pChild;	// 等价,向上转型
	pParent->Intro();			// 输出:I'm a Child, I like basketball!
    delete pChild;
	return 0;
}

分析:

  • CParent 类的 IntroHobby 方法被声明为虚函数。
  • CChild 类重写了这两个虚函数。
  • 当通过 CParent 指针调用 Intro 方法时,实际执行的是 CChild 的实现,实现了多态性。

3.多态的例外情况

但也存在两个例外:

3.1 协变(基类与派生类虚函数返回值类型不同)

即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或引用时,称之为协变。(了解)

class A{};
class B : public A {};
class Person {
public:
	virtual A* f() {return new A;}
};
class Student : public Person {
public:
	virtual B* f() {return new B;}
};

分析:

  • 在基类 Person 中,虚函数 f 返回 A*
  • 在派生类 Student 中,重写的 f 返回 B*,这是允许的,因为 BA 的派生类。
  • 协变返回类型增强了类型安全性,使得派生类可以返回更具体的类型。

3.2 析构函数重写(基类与派生类虚构函数名不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,尽管基类与派生类析构函数名字不同,编译器会对其进行特殊处理。

下面我们看一下没有虚构和虚构的情况对比:

  • 没有虚析构:

img

很明显这并不是我们想要的因为如果student的析构函数中有资源要释放而这里没有调用,就很容易发生内存泄漏。由此也可证明:不构成多态时,调用的指针类型是谁就调用谁的析构函数。

  • 有虚析构:

QQ_1721183870472

析构函数的函数名会被处理成destructor,构成重写。

结论

  • 如果基类的析构函数不是虚函数,通过基类指针删除派生类对象时,只会调用基类的析构函数,导致派生类资源未释放,可能引发内存泄漏。
  • 因此,基类的析构函数应始终声明为虚函数,以确保正确的析构链。

* 一个特殊示例

QQ_1721186505919

对象在调用方法时会隐式默认传递一个this指针。

由于B对A构成多态,此时由于test()中传递的this指针指向Bfunc因此会调用B的func而不是A的。

重写的前提是继承,继承是派生类继承基类的接口(函数名,参数,返回值等),不会继承方法的具体实现和缺省参数(且默认参数是在编译时解析的,而不是在运行时解析的。)

放在此案例中就是:B继承Avirtual void func(int val)(当然也包括对test的继承),由于 test 是虚函数,实际调用的是 B 类中的 test 函数(尽管 B 类没有重写 test,但 test 仍然是虚函数)。而重写就是在这个基础上对基类接口的是实现,于是打印了B的输出语句 但是其val无法通过重写改变,故而继续使用A的缺省值。

image-20240717113239106

4.overridefinal

4.1 override 关键字

override 关键字用于标识派生类中的成员函数是对基类中虚函数的重写。它确保派生类函数确实覆盖了基类的虚函数,如果函数签名不匹配,编译器将报错。

作用与优势
  • 编译时检查:确保派生类中的函数正确地重写了基类的虚函数,防止由于函数签名不匹配导致的意外重载。
  • 提高代码可读性:明确表示该函数是对基类虚函数的重写,便于理解类层次结构。
  • 防止错误:避免因拼写错误、参数类型不匹配等问题导致的函数未正确重写。

示例

#include <iostream>
using namespace std;

class Base {
public:
    virtual void show() const {
        cout << "Base show()" << endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    void show() const override { // 正确重写
        cout << "Derived show()" << endl;
    }

    // 错误示例:函数签名不匹配,编译器将报错
    // void show(int val) override { 
    //     cout << "Derived show(int): " << val << endl;
    // }
};

int main() {
    Base* bPtr = new Derived();
    bPtr->show(); // 输出: Derived show()
    delete bPtr;
    return 0;
}

//输出:Derived show()

分析:

  • Derived::show 使用 override 明确表示它是对 Base::show 的重写。
  • 如果 Derived::show 的签名与 Base::show 不匹配(如增加参数),编译器会报错,防止未正确重写虚函数。
注意事项
  • 必须重写基类的虚函数override 关键字只能用于重写基类中的虚函数,不能用于非虚函数。
  • 函数签名必须匹配:包括返回类型、函数名、参数列表及 const 修饰符等,必须与基类的虚函数完全一致(除协变返回类型外)。
  • final 结合使用:可以同时使用 overridefinal,如 void show() const override final;,表示函数重写并禁止进一步重写。

4.2 final 关键字

final 关键字用于阻止类被进一步继承或防止虚函数被重写。它可以应用于类和成员函数,提供更严格的继承控制。

作用与优势
  • 控制继承:防止类被继承,确保设计的封闭性。
  • 防止虚函数重写:阻止派生类进一步重写特定的虚函数,保护函数的实现不被改变。
  • 优化编译器行为:编译器可以进行更多优化,因为知道某些函数不会被重写。
代码示例
  1. 禁止类继承
class FinalClass final { // 禁止任何类继承 FinalClass
public:
    void display() const {
        cout << "FinalClass display()" << endl;
    }
};

// 错误示例:派生类无法继承 FinalClass
// class Derived : public FinalClass {
// };

错误信息:

error: cannot derive from 'final' base 'FinalClass' in derived type 'Derived'
  1. 禁止虚函数重写
#include <iostream>
using namespace std;

class Base {
public:
    virtual void show() const {
        cout << "Base show()" << endl;
    }
    virtual void display() const final { // 禁止进一步重写
        cout << "Base display()" << endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    void show() const override { // 正确重写
        cout << "Derived show()" << endl;
    }

    // 错误示例:尝试重写被 final 修饰的函数,编译器将报错
    // void display() const override { 
    //     cout << "Derived display()" << endl;
    // }
};

int main() {
    Derived d;
    d.show();     // 输出: Derived show()
    d.display();  // 输出: Base display()

    Base* bPtr = &d;
    bPtr->show();     // 输出: Derived show()
    bPtr->display();  // 输出: Base display()

    return 0;
}

/*
输出:
Derived show()
Base display()
Derived show()
Base display()
*/

分析:

  • Base::display 被声明为 final,禁止 Derived 类进一步重写该函数。
  • 如果在 Derived 类中尝试重写 display,编译器将报错,确保 Base 类的实现不被改变。
注意事项
  • override 的结合final 可以与 override 一起使用,增强代码的表达力和安全性。

    class Derived : public Base {
    public:
        void show() const override final { // 重写并禁止进一步重写
            cout << "Derived show()" << endl;
        }
    };
    
  • 适用范围

    • 类级别:适用于不希望类被继承的场景,增强封闭性和安全性。
    • 成员函数级别:适用于需要防止特定虚函数被重写的场景,保护关键功能的实现。
  • 不能用于非虚函数final 关键字只能用于类和虚函数,不能用于非虚成员函数。

4.3 overridefinal 的对比与结合使用

关键字 用途 效果
override 标识派生类中函数是对基类虚函数的重写 确保正确重写,编译时检查签名是否匹配
final 禁止类被继承或禁止虚函数被进一步重写 增强封闭性,防止意外的继承和重写
1. 结合使用示例
#include <iostream>
using namespace std;

class Base {
public:
    virtual void show() const {
        cout << "Base show()" << endl;
    }
    virtual void display() const final { // 禁止重写
        cout << "Base display()" << endl;
    }
    virtual ~Base() {}
};

class Derived : public Base {
public:
    void show() const override final { // 重写并禁止进一步重写
        cout << "Derived show()" << endl;
    }

    // 错误示例:尝试重写被 final 修饰的函数,编译器将报错
    // void display() const override { 
    //     cout << "Derived display()" << endl;
    // }
};

int main() {
    Derived d;
    d.show();     // 输出: Derived show()
    d.display();  // 输出: Base display()

    Base* bPtr = &d;
    bPtr->show();     // 输出: Derived show()
    bPtr->display();  // 输出: Base display()

    return 0;
}
/*
输出:
Derived show()
Base display()
Derived show()
Base display()
*/

分析:

  • Derived::show 使用 override 确保正确重写 Base::show,并使用 final 防止进一步重写。
  • Base::display 被声明为 final,禁止 Derived 类重写该函数。

4.4 总结与最佳实践

overridefinal 关键字在 C++ 中提供了更强大的工具,以增强继承和多态性的安全性与可维护性。合理使用这两个关键字,可以:

  • 确保正确的函数重写:通过 override,防止因函数签名不匹配导致的意外重载,确保多态性正常工作。
  • 控制继承和函数重写:通过 final,明确表达设计意图,防止类被继承或函数被进一步重写,增强代码的封闭性和稳定性。
  • 提升代码可读性与安全性:明确的关键字使用使得类层次结构更加清晰,减少隐藏的错误,提高代码质量。

最佳实践:

  1. 始终使用 override

    • 在派生类中重写基类的虚函数时,始终使用 override 关键字。
    • 这不仅提高代码的可读性,还利用编译器的检查机制,防止错误的重写。
  2. 合理使用 final

    • 当类不应被继承时,将类声明为 final
    • 当虚函数不应被进一步重写时,将函数声明为 final
    • 这有助于保护类的实现,防止不必要的继承和重写,保持设计的一致性和稳定性。
  3. 结合 overridefinal

    • 在派生类中重写基类虚函数时,可以同时使用 overridefinal,明确表示该函数是重写并禁止进一步重写。
    • 例如:void show() const override final;
  4. 避免混淆用途

    • 理解 override 仅用于重写虚函数,final 可用于类和虚函数,避免将它们用于非虚函数或错误的上下文。

通过遵循这些最佳实践,开发者可以利用 overridefinal 关键字编写更加安全、清晰和高效的 C++ 代码,充分发挥多态性和继承的优势,同时避免潜在的错误和设计缺陷。

4.5 重载、覆盖(重写)、重定义(隐藏)的对比

QQ_1721190570363

特性 重载(Overloading) 重写(Overriding) 重定义(Hiding)
作用域 同一类内 派生类和基类之间 派生类和基类之间
是否需要继承
关键字 virtualoverride
参数列表 必须不同 必须相同 可以不同
返回类型 可以不同 必须相同(或协变) 可以不同
调用时间 编译时决定 运行时决定 编译时决定
用途 提供同名函数的不同版本 实现多态性 派生类中隐藏基类同名函数
#include <iostream>
using namespace std;

class Base {
public:
    void func() {
        cout << "Base::func()" << endl;
    }
    virtual void virtualFunc() {
        cout << "Base::virtualFunc()" << endl;
    }
};

class Derived : public Base {
public:
    void func(int val) { // 重载,隐藏 Base::func()
        cout << "Derived::func(int): " << val << endl;
    }
    void virtualFunc() override { // 覆盖 Base::virtualFunc()
        cout << "Derived::virtualFunc()" << endl;
    }
    void hiddenFunc() { // 新增函数
        cout << "Derived::hiddenFunc()" << endl;
    }
};

int main() {
    Derived d;
    Base* bPtr = &d;

    // 重载与隐藏
    //d.func();        // 错误:Derived 没有无参的 func(),需显式调用 Base::func()
    bPtr->func(); // Base::func(),静态绑定

    d.Base::func();  // 调用 Base::func()
    d.func(10);      // 调用 Derived::func(int)

    // 覆盖与多态
    bPtr->virtualFunc(); // 调用 Derived::virtualFunc()

    return 0;
}
/*
输出:
Base::func()
Derived::func(int): 10
Derived::virtualFunc()
*/

分析:

  • Derived::func(int) 通过重载隐藏了 Base::func(),无法通过 Derived 对象直接调用无参的 func()
  • 通过作用域限定符 d.Base::func() 可以访问被隐藏的基类函数。
  • virtualFunc()Derived 类覆盖,通过基类指针调用时,实际执行的是 Derived 的实现,实现了多态性。

动态绑定与静态绑定

  • 静态绑定又称前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
  • 动态绑定又称为后期绑定(晚绑定),实在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数也称为动态多态。

总结一句话就是:编译器会对非虚函数使用静态绑定,对虚函数使用动态绑定。

5.抽象类

概念:在虚函数的后面写=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后不能实例化出对象,只有重写出纯虚函数,派生类才能实例化出对象(否则不过是继承了抽象类的抽象类)。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承。

示例

#include <iostream>
using namespace std;

class Shape {
public:
    virtual void draw() const = 0; // 纯虚函数
    virtual ~Shape() {}
};

class Circle : public Shape {
public:
    void draw() const override {
        cout << "Drawing Circle" << endl;
    }
};

class Square : public Shape {
public:
    void draw() const override {
        cout << "Drawing Square" << endl;
    }
};

int main() {
    // Shape s; // 错误:抽象类不能实例化

    Shape* shapes[] = { new Circle(), new Square() };
    for(auto shape : shapes) {
        shape->draw();
        delete shape;
    }

    return 0;
}
/*
输出:
Drawing Circle
Drawing Square
*/

6.虚函数表

虚函数表(V-Table)是编译器为支持多态性而自动生成的数据结构。每个包含虚函数的类都有一个虚函数表,存储指向虚函数的指针。对象通过虚表指针(Vptr)指向其类的虚表,实现动态绑定。

虚函数与虚继承的不同

特性 虚函数 虚继承
定义 声明为 virtual 的成员函数 使用 virtual 关键字声明的继承方式
使用场景 通过基类指针或引用调用派生类的函数 菱形继承结构,确保派生类中只有一个基类的子对象
关键字 virtual virtual
主要功能 允许在运行时根据对象的实际类型调用相应函数,实现动态绑定和多态性 确保在多重继承中基类只有一个实例,防止基类成员的多次拷贝,避免数据冗余和二义性

示例

QQ_1721197603334

了解了虚继承的概念后再来看这个问题就很好理解了,上面代码以x86平台为例(x86中指针为4字节,x64中指针为8字节),虚函数在创建过程编译器会默认添加一个函数表指针_vfptr其指向虚函数表,此外要注意的是,如果基类已经存在虚表指针,将会继承给派生类,此时无论派生类是否重写基类的虚函数都会共用一张虚表。

再来看一个例子:

QQ_1721198512733

函数在调用Func时会做判断:如果满足多态的两个条件就会去_vfptr中去找对应的地址(虚函数和覆盖),指向谁就调用谁,如果不满足就根据参数类型调用。

以上面Func(Person& p)为例,如果Person中的方法不是虚函数,在调用Func时就根据括号中的参数类型(Person)来调用。

image-20240717145133337

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

下面通过分析汇编代码可以看出,那个满足多态后的函数调用,不是编译时确定的,是运行之后找对象中找到的,而不满足多态的函数调用是编译时就确定的–>虚表是编译时生成的。

如果使用指针对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法,这称为动态绑定。

image-20240717150645929

派生类就是把基类的方法拷贝过来对重写的部分进行覆盖。没有重写的虚函数仍然指向基类的虚函数。而派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

:虚函数存在哪?虚表存在哪?

答:虚表通常存储在程序的只读数据段(代码段\常量区)。需要注意的是虚表中存的是函数指针不是虚函数

上一篇:【数据结构和算法】三、动态规划原理讲解与实战演练


下一篇:MusicFree 0.4.3 | 免费畅听全网音乐,附加教程和插件链接