这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。早在1981年,我就指出,通过将我必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工,变成了应付一些可管理的对象,甚至更加简单了。
如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。
模板和标准库实现了容器、资源句柄以及诸如此类的东西,更早的使用甚至在多年以前。异常的使用使之更加完善。
如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。
#include<memory>
#include<iostream>
using namespace std;
struct S {
S() { cout << "make an S/n"; }
~S() { cout << "destroy an S/n"; }
S(const S&) { cout << "copy initialize an S/n"; }
S& operator=(const S&) { cout << "copy assign an S/n"; }
};
S* f()
{
return new S; // 谁该负责释放这个S?
};
auto_ptr<S> g()
{
return auto_ptr<S>(new S); // 显式传递负责释放这个S
}
int main()
{
cout << "start main/n";
S* p = f();
cout << "after f() before g()/n";
// S* q = g(); // 将被编译器捕捉
auto_ptr<S> q = g();
cout << "exit main/n";
// *p产生了内存泄漏
// *q被自动释放
}
在更一般的意义上考虑资源,而不仅仅是内存。
如果在你的环境中不能系统地应用这些技巧(例如,你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals,尼安德特人,旧石器时代广泛分布在欧洲的猿人)写的,如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。
我为什么在捕获一个异常之后就不能继续?
换句话说,C++为什么不提供一种简单的方式,让程序能够回到异常抛出点之后,并继续执行?
主要的原因是,如果从异常处理之后继续,那么无法预知掷出点之后的代码如何对待异常处理,是否仅仅继续执行,就象什么也没有发生一样。异常处理者无法知道,在继续之前,有关的上下文环境(context)是否是“正确”的。要让这样的代码正确执行,抛出异常的编写者与捕获异常的编写者必须对彼此的代码与上下文环境都非常熟悉才行。这样会产生非常复杂的依赖性,因此无论在什么情况下,都会导致一系列严重的维护问题。
当我设计C++的异常处理机制时,我曾经认真地考虑过允许这种继续的可能性,而且在标准化的过程中,这个问题被非常详细地讨论过。请参见《C++语言的设计和演变》中的异常处理章节。
在一次新闻组的讨论中,我曾经以一种稍微不同的方式回答过这个问题。
为什么C++中没有相当于realloc()的函数?
如果你需要,你当然可以使用realloc()。但是,realloc()仅仅保证能工作于这样的数组之上:它们被malloc()(或者类似的函数)分配,包含一些没有用户定义的复制构造函数(copy constructors)的对象。而且,要记住,与通常的期望相反,realloc()有时也必须复制它的参数数组。
在C++中,处理内存重新分配的更好的方法是,使用标准库中的容器,例如vector,并让它自我增长。
如何使用异常?
参见《C++程序设计语言》第4章,第8.3节,以及附录E。这个附录针对的是如何在要求苛刻的程序中写出异常安全的代码的技巧,而不是针对初学者的。一个关键的技术是“资源获得即初始化”(resource acquisiton is initialization),它使用一些有析构函数的类,来实现强制的资源管理。
怎样从输入中读取一个字符串?
你可以用这种方式读取一个单独的以空格结束的词:
#include<iostream>
#include<string>
using namespace std;
int main()
{
cout << "Please enter a word:/n";
string s;
cin>>s;
cout << "You entered " << s << '/n';
}
注意,这里没有显式的内存管理,也没有可能导致溢出的固定大小的缓冲区。
如果你确实想得到一行而不是一个单独的词,可以这样做:
#include<iostream>
#include<string>
using namespace std;
int main()
{
cout << "Please enter a line:/n";
string s;
getline(cin,s);
cout << "You entered " << s << '/n';
}
在《C++程序设计语言》(可在线获得)的第3章,可以找到一个对诸如字符串与流这样的标准库工具的简介。对于使用C与C++进行简单输入输出的详细比较,参见我的文章《将标准C++作为一种新的语言来学习》(Learning Standard C++ as a New Language),你可以在本人著作列表(my publications list)中下载到它。
为什么C++不提供“finally”的构造?
因为C++提供了另外一种方法,它几乎总是更好的:“资源获得即初始化”(resource acquisiton is initialization)技术。基本的思路是,通过一个局部对象来表现资源,于是局部对象的析构函数将会释放资源。这样,程序员就不会忘记释放资源了。举例来说:
class File_handle {
FILE* p;
public:
File_handle(const char* n, const char* a)
{ p = fopen(n,a); if (p==0) throw Open_error(errno); }
File_handle(FILE* pp)
{ p = pp; if (p==0) throw Open_error(errno); }
~File_handle() { fclose(p); }
operator FILE*() { return p; }
// ...
};
void f(const char* fn)
{
File_handle f(fn,"rw"); //打开fn进行读写
// 通过f使用文件
}
在一个系统中,需要为每一个资源都使用一个“资源句柄”类。无论如何,我们不需要为每一个资源获得都写出“finally”语句。在实时系统中,资源获得要远远多于资源的种类,因此和使用“finally”构造相比,“资源获得即初始化”技术会产生少得多的代码。
什么是自动指针(auto_ptr),为什么没有自动数组(auto_array)?
auto_ptr是一个非常简单的句柄类的例子,在<memory>中定义,通过“资源获得即初始化”技术支持异常安全。auto_ptr保存着一个指针,能够象指针一样被使用,并在生存期结束时释放指向的对象。举例:
#include<memory>
using namespace std;
struct X {
int m;
// ..
};
void f()
{
auto_ptr<X> p(new X);
X* q = new X;
p->m++; // 象一个指针一样使用p
q->m++;
// ...
delete q;
}
如果在...部分抛出了一个异常,p持有的对象将被auto_ptr的析构函数正确地释放,而q指向的X对象则产生了内存泄漏。更多的细节,参见《C++程序设计语言》14.4.2节。
auto_ptr是一个非常简单的类。特别地,它不是一个引用计数(reference counted)的指针。如果你将一个auto_ptr赋值给另一个,那么被赋值的auto_ptr将持有指针,而原来的auto_ptr将持有0。举例:
#include<memory>
#include<iostream>
using namespace std;
struct X {
int m;
// ..
};
int main()
{
auto_ptr<X> p(new X);
auto_ptr<X> q(p);
cout << "p " << p.get() << " q " << q.get() << "/n";
}
将会打印出一个指向0的指针和一个指向非0的指针。例如:
p 0x0 q 0x378d0
auto_ptr::get()返回那个辅助的指针。
这种“转移”语义不同于通常的“复制”语义,这是令人惊讶的。特别地,永远不要使用auto_ptr作为一个标准容器的成员。标准容器需要通常的“复制”语义。例如:
std::vector<auto_ptr<X> >v; // 错误
auto_ptr只持有指向一个单独元素的指针,而不是指向一个数组的指针:
void f(int n)
{
auto_ptr<X> p(new X[n]); //错误
// ...
}
这是错误的,因为析构函数会调用delete而不是delete[]来释放指针,这样就不会调用余下的n-1个X的析构函数。
那么我们需要一个auto_array来持有数组吗?不。没有auto_array。原因是根本没有这种需要。更好的解决方案是使用vector:
void f(int n)
{
vector<X> v(n);
// ...
}
当...部分发生异常时,v的析构函数会被正确地调用。
可以混合使用C风格与C++风格的内存分派与重新分配吗?
在这种意义上是可以的:你可以在同一个程序中使用malloc()和new。
在这种意义上是不行的:你不能使用malloc()来建立一个对象,又通过delete来释放它。你也不能用new建立一个新的对象,然后通过free()来释放它,或者通过realloc()在数组中再建立一个新的。
C++中的new和delete操作可以保证正确的构造和析构:构造函数和析构函数在需要它们的时候被调用。C风格的函数alloc(), calloc(), free(), 和realloc()却不能保证这一点。此外,用new和delete来获得和释放的原始内存,并不一定能保证与malloc()和free()兼容。如果这种混合的风格在你的系统中能够运用,只能说是你走运——暂时的。
如果你觉得需要使用realloc()——或者要做更多——考虑使用标准库中的vector。例如:
// 从输入中将词读取到一个字符串vector中
vector<string> words;
string s;
while (cin>>s && s!=".") words.push_back(s);
vector会视需要自动增长。
更多的例子与讨论,参见我的文章《将标准C++作为一种新的语言来学习》(Learning Standard C++ as a New Language),你可以在本人著作列表(my publications list)中下载到它。
我为什么必须使用一个造型来转换*void?
在C语言中,你可以隐式地将*void转换为*T。这是不安全的。考虑一下:
#include<stdio.h>
int main()
{
char i = 0;
char j = 0;
char* p = &i;
void* q = p;
int* pp = q; /* 不安全的,在C中可以,C++不行 */
printf("%d %d/n",i,j);
*pp = -1; /* 覆盖了从i开始的内存 */
printf("%d %d/n",i,j);
}
使用一个并不指向T类型的T*将是一场灾难。因此,在C++中,如果从一个void*得到一个T*,你必须进行显式转换。举例来说,要得到上列程序的这个令人别扭的效果,你可以这样写:
int* pp = (int*)q;
或者使用一个新的类型造型,以使这种没有检查的类型转换操作变得更加清晰:
int* pp = static_cast<int*>(q);
造型被最好地避免了。
在C语言中,这种不安全的转换最常见的应用之一,是将malloc()的结果赋予一个合适的指针。例如:
int* p = malloc(sizeof(int));
在C++中,使用类型安全的new操作符:
int* p = new int;
附带地,new操作符还提供了胜过malloc()的新特性:
new不会偶然分配错误的内存数量;
new会隐式地检查内存耗尽情况,而且
new提供了初始化。
举例:
typedef std::complex<double> cmplx;
/* C风格: */
cmplx* p = (cmplx*)malloc(sizeof(int)); /* 错误:类型不正确 */
/* 忘记测试p==0 */
if (*p == 7) { /* ... */ } /* 糟糕,忘记了初始化*p */
// C++风格:
cmplx* q = new cmplx(1,2); // 如果内存耗尽,将抛出一个bad_alloc异常
if (*q == 7) { /* ... */ }
我如何定义一个类内部(in-class)的常量?
如果你需要一个通过常量表达式来定义的常量,例如数组的范围,你有两种选择:
class X {
static const int c1 = 7;
enum { c2 = 19 };
char v1[c1];
char v2[c2];
// ...
};
乍看起来,c1的声明要更加清晰,但是要注意的是,使用这种类内部的初始化语法的时候,常量必须是被一个常量表达式初始化的整型或枚举类型,而且必须是static和const形式。这是很严重的限制:
class Y {
const int c3 = 7; // 错误:不是static
static int c4 = 7; // 错误:不是const
static const float c5 = 7; // 错误:不是整型
};
我倾向使用枚举的方式,因为它更加方便,而且不会诱使我去使用不规范的类内初始化语法。
那么,为什么会存在这种不方便的限制呢?一般来说,类在一个头文件中被声明,而头文件被包含到许多互相调用的单元去。但是,为了避免复杂的编译器规则,C++要求每一个对象只有一个单独的定义。如果C++允许在类内部定义一个和对象一样占据内存的实体的话,这种规则就被破坏了。对于C++在这个设计上的权衡,请参见《C++语言的设计和演变》。
如果你不需要用常量表达式来初始化它,那么可以获得更大的弹性:
class Z {
static char* p; // 在定义中初始化
const int i; // 在构造函数中初始化
public:
Z(int ii) :i(ii) { }
};
char* Z::p = "hello, there";
你可以获取一个static成员的地址,当且仅当它有一个类外部的定义的时候:
class AE {
// ...
public:
static const int c6 = 7;
static const int c7 = 31;
};
const int AE::c7; // 定义
int f()
{
const int* p1 = &AE::c6; // 错误:c6没有左值
const int* p2 = &AE::c7; // ok
// ...
}
为什么delete不会将操作数置0?
考虑一下:
delete p;
// ...
delete p;
如果在...部分没有涉及到p的话,那么第二个“delete p;”将是一个严重的错误,因为C++的实现(译注:原文为a C++ implementation,当指VC++这样的实现了C++标准的具体工具)不能有效地防止这一点(除非通过非正式的预防手段)。既然delete 0从定义上来说是无害的,那么一个简单的解决方案就是,不管在什么地方执行了“delete p;”,随后都执行“p=0;”。但是,C++并不能保证这一点。
一个原因是,delete的操作数并不需要一个左值(lvalue)。考虑一下:
delete p+1;
delete f(x);
在这里,被执行的delete并没有拥有一个可以被赋予0的指针。这些例子可能很少见,但它们的确指出了,为什么保证“任何指向被删除对象的指针都为0”是不可能的。绕过这条“规则”的一个简单的方法是,有两个指针指向同一个对象:
T* p = new T;
T* q = p;
delete p;
delete q; // 糟糕!
C++显式地允许delete操作将操作数左值置0,而且我曾经希望C++的实现能够做到这一点,但这种思想看来并没有在C++的实现中变得流行。
如果你认为指针置0很重要,考虑使用一个销毁的函数:
template<class T> inline void destroy(T*& p) { delete p; p = 0; }
考虑一下,这也是为什么需要依靠标准库的容器、句柄等等,来将对new和delete的显式调用降到最低限度的另一个原因。
注意,通过引用来传递指针(以允许指针被置0)有一个额外的好处,能防止destroy()在右值上(rvalue)被调用:
int* f();
int* p;
// ...
destroy(f()); // 错误:应该使用一个非常量(non-const)的引用传递右值
destroy(p+1); // 错误:应该使用一个非常量(non-const)的引用传递右值
我能够写“void main()”吗?
这种定义:
void main() { /* ... */ }
在C++中从未被允许,在C语言中也是一样。参见ISO C++标准3.6.1[2]或者ISO C标准5.1.2.2.1。规范的实现接受这种方式:
int main() { /* ... */ }
和
int main(int argc, char* argv[]) { /* ... */ }
一个规范的实现可能提供许多版本的main(),但它们都必须返回int类型。main()返回的int值,是程序返回一个值给调用它的系统的方式。在那些不具备这种方式的系统中,返回值被忽略了,但这并不使“void main()”在C++或C中成为合法的。即使你的编译器接受了“void main()”,也要避免使用它,否则你将冒着被C和C++程序员视为无知的风险。
在C++中,main()并不需要包含显式的return语句。在这种情况下,返回值是0,表示执行成功。例如:
#include<iostream>
int main()
{
std::cout << "This program returns the integer value 0/n";
}
注意,无论是ISO C++还是C99,都不允许在声明中漏掉类型。那就是说,与C89和ARM C++形成对照,当声明中缺少类型时,并不会保证是“int”。于是:
#include<iostream>
main() { /* ... */ }
是错误的,因为缺少main()的返回类型。
为什么我不能重载点符号,::,sizeof,等等?
大多数的运算符能够被程序员重载。例外的是:
. (点符号) :: ?: sizeof
并没有什么根本的原因要禁止重载?:。仅仅是因为,我没有发现有哪种特殊的情况需要重载一个三元运算符。注意一个重载了 表达式1?表达式2:表达式3 的函数,不能够保证表达式2:表达式3中只有一个会被执行。
Sizeof不能够被重载是因为内建的操作(built-in operations),诸如对一个指向数组的指针进行增量操作,必须依靠它。考虑一下:
X a[10];
X* p = &a[3];
X* q = &a[3];
p++; // p指向a[4]
// 那么p的整型值必须比q的整型值大出一个sizeof(X)
所以,sizeof(X)不能由程序员来赋予一个不同的新意义,以免违反基本的语法。
在N::m中,无论N还是m都不是值的表达式;N和m是编译器知道的名字,::执行一个(编译期的)范围解析,而不是表达式求值。你可以想象一下,允许重载x::y的话,x可能是一个对象而不是一个名字空间(namespace)或者一个类,这样就会导致——与原来的表现相反——产生新的语法(允许 表达式1::表达式2)。很明显,这种复杂性不会带来任何好处。
理论上来说,.(点运算符)可以通过使用和->一样的技术来进行重载。但是,这样做会导致一个问题,那就是无法确定操作的是重载了.的对象呢,还是通过.引用的一个对象。例如:
class Y {
public:
void f();
// ...
};
class X { // 假设你能重载.
Y* p;
Y& operator.() { return *p; }
void f();
// ...
};
void g(X& x)
{
x.f(); // X::f还是Y::f还是错误?
}
这个问题能够用几种不同的方法解决。在标准化的时候,哪种方法最好还没有定论。更多的细节,请参见《C++语言的设计和演变》。
怎样将一个整型值转换为一个字符串?
最简单的方法是使用一个字符串流(stringstream):
#include<iostream>
#include<string>
#include<sstream>
using namespace std;
string itos(int i) // 将int转换成string
{
stringstream s;
s << i;
return s.str();
}
int main()
{
int i = 127;
string ss = itos(i);
const char* p = ss.c_str();
cout << ss << " " << p << "/n";
}
自然地,这种技术能够将任何使用<<输出的类型转换为字符串。对于字符串流的更多说明,参见《C++程序设计语言》21.5.3节。
“int* p”正确还是“int *p”正确?
二者都是正确的,因为二者在C和C++中都是有效的,而且意义完全一样。就语言的定义与相关的编译器来说,我们还可以说“int*p”或者“int * p”。
在“int* p”和“int *p”之间的选择与正确或错误无关,而只关乎风格与侧重点。C侧重表达式;对声明往往比可能带来的问题考虑得更多。另一方面,C++则非常重视类型。
一个“典型的C程序员”写成“int *p”,并且解释说“*p表示一个什么样的int”以强调语法,而且可能指出C(与C++)的语法来证明这种风格的正确性。是的,在语法上*被绑定到名字p上。
一个“典型的C++程序员”写成“int* p”,并且解释说“p是一个指向int的指针类型”以强调类型。是的,p是一个指向int的指针类型。我明确地倾向于这种侧重方向,而且认为对于学好更多的高级C++这是很重要的。
严重的混乱(仅仅)发生在当人们试图在一条声明中声明几个指针的时候:
int* p, p1; // 也许是错的:p1不是一个int*
把*放到名字这一边,看来也不能有效地减少这种错误:
int *p, p1; // 也许是错的?
为每一个名字写一条声明最大程度地解决了问题——特别是当我们初始化变量的时候。人们几乎不会这样写:
int* p = &i;
int p1 = p; // 错误:int用一个int*初始化了
如果他们真的这么干了,编译器也会指出。
每当事情可以有两种方法完成,有人就会迷惑。每当事情仅仅是一个风格的问题,争论就会没完没了。为每一个指针写一条声明,而且永远都要初始化变量,这样,混乱之源就消失了。更多的关于C的声明语法的讨论,参见《C++语言的设计和演变》。
对于我的代码,哪一种布局风格(layout style)是最好的?
这种风格问题属于个人的爱好。人们往往对布局风格的问题持有强烈的意见,不过,也许一贯性比某种特定的风格更加重要。象大多数人一样,我花了很长的时间,来为我的偏好作出一个固定的结论。
我个人使用通常称为“K&R”的风格。当使用C语言没有的构造函数时,需要增加新的习惯,这样就变成了一种有时被称为“Stroustrup”的风格。例如:
class C : public B {
public:
// ...
};
void f(int* p, int max)
{
if (p) {
// ...
}
for (int i = 0; i<max; ++i) {
// ...
}
}
比大多数布局风格更好,这种风格保留了垂直的空格,我喜欢尽可能地在合理的情况下对齐屏幕。对函数开头的大括弧的放置,有助于我第一眼就分别出类的定义和函数的定义。
缩进是非常重要的。
设计问题,诸如作为主要接口的抽象基类的使用,使用模板以表现有弹性的类型安全的抽象,以及正确地使用异常以表现错误,比布局风格的选择要重要得多。
我应该将“const”放在类型之前还是之后?
我把它放在前面,但那仅仅是个人爱好问题。“const T”和“T const”总是都被允许的,而且是等效的。例如:
const int a = 1; // ok
int const b = 2; // also ok
我猜想第一种版本可能会让少数(更加固守语法规范)的程序员感到迷惑。
为什么?当我发明“const”(最初的名称叫做“readonly”,并且有一个对应的“writeonly”)的时候,我就允许它出现在类型之前或之后,因为这样做不会带来任何不明确。标准之前的C和C++规定了很少的(如果有的话)特定的顺序规范。
我不记得当时有过任何有关顺序问题的深入思考或讨论。那时,早期的一些使用者——特别是我——仅仅喜欢这种样子:
const int c = 10;
看起来比这种更好:
int const c = 10;
也许我也受了这种影响:在我最早的一些使用“readonly”的例子中
readonly int c = 10;
比这个更具有可读性:
int readonly c = 10;
我创造的那些最早的使用“const”的(C或C++)代码,看来已经在全球范围内取代了“readonly”。
我记得这个语法的选择在几个人——例如Dennis Ritchie——当中讨论过,但我不记得当时我倾向于哪种语言了。
注意在固定指针(const pointer)中,“const”永远出现在“*”之后。例如:
int *const p1 = q; // 指向int变量的固定指针
int const* p2 = q; //指向int常量的指针
const int* p3 = q; //指向int常量的指针
使用宏有什么问题?
宏不遵循C++中关于范围和类型的规则。这经常导致一些微妙的或不那么微妙的问题。因此,C++提供更适合其他的C++(译注:原文为the rest of C++,当指C++除了兼容C以外的部分)的替代品,例如内联函数、模板与名字空间。
考虑一下:
#include "someheader.h"
struct S {
int alpha;
int beta;
};
如果某人(不明智地)地写了一个叫“alpha”或“beta”的宏,那么它将不会被编译,或者被错误地编译,产生不可预知的结果。例如,“someheader.h”可能包含:
#define alpha 'a'
#define beta b[2]
将宏(而且仅仅是宏)全部大写的习惯,会有所帮助,但是对于宏并没有语言层次上的保护机制。例如,虽然成员的名字包含在结构体的内部,但这无济于事:在编译器能够正确地辨别这一点之前,宏已经将程序作为一个字符流进行了处理。顺便说一句,这是C和C++程序开发环境和工具能够被简化的一个主要原因:人与编译器看到的是不同的东西。
不幸的是,你不能假设别的程序员总是能够避免这种你认为“相当白痴”的事情。例如,最近有人报告我,他们遇到了一个包含goto的宏。我也见过这种情况,而且听到过一些——在很脆弱的时候——看起来确实有理的意见。例如:
#define prefix get_ready(); int ret__
#define Return(i) ret__=i; do_something(); goto exit
#define suffix exit: cleanup(); return ret__
void f()
{
prefix;
// ...
Return(10);
// ...
Return(x++);
//...
suffix;
}
作为一个维护的程序员,就会产生这种印象;将宏“隐藏”到一个头文件中——这并不罕见——使得这种“魔法”更难以被辨别。
一个常见的微妙问题是,一个函数风格的宏并不遵守函数参数传递的规则。例如:
#define square(x) (x*x)
void f(double d, int i)
{
square(d); // 好
square(i++); // 糟糕:这表示 (i++*i++)
square(d+1); //糟糕:这表示(d+1*d+1); 也就是 (d+d+1)
// ...
}
“d+1”的问题,可以通过在“调用”时或宏定义时添加一对圆括号来解决:
#define square(x) ((x)*(x)) /*这样更好 */
但是, i++被执行了两次(可能并不是有意要这么做)的问题仍然存在。
是的,我确实知道有些特殊的宏并不会导致C/C++预处理宏这样的问题。但是,我无心去发展C++中的宏。作为替代,我推荐使用C++语言中合适的工具,例如内联函数,模板,构造函数(用来初始化),析构函数(用来清除),异常(用来退出上下文环境),等等。