0
点赞
收藏
分享

微信扫一扫

【C++】多态的实现及其底层原理

cnlinkchina 2023-07-31 阅读 58

在这里插入图片描述
在这里插入图片描述


文章目录


前言

本文继C++继承之后讲解C++多态。


一、什么是多态?

单单从概念入手不好理解,应该深入理解多态的实现后再回过头来讲解。
现在简单举个例子:我们在购买高铁票时,往往会有成人票全价,学生票半价的优惠,针对不同的人群给予不同的优惠,这个就是多态(多种形态)。

二、多态的构成条件

多态的两个构成条件为:

2.1什么是虚函数?

virtual关键字修饰的类成员函数就是虚函数。

在继承中,子类要想重写父类,父类的成员函数必须是虚函数,而子类的成员函数可以不加virtual,但一般建议加上比较合适。

2.2虚函数的重写

虚函数的重写是:对父类的虚函数的实现进行覆盖,覆盖的内容是子类虚函数的实现。

满足重写的条件:三同。

2.3 什么是虚函数表?

虚函数表是继承体系中,如果一个函数是虚函数,则该函数的地址会存储在一张虚函数表中,而不是存储在对象中,该虚函数表的地址才存储在对象中。通过虚函数表可以找到对应的虚函数,从而能够进一步实现多态。
在这里插入图片描述

有虚函数的对象的大小

class A
{
public:
	virtual void func1()
	{}

	virtual void func2()
	{}

protected:
	int _a;
};

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

请计算上面的代码中,A这个类的大小。
A有两个虚函数,一个成员,实际上大小为8

虚函数表本质上是一个函数指针数组。
一般建议:如果不实现多态,就不要设置成虚函数

2.4普通对象调用和实现多态后的对象调用

普通对象调用成员函数是在编译期间就确定了地址,而实现多态后的函数是在运行期间才确定地址。因为子类继承父类,子类先拷贝父类的虚函数表的地址,如果某个函数是虚函数,则会在子类的虚函数表中重写改函数的地址。

所以编译器在遇到父类的指针或引用调用子类的函数时,编译器在编译期间无法确定到底调用谁的函数,只能运行起来去子类的虚函数表中查找,是谁的地址就调用谁。

三、多态的原理

我们通过以下例子来看待多态。

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

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

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person Mike;
	Func(Mike);

	Student Johnson;
	Func(Johnson);
	return 0;
}

在这里插入图片描述

当我们用父类的指针或引用调用子类的虚函数时,会去访问子类的虚函数表,当我们调用父类的虚函数时,会访问父类的虚函数表,这样就实现了父类指针指向父类对象就调用父类的虚函数表,指向子类对象就调用子类的虚函数表。在这里插入图片描述

所以多态就是:我们想调用父类的函数就传递父类对象给父类指针/引用,想调用子类的函数就传递子类对象给父类指针/引用。

经典题

这里有一道非常经典的坑人题目:

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

回到多态的两个条件

多态的两个条件:

(1)为什么不能是子类的对象赋值给父类的对象,而是子类对象赋值给父类的指针/引用?
因为子类对象赋值给父类对象,切片过程中不会拷贝虚表。所以父类对象只能调用父类的虚表,子类对象才能调用子类的虚表,满足不了多态。

(2)为什么子类的对象赋值给父类的对象不会拷贝虚表?
因为如果拷贝虚表,使用子类对象会调用子类的虚函数,使用父类对象也会拷贝子类的虚函数,就乱套了。

(3)为什么不能是子类的指针或引用?因为父类是子类的一部分,父类赋值给子类指针不会切片,就不能获取父类的虚函数表。

需要注意的以下几点:

void test_where()
{
	Person p;
	Student s;
	//栈
	int a;
	printf("栈->[%p]\n", &a);

	//堆区
	int* ptr = new int;
	printf("堆->[%p]\n", ptr);

	//数据段(静态区)
	static int b;
	printf("静态->[%p]\n", &b);

	//代码段(常量区)
	const char* str = "Hello World";
	printf("常量区->[%p]\n", str);

	printf("虚函数表1->[%p]\n", *((int*)&p));
	printf("虚函数表2->[%p]\n", *((int*)&s));

}

int main()
{
	test_where();
	return 0;
}

思路:获取父类或子类对象的虚函数表指针,也就是取出父类/子类在内存中的前4个字节,将该地址与内存中的栈区,堆区,静态区(数据段),常量区(代码段)的地址进行对比,跟谁的地址比较近,就大致在哪个区域。

多态条件的两个特例:(这一点是C++的大坑)

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

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

3.1动态绑定和静态绑定

多态可以分为动态多态和静态多态,动态多态也叫做动态绑定,静态多态也叫做静态绑定。

四、默认成员函数和虚函数的关系

4.1构造函数可以设置成虚函数吗?

4.2 析构函数可以设置成虚函数吗?

如果不设置成虚函数,假如有以下的场景,会出问题:

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

class Student : public Person
{
public:
	~Student()
	{
		cout << "son:~Student" << endl;
		delete [] ptr;
	}

protected:
	int* ptr = new int[10];
	//new ==> 构造 + operator new()
};

int main()
{
	Person* p = new Person;
	delete p;

	p = new Student;
	delete p;
	return 0;
}

delete p 会处理成以下方式:
p->destructor() + operator delete( p )

普通对象,看当前者的类型。
多态对象,看指向对象的类型。

p是父类对象,不管指向的对象是父类还是子类,都会调用父类的析构函数,在子类申请的空间就得不到释放,会造成内存泄露问题。
如果设置成虚函数,就能够实现虚函数的重写,从而实现多态。

一道经典面试题

设计一个不想被继承的类,如何创建?
(1)构造函数设置成私有

class A
{
private:
	A()
	{}

};

class B : public A
{
public:
	B()
	{}

};

原理:在继承体系中,子类的构造函数必须先去调用父类的构造函数,这里父类构造设置成私有子类就无法调用了。

不过这里出现一个问题,子类无法调用父类的构造,父类也无法调用自己的构造了。
我们可以写一个函数,在函数里面创建一个父类。

class A
{
private:
	A Createobj()
	{
		return A();
	}

	A()
	{}

};

但是又有一个问题:如何调用这个函数呢?因为调用该函数创建对象,而创建对象又需要在函数里面创建。
这里我们可以加一个static解决

class A
{
private:
	static A Createobj()
	{
		return A();
	}

	A()
	{}

};

int main()
{
    A a = A::Createobj();
}

这样就可以通过指定类域访问该函数解决。

五、单继承和多继承关系中的虚函数表

5.1单继承关系中的虚函数表

单继承关系中,子类会拷贝父类的虚函数表,如果子类还有自己的虚函数,则该虚函数的地址会放在虚函数表的最后。

我们通过调试窗口无法看到子类的虚函数。在这里插入图片描述
不过我们可以通过获取虚表的地址来打印虚表的各个虚函数的地址。

typedef void(*VFPTR)();

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a = 1;
};

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

void PrintVfptr(VFPTR* arr)
{
	printf("虚表地址是:[%p]\n", arr);
	for (int i = 0; arr[i] != nullptr; i++)
	{
		printf("第%d个虚函数地址是:[%p]\n", i, arr[i]);
	}
	printf("\n");

}

int main()
{
	Base b;

	Derive d;
	int vfptrb = *((int*)&b);
	int vfptrd = *((int*)&d);
	PrintVfptr((VFPTR*)vfptrb);
	PrintVfptr((VFPTR*)vfptrd);

	return 0;
}


  • 思路:1.先获取虚表的地址,取子类对象的地址,强转成int*,再进行解引用,就取到了子类对象的前4个字节,也就是虚表指针。
  • 2.再强转成(VFPTR*)通过打印该指针指向的内容,即可打印虚表的内容。

5.2多继承关系中的虚函数表

这里有几种猜测,子类的未重写的虚函数会放在第一个继承的父类的虚表中,或者放在其他的父类的虚表中。

我们也可以通过打印地址的方式确定。

typedef void(*VFPTR)();

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

private:
	int b1 = 1;
};

class Base2 
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Derive::func2" << endl; }
private:
	int b2 = 2;
};

class Derive1 :public Base1, public Base2
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d = 2;
};

void PrintVfptr(VFPTR* arr)
{
	printf("虚表地址是:[%p]\n", arr);
	for (int i = 0; arr[i] != nullptr; i++)
	{
		printf("第%d个虚函数地址是:[%p]\n", i, arr[i]);
	}
	printf("\n");

}

int main()
{
	Derive1 d;

	int vfptrb1 = *((int*)&d);
	
	Base2* p2 = &d;
	//自动切片,p2就指向Base2对象的首地址
	int vfptrb2 = *(int*)p2;
	//int vfptrb2 = *((int*)((char*)&d + sizeof(Base1)));

	PrintVfptr((VFPTR*)vfptrb1);
	PrintVfptr((VFPTR*)vfptrb2);

	return 0;
}

在这里插入图片描述

通过打印可以看到,子类中未重写的虚函数放在第一个继承的父类的虚表中。

但是这里有一个问题:为什么重写了func1,在Base1的func1的地址和Base2的func1的地址不一样?
在这里插入图片描述

在ptr1和ptr2调用func1的过程中,调用的是Derive的func1函数,因为func1已经被重写了。

而在内存中,ptr1和ptr2指向的地址如下:

在这里插入图片描述

因为ptr1指向的地址刚好是d对象的首地址,ptr1和this指针是重叠的,无需偏移,而ptr2需要偏移Base1字节才与this指针重叠。
这就导致在调用func1函数前,ptr2需要先偏移,才能调用。我们看到的是在偏移之前的ptr2的地址,这就是为什么看到的func1的地址不同,调用的确实同一个函数的原因,实际上ptr2会偏移。

六、抽象类

class
{
public:
	virtual void func1() = 0
	{}	
};

比如上面这个就是抽象类。
抽象类的特性:

  • 1.不能实例化出对象,子类继承后也不能实例化出对象,只有重写虚函数才能实例化出对象。
  • 2.可以定义指针或引用.

普通的对象是实现继承,而实现多态的对象是接口继承。

抽象类的作用:间接强制子类虚函数必须重写,否则无法实例化对象。

写在最后的面试题


总结

多态内容就讲到这里。

举报

相关推荐

0 条评论