C++多态详解

一、多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运⾏时多态(动态多态),下面我们来分别讲讲两种多态。
1. 编译时多态:
编译时 多态(静态多态)主要就是我们前⾯讲的函数重载和函数模板,他们传不同类型的参数就可以调⽤不同的 函数,通过参数不同达到多种形态。
2. 运行时多态:
运⾏时多态,具体点就是去完成某个⾏为(函数),可以传不同的对象就会完成不同的⾏为,就达到多种形态。⽐如买票这个⾏为,当普通⼈买票时,是全价买票;学⽣买票时,是优惠买票(5折或75折);军⼈买票时是优先买票。
之所以叫编译时多态和运行时多态,是因为它们分别在程序编译和运行时完成, 我们把编译时⼀般归为静态,运⾏时归为动态。

二、多态的定义和实现

2.1 多态的构成条件

多态是⼀个继承关系的下的类对象,去调⽤同⼀函数,产⽣了不同的⾏为。⽐如Student继承了
Person。Person对象买票全价,Student对象优惠买票。

2.1.1 实现多态的两个重要条件

1.必须基类指针或者引⽤调⽤虚函数

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

这两个条件缺一不可,缺少了任何一个都不能实现多态,如果不是基类的指针或引用调用虚函数,那便指向不了派生类,而派生类没有重写基类的虚函数,派生类就没有不同的函数,多态的不同形态效果就达不到,也就无法实现多态。

如上图所示,Student类的虚函数重写了基类Person的虚函数,且调用虚函数的都是基类的引用或指针,那么这就构成了多态,指向哪个对象,就调用哪个对象的虚函数。

结果:

2.1.2 虚函数

我们在上面提到了虚函数,接下来我们来讲讲什么是虚函数。

类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修
饰。在类的成员函数前加virtual,那么这个成员函数就是虚函数。
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

如上述代码所示,成员函数BuyTicket就是一个虚函数。

2.1.3 虚函数的重写

我们在上面也讲到了虚函数的重写,那么什么是虚函数的重写呢?

虚函数的重写就是派⽣类中有⼀个跟基类完全相同的虚函数,这里的完全相同有三同(返回类型相同、函数名相同以及参数列表相同),这样才能说派生类的虚函数重写了基类的虚函数。

这里要注意的是,在重写基类虚函数时,派生类的虚函数可以不加virtual关键字,这样也可以构成重写,语法上是允许的,但是不建议这样写,都加上virtual能够使代码更加的规范。

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};
class Student : public Person
{
public:
    //void BuyTicket()  //构成重写
	virtual void BuyTicket()  //构成重写
	{
		cout << "买票-半价" << endl;
	}
};

2.1.4 析构函数的重写

基类的析构函数为虚函数,此时派⽣类析构函数只要定义,⽆论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派⽣类的析构函数就构成重写。
那么析构函数为什么要设计成为重写呢?我们来看下面的代码:
class A
{
public:
	 ~A()
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};
int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

我们看上面的代码,此时我们并未将基类的析构函数设置成为虚函数,此时不构成重写,也就不构成多态,我们来看看代码的执行结果。

可以看到,我们原本想将新创建的对象A和B删掉,但我们执行发现结果却是调用了两次基类的析构函数,这就导致了B对象所申请的内存没被释放掉,造成内存泄漏。所以析构函数要设计成重写,防止这种情况下的内存泄漏。 

当我们将基类的析构函数设置成虚函数时,再次执行程序,就可以发现执行了B对象的析构函数,这样就不会造成内存泄漏了,这里调用两次基类的析构函数是因为B继承了A,所以B既要调用自己的析构函数也要调用基类的析构函数。 

2.1.5 override和final关键字

override关键字可以帮助我们检测是否进行了重写,加了override关键字就可以在编译时检测出来是否进行了重写,不需要执行完程序看到结果才知道没有进行重写。而final关键字可以不让派生类重写基类的这个虚函数。

class A
{
public:
	~A()
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B() override
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};
int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

我们未将基类的析构函数设为虚函数,那么在编译时就会报错,说没有重写任何函数。

class A
{
public:
	virtual ~A() final
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B() 
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};
int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

 我们在基类的虚函数后加上final关键字,此时编译就会报错,说不能被B的虚函数重写。

2.1.6 重载、重写和隐藏的对比

1.重载:

a. 重载要求两个函数要在同一作用域。

b. 重载要求两个函数的函数名相同,参数不同,参数的类型或者个数不同,返回值可同可不同。

2. 重写:

a. 重写要求两个函数分别在继承体系中的基类和派生类的作用域内。

b. 三同(返回类型相同、函数名相同以及参数列表相同)

c. 两个函数都必须是虚函数。

3. 隐藏:

a. 隐藏要求两个函数分别在继承体系中的基类和派生类的作用域内。

b. 隐藏要求函数名相同。

c. 两个函数只要不构成重写,就是隐藏。

d. 基类和派生类成员变量相同也叫隐藏。

三、纯虚函数和抽象类

在虚函数的后⾯写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现,只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派⽣类重写虚函数,因为不重写实例化不出对象

class Person
{
public:
	virtual void BuyTicket() = 0
	{
		cout << "买票-全价" << endl;
	}
};
class Student : public Person
{
public:
	virtual void BuyTicket(int a)
	{
		cout << "买票-半价" << endl;
	}
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

在上面的代码中我们将基类Person中的虚函数设为了纯虚函数,那么基类Person就是抽象类,不能实例化出对象,修改派生类Student虚函数的参数列表使派生类Student继承了Person但未对Person的虚函数进行重写,所以派生类Student也是一个抽象类,所以这两个类都不能够实例化出对象。

结果:

 四、多态的原理

4.1 虚函数表指针

我们先来看一道题目:

下⾯编译为32位程序的运⾏结果是什么()
A. 编译报错 B. 运⾏报错 C. 8 D. 12
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}

这一题根据学的内存对齐的规则来算,应该很快就能得出结果为8个字节,但结果真得是8字节吗?

我们可以看到,类Base的大小为12个字节,可是按照内存对齐的规则来算应该是8个字节才对,这其中是因为除了_b和_ch成员,还多⼀个__vfptr放在对象的前⾯(注意有些平台可能 会放到对象的最后⾯,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要 被放到这个类对象的虚函数表中,虚函数表也简称虚表。

我们可以通过调试来查看__vfptr。

我们来看下面的代码:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'x';
};

class Base1 : public Base
{
public:
	virtual void Func1()
	{
		cout << "Base1::Func1()" << endl;
	}
};

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

我们在原有代码的基础上增加了一个派生类Base1继承基类Base,在Base类中增加一个虚函数Func2,派生类Base1重写基类Base的Func1。我们通过调试来观察他们两个类的虚表。

通过上图我们可以发现,两个类所创建出的对象的虚表地址不同,说明两个类所创建出的对象拥有各自的虚表,而派生类Base1重写了基类的Func1,所以两个虚表中的Func1的地址不同,这也是为什么我们能够实现多态,实现传什么对象就调用哪个对象的虚函数的原因,重写也叫覆盖的原因也是这么来的,重写后的虚函数用一个新的地址将旧的地址覆盖,而未重写的虚函数Func2的地址则是一样的,通过继承继承下来的。 

这时我们再对代码进行修改,每个类多创建一个对象,来观察他们的虚表地址。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'x';
};

class Base1 : public Base
{
public:
	virtual void Func1()
	{
		cout << "Base1::Func1()" << endl;
	}
};

int main()
{
	Base b;
	Base b_1;
	Base1 b1;
	Base1 b1_1;
	//cout << sizeof(b) << endl;
	return 0;
}

我们每个类都创建了两个对象,下面我们通过调试来观察他们的虚表地址。 

 

可以发现,相同类所创建出的对象是共用一个虚表的。 

4.2 多态的原理

4.2.1 多态是如何实现的

从底层的⻆度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调⽤Person::BuyTicket,ptr指向Student对象调⽤Student::BuyTicket的呢?

由上图可知,满足多态后,是通过指向对象的虚表来确定虚函数的地址,这两个虚表中的虚函数的地址都是不同的,这样就实现了指针或引⽤指向基类就调⽤基类的虚函数,指向派⽣类就调⽤派⽣类对应的虚函数。

 

4.2.2 动态绑定和静态绑定

静态绑定:对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。

动态绑定: 满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数 的地址,也就做动态绑定。
下面我们通过汇编来观察一下。
动态绑定汇编:

静态绑定汇编:

 

 从上面两张图可以看出,动态绑定是编译运行到ptr指向对象的虚函数表中确定调用的函数地址,而静态绑定不满足多态条件,编译器直接确定调用函数地址,只会调用Person类的BuyTicket函数。

4.2.3 虚函数表

1. 基类对象的虚函数表中存放基类所有虚函数的地址。

2. 派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表 指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基 类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独立的。

3. 派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。

4. 派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地址三个部分。

5. 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标 记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000标记,g++系列编译不会放)

4.3 虚表和虚函数的存储位置

1. 虚函数的存储位置和普通函数一样,编译好后是一段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中。

2. 虚表的存储位置严格来说C++标准并没有明确规定,我们通过下面的代码来验证一下。

class Base 
{
public:
	virtual void func1() 
	{ 
		cout << "Base::func1" << endl; 
	}
	virtual void func2() 
	{ 
		cout << "Base::func2" << endl; 
	}
	void func5() 
	{ 
		cout << "Base::func5" << endl; 
	}
protected:
	int a = 1;
};
class Derive : public Base
{
public:
	// 重写基类的func1
	virtual void func1() 
	{ 
		cout << "Derive::func1" << endl; 
	}
	virtual void func3() 
	{ 
		cout << "Derive::func1" << endl; 
	}
	void func4() 
	{ 
		cout << "Derive::func4" << endl; 
	}
protected:
	int b = 2;
};

int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);
	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;
	printf("Person虚表地址:%p\n", *(int*)p3);
	printf("Student虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);
	return 0;
}

通过打印虚表的地址来判断虚表的存储位置。

 

从上图我们可以看出,虚表的存储地址与常量区的地址十分接近,所以我们能够判断在vs下虚函数表是存储在常量区(代码段)的

五、总结

以上就是关于多态的讲解了,多态要能理解构成重写和多态的条件,同时对于虚表的理解和多态是如何实现的也要掌握,希望以上所讲能够对你有所帮助,感谢阅读。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值