【C++】多态详解

💗个人主页💗

⭐个人专栏------C++学习

💫点击关注🤩一起学习C语言💯💫

目录

一、多态概念

二、多态的定义及实现

[1. 多态的构成条件](#1. 多态的构成条件)

[2. 虚函数](#2. 虚函数)

[2.1 什么是虚函数](#2.1 什么是虚函数)

[2.2 虚函数的重写](#2.2 虚函数的重写)

[2.3 虚函数重写的两个例外](#2.3 虚函数重写的两个例外)

[2.3.1 协变](#2.3.1 协变)

[2.3.2 析构函数的重写](#2.3.2 析构函数的重写)

[3. override 和 final](#3. override 和 final)

[4. 重载、覆盖(重写)、隐藏(重定义)的对比](#4. 重载、覆盖(重写)、隐藏(重定义)的对比)

三、抽象类

[1. 概念](#1. 概念)

[2. 接口继承和实现继承](#2. 接口继承和实现继承)

四、多态的原理

[1. 虚函数表](#1. 虚函数表)

[2. 多态的原理](#2. 多态的原理)

[3. 动态绑定与静态绑定](#3. 动态绑定与静态绑定)

五、单继承和多继承关系的虚函数表

[1. 单继承中的虚函数表](#1. 单继承中的虚函数表)

[2. 多继承中的虚函数表](#2. 多继承中的虚函数表)


一、多态概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。

举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。

多态的优势在于它增加了代码的灵活性和可扩展性。通过多态,我们可以编写通用的代码,处理多种类型的对象,使得程序更易于理解、扩展和维护。

二、多态的定义及实现

1. 多态的构成条件

那么在继承中要构成多态还有两个条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

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

cpp 复制代码
class Person 
{
public:
	virtual void BuyTicket() 
	{
		cout << "买票全价" << endl; 
	}
};

class Student : public Person 
{
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票半价" << endl; 
	}
};
void Func(Person& people)
{
	people.BuyTicket();
}
int main()
{
	Person Mike;
	Func(Mike);

    Student Johnson;
	Func(Johnson);

	return 0;
}

2. 虚函数

2.1 什么是虚函数

虚函数(virtual function)是面向对象编程中的一个概念,它是用来实现多态的机制之一。

虚函数:即被virtual修饰的类成员函数称为虚函数。

虚函数是在基类中声明的一种特殊的成员函数,用关键字virtual进行声明。当在派生类中重写(override)这个函数时,可以使用关键字override来显式标识。

通过将函数声明为虚函数,可以实现动态绑定(dynamic binding),即在运行时根据对象的实际类型来调用相应的函数。这意味着当通过基类指针或引用调用虚函数时,实际调用的是对应的派生类中的函数。

cpp 复制代码
class Person 
{
public:
    virtual void BuyTicket() 
    { 
        cout << "买票全价" << endl; 
    }
};

2.2 虚函数的重写

虚函数的重写是指在派生类中重新定义(override)基类中已经声明的虚函数。通过重写虚函数,可以在派生类中提供自己的实现,以替代基类中的默认实现。

重写虚函数的规则如下:

  1. 基类中的虚函数必须使用关键字virtual进行声明。
  2. 派生类中的重写函数必须具有相同的名称、参数列表和返回类型。
  3. 派生类中的重写函数必须使用关键字override进行标识,以确保编译器进行正确的检查。
  4. 重写函数可以被声明为虚函数,但这不是必需的。
cpp 复制代码
class Animal 
{
public:
    virtual void makeSound() 
    {
        cout << "Animal is making a sound" << endl;
    }
};

class Dog : public Animal 
{
public:
    void makeSound() override 
    { // 重写虚函数
        cout << "Dog is barking" << endl;
    }
};

class Cat : public Animal 
{
public:
    void makeSound() override 
    { // 重写虚函数
        cout << "Cat is meowing" << endl;
    }
};

int main() 
{
    Animal* animal = new Animal();
    Animal* dog = new Dog();
    Animal* cat = new Cat();

    animal->makeSound(); // 输出 "Animal is making a sound"
    dog->makeSound(); // 输出 "Dog is barking"
    cat->makeSound(); // 输出 "Cat is meowing"

    delete animal;
    delete dog;
    delete cat;

    return 0;
}

在上述代码中,Animal类中的makeSound()函数被声明为虚函数。派生类Dog和Cat分别重写了这个函数,并提供了自己的实现。

在main()函数中,通过基类指针调用makeSound()函数时,实际上会根据指针指向的对象类型来调用相应的派生类中的函数。这就是虚函数的重写实现多态性的一种方式。

2.3 虚函数重写的两个例外

2.3.1 协变

虚函数的协变(covariant)是指派生类可以返回比基类更具体的类型。

换句话说,派生类可以重写基类中的虚函数,并返回一个派生类类型的指针或引用,而不仅仅是基类类型的指针或引用。

协变发生在满足以下条件时:

  1. 基类中的虚函数必须返回指针或引用类型。
  2. 派生类中重写的函数可以返回指向派生类的指针或引用类型,该派生类是基类中返回类型的派生类。
cpp 复制代码
class Animal 
{
public:
    virtual Animal* create() 
    {
        return new Animal();
    }
};

class Dog : public Animal 
{
public:
    Dog* create() override 
    { // 协变的重写函数
        return new Dog();
    }
};

class Cat : public Animal 
{
public:
    Cat* create() override 
    { // 协变的重写函数
        return new Cat();
    }
};

int main() 
{
    Animal* animal = new Animal();
    Animal* dog = new Dog();
    Animal* cat = new Cat();

    Animal* newAnimal = animal->create();
    Animal* newDog = dog->create();
    Animal* newCat = cat->create();

    delete animal;
    delete dog;
    delete cat;
    delete newAnimal;
    delete newDog;
    delete newCat;

    return 0;
}

在上述代码中,Animal类中的create()函数返回一个指向Animal类型的指针。派生类Dog和Cat分别重写了这个函数,并返回指向Dog和Cat类型的指针。

在main()函数中,通过调用基类指针的create()函数,实际上会调用对应派生类中重写的create()函数。由于协变的存在,可以将返回的指针赋值给指向基类的指针,这样便保留了派生类的类型信息。

注意,为了实现协变,返回类型必须是指针或引用,而不能是值类型。否则无法满足派生类返回类型更具体的要求。

2.3.2 析构函数的重写

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

以下是一个示例,展示了虚函数中的析构函数重写是没有意义的:

cpp 复制代码
class Animal 
{
public:
    Animal() 
    {
        cout << "Animal constructor called." << endl;
    }

    virtual ~Animal() 
    {
        cout << "Animal destructor called." << endl;
    }

    virtual void speak() 
    {
        cout << "Animal speaks." << endl;
    }
};

class Dog : public Animal 
{
public:
    Dog() 
    {
        cout << "Dog constructor called." << endl;
    }

    ~Dog() 
    {
        cout << "Dog destructor called." << endl;
    }

    void speak() override 
    {
        cout << "Dog barks." << endl;
    }
};

int main() 
{
    Animal* animal = new Dog();
    animal->speak();
    delete animal;

    return 0;
}

Animal类定义了一个虚函数speak(),而派生类Dog中重写了该函数。在main()函数中,通过基类指针创建了一个Dog对象,然后调用speak()函数。

从结果可以看出,析构函数按照预期顺序调用,无需在派生类中显式重写。同时,虚函数speak()也可以正确地根据对象的实际类型调用对应的版本。

3. override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

  1. final:修饰虚函数,表示该虚函数不能再被重写
cpp 复制代码
class Base 
{
public:
   virtual void foo() final 
   {
      // 函数实现
   }
};

class Derived : public Base 
{
public:
   void foo() override 
   { // 编译错误,不能重写被声明为final的虚函数
      // 函数实现
   }
};
  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
cpp 复制代码
class Base 
{
public:
    virtual void foo() 
    {
        // 函数实现
    }
};

class Derived : public Base 
{
public:
    void foo() override 
    { // 使用override关键字重写基类的虚函数
       // 函数实现
    }
};

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

三、抽象类

1. 概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
抽象类可以用于实现接口的一致性和多态性。通过基类指针或引用,可以在运行时动态地选择调用派生类的实现。

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

    void sleep() 
    {
        cout << "Zzz..." << endl;
    }
};

class Dog : public Animal 
{
public:
    void makeSound() override 
    {
        cout << "Woof!" << endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() override 
    {
        cout << "Meow!" << endl;
    }
};

int main() 
{
    Animal* animalPtr = new Dog();
    animalPtr->makeSound(); // 输出 "Woof!"
    animalPtr->sleep(); // 输出 "Zzz..."
    delete animalPtr;

    animalPtr = new Cat();
    animalPtr->makeSound(); // 输出 "Meow!"
    animalPtr->sleep(); // 输出 "Zzz..."
    delete animalPtr;

    return 0;
}

Animal是一个抽象类,包含一个纯虚函数makeSound()和一个普通的成员函数sleep()DogCatAnimal的派生类,必须实现makeSound()函数。通过基类指针,可以调用派生类的实现。

需要注意的是,无法直接实例化抽象类,因为它包含纯虚函数没有具体的实现。派生类必须实现所有纯虚函数,才能被实例化。抽象类可以作为基类来定义其他类,从而实现代码的复用和扩展。

2. 接口继承和实现继承

接口继承和实现继承是面向对象编程中的两种继承方式。

  1. 接口继承(Interface Inheritance)是指一个接口(interface)可以继承自另一个接口,继承接口的子接口会继承父接口的所有方法定义。接口继承主要用于定义一组共享的方法规范,子接口可以继续扩展这些方法规范,同时可以添加自己的方法规范。接口继承可以实现多继承的效果,一个类可以同时实现多个接口。

  2. 实现继承(Implementation Inheritance)是指一个类(class)可以继承自另一个类,继承类会继承父类的属性和方法。实现继承主要用于类之间的层级关系,子类可以继承父类的行为和状态,同时还可以添加自己特有的行为和状态。实现继承是一种单继承的方式,一个类只能直接继承一个父类。

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

四、多态的原理

1. 虚函数表

我们来看下面的代码,想一下sizeof(a)是多少。

cpp 复制代码
class A
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};
int main()
{
	A a;
	cout << sizeof(a) << endl;

	return 0;
}

第一眼看去,这不就是4吗,我们来看一下运行结果:

为什么呢?我们打开监视窗口来看一下:

我们发现,a里面不仅仅存在一个_b,还有一个_vfptr放在对象的前面(注意有些 平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。

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

那么派生类中这个表放了些什么呢?我们接着往下分析,针对上面的代码我们做出以下改造

  1. 我们增加一个派生类Derive去继承Base
  2. Derive中重写Func1
  3. Base再增加一个虚函数Func2和一个普通函数Func3
cpp 复制代码
class A
{
public:
	virtual void Func1()
	{
		cout << "A::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "A::Func2()" << endl;
	}
	void Func3()
	{
		cout << "A::Func3()" << endl;
	}
private:
	int _a = 1;
};
class B : public A
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _b = 2;
};
int main()
{
	A a;
	B b;
	cout << sizeof(a) << endl;
	cout << sizeof(b) << endl;
	return 0;
}

我们再来看下这个代码结果是多少呢:

这又是为什么呢?我们继续来看一下调试窗口:

通过观察和测试,我们发现了以下几点问题:

  1. 派生类对象b中也有一个虚表指针,b对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
  2. 基类a对象和派生类b对象虚表是不一样的,这里我们发现Func1完成了重写,所以b的虚表中存的是重写的B::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

总结一下派生类的虚表生成:

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

这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。

注意虚表存的是虚函数指针,不是虚函数 ,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。**另外对象中存的不是虚表,存的是虚表指针。**那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。

2. 多态的原理

上面分析了这个半天了那么多态的原理到底是什么?我们来继续探索。

cpp 复制代码
class A
{
public:
	virtual void Func1()
	{
		cout << "A::Func1()" << endl;
	}
private:
	int _a = 1;
};
class B : public A
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _b = 2;
};
int main()
{
	A a;
	B b;

	return 0;
}

我们会疑惑,为什么多态可以实现指向父类调用父类函数 ,指向子类调用子类函数?

我们来看看虚表里的指针内容:

在监视窗口里我们可以看到a对象和b对象里的内容,我们继续打开内存窗口,取地址a,

我们看到第一个内容就是_vfptr的地址,继续通过_vfptr的地址,我们就能看到虚表里的内容,这时我们又看到了虚函数的地址。

我们来取地址b,发现和上诉同理。

但是,这时我们发现怎么在B类里的A的虚函数地址,和它原本在A类里的地址不一样,这就是虚函数的重写覆盖。

当一个类声明了虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个存储了虚函数指针的表格,每个包含虚函数的类都会有自己的虚函数表。虚函数表的每个表项存储了相应虚函数的地址。对于派生类,虚函数表中的表项可能会被重写以指向派生类中的虚函数。

当使用基类指针或引用调用虚函数时,编译器会根据对象的实际类型来查找虚函数表,并调用相应的虚函数。这种通过虚函数表来实现动态绑定的机制使得C++中的对象能够在运行时根据实际类型来确定调用的函数,而不是在编译时就确定下来。

3. 动态绑定与静态绑定

动态绑定和静态绑定是两种不同的函数调用机制,它们在编程语言中起到的作用也不同。

  • 静态绑定(静态分派): 静态绑定是在编译时期完成的,也称为早期绑定。在静态绑定中,函数调用的目标函数是在编译时根据对象的静态类型确定的。当调用一个函数时,编译器会根据对象的声明类型来确定要调用的函数,而不管对象的实际类型。

静态绑定适用于非虚函数和静态函数,这些函数的调用目标不会发生改变。在静态绑定中,对象的方法调用是基于声明类型进行的,也就是编译期间根据对象的静态类型来确定调用哪个函数,无法体现多态性。

  • 动态绑定(动态分派): 动态绑定是在运行时期完成的,也称为晚期绑定。在动态绑定中,函数调用的目标函数是根据对象的实际类型来确定的。当调用一个虚函数时,编译器会根据对象的实际类型来确定要调用的函数。

动态绑定适用于虚函数,虚函数表(vtable)是实现动态绑定的一种机制。每个包含虚函数的类都会有一个虚函数表,该表存储了类中虚函数的地址。运行时系统通过指向基类的指针或引用,查找对象实际的类型,并根据其虚函数表来确定要调用的函数。

动态绑定允许程序在运行时根据对象的实际类型来选择调用的函数,实现了多态性。这样可以实现基于对象的具体行为,并提高代码的灵活性。

五、单继承和多继承关系的虚函数表

1. 单继承中的虚函数表

cpp 复制代码
class Base 
{
public:
	virtual void func1() 
	{ 
		cout << "Base::func1" << endl; 
	}
	virtual void func2() 
	{ 
		cout << "Base::func2" << endl; 
	}
private:
	int a;
};
class Derive :public Base 
{
public:
	virtual void func1() 
	{ 
		cout << "Derive::func1" << endl; 
	}
	virtual void func3() 
	{ 
		cout << "Derive::func3" << endl; 
	}
	virtual void func4() 
	{
		cout << "Derive::func4" << endl; 
	}
private:
	int b;
};
int main()
{
	Base b;
	Derive d;

	return 0;
}

观察下图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这 两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?下面我们使用代码打印 出虚表中的函数。

cpp 复制代码
class Base 
{
public:
	virtual void func1() 
	{ 
		cout << "Base::func1" << endl; 
	}
	virtual void func2() 
	{ 
		cout << "Base::func2" << endl; 
	}
private:
	int _a;
};
class Derive :public Base 
{
public:
	virtual void func1() 
	{ 
		cout << "Derive::func1" << endl; 
	}
	virtual void func3() 
	{ 
		cout << "Derive::func3" << endl; 
	}
	virtual void func4() 
	{
		cout << "Derive::func4" << endl; 
	}
private:
	int _b;
};
typedef void(*VFTPTR)();

void PrintVFPtr(VFTPTR a[])
{
	for (size_t i = 0; a[i] != 0; i++)
	{
		printf("a[%d]:%p\n", i, a[i]);
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	PrintVFPtr((VFTPTR*)*(int*)&b);
	PrintVFPtr((VFTPTR*)*(int*)&d);

	return 0;
}

2. 多继承中的虚函数表

cpp 复制代码
class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

相关推荐
此生只爱蛋1 分钟前
【手撕排序2】快速排序
c语言·c++·算法·排序算法
blammmp9 分钟前
Java:数据结构-枚举
java·开发语言·数据结构
何曾参静谧21 分钟前
「C/C++」C/C++ 指针篇 之 指针运算
c语言·开发语言·c++
暗黑起源喵27 分钟前
设计模式-工厂设计模式
java·开发语言·设计模式
WaaTong32 分钟前
Java反射
java·开发语言·反射
咕咕吖33 分钟前
对称二叉树(力扣101)
算法·leetcode·职场和发展
Troc_wangpeng33 分钟前
R language 关于二维平面直角坐标系的制作
开发语言·机器学习
努力的家伙是不讨厌的35 分钟前
解析json导出csv或者直接入库
开发语言·python·json
Envyᥫᩣ1 小时前
C#语言:从入门到精通
开发语言·c#
九圣残炎1 小时前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode