C++ 从 C 继承的一个重要特性就是效率。如果 C++ 的效率比 C 低得多,将会有相当多的程序员无法证明使用 c++ 的合理性。
在 C 中,保持效率的方法之一是通过使用宏,它允许你使看起来乍看之下是一个函数调用,而没有正常的函数调用开销。宏是用预处理器而不是编译器本身来实现的,预处理器直接用宏代码替换所有的宏调用,所以推送参数、进行汇编语言调用、返回参数和执行汇编语言返回都没有成本。所有的工作都是由预处理器执行的,所以您拥有了函数调用的便利性和可读性,但它不会让您付出任何代价(就内存空间或消耗的时间等函数调用开销而言)。
在 C++ 中使用预处理宏有两个问题。第一个也适用于 C 语言:一个宏看起来像一个函数调用,但并不总是如此。这会导致隐藏难以发现的 bug。第二个问题是 C++ 特有的:预处理器没有访问类成员数据的权限。这意味着预处理宏不能用作类成员函数。
为了保持预处理宏的效率,但是为了增加真正函数的安全性和类范围,C++ 有了内联函数。在这一章中,你将会看到 C++ 中预处理宏的问题,这些问题是如何通过内联函数解决的,以及关于内联工作方式的指导和见解。
预处理器陷阱
预处理器宏问题的关键在于,你可能会被愚弄,以为预处理器的行为和编译器的行为是一样的。当然,这是为了让宏看起来和行为起来像一个函数调用,所以很容易陷入这种虚构。当细微的差异出现时,困难就开始了。
举个简单的例子,考虑以下情况:
#define F (x) (x + 1)
现在,如果给F
打电话,比如
F(1)
预处理器出乎意料地将其扩展为
(x) (x + 1)(1)
该问题的出现是因为在宏定义中F
和它的左括号之间有间隙。当这个间隙被去掉后,你就可以用这个间隙调用宏了
F (1)
并且它仍然会适当地膨胀到
(1 + 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)
只要你使用一个“普通的”参数,宏的工作方式就非常像一个实函数。但是一旦你放松下来,开始相信是一个真实的函数,问题就开始了,正如你在清单 9-1 中看到的。
清单 9-1 。宏副作用
//: C09:MacroSideEffects.cpp
#include "../require.h" // To be INCLUDED from Header FILE
// *ahead* (Section: Improved error
// checking) Or *[Chapter 3](03.html)*
#include <fstream>
using namespace std;
#define BAND(x) (((x)>5 && (x)<10) ? (x) : 0)
int main() {
ofstream out("macro.out");
assure(out, "macro.out");
for(int i = 4; i < 11; i++) {
int a = i;
out << "a = " << a << endl << '\t';
out << "BAND(++a)=" << BAND(++a) << endl;
out << "\t a = " << a << endl;
}
} ///:∼
注意宏名中所有大写字符的使用。这是一个有用的实践,因为它告诉读者这是一个宏而不是一个函数,所以如果有问题,它可以作为一个小小的提醒。
下面是程序产生的输出,这完全不是您对真实函数的预期:
a = 4
BAND(++a)=0
a = 5
a = 5
BAND(++a)=8
a = 8
a = 6
BAND(++a)=9
a = 9
a = 7
BAND(++a)=10
a = 10
a = 8
BAND(++a)=0
a = 10
a = 9
BAND(++a)=0
a = 11
a = 10
BAND(++a)=0
a = 12
当a
为 4 时,只出现条件的第一部分,所以表达式只计算一次,宏调用的副作用是a
变成了 5,这是你在相同情况下从普通函数调用中所期望的。然而,当数字在范围内时,两个条件都被测试,这导致两个增量。结果是通过再次计算参数产生的,这将导致第三次增量。一旦数字超出范围,两个条件仍然被测试,所以你得到两个增量。副作用是不同的,取决于论点。
这显然不是您想要的看起来像函数调用的宏的行为。在这种情况下,显而易见的解决方案是使它成为一个真正的函数,这当然会增加额外的开销,并且如果您大量调用该函数,可能会降低效率。不幸的是,问题可能并不总是如此明显,您可能会在不知不觉中获得一个包含函数和宏混合在一起的库,因此像这样的问题可能会隐藏一些非常难以发现的错误。例如,cstdio
中的putc()
宏可能会对其第二个参数求值两次。这是在标准 c 中规定的。另外,如果不小心将toupper()
作为一个宏来实现,可能会对参数求值不止一次,这会给你带来意想不到的结果。
宏和访问
当然,在 C 语言中需要小心地编码和使用预处理宏,如果不是因为一个问题:宏没有成员函数所需的作用域的概念,那么在 C++ 中也可以做到这一点。预处理器只是执行文本替换,所以你不能说
class X {
int i;
public:
#define VAL(X::i) // Error
或者任何相近的东西。此外,没有迹象表明你指的是哪个对象。根本没有办法在宏中表达类的作用域。如果没有预处理器宏的替代方案,程序员会为了提高效率而制作一些数据成员public
,从而暴露底层实现并防止该实现发生变化,同时消除private
提供的保护。
内联函数
在解决访问private
类成员的宏的 C++ 问题时,所有与预处理宏相关的问题都被消除了。这是通过将宏的概念置于它们所属的编译器的控制之下来实现的。C++ 将宏实现为内联函数,这在任何意义上都是一个真正的函数。您期望从普通函数中得到的任何行为,都可以从内联函数中得到。唯一的区别是内联函数被就地扩展,就像预处理宏一样,因此函数调用的开销被消除了。因此,你应该(几乎)永远不要使用宏,只使用内联函数。
在类体中定义的任何函数都是自动内联的,但是您也可以通过在非类函数前面加上inline
关键字来使其内联。但是,要使它生效,您必须在声明中包含函数体,否则编译器会将其视为普通的函数声明。因此,
Inline int plusOne(int x);
除了声明该函数之外,没有任何其他作用(该函数以后可能会也可能不会获得内联定义)。成功的方法提供了功能体:
inline int plusOne(int x) { return ++x; }
请注意,编译器将检查(一如既往)函数参数列表和返回值的使用是否正确(执行任何必要的转换),这是预处理器无法做到的。此外,如果您试图将上述内容编写为预处理宏,您将会得到一个不想要的副作用。
您几乎总是希望将内联定义放在头文件中。当编译器看到这样的定义时,它会将函数类型(签名结合返回值)和函数体放在其符号表中。当您使用函数时,编译器会检查以确保调用是正确的并且返回值被正确使用,然后用函数体替换函数调用,从而消除了开销。内联代码确实会占用空间,但是如果函数很小,这实际上比执行普通函数调用(将参数压入堆栈并执行调用)所生成的代码占用的空间要少。
头文件中的内联函数有一个特殊的状态,因为您必须在每个使用该函数的文件中包含包含函数和的头文件,但是您不会以多个定义错误结束(然而,在所有包含内联函数的地方定义必须相同)。
类内联
要定义一个内联函数,通常必须在函数定义之前加上inline
关键字。然而,这在类定义中是不必要的。你在类定义中定义的任何函数都是自动内联的,正如你在清单 9-2 中看到的。
清单 9-2 。类内部的内联
//: C09:Inline.cpp
// Inlines inside classes
#include <iostream>
#include <string>
using namespace std;
class Point {
int i, j, k;
public:
Point(): i(0), j(0), k(0) {}
Point(int ii, int jj, int kk)
: i(ii), j(jj), k(kk) {}
void print(const string& msg = "") const {
if(msg.size() != 0) cout << msg << endl;
cout << "i = " << I << ", "
<< "j = " << j << ", "
<< "k = " << k << endl;
}
};
int main() {
Point p, q(1,2,3);
p.print("value of p");
q.print("value of q");
} ///:∼
这里,两个构造器和print( )
函数默认都是内联的。注意在main( )
中,您使用内联函数的事实是透明的,也应该是透明的。一个函数的逻辑行为必须相同,不管它是不是内联的(否则你的编译器会崩溃)。您将看到的唯一区别是性能。
当然,在类声明中处处使用内联是一种诱惑,因为这样可以省去定义外部成员函数的额外步骤。但是,请记住,内联的目的是为编译器提供更好的优化机会。但是内联一个大函数将导致代码在调用该函数的任何地方都被复制,产生代码膨胀,这可能会降低速度优势。
注唯一可靠的方法是用你的编译器去实验发现内联对你的程序的影响。
访问功能
内联在类中最重要的用途之一是访问函数 。这是一个小函数,允许您读取或更改对象的部分状态,即一个或多个内部变量。内联对于访问函数如此重要的原因可以在清单 9-3 中看到。
清单 9-3 。内联访问功能
//: C09:Access.cpp
// Inline access functions
class Access {
int i;
public:
int read() const { return i; }
void set(int ii) { i = ii; }
};
int main() {
Access A;
A.set(100);
int x = A.read();
} ///:∼
在这里,类用户从不直接接触类内部的状态变量,它们可以保持private
,处于类设计者的控制之下。所有对private
数据成员的访问都可以通过成员函数接口来控制。此外,访问效率非常高。以read()
为例。如果没有内联,为调用read()
而生成的代码通常会包括将this
压入堆栈并进行汇编语言调用。对于大多数机器,这段代码的大小会比内联创建的代码大,执行时间肯定会更长。
如果没有内联函数,注重效率的类设计者会倾向于简单地使i
成为公共成员,通过允许用户直接访问i
来消除开销。从设计的角度来看,这是灾难性的,因为i
变成了公共接口的一部分,这意味着类设计者永远不能改变它。你被一只叫做i
的int
卡住了。这是一个问题,因为稍后您可能会发现将状态信息表示为float
比int
更有用,但是因为inti
是公共接口的一部分,所以您不能更改它。或者你可能想执行一些额外的计算作为读取或设置i
的一部分,如果是public
你就不能这么做。另一方面,如果您一直使用成员函数来读取和更改对象的状态信息,您可以根据自己的意愿修改对象的底层表示。
此外,使用成员函数控制数据成员允许您向成员函数添加代码,以检测数据何时被更改,这在调试过程中非常有用。如果一个数据成员是public
,任何人都可以在你不知道的情况下随时更改它。
访问器和变异器
有些人进一步将访问函数的概念分为访问器(从对象读取状态信息)和变异器(改变对象的状态)。此外,函数重载可以用来为的访问器和赋值器提供相同的函数名;你如何调用函数决定了你是否正在读取或修改状态信息(见清单 9-4 )。
清单 9-4 。访问器和赋值器
//: C09:Rectangle.cpp
// Accessors & mutators
class Rectangle {
int wide, high;
public:
Rectangle(int w = 0, int h = 0)
: wide(w), high(h) {}
int width() const { return wide; } // Read
void width(int w) { wide = w; } // Set
int height() const { return high; } // Read
void height(int h) { high = h; } // Set
};
int main() {
Rectangle r(19, 47);
// Change width & height:
r.height(2 * r.width());
r.width(2 * r.height());
} ///:∼
构造器使用构造器初始化列表(在第 8 章中有简要介绍,在第 14 章中有完整介绍)来初始化wide
和high
的值(对于内置类型使用伪构造器形式)。
成员函数名不能使用与数据成员相同的标识符,因此您可能会尝试用前导下划线来区分数据成员。但是,带有前导下划线的标识符是保留的,因此您不应该使用它们。
你可以选择使用" get 和" set 来表示访问器和赋值器,如清单 9-5 所示。
清单 9-5 。使用获取和设置
//: C09:Rectangle2.cpp
// Accessors & mutators with "get" and "set"
class Rectangle {
int width, height;
public:
Rectangle(int w = 0, int h = 0)
: width(w), height(h) {}
int getWidth() const { return width; }
void setWidth(int w) { width = w; }
int getHeight() const { return height; }
void setHeight(int h) { height = h; }
};
int main() {
Rectangle r(19, 47);
// Change width & height:
r.setHeight(2 * r.getWidth());
r.setWidth(2 * r.getHeight());
} ///:∼
当然,访问器和赋值器不一定是内部变量的简单管道。有时他们可以进行更复杂的计算。清单 9-6 使用标准的 C 库时间函数来产生一个简单的Time
类。
清单 9-6 。使用时间函数
//: C09:Cpptime.h
// A simple time class
#ifndef CPPTIME_H
#define CPPTIME_H
#include <ctime>
#include <cstring>
class Time {
std::time_t t;
std::tm local;
char asciiRep[26];
unsigned char lflag, aflag;
void updateLocal() {
if(!lflag) {
local = *std::localtime(&t);
lflag++;
}
}
void updateAscii() {
if(!aflag) {
updateLocal();
std::strcpy(asciiRep,std::asctime(&local));
aflag++;
}
}
public:
Time() { mark(); }
void mark() {
lflag = aflag = 0;
std::time(&t);
}
const char* ascii() {
updateAscii();
return asciiRep;
}
// Difference in seconds:
int delta(Time* dt) const {
return int(std::difftime(t, dt->t));
}
int daylightSavings() {
updateLocal();
return local.tm_isdst;
}
int dayOfYear() { // Since January 1
updateLocal();
return local.tm_yday;
}
int dayOfWeek() { // Since Sunday
updateLocal();
return local.tm_wday;
}
int since1900() { // Years since 1900
updateLocal();
return local.tm_year;
}
int month() { // Since January
updateLocal();
return local.tm_mon;
}
int dayOfMonth() {
updateLocal();
return local.tm_mday;
}
int hour() { // Since midnight, 24-hour clock
updateLocal();
return local.tm_hour;
}
int minute() {
updateLocal();
return local.tm_min;
}
int second() {
updateLocal();
return local.tm_sec;
}
};
#endif // CPPTIME_H ///:∼
标准的 C 库函数对时间有多种表示,这些都是Time
类的一部分。然而,没有必要更新它们,所以取而代之的是使用time_t t
作为基本表示,tm local
和 ASCII 字符表示asciiRep
都有标志来指示它们是否已经更新到当前的time_t
。两个private
函数updateLocal()
和updateAscii()
检查标志并有条件地执行更新。
构造器调用mark()
函数(,用户也可以调用该函数来强制对象表示当前时间,这将清除两个标志,以指示本地时间和 ASCII 表示现在无效。ascii()
函数调用updateAscii()
,它将标准 C 库函数asctime()
的结果复制到本地缓冲区,因为asctime()
使用了一个静态数据区,如果在别处调用该函数,该数据区将被覆盖。ascii()
函数返回值是这个本地缓冲区的地址。
所有以daylightSavings()
开头的函数都使用updateLocal()
函数,这导致生成的复合内联相当大。这似乎不值得,尤其是考虑到您可能不会经常调用这些函数。然而,这并不意味着所有的函数都应该是非内联的。如果你让其他函数非内联,至少让updateLocal()
保持内联,这样它的代码会在非内联函数中重复,消除额外的函数调用开销。
清单 9-7 是一个小测试程序。
清单 9-7 。测试一个简单的时间类
//: C09:Cpptime.cpp
// Testing a simple time class
#include "Cpptime.h" // To be INCLUDED from Header FILE above
#include <iostream>
using namespace std;
int main() {
Time start;
for(int i = 1; i < 1000; i++) {
cout << i << ' ';
if(i%10 == 0) cout << endl;
}
Time end;
cout << endl;
cout << "start = " << start.ascii();
cout << "end = " << end.ascii();
cout << "delta = " << end.delta(&start);
} ///:∼
创建一个Time
对象,然后执行一些耗时的活动,然后创建第二个Time
对象来标记结束时间。它们显示开始、结束和经过的时间。
使用内联进行存储和堆栈
有了内联,你现在可以更有效地转换Stash
和Stack
类;参见清单 9-8 。
清单 9-8 。隐藏头文件 (带内联函数)
//: C09:Stash4.h
// Inline functions
#ifndef STASH4_H
#define STASH4_H
#include "../require.h"
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
Stash(int sz) : size(sz), quantity(0),
next(0), storage(0) {}
Stash(int sz, int initQuantity) : size(sz),
quantity(0), next(0), storage(0) {
inflate(initQuantity);
}
Stash::∼Stash() {
if(storage != 0)
delete []storage;
}
int add(void* element);
void* fetch(int index) const {
require(0 <= index, "Stash::fetch (-)index");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int count() const { return next; }
};
#endif // STASH4_H ///:∼
小函数作为内联显然很好,但是请注意,两个最大的函数仍然保留为非 - 内联,因为内联它们可能不会带来任何性能提升;参见清单 9-9 。
清单 9-9 。隐藏源代码 cpp 文件 (带内联函数)
//: C09:Stash4.cpp {O}
#include "Stash4.h" // To be INCLUDED from Header FILE above
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
int Stash::add(void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = next * size;
unsigned char* e = (unsigned char*) element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void Stash::inflate(int increase) {
assert(increase >= 0);
if(increase == 0) return;
int newQuantity = quantity + increase;
int newBytes = newQuantity * size;
int oldBytes = quantity * size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete [](storage); // Release old storage
storage = b; // Point to new memory
quantity = newQuantity; // Adjust the size
} ///:∼
清单 9-10 中的测试程序再次验证了一切正常。
清单 9-10 。测试 隐藏(使用内联函数
//: C09:Stash4Test.cpp
//{L} Stash4
#include "Stash4.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash(sizeof(int));
for(int i = 0; i < 100; i++)
intStash.add(&i);
for(int j = 0; j <intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*)intStash.fetch(j)
<< endl;
const int bufsize = 80;
Stash stringStash(sizeof(char) * bufsize, 100);
ifstream in("Stash4Test.cpp");
assure(in, "Stash4Test.cpp");
string line;
while(getline(in, line))
stringStash.add((char*)line.c_str());
int k = 0;
char* cp;
while((cp = (char*)stringStash.fetch(k++))!=0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
} ///:∼
这与之前使用的测试程序相同,因此输出应该基本相同。
Stack
类更好地利用了内联,正如你在清单 9-11 中看到的。
清单 9-11 。堆栈头文件 (带内联函数)
//: C09:Stack4.h
// With inlines
#ifndef STACK4_H
#define STACK4_H
#include "../require.h"
class Stack {
struct Link {
void* data;
Link* next;
Link(void* dat, Link* nxt):
data(dat), next(nxt) {}
}* head;
public:
Stack() : head(0) {}
∼Stack() {
require(head == 0, "Stack not empty");
}
void push(void* dat) {
head = new Link(dat, head);
}
void* peek() const {
return head ? head->data : 0;
}
void* pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
};
#endif // STACK4_H ///:∼
注意,在先前版本的Stack
中存在但为空的Link
析构函数已经被移除。在pop()
中,delete oldHead
表达式只是释放了那个Link
所使用的内存(并没有破坏Link
所指向的data
对象)。
大多数内联函数工作得很好,非常明显,特别是对于Link
。甚至pop()
看起来也是合理的,尽管任何时候你有条件或局部变量,内联是否有益还不清楚。在这里,函数足够小,可能不会伤害任何东西。
如果你所有的函数都是内联的,使用这个库就变得非常简单,因为不需要链接,正如你在清单 9-12 中的测试例子中看到的那样(注意这里没有Stack4.cpp
)。
清单 9-12 。测试 堆栈(使用内联函数
//: C09:Stack4Test.cpp
//{T} Stack4Test.cpp
#include "Stack4.h" // To be INCLUDED from Header FILE above
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the stack and print them:
string* s;
while((s = (string*)textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
} ///:∼
人们有时会编写包含所有内联函数的类,这样整个类都在头文件中。在程序开发过程中,这可能是无害的,尽管有时会导致编译时间变长。一旦程序稍微稳定下来,您可能会想要返回并在适当的地方使函数非内联。
内联和编译器
为了理解什么时候内联是有效的,了解编译器在遇到内联时做什么是有帮助的。与任何函数一样,编译器在其符号表中保存函数类型(即函数原型,包括名称和参数类型 T3,以及函数返回值)。另外,当编译器看到内联的函数类型和函数体解析无误时,*函数体的代码也被带入符号表。*代码是否以源代码形式、编译后的汇编指令或其他表示形式存储取决于编译器。
当调用内联函数时,编译器首先确保调用能够正确进行。也就是说,所有参数类型必须是函数的参数列表中的精确类型,或者编译器必须能够将类型转换为正确的类型,并且返回值必须是目标表达式中的正确类型(或可转换为正确类型)。当然,这正是编译器对任何函数所做的,并且与预处理器所做的明显不同,因为预处理器不能检查类型或进行转换。
如果所有函数类型信息都符合调用的上下文,那么内联代码将直接替换函数调用,从而消除调用开销,并允许编译器进行进一步优化。同样,如果内联是一个成员函数,对象的地址(this
)被放在适当的位置,这当然是预处理器不能执行的另一个动作。
限制
在两种情况下,编译器不能执行内联。在这些情况下,它简单地通过获取内联定义并为函数创建存储,就像它为非内联函数所做的那样,来恢复函数的普通形式。如果它必须在多个翻译单元中这样做(这通常会导致多重定义错误),链接器被告知忽略多重定义。
如果函数太复杂,编译器无法执行内联。这取决于特定的编译器,但是在大多数编译器放弃的时候,内联可能不会给你带来任何效率。一般来说,任何类型的循环都被认为太复杂而不能扩展为内联,如果你仔细想想,循环在函数内部花费的时间可能比函数调用开销所需的时间要多得多。如果函数只是简单语句的集合,编译器内联它大概不会有什么问题,但是如果语句很多,函数调用的开销会比执行主体的开销小很多。记住,每次你调用一个大的内联函数时,整个函数体都被插入到每次调用的位置,所以你很容易得到代码膨胀而没有任何明显的性能提升。
如果函数的地址是隐式或显式获取的,编译器也不能执行内联。如果编译器必须产生一个地址,那么它将为函数代码分配存储空间并使用产生的地址。然而,在不需要地址的地方,编译器可能仍然会内联代码。
理解内联只是给编译器的一个建议是很重要的;编译器根本不需要内联任何东西。好的编译器会内联小而简单的函数,同时智能地忽略太复杂的内联。这将给你你想要的结果——一个函数调用的真正语义和一个宏的效率。
正向引用
如果你在想象编译器是如何实现内联的,你可能会迷惑自己,以为存在比实际更多的限制。特别是,如果一个内联引用了一个还没有在类中声明的函数(不管这个函数是不是内联的),编译器似乎不能处理它,如清单 9-13 所示。
清单 9-13 。内联评估顺序
//: C09:EvaluationOrder.cpp
class Forward {
int i;
public:
Forward() : i(0) {}
// Call to undeclared function:
int f() const { return g() + 1; }
int g() const { return i; }
};
int main() {
Forward frwd;
frwd.f();
} ///:∼
在f()
中,对g()
进行调用,尽管g()
尚未声明。这是可行的,因为语言定义规定,在类声明的右括号之前,类中的任何内联函数都不应被计算。
当然,如果g()
反过来调用f()
,你会得到一组递归调用,这对编译器来说太复杂了,无法内联。(此外,您必须在f()
或g()
中执行一些测试,以迫使其中一个“触底”,否则递归将是无限的。)
构造器和析构函数中隐藏的活动
构造器和析构函数是两个容易让人误以为内联比实际更有效的地方。构造器和析构函数可能有隐藏的活动,因为类可以包含子对象,必须调用它们的构造器和析构函数。这些子对象可能是成员对象,也可能因为继承而存在(在第 14 章中涉及)。作为一个带有成员对象的类的例子,参见清单 9-14 。
清单 9-14 。说明内联中隐藏的活动(对于具有成员对象的类)
//: C09:Hidden.cpp
// Hidden activities in inlines
#include <iostream>
using namespace std;
class Member {
int i, j, k;
public:
Member(int x = 0) : i(x), j(x), k(x) {}
∼Member() { cout << "∼Member" << endl; }
};
classWithMembers {
Member q, r, s; // Have constructors
int i;
public:
WithMembers(int ii) : i(ii) {} // Trivial?
∼WithMembers() {
cout << "∼WithMembers" << endl;
}
};
int main() {
WithMembers wm(1);
} ///:∼
Member
的构造器很简单,可以内联,因为没有什么特别的事情发生——没有继承或成员对象导致额外的隐藏活动。但是在class WithMembers
中,发生的事情比看上去的要多。成员对象q
、r
和s
的构造器和析构函数都是自动调用的,而且那些构造器和析构函数也是内联的,所以与普通成员函数的区别很大。这并不一定意味着你应该总是把构造器和析构函数定义成非内联的;有些情况下是有道理的。此外,当您通过快速编写代码来绘制程序的初始“草图”时,使用内联通常更方便。但是如果你关心效率,这是一个值得一看的地方。
减少混乱
如果你想优化和减少混乱 ,使用inline
关键字。使用这种方法,早先的Rectangle.cpp
例子显示在清单 9-15 中。
清单 9-15 。使用 inline 关键字
//: C09:Noinsitu.cpp
// Removing in situ functions
class Rectangle {
int width, height;
public:
Rectangle(int w = 0, int h = 0);
int getWidth() const;
void setWidth(int w);
int getHeight() const;
void setHeight(int h);
};
inline Rectangle::Rectangle(int w, int h)
: width(w), height(h) {}
inline int Rectangle::getWidth() const {
return width;
}
inline void Rectangle::setWidth(int w) {
width = w;
}
inline int Rectangle::getHeight() const {
return height;
}
inline void Rectangle::setHeight(int h) {
height = h;
}
int main() {
Rectangle r(19, 47);
// Transpose width & height:
int iHeight = r.getHeight();
r.setHeight(r.getWidth());
r.setWidth(iHeight);
} ///:∼
现在,如果您想比较内联函数和非内联函数的效果,您可以简单地删除inline
关键字。(内联函数通常应该放在头文件中,而非内联函数必须放在它们自己的翻译单元中。)如果你想把函数放到文档中,这是一个简单的剪切粘贴操作。
更多预处理功能
前面我说过,你几乎总是想用inline
函数代替预处理宏。例外情况是当你需要在 C 预处理器(也是 C++ 预处理器)中使用三个特殊的特性:字符串化 、字符串连接和标记粘贴。本书前面介绍的字符串化是通过#
指令执行的,它允许您获取一个标识符并将其转换成一个字符数组。当两个相邻的字符数组之间没有标点符号时,就会发生字符串串联 ,在这种情况下,它们被组合在一起。这两个特性在编写调试代码时特别有用。因此,
#define DEBUG(x) cout << #x " = " << x << endl
打印任何变量的值。您还可以获得一个在语句执行时打印出来的跟踪,例如
#define TRACE(s) cerr << #s << endl; s
#s
字符串化输出语句,第二个s
重复语句,如下所示:
for(int i = 0; I < 100; i++)
TRACE(f(i));
因为TRACE()
宏中实际上有两条语句,所以单行for
循环只执行第一条。解决方法是在宏中用逗号代替分号。
令牌粘贴
令牌粘贴 ,用##
指令实现,在你制作代码的时候非常有用。它允许您将两个标识符粘贴在一起,以自动创建一个新的标识符。举个例子,
#define FIELD(a) char* a##_string; int a##_size
class Record {
FIELD(one);
FIELD(two);
FIELD(three);
// ...
};
每次调用FIELD()
宏都会创建一个标识符来保存一个字符数组,另一个标识符保存该数组的长度。不仅更容易阅读,还可以消除编码错误,使维护更容易。
改进的错误检查
到目前为止,require.h
函数一直在使用,没有定义它们(尽管assert()
也被用来在适当的时候帮助检测程序员错误)。现在是时候定义这个头文件了。内联函数在这里很方便,因为它们允许将所有内容放在头文件中,这简化了使用包的过程。您只需要包含头文件,不需要担心链接实现文件。
您应该注意到,异常提供了一种更有效的方法来处理多种错误——尤其是那些您想要恢复的错误——而不仅仅是暂停程序。然而,require.h
处理的条件是那些阻止程序继续运行的条件,比如用户没有提供足够的命令行参数或者文件无法打开。因此,他们调用标准 C 库函数exit()
是可以接受的。
清单 9-16 就是这个头文件(你在第 3 章中也看到了,因为它被用来构建前几章中的一些例子。留给我自己,这是最合适的地方,因为它利用了内联)。
清单 9-16 。require.h 头文件
//: :require.h
// Test for error conditions in programs
// Local "using namespace std" for old compilers
#ifndef REQUIRE_H
#define REQUIRE_H
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <string>
inline void require(bool requirement,
const std::string& msg = "Requirement failed"){
using namespace std;
if (!requirement) {
fputs(msg.c_str(), stderr);
fputs("\n", stderr);
exit(1);
}
}
inline void requireArgs(int argc, int args,
const std::string& msg =
"Must use %d arguments") {
using namespace std;
if (argc != args + 1) {
fprintf(stderr, msg.c_str(), args);
fputs("\n", stderr);
exit(1);
}
}
inline void requireMinArgs(intargc, intminArgs,
const std::string& msg =
"Must use at least %d arguments") {
using namespace std;
if(argc < minArgs + 1) {
fprintf(stderr, msg.c_str(), minArgs);
fputs("\n", stderr);
exit(1);
}
}
inline void assure(std::ifstream& in,
const std::string& filename = "") {
using namespace std;
if(!in) {
fprintf(stderr, "Could not open file %s\n",
filename.c_str());
exit(1);
}
}
inline void assure(std::ofstream& out,
const std::string& filename = "") {
using namespace std;
if(!out) {
fprintf(stderr, "Could not open file %s\n",
filename.c_str());
exit(1);
}
}
#endif // REQUIRE_H ///:∼
默认值提供合理的消息,必要时可以更改。
您会注意到,没有使用char*
参数,而是使用了const string&
参数。这使得char*
和string
都可以作为这些函数的参数,因此更加有用(您可能希望在自己的编码中遵循这种形式)。
在对requireArgs()
和requireMinArgs()
的定义中,您在命令行上需要的参数数量增加了 1,因为argc
总是将正在执行的程序的名称作为参数 0,因此它的值总是比命令行上的实际参数数量多 1。
注意每个函数中局部using namespace std
声明的使用。这是因为在撰写本文时,一些编译器没有在namespace std
中包含标准的 C 库函数,所以显式限定会导致编译时错误。本地声明允许require.h
使用正确和不正确的库,而不需要为任何包含这个头文件的人开放名称空间std
。
清单 9-17 是一个测试require.h
的简单程序。
清单 9-17 。测试要求. h
//: C09:ErrTest.cpp
//{T} ErrTest.cpp
// Testing require.h
#include "../require.h"
#include <fstream>
using namespace std;
int main(int argc, char* argv[]) {
int i = 1;
require(i, "value must be nonzero");
requireArgs(argc, 1);
requireMinArgs(argc, 1);
ifstream in(argv[1]);
assure(in, argv[1]);
// Use the file name
ifstream nofile("nofile.xxx");
// Fails:
//! assure(nofile);
// The default argument
ofstream out("tmp.txt");
assure(out);
} ///:∼
您可能想更进一步打开文件,给require.h
添加一个宏,比如:
#define IFOPEN(VAR, NAME) \
ifstream VAR(NAME); \
assure(VAR, NAME);
它可以这样使用:
IFOPEN(in, argv[1])
乍一看,这似乎很吸引人,因为这意味着需要输入的内容更少。这不是非常不安全,但这是一条最好避开的路。再次注意,宏看起来像函数,但行为不同;它实际上创建了一个对象(in
),其作用域超出了宏的范围。你可能理解这一点,但是对于新的程序员和代码维护人员来说,这只是他们需要解决的又一个问题。C++ 已经够复杂的了,所以只要有可能,就尽量说服自己不要使用预处理宏。
审查会议
- 能够隐藏一个类的底层实现是非常重要的,因为以后你可能会想要改变这个实现。
- 您将为了效率做出这些改变,或者因为您对问题有了更好的理解,或者因为您想要在实现中使用的一些新类变得可用。
- 任何危及底层实现隐私的事情都会降低语言的灵活性。因此,内联函数非常重要,因为它几乎消除了对预处理器宏的需求以及随之而来的问题。
- 用
inlines
*,*成员函数可以作为efficient
作为预处理器宏。 inline
函数当然可以是类定义中的overused
。程序员被诱惑这样做,因为这样更容易,所以它会发生。然而,这并不是一个大问题,因为以后,当寻求尺寸缩减时,您可以将函数更改为非inlines
,而不会影响它们的功能。- 开发指南应该是“首先让代码工作,然后优化它。”
- 从这一点开始,我将只提及本章中给出的头文件
require.h
。