C 到 C++ 迁移手册(三)
七、函数重载和默认参数
任何编程语言的一个重要特征就是方便地使用名字。
当你创建一个对象(一个变量)时,你给一个存储区域命名。功能是动作的名称。通过编造名字来描述手头的系统,你创建了一个更容易被人们理解和改变的程序。这很像写散文——目标是与你的读者交流。
当将人类语言中的细微差别的概念映射到编程语言时,出现了一个问题。通常,同一个词根据上下文表达不同的意思。也就是说,一个单词有多重含义——它“超载了”这是非常有用的,尤其是在涉及到细微差别的时候。你说“洗衬衫,洗车。“如果被迫说,“衬衫 _ 洗衬衫,汽车 _ 洗车”那就太傻了,这样听者就不必对所做的动作进行任何区分。人类的语言有内在的冗余,所以即使你漏掉了几个单词,你仍然可以确定意思。我们不需要唯一的标识符;我们可以从上下文推断出意思。
然而,大多数编程语言要求每个函数都有一个唯一的标识符。如果你想打印三种不同类型的数据:int、char和float,你通常需要创建三个不同的函数名,例如:print_int()、print_char()和print_float()。当你写程序时,这会给你带来额外的工作,当读者试图理解它时,也会给他们带来额外的工作。
在 C++ 中,另一个因素迫使函数名重载:构造器。因为构造器的名字是由类名预先确定的,所以似乎只能有一个构造器。但是如果你想用多种方式创建一个对象呢?例如,假设您构建了一个类,它可以用标准方式初始化自己,也可以通过从文件中读取信息来初始化自己。您需要两个构造器,一个不带参数(默认构造器),另一个带参数string,这是初始化对象的文件名。两者都是构造器,所以它们必须有相同的名字:类名。因此,函数重载对于允许相同的函数名(在本例中是构造器)用于不同的参数类型是必不可少的。
尽管函数重载对于构造器来说是必须的,但它是一种普遍的便利,可以用于任何函数,而不仅仅是类成员函数。此外,函数重载意味着如果你有两个包含同名函数的库,只要参数列表不同,它们就不会冲突。在这一章中,你会详细地看到所有这些因素。
本章的主题是函数名的方便使用。函数重载允许你为不同的函数使用相同的名字,但是还有第二种方法使调用函数更方便。如果你想以不同的方式调用同一个函数呢?当函数有很长的参数列表时,如果所有调用的大多数参数都是相同的,那么编写函数调用会变得很乏味(读起来也很混乱)。C++ 中一个常用的特性叫做默认参数。默认参数是编译器在函数调用中没有指定的情况下插入的参数。因此,调用f("hello")、f("hi", 1)和f("howdy", 2, 'c')都可以是对同一个函数的调用。它们也可能是对三个重载函数的调用,但是当参数列表如此相似时,您通常会想要相似的行为,即调用单个函数。
函数重载和默认参数其实并不复杂。当你读到本章末尾的时候,你将理解何时使用它们,以及在编译和链接过程中实现它们的底层机制。
更多名称装饰
在第四章中,引入了 名称修饰的概念。在代码中
void f();
class X { void f(); };
class X范围内的功能f()与f()的全局版本不冲突。编译器通过为f()和X::f()的全局版本制造不同的内部名称来执行这个范围。在第四章的中,有人建议名字只是类名和函数名的“修饰”,所以编译器使用的内部名字可能是_f和_X_f。然而,事实证明函数名修饰涉及的不仅仅是类名。
原因如下。假设您想重载两个函数名,
void print(char);
void print(float);
无论它们是在一个类中还是在全局范围中,都没有关系。如果编译器只使用函数名的作用域,它就不能生成唯一的内部标识符。在这两种情况下,你都会以_print结束。重载函数的思想是使用相同的函数名,但使用不同的参数列表。因此,为了使重载工作,编译器必须用参数类型的名称来修饰函数名。上面的函数定义在全局范围内,产生的内部名字可能看起来像_print_char和_print_float。值得注意的是,编译器修饰名字的方式没有标准,所以你会看到不同编译器的结果非常不同。
注意你可以通过告诉编译器生成汇编语言输出来看看它是什么样子。
当然,如果您想为特定的编译器和链接器购买编译过的库,这会带来问题——但是即使名称修饰是标准化的,也会有其他的障碍,因为不同的编译器生成代码的方式不同。
这里是一个关于汇编语言代码片段的例子:
………………………………………………………………………………………………………………………………………………………………………
IF LCODE ; if large code model
Extrn _func1:far ; then far function
ELSE
Extrn _func1:near ; else near function
ENDIF
………………………………………………………………………………………………………………………………………………………………………
……………………………………………………………………………………………………………………………………………………………………………………………
Begcode func2 ; begin code for func2
Public func2 ; make func2 global
IF LCODE ; if large code model
_func2 proc far ; then define func2 function
ELSE
_func2 proc near ; else define func2 function
ENDIF
……………………………………………………………………………………………………………………………………………………………………………………………
这就是函数重载的全部内容:只要参数列表不同,不同的函数可以使用相同的函数名。编译器修饰名称、范围和参数列表,以产生供它和链接器使用的内部名称。
返回值重载
很常见的问题是,"为什么只有作用域和参数列表?为什么不返回值?“乍一看,用内部函数名来修饰返回值似乎是有意义的。然后你也可以重载返回值,就像这样:
void f();
int f();
当编译器可以从上下文中明确地确定含义时,这很好,就像在int x = f();中一样。然而,在 C 中你总是可以调用一个函数并忽略返回值(也就是说,你可以调用它的副作用)。在这种情况下,编译器如何区分哪个调用是有意义的?可能更糟的是,读者很难知道哪个函数调用是什么意思。只对返回值重载有点太微妙了,因此在 C++ 中是不允许的。
类型安全链接
这种名称装饰还有一个额外的好处。当客户端程序员错误地声明一个函数,或者更糟的是,一个函数没有先声明就被调用,编译器从调用的方式推断出函数声明时,C 中就会出现一个特别棘手的问题。有时这个函数声明是正确的,但是当它不正确时,它可能是一个很难发现的 bug。
因为所有函数在 C++ 中使用之前都必须声明,所以这个问题出现的机会大大减少了。C++ 编译器拒绝为您自动声明一个函数,所以您可能会包含适当的头文件。然而,如果出于某种原因,您仍然设法错误地声明了一个函数,无论是通过手工声明还是包含错误的头文件(可能是一个过时的头文件),名称修饰提供了一个安全网,通常被称为类型安全链接。
考虑下面的场景。一个文件中有一个函数的定义。
//: C07:Def.cpp {O}
// Function definition
void f(int) {}
///:∼
在第二个文件中,函数被错误地声明,然后被调用。
//: C07:Use.cpp
//{L} Def
// WRONG Function declaration
void f(char);
int main() {
//! f(1); // Causes a linker error
} ///:∼
尽管您可以看到该函数实际上是f(int),但编译器并不知道这一点,因为它被告知(通过显式声明)该函数是f(char)。因此,编译是成功的。在 C 中,链接器也会成功,但是在 C++ 中不会。因为编译器修饰了名字,定义变成了类似于f_int的东西,而函数的使用是f_char。当链接器试图解析对f_char的引用时,它只能找到f_int,并给出一个错误消息。这是类型安全的链接。尽管问题并不经常出现,但一旦出现,就很难发现,尤其是在大型项目中。在这种情况下,只要通过 C++ 编译器运行一个 C 程序,就可以很容易地找到一个棘手的错误。
过载示例
让我们修改前面的例子来使用函数重载。如前所述,重载的一个直接有用的地方是在构造器中。你可以在清单 7-1 中的类的版本中看到这一点。
清单 7-1 。函数重载
//: C07:Stash3.h
// Function overloading
#ifndef STASH3_H
#define STASH3_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 size); // Zero quantity
Stash(int size, int initQuantity);
∼Stash();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH3_H ///:∼
第一个Stash()构造器和之前的一样,但是第二个构造器有一个Quantity参数来指示要分配的存储位置的初始数量。在定义中,你可以看到quantity的内部值被设置为零,还有storage指针。在第二个构造器中,对inflate(initQuantity)的调用将quantity增加到分配的大小(参见清单 7-2 )。
清单 7-2 。更多函数重载
//: C07:Stash3.cpp {O}
// Function overloading
#include "Stash3.h" // To be INCLUDED from Header FILE above
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
Stash::Stash(int sz) {
size = sz;
quantity = 0;
next = 0;
storage = 0;
}
Stash::Stash(int sz, int initQuantity) {
size = sz;
quantity = 0;
next = 0;
storage = 0;
inflate(initQuantity);
}
Stash::∼Stash() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
}
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::fetch(int index) {
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 Stash::count() {
return next;
// Number of elements in CStash
}
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
} ///:∼
当您使用第一个构造器时,不会为storage分配内存。分配发生在你第一次尝试add()一个对象的时候,以及在add()内部超过当前内存块的任何时候。
两个构造器都在清单 7-3 中的测试程序中进行了测试。
清单 7-3 。测试程序
//: C07:Stash3Test.cpp
//{L} Stash3
// Function overloading
#include "Stash3.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("Stash3Test.cpp");
assure(in, "Stash3Test.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;
} ///:∼
对stringStash的构造器调用使用了第二个参数;大概你知道一些关于你正在解决的特定问题的特别之处,这允许你为Stash选择一个初始大小。
联盟
如你所见,C++ 中struct和class的唯一区别是struct默认为public,class默认为private。如你所料,struct也可以有构造器和析构函数。但事实证明,union也可以有构造器、析构函数、成员函数,甚至访问控制。你可以在清单 7-4 中再次看到重载的用途和好处。
清单 7-4 。联合
//: C07:UnionClass.cpp
// Unions with constructors and member functions
#include <iostream>
using namespace std;
union U {
private: // Access control too!
int i;
float f;
public:
U(int a);
U(float b);
∼U();
int read_int();
float read_float();
};
U::U(int a) { i = a; }
U::U(float b) { f = b;}
U::∼U() { cout << "U::∼U()\n"; }
int U::read_int() { return i; }
float U::read_float() { return f; }
int main() {
U X(12), Y(1.9F);
cout << X.read_int() << endl;
cout << Y.read_float() << endl;
} ///:∼
从清单 7-4 中的代码你可能会认为union和class之间唯一的区别是数据存储的方式(也就是说, int和float 覆盖在同一块存储器)。然而,union在继承过程中不能用作基类,从面向对象设计的角度来看,这是非常有限的。
注你会在第十四章中了解到关于继承的知识。
尽管成员函数在某种程度上规范了对union的访问,但是一旦union被初始化,仍然没有办法防止客户端程序员选择错误的元素类型。在清单 7-4 中,你可以说X.read_float(),尽管这并不恰当。然而,一个“安全的”union可以被封装在一个类中。在清单 7-5 中,注意enum是如何阐明代码的,重载是如何在构造器中派上用场的。
清单 7-5 。安全的结合
//: C07:SuperVar.cpp
// A super-variable
#include <iostream>
using namespace std;
class SuperVar {
enum {
character,
integer,
floating_point
} vartype; // Define one
union { // Anonymous union
char c;
int i;
float f;
};
public:
SuperVar(char ch);
SuperVar(int ii);
SuperVar(float ff);
void print();
};
SuperVar::SuperVar(char ch) {
vartype = character;
c = ch;
}
SuperVar::SuperVar(int ii) {
vartype = integer;
i = ii;
}
SuperVar::SuperVar(float ff) {
vartype = floating_point;
f = ff;
}
voidSuperVar::print() {
switch (vartype) {
case character:
cout << "character: " << c << endl;
break;
case integer:
cout << "integer: " << i << endl;
break;
case floating_point:
cout << "float: " << f << endl;
break;
}
}
int main() {
SuperVarA('c'), B(12), C(1.44F);
A.print();
B.print();
C.print();
} ///:∼
在清单 7-5 中,enum没有类型名(它是一个未标记的枚举)。如果您打算立即定义enum的实例,这是可以接受的,就像这里所做的那样。以后不需要引用enum’s类型名,所以类型名是可选的。
union没有类型名和变量名。这被称为匿名联合,它为union创建了空间,但不需要使用变量名和点运算符访问union元素。例如,匿名union的一个例子是
//: C07:AnonymousUnion.cpp
int main() {
union {
int i;
float f;
};
// Access members without using qualifiers:
i = 12;
f = 1.22;
} ///:∼
请注意,您可以像访问普通变量一样访问匿名联合的成员。唯一的区别是两个变量占据相同的空间。如果匿名union在文件范围内(在所有函数和类之外),那么它必须被声明为static,这样它就有了内部链接。
虽然SuperVar现在是安全的,但它的有用性有点可疑;首先使用一个union的原因是为了节省空间,相对于union中的数据而言,vartype的添加占用了相当多的空间,因此节省的空间被有效地消除了。有几个备选方案可以使这个方案可行。如果vartype控制不止一个union实例——如果它们都是同一类型——那么你只需要一个用于这个组,它不会占用更多的空间。一个更有用的方法是在所有的vartype代码周围放上#ifdef,这样可以保证在开发和测试过程中正确使用。对于运输代码,可以消除额外的空间和时间开销。
默认参数
在Stash3.h ( 清单 7-1 )中,检查Stash()的两个构造器。他们看起来没什么不同,不是吗?事实上,第一个构造器似乎是第二个的特例,初始的size被设置为零。创建和维护一个相似功能的两个不同版本有点浪费精力。
C++ 提供了一个补救方法,用默认参数来。默认参数是在声明中给定的值,如果在函数调用中没有提供值,编译器会自动插入该值。在Stash的例子中,你可以替换这两个函数
Stash(int size); // Zero quantity
Stash(int size, int initQuantity);
使用单一功能
Stash(int size, int initQuantity = 0);
简单地删除了Stash(int)定义——只需要一个Stash(int, int)定义。
现在,两个对象定义
Stash A(100), B(100, 0);
会产生完全相同的结果。两种情况下调用的是相同的构造器,但是对于A,当编译器发现第一个参数是int并且没有第二个参数时,它会自动替换第二个参数。编译器已经看到了默认的参数,所以它知道如果它替换了第二个参数,它仍然可以调用函数,这就是你让它成为默认参数的目的。
默认参数很方便,因为函数重载也很方便。这两个特性都允许您在不同的情况下使用单个函数名。不同之处在于,使用缺省参数时,当你不想把它们放入自己的参数中时,编译器会替换它们。前面的示例是使用默认参数而不是函数重载的好地方;否则你会得到两个或更多具有相似特征和相似行为的函数。如果函数有非常不同的行为,使用默认参数通常没有意义(就此而言,您可能想问两个行为非常不同的函数是否应该有相同的名称)。
使用默认参数时,有两条规则你必须知道。首先,只能默认尾随参数。也就是说,不能有一个默认参数后跟一个非默认参数。其次,一旦你在一个特定的函数调用中开始使用缺省参数,那么该函数的参数列表中所有后续的参数都必须是缺省的(这遵循第一条规则)。
默认参数只放在函数声明中(通常放在头文件中)。编译器必须先看到默认值,然后才能使用它。有时,出于文档的目的,人们会将默认参数的注释值放在函数定义中,例如:
Void fn(int x /* = 0 */) { // ...
占位符参数
函数声明中的参数可以在没有标识符的情况下声明。当这些与默认参数一起使用时,看起来可能有点滑稽。你可以用结束
void f(int x, int = 0, float = 1.1);
在 C++ 中,函数定义中也不需要标识符。
void f(int x, int, float flt) { /* ... */ }
在函数体中,可以引用x和flt,但不能引用中间的参数,因为它没有名字。尽管如此,函数调用仍然必须为占位符提供一个值:f(1)或f(1,2,3.0)。该语法允许您将参数作为占位符放入,而不使用它。其思想是,您可能希望稍后更改函数定义以使用占位符,而不更改调用该函数的所有代码。当然,您可以通过使用命名参数来完成同样的事情,但是如果您为函数体定义了参数而没有使用它,大多数编译器会给您一个警告消息,假设您犯了一个逻辑错误。通过有意省略参数名称,可以隐藏此警告。
更重要的是,如果您开始使用一个函数参数,后来决定不再需要它,您可以有效地删除它,而不会生成警告,并且不会干扰任何调用该函数以前版本的客户端代码。
选择重载还是默认参数
函数重载和默认参数都为调用函数名提供了便利。然而,知道使用哪种技术有时会令人困惑。例如,考虑下面这个为你自动管理内存块而设计的工具(清单 7-6 )。
清单 7-6 。管理内存块(头文件)
//: C07:Mem.h
#ifndef MEM_H
#define MEM_H
typedef unsigned char byte;
classMem {
byte* mem;
int size;
void ensureMinSize(int minSize);
public:
Mem();
Mem(int sz);
∼Mem();
int msize();
byte* pointer();
byte* pointer(int minSize);
};
#endif // MEM_H ///:∼
一个Mem对象持有一个byte块,并确保你有足够的存储空间。默认构造器不分配任何存储,第二个构造器确保在Mem对象中有sz存储。析构函数释放存储,msize()告诉你Mem对象中当前有多少字节,pointer()产生一个指向存储起始地址的指针(Mem是一个相当低级的工具)。有一个pointer()的重载版本,其中客户端程序员可以说他们想要一个指向至少minSize大的字节块的指针,成员函数确保了这一点。
构造器和pointer()成员函数都使用private ensureMinSize()成员函数来增加内存块的大小(注意,如果调整了内存大小,保存pointer()的结果是不安全的)。
清单 7-7 显示了这个类的实现。
清单 7-7 。管理内存块(源代码对象 cpp 文件)
//: C07:Mem.cpp {O}
#include "Mem.h" // To be INCLUDED from Header FILE above
#include <cstring>
using namespace std;
Mem::Mem() { mem = 0; size = 0; }
Mem::Mem(int sz) {
mem = 0;
size = 0;
ensureMinSize(sz);
}
Mem::∼Mem() { delete []mem; }
int Mem::msize() { return size; }
void Mem::ensureMinSize(int minSize) {
if(size < minSize) {
byte* newmem = new byte[minSize];
memset(newmem + size, 0, minSize - size);
memcpy(newmem, mem, size);
delete []mem;
mem = newmem;
size = minSize;
}
}
byte* Mem::pointer() { return mem; }
byte* Mem::pointer(int minSize) {
ensureMinSize(minSize);
return mem;
} ///:∼
您可以看到,ensureMinSize()是唯一负责分配内存的函数,,它是从第二个构造器和第二个重载形式的pointer()中使用的。在 ensureMinSize()内部,如果size足够大,什么都不需要做。如果必须分配新的存储空间以使块更大(默认构造后块的大小为零也是这种情况),则使用标准 C 库函数memset()将新的“额外”部分设置为零,该函数在第五章 *中介绍。*随后的函数调用是对标准 C 库函数memcpy()的调用,在这种情况下,它将现有字节从mem复制到newmem(通常以高效的方式)。最后,旧的内存被删除,新的内存和大小被分配给适当的成员。
Mem类被设计用作其他类中的工具,以简化它们的内存管理(它也可以用来隐藏更复杂的内存管理系统,例如由操作系统提供的)。通过创建一个简单的*“字符串”*类,在清单 7-8 中对其进行了适当的测试。
清单 7-8 。测试 Mem 类
//: C07:MemTest.cpp
// Testing the Mem class
//{L} Mem
#include "Mem.h"
#include <cstring>
#include <iostream>
using namespace std;
classMyString {
Mem* buf;
public:
MyString();
MyString(char* str);
∼MyString();
void concat(char* str);
void print(ostream &os);
};
MyString::MyString() { buf = 0; }
MyString::MyString(char* str) {
buf = new Mem(strlen(str) + 1);
strcpy((char*)buf->pointer(), str);
}
void MyString::concat(char* str) {
if(!buf) buf = new Mem;
strcat((char*)buf->pointer(
buf->msize() + strlen(str) + 1), str);
}
void MyString::print(ostream &os) {
if(!buf) return;
os << buf->pointer() << endl;
}
MyString::∼MyString() { delete buf; }
int main() {
MyStrings("My test string");
s.print(cout);
s.concat(" some additional stuff");
s.print(cout);
MyString s2;
s2.concat("Using default constructor");
s2.print(cout);
} ///:∼
这个类所能做的就是创建一个MyString,连接文本,并打印到一个ostream。该类只包含一个指向Mem、的指针,但是请注意默认构造器和第二个构造器之间的区别,前者将指针设置为零,后者创建一个Mem并将数据复制到其中。默认构造器的优点是,你可以很便宜地创建一个空的MyString对象的大数组,因为每个对象的大小只有一个指针,默认构造器唯一的开销就是赋值给零。当你连接数据时,MyString的成本才开始增加;此时,Mem对象被创建,如果它还没有被创建的话。然而,如果您使用默认的构造器并且从不连接任何数据,那么析构函数调用仍然是安全的,因为调用零的delete被定义为它不会试图释放存储空间或者导致问题。
如果你看看这两个构造器,乍一看,它似乎是默认参数的首选。但是,如果您删除默认构造器并使用默认参数编写剩余的构造器,如
MyString(char* str = "");
一切都会正常工作,但是您会失去之前的效率优势,因为总是会创建一个Mem对象。要恢复效率,您必须以这种方式修改构造器:
MyString::MyString(char* str) {
if(!*str) { // Pointing at an empty string
buf = 0;
return;
}
buf = new Mem(strlen(str) + 1);
strcpy((char*)buf->pointer(), str);
}
这实际上意味着,与使用非默认值的情况相比,默认值成为一个标志,导致执行一段单独的代码。虽然对于这样的小构造器来说,这似乎是无害的,但通常这种做法会带来问题。
如果您不得不寻找默认值,而不是将其视为普通值,那么这应该是一个线索,您将在一个函数体中有效地结束两个不同的函数:一个版本用于正常情况,一个版本用于默认值。你也可以把它分成两个不同的函数体,让编译器来选择。
这导致了效率的轻微(但通常看不见的)提高,因为额外的参数没有被传递,条件的额外代码没有被执行。更重要的是,你为两个独立的函数保存代码两个独立的函数,而不是使用默认参数将它们合并成一个,这将导致更容易维护,尤其是如果函数很大的话。
另一方面,考虑一下Mem类。如果您查看两个构造器和两个pointer()函数的定义,您会发现在这两种情况下使用默认参数根本不会导致成员函数定义发生变化。因此,该类很容易如下所示:
清单 7-9 。管理内存块(修改后的头文件)
//: C07:Mem2.h
#ifndef MEM2_H
#define MEM2_H
typedef unsigned char byte;
class Mem {
byte* mem;
int size;
void ensureMinSize(int minSize);
public:
Mem(int sz = 0);
∼Mem();
int msize();
byte* pointer(int minSize = 0);
};
#endif // MEM2_H ///:∼
注意,调用ensureMinSize(0)总是非常有效。
尽管在这两种情况下,一些决策过程是基于效率问题的,但是您必须小心不要陷入只考虑效率的陷阱(迷人,因为它是!).
类设计中最重要的问题是类的接口(它的public成员,对客户端程序员可用)。如果这些产生了一个易于使用和重用的类,那么你就成功了;如果有必要的话,你总是可以调整效率,但是一个设计糟糕的类的影响可能是可怕的,因为程序员过于关注效率问题。
您主要关心的应该是这个接口对使用它和阅读结果代码的人有意义。注意,在MemTest.cpp中,不管是否使用默认构造器,也不管效率是高还是低,MyString的用法都不会改变。
审查会议
- 作为一个指导原则,您不应该使用默认参数作为有条件地执行代码的标志。如果可以的话,你应该将函数分解成两个或更多的重载函数。
- 默认参数应该是您通常放在那个位置的值。这是一个比所有其他值更有可能出现的值,所以客户端程序员通常可以忽略它,或者只在他们想改变默认值时才使用它。
- 包含默认参数是为了使函数调用更容易,尤其是当那些函数有许多带有典型值的参数时。不仅编写调用更容易,阅读它们也更容易,特别是如果类创建者可以对参数排序,使得修改最少的默认值出现在列表的最后。
- 默认参数的一个特别重要的用途是,当你开始使用一个有一组参数的函数时,在使用了一段时间后,你发现你需要添加参数。通过默认所有的新参数,你确保所有使用先前接口的客户端代码不会被干扰。
八、常量
创建常量(由 const 关键字表示)的概念是为了让程序员在变化和不变化之间画一条线。这在 C++ 编程项目中提供了安全性和控制。
自从诞生以来,const已经有了许多不同的用途。与此同时,它又回到了 C 语言中,在那里它的含义发生了变化。所有这些起初看起来有点混乱,在这一章中你将学习何时、为何以及如何使用const关键字。最后有一个关于volatile的讨论,它是const的近亲(因为它们都涉及变化),并且有相同的语法。
使用const的第一个动机似乎是为了避免使用预处理器#define进行值替换。此后,它被用于指针、函数参数、返回类型、类对象和成员函数。所有这些都有稍微不同但概念上兼容的含义,将在本章的单独章节中讨论。
值替换
当用 C 语言编程时,预处理器被自由地用于创建宏和替换值。因为预处理器只是简单地进行文本替换,没有类型检查的概念和工具,预处理器值替换引入了一些微妙的问题,这些问题在 C++ 中可以通过使用const值来避免。
在 C # 中,用值替换名称的预处理器的典型用法如下:
#define BUFSIZE 100
BUFSIZE是一个只在预处理过程中存在的名字,所以它不占用存储空间,可以放在头文件中,为所有使用它的翻译单元提供一个值。对于代码维护来说,使用值替换而不是所谓的“幻数”非常重要如果您在代码中使用神奇的数字,不仅读者不知道这些数字来自哪里或它们代表什么,而且如果您决定更改一个值,您必须执行手动编辑,并且您没有任何线索可循,以确保您不会错过某个值(或意外更改了一个您不应该更改的值)。
大多数时候,BUFSIZE会表现得像一个普通变量,但不是所有时候。此外,没有类型信息。这可以隐藏很难发现的错误。C++ 使用const通过将值替换引入编译器的领域来消除这些问题。现在你可以说
const int bufsize = 100;
你可以在编译器在编译时必须知道值的任何地方使用bufsize。编译器可以使用bufsize来执行常量折叠,这意味着编译器将通过在编译时执行必要的计算,将复杂的常量表达式简化为简单的表达式。这在数组定义中尤其重要,比如
char buf[bufsize];
您可以将const用于所有内置类型(char、int、float和double)及其变体(以及类对象,您将在本章后面看到)。由于预处理器可能引入的细微错误,您应该总是使用const而不是#define值替换。
头文件中的常量
要使用const而不是#define,您必须能够将const定义放在头文件中,就像使用#define一样。这样,您可以将const的定义放在一个地方,并通过包含头文件将其分发给翻译单元。C++ 中的 A const默认为“内部联动;也就是说,它只在定义它的文件中可见,在链接时不能被其他翻译单元看到。在定义const和时,必须始终为其赋值,除非使用extern进行显式声明,例如:
extern const int bufsize;
通常,C++ 编译器避免为const创建存储,而是将定义保存在其符号表中。然而,当您将extern与const一起使用时,您会强制分配存储空间(对于某些其他情况也是如此,比如获取const的地址)。必须分配存储,因为extern说“使用外部链接”,这意味着几个翻译单元必须能够引用该项目,这要求它有存储。
在一般情况下,当extern不是定义的一部分时,不分配存储。当使用const时,它只是在编译时被折叠起来。
从不为一个const分配存储的目标对于复杂的结构也是失败的。每当编译器必须分配存储空间时,就要防止常量合并(因为编译器没有办法确切知道存储空间的值是多少;如果它知道这一点,它就不需要分配存储)。
因为编译器不能总是避免为const、const定义分配存储空间,所以必须默认为内部链接,也就是说,链接只在特定翻译单元内进行。否则,复杂的const会出现链接器错误,因为它们会导致存储被分配到多个cpp文件中。然后链接器会在多个目标文件中看到相同的定义,并抱怨。因为一个const默认为内部链接,所以链接器不会试图跨翻译单元链接那些定义,也没有冲突。对于内置类型(在大多数涉及常量表达式的情况下使用),编译器总是可以执行常量合并。
安全常量
const的使用不限于在常量表达式中替换#define s。如果你用一个在运行时产生的值初始化一个变量,并且你知道这个值在这个变量的生命周期内不会改变,那么把它设为一个const是一个很好的编程习惯,这样如果你不小心试图改变它,编译器会给你一个错误信息。参见清单 8-1 中的示例。
清单 8-1 。使用 const 确保安全
//: C08:Safecons.cpp
// Using const for safety
#include <iostream>
using namespace std;
const int i = 100;
const int j = i + 10;
long address = (long)&j; // Forces storage
char buf[j + 10]; // Still a const expression
int main() {
cout << "type a character & CR:";
const char c = cin.get(); // Can't change
const char c2 = c + 'a';
cout << c2;
// ...
} ///:∼
你可以看到i是一个编译时的const,但是j是从i开始计算的。然而,因为i是一个const,所以j的计算值仍然来自一个常量表达式,它本身就是一个编译时常量。下一行需要j的地址,因此迫使编译器为j分配存储空间。然而这并不妨碍使用j来确定buf的大小,因为编译器知道j是const并且该值是有效的,即使在程序中的某个点分配了存储来保存该值。
在main( )中,您会在标识符c中看到不同种类的const,因为在编译时无法知道其值。这意味着需要存储,并且编译器不会试图在它的符号表中保存任何东西(与 C 中的行为相同)。初始化必须仍然发生在定义点,一旦初始化发生,值就不能改变。你可以看到c2是从c中计算出来的,而且作用域对于const s 和其他类型一样有效——这是对使用#define的又一个改进。
实际上,如果你认为一个值不应该改变,你应该把它变成一个const。这不仅提供了防止意外更改的保障,还允许编译器通过消除存储和内存读取来生成更高效的代码。
总计
可以使用const进行聚合,但是实际上可以肯定,编译器不会复杂到在其符号表中保存一个聚合,所以存储将被分配。在这些情况下,const意味着一块不能改变的存储器。“然而,该值不能在编译时使用,因为编译器在编译时不需要知道存储器的内容。在清单 8-2 中,您可以看到非法的语句。
清单 8-2 。常量和集合
//: C08:Constag.cpp
// Constants and aggregates
const int i[] = { 1, 2, 3, 4 };
//! float f[i[3]]; // Illegal
struct S { int i, j; };
const S s[] = { { 1, 2 }, { 3, 4 } };
//! double d[s[1].j]; // Illegal
int main() {} ///:∼
在数组定义中,编译器必须能够生成移动堆栈指针以容纳数组的代码。在清单 8-2 中的两个非法定义中,编译器报错,因为它在数组定义中找不到常量表达式。
与 C 的区别
常量是在早期版本的 C++ 中引入的,而标准的 C 规范仍在完善中。尽管 C 委员会随后决定将const包含在 C 中,但不知何故,它对他们来说意味着“一个不能改变的普通变量。“在 C 中,a const总是占用存储,它的名字是 global。C 编译器不能将const视为编译时常量。在 C 中,如果你说
const int bufsize = 100;
char buf[bufsize];
您将得到一个错误,尽管这看起来是一件合理的事情。因为bufsize占用了某个地方的存储,所以 C 编译器在编译时无法知道这个值。你可以选择说
const int bufsize;
在 C 中是这样,但在 C++ 中不是,C 编译器将它作为一个声明接受,表明在其他地方分配了存储。因为 C 默认为const s 的外部联动,这是有意义的。对于const s,C++ 默认为内部链接,因此如果您想在 C++ 中完成同样的事情,您必须使用extern显式地将链接更改为外部,例如:
extern const int bufsize; // Declaration only
这一行也适用于 c 语言。
在 C++ 中,const不一定会创建存储。在 C 中,a const总是创建存储。在 C++ 中,存储是否被保留给一个const取决于它是如何被使用的。一般来说,如果一个const只是用来用一个值替换一个名字(就像你使用一个 #define),那么就不需要为const创建存储。如果没有创建存储(这取决于数据类型的复杂性和编译器的复杂程度),值可以在类型检查后合并到代码中以获得更高的效率,而不是像#define那样在之前。但是,如果您获取了一个const ( ,甚至是在不知情的情况下,通过将它传递给一个采用引用参数的函数,或者您将它定义为extern,那么就会为const创建存储。
在 C++ 中,在所有函数之外的const具有文件范围(即,它在文件之外是不可见的)。即默认为内部联动。这与 C++ 中的所有其他标识符(和 C 中的 const 有很大的不同!)默认为外部链接。因此,如果你在两个不同的文件中声明了一个同名的const,并且你没有获取地址或者将那个名字定义为extern,那么理想的 C++ 编译器不会为const分配存储空间,而是简单地将它合并到代码中。因为const有隐含的文件作用域,所以你可以把它放在 C++ 头文件中,在链接时不会有冲突。
由于 C++ 中的一个const默认为内部链接,所以你不能只在一个文件中定义一个const,而在另一个文件中将其引用为一个extern。要给一个const外部链接,以便它可以从另一个文件中被引用,您必须显式地将其定义为extern,就像这样:
extern const int x = 1;
注意,通过给它一个初始化器,并说它是extern,你强制为const ( ,尽管编译器仍然可以选择在这里进行常量折叠)创建存储。初始化将它建立为一个定义,而不是声明。宣言
extern const int x;
在 C++ 中意味着定义存在于别处(再次强调,这在 C 中不一定成立)。现在你可以明白为什么 C++ 需要一个const定义来拥有一个初始化式:初始化式区分了声明和定义(在 C 中它总是一个定义,所以不需要初始化式)。使用extern const声明,编译器不能进行常量折叠,因为它不知道值。
C 语言对const的处理不是很有用,如果你想在一个常量表达式中使用一个已命名的值(必须在编译期计算),C 语言几乎强迫你在预处理器中使用#define。
两颗北极指极星
指针可以做成const。在处理const指针时,编译器仍然会努力阻止存储分配并进行常量折叠,但在这种情况下,这些功能似乎不太有用。更重要的是,如果你试图改变一个const指针,编译器会告诉你,这增加了很大的安全性。
对指针使用const时,有两种选择:const可以应用于指针所指向的内容,或者const可以应用于指针本身存储的地址。这些的语法一开始有点混乱,但是通过练习会变得很舒服。
指向常量的指针
与任何复杂的定义一样,指针定义的诀窍是从标识符开始读取,然后一步步地读取。const说明符绑定到它“最接近”的东西因此,如果您想防止对所指向的元素进行任何更改,您可以编写如下定义:
const int* u;
从标识符开始,你读“u 是一个指针,它指向一个 const int”在这里,不需要初始化,因为你在说u可以指向任何东西(也就是说,它不是const),但是它所指向的东西不能被改变。
这是有点令人困惑的部分。你可能会想,为了使指针本身不变,也就是说,为了防止对包含在u中的地址进行任何更改,你可以简单地将const移动到int的另一边,就像这样:
const int* u;
认为这个应该读作“v是一个指向int的const指针”并不疯狂然而,它实际上的读法是“v是一个普通指针,指向一个恰好是const的int也就是说,const已经再次将自己绑定到了int上,效果和之前的定义一样。这两个定义相同的事实是令人困惑的地方;为了防止读者产生这种困惑,你应该坚持第一种形式。
常量指针
要使指针本身成为一个const,你必须把const说明符放在*的右边,就像这样:
int d = 1;
int* const w = &d;
现在上面写着:“w是指针,也就是const,指向一个int。”因为指针本身现在是const,编译器要求给它一个初始值,这个值在指针的生命周期内保持不变。但是,可以通过下面的语句来改变该值所指向的内容
*w = 2;
您还可以使用两种合法形式中的任何一种来创建指向const对象的const指针:
int d = 1;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
现在指针和对象都不能改变了。
有些人认为第二种形式更一致,因为const总是放在它所修饰的对象的右边。您必须决定哪一种更适合您的特定编码风格。
清单 8-3 显示了一个可编译文件中的上述行。
清单 8-3 。指针
//: C08:ConstPointers.cpp
const int* u;
int const* v;
int d = 1;
int* const w = &d;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
int main() {} ///:∼
格式化
这本书强调在一行中只放一个指针定义,并尽可能在定义点初始化每个指针。因此,将“*”附加到数据类型的格式样式是可能的,看起来像
int* u = &i;
好像int*是一个独立的类型。这使得代码更容易理解,但不幸的是,事情实际上并不是这样。事实上,‘*’绑定到标识符,而不是类型。它可以放在类型名和标识符之间的任何位置。所以你可以写作
int *u = &i, v = 0;
它创建了一个int* u,和一个非指针int v。因为读者常常觉得这令人困惑,所以最好遵循本书中所示的形式。
赋值和类型检查
C++ 非常注重类型检查,这也延伸到了指针赋值。你可以将一个非const对象的地址赋给一个const指针,因为你只是保证不改变那些可以改变的东西。然而,你不能把一个const对象的地址分配给一个非const指针,因为这样你就可以通过指针改变对象。当然,您总是可以使用强制类型转换来强制进行这样的赋值,但是这是一种糟糕的编程实践,因为这样会破坏对象的const属性,以及const承诺的任何安全性。参见清单 8-4 中的示例。
清单 8-4 。指针分配
//: C08:PointerAssignment.cpp
int d = 1;
const int e = 2;
int* u = &d; // OK -- d not const
//! int* v = &e; // Illegal -- e const
int* w = (int*)&e; // Legal but bad practice
int main() {} ///:∼
尽管 C++ 有助于防止错误,但如果你想破坏安全机制,它并不能保护你免受伤害。
字符数组文字
不强制使用严格const属性的地方是字符数组文字。你可以说
char* cp = "howdy";
编译器会毫无怨言地接受它。从技术上来说,这是一个错误,因为字符数组文字(在本例中为"howdy")是由编译器创建的,作为一个常量字符数组,引用的字符数组的结果是它在内存中的起始地址。修改数组中的任何字符都是运行时错误,尽管不是所有的编译器都正确地执行这一点。
所以字符数组实际上是常量字符数组。当然,编译器允许您将它们作为非const处理,因为有太多现有的 C 代码依赖于此。然而,如果您试图更改字符数组中的值,行为是未定义的,尽管它可能在许多机器上工作。
如果您希望能够修改字符串,请将它放在一个数组中,例如:
charcp[] = "howdy";
因为编译器通常不会强制区别,所以不会提醒你使用后一种形式,所以这一点变得相当微妙。
函数参数和返回值
使用const来指定函数参数和返回值是常量概念容易混淆的另一个地方。如果您通过值传递对象*,指定const对客户端没有意义(这意味着传递的参数不能在函数内部修改)。如果你通过值返回一个用户定义类型的对象作为一个const,这意味着返回值不能被修改。如果你是传递和返回地址,const是承诺地址的目的地不会改变。*
按常量值传递
当通过值传递函数参数时,可以指定它们是const,比如
void f1(const int i) {
i++; // Illegal -- compile-time error
}
但这意味着什么呢?你承诺变量的初始值不会被函数f1( )改变。但是,因为参数是通过值传递的,所以您会立即制作原始变量的副本,这样就隐式地保持了对客户端的承诺。
在函数内部,const的含义是:参数不能改变。所以它实际上是函数创建者的工具,而不是调用者的。
为了避免调用者混淆,你可以将参数设为函数中的const ,而不是在参数列表中。你可以用一个指针来做这件事,但是一个更好的语法是通过引用来实现的,这个主题将在第十一章的中充分展开。简而言之,引用就像一个被自动解引用的常量指针,所以它的作用就像是对象的别名。要创建一个引用,可以在定义中使用&。因此,非混淆函数定义如下所示:
void f2(int ic) {
const int& i = ic;
i++; // Illegal -- compile-time error
}
Again,你会得到一个错误消息,但是这次本地对象的const属性不是函数签名的一部分;它只对函数的实现有意义,因此对客户端是隐藏的。
按常量值返回
类似的道理也适用于返回值。如果你说一个函数的返回值是const,比如
const int g();
您承诺原始变量( 内的*)不会被修改。同样,因为你是通过值返回的,所以它是被复制的,所以原始值不能通过返回值被修改。*
起初,这可以使const的规范看起来毫无意义。在清单 8-5 中,你可以看到通过值返回const的效果明显缺乏。
清单 8-5 。通过值返回常量
//: C08:Constval.cpp
// Returning consts by value
// has no meaning for built-in types
int f3() { return 1; }
const int f4() { return 1; }
int main() {
const int j = f3(); // Works fine
int k = f4(); // But this works fine too!
} ///:∼
对于内置类型,是否以const的形式通过值返回并不重要,因此在通过值返回内置类型时,应该避免让客户端程序员感到困惑,并去掉const。
当您处理用户定义的类型时,通过值作为const返回变得很重要。如果一个函数通过值作为const返回一个类对象,那么这个函数的返回值不能是一个左值(也就是说,它不能被赋值或者修改)。参见清单 8-6 中的示例。
清单 8-6 。由值返回的常量
//: C08:ConstReturnValues.cpp
// Constant return by value
// Result cannot be used as an lvalue
class X {
int i;
public:
X(int ii = 0);
void modify();
};
X::X(int ii) { i = ii; }
void X::modify() { i++; }
X f5() {
return X();
}
const X f6() {
return X();
}
void f7(X& x) { // Pass by non-const reference
x.modify();
}
int main() {
f5() = X(1); // OK -- non-const return value
f5().modify(); // OK
//! f7(f5()); // Causes warning or error
// Causes compile-time errors:
//! f7(f5());
//! f6() = X(1);
//! f6().modify();
//! f7(f6());
} ///:∼
f5( )返回一个非constX对象,而f6( )返回一个const X对象。只有非const返回值可以作为左值使用。因此,当通过值返回一个对象时,如果你想防止它被用作左值,使用const是很重要的。
当通过值返回内置类型时,const没有意义的原因是编译器已经防止它成为左值(因为它总是值,而不是变量)。只有当你通过值返回用户定义类型的对象时,它才成为一个问题。
函数f7( )将其参数作为非const 引用(c++ 中处理地址的附加方式,也是第十一章的主题)。这实际上与采用非const指针是一样的;只是语法不同而已。这在 C++ 中不能编译的原因是因为创建了一个临时的。
临时工
有时候,在表达式求值期间,编译器必须创建临时对象 。这些物品和其他物品一样:它们需要储藏,它们必须被建造和摧毁。区别在于你永远看不到它们——编译器负责决定它们是否需要以及它们存在的细节。但是临时演员有一个特点:他们是自动的。因为你通常无法得到一个临时的对象,告诉它做一些会改变那个临时的东西几乎肯定是一个错误,因为你将无法使用那个信息。通过自动设置所有的临时变量const,编译器会在你出错时通知你。
在清单 8-6 中,f5( )返回一个非constX对象。但是在表情上
f7(f5());
编译器必须制造一个临时对象来保存f5( )的返回值,这样它就可以被传递给f7( )。如果f7( )以价值来衡量它的论点,这没什么大不了的;然后,临时文件将被复制到f7( )中,临时文件X将不会发生任何变化。然而,f7( )通过引用得到它的参数*,这意味着在这个例子中它得到临时X的地址。因为f7( )没有通过const引用获取它的参数,所以它有修改临时对象的权限。但是编译器知道一旦表达式求值完成,临时变量就会消失,因此你对临时变量X所做的任何修改都会丢失。通过自动生成所有临时对象const,,这种情况会导致一个编译时错误消息,这样您就不会被一个很难发现的错误所困扰。*
但是,请注意合法的表达方式:
f5() = X(1);
f5().modify();
尽管这些符合编译器的要求,但它们实际上是有问题的。f5( )返回一个X对象,为了满足上面的表达式,编译器必须创建一个临时来保存返回值。所以在这两个表达式中,临时对象都被修改,一旦表达式结束,临时对象就被清除。结果,修改丢失了,所以这段代码可能是一个 bug——但是编译器不会告诉你任何关于它的信息。像这样的表达式足够简单,您可以发现问题,但是当事情变得更复杂时,错误就有可能从这些裂缝中溜走。
保存类对象的const 属性的方法将在本章后面介绍。
传递和返回地址
如果您传递或返回一个地址(指针或引用),客户端程序员就有可能接受它并修改原始值。如果您将指针或引用设为const,就可以防止这种情况发生,这可能会让您避免一些痛苦。事实上,每当你把一个地址传递给一个函数时,如果可能的话,你应该把它变成一个const。如果你不这样做,你就排除了对任何一个const使用该功能的可能性。
选择是返回一个指向const的指针还是引用取决于你想让你的客户程序员用它做什么。清单 8-7 展示了使用const指针作为函数参数和返回值。
清单 8-7 。常量指针作为函数参数和返回值
//: C08:ConstPointer.cpp
// Constant pointer arg/return
void t(int*) {}
void u(const int* cip) {
//! *cip = 2; // Illegal -- modifies value
int i = *cip; // OK -- copies value
//! int* ip2 = cip; // Illegal: non-const
}
const char* v() {
// Returns address of static character array:
return "result of function v()";
}
const int* const w() {
static int i;
return &i;
}
int main() {
int x = 0;
int* ip = &x;
const int* cip = &x;
t(ip); // OK
//! t(cip); // Not OK
u(ip); // OK
u(cip); // Also OK
//! char* cp = v(); // Not OK
const char* ccp = v(); // OK
//! int* ip2 = w(); // Not OK
const int* const ccip = w(); // OK
const int* cip2 = w(); // OK
//! *w() = 1; // Not OK
} ///:∼
函数t( )以一个普通的非const指针作为参数,u( )以一个const指针作为参数。在u( )中,您可以看到试图修改const指针的目的地是非法的,但是您当然可以将信息复制到非const变量中。编译器还阻止你使用存储在const指针中的地址创建非const指针。
函数v( )和w( )测试返回值语义。v( )返回一个从字符数组文字创建的const char*。在编译器创建字符数组并将其存储在静态存储区域后,该语句实际上产生了字符数组的地址。如前所述,这个字符数组在技术上是一个常量,用v( )的返回值来恰当地表示。
w( )的返回值要求指针和它所指向的都必须是const。和v( )一样,w( )返回的值在函数返回后有效,只是因为它是static。你永远不要返回指向局部栈变量的指针,因为在函数返回和栈被清理后它们将是无效的。
注意你可能返回的另一个公共指针是在堆上分配的存储地址,它在函数返回后仍然有效。
在main( )中,使用各种参数测试函数。您可以看到,t( )将接受一个非const指针参数,但是如果您试图将一个指向const的指针传递给它,并不能保证t( )会留下指针的目的地,所以编译器会给您一个错误消息。u( )接受一个const指针,因此它将接受两种类型的参数。因此,采用const指针的函数比不采用指针的函数更通用。
正如所料,v( )的返回值只能分配给一个指向const的指针。您可能还会期望编译器拒绝将w( )的返回值赋给一个非const指针,而是接受一个const int* const,但是看到它也接受一个const int*,这可能有点令人惊讶,?? 与返回类型并不完全匹配。同样,因为值(包含在指针中的地址)正在被复制,所以原始变量不变的承诺被自动保持。因此,const int* const中的第二个const只有当你试图将其用作左值时才有意义,在这种情况下,编译器会阻止你。
标准参数传递
在 C 中,通过值传递是很常见的,当你想传递一个地址时,你唯一的选择就是使用指针。然而,这两种方法在 C++ 中都不是首选的。相反,当传递一个参数时,你的第一选择是通过引用传递,而且是通过const引用。对于客户端程序员来说,语法与按值传递的语法相同,所以不会对指针产生混淆——他们甚至不需要考虑指针。对于函数的创建者来说,传递一个地址实际上总是比传递整个类对象更有效,如果你通过const引用传递,这意味着你的函数不会改变那个地址的目的地,所以从客户端程序员的角度来看,效果与通过值传递完全一样(只是更有效)。
由于引用的语法(对调用者来说,看起来像是按值传递)可以将一个临时对象传递给一个采用const引用的函数,而你永远不能将一个临时对象传递给一个采用指针的函数;对于指针,必须显式获取地址。因此,通过引用传递会产生一种在 C 中从未出现过的新情况:一个总是为const的临时变量可以将其地址传递给一个函数。这就是为什么,要允许临时变量通过引用传递给函数,参数必须是一个const引用。清单 8-8 展示了这一点。
清单 8-8 。临时工
//: C08:ConstTemporary.cpp
// Temporaries are const
class X {};
X f() { return X(); } // Return by value
void g1(X&) {} // Pass by non-const reference
void g2(const X&) {} // Pass by const reference
int main() {
// Error: const temporary created by f():
//! g1(f());
// OK: g2 takes a const reference:
g2(f());
} ///:∼
f( )通过值返回一个class X 的对象。这意味着当你立即获取f( )的返回值并将其传递给另一个函数时,就像在对g1( )和g2( )的调用中一样,会创建一个临时变量,这个临时变量就是const。因此,g1( )中的调用是错误的,因为g1( )没有引用const,但是对g2( )的调用是正确的。
班级
本节展示了对类使用const的方法。您可能希望在一个类中创建一个局部const,以便在编译时计算的常量表达式中使用。然而,const的含义在类内部是不同的,所以为了创建一个类的const数据成员,你必须理解这些选项。
你也可以创建一个完整的对象const(正如你刚刚看到的,编译器总是创建临时对象const)。但是保留对象的const属性更复杂。编译器可以确保内置类型的const属性,但是它不能监控类的复杂性。为了保证类对象的const属性,引入了const成员函数:一个const对象只能调用一个const成员函数。
类中的常量
对于常量表达式,您希望使用const的地方之一是在类内部。典型的例子是当你在一个类中创建一个数组时,你想用一个const而不是一个#define来确定数组的大小,并在涉及数组的计算中使用。数组大小是您希望隐藏在类中的东西,因此,例如,如果您使用像size这样的名称,您可以在另一个类中使用该名称而不会发生冲突。预处理器从定义开始就将所有的#define视为全局的,所以这不会达到预期的效果。
您可能会认为合乎逻辑的选择是在类中放置一个const。这不会产生预期的结果。在一个类内部,const部分回复到它在 c 中的含义,它在每个对象内部分配存储,代表一个初始化一次就不能改变的值。在类中使用const意味着“这是对象生命周期的常量。然而,每个不同的对象可能包含该常量的不同值。
因此,当你在一个类中创建一个普通的( non - static ) const时,你不能给它一个初始值。当然,这种初始化必须发生在构造器中,但是是在构造器中的一个特殊位置。因为一个const必须在它被创建的时候被初始化,在构造器的主体内部const必须已经被初始化。否则,您只能选择等待,直到构造器体中的某个时刻,这意味着const将暂时不初始化。此外,没有什么可以阻止您在构造器体的不同位置更改const的值。
构造器初始值设定项列表
这个特殊的初始化点被称为构造器初始化列表 ,它最初是为在继承中使用而开发的(在第十四章中涉及)。构造器初始化列表——顾名思义,只出现在构造器的定义中——是一个“构造器调用”列表,出现在函数参数列表和冒号之后,但在构造器体的左括号之前。这是为了提醒您,列表中的初始化发生在任何主构造器代码执行之前。这是放置所有const初始化的地方。清单 8-9 中显示了const在一个类中的正确形式。
清单 8-9 。初始化类中的常量
//: C08:ConstInitialization.cpp
// Initializing const in classes
#include <iostream>
using namespace std;
class Fred {
const int size;
public:
Fred(int sz);
void print();
};
Fred::Fred(int sz) : size(sz) {}
void Fred::print() { cout << size << endl; }
int main() {
Fred a(1), b(2), c(3);
a.print(), b.print(), c.print();
} ///:∼
清单 8-9 中显示的构造器初始化列表的形式起初令人困惑,因为你不习惯看到一个内置类型被当作它有一个构造器。
内置类型的“构造器”
随着语言的发展,越来越多的人致力于使用户定义的类型看起来像内置类型,很明显,有时使内置类型看起来像用户定义的类型是有帮助的。在构造器初始化列表中,你可以把一个内置类型当作它有一个构造器,如清单 8-10 所示。
清单 8-10 。内置构造器
//: C08:BuiltInTypeConstructors.cpp
#include <iostream>
using namespace std;
class B {
int i;
public:
B(int ii);
void print();
};
B::B(int ii) : i(ii) {}
void B::print() { cout << I << endl; }
int main() {
B a(1), b(2);
float pi(3.14159);
a.print(); b.print();
cout << pi << endl;
} ///:∼
这在初始化const数据成员时尤其重要,因为它们必须在进入函数体之前初始化。
将内置类型的这个“构造器”扩展到一般情况是有意义的(它只是意味着赋值),这就是为什么float pi(3.14159)定义在清单 8-10 中有效。
将内置类型封装在类中以保证用构造器初始化通常是有用的。例如,清单 8-11 显示了一个Integer类。
清单 8-11 。装入胶囊
//: C08:EncapsulatingTypes.cpp
#include <iostream>
using namespace std;
class Integer {
int i;
public:
Integer(int ii = 0);
void print();
};
Integer::Integer(int ii) : i(ii) {}
void Integer::print() { cout << I << ' '; }
int main() {
Integer i[100];
for(int j = 0; j < 100; j++)
i[j].print();
} ///:∼
main( )中的数组Integer全部自动初始化为零。这种初始化不一定比for循环或memset( )更昂贵。许多编译器很容易将这个过程优化得非常快。
类中的编译时常量
上面对const的使用很有趣,在某些情况下可能很有用,但它没有解决最初的问题,即如何在类中创建一个编译时的常量?答案需要使用一个额外的关键字(要到第十章才会全面介绍):static。在这种情况下,static关键字的意思是“只有一个实例,不管创建了多少个该类的对象,”,这正是我们在这里所需要的:一个恒定的类成员,不能从该类的一个对象改变到另一个对象。因此,内置类型的static const可以被视为编译时常量。
当在类内部使用时,static const有一个特性有点不寻常:你必须在定义static const的时候提供初始化器。这是只发生在static const身上的事情;尽管您可能想在其他情况下使用它,但它不会起作用,因为所有其他数据成员都必须在构造器或其他成员函数中初始化。
清单 8-12 展示了在一个表示字符串指针堆栈的类中创建和使用一个名为size的static const。
清单 8-12 。使用静态常量
//: C08:StringStack.cpp
// Using static const to create a
// compile-time constant inside a class
#include <string>
#include <iostream>
using namespace std;
class StringStack {
static const int size = 100;
const string* stack[size];
int index;
public:
StringStack();
void push(const string* s);
const string* pop();
};
StringStack::StringStack() : index(0) {
memset(stack, 0, size * sizeof(string*));
}
void StringStack::push(const string* s) {
if(index < size)
stack[index++] = s;
}
const string* StringStack::pop() {
if(index > 0) {
const string* rv = stack[--index];
stack[index] = 0;
return rv;
}
return 0;
}
string iceCream[] = {
"pralines& cream",
"fudge ripple",
"jamocha almond fudge",
"wild mountain blackberry",
"raspberry sorbet",
"lemon swirl",
"rocky road",
"deep chocolate fudge"
};
const int iCsz =
sizeof iceCream / sizeof *iceCream;
int main() {
StringStack ss;
for(int i = 0; i < iCsz; i++)
ss.push(&iceCream[i]);
const string* cp;
while((cp = ss.pop()) != 0)
cout << *cp << endl;
} ///:∼
因为size用于确定数组stack的大小,所以它确实是一个编译时常量,但是它隐藏在类内部。
注意,push( )以一个const string*作为参数,pop( )返回一个const string*,StringStack持有const string*。如果这不是真的,你就不能用一个StringStack来保存iceCream中的指针。然而,它也阻止你做任何会改变由StringStack包含的对象的事情。当然,并不是所有的容器都有这种限制。
旧代码中的“枚举黑客”
在 C++ 的旧版本中,类内部不支持static const。这意味着const对于类中的常量表达式是无用的。然而,人们仍然想这样做,所以一个典型的解决方案(通常被称为*“enum hack”*)是使用一个没有实例的未标记的enum。枚举必须在编译时建立所有的值,它对于类是局部的,并且它的值可用于常量表达式。因此,你通常会在清单 8-13 中看到类似的代码。
清单 8-13 。枚举黑客
//: C08:EnumHack.cpp
#include <iostream>
using namespace std;
class Bunch {
enum { size = 1000 };
int i[size];
};
int main() {
cout << "sizeof(Bunch) = " << sizeof(Bunch)
<< ", sizeof(i[1000]) = "
<< sizeof(int[1000]) << endl;
} ///:∼
这里使用enum保证不占用对象中的存储,枚举器都是在编译时计算的。您还可以显式建立枚举数的值,例如:
enum { one = 1, two = 2, three };
对于整型enum类型,编译器将继续从最后一个值开始计数,因此枚举器three将得到值 3。
在上面的StringStack.cpp示例中,该行
Static const int size = 100;
反而会是
enum { size = 100 };
虽然您经常会在遗留代码中看到enum技术,但是语言中添加了static const特性来解决这个问题。然而,没有压倒性的理由让你必须选择static const而不是enum hack,在本书中使用enum hack 是因为在写作时它被更多的编译器支持。
常量对象和成员函数
类成员函数可以做成const。这是什么意思?要理解,首先要掌握const对象的概念。
一个const对象被定义为与内置类型相同的用户定义类型,例如
const int i = 1;
const blob b(2);
这里,b是一个类型为blob的const对象。它的构造器是用两个参数调用的。为了让编译器执行const属性,它必须确保在对象的生命周期中没有对象的数据成员被改变。它可以很容易地确保没有公共数据被修改,但是如何知道哪些成员函数将改变数据,哪些对于一个const对象是“安全的”?
如果你声明一个成员函数const,你告诉编译器这个函数可以被一个const对象调用。没有明确声明const的成员函数被视为修改对象中数据成员的函数,编译器不允许你为const对象调用它。
然而,这还不止于此。仅仅声明一个成员函数是const并不能保证它会那样做,所以编译器会强迫你在定义函数时重申const规范。(const成为函数签名的一部分,所以编译器和链接器都检查 const 属性。)然后,如果您试图更改对象的任何成员或者调用非const成员函数,它会在函数定义期间通过发出错误消息来强制执行const属性。因此,您声明的任何成员函数在定义中都保证会以这种方式运行。
为了理解声明const成员函数的语法,首先要注意,在函数声明之前加上const意味着返回值是const,这样不会产生想要的结果。相反,您必须将const说明符放在和参数列表*之后。参见清单 8-14 。
清单 8-14 。常量成员函数
//: C08:ConstMember.cpp
class X {
int i;
public:
X(int ii);
int f() const;
};
X::X(int ii) : i(ii) {}
int X::f() const { return i; }
int main() {
X x1(10);
const X x2(20);
x1.f();
x2.f();
} ///:∼
请注意,const关键字必须在定义中重复出现,否则编译器会将其视为不同的函数。由于f( )是一个const成员函数,如果它试图以任何方式改变i或者调用另一个不是const的成员函数,编译器会将其标记为错误。
你可以看到用const和非const对象调用const成员函数是安全的。因此,你可以把它看作是成员函数的最一般的形式(正因为如此,成员函数不会自动地默认为 const),这是很不幸的。任何不修改成员数据的函数都应该声明为const,这样它就可以和const对象一起使用。
清单 8-15 对比了一个const和非const成员函数。
清单 8-15 。对比常量和非常量成员函数
//: C08:Quoter.cpp
// Random quote selection
#include <iostream>
#include <cstdlib> // Random number generator
#include <ctime> // To seed random generator
using namespace std;
class Quoter {
int lastquote;
public:
Quoter();
int lastQuote() const;
const char* quote();
};
Quoter::Quoter(){
lastquote = -1;
srand(time(0)); // Seed random number generator
}
int Quoter::lastQuote() const {
return lastquote;
}
const char* Quoter::quote() {
static const char* quotes[] = {
"Are we having fun yet?",
"Doctors always know best",
"Is it ... Atomic?",
"Fear is obscene",
"There is no scientific evidence "
"to support the idea "
"that life is serious",
"Things that make us happy, make us wise",
};
const int qsize = sizeof quotes/sizeof *quotes;
int qnum = rand() % qsize;
while(lastquote >= 0 && qnum == lastquote)
qnum = rand() % qsize;
return quotes[lastquote = qnum];
}
int main() {
Quoter q;
const Quoter cq;
cq.lastQuote(); // OK
//! cq.quote(); // Not OK; non const function
for(int i = 0; i < 20; i++)
cout << q.quote() << endl;
} ///:∼
构造器和析构函数都不能是const成员函数,因为它们实际上总是在初始化和清理期间对对象进行一些修改。成员函数quote( )也不能是const,因为它修改了数据成员lastquote(参见return语句)。然而,lastQuote( )不做任何修改,因此它可以是const,并且可以被const对象cq安全地调用。
可变:按位与逻辑常量
如果您想创建一个const成员函数,但是您仍然想改变对象中的一些数据,该怎么办呢?这有时被称为按位 const和逻辑 const (有时也被称为基于成员的 const ) *。*按位const表示对象中的每一位都是永久的,所以对象的一个位图像永远不会改变。逻辑const意味着,尽管整个对象在概念上是不变的,但在每个成员的基础上可能会有变化。然而,如果编译器被告知一个对象是const,它将小心翼翼地保护该对象以确保按位const属性。为了影响逻辑const属性,有两种方法可以在const成员函数中改变数据成员。
第一种方法是历史方法,称为抛弃 const属性。它以一种相当奇怪的方式表演。您获取this(产生当前对象地址的关键字)并将其转换为指向当前类型对象的指针。看来this已经是这样的指针了。然而,在一个const成员函数中,它实际上是一个const指针,所以通过将它转换成一个普通的指针,可以为该操作移除const属性。清单 8-16 显示了一个例子。
*清单 8-16 。丢弃常量属性
//: C08:Castaway.cpp
// "Casting away" const attribute
class Y {
int i;
public:
Y();
void f() const;
};
Y::Y() { i = 0; }
void Y::f() const {
//! i++; // Error -- const member function
((Y*)this)->i++; // OK: cast away const-ness
// Better: use C++ explicit cast syntax:
(const_cast<Y*>(this))->i++;
}
int main() {
const Y yy;
yy.f(); // Actually changes it!
} ///:∼
这种方法是可行的,您将看到它在遗留代码中的使用,但它不是首选的技术。问题是,const属性的缺失隐藏在一个成员函数定义中,除非您可以访问源代码,否则您无法从类接口中得知对象的数据实际上被修改了(并且您必须怀疑const属性被丢弃了,并寻找类型转换)。为了公开一切,你应该在类声明中使用mutable关键字来指定一个特定的数据成员可以在const对象中被改变,如清单 8-17 所示。
清单 8-17 。可变关键字
//: C08:Mutable.cpp
// The "mutable" keyword
class Z {
int i;
mutable int j;
public:
Z();
void f() const;
};
Z::Z() : i(0), j(0) {}
void Z::f() const {
//! i++; // Error -- const member function
j++; // OK: mutable
}
int main() {
const Z zz;
zz.f(); // Actually changes it!
} ///:∼
这样,类的用户可以从声明中看出哪些成员可能在const成员函数中被修改。
罗马性
如果一个对象被定义为const,那么它就是要放入只读存储器(ROM)中的候选者,这通常是嵌入式系统编程中的一个重要考虑因素。然而,仅仅制造一个对象const是不够的;对 ROMability 的要求要严格得多。当然,对象必须是按位 - const,而不是逻辑 - const。如果逻辑const属性仅通过mutable关键字实现,这很容易看出,但是如果const属性被丢弃在const成员函数中,编译器可能检测不到。还有两条附加规则。
class或struct必须没有用户定义的构造器或析构函数。- 不能有基类(在第十四章中涉及)或带有用户定义的构造器或析构函数的成员对象。
写操作对 ROMable 类型的const对象的任何部分的影响是未定义的。虽然一个合适形式的对象可以放在 ROM 中,但是从来没有对象需要放在 ROM 中。
挥发性关键字
volatile的语法与const的语法相同,但是volatile意味着“这些数据可能会在编译器的知识范围之外发生变化。”不知何故,环境正在改变数据(可能通过多任务、多线程或中断),并且volatile告诉编译器不要对该数据做任何假设,尤其是在优化期间。
如果编译器说,“我之前把这个数据读入了一个寄存器,我还没有动那个寄存器*,*”,正常情况下它不需要再次读取数据。但是如果数据是volatile,编译器不能做出这样的假设,因为数据可能已经被另一个进程改变了,它必须重新读取该数据,而不是优化代码来删除通常是冗余的读取。
使用与创建const对象相同的语法创建volatile对象。您还可以创建const volatile对象,这些对象不能由客户端程序员更改,而是通过一些外部代理进行更改。清单 8-18 包含了一个可能代表与某个通信硬件相关的类的例子。
清单 8-18 。volatile 关键字
//: C08:Volatile.cpp
// The volatile keyword
classComm {
const volatile unsigned char byte;
volatile unsigned char flag;
enum { bufsize = 100 };
unsigned char buf[bufsize];
int index;
public:
Comm();
void isr() volatile;
char read(int index) const;
};
Comm::Comm() : index(0), byte(0), flag(0) {}
// Only a demo; won't actually work
// as an interrupt service routine:
void Comm::isr() volatile {
flag = 0;
buf[index++] = byte;
// Wrap to beginning of buffer:
if(index >= bufsize) index = 0;
}
charComm::read(int index) const {
if(index < 0 || index >= bufsize)
return 0;
return buf[index];
}
int main() {
volatile Comm Port;
Port.isr(); // OK
//! Port.read(0); // Error, read() not volatile
} ///:∼
与const一样,您可以将volatile用于数据成员、成员函数和对象本身。你只能为volatile对象调用volatile成员函数。
isr( )实际上不能作为中断服务例程的原因是,在成员函数中,必须秘密传递当前对象的地址(this),而一个 ISR 一般根本不想要参数。为了解决这个问题,你可以让isr( )成为一个static成员函数,这个主题在第十章中有介绍。
volatile 的语法与 const相同,所以两者的讨论常被放在一起。这两者合起来被称为c-v 限定词。
审查会议
- 关键字
const让你能够将对象、函数参数、返回值和成员函数定义为常量*,并且在不损失任何预处理好处*的情况下,消除预处理器进行值替换。 - 所有这些都为编程中的类型检查和安全性提供了一种重要的额外形式。使用所谓的“const 正确性”(在任何可能的地方使用 const)可以成为项目的救星。
- 尽管您可以忽略
const并继续使用旧的 C 编码实践,但它会帮助您。第十一章及以后开始大量使用引用,在那里你会看到更多关于在函数参数中使用const的重要性。**
九、内联函数
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*
#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());
} ///:∼
构造器使用构造器初始化列表(在第八章中有简要介绍,在第十四章中有完整介绍)来初始化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;
}
} ///:∼
人们有时会编写包含所有内联函数的类,这样整个类都在头文件中。在程序开发过程中,这可能是无害的,尽管有时会导致编译时间变长。一旦程序稍微稳定下来,您可能会想要返回并在适当的地方使函数非内联。
内联和编译器
为了理解什么时候内联是有效的,了解编译器在遇到内联时做什么是有帮助的。与任何函数一样,编译器在其符号表中保存函数类型(即函数原型,包括名称和参数类型 ??,以及函数返回值)。另外,当编译器看到内联的函数类型和函数体解析无误时,*函数体的代码也被带入符号表。*代码是否以源代码形式、编译后的汇编指令或其他表示形式存储取决于编译器。
当调用内联函数时,编译器首先确保调用能够正确进行。也就是说,所有参数类型必须是函数的参数列表中的精确类型,或者编译器必须能够将类型转换为正确的类型,并且返回值必须是目标表达式中的正确类型(或可转换为正确类型)。当然,这正是编译器对任何函数所做的,并且与预处理器所做的明显不同,因为预处理器不能检查类型或进行转换。
如果所有函数类型信息都符合调用的上下文,那么内联代码将直接替换函数调用,从而消除调用开销,并允许编译器进行进一步优化。同样,如果内联是一个成员函数,对象的地址(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()中执行一些测试,以迫使其中一个“触底”,否则递归将是无限的。)
构造器和析构函数中隐藏的活动
构造器和析构函数是两个容易让人误以为内联比实际更有效的地方。构造器和析构函数可能有隐藏的活动,因为类可以包含子对象,必须调用它们的构造器和析构函数。这些子对象可能是成员对象,也可能因为继承而存在(在第十四章中涉及)。作为一个带有成员对象的类的例子,参见清单 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 就是这个头文件(你在第三章中也看到了,因为它被用来构建前几章中的一些例子。留给我自己,这是最合适的地方,因为它利用了内联)。
清单 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。