Function语义学(三)
今天的内容比较简单,
指向 Member Function 的指针
之前在学习 nonstatic data member 的地址时,得到的地址时该 member 在 class 布局中的 bytes 位置。可以这样认为:它是一个不完整的值,他需要被绑定在某个 class object 的地址上,才能够被存取。
取一个 nonstatic member function 的地址,如果该函数是 nonvirtual,得到的结构是它在内存中真正的地址。然而这个地址也是不完全的。它也需要被绑定在某个 class object 的地址上,才能够通过它调用函数,所有的 nonstatic member function 都需要对象的地址。
我们实战一下即可
#include <iostream>
using namespace std;
class Test {
public:
void test_1() {
cout << "test_1()" << endl;
}
virtual void test_2()
{
cout << "test_2()" << endl;
}
virtual void test_3()
{
cout << "test_3()" << endl;
}
};
// 用来打印指针地址的函数
void print(void (Test::* ptr)())
{
printf("address -> %p \n", ptr);
}
int main(int argc, char** argv)
{
// 声明一个指针对象
void (Test:: * ptr_1)() = &Test::test_1;
void (Test:: * ptr_2)();
ptr_2 = &Test::test_2;
void (Test:: * ptr_3)() = &Test::test_3;
print(ptr_1);
print(ptr_2);
print(ptr_3);
// 调用
Test test{};
(test.*ptr_1)();
// 编译器在底层会将他转换为 (ptr_1)(&test)
Test* test_ = new Test();
(test_->*ptr_2)();
// 这个是virtual function 下面会讲到
}
-
支持指向 Virtual Member Function 的指针
其实通过上面的实战,我们也不难看出,
test_1()
和test_2()
都是 virtual member function,它们通过 member function pointer 得到的地址值正好相差 8,这正是一个指针的大小。所以,当面对一个 virtual function 时,其地址是未知的,所能知道的仅仅是 virtual function 在其相关的 virtual table 中的索引值。也就是说,对一个 virtual member function 取其地址,所能获得的只是一个索引。 -
在多重继承下,指向 Member Functions 的指针
为了让指向 member function 的指针也能够支持多重继承和虚拟继承,Stroustrup(我也不知道是谁)设计了下面的一个结构体:
struct __mptr { int delta; int index; union { ptrtofunc faddr; int v_offset; }; };
index 和 faddr 分别持有 virtual table 索引和 nonvirtual memebr function 地址,当 index 不指向 virtual table 时,会被设为 -1。在这种模型下,
(ptr->*pmf)(); // 会变成 (pmf.index < 0) ? // non-virtual invocation (*pmf.faddr)(ptr) : // virtual invocation (*ptr->vptr[pmf.index](ptr));
它的缺点也很明显,就是每一次进行调用都会执行上面的操作,检查调用的函数是否为 virtual 或 nonvirtual。
MIcrosoft 把这项检查拿掉,导入了一个 vcall thunk,在这个策略下,faddr 被指定为要不就是真正的 member function 地址(如果函数是 nonvirtual 的话),要不就是 vcall thunk 的地址。于是 virtual 或 nonvirtual 函数的调用操作透明化,vcall thunk 会选出并调用相关 virtual table 中的适当 slot。
这个结构体的另一个副作用是:当传递一个不变的指针给 member function 时,它需要产生一个临时对象。
extern Point3d foo( const Point3d&, Point3d (Point3d::*)()); void bar( const Point3d& p ) { POint3d pt = foo( p, &Point3d::normal); } // 其中&Point3d::normal的值类似这样 { 0, -1, 10727147 } // 将产生一个临时对象,有明确的初值。 _mptr temp = { 0, -1, 10727147 }; foo(p, temp);
看到我们最开始的结构体
struct __mptr { int delta; int index; union { ptrtofunc faddr; int v_offset; }; };
delta 字段表示 this 指针的 offset 值,而 v_offset 字段放的是一个 virtual (或多重继承中的第二或后继的)base class 的 vptr。如果 vprt 被编译器放在 class 对象的开头处,这个字段就没有必要了,代价则是C对象兼容性降低,这些字段只在多重继承或虚拟继承的情况下才有必要,有许多编译器在自身内部根据不同的 classes 特性提供了多种指向 member functions 的指针的形式。例如,microsoft就供应了三种:
- 一个单一继承实例–持有vcall thunk 地址或是函数地址
- 一个多重继承实例–持有faddr和delta两个members
- 一个虚拟继承实例–持有4个members
inline function 内联函数
看看下面这段代码
if 0
class Point {
friend Point operator+(const Point&, const Point&);
private:
int _x;
int _y;
};
Point operator+(const Point& lhs, const Point& rhs)
{
Point new_ptr{};
new_ptr._x = lhs._x + rhs._x;
new_ptr._y = lhs._y + lhs._y;
return new_ptr;
}
#else
// 我们看一种比较好的做法
class Point {
public:
inline int x();
inline int y();
inline auto x(int) -> void;
inline auto y(int) -> void;
private:
int _x;
int _y;
};
auto Point::x() -> int
{
return _x;
}
auto Point::y() -> int
{
return _y;
}
auto Point::x(int x) -> void
{
_x = x;
}
auto Point::y(int y) -> void
{
_y = y;
}
Point operator+(Point& lhs, Point& rhs)
{
Point new_ptr{};
new_ptr.x(lhs.x() + rhs.x());
new_ptr.y(lhs.y() + rhs.y());
return new_ptr;
}
int main() {}
#endif
熟悉面向对象的兄弟们应该很容易看出上面两种实现的区别。现在我们就来看看 inline 这个关键字吧
关键字 inline只是一项请求,如果这项请求被接收,编译器就必须认为它可以用一个表达式(expression)合理地将这个函数拓展开来。
在某个层次上,如果编译器拓展一个inline函数的执行成本比一般的函数调用及返回机制所带来的符合低,编译器就会对这个inline函数进行展开。编译器有一套复杂的测试法,通常是用来计算 assigments,function calls,virtual function calls 等操作的次数。每个表达式(expression)种类有一个权值,而inline函数的复杂度取决于这些操作的总和。
一般而言,处理一个 inline 函数,有两个阶段:
-
分析函数定义,以决定函数的“intrinsic inline ability”(本质的inline能力)。与编译器本身有关。
-
真正的inline函数拓展是在调用的那一点上。这会带来参数的求值操作(evaluation)以及临时性对象的管理。
// 还是上面的例子,这时候 // 我们会看见编译器帮我们做了一件 // 很有意思的事情 new_ptr.x(lhs.x() + rhs.x()); // new_ptr.x(lhs._x + rhs._x); // 因为直接将inline函数展开并不会带来效率上的提升
现在我们看看,inline 函数的形式参数
-
formal arguments 形式参数
一般而言,面对“会带来副作用的实际参数”,通常需要引入临时性对象。换句话说,如果实际参数是一个常量表达式(constant expression),我们可以在替换之前先完成其求值操作,后继的inline替换,就可以把常量直接绑定上去,如果既不是常量表达式,也不是带有副作用的表达式,那么就直接替换。
inline int min(int i, int j) { return i < j ? i : j; } inline int bar() { int minval; int val1 = 1024; int val2 = 2048; minval = min(val1, val2); // 参数直接替换 minval = val1 < val2 ? val1 : val2; minval = min(1024, 2048); // 直接用常量结果 mminval = 1024; minval = min( foo(), bar()+1); // 引发参数的副作用,需要导入一个临时性对象,以避免重复取值(multiple evaluation) int t1, t2; minval = ( t1 = foo() ), (t2 = bar() + 1), t1 < t2 ? t1 : t2; }
-
局部变量(local variables)
一般而言,inline函数中的每一个局部变量都必须被放在函数调用的一个封闭区段中,拥有一个独一无二的名称。如果inline函数以单一表达式(expression)扩展多次,则每次拓展都需要自己的一组局部变量。如果inline函数以分离的多个式子(discrete statements)被拓展多次,那么只需一组局部变量,就可以重复使用。因为它们被放在同一个区段中。
看个例子吧
inline int min(int i, int j) { int minval = i < j ? i : j; return minval; } { int local_var; int minval; // ... minval = min(val1, val2); } // 拓展之后 { int local_var; int minval; // 将 inline 函数的局部变量处以“mangline”操作 int __min_lv_minval; minval = ( __min_lv_minval = val1 < val2 ? val1 : val2 ), __min_lv_minval; }
inline 函数中的局部变量,再加上有副作用的参数,可能会导致大量的临时对象产生。特别是如果它以单一表达式(expresssion)被拓展多次的话。
minval = min( val1, val2 ) + min(foo(), foo() + 1); // 拓展 int __min_lv_minval_00; int __min_lv_minval_01; int t1; int t2; minval = ((__min_lv_minval_01 = val1 < val2 ? val1 : val2), __min_lv_minval) + ((__min_lv_minval_01 = (t1 = foo() ), ( t2 = foo() + 1), t1 < t2 ? t1 : t2), __min_lv_minval_01);
需要注意的是,如果一个inline被调用太多次的话,会产生大量的代码,是程序的大小暴涨。
如果inline中又有inline,可能会使一个表面看起来平凡的inline却因为其连锁复杂度而没办法拓展开来。
val1 < val2 ? val1 : val2),
__min_lv_minval)
+
((__min_lv_minval_01 = (t1 = foo() ),
( t2 = foo() + 1),
t1 < t2 ? t1 : t2),
__min_lv_minval_01);
需要注意的是,如果一个inline被调用太多次的话,会产生大量的代码,是程序的大小暴涨。
如果inline中又有inline,可能会使一个表面看起来平凡的inline却因为其连锁复杂度而没办法拓展开来。