您现在的位置是:首页 >学无止境 >【C++】多态网站首页学无止境
【C++】多态
文章目录
一、多态的基础知识
1.多态的概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态,比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票
2.多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价
那么在继承中要构成多态还有两个条件:
1.继承中才有多态
2.必须通过基类的指针或者引用调用虚函数
3.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
class Soldier :public Person
{
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier so;
Func(ps);
Func(st);
Func(so);
return 0;
}
如上,在Func函数中,我们使用父类的引用来调用虚函数BuyTicket(),然后分别使用父类对象和子类对象作为Func函数的参数,实现了不同对象进行同一种行为产生不同的状态,从而构成了多态。
3.普通调用和多态调用
多态调用
多态调用必须满足构成多态的几个条件–继承,虚函数重写,由父类的指针或引用来调用虚函数,多态调用与调用此函数的对象/引用的类型有关
比如在上面的Func函数中,ps是Person的对象,它作为实参传递给p,此时p是ps的别名,p调用BuyTicket函数,满足多态条件,由于p引用的对象ps的类型是Person,所以实际上调用的是Person的虚函数BuyTicket,所以打印出来的是"买票-全价"
st是Student的对象,它作为实参传递给p(此过程发生切片,无类型转换),此时,p是st的别名,p调用BuyTicket函数,满足多态条件,由于p引用的对象是st的类型Student,所以实际上调用的是Student的虚函数,打印出来的结果为"买票-半价"。Solider的对象和Student的情况一样
普通调用
多态意外的调用都是普通调用,只要不满足多态调用的任意一个条件的就是普通调用,普通调用与调用该函数的对象的类型有关
当我们将Func函数的引用去掉之后,由于此时调用BuyTicket不满足多态调用的第三个条件-由父类的指针或引用调用,所以即使我们调用的是虚函数,子类中也重写了虚函数,这里仍然是普通调用,普通调用与对象的类型有关,与对象的指向无关,而这里无论是否为切片,p的类型都是Person,所以全部调用对象类型都是Person的BuyTicket函数
3.虚函数
3.1 虚函数的概念
被关键字virtual修饰的类成员函数称为虚函数
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
3.2.虚函数的重写
虚函数的重写/覆盖
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
class Soldier :public Person
{
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
【注意】
在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是父类的virtual一定不能省略–虚函数的继承是接口继承,即子类中继承得到的虚函数和父类虚函数的函数接口是完全相同的,而子类对虚函数进行重写,重写的也仅仅是虚函数的实现,并没有改变虚函数的接口,所以即使我们不加virtual子虚函数的类型也和父类一样,是虚函数类型,但是该种写法不是很规范,不建议这样使用
父类函数是虚函数,子类虚函数不加virtual也是虚函数,构造多态
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
class Soldier :public Person
{
void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier so;
Func(ps);
Func(st);
Func(so);
return 0;
}
如果父类不是虚函数,则子函数和父类函数构成隐藏(函数名相同)
class Person
{
public:
void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
class Soldier :public Person
{
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier so;
Func(ps);
Func(st);
Func(so);
return 0;
}
3.3协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变.
我们需要注意的是,这里的基类和派生类不一定是当前的基类和派生类,使用其他基类和派生类类型作为函数返回值也可以
使用当前基类和派生类作为函数返回值
class Person
{
public:
virtual Person* BuyTicket()
{
cout << "买票-全价" << endl;
return this;
}
};
class Student : public Person
{
public:
virtual Student* BuyTicket()
{
cout << "买票-半价" << endl;
return nullptr;
}
};
class Soldier :public Person
{
virtual Soldier* BuyTicket()
{
cout << "买票-优先" << endl;
return nullptr;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier so;
Func(ps);
Func(st);
Func(so);
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;
}
};
class Soldier :public Person
{
virtual B* BuyTicket()
{
cout << "买票-优先" << endl;
return nullptr;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier so;
Func(ps);
Func(st);
Func(so);
return 0;
}
【注意】
1.如果子类和父类函数不满足这四个条件中的任意一个–虚函数,函数名相同,返回值相同,参数类型相同,也不属于协变的话,那么他们一般构成隐藏,因为隐藏只需要函数名相同。
2.构成重写的条件:虚函数+三同(子类不写virtual和协变除外).3.在C++中,协变通常指派生类中覆盖了基类的虚函数,并且返回类型被改为派生类的类型。这意味着可以使用派生类对象来替代基类对象,并且调用相同的虚函数将会调用到派生类中的实现。C++11引入了对返回类型协变的支持,即在派生类中可以使用与基类虚函数的返回类型不同但兼容的类型来重载虚函数。例如,如果基类中的虚函数返回一个指向基类对象的指针,则在派生类中可以使用指向派生类对象的指针来覆盖该函数,因为指向派生类对象的指针可以隐式转换为指向基类对象的指针
3.4析构函数的重写
我们知道,为了满足子类先析构,父类后析构的特性,子类析构函数调用完毕之后编译器会自动调用父类析构函数完成对父类成员的清理工作,不需要我们在子类析构中显式的调用父类析构,对于一般场景来说,这种做法是没有问题的
class A
{
public:
~A()
{
cout << "~A();" << this << endl;
delete[] _a;
}
protected:
int* _a = new int[10];
};
class B :public A
{
public:
~B()
{
cout << "~B():" << this << endl;
delete[] _b;
}
protected:
int* _b = new int[10];
};
但是在一些特殊的场景下,上述代码可能会出现内容泄漏
我们可以看到,在切片场景下开辟空间的时候,由于delete b的时候,使用指针调用析构函数进行析构,调用operator delete释放堆上B对象,这里虽然将调用A析构将A的资源释放了,但是b是由B切片得来的,所以b中B对象的那一部分资源并没有释放,所以发生看内存泄漏
为了解决上面的这种情况,我们一般将父类的析构函数定义为虚函数,然后在子类中对虚函数进行重写;析构函数没有返回值和参数,并且所有的析构函数的函数名在编译阶段都会变成destructor,再加上子类虚函数的virtual关键字可以省略,所以只要父类析构函数定义为虚函数,子类虚函数就一定可以构成重写。
在子类析构和父类析构构成重写的前提下,如果再遇到上面切片和动态开辟空间的时候,就会满足多态的条件–子类虚函数重写+父类指针/引用调用虚函数,为多态调用,与调用函数的对象(父类对象)指向的类型(子类类型)有关,所以就会调用子类的析构,同时,由于子类析构调用完毕之后会自动调用父类的析构函数,所以就可以使得子类和父类的资源都可以得到清理
【总结】
我们在继承关系中,无论是否析构函数需要多态调用的场景,都可以将父类的析构函数定义为虚函数,虽然虚函数会建立虚表,就会使得空间有一定的浪费,但是这一很小的空间几乎没有影响,而这样做就避免了可能存在内存泄漏的风险。
4. C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写
4.2 final
final:修饰虚函数,表示虚函数不能被重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
4.1 override
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Car {
public:
virtual void Drive() {}
};
class Benz :public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
5.重载、覆盖(重写)、隐藏(重定义)的对比
重载
1.在同一作用域内
2.函数名相同,参数不同(参数类型,参数个数,参数顺序),返回值没有要求
隐藏/重定义
1.在子类和父类中
2.子类定义和父类同名的成员变量造成父类的成员变量隐藏,需要父类指定父类作用域访问
3.子类定义和父类同名的成员函数(只要求函数名相同)造成父类成员函数隐藏,需要指定父类作用域访问
重写/覆盖
1.在子类和父类中
2.虚函数+三同–子类和父类都使用virtual修饰,子类父类虚函数的函数名相同,函数参数相同,函数的返回值相同相同构成重写
3.存在两个特例–子类的virtual可以省略,子类父类函数的返回值可以不同,但是要求是子类类型/父类类型的指针/引用
二、抽象类
1.抽象类的概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,抽象类有如下特征:
1.抽象类不能实例化出对象
2.派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
3.纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
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;
}
};
override和纯虚函数的区别
override是检查重写,没有重写直接编译报错,纯虚函数是强制重写,如果没有进行重写,就不能实例化出对象,但是不需要实例化出对象可以不重写,此时仍然为一个抽象类
2.接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数
注:函数接口指的是函数控制块{}上面的部分,即函数声明,函数实现指的是函数控制块{}中的部分,这也就解释了为什么子类函数不加virtual修饰也是虚函数
三、多态的原理
1.虚函数表
我们先下面的一道题目:在该程序中,sizeof(Base)是多少?
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;
};
我们的第一反应这道题不就是考的内存对齐嘛,_b占0~3号位置,_ch占4号位置,一共5个字节,然后再整体内存对齐,就占8个字节,但事实是这样 吗,我们来看运行结果:
通过观察测试我们发现b对象是12bytes,除了_b和_ch成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表
在VS下,虚表指针放在对象的最前面,且虚表的最后一个元素为空,但是这个不代表其他平台也是这样,比如gcc下虚表最后一个元素并不是nullptr
我们需要区分虚表和虚基表–虚表是多态中的概念,该表本质上是一个函数指针数组,里面存放的是虚函数的地址,而虚基表是菱形虚拟继承中的概念,该表本质上是一个整形数组,里面存放的是与虚基类的偏移量
针对上面的代码我们做出以下改造:
1.我们增加一个派生类Derive去继承Base
2.Derive中重写Func1
3.删除Base中的_ch成员
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;
};
通过观察和测试,我们发现了以下几点问题:
1.派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,一部分是自己的成员,虚表指针也就是存在与父类部分中的,另一部分是自己的成员。说明子类对象的虚表是从父类中继承/拷贝过来的
2.基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以子类虚表是通过拷贝父类虚表,然后将子类进程重写得到的新的虚函数地址覆盖虚表该函数原来的地址得到的,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3.另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
4.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5.这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的
总结:派生类的虚表生成
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
下面我们写一个函数来找出虚表的存储位置:
int main()
{
int a = 0;
cout << "栈:" << &a << endl;
int* p1 = new int;
cout << "堆:" << p1 << endl;
const char* str = "hello world";
cout << "代码段/常量区:" << (void*)str << endl;
static int b = 0;
cout << "静态区/数据段:" << &b << endl;
Base be;
cout << "虚表:" << (void*)*((int*)&be) << endl;
Base* ptr1 = &be;
int* ptr2 = (int*)ptr1;
printf("虚表:%p
", *ptr2);
Derive de;
cout << "虚表:" << (void*)*((int*)&de) << endl;
return 0;
}
我们可以将位于各个区域的类型的变量的地址和虚表的地址进行对比,看虚表地址和哪个类型的变量的地址最为相近,那么虚表就存储在哪个区域–因为虚表地址存储在虚表指针vfptr中,所以我们得到vfptr即可,而vfptr是一个指针变量,32位平台下为4个字节,所以我们将类对象的地址强转为int*,然后对其解引用就可以得到vfptr了
我们需要注意的是,我们将类的对象地址强转为int*后,此时再对其进行解引用得到的是一个整形的数据,而整形不便于我们和地址进行对比,所以我们将其转换为指针类型,使用cout输出即可
我们可以看到,虚表的地址和代码段中变量的地址最相近,所以虚表存储在代码段中,由于虚表存储在代码段中,所以同一类型的虚表是共享的
我们可能有疑问,虚表如果在代码段是不能对其进行覆盖了,其实不是这样的,子类的虚表是先拷贝父类虚表,然后进行覆盖,覆盖完毕之后才存储到代码段中的
2.多态的原理
上面分析了这个半天了那么多态的原理到底是什么?我们以下面的例子为例:
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;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
private:
int _d = 2;
};
分析:
我们可以看到,当调用Func3时,由于Func3是普通函数,所以即使调用Func3函数的是父类的指针也仍然不满足多态调用此时,此时调用至于调用的对象有关,与对象指向的类型无关,由于ptr是Base的指针,所以全部调用父类的Fun3函数,通过反汇编我们可以看到,调用Func3时是直接call父类Func3函数的地址
当我们调用Func1时,由于Func1是虚函数,并且子类对其进行了重写,再加上是父类的指针去调用,所以这里满足多态调用,此时调用与对象的类型有关,当ptr指向父类b时,调用父类的Func1函数,当指向子类对象d时,调用子类的Func1函数
从反汇编我们能够更清楚的看出多态的调用逻辑–当进行多态调用时,编译器会先将ptr移动到eax中,然后取出eax中前四个字节内容移动到edx(这个步骤相当于取出ptr指向对象的虚表指针vfptr),然后取出edx中前四个字节的内容移动到eax(因为Func1函数是最先定义的函数,所以其地址位于虚表中的前四个字节,所以这个步骤相当于取出ptr指向对象的虚表中Func1函数的地址),最后call eax中存放的地址,也就是call Func1
总结:
当进行多态调用时,父类指针如果指向的是父类对象,就去父类对象的虚表里面取被调用函数的地址,此时取出来的地址是父类虚函数的地址
如果指向的是子类对象中父类的那一部分,则去子类对象中属于父类对象那部分中找到虚表,然后从虚表里面取出来被调用函数的地址,由于子类对象对虚表进行了覆盖,所以取出来的地址是子类重写后虚函数的地址
这样就实现了父类的指针/引用指向父类对象就调用父类虚函数,指向子类对象就调用子类虚函数,从而实现了多态,所以多态的原理是:依靠虚表实现指向谁就调用谁
看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的
3.动态绑定与静态绑定
1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
我们上面多态原理的反汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑定,一个在编译时就确定函数的地址,后面运行时直接调用即可,一个在运行时才去别处取函数的地址进行调用
四、单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,就算将基类的虚函数都放进虚表中,然后再类对象中增加vfptr来指向这个虚表
1.单继承中的虚函数表
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; }
void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
观察的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。我们可以通过内存窗口来查看子类的虚表:
但是每次调用内存窗口来查看很不方便,下面我们使用代码打印出虚表中的函数
// 将返回值为void ,参数为void的函数指针重命名为VFPtr
typedef void(*VFPtr)();
void PrintVFTbale(VFPtr vft[])
{
cout << "虚表地址:" << vft << endl;
// 依次取虚表中的虚函数指针打印并调用,调用可以看出存的是哪个函数
for (int i = 0; vft[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, vft[i]);
vft[i]();
}
cout << endl;
}
此时,我们就观察到,在单继承中,子类首先会拷贝父类的虚表,然后进行重写,最后将自己特有的虚函数的地址填入虚表中
注意:
1.打印虚表的函数只能保证在VS下运行成功,在其他平台下无法保证,因为在其他平台下虚表最后一个元素不一定是nullptr
2.函数指针typedef与其他类型的typedef不同,重命名的名字要放在括号里面作为函数名(函数名就是函数指针)
3.使用函数名可以不解引用,因为函数名就是函数指针
4.由于编译器的某些原因,如果在VS下运行PrintVTable出现运行崩溃的情况,可以在生成选项中先清理解决方案,然后重新运行即可
2.多继承中的虚函数表
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中也有两个虚函数要建立虚表,子类同时继承了Base1和Base2.我们可以看到,子类拥有两张虚表,一张是从Base1拷贝过来的,一张是从Base2拷贝过来的
同时子类有两张虚表,我们也需要调用两次PrintVTable函数,一次打印子类对象中父类Base1的虚表,一张打印Base2的虚表,我们观察发现,子类特有的虚函数存放在第一个继承的父类的虚表里面
所以,在多继承中,父类一共有多少张虚表,那么子类就会拷贝多少张虚表,然后进行重写,最后将自己特有的虚函数的地址存放在最先继承的父类的虚表中
需要特别注意的是,子类在对一个虚函数进行重写时,如果同时对应了不同的父类的虚函数,则在进行虚表覆盖时不同的父类虚表中的同一个被重写函数覆盖的不是同一个地址
3.菱形继承、菱形虚拟继承的虚函数表
我们在实际的开发中,不建议设计出菱形继承及菱形虚拟继承,一方面太容易出问题,另一方面这样的模型,对访问基类成员有一定性能的消耗,实际上,研究菱形继承和菱形虚拟继承在实际工作中几乎没有意义的。我们只需要理解菱形继承和菱形虚拟继承即可,不需要了解其虚表模型,有想了解的同学可以参考下面两篇陈皓大佬的文章:
五、继承和多态常见的面试问题
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类使用的不是同一张虚表
8.下面程序输出结果是什么? ()
#include<iostream>
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
解析:A B C D 是菱形虚拟继承,所以D对象中只能有一份A,所以A只能调用一次构造函数,并且A对象应该由D对象来调用构造,同时,变量的初始化顺序与变量在初始化列表的顺序无关,而是与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化
变形:
#include<iostream>
using namespace std;
class A {
public:
A(char* s) { cout << s << endl; }
~A() {}
};
class B :public A
{
public:
B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :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) :C(s1, s3), B(s1, s2), A(s1)
{
cout << s4 << endl;
}
};
int main() {
D* p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
A B C D 是菱形继承,所以D对象中的属于B和C的部分各有一个,即应该调用两次构造,同时变量的初始化顺序与变量在初始化列表的顺序无关,与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化
所以输出的顺序是 A B A C D
9.多继承中指针偏移问题?下面说法正确的是( )
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 Base1 { public:int _b1; };
class Base2 { public:int _b2; };
class Derive : public Base2, public Base1 { public: int _d; };
int main() {
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
解析:子类对象模型中先被继承的父类模型会被放在 子类对象模型的上面,栈的使用的规则是先使用高地址,后使用低地址,而对象内部是先使用低地址,后使用高地址,即先继承的父类其对象模型在子类模型的低地址处,所以,大小关系为:p1 > p2 == p3
10.以下程序输出结果是什么()
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;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
解析:main函数中,p指向一个B对象,然后p调用test函数,由于B继承A,所以B中也会有test函数,但是由于B中没有对test函数进行隐藏或重写,所以B中的test和A 中的一模一样,包括隐藏的this指针,显示写出来如下:
class A
{
public:
virtual void func(int val=1){ std::cout << "A->" << val << std::endl; }
virtual void test(A* this) { this->func(); }
};
class B :public A
{
void func(int val=0){ std::cout << "B->" << val << std::endl; }
virtual void test(A* this) { this->func(); }
};
在此处,由于p的类型是B*,而test函数的this指针类型是A*,所以发生切片,然后,test函数在其内部调用func函数,由于子类对func函数进行了重写,再加上this指针的类型是A*,满足多态调用的条件,所以这里是一个多态调用,多态调用看对象指向的类型,所以this指向的类型为B,调用的是B的func,在多态调用中,虚函数的继承是接口继承,重写只是重写实现,所以B中func函数的接口应该使用A中继承下来的接口,即val的缺省值为1,所以输出B->1
11 .下面的函数输出结果是什么?
class A
{
public:
virtual void f()
{
cout << "A::f()" << endl;
}
};
class B :public A
{
private:
virtual void f()
{
cout << "B::f()" << endl;
}
};
int main()
{
A* pa = (A*) new B;
pa->f();
return 0;
}
A. B::f() B.A::f() ,因为子类的f()函数是私有的 C. A::f() 因为强制类型转换后,生成一个临时对象,pa实际上指向的一个基类的临时对象 D.编译错误,私有成员函数不能在类外调用
参考答案:
1.A 2. D 3. C 4. A 5. B
6.D 7. D 8. A 9. C 10. B 11. A
2.问答题
1.什么是多态?
多态(Polymorphism)是面向对象编程中的重要概念之一,指的是同一个类的不同实例在不同的情况下可以表现出不同的行为。具体来说,多态包括两种类型:静态多态和动态多态。
静态多态主要包括函数重载和运算符重载,即在编译时根据传递的参数类型或操作符对应的操作数类型来确定具体调用哪个函数或运算符重载函数。
动态多态则是通过虚函数(virtual function)来实现的。当基类指针或引用指向派生类对象时,通过虚函数机制可以在运行时确定调用哪个函数。这种通过基类指针或引用调用派生类对象的函数被称为虚函数,具有动态绑定的特性。
多态的好处在于它能够增强代码的灵活性和可扩展性,使得程序员能够更加方便地进行代码复用和维护
C++多态是指通过派生类对象调用基类的虚函数,实现在运行时动态决定调用哪个函数的特性。它可以让程序设计更加灵活,增强代码的可扩展性和可维护性。多态的实现需要基类中定义虚函数,并在派生类中进行重载,在程序运行时根据对象类型选择调用相应的函数。
2.什么是重载、重写(覆盖)、重定义(隐藏)?
- 重载(Overloading):指在同一作用域下,函数或运算符使用相同名称,但是参数列表不同的情况。通过重载机制,可以让函数或运算符能够处理不同类型和数量的数据,提高代码的复用性和可读性。
- 重写(覆盖)(Override):指派生类重新定义基类中已有的虚函数,使之能够根据派生类的特定需求进行实现。通过重写机制,可以让类的行为在运行时动态确定,增强程序的灵活性。
- 重定义(隐藏)(Redeclaration/Hide):指当派生类中定义一个与基类同名的成员函数或变量时,基类的同名成员被隐藏。这种情况下,派生类对象调用同名函数或变量时,会优先选择派生类自己的成员。如果需要访问基类的同名成员,可以使用作用域解析运算符(::)显式指定
3.多态的实现原理?
C++中实现多态的主要机制是虚函数(virtual function)。当一个成员函数被声明为虚函数时,这个函数就可以在派生类中被重写。通过使用基类指针或引用调用虚函数时,会根据对象的实际类型来决定调用哪个版本的虚函数,从而实现多态性。具体的实现原理是,在每个包含虚函数的类中,编译器会生成一个虚函数表(vtable),其中保存了类中所有虚函数的地址。派生类会继承基类的虚函数表,并且如果重写了基类的虚函数,则会在派生类的虚函数表中替换原有的地址。当使用基类指针或引用进行调用虚函数时,编译器会根据对象的实际类型查找对应的虚函数表,并调用正确的虚函数
4.inline函数可以是虚函数吗?
可以是,但通常不建议将虚函数声明为内联函数。虚函数的调用需要在运行时动态确定函数地址,而内联函数的调用则在编译时展开为代码,因此这两个概念存在一定的冲突。如果将虚函数声明为内联函数,编译器会尝试在编译时进行优化,但由于虚函数具有动态绑定的特性,可能会导致程序出错,例如调用基类中的虚函数而不是派生类重写的版本。因此,一般情况下应避免将虚函数声明为内联函数
可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
5.静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表
6.构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
7.析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。
析构函数可以是虚函数。当一个基类指针(或引用)指向一个派生类对象时,如果基类的析构函数不是虚函数,则在使用delete释放内存时只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致内存泄漏。
因此,当一个类作为其他类的基类,并且该基类有至少一个虚函数,那么它的析构函数应该声明为虚函数。这样,在删除一个指向派生类对象的基类指针(或引用)时,将自动调用派生类的析构函数
8.对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
使用普通函数进行对象成员访问时,编译器可以在编译时静态地确定要调用哪个函数,因为它们不是虚函数。这使得编译器能够生成更高效的代码,并且对象成员的访问速度更快
相比之下,虚函数被设计为动态绑定,即在运行时才能决定要调用哪个函数。因此,在调用虚函数时需要额外的开销来查找正确的函数指针,并对其进行间接调用。这种额外的开销可能会减慢代码的执行速度,所以访问虚函数通常比访问普通函数慢一些
9.虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
虚函数表是在编译期间生成的,它存储了一个类中所有虚函数的指针。当类被实例化时,每个对象都有一个指向该类虚函数表的指针。虚函数表位于程序的数据段中,属于类定义的静态成员,并且对于每个具有虚函数的类,都会单独生成一个虚函数表。虚函数表的地址通常存在于对象的内存布局中的一个指针成员中
10.C++菱形继承的问题?虚继承的原理?
C++中的菱形继承问题是指在一个类继承自两个虚基类,而这两个虚基类又都继承自同一个基类,从而导致派生类中存在两份相同的基类数据成员,从而产生二义性。
为了解决这个问题,C++中提供了虚继承机制。虚继承可以让某个类成为虚基类,使得其派生类只包含一份虚基类子对象。当有多个派生类继承同一个虚基类时,虚基类子对象会被共享,从而避免了菱形继承所带来的数据冗余和二义性问题。
虚继承的原理是,在虚继承中,派生类不再直接继承虚基类的成员变量和成员函数,而是维护虚基类子对象的指针或偏移量,通过这些信息来访问虚基类的成员。这样就能保证每个虚基类子对象仅在内存中存在一份,并且所有派生类都能够正确地访问它。
11.什么是抽象类?抽象类的作用?
抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
抽象类是一个不能被实例化的类,其中至少有一个抽象方法,即只有方法签名,没有实现。抽象类的作用是为了被其它类继承并实现抽象方法,从而强制要求子类实现某些共性的特定行为或操作,以便将代码复用和提供一种标准的接口规范。
12.为什么父类对象不能 实现多态,而必须是父类的指针/引用
因为子类对象赋值给父类对象时不会将子类对象的虚表拷贝给父类,也就无法运行时绑定,而且就算是拷贝,父类也区分不了此时的父类对象的虚表是父类本身的虚表还是从子类拷贝过来的虚表,所以只能实现静态绑定,无法实现多态