这个章节我们会学到以下3个知识点:
1.不同的类型函数是怎么调用的。
2.成员函数指针各个模型实现的原理是什么以及各个指针的效率如何。
3.inline函数的注意事项。
看下面的代码,这里面我分别调用了类成员和全局函数
class NormalCall
{
public:
void Add(int number)
{
number+m_add;
}
static void ClassStatic()
{
cout << "ClassStatic" << endl;
}
virtual void ClassVirutal()
{
cout << "ClassVirutal" << endl;
}
public:
int m_add;
};
void Add(NormalCall *nc, int number)
{
nc->m_add + number;
}
void test_normal_call()
{
NormalCall nc;
nc.Add(1);
// 其实被编译器转成__ZN6NormalCall6AddEi(&nc, 1)
// 编译器在调用类的普通成员函数时,会在函数的参数中隐式添加了一个this指针,这个指针
// 就是当前生成对象的首地址。同时对普通成员变量的存取也是通过this指针
Add(&nc, 1); // 调用全局函数
}
int main()
{
return 0;
}
把上面的源代码保存文件main.cpp,然后在linux平台上用g++ -Wall –g main.cpp –o main,再用nm main,就会导出main里面的符号等等其他东西。我把重要的东西拿出来看下。
080486f0 T __x86.get_pc_thunk.bx |
08048820 T _Z16test_normal_callv |
0804881b T _Z3AddP10NormalCalli |
08048888 t _Z41__static_initialization_and_destruction_0ii |
U _ZdlPv@@GLIBCXX_3.4 |
08048bea W _ZN10NormalCall12ClassVirutalEv |
08048be4 W _ZN10NormalCall3AddEi |
从这里我们这里可以看出,我们写代码的时候名字就是Add,但是编译完之后名称全变了。_Z3AddP10NormalCalli我们可以猜测下就是我们写的Add(NormalCall, int)原型。_ZN10NormalCall3AddEi应该就是我们的NormalCall成员函数Add(int)原型。你可能会奇怪,为什么C++编译器编译出来的名称都变了,这种做法叫做name mangling(命名粉碎),其实是为了支持C++另外一个特性,就是函数重载的特性。同时,也是C++确保调用普通成员函数,和调用全局函数的在效率上是一致的。
void test _static_call()
{
NormalCall *pNC = new NormalCall();
pNC->ClassVirutal();
NormalCall NC;
NC.ClassStatic();
pNC->ClassStatic();
NormalCall::ClassStatic();
}
; 上面三种调用static函数的生成的反汇编代码是一致的。
22C7D4 call NormalCall::ClassStatic (0221550h)
22C7D9 call NormalCall::ClassStatic (0221550h)
22C7DE call NormalCall::ClassStatic (0221550h)
总结:
1.静态成员函数,没有this指针。
无法直接存取类中普通的非静态成员变量。
3.调用方式可以向类中普通的成员函数,也可以用ClassName::StaticFunction。
4.可以将静态的成员函数在某些环境下当做回调函数使用。
5.静态的成员函数不能够被声明为const、volatile和virtual。
关于虚函数在第四章做了专门的介绍,这里就不在啰嗦了。
从字面意思看,有两点内容。
1.是个指针
2.指向的类的成员函数
class Car
{
public:
void Run() { cout << "Car run" << endl; }
static void Name() { cout << "lexus" << endl; }
};
void test_function_pointer()
{
// void (Car::*fRun)() = &Car::Run;
// 可以将成员函数指针分成四步看
void // 1.返回值
(Car::* // 2.哪个类的成员函数指针
fRun) // 3.函数指针名称
() // 4.参数列表
= &Car::Run;
Car car;
(car.*fRun)();
}
从上面的代码中看出,定义一个成员函数指针,只需要注意四步就行:
1.返回值。
2.哪个类的成员函数指针。
3.函数指针名称。
4.参数列表。
在注意调用的方式(car.fRun)(),它比平常调用car.Run()时候多了个。
这个背后实现的原理:
每个成员函数都一个固定的地址,把普通成员函数的地址赋值给一个函数指针,然后在调用函数指针的时候再把this指针当做参数传递进去。这个就和普通成员函数调用的原理是一致的。
void test_function_pointer()
{
void (*fName)() = &Car::Name;
fName();
}
注意到没有我们在定义静态成员函数时,没有加上类名Car。这是因为静态函数里面没有this指针,所以就造成了不需要加上类名Car,同时也造成静态成员函数不能直接使用类的普通成员变量和函数。你可能发现类的静态成员函数,和全局函数非常类似,其实本质上是一样的,都是一个可调用的地址。
上面的两节,我们看了普通成员函数指针和静态成员函数指针,觉得比较简单。接下来的重头戏虚拟成员函数指针,这里的套路更深也更难理解,且听我一步步道来。
class Animal
{
public:
virtual ~Animal() { cout << "~Animal" << endl; }
virtual void Name() { cout << "Animal" << endl; }
};
class Cat : public Animal
{
public:
virtual ~Cat() { cout << "~Cat" << endl; }
virtual void Name() { cout << "Cat Cat" << endl; }
};
class Dog : public Animal
{
public:
virtual ~Dog() { cout << "~Dog" << endl; }
virtual void Run() { cout << "Dog Run" << endl; }
virtual void Name() { cout << "Dog Dog" << endl; }
};
void test_virtual_fucntion_pointer()
{
Animal *animal = new Cat();
void (Animal::*fName)() = &Animal::Name;
printf("fName address %p\n", fName);
// fName address 00FD1802
(animal->*fName)();
// Cat Cat
// 打印Cat的虚表中的Name地址
Cat *cat = new Cat();
long *vtable_address = (long *)(*((long *)(cat)));
printf("virtual Name address %p\n", (long *)vtable_address[1]);
// virtual Name address 00FD1429
// 编译器在语法层面阻止我们获取析构函数地址
// 但是我们知道的在虚函数章节里面,我们可以通过虚表的地址间接获取析构函数地址
// void (Animal::*Dtor)() = &Animal::~Animal;
// (animal->*Dtor)();
printf("fName address %p\n", fName);
// fName address 00FD1802
animal = new Dog();
(animal ->*fName)();
// Dog Dog
// 打印Dog的虚表中的Name地址
Dog *dog = new Dog();
long *dog_vtable_address = (long *)(*((long *)(dog)));
printf("virtual Name address %p\n", (long *)dog_vtable_address[1]);
// virtual Name address 00FD1672
}
在代码中我们定义了一个变量fName。
void (Animal::*fName)() = &Animal::Name;
并赋值为&Animal:Name;我们再打印出Name的地址0x009F1802。
我们先思考这个地址到底指向谁?
这个地址就是虚函数的地址?如果是,那么它的地址是父类的?还是子类,如果那么编译器又是怎么我指向的是哪个虚函数地址?如果不是,那么又是个什么地址?接下里我们一步步的通过汇编代码验证猜想。
我们在VS的调试模式下,将鼠标移动fName变量上就会显示一串信息。
显示的什么thunk,vcall{4…},都是什么玩意看不懂。反汇编走一遍,到底是个什么锤子。
以下是关键的汇编代码:
(animal->*fName)();
00FDD66F mov esi,esp
; 是不是条件反射了,将this指针地址放到ecx中
00FDD671 mov ecx,dword ptr [animal]
00FDD674 call dword ptr [fName]
function_semantic::Animal::`vcall'{4}':
00FD1802 jmp function_semantic::Animal::`vcall'{4}' (0FD73BCh)
; 拿到虚表首地址
00FD73BC mov eax,dword ptr [ecx]
; 偏移地址,找到正确的虚函数地址
00FD73BE jmp dword ptr [eax+4]
function_semantic::Cat::Name:
; 真正的虚函数地址
00FD1429 jmp function_semantic::Cat::Name (0FD8FB0h)
画了一张图解释下:
首先,我们不看蓝色虚线的部分。此时并不是直接找到虚函数地址,而是通过一个中间层(黑色虚框部分)去找到。这种技术,在microsoft编译器中被包装了一个高大上的名词叫做thunk。
我们再看整张图,你会发现和以前调用虚函数的方式(蓝色虚线箭头)相比,是不是就是多了一个thunk的调用过程。但是为啥要多个中间层,那不意味着效率又降低了?首先引入thunk是为了寻找虚函数地址增加强大的灵活性。其次需要承认的是效率的确下降了,但是没有下降的那么厉害,这几行代码都是汇编级别的代码,所以执行的效率还是很高。
接下来我详细解释下是如何增加灵活性,仔细观察上面的黄色高亮的代码块,为了方便查看我摘抄下来。第一次看到下面的代码,总觉得非常的别扭。声明的类型是父类的成员函数指针,最后调用的却是子类重写的虚函数打印的结果分别是Cat Cat,Dog Dog,很是神奇,而且fName是个变量在这个过程是不变化的,这是怎么做到的。这背后就是thunk的功劳了。
Animal *animal = new Cat();
void (Animal::*fName)() = &Animal::Name;
(animal->*fName)();
// Cat Cat
animal = new Dog();
(animal ->*fName)();
// Dog Dog
那么thunk到底什么?
从汇编层看,thunk就是那么几行代码。干了一件很简单的事,就是根据传递过来的ecx指针,找到虚表地址,在根据偏移量(这里偏移为4byte)找到正确的虚函数地址。所以ecx里面就是保存了对象的首地址(也就是包括了vptr),根据不同的虚表就能找到不同的虚函数。
class Fly
{
public:
virtual ~Fly() { cout << "~Fly" << endl; }
virtual void CanFly() { cout << "Fly" << endl; }
void Distance() { cout << "Fly distance" << endl; }
};
class Fish : public Animal, public Fly
{
public:
virtual ~Fish() { cout << "~Fish" << endl; }
virtual void Name() { cout << "Fish" << endl; }
virtual void CanFly() { cout << "Fish Fly" << endl; }
};
void test_mult_inherit_vir_fun_pointer()
{
void (Animal::*fName)() = &Animal::Name;
void (Fly::*fFly)() = &Fly::CanFly;
Fish *fish = new Fish();
Fly *fishfly = fish;
(fishfly->*fFly)();
Animal *animal = fish;
(animal->*fName)();
}
这里从反汇编的角度看,在单继承下面都是调用thunk方法,和上面的没啥区别。
提前预警,这里的模型更复杂了,大家一定要耐心看下去。
class Animal
{
public:
virtual ~Animal() { cout << "~Animal" << endl; }
virtual void Name() { cout << "Animal" << endl; }
void Size() { cout << "Animal Size" << endl; }
};
class BigTiger: public virtual Animal
{
public:
virtual ~BigTiger() { cout << "~Big Tiger" << endl; }
virtual void Name() { cout << "Big Tiger" << endl; }
};
class FatTiger: public virtual Animal
{
public:
virtual ~FatTiger() { cout << "~Fat Tiger" << endl; }
virtual void Name() { cout << "Fat Tiger" << endl; }
};
class Tiger: public BigTiger, public FatTiger
{
public:
virtual ~Tiger() { cout << "~Tiger" << endl; }
virtual void Name() { cout << "Tiger" << endl; }
virtual void CanFly() { cout << "Tiger Fly" << endl; }
};
void test_virtual_mult_inherit_vir_fun_pointer()
{
// 1.测试代码
// void (Animal::*fName)() = &Animal::Name;
// 下面这句和上面注释的一句是等价的
void (BigTiger::*fName)() = &Animal::Name;
Tiger *temptiger2 = new Tiger();
BigTiger *bigtiger = temptiger2;
(bigtiger->*fName)();
// 打印出:Tiger
// 2.测试代码
// void (FatTiger::*fFatName)() = &Animal::Name;
// 下面这句和上面注释的一句是等价的
void (FatTiger::*fFatName)() = &FatTiger::Name;
Tiger *temptiger = new Tiger();
FatTiger *fattiger = temptiger;
(fattiger->*fFatName)();
// 打印出:Tiger
}
int main() {
test_virtual_mult_inherit_vir_fun_pointer();
}
上述的测试代码,我们先看看Tiger的内存布局是什么样的。
把代码copy拿出来保存为main.cpp,在vs2013命令行工具中,cd到main.cpp所在的目录,运行指令cl /d1 reportSingleClassLayoutTiger main.cpp。打印出如下内容:
增加了虚继承之后,内存的模型复杂度立马上升了一个档次。上述表格看的不明显,我花了几张图方便大家观看。
好了,我们画图Tiger相关的内存模型图。接下来我们看看这是指向虚成员函数指针是如何实现的。
我们看下面这段代码的执行流程。
// 1.测试代码
// void (Animal::*fName)() = &Animal::Name;
// 下面这句和上面注释的一句是等价的
void (BigTiger::*fName)() = &Animal::Name;
Tiger *temptiger2 = new Tiger();
BigTiger *bigtiger = temptiger2;
(bigtiger->*fName)();
大家可能在第二步调整this指针的时候会很奇怪的,但是根据我debug模式下跟下来,vtordisp for vbase Animal 这个位置的值为0。
那么ecx = ecx-[ecx-4]等价于ecx=ecx-0还是等于ecx本身,ecx里面就保存了this指针的地址,最后再调用虚函数。这里我也很好奇为什么这里还有个调整this地址的问题。
还有个关于vtordisp的,我也没有理解,从调试的过程看下来,就知道他参与了最后一次的this指针调整。这里我贴出网上的一个地址讨论这个的
https://www.cnblogs.com/fanzhidongyzby/archive/2013/01/14/2860015.html。
那么上面的调用过程就是:
1.根据thunk找到正确的虚函数地址。
2.调整this指针的偏移,再调用第一步找到的虚函数地址。
成员函数指针有两种形态:
1.和C语言中一样的函数指针。
2.thunk vcall的技术,就是几行汇编代码:1.以调整this的地址。
2.可以协助找到真正的虚函数地址。
不知道大家有没有感觉,这个thunk非常像桥接模式的思路,将桥的两边变化都隔离开,就是解耦,各自可以随意变化。
大家可能对学习了这节的成员函数指针觉得没啥用处,其实这节的用处可大了。想想C++11中的functional,bind是怎么实现的。后面有机会的话通过functional重写观察者设计模式,让你感叹这个的强大。
同时这里面还有其他的模式组合(比如:虚继承普通成员函数),我这里就没有一一的探讨了,希望读者对自己感兴趣的部分动手实践,或者和我讨论也可以。
最后我们在比较下各种函数指针的效率如何:
inline函数调用的过程中,需要注意两点:
inline int max(int left, int right)
{
return left > right ? left : right;
}
// 调用方式
max(foo(), bar()+1)
// inline 被扩展之后
int t1;
int t2;
maxvale = (t1=foo()),(t2=bar()+1), t1 > t2 ? t1 : t2;
这样做的话,其实会造成大量的临时对象构造。如果你的对象需要大量的初始化操作,会带来效率问题。
inline int max(int left, int right)
{
// 添加临时变量max_value
int max_value = left > right ? left : right;
return max_value;
}
{
// 调用方式
int local_var;
int maxval;
maxval = max(left, right);
}
// inline 被扩展之后
// max里面的max_value会被mangling,现在假设为__max_maxval
int __max_maxval;
maxval = (__max_maxval = left > right ? left : right), __max_maxval;
在inline函数中增加了临时变量,看到最后inline展开的时候也会临时对象的构造,就和上面的影响是一样的,造成的效率损失。