C++ 多态与虚函数

1. 多态的概念

多态的性质

多态性是面向对象程序设计的一个重要特性。
多态本意:一个事物有多种形态。
在面向对象程序设计中的多态性:向不同对象发送同一个消息,不同的对象在接受时会产生不同的行为(方法)。也就是说,每个对象可以用自己的方式去响应相同的消息。所谓消息,就是调用函数,不同的行为就是指不同的实现,即执行不同的函数。
例如:
买票问题:人群是个抽象的概念,学生,军人,成人,小孩等都是人群的具体化。
比如买票这个行为,同一个买票窗口,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。从抽象角度来解释就是,不同对象调用同一个函数,执行的却不是同一个行为,而是多种不同行为。

说明:其实我们之前接触到的函数重载、运算符重载其实都是多态现象(在同一作用域的)。只是根据参数属性来调用不同的函数来实现,即它们以不同的行为或方法来响应同一个消息。

多态的分类

从系统实现的角度来看,多态性分为两类:静态多态性和动态多态性。

静态多态性是通过函数重载实现的。由函数重载和运算符重载形成的多态性属于静态多态性,要求在程序编译时就知道调用函数的全部信息,因此,在程序编译时系统就能决定要调用那个函数。所以静态多态性又称编译时的多态性。
特点:函数调用速度快、效率高、但缺乏灵活性,在程序运行前就决定了执行的函数和方法。
动态多态性是通过虚函数实现的。不在编译时确定调用的是那一个函数,而是在程序运行过程中才动态地确定操作所针对的对象,再去调用具体的行为函数。又称运行时的多态。

2. 虚函数实现动态多态性

基类指针/引用+ 虚函数 = 动态多态性

多态的构成条件

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

#include<iostream>
using namespace std;

class Person {
public:
	virtual void BuyTicket() { cout << "Person:买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "Student:买票-半价" << endl; }
	/*void BuyTicket() { cout << "买票-半价" << endl; }*/
	/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继
	承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用
	*/
	
};
void Func(Person& people)//引用或指针调用,根据对象的不同,去调用具体的实现
{
	people.BuyTicket();
}
int main()
{
	Person Mike;
	Student Johnson;
	Func(Mike);
	Func(Johnson);
	return 0;
}

在继承中要构成多态的两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
在这里插入图片描述
满足多态
引用或指针调用,且满足虚函数重写,即构成多态,根据所指或所引用对象的不同,去调用该对象具体的实现 :
在这里插入图片描述
不满足多态
不构成多态,类型决定调用的实现,即调用该类型对象的函数实现:
在这里插入图片描述

继承里学过:派生类对象可以代替基类对象向基类对象的引用初始化或赋初值(切片原理)

传参的隐含转换,Person& p=Johnson ;//p是Person类的引用,被派生类对象johnson初始化。

这一句隐含的转换中,不能认为p是派生类对象Johnson的别名,它得到了johnson的起始地址,只是john中基类部分的别名(切片),与john中基类部分共享同一段存储单元。所以调用的是基类Person的买票函数。
在这里插入图片描述

虚函数

虚函数:即被virtual修饰的类成员函数可称为虚函数。
虚函数的作用:允许在派生类中重新定义与基类同名的函数,并且可以通过基类指针或引用来访问基类和派生类中的同名函数。

回顾与思考:
1.在同一个作用域中是不能定义两个名字相同、参数个数、参数类型都相同的函数的,及重复定义。
2.在类的继承层次结构中,在不同的类中可以出现这样相同的函数,即派生类此函数隐藏了基类的此函数,派生类对象可以直接调用,如:son.func(); 而基类函数要显式调用,如:son.Father::func();
3.而利用虚函数和指针/引用实现的多态,可通过同一个调用方式(p>func()/p.func()),来调用基类和派生类的同名函数。调用前临时给一个指针变量或者引用类型变量赋以不同对象,在程序运行期间,通过该指针变量调用此虚函数,此时调用的就是指针变量指向的对象的同名函数。来实现多态性。

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

虚函数重写的两个例外(不是特定规则)

1. 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class Person {
public:
	virtual Person* BuyTicket() { 
		cout << "Person:买票-全价" << endl; 
		return new Person();
	}
};
class Student : public Person {
public:
	virtual Student* BuyTicket() { 
		cout << "Student:买票-半价" << endl; 
		return new Student(); 
	}
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person Mike;
	Student Johnson;
	Func(Mike);
	Func(Johnson);
	return 0;
}

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


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

class Student : public Person {
public:
	virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数(不重写便只是继承了基类的虚函数),下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;//要是不是多态,这里p2只能访问基类的析构函数(切片)。满足多态了,则派生类会重写从person继承下来的同名析构函数,由于我在虚函数表里已重写,再根据所指对象调用正确的析构,而派生类会先执行自己的析构,在调用父类的析构
	return 0;
}

在这里插入图片描述
在这里插入图片描述
从上面这个例子中便又可以看出虚函数的作用(感觉一直在重复这个概念):

  • 没用虚函数声明前(未满足多态)
    本来基类指针是用来指向基类对象的,如果用它来指向派生类对象(或者说派生类对象来初始化基类指针),则自动进行指针类型转换,将派生类对象的指针先转换为基类的指针,这样,基类指针指向的是派生类对象中的基类部分。
//类似于这句
double d;
int a=d;//; 其实有一步隐藏操作, int tmp=(double)d;再 a=tmp;
  • 用了虚函数声明后(即满足了多态)
    虚函数突破了这一个限制,在用virtual声明函数为虚函数后,派生类中继承下去后被重写,此时派生类中的同名函数就取代了基类的同名函数(覆盖了)。因此在使基类指针指向派生类后,调用这个同名函数时就调用的是派生类的函数。

说明:在程序中最好把基类的析构函数声明成虚函数,这使得所有的派生类析构函数自动成为虚函数。即使基类不需要析构函数,也要显式的定义一个函数体为空的虚析构函数(可不是默认析构函数,这是虚的),以满足派生类撤销动态内存空间能得到正确的处理。
注意:构造函数不能声明成虚函数。因为是运行时确定的,但是构造函数是完成对象建立的,还没建立,怎么把函数和类对象绑定。

容易混淆的三个概念
在这里插入图片描述

C++11 override 和 final

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

1.final:修饰虚函数,表示该虚函数不能再被继承,修饰类,表示该类是最终类,不能被继承。

class A
{
public:
	virtual void func() final {} //则该虚函数不允许被继承
};
class B :public A
{
public:
	virtual void func() {cout << "!!!" << endl;}
};

2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
例如:要是包含重写说明符override的方法没有重写任何基类方法,便报错,类似于断言的机制。

class A{
public:
	 void func(){}//忘写虚函数声明
};
class B :public A {
public:
	virtual void func()override{ cout << "!!!" << endl; } //类似于断言的机制,不加override检测的话,就是同名函数的隐藏,编译时是检测不到程序没有实现预期的多态的失误,当运行时在来debug,就得不偿失了
};

在这里插入图片描述
静态关联和动态关联

3.纯虚函数与抽象类

纯虚函数:

有时在基类中将某一成员函数定为一个虚函数,并不是基类本身的需求,而是考虑到继承多态中派生类的需要,在基类中预留了一个函数名(接口?),具体功能留给派生类根据需要去定义。

格式:

//virtual 函数类型 函数名(参数列表) = 0;

注意:
1.纯虚函数没有函数体;即只有函数的名字,而不具备函数的功能,不能被调用;
2.最后面的“=0”不是说返回值为0,而是标识或声明这是个纯虚函数;
3.若派生类没有对继承的纯虚函数重写,则该函数在派生类中仍为纯虚函数。

抽象类

不用来定义对象而只作为一种基本类型用作继承的类,唯一目的就是用它去作为基类建立派生类,或者说是作为一个类族的公共基类,为一个类族提供一个公共接口。
总结:
1.在虚函数的后面写上 =0 ,则这个函数为纯虚函数。
2.包含纯虚函数的类都是抽象类(也叫接口类),抽象类不能实例化出对象。
3.派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
4.纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

接口继承和实现继承

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

4.多态的原理

虚函数表

问题:
在这里插入图片描述
通过观察测试我们发现aa对象是8字节,除了_a成员,还多一个_vfptr,对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,这就是为什么不需要传具体的对象,而只是利用基类指针(且指向具体的类对象的)调用同名函数,却可以实现多种行为的原因。虚函数表也简称虚表。

那么派生类中这个表放了些什么呢?我们再举个例子往下分析
在这里插入图片描述

  1. 派生类对象bb中也有一个虚函数表指针,bb对象由两部分构成,一部分是父类继承下来的成员,虚表指针也是存在这部分的,另一部分是自己的成员。
  2. 基类aa对象和派生类bb对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的B::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但不是虚函数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,注意这个数组最后面放了一个nullptr结尾
  5. 总结一下派生类的虚表生成:
    a.先将基类中的虚表内容拷贝一份到派生类虚表中
    b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
    c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后(这里在vs下的监视表里没有见到,但是我们知道了表指针地址,就可以自己打印着看)。
  6. 注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。

这也是为什么多态的实现条件之一 是必须为基类的指针或引用调用虚函数:
在运行阶段,基类指针变量先指向了一个类对象(有this指针),再通过此指针变量找到虚函数表再调用该对象的函数。只有是指针和引用(都可以确定到地址),而普通调用,相当于重新调用构造函数是一个新的地址(所以不行),而根据确定的地址才可以找到对象里虚函数表的地址,再由于发生了同名函数的重写覆盖,所以可以实现多态。

打印虚函数表
在这里插入图片描述

动态绑定与静态绑定(关联)

确定调用的具体对象的过程称为关联,一般来说,关联是指把一个函数名和一个存储地址联系起来,或者说绑定起来。

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

5.多继承关系中的虚函数表

class A
{
public:
	virtual void Func1(){cout << "A::Func1()" << endl;}
	virtual void Func2(){cout << "A::Func2()" << endl;}
private:
	int _a = 1;
};

class B
{
public:
	virtual void Func1(){ cout << "B::Func1()" << endl;}
	virtual void Func2(){ cout << "B::Func2()" << endl;}
private:
	int _b = 2;
};

class C : public A, public B {
public:
	virtual void func1() { cout << "C::func1" << endl; }
	virtual void func3() { cout << "C::func3" << endl; }
private:
	int _c = 3;
};

typedef void(*V_FUNC)();//函数指针,虚表里存的都是虚函数的指针
//打印虚函数表,传入指向虚函数的指针
void PrintVFTable(V_FUNC* p)
{
	cout << "虚函数表地址:" << p << endl;;
	for (int i = 0; p[i] != 0; ++i) //虚函数表最后有nullptr结尾
	{
		printf("vtable[%d]:%p->", i, p[i]);
		V_FUNC f = p[i]; //地址在转换为函数指针
		f();
	}
}
int main()
{
	C cc;
	cout << sizeof(cc) << endl;//20 两张表指针和三个整型
	PrintVFTable((V_FUNC*)(*(int*)&cc));//  因为是多继承,取派生类cc对象里前四个字节,即基类A的虚函数表

	PrintVFTable((V_FUNC*)(*(int*) ((char*)&cc+sizeof(A)))); //取到派生类cc对象里保存的基类B的虚函数表

	return 0;
}

在这里插入图片描述

虚函数存在哪的?虚表存在哪的?

1、虛函数跟普通函数一样样,存在代码段。(注意:虚函数不是存在与虛表, 虚表中存的是虚函数指针) 。
2、虚表是也是存在代码段(常量区)。
在这里插入图片描述

其他需要注意的问题

  1. inline函数可以是虚函数吗?
    答:不能,因为inline函数在编译时直接展开的,没有地址,所以无法把地址放到虚函数表中里供调用。
  2. 静态成员可以是虚函数吗?
    答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
  3. 构造函数可以是虚函数吗?
    答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的(而对象也才初始化)。
  4. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
    答:可以,并且最好把基类的析构函数定义成虚函数,派生类析构函数就会自动变为虚函数,再根据需要重写即可。
  5. 对象访问普通函数快还是虚函数更快?
    答:首先如果是普通对象来访问,是一样快的。
    如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
  6. 虚函数表是在什么阶段生成的,存在哪的?
    答:虚函数表是在编译阶段就生成的,只是在运行时才把虚函数与类对象关联(绑定)在一起(即实现动态多态)。一般情况下存在代码段(常量区)的。
  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值