第四章 类和函数:设计与声明

转载 2006年05月29日 22:00:00

条款23: 必须返回一个对象时不要试图返回一个引用

据说爱因斯坦曾提出过这样的建议:尽可能地让事情简单,但不要过于简单。在c++语言中相似的说法应该是:尽可能地使程序高效,但不要过于高效。

一旦程序员抓住了“传值”在效率上的把柄(参见条款22),他们会变得十分极端,恨不得挖出每一个隐藏在程序中的传值操作。岂不知,在他们不懈地追求纯粹的“传引用”的过程中,他们会不可避免地犯另一个严重的错误:传递一个并不存在的对象的引用。这就不是好事了。

看一个表示有理数的类,其中包含一个友元函数,用于两个有理数相乘:

class rational {
public:
  rational(int numerator = 0, int denominator = 1);

  ...

private:
  int n, d;              // 分子和分母

friend
  const rational                      // 参见条款21:为什么
    operator*(const rational& lhs,    // 返回值是const
              const rational& rhs)    
};

inline const rational operator*(const rational& lhs,
                                const rational& rhs)
{
  return rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

很明显,这个版本的operator*是通过传值返回对象结果,如果不去考虑对象构造和析构时的开销,你就是在逃避作为一个程序员的责任。另外一件很明显的事实是,除非确实有必要,否则谁都不愿意承担这样一个临时对象的开销。那么,问题就归结于:确实有必要吗?

答案是,如果能返回一个引用,当然就没有必要。但请记住,引用只是一个名字,一个其它某个已经存在的对象的名字。无论何时看到一个引用的声明,就要立即问自己:它的另一个名字是什么呢?因为它必然还有另外一个什么名字(见条款m1)。拿operator*来说,如果函数要返回一个引用,那它返回的必须是其它某个已经存在的rational对象的引用,这个对象包含了两个对象相乘的结果。

但,期望在调用operator*之前有这样一个对象存在是没道理的。也就是说,如果有下面的代码:

rational a(1, 2);                // a = 1/2
rational b(3, 5);                // b = 3/5
rational c = a * b;              // c 为 3/10

期望已经存在一个值为3/10的有理数是不现实的。如果operator* 一定要返回这样一个数的引用,就必须自己创建这个数的对象。

一个函数只能有两种方法创建一个新对象:在堆栈里或在堆上。在堆栈里创建对象时伴随着一个局部变量的定义,采用这种方法,就要这样写operator*:

// 写此函数的第一个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  rational result(lhs.n * rhs.n, lhs.d * rhs.d);
  return result;
}

这个方法应该被否决,因为我们的目标是避免构造函数被调用,但result必须要象其它对象一样被构造。另外,这个函数还有另外一个更严重的问题,它返回的是一个局部对象的引用,关于这个错误,条款31进行了深入的讨论。

那么,在堆上创建一个对象然后返回它的引用呢?基于堆的对象是通过使用new产生的,所以应该这样写operator*:

// 写此函数的第二个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  rational *result =
    new rational(lhs.n * rhs.n, lhs.d * rhs.d);
  return *result;
}

首先,你还是得负担构造函数调用的开销,因为new分配的内存是通过调用一个适当的构造函数来初始化的(见条款5和m8)。另外,还有一个问题:谁将负责用delete来删除掉new生成的对象呢?

实际上,这绝对是一个内存泄漏。即使可以说服operator*的调用者去取函数返回值地址,然后用delete去删除它(绝对不可能——条款31展示了这样的代码会是什么样的),但一些复杂的表达式会产生没有名字的临时值,程序员是不可能得到的。例如:

rational w, x, y, z;

w = x * y * z;

两个对operator*的调用都产生了没有名字的临时值,程序员无法看到,因而无法删除。(再次参见条款31)

也许,你会想你比一般的熊——或一般的程序员——要聪明;也许,你注意到在堆栈和堆上创建对象的方法避免不了对构造函数的调用;也许,你想起了我们最初的目标是为了避免这种对构造函数的调用;也许,你有个办法可以只用一个构造函数来搞掂一切;也许,你的眼前出现了这样一段代码:operator*返回一个“在函数内部定义的静态rational对象”的引用:

// 写此函数的第三个错误方法
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  static rational result;      // 将要作为引用返回的
                               // 静态对象

  lhs和rhs 相乘,结果放进result;

  return result;
}

这个方法看起来好象有戏,虽然在实际实现上面的伪代码时你会发现,不调用一个rational构造函数是不可能给出result的正确值的,而避免这样的调用正是我们要谈论的主题。就算你实现了上面的伪代码,但,你再聪明也不能最终挽救这个不幸的设计。

想知道为什么,看看下面这段写得很合理的用户代码:

bool operator==(const rational& lhs,      // rationals的operator==
                const rational& rhs);     //

rational a, b, c, d;

...

if ((a * b) == (c * d)) {

  处理相等的情况;

} else {

  处理不相等的情况;

}

看出来了吗?((a*b) == (c*d)) 会永远为true,不管a,b,c和d是什么值!

用等价的函数形式重写上面的相等判断语句就很容易明白发生这一可恶行为的原因了:

if (operator==(operator*(a, b), operator*(c, d)))

注意当operator==被调用时,总有两个operator*刚被调用,每个调用返回operator*内部的静态rational对象的引用。于是,上面的语句实际上是请求operator==对“operator*内部的静态rational对象的值”和“operator*内部的静态rational对象的值”进行比较,这样的比较不相等才怪呢!

幸运的话,我以上的说明应该足以说服你:想“在象operator*这样的函数里返回一个引用”实际上是在浪费时间。但我没幼稚到会相信幸运总会光临自己。一些人——你们知道这些人是指谁——此刻会在想,“唔,上面那个方法,如果一个静态变量不够用,也许可以用一个静态数组……”

请就此打住!我们难道还没受够吗?

我不能让自己写一段示例代码来太高这个设计,因为即使只抱有上面这种想法都足以令人感到羞愧。首先,你必须选择一个n,指定数组的大小。如果n太小,就会没地方储存函数返回值,这和我们前面否定的那个“采用单个静态变量的设计”相比没有什么改进。如果n太大,就会降低程序的性能,因为函数第一次被调用时数组中每个对象都要被创建。这会带来n个构造函数和n个析构函数的开销,即使这个函数只被调用一次。如果说"optimization"(最优化)是指提高软件的性能的过程, 那么现在这种做法简直可以称为"pessimization"(最差化)。最后,想想怎么把需要的值放到数组的对象中以及需要多大的开销?在对象间传值的最直接的方法是通过赋值,但赋值的开销又有多大呢?一般来说,它相当于调用一个析构函数(摧毁旧值)再加上调用一个构造函数(拷贝新值)。但我们现在的目标正是为了避免构造和析构的开销啊!面对现实吧:这个方法也绝对不能选用。

所以,写一个必须返回一个新对象的函数的正确方法就是让这个函数返回一个新对象。对于rational的operator*来说,这意味着要不就是下面的代码(就是最初看到的那段代码),要不就是本质上和它等价的代码:

inline const rational operator*(const rational& lhs,
                                const rational& rhs)
{
  return rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

的确,这会导致“operator*的返回值构造和析构时带来的开销”,但归根结底它只是用小的代价换来正确的程序运行行为而已。况且,你所担心的开销还有可能永远不会出现:和所有程序设计语言一样,c++允许编译器的设计者采用一些优化措施来提高所生成的代码的性能,所以,在有些场合,operator*的返回值会被安全地除去(见条款m20)。当编译器采用了这种优化时(当前大部分编译器这么做),程序和以前一样继续工作,只不过是运行速度比你预计的要快而已。

以上讨论可以归结为:当需要在返回引用和返回对象间做决定时,你的职责是选择可以完成正确功能的那个。至于怎么让这个选择所产生的代价尽可能的小,那是编译器的生产商去想的事。
条款24: 在函数重载和设定参数缺省值间慎重选择

会对函数重载和设定参数缺省值产生混淆的原因在于,它们都允许一个函数以多种方式被调用:

void f(); // f被重载
void f(int x);
f(); // 调用f()
f(10); // 调用f(int)
void g(int x = 0); // g 有一个
  // 缺省参数值
g(); // 调用g(0)
g(10); // 调用g(10)
那么,什么时候该用哪种方法呢?
答案取决于另外两个问题。第一,确实有那么一个值可以作为缺省吗?第二,要用到多少种算法?一般来说,如果可以选择一个合适的缺省值并且只是用到一种算法,就使用缺省参数(参见条款38)。否则,就使用函数重载。
下面是一个最多可以计算五个int的最大值的函数。这个函数使用了——深呼一口气,看清楚啦——std::numeric_limits<int>::min(),作为缺省参数值。等会儿再进一步介绍这个值,这里先给出函数的代码:
int max(int a,
 int b = std::numeric_limits::min(),
 int c = std::numeric_limits::min(),
 int d = std::numeric_limits::min(),
 int e = std::numeric_limits::min())
{
 int temp = a > b ? a : b;
 temp = temp > c ? temp : c;
 temp = temp > d ? temp : d;
 return temp > e ? temp : e;
}

现在可以放松了。std::numeric_limits<int>::min()是c++标准库用一种特有的新方法所表示的一个在c里已经定义了的东西,即c在<limits.h>中定义的int_min宏所表示的那个东西——处理你的c++原代码的编译器所产生的int的最小可能值。是的,它的句法背离了c所具有的简洁,但在那些冒号以及其它奇怪的句法背后,是有道理可循的。
假设想写一个函数模板,其参数为固定数字类型,模板产生的函数可以打印用“实例化类型”表示的最小值。这个模板可以这么写:
template
void printminimumvalue()
{
 cout << 表示为t类型的最小值;
}

如果只是借助<limits.h>和<float.h>来写这个函数会觉得很困难,因为不知道t是什么,所以不知道该打印int_min还是dbl_min,或其它什么类型的值。
为避开这些困难,标准c++库(见条款49)在头文件<limits>
中定义了一个类模板numeric_limits,这个类模板本身也定义了一些静态成员函数。每个函数返回的是“实例化这个模板的类型”的信息。也就是说,numeric_limits<int>中的函数返回的信息是关于类型int的,numeric_limits<double>
中的函数返回的信息是关于类型double的。numeric_limits中有一个函数叫min,min返回可表示为“实例化类型”的最小值,所以numeric_limits<int>::min()返回的是代表整数类型的最小值。
有了numeric_limits(和标准库中其它东西一样,numeric_limits存在于名字空间std中;numeric_limits本身在头文件<limits>中),写printminimumvalue就可以象下面这样容易:
template
void printminimumvalue()
{
 cout << std::numeric_limits::min();
}

采用基于numeric_limits的方法来表示“类型相关常量”看起来开销很大,其实不然。因为原代码的冗长的语句不会反映到生成的目标代码中。实际上,对numeric_limits的调用根本就不产生任何指令。想知道怎么回事,看看下面,这是numeric_limits<int>::min的一个很简单的实现:
#include
namespace std {
 inline int numeric_limits<int>::min() throw ()
 { return int_min; }
}

因为此函数声明为inline,对它的调用会被函数体代替(见条款33)。它只是个int_min,也就是说,它本身仅仅是个简单的“实现时定义的常量”的#define。所以即使本条款开头的那个max函数看起来好象对每个缺省参数进行了函数调用,其实只不过是用了另一种聪明的方法来表示一个类型相关常量而已(本例中常量值为int_min)。象这样一些高效巧妙的应用在c++标准库里俯拾皆是,这可以参考条款49。
回到max函数上来:最关键的一点是,不管函数的调用者提供几个参数,max计算时采用的是相同(效率很低)的算法。在函数内部任何地方都不用在意哪些参数是“真”的,哪些是缺省值;而且,所选用的缺省值不可能影响到所采用的算法计算的正确性。这就是使用缺省参数值的方案可行的原因。
对很多函数来说,会找不到合适的缺省值。例如,假设想写一个函数来计算最多可达5个int的平均值。这里就不能用缺省参数,因为函数的结果取决于传入的参数的个数:如果传入3个值,就要将总数除以3;如果传入5个值,就要将总数除以5。另外,假如用户没有提供某个参数时,没有一个“神奇的数字”可以作为缺省值,因为所有可能的int都可以是有效参数。这种情况下就别无选择:必须重载函数:
double avg(int a);
double avg(int a, int b);
double avg(int a, int b, int c);
double avg(int a, int b, int c, int d);
double avg(int a, int b, int c, int d, int e);

另一种必须使用重载函数的情况是:想完成一项特殊的任务,但算法取决于给定的输入值。这种情况对于构造函数很常见:“缺省”构造函数是凭空(没有输入)构造一个对象,而拷贝构造函数是根据一个已存在的对象构造一个对象:
// 一个表示自然数的类
class natural {
public:
 natural(int initvalue);
 natural(const natural& rhs);
 
private:
 unsigned int value;
 
 void init(int initvalue);
 void error(const string& msg);
};

inline
void natural::init(int initvalue) { value = initvalue; }
natural::natural(int initvalue)
{
 if (initvalue > 0) init(initvalue);
 else error("illegal initial value");
}

inline natural::natural(const natural& x)
{ init(x.value); }

输入为int的构造函数必须执行错误检查,而拷贝构造函数不需要,所以需要两个不同的函数来实现,这就是重载。还请注意,两个函数都必须对新对象赋一个初值。这会导致在两个构造函数里出现重复代码,所以要写一个“包含有两个构造函数公共代码”的私有成员函数init来解决这个问题。这个方法——在重载函数中调用一个“为重载函数完成某些功能”的公共的底层函数——很值得牢记,因为它经常有用(见条款12)。
条款25: 避免对指针和数字类型重载

快速抢答:什么是“零”?

更明确地说,下面的代码会发生什么?

void f(int x);
void f(string *ps);

f(0);   // 调用f(int)还是f(string*)?

答案是,0是一个int——准确地说,一个字面上的整数常量——所以,“总是”f(int)被调用。这就是问题所在:因为不是所有的人总是希望它这样执行。这是c++世界中特有的一种情况:当人们认为某个调用应该具有多义性时,编译器却不这么干。

如果能想办法用符号名(比如,null表示null指针)来解决这类问题就好了,但实现起来比想象的要难得多。

void * const null = 0; // 可能的null定义

f(0);   // 还是调用f(int)
f(static_cast(null)); // 调用f(string*)
f(static_cast(0)); // 调用f(string*)

不过细想一下,用null来表示一个void*常量的方法还是比最初要好一点,因为如果能保证只是用null来表示null指针的话,是可以避免歧义的:

f(0);   // 调用f(int)
f(null);  // 错误! — 类型不匹配
f(static_cast(null)); // 正确, 调用f(string*)

至少现在已经把一个运行时的错误(对0调用了“错误的”f函数)转移成了一个编译时的错误(传递一个void*给string*参数)。情况稍微有点改善(见条款46),但需要进行类型转换还是令人讨厌。

如果想可耻地退回去求助于欲处理,你会发现它也解决不了问题,因为最明显的办法不外乎:

#define null 0

#define null ((void*) 0)

第一种办法只不过是字面上的0,本质上还是一个整数常量(如果你记得的话,还是最初的问题);第二种方法则又把你拉回到“传void*指针给某种类型的指针”的麻烦中。

如果对类型转换的规则有研究,你就会知道,c++会认为“从long int 0到null指针的转换”和“从long int到int的转换”一样,没什么不妥的。所以可以利用这一点,将多义性引入到上面那个你可能认为有“int/指针”问题的地方:

#define null 0l  // null现在是一个long int

void f(int x);
void f(string *p);

f(null);  // 错误!——歧义

然而,当想重载long int和指针时,它又不起作用了:

#define null 0l

void f(long int x); // 这个f现在的参数为long
void f(string *p);

f(null);  // 正确, 调用f(long int)

实际编程中,这比把null定义为int可能要安全,但它无非只是在转移问题,而不是消除问题。

这个问题可以消除,但需要使用c++语言最新增加的一个特性:成员函数模板(往往简称为成员模板)。顾名思义,成员函数模板是在类的内部为类生成成员函数的模板。拿上面关于null的讨论来说,我们需要一个“对每一个t类型,运作起来都象static_cast<t*>(0)表达式”的对象。即,使null成为一个“包含一个隐式类型转换运算符”的类的对象,这个类型转换运算符可以适用于每种可能的指针类型。这就需要很多转换运算符,但它们可以求助于c++从成员模板生成:

// 一个可以产生null指针对象的类的第一步设计
class nullclass {
public:
class nullclass {
public:
  template    // 为所有类型的t
    operator t*() const { return 0; } // 产生operator t*;
};     // 每个函数返回一个
     // null指针
     //

const nullclass null;   // null是类型nullclass
     // 的一个对象

void f(int x);    // 和以前一样

void f(string *p);   // 同上

f(null);    // 将null转换为string*,
     // 然后调用f(string*)


这是一个很好的初步设计,但还可以从几方面进行改进。第一,我们实际上只需要一个nullclass对象,所以给这个类一个名字没必要;我们只需要定义一个匿名类并使null成为这种类型。第二,既然我们是想让null可以转换为任何类型的指针,那就也要能够处理成员指针。这就需要定义第二个成员模板,它的作用是为所有的类c和所有的类型t,将0转换为类型t c::*(指向类 c里类型为t的成员)。(如果你不懂成员指针,或者你从没听说过,或很少用,那也不要紧。成员指针可以称得上是稀有动物,是很少见,也许很多人从来没用
条款26: 当心潜在的二义性

每个人都有思想。有些人相信自由经济学,有些人相信来生。有些人甚至相信COBOL是一种真正的程序设计语言。C++也有一种思想:它认为潜在的二义性不是一种错误。

这是潜在二义性的一个例子:

class
B;  // 对类B提前声明
  //
class A {
public:
 A(const B&); // 可以从B构造而来的类A
};

class B {
public:
 operator A() const; // 可以从A转换而来的类B
};

这些类的声明没一点错——他们可以在相同的程序中共存而没一点问题。但是,看看下面,当把这两个类结合起来使用,在一个输入参数为A的函数里实际传进了一个B的对象,这时将会发生什么呢?

void f(const A&);
B b;
f(b);   // 错误!——二义

一看到对f的调用,编译器就知道它必须产生一个类型A的对象,即使它手上拿着的是一个类型B的对象。有两种都很好的方法来实现(见条款M5)。一种方法是调用类A的构造函数,它以b为参数构造一个新的A的对象。另一种方法是调用类B里自定义的转换运算符,它将b转换成一个A的对象。因为这两个途径都一样可行,编译器拒绝从他们中选择一个。

当然,在没碰上二义的情况下,程序可以使用。这正是潜在的二义所具有的潜伏的危害性。它可以长时期地潜伏在程序里,不被发觉也不活动;一旦某一天某位不知情的程序员真的做了什么具有二义性的操作,混乱就会爆发。这导致有这样一种令人担心的可能:你发布了一个函数库,它可以在二义的情况下被调用,而你却不知道自己正在这么做。

另一种类似的二义的形式源于C++语言的标准转换——甚至没有涉及到类:

void f(int);
void f(char);

double d = 6.02;
f(d);   // 错误!——二义

d是该转换成int还是char呢?两种转换都可行,所以编译器干脆不去做结论。幸运的是,可以通过显式类型转换来解决这个问题:

f(static_cast(d)); // 正确, 调用f(int)
f(static_cast(d)); // 正确, 调用f(char)

多继承(见条款43)充满了潜在二义性的可能。最常发生的一种情况是当一个派生类从多个基类继承了相同的成员名时:

class Base1 {
public:
 int doIt();
};

class Base2
{
public:
 void doIt();
};

class Derived: public Base1 // Derived没有声明
  public Base2 { // 一个叫做doIt的函数
...
};

Derived d;
d.doIt();  // 错误!——二义

当类Derived继承两个具有相同名字的函数时,C++没有认为它有错,此时二义只是潜在的。然而,对doIt的调用迫使编译器面对这个现实,除非显式地通过指明函数所需要的基类来消除二义,函数调用就会出错:

d.Base1::doIt(); // 正确, 调用Base1::doIt
d.Base2::doIt(); // 正确, 调用Base2::doIt
这不会令很多人感到麻烦,但当看到上面的代码没有用到访问权限时,一些本来很安分的人会动起心眼想做些不安分的事:

class Base1 { ... }; // 同上
class Base2 {
private:
void doIt(); // 此函数现在为private
};

class Derived: public Base1, public Base2
{ ... };  // 同上

Derived d;
int i = d.doIt(); // 错误! — 还是二义!

对doIt的调用还是具有二义性,即使只有Base1中的函数可以被访问。另外,只有Base1::doIt返回的值可以用于初始化一个int这一事实也与之无关——调用还是具有二义性。如果想成功地调用,就必须指明想要的是哪个类的doIt。

C++中有一些最初看起来会觉得很不直观的规定,现在就是这种情况。具体来说,为什么消除“对类成员的引用所产生的二义”时不考虑访问权限呢?有一个非常好的理由,它可以归结为:改变一个类成员的访问权限不应该改变程序的含义。

比如前面那个例子,假设它考虑了访问权限。于是表达式d.doIt()决定调用Base1::doIt,因为Base2的版本不能访问。现在假设Base1的Doit版本由public改为protected,Base2的版本则由private改为public。

转瞬之间,同样的表达式d.doIt()将导致另一个完全不同的函数调用,即使调用代码和被调用函数本身都没有被修改!这很不直观,编译器甚至无法产生一个警告。可见,不是象你当初所想的那样,对多继承的成员的引用要显式地消除二义性是有道理的。

既然写程序和函数库时有这么多不同的情况会产生潜在的二义性,那么,一个好的软件开发者该怎么做呢?最根本的是,一定要时时小心它。想找出所有潜在的二义性的根源几乎是不可能的,特别是当程序员将不同的独立开发的库结合起来使用时(见条款28),但在了解了导致经常产生潜在二义性的那些情况后,你就可以在软件设计和开发中将它出现的可能性降到最低。
条款27: 如果不想使用隐式生成的函数就要显式地禁止它

假设想写一个类模板Array,它所生成的类除了可以进行上下限检查外,其它行为和C++标准数组一样。设计中面临的一个问题是怎么禁止掉Array对象之间的赋值操作,因为对标准C++数组来说赋值是不合法的:

double values1[10];
double values2[10];

values1 = values2;                 // 错误!

对很多函数来说,这不是个问题。如果你不想使用某个函数,只用简单地不把它放进类中。然而,赋值运算符属于那种与众不同的成员函数,当你没有去写这个函数时,C++会帮你写一个(见条款45)。那么,该怎么办呢?

方法是声明这个函数(operator=),并使之为private。显式地声明一个成员函数,就防止了编译器去自动生成它的版本;使函数为private,就防止了别人去调用它。

但是,这个方法还不是很安全,成员函数和友元函数还是可以调用私有函数,除非——如果你够聪明的话——不去定义(实现)这个函数。这样,当无意间调用了这个函数时,程序在链接时就会报错。

对于Array来说,模板的定义可以象这样开始:

template<class T>
class Array {
private:
  // 不要定义这个函数!
  Array& operator=(const Array& rhs);

  ...

};

现在,当用户试图对Array对象执行赋值操作时,编译器会不答应;当你自己无意间在成员或友元函数中调用它时,链接器会嗷嗷大叫。

不要因为这个例子就认为本条款只适用于赋值运算符。不是这样的。它适用于条款45所介绍的每一个编译器自动生成的函数。实际应用中,你会发现赋值和拷贝构造函数具有行为上的相似性(见条款11和16),这意味着几乎任何时候当你想禁止它们其中的一个时,就也要禁止另外一个。
条款28: 划分全局名字空间

全局空间最大的问题在于它本身仅有一个。在大的软件项目中,经常会有不少人把他们定义的名字都放在这个单一的空间中,从而不可避免地导致名字冲突。例如,假设library1.h定义了一些常量,其中包括:

const double lib_version = 1.204;

类似的,library2.h也定义了:

const int lib_version = 3;

很显然,如果某个程序想同时包含library1.h和library2.h就会有问题。对于这类问题,你除了嘴里骂几句,或给作者发报复性邮件,或自己编辑头文件来消除名字冲突外,也没其它什么办法。

但是,作为程序员,你可以尽力使自己写的程序库不给别人带来这些问题。例如,可以预先想一些不大可能造成冲突的某种前缀,加在每个全局符号前。当然得承认,这样组合起来的标识符看起来不是那么令人舒服。

另一个比较好的方法是使用c++ namespace。namespace本质上和使用前缀的方法一样,只不过避免了别人总是看到前缀而已。所以,不要这么做:

const double sdmbook_version = 2.0;      // 在这个程序库中,
                                         // 每个符号以"sdm"开头
class sdmhandle { ... };                

sdmhandle& sdmgethandle();             // 为什么函数要这样声明?
                                       // 参见条款47

而要这么做:

namespace sdm {
  const double book_version = 2.0;
  class handle { ... };
  handle& gethandle();
}

用户于是可以通过三种方法来访问这一名字空间里的符号:将名字空间中的所有符号全部引入到某一用户空间;将部分符号引入到某一用户空间;或通过修饰符显式地一次性使用某个符号:

void f1()
{
  using namespace sdm;           // 使得sdm中的所有符号不用加
                                 // 修饰符就可以使用

  cout << book_version;          // 解释为sdm::book_version
  ...

  handle h = gethandle();        // handle解释为sdm::handle,
                                 // gethandle解释为sdm::gethandle
  ...                           

}

void f2()
{
  using sdm::book_version;        // 使得仅book_version不用加
                                 // 修饰符就可以使用

  cout << book_version;           // 解释为
                                  // sdm::book_version
  ...

  handle h = gethandle();         // 错误! handle和gethandle
                                  // 都没有引入到本空间
  ...                            

}

void f3()
{
  cout << sdm::book_version;      // 使得book_version
                                  // 在本语句有效
  ...                            

  double d = book_version;        // 错误! book_version
                                  // 不在本空间

  handle h = gethandle();         // 错误! handle和gethandle
                                  // 都没有引入到本空间
  ...                           

}

(有些名字空间没有名字。这种没命名的名字空间一般用于限制名字空间内部元素的可见性。详见条款m31。)

名字空间带来的最大的好处之一在于:潜在的二义不会造成错误(参见条款26)。所以,从多个不同的名字空间引入同一个符号名不会造成冲突(假如确实真的从不使用这个符号的话)。例如,除了名字空间sdm外,假如还要用到下面这个名字空间:

namespace acmewindowsystem {

  ...

  typedef int handle;

  ...

}

只要不引用符号handle,使用sdm和acmewindowsystem时就不会有冲突。假如真的要引用,可以明确地指明是哪个名字空间的handle:

void f()
{
  using namespace sdm;                 // 引入sdm里的所有符号
  using namespace acmewindowsystem;    // 引入acme里的所有符号

  ...                                  // 自由地引用sdm
                                       // 和acme里除handle之外
                                       // 的其它符号

  handle h;                            // 错误! 哪个handle?

  sdm::handle h1;                      // 正确, 没有二义

  acmewindowsystem::handle h2;         // 也没有二义

  ...

}

假如用常规的基于头文件的方法来做,只是简单地包含sdm.h和acme.h,这样的话,由于handle有多个定义,编译将不能通过。

名字空间的概念加入到c++标准的时间相对较晚,所以有些人会认为它不太重要,可有可无。但这种想法是错误的,因为c++标准库(参见条款49)里几乎所有的东西都存在于名字空间std之中。这可能令你不以为然,但它却以一种直接的方式影响到你:这就是为什么c++提供了那些看起来很有趣的、没有扩展名的头文件,如<iostream>, <string>等。详细介绍参见条款49。

由于名字空间的概念引入的时间相对较晚,有些编译器可能不支持。就算是这样,那也没理由污染全局名字空间,因为可以用struct来近似实现namespace。可以这样做:先创建一个结构用以保存全局符号名,然后将这些全局符号名作为静态成员放入结构中:

// 用于模拟名字空间的一个结构的定义
struct sdm {
  static const double book_version;
  class handle { ... };
  static handle& gethandle();
};

const double sdm::book_version = 2.0;      // 静态成员的定义

现在,如果有人想访问这些全局符号名,只用简单地在它们前面加上结构名作为前缀:

void f()
{
  cout << sdm::book_version;

  ...

  sdm::handle h = sdm::gethandle();

  ...
}

但是,如果全局范围内实际上没有名字冲突,用户就会觉得加修饰符麻烦而多余。幸运的是,还是有办法来让用户选择使用它们或忽略它们。

对于类型名,可以用类型定义(typedef)来显式地去掉空间引用。例如,假设结构s(模拟的名字空间)内有个类型名t,可以这样用typedef来使得t成为s::t的同义词:

typedef sdm::handle handle;

对于结构中的每个(静态)对象x,可以提供一个(全局)引用x,并初始化为s::x:

const double& book_version = sdm::book_version;

老实说,如果读了条款47,你就会不喜欢定义一个象book_version这样的非局部静态对象。(你就会用条款47中所介绍的函数来取代这样的对象)

处理函数的方法和处理对象一样,但要注意,即使定义函数的引用是合法的,但代码的维护者会更喜欢你使用函数指针:

sdm::handle& (* const gethandle)() =      // gethandle是指向sdm::gethandle
  sdm::gethandle;                         // 的const 指针 (见条款21)

注意gethandle是一个常指针。因为你当然不想让你的用户将它指向别的什么东西,而不是sdm::gethandle,对不对?

(如果真想知道怎么定义一个函数的引用,看看下面:

sdm::handle& (&gethandle)() =      // gethandle是指向
  sdm::gethandle;                  // sdm::gethandle的引用

我个人认为这样的做法也很好,但你可能以前从没见到过。除了初始化的方式外,函数的引用和函数的常指针在行为上完全相同,只是函数指针更易于理解。)

有了上面的类型定义和引用,那些不会遭遇全局名字冲突的用户就会使用没有修饰符的类型和对象名;相反,那些有全局名字冲突的用户就会忽略类型和引用的定义,代之以带修饰符的符号名。还要注意的是,不是所有用户都想使用这种简写名,所以要把类型定义和引用放在一个单独的头文件中,不要把它和(模拟namespace的)结构的定义混在一起。

struct是namespace的很好的近似,但实际上还是相差很远。它在很多方面很欠缺,其中很明显的一点是对运算符的处理。如果运算符被定义为结构的静态成员,它就只能通过函数调用来使用,而不能象常规的运算符所设计的那样,可以通过自然的中缀语法来使用:

// 定义一个模拟名字空间的结构,结构内部包含widgets的类型
// 和函数。widgets对象支持operator+进行加法运算
struct widgets {
  class widget { ... };


  // 参见条款21:为什么返回const
  static const widget operator+(const widget& lhs,
                                const widget& rhs);

  ...

};

// 为上面所述的widge和operator+
// 建立全局(无修饰符的)名称

typedef widgets::widget widget;


const widget (* const operator+)(const widget&,        // 错误!
                                 const widget&);       // operator+不能是指针名
 

widget w1, w2, sum;

sum = w1 + w2;                           // 错误! 本空间没有声明
                                         // 参数为widgets 的operator+

sum = widgets::operator+(w1, w2);        // 合法, 但不是
                                         // "自然"的语法

正因为这些限制,所以一旦编译器支持,就要尽早使用真正的名字空间。

 

相关文章推荐

(Effective C++)第四章 设计与声明(Design and declaration)

6.1 条款18:让接口容易被正确使用,不易被误用(Make interfaces easy to use correctly and hard to use incorrectly) 条款13表明...
  • wzhwho
  • wzhwho
  • 2011年10月15日 20:03
  • 455

第四章 设计与声明

条款18:让接口容易被正确使用,不易被误用 (1)“促进正确使用”的办法包括建立接口的一致性,以及与内置类型的行为兼容。 (2)“阻止误用”的办法包括建立型类型,限制类型上的操作,束缚对象的值,以...

Effective C++第四章-设计与声明-1

多态类型多态类型在作为接口时有数据保护和隐藏的效果。多态分为两种:通用多态和特定多态 通用多态:对工作的类型不加限制,允许对不同类型的值执行相同的代码又分为参数多态(parametric)和包含多态(...
  • mlyjqx
  • mlyjqx
  • 2017年07月03日 16:30
  • 74

《Effective C++》第四章:设计与声明

条款20:宁以pass-by-reference-to-const替换pass-by-value 这个条款其实也可以用pass-by-pointer-to-const来替代。因为如果传值,对于用户自定...

Effective c++(笔记) 之 类与函数的设计声明中常遇到的问题

Effective c++ 类的设计应该考虑的一些问题

《C程序设计语言》笔记----第四章 函数与程序结构

第4章函数与程序结构Ø         如果函数定义中省略了返回值类型,则默认为int类型。 Ø         如果某个函数从一个地方返回时有返回值,而从另一个地方返回时没有返回值,该函数并不非...
  • tianfu1
  • tianfu1
  • 2011年04月25日 11:00
  • 405

《C程序设计语言》第四章 函数和程序结构

4.1 函数的基本知识 如果函数定义中省略了返回值类型,则默认为int类型。 练习4-1     编写函数strindex(s, t),它返回字符串t在s中最右边出现的位置。 如...
  • dc_726
  • dc_726
  • 2011年12月15日 21:09
  • 3250

第四章:方法的声明及使用

方法的声明及使用 一. 方法的定义 方法的定义在Java中可以使用多种格式,方法暂时使用如下格式进行定义。 【方法的定义格式】public static 返回值 方法名(类型 参数1,参数2,...

关于模板类中友元函数的声明与定义

#include using namespace std; template class Widget { friend void AccessWidget(const Widget& w); ...

为多态基类声明virtual析构函数吧

在《C++ primer》,以及在Meyers的《Effective C++》《More Effective C++》中都提到过virtual析构函数,在这里做一下总结。 class Bas...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:第四章 类和函数:设计与声明
举报原因:
原因补充:

(最多只允许输入30个字)