本文我们将学习面向对象三大特性中的最后一个特性 —— 多态。需要声明的是,多态一文包括继承一文中所演示的测试用例都是按我的编译器 VS2017 来演示的,我们会演示底层细节,如果你换一个编译器,包括 VS 系列,可能展示出来的效果会有一点变动,因为 C++ 并没有规定具体的实现细节。
顾名思义,多态就是多种形态,具体就是去完成某种行为时,不同的对象去完成时会产生不同的状态。

举个粟子,比如买票这种行为,当普通人去买票时,是全价买票;当学生去买票时,是半价买票;当军人买票时,是优先买票。
再举个粟子,为了争夺在线支持市场,某软件会经常做一些扫码领红包的活动,其中我们会发现,有的人扫到了七八块,有的人扫到了七八角等,其实这背后就是一个多态的行为,它分析你的帐户数据,比如你是第一次扫码,给你 rand() % 99 块、第二次扫码 rand() % 10 块,第三次扫码 rand() % 1 角 … …,同样都是扫码动作,不同的用户扫到的红包也不一样,本质也是一种多态行为。
✔ 测试用例一:
#include
#include
using namespace std;
class Person
{
public:
void BuyTicket()
{
cout << "正常排队-全价买票" << endl;
}
protected:
int _age;
string _name;
};
class Student : public Person
{
public:
void BuyTicket()
{
cout << "正常排队-半价买票" << endl;
}
protected:
//...
};
class Soldier : public Person
{
public:
void BuyTicket()
{
cout << "优先排队-全价买票" << endl;
}
protected:
//...
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier sd;
Func(&ps);
Func(&st);
Func(&sd);
return 0;
}
怎么让不同的对象都能传给Person* ptr呢 —— 切片,让 Studnet 和 Soldier 继承 Person,但是这里继承之后,基类和派生类中都有 BuyTicket(),那么派生类就会对基类的 BuyTicket() 隐藏。运行程序,可以看到并没有实现多态,都去调用了基类的,这是因为多态的构成需要满足两个条件。

✔ 测试用例二:
#include
#include
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "正常排队-全价买票" << endl;
}
protected:
int _age;
string _name;
};
class Student : public Person
{
public:
virtual void BuyTicket()//重写或覆盖父类的虚函数
{
cout << "正常排队-半价买票" << endl;
}
protected:
//...
};
class Soldier : public Person
{
public:
virtual void BuyTicket()//重写或覆盖父类的虚函数
{
cout << "优先排队-全价买票" << endl;
}
protected:
//...
};
void Func(Person* ptr)//指针
{
//多态 - ptr指向父类对象,调用父类的虚函数;指向子类对象,调用子类的虚函数
ptr->BuyTicket();
}
void Func(Person& ptr)//引用
{
//多态 - ptr指向父类对象,调用父类的虚函数;指向子类对象,调用子类的虚函数
ptr.BuyTicket();
}
//void Func(Person ptr)//对象
//{
// ptr.BuyTicket();
//}
int main()
{
Person ps;
Student st;
Soldier sd;
Func(&ps);
Func(&st);
Func(&sd);
Func(ps);
Func(st);
Func(sd);
return 0;
}
在继承中要构成多态还有两个条件:a) 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写或覆盖,在继承中我们称这里为隐藏或重定义,注意区分;b) 必须通过基类的指针或者引用调用虚函数,这个条件在测试用例一中就满足了。
此时就完成了多态,如果没有多态的语法,按以前的理解,这就是调用 Person* 类型的。这里说明以前是跟类型有关,现在是跟对象有关,多态就是让调用跟对象有关,不同的对象去做同一件事,达到的行为是不一样的。

注意切片切的是成员变量,与成员函数没有关系,并且这块要实现指向谁调用谁跟切片没有关系,切片只是让父类的指针可以指向子类对象或父类对象,指向子类对象就意味着看到子类对象的那一部分。
多态的两个条件缺一不可,这里可以看到通过基类的对象调用虚函数就不构成多态了,这个问题我们只能先放着,因为这必须了解多态的底层原理才能知晓。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
//重写(覆盖)
class Person
{
public:
virtual void BuyTicket()
{
cout << "正常排队-全价买票" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "正常排队-半价买票" << endl;
}
};
//隐藏(重定义)
class A
{
public:
void fun()
{
cout << "fun()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "fun(int i)" << endl;
}
};
构成多态的条件之一是虚函数的重写,而虚函数也有自己的规则,虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数,即派生类虚函数和基类虚函数的返回值类型、函数名、参数列表完全相同,就称子类的虚函数重写了基类的虚函数。
注意区分隐藏的概念,隐藏是只要基类函数名和派生类函数名相同即是隐藏或重定义。
虚函数要求三同,但是这三同有些例外,这就恶心了,具体例外看测试用例三四五。
✔ 测试用例三:
#include
using namespace std;
//class A {};//AB为无关联的类
//class B {};
class A {};//AB为关联的父子类
class B : public A {};
class Person
{
public:
virtual A* BuyTicket()
{
cout << "正常排队-全价买票" << endl;
return new A;
}
protected:
int _age;
string _name;
};
class Student : public Person
{
public:
virtual B* BuyTicket()
{
cout << "正常排队-半价买票" << endl;
return new B;
}
protected:
//...
};
void Func(Person& ptr)
{
ptr.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
协变 (基类与派生类虚函数返回值类型不同),即重写的虚函数可以不同,但是返回值必须是父子类型指针或引用。
如果返回值是普通没有关联的类,那么它既不满足三同、也不满足协变,会编译报错。

如果返回值是有关联的父子类,那么虽然它不满足三同,但是它满足协变这个例外,所以能构成多态。

✔ 测试用例四:
#include
using namespace std;
class Person
{
public:
//~Person()
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
//~Student()
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
//普通场景
Person p;
Student s;
//new对象的特殊场景
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;//p1->destructor() + operator delete(p1)
delete p2;//p2->destructor() + operator delete(p2)
return 0;
}
析构函数的重写 (基类与派生类析构函数的名字不同)
如果是普通的析构, 程序运行没有问题,这里生命周期结束,s 后定义,s 先析构,s 中分为为两个部分,先调用自己的析构,再去调用继承的父类的析构,随后再去调用 p 的析构;如果是虚函数的析构,可以看到结果同普通的析构。

虚函数的析构有什么意义 ❓
普通场景下,虚函数是否重写都是 ok 的;new 对象的特殊场景下,Person 的指针 p1 指向 Person 的对象、Person 的指针 p2 指向 Student 的对象、delete Person 的对象、delete Student 的对象。这里 new Person 调用 Person 的构造函数、new Student 调用 Studnet 的构造函数 + Person 的构造函数都没有问题;这里 delete p1 期望的是 delete 调用 Person 的析构函数、delete p2 调用 Student 的析构函数 + Person 的析构函数,但是在继承中我们说过,在子类中要去显示的调用父类的析构函数,需要指定作用域,因为所有类的析构函数名都被处理成了 destructor(),所以子类和父类的析构函数构成隐藏关系。为什么它要对析构函数名作单独处理呢,因为如果这里不构成多态,调用时看的是指针的类型,那么这里 p1 和 p2 调用的都是 Person 的析构函数,此时就不对了。p1 没问题,但是 p2 指向的是一个子类对象,子类对象应该先调用子类的析构函数,再去调用父类的析构函数,万一子类对象中又去 delete,那么 Student 的析构函数没调到就有可能会出现资源泄漏。

所以这里 delete p1/p2 是想达到多态的场景,Person* 指向父类调父类,指向子类调用子类,上面已经满足多态的条件之一,通过基类的指针或者引用调用虚函数;但是并没有满足多态的条件之二,被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写;要完成虚函数的重写有两个条件:它们必须是虚函数以及三同,析构函数没有返回值,也就不考虑协变了。这里的两个析构函数没有返回值、参数,函数名不相同,因为在这种场景下需要多态,所以编译器对它们进行了特殊处理,统一成 destructor(),所以这里我们对于这种场景是需要加上 virtual 的,所以 delete p1 指向父类,调用父类的虚函数,delete p2 指向子类,调用子类的虚函数,子类析构函数结束后,再调用父类的虚函数。

✔ 测试用例五:
#include
#include
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "正常排队-全价买票" << endl;
}
protected:
int _age;
string _name;
};
class Student : public Person
{
public:
void BuyTicket()//可以不加virtual
{
cout << "正常排队-半价买票" << endl;
}
protected:
//...
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
其实严格来说这里还有一个例外,子类中的重写函数可以不加 virtual,但是通常不建议这样做。

为什么子类重写时可以不加 virtual ❓

因为它的理解是认为你是先继承下来的,我是在重写你,继承后你都有虚函数属性了,我去重写你,加与不加都无所谓。主要的实用场景还是测试用例四中的问题, 如果基类的析构函数为虚函数,此时派生类的析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类的析构函数名不同,看起来违背了重写的规则,其实编译器对析构函数名统一处理成了 destructor()。也就是说如果支持子类不加虚函数也构成重写的话,那么只要父类中析构函数是虚函数,析构函数就一定构成重写,之后的问题就不存在了。
这种例外,无疑是让语法变的更重了,C++ 经常爱搞这种东西,已经见怪不怪了。
有些书籍会把多态进行细分:
✔ 测试用例六:
class A final
{};
class B : public A
{};
int main()
{
return 0;
}
#include
using namespace std;
class Car
{
public:
virtual void Drive() final
{}
};
class Benz : public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
int main()
{
return 0;
}
final 修饰类,表示该类不能再被继承。同样是实现出一个不能被继承的类,C++11 中的 final 相对 C++98 中对构造函数私有化更为彻底。

final 修饰虚函数,表示该虚函数不能再被重写,重写则会报错。

✔ 测试用例七:
#include
using namespace std;
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
//virtual void Drive() override//ok,重写
//{
// cout << "Benz-舒适" << endl;
//}
//void Drive() override//ok,属于重写的例外
//{
// cout << "Benz-舒适" << endl;
//}
virtual void Drive(int) override//err,没有完成重写
{
cout << "Benz-舒适" << endl;
}
};
int main()
{
return 0;
}
override 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写则编译报错。


✔ 测试用例八:
#include
using namespace std;
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()
{
Benz bz;
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
在虚函数的后面写上 = 0,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类 (也叫接口类,你可以模糊的认为一个类的公有成员函数是接口,但更多是因为纯虚函数只有声明,没有实现,所以叫接口类。通常还是叫抽象类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象,也就是说纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
理解接口类和抽象类 ❓
接口类相比抽象类的概念更广泛,你可以认为一个类的公有成员函数是接口,换一个角度,如果一个类设计的不规范,也不能说公有的成员函数就是接口。但更重要的是纯虚函数只有声明,没有定义,所以有些地方叫接口类。这个概念比较模糊,但是也要能理解有些地方叫接口类,但是更重要的还是要理解它叫抽象类。
抽象这个词,我们理解的场景是 “ 你长的好抽象 ” 或 “ 抽象派画家画的画好抽象 ”。本质抽象类指的是在现实世界中没有具体的对应实物,也就没必要实例化对象,比如说车去实例化对象,那么对象是卡车、公交车还是观光车?车是抽象的,它实例化的对象没有具体对应实物,所以我们可以把它实现为抽象类,让它不能实例化对象。
抽象类可以创建指针,但是只能指向子类,因为父类不能创建对象。
✔ 测试用例九:
#include
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
private:
int _b = 1;
char _ch = 'a';
};
int main()
{
cout << sizeof(Base) << endl;
Base b;
return 0;
}
这是常考一道笔试题,遇到这种题时打死也不可能是 8,因为是 8 的话那就是考查结构体的内存对齐,为啥还要搞个类呢。
当一个类有虚函数后,这个类会增加 4 个字节在前面,这 4 个字节是一个指针,这个指针叫做虚函数表指针,简称虚表指针 __vfptr (v 是 virtual、f 是 function、ptr 是指针,但是 __vftptr 更准确,就是说这个指针不是指向虚函数,而是指向虚函数表,表里才是虚函数),__vfptr 指向的表是虚函数表,简称虚表,这个表你可以认为它是函数指针数组,表里存储的是虚函数的地址 (注意虚函数存储于虚表中这种说法不完全对,因为虚函数被编译成指令后,跟普通函数一样存储在代码段,只是它的地址放到了虚表中)。注意区分继承中谈的虚基表指针,它所指向的表所存储的是偏移量,用于查找基类。

✔ 测试用例十:
#include
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()//非虚函数
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
char _ch = 'a';
};
class Drive : public Base
{
public:
virtual void Func1()//重写Func1
{
cout << "Drive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b1;
Base b2;
Base b3;
Drive d1;
Drive d2;
return 0;
}
可以看到普通函数并不会放到虚函数表中。
可以看到 Base 和 Drive 所创建的对象的虚表指针不同。
如果虚函数 Func2 没有被重写,子类中的虚表中放的依旧是 Func2 的虚函数的地址;如果虚函数 Func1 重写,我们说重写也叫做覆盖,你可以理解为子类的虚表是把父类的虚表拷贝过来 (当然这里没必要做写时拷贝),谁完成了重写,就把重写的位置覆盖成重写的虚函数,所以你可以认为重写是语法层的概念,覆盖是原理层的概念;如果都不完成重写,虽然父子类中虚表的内容是一样的,但是并不代表着它们要共用一张虚表,也没必要,因为空间用的不多。
所以一个类的所有对象共享一张虚表;父子类无论是否完成虚函数重写,都有各自独立的虚表;

✔ 测试用例十一:
#include
#include
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
//int _a = 0;
//string _b = "dancebit";
};
void Func(Person& p)
{
p.BuyTicket();
}
void f()
{
cout << "f()" << endl;
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
//Person p = Johnson;
f();//普通函数
return 0;
}
为什么多态的条件之一是重写。因为只有完成了重写,子类的虚表里面才会被覆盖成子类的虚函数。

为什么多态的条件之二是父类的指针或引用调用虚函数。首先这里的虚表一定是用来被调用的,如果父类的指针指向父类对象,就去父类的虚表中找虚函数;如果父类的指针指向子类对象,看到的就是子类中父类的那一部分,虽然对指针而言看到的与父类对象一样,也是在类似的位置找虚函数调用,但是这时调用的是子类的虚函数,因为父子类无论是否完成虚函数重写,都有各自独立的虚表,且这里重写后子类会把自己的虚函数覆盖拷贝下来的虚函数。所以这里达到的目的就是指向谁,调用谁,这里 p.BuyTicket(),它并不知道也不识别指向的是父类还是子类 (就像虚继承中也不识别,而是统一取偏移量,然后找基类),你传不同的对象去调用 Func,执行的是同样的指令,都是去找头上的 4 个字节,也就是虚表指针,然后找虚函数调用。

为什么条件之二是对象就不行了 ❓
因为如果是指针或引用调用虚函数,这里的切片行为:指向父类就是父类;指向子类,看到的是子类中继承下来的父类部分,虽然看到的是父类部分,但是虚表是子类的虚表,虚函数也是子类的虚函数。
如果是对象,虽然能编译通过,但是没有构成多态。原因是如果是对象调用虚函数,那么对于传父子类对象都是拷贝构造,此时,父类对象会拷贝构造成员,但不会处理虚表,也不需要处理,因为它们指向的是同一张虚表;子类对象会先切片出父类部分,然后再拷贝构造成员,子类的虚表不会处理,因为要是把子类的虚表也拷贝了,如果给你一个父类对象,你都不知道父类对象中的虚表内容是什么,因为父类可能指向父类虚表,也可能父类被子类切片过,然后指向子类虚表,一个父类对象指向子类虚表当然不合理,这时就会导致一个父类指针指向一个父类对象,调用的是子类的虚函数。

普通函数的调用和多态的调用 ❓
普通函数的调用是编译或链接时确定地址,有两种情况,当看到 f 的调用时,编译期间,往上找到函数的定义,这里就直接成 call + 地址;编译期间,往上找到函数的声明,这里就先 call + ???,链接时再其它文件中查找。
多态的调用是运行时确定地址,编译器会先检查是否多态,如果是就按多态的规则执行,它会去指向对象中的前 4 个字节指向的虚函数表中找到虚函数的地址。如果不是多态,就在编译时确定地址。

//简单瞅下汇编:
//[p]就是取p指向的内容,这里把p移动到eax中
00BD25D8 mov eax, dword ptr [p]
//[eax]就是取eax指向的内容,这里就是把指向对象的头4个字节(虚表指针)移动到edx中
00BD25D8 mov edx, dword ptr [eax]
//[edx]就是取edx指向的内容,这里把虚表中所存储的虚函数的地址移动到eax中
00BD25E2 mov eax, dword ptr [edx]
//call eax中虚函数的指针,这里就可以看出多态的调用不是在编译时确定的
00BD25E4 call eax
✔ 测试用例十二:
#include
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 2;
};
//typedef void(*)() VFPTR//err
typedef void(*VFPTR)();//VFPTR是函数指针,它指向无参的函数,返回值是void*
//打印虚表
void PrintVFT(void* vft[])//虚函数表是一个函数指针数组,不是说函数指针必须是类型一样的才能玩,因为就算类型不一样,也可以使用void*
{
printf("%p\n", vft);
for(size_t i = 0; vft[i] != nullptr; i++)//虚表最后会放nullptr
{
//printf("vft[%d]: %p\n", i, vft[i]);//打印虚函数
printf("vft[%d]: %p->", i, vft[i]);//打印虚函数
//vft[i]();//err,要调用函数指针,就必须是函数指针类型
VFPTR f = (VFPTR)vft[i];//将虚函数的地址的类型void*强转为函数指针类型,赋值给f
f();//调用这个虚函数,VS有些版本这里会标红提示,不用理它
}
printf("\n");
}
//typedef void(*VFPTR)();
//void PrintVFT(VFPTR vft[])//函数指针数组
//{
// printf("%p\n", vft);
// for(size_t i = 0; vft[i] != nullptr; i++)
// {
// printf("vft[%d]: %p->", i, vft[i]);
// vft[i]();//ok,因为与函数指针类型匹配
// }
// printf("\n");
//}
int main()
{
Base b;
Derive d;
PrintVFT((void**)*(int*)&b);//取头上4个字节:&b是bash*;*(int*)&b是bash*到int*的强转,再解引用就是4个字节;(void**)(*((int*)&b))是对头4个字节强转void**,为了与形参类型匹配;如果是64位要取8个字节,就是longlong*
PrintVFT((void**)*(int*)&d);
//PrintVFT((void**)*(int)&b);//err,bash*到int的强转,不能取到头4个字节,这里崩溃了,因为这里&b是对象的地址,你只是说把它强转为int,它还是&b的地址,待会对这块空间解引用找虚函数,而这块空间并没有虚函数。
//PrintVFT((VFPTR*)*(int*)&b);//强转为函数指针
//PrintVFT((VFPTR*)*(int*)&d);
return 0;
}
这里基类的 Func1 和 Func2 是虚函数,Func3 是普通函数;派生类重写了基类的 Func1,自己增加了 Func4 的虚函数。但是我们通过监视窗口并没有看到派生类对象中的 Func4,不能确定 VS 在设计这块时是有意的还是无意的,不过大概率猜测是有意的,VS 觉得派生类中新增的虚函数在监视中展现出来也没什么用,所以就没展示,我们也一再的说过监视窗口不一定真实,也并没有保证要给你看到原生内存是什么样子,所以不能全依赖它。

这里需要补充一个细节是虚函数表本质是一个存储虚函数指针的指针数组,所以一般情况这个数组最后面放了一个 nullptr,这不是标准规定的,但是大多数编译器都是这样实现的。
基于上面的问题,这里我们就模拟一下虚函数表,把虚函数的地址打印出来,其实这个过程就类似模拟着编译器调用虚函数。
运行程序打印了一大串地址,完全对不上,这里其实是编译器的 bug,可能是由于不断的改代码,导致虚表中的 nullptr 加上。

解决方法就是清理解决方案,重新编译。这里我们通过监视窗口验证的同时发现恶心的是验证不了 Func4,虽然我们能猜到它就是 Func4。

我们发现这些函数的类型都是一样的 void*,我们有函数的地址就可以调用它们,但是 void* 没法调,所以我们要对 void* typedef 为函数指针类型 VFPTR,再用这个函数指针类型定义的变量接收 对 void* 类型强转之后的地址,最后再调用这个函数。这时就一定能确定这个地址就是对应虚函数的地址,因为我调用了它,只有调用到正确的那个虚函数,它才能打印后面的那句话出来。

✔ 测试用例十三:
#include
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 2;
};
typedef void(*VFPTR)();
void PrintVFT(void* vft[])
{
printf("%p\n", vft);
for (size_t i = 0; vft[i] != nullptr; i++)
{
printf("vft[%d]: %p->", i, vft[i]);
VFPTR f = (VFPTR)vft[i];
f();
}
printf("\n");
}
int main()
{
Base bb;
int a = 0;
int* p1 = new int;
const char* p2 = "dancebit";
auto pf = PrintVFT;
static int b = 1;
printf("栈帧变量: %p\n", &a);
printf("堆变量: %p\n", p1);
printf("常量区变量: %p\n", p2);
printf("函数地址变量: %p\n", pf);
printf("静态区变量: %p\n", &b);
printf("虚函数表地址: %p\n", *(int*)(&bb));
return 0;
}
虚函数存在哪 ?虚表存在哪 ❓
很多人都会深以为然的认为虚函数存在虚表,虚表存在对象。但其实虚函数不存在对象,对象里存的是一个虚表的指针,虚函数编译出来的函数指令同普通函数一样,存在代码段,只是虚函数的地址又被放到了虚表中。而关于虚表存在哪,我们这里采用一种比较粗糙的验证方式,通过虚函数表地址与其它内存区域的地址进行比对,最终我们认为虚函数表是在常量区或代码段。

✔ 测试用例十四:
#include
using namespace std;
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(*VFPTR)();
void PrintVFT(void* vft[])
{
printf("%p\n", vft);
for (size_t i = 0; vft[i] != nullptr; i++)
{
printf("vft[%d]: %p->", i, vft[i]);
VFPTR f = (VFPTR)vft[i];
f();
}
printf("\n");
}
int main()
{
Derive d;
PrintVFT((void**)(*(int*)&d));//打印Base1的虚表
PrintVFT((void**)(*(int*)((char*)&d + sizeof(Base1))));//打印Base2的虚表,这里需要从起始位置跳过Base1个字节,这里跳时,要按1个字节跳,所以要先对起始位置强转
//重写两次不多余,因为你要实现各自的多态
Base1* p1 = &d;
p1->func1();
Base2* p2 = &d;
p2->func1();
return 0;
}
这里有两个基类 Base1 和 Base2,这两个类中都包含两个相同的虚函数 func1 和 func2,Derive 继承了 Base1 和 Base2,并对 func1 进行重写,还新增了一个虚函数 func3。
那么大概率猜测 Derive 对象中有两张虚表,因为如果混在一起,太麻烦了。其中多继承派生类的未重写的虚函数放在第一个继承基类 Base1 的虚函数表中。多继承中的重写会对两个基类重写。

重写两次有必要吗 ❓
当然有必要,因为你要实现各自的多态,所以需要独立开来,虽然调用的都是同一个函数,如果是单继承,那么重写一次是没问题的,如果是多继承,那么只重写一次是有问题的,就类似过年回家要买礼物时,你不可能只给爸爸或只给妈妈买。
Base1 和 Base2 中的 func1 都被重写了,它们都是 Derive::func1,但是地址却不一样 ❓
这个不需要太在意,虽然地址不同,但是最终调用的是同一个函数,你可以认为虚表中不是它真正的地址,它在真正的地址上还包了一层,至于为啥要包就无从得知了。

简单看下汇编,注意不同的编译器实现的可能大同小异

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面实际中用起来它的底层非常的复杂,我们之前是也仅仅是用成员变量做测试模型;另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用,我们这里就了解下在继承中说的虚基表中空的位置,其实这里虚基表中头上 4 个字节是找虚函数表指针的偏移量。

#include
using namespace std;
class A
{
public:
virtual void func1()
{}
public:
int _a;
};
//class B : public A
class B : virtual public A
{
public:
virtual void func1()
{}
virtual void func2()
{}
public:
int _b;
};
//class C : public A
class C : virtual public A
{
public:
virtual void func1()
{}
virtual void func2()
{}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()//重写B、C
{}
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
如果 B 和 C,都不重写的话,是可以正常运行的。但如果 B 和 C,都完成重写的话,D 一定需要重写,因为 D 如果不重写,就会存在歧义 —— 重写不明确,因为 D 继承了 B、C,虚继承解决二义性后,B、C 都没有放 A 了,而是把 A 放到了公共的最下面,A 中又带一个虚表,现在 B 和 C 对 A 中的 func 重写,D 继承之后,到底是用 B 的重写还是 C 的重写这是有歧义的。所以 D 也应该重写。

如果 B、C 单纯是重写 A 的,那么 B、C 中不需要虚表,但是 B、C 新增自己的虚函数,那它就得有单独的虚表,因为这时 B、C 共享的是公共的 A,如果把它们的虚函数往公共的 A 中放就不合适了。
此时我们内存对象模型如下:

验证一下:

如果感兴趣,可以去看下面的两篇链接文章。
下面哪种面向对象的方法可以让你变得富有 ( )
A. 继承
B. 封装
C. 多态
D. 抽象
( ) 是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
A. 继承
B. 模板
C. 对象的自身引用
D. 动态绑定
面向对象设计中的继承和组合,下面说法错误的是 ( )
A. 继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用
B. 组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用
C. 优先使用继承,而不是组合,是面向对象设计的第二原则
D. 继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现
以下关于纯虚函数的说法,正确的是 ( )
A. 声明纯虚函数的类不能实例化对象
B. 声明纯虚函数的类是虚基类
C. 子类必须实现基类的纯虚函数
D. 纯虚函数必须是空函数
关于虚函数的描述正确的是 ( )
A. 派生类的虚函数与基类的虚函数具有不同的参数个数和类型
B. 内联函数不能是虚函数
C. 派生类必须重新定义基类的虚函数
D. 虚函数可以是一个 static 型的函数
关于虚表说法正确的是 ( )
A. 一个类只能有一张虚表
B. 基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C. 虚表是在运行期间动态生成的
D. 一个类的不同对象共享该类的虚表
假设 A 类中有虚函数,B 继承自 A,B 重写 A 中的虚函数,也没有定义任何虚函数,则 ( )
A. A 类对象的前 4 个字节存储虚表地址,B 类对象前 4 个字节不是虚表地址
B. A 类对象和 B 类对象前 4 个字节存储的都是虚基表的地址
C. A 类对象和 B 类对象前 4 个字节存储的虚表地址相同
D. A 类和 B 类虚表中虚函数个数相同,但 A 类和 B 类使用的不是同一张虚表
下面程序输出结果是什么? ( )
#include
using namespace std;
class A{
public:
A(char* s) { cout << s << endl; }
~A(){}
};
class B : virtual public A {
public:
B(char* s1,char* s2) : A(s1) { cout << s2 << endl; }
};
class C : virtual public A {
public:
C(char* s1, char* s2) : A(s1) { cout << s2 << endl; }
};
class D : public B,public C {
public:
D(char* s1, char* s2, char* s3, char * s4) : B(s1, s2), C(s1, s3), A(s1)
{ cout << s4 << endl;}
};
int main(){
D *p=new D("class A","class B","class C","class D");
delete p;
return 0;
}
A. class A class B class C class D
B. class D class B class C class A
C. class D class C class B class A
D. class A class C class B class D
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
A. p1 == p2 == p3
B. p1 < p2 < p3
C. p1 == p3 != p2
D. p1 != p2 != p3
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;
}
什么是多态 ?
答:参考如上
什么是重载、重写(覆盖)、重定义(隐藏) ?
答:参考如上
多态的实现原理 ?
答:参考如上
inline 函数可以是虚函数吗 ?
答:可以,不过编译器就忽略 inline 属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。
静态成员可以是虚函数吗 ?
答:不能,因为静态成员函数没有 this 指针,使用类型 :: 成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
构造函数可以是虚函数吗 ?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
析构函数可以是虚函数吗 ?什么场景下析构函数是虚函数 ?
答:可以,并且最好把基类的析构函数定义成虚函数。参考如上。
对象访问普通函数快还是虚函数更快 ?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
虚函数表是在什么阶段生成的,存在哪的 ?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段 (常量区)的。
C++ 菱形继承的问题 ?虚继承的原理 ?
答:参考继承一文 。注意这里不要把虚函数表和虚基表搞混了。
什么是抽象类 ?抽象类的作用 ?
答:参考如上。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系