多态:从多种形态到运行时绑定

本文代码已同步Github

一、多态的概念及实现

1、多态的概念

面向对象三大特性:封装、继承、多态

前面我们已经学习了封装和继承,接下来来看最后一个特性:多态

我们先来感受一种场景

假设一个网盘产品要提供下载功能,那么不同身份的用户,下载策略是不一样的:

  • 普通用户:限速下载;
  • 会员用户:全速下载;
  • 超级会员:多线程加速下载。

这三个用户走的都是同一个「下载」行为,传进去的对象不同,得到的下载方式就不同;

再比如,同样是「写日志」这个行为,传控制台日志对象过去,就往屏幕输出,传文件日志对象过去,就往磁盘文件里写

这便是多态(polymorphism)的概念:通俗来说,就是多种形态

不过这里有个值得先想清楚的问题:这些不同的策略,不用多态也能写啊,判断一下用户类型不就行了?

我们来看看不用多态的写法大概是什么样

cpp 复制代码
void StartDownload(User* ptr)
{
	if (ptr->_type == NORMAL)
	{
		//限速下载
	}
	else if (ptr->_type == VIP)
	{
		//全速下载
	}
	else if (ptr->_type == SUPER_VIP)
	{
		//多线程加速下载
	}
}

这样写不是不能跑,问题在于:每新增一种用户类型,都要回来改这个函数

用户类型越多,这个if-else就越长,而且所有和用户类型相关的判断都散落在各处,改一处漏一处

多态把这件事反了过来:调用代码只管调用,具体执行哪个版本由对象自己决定

所以多态解决的并不是「代码复用」------代码复用主要靠继承,派生类直接把基类的成员和实现拿过来用;多态解决的是另一件事:让调用代码和具体类型解耦

解耦带来的好处是实实在在的:新增一种用户,只需要加一个新类并重写DownloadStartDownload一行都不用动

多态分为编译时多态(静态多态)运行时多态(动态多态),这里我们重点讲运行时多态;

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

运行时多态,具体点就是去完成某个行为(函数),传不同的对象就会完成不同的行为,就达到多种形态

2、多态的构成条件

多态是一个继承关系下的类对象,去调用同一函数,产生了不同的行为;

比如NormalUser继承了UserUser对象限速下载,VipUser对象全速下载

那么,实现多态必须满足两个条件,缺一不可

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

为什么两个条件缺一不可呢?

少了「基类的指针或引用」:只有基类的指针或引用才能既指向基类对象,又指向派生类对象;如果直接用对象来调用,对象的类型在编译时就定死了,根本没有「运行时再看一眼指向的是谁」的余地

少了「虚函数重写」:基类里的函数没有被派生类改写,那不管指针指向谁,调到的都是基类那一份实现,自然也就谈不上有多种形态了

3、虚函数

virtual修饰的类成员函数,被称为虚函数

注意⚠️:非成员函数不能加virtual修饰

cpp 复制代码
class User
{
public:
	//虚函数
	virtual void Download()
	{
		cout << "限速下载" << endl;
	}
};

4、虚函数的重写/覆盖

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

我们通过一个完整的例子来看多态的效果

cpp 复制代码
class User
{
public:
	virtual void Download()
	{
		cout << "限速下载" << endl;
	}
};

class NormalUser : public User
{
public:
	virtual void Download()
	{
		cout << "限速下载" << endl;
	}
};

class VipUser : public User
{
public:
	virtual void Download()
	{
		cout << "全速下载" << endl;
	}
};

class SuperVipUser : public User
{
public:
	virtual void Download()
	{
		cout << "多线程加速下载" << endl;
	}
};

void StartDownload(User* ptr)
{
	//虽然都是User指针ptr在调用Download
	//但是跟ptr没有关系,而是由ptr指向的对象决定的
	ptr->Download();
}

int main()
{
	NormalUser nu;
	VipUser vu;
	SuperVipUser sv;

	StartDownload(&nu);
	StartDownload(&vu);
	StartDownload(&sv);

	return 0;
}

来测试一下

同一个StartDownload函数,传不同对象进去,产生了不同的行为,这便是多态

同时也能看到,多态并不只发生在基类和派生类之间,多个派生类继承同一个基类并各自重写虚函数之后,多态也会发生在这些派生类之间

再来看用引用实现的另一组例子

cpp 复制代码
class Log
{
public:
	virtual void Write(const string& msg) const
	{}
};

class ConsoleLog : public Log
{
public:
	virtual void Write(const string& msg) const
	{
		cout << "控制台:" << msg << endl;
	}
};

class FileLog : public Log
{
public:
	virtual void Write(const string& msg) const
	{
		cout << "文件:" << msg << endl;
	}
};

void Record(const Log& log, const string& msg)
{
	log.Write(msg);
}

int main()
{
	ConsoleLog cl;
	FileLog fl;

	Record(cl, "服务启动");
	Record(fl, "服务启动");

	return 0;
}

注意⚠️:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用;

不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态

5、多态场景的一道经典题目

下面来看一道经典的题目

1、以下程序输出结果是什么()A: Printer->1 B: ColorPrinter->3 C: Printer->3 D: ColorPrinter->1 E: 编译出错F: 以上都不正确

cpp 复制代码
class Printer
{
public:
	virtual void Print(int copies = 1)
	{
		cout << "Printer->" << copies << endl;
	}

	virtual void Run()
	{
		Print();
	}
};

class ColorPrinter : public Printer
{
public:
	void Print(int copies = 3)
	{
		cout << "ColorPrinter->" << copies << endl;
	}
};

int main()
{
	ColorPrinter* p = new ColorPrinter;
	p->Run();

	return 0;
}

分析:

Run在基类中定义,ColorPrinter中并没有重写它,因此p->Run()调用的是Printer::Run

接着在Run内部调用Print()Print是虚函数且被ColorPrinter重写了,构成多态,所以调用的是ColorPrinter::Print的实现;

那么copies的值是1还是3呢?

答案是1

关键在于:虚函数的重写只重写了函数的实现,默认参数不是函数签名的一部分,它不会进虚表,而是在编译调用点时,由「调用者的静态类型」决定填哪个值

Printer::Run内部,this的静态类型就是Printer*,所以编译器在这里填的是基类的默认值copies = 1;运行时查虚表只决定了执行哪一份函数体,改不了这个已经填好的参数

也就是说,这里出现了「函数体是多态的、默认参数却是静态绑定」的割裂现象,这是这道题真正想考的点

我们换个调用方式,对比一下就更清楚了

cpp 复制代码
ColorPrinter* p = new ColorPrinter;
p->Run();     // 在Printer::Run内部调用,静态类型是Printer*,填1
p->Print();   // 直接在main中调用,静态类型是ColorPrinter*,填3

同样一个Print,两次调用的执行体都是ColorPrinter::Print,输出的却是ColorPrinter->1ColorPrinter->3

决定默认参数的是调用处的静态类型,不是对象的真实类型

因此题目中最终输出ColorPrinter->1,选择D

我们运行验证一下

二、纯虚函数和抽象类

前面我们学习了虚函数的重写,那么基类中的虚函数是否一定要给出实现呢?

在虚函数的后面写上=0,则这个函数为纯虚函数

纯虚函数不需要定义实现(实现没啥意义,因为要被派生类重写,但是语法上可以实现),只要声明即可;

包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象;

如果派生类继承后不重写纯虚函数,那么派生类也是抽象类;

纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象

cpp 复制代码
class Serializer
{
public:
	//纯虚函数
	virtual void Serialize() = 0;
};

class JsonSer : public Serializer
{
public:
	virtual void Serialize()
	{
		cout << "序列化为json格式" << endl;
	}
};

class XmlSer : public Serializer
{
public:
	virtual void Serialize()
	{
		cout << "序列化为xml格式" << endl;
	}
};

int main()
{
	//error C2259: "Serializer": 无法实例化抽象类
	//Serializer ser;

	Serializer* pJson = new JsonSer;
	pJson->Serialize();

	Serializer* pXml = new XmlSer;
	pXml->Serialize();

	return 0;
}

来测试一下

这里值得多想一层:抽象类为什么不允许实例化?

我们反过来想------如果允许Serializer ser;这样创建对象,那么调用ser.Serialize()时该执行什么?纯虚函数根本就没有实现,这是一个「不知道该怎么执行」的调用,语言干脆在源头把它禁掉了

顺着这个角度,也能看清普通虚函数和纯虚函数的区别:

  • 普通虚函数:派生类继承的是接口 + 一份默认实现,派生类可以重写,不重写就用基类那份;
  • 纯虚函数:派生类继承的是只有接口,实现必须由派生类自己给。

所以当我们写一个基类时,如果这个函数「基类根本不知道该怎么写,只有派生类才知道」,那就把它设计成纯虚函数。比如Serializer只知道要序列化,至于序列化成json还是xml,只有具体的派生类才知道

这也是抽象类最主要的用途:把「要做什么」定下来,把「怎么做」留给派生类

落到实际写法上,抽象类通常不直接拿来定义对象,而是拿它的指针或引用当函数参数

cpp 复制代码
// 参数写成抽象类指针,将来传任何派生类对象进来都能用
void SaveToFile(Serializer* s)
{
	s->Serialize();
}

这样一来,SaveToFile里根本没有出现过json、xml这些具体类型,它只认「能序列化的东西」;

哪天新增一种YamlSer,只要让它继承Serializer并重写SerializeSaveToFile一行都不用改

这其实就是第一篇讲的那个下载限速场景的另一种写法------调用方只依赖抽象,不依赖具体

三、虚函数重写的其他问题

1、协变

派生类重写基类虚函数时,与基类虚函数返回值类型不同;

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

cpp 复制代码
class Account {};
class VipAccount : public Account {};

class User
{
public:
	virtual Account* GetAccount()
	{
		cout << "普通账号" << endl;
		return nullptr;
	}
};

class VipUser : public User
{
public:
	virtual VipAccount* GetAccount()
	{
		cout << "会员账号" << endl;
		return nullptr;
	}
};

注意⚠️:协变的两个返回值必须本身构成继承关系,也就是基类的指针/引用在前,派生类的指针/引用在后,反过来是不成立的

协变的实际意义并不大,我们了解一下即可

2、析构函数的重写

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写;

基类与派生类析构函数名字不同,看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以就构成了重写

那么,为什么基类的析构函数建议设计为虚函数呢?

我们通过下面的例子来分析

cpp 复制代码
class Person
{
public:
	//如果不加virtual,delete p2时只会调用Person的析构函数
	virtual ~Person()
	{
		cout << "~Person()" << endl;
	}
};

class Student : public Person
{
public:
	~Student()
	{
		cout << "~Student()" << endl;
		delete[] _name;
	}
protected:
	char* _name = new char[10];
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;

	return 0;
}

如果~Person()不加virtual,那么delete p2时只会调用基类的析构函数,没有调用Student的析构函数;

~Student()中在释放资源,这样就会导致内存泄漏问题;

只有派生类的析构函数重写了基类的析构函数,delete p2才能构成多态,才能保证p2指向的对象正确调用析构函数

注意⚠️:这个问题面试中经常考察,一定要结合这样的样例才能讲清楚为什么基类中的析构函数建议设计为虚函数

3、override和final

C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错、参数写错等导致无法构成重写;

而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug,会得不偿失;

因此C++11提供了override,帮助用户检测是否完成重写;

不加override也能重写,但建议派生类里重写虚函数时都加上------成本只是一个关键字,收益是把「运行时才发现结果不对」提前成「编译期直接报错」

如果我们不想让派生类重写这个虚函数,那么可以用final去修饰

先来看override检测重写

cpp 复制代码
//error C3668: "Student::Print": 包含重写说明符"override"的方法没有重写任何基类方法
class Person
{
public:
	//函数名不小心拼错了,Print写成了Prnt
	virtual void Prnt()
	{}
};

class Student : public Person
{
public:
	virtual void Print() override
	{
		cout << "Student::Print" << endl;
	}
};

基类函数名拼错了,没有构成重写,加上override之后编译器直接帮我们检查出来;

再来看final禁止重写

cpp 复制代码
//error C3248: "Base::Func": 声明为 "final" 的函数不能由 "Derive::Func" 重写
class Base
{
public:
	virtual void Func() final {}
};

class Derive : public Base
{
public:
	virtual void Func()
	{
		cout << "Derive::Func" << endl;
	}
};

四、多态的原理

这一节我们把前面看到的多态现象拿到底层去看一看

1、对象里多出来的虚表指针

下面编译为32位程序的运行结果是什么()A. 编译报错B. 运行报错C. 8 D. 12

cpp 复制代码
class Widget
{
public:
	virtual void Paint()
	{
		cout << "Paint()" << endl;
	}
protected:
	int _w = 1;
	char _tag = 'x';
};

int main()
{
	Widget w;
	cout << "sizeof(Widget) = " << sizeof(w) << endl;

	return 0;
}

分析:

除了_w_tag成员,对象中还多了一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关);

对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function);

一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表

因此32位下,_w占4字节,_tag占1字节对齐到4字节,再加上4字节的虚表指针,共12字节,选择C

2、派生类对象的存储布局

知道了对象里藏着一个虚表指针,我们再来看整个派生类对象是怎么摆的

对于派生类,底层存储空间布局从上往下依次是:虚表指针、基类成员、派生类特有成员

复制代码
低地址
┌────────────────┐
│   虚表指针      │  ← 基类指针和派生类指针都指向这里
├────────────────┤
│   基类成员      │
├────────────────┤
│  派生类特有成员  │
└────────────────┘
高地址

这里有个容易含糊的点:虚表指针是基类那一部分的,派生类不会再新生成一个

正因为基类部分就在对象的起始位置,派生类指针才能直接赋给基类指针(向上转换),而且两个指针的值是相同的

区别只在于「能看到的范围」:派生类指针能看到整个对象,基类指针只能看到基类那一部分

注意⚠️:这个「地址相同」只在单继承下成立。如果是多继承,第二个及以后的基类子对象在派生类对象里存在偏移,此时基类指针和派生类指针的值就不一样了

3、虚表指针指向的到底是什么

这里有个很容易记混的地方,值得单独拎出来说:虚表指针里存的并不是虚函数的地址

正确的关系是三级:

复制代码
对象里的虚表指针(vptr) → 虚函数表(vtable) → 虚函数的地址

虚表指针只是「指向虚表」的那一个指针;而虚表本身是一个存放虚函数地址的数组,虚函数的地址是这个数组里的元素

一句话记:指针指向表,表里放地址

4、多态是如何实现的

有了上面的铺垫,我们再回头看StartDownload函数中的ptr->Download(),它到底是怎么做到ptr指向NormalUser对象就调用NormalUser::Download,指向VipUser对象就调用VipUser::Download的呢?

关键就一句话:编译这一行时,编译器会先判断这次调用是否满足多态条件

  • 满足多态(指针或引用调用 + 被调用的是虚函数):不在编译时把地址定死,而是运行时到ptr指向的对象的虚表中取出虚函数的地址,再call过去;
  • 不满足多态(普通函数,或者是用对象直接调用):编译时就能确定地址,直接call函数地址即可。

这样也就解释了为什么指向基类对象就调用基类的虚函数,指向派生类对象就调用派生类重写后的虚函数------运行时查的是那个对象自己的虚表

我们直接在程序里把两个对象的虚表指针和虚表里存的内容打印出来看一看(为了让「表」的形状看得更清楚,这里给User多加了一个虚函数Login,VipUser只重写了Download)

重点看两个地方:

一是对象的前4字节确实是一个指针,它的值就是虚表的地址,两个对象各自指向自己的虚表;

二是对比两张虚表的内容:被重写的Download,VipUser虚表里存的地址和User不一样(已经换成了重写后的那个函数);而没被重写的Login,两张虚表里存的是同一个地址

所以「重写」这件事在底层就一件事:派生类的虚表把对应槽位覆盖成了自己版本的函数地址,没重写的槽位就原封不动沿用基类的

5、动态绑定与静态绑定

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

我们通过汇编指令来对比

满足多态条件(ptr是指针 + Download是虚函数),这里是动态绑定,运行时到ptr指向对象的虚函数表中确定调用函数地址(地址因环境而异,下面只给出指令形式):

asm 复制代码
mov         eax,dword ptr [ptr]  
mov         edx,dword ptr [eax]  
mov         ecx,dword ptr [ptr]  
mov         eax,dword ptr [edx]  
call        eax  

不满足多态条件(Download不是虚函数),这里是静态绑定,编译器直接确定调用函数地址:

asm 复制代码
mov         ecx,dword ptr [ptr]  
call        User::Download (...)  

显然,动态绑定多了一步取虚表的操作,调用地址是运行时从虚表中取出来的;

这也和汇编里call eax(先取地址再调用)与直接call函数地址的区别对应上了

6、一个容易踩的坑:构造和析构函数中调用虚函数,不会多态

理解了「虚表指针是构造过程中才被改写的」,就能想明白一个很隐蔽的坑

前面说过,派生类对象里那个虚表指针属于基类部分,而且它的值并不是一成不变的:

  • 进入基类构造函数时,它指向的是基类的虚表;
  • 基类构造结束、进入派生类构造函数时,它才被改写成指向派生类的虚表。

那么问题来了:如果在基类的构造函数里调用虚函数,会调到派生类重写的那个版本吗?

答案是不会。那一刻虚表指针还指向基类的虚表,查表只能查到基类的版本

我们用代码验证一下

cpp 复制代码
//这个例子用来验证:在基类构造函数里调用虚函数,不会触发多态
class Widget
{
public:
	Widget()
	{
		//基类构造里调用虚函数
		Paint();
	}

	virtual void Paint()
	{
		cout << "Widget::Paint" << endl;
	}

	virtual ~Widget() {}
};

class Button : public Widget
{
public:
	Button() {}

	//确实重写了
	virtual void Paint() override
	{
		cout << "Button::Paint" << endl;
	}
};

int main()
{
	Button b;          //构造时,基类构造函数里调用了Paint

	Widget* p = &b;
	p->Paint();        //构造完成后,通过基类指针调用

	return 0;
}

来测试一下

同一个Paint(),构造期间打印的是Widget::Paint,等对象构造完再通过基类指针调用,才正常打印Button::Paint

同样的道理也适用于析构:析构的顺序和构造相反,派生类先析构,析构完虚表指针就指回基类的虚表了,所以在基类的析构函数里调用虚函数,同样调不到派生类的版本

这也是为什么一般不建议在构造和析构函数里调用虚函数------它看起来会多态,实际上不会,一旦出问题很难查

7、虚函数表

关于虚表,有几个重要的结论:

  • 基类对象的虚函数表中存放基类所有虚函数的地址;同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表
  • 派生类由两部分构成:继承下来的基类和自己的成员;一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针;但继承下来的基类部分的虚表指针和基类对象的虚表指针不是同一个
  • 派生类中重写了基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址
  • 派生类的虚函数表中包含三部分:基类的虚函数地址、派生类重写的虚函数地址(完成覆盖)、派生类自己的新虚函数地址
  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况下这个数组最后面放了一个0x00000000标记(C++标准并没有规定,vs系列编译器会在后面放个0x00000000标记,g++系列编译器不会放)

那么,虚函数存在哪?虚函数表又存在哪呢?

虚函数和普通函数一样,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中;

而虚函数表存在哪,C++标准并没有规定,我们写下面的代码对比验证一下

cpp 复制代码
class Widget
{
public:
	virtual void Paint() { cout << "Widget::Paint" << endl; }
	virtual void Resize() { cout << "Widget::Resize" << endl; }
	void SetId() { cout << "Widget::SetId" << endl; }
protected:
	int _w = 1;
};

class Button : public Widget
{
public:
	//重写基类的Paint
	virtual void Paint() { cout << "Button::Paint" << endl; }
	virtual void OnClick() { cout << "Button::OnClick" << endl; }
	void SetText() { cout << "Button::SetText" << endl; }
protected:
	int _h = 2;
};

int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";

	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);

	Widget w;
	Button bt;
	Widget* p3 = &w;
	Button* p4 = &bt;

	printf("Widget虚表地址:%p\n", *(void**)p3);
	printf("Button虚表地址:%p\n", *(void**)p4);

	return 0;
}

注意⚠️:vs的监视窗口看不到派生类自己的OnClick,可以通过内存窗口查看

来测试一下

从运行结果来看,Widget和Button的虚表地址与常量区地址非常接近,而与堆区相距甚远;

vs下虚表是存在代码段(常量区)的

五、重载/重写/隐藏的对比

到这里,三个容易混淆的概念我们都学过了,这里统一对比一下

注意⚠️:这个对比经常考,需要理解记忆一下

重载 重写(覆盖) 隐藏(重定义)
作用域 同一作用域 基类和派生类 基类和派生类
函数名 相同 相同 相同
参数 不同 相同 相同或不同均可
返回值 无要求 相同(协变除外) 无要求
其他要求 - 两个函数必须都是虚函数 -

其中隐藏就是我们在继承一篇中提到的:派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问

也就是说,只要函数名相同就构成隐藏,参数不同不构成重载

这张表看起来信息很多,其实抓住两个判据就能推出来:

  • 看作用域:两个函数在同一个作用域里(比如都是某个类的成员函数),才轮得到谈重载;分属基类和派生类,就不可能是重载,只可能是重写或隐藏;
  • 看是不是虚函数:跨作用域且两个都是虚函数、签名又完全相同,那是重写;跨作用域但只要有任意一个不是虚函数,那就是隐藏。

不过光看表格还是抽象的,我们用一个例子看看重写和隐藏在调用时的差别到底在哪

cpp 复制代码
class Task
{
public:
	virtual void Execute() { cout << "Task::Execute" << endl; }  // 虚函数
	void Cancel() { cout << "Task::Cancel" << endl; }            // 普通函数
};

class TimerTask : public Task
{
public:
	virtual void Execute() { cout << "TimerTask::Execute" << endl; }  // 重写
	void Cancel() { cout << "TimerTask::Cancel" << endl; }            // 隐藏
};

int main()
{
	TimerTask tt;
	Task* p = &tt;

	p->Execute();   // 重写 → 多态,跟对象走
	p->Cancel();    // 隐藏 → 静态绑定,跟类型走
	tt.Cancel();    // 用派生类对象直接调,找到派生类的就停了

	return 0;
}

三个调用逐个看:

p->Execute()Execute是虚函数且被重写,满足多态,运行时查虚表 → TimerTask::Execute

p->Cancel()Cancel不是虚函数,只是被隐藏了。指针的静态类型是Task*,编译期就把地址定死了 → Task::Cancel

tt.Cancel()tt的静态类型就是TimerTask,名字查找在TimerTask里找到Cancel就停止,基类的同名函数被屏蔽 → TimerTask::Cancel

一句话概括:重写换来的是「跟着对象走」,隐藏换来的只是「跟着类型走」。

这也解释了为什么隐藏很容易埋雷------你以为自己改的是派生类那份,结果通过基类指针一调用,执行的还是基类的,而且编译不报错、运行也不崩,只是结果不对。

六、总结

通过这一篇的学习,我们系统学习了C++中的多态。

首先,我们了解了多态的概念:它解决的不是代码复用,而是让调用代码与具体类型解耦------新增一种类型只要加一个类,已有的调用代码一行不用改;

多态分为编译时多态(函数重载、模板)和运行时多态,我们重点学的是后者。

然后,我们学习了构成多态的两个必要条件,缺一不可:基类的指针或引用调用虚函数 + 虚函数完成了重写。

接着,我们梳理了虚函数重写中的细节问题:协变、析构函数的重写,以及override和final两个关键字的作用;也学习了纯虚函数和抽象类------纯虚函数强制派生类完成重写,让「要做什么」和「怎么做」彻底分开。

之后,我们揭开了多态的原理:对象里藏着虚表指针,它指向虚函数表,虚函数表里才存着虚函数的地址;

派生类对象的布局从上到下是虚表指针、基类成员、派生类成员;

满足多态条件时,函数调用在运行时到指向对象的虚表中取地址,也就是动态绑定;不满足时仍在编译期确定地址,也就是静态绑定;

我们还验证了虚表指针是在构造过程中才被改写的,由此解释了为什么在构造和析构函数中调用虚函数不会多态。

另外,我们把三个容易混淆的概念放到一起做了对比:重载发生在同一作用域,重写和隐藏都跨基类和派生类,区别只在于那两个函数是不是虚函数------重写跟着对象走,隐藏跟着类型走

最后:

多态的本质,是把「调用哪个函数」的决定权从编译时推迟到了运行时,交给了对象自己;换来的,是调用代码与具体类型的解耦。

如果觉得有帮助,可以关注Github项目持续更新

相关推荐
别动我齐刘海2 小时前
ROS2 Jazzy + C++ 实战路线——ros2_control
c++·人工智能·python·opencv·机器学习·机器人·github
大黄说说2 小时前
iOS 列表滑动卡顿:UITableView / UICollectionView 深度优化实战
开发语言
于樱花森上飞舞2 小时前
【Redis】哨兵详解
java·开发语言·数据库·redis
All for pursuit.2 小时前
【栈-4】739.每日温度
数据结构·c++·算法·leetcode
码匠许师傅2 小时前
【C++三方组件】glog:Google 出品的 C++ 日志库
c++
Lazionr3 小时前
二叉搜索树:从树形结构到高效查找
开发语言·c++
此生决int3 小时前
深入理解C++系列(21)——异常
开发语言·c++
曼巴UE53 小时前
UE5 客户端 需要的网络同步概念总结(3 )-事件同步 RPC 以及三种调用方式
网络·c++·网络协议·学习·rpc·ue5
乌暮3 小时前
Java 抽象类与接口详解:半成品图纸 vs 能力合同
java·开发语言·后端·学习