【C++从入门到踹门】第十三篇:多态


在这里插入图片描述


1.多态概念

看上去用的是一个函数,实际上一个函数可以有多种形态。

目前我们接触过的多态有 函数重载运算符重载

如果以现实生活举例子,去游乐园购票就类似于多态行为:

  • 成人购票全价
  • 学生购票半价
  • 儿童购票免费

购票这同一行为,当不同的个体去进行调用的时候它就体现出不同的行为特征。

2.多态实现

2.1 构成多态的条件

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

构成多态可以让父类的指针(及引用)具有了多种行为。

2.2 虚函数的重写

虚函数重写(又称覆盖):子类有一个与父类完全相同的虚函数(两者函数名,参数列表,返回值完全一致),且继承了父类的属性(访问限定符)。

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

class Student :public Person
{
public:
	virtual void Buy()//子类对父类虚函数的重写
	{
		cout << "半价票" << endl;
	}
};

class Child :public Person
{
public:
	virtual void Buy()//子类对父类虚函数的重写
	{
		cout << "免费" << endl;
	}
};

被virtual修饰的才能称为虚函数,子类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后父类的虚函数被继承下来了在子类依旧保持虚函数属性),但是该种写法不规范,不建议这样使用。

而且必须要使用父类的指针或者引用去调用子类对象的虚函数,如果子类对象直接给父类对象赋值然后再调用,结果将直接是调用父类的虚函数。

原因将在后面介绍。

虚函数重写特例

  1. 协变

父类虚函数的返回值与子类的返回值可以不同,但是必须满足:

  • 父类虚函数返回父类(当前类或者其他类)对象的指针或引用,子类虚函数返回子类对象的指针或引用。

见下例

class A
{};
class B:public A
{};
class Person
{
public:
    virtual A* func()
    {
        cout<<"Person::func()"<<endl;
        return new A;
    }
};
class Student:public Person
{
public:
    virtual B* func()
    {
        cout<<"Student::func()"<<endl;
        return new B;
    }
};
int main()
{
	Person p;
	Student s;
	Person* ptr = &p;
	ptr->func();
	ptr = &s;
	ptr->func();
	return 0;
}

2.析构函数的重写(父类与子类析构函数的名字不同)

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

在普通场景下,父子的析构函数重写与不重写没有差别:

在父类指针new了两者的对象,delete的场景下,父子的析构函数重写就起了大作用:

子类析构不重写的话,只会调用父类的析构函数,析构的只是子类中的父类成员,于是可能会造成内存泄漏(子类成员需要释放空间的情况)。

2.3 虚函数性质

  1. 只有类的非静态成员函数才可以是虚函数,普通函数不可以。

  2. 虚函数与虚继承没有关系。

    虚函数是为了实现多态。

    虚继承是为了解决菱形继承的数据冗余和二义性。

  3. 虚表指针在什么阶段初始化的?虚表是在什么阶段生成的?

    构造函数的初始化列表阶段生成虚表指针。虚表在编译时就生成了

  4. 虚函数在虚表里吗?

    并不是,虚表里存放的是虚函数而定指针,而函数的实现代码存放在代码段里。

  5. 子类的虚表如何而来?

    拷贝父类的虚表,如果有重写,就覆盖虚表中的相应内容。如果有新增虚函数则添加在之后。

  6. 虚表存在哪里?

    虚表存放在代码段中。

  7. 获得虚表中虚函数的地址

    虚表以nullptr作为结束,而且虚表存放于对象的内存中的起始位置。

    我们试用程序,罗列出虚表中的元素(虚函数指针):

    由于指针大小是4字节,被我们强转成(int*)

    int main()
    {
    	A a;
    	A* pa = &a;
    	printf("vfptr:%p\n", *((int*)pa));
    
    	int* ptr = (int*)(*(int*)pa);
    	while (1)
    	{
    		printf("%p\n", *ptr);
    		ptr += 1;
    		if(*ptr==0)
    			break;
    	}
    
    }
    

    注意:执行这段程序前需要先清理解决方案。

2.4 override 、final (C++11)

  1. final:修饰虚函数,表示该虚函数不能再被重写。修饰类,表示该类不能继承。
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() {cout << "Benz-舒适" << endl;}//此处报错,Drive不能被重写
};
class Car final
{
public:
	virtual void Drive()  {}
};
class Benz :public Car//报错,Car不能被继承
{
public:
	virtual void Drive() {cout << "Benz-舒适" << endl;}
};

在C++11以前防止继承的做法

  • 单例模式
class A
{
private:
	A(int a=0):_a(a)//构造函数为私有便不能继承,因为子类对象构造时需要调用父类的构造,但是不可见
	{
	}
public:
	static A CreatA(int a=0)//A类自己调用构造需要使用静态,因为构造前没有对象,需要用类域调用
	{
		return A(a);
	}
protected:
	int _a;
};

int main()
{
	A aa=A::CreatA(10);
	return 0;
}
  1. override: 检查子类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Car{
public:

};
class Benz :public Car {
public:
	virtual void Drive() override {cout << "Benz-舒适" << endl;}//此处报错,因为父类没有Drive虚函数,这里不构成重写
};

2.5 重载、覆盖(重写)、隐藏(重定义)的区别

3.抽象类

抽象是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。

3.1 抽象类的概念

  • 在虚函数的后面写上 =0 ,则这个函数为纯虚函数
  • 包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象
  • 子类继承后也不能实例化出对象,只有重写纯虚函数,子类才能实例化出对象。
  • 纯虚函数规范了子类必须重写,另外纯虚函数更体现出了接口继承
//动物类为抽象
class Animal
{
public:
	virtual void Eat() = 0;
};

//猫为实体
class Cat :public Animal
{
public:
	virtual void Eat()
	{
		cout << "cat eat fish" << endl;
	}
};

int main()
{
	Animal* pa;
	Cat c;//Cat类的实例化
	pa = &c;
	pa->Eat();
	return 0;
}

3.2 接口继承和实现继承

  • 普通成员函数的继承是一种实现继承,子类继承了父类,同时也继承了函数的实现。
  • 虚函数的继承是一种接口继承,子类继承的是父类的虚函数接口,目的是为了重写达成多态。

如果不是为了实现多态就不要把成员函数定义为虚函数。

4.多态的原理

我们来计算一个包含虚函数的类A的大小

发现和普通类的大小不一样,调试一下:

其中多了一个 _vfptr的“成员”,它实际上是一个虚函数表指针,指向了虚函数表 。

4.1 虚函数表

对象中的 _vfptr指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

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

class Student :public Person
{
public:
	virtual void Buy()
	{
		cout << "半价票" << endl;
	}
};

class Child :public Person
{
public:
	virtual void Buy()
	{
		cout << "免费" << endl;
	}
};
int main()
{
	Person p;
	Student s;
	Child c;
	return 0;
}

当子类对虚函数重写,就会将重写的虚函数覆盖掉虚函数表中的原虚函数:

如果不重写虚函数,则虚函数表中指向的函数将不变

注意:即使子类缺省virtual的同名同参的成员函数还是会默认重写(因为父类写的是虚函数,子类将默认成为虚函数),我们说的不重写是完全不写这个函数。此时不构成多态。

虚函数表中元素依旧是指向父类的虚函数的地址,没有达到多态的要求。所以父类一旦写了虚函数,为构成多态就要在子类中去重写它。

总结

  1. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

  2. 总结一下子类的虚表生成:

    a. 先将父类中的虚表内容拷贝一份到子类虚表中

    b. 如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数

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

⚠ 注意:

虚继承和虚函数没有关系,使用场景不一样,解决的也是完全不同的问题。

虚函数表区别于虚继承的虚基表:

虚基表中存放的是 virtual继承的子类与父类成员的偏移量

4.2 多态原理

构成多态:父类指针或者引用调用虚函数时,不是编译时确定的,而是运行时到指向对象的虚表中去找虚函数进行调用。指向哪一个对象,当然就调用哪一个对象的虚函数。所以多态与对象相关。

不构成多态:编译时就根据类型确定该调用哪一个函数,跟传来的是什么对象没有关系。

首先需要确定的是,同一个类的对象的虚函数表是一样的

上图中,当func的函数参数为父类本身(Person)时,无论我们传入的是父类对象,还是子类对象(切片只会拷贝成员变量,不会拷贝虚函数表),虚函数表将根据p本身的类型而定,p作为父类类型的对象,他的虚函数表将始终是父类的虚函数表。

可以理解为同类型对象共享一张虚表,父类有父类的虚表指针,子类有子类的虚表指针。

当子类给父类赋值,该父类的虚表指针仍是父类自己的。

所以在我们传入子类时,调用的还是父类的虚函数,没有起到多态的效果。

而func函数参数为指针或者引用,p访问的就是对象本身,访问的虚函数表自然也是父类或者子类本身(根据传入对象而定)的虚函数表。

4.3 动态绑定与静态绑定

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

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

5.1 单继承虚函数表

单继承的子类虚函数表已在上面讲述,这里贴张图解释下:

Derive 类继承了 Base1类,复制了Base1的虚表,重写的虚函数就进行覆盖,Derive新增的虚函数就添加在后面。

5.2 多继承虚函数表

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;
};

我们可以看到:

1) 每个父类都有自己的虚表。

2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)

这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

Derive类继承了Base1和Base2,这时会有两张虚表分别对应Base1和Base2的虚函数地址。

如果Derive进行了重写,便会对虚表中原来的虚函数地址进行覆盖。如果重写的虚函数恰好出现在两个父类中,那么就同时对两个虚函数进行重写。

Derive新增的虚函数会放在第一个继承的父类的虚函数表中。(这里由于vs显示问题,没有指出Derive::func3()的存放情况,但是可以通过内存窗口查看)

5.3 打印虚表中的虚函数地址

  1. 得到Derive对象d的指针pd
  2. 将pd指向的内存的首位4字节内容强转成 int* 赋给ptr1,ptr1的值就是d中Base1的虚表指针。
  3. 将pd强转成 char*,加上sizeof(Base1)个字节后,强转int*,指向前四个字节内容,再解引用得到Base2的虚表的地址,强转int*后赋给ptr2。
int main()
{
	Derive d;
	Derive* pd = &d;

	int* ptr1 = (int*)(*(int*)pd);
	printf("Base1的vfptr:%p\n", ptr1);
	for(int i=0;*ptr1!=0;++i)
	{
		printf("vfptr[%d]:%p\n",i, *ptr1);
		ptr1 += 1;

	}
	cout << endl;

	int* ptr2 = (int*)(*(int*)((char*)pd + sizeof(Base1)));
	printf("Base2的vfptr:%p\n", ptr2);
	for (int i = 0; *ptr2 != 0; ++i)
	{
		printf("vfptr[%d]:%p\n", i, *ptr2);
		ptr2 += 1;
	}


	return 0;
}

结果:

5.3 菱形虚拟继承

我们在继承中讨论过,菱形继承为避免重复继承的情况,在中间的继承关系前加上virtual表示虚拟继承。

Derive 实例化的对象d只包含一份Ace的数据。

如果Ace中有虚函数,那么Base1和Base2同时对其重写会产生歧义,导致编译错误,因为Derive的对象中的Ace虚表不知道采用哪一个类的重写(除非Derive对Base1,2的对A继承的虚函数全部重写)。

我们看一下菱形虚继承情况下的虚函数表以及虚基表的布局情况:

我在Base1和Base2中各自添加了虚函数,Base1_self(),和Base2_self()。实验代码如下:

class Ace
{
public:
	virtual void func1()
	{
		cout << "A::func1" << endl;
	}
	virtual void func2()
	{
		cout << "A::func2" << endl;
	}
	int a;
};

class Base1 :virtual public Ace
{
public:
	virtual void Base1_self()
	{
		cout << "Base1_self" << endl;
	}

	int b1;
};

class Base2 :virtual public Ace
{
public:
	virtual void Base2_self()
	{
		cout << "Base2_self" << endl;
	}

	int b2;
};

class Derive :public Base1, public Base2
{
public:
	virtual void func1()
	{
		cout << "Derive::func1" << endl;
	}
	virtual void func3()
	{
		cout << "Derive::func3" << endl;
	}

	int d1;
};

以中间类Base2为例(Base1也是同样的结构):

与普通多继承不同的是,多态多继承内存中,子类Derive的对象d的内存中的每个父类不仅仅有虚基表指针,在其上还有虚函数表指针(虚函数表只包含该类自己的虚函数),同时Base1虚基表中的首4位字节存放了该虚基表与虚函数表的偏移量,后四个字节存放了该虚基表与它的父类(Ace)的偏移量。

6.继承与多态常见问题

  1. 重写会复用父类的函数声明,重写的只是函数定义:
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(int argc ,char* argv[])
{
	B*p = new B;
	p->test();
	return 0;
}

p 调用test后,p会给test的this指针赋值,于是构成多态:this->func()

this指向p(B类指针),将调用B::func(),又因为重写不理会缺省值,只要参数类型、函数名、返回值对应即可,所以延用A的缺省值val=1,所以输出 B->1

  • 虚函数表与虚函数表指针的生成时间

答: 虚函数表是在编译时生成的,虚函数表指针是运行时确定的。

  • inline函数是否可以做虚函数?

答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去,而内联的函数不会提供函数地址。

  • 静态成员可以是虚函数吗?

答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

  • 构造函数可以是虚函数吗?

答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。虚函数对应一个虚指针,虚指针其实是存储在对象的内存空间的。

如果构造函数是虚函数,就需要通过虚函数表中对应的虚函数指针(编译期间生成属于类)来调用,可对象目前还没有实例化,也即是还没有内存空间,何来的虚指针,所以构造函数不能是虚函数;

虚函数的作用在于通过父类的指针或者引用来调用它的成员函数的时候,能够根据动态类型来调用子类相应的成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,所以构造函数不能是虚函数;

  • 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

答:可以,并且最好把基类的析构函数定义成虚函数。在delete时。

  • 对象访问普通函数快还是虚函数更快?

答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

  • 虚函数表存在哪的?虚函数表指针存在哪里

答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。虚函数表指针存在栈上。


青山不改 绿水长流

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值