C++进阶——多态

发布于:2025-09-08 ⋅ 阅读:(22) ⋅ 点赞:(0)

ʕ • ᴥ • ʔ

づ♡ど

 🎉 欢迎点赞支持🎉

个人主页:励志不掉头发的内向程序员

专栏主页:C++语言


前言

学习了继承以后,我们接着来看看我们继承的延伸—多态,多态的内容也是非常的多,我们大家得仔细学习,我们想要成为 C++ 大佬是无论如何都绕不开我们继承和多态的。


一、多态的概念

多态通俗来说就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态。

编译时多态主要就是我们前面讲的函数重载和函数模板,他们通过传不同类型的参数就可以调用不同的函数,通过参数的不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如吃饭这个行为,当中国人吃饭时,拿的是筷子调羹;美国人吃饭时,拿的是刀和叉子;印度人吃饭时直接用手等。再比如同样是动物叫的一个行为(函数),传猫对象过去就是 "喵喵";狗对象过去就是 "汪汪"。

二、多态的定义及实现

2.1、多态的构成条件

(1)虚函数

类成员函数前面加 virtual 修饰,那么这个成员函数就被称为虚函数。注意非成员函数不能加 virtual 修饰。

class Person
{
public:
    // 虚函数
	virtual void EatFood()
	{
		cout << "用餐具吃饭" << endl;
	}
};

(2)虚函数的重写/覆盖

虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值、函数名字、参数列表完全相同(简称 "三同"),称派生类的虚函数重写了基类的虚函数。

class Person
{
public:
	virtual void EatFood()
	{
		cout << "用餐具吃饭" << endl;
	}
};

class Chinese : public Person
{
public:
    // 此处构成虚函数的覆盖/重写
	virtual void EatFood()
	{
		cout << "用筷子吃饭" << endl;
	}
};

我们发现上面的代码在重写我们的虚函数时,派生类的虚函数前也加了 virtual 关键字。但其实派生类的虚函数在不加 virtual 关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是这样写不是非常规范,不建议这样使用。我们要明白虚函数的具体写法,而不是看前面有没有 virtual。

class Person
{
public:
	virtual void EatFood()
	{
		cout << "用餐具吃饭" << endl;
	}
};

class Chinese : public Person
{
public:
    // 此处依然构成虚函数的覆盖/重写
	void EatFood()
	{
		cout << "用筷子吃饭" << endl;
	}
};

这样写也可以,但是强烈不建议。

 (3)实现多态的两个必须重要条件

我们在学习完我们上面的内容,我们结合上面内容来看看实现多态的两个重要条件。

  • 必须是基类的指针或者引用调用虚函数。
  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

我们在继承那一章节学习了切片,子类和子类的指针引用可以赋值给我们父类以达到切片的效果,但是反过来就不太行,所以这里的函数就得调用基类的指针或者引用,这样我们不管是传父类还是子类(子类会被父类切片)都可以传参。

同时子类必须对父类的虚函数进行重写/覆盖,不然我们不管调用父类的函数还是子类的函数都是一样的效果,也没办法实现多态的不同形态的效果。

class Person
{
public:
    // 虚函数
	virtual void EatFood()
	{
		cout << "用餐具吃饭" << endl;
	}
};

class Chinese : public Person
{
public:
    // 基类虚函数的重写/覆盖
	virtual void EatFood()
	{
		cout << "用筷子吃饭" << endl;
	}
};

void Func(Person* ptr)
{
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤EatFood
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr->EatFood();
}

int main()
{
	Person ps;
	Chinese ce;

	Func(&ps);
	Func(&ce);
	return 0;
}

看上去我们都是调用的同一个函数,但其实这个函数的输出是和我们传的 ptr 的类型是有关联的,指向父类调用父类,指向子类调用子类。

我们传给函数 Person 的 引用也是可以的。

void Func(Person& ptr)
{
	ptr.EatFood();
}

但是如果我们破环掉这两个必须的重要条件,我们的程序就无法实现多态的效果了。

破坏第一条,我们传值给 ptr。

void Func(Person ptr)
{
	ptr.EatFood();
}

或者破坏第二条,我们不去给我们的从父类继承下来的虚函数进行重写。

class Chinese : public Person
{
public:
};

void Func(Person& ptr)
{
	ptr.EatFood();
}

我们的多态都会失效。

当然,我们多个子类之间也可以实现多态的条件。

class Animal
{
public:
	virtual void talk() const
	{
	}
};

class Dog : public Animal
{
public:
	virtual void talk() const
	{
		cout << "汪汪" << endl;
	}
};

class Cat : public Animal
{
public:
	virtual void talk() const
	{
		cout << "(>^ω^<)喵" << endl;
	}
};

class Mouse : public Animal
{
public:
	virtual void talk() const
	{
		cout << "吱吱" << endl;
	}
};

void letsHear(const Animal& animal)
{
	animal.talk();
}

int main()
{
	Cat cat;
	Dog dog;
	Mouse mouse;
	letsHear(cat);
	letsHear(dog);
	letsHear(mouse);
	return 0;
}

(4)虚函数重写的一些其他问题

协变(了解)

派生类重写基类虚函数时,与基类函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用,称为协变。协变的实际意义并不大,所以我们了解一下即可。

class A{};

class B : public A {};

class Person
{
public:
	virtual A* EatFood()
	{
		cout << "用餐具吃饭" << endl;
		return nullptr;
	}
};

class Chinese : public Person
{
public:
	virtual B* EatFood()
	{
		cout << "用筷子吃饭" << endl;
		return nullptr;
	}
};

void Func(Person& ptr)
{
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤EatFood
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr.EatFood();
}

int main()
{
	Person ps;
	Chinese ce;

	Func(ps);
	Func(ce);
	return 0;
}

我们的虚函数的返回值不相同,父类对象返回一个父类指针或引用,子类对象返回一个子类指针或引用。同样可以实现多态的效果,我们就称为协变。

析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。虽然基类与派生类析构函数名字不同看起来不符合重写规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,所以基类的析构函数加了 virtual 修饰,派生类的析构函数就构成重写了。

class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

class B : public A 
{
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};

我们这里可以看到,我们 B 的析构名字和 A 的不一样,按道理来说是不会构成重写的,但实际上它们是构成的,因为我们编译器会把析构的名字统一处理成 destructor,所以此时它们名字相同了就构成了重写了。

我们要让析构函数变成虚函数的原因是因为当我们创建了一个父类的指针,有可能指向父类对象,也有可能指向子类对象。如果指向子类对象,此时我们调用父类的 delete 就会无法析构子类的那一部分。

int main()
{
	// 没问题
	A* p1 = new A;
	delete p1;

	// 有问题
	A* p2 = new B;
	delete p2;
	return 0;
}

我们 delete p2 时我们其实是期望它调用子类的析构函数的,不然就会出现问题。所以我们就得实现虚函数的重写去让我们指向父类调用父类,指向子类调用子类。

(5)override 和 final 关键字

从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,而在程序运行时没有得到预期结果去 debug 就会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用 final 去修饰。

class Car 
{
public:
	virtual void Dirve()
	{
	}
};

class Benz :public Car {
public:
	virtual void Drive() override 
	{ 
		cout << "Benz-舒适" << endl;
	}
};

此时我们的 Dirve 由于写错了而导致无法构成重写,此时我们在后面加入的 override 就起作用了。

当然,如果有基类的成员函数不想被重写,加入 final 就不能被继承了。

class Car
{
public:
	virtual void Drive() final {}
};

class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

(6)重载/重写/隐藏的对比

它们都有函数之间的关系,相似性极高。

三、纯虚函数和抽象类

在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义,因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

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;
	}
};

此时我们 Car 中的 Drive 就是我们的纯虚函数,Car 就是我们的抽象类。我们的抽象类无法实例出对象。

int main()
{
	// 编译报错⽆法实例化抽象类
	Car car;
	return 0;
}

正是因为无法实例化出对象,所以我们没有必要去实现我们的纯虚函数,因为无法调用。

如果我们的子类不去实现我们的纯虚函数,我们的类依旧是抽象类,因为依旧会继承我们的父类的纯虚函数。但是我们的子类也无法实例化出对象了。

class Benz :public Car
{
public:
};

int main()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	return 0;
}

所以说我们的纯虚函数的作用就是强制我们的派生类去实现这个纯虚函数,不然就无法调用。当我们实现了,就能够成功调用了。

int main()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

四、多态的原理

4.1、虚函数表指针

我们来算一下下面代码的运行结果。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

我们大家一看,这不是很简单吗,Base 类中有一个 int 整型,char 整型,所以就是 8 字节秒了。实则不然。

我们的答案是 12 字节,我们通过调试发现,除了 _b 和 _c 成员,还多一个 _vfptr 放在对象的前面(有的平台可能放对象后面,和平台有关)。

对象中的这个指针我们叫做虚函数表指针(v 表示 virtual,f 表示 function)。一个含有虚函数的类中都至少有一个虚函数表指针,因为一个类所有的虚函数的地址要被放到这个类对象的虚函数中,虚函数表也称为虚表。严格来说,它是一个函数指针数组。

我们尝试多加几个虚函数来看看效果。
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	virtual void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'x';
};

4.2、多态的原理

(1)多态是如何实现的

从底层的角度 Func 函数中的 ptr->BuyTicket(),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicket,ptr 指向 Student 对象调用 Student::BuyTicket 的呢?

class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
	string _name;
};

class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
	string _id;
};

class Soldier : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
	string _codename;
};

void Func(Person* ptr)
{
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr->BuyTicket();
}

int main()
{
	// 其次多态不仅仅发⽣在派⽣类对象之间,多个派⽣类继承基类,重写虚函数后
	// 多态也会发⽣在多个派⽣类之间。
	Person ps;
	Student st;
	Soldier sr;
	Func(&ps);
	Func(&st);
	Func(&sr);
	return 0;
}

通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类,指向派生类就调用派生类对应的虚函数。

由于切片,导致我们传递给 Func 函数的每一个类都只有 Person 的那一部分,此时我们编译器就是更具我们的虚函数表指针中的地址找到我们不同的函数位置,从而形成多态行为的。

这张图就是我们 Person 类传递给 ptr,ptr 指向 Person,此时我们就会调用 Person 的 _vfptr 去找寻关于 BuyTicket 函数的指针。

这张图是我们 Student 类传递给 ptr,在这之间进行了切片操作,然后 ptr 指向 Student 中属于 Person 的那一部分,此时我们就会调用 Student 中的 Person 的 _vfptr 去找寻关于 BuyTicket 函数的指针。

(2)动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用 + 调用虚函数)的函数调用是在编译时绑定的,也就是编译时确定调用函数的地址,叫做静态绑定
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时指向对象的虚函数表中找到调用函数的地址,也叫做动态绑定。

我们可以从汇编来看看。

// ptr是指针+BuyTicket是虚函数满⾜多态条件。
// 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虚函数,不满⾜多态条件。
// 这⾥就是静态绑定,编译器直接确定调⽤函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)

(3)虚函数表

  • 基类对象的虚函数表中存放基类所有的虚函数的地址。同类型的对象共用一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
  • 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也是相互独立的。
  • 派生类中重写基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  • 派生类的虚函数表中包含:(1)基类的虚函数地址;(2)派生类重写的虚函数地址完成覆盖;(3)派生类自己的虚函数地址三个部分。
  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000 标记(这个 C++并没有进行规定,各个编译器自行定义的,vs 系列编译器会在后面放,g++系列编译不会放)
  • 虚函数和普通函数一样,编译好后是一段指令,都是存在代码段的,至少虚函数的地址又存在虚表中。
  • 虚函数表存放的位置严格来说并没有标准答案 C++ 标准并没有规定,在 vs 中存在代码段(常量区)

我们每个基类和派生类如果有虚函数都有一张虚表,但是如果它们类型相同,那它们使用的虚表也是相同的。我们子类继承的父类的虚函数表,但是它们不是同一个虚函数,而是独立的。

如果我们的子类没有重写我们的父类,那我们子类的虚函数表就会在自己的虚函数表中从父类的虚函数表拷贝一份放到子类中属于父类的那一部分虚函数表中。

如果我们有重写,那我们重写的内容就会把基类对应位置进行覆盖,然后写上自己的函数内容。


总结

以上便是我们多态的全部内容,继承和多态的产生,使我们 C++ 的代码的层次更加的多样,在我们以后的大型项目中会经常遇见,我们这里一定得多多学习了解。

🎇坚持到这里已经很厉害啦,辛苦啦🎇

ʕ • ᴥ • ʔ

づ♡ど


网站公告

今日签到

点亮在社区的每一天
去签到