“码”上认亲!面向对象之多态:一脉相承的「同名不同用」


个人主页

🎬 个人主页Vect个人主页

🎬 GitHubVect的代码仓库

🔥 个人专栏: 《数据结构与算法》《C++学习之旅》《计算机基础

⛺️Per aspera ad astra.



多态是面向对象的一大特性,通俗理解即是 不同类型对象去做同一个行为,产生的结果不同,比如“叫”这个动作,狗、猫、牛同样是“叫”,发声结果不一样,这就是多态

1.为什么需要多态?

在上一章中,CarpTunaFish那里继承了swim(),但是,CarpTuna都提供了自己的swim()实现,而它们都是鱼类,那么如果将Carp实例化出的对象作为实参传给Fish参数,并通过这个参数调用swim(),最终执行的是Fish::swim(),而不是Fish::swim(),代码如下:

class Fish {
public:
	void swim() { cout << "Fish swims" << endl; }
};

class Carp : public Fish {
public:
	void swim() { cout << "Carp swims" << endl; } // 重写swim
};

void makeFishSwim(Fish& fish) { fish.swim(); }

void test1() {
	Carp myLunch;
	myLunch.swim();
	makeFishSwim(myLunch); //  想要输出Carp swims 但是并未输出
}

分析:

Carp继承了Fish,并且重写了Fish::swim()16行传入的Carp对象,但是 makeFishSwim(Fish&)也将其视为Fish,进而调用Fish::swim。而我们想要的结果是Carp对象表现出鲤鱼的行为,即便是通过Fish调用swim也能表现鲤鱼行为(不同对象做同一种动作,产生的结果不同),让Fish参数表现出实际类型(派生类)的行为结果,可将Fish::swim()声明为虚函数

2. 使用虚函数实现多态

可以通过基类指针基类引用,这个指针或引用可以指向FishCarpTuna对象,但是我们无需关心指向的是哪种对象,只要这个指针或引用调用了swim,就可以实现不同类型对象的不同行为结果

class Fish {
public:
	// 声明为虚函数 可实现多态行为
	virtual void swim() { cout << "Fish swims" << endl; }
};

class Carp : public Fish {
public:
	virtual void swim() { cout << "Carp swims" << endl; } // 重写swim
};
class Tuna : public Fish {
public:
	virtual void swim() { cout << "Tuna swims" << endl; } // 重写swim
};

void makeFishSwim(Fish& fish) { fish.swim(); }

void test1() {
	Carp myLunch;
	Tuna mydinner;
	makeFishSwim(myLunch); 
	makeFishSwim(mydinner);
}

分析:这次没有调用Fish::swim(),覆盖重写了Fish::swim()的动作,重写的函数优先于被声明为虚函数的Fish::swim(),这意味着可以通过Fish&参数调用派生类定义的swim(),而无需关心这个参数指向的是那个类型的对象

总结一下多态构成条件:

  1. 实现基类虚函数重写,这里的重写是重写函数行为即函数体内的内容,而返回类型、函数名、参数列表要和基类函数相同
  2. 基类指针或基类引用调用虚函数
  3. 在这里插入图片描述

3. 基类析构函数重写

如果基类指针指向派生类对象,并通过该指针调用了delete时,结果如何?

class Fish {
    public:
    Fish() { cout << "Fish 构造" << endl; }
    ~Fish() { cout << "Fish 析构" << endl; }
};

class Carp : public Fish {
    public:
    Carp() { cout << "Carp 构造" << endl; }
    ~Carp() { cout << "Carp 析构" << endl; }
};

// 使用new在堆区中实例化的派生类对象 如果将其赋给基类指针
// 并通过该指针调用 delete 将不会调用派生类的析构函数
// 这可能导致资源未释放 内存泄露等问题
void deleteFishMemory(Fish* pFish) { delete pFish; }

void test2() {
    cout << "== 演示开始" << endl;
    // 使用new在堆区中实例化的派生类对象 如果将其赋给基类指针
    // 并通过该指针调用 delete 将不会调用派生类的析构函数
    // 这可能导致资源未释放 内存泄露等问题
    Carp* pCarp = new Carp;
    cout << "==开始删除" << endl;
    // 在析构过程中 需要调用所有相关的析构 但是!!!!
    deleteFishMemory(pCarp); // 没有对Carp进行清理!!!! ---->基类析构函数构造成虚函数!!!

    cout << endl << "Carp 在栈区" << endl;
    Carp myDinner;
    cout << "== 析构行为" << endl;
}

对于使用new在堆区中实例化的派生类对象,如果将其赋给基类指针,并通过该指针调用delete,将不会调用派生类的析构函数。这可能导致资源未释放、内存泄露等问题

所以需要给基类析构函数构造为虚函数,编译器会对析构函数做特殊处理,变成destructor,仍然满足函数重写的规则

class Fish {
public:
	Fish() { cout << "Fish 构造" << endl; }
	virtual ~Fish() { cout << "Fish 析构" << endl; }
};

class Carp : public Fish {
public:
	Carp() { cout << "Carp 构造" << endl; }
	~Carp() { cout << "Carp 析构" << endl; }
};

// 使用new在堆区中实例化的派生类对象 如果将其赋给基类指针
// 并通过该指针调用 delete 将不会调用派生类的析构函数
// 这可能导致资源未释放 内存泄露等问题
void deleteFishMemory(Fish* pFish) { delete pFish; }

void test2() {
	cout << "== 演示开始" << endl;
	// 使用new在堆区中实例化的派生类对象 如果将其赋给基类指针
	// 并通过该指针调用 delete 将不会调用派生类的析构函数
	// 这可能导致资源未释放 内存泄露等问题
	Carp* pCarp = new Carp;
	cout << "==开始删除" << endl;
	// 在析构过程中 需要调用所有相关的析构 但是!!!!
	deleteFishMemory(pCarp); // 没有对Carp进行清理!!!! ---->基类析构函数构造成虚函数!!!

	cout << endl << "Carp 在栈区" << endl;
	Carp myDinner;
	cout << "== 析构行为" << endl;
}

这样Carp也做了资源清理工作

4. C++11的 override 和 final

C++对于函数重写要求较高,我们很容易疏忽,可能会导致函数名字字母次序写反而构成不了重载,这种错误在编译期间是不报错的,只会在程序运行时得不到预期结果。

final:修饰虚函数,表示该虚函数不能再被重写(上一篇修饰类的话,表明类不能被继承)

class Car {
    public:
    virtual void drive() { cout << "Car" << endl; }
};
class Benz : public Car{
    public:
    virtual void drive() final { cout << " Benz 豪华" << endl; } // 虚函数不能再被重写
};

override:检查派生类虚函数是否重写了基类的某个虚函数,若没有重写编译报错

class Car {
    public:
    virtual void drive() { cout << "Car" << endl; }
};
class BMW : public Car {
    public:
    virtual void drive() override { cout << "BWM 运动" << endl; }
};

5. 多态的原理—>虚函数表

5.1.虚函数表

class Base {
public:
	virtual void func1() { cout << "Base" << endl; }
private:
	int _b = 1;
};
void testSize() { cout << sizeof(Base) << endl; }

除了_b成员,还多了一个_vfptr(virtual function ptr)存在对象的前面,对象中的这个指针称为虚函数表指针

在这里插入图片描述

一个含有虚函数的类中至少都有一个虚函数表指针,因为虚函数的地址要被存放到虚函数表中,那么派生类中这个表中放了什么呢?我们继续扩充代码:

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func3() { cout << "Base::fun3" << endl; }
private:
	int _b = 1;
};

class Derived : public Base {
public:
	void func1() { cout << "Derived::func1" << endl; } // 只重写func1
private:
	int _d = 0;
};

void testShow() {
	Base b;
	Derived d;
}

通过调试可以知道:

  • 派生类对象d也有一个虚表指针
  • 基类b对象和派生类d对象的虚表不同,** func1完成重写,所以d的虚表中存的是重写的Derived::func1,** 而func2未被重写,所以两份虚表中的func2都是Base::func2,地址相同。

在这里插入图片描述

在这里插入图片描述

  • func3也继承了,但不是虚函数,不会放到虚表中

  • 虚函数表本质是一个存虚函数指针的指针数组

  • 派生类虚表生成过程:

    1. 先将基类中的虚表内容拷贝一份到派生类虚表中
    2. 若派生类重写了基类中的某个虚函数,派生类用自己的虚函数覆盖虚表中基类的虚函数
    3. 派生类自己新增加的虚函数按照其在派生类中的声明次序增加到派生类虚表的最后
  • 虚函数表指针存在对象中,虚函数表存在只读常量区,虚函数存在代码段中

5.2. 多态的原理

多态何时发生?

只有通过基类指针或引用调用虚函数时,才会发生动态绑定(根据对象的动态类型走虚函数表)动态绑定是在运行时发生的

Base  b;
Derived d;

Base* pb = &b;
Base* pd = &d;   // 指向“派生类对象的基类子对象”

pb->func1();   // Base::func1
pd->func1();   // Derived::func1  ← 多态
pd->func2();   // Base::func2     ← 未重写,落回基类

直接用对象名(按值)调用或者非虚函数,都是静态绑定(在编译时发生):
b.func1(); 调的就是 Base::func1()d.func3(); 只是普通函数,不走虚表。

为何 func3()不参与多态

func3 不是虚函数,不会进虚函数表;通过 Base*/Base& 调它也只会静态绑定到 Base::func3()

析构函数要点

只要类里有虚函数,就应给基类一个虚析构函数,否则通过基类指针 delete 派生对象会只调用基类析构,导致资源泄露:

class Base {
public:
    virtual ~Base() = default;  // 建议
    virtual void func1();
    virtual void func2();
    void func3();
};

6. 抽象类和纯虚函数

在虚函数的后面写上 “=0” ,则这个函数为纯虚函数。原理

包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

class Fish {
public:
	virtual void swim() = 0; // 声明纯虚函数
};

class Carp : public Fish {
public:
	void swim() override { cout << "Carp swims" << endl; } // 必须重写纯虚函数
};

class Tuna : public Fish {
public:
	void swim() override { cout << "Tuna swims" << endl; } // 必须重写纯虚函数
};

void test() {
	Fish* carp = new Carp;
	carp->swim();
	Fish* tuna = new Tuna;
	tuna->swim();
}

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。

虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

7.重载 / 模板 / 重写区分

1) 重写(Override,运行期多态)

目的:替换基类虚函数行为,支持多态。

位置:派生类对基类的虚函数进行实现替换。

特征

  • 基类函数必须是 virtual
  • 函数签名一致
  • 建议在派生端加 override,不匹配会编译报错
    示例
struct Base { virtual void f(int) const; };
struct D : Base { void f(int) const override; };  //  重写

易错:签名微差(如少了 const、引用限定、默认参数不算签名的一部分)会变成重载而非重写。

2) 重载(Overload,编译期选择)

目的:同一作用域下,让同名函数根据参数列表不同有多个版本。
位置:同一个类/命名空间内(包括派生类“继承来”的同名也会形成可见性关系)。
特征

  • 同名参数个数或类型不同;与返回值无关
  • 编译器在编译期做重载决议。
    示例
void g(int);
void g(double);      // 重载
// void g(int) -> int; // 仅改返回值  不构成重载

易错:把想“重写”的函数写成了“参数略不同”的版本,结果成了重载,导致多态失效。

3) 模板(Templates,编译期生成)

目的:写一份泛型代码,编译期对具体实参实例化出具体函数/类。

位置:函数模板、类模板等

特征

  • 模板不是多态机制;它是编译期多态/泛型(通过实例化/重载决议/特化选择)。
  • 模板函数可以与普通函数/重载同时参与匹配,规则遵循“更佳匹配/特化优先”。
    示例
template<class T>
void h(T);       // 模板

void h(int);     // 非模板重载
// 调用 h(42) 通常选非模板版本(更佳匹配)

8. 总结

为什么需要多态
希望“通过基类指针/引用调用同名接口”,实际执行对象的动态类型对应的实现。若基类函数不为虚,调用会静态绑定到基类版本,违背“同接口不同行为”的初衷。

如何实现多态

  1. 基类将需要多态的函数声明为 virtual
  2. 派生类重写(函数签名一致)该虚函数;
  3. 通过基类指针或引用调用虚函数 ⇒ 运行期根据对象的 vptr→vtable 动态分派。

虚表与对象布局

  • 每个含虚函数的对象里有一根虚表指针。
  • 虚函数表位于只读数据段(如 .rodata/.rdata),表项是虚函数的地址;函数代码.text
  • 派生类的虚函数表与基类“形状”一致:覆盖处放派生版本;未覆盖沿用基类版本;新增虚函数按声明顺序追加。

多态何时发生

只有基类指针/引用 + 虚函数才有动态绑定。

直接用对象按值调用/非虚函数 ⇒ 静态绑定。

析构函数要点

只要类有虚函数/要“多态删除”,务必让基类析构为虚virtual ~Base() = default;,否则 delete Base* 指向 Derived 会只析构基类,可能泄漏。

纯虚函数与抽象类

virtual void f() = 0; 声明接口约束;包含纯虚的类不可实例化;派生类必须重写后方可实例化。

评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值