C++多态

1.多态的概念

通俗来说,就是有多种形态,在完成某个行为时,不同的对象去完成时会产生出不同的状态。

举个例子:在买票时,普通人买票就是全价,学生买票会有半价,军人买票则是优先买票。

2.多态的定义及实现

多态的构成条件

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

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

        1.必须通过基类的指针或者引用调用虚函数。

        2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。

虚函数

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

class Person
{
public:
    virtual void buyticket()//虚函数
    {
        cout << "全价买票" << endl;
    }
};

 注意:

1.只有类的非静态成员函数前可以加virtual,普通函数前不能加virtual。

2.虚函数这里的virtual和虚继承中的virtual是同一个关键字,但是它们并没有关系。虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。

虚函数的重写

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

例如下面代码,子类student与soldier都对基类person的虚函数进行了重写:

//基类
class Person
{
public:
    virtual void buyticket()//虚函数
    {
        cout << "全价买票" << endl;
    }
};

//子类
class student : public Person
{
public:
    virtual void buyticket()//重写虚函数
    {
        cout << "半价买票" << endl;
    }
};

//子类
class soldier : public Person
{
    virtual void buyticket()//重写虚函数
    {
        cout << "优先买票" << endl;
    }
};

在对虚函数重写后,我们就可以通过父类的指针或者引用对虚函数buyticket进行调用,那么此时不同对象就会产生不同的结果,实现函数调用的多种形态


void func(Person& people)
{
    //使用父类的引用调用虚函数
    people.buyticket();
}

void func(Person* people)
{
    //使用父类的指针调用虚函数
    people.buyticket();
}

int main() {
    Person ps;//父类对象
    student st;//子类对象
    soldier sd;//子类对象
    
    //引用调用
    func(ps);//全价买票
    func(st);//半价买票
    func(sd);//优先买票

    //指针调用
    func(ps);//全价买票
    func(st);//半价买票
    func(sd);//优先买票
    
}

注意:

在重写基类虚函数时,派生类的虚函数不加virtual关键字也可以构成重写,因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数属性。但是这种写法不是很规范,因此建议在派生类的虚函数前也加上关键字。

 虚函数重写的两个例外

1.协变(基类与派生类虚函数返回值类型不同)

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

例如下列代码:
 

//基类
class A
{};
//子类
class B : public A
{};
//基类
class Person
{
public:
	//返回基类A的指针
	virtual A* fun()
	{
		cout << "A* Person::f()" << endl;
		return new A;
	}
};
//子类
class student : public Person
{
public:
	//返回子类B的指针
	virtual B* fun()
	{
		cout << "B* student::f()" << endl;
		return new B;
	}
};

 此时,我们通过父类Person的指针调用虚函数fun,父类指针若指向的是父类对象,则调用父类的虚函数,父类指针若指向的是子类对象,则调用子类的虚函数。

int main()
{
	Person ps;
	student st;
	//父类指针指向父类对象
	Person* ptr1 = &ps;
	//父类指针指向子类对象
	Person* ptr2 = &st;
	//父类指针ptr1指向的p是父类对象,调用父类的虚函数
	ptr1->fun(); //A* Person::f()
	//父类指针ptr2指向的st是子类对象,调用子类的虚函数
	ptr2->fun(); //B* Student::f()
	return 0;
}

析构函数的重写(基类与派生类析构函数的名字不同)

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

例如下列代码,在运行时会发现还是构成了重写

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

 考虑一下,父类和子类的析构函数构成重写的意义何在呢?试想一下:分别nwe一个父类对象和子类对象,都用父类指针指向它们,然后分别用delete调用析构函数并释放对象空间。

int main()
{
	//分别new一个父类对象和子类对象,并均用父类指针指向它们
	Person* p1 = new Person;
	Person* p2 = new student;

	//使用delete调用析构函数并释放对象空间
	delete p1;
	delete p2;
	return 0;
}

在这种场景下,若是父类和子类的析构函数没有构成重写就可能会导致内存泄露,因为此时delete p1和delete p2都是调用的父类的析构函数,而我们所期望的p1调用父类的析构函数,p2调用子类的析构函数,是我们期望的一种多态行为。

只有父类和子类构成了重写,才会按照预期进行析构函数调用,才能实现多态。所以,为了避免这种情况,建议将父类的析构函数定义为虚函数。

C++11 override和final

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

1.final:修饰虚函数,表示该虚函数不能再被重写。

//父类
class Person
{
public:
	//被final修饰,该虚函数不能再被重写
	virtual void buyticket() final
	{
		cout << "全价买票" << endl;
	}
};

override:检查派生类虚函数是否重写了基类的某个虚函数,如果没有则编译报错。

 

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

//子类
class student : public Person
{
public:
	//子类完成了父类虚函数的重写,编译通过
	virtual void buyticket() override
	{
		cout << "买票-半价" << endl;
	}
};

//子类
class soldier : public Person
{
public:
	//子类没有完成了父类虚函数的重写,编译报错
	virtual void buyticket(int i) override
	{
		cout << "优先-买票" << endl;
	}
};

最后,回顾一下重载,覆盖(重写),隐藏(重定义)的对比 

3.抽象类

概念

在虚函数的后面写上 =0,则这个函数为纯虚函数。包含纯虚函数的类也叫做抽象类(也叫做接口类),抽象类不能实例化出对象。

例如下列代码,不能实例化出对象。

#include <iostream>
using namespace std;
//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};
int main()
{
	Car c; //抽象类不能实例化出对象,error
	return 0;
}

 只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

#include <iostream>
using namespace std;
//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};
//派生类
class Benz : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
//派生类
class BMV : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "BMV-操控" << endl;
	}
};
int main()
{
	//派生类重写了纯虚函数,可以实例化出对象
	Benz b1;
	BMV b2;
	//不同对象用基类指针调用Drive函数,完成不同的行为
	Car* p1 = &b1;
	Car* p2 = &b2;
	p1->Drive();  //Benz-舒适
	p2->Drive();  //BMV-操控
	return 0;
}

 很多同学好奇,抽象类不能实例化出对象,那么抽象类存在的意义是什么?

1.抽象类可以更好的去表示现实世界中,没有实力对象的抽象类型,比如:植物,人,动物等。

2.抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写虚函数,因为子类加入不是重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

接口继承和实现继承

实现继承:普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。

接口继承:虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。

建议:如果不实现多态,就不要把函数定义成虚函数。

4.多态的原理

虚函数表

有一道题目:在下列代码中,Base类实例化出对象的大小是多少?

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

 通过运行测试,可以看到实例化的对象b的大小是8个字节

int main()
{
	Base b;
	cout << sizeof(b) << endl; //8
	return 0;
}

 在调试中可以看到,b对象除了_b成员外,还有一个_vfptr放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。

这个指针叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,没一个含有虚函数的类中都至少有一个虚表指针。

那么虚函数表中放的是什么?

例如下列代码,Base类有三个成员函数,Func1和Func2是虚函数,Func3是普通成员函数,子类Derive中只对Func1进行重写。

#include <iostream>
using namespace std;
//父类
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:
	//重写虚函数Func1
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

 通过调试可以发现,基类对象b和子类对象d都有自己的成员变量和虚表指针,这个虚表指针分别指向自己的虚表。

 实际上,虚表中存储的地址就是虚函数的地址,父类中的Func1和Func2都是虚函数,那么对象b的虚表中存储的就是虚函数Func1和Func2的地址

而子类虽然继承了父类的虚函数Func1和Func2,但是子类只对Func1进行了重写,那么,对象d中的虚表存储的就是重写厚的Func1的地址和Func2的地址,这就是为什么虚函数的重写也叫做覆盖,意思就是将继承下来的虚函数进行覆盖,更新了地址。

那么Func3呢,因为它不是虚函数,所以在b和d的虚表中都不存在。

注意:虚函数表本质上是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个nullptr。

总结:

1.先将基类中的虚表内容拷贝一份到派生类的虚表。

2.如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。

3.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

虚表常见问题

问:虚函数存在哪里?虚表存在哪里?

当刚接触上面的知识时,许多同学会说:虚函数u存在虚表,虚表存在对象中。

答当然,这种回答是错误的。要知道虚表存的是虚函数指针,并不是虚函数。

答:虚函数和普通函数一样,都存在代码段,只是它们的指针又存到了虚表中。另外,对象中存的不是虚表,是虚表指针,那么虚表存在哪呢?

int j = 0;
int main()
{
	Base b;
	Base* p = &b;
	printf("vfptr:%p\n", *((int*)p)); //000FDCAC
	int i = 0;
	printf("栈上地址:%p\n", &i);       //005CFE24
	printf("数据段地址:%p\n", &j);     //0010038C

	int* k = new int;
	printf("堆上地址:%p\n", k);       //00A6CA00
	char* cp = "hello world";
	printf("代码段地址:%p\n", cp);    //000FDCB4
	return 0;
}

可以发现,虚表指针也就是虚表的地址,可以发现,虚表地址与代码段地址非常接近,所以由此可以得出虚表实际上是存在代码段的。

问:虚表是在什么阶段初始化的?

答:虚表实际上是在构造函数初始化列表阶段进行初始化的

多态的原理

学到这,那么多态的原理到底是什么?

再来看看下面的代码,为什么父类Person指针指向的是父类对象Mike时,调用的就是父类的BuyTicket,当父类Person指针指向的是子类对象Johnson时,调用的就是子类的BuyTicket?

#include <iostream>
using namespace std;
//父类
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
	int _p = 1;
};
//子类
class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
	int _s = 2;
};
int main()
{
	Person Mike;
	Student Johnson;
	Johnson._p = 3; //以便观察是否完成切片
	Person* p1 = &Mike;
	Person* p2 = &Johnson;
	p1->BuyTicket(); //买票-全价
	p2->BuyTicket(); //买票-半价
	return 0;
}

还是可以通过调试观察,对象Mike中包含一个成员变量_p,和一个虚表指针,对象Johnson中包含两个成员变量_p和_s和一个虚表指针,这两个对象当中的虚表指针分别指向自己的虚表。

 根据上图可以看到:

1.父类指针p1指向Mike对象,p1->BuyTicket在Mike中找到的虚函数就是Person::BuyTicket。

2.父类指针p2指向Johnson对象,p1->BuyTicket在Johnson中找到的虚函数是Student::BuyTicket。

所以,这就实现了不同对象做同一行为时,有着不同的结果的原因。

其实看到这里,许多同学会有疑问,多态构成的两个条件:1.虚函数重写。2.使用父类指针或者引用去调用虚函数。条件1是为了让子类对虚函数重写并覆盖地址,那么为什么条件2一定要用指针或者引用去调用虚函数呢?直接使用父类对象去调用却不行呢?

Person* p1 = &Mike;
Person* p2 = &Johnson;

使用父类指针或者引用时,实际上是一种切片行为,切片时只会让父类指针或者引用得到父类对象或子类对象中切出来的那一部分。

因此,我们后序用p1和p2调用虚函数时,p1和p2通过虚表指针找到的虚表是不一样的,最终调用的函数也是不一样的。

Person p1 = Mike;
Person p2 = Johnson;

使用父类对象时,切片得到部分成员变量后,会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造,而拷贝构造出来的父类对象p1和p2当中的虚表指针指向的都是父类对象的虚表。因为同类型的对象共享一张虚表,他们的虚表指针指向的虚表是一样的。

因此,我们后序用p1和p2调用虚函数时,p1和p2通过虚表指针找到的虚表是一样的,最终调用的函数也是一样的,也就无法构成多态。

总结

1.构成多态,指向谁就调用谁的虚函数,与对象有关。

2.不构成多态,对象是谁就调用谁的虚函数,跟类型有关。

动态绑定和静态绑定

静态绑定:静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,比如:函数重载。

动态绑定:动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

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

单继承中的虚函数表

先看下列代码:

//基类
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;
};

 基类和派生类的虚表模型如下:

通过上图,可以得出在单继承关系中,派生类的虚表生成过程如下:

1.继承基类的虚表内容到派生类的虚表。

2.对派生类重写了的虚函数地址进行覆盖,比如func1。

3.虚表中新增派生类中新的虚函数地址,比如func3和func4。

注意:在调试过程中,某些编译器的监视窗口当中看不到徐表中的func3和func4,可能是故意隐藏,也可能是一个bug,我们可以通过内存监视窗口查看。

 多继承中的虚函数表

先看下面代码:

//基类1
class Base1
{
public:
	virtual void func1() { cout << "Base1::func1()" << endl; }
	virtual void func2() { cout << "Base1::func2()" << endl; }
private:
	int _b1;
};
//基类2
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;
};

其中,两个基类的虚表模型如下:

派生类的模型如下:

在多继承关系中,派生类的虚表生成过程如下:

1.分别继承各个基类的虚表内容到派生类的各个虚表当中。

2.对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖),比如func1。

3.在派生类第一个继承基类部分的虚表当中新增派生类当中新的虚函数地址,比如func3。

注意:这里仍会出现显示不全的问题,想要看全还是得调用内存窗口进行查看。

菱形继承、菱形虚拟继承

先看下面代码:

class A
{
public:
	virtual void funcA()
	{
		cout << "A::funcA()" << endl;
	}
private:
	int _a;
};
class B : virtual public A
{
public:
	virtual void funcA()
	{
		cout << "B::funcA()" << endl;
	}
	virtual void funcB()
	{
		cout << "B::funcB()" << endl;
	}
private:
	int _b;
};
class C : virtual public A
{
public:
	virtual void funcA()
	{
		cout << "C::funcA()" << endl;
	}
	virtual void funcC()
	{
		cout << "C::funcC()" << endl;
	}
private:
	int _c;
};
class D : public B, public C
{
public:
	virtual void funcA()
	{
		cout << "D::funcA()" << endl;
	}
	virtual void funcD()
	{
		cout << "D::funcD()" << endl;
	}
private:
	int _d;
};

 继承关系如下:

可以看到,ABCD类中分别有ABCD的虚函数,其中BCD类都对虚函数A进行了重写,B和C都继承了A,D类继承了B和C。

先看A,包含变量_a和虚表指针。

再看B

由于B是虚拟继承A,所以将A类的成员放到最后,其余的B类对象包括了一个虚表指针,一个虚基表指针和成员变量_b,虚指针指向的是B类虚函数funcB的地址。虚基表中存储的是两个便宜两,一个是虚基表指针距离B虚表指针的偏移量,第二个是虚基表指针距离虚基类A的偏移量。

 再看C

C的情况与B的情况相同

 最后看最为复杂的D

D类对象当中成员的分布情况较为复杂,D类的继承方式是菱形虚拟继承,在D类对象当中,将A类继承下来的成员放到了最后,除此之外,D类对象的成员还包括从B类继承下来的成员、从C类继承下来的成员和成员变量_d。
需要注意的是,D类对象当中的虚函数funcD的地址是存储到了B类的虚表当中。

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面使用这样的模型访问基类成员有一定的性能损耗。 

  • 23
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值