内联函数

转自《C++编程思想》,这本书写得真的是太棒了。

在C语言中,保持效率的一个方法是使用宏(macro)。宏可以不要普通的函数调用代码就可以是之看起来像函数调用。宏的实现不是预处理器而是编译器。预处理器直接用宏代码代替宏调用,所以没有了参数压栈、生成汇编语言的CALL、返回参数、执行汇编语言的RETURN等的开销。所有的工作由处理器来完成,因此不用花费什么就具有了程序调用的便利和可读性。

但是C++有一个特有的问题:预处理器不允许访问类的成员数据。 这意味着预处理器宏不能用作类的成员函数。

为了既保持预处理器宏的效率又增加安全性,而且还能像一般成员函数一样可以在类里访问自如,C++引入了内联函数。

1 预处理器的缺陷

当在宏调用中使用表达式作为参数时,问题出现。

#define FLOOR(x,b) x>=b?0:1

假如使用表达式作参数:

if(FLOOR(a&0x0f,0x07)) //...

宏就将展开成

if(a&0x0f>=0x07?0:1)

因为&的优先级比>=的低,所以宏的展开结果将会使我们惊讶。一旦发现这个问题,可以通过在宏定义内的各个地方使用括弧来解决。(这是创建预处理器宏时使用的好方法。)上面的定义可以改写如下:

#define FLOOR(x,b) ((x)>=(b)?0:1)

再看一个例子。

下面这个宏决定它的参数是否在一定的范围:
#define BAND(x) (((x)>5 && (x)<10) ? (x):0)

然后看一段程序:

#include <iostream>  
#include <algorithm>  
#include <cmath>  
#include <vector>  
#include <string>  
#include <cstring>  
#pragma warning(disable:4996)  
using namespace std;

#define BAND(x) (((x)>5 && (x)<10) ? (x):0)

int main()
{
    //freopen("i.txt", "r", stdin);
    //freopen("o.txt", "w", stdout);

    for (int i = 4; i < 11; i++)
    {
        int a = i;
        cout << "a=" << a << endl << '	';
        cout << "BAND(++a)=" << BAND(++a) << endl;
        cout << "	 a = " << a << endl;
    }
    system("pause");
    return 0;
}

注意宏名中所有大写字母的使用。这是一种很有用的做法,因为大写的字母告诉读者这是一个宏而不是一个函数,所以如果出现问题,也可以起到一定的提示作用。

执行效果如图
这里写图片描述

原因在于,当a等于4时仅测试了条件表达式的第一部分,表达式只求值一次,所以宏调用的副作用是a等于5,这是在相同的情况下从普通函数调用所期望得到的。但当数字在值域范围内时,两个表达式都测试,产生两次自增操作。产生这个结果是由于再次对参数操作。一旦数组出了范围,两个条件仍然测试,所以也产生两次自增操作。根据参数不同产生的副作用也不同。

2 内联函数

在C++中,宏的概念是作为内联函数(inline function)来实现的,而内联函数无论从哪一方面上说都是真正的函数。内联函数能够像普通函数一样具有我们所有期望的任何行为。唯一不同之处是内联函数在适当的地方像宏一样展开,所以不需要函数调用的开销。因此,应该(几乎)永远不使用宏,只使用内联函数。

任何在类中定义的函数自动地成为内联函数,但也可以在非类的函数前面加上inline关键字使之成为内联函数。但为了使之有效,必须使函数体和声明结合在一起,否则,编译器将它作为普通函数对待。因此

inline int plusOne(int x);

没有任何效果,仅仅是声明函数,成功的方法如下:

inline int plusOne(int x) {return ++x;}

注意,编译器将检查函数参数列表使用是否正确,并返回值(进行必要的转换)。这些事情是预处理器无法完成的。假如对于上面的内联函数写成一个预处理器宏的话,将得到不想要的副作用。

一般应该把内联定义放在头文件里。当编译器看到这个定义时,它把函数类型(函数名,返回值)和函数体放到符号表里。当使用函数时,编译器检查以确保调用时正确的且返回值被正确使用,然后将函数调用替换为函数体,因此消除了开销。内联代码的确占用空间,这实际上比为了一个普通函数调用而产生的代码(参数压栈和执行CALL)占用的空间还少。

在头文件中,内联函数处于一种特殊状态,因为在头文件中声明该函数,所以必须包含头文件和该函数的定义,这些定义在每个用到该函数的文件中,但是不会产生多个定义错误的情况(不过,在任何使用内联函数地方该内联函数的定义都必须是相同的)。

2.1 类内部的内联函数

任何类内部定义的函数自动地成为内联函数。

因为类内部的内联函数节省了在外部定义成员函数的额外步骤,所以我们一定想在类声明内每一处都使用内联函数。但应记住,使用内联函数的目的是减少函数调用的开销。但是如果函数较大,由于需要在调用函数的每一处都重复复制代码,这样使代码膨胀,在速度方面获得的好处就会减少。

2.2 访问函数

不用内联函数,考虑效率的类设计者将忍不住简单地使变量成为公共成员,从而通过让用户直接访问变量来消除开销。从设计的角度看,这是很不好的。

4 内联函数和编译器

为了理解内联函数何时有效,应该先理解当编译器遇到一个内联函数时将做什么。

对于任何函数,编译器在它的符号表里放入函数类型(即包括名字和参数类型的函数原型及函数的返回类型)。另外,当编译器看到内联函数和对内联函数体的进行分析没有发现错误时,就将对应与函数体的代码也放入符号表。源代码是以源程序的形式存放还是以编译过的汇编指令形式存放取决于编译器。
当调用一个内联函数时,编译器首先确保调用正确,即所有的参数类型必须满足:要么与函数参数表中的参数类型一样,要么编译器能够将其转换为正确类型,并且返回值在目标表达式里应该是正确类型或可改变为正确类型。当然,编译器为任何类型函数都是这样做的,并且这是与预处理器显著的不同之处,因为预处理器不能检查类型和进行转换。
假如所有的函数类型信息符合调用的上下文的话,内联函数代码就会直接替换函数调用,这消除了调用的开销,也考虑了编译器的进一步优化。

4.1 限制

有两种编译器不能执行内联的情况。在这些情况下,它就像对非内联函数一样,根据内联函数定义和为函数建立存储空间,简单地将其转换为函数的普通形式。假如它必须在多重编译单元里做这些(通常将产生一个多定义错误),连接器就会被告知忽略多重定义。

1.假如函数太复杂,编译器将不能执行内联。这取决于特定的编译器,但对于大多数编译器这时都会放弃内联方式,这是内联将可能不能提高任何效率。一般地,任何种类的循环都会被认为太复杂而不扩展为内联函数。循环在函数里可能比调用要花费更多的时间。假如函数仅由简单语句组成,编译器可能没有任何内联的麻烦,但假如函数有很多语句,调用函数的开销将比执行函数体的开销少多了。记住,每次调用一个大的内联函数,整个函数体就被插入在函数调用的地方,所以很容易使代码膨胀,而程序性能上没有任何显著的改进。

2.假如要显式地或隐式地取一个函数的地址,编译器也不能执行内联。因为这时编译器必须为函数代码分配内存而产生一个函数的地址,但当地址不需要时,编译器仍将可能内联代码。

内联仅是编译器的一个建议,编译器不会被强迫内联任何代码。一个好的编译器将会内联小的、简单的函数,同时明智地忽略那些太复杂的内联,这将给我们想要的结果–具有宏效率的函数调用的真正的语义学。

4.2 向前引用

如果猜想编译器执行内联函数时将会做什么事情,就可能会糊涂地认为限制比实际存在的要多。特别当一个内联函数在类中向前引用一个还没有声明的函数时,看起来好像实际编译器不能处理。

class Forward
{
    int i;
public:
    Forward() :i(0) {}
    int f()const { return g() + 1; }
    int g()const { return i; }
};

int main()
{
    Forward frwd;
    frwd.f();
}

函数f()调用g(),但此时还没有声明g()。这也能正常工作,因为C++语言规定:只有在类函数声明结束后,其中的内联函数才会被计算。

当然,如果g()反过来调用f(),就会产生递归调用,这对于编译器来说太复杂而不能执行内联。

4.3 在构造函数和析构函数里隐藏行为

在构造函数和析构函数中,可能易于认为内联的作用比它实际上更有效。构造函数和析构函数都可能隐藏行为,因为类可以包含子对象,子对象的构造函数和析构函数必须被调用。这些子对象可能是成员对象,或可能由于集成而存在。看例子。

#include <iostream>  
#include <algorithm>  
#include <cmath>  
#include <vector>  
#include <string>  
#include <cstring>  
#pragma warning(disable:4996)  
using namespace std;

class Member
{
    int i, j, k;
public:
    Member(int x=0):i(x),j(x),k(x){}
    ~Member() { cout << "~Member" << endl; }
};

class WithMember
{
    Member q, r, s;
    int i;
public:
    WithMember(int ii):i(ii){}
    ~WithMember() {
        cout << "~WithMember" << endl;
    }
};

int main()
{
    WithMember wm(1);
    return 0;
}

Member的构造函数对于内联是足够简单的,它不做什么特别的事情。没有继承和成员对象会引起的额外隐藏行为。但是在类WithMembers里,内联的构造函数和析构函数看起来似乎很直接很简单,但其实很复杂。成员对象q、r和s的构造函数和析构函数将被自动调用,这些构造函数和析构函数也是内联的,所以它们和普通的成员函数的差别是非常显著的。这并不意味着应该使构造函数和析构函数定义为非内联的,只是在一些特定的情况下,这样做才是合理的。一般来说,快速地写代码来建立一个程序的初始“轮廓”时,使用内联函数经常是便利的,但假如要考虑效率,内联是值得注意的一个问题。

5 预处理器的更多特征

前面说过,我们几乎总是希望使用内联函数代替预处理宏。然而当需要在标准C预处理器(通过继承也是C++预处理器)里使用3个特殊特征时却是例外:字符串定义、字符串拼接和标志粘贴。字符串定义的完成是用#指示,他容许一个标识符并把它转化为字符数组,然而字符串拼接在当两个相邻的字符串没有分隔符时发生,在这种情况下字符串组合在一起。在写调试代码时,这两个特征特别有用。

#define DEBUG(x) cout<<#x"="<<x<<endl;

上面的这个定义可以打印任何变量的值。也可以得到一个跟踪信息,在此信息里打印出他们执行的语句。

#define TRACE(s) cerr<<#s<<endl;s

#s将输出语句字符。第2个s重申了该语句,所以这个语句被执行。当然,这可能会产生问题,尤其是在一行for循环中。

for (int i = 0; i < 100; i++)
     TRACE(f(i));

因为在TRACE()宏里实际上有两个语句,所以一行for循环只执一个,解决办法是在宏中用逗号代替分号。