面向对象的编程语言有三大特性:封装、继承、多态。其中封装和继承已经讲过了,今天把多态结束就基本齐全了~
C++的多态语法知识也是比较复杂的,不过只有难的知识学会了我们的优越感才会更大。博主也尽量会详细、全面的把知识总结出来,今天我们一起在知识的海洋里遨游吧~
目录
如果子类继承纯属函数类如果不重写会如何?答案:子类也不能实例化出对象
如果子类继承纯属函数类如果重写会如何?答案:子类能实例化出对象
多态的概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
静态的多态:函数重载,看起来调用同一个函数,但有不同的行为。静态:编译时确定。
动态的多态:一个父类的引用或指针去调用同一个函数,传递不同的对象,会调用不同的函数。动态:运行时确定。
注意:模板不是静态的多态!
举个栗子:
ps1:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
ps2:比如去超市购物时,每个用户可以有不同的会员卡。白银会员卡打九折;黄金会员卡打八折;钻石会员卡打七折...这样的话,对于不同的顾客,同时后买相同的商品,但是根据折扣的不同,最后付款结果也是不同的。
以上都是不同人对于做同一个动作,但是产生的结果不同,这种行为类似于多态的行为。
多态的定义及实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student(子类)继承了Person(父类)。Person对象买票全价,Student对象买票半价。
因此在继承中要构成多态还有两个条件:
1、必须通过基类的指针或者引用调用虚函数
2、被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
我们在解释前先看一张图表示的多态:
我们发现Person&接收不同的类的对象时,由于实现了多态,即使Func参数的类型都是Person,但是也是能调用了不同类中的虚函数,来实现不同的方法(函数)。
我们是不是有几个问题?什么是虚函数,什么是重写?重写和我之前介绍的隐藏又有什么关系?不要着急,来接着往下看~
虚函数
即被virtual修饰的类成员函数称为虚函数。
我们来举个栗子:
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;
}
};
我们画个图来使其更加形象一些:
注意
在重写基类虚函数时,派生类的虚函数在不加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 p;
Func(p);
Student s;
Func(s);
return 0;
}
上面的代码我们发现派生类在重写基类的虚函数时,子类重写的BuyTicket函数前面没有加virtual,但是在经过父类的指针或者引用调用时也可以构成多态。(这是一种比较特殊的情况,实际上大佬这样设计语法是为了方便析构函数的调用(有兴趣的老铁可以百度看看相关资料~),不过在这里就有些小"bug"了。) 我们要保证代码的可读性,尽量在父子两类中的重写的虚函数都要加virtual。
虚函数重写的两个例外
协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值可以类型不同。只要基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)
举个栗子:
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 p;
Func(p);
Student s;
Func(s);
return 0;
}
运行结果:
我们发现基类的虚函数和父类的虚函数的返回类型也构成一个继承类的关系。这样的话即使返回类型不同,但是这种情况也构成虚函数重写。
总结:基类返回类型是另一个继承类中基类的指针或引用,派生类的返回类型是这个继承类中派生类的指针或引用。这属于重写的特例。
析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor(无论是不是虚函数都会这么处理)。
举个栗子:
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
我们运行一下先看看结果:
当我们实现虚函数的多态时,delete后会自动调用各自类中的虚函数,这样就可以释放掉对应的空间资源。 这个过程中,父子类的析构函数都被处理成了destructor,由于实现了多态,detete p过程是析构函数+释放空间,之前构造的时候Person* p,p指向哪个类,在delete时就调用哪个类的destructor。
我们画个图来理解一下:
所以说我们什么时候要把继承体系中的类的析构函数实现出虚函数?
答案:形成多态时,父类的指针或引用指向子类时,并且子类中有需要释放的资源。
如果子类中有在堆上申请的资源,且不把析构函数实现成虚函数时,这样会怎样?
举个栗子:
class Person
{
public:
~Person()
{
delete[] p1;
cout << "~Person()" << endl;
}
private:
int* p1 = new int[10];
};
class Student : public Person
{
public:
~Student()
{
delete[] p2;
cout << "~Student()" << endl;
}
private:
int* p2 = new int[100];
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
运行结果:
我们发现delete p1;和delete p2都是调用了父类的析构函数(注意这里不会造成崩溃,第一次析构是析构父类对象,第二次析构是析构子类中的父类对象)。
我们发现如果父子类的析构函数不实现成虚函数的话,这样使用就不会调到子类的虚函数的析构函数。那么就有不用资源占着空间没法用,导致内存泄漏。
我们记住这个场景(可能以后面试会被问到的~):
Person* p1 = new Person;
Person* p2 = new Student;
delete p1; //p1类型是Person,调用的是父类的析构函数(这里不构成多态)
delete p2; //p2类型是Person,调用的是父类的析构函数(这里不构成多态)
如果我们实现成虚函数就能解决问题了:
C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反(就不构成重写)而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果再来debug调试会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
再讲这两个语法之前我们先思考一个问题。
在C++98版本中如何设计一个不能被继承的类?
class A
{
private:
A(int a = 0) //将构造函数设计成私有
:_a(a)
{}
public:
static A CreatOBj(int a = 0)
{
return A(a);
}
protected:
int _a;
};
class B : public A //间接限制 -- B继承A之前,要调用A的构造函数完成初始化
{};
int main()
{
A aa = A::CreatOBj(10); //a类构造对象的方式
return 0;
}
我们将构造函数设计成私有,那么就不能A a的方式来初始化A类对象,因为调用不到A类中的私有成员(该类的构造函数)。
我们可以封装一个public的静态成员函数,通过返回值来间接帮助我们初始化A对象。
static A CreatOBj(int a = 0) //这个函数必须是静态的,这样的话不用通过对象(通过类域::的方式来调用)就可以在类外调用得到。如果不设置成静态函数的话,那么在类外就只能通过对象/对象指针来调用,但是又不能直接构造出对象,那么这个函数就不能在类外调到了。就类似于先有鸡还是先有蛋的问题了。
{
return A(a); //返回一个匿名对象-->不能用引用接收匿名对象(匿名对象不能被使用引用的参数接收也不能做引用对象的返回值)
}
我们运行一下代码看是否正确:
我们来测试一下A类这样设计是否能被继承:
注意:
这种方法在功能上的确使一个类不能被继承。但是这种限制是间接限制,子类构造函数无法调用父类构造函数来初始化继承下来的父类成员,继承的子类也就没办法实例化出对象。
但是这种方法有点偏门,大家来看看C++11是怎么限制一个类不能被继承的。
final
final放在虚函数后面:表示该虚函数不能再被重写。
final放在类名后面:表示该类不能被继承。
举个栗子:
final放在类名后面:表示该类不能被继承。
class A final
{
private:
A(int a = 0) //将构造函数设计成私有
:_a(a)
{}
public:
static A CreatOBj(int a = 0)
{
return A(a);
}
protected:
int _a;
};
class B : public A //间接限制 -- B继承A之前,要调用A的构造函数完成初始化
{};
int main()
{
return 0;
}
我们编译一下看看是否通过:
我们发现在A类名后面加一个final之后,这个类就不能被继承了。这种限制一个类不能被继承的方式更严格。
final放在虚函数后面:表示该虚函数不能再被重写。
举个栗子:
class A
{
virtual void Print() final
{
cout << "class A " << endl;
}
};
class B : public A
{
virtual void Print()
{
cout << "class B : public A" << endl;
}
};
int main()
{
return 0;
}
我们编译一下看看是否能通过:
我们发现A类中的Print函数就不能被重写。
总结:
final是最后的意思,我们可以想成放到类后面,那这个类是最后的类,不能被继承。
放到虚函数后面,那这个虚函数是最后的虚函数,不能被重写。
这是一种便于记忆的方法,仅供参考~
override
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
这种检查方式是一种委婉的方式。上面的在虚函数后面加final是一种强制子类实现重写的方式。
举个栗子:
class A
{
virtual void Print()
{
cout << "class A " << endl;
}
};
class B : public A
{
virtual void Print_s() override
{
cout << "class B : public A" << endl;
}
};
上面的代码我们在派生类一个虚函数后面加上override,当该虚函数不构成重写时,我们编译一下看看是否能通过。
编译结果:
当实现重写时,我们在子类虚函数后面加上override看看这次是否能编译通过:
重载、覆盖(重写)、隐藏(重定义)的对比
总结:重写(覆盖)和重定义(隐藏)的区分主要看父子类中两个函数是不是虚函数,如果是虚函数再进一步判断,如果还有条件不符合那就构成隐藏;如果不是虚函数,那就是隐藏。
抽象类
概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
举个栗子:
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;
}
};
在虚函数后面加=0:virtual void Drive=0,这个虚函数就是纯虚函数。
这个纯虚函数又包含在Car类中,那么这个Car类叫作抽象类。
抽象类是否能够实例化出对象?答案:不能
我们来测试一下:
代码:
class Car
{
public:
virtual void Drive() = 0;
};
int main()
{
Car car;
return 0;
}
测试结果:
如果子类继承纯属函数类如果不重写会如何?答案:子类也不能实例化出对象
我们举个栗子:
class Car
{
public:
virtual void Drive() = 0;
};
class Benz : public Car
{};
int main()
{
Benz be;
return 0;
}
我们编译一下看看情况:
如果子类继承纯属函数类如果重写会如何?答案:子类能实例化出对象
举个栗子:
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 be;
BMW bm;
Car& p1 = be;
Car& p2 = bm;
p1.Drive();
p2.Drive();
return 0;
}
测试结果:
我们发现,当我们继承抽象类时,子类重写这个虚函数时,就能实例化出对象,并且构成多态。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数表和虚函数表指针
这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
virtual void func()
{
cout << "func()" << endl;
}
protected:
int _b = 1;
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
如果按照常规思路的话,我们按照对齐规则,_b为四个字节(函数存在公共代码段,不参与这里的计算),那么结果应该是4,那结果真的是这样呢?
我们运行一下看看结果:
通过测试我们发现Base类对象是8byte,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
通过具体例子进一步分析:
我们通过调试窗口来看一下b对象中成员变量:
我们在线在VS2019下_vfptr放在了最前面。这个指针就是虚表指针。
那我们思考,这个虚表指针指向哪里呢? 答案是:虚函数表(简称虚表)
我们通过内存窗口来查看这个虚表指针指向了一个地址(虚表),这个地址下面的地方就是函数func()的地址。这就说明,通过虚函数表指针可以找到对应的虚函数地址。
我们写一个继承类再来观察这个虚表指针:
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;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
Base* p1 = &b;
Base* p2 = &b;
p1->Func2();
p2->Func2();
return 0;
}
Derive继承Base类。Base类中有虚函数Func1()、Func2()。Derive类中有虚函数Func1()。子类中虚函数Func1()重写了父类的虚函数Func1()。
我们来调试一下:
我们发现b对象中虚表指针指向的虚表里面存有对应的两个虚函数的地址(截图中箭头所指)。
d对象中虚表指针所指向的虚表中也有对应的虚函数的地址(截图中箭头所指)
还有一种特殊情况,在监视窗口里,不一定显示所有的虚函数,因为监视窗口是被处理过的。
举个栗子:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
virtual void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
Base* p1 = &b;
Base* p2 = &b;
p1->Func2();
p2->Func2();
return 0;
}
我们通过调试窗口来观察一下:
这时候我们发现d对象中Func3()函数地址是被有造监视窗口里找到。但是我们可以通过内存窗口来找,内存是不会骗人的~
我们发现前两个地址和监视是对上了,那么地址0x00bd1627这个地址就是Func3()的地址了。
总结
1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在父类部分的,另一部分是自己的成员。
2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
6. 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多人都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。
虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的
验证代码:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
};
int main()
{
int* p = (int*)malloc(10);//p指向的地址是堆空间的地址,但是&p本身存在栈上
printf("堆:%p\n", p);
int a = 10;
printf("栈:%p\n", &a);
static int b = 0;
printf("数据段:%p\n", &b);
const char* str = "wmm"; //字符常量存在代码段
printf("代码段:%p\n", str);
Base bs;
printf("虚函数表:%p\n", *((int*)&bs)); //前四个字节是_vfptr指针
return 0;
}
运行结果:
我们发现虚函数表的地址距离代码段最近,基本说明虚函数表存在代码段。
*((int*)&bs):&bs是为了取到它的地址。(int*)是为了强转类型,取到它前四个字节的地址,通过上面知识我们知道,_vfptr虚表指针是指向虚表的,并且虚表指针存在对象的前四个字节中,所以要(int*)。最外层的解引用*,是为了取出这个阶段后的值(实际上是地址),然后按照%p打印即可。
在Linux下验证一下:
代码:
编译并运行后:
我们发现Linux和VS一样,虚函数表都是和代码段的地址最近,基本说明虚函数表是存在代码段的。
总结:虚函数表指针存在对象中,虚函数表中存虚函数地址,虚函数表和虚函数都存在代码段,
多态的原理
上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的
Person::BuyTicket,传Student调用的是Student::BuyTicket
1、 观察下图的红色箭头我们看到,p是指向p对象时,p->BuyTicket在p的虚表中找到虚函数是Person::BuyTicket。
2、 观察下图的绿蓝色箭头我们看到,p是指向s对象时,p->BuyTicket在s的虚表中找到虚函数是Student::BuyTicket。
3、 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
4、 反过来思考我们要达到多态,有两个条件,一个是虚函数重写(覆盖),一个是对象的指针或引用调用虚函数。反思一下为什么?
5、 再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
汇编代码分析满足多态的函数调用时是在运行时确定的
代码测试:
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 p;
p.BuyTicket(); //不满足多态时
Student s;
Func(s); //满足多态时
return 0;
}
汇编代码分析:
形成多态为什么只能用父类的指针或引用而不能使用父类对象?
使用父类指针接收子类指针时,父类指针能找到子类的虚表指针,进而找到虚表地址,接着就可以找到子类中的虚函数的地址,来达到调用子类虚函数的功能。
使用父类引用接收子类引用时,父类引用能找到子类的虚表指针,进而找到虚表地址,接着就可以找到子类中的虚函数的地址,来达到调用子类虚函数的功能。
如果父类对象接收子类对象时,就算会发生切片,但是也不会把子类的虚表指针切过去。那么父类对象是找不到子类对象里面的虚表指针,那么就不会找到子类里面的虚函数!
这个问题用代码还不是很好验证。我们用语言来描述一下吧~
我们假设子类能把虚表指针切割给父类对象,那么父类对象存的就是虚表指针!?这样做的话,一个对象里面竟然存的不是自己的虚表指针!如果对于复杂的项目来说,成百上千个对象存的都不是自己的虚表指针,那么函数调用会搞得非常复杂,可以说是已经乱套了。所以,这种方式是不可取的!因此形成多态时,不能用父类对象来接收。
动态绑定与静态绑定
1、 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2、 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
3、 上面关于买票的汇编代码分析很好的解释了什么是静态(编译时)绑定和动态(运行时)绑定。
单继承和多继承的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的
单继承中的虚函数表
代码测试:
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
};
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;
}
};
int main()
{
Base b;
Derive d;
return 0;
}
我们先通过监视窗口来看看虚表里面的函数地址:
观察上图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数。也可以认为是他的一个小bug。
那么我们如何查看d的虚表呢?我们可以通过内存窗口来查看:
下面我们使用代码打印出虚表中的函数地址
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
};
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;
}
};
typedef void(*VF_PTR)(); //定义一个函数指针
void PrintVFTable(VF_PTR* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("vf_ptr[%d]:%p->", i, table[i]);
VF_PTR f = table[i];//f的类型是函数指针
f(); //函数调用,函数名相当于函数地址(指针),这里相当于调用虚表里对应的函数
//table[i]()也可以达到上面代码的功能
}
}
int main()
{
Base b;
printf("Base类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&b));
//PrintVFTable((VF_PTR*)*((void**)&b)); 32位/64位下都可以用
Derive d;
printf("Derive类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&d));
//PrintVFTable((VF_PTR*)*((void**)&d)); 32位/64位下都可以用
return 0;
}
typedef void(*VF_PTR)(); //定义一个函数指针
void PrintVFTable(VF_PTR* table)
{
for (int i = 0; table[i] != nullptr; i++) //通过上面的内存窗口发现,在继承体系中虚表中最后一个虚函数的下一个位置以nullptr结束。
{
printf("vf_ptr[%d]:%p->", i, table[i]);
VF_PTR f = table[i];//f的类型是函数指针
f(); //函数调用,函数名相当于函数地址(指针),这里相当于调用虚表里对应的函数//table[i]()也可以达到上面代码的功能
}
}
int main()
{
Base b;
printf("Base类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&b)); //(int*)是为了发生截断,截断大小是int(去掉前面的*来观察)。(VF_PTR*)是为了强制类型转换(VF_PTR也是指针),使其能够传参
//PrintVFTable((VF_PTR*)*((void**)&b)); 32位/64位下都可以用 截断大小是(void*,根据平台来确定大小,这样做代码可以在别的平台运行)。
Derive d;
printf("Derive类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&d));
//PrintVFTable((VF_PTR*)*((void**)&d)); 32位/64位下都可以用
return 0;
}
我们来看一下运行结果:
我们发现fun3和fun4就被打印出来了~
可能在运行时会出现光标停留在某个位置闪动,实际上这是一个bug,我们可以在运行前这么做来解决问题:
![]()
多继承中的虚函数表
代码测试:
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;
};
int main()
{
Derive d;
return 0;
}
我们先通过监视窗口来观察一下d中的两张虚表里面的虚函数:
我们发现和上面的情况和单继承一样,子类中不构成重写的虚函数在监视窗口里面是找不到的。我们可以通过内存窗口来观察(可能在显示时也会出bug,我们可以提前点清理解决方案来解决~):
我们通过代码来打印虚函数表里面的函数地址
typedef void(*VF_PTR)(); //定义一个函数指针
void PrintVFTable(VF_PTR* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("vf_ptr[%d]:%p->", i, table[i]);
VF_PTR f = table[i];//f的类型是函数指针
f(); //函数调用,函数名相当于函数地址(指针),这里相当于调用虚表里对应的函数
//table[i]()也可以达到上面代码的功能
}
}
int main()
{
Base1 b1;
printf("Base1类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&b1));
cout << endl;
Base2 b2;
printf("Base2类中的虚函数\n");
PrintVFTable((VF_PTR*)*((int*)&b2));
cout << endl;
Derive d;
printf("Derive类中的第一张虚表\n");
PrintVFTable((VF_PTR*)*((int*)&d));
cout << endl;
printf("Derive类中的第二张虚表\n");
PrintVFTable((VF_PTR*)*((int*)((char*)&d+sizeof(Base1))));
cout << endl;
return 0;
}
PrintVFTable((VF_PTR*)*((int*)((char*)&d+sizeof(Base1)))):(char*)&d+sizeof(Base1)是让d偏移sizeof(Base1)个字节,这时候就指向了Base2对象。
我们可以来看看这个d对象存放变量的顺序VS2019环境下:
运行结果:
我们发现子类中没有被重写的虚函数地址存在了继承的多个父类中的第一个被构造的父类中的虚表里面。后面的父类就不再存储了。
所以我们发现在打印Derive第二张虚表里面的函数地址时,就没有打印出来Derive::func3()。
菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表就不介绍了~一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的老铁,可以去看下面的两篇链接文章。
继承和多态常见的面试问题
1. 什么是多态?
多态即多种形态。在继承体系中构成多态要满足两种条件:
a、父类的指针或者引用调用虚函数
b、被调用函数必须是虚函数,而且子类对父类完成虚函数的重写。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
重载:在同一作用域中,两个函数函数名相同,但是两个函数参数列表不同(参数的顺序、个数、类型只要有一个不同,参数列表就不同)。
重写(覆盖):两个函数必须分别在基类和派生类中,两个函数必须是虚函数,并且两个函数的返回值/参数列表/函数名都相同(协变除外)。
重定义(隐藏):两个函数必须分别在基类和派生类中,两个函数的函数名相同。两个基类和派生类的同名函数不构成重写就构成隐藏。
3. 多态的实现原理?
在构成多态时,父类的指针或者引用指向哪个类,就是调用哪个类的虚函数。父类首先找到被指向类的虚表指针,再通过虚表指针找到虚表里面对应的虚函数的地址,进而找到要调用的虚函数。同时我们可以发现,满足多态时的函数调用是在运行起来以后确定的。
4. inline函数可以是虚函数吗?
不可以,编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
内联函数是在编译时就展开了,是没有地址的。而虚函数是需要把地址存在虚函数表中去的。
5. 静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表(使用->来访问),所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。虚函数的调用是通过虚表指针找到虚表进而找到对应的函数来调到的。这时候就会出现先有鸡还是先有蛋的场景了。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。
如果有多态一个多态场景,父类的指针指向子类对象并且当子类有需要释放的资源时,需要将析构函数定义成虚函数。(具体场景看上面讲解)
8. 对象访问普通函数快还是虚函数更快?
首先如果是普通对象(不构成多态条件),是一样快的。
如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
菱形继承的原理:会出现数据冗余和二义性的问题。
虚继承的原理:在上一篇博客有讲解~ (通过偏移量来找公共的数据)
注意这里不要把虚函数表和虚基表搞混了。
11. 什么是抽象类?抽象类的作用?
抽象类:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。
抽象类的作用:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
都看到这里了,别忘了给博主支持一下~