What & How & Why

C++面向对象高级编程(下)第二周

本页内容是作为 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;
};

这三个类中, DerivedSubDerived 类均存在对父类的重写。我们知道,子类对虚函数的重写可以实现多态。那么虚函数到底是怎么实现的?第三周的笔记(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 中才能建立。因此,我们不能在编译的时候直接得到对应虚函数的地址,而是在程序运行中通过对象的创建,才能通过虚指针去找到对应的虚函数。对于虚函数的绑定来说,这个过程是动态的,因此称为动态绑定。

虚函数进行动态绑定的过程大概如下:

  • 建立一个指向对象的指针 pp 通过 upcast 的方式声明对象。
  • 因为虚指针的位置位于对象地址的开头,所以有 指针p 即指向了 vptr
  • 通过 vptr ,我们找到了类对应的虚表 vtbl
  • 通过 vtbl 最终找到对应虚函数所在地址,调用完成。

整个过程可以写成如下代码:

(*(p->vptr) [n]) (p);
我们把这个整个过程称为虚机制。其中n 代表了虚表数组的 Index, 而这个 Index 的顺序则是由我们决定的:在类中,越定义在前面的虚函数,在虚表中越靠前

值得注意的是,动态绑定的判定有三个条件: -

  1. 需要用指针调用函数。所有对象直接调用函数(比如 obj.func())这样的都是静态绑定。
  2. 调用函数的指针要完成 upcast 的过程,比如 Base* ptr = new Derived;
  3. 调用的函数一定是虚函数。

子类对象调用父类函数

子类对象调用父类函数是通过 this 指针来完成的:

  • 当新建一个子类对象,并用这个对象调用父类函数的时候, this 会指向子类对象
  • 如果调用的是父类普通函数,那么调用方法为 call,即 this→Basefunc()
  • 如果调用的是虚函数,那么 this 指针会在此完成 upcast 过程,即 (*(this→vptr)[n]) (this)

Const和成员函数

const 修饰类成员函数和修饰全局函数的写法不同:

void func() const {} // member function
我们知道,const 实际上是表达了设计者的是否倾向于修改数据的意愿。成员函数也是相同的。对于成员函数,我们可以考虑下下面几种组合:

  1. non-const 对象调用 non-const 成员函数
  2. non-const 对象调用 const 成员函数
  3. const 对象调用 non-const 成员函数
  4. const 对象调用 const 成员函数

纵观这四个组合,我们发现只有第三条有问题:我们声明了一个 const 对象,就是希望不修改对象内容。但随后其调用的成员函数则是non-const 的,表明该函数允许修改对象内容,因此矛盾。

额外的规则

除开第三种组合,其余的组合从 const 的理念上都可以说的通。但需要注意的是这里仍然有例外。比如标准库 string 类的成员函数中有这么两个:

operator[](sizetype pos) const {...}
operator[](sizetype pos) {...}
string 类中,很可能存在多个函数对同一个字符串进行操作的情况。因此对于常量字符串,我们不需要考虑 COW(copy on write);但对于非常量字符串,我们则必须考虑 COW

而我们发现,如果使用非常量字符串去调用常量成员函数,那么从函数的角度来看是不用考虑 COW 的,但从对象的角度上看是需要考虑 COW 的,这样也矛盾了。

因此,C++ 对此提出了额外的一个规则:成员函数中如果同时存在常量和非常量版本,那么常量对象只能调用常量版本,非常量对象只能调用非常量版本

New / Detete 中的相关函数重载

前面我们学习 newdelete 的时候得知 newdelete 不能重载;但我们可以通过对 newdetele 过程中起作用的函数进行重载,从而达到对 newdelete 重载的效果。

为何要重载 new / delete

对于我们来说,new / delete 的功能性我们是没有办法改变的。我们只能用 new / delete 来申请和删除堆空间和对象。我们所能改变的就是如何为对象分配内存,而这个正是重载的的意义:使用自定义的 new / delete 往往能够更有效的管理和分配内存。

重载方式:operator new / delete

一种重载 new / delete 的方式是通过重载 operate newoperate delete 这两个函数。而对于这两个函数来说,重载又分全局重载类成员重载两种。

Operator 全局重载

全局重载的示例如下:

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); }

Operator 类成员重载

类成员重载的实例如下:

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 代表了对象数组的大小;在普通版本的 newdelete 中该值为 1,该参数也是可选的。
  • 如果没有重载,默认调用全局函数;如果有重载,则使用类重载版本。
  • 可以通过 :: scope 操作符强制使用全局函数。
  • new[] 创建的对象占用空间会额外多出一个计数器的空间,用于保存数组的大小。

重载方式:new() / delete()

C++ 中还提供了另外一种形式对 newdelete 进行重载: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
  • 各个重载参数的参数列表必须不同。
  • 重载的 placement 版本的 operator delete 不会被 delete 调用;只有在 new 调用的构造函数抛出异常的时候,才会调用,用于归还未能完全创建成功的对象所占的内存。
  • 如果没有对应的 operator delete,编译器也不会报错;这只代表你告诉编译器放弃处理该异常。