对普通函数、宏函数、内联函数的作用机制的探索

     这次我们来分析的是C/C++程序员经常遇到的问题,如何在普通函数、宏函数、内联函数之间做取舍,其实它们三者之间并没有什么绝对的你好我差的说法,只要掌握了三者的作用机制的话,结合实际情况一般都能做出正确的选择。下面我们一个个介绍上面的三个方法:

1、普通函数

    就和它的名字一样,它代表着千千万万在普通不过的函数,说它普通并不是因为它负责的工作很普通,而是相较于宏定义和内联来说的,这样的函数有可能存在于类中,那时候我们叫它成员函数,而如果不在类中,我们一般都是叫它···函数,所以在这里我把它们通通叫做普通函数了。这类函数在程序的执行过程中是如何被识别并调用的呢?我们以下面的样例程序来进行分析:
 1 #include <iostream>
 2  
 3 using namespace std;
 4  
 5 int foo( int a, int b)
 6 {
 7     return a + b;
 8 }
 9  
10 int main()
11 {
12     int x = foo (1, 2);
13  
14     printf("Add = %d\n" , x);
15     cout << "Add = " << x << endl ;
16  
17     return 0;
18 }
19  

 

    首先要明白的是,程序一般有这几个状态:预处理阶段、编译阶段、链接阶段和执行阶段,而这类函数需要在后三个阶段进行不同的操作才能保证程序的正确运行
     
     (1)编译阶段:
     编译阶段开始时,编译器首先要对函数进行名称修饰,像上面的foo函数,C++编译器会将名字翻译成?foo@@YGHHH@Z(这个根据不同的编译器有不同的翻法)。大多数程序在Link的时候判断函数调用对应的是哪个函数时,一般是靠函数名称、参数数目和类型以及返回值来决定调用哪个函数的,而这样的命名非常方便链接器识别并做出最佳的选择
 
     (2)链接阶段:
     编译器在编译的过程中是对不同的文件独立编译的,它们之间的引用情况编译器并不了解,这时候需要链接器站出来为各个文件之间的关联指路。之前编译器进行的名称修饰也是为了链接阶段顺利进行而提前做准备。在这个阶段,foo函数的调用与foo函数的代码本体相连接,保证调用时能够正确的找到foo函数。
 
     (3)执行阶段:
     因为在编译阶段中已经对代码进行了一定的处理,我们在main中进行的foo(1,2)的调用链接器也已经找到了对应的函数,那么在执行到那里的时候,程序会做些什么?大家都知道程序在运行时会动态的维护一个运行栈来辅助程序的执行,下面我们来分析一下foo(1,2)这句话执行的时候栈进行了怎样的变化。首先根据__stdcall(C++的标准函数调用,C用的是__cdecl)的规则,要将foo函数的参数从右向左压入栈顶,因此该句话的汇编语句为:
 
1 push 2
2 push 1
3 call foo // 压入当前EIP(代码执行指针)后跳转
 
(下面的内容需要一定的汇编基础再进行阅读,目的是为了之后的某个结果做理论论证,如果读不懂的话可以先放一放看看结论,再来慢慢理解这部分)
     再进一步的分析,事实上堆栈不止是放入了这两个参数,为了能够更好的控制这个栈,程序使用esp和ebp这两个指针寄存器来存储当前堆栈指针和栈顶指针,在函数调用发生时,ebp首先会被压入栈内用于函数调用完成的恢复,紧接着esp将会赋值给ebp来存储当前的栈顶,之后在新的函数中,ebp将作为基址存在。然后是对esp进行一次减法运算,这次减少的值正是局部变量所需要占用的总空间,其实减法操作就是在申请空间了。函数主要部分运行完毕后将会把运行结果保存在eax中,在这里需要注意一下,函数返回值在不同的操作系统上,我们先不谈这个问题,如果想提前了解翻到这篇文章最后就可以看到了。保存完返回值结果后,首先要释放申请的局部变量空间,所以我们又对esp进行了一次加法运算以释放空间,现在我们可以把ebp的值赋予esp了,同时弹出ebp(这时esp指向的正是之前保存的那个主函数的栈顶指针)恢复之前的epb,最后调用ret返回主函数,提取之前压入的EIP继续执行下一句代码,这样整个栈在函数调用前后保持了栈平衡,顺利完成了调用。这样一来我们再看看该函数对应的汇编代码:
     
 1 pusb ebp                   // ebp入栈以保存调用前的栈基址,等待函数调用完毕后出栈
 2 mov ebp,esp                // 将esp给ebp,这时ebp代表着新的一段程序(函数内部)的栈的基址
 3 sub esp, Size              // Size不固定,代表函数内部的局部变量总大小,目的在于申请局部变量的空间
 4 ······                     // 局部变量初始化
 5 mov eax, [ebp + 8H]       // 将1交给寄存器eax等待运算
 6 add eax, [ebp + 0CH]       // 将2加在eax上得到计算结果
 7 add esp,Size               // 释放局部变量占用的空间
 8 mov esp,ebp                // 函数即将结束,首先恢复esp
 9 pop ebp                    // 通过pop操作将之前保存的ebp再交还给ebp
10 ret                        // 弹出EIP返回主调用函数执行下一句命令

 

 
     
下面是运行栈的状态图:
······ (主函数栈)
参数 2
参数 1
EIP
EBP
······ (函数局部变量)
 
  
 
     综上我们能够得出一个结论:普通函数在调用的过程中,需要进行压栈、出栈等操作,同时还要维护一个运行栈,进行这些操作都是要付出一定时间代价的。如果我们约定函数体核心程序运行时间为TC,入栈出栈以及其他运行栈操作为TS,那么TC/TS越大,说明函数工作效率越高,反而如果函数体执行的时间远远小于维护栈的时间(即TC/TS -> 0),那么函数的实际效率会变的相当不乐观。一旦出现效率不佳的情况,我们就可以考虑用宏函数或内联函数来进行替换了,因为它们不需要付出函数调用和堆栈操作的代价。
 

 
 
2、宏函数
 
     宏大家都并不陌生,学过C/C++的朋友们大多都有所接触,虽然许多书籍上都并不推荐大家使用宏定义,主要原因是考虑到宏替换是完全忽略语言特性和规则、忽略作用域、忽略类型系统的替换(来自《C++编程规范》)。确实,这种完全不考虑后果的替换很有可能带来非常可怕的后果,所以在这里提一句,如果可以的话尽可能用const、enum或者是inline代替#define,但这么一说岂不是就代表着宏是一个很可怕的怪物了么?不是的,看待事物不能带着有色眼镜,宏也有他好用的一面。宏在对于简单函数上的替换方面就可以做的很好,C程序员们也是经常用到这个手段的。我们通过下面这个例子来分析一下宏函数:
 1 #include <iostream>
 2 using namespace std;
 3  
 4 #define imax(a,b) ((a) > (b)) ? (a) : (b)
 5  
 6 int main()
 7 {
 8         int x = imax(1 ,2);
 9         printf ("MaxInt = %d\n", imax(1 ,2));
10         cout << "MaxInt = " << imax(1 ,2);
11         return 0 ;
12 }
     上面这段简短的样例程序实现了一个比较最大值并返回的宏函数,那么这段程序在执行的整个过程中发生了什么?宏起作用是在预处理阶段。预处理器在运行的过程中,将与imax(a,b)这样格式匹配的段落直接替换成我们定义的格式,也就是((a) > (b)) ? (a) : (b)。在替换的过程中,预处理器进行的是纯文本替换,完全忽略语言特性和规则、作用域、类型系统(再强调一遍以示重要性)。预处理阶段结束后,程序依次进入编译、链接、执行阶段,最终完成执行。
     与普通函数不同的是,宏函数在执行过程中不涉及运行栈的操作和函数调用,实际上就相当于用于维护动态栈的时间TS = 0,这样一来我们的效率就是100%了,这对于追求效率的编程人员来说可是一件很不错的事情,尤其是在如单片机这样的领域,硬件机能的限制我们需要追求尽可能的高效率,因此用宏函数替代普通函数提高效率是个不错的选择。而高效随之带来的副作用也是显而易见的,如果替换内容过长,会导致整个程序的代码量激增,只要有一处替换,就会多出一块代码,这种复制代码式的替换如果控制不当会带来代码膨胀,占用更多的空间。除此之外,因程序员宏定义的疏忽导致的一些不容易发现的错误也是很有可能的,毕竟宏替换不会检查任何合法性,少打一对括号就有可能惹来麻烦,比如,imax(a,b)的宏定义我们写成:
     #define imax(a,b) a > b ? (a) : (b)
会是什么样子呢?
     考虑这样一句话:
    int x = 3 + imax(1 ,2);
这句话如果是之前的imax毫无问题··· 但是现在他就非常的神奇了,我们看看他替换之后会生成什么:
     int x = 3 + 1 > 2 ? ( 1) : ( 2)
这个结果本该是5,但却成了2,因为运算符"+"的优先级高于">"和"?:",因此这句话实际上变成了:
      int x = (3 + 1) > 2 ? ( 1 ) : ( 2 )
所以这个结果当然是2了··· 为了不让大家辛辛苦苦的花大量时间去调这样的bug,建议在使用宏定义时,每个成员和运算保险起见最好用括号括起来以保证正确的优先级
 
     最后这个代码其实还有一个问题,cout输出的那句话你认为是多少,是2么?其实这个输出的结果是0,不要吃惊,我们来看看程序运行时到底发生了什么。首先我们先将替换后的cout语句展开看看:
  cout << "MaxInt = " << ((1) > ( 2)) ? ( 1) : ( 2);
     注意到了么,cout函数在判断输出内容时只识别了?之前的部分,也就是((1 ) > ( 2 ))这一部分了,这个的结果是false,自然就输出0了,而后面的东西去哪了呢,通过跟踪这段代码我们发现,cout在输出时因为无法与任何一个<<重载类型相匹配,因此进入了错误处理而不是输出:
    
 __CLR_OR_THIS_CALL operator void *() const
         {   // test if any stream operation has failed
         return ( fail() ? 0 : (void *)this);
         }
而如果使用普通函数或内联函数的话就完全没有问题了,这个问题正是因为imax的宏声明缺少一个最外层的括号,如果写成
     #define imax(a,b) (((a) > (b)) ? (a) : (b))
就没有问题了
 

3、内联函数
 
     安排内联函数作为最后一种类型登场是有一定原因的,一方面内联函数有点像宏函数采用替换的方式,另一方面内联函数还和普通函数一样考虑了语言特性、作用域和类型系统等内容,单从这方面来看,内联函数好像成为了解决问题的银弹,它简直是棒极了,拥有着宏函数和普通函数优点,是不是恨不得把所有的函数都inline化?如果你真的是这么想的,希望在你抱着这个念头开始编程之前先把后面的部分仔细阅读完,以防止被inline美丽的容貌所误导(当然并不是说inline很糟糕简直像一个骗子,恰当的使用inline才是关键)。
 
     使用一个工具之前最好了解它,这往往能够让你更加熟练的使用它创造,而不是被它牢牢拴住动弹不得。接下来我们来分析一下内联函数的运行机理:
     
      在编译阶段,和普通函数类似的,内联函数也要进行名字修饰、合法性检查等等,但要注意的是,内联函数在经过检查后不仅会保存函数名称、参数类型和返回值类型,还会把内联函数的本体也一并保存起来,在之后的编译过程中一旦遇到该函数的调用时首先会检查调用是否合法,通过编译器检查后便直接将函数代码嵌入在调用出替代调用语句。内联函数的替换相较于宏函数的替换在这个时候就显现出它的优势了:
 
          a.      内联函数的替换是要进行类型检查的,而宏替换只是简单的字符串替换,别的是不管的
          b.      因为宏替换是文本替换,可能导致无法预料的后果,因此要注意宏内部的计算顺序
          c.      宏替换无法发现编译错误,而内联函数是真正意义上的函数,一旦有语法错误编译器会报错
          d.      宏替换错误很难调试,因为是文本替换,而内联函数调试起来就容易得多了
          e.      从编程思路角度上来说,内联函数一般更加有意义
 
      相比于普通函数,内联函数直接嵌入代码这样的做法也省去了运行栈维护和函数调用的开销。同时这里面还有一个好处,编译器对于一些顺序代码是进行优化的,而我们很少看到编译器对有函数调用的部分进行优化。如果我们使用内联函数代替了函数调用,也就意味着编译器可以对这一部分的代码进行优化,这对于提升整体运行速度也是有很大的帮助的。
     不过,作为牺牲,内联函数也有“副作用”,当你在使用内联函数时发现境况与下面的内容有些相似时,就该考虑一下是否真的应该使用内联函数了:
          a.      替换带来的代码膨胀是不可避免的,尤其是函数体比较复杂时,空间代价会相当的大,甚至会超过内联带来的收益,因此内联函数体不宜太长太复杂,所谓复杂          就是不能够包判断、循环等语句,更复杂的就不用说了
          b.      内联函数会导致页面开销变大,当函数体内容较多时甚至有可能会降低命中率从而导致程序效率降低
          c.      内联函数会破坏封装,因为它是直接采用代码替换,也就意味着能被看到,因此pimpl模式是不能够和inline一块使用的
          d.      将内联函数定义在头文件后,一旦修改了内联函数,整个头文件都要重新编译,在大型程序中这样的代价可能不小
 
     除去上面的情况,我们就可以考虑使用内联函数,看看下面这个例子,这是一个比较适合使用内联的范例:
 
 1 #include <iostream>
 2 using namespace std ;
 3  
 4 inline int foo (int a , int b )
 5 {
 6      return a + b;
 7 }
 8  
 9 int main()
10 {
11      int x = foo ( 1, 2 );
12     printf ("Count = %d\n" , x);
13      return 0 ;
14 }

 

     相比于普通函数,内联函数在函数最开始以inline关键字作为提示。要注意的是,inline并不是声明,而是一种提示,它告诉编译器这个函数要以内联的方式进行处理,因此在头文件的函数定义中,是不需要要添加inline的。在样例代码中,foo函数体非常简单,仅仅是返回加法运算结果,因此像这样的函数就比较适合inline化了。
     现在对于内联函数的优缺点和原理也有一定的认识了,我们再来看看如何构造inline函数,像上面的样例程序是一种方法,它还有一种等效的方法是这样:
1 int foo( int a, int b );
2  
3 inline int foo (int a , int b )
4 {
5      return a + b;
6 }
     这就是刚才说的inline特性,它并不是声明而是一种提示,因此在定义时不需要使用inline,就算是在class中定义inline也是一样的,如下代码:
 1 class A
 2 {
 3 public :
 4      int foo (int a , int b );
 5 };
 6  
 7 inline int A ::foo (int a , int b )
 8 {
 9      return a + b;
10 }
     除此之外,直接在class中实现的类也会被编译器当做inline函数进行处理,如:
 
1 class A
2 {
3 public :
4      int foo (int a , int b )
5      {
6          return a + b ;
7      }
8 };

     
  要注意的是,以下情况就算我们让编译器去生成inline函数,编译器也会拒绝:
     a.      当我们显示的用inline提示,但函数内部使用了诸如判断语句、循环语句等复杂的表达方式时,编译器会拒绝函数inline化
     b.      当在class内部直接实现函数时,如果函数内部较为复杂,编译器一样会拒绝函数inline化
     c.      虚函数是不会被inline化的,因为虚函数意味着直到执行时才确定,而inline函数则是在执行前完成所有工作,这两者是相矛盾的,因此任何为虚函数inline化的操作编译器都会拒绝
 
     目前大多数的编译器都具备了诊断能力,只要编译器认为它太复杂,就会坚决的拒绝程序员的请求。另外尽量不要对构造函数和析构函数进行inline化,虽然看起来它们可能是非常简单,但实际上编译后最终形成的样子往往会出乎意料,因为编译器为了能够为class实现各种功能往往会在编译时向构造和析构函数添加大量的其他内容。比如说vtbl的构造,继承的调用,this指针的添加,类成员初始化序列的补充等等(具体内容可以参考《深度探索C++对象模型》的第五章,构造、析构、拷贝语义学),所以除非你对他们背后实际的样子了如指掌,还是尽量避免构造、析构、拷贝函数inline化。
     
          

 
附:函数返回值的处理
 
     函数在返回值是借助寄存器进行传递的,使用哪些寄存器以及怎样的形式传回与返回值的类型有关。
     如果返回值类型是32位可承受的,如int、char、short、指针这样的类型,通过eax寄存器传递就好了;如果是64为可承受的,如_int64这样的则可以用edx+eax的方式返回,其中edx保存高32位,eax保存低32位;如果是浮点数返回值类型,如float、double等,将采用一个专用的浮点数寄存器的栈顶返回;如果是返回struct或class,编译器将会以引用的形式返回该参数,采用eax返回。本文中的foo函数返回值为int,因此采用的便是eax寄存器返回了。
 
     这里还有一点值得我们注意一下,因为函数返回值借助寄存器而非栈空间,这意味着返回值的代价很低,因此在c89规范中声明,凡是没有显示的声明函数返回值类型的统统都默认为int类型的返回值,而在C++的标准中任何函数没有返回值类型是被报错的,没有返回类型就是void,而且在void返回值类型的函数中也是不准许return任何值的,因此为了规范化,建议函数最好显示的声明返回值,而不是放着不写让编译器自己去猜。

posted on 2013-08-12 12:37  woud  阅读(1838)  评论(0编辑  收藏  举报

导航