千面多态:C++中的扮演大师

       在C++中,多态能够让同一段代码在不同场景中展现出截然不同的面貌。这种特性让程序更具灵活性与扩展性,使对象可以根据需求“变身”为合适的模样,完美适应复杂的编程需求。从基类到派生类,再到虚函数与动态绑定,C++的多态机制带来了代码复用和模块化的巨大优势。

1.多态的概念及定义

    多态是一种面向对象的特性,核心在于同一接口在不同对象上具有不同的实现。简单来说,多态使得一个函数或方法可以表现出多种形态,这种机制让程序在运行时可以根据对象的类型来执行不同的操作。

1.1.多态的概念

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

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

运行时多态,具体点就是去完成某个行为(函数)时,传不同的对象就会完成不同的行为,达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<) 喵“,传狗对象过去,就是"汪汪"。

1.2.多态的定义

多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票。

多态的实现关键在于:

(1)必须使用指针或引用调用虚函数,并且必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象又指向基类对象

(2)被调用的函数必须是虚函数。派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到

通过基类指针指向派生类对象,调用虚函数时会根据指向的对象类型执行相应的派生类方法,而不是基类中的方法。这一特性使得代码更加灵活和可扩展,便于后期维护和功能扩展。

2.虚函数

2.1.虚函数简介

虚函数(Virtual Function)是一种通过动态绑定(运行时绑定)来实现多态的机制,使得基类的指针或引用可以调用派生类的重写函数,通常在具有继承关系的类之间使用。

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

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

在以上代码中,BuyTicket() 是一个虚函数,允许派生类重写这个函数的实现。在运行时,如果基类指针或引用指向派生类对象调用该函数时会执行派生类的重写版本,而不是基类的版本。

基类指针指向派生类对象:

基类的核心作用体现在编译阶段,决定了 能调用哪些成员

 - 基类指针的静态类型(即声明时的类型,如 A*)在编译期就固定,编译器会根据这个类型检查代码合法性。

 - 基类指针或引用只能调用基类中声明的成员(包括成员变量和成员函数),即使派生类有扩展的成员也无法直接访问。

派生类的核心作用体现在运行阶段,决定了 实际执行的逻辑

 - 基类指针指向的动态类型(即对象的真实类型,如 B)在运行时才确定,当调用虚函数时,会根据动态类型触发多态。

 - 派生类对基类虚函数的 “重写” 逻辑,会在运行时替代基类的默认实现

虚函数的注意事项

- 性能开销:虚函数的动态绑定需要查找V-Table,这会带来一定的运行时开销。
- 继承结构中的使用:当基类设计为多态接口时,应将其成员函数尽量声明为虚函数,尤其是析构函数
- 不能是内联函数:虚函数通常不能作为内联函数,因为内联函数是在编译时展开,而虚函数在运行时决定调用的版本。

2.2.虚函数的重写/覆盖 

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

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

class Person {
public:
	virtual void BuyTicket() { cout << "买票-不打折" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "学生票-打折" << endl; }
};

void Func(Person* ptr)//基类的指针或引用
{
    // 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
    // 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

注意:下面的代码直接调用方法虽然也能达到相同的效果,但是这并不构成多态的实现

多态的核心在于同一接口在不同情况下能够表现出不同的行为。多态使得一个函数或方法可以表现出多种形态。只有在使用基类指针或引用来调用虚函数,并且根据对象的实际类型决定具体调用的函数时,这才称为多态。

int main()
{
	Person ps;
	Student st;
	ps.BuyTicket();
	st.BuyTicket();
	return 0;
}

虚函数重写的核心规则:“三同两兼容

 - 三同:函数名相同参数列表相同const 限定相同

 - 两兼容:返回值类型兼容(或协变)、基类函数必须为虚函数(基类声明,派生类可省略但建议显式标注)。

2.3.调用规则

(1)基类引用 / 指针指向派生类对象

非虚函数:按基类的静态类型调用(编译时确定,调用基类版本)。

虚函数:按派生类的动态类型调用(运行时确定,调用派生类重写的版本,若未重写,则调用基类版本)。

(2)派生类引用 / 指针指向派生类对象

无论函数是否为虚函数,优先调用派生类自己的版本;如果派生类没有重写,则调用从基类继承的版本(继承的是基类的实现,本质还是 派生类的函数逻辑)。

(3)基类引用 / 指针指向基类对象

调用基类的函数(无论是否为虚函数,因为没有派生类重写的情况)。

(4)派生类指针 / 引用无法指向基类对象

 - 派生类指针不能安全地指向基类对象(强制转换会导致风险);

 - 派生类引用完全不能指向基类对象(语法不允许)。

2.4.派生类的继承

(1)成员变量

派生类会继承基类中所有的成员变量(包括普通变量、静态变量等),但访问权限会根据继承方式(public、protected、private)发生变化:

 - 基类 public 成员:在派生类中访问权限由继承方式决定(public 继承保留 public,protected 继承变为 protected,private 继承变为 private)。

 - 基类 protected 成员:在派生类中变为 protected(public/protected 继承)或 private(private 继承),派生类内部可访问,外部不可访问。

基类 private 成员:始终被派生类继承,但无论哪种继承方式,派生类都无法直接访问(需通过基类的 public 或 protected 成员函数间接访问)。

(2)成员函数

派生类会继承基类中除构造函数、析构函数外的所有成员函数(包括普通函数、虚函数、静态函数等),访问权限同样受继承方式影响:

 - 基类 public/protected 函数:继承规则与成员变量一致,派生类可直接调用(内部)或通过对象调用(外部,取决于权限)。

 - 基类 private 函数:被继承但派生类无法直接调用。

 - 虚函数:若派生类重写(override)基类虚函数,会形成多态;若不重写,则直接继承基类的实现。

注意:构造函数和析构函数不能被继承(因与类名强绑定),但派生类构造函数会隐式 / 显式调用基类构造函数析构函数会在派生类析构后自动调用基类析构

(3)不能被继承的内容

 - 构造函数和析构函数:与类名绑定(如 Base()、~Base()),派生类需自行定义,且派生类构造函数必须调用基类构造函数(隐式 / 显式)。

 - 赋值运算符重载函数(operator=):编译器会为每个类生成默认的赋值运算符,派生类不会继承基类的 operator=,但可显式调用基类的版本。

 - 友元关系:基类的友元无法访问派生类的私有成员,友元关系不具有继承性。

2.5.多态场景下的一个题

class A
{
public: 
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A
{
public:
	virtual void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

解析:

(1)虚函数的默认参数绑定
在C++中,默认参数的值在编译期确定。也就是说,当你定义一个带默认参数的虚函数时,默认参数的值取决于声明时的定义。在本例中:A::func(int val = 1) 声明了一个默认值 val = 1。B::func(int val = 0) 声明了一个默认值 val = 0。

(2)虚函数的调用

当通过 B* p = new B; p->test() 调用时,由于 test 是虚函数,会触发动态绑定:运行时根据 p 指向的实际对象类型(B),沿继承链查找 test 的实现,最终调用基类 A 中的 test(因 B 未重写)。

在 A::test() 内部,func() 的调用等价于 this->func()。此时 this 指针的静态类型是 A*,但动态类型是 B*(实际指向 B 对象)。由于 func 是虚函数且被 B 重写,因此动态绑定到 B::func。函数调用的动态绑定仅决定函数版本,不影响默认参数。

(3)默认参数值的选择

尽管 func 动态绑定到了 B::func,但默认参数的值始终由调用点的静态类型决定,与动态类型(运行时实际指向的对象类型)无关:

test 函数在 A 中定义,调用 func() 时的静态上下文是 A 类(即 this 的静态类型为 A*),因此默认参数会采用 A::func 中声明的 val = 1,而非 B::func 中的 val = 0。

关键点总结

 - 动态绑定 决定了调用的是 B::func(),因此输出显示为 B->。

 - 静态绑定 决定了使用 A::func(int val = 1) 中的默认参数值 1,因此 val 的值为 1。

2.6.协变

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

协变的 3 个严格条件(缺一不可):

(1)基类虚函数必须返回 “基类指针 / 引用”

若基类虚函数返回普通类型(如int、double)或其他类型,不支持协变,必须返回完全相同的类型。

(2)派生类重写函数必须返回 “派生类指针 / 引用”

这里的 “派生类” 必须是基类的直接 / 间接 public 派生类,且指针 / 引用的层级要对应(基类返回指针,派生类也返回指针;基类返回引用,派生类也返回引用)。

(3)函数的其他重写规则不变

除返回值外,函数名、参数列表、const限定符仍需严格匹配,且基类函数必须带virtual关键字。

class A {};
class B : public A {};
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};
class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

2.7.析构函数的重写

析构函数不能被重写,但可以通过将基类的析构函数声明为虚函数,从而确保正确的析构顺序,尤其是在多态环境下处理派生类对象时

当基类指针指向派生类对象时,如果基类的析构函数不是虚函数在删除该对象时仅会调用基类的析构函数,派生类的析构函数将不会执行。这会导致派生类中的资源无法正确释放,可能引发内存泄漏或其他资源管理问题。

 - 若析构函数 不是虚函数:调用哪个析构函数,由 指针的 “静态类型” 决定(编译期就固定)。

 - 若析构函数 是虚函数:调用哪个析构函数,由 指针实际指向的对象的 “动态类型” 决定(运行期动态判断)。

class Base {
public:
	Base() { cout << "Base constructor" << endl; }
	virtual ~Base() { // 将析构函数声明为虚函数
		cout << "Base destructor" << endl;
	}
};

class Derived : public Base {
public:
	Derived() { cout << "Derived constructor" << endl; }
	~Derived() { // 派生类的析构函数
		cout << "Derived destructor" << endl;
	}
};

int main() 
{
	Base* ptr = new Derived(); // 基类指针指向派生类对象
	delete ptr; // 删除基类指针
	return 0;
}

 - 只有当基类的析构函数声明为虚函数时,在基类指针指向派生类对象的场景中,析构函数的调用才会构成多态。

 - 此时delete基类指针时,会通过动态类型绑定调用派生类的析构函数(再调用基类析构函数)。

 - 如果去掉基类析构函数的virtual,析构函数不构成多态,delete会根据指针的静态类型(基类)调用基类析构函数,派生类析构函数不会被调用。

 - 每个派生类对象被创建时,一定会执行基类的构造函数;被销毁时,一定会执行基类的析构函数。

 - 先执行基类构造函数,本质是为了保证派生类对象能安全使用从基类继承的成员,派生类对象包含基类的 “子对象”,需先初始化基类部分,构造函数不能继承,派生类需显式 / 隐式调用基类构造。

2.8.override和final关键字

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

class Car {
public:
	virtual void Dirve()
	{
	}
};
class Benz :public Car {
public:
	// 函数名错误
	//	E1455	使用“override”声明的成员函数不能重写基类成员	
	virtual void Drive() override
	{
		cout << "Benz-舒适" << endl;
	}
};
int main()
{
	return 0;
}
class Car
{
public:
	virtual void Drive() final 
    {}
};
class Benz :public Car
{
public:
	//E1850	无法重写“final”函数 "Car::Drive" 
    virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{
	return 0;
}

2.9.重写/重载/隐藏的对比

 3.纯虚函数和抽象类

在虚函数的后面写上 =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;
	}
};
int main()
{
	//E0322	不允许使用抽象类类型 "Car" 的对象
	Car car;

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

4.多态的原理

在C++中,多态(Polymorphism)是通过虚函数动态绑定机制实现的。多态使得基类指针或引用能够在运行时调用派生类的重写函数,从而根据对象的实际类型表现出不同的行为。多态的实现涉及到虚函数表(V-Table)和虚指针(V-Pointer)的运作。

4.1.虚函数表和虚指针

虚函数实现运行时多态的原理主要依赖于虚函数表(V-Table)和虚指针(V-Pointer)。

4.1.1.虚指针

每个包含虚函数的对象都会包含一个虚指针,它指向该对象所属类的V-Table。在运行时,通过基类指针调用虚函数时,程序会根据虚指针找到实际的虚函数实现。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;//x86环境下是12,x64环境下是16
	return 0;
}

在32位环境下,上面代码b中一个int类型一个char类型,根据对齐规则,应该是8,为什么会输出12呢?那是因为除了_b和_ch成员,还多一个_vfptr放在对象的前面,每个包含虚函数的对象都会包含一个虚指针

4.1.2.虚函数表

编译器为每个含虚函数的类创建一个虚函数表(V-Table)

(1)表中记录了该类所有虚函数的地址。

(2)当派生类重写基类的虚函数时派生类的V-Table会更新该函数的地址指向派生类的实现

(3)派生类的虚函数表中包含三个部分,基类虚函数的地址,派生类重写的虚函数的地址,派生类自己的虚函数的地址。

(4)虚函数表本质是一个存虚函数指针的指针数组,⼀般情况这个数组最后面放了一个0x00000000 标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000 标记,g++系列编译不会放)

(5)虚函数存在哪的?虚函数和普通函数一样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

(6)虚函数表的存储位置和实现依赖于编译器,但一般来说,虚函数表是在程序的静态存储区(如数据段)中创建的。这意味着虚函数表的内容在程序的整个生命周期内都存在,不会随着对象的创建和销毁而变化。

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};
class Derive : public Base
{
public:
	// 重写基类的func1
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func1" << endl; }
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

从下面我们可以看到被重写的func1地址发生了改变,没有被重写的func2地址未改变

4.3 动态绑定与静态绑定

(1)对不满足多态条件的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。

#include <iostream>
using namespace std;

class Base {
public:
    void display() 
    { // 非虚函数
        cout << "Base display()" << endl;
    }
};

class Derived : public Base {
public:
    void display() 
    { // 隐藏基类的display()
        cout << "Derived display()" << endl;
    }
};

int main() {
    Base b;
    Derived d;
    b.display(); // 静态绑定到Base::display()
    d.display(); // 静态绑定到Derived::display()
    return 0;
}

(2) 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

#include <iostream>
using namespace std;

class Base {
public:
    virtual void display() { // 虚函数
        cout << "Base display()" << endl;
    }
};

class Derived : public Base {
public:
    void display() override { // 重写虚函数
        cout << "Derived display()" << endl;
    }
};

int main() {
    Base* ptr = new Derived(); // 基类指针指向派生类对象
    ptr->display(); // 动态绑定到Derived::display()
    delete ptr;
    return 0;
}
评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值