本页内容是作为 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!
上节课的作业和第三周的内容都探讨了与虚函数相关的问题,今天我们可以着重来分析一下虚函数是怎么运作的。
来考虑一下如下继承情况:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_5_class1.svg” width=“245”/>
</html>
Class Base {
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();
private:
int data1, data2;
};
Class Derived:Base {
public:
virtual void vfunc1();
void func2();
private:
int data3;
};
Class SubDerived:Dervived {
public:
virtual void vfunc1();
void func2();
private:
int data1, data2;
};
这三个类中, Derived
、SubDerived
类均存在对父类的重写。我们知道,子类对虚函数的重写可以实现多态。那么虚函数到底是怎么实现的?第三周的笔记(Section 2.3)我简要的叙述了一下这个过程,今天用图示的方法再回顾一遍。
在叙述过程之前,我们需要明白:和普通的函数调用不一样,虚函数的调用是通过指针查询一个表(数组)来实现的;这个表就像是虚函数的地址簿一样。
当一个类中有虚函数的声明时候,该类的对象里就会多一个指针,这个指针称为虚指针(Virtual Pointer);同时,类中还会多出一张表用于保存该类中所有虚函数定义所在的入口地址。这个表被称作为虚函数表(Virtual Table,以下简称虚表)。虚指针是以对象为单位,指向对应类的虚表的入口;而虚表是以类为单位;该类的所有对象共享一张虚表;也就是说,只要是通过这个类实例化的对象,他们包含的虚函数的入口地址,都存在这张表里。
在调用的时候,编译器首会根据对象的具体类型初始化一个指向当前类的虚指针。通过这个虚指针,我们就能在虚表里找到对应的虚函数的地址,从而可以调用正确的虚函数。比如上图的 Base
类,其调用虚函数的过程如下图:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/virtualfunc_1.svg” width=“850”/>
</html>
这是单个类调用虚函数的情况。不过我们都知道,虚函数主要应用于类的继承关系中去实现多态;因此我们必须考虑继承的情况。
当继承关系确定的时候,子类会构造一个与父类相同的虚表;也就是说,虚表可以继承。
打个比方,如果基类有3个虚函数,那么基类的虚表中就有三项(虚函数地址)。而派生类的虚表中也至少有三个地址。如果重写了相应的虚函数,那么派生类虚表中对应的虚函数地址就会改变,转而指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
因此,可以知道子类的虚函数调用分为两种情况:
本节开头的例子中 Derived
类重写了 vfunc1()
, 而继承了 vfunc2()
, 其调用对象模型图可以表示如下:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_5_class2a.svg” width=“850”/>
</html>
此时,Derived
类中的虚函数表中 vfunc1()
的地址,已经由 Base
类中对应虚函数的地址改为了在 Derived
类中重写的 vfunc1()
的地址;而没有重写的 vfunc2()
, 则沿用继承自 Base
类的对应虚函数地址。
如果子类同时继承多个父类,那么该子类将继承所有父类中的虚函数表,然后根据子类中重写的情况覆盖对应虚表中的虚函数地址。
一个源程序需要经过编译、连接才能形成可执行文件,在这个过程中要把调用函数的名称和对应函数在内存中的区域关联在一起,这个过程就是绑定(Binding),又称联编。
绑定分为静态绑定和动态绑定。这两者的根本目的都是在为被调用函数寻找其所处内存的位置,只是寻址的方式有所不同。
静态绑定又称静态联编,是指在编译程序时候,被调用函数的信息中就包含了函数在内存中所处的位置。如果用汇编语言来表示静态绑定,可以直接写成:call + address
。
动态绑定又称动态联编,也就是虚函数绑定其函数在内存中的地址的过程。跟静态绑定不同的是,虚函数无法在编译的时候确定我们需要具体调用哪个虚函数;因为虚函数的调用存在于对象里,而对象必须要在 run-time 中才能建立。因此,我们不能在编译的时候直接得到对应虚函数的地址,而是在程序运行中通过对象的创建,才能通过虚指针去找到对应的虚函数。对于虚函数的绑定来说,这个过程是动态的,因此称为动态绑定。
虚函数进行动态绑定的过程大概如下:
p
,p
通过 upcast
的方式声明对象。p
即指向了 vptr
。vptr
,我们找到了类对应的虚表 vtbl
。vtbl
最终找到对应虚函数所在地址,调用完成。整个过程可以写成如下代码:
(*(p->vptr) [n]) (p);
我们把这个整个过程称为虚机制。其中n
代表了虚表数组的 Index, 而这个 Index 的顺序则是由我们决定的:在类中,越定义在前面的虚函数,在虚表中越靠前。
obj.func()
)这样的都是静态绑定。upcast
的过程,比如 Base* ptr = new Derived;
子类对象调用父类函数是通过 this
指针来完成的:
this
会指向子类对象call
,即 this→Basefunc()
this
指针会在此完成 upcast
过程,即 (*(this→vptr)[n]) (this)
const
修饰类成员函数和修饰全局函数的写法不同:
void func() const {} // member function
我们知道,const
实际上是表达了设计者的是否倾向于修改数据的意愿。成员函数也是相同的。对于成员函数,我们可以考虑下下面几种组合:
纵观这四个组合,我们发现只有第三条有问题:我们声明了一个 const
对象,就是希望不修改对象内容。但随后其调用的成员函数则是non-const
的,表明该函数允许修改对象内容,因此矛盾。
除开第三种组合,其余的组合从 const
的理念上都可以说的通。但需要注意的是这里仍然有例外。比如标准库 string
类的成员函数中有这么两个:
operator[](sizetype pos) const {...}
operator[](sizetype pos) {...}
在 string
类中,很可能存在多个函数对同一个字符串进行操作的情况。因此对于常量字符串,我们不需要考虑 COW(copy on write);但对于非常量字符串,我们则必须考虑 COW。
前面我们学习 new
和 delete
的时候得知 new
和 delete
不能重载;但我们可以通过对 new
和 detele
过程中起作用的函数进行重载,从而达到对 new
和 delete
重载的效果。
对于我们来说,new / delete
的功能性我们是没有办法改变的。我们只能用 new / delete
来申请和删除堆空间和对象。我们所能改变的就是如何为对象分配内存,而这个正是重载的的意义:使用自定义的 new / delete
往往能够更有效的管理和分配内存。
一种重载 new / delete
的方式是通过重载 operate new
和 operate delete
这两个函数。而对于这两个函数来说,重载又分全局重载和类成员重载两种。
全局重载的示例如下:
inline void * operator new(size_t size) { return myAlloc(size); }
inline void * operator new[] (size_t size) { return myAlloc(size); }
inline void * operator delete(size_t size) { myFree(ptr); }
inline void * operator delete[] size_t size) { myFree(ptr); }
类成员重载的实例如下:
class Foo {
....
static void* operator new(size_t size);
static void operator delete(void* pdead, size_t size);
/*array version*/
static void* operator new[](size_t size);
static void operator delete[](void* pdead, size_t size);
};
调用的方法如下:
Foo*p = new Foo;
delete p;
/*array version*/
Foo*pa = new Foo[n];
delete [] pa;
类中重载 operator new / delete
需要注意几点:
size_t
代表了对象数组的大小;在普通版本的 new
和 delete
中该值为 1
,该参数也是可选的。::
scope 操作符强制使用全局函数。new[]
创建的对象占用空间会额外多出一个计数器的空间,用于保存数组的大小。
C++ 中还提供了另外一种形式对 new
和 delete
进行重载:Placement 形式。这种形式在实际的操作上也是对 operator new / delete
的重载,但是调用的形式不同:
Foo* p = new (size_t, placement_arg) Foo;
重载的写法示例如下:
void* operator new(size_t size, void* start) { return start; };
void* operator new(size_t size, long extra) { return malloc(size + extra); };
而我们也需要针对这些不同的版本写出相应的 operator delete
的重载版本:
void operator delete(void*, void*) {};
void operator delete(void*, long) {};
这类的 operator delete
用于处理没有创建成功的对象所占用的内存空间。
size_t
。operator delete
不会被 delete
调用;只有在 new
调用的构造函数抛出异常的时候,才会调用,用于归还未能完全创建成功的对象所占的内存。operator delete
,编译器也不会报错;这只代表你告诉编译器放弃处理该异常。