C++ Style and Technique FAQ (中文版)

C++ Style and Technique FAQ (中文版)     -|kingfox 发表于 2006-6-10 11:15:36 

 

Bjarne Stroustrup 著, 紫云英 译

Q: 这个简单的程序……我如何把它搞定?

A: 常常有人问我一些简单的程序该如何写,这在学期之初时尤甚。一个典型的问题是:如何读入一些数字,做些处理(比如数学运算),然后输出……好吧好吧,这里我给出一个通用示范程序

i nclude<iostream>

i nclude<vector>

i nclude<algorithm>

using namespace std;

 

int main()

{

vector<double> v;

 

double d;

while(cin>>d) v.push_back(d);// read elements

if (!cin.eof()) {// check if input failed

cerr << "format error\n";

return 1;// error return

}

 

cout << "read " << v.size() << " elements\n";

 

reverse(v.begin(),v.end());

cout << "elements in reverse order:\n";

for (int i = 0; i<v.size(); ++i) cout << v[i] << '\n';

 

return 0; // success return

}

 

程序很简单,是吧。这里是对它的一些观察报告

· 这是一个用标准C++写的程序,使用了标准库[译注:标准库主要是将原来的C运行支持库(Standard C Library)、iostream库、STLStandard Template Library,标准模板库)等标准化而得的] 。标准库提供的功能都位于namespace std之中,使用标准库所需包含的头文件是不含.h扩展名的。[译注:有些编译器厂商为了兼容性也提供了含.h扩展名的头文件。

· 如果你在Windows下编译,你需要把编译选项设为“console application”。记住,你的源代码文件的扩展名必须为.cpp,否则编译器可能会把它当作C代码来处理。 

· 主函数main()要返回一个整数。[译注:有些编译器也支持void main()的定义,但这是非标准做法] 

· 将输入读入标准库提供的vector容器可以保证你不会犯缓冲区溢出之类错误——对于初学者来说,硬是要求把输入读到一个数组之中,不许犯任何愚蠢的错误’”似乎有点过份了——如果你真能达到这样的要求,那你也不能算完全的初学者了。如果你不相信我的这个论断,那么请看看我写的《Learning Standard C++ as a New Language》一文。 [译注:CSDN文档区有该文中译。

· 代码中“ !cin.eof() ”是用来测试输入流的格式的。具体而言,它测试读输入流的循环是否因遇到EOF而终止。如果不是,那说明输入格式不对(不全是数字)。还有细节地方不清楚,可以参看你使用的教材中关于流状态的章节。 

· Vector是知道它自己的大小的,所以不必自己清点输入了多少元素。 

· 这个程序不含任何显式内存管理代码,也不会产生内存泄漏。Vector会自动配置内存,所以用户不必为此烦心。 

· 关于如何读入字符串,请参阅后面的我如何从标准输入中读取string”条目。 

· 这个程序以EOF为输入终止的标志。如果你在UNIX上运行这个程序,可以用Ctrl-D输入EOF。但你用的Windows版本可能会含有一个bughttp://support.microsoft.com/support/kb/articles/Q156/2/58.asp?LN=EN-US&SD=gn&FR=0&qry=End of File&rnk=11&src="/htblog/DHCS_MSPSS_gn_SRCH&";SPR=NTW40),导致系统无法识别EOF字符。如果是这样,那么也许下面这个有稍许改动的程序更适合你:这个程序以单词“end”作为输入终结的标志。 

· i nclude<iostream>

· i nclude<vector>

· i nclude<algorithm>

· i nclude<string>

· using namespace std;

· 

· int main()

· {

· vector<double> v;

· 

· double d;

· while(cin>>d) v.push_back(d);// read elements

· if (!cin.eof()) {// check if input failed

· cin.clear();// clear error state

· string s;

· cin >> s;// look for terminator string

· if (s != "end") {

· cerr << "format error\n";

· return 1;// error return

· }

· }

· 

· cout << "read " << v.size() << " elements\n";

· 

· reverse(v.begin(),v.end());

· cout << "elements in reverse order:\n";

· for (int i = 0; i<v.size(); ++i) cout << v[i] << '\n';

· 

· return 0; // success return

· }

The C++ Programming Language 》第三版中关于标准库的章节里有更多更详细例子,你可以通过它们学会如何使用标准库来轻松搞定简单任务

 

 

Q: 为何我编译一个程序要花那么多时间?

A: 也许是你的编译器有点不太对头——它是不是年纪太大了,或者没有安装正确?也可能你的电脑该进博物馆了……对于这样的问题我可真是爱莫能助了。

不过,也有可能原因在于你的程序——看看你的程序设计还能不能改进?编译器是不是为了顺利产出正确的二进制码而不得不吃进成百个头文件、几万行的源代码?原则上,只要对源码适当优化一下,编译缓慢的问题应该可以解决。如果症结在于你的类库供应商,那么你大概除了换一家类库供应商外确实没什么可做的了;但如果问题在于你自己的代码,那么完全可以通过重构(refactoring)来让你的代码更为结构化,从而使源码一旦有更改时需重编译的代码量最小。这样的代码往往是更好的设计:因为它的藕合程度较低,可维护性较佳。 

我们来看一个OOP的经典例子:

class Shape {

public:// interface to users of Shapes

virtual void draw() const;

virtual void rotate(int degrees);

// ...

protected:// common data (for implementers of Shapes)

Point center;

Color col;

// ...

};

 

class Circle : public Shape {

public:

void draw() const;

void rotate(int) { }

// ...

protected:

int radius;

// ...

};

 

class Triangle : public Shape {

public:

void draw() const;

void rotate(int);

// ...

protected:

Point a, b, c;

// ...

};

 

上述代码展示的设计理念是:让用户通过Shape的公共界面来处理各种形状;而Shape的保护成员提供了各继承类(比如CircleTriangle)共同需要的功能。也就是说:将各种形状(shapes)的公共因素划归到基类Shape中去。这种理念看来很合理,不过我要提请你注意:

· 要确认哪些功能会被所有的继承类用到,而应在基类中实作可不是件简单的事。所以,基类的保护成员或许会随着要求的变化而变化,其频度远高于公共界面之可能变化。例如,尽管我们把“center”作为所有形状的一个属性(从而在基类中声明)似乎是天经地义的,但因此而要在基类中时时维护三角形的中心坐标是很麻烦的,还不如只在需要时才计算——这样可以减少开销。 

· 和抽象的公共界面不同,保护成员可能会依赖实作细节,而这是Shape类的使用者所不愿见到的。例如,绝大部分使用Shape的代码应该逻辑上和color无关;但只要color的声明在Shape类中出现了,就往往会导致编译器将定义了该操作系统中颜色表示的头文件读入、展开、编译。这都需要时间! 

· 当基类中保护成员(比如前面说的centercolor)的实作有所变化,那么所有使用了Shape类的代码都需要重新编译——哪怕这些代码中只有很少是真正要用到基类中的那个语义变化了的保护成员。 

所以,在基类中放一些对于继承类之实作有帮助的功能或许是出于好意,但实则是麻烦的源泉。用户的要求是多变的,所以实作代码也是多变的。将多变的代码放在许多继承类都要用到的基类之中,那么变化可就不是局部的了,这会造成全局影响的!具体而言就是:基类所倚赖的一个头文件变动了,那么所有继承类所在的文件都需重新编译。 

这样分析过后,解决之道就显而易见了:仅仅把基类用作为抽象的公共界面,而将对继承类有用的实作功能移出。 

class Shape {

public:// interface to users of Shapes

virtual void draw() const = 0;

virtual void rotate(int degrees) = 0;

virtual Point center() const = 0;

// ...

 

// no data

};

 

class Circle : public Shape {

public:

void draw() const;

void rotate(int) { }

Point center() const { return center; }

// ...

protected:

Point cent;

Color col;

int radius;

// ...

};

 

class Triangle : public Shape {

public:

void draw() const;

void rotate(int);

Point center() const;

// ...

protected:

Color col;

Point a, b, c;

// ...

};

 

这样,继承类的变化就被孤立起来了。由变化带来的重编译时间可以极为显著地缩短。 

但是,如果确实有一些功能是要被所有继承类(或者仅仅几个继承类)共享的,又不想在每个继承类中重复这些代码,那怎么办?也好办:把这些功能封装成一个类,如果继承类要用到这些功能,就让它再继承这个类: 

class Shape {

public:// interface to users of Shapes

virtual void draw() const = 0;

virtual void rotate(int degrees) = 0;

virtual Point center() const = 0;

// ...

 

// no data

};

 

struct Common {

Color col;

// ...

};

class Circle : public Shape, protected Common {

public:

void draw() const;

void rotate(int) { }

Point center() const { return center; }

// ...

protected:

Point cent;

int radius;

};

 

class Triangle : public Shape, protected Common {

public:

void draw() const;

void rotate(int);

Point center() const;

// ...

protected:

Point a, b, c;

};

[译注:这里作者的思路就是孤立变化,减少耦合。从这个例子中读者可以学到一点Refactoring的入门知识 :O) ]

 

 

Q: 为何空类的大小不是零?

A: 为了确保两个不同对象的地址不同,必须如此。也正因为如此,new返回的指针总是指向不同的单个对象。我们还是来看代码吧:

class Empty { };

 

void f()

{

Empty a, b;

if (&a == &b) cout << "impossible: report error to compiler supplier";

 

Empty* p1 = new Empty;

Empty* p2 = new Empty;

if (p1 == p2) cout << "impossible: report error to compiler supplier";

}

 

另外,C++中有一条有趣的规则——空基类并不需要另外一个字节来表示: 

struct X : Empty {

int a;

// ...

};

 

void f(X* p)

{

void* p1 = p;

void* p2 = &p->a;

if (p1 == p2) cout << "nice: good optimizer";

}

 

如果上述代码中p1p2相等,那么说明编译器作了优化。这样的优化是安全的,而且非常有用。它允许程序员用空类来表示非常简单的概念,而不需为此付出额外的(空间)代价。一些现代编译器提供了这种虚基类优化功能。

 

 

Q: 为什么我必须把数据放到类的声明之中?

A: 没人强迫你这么做。如果你不希望界面中有数据,那么就不要把它放在定义界面的类中,放到继承类中好了。参看为何我编译一个程序要花那么多时间条目。[译注:本FAQ中凡原文为declare/declaration的均译为声明;define/definition均译为定义。两者涵义之基本差别参见后面“‘int* p;’‘int *p;’到底哪个正确条目中的译注。通常而言,我们还是将下面的示例代码称为complex类的定义,而将单单一行“class complex;”称作声明]

 

但也有的时候你确实需要把数据放到类声明里面,比如下面的复数类的例子: 

template<class Scalar> class complex {

public:

complex() : re(0), im(0) { }

complex(Scalar r) : re(r), im(0) { }

complex(Scalar r, Scalar i) : re(r), im(i) { }

// ...

 

complex& operator+=(const complex& a)

{ re+=a.re; im+=a.im; return *this; }

// ...

private:

Scalar re, im;

};

 

这个complex(复数)类是被设计成像C++内置类型那样使用的,所以数据表示必须出现在声明之中,以便可以建立真正的本地对象(即在堆栈上分配的对象,而非在堆中分配),这同时也确保了简单操作能被正确内联化。本地对象内联这两点很重要,因为这样才可以使我们的复数类达到和内置复数类型的语言相当的效率。 

[译注:我觉得Bjarne的这段回答有点逃避问题之嫌。我想,提问者的真实意图或许是想知道如何用C++界面实作完全分离。不幸的是,C++语言和类机制本身不提供这种方式。我们都知道,类的界面部分往往被定义为公有(一般是一些虚函数);实作部分则往往定义为保护或私有(包括函数和数据);但无论是“public”段还是“protected”“private”段都必须出现在类的声明中,随类声明所在的头文件一起提供。想来这就是为何数据必须放到类声明中问题的由来吧。为了解决这个问题,我们有个变通的办法:使用Proxy模式(参见《Design Patterns : Elements of Reusable Object-Oriented Software》一书),我们可以将实作部分在proxy类中声明(称为对象组合),而不将proxy类的声明暴露给用户。例如:

class Implementer; // forward declaration

class Interface {

public:

// interface

private:

Implementer impl;

};

在这个例子中,Implementer类就是proxy。在Interface中暴露给用户的只是一个impl对象的存根,而无实作内容。Implementer类可以如下声明:

class Implementer {
public:
// implementation details, including data members

  };


上述代码中的注释处可以存放提问者所说的数据,而Implementer的声明代码不需暴露给用户。不过,Proxy模式也不是十全十美的——Interface通过impl指针间接调用实作代码带来了额外的开销。或许读者会说,C++不是有内联机制吗?这个开销能通过内联定义而弥补吧。但别忘了,此处运用Proxy模式的目的就是把实作部分隐藏起来,这隐藏往往就意味着实作代码以链接库中的二进制代码形式存在。目前的C++编译器和链接器能做到既代码内联二进制隐藏吗?或许可以。那么Proxy模式又能否和C++的模板机制合作愉快呢?(换句话说,如果前面代码中InterfaceImplementer的声明均不是class,而是template,又如何呢?)关键在于,编译器对内联和模板的支持之实作是否需要进行源码拷贝,还是可以进行二进制码拷贝。目前而言,C#的泛型支持之实作是在Intermediate Language层面上的,而C++则是源码层面上的。Bjarne给出的复数类声明代码称数据必须出现在类声明中也是部分出于这种考虑。呵呵,扯远了……毕竟,这段文字只是FAQ译注而已,此处不作更多探讨,有兴趣的读者可以自己去寻找答案 :O) ]

 

 

Q: 为何成员函数不是默认为虚?

A: 因为许多类不是被用来做基类的。[译注:用来做基类的类常类似于其它语言中的interface概念——它们的作用是为一组类定义一个公共介面。但C++中的类显然还有许多其他用途——比如表示一个具体的扩展类型。] 例如,复数类就是如此。 

另外,有虚函数的类有虚机制的开销[译注:指存放vtable带来的空间开销和通过vtable中的指针间接调用带来的时间开销],通常而言每个对象增加的空间开销是一个字长。这个开销可不小,而且会造成和其他语言(比如CFortran)的不兼容性——有虚函数的类的内存数据布局和普通的类是很不一样的。[译注:这种内存数据布局的兼容性问题会给多语言混合编程带来麻烦。

The Design and Evolution of C++》 中有更多关于设计理念的细节。 

 

Q: 为何析构函数不是默认为虚?

A: 哈,你大概知道我要说什么了 :O) 仍然是因为——许多类不是被用来做基类的。只有在类被作为interface使用时虚函数才有意义。(这样的类常常在内存堆上实例化对象并通过指针或引用访问。)

那么,何时我该让析构函数为虚呢?哦,答案是——当类有其它虚函数的时候,你就应该让析构函数为虚。有其它虚函数,就意味着这个类要被继承,就意味着它有点“interface”的味道了。这样一来,程序员就可能会以基类指针来指向由它的继承类所实例化而来的对象,而能否通过基类指针来正常释放这样的对象就要看析构函数是否为虚了。 例如:

class Base {

// ...

virtual ~Base();

};

 

class Derived : public Base {

// ...

~Derived();

};

 

void f()

{

Base* p = new Derived;

delete p;// virtual destructor used to ensure that ~Derived is called

}

 

如果Base的析构函数不是虚的,那么Derived的析构函数就不会被调用——这常常会带来恶果:比如,Derived中分配的资源没有被释放。

 

Q: C++中为何没有虚拟构造函数?

A: 虚拟机制的设计目的是使程序员在不完全了解细节(比如只知该类实现了某个界面,而不知该类确切是什么东东)的情况下也能使用对象。但是,要建立一个对象,可不能只知道这大体上是什么就完事——你必须完全了解全部细节,清楚地知道你要建立的对象是究竟什么。所以,构造函数当然不能是虚的了。

 

不过有时在建立对象时也需要一定的间接性,这就需要用点技巧来实现了。(详见《The C++ Programming Language》,第三版,15.6.2)这样的技巧有时也被称作虚拟构造函数。我这里举个使用抽象类来虚拟构造对象的例子:

struct F {// interface to object creation functions

virtual A* make_an_A() const = 0;

virtual B* make_a_B() const = 0;

};

 

void user(const F& fac)

{

A* p = fac.make_an_A();// make an A of the appropriate type

B* q = fac.make_a_B();// make a B of the appropriate type

// ...

}

 

struct FX : F {

A* make_an_A() const { return new AX();} // AX is derived from A

B* make_a_B() const { return new BX();} // BX is derived from B

};

 

struct FY : F {

A* make_an_A() const { return new AY();} // AY is derived from A

B* make_a_B() const { return new BY();} // BY is derived from B

 

};

 

int main()

{

user(FX());// this user makes AXs and BXs

user(FY());// this user makes AYs and BYs

// ...

}

 

看明白了没有?上述代码其实运用了Factory模式的一个变体。关键之处是,user()被完全孤立开了——它对AXAY这些类一无所知。(嘿嘿,有时无知有无知的好处 ^_^

 

 

Q: 为何无法在派生类中重载?

A: 这个问题常常是由这样的例子中产生的:

i nclude<iostream>

using namespace std;

 

class B {

public:

int f(int i) { cout << "f(int): "; return i+1; }

// ...

};

 

class D : public B {

public:

double f(double d) { cout << "f(double): "; return d+1.3; }

// ...

};

 

int main()

{

D* pd = new D;

 

cout << pd->f(2) << '\n';

cout << pd->f(2.3) << '\n';

}

 

程序运行结果是:

f(double): 3.3
f(double): 3.6

而不是某些人(错误地)猜想的那样:

f(int): 3
f(double): 3.6

换句话说,在DB之间没有重载发生。你调用了pd->f(),编译器就在D的名字域里找啊找,找到double f(double)后就调用它了。编译器懒得再到B的名字域里去看看有没有哪个函数更符合要求。记住,在C++中,没有跨域重载——继承类和基类虽然关系很亲密,但也不能坏了这条规矩。详见《The Design and Evolution of C++》或者《The C++ Programming Language》第三版。 

不过,如果你非得要跨域重载,也不是没有变通的方法——你就把那些函数弄到同一个域里来好了。使用一个using声明就可以搞定。

class D : public B {

public:

using B::f;// make every f from B available

double f(double d) { cout << "f(double): "; return d+1.3; }

// ...

};

 

这样一来,结果就是

f(int): 3

f(double): 3.6

重载发生了——因为D中的那句 using B::f 明确告诉编译器,要把B域中的f引入当前域,请编译器一视同仁

 

Q: 我能从构造函数调用虚函数吗?

A: 可以。不过你得悠着点。当你这样做时,也许你自己都不知道自己在干什么!在构造函数中,虚拟机制尚未发生作用,因为此时overriding尚未发生。万丈高楼平地起,总得先打地基吧?对象的建立也是这样——先把基类构造完毕,然后在此基础上构造派生类。

 

看看这个例子:

i nclude<string>

i nclude<iostream>

using namespace std;

 

class B {

public:

B(const string& ss) { cout << "B constructor\n"; f(ss); }

virtual void f(const string&) { cout << "B::f\n";}

};

 

class D : public B {

public:

D(const string & ss) :B(ss) { cout << "D constructor\n";}

void f(const string& ss) { cout << "D::f\n"; s = ss; }

private:

string s;

};

 

int main()

{

D d("Hello");

}

 

这段程序经编译运行,得到这样的结果:

B constructor
B::f
D constructor

注意,输出不是D::f 。 究竟发生了什么?f()是在B::B()中调用的。如果构造函数中调用虚函数的规则不是如前文所述那样,而是如一些人希望的那样去调用D::f()。那么因为构造函数D::D()尚未运行,字符串s还未初始化,所以当D::f()试图将参数赋给s时,结果多半是——立马当机。

析构则正相反,遵循从继承类到基类的顺序(拆房子总得从上往下拆吧?),所以其调用虚函数的行为和在构造函数中一样:虚函数此时此刻被绑定到哪里(当然应该是基类啦——因为继承类已经被——析构了!),调用的就是哪个函数。 

更多细节请见《The Design and Evolution of C++》,13.2.4.2 或者《The C++ Programming Language》第三版,15.4.3 。 

有时,这条规则被解释为是由于编译器的实作造成的。[译注:从实作角度可以这样解释:在许多编译器中,直到构造函数调用完毕,vtable才被建立,此时虚函数才被动态绑定至继承类的同名函数。] 但事实上不是这么一回事——让编译器实作成构造函数中调用虚函数也和从其他函数中调用一样是很简单的[译注:只要把vtable的建立移至构造函数调用之前即可]。关键还在于语言设计时的考量——让虚函数可以求助于基类提供的通用代码。[译注:先有鸡还是先有蛋?Bjarne实际上是在告诉你,不是先有实作再有规则,而是如此实作,因为规则如此] 

 

Q: "placement delete"?

A: 没有。不过如果你真的想要,你就说嘛——哦不,我的意思是——你可以自己写一个。

 

我们来看看将对象放至某个指定场所的placement new

 

 class Arena {
 public:
   void* allocate(size_t);
  void deallocate(void*);

  // ...
};


void* operator new(size_t sz, Arena& a)
{
return a.allocate(sz);
}



Arena a1(some arguments);
Arena a2(some arguments);

 

现在我们可以写:

X* p1 = new(a1) X;

Y* p2 = new(a1) Y;

Z* p3 = new(a2) Z;

// ...

但之后我们如何正确删除这些对象?没有内置“placement delete”的理由是,没办法提供一个通用的placement deleteC++的类型系统没办法让我们推断出p1是指向被放置在a1中的对象。即使我们能够非常天才地推知这点,一个简单的指针赋值操作也会让我们重陷茫然。不过,程序员本人应该知道在他自己的程序中什么指向什么,所以可以有解决方案:

template<class T> void destroy(T* p, Arena& a)

{

if (p) {

p->~T();// explicit destructor call

a.deallocate(p);

}

}

 

这样我们就可以写:

 

  destroy(p1,a1);
  destroy(p2,a2);
  destroy(p3,a3);

 

如果Arena自身跟踪放置其中的对象,那么你可以安全地写出destroy()函数 ,把保证无错的监控任务交给Arena,而不是自己承担。

如何在类继承体系中定义配对的operator new() 和 operator delete() 可以参看 The C++ Programming Language》,Special Edition15.6 ,《The Design and Evolution of C++》,10.4节,以及《The C++ Programming Language》,Special Edition19.4.5节。[译注:此处按原文照译。前面有提到参见《The C++ Programming Language》第三版的,实际上特别版(Special Edition)和较近重印的第三版没什么区别。

 

 

Q: 我能防止别人从我的类继承吗?

A: 可以的,但何必呢?好吧,也许有两个理由: 

· 出于效率考虑——不希望我的函数调用是虚的 

· 出于安全考虑——确保我的类不被用作基类(这样我拷贝对象时就不用担心对象被切割(slicing)了)[译注:对象切割指,将派生类对象赋给基类变量时,根据C++的类型转换机制,只有包括在派生类中的基类部分被拷贝,其余部分被切割掉了。]

根据我的经验,效率考虑常常纯属多余。在C++中,虚函数调用如此之快,和普通函数调用并没有太多的区别。请注意,只有通过指针或者引用调用时才会启用虚拟机制;如果你指名道姓地调用一个对象,C++编译器会自动优化,去除任何的额外开销。

如果为了和虚函数调用byebye,那么确实有给类继承体系封顶的需要。在设计前,不访先问问自己,这些函数为何要被设计成虚的。我确实见过这样的例子:性能要求苛刻的函数被设计成虚的,仅仅因为我们习惯这样做! 

好了,无论如何,说了那么多,毕竟你只是想知道,为了某种合理的理由,你能不能防止别人继承你的类。答案是可以的。可惜,这里给出的解决之道不够干净利落。你不得不在在你的封顶类中虚拟继承一个无法构造的辅助基类。还是让例子来告诉我们一切吧:

class Usable;

class Usable_lock {
friend class Usable;
private:
Usable_lock() {}
Usable_lock(const Usable_lock&) {}
};
        
class Usable : public virtual Usable_lock {
// ...
public:
Usable();
Usable(char*);
// ...
};
        
Usable a;
        
class DD : public Usable { };
        
DD dd;  // error: DD::DD() cannot access
 // Usable_lock::Usable_lock(): private member

 

(参见《The Design and Evolution of C++》,11.4.3

 

Q: 为什么我无法限制模板的参数?

A: 呃,其实你是可以的。而且这种做法并不难,也不需要什么超出常规的技巧。

让我们来看这段代码:

template<class Container>

void draw_all(Container& c)

{

for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

}

如果c不符合constraints,出现了类型错误,那么错误将发生在相当复杂的for_each解析之中。比如说,参数化的类型被要求实例化int型,那么我们无法为之调用Shape::draw()。而我们从编译器中得到的错误信息是含糊而令人迷惑的——因为它和标准库中复杂的for_each纠缠不清。

为了早点捕捉到这个错误,我们可以这样写代码:

template<class Container>

void draw_all(Container& c)

{

Shape* p = c.front(); // accept only containers of Shape*s

 

for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

}

我们注意到,前面加了一行Shape *p的定义(尽管就程序本身而言,p是无用的)。如果不可将c.front()赋给Shape *p,那么就大多数现代编译器而言,我们都可以得到一条含义清晰的出错信息。这样的技巧在所有语言中都很常见,而且对于所有不同寻常的构造都不得不如此。[译注:意指对于任何语言,当我们开始探及极限,那么不得不写一些高度技巧性的代码。

 

不过这样做不是最好。如果要我来写实际代码,我也许会这样写:

template<class Container>

void draw_all(Container& c)

{

typedef typename Container::value_type T;

Can_copy<T,Shape*>(); // accept containers of only Shape*s

 

for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

}

 

这就使代码通用且明显地体现出我的意图——我在使用断言[译注:即明确断言typename Containerdraw_all()所接受的容器类型,而不是令人迷惑地定义了一个Shape *指针,也不知道会不会在后面哪里用到]Can_copy()模板可被这样定义:

template<class T1, class T2> struct Can_copy {

static void constraints(T1 a, T2 b) { T2 c = a; b = a; }

Can_copy() { void(*p)(T1,T2) = constraints; }

};

Can_copy在编译期间检查确认T1可被赋于T2Can_copy<T,Shape*>检查确认T是一个Shape*类型,或者是一个指向Shape的公有继承类的指针,或者是用户自定义的可被转型为Shape *的类型。注意,这里Can_copy()的实现已经基本上是最优化的了:一行代码用来指明需要检查的constraints[译注:指第1行代码;constraintsT2]和要对其做这个检查的类型[译注:要作检查的类型为T1] ;一行代码用来精确列出所要检查是否满足的constraintsconstraints()函数) [译注:第2行之所以要有2个子句并不是重复,而是有原因的。如果T1T2均是用户自定义的类,那么T2 c = a; 检测能否缺省构造;b = a; 检测能否拷贝构造] ;一行代码用来提供执行这些检查的机会 [译注:指第3行。Can_copy是一个模板类;constraints是其成员函数,第2行只是定义,而未执行

 

[译注:这里constraints实现的关键是依赖C++强大的类型系统,特别是类的多态机制。第2行代码中T2 c = a; b = a; 能够正常通过编译的条件是:T1实现了T2的接口。具体而言,可能是以下4种情况:(1) T1T2 同类型 (2) 重载operator = (3) 提供了 cast operator (类型转换运算符)(4) 派生类对象赋给基类指针。说到这里,记起我曾在以前的一篇文章中说到,C++genericity实作——template不支持constrained genericity,而Eiffel则从语法级别支持constrained genericity(即提供类似于template <typename T as Comparable> xxx 这样的语法——其中Comparable即为一个constraint)。曾有读者指出我这样说是错误的,认为C++ template也支持constrained genericity。现在这部分译文给出了通过使用一些技巧,将OOPGP的方法结合,从而在C++中巧妙实现constrained genericity的方法。对于爱好C++的读者,这种技巧是值得细细品味的。不过也不要因为太执著于各种细枝末节的代码技巧而丧失了全局眼光。有时语言支持方面的欠缺可以在设计层面(而非代码层面)更优雅地弥补。另外,这能不能算“C++template支持constrained genericity”,我保留意见。正如,用C通过一些技巧也可以OOP,但我们不说C语言支持OOP]

 

请大家再注意,现在我们的定义具备了这些我们需要的特性: 

· 你可以不通过定义/拷贝变量就表达出constraints[译注:实则定义/拷贝变量的工作被封装在Can_copy模板中了] ,从而可以不必作任何那个类型是这样被初始化之类假设,也不用去管对象能否被拷贝、销毁(除非这正是constraints所在)。[译注:即——除非constraints正是可拷贝可销毁。如果用易理解的伪码描述,就是template <typename T as Copy_Enabled> xxxtemplate <typename T as Destructible> xxx ] 

· 如果使用现代编译器,constraints不会带来任何额外代码 

· 定义或者使用constraints均不需使用宏定义 

· 如果constraints没有被满足,编译器给出的错误消息是容易理解的。事实上,给出的错误消息包括了单词“constraints” (这样,编码者就能从中得到提示)constraints的名称、具体的出错原因(比如“cannot initialize Shape* by double*”) 

既然如此,我们干吗不干脆在C++语言本身中定义类似Can_copy()或者更优雅简洁的语法呢?The Design and Evolution of C++分析了此做法带来的困难。已经有许许多多设计理念浮出水面,只为了让含constraints的模板类易于撰写,同时还要让编译器在constraints不被满足时给出容易理解的出错消息。比方说,我在Can_copy使用函数指针的设计就来自于Alex StepanovJeremy Siek。我认为我的Can_copy()实作还不到可以标准化的程度——它需要更多实践的检验。另外,C++使用者会遭遇许多不同类型的constraints,目前看来还没有哪种形式的带constraints的模板获得压倒多数的支持。 

已有不少关于constraints内置语言支持方案被提议和实作。但其实要表述constraint根本不需要什么异乎寻常的东西:毕竟,当我们写一个模板时,我们拥有C++带给我们的强有力的表达能力。让代码来为我的话作证吧:

template<class T, class B> struct Derived_from {

static void constraints(T* p) { B* pb = p; }

Derived_from() { void(*p)(T*) = constraints; }

};

 

template<class T1, class T2> struct Can_copy {

static void constraints(T1 a, T2 b) { T2 c = a; b = a; }

Can_copy() { void(*p)(T1,T2) = constraints; }

};

 

template<class T1, class T2 = T1> struct Can_compare {

static void constraints(T1 a, T2 b) { a==b; a!=b; a<b; }

Can_compare() { void(*p)(T1,T2) = constraints; }

};

 

template<class T1, class T2, class T3 = T1> struct Can_multiply {

static void constraints(T1 a, T2 b, T3 c) { c = a*b; }

Can_multiply() { void(*p)(T1,T2,T3) = constraints; }

};

 

struct B { };

struct D : B { };

struct DD : D { };

struct X { };

 

int main()

{

Derived_from<D,B>();

Derived_from<DD,B>();

Derived_from<X,B>();

Derived_from<int,B>();

Derived_from<X,int>();

 

Can_compare<int,float>();

Can_compare<X,B>();

Can_multiply<int,float>();

Can_multiply<int,float,double>();

Can_multiply<B,X>();

Can_copy<D*,B*>();

Can_copy<D,B*>();

Can_copy<int,B*>();

}

 

// the classical "elements must derived from Mybase*" constraint:

 

template<class T> class Container : Derived_from<T,Mybase> {

// ...

};

事实上Derived_from并不检查继承性,而是检查可转换性。不过Derive_from常常是一个更好的名字——有时给constraints起个好名字也是件需细细考量的活儿。 

 

Q: 我们已经有了 "美好的老qsort()",为什么还要用sort()?

A: 对于初学者而言,

qsort(array,asize,sizeof(elem),elem_compare);

 

看上去有点古怪。还是

sort(vec.begin(),vec.end());

 

比较好理解,是吧。那么,这点理由就足够让你舍qsort而追求sort了。对于老手来说,sort()要比qsort()快的事实也会让你心动不已。而且sort是泛型的,可以用于任何合理的容器组合、元素类型和比较算法。例如:

struct Record {

string name;

// ...

};

 

struct name_compare {// compare Records using "name" as the key

bool operator()(const Record& a, const Record& b) const

{ return a.name<b.name; }

};

 

void f(vector<Record>& vs)

{

sort(vs.begin(), vs.end(), name_compare());

// ...

}

另外,还有许多人欣赏sort()的类型安全性——要使用它可不需要任何强制的类型转换。对于标准类型,也不必写compare()函数,省事不少。如果想看更详尽的解释,参看我的《Learning Standard C++ as a New Language》一文。 

另外,为何sort()要比qsort()快?因为它更好地利用了C++的内联语法语义。 

 

Q: 什么是function object?

A: Function object是一个对象,不过它的行为表现像函数。一般而言,它是由一个重载了operator()的类所实例化得来的对象。

Function object的涵义比通常意义上的函数更广泛,因为它可以在多次调用之间保持某种状态”——这和静态局部变量有异曲同工之妙;不过这种状态还可以被初始化,还可以从外面来检测,这可要比静态局部变量强了。我们来看一个例子:

class Sum {

int val;

public:

Sum(int i) :val(i) { }

operator int() const { return val; }// extract value

 

int operator()(int i) { return val+=i; }// application

};

 

void f(vector v)

{

Sum s = 0;// initial value 0

s = for_each(v.begin(), v.end(), s);// gather the sum of all elements

cout << "the sum is " << s << "\n";

// or even:

cout << "the sum is " << for_each(v.begin(), v.end(), Sum(0)) << "\n";

}

 

这里我要提请大家注意:一个function object可被漂亮地内联化(inlining),因为对于编译器而言,没有讨厌的指针来混淆视听,所以这样的优化很容易进行。[译注:这指的是将operator()定义为内联函数,可以带来效率的提高。] 作为对比,编译器几乎不可能通过优化将通过函数指针调用函数这一步骤所花的开销省掉,至少目前如此。

在标准库中function objects被广泛使用,这给标准库带来了极大的灵活性和可扩展性。 

[译注:C++是一个博采众长的语言,function object的概念就是从functional programming中借来的;而C++本身的强大和表现力的丰富也使这种拿来主义成为可能。一般而言,在使用function object的地方也常可以使用函数指针;在我们还不熟悉function object的时候我们也常常是使用指针的。但定义一个函数指针的语法可不是太简单明了,而且在C++中指针早已背上了错误之源的恶名。更何况,通过指针调用函数增加了间接开销。所以,无论为了语法的优美还是效率的提高,都应该提倡使用function objects 

下面我们再从设计模式的角度来更深入地理解function objects:这是Visitor模式的典型应用。当我们要对某个/某些对象施加某种操作,但又不想将这种操作限定死,那么就可以采用Visitor模式。在Design Patterns一书中,作者把这种模式实作为:通过一个Visitor类来提供这种操作(在前面Bjarne Stroustrup的代码中,Sum就是一个Visitor的变体),用Visitor类实例化一个visitor对象(当然,在前面的代码中对应的是s);然后在Iterator的迭代过程中,为每一个对象调用visitor.visit()。这里visit()Visitor类的一个成员函数,作用相当于Sum类中那个特殊的成员函数”——operator()visit()也完全可以被定义为内联函数,以去除间接性,提高性能。在此提请读者注意,C++把重载的操作符也看作函数,只不过是具有特殊函数名的函数。所以实际上Design Patterns一书中Visitor模式的示范实作和这里function object的实作大体上是等价的。一个function object也就是一个特殊的Visitor] 

 

Q: 我应该怎样处理内存泄漏?

A: 很简单,只要写不漏的代码就完事了啊。显然,如果你的代码到处是newdelete、指针运算,那你想让它不漏都难。不管你有多么小心谨慎,君为人,非神也,错误在所难免。最终你会被自己越来越复杂的代码逼疯的——你将投身于与内存泄漏的奋斗之中,对bug们不离不弃,直至山峰没有棱角,地球不再转动。而能让你避免这样困境的技巧也不复杂:你只要倚重隐含在幕后的分配机制——构造和析构,让C++的强大的类系统来助你一臂之力就OK了。标准库中的那些容器就是很好的实例。它们让你不必化费大量的时间精力也能轻松惬意地管理内存。我们来看看下面的示例代码——设想一下,如果没有了stringvector,世界将会怎样?如果不用它们,你能第一次就写出毫无内存错误的同样功能代码吗?

i nclude<vector>

i nclude<string>

i nclude<iostream>

i nclude<algorithm>

using namespace std;

 

int main()// small program messing around with strings

{

cout << "enter some whitespace-separated words:\n";

vector<string> v;

string s;

while (cin>>s) v.push_back(s);

 

sort(v.begin(),v.end());

 

string cat;

typedef vector<string>::const_iterator Iter;

for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";

cout << cat << '\n';

}

请注意这里没有显式的内存管理代码。没有宏,没有类型转换,没有溢出检测,没有强制的大小限制,也没有指针。如果使用function object和标准算法[译注:指标准库中提供的泛型算法],我连Iterator也可以不用。不过这毕竟只是一个小程序,杀鸡焉用牛刀? 

当然,这些方法也并非无懈可击,而且说起来容易做起来难,要系统地使用它们也并不总是很简单。不过,无论如何,它们的广泛适用性令人惊讶,而且通过移去大量的显式内存分配/释放代码,它们确实增强了代码的可读性和可管理性。早在1981年,我就指出通过大幅度减少需要显式加以管理的对象数量,使用C++“将事情做对将不再是一件极其费神的艰巨任务。 

如果你的应用领域没有能在内存管理方面助你一臂之力的类库,那么如果你还想让你的软件开发变得既快捷又能轻松得到正确结果,最好是先建立这样一个库。 

如果你无法让内存分配和释放成为对象的自然行为,那么至少你可以通过使用资源句柄来尽量避免内存泄漏。这里是一个示例:假设你需要从函数返回一个对象,这个对象是在自由内存堆上分配的;你可能会忘记释放那个对象——毕竟我们无法通过检查指针来确定其指向的对象是否需要被释放,我们也无法得知谁应该负责释放它。那么,就用资源句柄吧。比如,标准库中的auto_ptr就可以帮助澄清:释放对象责任究竟在谁。我们来看:

i nclude<memory>

i nclude<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;// who is responsible for deleting this S?

};

 

auto_ptr<S> g()

{

return auto_ptr<S>(new S);// explicitly transfer responsibility for deleting this S

}

 

int main()

{

cout << "start main\n";

S* p = f();

cout << "after f() before g()\n";

//S* q = g();// caught by compiler

auto_ptr<S> q = g();

cout << "exit main\n";

// leaks *p

// implicitly deletes *q

}

这里只是内存资源管理的例子;至于其它类型的资源管理,可以如法炮制。 

如果在你的开发环境中无法系统地使用这种方法(比方说,你使用了第三方提供的古董代码,或者远古穴居人参与了你的项目开发),那么你在开发过程中可千万要记住使用内存防漏检测程序,或者干脆使用垃圾收集器(Garbage Collector)。 

 

 

Q: 为何捕捉到异常后不能继续执行后面的代码呢?

A: 这个问题,换句话说也就是:为什么C++不提供这样一个原语,能使你处理异常过后返回到异常抛出处继续往下执行?[译注:比如,一个简单的resume语句,用法和已有的return语句类似,只不过必须放在exception handler的最后。]

嗯,从异常处理代码返回到异常抛出处继续执行后面的代码的想法很好[译注:现行异常机制的设计是:当异常被抛出和处理后,从处理代码所在的那个catch块往下执行],但主要问题在于——exception handler不可能知道为了让后面的代码正常运行,需要做多少清除异常的工作[译注:毕竟,当有异常发生,事情就有点不太对劲了,不是吗;更何况收拾烂摊子永远是件麻烦的事],所以,如果要让继续执行能够正常工作,写throw代码的人和写catch代码的人必须对彼此的代码都很熟悉,而这就带来了复杂的相互依赖关系[译注:既指开发人员之间的相互依赖,也指代码间的相互依赖——紧耦合的代码可不是好代码哦 :O) ],会带来很多麻烦的维护问题。 

在我设计C++的异常处理机制的时候,我曾认真地考虑过这个问题;在C++标准化的过程中,这个问题也被详细地讨论过。(参见《The Design and Evolution of C++》中关于异常处理的章节)如果你想试试看在抛出异常之前能不能解决问题然后继续往下执行,你可以先调用一个检查恢复函数,然后,如果还是不能解决问题,再把异常抛出。一个这样的例子是new_handler。 

 

Q: 为何C++中没有Crealloc()的对应物?

A: 如果你一定想要的话,你当然可以使用realloc()。不过,realloc() 只和通过malloc()之类C函数分配得到的内存合作愉快,在分配的内存中不能有具备用户自定义构造函数的对象。请记住:与某些天真的人们的想象相反,realloc()必要时是会拷贝大块的内存到新分配的连续空间中的。所以,realloc没什么好的 ^_^

C++中,处理内存重分配的较好办法是使用标准库中的容器,比如vector[译注:这些容器会自己管理需要的内存,在必要时会增长尺寸”——进行重分配。

 

Q: 我如何使用异常处理?

A: 参见《The C++ Programming Language148.3节,以及附录E。附录E主要阐述如何撰写“exception-safe”代码,这个附录可不是写给初学者看的。一个关键技巧是资源分配即初始化”——这种技巧通过类的析构函数给易造成混乱的资源管理带来了秩序的曙光

 

Q: 我如何从标准输入中读取string

A: 如果要读以空白结束的单个单词,可以这样:

i nclude<iostream>

i nclude<string>

using namespace std;

 

int main()

{

cout << "Please enter a word:\n";

 

string s;

cin>>s;

cout << "You entered " << s << '\n';

}

 

请注意,这里没有显式的内存管理代码,也没有限制尺寸而可能会不小心溢出的缓冲区。 [译注:似乎Bjarne常骄傲地宣称这点——因为这是string乃至整个标准库带来的重大好处之一,确实值得自豪;而在老的C语言中,最让程序员抱怨的也是内置字符串类型的缺乏以及由此引起的操作字符串所需要之复杂内存管理措施所带来的麻烦。Bjarne一定在得意地想,哈,我的叫C++的小baby终于长大了,趋向完美了!” :O) ]

如果你需要一次读一整行,可以这样: 

i nclude<iostream>

i nclude<string>

using namespace std;

 

int main()

{

cout << "Please enter a line:\n";

 

string s;

getline(cin, s);

cout << "You entered " << s << '\n';

}

 

关于标准库所提供之功能的简介(诸如iostreamstream),参见《The C++ Programming Language》第三版的第三章。如果想看CC++的输入输出功能使用之具体比较,参看我的《Learning Standard C++ as a New Language》一文。 

 

Q: 为何C++不提供“finally”结构?

A: 因为C++提供了另一种机制,完全可以取代finally,而且这种机制几乎总要比finally工作得更好:就是——“分配资源即初始化。(见《The C++ Programming Language14.4节)基本的想法是,用一个局部对象来封装一个资源,这样一来局部对象的析构函数就可以自动释放资源。这样,程序员就不会忘记释放资源了。 [译注:因为C++的对象生命周期机制替他记住了 :O) ] 下面是一个例子:

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");// open fn for reading and writing

// use file through f

}

 

在一个系统中,每一样资源都需要一个资源局柄对象,但我们不必为每一个资源都写一个“finally”语句。在实作的系统中,资源的获取和释放的次数远远多于资源的种类,所以资源分配即初始化机制产生的代码要比“finally”机制少。

 

[译注:Object PascalJavaC#等语言都有finally语句块,常用于发生异常时对被分配资源的资源的处理——这意味着有多少次分配资源就有多少finally语句块(少了一个finally就意味着有一些资源分配不是“exception safe”的);而资源分配即初始化机制将原本放在finally块中的代码移到了类的析构函数中。我们只需为每一类资源提供一个封装类即可。需代码量孰多孰少?除非你的系统中每一类资源都只被使用一次——这种情况下代码量是相等的;否则永远是前者多于后者 :O) ]

另外,请看看《The C++ Programming Language》附录E中的资源管理例子。 

 

Q: 那个auto_ptr是什么东东啊?为什么没有auto_array

A: 哦,auto_ptr是一个很简单的资源封装类,是在<memory>头文件中定义的。它使用资源分配即初始化技术来保证资源在发生异常时也能被安全释放(“exception safety”)。一个auto_ptr封装了一个指针,也可以被当作指针来使用。当其生命周期到了尽头,auto_ptr会自动释放指针。例如:

i nclude<memory>

using namespace std;

 

struct X {

int m;

// ..

};

 

void f()

{

auto_ptr<X> p(new X);

X* q = new X;

 

p->m++;// use p just like a pointer

q->m++;

// ...

 

delete q;

}

如果在代码用// ...标注的地方抛出异常,那么p会被正常删除——这个功劳应该记在auto_ptr的析构函数头上。不过,q指向的X类型对象就没有被释放(因为不是用auto_ptr定义的)。详情请见《The C++ Programming Language14.4.2节。

Auto_ptr是一个轻量级的类,没有引入引用计数机制。如果你把一个auto_ptr(比如,ap1)赋给另一个auto_ptr(比如,ap2),那么ap2将持有实际指针,而ap1将持有零指针。例如:

i nclude<memory>

i nclude<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";

}

 

运行结果应该是先显示一个零指针,然后才是一个实际指针,就像这样: 

p 0x0 q 0x378d0

 

auto_ptr::get()返回实际指针。

这里,语义似乎是转移,而非拷贝,这或许有点令人惊讶。特别要注意的是,不要把auto_ptr作为标准容器的参数——标准容器要求通常的拷贝语义。例如:

std::vector<auto_ptr<X> >v;// error

一个auto_ptr只能持有指向单个元素的指针,而不是数组指针: 

void f(int n)

{

auto_ptr<X> p(new X[n]);// error

// ...

}

 

上述代码会出错,因为析构函数是使用delete而非delete[]来释放指针的,所以后面的n-1X没有被释放。 

那么,看来我们应该用一个使用delete[]来释放指针的,叫auto_array的类似东东来放数组了?哦,不,不,没有什么auto_array。理由是,不需要有啊——我们完全可以用vector嘛:

void f(int n)

{

vector<X> v(n);

// ...

}

 

如果在 // ... 部分发生了异常,v的析构函数会被自动调用。 

 

 

Q: CC++风格的内存分配/释放可以混用吗?

A: 可以——从你可在一个程序中同时使用malloc()new的意义上而言。 

不可以——从你无法delete一个以malloc()分配而来之对象的意义上而言。你也无法free()realloc()一个由new分配而来的对象。 

C++newdelete运算符确保构造和析构正常发生,但C风格的malloc()calloc()free()realloc()可不保证这点。而且,没有任何人能向你担保,new/deletemalloc/free所掌控的内存是相互兼容的。如果在你的代码中,两种风格混用而没有给你造成麻烦,那我只能说:直到目前为止,你是非常幸运的 :O) 

如果你因为思念美好的老realloc()”(许多人都思念她)而无法割舍整个古老的C内存分配机制(爱屋及乌?),那么考虑使用标准库中的vector吧。例如:

// read words from input into a vector of strings:

 

vector<string> words;

string s;

while (cin>>s && s!=".") words.push_back(s);

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值