实例说明C++的virtual function的作用以及内部工作机制初探
C++为何要引入virtual function?
来看一个基类的实现:
1 class CBase 2 { 3 public: 4 CBase(int id) : m_nId(id), m_pBaseEx(NULL) { 5 printf(" Base constructor for id=%d\n", id); 6 if (id > 0) { 7 m_pBaseEx = new int[id]; 8 for (int idx = 0; idx < m_nId; ++idx) { 9 m_pBaseEx[idx] = idx + 1 + m_nId; 10 } 11 } 12 } 13 //virtual 14 ~CBase() { 15 if (m_pBaseEx) 16 delete []m_pBaseEx; 17 printf(" Base destructor for id=0x%X\n", m_nId); 18 } 19 int getId() { 20 return m_nId; 21 } 22 //virtual 23 void action() { 24 if (m_nId > 0) { 25 for (int idx = 0; idx < m_nId; ++idx) { 26 printf(" %d+%d=%d ", idx + 1, m_nId, m_pBaseEx[idx]); 27 } 28 printf("\n"); 29 } 30 } 31 private: 32 int m_nId; 33 int* m_pBaseEx; 34 };
CBase类内部有两个成员变量,m_nId记录一个整数,m_pBaseEx是一个指针,m_nId为正整数时,构造函数会动态申请一块内存以存放m_nId个整数(分别记录1到m_nId与m_nId的和),m_pBaseEx指向这块申请的内存;action方法用于输出一组加法算式。
一个派生类CDerived的实现如下:
1 class CDerived : public CBase 2 { 3 public: 4 CDerived(int id) : CBase(id), m_pEx(NULL) 5 { 6 printf(" Derived constructor for id=%d\n", id); 7 if (id > 0) { 8 m_pEx = new int[id]; 9 for (int idx = 0; idx < id; ++idx) { 10 m_pEx[idx] = (idx + 1) * id; 11 } 12 } 13 } 14 ~CDerived() { 15 if (m_pEx) 16 delete []m_pEx; 17 printf(" Derived destructor for id=%d\n", getId()); 18 } 19 void action() { 20 int id = getId(); 21 if (id > 0) { 22 for (int idx = 0; idx < id; ++idx) { 23 printf(" %d*%d=%d ", idx + 1, id, m_pEx[idx]); 24 } 25 printf("\n"); 26 } 27 } 28 private: 29 int* m_pEx; 30 };
CDerived类除了继承自CBase类的两个成员变量(即m_nId和m_pBaseEx),自身还另有一个成员变量int* m_pEx,m_nId为正整数时,CDerived的构造函数会动态申请一块内存以存放m_nId个整数(分别记录1到m_nId与m_nId的积),m_pEx指向这块申请的内存;action方法用于输出一组乘法算式。
main函数实现如下:
1 int main() 2 { 3 printf(" Size of derived obj: %u, size of base obj: %u.\n\n", sizeof(CDerived), sizeof(CBase)); 4 while (true) { 5 std::vector<CBase*> vec; 6 for (int idx = 0; idx < 10; ++idx) { 7 CBase* pObj = new CBase(idx); 8 vec.push_back(pObj); 9 } 10 for (int idx = 0; idx < 10; ++idx) { 11 CDerived* pObj = new CDerived(idx); 12 vec.push_back(pObj); 13 } 14 for (size_t idx = 0; idx < vec.size(); ++idx) { 15 vec[idx]->action(); 16 } 17 for (size_t idx = 0; idx < vec.size(); ++idx) { 18 delete vec[idx]; 19 } 20 int nVal = getchar(); 21 if (nVal == 'q') { 22 break; 23 } 24 } 25 return 0; 26 }
生成32位可执行程序运行结果如下:
Size of derived obj: 12, size of base obj: 8. Base constructor for id=0 Base constructor for id=1 ...... Base constructor for id=9 Base constructor for id=0 Derived constructor for id=0 Base constructor for id=1 Derived constructor for id=1 ...... Base constructor for id=9 Derived constructor for id=9 1+1=2 1+2=3 2+2=4 1+3=4 2+3=5 3+3=6 1+4=5 2+4=6 3+4=7 4+4=8 1+5=6 2+5=7 3+5=8 4+5=9 5+5=10 1+6=7 2+6=8 3+6=9 4+6=10 5+6=11 6+6=12 1+7=8 2+7=9 3+7=10 4+7=11 5+7=12 6+7=13 7+7=14 1+8=9 2+8=10 3+8=11 4+8=12 5+8=13 6+8=14 7+8=15 8+8=16 1+9=10 2+9=11 3+9=12 4+9=13 5+9=14 6+9=15 7+9=16 8+9=17 9+9=18 1+1=2 1+2=3 2+2=4 1+3=4 2+3=5 3+3=6 1+4=5 2+4=6 3+4=7 4+4=8 1+5=6 2+5=7 3+5=8 4+5=9 5+5=10 1+6=7 2+6=8 3+6=9 4+6=10 5+6=11 6+6=12 1+7=8 2+7=9 3+7=10 4+7=11 5+7=12 6+7=13 7+7=14 1+8=9 2+8=10 3+8=11 4+8=12 5+8=13 6+8=14 7+8=15 8+8=16 1+9=10 2+9=11 3+9=12 4+9=13 5+9=14 6+9=15 7+9=16 8+9=17 9+9=18 Base destructor for id=0x0 Base destructor for id=0x1 ...... Base destructor for id=0x9 Base destructor for id=0x0 Base destructor for id=0x1 ...... Base destructor for id=0x9
从上面的结果看,指向CDerived类对象的指针被加入std::vector<CBase*>后,通过vector访问这些指针指向的对象时,这些对象就完全被当作CBase类对象在使用了:
vec[idx]->action()执行的是CBase类的action方法,导致输出结果里加法表被输出了两篇;
delete vec[idx]也只是释放了CBase类对象所占用的动态申请的内存,导致内存泄漏的问题。
对于上面的程序,我们可以把 std::vector<CBase*> vec 分拆成两个:std::vector<CBase*> vec1 和 std::vector<CDerived*> vec2,10个CBase类对象指针加到vec1里,而10个CDerived类对象指针加到vec2里。这样的确可以解决问题,但是程序语言上这种限制(或曰缺陷)会导致编程上额外代码的开销,尤其是有很多派生类以及多级派生的情形,为了应对语言上的缺陷,额外的代码会显得臃肿而笨拙,这也有违面向对象编程的初衷。
C++解决上述缺陷的办法就是引入virtual function。以上面的示例为例,只需要把CBase类的构造函数和action方法加上virtual声明(即去掉CBase类代码段里第13行和第22行里的“//”),重新生成可执行程序,运行后得到输出结果如下:
Size of derived obj: 16, size of base obj: 12. Base constructor for id=0 Base constructor for id=1 ...... Base constructor for id=9 Base constructor for id=0 Derived constructor for id=0 Base constructor for id=1 Derived constructor for id=1 ...... Base constructor for id=9 Derived constructor for id=9 1+1=2 1+2=3 2+2=4 1+3=4 2+3=5 3+3=6 1+4=5 2+4=6 3+4=7 4+4=8 1+5=6 2+5=7 3+5=8 4+5=9 5+5=10 1+6=7 2+6=8 3+6=9 4+6=10 5+6=11 6+6=12 1+7=8 2+7=9 3+7=10 4+7=11 5+7=12 6+7=13 7+7=14 1+8=9 2+8=10 3+8=11 4+8=12 5+8=13 6+8=14 7+8=15 8+8=16 1+9=10 2+9=11 3+9=12 4+9=13 5+9=14 6+9=15 7+9=16 8+9=17 9+9=18 1*1=1 1*2=2 2*2=4 1*3=3 2*3=6 3*3=9 1*4=4 2*4=8 3*4=12 4*4=16 1*5=5 2*5=10 3*5=15 4*5=20 5*5=25 1*6=6 2*6=12 3*6=18 4*6=24 5*6=30 6*6=36 1*7=7 2*7=14 3*7=21 4*7=28 5*7=35 6*7=42 7*7=49 1*8=8 2*8=16 3*8=24 4*8=32 5*8=40 6*8=48 7*8=56 8*8=64 1*9=9 2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81 Base destructor for id=0x0 Base destructor for id=0x1 ...... Base destructor for id=0x9 Derived destructor for id=0 Base destructor for id=0x0 Derived destructor for id=1 Base destructor for id=0x1 ...... Derived destructor for id=9 Base destructor for id=0x9
这个输出结果如期输出了加法表和乘法表,派生类对象也如期得到释放。
virtual function内部机制初探
那么,virtual function是怎么起作用的呢?具体来说,在上面的示例中, 通过std::vector<CBase*>对象vec内的一个单元指针调用action方法,具体是怎么判别该调用哪一个类的action方法的呢?
对比上面的两个输出结果的开头部分可以看到,CBase类的析构函数和action方法加了virtual声明后,CBase类的sizeof值由8变成了12,CDerived类的sizeof值也由12变成了16。CBase类里定义了两个成员变量,一个int型,一个int*型,在32位程序里,这两个分量各占4个字节,sizeof值等于8是符合预期的,加了virtual声明之后,内部成员发生了什么变化?利用VS2010调试器来跟踪看一下。
对照上图可以看到,加了virtual声明之后,C++的编译器给CBase类增加了一个叫做__vfptr的成员变量,virtual function pointer,虚拟函数指针,正是这个指针变量使得CBase的sizeof值增加了4个字节,增加了这个隐藏的成员变量之后,类的构造函数会相应地增加对这个变量进行赋值的操作;从图中可以看出这个__vfptr指向一个叫做 CBase::`vftable' 的实体,其内部数据如下:
CBase::`vftable' ,CBase的virtual function table(虚拟函数表),内部是一组指针:第一个指针指向CBase::`vector deleting destructor'(unsigned int),这个应该就是对应CBase的析构函数~CBase(),原来它内部表示是这样的,而且还带了一个unsigned int参数,不知道具体指代什么,后面还会看到派生类CDerived的析构函数内部表示是CDerived::`scalar deleting destructor(unsigned int);第二个指针指向CBase::action(void);随后是一个NULL指针,标示vftable的完结。
把上面示例代码中CBase类里两个virtual函数顺序反一下,即把析构函数放到最后,重新生成程序调试运行,发现CBase的虚拟函数表里的指针顺序相应也会调整,如下图所示:
进一步跟踪查看,可以发现10个CBase对象都有各自的__vfptr成员变量,但这些变量都指向同一张虚拟函数表,另外,从虚拟函数表的内容我们也能确认一个类里的函数/方法在内存里只有一个实例(实质上会对应成C语言的函数实例形式,内部会有一些转换处理,比如增加一个对应类的对象指针的参数)。
再来看CDerived类对象的情形:
从这个图看到CDerived类里没有自身的__vfptr成员变量,但它从CBase类里继承了__vfptr成员变量,如下图所示:
可以看到,CDerived类对象的__vfptr指向的是CDerived类的虚拟函数表,其内容如下图所示):
这就可以解释上面那个疑问了:通过std::vector<CBase*>对象vec内的一个单元指针调用action方法,怎么判别实际应该调用哪一个类的action方法。实例化一个CDerived类对象时,该类的构造函数会让__vfptr成员指向CDerived类的虚拟函数表,该函数表指明了action方法和析构函数的具体例程,这样,即便把CDerived类对象指针放进std::vector<CBase*>里,后续从vector里统一当成CBase*指针来调用action方法时,都会从其成员变量__vfptr所指向的虚拟函数表里查找匹配的调用实例。
从std::vector<CBase*>里看的效果如下图所示:
进而可以推断,派生类和基类的虚拟函数表是可以不一样大的,前者可以大于后者。实际验证一下,在CDerived类内部增加一个virtual函数,如下:
virtual void test() { printf(" In derived.\n"); }
并增加一个派生于CDerived类的新类CDerived2:
1 class CDerived2 : public CDerived 2 { 3 public: 4 CDerived2(int id) : CDerived(id) {} 5 void test() { 6 printf(" In derived2.\n"); 7 } 8 };
相应地把main函数实现改为:
1 int main() 2 { 3 printf(" Size of derived2 obj: %u, size of derived obj: %u, size of base obj: %u.\n\n", sizeof(CDerived2), sizeof(CDerived), sizeof(CBase)); 4 while (true) { 5 std::vector<CBase*> vec; 6 std::vector<CDerived*> vec2; 7 for (int idx = 0; idx < 10; ++idx) { 8 CBase* pObj = new CBase(idx); 9 vec.push_back(pObj); 10 } 11 for (int idx = 0; idx < 10; ++idx) { 12 CDerived* pObj = new CDerived(idx); 13 vec.push_back(pObj); 14 vec2.push_back(pObj); 15 } 16 CDerived2* pObj = new CDerived2(11); 17 vec2.push_back(pObj); 18 for (size_t idx = 0; idx < vec.size(); ++idx) { 19 vec[idx]->action(); 20 } 21 for (size_t idx = 0; idx < vec2.size(); ++idx) { 22 vec2[idx]->test(); 23 } 24 for (size_t idx = 0; idx < vec.size(); ++idx) { 25 delete vec[idx]; 26 } 27 delete vec2[vec2.size() - 1]; 28 29 int nVal = getchar(); 30 if (nVal == 'q') { 31 break; 32 } 33 } 34 return 0; 35 }
重新生成程序运行,得到如下输出结果:
Size of derived2 obj: 16, size of derived obj: 16, size of base obj: 12. Base constructor for id=0 ...... Base constructor for id=9 Base constructor for id=0 Derived constructor for id=0 ......Base constructor for id=9 Derived constructor for id=9 Base constructor for id=11 Derived constructor for id=11 1+1=2 ...... 1+9=10 2+9=11 3+9=12 4+9=13 5+9=14 6+9=15 7+9=16 8+9=17 9+9=18 1*1=1 ...... 1*9=9 2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81 In derived. ......In derived. In derived2. Base destructor for id=0x0 ...... Base destructor for id=0x9 Derived destructor for id=0 Base destructor for id=0x0 ......Derived destructor for id=9 Base destructor for id=0x9 Derived destructor for id=11 Base destructor for id=0xB
这个结果是符合预期的。进一步调试运行,依次来看这三个类的虚拟函数表:
这是vec里的一个指针指向的CBase类对象,其__vfptr成员指向CBase类的虚拟函数表,该函数表存有两个函数指针,符合预期。
只是不知为何,这里CBase类的析构函数的内部表示名却是CBase::`scalar deleting destructor'(unsigned int),而不是此前看到的CBase::`vector deleting destructor'(unsigned int)。
这是vec2里的一个指针指向的CDerived类对象,其__vfptr成员指向CDerived类的虚拟函数表,该函数表从图中__vfptr变量的展开视图只能看到两个函数指针,但从__vfptr变量指向的虚拟函数表的内存地址看确实是有三个函数指针,这样才是符合预期的。红框内的函数指针没有展现出来,这应该是VS2010调试器的一个bug。
这是vec2里最后那个指针指向的CDerived2类对象,其__vfptr成员指向CDerived2类的虚拟函数表,该函数表也有三个函数指针,符合预期。不过界面显示上也有上面发现的问题。
对照CDerived类和CDerived2类的虚拟函数表可以发现:
(1)CDerived2类的action()的例程地址和CDerived类的action()的例程地址是同一个,这是因为CDerived2类没有定义自己的action方法,所以就直接继承了直接上级类的action方法。
(2)CDerived2类也没有显式定义自己的析构函数,但是CDerived2类的析构函数的例程地址和CDerived类的析构函数的例程地址却不相同,这是因为C++编译器会为缺少显式析构函数的代码补充上缺省析构函数。
(3)CDerived2类有自己的test方法实现,而且test方法在CDerived类里被声明为virtual,所以CDerived2类的test方法的例程地址和CDerived类的test方法的例程地址应该不相同。
上面的验证也证实了前面的推测,即派生类的虚拟函数表的条目数是可以大于基类的虚拟函数表的条目数的。
由上面的探讨过程,很容易理解:对于一个带有virtual function的基类,强行把该基类的析构函数加上virtual声明是个很好的习惯,从而避免由此引起的内存泄漏问题。或许以后的C++会自动做到这一点。
最后再梳理一下虚拟函数表。前述的虚拟函数表就是一个函数指针数组,该数组以一个NULL指针结尾。光是这这个数组结构还不足于由基类对象指针和一个方法名就能匹配到数组里的函数指针的。虚拟函数表应该还有一块内存结构,用于由类名和方法名去匹配该类中虚函数的序号;有了这个序号就可以到函数指针数组里匹配到具体的函数指针了。