标题: More Effective C++
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:55  资料  个人空间  主页 短消息  加为好友 
条款十一:禁止异常信息(exceptions)传递到析构函数外

  在有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地delete。第二种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。
  在上述两种情况下,调用析构函数时异常可能处于激活状态也可能没有处于激活状态。遗憾的是没有办法在析构函数内部区分出这两种情况。因此在写析构函数时你必须保守地假设有异常被激活,因为如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用terminate函数。这个函数的作用正如其名字所表示的:它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放。
  下面举一个例子,一个Session类用来跟踪在线计算机的sessions,session就是运行在从你一登录计算机开始一直到注销出系统为止的这段期间的某种东西。每个Session对象关注的是它建立与释放的日期与时间:

  class Session {
  public:
   Session();
   ~Session();
   ...

  private:
   static void logCreation(Session *objAddr);
   static void logDestruction(Session *objAddr);
  };

  函数logCreation 和 logDestruction被分别用于记录对象的建立与释放。我们因此可以这样编写Session的析构函数:

  Session::~Session()
  {
   logDestruction(this);
  }

  一切看上去很好,但是如果logDestruction抛出一个异常,会发生什么事呢?异常没有被Session的析构函数捕获住,所以它被传递到析构函数的调用者那里。但是如果析构函数本身的调用就是源自于某些其它异常的抛出,那么terminate函数将被自动调用,彻底终止你的程序。这不是你所希望发生的事情。程序没有记录下释放对象的信息,这是不幸的,甚至是一个大麻烦。那么事态果真严重到了必须终止程序运行的地步了么?如果没有,你必须防止在logDestruction内抛出的异常传递到Session析构函数的外面。唯一的方法是用try和catch blocks。一种很自然的做法会这样编写函数:

  Session::~Session()
  {
   try {
    logDestruction(this);
   }
   catch (...) {
    cerr << "Unable to log destruction of Session object "
       << "at address "
<< this
<< ".\n";
   }
  }

  但是这样做并不比你原来的代码安全。如果在catch中调用operator<<时导致一个异常被抛出,我们就又遇到了老问题,一个异常被转递到Session析构函数的外面。

  我们可以在catch中放入try,但是这总得有一个限度,否则会陷入循环。因此我们在释放Session时必须忽略掉所有它抛出的异常:

  Session::~Session()
  {
   try {
    logDestruction(this);
   }
   catch (...) { }
  }

  catch表面上好像没有做任何事情,这是一个假象,实际上它阻止了任何从logDestruction抛出的异常被传递到session析构函数的外面。我们现在能高枕无忧了,无论session对象是不是在堆栈辗转开解(stack unwinding)中被释放,terminate函数都不会被调用。

  不允许异常传递到析构函数外面还有第二个原因。如果一个异常被析构函数抛出而没有在函数内部捕获住,那么析构函数就不会完全运行(它会停在抛出异常的那个地方上)。如果析构函数不完全运行,它就无法完成希望它做的所有事情。例如,我们对session类做一个修改,在建立session时启动一个数据库事务(database transaction),终止session时结束这个事务:

  Session::Session() // 为了简单起见
  { // 这个构造函数没有
   // 处理异常
   logCreation(this);
   startTransaction(); // 启动 database transaction
  }

  Session::~Session()
  {
   logDestruction(this);
   endTransaction(); // 结束database transaction
  }

  如果在这里logDestruction抛出一个异常,在session构造函数内启动的transaction就没有被终止。我们也许能够通过重新调整session析构函数内的函数调用顺序来消除问题,但是如果endTransaction也抛出一个异常,我们除了回到使用try和catch外,别无选择。

  综上所述,我们知道禁止异常传递到析构函数外有两个原因,第一能够在异常转递的堆栈辗转开解(stack-unwinding)的过程中,防止terminate被调用。第二它能帮助确保析构函数总能完成我们希望它做的所有事情。(如果你仍旧不很信服我所说的理由,可以去看Herb Sutter的文章Exception-Safe Generic Containers ,特别是“Destructors That Throw and Why They’re Evil”这段)。





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:55  资料  个人空间  主页 短消息  加为好友 
条款十二:理解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”间的差异
 
  从语法上看,在函数里声明参数与在catch子句中声明参数几乎没有什么差别:

  class Widget { ... }; //一个类,具体是什么类
    // 在这里并不重要
  void f1(Widget w); // 一些函数,其参数分别为
  void f2(Widget& w); // Widget, Widget&,或
  void f3(const Widget& w); // Widget* 类型
  void f4(Widget *pw);
  void f5(const Widget *pw);
  catch (Widget w) ... //一些catch 子句,用来
  catch (Widget& w) ... //捕获异常,异常的类型为
  catch (const Widget& w) ... // Widget, Widget&, 或
  catch (Widget *pw) ... // Widget*
  catch (const Widget *pw) ...

  你因此可能会认为用throw抛出一个异常到catch子句中与通过函数调用传递一个参数两者基本相同。这里面确有一些相同点,但是他们也存在着巨大的差异。

  让我们先从相同点谈起。你传递函数参数与异常的途径可以是传值、传递引用或传递指针,这是相同的。但是当你传递参数和异常时,系统所要完成的操作过程则是完全不同的。产生这个差异的原因是:你调用函数时,程序的控制权最终还会返回到函数的调用处,但是当你抛出一个异常时,控制权永远不会回到抛出异常的地方。

  有这样一个函数,参数类型是Widget,并抛出一个Widget类型的异常:

  // 一个函数,从流中读值到Widget中
  istream operator>>(istream& s, Widget& w);
  void passAndThrowWidget()
  {
   Widget localWidget;
   cin >> localWidget; //传递localWidget到 operator>>
   throw localWidget; // 抛出localWidget异常
  }

  当传递localWidget到函数operator>>里,不用进行拷贝操作,而是把operator>>内的引用类型变量w指向localWidget,任何对w的操作实际上都施加到localWidget上。这与抛出localWidget异常有很大不同。不论通过传值捕获异常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行lcalWidget的拷贝操作,也就说传递到catch子句中的是localWidget的拷贝。必须这么做,因为当localWidget离开了生存空间后,其析构函数将被调用。如果把localWidget本身(而不是它的拷贝)传递给catch子句,这个子句接收到的只是一个被析构了的Widget,一个Widget的“尸体”。这是无法使用的。因此C++规范要求被做为异常抛出的对象必须被复制。

  即使被抛出的对象不会被释放,也会进行拷贝操作。例如如果passAndThrowWidget函数声明localWidget为静态变量(static),

  void passAndThrowWidget()
  {
   static Widget localWidget; // 现在是静态变量(static);
    //一直存在至程序结束

   cin >> localWidget; // 象以前那样运行
   throw localWidget; // 仍将对localWidget
  } //进行拷贝操作

  当抛出异常时仍将复制出localWidget的一个拷贝。这表示即使通过引用来捕获异常,也不能在catch块中修改localWidget;仅仅能修改localWidget的拷贝。对异常对象进行强制复制拷贝,这个限制有助于我们理解参数传递与抛出异常的第二个差异:抛出异常运行速度比参数传递要慢。

  当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数。比如以下这经过少许修改的passAndThrowWidget:

  class Widget { ... };
  class SpecialWidget: public Widget { ... };
  void passAndThrowWidget()
  {
   SpecialWidget localSpecialWidget;
   ...
   Widget& rw = localSpecialWidget; // rw 引用SpecialWidget
   throw rw; //它抛出一个类型为Widget
    // 的异常
  }

  这里抛出的异常对象是Widget,即使rw引用的是一个SpecialWidget。因为rw的静态类型(sta-tic type)是Widget,而不是SpecialWidget。你的编译器根本没有主要到rw引用的是一个Special-Widget。编译器所注意的是rw的静态类型(static type)。这种行为可能与你所期待的不一样,但是这与在其他情况下C++中拷贝构造函数的行为是一致的。(不过有一种技术可以让你根据对象的动态类型dynamic type进行拷贝,参见条款25)

  异常是其它对象的拷贝,这个事实影响到你如何在catch块中再抛出一个异常。比如下面这两个catch块,乍一看好像一样:

  catch (Widget& w) // 捕获Widget异常
  {
   ... // 处理异常
   throw; // 重新抛出异常,让它
  }    // 继续传递
  catch (Widget& w) // 捕获Widget异常
  {
   ... // 处理异常
   throw w; // 传递被捕获异常的
  } // 拷贝

  这两个catch块的差别在于第一个catch块中重新抛出的是当前捕获的异常,而第二个catch块中重新抛出的是当前捕获异常的一个新的拷贝。如果忽略生成额外拷贝的系统开销,这两种方法还有差异么?

  当然有。第一个块中重新抛出的是当前异常(current exception),无论它是什么类型。特别是如果这个异常开始就是做为SpecialWidget类型抛出的,那么第一个块中传递出去的还是Special-Widget异常,即使w的静态类型(static type)是Widget。这是因为重新抛出异常时没有进行拷贝操作。第二个catch块重新抛出的是新异常,类型总是Widget,因为w的静态类型(static type)是Widget。一般来说,你应该用throw来重新抛出当前的异常,因为这样不会改变被传递出去的异常类型,而且更有效率,因为不用生成一个新拷贝。

  (顺便说一句,异常生成的拷贝是一个临时对象。正如条款19解释的,临时对象能让编译器优化它的生存期(optimize it out of existence),不过我想你的编译器很难这么做,因为程序中很少发生异常,所以编译器厂商不会在这方面花大量的精力。)

  让我们测试一下下面这三种用来捕获Widget异常的catch子句,异常是做为passAndThrowWidgetp抛出的:

  catch (Widget w) ... // 通过传值捕获异常
  catch (Widget& w) ... // 通过传递引用捕获
            // 异常
  catch (const Widget& w) ... //通过传递指向const的引用
               //捕获异常

  我们立刻注意到了传递参数与传递异常的另一个差异。一个被异常抛出的对象(刚才解释过,总是一个临时对象)可以通过普通的引用捕获;它不需要通过指向const对象的引用(reference-to-const)捕获。在函数调用中不允许转递一个临时对象到一个非const引用类型的参数里(参见条款19),但是在异常中却被允许。

  让我们先不管这个差异,回到异常对象拷贝的测试上来。我们知道当用传值的方式传递函数的参数,我们制造了被传递对象的一个拷贝(参见Effective C++ 条款22),并把这个拷贝存储到函数的参数里。同样我们通过传值的方式传递一个异常时,也是这么做的。当我们这样声明一个catch子句时:

  catch (Widget w) ... // 通过传值捕获

  会建立两个被抛出对象的拷贝,一个是所有异常都必须建立的临时对象,第二个是把临时对象拷贝进w中。同样,当我们通过引用捕获异常时,

  catch (Widget& w) ... // 通过引用捕获
  catch (const Widget& w) ... //也通过引用捕获

  这仍旧会建立一个被抛出对象的拷贝:拷贝是一个临时对象。相反当我们通过引用传递函数参数时,没有进行对象拷贝。当抛出一个异常时,系统构造的(以后会析构掉)被抛出对象的拷贝数比以相同对象做为参数传递给函数时构造的拷贝数要多一个。

  我们还没有讨论通过指针抛出异常的情况,不过通过指针抛出异常与通过指针传递参数是相同的。不论哪种方法都是一个指针的拷贝被传递。你不能认为抛出的指针是一个指向局部对象的 指针,因为当异常离开局部变量的生存空间时,该局部变量已经被释放。Catch子句将获得一个指向已经不存在的对象的指针。这种行为在设计时应该予以避免。

  对象从函数的调用处传递到函数参数里与从异常抛出点传递到catch子句里所采用的方法不同,这只是参数传递与异常传递的区别的一个方面,第二个差异是在函数调用者或抛出异常者与被调用者或异常捕获者之间的类型匹配的过程不同。比如在标准数学库(the standard math library)中sqrt函数:

  double sqrt(double); // from <cmath> or <math.h>

  我们能这样计算一个整数的平方根,如下所示:

  int i;
  double sqrtOfi = sqrt(i);

  毫无疑问,C++允许进行从int到double的隐式类型转换,所以在sqrt的调用中,i 被悄悄地转变为double类型,并且其返回值也是double。(有关隐式类型转换的详细讨论参见条款5)一般来说,catch子句匹配异常类型时不会进行这样的转换。见下面的代码:

  void f(int value)
  {
   try {
    if (someFunction()) { // 如果 someFunction()返回
     throw value; //真,抛出一个整形值
     ...
    }
   }
   catch (double d) { // 只处理double类型的异常
    ...
   }

   ...

  }

  在try块中抛出的int异常不会被处理double异常的catch子句捕获。该子句只能捕获真真正正为double类型的异常;不进行类型转换。因此如果要想捕获int异常,必须使用带有int或int&参数的catch子句。

  不过在catch子句中进行异常匹配时可以进行两种类型转换。第一种是继承类与基类间的转换。一个用来捕获基类的catch子句也可以处理派生类类型的异常。例如在标准C++库(STL)定义的异常类层次中的诊断部分(diagnostics portion )(参见Effective C++ 条款49)。

  捕获runtime_errors异常的Catch子句可以捕获range_error类型和overflow_error类型的异常,可以接收根类exception异常的catch子句能捕获其任意派生类异常。

  这种派生类与基类(inheritance_based)间的异常类型转换可以作用于数值、引用以及指针上:

  catch (runtime_error) ... // can catch errors of type
  catch (runtime_error&) ... // runtime_error
  catch (const runtime_error&) ... // range_error or
                  // overflow_error
  catch (runtime_error*) ... // can catch errors of type
  catch (const runtime_error*) ... // runtime_error*
                   // range_error* or
  // overflow_error*


  第二种是允许从一个类型化指针(typed pointer)转变成无类型指针(untyped pointer),所以带有const void* 指针的catch子句能捕获任何类型的指针类型异常:

  catch (const void*) ... //捕获任何指针类型异常

  传递参数和传递异常间最后一点差别是catch子句匹配顺序总是取决于它们在程序中出现的顺序。因此一个派生类异常可能被处理其基类异常的catch子句捕获,即使同时存在有能处理该派生类异常的catch子句,与相同的try块相对应。例如:

  try {
   ...
  }
  catch (logic_error& ex) { // 这个catch块 将捕获
   ... // 所有的logic_error
  } // 异常, 包括它的派生类

  catch (invalid_argument& ex) { // 这个块永远不会被执行
   ... //因为所有的
  } // invalid_argument
   // 异常都被上面的
   // catch子句捕获。

  与上面这种行为相反,当你调用一个虚拟函数时,被调用的函数位于与发出函数调用的对象的动态类型(dynamic type)最相近的类里。你可以这样说虚拟函数采用最优适合法,而异常处理采用的是最先适合法。如果一个处理派生类异常的catch子句位于处理基类异常的catch子句前面,编译器会发出警告。(因为这样的代码在C++里通常是不合法的。)不过你最好做好预先防范:不要把处理基类异常的catch子句放在处理派生类异常的catch子句的前面。象上面那个例子,应该这样去写:

  try {
   ...
  }
  catch (invalid_argument& ex) { // 处理 invalid_argument
   ... //异常
  }
  catch (logic_error& ex) { // 处理所有其它的
   ... // logic_errors异常
  }

  综上所述,把一个对象传递给函数或一个对象调用虚拟函数与把一个对象做为异常抛出,这之间有三个主要区别。第一、异常对象在传递时总被进行拷贝;当通过传值方式捕获时,异常对象被拷贝了两次。对象做为参数传递给函数时不需要被拷贝。第二、对象做为异常被抛出与做为参数传递给函数相比,前者类型转换比后者要少(前者只有两种转换形式)。最后一点,catch子句进行异常类型匹配的顺序是它们在源代码中出现的顺序,第一个类型匹配成功的catch将被用来执行。当一个对象调用一个虚拟函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:56  资料  个人空间  主页 短消息  加为好友 
条款十三:通过引用(reference)捕获异常
 
  当你写一个catch子句时,必须确定让异常通过何种方式传递到catch子句里。你可以有三个选择:与你给函数传递参数一样,通过指针(by pointer),通过传值(by value)或通过引用(by reference)。

  我们首先讨论通过指针方式捕获异常(catch by pointer)。从throw处传递一个异常到catch子句是一个缓慢的过程,在理论上这种方法的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象(参见条款12),例如:

  class exception { ... }; // 来自标准C++库(STL)
   // 中的异常类层次
  // (参见条款12)

  void someFunction()
  {
   static exception ex; // 异常对象
   ...
   throw &ex; // 抛出一个指针,指向ex
   ...
  
  void doSomething()
  {
   try {
    someFunction(); // 抛出一个 exception*
   }
   catch (exception *ex) { // 捕获 exception*;
    ... // 没有对象被拷贝
   }
  }

  这看上去很不错,但是实际情况却不是这样。为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存。全局与静态对象都能够做到这一点,但是程序员很容易忘记这个约束。如果真是如此的话,他们会这样写代码:

  void someFunction()
  {
   exception ex; // 局部异常对象;
    // 当退出函数的生存空间时
    // 这个对象将被释放。
   ...

  throw &ex; // 抛出一个指针,指向
  ... // 已被释放的对象
  }

  这简直糟糕透了,因为处理这个异常的catch子句接受到的指针,其指向的对象已经不再存在。
另一种抛出指针的方法是在建立一个堆对象(new heap object):

  void someFunction()
  {
   ...
   throw new exception; // 抛出一个指针,指向一个在堆中
   ... // 建立的对象(希望
  } // 操作符new — 参见条款8—
    // 自己不要再抛出一个
    // 异常!)

  这避免了捕获一个指向已被释放对象的指针的问题,但是catch子句的作者又面临一个令人头疼的问题:他们是否应该删除他们接受的指针?如果是在堆中建立的异常对象,那他们必须删除它,否则会造成资源泄漏。如果不是在堆中建立的异常对象,他们绝对不能删除它,否则程序的行为将不可预测。该如何做呢?

  这是不可能知道的。一些clients可能会传递全局或静态对象的地址,另一些可能转递堆中建立的异常对象的地址。通过指针捕获异常,将遇到一个哈姆雷特式的难题:是删除还是不删除?这是一个难以回答的问题。所以你最好避开它。

  而且,通过指针捕获异常也不符合C++语言本身的规范。四个标准的异常――bad_alloc(当operator new(参见条款8)不能分配足够的内存时,被抛出),bad_cast(当dynamic_cast针对一个引用(reference)操作失败时,被抛出),bad_typeid(当dynamic_cast对空指针进行操作时,被抛出)和bad_exception(用于unexpected异常;参见条款14)――都不是指向对象的指针,所以你必须通过值或引用来捕获它们。

  通过值捕获异常(catch-by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款12)。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的sliced对象实际上是一个基类对象:它们没有派生类的数据成员,而且当调用它们的虚拟函数时,系统解析后调用的是基类对象的函数。(当一个对象通过传值方式传递给函数,也会发生一样的情况――参见Effective C++ 条款22)。例如下面这个程序采用了扩展自标准异常类的异常类层次体系:

  class exception { // 如上,这是
  public: // 一个标准异常类
   virtual const char * what() throw(); // 返回异常的简短描述.
   ... // (在函数声明的结尾处
    // 的"throw()",
  }; //有关它的信息
   // 参见条款14)

  class runtime_error: //也来自标准C++异常类
  public exception { ... };

  class Validation_error: // 客户自己加入个类
   public runtime_error {
  public:
   virtual const char * what() throw(); // 重新定义在异常类中
   ... //虚拟函数
  };

  void someFunction() // 抛出一个 validation
  { // 异常
   ...

   if (a validation 测试失败) {
    throw Validation_error();
   }
   ...
  }

  void doSomething()
  {
   try {
    someFunction(); // 抛出 validation
   } //异常

   catch (exception ex) { //捕获所有标准异常类
              // 或它的派生类
    cerr << ex.what(); // 调用 exception::what(),
    ... // 而不是Validation_error::what()
   }
  }

  调用的是基类的what函数,即使被抛出的异常对象是Validation_error和 Validation_error类型,它们已经重新定义的虚拟函数。这种slicing行为绝不是你所期望的。

  最后剩下方法就是通过引用捕获异常(catch-by-reference)。通过引用捕获异常能使你避开上述所有问题。不象通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有slicing problem,而且异常对象只被拷贝一次。
我们采用通过引用捕获异常的方法重写最后那个例子,如下所示:

  void someFunction() //这个函数没有改变
  {
   ...

   if (a validation 测试失败) {
    throw Validation_error();
   }
   ...
  }

  void doSomething()
  {
   try {
    someFunction(); // 没有改变
   }
   catch (exception& ex) { // 这里,我们通过引用捕获异常
              // 以替代原来的通过值捕获
    cerr << ex.what(); // 现在调用的是
             // Validation_error::what(),
    ... // 而不是 exception::what()
   }
  }

  这里没有对throw进行任何改变,仅仅改变了catch子句,给它加了一个&符号。然而这个微小的改变能造成了巨大的变化,因为catch块中的虚拟函数能够如我们所愿那样工作了:调用的Validation_erro函数是我们重新定义过的函数。

  如果你通过引用捕获异常(catch by reference),你就能避开上述所有问题,不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。所以你还在等什么?通过引用捕获异常吧(Catch exceptions by reference)!





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:56  资料  个人空间  主页 短消息  加为好友 
条款十三:通过引用(reference)捕获异常
 
  当你写一个catch子句时,必须确定让异常通过何种方式传递到catch子句里。你可以有三个选择:与你给函数传递参数一样,通过指针(by pointer),通过传值(by value)或通过引用(by reference)。

  我们首先讨论通过指针方式捕获异常(catch by pointer)。从throw处传递一个异常到catch子句是一个缓慢的过程,在理论上这种方法的实现对于这个过程来说是效率最高的。因为在传递异常信息时,只有采用通过指针抛出异常的方法才能够做到不拷贝对象(参见条款12),例如:

  class exception { ... }; // 来自标准C++库(STL)
   // 中的异常类层次
  // (参见条款12)

  void someFunction()
  {
   static exception ex; // 异常对象
   ...
   throw &ex; // 抛出一个指针,指向ex
   ...
  
  void doSomething()
  {
   try {
    someFunction(); // 抛出一个 exception*
   }
   catch (exception *ex) { // 捕获 exception*;
    ... // 没有对象被拷贝
   }
  }

  这看上去很不错,但是实际情况却不是这样。为了能让程序正常运行,程序员定义异常对象时必须确保当程序控制权离开抛出指针的函数后,对象还能够继续生存。全局与静态对象都能够做到这一点,但是程序员很容易忘记这个约束。如果真是如此的话,他们会这样写代码:

  void someFunction()
  {
   exception ex; // 局部异常对象;
    // 当退出函数的生存空间时
    // 这个对象将被释放。
   ...

  throw &ex; // 抛出一个指针,指向
  ... // 已被释放的对象
  }

  这简直糟糕透了,因为处理这个异常的catch子句接受到的指针,其指向的对象已经不再存在。
另一种抛出指针的方法是在建立一个堆对象(new heap object):

  void someFunction()
  {
   ...
   throw new exception; // 抛出一个指针,指向一个在堆中
   ... // 建立的对象(希望
  } // 操作符new — 参见条款8—
    // 自己不要再抛出一个
    // 异常!)

  这避免了捕获一个指向已被释放对象的指针的问题,但是catch子句的作者又面临一个令人头疼的问题:他们是否应该删除他们接受的指针?如果是在堆中建立的异常对象,那他们必须删除它,否则会造成资源泄漏。如果不是在堆中建立的异常对象,他们绝对不能删除它,否则程序的行为将不可预测。该如何做呢?

  这是不可能知道的。一些clients可能会传递全局或静态对象的地址,另一些可能转递堆中建立的异常对象的地址。通过指针捕获异常,将遇到一个哈姆雷特式的难题:是删除还是不删除?这是一个难以回答的问题。所以你最好避开它。

  而且,通过指针捕获异常也不符合C++语言本身的规范。四个标准的异常――bad_alloc(当operator new(参见条款8)不能分配足够的内存时,被抛出),bad_cast(当dynamic_cast针对一个引用(reference)操作失败时,被抛出),bad_typeid(当dynamic_cast对空指针进行操作时,被抛出)和bad_exception(用于unexpected异常;参见条款14)――都不是指向对象的指针,所以你必须通过值或引用来捕获它们。

  通过值捕获异常(catch-by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款12)。而且它会产生slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的sliced对象实际上是一个基类对象:它们没有派生类的数据成员,而且当调用它们的虚拟函数时,系统解析后调用的是基类对象的函数。(当一个对象通过传值方式传递给函数,也会发生一样的情况――参见Effective C++ 条款22)。例如下面这个程序采用了扩展自标准异常类的异常类层次体系:

  class exception { // 如上,这是
  public: // 一个标准异常类
   virtual const char * what() throw(); // 返回异常的简短描述.
   ... // (在函数声明的结尾处
    // 的"throw()",
  }; //有关它的信息
   // 参见条款14)

  class runtime_error: //也来自标准C++异常类
  public exception { ... };

  class Validation_error: // 客户自己加入个类
   public runtime_error {
  public:
   virtual const char * what() throw(); // 重新定义在异常类中
   ... //虚拟函数
  };

  void someFunction() // 抛出一个 validation
  { // 异常
   ...

   if (a validation 测试失败) {
    throw Validation_error();
   }
   ...
  }

  void doSomething()
  {
   try {
    someFunction(); // 抛出 validation
   } //异常

   catch (exception ex) { //捕获所有标准异常类
              // 或它的派生类
    cerr << ex.what(); // 调用 exception::what(),
    ... // 而不是Validation_error::what()
   }
  }

  调用的是基类的what函数,即使被抛出的异常对象是Validation_error和 Validation_error类型,它们已经重新定义的虚拟函数。这种slicing行为绝不是你所期望的。

  最后剩下方法就是通过引用捕获异常(catch-by-reference)。通过引用捕获异常能使你避开上述所有问题。不象通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有slicing problem,而且异常对象只被拷贝一次。
我们采用通过引用捕获异常的方法重写最后那个例子,如下所示:

  void someFunction() //这个函数没有改变
  {
   ...

   if (a validation 测试失败) {
    throw Validation_error();
   }
   ...
  }

  void doSomething()
  {
   try {
    someFunction(); // 没有改变
   }
   catch (exception& ex) { // 这里,我们通过引用捕获异常
              // 以替代原来的通过值捕获
    cerr << ex.what(); // 现在调用的是
             // Validation_error::what(),
    ... // 而不是 exception::what()
   }
  }

  这里没有对throw进行任何改变,仅仅改变了catch子句,给它加了一个&符号。然而这个微小的改变能造成了巨大的变化,因为catch块中的虚拟函数能够如我们所愿那样工作了:调用的Validation_erro函数是我们重新定义过的函数。

  如果你通过引用捕获异常(catch by reference),你就能避开上述所有问题,不会为是否删除异常对象而烦恼;能够避开slicing异常对象;能够捕获标准异常类型;减少异常对象需要被拷贝的数目。所以你还在等什么?通过引用捕获异常吧(Catch exceptions by reference)!





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:57  资料  个人空间  主页 短消息  加为好友 
条款十四:审慎使用异常规格(exception specifications)

  毫无疑问,异常规格是一个引人注目的特性。它使得代码更容易理解,因为它明确地描述了一个函数可以抛出什么样的异常。但是它不只是一个有趣的注释。编译器在编译时有时能够检测到异常规格的不一致。而且如果一个函数抛出一个不在异常规格范围里的异常,系统在运行时能够检测出这个错误,然后一个特殊函数unexpected将被自动地调用。异常规格既可以做为一个指导性文档同时也是异常使用的强制约束机制,它好像有着很诱人的外表。

  不过在通常情况下,美貌只是一层皮,外表的美丽并不代表其内在的素质。函数unexpected缺省的行为是调用函数terminate,而terminate缺省的行为是调用函数abort,所以一个违反异常规格的程序其缺省的行为就是halt(停止运行)。在激活的stack frame中的局部变量没有被释放,因为abort在关闭程序时不进行这样的清除操作。对异常规格的触犯变成了一场并不应该发生的灾难。

  不幸的是,我们很容易就能够编写出导致发生这种灾难的函数。编译器仅仅部分地检测异常的使用是否与异常规格保持一致。一个函数调用了另一个函数,并且后者可能抛出一个违反前者异常规格的异常,(A函数调用B函数,因为B函数可能抛出一个不在A函数异常规格之内的异常,所以这个函数调用就违反了A函数的异常规格 译者注)编译器不对此种情况进行检测,并且语言标准也禁止它们拒绝这种调用方式(尽管可以显示警告信息)。

  例如函数f1没有声明异常规格,这样的函数就可以抛出任意种类的异常:

  extern void f1(); // 可以抛出任意的异常

  假设有一个函数f2通过它的异常规格来声明其只能抛出int类型的异常:

  void f2() throw(int);

  f2调用f1是非常合法的,即使f1可能抛出一个违反f2异常规格的异常:

  void f2() throw(int)
  {
   ...
   f1(); // 即使f1可能抛出不是int类型的
   //异常,这也是合法的。
   ...
  }

  当带有异常规格的新代码与没有异常规格的老代码整合在一起工作时,这种灵活性就显得很重要。

  因为你的编译器允许你调用一个函数其抛出的异常与发出调用的函数的异常规格不一致,并且这样的调用可能导致你的程序执行被终止,所以在编写软件时采取措施把这种不一致减小到最少。一种好方法是避免在带有类型参数的模板内使用异常规格。例如下面这种模板,它好像不能抛出任何异常:

  // a poorly designed template wrt exception specifications
  template<class T>
  bool operator==(const T& lhs, const T& rhs) throw()
  {
   return &lhs == &rhs;
  }

  这个模板为所有类型定义了一个操作符函数operator==。对于任意一对类型相同的对象,如果对象有一样的地址,该函数返回true,否则返回false。

  这个模板包含的异常规格表示模板生成的函数不能抛出异常。但是事实可能不会这样,因为opertor&(地址操作符,参见Effective C++ 条款45)能被一些类型对象重载。如果被重载的话,当调用从operator==函数内部调用opertor&时,opertor&可能会抛出一个异常,这样就违反了我们的异常规格,使得程序控制跳转到unexpected。

  上述的例子是一种更一般问题的特例,这个问题也就是没有办法知道某种模板类型参数抛出什么样的异常。我们几乎不可能为一个模板提供一个有意义的异常规格。,因为模板总是采用不同的方法使用类型参数。解决方法只能是模板和异常规格不要混合使用。

  能够避免调用unexpected函数的第二个方法是如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。这很容易理解,但是实际中容易被忽略。比如允许用户注册一个回调函数:

  // 一个window系统回调函数指针
  //当一个window系统事件发生时
  typedef void (*CallBackPtr)(int eventXLocation,
    int eventYLocation,
    void *dataToPassBack);
  //window系统类,含有回调函数指针,
  //该回调函数能被window系统客户注册

  class CallBack {
  public:
   CallBack(CallBackPtr fPtr, void *dataToPassBack)
    : func(fPtr), data(dataToPassBack) {}
   void makeCallBack(int eventXLocation,
   int eventYLocation) const throw();
  private:
   CallBackPtr func; // function to call when
   // callback is made
   void *data; // data to pass to callback
  }; // function

  // 为了实现回调函数,我们调用注册函数,
  //事件的作标与注册数据做为函数参数。
  void CallBack::makeCallBack(int eventXLocation,
   int eventYLocation) const throw()
  {
   func(eventXLocation, eventYLocation, data);
  }

  这里在makeCallBack内调用func,要冒违反异常规格的风险,因为无法知道func会抛出什么类型的异常。

  通过在程序在CallBackPtr typedef中采用更严格的异常规格来解决问题:

  typedef void (*CallBackPtr)(int eventXLocation,
   int eventYLocation,
  void *dataToPassBack) throw();

  这样定义typedef后,如果注册一个可能会抛出异常的callback函数将是非法的:

  // 一个没有异常规格的回调函数
  void callBackFcn1(int eventXLocation, int eventYLocation,
   void *dataToPassBack);
   void *callBackData;
  ...
  CallBack c1(callBackFcn1, callBackData);
  //错误!callBackFcn1可能
  //抛出异常
  //带有异常规格的回调函数

  void callBackFcn2(int eventXLocation,
   int eventYLocation,
   void *dataToPassBack) throw();
  CallBack c2(callBackFcn2, callBackData);
  // 正确,callBackFcn2
  // 没有异常规格

  传递函数指针时进行这种异常规格的检查,是语言的较新的特性,所以有可能你的编译器不支持这个特性。如果它们不支持,那就依靠你自己来确保不能犯这种错误。

  避免调用unexpected的第三个方法是处理系统本身抛出的异常。这些异常中最常见的是bad_al-loc,当内存分配失败时它被operator new 和operator new[]抛出(参见条款8)。如果你在函数里使用new操作符(还参见条款8),你必须为函数可能遇到bad_alloc异常作好准备。

  现在常说预防胜于治疗(即做任何事都要未雨绸缪 译者注),但是有时却是预防困难而治疗容易。也就是说有时直接处理unexpected异常比防止它们被抛出要简单。例如你正在编写一个软件,精确地使用了异常规格,但是你必须从没有使用异常规格的程序库中调用函数,要防止抛出unexpe-cted异常是不现实的,因为这需要改变程序库中的代码。

  虽然防止抛出unexpected异常是不现实的,但是C++允许你用其它不同的异常类型替换unexpec-ted异常,你能够利用这个特性。例如你希望所有的unexpected异常都被替换为UnexpectedExcepti-on对象。你能这样编写代码:

  class UnexpectedException {}; // 所有的unexpected异常对象被
    //替换为这种类型对象

  void convertUnexpected() // 如果一个unexpected异常被
  { // 抛出,这个函数被调用
   throw UnexpectedException();
  }

  通过用convertUnexpected函数替换缺省的unexpected函数,来使上述代码开始运行。

  set_unexpected(convertUnexpected);

  当你这么做了以后,一个unexpected异常将触发调用convertUnexpected函数。Unexpected异常被一种UnexpectedException新异常类型替换。如果被违反的异常规格包含UnexpectedException异常,那么异常传递将继续下去,好像异常规格总是得到满足。(如果异常规格没有包含Unexpected-Exception,terminate将被调用,就好像你没有替换unexpected一样)

  另一种把unexpected异常转变成知名类型的方法是替换unexpected函数,让其重新抛出当前异常,这样异常将被替换为bad_exception。你可以这样编写:

  void convertUnexpected() // 如果一个unexpected异常被
  {            //抛出,这个函数被调用
   throw;         // 它只是重新抛出当前
  } // 异常

  set_unexpected(convertUnexpected);
  // 安装 convertUnexpected
  // 做为unexpected
  // 的替代品

  如果这么做,你应该在所有的异常规格里包含bad_exception(或它的基类,标准类exceptio-n)。你将不必再担心如果遇到unexpected异常会导致程序运行终止。任何不听话的异常都将被替换为bad_exception,这个异常代替原来的异常继续传递。

  到现在你应该理解异常规格能导致大量的麻烦。编译器仅仅能部分地检测它们的使用是否一致,在模板中使用它们会有问题,一不注意它们就很容易被违反,并且在缺省的情况下它们被违反时会导致程序终止运行。异常规格还有一个缺点就是它们能导致unexpected被触发即使一个high-level调用者准备处理被抛出的异常,比如下面这个几乎一字不差地来自从条款11例子:

  class Session { // for modeling online
  public:     // sessions
   ~Session();
   ...

  private:
   static void logDestruction(Session *objAddr) throw();
  };

  Session::~Session()
  {
   try {
    logDestruction(this);
   }
   catch (...) { }
  }

  session的析构函数调用logDestruction记录有关session对象被释放的信息,它明确地要捕获从logDestruction抛出的所有异常。但是logDestruction的异常规格表示其不抛出任何异常。现在假设被logDestruction调用的函数抛出了一个异常,而logDestruction没有捕获。我们不会期望发生这样的事情,凡是正如我们所见,很容易就会写出违反异常规格的代码。当这个异常通过logDes-truction传递出来,unexpected将被调用,缺省情况下将导致程序终止执行。这是一个正确的行为,这是session析构函数的作者所希望的行为么?作者想处理所有可能的异常,所以好像不应该不给session析构函数里的catch块执行的机会就终止程序。如果logDestruction没有异常规格,这种事情就不会发生。(一种防止的方法是如上所描述的那样替换unexpected)

  以全面的角度去看待异常规格是非常重要的。它们提供了优秀的文档来说明一个函数抛出异常的种类,并且在违反它的情况下,会有可怕的结果,程序被立即终止,在缺省时它们会这么做。同时编译器只会部分地检测它们的一致性,所以他们很容易被不经意地违反。而且他们会阻止high-level异常处理器来处理unexpected异常,即使这些异常处理器知道如何去做。综上所述,异常规格是一个应被审慎使用的公族。在把它们加入到你的函数之前,应考虑它们所带来的行为是否就是你所希望的行为。





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 21:59  资料  个人空间  主页 短消息  加为好友 
条款十五:了解异常处理的系统开销

  为了在运行时处理异常,程序要记录大量的信息。无论执行到什么地方,程序都必须能够识别出如果在此处抛出异常的话,将要被释放哪一个对象;程序必须知道每一个入口点,以便从try块中退出;对于每一个try块,他们都必须跟踪与其相关的catch子句以及这些catch子句能够捕获的异常类型。这种信息的记录不是没有代价的。确保程序满足异常规格不需要运行时的比较(runtime co-mparisons),而且当异常被抛出时也不用额外的开销来释放相关的对象和匹配正确的catch字句。但是异常处理确是有代价的,即使你没有使用try,throw或catch关键字,你同样得付出一些代价。
让我们先从你不使用任何异常处理特性也要付出的代价谈起。你需要空间建立数据结构来跟踪对象是否被完全构造(constructed)(参加条款10),你也需要系统时间保持这些数据结构不断更新。这些开销一般不是很大,但是当采用不支持异常的方法编译的程序一般比支持异常的程序运行速度更快所占空间也更小。

  在理论上,你不能对此进行选择:C++编译器必须支持异常,也就是说,当你不用异常处理时你不能让编译器生产商消除这方面的开销,因为程序一般由多个独立生成的目标文件(object file-s)组成,只有一个目标文件不进行异常处理并不能代表其他目标文件不进行异常处理。而且即使组成可执行文件的目标文件都不进行异常处理,那么还有它们所连接的程序库呢?如果程序的任何部分使用了异常,其它部分必须也支持异常。否则在运行时程序就不可能提供正确的异常处理。

  不过这只是理论,实际上大部分支持异常的编译器生产商都允许你自由控制是否在生成的代码里包含进支持异常的内容。如果你知道你程序的任何部分都不使用try,throw或catch,并且你也知道所连接的程序库也没有使用try,throw或catch,你就可以采用不支持异常处理的方法进行编译,这可以缩小程序的尺寸和提高速度,否则你就得为一个不需要的特性而付出代价。随着时间的推移,使用异处理的程序库开始变得普遍了,上面这种方法将逐渐不能使用,但是根据目前的软件开发情况来看,如果你已经决定不使用任何的异常特性,那么采用不支持异常的方法编译程序是一个性能优化的合理方法。同样这对于想避开异常的程序库来说也是一个性能优化的好方法,这能保证异常不会从客户端程序传递进程序库里,不过同时这样做也会妨碍客户端程序重定义程序库中声明的虚拟函数,并不允许有在客户端定义的回调函数。

  使用异常处理的第二个开销来自于try块,无论何时使用它,也就是无论何时你想能够捕获异常,那你都得为此付出代价。不同的编译器实现try块的方法不同,所以编译器与编译器间的开销也不一样。粗略地估计,如果你使用try块,代码的尺寸将增加5%-10%并且运行速度也同比例减慢。这还是假设程序没有抛出异常,我这里讨论的只是在程序里使用try块的开销。为了减少开销,你应该避免使用无用的try块。

  编译器为异常规格生成的代码与它们为try块生成的代码一样多,所以一个异常规格一般花掉与tyr块一样多的系统开销。什么?你说你认为异常规格只是一个规格而已,你认为它们不会产生代码?那么好,现在你应该对此有新的认识了。

  现在我们来到了问题的核心部分,看看抛出异常的开销。事实上我们不用太关心这个问题,因为异常是很少见的,这种事件的发生往往被描述为exceptional(异常的,罕见的)。80-20规则(参见条款16)告诉我们这样的事件不会对整个程序的性能造成太大的影响。但是我知道你仍旧好奇地想知道如果抛出一个异常到底会有多大的开销,答案是这可能会比较大。与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级。这个开销很大。但是仅仅当你抛出异常时才会有这个开销,一般不会发生。但是如果你用异常表示一个比较普遍的状况,例如完成对数据结构的遍历或结束一个循环,那你必须重新予以考虑。

  不过请等一下,你问我是怎么知道这些事情的呢?如果说支持异常对于大多数编译器来说是一个较新的特性,如果说不同的编译器异常方法也不同,那么我如何能说程序的尺寸将增大5%-10%,它的速度也同比例减慢,而且如果有大量的异常被抛出,程序运行速度会呈数量级的减慢呢?答案是令人惊恐的:一些传闻和一些基准测试(benchmarks)(参见条款23)。事实是大部分人包括编译器生产商在异常处理方面几乎没有什么经验,所以尽管我们知道异常确实会带来开销,却很难预测出开销的准确数量。

  谨慎的方法是对本条款所叙述的开销有了解,但是不深究具体的数量。(即定性不定量 译者注)不论异常处理的开销有多大我们都得坚持只有必须付出时才付出的原则。为了使你的异常开销最小化,只要可能尽量就采用不支持异常的方法编译程序,把使用try块和异常规格限制在你确实需要它们的地方,并且只有在确为异常的情况下(exceptional)才抛出异常。如果你在性能上仍旧有问题,总体评估一下你的软件以决定异常支持是否是一个起作用的因素。如果是,那就考虑选择其它的编译器,能在C++异常处理方面具有更高实现效率的编译器。





不要问我结果 我只研究过程与思路
无双客栈
顶部
[广告] 记录自己的思想火花,留住每日的技术积累,尽在拥有属于自己独立域名的博客。
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:01  资料  个人空间  主页 短消息  加为好友 
效率
  我怀疑一些人在C++软件开发人员身上进行秘密的巴甫洛夫试验,否则为什么当提到“效率”这个词时,许多程序员都会流口水。(Scott Meyers真幽默 译者注)

  事实上,效率可不是一个开玩笑的事情。一个太大或太慢的程序它们的优点无论多么引人注目都不会为人们所接受。本来就应该这样。软件是用来帮助我们更好地工作,说运行速度慢才是更好的,说需要32MB内存的程序比仅仅需要16MB内存的程序好,说占用100MB磁盘空间的程序比仅仅占用50MB磁盘空间的程序好,这简直是无稽之谈。而且尽管有一些程序确是为了进行更复杂的运算才占用更多的时间和空间,但是对于许多程序来说只能归咎于其糟糕的设计和马虎的编程。

  在用C++写出高效地程序之前,必须认识到C++本身绝对与你所遇到的任何性能上的问题无关。如果想写出一个高效的C++程序,你必须首先能写出一个高效的程序。太多的开发人员都忽视了这个简单的道理。是的,循环能够被手工展开,移位操作(shift operation)能够替换乘法,但是如果你所使用的高层算法其内在效率很低,这些微调就不会有任何作用。当线性算法可用时你是否还用二次方程式算法?你是否一遍又一遍地计算重复的数值?如果是的话,可以毫不夸张地把你的程序比喻成一个二流的观光胜地,即如果你有额外的时间,才值得去看一看。

  本章的内容从两个角度阐述效率的问题。第一是从语言独立的角度,关注那些你能在任何语言里都能使用的东西。C++为它们提供了特别吸引人的实现途径,因为它对封装的支持非常好,从而能够用更好的算法与数据结构来替代低效的类实现,同时接口可以保持不变。

  第二是关注C++语言本身。高性能的算法与数据结构虽然非常好,但如果实际编程中代码实现得很粗糙,效率也会降低得相当多。潜在危害性最大的错误是既容易犯又不容易察觉的错误,濒繁地构造和释放大量的对象就是一种这样的错误。过多的对象构造和对象释放对于你的程序性能来说就象是在大出血,在每次建立和释放不需要的对象的过程中,宝贵的时间就这么流走了。这个问题在C++程序中很普遍,我将用四个条款来说明这些对象从哪里来的,在不影响程序代码正确性的基础上如何消除它们。

  建立大量的对象不会使程序变大而只会使其运行速度变慢。还有其它一些影响性能提高的因素,包括程序库的选择和语言特性的实现(implementations of language features)。在下面的条款中我也将涉及。

  在学习了本章内容以后,你将熟悉能够提高程序性能的几个原则,这些原则可以适用于你所写的任何程序。你将知道如何准确地防止在你的软件里出现不需要的对象,并且对编译器生成可执行代码的行为有着敏锐的感觉。

  俗话说有备无患(forewarned is forearmed)。所以把下面的内容想成是战斗前的准备。

条款十六:牢记80-20准则(80-20 rule)


  80-20准则说的是大约20%的代码使用了80%的程序资源;大约20%的代码耗用了大约80%的运行时间;大约20%的代码使用了80%的内存;大约20%的代码执行80%的磁盘访问;80%的维护投入于大约20%的代码上;通过无数台机器、操作系统和应用程序上的实验这条准则已经被再三地验证过。80-20准则不只是一条好记的惯用语,它更是一条有关系统性能的指导方针,它有着广泛的适用性和坚实的实验基础。

  当想到80-20准则时,不要在具体数字上纠缠不清,一些人喜欢更严格的90-10准则,而且也有一些试验证据支持它。不管准确地数字是多少,基本的观点是一样的:软件整体的性能取决于代码组成中的一小部分。

  当程序员力争最大化提升软件的性能时,80-20准则既简化了你的工作又使你的工作变得复杂。一方面80-20准则表示大多数时间你能够编写性能一般的代码,因为80%的时间里这些代码的效率不会影响到整个系统的性能,这会减少一些你的工作压力。而另一方面这条准则也表示如果你的软件出现了性能问题,你将面临一个困难的工作,因为你不仅必须找到导致问题的那一小块代码的位置,还必须寻找方法提高它们的性能。这些任务中最困难的一般是找到系统瓶颈。基本上有两个不同的方法用来寻找:大多数人用的方法和正确的方法。

  大多数人寻找瓶颈的方法就是猜。通过经验、直觉、算命纸牌、显灵板、传闻或者其它更荒唐的东西,一个又一个程序员一本正经地宣称程序的性能问题已被找到,因为网络的延迟,不正确的内存分配,编译器没有进行足够的优化或者一些笨蛋主管拒绝在关键的循环里使用汇编语句。这些评估总是以一种带有嘲笑的盛气凌人的架式发布出来,通常这些嘲笑者和他们的预言都是错误的。
大多数程序员在他们程序性能特征上的直觉都是错误的,因为程序性能特征往往不能靠直觉来确定。结果为提高程序各部分的效率而倾注了大量的精力,但是对程序的整体行为没有显著的影响。例如在程序里加入能够最小化计算量的奇特算法和数据结构,但是如果程序的性能限制主要在I/O上(I/O-bound)那么就丝毫起不到作用。采用I/O性能强劲的程序库代替编译器本身附加的程序库(参见条款23),如果程序的性能瓶颈主要在CPU上(CPU-bound),这种方法也不会起什么作用。
在这种情况下,面对运行速度缓慢或占用过多内存的程序,你该如何做呢?80-20准则的含义是胡乱地提高一部分程序的效率不可能有很大帮助。程序性能特征往往不能靠直觉确定,这个事实意味着试图猜出性能瓶颈不可能比胡乱地提高一部分程序的效率这种方法好到哪里去。那么会后什么结果呢?

  结果是用经验识别程序20%的部分只会导致你心痛。正确的方法是用profiler程序识别出令人讨厌的程序的20%部分。不是所有的工作都让profiler去做。你想让它去直接地测量你感兴趣的资源。例如如果程序太缓慢,你想让profiler告诉你程序的各个部分都耗费了多少时间。然后你关注那些局部效率能够被极大提高的地方,这也将会很大地提高整体的效率。

  profiler告诉你每条语句执行了多少次或各函数被调用了多少次,这是一个作用有限的工具。从提高性能的观点来看,你不用关心一条语句或一个函数被调用了多少次。毕竟很少遇到用户或程序库的调用者抱怨执行了太多的语句或调用了太多的函数。如果软件足够快,没有人关心有多少语句被执行,如果程序运行过慢,不会有人关心语句有多么的少。他们所关心的是他们厌恶等待,如果你的程序让他们等待,他们也会厌恶你。

  不过知道语句执行或函数调用的频繁程度,有时能帮助你洞察软件内部的行为。例如如果你建立了100个某种类型的对象,会发现你调用该类的构造函数有上千次,这个信息无疑是有价值的。而且语句和函数的调用次数能间接地帮助你理解不能直接测量的软件行为。例如如果你不能直接测量动态内存的使用,知道内存分配函数和内存释函数的调用频率也是有帮助的。(也就是,operators new, new[], delete, and delete[]—参见条款8)

  当然即使最好的profiler也是受其处理的数据所影响。如果用缺乏代表性的数据profile你的程序,你就不能抱怨profiler会导致你优化程序的那80%的部分,从而不会对程序通常的性能有什么影响。记住profiler仅能够告诉你在某一次运行(或某几次运行)时一个程序运行情况,所以如果你用不具有代表性的输入数据profile一个程序,那你所进行的profile也没有代表型。相反这样做很可能导致你去优化不常用的软件行为,而在软件的常用领域,则对软件整体的效率起相反作用(即效率下降)。

  防止这种不正确的结果,最好的方法是用尽可能多的数据profile你的软件。此外,你必须确保每组数据在客户(或至少是最重要的客户)如何使用软件的方面能有代表性。通常获取有代表性的数据是很容易的,因为许多客户都愿意让你用他们的数据进行profile。毕竟你是为了他们需求而优化软件。





不要问我结果 我只研究过程与思路
无双客栈
顶部
[广告] 记录自己的思想火花,留住每日的技术积累,尽在拥有属于自己独立域名的博客。
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:02  资料  个人空间  主页 短消息  加为好友 
条款十七:考虑使用lazy evaluation(懒惰计算法)

  从效率的观点来看,最佳的计算就是根本不计算,那好,不过如果你根本就不用进行计算的话,为什么还在程序开始处加入代码进行计算呢?并且如果你不需要进行计算,那么如何必须执行这些代码呢?

  关键是要懒惰。

  还记得么?当你还是一个孩子时,你的父母叫你整理房间。你如果象我一样,就会说“好的“,然后继续做你自己的事情。你不会去整理自己的房间。在你心里整理房间被排在了最后的位置,实际上直到你听见父母下到门厅来查看你的房间是否已被整理时,你才会猛跑进自己的房间里并用最快的速度开始整理。如果你走运,你父母可能不会来检查你的房间,那样的话你就能根本不用整理房间了。

  同样的延迟策略也适用于具有五年工龄的C++程序员的工作上。在计算机科学中,我们尊称这样的延迟为lazy evaluation(懒惰计算法)。当你使用了lazy evaluation后,采用此种方法的类将推迟计算工作直到系统需要这些计算的结果。如果不需要结果,将不用进行计算,软件的客户和你的父母一样,不会那么聪明。

  也许你想知道我说的这些到底是什么意思。也许举一个例子可以帮助你理解。lazy evaluation广泛适用于各种应用领域,所以我将分四个部分讲述。

引用计数

  class String { ... }; // 一个string 类 (the standard
  // string type may be implemented
  // as described below, but it
  // doesn't have to be)

  String s1 = "Hello";

  String s2 = s1; // 调用string拷贝构造函数

  通常string拷贝构造函数让s2被s1初始化后,s1和s2都有自己的”Hello”拷贝。这种拷贝构造函数会引起较大的开销,因为要制作s1值的拷贝,并把值赋给s2,这通常需要用new操作符分配堆内存(参见条款8),需要调用strcpy函数拷贝s1内的数据到s2。这是一个eager evaluation(热情计算):只因为到string拷贝构造函数,就要制作s1值的拷贝并把它赋给s2。然而这时的s2并不需要这个值的拷贝,因为s2没有被使用。

  懒惰能就是少工作。不应该赋给s2一个s1的拷贝,而是让s2与s1共享一个值。我们只须做一些记录以便知道谁在共享什么,就能够省掉调用new和拷贝字符的开销。事实上s1和s2共享一个数据结构,这对于client来说是透明的,对于下面的例子来说,这没有什么差别,因为它们只是读数据:

  cout << s1; // 读s1的值

  cout << s1 + s2; // 读s1和s2的值

  仅仅当这个或那个string的值被修改时,共享同一个值的方法才会造成差异。仅仅修改一个string的值,而不是两个都被修改,这一点是极为重要的。例如这条语句:

  s2.convertToUpperCase();

  这是至关紧要的,仅仅修改s2的值,而不是连s1的值一块修改。

  为了这样执行语句,string的convertToUpperCase函数应该制作s2值的一个拷贝,在修改前把这个私有的值赋给s2。在convertToUpperCase内部,我们不能再懒惰了:必须为s2(共享的)值制作拷贝以让s2自己使用。另一方面,如果不修改s2,我们就不用制作它自己值的拷贝。继续保持共享值直到程序退出。如果我们很幸运,s2不会被修改,这种情况下我们永远也不会为赋给它独立的值耗费精力。

  这种共享值方法的实现细节(包括所有的代码)在条款29中被提供,但是其蕴含的原则就是lazy evaluation:除非你却是需要,不去为任何东西制作拷贝。我们应该是懒惰的,只要可能就共享使用其它值。在一些应用领域,你经常可以这么做。

区别对待读取和写入

  继续讨论上面的reference-counting string对象。来看看使用lazy evaluation的第二种方法。考虑这样的代码:

  String s = "Homer's Iliad"; // 假设是一个
  // reference-counted string
  ...

  cout << s[3]; // 调用 operator[] 读取s[3]
  s[3] = 'x'; // 调用 operator[] 写入 s[3]

  首先调用operator[]用来读取string的部分值,但是第二次调用该函数是为了完成写操作。我们应能够区别对待读调用和写调用,因为读取reference-counted string是很容易的,而写入这个string则需要在写入前对该string值制作一个新拷贝。

  我们陷入了困难之中。为了能够这样做,需要在operator[]里采取不同的措施(根据是为了完成读取操作而调用该函数还是为了完成写入操作而调用该函数)。我们如果判断调用operator[]的context是读取操作还是写入操作呢?残酷的事实是我们不可能判断出来。通过使用lazy evaluation和条款30中讲述的proxy class,我们可以推迟做出是读操作还是写操作的决定,直到我们能判断出正确的答案。

Lazy Fetching(懒惰提取)

  第三个lazy evaluation的例子,假设你的程序使用了一些包含许多字段的大型对象。这些对象的生存期超越了程序运行期,所以它们必须被存储在数据库里。每一个对都有一个唯一的对象标识符,用来从数据库中重新获得对象:

  class LargeObject { // 大型持久对象
  public:
   LargeObject(ObjectID id); // 从磁盘中恢复对象

   const string& field1() const; // field 1的值
   int field2() const; // field 2的值
   double field3() const; // ...
   const string& field4() const;
   const string& field5() const;
   ...
  };

  现在考虑一下从磁盘中恢复LargeObject的开销:

  void restoreAndProcessObject(ObjectID id)
  {
   LargeObject object(id); // 恢复对象
   ...
  }

  因为LargeObject对象实例很大,为这样的对象获取所有的数据,数据库的操作的开销将非常大,特别是如果从远程数据库中获取数据和通过网络发送数据时。而在这种情况下,不需要读去所有数据。例如,考虑这样一个程序:

  void restoreAndProcessObject(ObjectID id)
  {
   LargeObject object(id);

   if (object.field2() == 0) {
    cout << "Object " << id << ": null field2.\n";
   }
  }

  这里仅仅需要filed2的值,所以为获取其它字段而付出的努力都是浪费。

  当LargeObject对象被建立时,不从磁盘上读取所有的数据,这样懒惰法解决了这个问题。不过这时建立的仅是一个对象“壳”,当需要某个数据时,这个数据才被从数据库中取回。这种“ demand-paged”对象初始化的实现方法是:

  class LargeObject {
  public:
   LargeObject(ObjectID id);

   const string& field1() const;
   int field2() const;
   double field3() const;
   const string& field4() const;
   ...

  private:
  ObjectID oid;

   mutable string *field1Value; //参见下面有关
   mutable int *field2Value; // "mutable"的讨论
   mutable double *field3Value;
   mutable string *field4Value;
   ...
  };

  LargeObject::LargeObject(ObjectID id)
   : oid(id), field1Value(0), field2Value(0), field3Value(0), ...
  {}

  const string& LargeObject::field1() const
  {
   if (field1Value == 0) {
    从数据库中为filed 1读取数据,使
    field1Value 指向这个值;
   }

   return *field1Value;
  }

  对象中每个字段都用一个指向数据的指针来表示,LargeObject构造函数把每个指针初始化为空。这些空指针表示字段还没有从数据库中读取数值。每个LargeObject成员函数在访问字段指针所指向的数据之前必须字段指针检查的状态。如果指针为空,在对数据进行操作之前必须从数据库中读取对应的数据。

  实现Lazy Fetching时,你面临着一个问题:在任何成员函数里都有可能需要初始化空指针使其指向真实的数据,包括在const成员函数里,例如field1。然而当你试图在const成员函数里修改数据时,编译器会出现问题。最好的方法是声明字段指针为mutable,这表示在任何函数里它们都能被修改,甚至在const成员函数里(参见Effective C++条款21)。这就是为什么在LargeObject里把字段声明为mutable。

  关键字mutalbe是一个比较新的C++ 特性,所以你用的编译器可能不支持它。如果是这样,你需要找到另一种方法让编译器允许你在const成员函数里修改数据成员。一种方法叫做”fake this”(伪造this指针),你建立一个指向non-const指针,指向的对象与this指针一样。当你想修改数据成员时,你通过”fake this”访问它:

  class LargeObject {
  public:
   const string& field1() const; // 没有变化
   ...

  private:
   string *field1Value; // 不声明为 mutable
   ... // 因为老的编译器不
  }; // 支持它

  const string& LargeObject::field1() const
  {
   // 声明指针, fakeThis, 其与this指向同样的对象
   // 但是已经去掉了对象的常量属性
   LargeObject * const fakeThis =
   const_cast<LargeObject* const>(this);

   if (field1Value == 0) {
    fakeThis->field1Value = // 这赋值是正确的,
    the appropriate data // 因为fakeThis指向的
    from the database; //对象不是const
   }

   return *field1Value;
  }

  这个函数使用了const_cast(参见条款2),去除了*this的const属性。如果你的编译器不支持cosnt_cast,你可以使用老式C风格的cast:

  // 使用老式的cast,来模仿mutable
  const string& LargeObject::field1() const
  {
   LargeObject * const fakeThis = (LargeObject* const)this;

   ... // as above
  }

  再来看LargeObject里的指针,必须把这些指针都初始化为空,然后每次使用它们时都必须进行测试,这是令人厌烦的而且容易导致错误发生。幸运的是使用smart(灵巧)指针可以自动地完成这种苦差使,具体内容可以参见条款28。如果在LargeObject里使用smart指针,你也将发现不再需要用mutalbe声明指针。这只是暂时的,因为当你实现smart指针类时你最终会碰到mutalbe。

Lazy Expression Evaluation(懒惰表达式计算)

  有关lazy evaluation的最后一个例子来自于数字程序。考虑这样的代码:

  template<class T>
  class Matrix { ... }; // for homogeneous matrices

  Matrix<int> m1(1000, 1000); // 一个 1000 * 1000 的矩阵
  Matrix<int> m2(1000, 1000); // 同上

  ...

  Matrix<int> m3 = m1 + m2; // m1+m2

  通常operator的实现使用eagar evaluation:在这种情况下,它会计算和返回m1与m2的和。这个计算量相当大(1000000次加法运算),当然系统也会分配内存来存储这些值。

  lazy evaluation方法说这样做工作太多,所以还是不要去做。而是应该建立一个数据结构来表示m3的值是m1与m2的和,在用一个enum表示它们间是加法操作。很明显,建立这个数据结构比m1与m2相加要快许多,也能够节省大量的内存。

  考虑程序后面这部分内容,在使用m3之前,代码执行如下:

  Matrix<int> m4(1000, 1000);

  ... // 赋给m4一些值

  m3 = m4 * m1;

  现在我们可以忘掉m3是m1与m2的和(因此节省了计算的开销),在这里我们应该记住m3是m4与m1运算的结果。不必说,我们不用进行乘法运算。因为我们是懒惰的,还记得么?

  这个例子看上去有些做作,因为一个好的程序员不会这样写程序:计算两个矩阵的和而不去用它们,但是它实际上又不象看上去的那么做作。虽然好程序员不会进行不需要的计算,但是在维护中程序员修改了程序的路径,使得以前有用的计算变得没有了作用,这种情况是常见的。通过定义使用前才进行计算的对象可以减少这种情况发生的可能性(参见Effective C++条款32),不过这个问题偶尔仍然会出现。

  但是如果这就是使用lazy evaluation唯一的时机,那就太不值得了。一个更常见的应用领域是当我们仅仅需要计算结果的一部分时。例如假设我们初始化m3的值为m1和m2的和,然后象这样使用m3:

  cout << m3[4]; // 打印m3的第四行

  很明显,我们不能再懒惰了,应该计算m3的第四行值。但是我们也不能雄心过大,我们没有理由计算m3第四行以外的结果;m3其余的部分仍旧保持未计算的状态直到确实需要它们的值。很走运,我们一直不需要。

  我们怎么可能这么走运呢?矩阵计算领域的经验显示这种可能性很大。实际上lazy evaluation就存在于APL语言中。APL是在1960年代发展起来语言,能够进行基于矩阵的交互式的运算。那时侯运行它的计算机的运算能力还没有现在微波炉里的芯片高,APL表面上能够进行进行矩阵的加、乘,甚至能够快速地与大矩阵相除!它的技巧就是lazy evaluation。这个技巧通常是有效的,因为一般APL的用户加、乘或除以矩阵不是因为他们需要整个矩阵的值,而是仅仅需要其一小部分的值。APL使用lazy evaluation 来拖延它们的计算直到确切地知道需要矩阵哪一部分的结果,然后仅仅计算这一部分。实际上,这能允许用户在一台根本不能完成eager evaluation的计算机上交互式地完成大量的计算。现在计算机速度很快,但是数据集也更大,用户也更缺乏耐心,所以很多现在的矩阵库程序仍旧使用lazy evaluation。

  公正地讲,懒惰有时也会失败。如果这样使用m3:

  cout << m3; // 打印m3所有的值

  一切都完了,我们必须计算m3的全部数值。同样如果修改m3所依赖的任一个矩阵,我们也必须立即计算:

  m3 = m1 + m2; // 记住m3是m1与m2的和

  m1 = m4; // 现在m3是m2与m1的旧值之和!

  这里我们我们必须采取措施确保赋值给m1以后不会改变m3。在Matrix<int>赋值操作符里,我们能够在改变m1之前捕获m3的值,或者我们可以给m 1的旧值制作一个拷贝让m3依赖于这个拷贝计算,我们必须采取措施确保m1被赋值以后m3的值保持不变。其它可能会修改矩阵的函数都必须用同样的方式处理。

  因为需要存储两个值之间的依赖关系,维护存储值、依赖关系或上述两者,重载操作符例如赋值符、拷贝操作和加法操作,所以lazy evaluation在数字领域应用得很多。另一方面运行程序时它经常节省大量的时间和空间。
总结

  以上这四个例子展示了lazy evaluation在各个领域都是有用的:能避免不需要的对象拷贝,通过使用operator[]区分出读操作,避免不需要的数据库读取操作,避免不需要的数字操作。但是它并不总是有用。就好象如果你的父母总是来检查你的房间,那么拖延整理房间将不会减少你的工作量。实际上,如果你的计算都是重要的,lazy evaluation可能会减慢速度并增加内存的使用,因为除了进行所有的计算以外,你还必须维护数据结构让lazy evaluation尽可能地在第一时间运行。在某些情况下要求软件进行原来可以避免的计算,这时lazy evaluation才是有用的。

  lazy evaluation对于C++来说没有什么特殊的东西。这个技术能被运用于各种语言里,几种语言例如著名的APL、dialects of Lisp(事实上所有的数据流语言)都把这种思想做为语言的一个基本部分。然而主流程序设计语言采用的是eager evaluation,C++是主流语言。不过C++特别适合用户实现lazy evaluation,因为它对封装的支持使得能在类里加入lazy evaluation,而根本不用让类的client知道。

  再看一下上述例子中的代码片段,你就能知道采用eager还是lazy evaluation,在类的interface并没有半点差别。这就是说我们可以直接用eager evaluation方法来实现一个类,但是如果你用通过profiler调查(参见条款16)显示出类实现有一个性能瓶颈,就可以用使用lazy evaluation的类实现来替代它(参见Effective C++条款34)。对于client来说所改变的仅是性能的提高(重新编译和链接后)。这是client喜欢的软件升级方式,它使你完全可以为懒惰而骄傲。





不要问我结果 我只研究过程与思路
无双客栈
顶部
[广告] 记录自己的思想火花,留住每日的技术积累,尽在拥有属于自己独立域名的博客。
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:02  资料  个人空间  主页 短消息  加为好友 
条款十八:分期摊还期望的计算

 在条款17中,我极力称赞懒惰的优点,尽可能地拖延时间,并且我解释说懒惰如何提高程序的运行效率。在这个条款里我将采用一种不同的态度。这里将不存在懒惰。我鼓励你让程序做的事情比被要求的还要多,通过这种方式来提高软件的性能。这个条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。例如下面这个模板类,用来表示放有大量数字型数据的一个集合:

  template<class NumericalType>
  class DataCollection {
  public:
   NumericalType min() const;
   NumericalType max() const;
   NumericalType avg() const;
   ...
  };

  假设min,max和avg函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这三种函数。使用eager evaluation(热情计算法),当min,max和avg函数被调用时,我们检测集合内所有的数值,然后返回一个合适的值。使用lazy evaluation(懒惰计算法),只有确实需要函数的返回值时我们才要求函数返回能用来确定准确数值的数据结构。使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当min,max或avg被调用时,我们可以不用计算就立刻返回正确的数值。如果频繁调用min,max和avg,我们把跟踪集合最小值、最大值和平均值的开销分摊到所有这些函数的调用上,每次函数调用所分摊的开销比eager evaluation或lazy evaluation要小。

  隐藏在over-eager evaluation后面的思想是如果你认为一个计算需要频繁进行。你就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求的开销。

  采用over-eager最简单的方法就是caching(缓存)那些已经被计算出来而以后还有可能需要的值。例如你编写了一个程序,用来提供有关雇员的信息,这些信息中的经常被需要的部分是雇员的办公隔间号码。而假设雇员信息存储在数据库里,但是对于大多数应用程序来说,雇员隔间号都是不相关的,所以数据库不对查抄它们进行优化。为了避免你的程序给数据库造成沉重的负担,可以编写一个函数findCubicleNumber,用来cache查找的数据。以后需要已经被获取的隔间号时,可以在cache里找到,而不用向数据库查询。

  以下是实现findCubicleNumber的一种方法:它使用了标准模板库(STL)里的map对象(有关STL参见条款35)。

  int findCubicleNumber(const string& employeeName)
  {
   // 定义静态map,存储 (employee name, cubicle number)
   // pairs. 这个 map 是local cache。
   typedef map<string, int> CubicleMap;
   static CubicleMap cubes;

   // try to find an entry for employeeName in the cache;
   // the STL iterator "it" will then point to the found
   // entry, if there is one (see Item 35 for details)
   CubicleMap::iterator it = cubes.find(employeeName);

   // "it"'s value will be cubes.end() if no entry was
   // found (this is standard STL behavior). If this is
   // the case, consult the database for the cubicle
   // number, then add it to the cache
   if (it == cubes.end()) {
    int cubicle =
    the result of looking up employeeName's cubicle
    number in the database;

    cubes[employeeName] = cubicle; // add the pair
    // (employeeName, cubicle)
     // to the cache
    return cubicle;
   }
   else {
    // "it" points to the correct cache entry, which is a
    // (employee name, cubicle number) pair. We want only
    // the second component of this pair, and the member
    // "second" will give it to us
    return (*it).second;
   }
  }

  不要陷入STL代码的实现细节里(你读完条款35以后,你会比较清楚)。应该把注意力放在这个函数蕴含的方法上。这个方法是使用local cache,用开销相对不大的内存中查询来替代开销较大的数据库查询。假如隔间号被不止一次地频繁需要,在findCubicleNumber内使用cache会减少返回隔间号的平均开销。

  (上述代码里有一个细节需要解释一下,最后一个语句返回的是(*it).second,而不是常用的it->second。为什么?答案是这是为了遵守STL的规则。简单地说,iterator是一个对象,不是指针,所以不能保证”->”被正确应用到它上面。不过STL要求”.”和”*”在iterator上是合法的,所以(*it).second在语法上虽然比较繁琐,但是保证能运行。)

  catching是一种分摊期望的计算开销的方法。Prefetching(预提取)是另一种方法。你可以把prefech想象成购买大批商品而获得的折扣。例如磁盘控制器从磁盘读取数据时,它们会读取一整块或整个扇区的数据,即使程序仅需要一小块数据。这是因为一次读取一大块数据比在不同时间读取两个或三个小块数据要快。而且经验显示如果需要一个地方的数据,则很可能也需要它旁边的数据。这是位置相关现象,正因为这种现象,系统设计者才有理由为指令和数据使用磁盘cache和内存cache,还有使用指令prefetch。

  你说你不关心象磁盘控制器或CPU cache这样低级的东西。没有问题。prefetch在高端应用里也有优点。例如你为dynamic数组实现一个模板,dynamic就是开始时具有一定的尺寸,以后可以自动扩展的数组,所以所有非负的索引都是合法的:

  template<class T> // dynamic数组
  class DynArray { ... }; // 模板

  DynArray<double> a; // 在这时, 只有 a[0]
  // 是合法的数组元素

  a[22] = 3.5; // a 自动扩展
  //: 现在索引0-22
  // 是合法的

  a[32] = 0; // 有自行扩展;
  // 现在 a[0]-a[32]是合法的

  一个DynArray对象如何在需要时自行扩展呢?一种直接的方法是分配所需的额外的内存。就象这样:

  template<class T>
  T& DynArray<T>::operator[](int index)
  {
   if (index < 0) {
    throw an exception; // 负数索引仍不
   } // 合法

   if (index >当前最大的索引值) {
    调用new分配足够的额外内存,以使得
    索引合法;
   }

   返回index位置上的数组元素;
  }

  每次需要增加数组长度时,这种方法都要调用new,但是调用new会触发operator new(参见条款8),operator new (和operator delete)的调用通常开销很大。因为它们将导致底层操作系统的调用,系统调用的速度一般比进程内函数调用的速度慢。因此我们应该尽量少使用系统调用。

  使用Over-eager evaluation方法,其原因我们现在必须增加数组的尺寸以容纳索引i,那么根据位置相关性原则我们可能还会增加数组尺寸以在未来容纳比i 大的其它索引。为了避免为扩展而进行第二次(预料中的)内存分配,我们现在增加DynArray的尺寸比能使i 合法的尺寸要大,我们希望未来的扩展将被包含在我们提供的范围内。例如我们可以这样编写DynArray::operator[]:

  template<class T>
  T& DynArray<T>::operator[](int index)
  {
   if (index < 0) throw an exception;

   if (index > 当前最大的索引值) {
    int diff = index – 当前最大的索引值;

    调用new分配足够的额外内存,使得
    index+diff合法;
   }

   返回index位置上的数组元素;
  }

  这个函数每次分配的内存是数组扩展所需内存的两倍。如果我们再来看一下前面遇到的那种情况,就会注意到DynArray只分配了一次额外内存,即使它的逻辑尺寸被扩展了两次:

  DynArray<double> a; // 仅仅a[0]是合法的

  a[22] = 3.5; // 调用new扩展
   // a的存储空间到索引44
  // a的逻辑尺寸
  // 变为23

  a[32] = 0; // a的逻辑尺寸
   // 被改变,允许使用a[32],
  // 但是没有调用new

  如果再次需要扩展a,只要提供的新索引不大于44,扩展的开销就不大。

  贯穿本条款的是一个常见的主题,更快的速度经常会消耗更多的内存。跟踪运行时的最小值、最大值和平均值,这需要额外的空间,但是能节省时间。Cache运算结果需要更多的内存,但是一旦需要被cache的结果时就能减少需要重新生成的时间。Prefetch需要空间放置被prefetch的东西,但是它减少了访问它们所需的时间。自从有了计算机就有这样的描述:你能以空间换时间。(然而不总是这样,使用大型对象意味着不适合虚拟内存或cache 页。在一些罕见的情况下,建立大对象会降低软件的性能,因为分页操作的增加(详见操作系统中内存管理 译者注),cache命中率降低,或者两者都同时发生。如何发现你正遭遇这样的问题呢?你必须profile, profile, profile(参见条款16)。

  在本条款中我提出的建议,即通过over-eager方法分摊预期计算的开销,例如caching和prefe-thing,这并不与我在条款17中提出的有关lazy evaluation的建议相矛盾。当你必须支持某些操作而不总需要其结果时,可以使用lazy evaluation用以提高程序运行效率。当你必须支持某些操作而其结果几乎总是被需要或被不止一次地需要时,可以使用over-eager用以提高程序运行效率。它们对性能的巨大提高证明在这方面花些精力是值得的。





不要问我结果 我只研究过程与思路
无双客栈
顶部
[广告] 记录自己的思想火花,留住每日的技术积累,尽在拥有属于自己独立域名的博客。
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:02  资料  个人空间  主页 短消息  加为好友 
条款十九:理解临时对象的来源

  当程序员之间进行交谈时,他们经常把仅仅需要一小段时间的变量称为临时变量。例如在下面这段swap(交换)例程里:

  template<class T>
  void swap(T& object1, T& object2)
  {
   T temp = object1;
   object1 = object2;
   object2 = temp;
  }

  通常把temp叫做临时变量。不过就C++而言,temp跟本不是临时变量,它只是一个函数的局部对象。

  在C++中真正的临时对象是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆(non-heap)对象会产生临时对象。这种未命名的对象通常在两种条件下产生:为了使函数成功调用而进行隐式类型转换和函数返回对象时。理解如何和为什么建立这些临时对象是很重要的,因为构造和释放它们的开销对于程序的性能来说有着不可忽视的影响。

  首先考虑为使函数成功调用而建立临时对象这种情况。当传送给函数的对象类型与参数类型不匹配时会产生这种情况。例如一个函数,它用来计算一个字符在字符串中出现的次数:

  // 返回ch在str中出现的次数
  size_t countChar(const string& str, char ch);

  char buffer[MAX_STRING_LEN];
  char c;

  // 读入到一个字符和字符串中,用setw
  // 避免缓存溢出,当读取一个字符串时
  cin >> c >> setw(MAX_STRING_LEN) >> buffer;

  cout << "There are " << countChar(buffer, c)
     << " occurrences of the character " << c
     << " in " << buffer << endl;

  看一下countChar的调用。第一个被传送的参数是字符数组,但是对应函数的正被绑定的参数的类型是const string&。仅当消除类型不匹配后,才能成功进行这个调用,你的编译器很乐意替你消除它,方法是建立一个string类型的临时对象。通过以buffer做为参数调用string的构造函数来初始化这个临时对象。countChar的参数str被绑定在这个临时的string对象上。当countChar返回时,临时对象自动释放。

  这样的类型转换很方便(尽管很危险-参见条款5),但是从效率的观点来看,临时string对象的构造和释放是不必要的开销。通常有两个方法可以消除它。一种是重新设计你的代码,不让发生这种类型转换。这种方法在条款5中被研究和分析。另一种方法是通过修改软件而不再需要类型转换,条款21讲述了如何去做。

  仅当通过传值(by value)方式传递对象或传递常量引用(reference-to-const)参数时,才会发生这些类型转换。当传递一个非常量引用(reference-to-non-const)参数对象,就不会发生。考虑一下这个函数:

  void uppercasify(string& str); // 把str中所有的字符
   // 改变成大写

  在字符计数的例子里,能够成功传递char数组到countChar中,但是在这里试图用char数组调用upeercasify函数,则不会成功:

  char subtleBookPlug[] = "Effective C++";

  uppercasify(subtleBookPlug); // 错误!

  没有为使调用成功而建立临时对象,为什么呢?

  假设建立一个临时对象,那么临时对象将被传递到upeercasify中,其会修改这个临时对象,把它的字符改成大写。但是对subtleBookPlug函数调用的真正参数没有任何影响;仅仅改变了临时从subtleBookPlug生成的string对象。无疑这不是程序员所希望的。程序员传递subtleBookPlug参数到uppercasify函数中,期望修改subtleBookPlug的值。当程序员期望修改非临时对象时,对非常量引用(references-to-non-const)进行的隐式类型转换却修改临时对象。这就是为什么C++语言禁止为非常量引用(reference-to-non-const)产生临时对象。这样非常量引用(reference-to-non-const)参数就不会遇到这种问题。

  建立临时对象的第二种环境是函数返回对象时。例如operator+必须返回一个对象,以表示它的两个操作数的和(参见Effective C++ 条款23)。例如给定一个类型Number,这种类型的operator+被这样声明:

  const Number operator+(const Number& lhs,
  const Number& rhs);

  这个函数的返回值是临时的,因为它没有被命名;它只是函数的返回值。你必须为每次调用operator+构造和释放这个对象而付出代价。(有关为什么返回值是const的详细解释,参见Effect-ive C++条款21)

  通常你不想付出这样的开销。对于这种函数,你可以切换到operator=,而避免开销。条款22告诉我们进行这种转换的方法。不过对于大多数返回对象的函数来说,无法切换到不同的函数,从而没有办法避免构造和释放返回值。至少在概念上没有办法避免它。然而概念和现实之间又一个黑暗地带,叫做优化,有时你能以某种方法编写返回对象的函数,以允许你的编译器优化临时对象。这些优化中,最常见和最有效的是返回值优化,这是条款20的内容。

  综上所述,临时对象是有开销的,所以你应该尽可能地去除它们,然而更重要的是训练自己寻找可能建立临时对象的地方。在任何时候只要见到常量引用(reference-to-const)参数,就存在建立临时对象而绑定在参数上的可能性。在任何时候只要见到函数返回对象,就会有一个临时对象被建立(以后被释放)。学会寻找这些对象构造,你就能显著地增强透过编译器表面动作而看到其背后开销的能力。





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:29  资料  个人空间  主页 短消息  加为好友 
条款二十:协助完成返回值优化

  一个返回对象的函数很难有较高的效率,因为传值返回会导致调用对象内的构造和析构函数(参见条款19),这种调用是不能避免的。问题很简单:一个函数要么为了保证正确的行为而返回对象要么就不这么做。如果它返回了对象,就没有办法摆脱被返回的对象。就说到这。

  考虑rational(有理数)类的成员函数operator*:

  class Rational {
  public:
   Rational(int numerator = 0, int denominator = 1);
   ...
   int numerator() const;
   int denominator() const;

  };

  // 有关为什么返回值是const的解释,参见条款6
  const Rational operator*(const Rational& lhs,
  const Rational& rhs);

  甚至不用看operator*的代码,我们就知道它肯定要返回一个对象,因为它返回的是两个任意数字的计算结果。这些结果是任意的数字。operator*如何能避免建立新对象来容纳它们的计算结果呢?这是不可能的,所以它必须得建立新对象并返回它。不过C++程序员仍然花费大量的精力寻找传说中的方法,能够去除传值返回的对象(参见Effective C++ 条款23和条款31)。

  有时人们会返回指针,从而导致这种滑稽的句法:

  // 一种不合理的避免返回对象的方法
  const Rational * operator*(const Rational& lhs,
  const Rational& rhs);

  Rational a = 10;
  Rational b(1, 2);

  Rational c = *(a * cool.gif; //你觉得这样很“正常”么?

  它也引发出一个问题。调用者应该删除函数返回对象的指针么?答案通常是肯定的,并且通常会导致资源泄漏。

  其它一些开发人员会返回引用。这种方法能产生可接受的句法,

  //一种危险的(和不正确的)方法,用来避免返回对象
  const Rational& operator*(const Rational& lhs,
  const Rational& rhs);

  Rational a = 10;
  Rational b(1, 2);

  Rational c = a * b; // 看上去很合理

  但是函数不能被正确地实现。一种尝试的方法是这样的:

  // 另一种危险的方法 (和不正确的)方法,用来
  // 避免返回对象
  const Rational& operator*(const Rational& lhs,
  const Rational& rhs)
  {
    Rational result(lhs.numerator() * rhs.numerator(),
     lhs.denominator() * rhs.denominator());
    return result;
  }

  这个函数返回的引用,其指向的对象已经存在了。它返回的是一个指向局部对象result的引用,当operator* 退出时result被自动释放。返回指向已被释放的对象的引用,这样的引用绝对不能使用。

  相信我:一些函数(operator*也在其中)必须要返回对象。这就是它们的运行方法。不要与其对抗,你不会赢的。

  你消除传值返回的对象的努力不会获得胜利。这是一场错误的战争。从效率的观点来看,你不应该关心函数返回的对象,你仅仅应该关心对象的开销。你所应该关心的是把你的努力引导到寻找减少返回对象的开销上来,而不是去消除对象本身(我们现在认识到这种寻求是无用的)。如果没有与这些对象相关的开销,谁还会关心有多少对象被建立呢?

  以某种方法返回对象,能让编译器消除临时对象的开销,这样编写函数通常是很普遍的。这种技巧是返回constructor argument而不是直接返回对象,你可以这样做:

  // 一种高效和正确的方法,用来实现
  // 返回对象的函数
  const Rational operator*(const Rational& lhs,
  const Rational& rhs)
  {
   return Rational(lhs.numerator() * rhs.numerator(),
    lhs.denominator() * rhs.denominator());
  }

  仔细观察被返回的表达式。它看上去好象正在调用Rational的构造函数,实际上确是这样。你通过这个表达式建立一个临时的Rational对象,
Rational(lhs.numerator() * rhs.numerator(),lhs.denominator() * rhs.denominator());
并且这是一个临时对象,函数把它拷贝给函数的返回值。

  返回constructor argument而不出现局部对象,这种方法还会给你带来很多开销,因为你仍旧必须为在函数内临时对象的构造和释放而付出代价,你仍旧必须为函数返回对象的构造和释放而付出代价。但是你已经获得了好处。C++规则允许编译器优化不出现的临时对象(temporary objects out of existence)。因此如果你在如下的环境里调用operator*:

  Rational a = 10;
  Rational b(1, 2);

  Rational c = a * b; // 在这里调用operator*

  编译器就会被允许消除在operator*内的临时变量和operator*返回的临时变量。它们能在为目标c分配的内存里构造return表达式定义的对象。如果你的编译器这样去做,调用operator*的临时对象的开销就是零:没有建立临时对象。你的代价就是调用一个构造函数――建立c时调用的构造函数。而且你不能比这做得更好了,因为c是命名对象,命名对象不能被消除(参见条款22)。不过你还可以通过把函数声明为inline来消除operator*的调用开销(不过首先参见Effective C++ 条款33):

  // the most efficient way to write a function returning
  // an object
  inline const Rational operator*(const Rational& lhs,
   const Rational& rhs)
  {
   return Rational(lhs.numerator() * rhs.numerator(),
    lhs.denominator() * rhs.denominator());
  }

  “好,不错”,你嘀咕地说,“优化,谁关心编译器能做什么?我想知道它们确实做了什么,Does any of this nonsense work with real compilers?” It does。这种特殊的优化――通过使用函数的return location(或者用一个在函数调用位置的对象来替代),来消除局部临时对象――是众所周知的和被普遍实现的。它甚至还有一个名字:返回值优化(return value optimizatio-n)。实际上这种优化有自己的名字本身就可以解释为什么它被广泛地使用。寻找C++编译器的程序员会问销售商编译器是否有返回值优化功能。如果一个销售商说有而另一个问“那是什么东西?”,第一个销售商就会有明显的竞争优势。啊,资本主义,有时你实在应该去爱它。(谨代表作者观点,译者坚决拥护四项基本原则 译者注 :-) )


附录:
  文中最后一段黑体部分如何翻译,我有些拿不准,请高手告知,为了容易理解,我在此附上此文最后一段的英文原文:

  "Yeah, yeah," you mutter, "optimization, schmoptimization. Who cares what compilers can do? I want to know what they do do. Does any of this nonsense work with real compilers?" It does. This particular optimization — eliminating a local temporary by using a function's return location (and possibly replacing that with an object at the function's call site) — is both well-known and commonly implemented. It even has a name: the return value optimization. In fact, the existence of a name for this optimization may explain why it's so widely available. Programmers looking for a C++ compiler can ask vendors whether the return value optimization is implemented. If one vendor says yes and another says "The what?," the first vendor has a notable competitive advantage. Ah, capitalism. Sometimes you just gotta love it.

以下是某位网友的翻译。(Sorry,名字及不得了)

  “好好好,”你嘀咕着,“optimization,还有那个什么schmoptimization。但谁会关心编译器应该可以做什么?我只想知道编译器现在能够做什么。那些该死的什么优化到底能不能在实际的编译器中工作?”回答是确实可以。这种很特别的优化方法——即通过利用函数在return处的位置来消除一些局部临时对象(并可能就是利用函数调用处的一个对象的空间来顶替局部临时对象的空间)的方法——其实已经是众所周知并被广泛实现的了。这个方法还有一个名称:返回值优化(return value optimization)。实际上,“它有一个名称”这个事实或许就是它被广泛实现的原因。试想程序员在选择C++编译器的时候,向销售商询问其编译器是否实现了返回值优化(return value optimization)的功能。如果一个销售商说是的我们的产品实现了这个功能,而另一个销售商却无知的反问道“什么功能?”;那么第一个销售商显然就具有了明显的竞争优势。呃,资本主义,有时候你必须热爱它。(译注:这里作者似乎在以调侃的方式说明,资本主义营造的这种商业竞争状况,使得返回值优化(return values optimization)这样一个技术很快的被编译器厂商用来作为对抗竞争对手的砝码——特别是一旦这个技术有了个冠冕堂皇的名称的时候,这个名称马上就可以被列在宣传广告中,招揽客户。)





不要问我结果 我只研究过程与思路
无双客栈
顶部
无双
荣誉斑竹
Rank: 14Rank: 14Rank: 14Rank: 14
天才猪



UID 4
精华 84
积分 5863
帖子 11390
活跃指数 0
LU金币 4248 个
LU金条 0 个
阅读权限 200
注册 2003-9-16
来自 杭州
 
发表于 2003-9-28 22:34  资料  个人空间  主页 短消息  加为好友 
条款二十一:通过重载避免隐式类型转换

  以下是一段代码,如果没有什么不寻常的原因,实在看不出什么东西:

  class UPInt { // unlimited precision
  public: // integers 类
   UPInt();
   UPInt(int value);
   ...
  };

  //有关为什么返回值是const的解释,参见Effective C++ 条款21
  const UPInt operator+(const UPInt& lhs, const UPInt& rhs);

  UPInt upi1, upi2;

  ...

  UPInt upi3 = upi1 + upi2;

  这里还看不出什么令人惊讶的东西。upi1 和upi2都是UPInt对象,所以它们之间相加就会调用UPInts的operator函数。

  现在考虑下面这些语句:

  upi3 = upi1 + 10;
  upi3 = 10 + upi2;

  这些语句也能够成功运行。方法是通过建立临时对象把整形数10转换为UPInts(参见条款19)。

  让编译器完成这种类型转换是确实是很方便,但是建立临时对象进行类型转换工作是有开销的,而我们不想承担这种开销。就象大多数人只想从政府那里受益而不想为此付出一样,大多数C++程序员希望进行没有临时对象开销的隐式类型转换。但是在计算领域里发生不了赤字现象,我们如何能这么做呢?

  让我们回退一步,认识到我们的目的不是真的要进行类型转换,而是用UPint和int做为参数调用operator。隐式类型转换只是用来达到目的的手段,但是我们不要混淆手段与目的。还有一种方法可以成功进行operator的混合类型调用,它将消除隐式类型转换的需要。如果我们想要把UPInt和int对象相加,通过声明如下几个函数达到这个目的,每一个函数有不同的参数类型集。

  const UPInt operator+(const UPInt& lhs, // add UPInt
  const UPInt& rhs); // and UPInt

  const UPInt operator+(const UPInt& lhs, // add UPInt
   int rhs); // and int

  const UPInt operator+(int lhs, // add int and
  const UPInt& rhs); // UPInt

  UPInt upi1, upi2;

  ...

  UPInt upi3 = upi1 + upi2; // 正确,没有由upi1 或 upi2
    // 生成的临时对象

  upi3 = upi1 + 10; // 正确, 没有由upi1 or 10
    // 生成的临时对象

  upi3 = 10 + upi2; //正确, 没有由10 or upi2
   //生成的临时对象。

  一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:
const UPInt operator+(int lhs, int rhs); // 错误!

  这个想法是合情合理的。对于UPInt和int类型,我们想要用所有可能的组合来重载operator函数。上面只给出了三种重载函数,唯一漏掉的是带有两个int参数的operator,所以我们想把它加上。

  有道理么?在C++中有一条规则是每一个重载的operator必须带有一个用户定义类型(user-defined type)的参数。int不是用户定义类型,所以我们不能重载operator成为仅带有此类型参数的函数。(如果没有这条规则,程序员将能改变预定义的操作,这样做肯定把程序引入混乱的境地。比如企图重载上述的operator,将会改变int类型相加的含义。)

  利用重载避免临时对象的方法不只是用在operator函数上。比如在大多数程序中,你想允许在所有能使用string对象的地方,也一样可以使用char*,反之亦然。同样如果你正在使用numerical(数字)类,例如complex(参见条款35),你想让int和double这样的类型可以使用在numerical对象的任何地方。因此任何带有string、char*、complex参数的函数可以采用重载方式来消除类型转换。

  不过,必须谨记80-20规则(参见条款16)。没有必要实现大