【C++私房菜】面向对象中的多态


一、多态

OOP的核心思想是多态性(polymorphism)。多态性这个词源自希腊语,其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

❕ 当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

二、对象的静态类型和动态类型

⚠️在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。

当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开来。表达式的静态类型是在编译时确定的,它是变量声明时的类型或表达式生成的类型:动态类型则是变量或表达式表示的内存中的对象类型,在运行时才可知。

❕ 如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型保持一致。

因此我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,我们直到运行时才知道到底调用了哪个版本的虚函数,所以所有的虚函数都必须有定义。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个函数。

派生类可以继承其基类的成员,然而当遇到与类型相关的操作时,派生类必须对其重新定义。换句话说,派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。我们来看如下代码:

class A{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(){
	B bb;
	B* p = &bb;
	p->test();		//B->1
	p->func();		//B->0
	bb.func();		//B->0
	A& a = bb;
	a.test();		//B->1
	a.func();		//B->1
	return 0;
}

当我们使用指向bb的指针p调用 test函数时,test函数中隐含的传入了 A* this因此在此处我们是多态调用。派生类用自己的新定义覆盖了从基类继承而来的旧定义,但是调用时仍使用的是基类的声明。下面 A& a 到底调用哪个版本的 func完全依赖于运行时绑定到它上面的动态类型。

虚函数与其他函数一样,虚函数也可以用有默认实参,如果某次虚函数调用使用了默认实参,则该实参指由本次调用的静态类型决定。

换句话说,如果我们通过基类的引用或指针调用函数。则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。

class A{ public: void test(float a) { cout << a; } }; class B :public A{ public: void test(int b){ cout << b; } }; 
int main() { 
    A *a = new A; 
    B *b = new B; 
    a = b; 
    a->test(1.1);   //输出1.1
}

📔 如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

调用的虚函数在运行时才会被解析,当某个虚函数通过指针或引用被调用时,编译器产生的代码直到运行时才会确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。

那么如果我们使用普通类型(非指针非引用)的表达式调用虚函数,在编译时就会将调用的版本确定下来。

那么我们就产生疑问,inline函数可以是虚函数吗?答案当然是可以,当我们使用普通类型调用虚函数时,具有inline属性。如果是多态调用,这个函数酒不再是inline,因为虚函数要放进虚表中去。

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

三、虚函数和纯虚函数

1、虚函数

在C++语言中,基类必须将它的两种成员函数区分开来,一种是基类希望其派生类进行覆盖的函数:另一种是基类希望派生类直接继承而不要改变的函数。对于前者,基类通常将其定义未虚函数(virtual)。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。

基类通过在其成员函数的声明语句之前加上关键字 virtual使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。

同样的,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在两个例外。

  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; }
    };
    
  2. 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统-处理成destructor。(析构函数的重写,我们将在后文再进行叙述)

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

⚠️关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。 而且 virtual不能与static同时使用。静态成员一定是不被包含在对象中的静态成员属于整个类,不属于任何对象,所以在整体体系中只有一份。

⚠️静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数。

当然我们也可以会批虚函数的机制,在某些情况下我们可能希望对虚函数的调用不进行动态绑定,而是强迫其执行虚函数的某个特定版本。我们可以使用作用域运算符实现此目的:

class Base {
public:
	virtual void func() {
		cout << "Base::func" << endl;
	}
};
class Derived :public Base {
public:
	virtual void func() {
		cout << "Derived::func" << endl;
	}
};
int main(){
	Derived d;
	Base* pb = &d;
	pb->func();			//"Base::func" 
	pb->Base::func();	//"Derived::func" 
	return 0;
}

运行时的多态性可通过和虚函数实现。不可通过模板实现,因为模板属于编译时多态。编译时的多态性可通过函数重载实现。

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

class B : public A {
private:
	virtual void f() {
		cout << "B::f()" << endl;
	}
};
int main()
{
	A* pa = (A*)new B;	//或 A* pa = new B;均合法
	pa->f();			//B::f()
}

此段代码编译正确,虽然子类函数为私有,但是多态仅仅是用子类函数的地址覆盖虚表,最终调用的位置不变,只是执行函数发生变化。不强制也可以直接赋值,因为赋值兼容规则作出了保证。


2、虚析构函数

继承关系中对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了。

当我们delete一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。

例如,QutoeBulk_quote 的父类。我们 delete一个 Quote*类型的指针,则该指针有可能实际指向了一个Bulk_quote 类型的对象。如果这样的话,编译器就必须清楚它应该执行的是Bu1k_quote的析构函数。和其他函数一样,我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:

class Quote {
public:
    如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
	virtual ~Quote() = default;		动态绑定析构函数
};

和其他虚函数一样,析构函数的虚属性也会被继承。因此,无论Quote的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们 delete基类指针时将运行正确的析构函数版本:

Quote* itemP = new Quote;	//静态类型与动态类型一致
delete itemP;				//调用 Quote的析构函数
itemP = new Bulk_quote;		//静态类型与动态类型不一致
delete itemP				//调用Bulk guote的析构函数

⚠️如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。

析构函数需要构成重写,那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

如前所述,在析构函数体执行完成后,对象的成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源:

class D :public Base {
public:
	//Base::~Base被自动调用执行
	~D() {/*该处由用户定义清除派生类成员的操作*/ }
};

对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。

那么我们在构造函数和析构函数中调用虚函数会发生什么呢?

如我们所知,派生类对象的基类部分将首先被构建。当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态。类似的,销毁派生类对象的次序正好相反,因此当执行基类的析构函数时,派生类部分已经被销毁掉了。由此可知,当我们执行上述基类成员的时候,该对象处于未完成的状态。
为了能够正确地处理这种未完成状态,编译器认为对象的类型在构造或析构的过程中仿佛发生了改变一样。也就是说,当我们构建一个对象时,需要把对象的类和构造函数的类看作是同一个:对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看成同一个的要求;对于析构函数也是同样的道理。上述的绑定不但对直接调用虚函数有效,对间接调用也是有效的,这里的间接调用是指通过构造函数(或析构函数)调用另一个函数。为了理解上述行为,不妨考虑当基类构造函数调用虚函数的派生类版本时会发生什么情况。这个虚函数可能会访问派生类的成员,毕竟,如果它不需要访问派生类成员的话,则派生类直接使用基类的虚函数版本就可以了。然而,当执行基类构造函数时,它要用到的派生类成员尚未初始化,如果我们允许这样的访问,则程序很可能会崩溃。

在此我们看一道选择题:

假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则()

A.A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址

B.A类对象和B类对象前4个字节存储的都是虚表的地址

C.A类对象和B类对象前4个字节存储的虚表地址相同

D.A类和B类中的内容完全一样,但是A类和B类使用的不是同一张虚表

此题选 B。为什么呢?

A.父类对象和子类对象的前4字节都是虚表地址。
B.A类对象和B类对象前4个字节存储的都是虚表的地址,只是各自指向各自的虚表。
C.不相同,各自有各自的虚表。
D.A类和B类不是同一类内容不同。

如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对于的虚函数版本。

⚠️派生类对象销毁时,先调用基类析构函数,后调用子类析构函数!


3、抽象基类和纯虚函数

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

抽象类负责定义接口,而后续的其他类可以覆盖该接口。

class LPL{
public:
	virtual void name() = 0;
};
class EDG :public LPL {
public:
	virtual void name() { cout << "EDG" << endl; }
};
class LNG :public LPL
{
public:
	virtual void name() { cout << "LNG" << endl; }
};
int main(){
	LPL* pEDG = new EDG;
	pEDG->name();		//EDG
	LPL* pLNG = new LNG;
	pLNG->name();		//LNG
	return 0;
}

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

我们可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个 =0 的函数提供函数体。若定义在类的内部,会出现错误:pure-specifier on function-definition。

4、多态的原理

通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个_vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f 代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析

class Base{
public:
    virtual void func1()  { cout << "Base::func1()" << endl; }
private:
    int _b = 1;
    char _ch = 'a';
};
int main(){
    cout << sizeof(Base) << endl;//12
    //有了虚函数后对象中会多一个指针,虚函数表指针
}

在这里插入图片描述

添加了两个函数后,类的大小仍不改变。

class Base{
public:
    virtual void func1() { cout << "Base::func1()" << endl; }
    virtual void func2() { cout << "Base::func2()" << endl; }
    void func3() { cout << "Base::func3()" << endl; }
private:
    int _b = 1;
    char _ch = 'a';
};
int main(){
    cout << sizeof(Base) << endl;//12
    //有了虚函数后对象中会多一个指针,虚函数表指针
    Base bb;
}

⚠️虚函数表指针简称虚表指针。

在这里插入图片描述

我们增加一个派生类Derive去继承Base,且Derive中重写Func1。Base再增加一个虚函数Func2和一个普通函数Func3。代码如下后:

class Base{
public:
    virtual void func1() { cout << "Base::func1()" << endl; }
    virtual void func2() { cout << "Base::func2()" << endl; }
    void func3() { cout << "Base::func3()" << endl; }
private:
    int _b = 1;
    char _ch = 'a';
};
class Derive :public Base{
public:
    virtual void func1() { cout << "Derive::func1()" << endl; }
private:
    int _d = 2;
}; 
void t()
{
    Base bb;	Derive dd;
    cout << sizeof(Base) << endl;//12
    cout << sizeof(Derive) << endl;//16
}

虚函数的重写也叫覆盖。 在这里插入图片描述

派生类对象dd中也有一个虚表指针,dd对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。

基类对象和派生类对象虚表是不一样的,这里我们发现Func1完成了重写,所以dd的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数 的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

即派生类由父类和派生类构成,父类中有虚表,子类中包含的父类(含有虚表)+子类自己的成员(无虚表)。另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。

虚表中存储的是虚函数的地址。虚函数和普通函数都存在代码段。

在此我们总结一下派生类的虚表生成:

  1. 先将基类中的虚表内容拷贝一份到派生类虚表中。
  2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
  3. 派生类自己,新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

满足多态以后的函数调用,不是在编译时确定的,是运行 起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

那么虚函数表存在哪呢? 栈区?堆区?还是常量区? (虚表地址存在对象的头四个字节上),我们通过如下代码观察:

class Base {
public:
    virtual void func1() { cout << "Base::func1()" << endl; }
    virtual void func2() { cout << "Base::func2()" << endl; }
    void func3() { cout << "Base::func3()" << endl; }
private:
    int _b = 1;
};
class Derive :public Base {
public:
    virtual void func1() { cout << "Derive::func1()" << endl; }
private:
    int _d = 2;
};
void t() {
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxx";
    printf("栈:%pn", &i);
    printf("静态区:%pn", &j);
    printf("堆:%pn", p1);
    printf("常量区:%pn", p2);
    Base b;  
    Derive d;
    Base* pb = &b;
    Derive* pd = &d;

    printf("Base虚表地址:%pn", *(int*)pb);
    printf("Derive虚表地址:%pn", *(int*)pd);
}

在这里插入图片描述

从打印结果可以看出 虚表位于常量区。(vs和linux下都是)

虚函数表是class specific的,也就是针对一个类来说的,这里就如同类里面的static成员遍历,即它是属于一个类所有对象的,不是属于某个对象特有的,是一个类所有对象公有的。

虚表是什么阶段生成的?

虚表是在编译时期生成的,而虚表指针是在构造函数的初始化列表生成的。一个类的不同对象用的同一张虚表。

虚表是在编译时生成的。
在构造函数中,走初始化列表之前,初始化虚表指针。

我们可以通过如下代码打印类的虚表,大家可以拿来实验:

class Base {
public:
    virtual void func1() { cout << "Base::func1()" << endl; }
    virtual void func2() {
        cout << "Base::func2()" << endl;
    }
    void func3() { cout << "Base::func3()" << endl; }
private:
    int _b = 1;
};
class Derive :public Base {
public:
    virtual void func1() { cout << "Derive::func1()" << endl; }
    virtual void func3() { cout << "Derive::func3()" << endl; }
private:
    int _d = 2;
};
typedef void(*VF_PTR)();
void PrintVFT(VF_PTR vtf[])
{
    cout << " 虚表地址>" << vtf << endl;
    for (int i = 0; vtf[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%x,->", i, vtf[i]);
        VF_PTR f = vtf[i];
        f();
    }
    cout << endl;
}
void t()
{
    Base b;
    Derive d;
    // 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数
    ///指针的指针数组,这个数组最后面放了一个nullptr
    // 1.先取b的地址,强转成一个int*的指针
    // 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
    // 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
    // 4.虚表指针传递给PrintVTable进行打印虚表
    // 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
    VF_PTR* vTableb = (VF_PTR*)(*(int*)&b);
    PrintVFT(vTableb);
    VF_PTR* vTabled = (VF_PTR*)(*(int*)&d);
    PrintVFT(vTabled);
}

⚠️多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

下面我们看一道关于继承的选择题,来帮我们理解:

假设D类先继承B1,然后继承B2,B1和B2基类均包含虚函数,D类对B1和B2基类的虚函数重写了,并且D类增加了新的虚函数,则:( )

A.D类对象模型中包含了3个虚表指针

B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后

C.D类对象有两个虚表,D类新增加的虚函数放在第二张虚表最后

D.以上全部错误

此题选 B。为什么呢?

A.D类有几个父类,如果父类有虚函数,则就会有几张虚表,自身子类不会产生多余的虚表,所以只有2张虚表。
C.子类自己的虚函数只会放到第一个父类的虚表后面,其他父类的虚表不需要存储,因为存储了也不能调用。


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

在C++中,重载、覆盖(重写)和隐藏(重定义)都是面向对象编程中的概念,用于处理函数的多态性。下面对它们进行比较:

  1. 重载(Overloading)

    • 定义:重载是指在同一个作用域内,使用相同的函数名但具有不同的参数列表的情况。函数重载可以根据参数的类型、顺序和个数进行区分。
    • 特点:
      • 函数名相同,参数列表不同。
      • 返回值类型可以相同也可以不同。
      • 发生在同一个类或命名空间中。
  2. 覆盖(重写,Override)

    • 定义:覆盖是指在派生类中重新实现基类中已经存在的虚函数。通过在派生类中使用相同的函数名、参数列表和返回类型来覆盖基类的函数。(协变除外)
    • 特点:
      • 函数名、参数列表和返回类型相同。
      • 发生在继承关系中,基类函数必须声明为虚函数。
  3. 隐藏(重定义,Hide)

    • 定义:隐藏是指在派生类中定义了与基类中相同名称的非虚函数,从而隐藏了基类中的同名函数。隐藏并不涉及到动态绑定。
    • 特点:
      • 函数名相同,参数列表可以相同也可以不同。
      • 发生在继承关系中,两个基类和派生类的同名函数不构成重写就是重定义。

总结:

  • 重载发生在同一个类或命名空间中的函数之间,根据参数的类型、顺序和个数进行区分。
  • 覆盖发生在继承关系中,派生类重新实现了基类中的虚函数,函数名、参数列表和返回类型相同。
  • 隐藏发生在继承关系中,派生类定义了与基类中同名的非虚函数,基类中的同名函数被隐藏。

需要注意的是,覆盖只能发生在虚函数上,而隐藏可以发生在虚函数和非虚函数上。使用 virtual 关键字声明函数为虚函数,从而允许覆盖。使用作用域解析运算符 :: 可以指定访问被隐藏的基类函数。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>