此文章关于多态的代码全部是使用Visua Studio2019 (x86) 实现的,C++多态在不同编译器中的实现细节可能不同,所以部分情况下相同代码运行结果可能不同,在此声明。
目录
多态的概念
多态的定义与实现
虚函数
虚函数的重写
虚函数重写的两个例外
C++11 override & final
重载&重写&重定义
纯虚函数及抽象类
接口继承与实现继承
多态的原理
虚函数存在哪里?虚表存在哪里?
多态的原理
动态绑定与静态绑定
多继承下的虚函数表
多态,顾名思义,即多种形态。具体来说就是对于某个行为,不同的对象去完成该行为会产生不同的状态。
就像 “叫” 这个行为,猫来完成时其叫声是 “喵喵喵” ,而狗则是 “汪汪汪”。
还有坐公交车,老人和小孩坐车是免票,而其他人则是全价票。
同样的一个动作,不同的对象来执行,其实现的方法不同。
多态是建立在继承基础上的,在不同的继承关系下的类对象,调用同一函数,产生不同的行为。
在C++中,继承体系下构成多态需要满足以下两个条件:
1.必须通过基类指针或引用调用虚函数。
2.被调用函数必须是虚函数,且派生类必须对基类虚函数进行重写。
虚函数:即被 virtual 修饰的类的成员函数。
class A {
public:
virtual int add(int a, int b) {
return a + b;
}
}
使用virtual修饰虚函数时,只需要修饰类内部修饰即可。
class A {
public:
virtual void print_1();
};
virtual void A::print_1() {
cout << "1 : class A" << endl;
}
虚函数的重写指的是:派生类中有一个与基类完全相同的虚函数 (即派生类的虚函数与基类虚函数的返回值类型,参数列表,函数名完全相同),称子类的虚函数重写了基类的虚函数。
class A {
public:
virtual void print_1() {
cout << "1 : class A" << endl;
}
};
class B : public A {
public:
virtual void print_1() {
cout << "1 : class B" << endl;
}
};
void func(A& a) {
a.print_1();
}
int main() {
A a;
B b;
func(a);
func(b);
return 0;
}
PS.子类重写父类虚函数时子类虚函数可以不加virtual关键字,这样也可以构成重写——继承后基类的虚函数被继承下来了,在派生类之中依旧保持虚函数属性。但是这种写法并不规范,并不建议使用。
1.协变
基类与派生类虚函数返回值不同
派生类重写基类虚函数时,若基类虚函数的返回类型是基类的指针或引用,新的返回类型是派生类的指针或引用,依旧可以构成重写。这样的重写被称为返回类型协变。
class A {
public:
virtual A& print_1() {
cout << "1 : class A" << endl;
return *this;
}
};
class B : public A {
public:
virtual B& print_1() {
cout << "1 : class B" << endl;
return *this;
}
};
需要注意的是,只有返回对应类型的指针或引用才会构成协变,单纯返回派生类或基类类型并不构成协变。
class A {
public:
virtual A print_1() {
cout << "1 : class A" << endl;
return *this;
}
};
class B : public A {
public:
virtual B print_1() {
cout << "1 : class B" << endl;
return *this;
}
};
2.析构函数的重写
基类与派生类析构函数函数名不同
当基类的析构函数为虚函数时,此时派生类的虚构函数只要定义,无论是否被virtual关键字修饰,都与基类析构函数构成重写。
class A {
public:
virtual ~A() {
cout << "A" << endl;
}
};
class B : public A {
public:
virtual ~B() {
cout << "B" << endl;
}
};
int main() {
A* a = new B;
delete a;
return 0;
}
析构函数的重写最常见的应用便是基类指针指向派生类对象的情况下,如果我们所使用的派生类对象是从堆上 new 来的,那么,在没有重写析构函数的情况下,在基类对象 delete 时,只会调用基类的析构函数释放基类的空间,而派生类对象所占用的空间则不会被释放,就会导致内存泄漏。
1. override
在C++中,override关键字用来表示当前虚函数重写了基类虚函数。
在函数比较多,继承关系负责的情况下,override关键字可以起到提示编写者与其后继者某个函数重写了基类的虚函数——也代表这个函数不是派生类中定义的。
override关键字可以强制编译器去检查某个函数是否重写了基类虚函数,若否,则会报错。
class A {
public:
virtual void print() {
cout << "a" << endl;
}
};
class B : public A {
public:
virtual void print() override {
cout << "b" << endl;
}
};
当override修饰的函数并非重写的基类虚函数时,会报错:
class A {
public:
virtual void print() {
cout << "a" << endl;
}
};
class B : public A {
public:
virtual void not_print() override {
cout << "b" << endl;
}
};
2.final
final用于修饰虚函数,表明该虚函数不能被重写
我们是可以直接用final修饰父类虚函数的,表明该虚函数不能被重写,但并不建议这样做,虚类既然与平常的类不同,其实例化时自然会比普通的类多一份开,而final只是指明了这个虚函数不可以被重写,但是虚类的相关开销依旧在。
class A {
public:
int a;
virtual void print() final{
cout << "a" << endl;
}
};
class B : public A {
public:
int b;
void a_print() {
cout << "b" << endl;
}
};
class C {
public:
int a;
void print() {
cout << "a" << endl;
}
};
class D : public C {
public:
int b;
void a_print() {
cout << "b" << endl;
}
};
int main() {
B b;
D d;
b.b = 3;
b.a = 2;
d.a = 2;
d.b = 3;
while (0);
return 0;
}
重载
将语义或功能相似的函数用同一个名字表示。
条件:
1.两个函数在同一个作用域
2.函数名相同
3.参数列表不同
4.返回值可以不同
重写 (覆盖)
覆盖是C++中多态的基础,使派生类可以重新定义基类的虚函数。
条件:
1.两个函数别属于基类成员函数与派生类成员函数
2.两个函数都必须是虚函数
3.函数名相同 (析构函数除外,至少看起来不同)
4.返回值类型相同 (协变例外)
5.参数列表相同
6.函数的访问权限可以不同 (如基类中的虚函数的访问权限是 private ,派生类中可以重写为public、protected)
重定义 (同名隐藏)
在继承体系中,子类与父类中有同名成员,子类可以屏蔽父类指针对子类中父类成员的直接访问。(但是可以使用 :: 访问父类成员)
条件:
1.在不同作用域
2.函数名相同
3.返回值可以不同
4.在继承体系中,只要不构成重写就是重定义
1.参数相同时,基类没有关键字virtual修饰,基类的函数被隐藏
2.参数不同时,无论有没有virtual关键字修饰,基类函数都被隐藏
class A {
void print() {
cout << "a" << endl;
}
};
class B : public A {
public:
int b;
void print() {
cout << "b" << endl;
}
};
int main() {
A a;
B b;
a.print();
b.print();
while (0);
return 0;
}
在有些时候,基类的某些行为在没有被派生类重写之前会非常不明确,或者说,不合常理,不好定义。比如我们先前举过的例子——动物的 “叫” 这个行为,在动物这个基类中我们很难定义它具体是一种什么样的行为,表达方式是怎样的。我们是无法对这个行为做出具体的定义的,无法提供实现。但是作为一种 “共性” 我们又想要在基类中声明它。
于是乎,在虚函数的基础上,C++又提出了一种新的函数——纯虚函数。
在虚函数后面写上 = 0 ,则这个函数就是纯虚函数。
包含纯虚函数的类叫做接口类——也被称为抽象类,抽象类不能实例化对象。
派生类继承抽象类后,如果不重写基类的纯虚函数,也无法实例化对象。
纯虚函数规定了派生类必须重写,同时也实现了接口继承。
class A {
public:
virtual void print() = 0;
};
class B : public A {
public:
int b;
virtual void print() {
cout << "b" << endl;
}
};
int main() {
B b;
b.print();
while (0);
return 0;
}
普通函数的继承是一种实现继承,派生类继承了基类的函数,可以使用函数,这种继承继承的是函数的实现。
虚函数的继承则是一种接口继承,派生类继承的只是虚函数的接口,目的是为了重写,从而达成多态,继承的是一个接口。
所以,如果不实现多态,就不要把函数设置为虚函数。
class A {
public:
int _a1;
int _a2;
virtual void study() {
cout << "study" << endl;
}
A()
:_a1(1)
,_a2(2)
{}
};
int main() {
A a;
return 0;
}
以上代码中,类A的实例化对象a在实例化后,其内存如上图所示,明显在我们所定义的变量a1, a2之前,还有着一串神奇的数字——00 75 9b 34。(据说有些编译器这玩意可能在后边)
单从表面上来看,它应该是一个指针。而实质上,它的确是一个指针。
在VS调试模式下的局部变量窗口中,我们可以很清楚的看到,这玩意还不是一个简单的指针,它是一个二级指针,指针名为 __vfptr (v——virtual f——function)我们通常称其为虚函数表指针。
进一步将这个虚函数表指针展开来看
很容易看到,这个虚函数表指针指向的对象是一个函数指针,而函数指针指向的对象则是我们代码中所定义的虚函数——A::study(void)
一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被存放到虚函数表中。
虚函数表也简称虚表。
下面,我们将之放入继承体系之中来分析。
class A {
public:
int _a1;
int _a2;
virtual void func1() {
cout << "A::func1" << endl;
}
virtual void func2() {
cout << "A::func2" << endl;
}
virtual void func3() {
cout << "A::func3" << endl;
}
A()
:_a1(1)
,_a2(2)
{}
};
class B : public A{
public:
int _b1;
int _b2;
virtual void func1() {
cout << "B::func1" << endl;
}
virtual void func2() {
cout << "B::func2" << endl;
}
B()
:_b1(3)
,_b2(4)
{}
};
int main() {
A a;
B b;
return 0;
}
通过以上调试图片,可以看出
1.基类中有一个虚函数表指针,虚函数表中存储着三个基类虚函数的地址。
2.派生类对象中也有一个虚函数表指针,且该虚函数表由两部分构成——派生类重写的虚函数以及从基类中继承的虚函数。
3.基类对象与派生类对象的虚函数表中的内容是不同的,由于我们在派生类代码中重写了虚函数func1以及func2,所以派生类的虚函数表中对应位置存储的是被派生类重写后的B::func1(void)以及B::func2(void)。所以虚函数的重写也被称为覆盖,覆盖指的是虚表中虚函数的覆盖,派生类重写的虚函数地址覆盖了基类的虚函数地址。重写是语法层面的称呼,覆盖是原理层面的称呼。
4.虚函数表本质是一个存放虚函数指针的指针数组,一般情况下这个数组最后会放一个nullptr。
5.派生类虚表生成的过程如下:首先,将基类中虚表内容拷贝一份到派生类之中;而后,如果派生类重写了某个虚函数,用派生类自身的虚函数的地址覆盖虚函数表中基类虚函数的地址;最后,派生类将派生类之中新增的虚函数增加到虚函数表的最后。
6.虚函数表中只存放继承或派生类自身定义的虚函数,非虚函数不会被存放。
与普通的函数一样,虚函数存放于代码段中,只是它的指针被存储到了虚表之中。虚表并不存放于对象之中,对象中存储的是虚表指针。
虚表存放于代码段之中。(个人实力原因暂时无法证明)
分析了这么多,多态的原理究竟是什么?这里就需要用到我们之前屡试不爽的方法——通过基类指针/引用调用函数:
void Func(A* tmp) {
tmp->func1();
}
int main() {
A a;
B b;
Func(&a);
Func(&b);
return 0;
}
通过对以上代码进行反汇编,我们可以看到在使用基类引用调用虚函数时的操作如下:
mov eax,dword ptr [tmp]
tmp中存储的是基类对象的指针,将tmp中的值移动到eax寄存器中。
mov edx,dword ptr [eax]
将eax的值指向的内容存储到edx中,即将基类对象前4个字节(虚表指针)移动到edx中。
mov ecx,dword ptr [tmp]
取tmp指针存储的值到ecx寄存器中
mov eax,dword ptr [edx]
取edx寄存器中的值指向的内容,相当于取了虚表的前四个字节,即虚表中存储的第一个虚函数的地址。如果我们此处调用的是fun2或者func3,则会在edx基础上偏移一定字节。
call eax
调用eax寄存器中存储的指针指向的函数。
静态绑定
静态绑定又被称为前期绑定或早绑定,在程序编译期间便已经确定了程序的行为,也被称为静态多态。
动态绑定
动态绑定又被称为后期绑定或晚绑定,在程序运行期间,根据具体拿到的类型来确定程序的行为,调用具体的函数,也称为动态多态。
我们上文所主要讲解使用的是单继承情况下的虚函数表。这里我们就简单研究下如果在多继承体系下,虚函数表又会有怎样的不同。是将多个基类的虚函数表直接合为一个,还是分别存储与派生类对象中分属于不同基类的存储空间里。
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 PrintVTable(VFPTR vTable[])
{
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
return 0;
}
可能是之前对VS的编译器做过太多设置的修改,这个程序在Debug模式下运行会发生崩溃,崩溃原因是虚函数表末尾并没有按照我们预期的那样缀上一个nullptr。
但是在release模式下可以正常地运行,并得到一下结果:
通过观察图示结果我们可以很直观的看到,多继承情况下,派生类重写的虚函数会放在第二个继承基类部分的虚函数表中,而未重写虚函数则会在第一个继承基类部分的虚函数表中。
同时,在多个基类中有相同的函数(函数名,返回值,参数列表)时,默认会重写先继承的基类中的对应函数。