一、多态的概念

多态简单来说就是多种形态,多态可以分成编译时多态(静态多态)和运行时多态(动态多态)。

多态的核心就是设计一个接口根据传参内容的不同,编译器去寻找与之相匹配的函数实现由此实现一个接口多种操作。

编译时的多态体现在,根据传参的不同,去绑定好对应调用的函数,如果是模板先生成对应类型再绑定。

运行时多态是我们今天重点要说的内容,它也是通过传参控制调用的函数,与编译多态的区别主要在于运行时多态依托于继承体系中的指针和引用,具体内容且听我娓娓道来。

生活中常见的多态行为

买高铁票、火车票时

正常民众原价买票;学生支持七五折,半价买票;军人有优先买票权。

游乐场游玩时

购买了VIP的人不需要排队可以直接游玩各种项目;

普通人每玩一个项目都得老老实实排队。

不同小动物的叫声

对狗发出叫的指令,狗发出的声音就是“旺旺”;

对猫发出叫的指令,猫发出的声音就是“喵喵”。

二、多态的定义和语法规则

以下继承体系中的多态其实是运行中的多态,简称多态。

1.实现多态的条件

  • 多态必须是继承体系下的类对象去调用同一个虚函数,产生不同的行为
  • 必须是基类的指针或引用调用虚函数
  • 被调用的虚函数必须完成了虚函数的重写/覆盖

其实说起来有点抽象,或者说非常麻烦,必须存在继承,而且多态发生在继承体系中的成员函数,这个成员函数不仅得是虚函数,还得保证在基类中实现,在派生类中重写。

直接上例子,在例子中解决问题:

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

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

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

继承体系不难看出来。

虚函数是谁呢?

	virtual void BuyTicket() 
    {
		cout << "买票全价" << endl; 
    }
	virtual void BuyTicket() 
    {
		cout << "买票打折" << endl; 
    }

虚函数首先最根本最根本的要求就是必须是成员函数,成员函数前加virtual就是虚函数。

不过千万不能把这个virtual跟虚继承那里的用法记混,其实可以说俩用法关联性不大,直接分开记住就行。这里需要记住想要重写的函数前加virtual,才能实现多态。


除此之外,虚函数还有几点要求:

  • 根据虚函数必须是成员函数的要求,友元函数一定不能设计成虚函数
  • 能够设计成虚函数的成员函数必须符合一句话:依赖于对象实例化的调用

因此针对于这句话,不能设计成虚函数的成员函数就包括构造函数和静态成员函数。

构造函数

构造函数调用的时候绝对不是创建好对象,用基类的指针或者引用去调用,构造函数调用的时候还没有对象可用,因此不符合依赖对象实例化的调用。

静态成员函数

静态成员严格来说属于类,完全可以通过类域直接调用,不依赖于对象实例化,而且说起来,静态成员函数由于属于类,通过类而不通过对象调用,故而连this指针都没有,因为根本不需要保证指定对象。说这么多,静态成员函数由于与对象无关,根本就不能设计成虚函数,语法上就限定死了。

  • 如果一个类被设置成基类,换句话说这个类被继承,那么这个类的析构函数必须设计成虚函数使之去符合多态要求。即使一个类暂时没有被设置成基类,但是天有不测风云,难免可能用得到做基类,所以一般建议直接讲析构函数设置成虚函数。具体原因下面讲

还有基类的指针:

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

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

可以看到,传参的时候一个传的是基类的指针,一个传的是派生类的指针,这两个指针最后都被Person*类型接收也就是在Func中用的都是基类的指针去调用函数。

最后运行的结果是:

从这里我们可以看出来,Func调用函数的时候都是以基类的指针去调用,可是调用的结果不一样,说明起决定性作用因素的并不是调用函数时的类型,而是真实类型起作用,否则按道理来说都应该调用基类的函数,也就是输出两次买票全价。

当然,引用当然也是一个道理:

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

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

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

C++比较特殊的两个现象对比

在上面的例子中我们实现了虚函数的重写,虚函数重写的概念是什么呢?

虚函数的重写/覆盖

派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数返回值、函数名、参数列表全部相同。当然,内部逻辑肯定不一样才能实现多态,重点是调用的时候看起来是一样的),则称派生类对基类的虚函数完成了重写。

为什么说情况比较特殊呢?虚函数的概念已经有了,针对上面的例子,存在两种情况。

只去除基类函数前的virtual关键字:

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

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

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

这样的输出结果是这样的:

只去除派生类函数前的virtual关键字:

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

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

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


这个事看起来匪夷所思,如果基类中的函数不是虚函数,那么:

此时的调用因为不符合多态的要求(基类指针调用虚函数、虚函数被派生类重写),基类的函数显然不是虚函数,那就啥类型调用走啥类型成员函数呗,所以输出两次买票全价。

但是问题就接踵而至了,第二种代码写法给出的派生类中的函数并不是虚函数,结果最终结果还是构成了多态。

解释是这么说的,重写基类虚函数时,即使派生类的虚函数不加virtual关键字,仍然构成重写,但是这种写法不符合重写的定义,不建议这样使用,但是为什么还要展示这一点呢?因为真的有人会考这个点,那既然如此,我们如何理解这一点呢?

继承到底继承了什么?

可能有人说继承就是直接给子类里塞进所有能塞的父类对象呗,但是按照这个说法那:

当时这段代码我们给的是:

如果仅仅是简单的父类对象拷贝到子类对象,在继承章节我们学的隐藏是不是不复存在了,因为我们给出的解释是子类对象调用,优先在子类中找,子类中找不到再去父类里找,如果简简单单从父类拷贝成员,那构成重载就不会编译报错了。

回到我们的例子:

这里的继承应当认为成仅仅就是简单的声明继承过去了。

如果是普通函数的声明被继承,编译器通过派生类对象先从派生类的成员中找,如果派生类的成员里找不到,再通过父类的声明上去调用父类的成员函数;

如果是虚函数的声明被继承,编译器就会去找函数名相同的,检查是不是返回值和参数类型完全相同,构成函数的重写就能形成多态,也就是:

披着基类虚函数的声明,用着派生类的函数体。其实也就是重写重写了虚函数的实现。

可能说这么理解感觉没意义,死记住规范情况下虚函数重写虚函数,但是派生类虚函数的virtual可以没有,但是。

2.多态场景下的两道选择题

一下程序输出结果是:

A.A->0  B.B->1  C.A->1 D.B->0  E.编译出错  F.以上都不正确

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();
    p->func();
	return 0;
}

这道题可就盖了帽了,我就直接说,它考的就是你对继承体系多态中重写的这个理解。

从main函数往里走,其实也很简单,B类型的指针,也就是派生类的指针去调用函数去了,但是在B里面根本没有test啊,由于继承关系,又去A类里面找,这里就非常考验我们对继承的理解,是去A类里找,而不是说从A类往B类拷贝一份,编译时底层细节:

void test(A *const this)

{

        this->func();

}

所以就算你从B类对象调用test也是用的A类的指针,为什么强调这一点,因为多态的要求就是基类的指针调用被重写后的虚函数,如果不是基类指针,触发不了多态。

现在的问题就转移了,A类的指针去调用func函数,按理来说根据指针实际的对象走的是B里面的func函数,输出B->0。

但是但是,我个人感觉最抽象点就在这里了,这里按道理来说调用B的func就完事算了,结果呢,多态重写的实质是,到时候实行多态的时候,还是站在A类的角度,先去走A类func的声明,再去走B类的实现,也就是:

细细解释我还是愿意从作用域的角度,A类的test函数,肯定优先调用A类的func函数,结果读着读着,virtuall void func(int val = 1),有virtual是虚函数,并且检查调用指针是B类对象,转而就走向B类中func函数的实现了。

所以最后绕来绕去,搞出来的是B -> 1,选B。

至于后面的p->fun(),多的不是,根本不是基类指针调用,那构成隐藏,只能调用B的func,那就普通调用:B->0。

答案:

3.虚函数重写的其他问题

①协变

在上面讲解重写时我们一再强调,如果两个虚函数想要构成重写,那么一定需要参数名相同、返回值类型相同、参数列表类型相同。

但是协变就是例外。

协变是这样规定的,派生类重写基类虚函数时,允许与基类虚函数返回值类型不同,但这种情况必须是基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用(当然,它们返回的不一定就是自己类型的指针或者引用)。

协变其实实际应用没有太重大的意义,意思意思知道这个点就行:

class Person {
public:
	virtual Person* BuyTicket()
	{
		cout << "买票全价" << endl;
		return nullptr;
	}
};
class Student : public Person {
public:
	virtual Student* BuyTicket()
	{
		cout << "买票打折" << endl;
		return nullptr;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);

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

保证基类返回基类的指针/引用,派生类返回派生类的指针/引用即可,不一定非得是它们自己。

②析构函数的重写

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

class B : public A
{
public:
	~B()
	{
		delete _arr;
		cout << "~B() delete:" << _arr << endl;
	}

protected:
	int* _arr = new int[10];
};

int main()
{
	A a;
	B b;

	return 0;
}

这个场景其实也没啥好说的,后创建的对象先析构,所以B b先析构,由于它是派生类对象,所以先走派生类自己的析构,再自动调用基类的析构;然后析构A a,直接调用A类的析构就行。

但是换个场景,如果是new出来的对象呢:

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

class B : public A
{
public:
	~B()
	{
		delete[] _arr;
		cout << "~B() delete:" << _arr << endl;
	}

protected:
	int* _arr = new int[10];
};

int main()
{
	//A a;
	//B b;
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;

	return 0;
}

基类指针接收基类对象的地址,基类指针接收派生类对象的地址,没毛病吧。

看着也没啥问题是吧,跑起来试试:

这你总不能再给我继续装聋作哑了吧,根本没去调B的析构,B里面可有堆内存申请啊,如果不管是不是就是内存泄露了。

为什么造成这样的问题呢?

delete的底层机制:

p1->调用析构+operator delete(p1);

p2->调用析构+operator delete(p2);

而p1 p2都是A类型的指针,理所当然调用的就是A类的析构。

但是按照我们的思路来看,不能用指针类型决定调用谁的析构,必须要根据实际new出来的对象的类型啊。

这成啥了:

基类指针->成员函数;

必须根据基类指针实际指向的对象调用成员函数

多态嘛。

所以:

class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

class B : public A
{
public:
	virtual ~B()
	{
		delete[] _arr;
		cout << "~B() delete:" << _arr << endl;
	}

protected:
	int* _arr = new int[10];
};

int main()
{
	//A a;
	//B b;
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;

	return 0;
}


当然,还没完:

我问你,A和B类的析构函数构成重载吗?从结果上来看是的,不构成重载怎么能根据基类指针的真正指向来确定调用谁的成员函数。

但是构成重载的规则不是这么说的啊???!!!

继承体系里派生类和基类实现虚函数的重写,并用基类指针调用这个虚函数才符合多态;

重写是什么?

基类和派生类中的虚函数函数名、返回值、参数列表完全相同构成函数重写。

这里满足重写吗?

很明显不满足,因此这个自相矛盾的点诞生以后为了解决,就隐式的将所有类的析构函数在编译时全部转换为destructor的函数名。

也就相当于p1->destructor() + operator delete(p1);p2->destructor() + operator delete(p2);

为了构成多态可真是煞费苦心。

3.override和final关键字

override

其实看了这么多多态的场景,不难看出对于多态的要求很多,什么继承体系了,基类派生类的虚函数重写了,基类指针调用了。

但是难免会有疏忽的情况,比如函数名写错、参数写错就不构成重写,这个错误编译又检查不出来,它哪知道你是不是故意写错的,只有运行时发现结果不对了,你可能才会一点一点的debug,那就实在得不偿失了,因此C++11提供了一个override关键字,写到重写的虚函数后,如果重写失败将会有提醒,帮助程序员检测。


class Person
{
public:
	virtual void Print()
	{
		cout << "Person::Print()" << endl;
		cout << _name << endl;
	}
protected:
	string _name = "张三";
};

class Student : public Person
{
public:
	virtual void Print() override
	{
		cout << "Student::Print()" << endl;
		cout << _name << endl;
		cout <<  _id << endl;
	}

protected:
	int _id = 111;
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	p1->Print();
	p2->Print();

	delete p1;
	delete p2;

	return 0;
}

输出结果也很简单:

但是你这么搞:

根本没有构成重写,运行起来你傻眼了,不过加个override:

override就是放到派生类要重写的虚函数后的。

final

final关键字就直接说了,final关键字放到一个虚函数后面可以防止被重写。

4.重载/重写/隐藏对比

当然,成员变量的相同也叫隐藏。

三、纯虚函数和抽象类

虚函数后加=0就是纯虚函数,纯虚函数不需要定义实现(因为纯虚函数创造出来肯定要被派生类实例化,但是语法上支持你去实现),含有纯虚函数的类称为抽象类,抽象类不能实例化对象。

抽象类的派生类的隐形要求就是必须重写纯虚函数。

copy的一段代码:

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 car;
	//抽象类通常只当做接口使用
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

派生类如果不重写,那就得继承抽象类的纯虚函数,那派生类就也不能实例化了,不实例化有啥用,所以抽象类一定程度上也保证了重写的完成。

四、多态的原理

先看一道题:

下面程序的运行结果是什么?

A.编译报错  B.运行报错  C.8  D.12      

//32位操作系统下
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;
}

其实按照我们以前的认知,对象里面不存函数,也就是不存函数指针,一看就是只有个:

int

char

根据内存对齐,8个字节大小呗。

??直接给人弄懵了,咋回事呢?这个类里面除了两个变量就是函数了,难道函数指针也算上了吗。其实猜的是没错的,但是为什么之前的函数不存,现在的函数又存起来了呢?

调试1发现,除了两个成员变量以外还有一个_vfptr指针,在32位操作系统下,指针就是占四个字节。

直接说结论,一切变数就是virtual虚函数,虚函数的地址需要存到虚函数表,为了方便多态的调用。

原来,在继承体系中基类所有的虚函数地址都被存到虚函数表里,而虚函数表实际上就是个函数指针数组,因为要存所有的虚函数地址嘛,但是让对象存整个虚函数表会加大对象的负担,所以干脆用一个指针指向这个虚函数表也就是一个指针存起来这个函数指针数组的首元素地址。

当然,编译器处理起来就是我们看到的样子。

多存两个这个事就更加明显:

确实是用一个指针专门存函数指针数组的地址。

因此加再多虚函数,仍旧只是多加一个指针的内存:

1.多态是如何实现的

上面那么多多态场景,我们直观感受就是,噢,基类指针去调用被重写的虚函数了,根据实际指针指向的对象来确定到底调用谁的虚函数。

但是再说也是直观感受,如何实现我们是没有了解的。

有了这个虚函数表以后的事情就简单了:

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "原价购买" << endl;
	}
protected:
	string _name;
};


class Student : public Person
{
public:
	virtual void BuyTicket()override
	{
		cout << "打折购买" << endl;
	}
protected:
	int _id;
};

class Soldier : public Person
{
public:
	virtual void BuyTicket()override
	{
		cout << "优先购买" << endl;
	}
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	Person* p3 = new Soldier;

	p1->BuyTicket();
	p2->BuyTicket();
	p3->BuyTicket();

	return 0;
}

经典的多态场景,所以答案不言而喻:

具体细节是这样的:

检测到函数调用符合多态行为,顺着指针去找存虚函数表的指针,找到虚函数表的指针以后就去找对应虚函数的指针,最终完成调用。

不同对象的虚函数表不同,顺着不同的虚函数表就可以实现多态的调用。

2.静态绑定和静态绑定

  • 不满足多态条件的成员函数的调用,在编译时编译器即确定好函数的地址,就算编译时只能找到声明,最晚链接也能找到定义,这就叫做静态绑定
  • 满足多态条件的虚函数的调用,由于仅仅通过函数名找到的函数可能有多个版本,因此直到运行时才根据虚函数表找到真正的函数地址,这就叫做动态绑定

比如:

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "原价购买" << endl;
	}
protected:
	string _name;
};


class Student : public Person
{
public:
	virtual void BuyTicket()override
	{
		cout << "打折购买" << endl;
	}
protected:
	int _id;
};

class Soldier : public Person
{
public:
	virtual void BuyTicket()override
	{
		cout << "优先购买" << endl;
	}
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	Person* p3 = new Soldier;

	p1->BuyTicket();
	p2->BuyTicket();
	p3->BuyTicket();

	return 0;
}

满足多态调用,此时调试:

从p1里找对应的BuyTicket方法把指针给给eax,又倒了一手给了edx,跳过两行直接看,edx又倒回eax,最后call的是eax存的函数地址。

把基类virtual去掉,即不满足多态的条件:

class Person
{
public:
	void BuyTicket()
	{
		cout << "原价购买" << endl;
	}
protected:
	string _name;
};


class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "打折购买" << endl;
	}
protected:
	int _id;
};

class Soldier : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "优先购买" << endl;
	}
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	Person* p3 = new Soldier;

	p1->BuyTicket();
	p2->BuyTicket();
	p3->BuyTicket();

	return 0;
}

此时调试:

从p1中找到了函数地址,直接就call了,等于编译时就找好了函数的地址了。

3.虚函数表

  • 基类对象的虚函数表存放所有虚函数的地址。同类型的对象虚函数共用一个虚表,不同类型的对象分别有各自的虚表

  • 派生类由两部分组成:从基类继承过来的成员和自己的成员。而基类中如果有基类虚表变量_vfptr那么派生类将不会再重新生成这个变量,而是直接继承下来用,当然,继承下来的就只是这个变量的壳,毕竟不同类型的虚表不同,所以派生类_vfptr值还是自己的。这种情况类似于普通变量的继承
  • 派生类中重写基类的虚函数,派生类的虚函数表对应的虚函数就会被覆盖成派生类重写的虚函数地址
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "原价购买" << endl;
	}
	virtual void Func1(){}
	virtual void Func2(){}

protected:
	string _name;
};

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

protected:
	int _id;
};

Person类里的BuyTicket方法被重写了,Func1也被重写了,Func2没有被重写,按道理来说就被继承,而Student类中自己还独有一个虚函数Func3。

调试可以观察到:

虚表的继承简单理解就是完全把基类虚表里的值拷贝一份到派生类的虚表,但是由于存在重写(覆盖),有的就被修改了,没有重写就还是直接拷贝下来的值。

当然,编译器就是这么理解的,所以监视窗口设计的时候突出表现了派生类虚表由基类虚表继承而来,就没展示Student独有的Func3这个虚函数,不过通过内存能勉勉强强看到虚表里其实还存了一个值:

上面三个不用多说,最后剩了一个那就是Func3的地址了。

  • 派生类虚表含有三类成员:(1)基类的虚函数地址(2)派生类重写(覆盖)后虚函数的地址(3)派生类自己独有的虚函数的地址
  • 虚函数存放在哪里,C++标准并没有指定,但是我们可以通过代码巧妙推断存放在哪里。
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "原价购买" << endl;
	}
};

int main()
{
	int x = 1;//栈区
	int* py = new int;//堆区
	static int z = 0;//静态区
	const char* str = "xxxxxx";//常量区

	Person* p = new Person;

	printf("栈区地址:  %p\n", &x);
	printf("堆区地址:  %p\n", py);
	printf("静态区地址:%p\n", &z);
	printf("常量区地址:%p\n", str);
	//虚表地址
	printf("虚表地址:  %p\n", *(int*)p);

	return 0;
}

主要是因为Person如果存在虚函数肯定就存在虚表指针这个成员变量,调试也可以观察到对象的内存分布,虚表指针肯定是第一个成员,拿到Person*直接解引用拿到的是整个Person对象,我们现在只要虚表指针,也就是只要前四个字节,那么就强转为int*再解引用,强制只拿前四个字节的虚表指针的内容。

运行可以观察到:

其实吧,虚表指针的值离静态区和常量区都挺近的,但是明显离常量区更近,因此VS下虚表存放在常量区。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐