内联函数

来源:百度文库 编辑:神马文学网 时间:2024/06/06 09:13:45
在C中为什么要使用宏呢?
因为函数的调用必须要将程序执行的顺序转移到函数所存放在内存中的某个地址,将函数的程序内容执行完后,再返回到转去执行该函数前的地方。这种转移操作要求在转去执行前要保存现场并记忆执行的地址,转回后要恢复现场,并按原来保存地址继续执行。因此,函数调用要有一定的时间和空间方面的开销,于是将影响其效率。
而宏只是在预处理的地方把代码展开,不需要额外的空间和时间方面的开销,所以调用一个宏比调用一个函数更有效率。
宏和函数的区别:
1. 宏做的是简单的字符串替换(注意是字符串的替换,不是其他类型参数的替换),而函数的参数的传递,参数是有数据类型的,可以是各种各样的类型.
2. 宏的参数替换是不经计算而直接处理的,而函数调用是将实参的值传递给形参,既然说是值,自然是计算得来的.
3. 宏在编译之前进行,即先用宏体替换宏名,然后再编译的,而函数显然是编译之后,在执行时,才调用的.因此,宏占用的是编译的时间,而函数占用的是执行时的时间.
4. 宏的参数是不占内存空间的,因为只是做字符串的替换,而函数调用时的参数传递则是具体变量之间的信息传递,形参作为函数的局部变量,显然是占用内存的.
5. 函数的调用是需要付出一定的时空开销的,因为系统在调用函数时,要保留现场,然后转入被调用函数去执行,调用完,再返回主调函数,此时再恢复现场,这些操作,显然在宏中是没有的.
宏也有很多的不尽人意的地方。
1、宏不能访问对象的私有成员。
2、宏的定义很容易产生二意性。
我们举个例子:
#define square(x) (x*x)
我们用一个数字去调用它,square(5),这样看上去没有什么错误,结果返回25,是正确的,但是如果我们用squre (5+5)去调用的话,我们期望的结果是100,而宏的调用结果是(5+5*5+5),结果是35,这显然不是我们要得到的结果。避免这些错误的方法,一是给宏的参数都加上括号。
#define square(x) ((x)*(x))
这样可以确保不会出错,但是,即使使用了这种定义,这个宏依然有可能出错,例如使用TABLE_MULTI(a++)调用它,他们本意是希望得到(a+1)*(a+1)的结果,而实际上呢?我们可以看看宏的展开结果: (a++)*(a++),如果a的值是4,我们得到的结果是6*6=36。而我们期望的结果是5*5=25,这又出现了问题。事实上,在一些C的库函数中也有这些问题。例如: Toupper(*pChar++)就会对pChar执行两次++操作,因为Toupper实际上也是一个宏。
我们可以看到宏有一些难以避免的问题,怎么解决呢?
下面就是用我要介绍的内联函数来解决这些问题,我们可以使用内联函数来取代宏的定义。而且事实上我们可以用内联函数完全取代预处理宏。
内联函数和宏的区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。你可以象调用函数一样来调用内联函数,而不必担心会产生于处理宏的一些问题。
我们可以用inline来定义内联函数,不过,任何在类的说明部分定义的函数都会被自动的认为是内联函数。
下面我们来介绍一下内联函数的用法。
内联函数必须是和函数体申明在一起,才有效。像这样的申明 inline Tablefunction(int i) 是没有效果的,编译器只是把函数作为普通的函数申明,我们必须定义函数体。
inline tablefunction(int i)
{
return i*i
};
这样我们才算定义了一个内联函数。我们可以把它作为一般的函数一样调用。但是执行速度确比一般函数的执行速度要快。
我们也可以将定义在类的外部的函数定义为内联函数,比如:
class tableClass{
private:
int i,j;
public:
int add() { return iI+j;};
inline int dec() { return i-j;}
int GetNum();
}
inline int tableclass::GetNum(){
return i;
}
上面申明的三个函数都是内联函数。在C++中,在类的内部定义了函数体的函数,被默认为是内联函数。而不管你是否有inline关键字。
内联函数在C++类中,应用最广的,应该是用来定义存取函数。我们定义的类中一般会把数据成员定义成私有的或者保护的,这样,外界就不能直接读写我们类成员的数据了。对于私有或者保护成员的读写就必须使用成员接口函数来进行。如果我们把这些读写成员函数定义成内联函数的话,将会获得比较好的效率。
class sample{
private:
int nTest;
public:
int readtest(){ return nTest; }
void settest(int i) { nTest=i; }
}
当然,内联函数也有一定的局限性。就是函数中的执行代码不能太多了,如果,内联函数的函数体过大,一般的编译器会放弃内联方式,而采用普通的方式调用函数。这样,内联函数就和普通函数执行效率一样了。
下面再举一个例子:
开发人员可以有两种方式告诉编译器需要内联哪些类成员函数,一种是在类的定义体外;一种是在类的定义体内。
(1)当在类的定义体外时,需要在该成员函数的定义前面加“inline”关键字,显式地告诉编译器该函数在调用时需要“内联”处理,如:
class Student
{
public:
String GetName();
int     GetAge();
void        SetAge(int ag);
……
private:
String  name;
int     age;
……
};
inline String GetName()
{
return name;
}
inline int GetAge()
{
return age;
}
inline void SetAge(int ag)
{
age = ag;
}
(2)当在类的定义体内且声明该成员函数时,同时提供该成员函数的实现体。此时,“inline”关键字并不是必需的,如:
class Student
{
public:
String GetName()       { return name; }
int     GetAge()        { return age; }
void        SetAge(int ag) { age = ag; }
……
private:
String  name;
int     age;
……
};
当普通函数(非类成员函数)需要被内联时,则只需要在函数的定义时前面加上“inline”关键字,如:
inline int DoSomeMagic(int a, int b)
{
return a * 13 + b % 4 + 3;
}
因为C++是以“编译单元”为单位编译的,而一个编译单元往往大致等于一个“.cpp”文件。在实际编译前,预处理器会将“#include”的各头文件的内容(可能会有递归头文件展开)完整地拷贝到cpp文件对应位置处(另外还会进行宏展开等操作)。预处理器处理后,编译真正开始。一旦C++编译器开始编译,它不会意识到其他cpp文件的存在。因此并不会参考其他cpp文件的内容信息。联想到内联的工作是由编译器完成的,且内联的意思是将被调用内联函数的函数体代码直接代替对该内联函数的调用。这也就意味着,在编译某个编译单元时,如果该编译单元会调用到某个内联函数,那么该内联函数的函数定义(即函数体)必须也包含在该编译单元内。因为编译器使用内联函数体代码替代内联函数调用时,必须知道该内联函数的函数体代码,而且不能通过参考其他编译单元信息来获得这一信息。
如果有多个编译单元会调用到某同一个内联函数,C++规范要求在这多个编译单元中该内联函数的定义必须是完全一致的,这就是“ODR”(one-definition rule)原则。考虑到代码的可维护性,最好将内联函数的定义放在一个头文件中,用到该内联函数的各个编译单元只需#include该头文件即可。进一步考虑,如果该内联函数是一个类的成员函数,这个头文件正好可以是该成员函数所属类的声明所在的头文件。这样看来,类成员内联函数的两种声明可以看成是几乎一样的,虽然一个是在类外,一个在类内。但是两个都在同一个头文件中,编译器都能在#include该头文件后直接取得内联函数的函数体代码。讨论完如何声明一个内联函数,来查看编译器如何内联的。继续上面的例子,假设有个foo函数:
#include "student.h"
...
void foo()
{
...
Student abc;
abc.SetAge(12);
cout << abc.GetAge();
...
}
foo函数进入foo函数时,从其栈帧中开辟了放置abc对象的空间。进入函数体后,首先对该处空间执行Student的默认构造函数构造abc对象。然后将常数12压栈,调用abc的SetAge函数(开辟SetAge函数自己的栈帧,返回时回退销毁此栈帧)。紧跟着执行abc的GetAge函数,并将返回值压栈。最后调用cout的<<操作符操作压栈的结果,即输出。
内联后大致如下:
#include "student.h"
...
void foo()
{
...
Student abc;
{
abc.age = 12;
}
int tmp = abc.age;
cout << tmp;
...
}
这时,函数调用时的参数压栈、栈帧开辟与销毁等操作不再需要,而且在结合这些代码后,编译器能进一步优化为如下结果:
#include "student.h"
...
void foo()
{
...
cout << 12;
...
}
这显然是最好的优化结果;相反,考虑原始版本。如果SetAge/GetAge没有被内联,因为非内联函数一般不会在头文件中定义,这两个函数可能在这个编译单元之外的其他编译单元中定义。即foo函数所在编译单元看不到SetAge/GetAge,不知道函数体代码信息,那么编译器传入12给SetAge,然后用GetAge输出。在这一过程中,编译器不能确信最后GetAge的输出。因为编译这个编译单元时,不知道这两个函数的函数体代码,因而也就不能做出最终版本的优化。
从上述分析中,可以看到使用内联函数至少有如下两个优点。
(1)减少因为函数调用引起开销,主要是参数压栈、栈帧开辟与回收,以及寄存器保存与恢复等。
(2)内联后编译器在处理调用内联函数的函数(如上例中的foo()函数)时,因为可供分析的代码更多,因此它能做的优化更深入彻底。
第一条优点对于开发人员来说往往更显而易见一些,第二条优点对最终代码的优化可能贡献更大。
这时,有必要简单介绍函数调用时都需要执行哪些操作,这样可以帮助分析一些函数调用相关的问题。假设下面代码:
void foo()
{
...
i = func(a, b, c);                                          ①
...                                                         ②
}
调用者(这里是foo)在调用前需要执行如下操作。
(1)参数压栈:这里是a、b和c。压栈时一般都是按照逆序,因此是c->b->c。如果a、b和c有对象,则需要先进行拷贝构造。
(2)保存返回地址:即函数调用结束返回后接着执行的语句的地址,这里是②处语句的地址。
(3)保存维护foo函数栈帧信息的寄存器内容:如SP(堆栈指针)和FP(栈帧指针)等。到底保存哪些寄存器与平台相关,但是每个平台肯定都会有对应的寄存器。
(4)保存一些通用寄存器的内容:因为有些通用寄存器会被所有函数用到,所以在foo调用func之前,这些寄存器可能已经放置了对foo有用的信息。这些寄存器在进入func函数体内执行时可能会被func用到,从而被覆写。因此foo在调用func前保存一份这些通用寄存器的内容,这样在func返回后可以恢复它们。
接着调用func函数,它首先通过移动栈指针来分配所有在其内部声明的局部变量所需的空间,然后执行其函数体内的代码等。
最后当func执行完毕,函数返回时,foo函数还需要执行如下善后处理。
(1)恢复通用寄存器的值。
(2)恢复保存foo函数栈帧信息的那些寄存器的值。
(3)通过移动栈指针,销毁func函数的栈帧,
(4)将保存的返回地址出栈,并赋给IP寄存器。
(5)通过移动栈指针,回收传给func函数的参数所占用的空间。
我们知道,如果传入参数和返回值为对象时,还会涉及对象的构造与析构,函数调用的开销就会更大。尤其是当传入对象和返回对象是复杂的大对象时,更是如此。
因为函数调用的准备与善后工作最终都是由机器指令完成的,假设一个函数之前的准备工作与之后的善后工作的指令所需的空间为SS,执行这些代码所需的时间为TS,现在可以更细致地从空间与时间两个方面来分析内联的效果。
(1)在空间上,一般印象是不采用内联,被调用函数的代码只有一份,调用它的地方使用call语句引用即可。而采用内联后,该函数的代码在所有调用其处都有一份拷贝,因此最后总的代码大小比采用内联前要大。但事实不总是这样的,如果一个函数a的体代码大小为AS,假设a函数在整个程序中被调用了n次,不采用内联时,对a的调用只有准备工作与善后工作两处会增加最后的代码量开销,即a函数相关的代码大小为:n * SS + AS。采用内联后,在各处调用点都需要将其函数体代码展开,即a函数相关的代码大小为n * AS。这样比较二者的大小,即比较(n * SS + AS)与(n*AS)的大小。考虑到n一般次数很多时,可以简化成比较SS与AS的大小。这样可以得出大致结论,如果被内联函数自己的函数体代码量比因为函数调用的准备与善后工作引入的代码量大,内联后程序的代码量会变大;相反,当被内联函数的函数体代码量比因为函数调用的准备与善后工作引入的代码量小,内联后程序的代码量会变小。这里还没有考虑内联的后续情况,即编译器可能因为获得的信息更多,从而对调用函数的优化做得更深入和彻底,致使最终的代码量变得更小。
(2)在时间上,一般而言,每处调用都不再需要做函数调用的准备与善后工作。另外内联后,编译器在做优化时,看到的是调用函数与被调用函数连成的一大块代码。即获得的代码信息更多,此时它对调用函数的优化可以做得更好。最后还有一个很重要的因素,即内联后调用函数体内需要执行的代码是相邻的,其执行的代码都在同一个页面或连续的页面中。如果没有内联,执行到被调用函数时,需要跳到包含被调用函数的内存页面中执行,而被调用函数所属的页面极有可能当时不在物理内存中。这意味着,内联后可以降低“缺页”的几率,知道减少“缺页”次数的效果远比减少一些代码量执行的效果。另外即使被调用函数所在页面可能也在内存中,但是因为与调用函数在空间上相隔甚远,所以可能会引起“cache miss”,从而降低执行速度。因此总的来说,内联后程序的执行时间会比没有内联要少。即程序的速度更快,这也是因为内联后代码的空间“locality”特性提高了。但正如上面分析空间影响时提到的,当AS远大于SS,且n非常大时,最终程序的大小会比没有内联时要大很多。代码量大意味着用来存放代码的内存页也会更多,这样因为执行代码而引起的“缺页”也会相应增多。如果这样,最终程序的执行时间可能会因为大量的“缺页”而变得更多,即程序的速度变慢。这也是为什么很多编译器对于函数体代码很多的函数,会拒绝对其进行内联的请求。即忽略“inline”关键字,而对如同普通函数那样编译。
综合上面的分析,在采用内联时需要内联函数的特征。比如该函数自己的函数体代码量,以及程序执行时可能被调用的次数等。当然,判断内联效果的最终和最有效的方法还是对程序的大小和执行时间进行实际测量,然后根据测量结果来决定是否应该采用内联,以及对哪些函数进行内联。
如下根据内联的本质来讨论与其相关的一些其他特点。
如前所述,因为调用内联函数的编译单元必须有内联函数的函数体代码信息。又因为ODR规则和考虑到代码的可维护性,所以一般将内联函数的定义放在一个头文件中,然后在每个调用该内联函数的编译单元中#include该头文件。现在考虑这种情况,即在一个大型程序中,某个内联函数因为非常通用,而被大多数编译单元用到对该内联函数的一个修改,就会引起所有用到它的编译单元的重新编译。对于一个真正的大型程序,重新编译大部分编译单元往往意味着大量的编译时间。因此内联最好在开发的后期引入,以避免可能不必要的大量编译时间的浪费。
再考虑这种情况,如果某开发小组在开发中用到了第三方提供的程序库,而这些程序库中包含一些内联函数。因为该开发小组的代码中在用到第三方提供的内联函数处,都是将该内联函数的函数体代码拷贝到调用处,即该开发小组的代码中包含了第三方提供代码的“实现”。假设这个第三方单位在下一个版本中修改了某些内联函数的定义,那么虽然这个第三方单位并没有修改任何函数的对外接口,而只是修改了实现,该开发小组要想利用这个新的版本,仍然需要重新编译。考虑到可能该开发小组的程序已经发布,那么这种重新编译的成本会相当高;相反,如果没有内联,并且仍然只是修改实现,那么该开发小组不必重新编译即可利用新的版本。
因为内联的本质就是用函数体代码代替对该函数的调用,所以考虑递归函数,如:
[inline] int foo(int n)
{
...
return foo(n-1);
}
如果编译器编译某个调用此函数的编译单元,如:
void func()
{
...
int m = foo(n);
...
}
考虑如下两种情况。
(1)如果在编译该编译单元且调用foo时,提供的参数n不能知道其实际值,则编译器无法知道对foo函数体进行多少次代替。在这种情况下,编译器会拒绝对foo函数进行内联。
(2)如果在编译该编译单元且调用foo时,提供的参数n能够知道其实际值,则编译器可能会视n值的大小来决定是否对foo函数进行内联。因为如果n很大,内联展开可能会使最终程序的大小变得很大。
如前所述,因为内联函数是编译期行为,而虚拟函数是执行期行为,因此编译器一般会拒绝对虚拟函数进行内联的请求。但是事情总有例外,内联函数的本质是编译器编译调用某函数时,将其函数体代码代替call调用,即内联的条件是编译器能够知道该处函数调用的函数体。而虚拟函数不能够被内联,也是因为在编译时一般来说编译器无法知道该虚拟函数到底是哪一个版本,即无法确定其函数体。但是在两种情况下,编译器是能够知道虚拟函数调用的真实版本的,因此虚拟函数可以被内联。
其一是通过对象,而不是指向对象的指针或者对象的引用调用虚拟函数,这时编译器在编译期就已经知道对象的确切类型。因此会直接调用确定的某虚拟函数实现版本,而不会产生“动态绑定”行为的代码。
其二是虽然是通过对象指针或者对象引用调用虚拟函数,但是编译时编译器能知道该指针或引用对应到的对象的确切类型。比如在产生的新对象时做的指针赋值或引用初始化,发生在于通过该指针或引用调用虚拟函数同一个编译单元并且二者之间该指针没有被改变赋值使其指向到其他不能确切知道类型的对象(因为引用不能修改绑定,因此无此之虞)。此时编译器也不会产生动态绑定的代码,而是直接调用该确定类型的虚拟函数实现版本。
在这两种情况下,编译器能够将此虚拟函数内联化,如:
inline virtual int x::y (char* a)
{
...
}
void z (char* b)
{
x_base* x_pointer = new x(some_arguments_maybe);
x x_instance(maybe_some_more_arguments);
x_pointer->y(b);
x_instance.y(b);
当然在实际开发中,通过这两种方式调用虚拟函数时应该非常少,因为虚拟函数的语义是“通过基类指针或引用调用,到真正运行时才决定调用哪个版本”。
从上面的分析中已经看到,编译器并不总是尊重“inline”关键字。即使某个函数用“inline”关键字修饰,并不能够保证该函数在编译时真正被内联处理。因此与register关键字性质类似,inline仅仅是给编译器的一个“建议”,编译器完全可以视实际情况而忽略之。
另外从内联,即用函数体代码替代对该函数的调用这一本质看,它与C语言中的函数宏(macro)极其相似,但是它们之间也有本质的区别。即内联是编译期行为,宏是预处理期行为,其替代展开由预处理器来做。也就是说编译器看不到宏,更不可能处理宏。另外宏的参数在其宏体内出现两次或两次以上时经常会产生副作用,尤其是当在宏体内对参数进行++或--操作时,而内联不会。还有,预处理器不会也不能对宏的参数进行类型检查。而内联因为是编译器处理的,因此会对内联函数的参数进行类型检查,这对于写出正确且鲁棒的程序,是一个很大的优势。最后,宏肯定会被展开,而用inline关键字修饰的函数不一定会被内联展开。
最后顺带提及,一个程序的惟一入口main()函数肯定不会被内联化。另外,编译器合成的默认构造函数、拷贝构造函数、析构函数,以及赋值运算符一般都会被内联化