第五章 类和函数: 实现

来源:互联网 发布:雷克萨斯 奥迪 知乎 编辑:程序博客网 时间:2024/06/05 18:28

第五章 类和函数: 实现

c++是一种高度类型化的语言,所以,给出合适的类和模板的定义以及合适的函数声明是整个设计工作中最大的一部分。按理说,只要这部分做好了,类、模板以及函数的实现就不容易出问题。但是,往往人们还是会犯错。

犯错的原因有的是不小心违反了抽象的原则:让实现细节可以提取类和函数内部的数据。有的错误在于不清楚对象生命周期的长短。还有的错误起源于不合理的前期优化工作,特别是滥用inline关键字。最后一种情况是,有些实现策略会导致源文件间的相互联结问题,它可能在小规模范围内很合适,但在重建大系统时会带来难以接受的成本。

所有这些问题,以及与之类似的问题,都可以避免,只要你清楚该注意哪些方面。以下的条款就指明了应该特别注意的几种情况。

 条款29: 避免返回内部数据的句柄

请看面向对象世界里发生的一幕:

对象a:亲爱的,永远别变心!
对象b:别担心,亲爱的,我是const。

然而,和现实生活中一样,a会怀疑,"能相信b吗?" 同样地,和现实生活中一样,答案取决于b的本性:其成员函数的组成结构。

假设b是一个const string对象:

class string {
public:
  string(const char *value);        // 具体实现参见条款11
  ~string();                        // 构造函数的注解参见条款m5

  operator char *() const;          // 转换string -> char*;
                                    // 参见条款m5
  ...

private:
  char *data;
};

const string b("hello world");      // b是一个const对象

既然b为const,最好的情况当然就是无论现在还是以后,b的值总是"hello world"。这就寄希望于别的程序员能以合理的方式使用b了。特别是,千万别有什么人象下面这样残忍地将b强制转换掉const(参见条款21):

string& alsob =              // 使得alsob成为b的另一个名字,
  const_cast<string&>(b);    // 但不具有const属性

然而,即使没有人做这种残忍的事,就能保证b永远不会改变吗?看看下面的情形:

char *str = b;               // 调用b.operator char*()

strcpy(str, "hi mom");       // 修改str指向的值

b的值现在还是"hello world"吗?或者,它是否已经变成了对母亲的问候语?答案完全取决于string::operator char*的实现。

下面是一个有欠考虑的实现,它导致了错误的结果。但是,它工作起来确实很高效,所以很多程序员才掉进它的错误陷阱之中:

// 一个执行很快但不正确的实现
inline string::operator char*() const
{ return data; }

这个函数的缺陷在于它返回了一个"句柄"(在本例中,是个指针),而这个句柄所指向的信息本来是应该隐藏在被调用函数所在的string对象的内部。这样,这个句柄就给了调用者自由访问data所指的私有数据的机会。换句话说,有了下面的语句:

char *str = b;

情况就会变成这样:

str------------------------->"hello world/0"
              /   
             /
b.data

显然,任何对str所指向的内存的修改都使得b的有效值发生变化。所以,即使b声明为const,而且即使只是调用了b的某个const成员函数,b也会在程序运行过程中得到不同的值。特别是,如果str修改了它所指的值,b也会改变。

string::operator char*本身写的没有一点错,麻烦的是它可以用于const对象。如果这个函数不声明为const,就不会有问题,因为这样它就不能用于象b这样的const对象了。

但是,将一个string对象转换成它相应的char*形式是很合理的一件事,无论这个对象是否为const。所以,还是应该使函数保持为const。这样的话,就得重写这个函数,使得它不返回指向对象内部数据的句柄:

// 一个执行慢但很安全的实现
inline string::operator char*() const
{
  char *copy = new char[strlen(data) + 1];
  strcpy(copy, data);

  return copy;

}

这个实现很安全,因为它返回的指针所指向的数据只是string对象所指向数据的拷贝;通过函数返回的指针无法修改string对象的值。当然,安全是要有代价的:这个版本的string::operator char* 运行起来比前面那个简单版本要慢;此外,函数的调用者还要记得delete掉返回的指针。

如果不能忍受这个版本的速度,或者担心内存泄露,可以来一点小小的改动:使函数返回一个指向const char的指针:

class string {
public:
  operator const char *() const;

  ...

};

inline string::operator const char*() const
{ return data; }

这个函数既快又安全。虽然它和最初给出的那个函数不一样,但它可以满足大多数程序的需要。这个做法还和c++标准组织处理string/char*难题的方案一致:标准string类型中包含一个成员函数c_str,它的返回值是string的const char*版本。关于标准string类型的更多信息参见条款49。

指针并不是返回内部数据句柄的唯一途径。引用也很容易被滥用。下面是一种常见的用法,还是拿string类做例子:

class string {
public:

  ...

  char& operator[](int index) const
  { return data[index]; }

private:
  char *data;
};

string s = "i'm not constant";

s[0] = 'x';               // 正确, s不是const

const string cs = "i'm constant";

cs[0] = 'x';              // 修改了const string,
                          // 但编译器不会通知

注意string::operator[]是通过引用返回结果的。这意味着函数的调用者得到的是内部数据data[index]的另一个名字,而这个名字可以用来修改const对象的内部数据。这个问题和前面看到的相同,只不过这次的罪魁祸首是引用,而不是指针。

这类问题的通用解决方案和前面关于指针的讨论一样:或者使函数为非const,或者重写函数,使之不返回句柄。如果想让string::operator[]既适用于const对象又适用于非const 对象,可以参见条款21。

并不是只有const成员函数需要担心返回句柄的问题,即使是非const成员函数也得承认:句柄的合法性失效的时间和它所对应的对象是完全相同的。这个时间可能比用户期望的要早很多,特别是当涉及的对象是由编译器产生的临时对象时。

例如,看看这个函数,它返回了一个string对象:

string somefamousauthor()           // 随机选择一个作家名
{                                   // 并返回之

  switch (rand() % 3) {             // rand()在<stdlib.h>中
                                    // (还有<cstdlib>。参见条款49)
  case 0:
    return "margaret mitchell";     // 此作家曾写了 "飘",
                                    // 一部绝对经典的作品
  case 1:
    return "stephen king";          // 他的小说使得许多人
                                    // 彻夜不眠
  case 2:
    return "scott meyers";          // 嗯...滥竽充数的一个
  }                               
                                  

  return "";                        // 程序不会执行到这儿,
                                    // 但对于一个有返回值的函数来说,
                                    // 任何执行途径上都要有返回值
}

希望你的注意力不要集中在随机数是怎样从rand产生的问题上,也不要嘲笑我把自己和这些作家联系在一起。真正要注意的是,somefamousauthor的返回值是一个string对象,一个临时string对象(参见条款m19)。这样的对象是暂时性的,它们的生命周期通常在函数调用表达式结束时终止。例如上面的情况中,包含somefamousauthor函数调用的表达式结束时,返回值对象的生命周期也就随之结束。

具体看看下面这个使用somefamousauthor的例子,假设string声明了一个上面的operator const char*成员函数:

const char *pc = somefamousauthor();

cout << pc;                  

不论你是否相信,谁也不能预测这段代码将会做些什么,至少不能确定它会做些什么。因为当你想打印pc所指的字符串时,字符串的值是不确定的。造成这一结果的原因在于pc初始化时发生了下面这些事件:
1. 产生一个临时string对象用以保存somefamousauthor的返回值。
2. 通过string的operator const char*成员函数将临时string对象转换为const char*指针,并用这个指针初始化pc。
3. 临时string对象被销毁,其析构函数被调用。析构函数中,data指针被删除(代码详见条款11)。然而,data和pc所指的是同一块内存,所以现在pc指向的是被删除的内存--------其内容是不可确定的。

因为pc是被一个指向临时对象的句柄初始化的,而临时对象在被创建后又立即被销毁,所以在pc被使用前句柄已经是非法的了。也就是说,无论想做什么,当要使用pc时,pc其实已经名存实亡。这就是指向临时对象的句柄所带来的危害。

所以,对于const成员函数来说,返回句柄是不明智的,因为它会破坏数据抽象。对于非const成员函数来说,返回句柄会带来麻烦,特别是涉及到临时对象时。句柄就象指针一样,可以是悬浮(dangle)的。所以一定要象避免悬浮的指针那样,尽量避免悬浮的句柄。

同样不能对本条款绝对化。在一个大的程序中想消灭所有可能的悬浮指针是不现实的,想消灭所有可能的悬浮句柄也是不现实的。但是,只要不是万不得已,就要避免返回句柄,这样,不但程序会受益,用户也会更信赖你。
条款30: 避免这样的成员函数:其返回值是指向成员的非const指针或引用,但成员的访问级比这个函数要低

使一个成员为private或protected的原因是想限制对它的访问,对吗?劳累的编译器要费九牛二虎之力来确保你设置的访问限制不被破坏,对不对?所以,写个函数来让用户随意地访问受限的成员没多大意义,对不对?如果你确实认为有意义,那么请反复阅读本段,直到你不这样认为为止。

实际编程中很容易违反这条规则,下面是个例子:

class address { ... };           // 某人居住在此

class person {
public:
  address& personaddress() { return address; }
  ...

private:
  address address;
  ...
};

成员函数personaddress为调用者提供的是person对象中所包含的address对象,但是,也许是出于效率上的考虑,返回结果采用的是引用,而不是值(见条款22)。遗憾的是,这个成员函数的做法有违当初将person::address声明为private的初衷:

person scott(...);             // 为简化省略了参数

address& addr =                // 假设addr为全局变量
  scott.personaddress();

现在,全局对象addr成为了scott.address的另一个名字,利用它可以随意读写scott.address。实际上,scott.address不再为private,而是public,访问级提升的根源在于成员函数personaddress。当然,本例中给出的address 的访问级是private,如果是protected,情况完全一样。

不仅仅是引用,指针也会产生以上问题。下面的例子和上面相同,只不过这次用的是指针:

class person {
public:
  address * personaddress() { return &address; }
  ...

private:
  address address;
  ...
};

address *addrptr =
  scott.personaddress();        // 问题和上面一样

而且,对于指针来说,要担心的不仅仅是数据成员,还要考虑到成员函数。因为返回一个成员函数的指针也是有可能的:

class person;                   // 提前声明

// ppmf = "pointer to person member function"
// (指向person成员函数的指针)
typedef void (person::*ppmf)();

class person {
public:
  static ppmf verificationfunction()
  { return &person::verifyaddress; }

  ...

private:
  address address;

  void verifyaddress();

};

如果你过去没试过象上面那样将成员函数指针和typedef结合起来的用法,可能会觉得person::verificationfunction的声明有点吓人。别害怕,它的全部含义只不过是:

· verificationfunction是一个没有输入参数的成员函数
· 它的返回值是person类中一个成员函数的指针
· 被指向的函数(即,verificationfunction的返回值)没有输入参数且没有返回值,即void。

至于static关键字,当它用于对成员的声明时,其含义是:整个类只有这个成员的一份拷贝,并且这个成员可以不通过类的具体对象来访问。有关static的完整介绍可以参考c++教程。(如果你的c++教程里没有介绍静态成员,请把书页撕了扔到垃圾回收站吧。注意封面一定不要乱扔以免破坏环境。最后,去借或买本更好的教程吧。)

最后一个例子中,verifyaddress是一个私有成员函数,这意味着它只是类的一个实现细节,只有类的成员才应该知道它(当然,友员也知道)。但是,由于公有成员函数verificationfunction返回了指向verifyaddress的指针,用户就可以做这样的事:

ppmf pmf = scott.verificationfunction();

(scott.*pmf)();                     // 等同于调用
                                    // scott.verifyaddress

这里,pmf成了person::verifyaddress的同义词,只是有个重要的区别:可以没有限制地使用它。

虽然前面说了那么多,有一天你可能为了程序的性能还是不得不写象上面那样的函数--------返回值是某个访问级较低的成员的指针或引用。但同时,你又不想牺牲private和protected为你提供的访问限制。这种情况下,你可以通过返回指向const对象的指针或引用来达到两全其美的效果。详细介绍参见条款21。
条款31: 千万不要返回局部对象的引用,也不要返回函数内部用new初始化的指针的引用

本条款听起来很复杂,其实不然。它只是一个很简单的道理,真的,相信我。

先看第一种情况:返回一个局部对象的引用。它的问题在于,局部对象 ----- 顾名思义 ---- 仅仅是局部的。也就是说,局部对象是在被定义时创建,在离开生命空间时被销毁的。所谓生命空间,是指它们所在的函数体。当函数返回时,程序的控制离开了这个空间,所以函数内部所有的局部对象被自动销毁。因此,如果返回局部对象的引用,那个局部对象其实已经在函数调用者使用它之前被销毁了。

当想提高程序的效率而使函数的结果通过引用而不是值返回时,这个问题就会出现。下面的例子和条款23中的一样,其目的在于详细说明什么时候该返回引用,什么时候不该:

class rational {          // 一个有理数类
public:
  rational(int numerator = 0, int denominator = 1);
  ~rational();

  ...

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

// 注意operator* (不正确地)返回了一个引用
friend const rational& operator*(const rational& lhs,
                                 const rational& rhs);
};

// operator*不正确的实现
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  rational result(lhs.n * rhs.n, lhs.d * rhs.d);
  return result;
}

这里,局部对象result在刚进入operator*函数体时就被创建。但是,所有的局部对象在离开它们所在的空间时都要被自动销毁。具体到这个例子来说,result是在执行return语句后离开它所在的空间的。所以,如果这样写:

rational two = 2;

rational four = two * two;         // 同operator*(two, two)


函数调用时将发生如下事件:

1. 局部对象result被创建。
2. 初始化一个引用,使之成为result的另一个名字;这个引用先放在另一边,留做operator*的返回值。
3. 局部对象result被销毁,它在堆栈所占的空间可被本程序其它部分或其他程序使用。
4. 用步骤2中的引用初始化对象four。

一切都很正常,直到第4步才产生了错误,借用高科技界的话来说,产生了"一个巨大的错误"。因为,第2步被初始化的引用在第3步结束时指向的不再是一个有效的对象,所以对象four的初始化结果完全是不可确定的。

教训很明显:别返回一个局部对象的引用。

"那好,"你可能会说,"问题不就在于要使用的对象离开它所在的空间太早吗?我能解决。不要使用局部对象,可以用new来解决这个问题。"象下面这样:

// operator*的另一个不正确的实现
inline const rational& operator*(const rational& lhs,
                                 const rational& rhs)
{
  // create a new object on the heap
  rational *result =
    new rational(lhs.n * rhs.n, lhs.d * rhs.d);

  // return it
  return *result;
}

这个方法的确避免了上面例子中的问题,但却引发了新的难题。大家都知道,为了在程序中避免内存泄漏,就必须确保对每个用new产生的指针调用delete,但是,这里的问题是,对于这个函数中使用的new,谁来进行对应的delete调用呢?

显然,operator*的调用者应该负责调用delete。真的显然吗?遗憾的是,即使你白纸黑字将它写成规定,也无法解决问题。之所以做出这么悲观的判断,是基于两条理由:

第一,大家都知道,程序员这类人是很马虎的。这不是指你马虎或我马虎,而是指,没有哪个程序员不和某个有这类习性的人打交道。想让这样的程序员记住无论何时调用operator*后必须得到结果的指针然后调用delete,这样的几率有多大呢?也是说,他们必须这样使用operator*:

const rational& four = two * two;      // 得到废弃的指针;
                                       // 将它存在一个引用中
...

delete &four;                          // 得到指针并删除

这样的几率将会小得不能再小。记住,只要有哪怕一个operator*的调用者忘了这条规则,就会造成内存泄漏。

返回废弃的指针还有另外一个更严重的问题,即使是最尽责的程序员也难以避免。因为常常有这种情况,operator*的结果只是临时用于中间值,它的存在只是为了计算一个更大的表达式。例如:

rational one(1), two(2), three(3), four(4);
rational product;

product = one * two * three * four;

product的计算表达式需要三个单独的operator*调用,以相应的函数形式重写这个表达式会看得更清楚:

product = operator*(operator*(operator*(one, two), three), four);

是的,每个operator*调用所返回的对象都要被删除,但在这里无法调用delete,因为没有哪个返回对象被保存下来。

解决这一难题的唯一方案是叫用户这样写代码:

const rational& temp1 = one * two;
const rational& temp2 = temp1 * three;
const rational& temp3 = temp2 * four;

delete &temp1;
delete &temp2;
delete &temp3;

果真如此的话,你所能期待的最好结果是人们将不再理睬你。更现实一点,你将会在指责声中度日,或者可能会被判处10年苦力去写威化饼干机或烤面包机的微代码。

所以要记住你的教训:写一个返回废弃指针的函数无异于坐等内存泄漏的来临。

另外,假如你认为自己想出了什么办法可以避免"返回局部对象的引用"所带来的不确定行为,以及"返回堆(heap)上分配的对象的引用"所带来的内存泄漏,那么,请转到条款23,看看为什么返回局部静态(static)对象的引用也会工作不正常。看了之后,也许会帮助你避免头痛医脚所带来的麻烦。
条款32: 尽可能地推迟变量的定义

是的,我们同意C语言中变量要放在模块头部定义的规定;但在C++中,还是取消这种做法吧,它没必要,不自然,而且昂贵。

还记得吗?如果定义了一个有构造函数和析构函数的类型的变量,当程序运行到变量定义之处时,必然面临构造的开销;当变量离开它的生命空间时,又要承担析构的开销。这意味着定义无用的变量必然伴随着不必要的开销,所以只要可能,就要避免这种情况发生。

正如我所知道的,你的编程方式优雅而不失老练。所以你可能会在想,你决不会定义一个无用的变量,所以本条款的建议不适用于你严谨紧凑的编程风格。但别急,看看下面这个函数:当口令够长时,它返回口令的加密版本;当口令太短时,函数抛出logic_error类型的异常(logic_error类型在C++标准库中定义,参见条款49):

// 此函数太早定义了变量"encrypted"
string encryptPassword(const string& password)
{
  string encrypted;

  if (password.length() < MINIMUM_PASSWORD_LENGTH) {
     throw logic_error("Password is too short");
  }

  进行必要的操作,将口令的加密版本 
  放进encrypted之中;

  return encrypted;
}

对象encrypted在函数中并非完全没用,但如果有异常抛出时,就是无用的。但是,即使encryptPassword抛出异常(见条款M15),程序也要承担encrypted构造和析构的开销。所以,最好将encrypted推迟到确实需要它时才定义:

// 这个函数推迟了encrypted的定义,
// 直到真正需要时才定义
string encryptPassword(const string& password)
{
  if (password.length() < MINIMUM_PASSWORD_LENGTH) {
    throw logic_error("Password is too short");
  }

  string encrypted;

  进行必要的操作,将口令的加密版本 
  放进encrypted之中;

  return encrypted;
}

这段代码还不是那么严谨,因为encrypted定义时没有带任何初始化参数。这将导致它的缺省构造函数被调用。大多数情况下,对一个对象首先做的一件事是给它一个什么值,这通常用赋值来实现。条款12说明了为什么"缺省构造一个对象然后对它赋值"比"用真正想要的值来初始化这个对象"效率要低得多。这一论断在此一样适用。例如,假设encryptPassword中最难处理的部分在这个函数中进行:

void encrypt(string& s);      // s在此加密

于是encryptPassword可以象这样实现(当然,它不是最好的实现方式):

// 这个函数推迟了encrypted的定义,
// 直到需要时才定义,但还是很低效
string encryptPassword(const string& password)
{
  ...                      // 同上,检查长度

  string encrypted;        // 缺省构造encrypted
  encrypted = password;    // 给encrypted赋值
  encrypt(encrypted);
  return encrypted;
}

更好的方法是用password来初始化encrypted,从而绕过了对缺省构造函数不必要的调用:

// 定义和初始化encrypted的最好方式
string encryptPassword(const string& password)
{
  ...                             // 检查长度

  string encrypted(password);     // 通过拷贝构造函数定义并初始化

  encrypt(encrypted);
  return encrypted;
}

这段代码阐述了本条款的标题中"尽可能"这三个字的真正含义。你不仅要将变量的定义推迟到必须使用它的时候,还要尽量推迟到可以为它提供一个初始化参数为止。这样做,不仅可以避免对不必要的对象进行构造和析构,还可以避免无意义的对缺省构造函数的调用。而且,在对变量进行初始化的场合下,变量本身的用途不言自明,所以在这里定义变量有益于表明变量的含义。还记得在C语言中的做法吗?每个变量的定义旁最好要有一条短注释,以标明这个变量将来做什么用。而现在,一个合适的名字(见条款28),再结合有意义的初始化参数,你就可以实现每个程序员的梦想:通过可靠的变量本身来消除对它不必要的注释。

推迟变量定义可以提高程序的效率,增强程序的条理性,还可以减少对变量含义的注释。看来是该和那些开放式模块的变量定义吻别了。
条款33: 明智地使用内联

内联函数------多妙的主意啊!它们看起来象函数,运作起来象函数,比宏(macro)要好得多(参见条款1),使用时还不需要承担函数调用的开销。你还能对它们要求更多吗?

然而,你从它们得到的确实比你想象的要多,因为避免函数调用的开销仅仅是问题的一个方面。为了处理那些没有函数调用的代码,编译器优化程序本身进行了专门的设计。所以当内联一个函数时,编译器可以对函数体执行特定环境下的优化工作。这样的优化对"正常"的函数调用是不可能的。

我们还是不要扯得太远。程序世界和现实生活一样,从来就没有免费的午餐,内联函数也不例外。内联函数的基本思想在于将每个函数调用以它的代码体来替换。用不着统计专家出面就可以看出,这种做法很可能会增加整个目标代码的体积。在一台内存有限的计算机里,过分地使用内联所产生的程序会因为有太大的体积而导致可用空间不够。即使可以使用虚拟内存,内联造成的代码膨胀也可能会导致不合理的页面调度行为(系统颠簸),这将使你的程序运行慢得象在爬。(当然,它也为磁盘控制器提供了一个极好的锻炼方式:))过多的内联还会降低指令高速缓存的命中率,从而使取指令的速度降低,因为从主存取指令当然比从缓存要慢。

另一方面,如果内联函数体非常短,编译器为这个函数体生成的代码就会真的比为函数调用生成的代码要小许多。如果是这种情况,内联这个函数将会确实带来更小的目标代码和更高的缓存命中率!

要牢记在心的一条是,inline指令就象register,它只是对编译器的一种提示,而不是命令。也就是说,只要编译器愿意,它就可以随意地忽略掉你的指令,事实上编译器常常会这么做。例如,大多数编译器拒绝内联"复杂"的函数(例如,包含循环和递归的函数);还有,即使是最简单的虚函数调用,编译器的内联处理程序对它也爱莫能助。(这一点也不奇怪。virtual的意思是"等到运行时再决定调用哪个函数",inline的意思是"在编译期间将调用之处用被调函数来代替",如果编译器甚至还不知道哪个函数将被调用,当然就不能责怪它拒绝生成内联调用了)。以上可以归结为:一个给定的内联函数是否真的被内联取决于所用的编译器的具体实现。幸运的是,大多数编译器都可以设置诊断级,当声明为内联的函数实际上没有被内联时,编译器就会为你发出警告信息(参见条款48)。

假设写了某个函数f并声明为inline,如果出于什么原因,编译器决定不对它内联,那将会发生些什么呢?最明显的一个回答是将f作为一个非内联函数来处理:为f生成代码时就象它是一个普通的"外联"函数一样, 对f的调用也象对普通函数调用那样进行。

理论上来说确实应该这样发生,但理论和现实往往会偏离,现在就属于这种情况。因为,这个方案对解决"被外联的内联"(outlined inline)这一问题确实非常理想,但它加入到C++标准中的时间相对较晚。较早的C++规范(比如ARM------参见条款50)告诉编译器制造商去实现的是另外不同的行为,而且这一旧的行为在现在的编译器中还很普遍,所以必须理解它是怎么一回事。

稍微想一想你就可以记起,内联函数的定义实际上都是放在头文件中。这使得多个要编译的单元(源文件)可以包含同一个头文件,共享头文件内定义的内联函数所带来的益处。下面给出了一个例子,例子中的源文件名以常规的".cpp"结尾,这应该是C++世界最普遍的命名习惯了:


 // 文件example.h
 inline void f() { ... }          // f的定义

 ...

 // 文件source1.cpp
 #include "example.h"             // 包含f的定义

 ...                              // 包含对f的调用

 // 文件source2.cpp
 #include "example.h"             // 也包含f的定义
  
 ...                              // 也调用f
假设现在采用旧的"被外联的内联"规则,而且假设f没有被内联,那么,当source1.cpp被编译时,生成的目标文件中将包含一个称为f的函数,就象f没有被声明为inline一样。同样地,当source2.cpp被编译时,产生的目标文件也将包含一个称为f的函数。当想把两个目标文件链接在一起时,编译器会因为程序中有两个f的定义而报错。

为了防止这一问题,旧规则规定,对于未被内联的内联函数,编译器把它当成被声明为static那样处理,即,使它局限于当前被编译的文件。具体到刚才看到的例子中,遵循旧规则的编译器处理source1.cpp中的f时,就象f在source1.cpp中是静态的一样;处理source2.cpp中的f时,也把它当成在source2.cpp中是静态的一样。这一策略消除了链接时的错误,但带来了开销:每个包含f的定义(以及调用f)的被编译单元都包含自己的f的静态拷贝。如果f自身定义了局部静态变量,那么,每个f的拷贝都有此局部变量的一份拷贝,这必然会让程序员大吃一惊,因为一般来说,函数中的"static"意味着"只有一份拷贝"。

具体实现起来也会令人吃惊。无论新规则还是旧规则,如果内联函数没被内联,每个调用内联函数的地方还是得承担函数调用的开销;如果是旧规则,还得忍受代码体积的增加,因为每个包含(或调用) f的被编译单元都有一份f的代码及其静态变量的拷贝!(更糟糕的是,每个f的拷贝以及每个f的静态变量的拷贝往往处于不同的虚拟内存页面,所以两个对f的不同拷贝进行调用有可能导致多个页面错误。)

还有呢!有时,可怜的随时准备为您效劳的编译器即使很想内联一个函数,却不得不为这个内联函数生成一个函数体。特别是,如果程序中要取一个内联函数的地址,编译器就必须为此生成一个函数体。编译器怎么能产生一个指向不存在的函数的指针呢?


inline void f() {...}            // 同上
void (*pf)() = f;                // pf指向f
int main()
{
  f();                           // 对f的内联调用
  pf();                          // 通过pf对f的非内联调用
  ...
}
这种情况似乎很荒谬:f的调用被内联了,但在旧的规则下,每个取f地址的被编译单元还是各自生成了此函数的静态拷贝。(新规则下,不管涉及的被编译单元有多少,将只生成唯一一个f的外部拷贝)

即使你从来不使用函数指针,这类"没被内联的内联函数"也会找上你的门,因为不只是程序员会使用函数指针,有时编译器也这么做。特别是,编译器有时会生成构造函数和析构函数的外部拷贝,这样就可以通过得到那些函数的指针,方便地构造和析构类的对象数组(参见条款M8)。

实际上,随便一个测试就可以证明构造函数和析构函数常常不适合内联;甚至,情况比测试结果还糟。例如,看下面这个类Derived的构造函数:


class Base {
public:
  ...
private:
  string bm1, bm2;   // 基类成员1和2
};
class Derived: public Base {
public:
  Derived() {}                  // Derived的构造函数是空的,
  ...                           // ------但,真的是空的吗?
private:
  string dm1, dm2, dm3;         // 派生类成员1-3
};
这个构造函数看起来的确象个内联的好材料,因为它没有代码。但外表常常欺骗人!仅仅因为它没有代码并不能说明它真的不含代码。实际上,它含有相当多的代码。

C++就对象创建和销毁时发生的事件有多方面的规定。条款5和M8介绍了当使用new时,动态创建的对象怎样自动地被它们的构造函数初始化,以及当使用delete时析构函数怎样被调用。条款13说明了当创建一个对象时,对象的每个基类以及对象的每个数据成员会被自动地创建;当对象被销毁时,会自动地执行相反的过程(即析构)。这些条款告诉你,C++规定了哪些必须发生,但没规定"怎么"发生。"怎么发生"取决于编译器的实现者,但要弄清楚的是,这些事件不是凭空自己发生的。程序中必然有什么代码使得它们发生,特别是那些由编译器的实现者写的、在编译其间插入到你的程序中的代码,必然也藏身于某个地方------有时,它们就藏身于你的构造函数和析构函数。所以,对于上面那个号称为空的Derived的构造函数,有些编译器会为它产生相当于下面的代码:

// 一个Derived构造函数的可能的实现
 Derived::Derived()
{
  // 如果在堆上创建对象,为其分配堆内存;
  // operator new的介绍参见条款8
  if (本对象在堆上)
    this = ::operator new(sizeof(Derived));
  Base::Base();                  // 初始化Base部分
  dm1.string();           // 构造dm1
  dm2.string();           // 构造dm2
  dm3.string();           // 构造dm3
}
别指望上面这样的代码可以通过编译,因为它在C++中是不合法的。首先,在构造函数内无法知道对象是不是在堆上。(想知道如何可靠地确定一个对象是否在堆上,请参见条款M27)另外,对this赋值是非法的。还有,通过函数调用访问构造函数也是不允许的。然而,编译器工作起来没这些限制,它可以随心所欲。但代码的合法性不是现在要讨论的主题。问题的要点在于,调用operator new(如果需要的话)的代码、构造基类部分的代码、构造数据成员的代码都会神不知鬼不觉地添加到你的构造函数中,从而增加构造函数的体积,使得构造函数不再适合内联。当然,同样的分析也适用于Base的构造函数,如果Base的构造函数被内联,添加到它里面的所有代码也会被添加到Derived的构造函数(Derived的构造函数会调用Base的构造函数)。如果string的构造函数恰巧也被内联,Derived的构造函数将得到其代码的5个拷贝,每个拷贝对应于Derived对象中5个string中的一个(2个继承而来,3个自己声明)。现在你应该明白,内联Derived的构造函数并非可以很简单就决定的!当然,类似的情况也适用于Derived的析构函数,无论如何都要清楚这一点:被Derived的构造函数初始化的所有对象都要被完全销毁。刚被销毁的对象以前可能占用了动态分配的内存,那么这些内存还需要释放。

程序库的设计者必须预先估计到声明内联函数带来的负面影响。因为想对程序库中的内联函数进行二进制代码升级是不可能的。换句话说,如果f是库中的一个内联函数,用户会将f的函数体编译到自己的程序中。如果程序库的设计者后来要修改f,所有使用f的用户程序必须重新编译。这会很令人讨厌(参见条款34)。相反,如果f是非内联函数,对f的修改仅需要用户重新链接,这就比需要重新编译大大减轻了负担;如果包含这个函数的程序库是被动态链接的,程序库的修改对用户来说完全是透明的。

内联函数中的静态对象常常表现出违反直觉的行为。所以,如果函数中包含静态对象,通常要避免将它声明为内联函数。具体介绍参见条款M26。

为了提高程序开发质量,以上诸项一定要牢记在心。但在具体编程时,从纯实际的角度来看,有一个事实比其余的因素都重要:大多数调试器遇上内联函数都会无能为力。

这不是什么新鲜事。你想,怎么在一个不存在的函数里设置断点呢?怎么单步执行到这样一个函数呢?怎么俘获对它的调用呢?除非你是个百年一遇的怪才,或者用了暗渡陈仓之类的伎俩,否则是不可能做到的。让人高兴的是,这一点倒是可以作为决定该不该对函数声明inline的决策依据之一。

一般来说,实际编程时最初的原则是不要内联任何函数,除非函数确实很小很简单,象下面这个age函数:

class Person {
public:
  int age() const { return personAge; }
  ...
private:
  int personAge;
  ...
};
慎重地使用内联,不但给了调试器更多发挥作用的机会,还将内联的作用定位到了正确的位置:它是一个根据需要而使用的优化工具。不要忘了从无数经验得到的这条80-20定律(参见条款M16):一个程序往往花80%的时间来执行程序中20%的代码。这是一条很重要的定律,因为它提醒你,作为程序员的一个很重要的目标,就是找出这20%能够真正提高整个程序性能的代码。你可以选择内联你的函数,或者没必要就不内联,但这些选择只有作用在"正确"的函数上才有意义。

一旦找出了程序中那些重要的函数,以及那些内联后可以确实提高程序性能的函数(这些函数本身依赖于所在系统的体系结构),就要毫不犹豫地声明为inline。同时,要注意代码膨胀带来的问题,并监视编译器的警告信息(参见条款48),看看是否有内联函数没有被编译器内联。

若能做到明智地使用,内联函数将是每个C++程序员百宝箱中的一件无价之宝。当然,正如前面的讨论所揭示的,它们并不象所想象的那样简单和直接。
条款34: 将文件间的编译依赖性降至最低

假设某一天你打开自己的C++程序代码,然后对某个类的实现做了小小的改动。提醒你,改动的不是接口,而是类的实现,也就是说,只是细节部分。然后你准备重新生成程序,心想,编译和链接应该只会花几秒种。毕竟,只是改动了一个类嘛!于是你点击了一下"Rebuild",或输入make(或其它类似命令)。然而,等待你的是惊愕,接着是痛苦。因为你发现,整个世界都在被重新编译、重新链接!

当这一切发生时,你难道仅仅只是愤怒吗?

问题发生的原因在于,在将接口从实现分离这方面,C++做得不是很出色。尤其是,C++的类定义中不仅包含接口规范,还有不少实现细节。例如:

class Person {
public:
  Person(const string& name, const Date& birthday,
         const Address& addr, const Country& country);
  virtual ~Person();

  ...                      // 简化起见,省略了拷贝构造
                           // 函数和赋值运算符函数
  string name() const;
  string birthDate() const;
  string address() const;
  string nationality() const;

private:
  string name_;            // 实现细节
  Date birthDate_;         // 实现细节
  Address address_;        // 实现细节
  Country citizenship_;    // 实现细节
};

这很难称得上是一个很高明的设计,虽然它展示了一种很有趣的命名方式:当私有数据和公有函数都想用某个名字来标识时,让前者带一个尾部下划线就可以区别了。这里要注意到的重要一点是,Person的实现用到了一些类,即string, Date,Address和Country;Person要想被编译,就得让编译器能够访问得到这些类的定义。这样的定义一般是通过#include指令来提供的,所以在定义Person类的文件头部,可以看到象下面这样的语句:

#include <string>           // 用于string类型 (参见条款49)
#include "date.h"
#include "address.h"
#include "country.h"

遗憾的是,这样一来,定义Person的文件和这些头文件之间就建立了编译依赖关系。所以如果任一个辅助类(即string, Date,Address和Country)改变了它的实现,或任一个辅助类所依赖的类改变了实现,包含Person类的文件以及任何使用了Person类的文件就必须重新编译。对于Person类的用户来说,这实在是令人讨厌,因为这种情况用户绝对是束手无策。

那么,你一定会奇怪为什么C++一定要将一个类的实现细节放在类的定义中。例如,为什么不能象下面这样定义Person,使得类的实现细节与之分开呢?

class string;         // "概念上" 提前声明string 类型
                      // 详见条款49

class Date;           // 提前声明
class Address;        // 提前声明
class Country;        // 提前声明

class Person {
public:
  Person(const string& name, const Date& birthday,
         const Address& addr, const Country& country);
  virtual ~Person();

  ...                      // 拷贝构造函数, operator=

  string name() const;
  string birthDate() const;
  string address() const;
  string nationality() const;
};

如果这种方法可行的话,那么除非类的接口改变,否则Person 的用户就不需要重新编译。大系统的开发过程中,在开始类的具体实现之前,接口往往基本趋于固定,所以这种接口和实现的分离将大大节省重新编译和链接所花的时间。

可惜的是,现实总是和理想相抵触,看看下面你就会认同这一点:

int main()
{
  int x;                      // 定义一个int

  Person p(...);              // 定义一个Person
                              // (为简化省略参数)
  ...

}

当看到x的定义时,编译器知道必须为它分配一个int大小的内存。这没问题,每个编译器都知道一个int有多大。然而,当看到p的定义时,编译器虽然知道必须为它分配一个Person大小的内存,但怎么知道一个Person对象有多大呢?唯一的途径是借助类的定义,但如果类的定义可以合法地省略实现细节,编译器怎么知道该分配多大的内存呢?

原则上说,这个问题不难解决。有些语言如Smalltalk,Eiffel和Java每天都在处理这个问题。它们的做法是,当定义一个对象时,只分配足够容纳这个对象的一个指针的空间。也就是说,对应于上面的代码,他们就象这样做:

int main()
{
  int x;                     // 定义一个int

  Person *p;                 // 定义一个Person指针
    
  ...
}

你可能以前就碰到过这样的代码,因为它实际上是合法的C++语句。这证明,程序员完全可以自己来做到 "将一个对象的实现隐藏在指针身后"。

下面具体介绍怎么采用这一技术来实现Person接口和实现的分离。首先,在声明Person类的头文件中只放下面的东西:

// 编译器还是要知道这些类型名,
// 因为Person的构造函数要用到它们
class string;      // 对标准string来说这样做不对,
                   // 原因参见条款49
class Date;
class Address;
class Country;

// 类PersonImpl将包含Person对象的实
// 现细节,此处只是类名的提前声明
class PersonImpl;

class Person {
public:
  Person(const string& name, const Date& birthday,
         const Address& addr, const Country& country);
  virtual ~Person();

  ...                               // 拷贝构造函数, operator=

  string name() const;
  string birthDate() const;
  string address() const;
  string nationality() const;

private:
  PersonImpl *impl;                 // 指向具体的实现类
};

现在Person的用户程序完全和string,date,address,country以及person的实现细节分家了。那些类可以随意修改,而Person的用户却落得个自得其乐,不闻不问。更确切的说,它们可以不需要重新编译。另外,因为看不到Person的实现细节,用户不可能写出依赖这些细节的代码。这是真正的接口和实现的分离。

分离的关键在于,"对类定义的依赖" 被 "对类声明的依赖" 取代了。所以,为了降低编译依赖性,我们只要知道这么一条就足够了:只要有可能,尽量让头文件不要依赖于别的文件;如果不可能,就借助于类的声明,不要依靠类的定义。其它一切方法都源于这一简单的设计思想。

下面就是这一思想直接深化后的含义:

· 如果可以使用对象的引用和指针,就要避免使用对象本身。定义某个类型的引用和指针只会涉及到这个类型的声明。定义此类型的对象则需要类型定义的参与。

· 尽可能使用类的声明,而不使用类的定义。因为在声明一个函数时,如果用到某个类,是绝对不需要这个类的定义的,即使函数是通过传值来传递和返回这个类:

  class Date;                    // 类的声明

  Date returnADate();            // 正确 ---- 不需要Date的定义
  void takeADate(Date d);    

当然,传值通常不是个好主意(见条款22),但出于什么原因不得不这样做时,千万不要还引起不必要的编译依赖性。

如果你对returnADate和takeADate的声明在编译时不需要Date的定义感到惊讶,那么请跟我一起看看下文。其实,它没看上去那么神秘,因为任何人来调用那些函数,这些人会使得Date的定义可见。"噢" 我知道你在想,"为什么要劳神去声明一个没有人调用的函数呢?" 不对!不是没有人去调用,而是,并非每个人都会去调用。例如,假设有一个包含数百个函数声明的库(可能要涉及到多个名字空间----参见条款28),不可能每个用户都去调用其中的每一个函数。将提供类定义(通过#include 指令)的任务从你的函数声明头文件转交给包含函数调用的用户文件,就可以消除用户对类型定义的依赖,而这种依赖本来是不必要的、是人为造成的。

· 不要在头文件中再(通过#include指令)包含其它头文件,除非缺少了它们就不能编译。相反,要一个一个地声明所需要的类,让使用这个头文件的用户自己(通过#include指令)去包含其它的头文件,以使用户代码最终得以通过编译。一些用户会抱怨这样做对他们来说很不方便,但实际上你为他们避免了许多你曾饱受的痛苦。事实上,这种技术很受推崇,并被运用到C++标准库(参见条款49)中;头文件<iosfwd>就包含了iostream库中的类型声明(而且仅仅是类型声明)。

Person类仅仅用一个指针来指向某个不确定的实现,这样的类常常被称为句炳类(Handle class)或信封类(Envelope class)。(对于它们所指向的类来说,前一种情况下对应的叫法是主体类(Body class);后一种情况下则叫信件类(Letter class)。)偶尔也有人把这种类叫 "Cheshire猫" 类,这得提到《艾丽丝漫游仙境》中那只猫,当它愿意时,它会使身体其它部分消失,仅仅留下微笑。

你一定会好奇句炳类实际上都做了些什么。答案很简单:它只是把所有的函数调用都转移到了对应的主体类中,主体类真正完成工作。例如,下面是Person的两个成员函数的实现:

#include "Person.h"          // 因为是在实现Person类,
                             // 所以必须包含类的定义

#include "PersonImpl.h"      // 也必须包含PersonImpl类的定义,
                             // 否则不能调用它的成员函数。
                             // 注意PersonImpl和Person含有一样的
                             // 成员函数,它们的接口完全相同

Person::Person(const string& name, const Date& birthday,
               const Address& addr, const Country& country)
{
  impl = new PersonImpl(name, birthday, addr, country);
}

string Person::name() const
{
  return impl->name();
}

请注意Person的构造函数怎样调用PersonImpl的构造函数(隐式地以new来调用,参见条款5和M8)以及Person::name怎么调用PersonImpl::name。这很重要。使Person成为一个句柄类并不改变Person类的行为,改变的只是行为执行的地点。

除了句柄类,另一选择是使Person成为一种特殊类型的抽象基类,称为协议类(Protocol class)。根据定义,协议类没有实现;它存在的目的是为派生类确定一个接口(参见条款36)。所以,它一般没有数据成员,没有构造函数;有一个虚析构函数(见条款14),还有一套纯虚函数,用于制定接口。Person的协议类看起来会象下面这样:

class Person {
public:
  virtual ~Person();

  virtual string name() const = 0;
  virtual string birthDate() const = 0;
  virtual string address() const = 0;
  virtual string nationality() const = 0;
};

Person类的用户必须通过Person的指针和引用来使用它,因为实例化一个包含纯虚函数的类是不可能的(但是,可以实例化Person的派生类----参见下文)。和句柄类的用户一样,协议类的用户只是在类的接口被修改的情况下才需要重新编译。

当然,协议类的用户必然要有什么办法来创建新对象。这常常通过调用一个函数来实现,此函数扮演构造函数的角色,而这个构造函数所在的类即那个真正被实例化的隐藏在后的派生类。这种函数叫法挺多(如工厂函数(factory function),虚构造函数(virtual constructor)),但行为却一样:返回一个指针,此指针指向支持协议类接口(见条款M25)的动态分配对象。这样的函数象下面这样声明:

// makePerson是支持Person接口的
// 对象的"虚构造函数" ( "工厂函数")
Person*
  makePerson(const string& name,         // 用给定的参数初始化一个
             const Date& birthday,       // 新的Person对象,然后
             const Address& addr,        // 返回对象指针
             const Country& country);  


用户这样使用它:

string name;
Date dateOfBirth;
Address address;
Country nation;

...

// 创建一个支持Person接口的对象
Person *pp = makePerson(name, dateOfBirth, address, nation);

...

cout  << pp->name()              // 通过Person接口使用对象
      << " was born on "        
      << pp->birthDate()
      << " and now lives at "
      << pp->address();

...

delete pp;                       // 删除对象

makePerson这类函数和它创建的对象所对应的协议类(对象支持这个协议类的接口)是紧密联系的,所以将它声明为协议类的静态成员是很好的习惯:

class Person {
public:
  ...        // 同上

// makePerson现在是类的成员
  static Person * makePerson(const string& name,
                             const Date& birthday,
                             const Address& addr,
                             const Country& country);

这样就不会给全局名字空间(或任何其他名字空间)带来混乱,因为这种性质的函数会很多(参见条款28)。

当然,在某个地方,支持协议类接口的某个具体类(concrete class)必然要被定义,真的构造函数也必然要被调用。它们都背后发生在实现文件中。例如,协议类可能会有一个派生的具体类RealPerson,它具体实现继承而来的虚函数:

class RealPerson: public Person {
public:
  RealPerson(const string& name, const Date& birthday,
             const Address& addr, const Country& country)
  :  name_(name), birthday_(birthday),
     address_(addr), country_(country)
  {}

  virtual ~RealPerson() {}

  string name() const;          // 函数的具体实现没有
  string birthDate() const;     // 在这里给出,但它们
  string address() const;       // 都很容易实现
  string nationality() const;   

private:
  string name_;
  Date birthday_;
  Address address_;
  Country country_;

有了RealPerson,写Person::makePerson就是小菜一碟:

Person * Person::makePerson(const string& name,
                            const Date& birthday,
                            const Address& addr,
                            const Country& country)
{
  return new RealPerson(name, birthday, addr, country);
}

实现协议类有两个最通用的机制,RealPerson展示了其中之一:先从协议类(Person)继承接口规范,然后实现接口中的函数。另一种实现协议类的机制涉及到多继承,这将是条款43的话题。

是的,句柄类和协议类分离了接口和实现,从而降低了文件间编译的依赖性。"但,所有这些把戏会带来多少代价呢?",我知道你在等待罚单的到来。答案是计算机科学领域最常见的一句话:它在运行时会多耗点时间,也会多耗点内存。

句柄类的情况下,成员函数必须通过(指向实现的)指针来获得对象数据。这样,每次访问的间接性就多一层。此外,计算每个对象所占用的内存大小时,还应该算上这个指针。还有,指针本身还要被初始化(在句柄类的构造函数内),以使之指向被动态分配的实现对象,所以,还要承担动态内存分配(以及后续的内存释放)所带来的开销 ---- 见条款10。

对于协议类,每个函数都是虚函数,所有每次调用函数时必须承担间接跳转的开销(参见条款14和M24)。而且,每个从协议类派生而来的对象必然包含一个虚指针(参见条款14和M24)。这个指针可能会增加对象存储所需要的内存数量(具体取决于:对于对象的虚函数来说,此协议类是不是它们的唯一来源)。

最后一点,句柄类和协议类都不大会使用内联函数。使用任何内联函数时都要访问实现细节,而设计句柄类和协议类的初衷正是为了避免这种情况。

但如果仅仅因为句柄类和协议类会带来开销就把它们打入冷宫,那就大错特错。正如虚函数,你难道会不用它们吗?(如果回答不用,那你正在看一本不该看的书!)相反,要以发展的观点来运用这些技术。在开发阶段要尽量用句柄类和协议类来减少 "实现" 的改变对用户的负面影响。如果带来的速度和/或体积的增加程度远远大于类之间依赖性的减少程度,那么,当程序转化成产品时就用具体类来取代句柄类和协议类。希望有一天,会有工具来自动执行这类转换。

有些人还喜欢混用句柄类、协议类和具体类,并且用得很熟练。这固然使得开发出来的软件系统运行高效、易于改进,但有一个很大的缺点:还是必须得想办法减少程序重新编译时消耗的时间。

 

原创粉丝点击