C++之多态

 多态的概念

多态的概念:

多态通俗来说, 就是多种形态, 具体来说就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

举个例子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人
买票时是优先买票。


多态的语法及实现

 虚拟函数

要学习多态,首先我们要认识一下虚函数(虚拟函数)

虚函数:即被virtual关键字修饰的类成员函数

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

注意:

这里说的虚函数和我们之前学的虚拟继承是没什么关系的, 只不过它们用了同一个关键字virtual罢了.

多态的构成条件

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

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

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

虚函数的重写(也可以叫覆盖):
派生类中有一个跟基类函数原型完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同, 函数的实现可以不同),称子类的虚函数重写了基类的虚函数.(可以认为子类继承了父类虚函数的接口,重写了实现,也称为接口继承) 

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

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

 这里子类Student的虚函数BuyTicket就对父类Person的虚函数BuyTicket进行了重写。

注意:在重写基类虚函数时,派生类的虚函数不加virtual关键字, 也可以构成重写(可以认为继承后基类的虚函数被继承下来了在派生类中依旧保持虚函数属性),但是父类的virtual是不能省的,, 略的话就不能构造多态了, 而是普通的隐藏关系。

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

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

基类加了virtual,派生类不加virtual ,也构成重写关系.

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

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 ps;
    Student st;
    Func(ps);
    Func(st);
    return 0;
}

此时已经成功实现了多态,Person类对象ps调用Func函数最终调的是Person里面的BuyTicket函数,而Student类对象st调用Func最终调到的是Student类里面的BuyTicket函数。
最终产生的是不同的结果。 

子类的virtual去掉也是可以的 

用基类的指针去调用也是可以的: 

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

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

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

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

用基类的对象是不行的: 

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

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

void Func(Person p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

没有报错,但是并没有实现多态。 

虚函数重写的两个例外 

析构函数的重写 

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

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

int main()
{
	Person p;
	Student s;
	return 0;
}

这可以理解:

子类对象s先析构:子类的析构函数调完后自动调用父类的析构清理父类的部分, 然后基类对象p再调用自己的析构

但如果这样: 

int main()
{
	Person* p = new Person;
	Person* s = new Student;
	delete p;
	delete s;
	return 0;
}

注意两个指针都是Person*,一个指向父类对象,一个指向子类对象

这次的析构调用就出现了问题:
s指向的是Student子类对象,但是析构的时候只调了父类的析构函数, 如果子类自己的成员存在资源管理, 那只调父类析构的话, 就只清理了子类里面父类的部分, 那是不是就内存泄漏了啊.

为什么是这样的结果呢?

对于自定义类型delete会做的是:
1. 在要释放的对象空间上执行析构函数,完成对象中资源的清理工作
2. 调用operator delete函数,operator delete实际调free释放对象的空间
那它这里第一步执行析构时它这个指针是哪个类型, 它执行的就是哪个类的析构, 所以这里delete s只会执行父类的析构.

但是我们期望它只是调父类的析构吗?

不期望,因为如果父类的指针指向的是子类对象,在delete的时候还是只调父类的析构,就可能会有内存泄漏的风险。我们期望它按照指向的对象的类型去调, 指向的是父类对象, 就按父类的析构去走, 指向的是子类的对象, 就按子类的析构去走。

那我们可以怎么做?

多态就可以上场了,把父类的析构变成虚函数,然后子类重写就行了啊。因为现在的情况就是基类的指针, delete的时候又会自动调用析构

析构正常调用了 

注意:重写不是要求函数原型相同吗吗,但这里基类和派生类的析构函数名不一样,为什么可以重写虚函数呢?

编译器会对析构函数名进行特殊处理,都会被处理成destrutor(), 那其实就是为这种场景做准备的!只有派生类Student的析构函数重写了Person的析构函数, delete对象调用析构函数, 才能构成多态, 以保证p1和p2指向的对象正确的调用析构函数。 

所以:

虽然函数名不相同, 看似违背了重写的规则, 其实不然, 这里编译器对析构函数的名称做了特殊处理, 编译后析构函数的名称统一处理成destructor。

协变

虚函数重写的第二个例外:

派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。

但是是有要求的:

基类虚函数必须返回基类对象的指针或引用, 派生类虚函数必须返回派生类对象的指针或引用,我们把这种情况称为协变

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

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

	}
};

void Func(Person& p)
{
	p.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 new A;
	}
};

class Student : public Person 
{
public:
	virtual B* BuyTicket() 
	{ 
		cout << "买票-半价" << endl; 
		return new B;

	}
};

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

int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	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;
 }

 首先,父类里面有两个虚函数:func和test,但是子类只对func进行了重写。
1.然后main函数里用一个子类对象的指针去调用从父类继承过来的test函数,这里调到的就是子类继承下来的那个test函数。
2.在test函数里面, 又去调了func函数。
问题: test函数里面调用func,是否构成多态?
那我们就看它是否满足多态的两个条件:

第一个条件:首先虚函数的重写, 这里是满足的, 子类对父类的虚函数func进行了重写。
第二个条件: 必须是基类的指针或引用去调用,也是满足的。
虽然子类的test是继承下来的, 但是继承下来test函数中this指针的类型是不会变的, 还是父类指针A*(继承下来函数的参数类型是不会变的), 这里相当于把子类对象p的指针赋给了父类的指针(this指针),然后,通过这个父类的指针去调用被重写的虚函数func。所以这里是满足多态的。
那这里调到的func就是子类对象p对应的func, 看上去结果应该是B->0.

但答案却是B->1,

因为虚函数的重写只是重写了函数的实现,而继承了接口, 所以父类Func中给val的缺省值1也继承了下来, 所以结果是B->1.

变个型 

如果把test放到B里面,再去调用:

这种情况下没有构成多态, 因此此时test只属于B,所以test的this指针是B*的,这次是子类的指针去调用func的, 所以没有构成多态. 那没有构成多态, 这里就是隐藏, 会优先调用子类里的同名函数, 缺省值就是自己里面的0.
因此结果是B->0


接口继承和实现继承

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

C++11 override 和 final 

final

首先我们来看final关键字,这个关键字有什么作用呢?

1.如果想设计一个不能被继承的类, 就可以用final。即用final修饰一个类, 可使该类变为最终类, 即不能被继承的类.

2.final修饰一个虚函数, 该虚函数将不能再被重写 

override 

再看一个关键字override,它有什么作用呢?

override:检查派生类是否对基类的虚函数进行了重写, 如果没有重写编译报错。

这里虚函数是重写了的, 所以没报错。
我们知道虚函数重写的话基类是必须加virtual的, 子类可以不加, 但建议加上.

如果把父类的virtual去掉就会报错:

 重写不正确也会检查出来:


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

可以看到重写和重定义的起名很有讲究, 重写就是如果构成多态, 派生类的虚函数只重写基类虚函数的函数体部分, 继承了接口; 而重定义则是重新定义了一个函数, 函数原型(除了函数名)和函数体都可以改变. 


抽象类 

在虚函数的后面写上 =0 , 则这个函数为纯虚函数(不需要函数体), 至少包含一个纯虚函数的类叫做抽象类.(这里的包含是只要包含就是抽象类,哪怕类中还有其它的成员)

1.抽象类不能实例化出对象, 但是可以定义抽象类的指针或引用来实现多态. 

2. 抽象类是可以定义普通成员函数和虚函数的.
3. 派生类继承抽象类后也不能实例化出对象, 只有重写纯虚函数, 派生类才能实例化出对象.

纯虚函数规范了派生类必须重写(不重写就不能实例化), 另外纯虚函数更体现出了接口继承.

4. 派生类的基类不能是普通类. 

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

这里的Car这个类就是一个抽象类, 因为它包含纯虚函数, 所以,Car这个类不能实例化出对象

如何理解抽象类?

它定义了一组方法和行为, 但并没有具体的实现细节, 可以把它看作是一个规范, 告诉其他类应该有哪些方法,并且如何使用这些方法。

举个例子来说明抽象类的概念:

假设我们有一个抽象类叫做"动物",其中有一个纯虚函数"发出声音"。我们知道每种动物都会发出声音,但是具体的声音是不同的。那么我们可以定义一个"狗"类和一个"猫"类,它们都继承自"动物"类,并实现了"发出声音"方法。这样,无论我们有一只狗还是一只猫,我们都可以使用"动物"类的指针或引用来调用"发出声音"方法,而不需要关心具体是哪种动物。
可以认为如果一个类在现实中没有对应的实体,我们就可以把它定义成一个抽象类。

派生类只有重写纯虚函数, 才能实例化出对象:

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

void Test()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
}

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

 重写之后, 它就不包含纯虚函数了, 所以他就不是抽象类, 那就可以实例化出对象了.


多态的原理 

虚函数表指针与虚函数表

先看一段程序:

class Base
{
public:
     virtual void Func1()
     {
         cout << "Func1()" << endl;
     }
private:
     int _b = 1;
     char _ch;
};

之前在类和对象中提到计算一个类的大小其实只考虑成员变量就行, 因为类对象中只存储成员变量, 成员函数是放在公共的代码段的, 那按照内存对齐规则,Base就应该是8个字节.

结果:

为什么是12呢?

我们可以通过监视窗口观察一下Base都有哪些成员:

我们看到除了两个成员变量之外,还有一个_vfptr。 

这个_vfptr是什么?

他其实是一个指针, 它存放在对象的最前面(注意有些平台可能会放到对象的最后面, 这个跟平台有关), 对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function),简称虚表指针.

为什么有虚表指针? 

是因为我们的Base类里面有虚函数, 含有虚函数的类里面就会有虚函数表指针.

回顾我们菱形继承里面用到的虚拟继承, 也是用的关键字virtual, 它也是会给对象里面增加一个指针, 那个指针叫做虚基表指针, 它指向一张表(虚基表), 里面存的是偏移量, 用来去寻找公共的基类成员.

那这里的虚函数表指针也指向一张表, 即虚函数表(简称虚表), 虚函数表里面存的是虚函数的地址.

虚函数表其实就是一个函数指针数组, 存放虚函数的地址, 所以虚函数指针_vfptr其实就是一个数组指针(指向函数指针数组的指针). 因为Base里面现在只有一个虚函数, 所以我们看到它里面现在只有一个元素, 就是虚函数Func1的地址.

为什么要在对象里搞一个虚函数指针, 去指向一个虚函数表, 表里面存放虚函数的地址呢, 为什么不直接把虚函数地址放在对象里面虚函数指针的位置? 

因为虚函数可能会有多个, 所以虚函数表里面可能会放很多个虚函数地址,  而且同一个类实例化出来多个对象, 它们是共用一张虚函数表的, 如果每个对象里面都放一张虚函数表, 是不是有点浪费啊.

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}	virtual void Func3()
	{
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
	char _ch;
};

int main()
{
	cout << sizeof(Base) << endl;
	Base b1;
	Base b2;
	Base b3;
	return 0;
}

子类的虚函数表(单继承) 

上面我们只有一个类, 那如果是在继承体系中(当然是在多态的情况下, 子类继承父类或重写父类的虚函数, 这时子类才会有虚函数表), 子类的虚函数表是怎么生成的?

首先大家思考一个问题, 虚函数表会被继承吗?

会继承的, 因为子类会继承父类的虚函数, 那有了虚函数就会有虚函数指针, 那就会有虚函数表.

我们可以来看一下:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
private:
	int _b = 1;
	char _ch;
};

class Derive :public Base
{};
int main()
{
	cout << sizeof(Base) << endl;
	Base b;
	Derive d;
	return 0;
}


 我们发现子类虽然什么也没写, 但是它里面有自己的虚函数指针(和父类的是相互独立的,看到它们地址是不一样的), 并且我们看到子类虚表里面内容和父类是一样的。
所以可以认为子类会继承父类的虚表, 子类的虚表是父类虚表的拷贝.

现在在Derive中重写Func1:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
private:
	int _b = 1;
	char _ch;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

 然后我们来观察对比一下子类和父类的虚函数表:

首先现在父类里面两个虚函数, 那子类继承父类的虚表, 另外他没有自己增加虚函数(子类只是重写了其中一个虚函数Func1), 所以子类虚表里面也是两个虚函数的地址.
但是它们表里面第一个函数地址是不一样的。因为但是现在子类重写了父类的虚函数,那就会在虚表里覆盖上重写之后的虚函数地址。所以,当时提到重写的时候我们说还可以叫覆盖。其实覆盖就是指这里的虚函数地址的覆盖, 重写是语法层的叫法,而覆盖是原理层的叫法。

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

先将基类中的虚表内容拷贝一份到派生类虚表中, 如果派生类重写了基类中某个虚函数, 则用派生类自己重写后的虚函数的地址覆盖虚表中原来存的继承下来的基类的虚函数地址.
派生类自己新增加的虚函数的地址按其在派生类中的声明次序增加到派生类虚表的最后, 但这个在监视窗口可能看不到.


多态是如何实现的

了解了上面的内容, 那多态到底是怎么实现的呢?它底层的原理是怎么样的?

相信大家看这张图就应该明白怎么回事了.
如果实现多态的话, 去调用的时候和引用或指针的类型有关吗?
无关, 而是跟它指向的对象的具体类型有关, 它指向的对象是什么类型的, 就去调该类对应的虚函数, 从而就实现了多态——不同的对象调用同一个函数, 产生不同的结果

所以它这里是怎么实现的? 

其实就是通过对象里的虚函数指针, 去找到其对应的虚函数表, 那子类对象的虚指针就指向子类的虚函数表, 父类对象的虚指针就指向父类的虚函数表, 那这样它们就能调到不同的虚函数, 进而实现多态(不同对象去完成同一行为时,展现出不同的形态)。 

多态调用与非多态调用的区别

那对于编译器来说, 多态调用和非多态调用有什么不同呢?

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

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

	}
};

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

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

 首先来看非多态调用的:

那不是多态的话其实就是普通函数的调用了,它在编译期间就可以根据这里引用或指针的类型确定要调用那个函数 

观察一下多态调用时的汇编: 

汇编我们可能看不太懂。但是我们知道它这里做的事情其实就是通过对象前4/8个字节里面存的虚函数指针去找到虚函数表,然后调用对应的虚函数。所以多态时的函数调用就不是在编译期间就能确定要调用的函数了, 而是在运行期间通对象里的虚函数指针找到虚表, 然后确定要调用的具体函数。 

那现在我们回过头来看多态的两个条件?为什么是它们两个?

1.首先是虚函数重写, 为什么要有虚函数的重写?
因为只有子类对父类的虚函数进行了重写, 子类的虚函数表里面才会有自己重写后的地址, 这样通过对象找到虚表的时候才能调到不同的函数,表面上子类对象和父类对象调的是同一个函数,但实际父类对象调父类的虚函数,子类对象调自己重写之后的虚函数,进而实现多态。

2.那第二个为什么必须是父类的指针或引用去调用虚函数呢?
因为父类的指针和引用是不是既可以指向子类对象,也可以指向父类对象啊,我们之前学过,它支持赋值转换(切片)。

为什么子类对象赋值给父类的对象不能实现多态

父类的对象也支持把子类的对象赋给它, 那为什么父类的对象去调用虚函数不能实现多态呢?而只能说父类的指针或引用去调用才可以实现多态?

我们说指针和引用的切片是不是可以理解成:

子类对象的地址赋给父类的指针,就可以认为是把子类对象中切出来的父类的那一部分的地址赋给父类的指针,子类对象赋给父类对象的引用就可以认为是给子类对象中切出来的父类的那一部分起一个别名。那这样的话,父类的指针和引用指向的是不是还是一个子类对象啊,只不过可能是子类对象的一部分, 那它通过虚表指针找到的不就还是子类对象的虚函数表, 那就可以实现多态啊。

那如果是子类对象赋给父类对象呢?

我们还可以按照切片理解,但是我们是不是说了把子类对象赋给父类对象其实是调用父类的拷贝构造完成的, 那这里就涉及一个问题:子类对象的虚表会不会拷贝过去
如果没有拷贝虚表,那父类对象的虚表就还是自己的,那就不能实现多态了。
如果敢拷贝的话,就可以实现多态。
 

子类的虚表会拷贝吗?拷贝过去可行吗?

结论是将子类对象赋给父类对象时,并不会拷贝虚函数表,父类的虚表是不会变的,也因此父类的对象不能实现多态。
因为一个父类对象的虚表肯定就是父类自己的虚表, 如果它里面的虚表指针指向子类的虚表,这就乱套了, 这是不合理的。子类对象里面虚表指针就指向子类的虚表, 父类对象里面虚表指针就指向父类的虚表, 这样才是合理的.


进一步理解虚函数

虚表是什么时候生成的?

虚表什么时候生成?

虚表是由编译器在编译阶段生成的, 因为编译过程中的汇编阶段会生成符号表, 此时就可以确定函数的地址, 那就可以生成虚函数表了。

虚函数是存在哪里的?

注意虚函数不是存在虚函数表里的:

虚函数和普通函数一样的,都是存在代码段的。只是虚函数的地址会被放进虚函数表里面,另外注意虚函数表不是放在对象中的,对象中放的是虚函数表指针。

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

void func()
{
	cout << "void func()" << endl;
}

int main()
{
	Base b1;
	Base b2;

	static int a = 0;
	int b = 0;
	int* p1 = new int;
	const char* p2 = "hello world";
	printf("静态区:%p\n", &a);
	printf("栈:%p\n", &b);
	printf("堆:%p\n", p1);
	printf("代码段:%p\n", p2);
	printf("虚表:%p\n", *((int*)&b1));//对象的前4/8个字节是虚表指针, 也就是虚表的地址
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", func);

	return 0;
}

可以看到虚函数表,虚函数都是存放在代码段的,虚函数表和常量区更接近一些,一般情况下,虚函数表通常被放置在代码段或常量区(只读数据段)中。这是因为虚函数表在运行时是只读的,不会被修改。当然不同的编译器或平台也可能会不同。

子类新增的虚函数地址是否进虚表

那如果是子类自己定义的虚函数(不是重写父类的),那么它的地址会进虚表吗?
这也是我们上面遗留的一个问题。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
private:
	int _b = 1;
	char _ch;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

在上面那个代码里给Drive类里面再增加一个新的虚函数func3,但这个虚函数并不是对父类虚函数的重写 .

 我们通过监视窗口看到myfunc的地址好像没有进虚表。但是我们说过,监视窗口看到的并不一定是真实的,有可能被处理过。

所以我们再通过内存窗口看一下:

我们看到前两个地址和监视窗口显示的是一样的,第三个地址和前两个很相近,所以猜测第三个就是myfunc的地址。
这里如果我们直接打印虚函数地址去验证的话,可能会发现打印出来的跟虚表里的地址不一样,可以理解为虚函数表内的地址是虚函数实际地址的一种间接表示形式,这可能与C++中的多态性、动态绑定和继承机制所导致的。(后面解释)

打印虚函数表的程序 

所以, 我们可以写一个打印虚函数表的程序验证一下。

虚表其实是一个函数指针数组, 那我们拿到这个数组打印里面的指针就行了, 
首先把虚函数对应的函数指针类型typedef, 方便演示和理解:typedef void(*VF_PTR)(); 然后按照打印数组的方式用循环去打印即可.

 怎么判断循环结束呢?
在vs系列的编译器上,它在虚表的最后放了一个空指针(nullptr),所以:

typedef void (*VFUNC)();

void PrintVFT(VFUNC* table)
{
	for (size_t i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p", i, table[i]);
	}
}

 那我们怎么调用它呢?对象里面的虚函数表指针怎么拿到?

取对象的前4/8个字节的内容即可, 这里我们把对象的地址强转成int*, 然后解引用, 就拿到前四个字节的内容, 但是int*解引用是个int, 所以再强制类型转换为VFUNC*

int main()
{
	//Base b;
	Derive d;
	PrintVFT((VFUNC*)(*(int*)&d));
	return 0;
}

 

可以看到Derive类对象里面是有三个虚函数地址的, 而正好也打印出来这三个地址

也可以调用一下函数, 因为每个虚函数都加了一些打印信息

void PrintVFT(VFUNC* table)
{
	for (size_t i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p->", i, table[i]);
		table[i]();
	}
}

所以第三个就是虚函数func3的地址,这证实了我们上面的猜想, 子类新增的虚函数也会进虚表. 派生类自己新增加的虚函数的地址按其在派生类中的声明次序增加到派生类虚表的最后,但这个在监视窗口可能看不到。 

 对象中的虚表指针什么时候初始化的?

 虚表指针其实是在构造函数的初始化列表初始化的.


静态多态和动态多态

静态多态(编译时多态、早期绑定/静态绑定):

静态多态是指在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载、模板

动态多态(运行时多态、晚期绑定/动态绑定):

动态多态是指是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

 多继承中的虚函数表

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

 有两个基类Base1, Base2, Derive继承了Base1和Base2, Base1和Base2里面都有两个虚函数func1、func2, Derive重写了func1, 自己增加了一个func3.

那在这样一个多继承的体系中, 子类的虚表又是怎么样的呢?

多继承中子类几张虚表?

有两张虚表, Derive重写了func1, 所以我们看到表里面进行了覆盖(两张表都覆盖了),func2是继承下来的,没有重写。

子类新增的虚函数放在哪张虚表?

通过上面的学习我们知道子类自己增加的虚函数也会进函数表的,不过监视窗口看不到,那对于当前的继承体系来说,子类增加的虚函数func3会放在那一张虚表里呢?

我们可以把两张虚表打印出来看一看, 我们只需要拿到两个虚表指针即可, 第一个虚表指针肯定在对象的前4个字节, 那第二个应该在子类对象里面第二个父类部分的前4个字节.

 

int main()
{
	Derive d;
	PrintVFT((VFUNC*)(*(int*)&d));

    cout << endl;

	//PrintVFT((VFUNC*)(*(int*)((char*)&d+sizeof(Base1))));
	Base2* ptr = &d;
	PrintVFT((VFUNC*)(*(int*)ptr));

	return 0;
}

 可以直接指针偏移sizeof(Base1)个字节,也可以直接定义一个指针指向第二个父类

所以我们看到func3放的第一个虚表里面了, 即多继承中派生类自己新增的虚函数放在第一个继承基类部分的虚函数表中.

子类重写的虚函数,为何在两张表的地址不同? 

观察上面打印出来的地址, 我们对func1重写了,但它在两个虚表里覆盖的地址却不一样

int main()
{
	Derive d;
	Base1* p1 = &d;
	p1->func1();

	Base2* p2 = &d;
	p2->func1();

	return 0;
}

 大家看,这两个函数调用调的是同一个func1()吗?

是,因为这里是满足多态的, func1完成了重写且是父类的指针去调用, 所以这里调用的都是子类重写的func1函数. 但是这里父类的指针一个是Base1*的,一个是Base2*的,, 所以它们应该一个去找第一个虚表的func1,另一个找第二个虚表里面的。
当然它们调的还是同一个func1, 因为子类重写后在两张表里都进行了覆盖。

但是为什么两张表里面func1的地址不一样呢? 

通过汇编我们可以看出来它们一开始call的地址是不一样的, 但是最终还是调到了同一个函数。可以理解成目的地是一样的,只是走的路线不同, 是殊途同归.
p1调用的过程其实是比较正常的一个函数调用的过程,但是p2好像多走了几步 

p2多了一个sub和两次jmp的操作,那它为什么要这样呢,为啥要绕绕路呢?

其实这里面比较关键的一步是sub这条指令, ptr1那边是没有这个的, 那它是什么作用呢?
它是对ecx进行操作的, 这里的ecx里面存的是什么?
其实之前提过,调用成员函数的时候,vs上面会把this指针存到ecx寄存器里面

调用成员函数的时候this存的是谁的地址?

调用函数的对象的地址, 所以这里的this指针指向子类对象的起始地址,
p1是Base1*类型, 刚好就指向子类对象的起始地址, 所以它可以直接去正常的去调用;

而p2是Base2*类型的地址, 指向子类对象中Base2部分的起始位置, 所以要对p2的指向进行修正, 而上面的sub ecx ,8这句汇编其实就是在修正this指针的位置。
sub这个指令用于执行减法操作, 所以它的意思是给this指针-8, 而p2现在是指向Base2, 它前面有一个base1, Base1这个类的大小刚好就是8, 这样一减, 刚好就指向子类对象的起始位置了, 所以当前减这个值跟Base1的大小有关系, 如果先继承Base2, 那就是p1需要修正了.

调试可以发现之前打印虚表打印出来的两个func1的地址分别是两次call的eax中的地址, 这也就说明调用虚函数不能直接调到func1第一个push的地址上, 实际调用的是对它的一个封装, 如果直接调用push地址, 编译器也就没有机会对刚才this指针进行修正, Base2调用的func1中的this指针就错了.

菱形虚拟继承下一些情况(比较复杂,仅了解) 

大家还记不记得在之前菱形虚拟继承那篇文章, 我们遗留一个问题:

就是我们当时看那个虚基表, 里面的偏移量是放在虚基表的第二个位置的, 第一个位置空了出来

我们当时说第一个位置空出来是跟多态有关系的, 那现在解释一下。 

把当时那个菱形继承的代码拿过来,给他们加一些虚函数, 现在A里面有一个虚函数func1,BC都对它进行了重写,D没有重写

class A
{
public:
    virtual void func1()
	{}
	int _a;
};
 
class B : public A
{
public:
    virtual void func1()
	{}
	int _b;
};
 
class C : public A
{
public:
    virtual void func1()
	{}
	int _c;
};
 
class D : public B, public C
{
public:
	int _d;
};

那现在这种情况, 如果没有虚继承的话, BC里面都有一个继承A的虚表, B重写会覆盖自己里面继承A的虚表, C重写也会

 现在改成菱形虚拟继承:

class A
{
public:
	virtual void func1()
	{}
	int _a;
};

class B : virtual public A
{
public:
	virtual void func1()
	{}
	int _b;
};

class C : virtual public A
{
public:
	virtual void func1()
	{}
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

 我们之前讲过这个继承体系的对象模型:

原本BC里面都有一个成员变量_a, 但是虚继承后只保留一份_a, 放在公共的区域, 然后原本BC里面放_a的位置变成一个虚基表指针, 指向虚基表, 虚基表里存的是偏移量,通过偏移量可以找到公共的_a.

虚表也是一样, 现在虚继承之后虚表只能有一份, 继承下来的A的虚表里面放B重写的fun1函数地址还是C重写的fun1函数地址?D最后对于父类的继承是不明确的:

既然不明确,那D就自己重写一份func1

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

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B:func1()" << endl;
	}
	int _b;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C:func1()"<<endl;
	}
	int _c;
};

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
	int _d;
};

 我们看到此时看起来虽然有三张虚表, 但是它们的地址是一样的, 里面存的虚函数地址也是一样的, 都是D重写的那个, 所以可以认为只有一张虚表.

给BC里面分别再增加一个虚函数, 其他地方不变: 

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

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

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

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
	int _d;
};

此时有三张虚表, D重写的fun1函数放在公共的A的虚表里面, 由于BC自己新增了虚函数, 所以BC又各自开辟了一张虚表, 新增的虚函数就各自放在了BC的虚表里面了. 

现在来解释一下虚基表里第一块空间到底是用来存放什么的: 

 

虚基类表中1-4字节记录的是对象首地址虚基表指针偏移量, 因为正常情况下虚基表指针是存放在对象的前四个字节的, 而由于B和C又各自新增了虚函数表占据了前四个字节的位置, 所以虚基表就存放对象首地址和虚基表指针的偏移量去寻找虚基表, 

再来讨论一下子类D如果新增虚函数, 新增虚函数地址会存放在谁的虚基表里? 

再修改代码, D中增加一个func3虚函数:

class A
{
public:
	virtual void func1()
	{
		cout << "A:func1()" << endl;
	}
	int _a = 1;
};

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B:func1()" << endl;
	}
	virtual void func2()
	{
		cout << "B:func2()" << endl;
	}
	int _b = 2;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C:func1()"<<endl;
	}
	virtual void func2()
	{
		cout << "C:func2()" << endl;
	}
	int _c = 3;
};

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
	virtual void func3()
	{
		cout << "D:func3()" << endl;
	}
	int _d = 4;
};

int main()
{
	D d;
	d.func3();
	return 0;
}

上面说过子类新增的虚函数会放在第一个父类的虚函数表里, 而且在监视窗口里看不到,所以可以再看一次内存: 

再把B中的func2函数去掉, 去掉B中的func2函数是为了让B不生成虚表, 这样的话子类新增的虚函数func3函数会存到哪里呢? 

class A
{
public:
	virtual void func1()
	{
		cout << "A:func1()" << endl;
	}
	int _a = 1;
};

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B:func1()" << endl;
	}
	//virtual void func2()
	//{
	//	cout << "B:func2()" << endl;
	//}
	int _b = 2;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C:func1()"<<endl;
	}
	virtual void func2()
	{
		cout << "C:func2()" << endl;
	}
	int _c = 3;
};

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
	virtual void func3()
	{
		cout << "D:func3()" << endl;
	}
	int _d = 4;
};

发现存到了C的虚表中, 子类新增的虚函数本应该存在继承的第一个父类B中, 但B中没有虚表, 所以存到了C的虚表中

再来修改代码,这次把C中的func2函数也去掉:

class A
{
public:
	virtual void func1()
	{
		cout << "A:func1()" << endl;
	}
	int _a = 1;
};

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B:func1()" << endl;
	}
	/*virtual void func2()
	{
		cout << "B:func2()" << endl;
	}*/
	int _b = 2;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C:func1()"<<endl;
	}
	/*virtual void func2()
	{
		cout << "C:func2()" << endl;
	}*/
	int _c = 3;
};

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
	virtual void func3()
	{
		cout << "D:func3()" << endl;
	}
	int _d = 4;
};

把C中的func2函数去掉, 也就是说C中也没有虚表了, 那子类的新增的虚函数放在哪里呢?

可以看到d中新开辟了一个虚表, 里面专门存放新增的虚函数的地址, 因为父类都没有生成虚表. 

 实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。


关于继承和多态的几个问题

1. 选择题

1. 下面哪种面向对象的方法可以让你变得富有( )
A: 继承 B: 封装 C: 多态 D: 抽象

2. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,
而对方法的调用则可以关联于具体的对象。
A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定

3. 面向对象设计中的继承和组合,下面说法错误的是?()
A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复
用,也称为白盒复用
B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动
态复用,也称为黑盒复用
C:优先使用继承,而不是组合,是面向对象设计的第二原则
D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封
装性的表现

4. 以下关于纯虚函数的说法,正确的是( )
A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数

5. 关于虚函数的描述正确的是( )
A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数
C:派生类必须重新定义基类的虚函数 D:虚函数可以是一个static型的函数

6. 关于虚表说法正确的是( )
A:一个类只能有一张虚表
B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C:虚表是在运行期间动态生成的
D:一个类的不同对象共享该类的虚表

7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
B:A类对象和B类对象前4个字节存储的都是虚基表的地址
C:A类对象和B类对象前4个字节存储的虚表地址相同
D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表

参考答案:
 1. A  2. D  3. C  4. A  5. B  6. D  7. D  

2. 问答题

2.1 什么是多态?

那这个问题其实在前面的文章里都有详细的讲解,这里就不展开说了,大家参考之前的文章。

2.2 什么是重载、重写(覆盖)、重定义(隐藏)?

参考之前的文章。

2.3 多态的实现原理?

参考之前的文章。

2.4 inline函数可以是虚函数吗?

我们先来回顾一下,什么是内联函数?

以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,用函数体替换函数的调用,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
那这样的话,如果一个内联函数真的被处理成内联函数的话,它是没有地址的,那没有地址的函数能是虚函数吗?
肯定是不行的,因为虚函数必须有地址,而且地址要放进虚函数表里面。
那这样来看,内联函数好像不能是虚函数。

但是:

inline修饰的函数不一定会被当成内联处理,因为内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。

 所以:

语法上可以,但是编译器会忽略inline这个属性,这个函数本质就不再是inline,因为虚函数要产生地址放到虚表中去。

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

 

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

不能,因为对象中的虚函数表指针在构造函数初始化列表阶段才初始化的。

2.7 析构函数可以是虚函数吗?什么场景下析构函数要搞成虚函数?

析构函数是可以搞成虚函数的,并且有些场景下还需要搞成虚函数,这个是我们讲的虚函数重写的两个例外之一,可以回看。

2.8 对象调普通函数快还是虚函数更快?

我们可能会觉得调普通函数快,因为调普通函数不需要像调虚函数那样还得通过虚指针去虚函数表里面找。

但是呢,这样说不准确,我问大家,调用虚函数一定要去虚表里面找吗?

要比较速度的话,要去看情况:
1.首先如果是普通对象去调用,是一样快的(普通对象调用不可能构成多态,和普通函数一样去调用)


2.如果是指针或引用去调用,则调用的普通函数比虚函数快,因为构成多态(即使不满足其它多态的条件,只要是指针或引用调用,他这里统一处理),运行时调用虚函数需要到虚函数表中去查找。

2.9 虚函数表是在什么阶段生成的,存在哪的?

2.10 C++菱形继承的问题?虚继承的原理?

2.11 什么是抽象类?抽象类的作用?

这三个问题之前提过

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值