此次咱们来分析的是C/C++程序员常常遇到的问题,如何在普通函数、宏函数、内联函数之间作取舍,其实它们三者之间并无什么绝对的你好我差的说法,只要掌握了三者的做用机制的话,结合实际状况通常都能作出正确的选择。下面咱们一个个介绍上面的三个方法:ios
一、普通函数程序员
就和它的名字同样,它表明着千千万万在普通不过的函数,说它普通并非由于它负责的工做很普通,而是相较于宏定义和内联来讲的,这样的函数有可能存在于类中,那时候咱们叫它成员函数,而若是不在类中,咱们通常都是叫它···函数,因此在这里我把它们统统叫作普通函数了。这类函数在程序的执行过程当中是如何被识别并调用的呢?咱们如下面的样例程序来进行分析:
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),那么函数的实际效率会变的至关不乐观。一旦出现效率不佳的状况,咱们就能够考虑用宏函数或内联函数来进行替换了,由于它们不须要付出函数调用和堆栈操做的代价。
二、宏函数
宏你们都并不陌生,学过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))
就没有问题了
三、内联函数
安排内联函数做为最后一种类型登场是有必定缘由的,一方面内联函数有点像宏函数采用替换的方式,另外一方面内联函数还和普通函数同样考虑了语言特性、做用域和类型系统等内容,单从这方面来看,内联函数好像成为了解决问题的银弹,它简直是棒极了,拥有着宏函数和普通函数优势,是否是巴不得把全部的函数都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任何值的,所以为了规范化,建议函数最好显示的声明返回值,而不是放着不写让编译器本身去猜。