0%

第4章 Function语意学

本文从编译器的角度讨论如何解析类成员函数调用.

成员函数的调用

普通非静态成员函数

C++的设计准则之一就是: nonstatic member function 至少必须和一般的 nonmember funciton 有相同的效率.

为了保证类成员函数的效率, 编译器将对普通非静态成员函数的调用转换为对普通函数的调用. 步骤如下:

  1. 修改函数签名, 添加一个额外的参数(作为第一个参数), 称为this指针. 由此将函数和对象关联起来.
  2. 将函数中对非静态成员的访问改为经过this指针访问.
  3. 将成员函数重写为一个外部函数, 生成一个独一无二的名字(name mangling).

虚成员函数

编译器将对虚成员函数的调用转化为通过vptr调用函数. 在虚继承体系下, 任何含有某一虚函数的类, 该函数在虚表中的偏移都是固定的, 因此编译器可以根据函数名在编译期确定函数指针在虚表中的下标. 所以, 虚函数带来的额外负担就是增加一个内存访问.

1
2
3
4
p->func(param); // 设其在虚表中的下标为index.

// 上面的语句将被转化为
(*(p->vptr)[index])(p, param) // 这里p等于this指针, 所以将其作为第一个参数.

静态成员函数

对静态成员函数的访问将被转化为对普通函数的访问, 由于静态成员不能访问非静态数据成员, 因此不需要添加this指针. 静态函数有下面几个特点:

  • 不能直接访问类对象的非静态成员.
  • 不能被声明为const, volatile, virtual(因此不需要动态绑定).
  • 可以通过类对象和类名来调用.

注意一点, 当通过类对象来调用静态成员函数, 并且这个对象是由一个表达式得到时, 虽然不需要执行表达式就能直接调用函数, 但是表达式仍然会被执行(evaluate), 因为此表达式可能会有副作用, 不能被忽略. 例如:

1
2
3
Object func();

func().static_func() // func()仍然会被先执行, func()中可能会有某些不可省略的操作.

虚成员函数的实现

单继承

前文提到的虚成员函数实现是单继承下的模型, 下面具体说明其实现(注意下面提到的函数都指的是虚函数). 首先, 我们知道每个类都只有一个虚表(多继承和虚继承的类对象有多个vtpr, 指向不同的虚表, 但是实际上这些虚表是一个, vptr只是指向虚表的不同偏移位置), 也就是说相同类型的对象的vptr值是相同的. 当单继承发生时, 子类不仅继承了父类的数据成员, 还继承了函数成员, 前者体现在类对象布局上, 而后者体现在虚表上. 虚表继承的步骤可能包含下面几步:

  1. 将父类虚表中的虚函数指针拷贝到子类虚表的相同下标位置.
  2. 如果子类重写了父类的虚函数, 就将被重写的虚函数的指针修改为对应函数的地址.
  3. 如果子类加入新的虚函数, 就增加虚表容量, 在后面添加新的函数指针.

从上面可以看到, 单继承下的虚函数效率高, 实现简单, 但是多继承和虚拟继承则要复杂很多.

多继承

多继承的复杂性在于下面几个问题:

  • 通过第2,3,...个父类的指针访问子类的虚函数.
  • 通过子类指针访问第2,3,...个父类的虚函数.
  • 重写的虚函数的返回类型可能和父类的被重写函数的返回类型不一样, 这是标准允许的.

在讨论上面的问题之前, 先复习一下C++中虚函数相关的知识.

首先, 明确虚函数重写的概念. 父类声明了一个虚函数, 如果其(直接或间接)子类定义了函数, 与父类虚函数具有相同的:

  • 名字
  • 参数类型列表(不包含返回值)
  • const/volatile类型, 参考 [1]
  • 引用类型(三种: 无引用符号, &, &&), 参考 [1]

则子类函数为虚函数(无论是否声明为virtual), 并且重写了父类的虚函数.

第二点, 多继承时, 我们通过子类指针可以访问所有父类的函数, 这一点很明确. 但是不能通过一个父类的指针访问其他父类的函数. 看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct B1
{
virtual void f1() {}
};
struct B2
{
virtual void f2() {}
};
struct D: B1, B2
{
virtual void f1() {}
virtual void f2() {}
virtual void fd() {}
};

B1 *p1 = new D;
p1->f2(); // illegal

B2 *p2 = new D;
p2->f1(); // illegal

也就是说, 通过一个类对象指针调用函数时, 这个函数必须要在这个类或其父类中声明过. 下面举例说明上面问题的复杂性.(调用虚函数时一定是通过指针或引用, 由于引用本质上是指针, 下面只讨论指针.)

对于第一个问题, 通过父类指针直接调用子类定义的函数时有两种情况:

  • 通过第一个基类指针访问时, 直接将指针值作为this指针值传给函数.
  • 通过第2,3,...个基类指针访问时, 需要调整指针值, 加上/减去一个偏移, 再作为this指针传给函数.

显然第二种情况下需要在运行时调整this指针的值, 因为编译时无法确定指针所指对象的实际类型.

除此之外, 再考虑一种特殊情况(间接调用子类虚函数):

  • 对一个父类指针调用delete.

如果析构函数被声明为virtual, 那么程序将根据指针所指对象的实际类型决定调用哪个析构函数. 这就需要在运行时需要调整指针的值, 以保证能够访问正确的vptr, 从而获得对应的析构函数.

上面两个例子说明第一个问题的复杂性在于需要在运行时根据指针所指对象的实际类型来调整指针的值, 使之指向子类对象. 其他两个问题复杂性的根源也来自于此, 不(会 %>_<%)做详述.

问题明确了, 解决办法呢? 老实说没怎么看懂, 就不瞎说了, 等以后看明白了再补.

虚继承

其复杂性同样在于指针值的运行时修改, 书中建议不要在虚基类中声明非静态的函数.

成员函数指针

成员函数指针只能指向类的非静态成员函数, 使用方法如下:

1
2
3
4
5
6
7
8
9
struct C
{
void f(int i) {}
};

void (C::* p)(int) = &C::f; // pointer to member function
C c, *cp = &c;
(c.*p)(1); // 通过对象调用函数f
(cp->*p)(2); // 通过对象指针调用函数f

父类成员函数指针可以直接赋值给子类成员函数指针, 如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct B
{
virtual void f() {}
};

struct D : B
{
virtual void f() {}
};

void (B::* bf)() = &B::f;
void (D::* df)() = bf;

B *bp = new D;
(bp->*bf)(); // 调用D::f()
(bp->*df)(); // 不合法

而子类的成员函数指针可以通过static_cast或C风格的类型转换将其转换为父类的成员函数指针.

1
2
3
void (D::* df)() = &D::f;
void (B::* bf1)() = static_cast<void (B::*)()>(df);
void (B::* bf2)() = (void (B::*)())df;

从上面的例子中可以看到, 成员函数指针仍然支持虚函数机制. 下面看看编译器是如何支持各种虚拟机制的.

虚函数

成员函数指针可以指向一个普通函数, 此时她可以是函数地址. 如果指向一个虚函数, 她可以是该函数在虚表中的偏移. 这两种值可以保存在相同类型的变量中, 但是如何区分她们呢? 早期C++限制最多有128个虚函数(应该是限制虚表长度吧), 所以偏移值最大为127. 而程序空间起始地址必定大于127, 因此可以通过将指针值和127做"&"(按位与)运算来判断是偏移还是函数地址.

1
(((int)pmf) & ~127) ? (*pmf)(ptr) : (*ptr->vptr[(int)pmf])(ptr);

多继承和虚继承

支持这些机制的方法就更加复杂了. Stroustrup提出的一种方式是将成员函数指针定义为一个结构体, 包含this指针偏移, 虚基类指针偏移等等. 不过因为对不需要如此复杂机制的函数调用带来额外负担而受到批评. 有的实现对成员函数指针有多种实现方式, 以减少不必要的负担. 比如微软, 对单继承, 多继承, 虚继承就采用不同的方式来实现. 这个地方感觉还是不够具体, 坑先留着, 以后再填(第二个坑了...).

inline函数

在下面的情况下, 一个函数是inline函数:

  • 声明中包含inline关键字的函数
  • 当一个函数(成员函数或非成员友元函数)的定义在类内部时
  • 被声明为constexpr的函数(since C++11)

inline函数只是一种建议, 建议编译器将对inline函数的调用转换, 但是编译器并不一定会接受该建议, 而且非inline函数也有可能被转换, 这依赖于具体实现. 使用inline函数时要注意下面几点:

  • inline函数可能会增加生成的文件的大小.
  • inline函数尽可能简单. 减少不必要的局部变量, 否则可能会在结果中产生大量的局部变量.(现在的编译器应该可以优化这个了吧)

参考

[1] https://en.cppreference.com/w/cpp/language/member_functions