C++之多态详解

多态

多态:多态就是函数调用的多种形态,调用函数更加灵活,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态

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

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

void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}


int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

我们可以看到上面的代码构成了多态,那么是怎么形成多态的呢?主要有两点

1、子类重写父类的虚函数

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

class Student : public Person {
public:
	void BuyTicket() { cout << "买票-打折" << endl; }//重写上面的虚函数
};

2、必须是父类的指针或者引用去调用虚函数

cpp 复制代码
//指针去调用虚函数
void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}

//引用去调用虚函数
void Func(Person& ptr)
{
	ptr.BuyTicket();
}

那么什么是虚函数?多态的原理又是怎么样的?上面为什么可以满足多态的条件?

接下来我们会带着这三个问题去学习:

有些书籍会把多态划分的更细:

静态的多态:函数重载,调用同一个函数,传不同的参数,就有不同的行为/形态

动态的多态:父类指针或引用调用重写虚函数,不同的对象去调用,会有不同的行为/状态,父类指针或者引用指向父类,调用的就是父类的虚函数,父类指针或引用指向那个子类,调用的就是子类的虚函数

多态的构成条件

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

那么我们在继承中要构成多态有两个条件

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

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

下面我们开始介绍虚函数和重写

虚函数

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

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }//虚函数
};

需要注意的是:

**√**只有类的非静态成员函数才可以加virtual

**√**虚函数这里virtual和虚继承中用的virtual是同一个关键字,但是他们都没有关系,这里的虚函数是为了实现多态,虚继承是为了解决菱形继承的数据冗余性和二义性

虚函数的重写

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

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

class Student : public Person {
public:
    //子类的虚函数重写了父类的虚函数
	void BuyTicket() { cout << "买票-打折" << endl; }
};

那么下面我们就看完整构成多态的代码:

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

class Student : public Person {
public:
	void BuyTicket() { cout << "买票-打折" << endl; }
};

void Func(Person* ptr)
{
    //传不同的类型对象,调用的是不同的函数,实现了调用的多种形态
	ptr->BuyTicket();
}

void Func(Person& ptr)
{
    //传不同的类型对象,调用的是不同的函数,实现了调用的多种形态
	ptr.BuyTicket();
}

int main()
{
	Person ps;//普通人
	Student st;//学生

	//指针调用
	Func(&ps);
	Func(&st);

	//引用调用
	Func(ps);
	Func(st);
	return 0;
}

那么我们需要再次注意!构成多态的条件

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

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

正常的虚函数重写,要求虚函数的函数名、参数、返回值都要相同,但是协变除外

虚函数重写的两个例外:

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

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

cpp 复制代码
class A {};
class B : public A {};

// 协变
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);
	Func(&st);

	return 0;
}

但是他的意义不大,只需要了解一下就可以了

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

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

不构成重写

cpp 复制代码
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;

	// p1->destructor() + operator delete 
	delete p1;
	delete p2;

	return 0;
}

为什么呢?

cpp 复制代码
delete p1;//p1->destuctor()+operator delete(p1)
delete p2;//p2->destuctor()+operator delete(p2)

因为delete时底层会去调用该对象类的析构函数和operator delete,不是虚函数时,他们构成隐藏,因为p1和p2都是父类指针,所以他们都是去调用父类的析构函数

构成重写时:

cpp 复制代码
class A
{
public:
	virtual ~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;

	// p1->destructor() + operator delete 
	delete p1;
	delete p2;

	return 0;
}

只有派生类Student的析构重写了Person的析构函数,下面的delete对象调用了析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数

注意:如果~A(),不加virtual,那么deletep2时只调⽤的A的析构函数,没有调⽤ B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源

C++11 override和findal

C++对虚函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错参数 写错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的,只有在程序运⾏时没有得到预期结 果才来debug会得不偿失,因此C++11提供了override,可以帮助⽤⼾检测是否重写。如果我们不想让 派⽣类重写这个虚函数,那么可以⽤final去修饰

如果不想虚函数被重写,那么就在虚函数后面加关键字final:

cpp 复制代码
class Car {
public:
	virtual void Dirve() final
	{}
};

class Benz :public Car {
public:
	virtual void Dirve() { cout << "Benz-舒适" << endl; }
};

override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写就发生报错

cpp 复制代码
class Car {
public:
	virtual void Dirve()
	{}
};

class Benz :public Car {
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

重载、覆盖(重写)、隐藏的对比

总结:

多态:调用一个函数时,展现出多种形态(通过调用不同的函数,完成不同的行为)。

多态分为静态的多态和动态的多态:

静态的多态:

函数重载就是静态的多态,在编译时确定地址。

动态的多态:

1、子类继承父类,完成虚函数重写

2、父类的指针或引用去调用这个重写的虚函数

父类的指针或引用指向父类对象,调用的是父类的虚函数

父类的指针或引用指向子类对象,调用的是子类的虚函数

虚函数重写条件:1、要是虚函数 2、函数名、参数、返回值都相等

例外:

1、协变(返回值不一样,父类的虚函数返回的是基类对象指针和引用,子类的虚函数返回的是子类对象指针和引用)

2、析构函数

3、子类中的重写的虚函数可以不加virtual关键字(建议加上)

抽象类

概念

在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化对象。派生类继承后也不能实例化对象,只有重写纯虚函数,派生类才能实例化对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承

cpp 复制代码
class Car
{
public:
	// 纯虚函数
	virtual void Drive() = 0;
};

int main()
{
	Car cc;
	return 0;
}

抽象类不能实例化出对象,可以更好的去表示现实世界中没有实例化对象对应的抽象类型,比如:植物、人、动物,它体现了接口继承,强调子类去重写虚函数,如果不重写,继承下还是纯虚函数,照样无法实例化出对象。

cpp 复制代码
class Car
{
public:
	// 纯虚函数
	virtual void Drive() = 0;
};

class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};

class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

int main()
{
	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();

	return 0;
}

注意:

要注意和override区分,override检查子类虚函数是否完成重写。纯虚函数是强制子类去重写虚函数,如果不重写,继承下来还是纯虚函数,照样无法实例化出对象。

接口继承和实现继承

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

多态的原理

虚函数表

这里常考一道笔试题:sizeof(Base)是多少?

cpp 复制代码
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
private:
	int _b = 1;
    char _ch = 'a';
};
int main()
{
    cout << sizeof(Base) << endl;
    return 0;
}

为什么是12呢?根据内存对齐应该是8呀,这里为什么会是12。

是因为只要包含虚函数的类,该类的对象就包含一个虚函数表指针(简称虚表指针),这个虚函数表指针就是用来实现多态的:

这个虚表指针指向一个数组,这个数组的元素是函数指针,这里面的函数指针指向该类中的虚函数

虚函数被编译成指令后,还是和普通函数一样,存在代码段,只是它的地址放在虚表中

需要注意的是:

这里跟虚继承那里是不一样的,他们虽然都用了virtual关键字,但是他们的使用场景完全不一样,解决的也是不一样的问题,他们之间没有关联,虚继承产生的是虚基表,虚基表里面存的是距离虚基类的偏移量

cpp 复制代码
class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
     virtual void Func1()
     {
         cout<<"Person::Func1()"<<endl;
     }
};
class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
    //不重写Func1,虚表里面的指针指向的是父类的虚函数
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
    Person Mike;
    Func(Mike);
    Student Johnson;
    Func(Johnson);
    return 0;
}

父子类无论是否完成虚函数重写,都有各自的独立虚表,一个类的所有对象共享一个虚表

满足多态条件以后,构成多态:指针或引用在调用虚函数时,不是在编译时确定,是在运行时到指针或引用指向的对象的虚表中去找对应的虚函数调用,如果指向的时父类对象,则调用的就是父类的虚函数,指向的是子类对象,调用的就是子类的虚函数。需要注意的是,如果不构成多态,那么这里调用的时候就是编译时确定的调用哪个函数,主要看的p的类型,调用的就是Person和Buyticket,跟传上面类型对象过来没有关系

总结:

构成多态,指向谁,调用谁的虚函数,跟对象有关;不构成多态,对象类型是什么,调用那个对象的函数,跟类型有关

为什么多态的条件之一必须是父类的指针或引用去调用虚函数时才会发生多态,父类对象却不行?

父类的指针和引用,在切片时,指向或者引用父类对象 或者 指向或引用子类对象中切出来的父类那一部分。vfptr在对象的前四个字节保存,指向父类看到的是父类的虚表,指向子类看到的是子类的虚表

如果为父类对象时,切片只会拷贝成员变量过去,不会拷贝vfptr过去,因为拷贝过去不合理,如果可以拷贝过去,因为一个类共享一个虚表,在创建一个父类对象,这个父类对象的虚表时子类的虚表,这样不合理

多态实际上有一些性能开销的:

我们通过汇编代码分析,可以看出满足多态的函数调用不是在编译时确定的,是运行起来以后到对象中去找的,不满足多态的函数调用是编译或者链接时确认好的:

满足多态时调用的虚函数汇编代码

call eax其实就是调用虚函数

不满足多态时调用的虚函数汇编代码"

动态绑定和静态绑定

静态绑定又叫前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态

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

普通函数的调用,编译(当在一个文件当中时在编译阶段确定)链接(当声明和定义分离时,在链接时确定)时确定地址,多态的调用是运行时确定地址,如何确定?去指向对象的虚函数表中找到虚函数地址

下面我们来看这样的几个问题:

对象中虚表指针是在什么阶段初始化的呢?虚表又是在什么阶段生成的呢?

对象中虚表指针是在构造函数初始化列表进行初始化,虚表是在编译时就生成好了

可以看到调用了构造函数后虚表指针依旧进行初始化了
虚函数放在虚表里面的,这句话对吗?

这句话不准确,虚表里面放的是虚函数地址,虚函数跟普通函数一样,编译完成后,都是放在代码段。
一个类中所有的虚函数地址,都会放在虚表中。

这句话是正确的,虽然可能大家有时候在调式的监视窗口看不到某个虚函数,这是因为编译器进行了优化,其实在内存中是可以看到有的,这些虚函数的地址都会放在虚表当中。
虚函数的重写,也叫做虚函数的覆盖,原因是子类刚开始是拷贝父类的虚函数过来,如果重写了哪个虚函数,就会将该虚函数拿过来进行覆盖从父类拷贝过来的虚函数
vs下会在虚表结束位置放一个空指针表示虚表结束了

在面试中,面试官可能会问虚表是存在哪里的?想办法写一段程序,论证一下虚表存在哪个区域的?那么怎么论证呢?我们定义各个区域的变量或者常量,通过看地址的方式看哪个地址和虚表指针的内容相近:

cpp 复制代码
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 j = 0;
int main()
{
    //取虚表地址打印一下
    Person p;
    Person* pp = &p;//pp指向整个对象p
    printf("vftptr:%p\n",*((int*)pp));//将pp强转为int*,即pp指向对象p的前四个字节,对它解引用就拿到了前四个字节,前四个字节就是vftptr(虚表指针)
    
    int i;
    printf("栈上地址:%p\n",&i);
    printf("数据段地址:%p\n",&j);
    
    int *k = new int;
    printf("堆地址:%p\n",k);
    char* cp = "hello world";
    printf("代码段地址:%p\n",cp);
    return 0;
}
cpp 复制代码
printf("vftptr:%p\n",*((int*)pp));

这个代码就打印出来了虚表指针,为什么呢?pp指向整个对象p,首先将pp强转为int*,此时pp指向p对象的前四个字节,对它解引用就拿到了前四个字节,这前四个字节就是虚表指针。

可以看到它是更接近代码段地址的,所以虚表是存在代码段的。虚函数编译出来函数指令跟普通函数一样,存在代码段,虚函数地址又被放到虚函数表中

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

单继承的虚函数表

cpp 复制代码
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;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

我们通过调试发现监视窗口看不到子类自己的虚函数fun3和funn4

监视窗口不一定真实,实际上是由的:

我们可以通过写一个程序打印一下虚表,通过调用虚表中的虚函数,确定上面两个就是我们说的fun3和fun4的地址:

cpp 复制代码
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;
};
typedef void(*VFunc)();//VFunc是函数指针
//void PrintVFT(VFunc ptr[])
void PrintVFT(VFunc ptr[])//存函数指针的数组指针,ptr指向函数指针数组
{
    for(int i = 0;ptr[i]!=nullptr;++i)
    {
        printf("VFT[%d]:%p\n",i,ptr[i]);
        ptr[i]();//调用该函数,确认地址是哪个函数的地址
    }
    printf("\n");
}
int main()
{
	Base b;
    PrintVFT((VFunc*)(*(int*)&b));//将b的虚表指针传过去,*(int*)&b)拿到虚表指针
	Derive d;
    PrintVFT((VFunc*)(*(int*)&d));//将d的虚表指针传过去
    
	return 0;
}

可以看到明确调用了func3和func4

多继承的虚函数表

我们来看一下多继承的虚函数表是怎么样的:

cpp 复制代码
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;
};
typedef void(*VFunc)();//VFunc是函数指针
//void PrintVFT(VFunc ptr[])
void PrintVFT(VFunc* ptr)//存函数指针的数组指针,ptr指向函数指针数组
{
    for(int i = 0;ptr[i]!=nullptr;++i)
    {
        printf("VFT[%d]:%p\n",i,ptr[i]);
        ptr[i]();//调用该函数,确认地址是哪个函数的地址
    }
    printf("\n");
}
int main()
{
    Base1 b1;
    Base2 b2;
    
    Derive d;
    PrintVFT((VFunc*)(*(int*)&d));//打印第一个虚表(d对象中起始位置为虚表指针)
    PrintVFT((VFunc*)(*(int*)((char*)&d+sizeof(Base1))));//打印第二个虚表
    
    return 0;
}

可以看到多继承中,Derive既继承了Base2,Derive就有两种虚表

我们看到Derive中自己的虚函数func3在监视窗口并没有,那么怎么证明它是存在的呢?和上面其实是一样的,只不过打印第二张虚表有些不一样:

cpp 复制代码
typedef void(*VFunc)();//VFunc是函数指针
//void PrintVFT(VFunc ptr[])
void PrintVFT(VFunc ptr[])//存函数指针的数组指针,ptr指向函数指针数组
{
    for(int i = 0;ptr[i]!=nullptr;++i)
    {
        printf("VFT[%d]:%p\n",i,ptr[i]);
        ptr[i]();//调用该函数,确认地址是哪个函数的地址
    }
    printf("\n");
}
int main()
{
    Base1 b1;
    Base2 b2;
    
    Derive d;
    PrintVFT((VFunc*)(*(int*)&d));//打印第一个虚表(d对象中起始位置为虚表指针),需要强转为VFunc*,因为虚表指针指向的类型是函数指针数组
    PrintVFT((VFunc*)(*(int*)((char*)&d+sizeof(Base1))));//打印第二个虚表
    
    return 0;
}

打印第一张虚表:

cpp 复制代码
PrintVFT((VFunc*)(*(int*)&d));

因为d对象中起始位置为第一张虚表的虚表指针,需要强转为VFunc*,因为虚表指针指向的类型是函数指针数组,这个和前面验证单继承没有区别,但是打印第二张虚表就有些不一样了:

cpp 复制代码
PrintVFT((VFunc*)*((int*)((char*)&d+sizeof(Base1))));

首先将取地址d将他转为char*类型,加上Base1的大小就到了Base2,Base2的前四个字节是虚表指针,所以再强转为int*,然后解引用拿到这四个字节,最后强转为VFunc*

这样就可以打印这两张虚表了:

可以看到func3是存在第一张虚表当中的。

相关推荐
阿蒙Amon2 小时前
C#每日面试题-Task和ValueTask区别
java·开发语言·c#
TDengine (老段)2 小时前
TDengine R 语言连接器进阶指南
大数据·开发语言·数据库·r语言·时序数据库·tdengine·涛思数据
FAFU_kyp2 小时前
Rust 泛型(Generics)学习教程
开发语言·学习·rust
VekiSon2 小时前
ARM架构——C 语言+SDK+BSP 实现 LED 点灯与蜂鸣器驱动
c语言·开发语言·arm开发·嵌入式硬件
研☆香2 小时前
JavaScript 历史列表查询的方法
开发语言·javascript·ecmascript
Elnaij2 小时前
从C++开始的编程生活(18)——二叉搜索树基础
开发语言·c++
Java程序员威哥2 小时前
【包教包会】SpringBoot依赖Jar指定位置打包:配置+原理+避坑全解析
java·开发语言·spring boot·后端·python·微服务·jar
a程序小傲2 小时前
中国邮政Java面试被问:边缘计算的数据同步和计算卸载
java·服务器·开发语言·算法·面试·职场和发展·边缘计算
Java程序员威哥2 小时前
Java微服务可观测性实战:Prometheus+Grafana+SkyWalking全链路监控落地
java·开发语言·python·docker·微服务·grafana·prometheus