C++ 基础教程之虚函数实例代码详解

虚函数的定义

虚函数:就是在基类的成员函数前加关键字virtual(即被virtual关键字修饰的成员函数),并在一个或多个派生类中被重新定义的成员函数;虚函数:就是在编译的时候不确定要调用哪个函数,而是动态决定将要调用哪个函数。它的作用就是为了能让这个函数在它的子类里面可以被重载,这样的话,编译器就可以使用后期绑定来达到多态了,也就是用基类的指针来调用子类的这个函数;虚函数的作用:在于用专业术语来解释就是实现多态性,多态性是将接口与实现进行分离,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数;用形象的语言来解释就是实现以共同的方法,但因个体差异,而采用不同的策略;虚函数用法格式为:virtual 函数返回类型 函数名(参数表) {函数体};

虚函数在 c++ 的继承体系中是一个非常重要概念,让我们可以在子类中复写父类的方法。学到这里我还不知道在 c++ 中是否有抽象类的概念,那么学习过虚函数我们就知道通过(纯)虚函数可以实现 java 中的抽象类,

要实现虚函数需要两个步骤进行修改

  • 在父类中,在函数GetName()前面加上virtual
  • 在子类中,在函数GetName()后面加上override

再次运行编译运行程序就得到我们想要结果了

Shap rectangle

下面开始正文

virtual 函数

示例代码如下:

#include <stdio.h>
class base {
public:
 virtual void name(){printf("base\n");};
 virtual ~base(){};
};
class plus: public base {
public:
 virtual void name(){printf("plus\n");};
};
void fv(base b){
 b.name();
}
void fp(base &b){
 b.name();
}
int main(){
 base b;
 plus p;
 fv(b);
 fv(p);
 fp(b);
 fp(p);
 return 0;
}

程序输出:

base base base plus

这里涉及到一个c++知识点-- 向上强制转换 :将派生类引用或指针转换为基类引用或指针。该规则使得公有继承不需要进行显示类型转化,它是is-a 规则的一部分。

相反的过程被称为-- 向下强制转换 ,向下强制类型转换必须是显示的。因为派生类可能对基类进行拓展,新增的成员变量和函数不能应用于基类。

隐式向上强制转换使得基类指针或引用可以指向基类对象或派生类对象,因此需要 动态联编 。C++ 使用虚成员函数函数满足这种需求。

动态联编

编译器在编译时要将调用的函数对应相应的可执行代码,此过程为 函数联编(binding) ,在C++因为函数重载的原因,需要查看调用函数名和传入参数才能确认是哪一个函数。在编译的时候可以确认使用哪一个函数的联编被称为 静态联编 或 早期联编 。

同时因为virtual函数的存在,编译工作变得更加复杂,如示例函数所示,具体使用的哪个类型对象不能确认。为此编译器必须生成一些代码,使得在程序运行的时候选择正确的虚函数,这被称为 动态联编 ,又被称为 晚期联编 。

为了验证上面所述我们可以做一组对照,首先我们用 gnu 工具 nm 来查看 sysbols,可以发现如下的部分:

$ nm virtual.exe | grep -c -E "plus|base"

然后我们改造一下上面的代码:

class base {
public:
 void name(){printf("base\n");}; // 修改
 virtual ~base(){};
};
class plus: public base {
public:
 void name(){printf("plus\n");}; // 修改
};

编译后重新执行 nm 命令:

nm virtual_.exe | grep -c -E "plus|base" 45

经过比对后我们会发现修改后缺少了以下symbols:

000000000040509c p .pdata$_ZN4plus4nameEv 0000000000402e00 t .text$_ZN4plus4nameEv 00000000004060a0 r .xdata$_ZN4plus4nameEv 0000000000402e00 T _ZN4plus4nameEv

动态联编在效率上要低于静态联编,在C++ 中默认使用静态联编。C++ 之父strousstup 认为 C++ 指导原则之一是不要为不使用的特性付出代价(cpu、memory等)。

所以在派生类不需要去重写基类函数时,则不要将其声明为virtual函数。

virtual 函数工作原理

虚函数表示每一个使用C++的开发者耳熟能详的东西,有一个道经典的试题如下:

#include <stdio.h>
class base
{
public:
 base(){};
 virtual ~base() { printf("base\n"); };
};
class plus : public base
{
public:
 plus(/* args */){};
 virtual ~plus() { printf("plus\n"); };
};
class plus2 : public base
{
public:
 plus2(/* args */){};
 ~plus2() { printf("plus2\n"); };
};
class plus3 : public base
{
public:
 virtual void name() { printf("plus3"); };
 plus3(/* args */){};
 virtual ~plus3() { printf("plus3\n"); };
};
class empty
{
private:
 /* data */
public:
 empty(/* args */){};
 ~empty() { printf("empty\n"); };
};
int main()
{
 base b;
 printf("base: %d\n", sizeof(b));
 plus p;
 printf("plus: %d\n", sizeof(p));
 plus2 p2;
 printf("plus2: %d\n", sizeof(p2));
 plus3 p3;
 printf("plus3: %d\n", sizeof(p3));
 empty e;
 printf("empty: %d\n", sizeof(e));
}

其最终输出的结果如下:

base: 8 plus: 8 plus2: 8 plus3: 8 empty: 1 empty plus3 base plus2 base plus base base

ps: 由于操作系统位数的影响结果可能有变动,在x64位系统中指针内存分配大小为 8 字节,x86 系统中指针内存分配大小为 4。

我们可以清楚的看到,只要存在虚函数不论是成员函数异或是析构函数,是在类中定义或继承都会有包含一个虚函数表。而这里的8字节就是分配给了虚函数表的指针。

我们可以通过gnu tool gdb 指令进行验证,在触发断点之后通过 info local 命令去查看:

(gdb) info locals
b = {_vptr.base = 0x555555755d20 <vtable for base+16>}
p = {<base> = {_vptr.base = 0x555555755d00 <vtable for plus+16>}, <No data fields>}
p2 = {<base> = {_vptr.base = 0x555555755ce0 <vtable for plus2+16>}, <No data fields>}
p3 = {<base> = {_vptr.base = 0x555555755cb8 <vtable for plus3+16>}, <No data fields>}
e = {<No data fields>}

我们可以看到每一个对象内都有一个指针指向vtable。

当一个基类声明一个虚函数后,在创建对象的时候会将该函数地址加入虚函数列表中,如果派生类重写了该函数,则会用新函数地址替换,如果其定义了新函数,则会将新函数的指针加入虚表中。

示例代码如下:

#include <stdio.h>
class base
{
public:
 base(){};
 virtual const char* feature(){return "test";};
 virtual void name() {printf("base\n");}
 virtual ~base() { printf("~base\n"); };
};
class plus : public base
{
public:
 plus(/* args */){};
 virtual void name() {printf("plus\n");}
 virtual void parant() {printf("base\n");}
 ~plus() { printf("plus\n"); };
};
int main()
{
 base b;
 printf("base: %ld\n", size_t(&b));
 plus p;
 printf("plus: %ld\n", size_t(&p));
}

仍然用 gdb 来验证,断点后通过 info vtbl 命令查看:

(gdb) info vtbl p
vtable for 'plus' @ 0x555555755d08 (subobject @ 0x7fffffffe010):
[0]: 0x555555554b4a <base::feature()>
[1]: 0x555555554bf8 <plus::name()>
[2]: 0x555555554c30 <plus::~plus()>
[3]: 0x555555554c66 <plus::~plus()>
[4]: 0x555555554c14 <plus::parant()>
(gdb) info vtbl b
vtable for 'base' @ 0x555555755d40 (subobject @ 0x7fffffffe008):
[0]: 0x555555554b4a <base::feature()>
[1]: 0x555555554b5c <base::name()>

当调用虚函数的时候,会在虚函数表中寻找对应的函数地址,因此它每一次调用动会多做一步匹配,相比静态联编的非虚函数要更加耗时。

需要注意的是构造函数不能声明为虚函数,而如果一个类作为除非不作为基类,否则建议声明一个虚析构函数。

总结