C++幕后故事(六)--函数我来调你了

读者如果觉得我文章还不错的,希望可以多多支持下我,文章可以转发,但是必须保留原出处和原作者署名。更多内容请关注我的微信公众号:cpp手艺人
image

这个章节我们会学到以下3个知识点:

1.不同的类型函数是怎么调用的。
2.成员函数指针各个模型实现的原理是什么以及各个指针的效率如何。
3.inline函数的注意事项。

1.普通成员调用

看下面的代码,这里面我分别调用了类成员和全局函数

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。

3.虚函数调用

关于虚函数在第四章做了专门的介绍,这里就不在啰嗦了。

4.成员函数指针

从字面意思看,有两点内容。

1.是个指针
2.指向的类的成员函数

4.1普通成员函数指针

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指针当做参数传递进去。这个就和普通成员函数调用的原理是一致的。

4.2 静态成员函数指针

void test_function_pointer()
{
    void (*fName)() = &Car::Name;
    fName();
}

注意到没有我们在定义静态成员函数时,没有加上类名Car。这是因为静态函数里面没有this指针,所以就造成了不需要加上类名Car,同时也造成静态成员函数不能直接使用类的普通成员变量和函数。你可能发现类的静态成员函数,和全局函数非常类似,其实本质上是一样的,都是一个可调用的地址。

4.3 虚拟成员函数指针

上面的两节,我们看了普通成员函数指针和静态成员函数指针,觉得比较简单。接下来的重头戏虚拟成员函数指针,这里的套路更深也更难理解,且听我一步步道来。

4.3.1单继承模型下调用

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变量上就会显示一串信息。

image

显示的什么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)  

画了一张图解释下:

image

首先,我们不看蓝色虚线的部分。此时并不是直接找到虚函数地址,而是通过一个中间层(黑色虚框部分)去找到。这种技术,在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),根据不同的虚表就能找到不同的虚函数。

4.3.2 多继承模型下调用

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方法,和上面的没啥区别。

4.3.3 虚拟继承模型下调用

提前预警,这里的模型更复杂了,大家一定要耐心看下去。

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。打印出如下内容:

image

image

增加了虚继承之后,内存的模型复杂度立马上升了一个档次。上述表格看的不明显,我花了几张图方便大家观看。

image

image

image

好了,我们画图Tiger相关的内存模型图。接下来我们看看这是指向虚成员函数指针是如何实现的。

我们看下面这段代码的执行流程。

// 1.测试代码
// void (Animal::*fName)() = &Animal::Name;
// 下面这句和上面注释的一句是等价的
void (BigTiger::*fName)() = &Animal::Name;
Tiger *temptiger2   = new Tiger();
BigTiger *bigtiger   = temptiger2;
(bigtiger->*fName)();

image

大家可能在第二步调整this指针的时候会很奇怪的,但是根据我debug模式下跟下来,vtordisp for vbase Animal 这个位置的值为0。

image

那么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指针的偏移,再调用第一步找到的虚函数地址。

4.4 成员函数指针总结

成员函数指针有两种形态:

1.和C语言中一样的函数指针。
2.thunk vcall的技术,就是几行汇编代码:

1.以调整this的地址。
2.可以协助找到真正的虚函数地址。

不知道大家有没有感觉,这个thunk非常像桥接模式的思路,将桥的两边变化都隔离开,就是解耦,各自可以随意变化。

大家可能对学习了这节的成员函数指针觉得没啥用处,其实这节的用处可大了。想想C++11中的functional,bind是怎么实现的。后面有机会的话通过functional重写观察者设计模式,让你感叹这个的强大。

同时这里面还有其他的模式组合(比如:虚继承普通成员函数),我这里就没有一一的探讨了,希望读者对自己感兴趣的部分动手实践,或者和我讨论也可以。

最后我们在比较下各种函数指针的效率如何:

image

5.inline函数调用

inline函数调用的过程中,需要注意两点:

5.1函数参数列表:

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;

这样做的话,其实会造成大量的临时对象构造。如果你的对象需要大量的初始化操作,会带来效率问题。

5.2局部变量

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展开的时候也会临时对象的构造,就和上面的影响是一样的,造成的效率损失。

6.总结:

image


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 635672377@qq.com

文章标题:C++幕后故事(六)--函数我来调你了

文章字数:3.7k

本文作者:刘世雄

发布时间:2019-11-19, 17:32:36

最后更新:2019-12-31, 05:36:59

原始链接:http://lsxcpp.com/2019/11/20/C-%E5%B9%95%E5%90%8E%E6%95%85%E4%BA%8B%EF%BC%88%E5%85%AD%EF%BC%89-%E5%87%BD%E6%95%B0%E6%88%91%E6%9D%A5%E8%B0%83%E4%BD%A0%E4%BA%86/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录