【C++】多态


声明:本文中的代码及运行结果均是在32位平台下、VS2022中的测试结果。


概念

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

再进一步到C++中,就是函数调用的多种形态,这一特性可以让我们在调用函数时更加灵活。

多态又分为静态多态和动态多态:

  • 静态多态:主要指函数重载。
  • 动态多态:主要指父类的指针或引用调用、重写虚函数。如果父类的指针或引用指向父类,就调用父类的虚函数;如果父类的指针或引用指向某个子类,就调用那个子类的虚函数。

静态多条在前面讲过,本篇主要讲动态多态,从其定义中就可看出,这里和继承关系非常密切,所以如果对继承不太熟的读者,可以到【C++】继承 了解一下。


一、多态的定义及实现

1.多态的构成条件

多态是指不同继承关系的类对象,调用同一函数时产生了不同的行为。比如Student(学生)继承了Person(承认),Person对象在买票这一行为上需要全价,而Student对象买票半价。

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

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

2.virtual关键字和虚函数

成员函数前加virtual关键字后,该函数就变为虚函数(注意一般的函数前不可以加virtual)。

示例代码如下:

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

virtual void func()
{}

编译结果如下:
在这里插入图片描述


注意:

  1. 只有类的非静态成员函数才可以成为虚函数。
  2. 虚函数和虚继承都用到了virtual关键字,但二者之间没有任何关系。虚函数使用virtual是为了实现多态,而虚继承是为了解决菱形继承中产生的数据冗余和二义性问题,它们之间没有关联。

3.虚函数重写

派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同,这时称子类的虚函数重写了基类的虚函数。

示例代码如下:

class Person //父类
{
public:
	virtual void BuyTickets()
	{
		cout << "全价票" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual void BuyTickets() //虚函数重写
	{
		cout << "半价票" << endl;
	}
};

void func(Person& p) //代码以引用为例,指针同理
{
	p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)
}

int main()
{
	Person p;
	Student s;

	//父类的引用接收不同类型的对象,实现了多态
	func(p);
	func(s);

	return 0;
}

运行结果如下:
在这里插入图片描述
上面代码中func函数的参数p是父类的引用,接收不同类型的对象时调用虚函数产生了不同的结果,如果将参数改为父类的指针也可以实现同样的效果,但是如果是父类的对象则不可以。


下面的代码中,func1以父类的引用为参数,func2以父类的指针为参数,func3以父类的对象为参数。

示例代码如下:

class Person //父类
{
public:
	virtual void BuyTickets()
	{
		cout << "全价票" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual void BuyTickets() //虚函数重写
	{
		cout << "半价票" << endl;
	}
};

void func1(Person& p) //父类引用
{
	p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)
}

void func2(Person* p) //父类指针
{
	p->BuyTickets();
}

void func3(Person p) //父类对象
{
	p.BuyTickets();
}

int main()
{
	Person p;
	Student s;

	//父类的引用接收不同类型的对象,实现了多态
	func1(p);
	func1(s);
	cout << endl;

	//父类的指针接收不同类型的对象,实现了多态
	func2(&p);
	func2(&s);
	cout << endl;

	//父类的对象接收不同类型的对象,无法实现多态
	func3(p);
	func3(s);

	return 0;
}

很显然以父类的对象为参数无法实现多态,这也印证了最开始提到的,只有父类的引用或指针才可以实现多态。

运行结果如下:
在这里插入图片描述


而如果父类的函数都不是virtual修饰的虚函数,那么就更无法实现多态了,很容易理解,这里就不做演示了。


4.虚函数重写的三个例外

(1)协变

子类重写父类虚函数时,与父类虚函数仅有返回值类型不同,且父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。

示例代码如下:

class Person //父类
{
public:
	virtual Person* pointer()  //返回值为Person*
	{
		cout << "Person* pointer()" << endl;
		return new Person;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual Student* pointer()  //返回值为Student*
	{
		cout << "Student* pointer()" << endl;
		return new Student;
	}
};

int main()
{
	Person p;
	Student s;
	Person* ptr;
	
	ptr = &p;
	ptr->pointer(); //调用父类的

	ptr = &s;
	ptr->pointer(); //调用子类的
	return 0;
}

运行结果如下,可以看到虽然pointer()函数的返回值不同,但依然实现了多态。

在这里插入图片描述


(2)析构函数重写

由于析构函数的函数名是有要求的,所以从代码角度看,子类和父类析构函数的函数名不同,破坏了多态的要求。但实际上,在【C++】继承 提到过,编译器对所有析构函数的函数名都做了特殊处理,编译后析构函数的名称统一处理成destructor。

但是析构函数重写是必要的吗?

下面列举两种情况:

①不需要重写虚构函数

示例代码如下:

class Person //父类
{
public:
	~Person()
	{
		cout << "~Person()" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	Person p;
	Student s;

	return 0;
}

运行结果如下:
在这里插入图片描述

这时两个类中都没有什么需要在析构函数中处理的东西,所以不重写析构函数没有什么影响。

这里再解释一下打印的结果:临时变量储存在栈中,符合后进先出的规则,所以先析构s,后析构p。而析构s时根据继承的规则,先调用子类的析构函数、再调用父类的析构函数,这时s的析构结束了。之后再析构p。


②需要重写析构函数

示例代码如下:

class Person //父类
{
public:
	~Person()
	{
		cout << "~Person()" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	Person* p = new Person;
	Person* s = new Student;

	delete p;
	delete s;

	return 0;
}

运行结果如下:
在这里插入图片描述

从打印结果来看,s指针在delete时仅调用了父类的析构函数,而他在new时申请的是Student的空间,这样就可能造成内存泄漏。这种场景下就需要对Student类的析构函数进行重写。


下面代码将两个类的析构函数定义为虚函数,这样就可以达到析构的目的。

示例代码如下:

class Person //父类
{
public:
	virtual ~Person()
	{
		cout << "~Person()" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual ~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	Person* p = new Person;
	Person* s = new Student;

	delete p;
	delete s;

	return 0;
}

运行结果如下,通过重写析构函数,指针在delete时就可以释放对应的资源,不会出现内存泄漏的问题。
在这里插入图片描述


(3)子类的虚函数可以不用virtual修饰

如果父类的某一个函数已经定义为虚函数,那么子类与之相同函数不需要用virtual修饰,也认为是完成了重写。但实际上这也是C++的一个小坑,为了方便代码的维护,建议还是只要需要用到虚函数的位置全部都用virtual修饰。


5.final

修饰父类的虚函数,使该虚函数无法被重写。

示例代码如下:

class Person //父类
{
public:
	virtual void BuyTickets() final
	{
		cout << "全价票" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual void BuyTickets() //虚函数重写
	{
		cout << "半价票" << endl;
	}
};

可以看到,在编译阶段就报错,子类中无法对父类的BuyTickets()进行重写。

编译结果如下:
在这里插入图片描述


6.override

修饰子类的虚函数,检查该虚函数是否重写,若没有重写则编译报错。

示例代码如下:

class Person //父类
{
public:
	virtual void BuyTickets()
	{
		cout << "全价票" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual void BuyTickets(int i) override//虚函数重写
	{
		cout << "半价票" << endl;
	}
};

代码中子类的BuyTickets函数与父类的参数列表不同,没有构成重写,override直接在编译阶段就报错。

编译结果如下:
在这里插入图片描述


二、抽象类

1.概念

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

下图中用抽象类创建对象失败,证明抽象类无法实例化出对象。
在这里插入图片描述


2.接口继承和实现继承

普通函数的继承是一种实现继承,子类继承了父类函数,继承的是函数的实现。而虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。尤其是抽象类的产生,更是强制子类重写父类(否则子类也无法实例化出对象,类的功能大打折扣)。


三、多态的原理

1.引出虚函数表指针

定义一个如下的类,类成员有一个int类型的变量和一个char类型的变量,成员函数有一个虚函数,计算它的大小。

class A
{
public:
	virtual void Func()
	{
		cout << "virtual void Func()" << endl;
	}
private:
	int _b = 0;
	char _ch = '\0';
};

int main()
{
	A a;
	cout << sizeof(a) << endl;
	return 0;
}

根据自定义类型(一):结构体 中内存对齐的规则可得该类定义出的对象的大小为8字节,但下面的运行结果是12字节(运行环境是32位平台、VS2022,后文中不再解释)。
在这里插入图片描述
通过调试来看对象a中具体有什么,为什么它的大小会是12字节?
在这里插入图片描述
通过调试看到对象a中不仅仅有类中定义的两个变量,还有一个void类型的指针,这个指针是虚函数表指针**,对象a大小为12字节正是因为多了这一个指针变量。

只要类中有虚函数,就一定会存在虚函数表指针,它就是用来实现多态的。


2.虚函数表

一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(本质是一个数组)中,虚表指针指向的就是这个数组的起始地址。

虚函数表也简称虚表,虚函数表指针简称为虚表指针。


如下代码的类中定义了两个虚函数,一个普通函数,观察通过它定义的对象中虚表指针的情况。

class A
{
public:
	virtual void Func() //虚函数
	{
		cout << "virtual void Func()" << endl;
	}
	
	virtual void Func2() //虚函数
	{}

	void Func3() //普通函数
	{}
private:
	int _b = 0;
	char _ch = '\0';
};

int main()
{
	A a;
	cout << sizeof(a) << endl;
	return 0;
}

可以看到,类中定义了两个虚函数,则对象中含有虚表指针指向的数组中有两个元素,而普通函数则没有对应的虚表指针。
在这里插入图片描述
再次提醒:多态和虚拟继承都用到了virtual关键字,但二者毫无关联。具体到这里,多态中的virtual定义虚函数,由此引出了虚函数表和虚函数指针;而虚拟继承中的虚基表存放的是虚基类的偏移量,二者同样无关,注意不要混淆。


3.多态的原理

再回到前面的代码,通过解释其逻辑来说明多态的原理:

class Person //父类
{
public:
	virtual void BuyTickets()
	{
		cout << "全价票" << endl;
	}
};

class Student : public Person //派生类继承父类
{
public:
	virtual void BuyTickets()//虚函数重写
	{
		cout << "半价票" << endl;
	}
};

int main()
{
	Person p;
	p.BuyTickets();
	Student s;
	s.BuyTickets();

	return 0;
}

通过调试可以看到,p和s中的虚表指针指向的地址不同,也就是说两个类中定义的BuyTickets()不是同一个,所以在调用时调用各自定义的函数,实现了多态。
在这里插入图片描述
满足多态条件、构成多态后,父类的指针或引用调用哪个虚函数,不是在编译时确定的,而是运行到指定位置、需要从指针或引用指向的对象的虚表中找虚函数地址时才确定的。所以如果指向的是父类对象,则调用父类的虚函数;指向的是子类对象,则调用子类的虚函数。

但如果不满足多态条件,那么调用哪个函数是在编译是就可以确定的,调用函数的对象是什么类型,就调用哪个类型定义的虚函数,与传什么类型的参数无关。


四、静态绑定和动态绑定

  • 静态绑定又称为前期绑定(早绑定),即在程序编译期间就确定了程序的行为,也称为静态多态,比如本文最开始提到的函数重载。
  • 动态绑定又称后期绑定(晚绑定),即在程序运行期间根据具体的类型来确定程序的具体行为,并确定具体调用哪个函数,也称为动态多态。

感谢阅读,如有错误请批评指正

  • 14
    点赞
  • 57
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
C语言是一种静态类型语言,它不像面向对象语言那样直接支持多态。但是,我们可以通过结构体、函数指针和类型转换等技巧来实现类似于多态的效果。 一种常见的方法是使用函数指针数组。我们可以定义一个基类结构体,其中包含一个函数指针数组,每个函数指针指向一个虚函数。然后,派生类可以继承这个基类结构体,并对其中的虚函数进行重写。在运行时,我们可以根据实际对象的类型来动态地选择调用哪个虚函数。 以下是一个简单的示例代码,演示了如何使用函数指针数组实现多态: ```c #include <stdio.h> // 基类结构体 struct Animal { const char* name; void (*speak)(); }; // 派生类结构体 struct Dog { struct Animal base; // 继承基类 }; // 虚函数的具体实现 void speak_dog() { printf("Woof!\n"); } int main() { struct Dog my_dog; my_dog.base.speak = speak_dog; // 继承虚函数 my_dog.base.speak(); // 动态调用虚函数 return 0; } ``` 在这个例子中,我们定义了一个基类结构体 `Animal`,其中包含一个函数指针 `speak`,指向一个虚函数。然后,我们定义了一个派生类结构体 `Dog`,它继承了 `Animal`。在 `main()` 函数中,我们创建了一个 `Dog` 对象,并将 `speak()` 函数指针指向 `speak_dog()` 函数,这样就实现了多态。最后,我们调用 `speak()` 函数,它会动态地调用 `speak_dog()` 函数。 需要注意的是,这种实现方法并不是真正的多态,因为它没有提供运行时的类型检查和类型转换。在实际开发中,建议使用面向对象语言来实现多态

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

山舟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值