星期二, 五月 08, 2007

将强制转型减到最少

C++ 的规则设计为保证不会发生类型错误。在理论上,如果你的程序想顺利地通过编译,你就不应该试图对任何对象做任何不 安全 的或无意义的操作。这是一个非常有价值的保证,你不应该轻易地放弃它。

  不幸的是,强制转型破坏了类型系统。它会引起各种各样的麻烦,其中一些容易被察觉,另一些则格外地微妙。如果你从 C, Java ,或 C# 转到 C++,请一定注意,因为强制转型在那些语言中比在 C++ 中更有必要,危险也更少。但是 C++ 不是 C,也不是 Java,也不是 C#。在这一语言中,强制转型是一个你必须全神贯注才可以靠近的特性。

  我们就从回顾强制转型的语法开始,因为对于同样的强制转型通常有三种不同的写法。C 风格(C-style)强制转型如下:

  (T) expression // cast expression to be of type T

  函数风格(Function-style)强制转型使用这样的语法:

  T(expression) // cast expression to be of type T

  这两种形式之间没有本质上的不同,它纯粹就是一个把括号放在哪的问题。我把这两种形式称为旧风格(old-style)的强制转型。

  C++ 同时提供了四种新的强制转型形式(通常称为新风格的或 C++ 风格的强制转型):

  const_cast(expression)

  dynamic_cast(expression)

  reinterpret_cast(expression)

  static_cast(expression)

  每一种适用于特定的目的:

  ·const_cast 一般用于强制消除对象的常量性。它是唯一能做到这一点的 C++ 风格的强制转型。

  ·dynamic_cast 主要用于执行“安全的向下转型(safe downcasting)”,也就是说,要确定一个对象是否是一个继承体系中的一个特定类型。它是唯一不能用旧风格语法执行的强制转型。也是唯一可能有重大运行时代价的强制转型。(过一会儿我再提供细节。)

  ·reinterpret_cast 是特意用于底层的强制转型,导致实现依赖(implementation-dependent)(就是说,不可移植)的结果,例如,将一个指针转型为一个整数。这样的强制转型在底层代码以外应该极为罕见。在本书中我只用了一次,而且还仅仅是在讨论你应该如何为裸内存(raw memory)写一个调谐分配者(debugging allocator)的时候。

  ·static_cast 可以被用于强制隐型转换(例如,non-const 对象转型为 const 对象(就像 Item 3 中的),int 转型为 double,等等)。它还可以用于很多这样的转换的反向转换(例如,void* 指针转型为有类型指针,基类指针转型为派生类指针),但是它不能将一个 const 对象转型为 non-const 对象。(只有 const_cast 能做到。)

  旧风格的强制转型依然合法,但是新的形式更可取。首先,在代码中它们更容易识别(无论是人还是像 grep 这样的工具都是如此),这样就简化了在代码中寻找类型系统被破坏的地方的过程。第二,更精确地指定每一个强制转型的目的,使得编译器诊断使用错误成为可能。例如,如果你试图使用一个 const_cast 以外的新风格强制转型来消除常量性,你的代码将无法编译。

  当我要调用一个 explicit 构造函数用来传递一个对象给一个函数的时候,大概就是我仅有的使用旧风格的强制转换的时候。例如:

  class Widget {

  public:

  explicit Widget(int size);

  ...

  };

  void doSomeWork(const Widget& w);

  doSomeWork(Widget(15)); // create Widget from int

  // with function-style cast   

  doSomeWork(static_cast(15)); // create Widget from int

  // with C++-style cast

  由于某种原因,有条不紊的对象创建感觉上不像一个强制转型,所以在这个强制转型中我多半会用函数风格的强制转型代替 static_cast。反过来说,在你写出那些导致核心崩溃(core dump)的代码时,你通常都感觉你有恰当的原因,所以你最好忽略你的感觉并始终都使用新风格的强制转型。

  很多程序员认为强制转型除了告诉编译器将一种类型看作另一种之外什么都没做,但这是错误的。任何种类的类型转换(无论是通过强制转型的显式的还是编译器添加的隐式的)都会导致运行时的可执行代码。例如,在这个代码片断中,   

  int x, y;

  ...

  double d = static_cast(x)/y; // divide x by y, but use

  // floating point division

  int x 到 double 的强制转型理所当然要生成代码,因为在大多数系统架构中,一个 int 的底层表示与 double 的不同。这可能还不怎么令人吃惊,但是下面这个例子可能会让你稍微开一下眼:   

  class Base { ... };   

  class Derived: public Base { ... };   

  Derived d;  

  Base *pb = &d; // implicitly convert Derived* → Base*

  这里我们只是创建了一个指向派生类对象的基类指针,但是有时候,这两个指针的值并不相同。在当前情况下,会在运行时在 Derived* 指针上应用一个偏移量以得到正确的 Base* 指针值。

  这后一个例子表明一个单一的对象(例如,一个类型为 Derived 的对象)可能会有不止一个地址(例如,它的被一个 Base* 指针指向的地址和它的被一个 Derived* 指针指向的地址)。这在 C 中就不会发生,也不会在 Java 中发生,也不会在 C# 中发生,它仅在 C++ 中发生。实际上,如果使用了多继承,则一定会发生,但是在单继承下也会发生。与其它事情合在一起,就意味着你应该总是避免对 C++ 如何摆放事物做出假设,你当然也不应该基于这样的假设执行强制转型。例如,将一个对象的地址强制转型为 char* 指针,然后对其使用指针运算,这几乎总是会导致未定义行为。

  但是请注意我说一个偏移量是“有时”被需要。对象摆放的方法和他们的地址的计算方法在不同的编译器之间有所变化。这就意味着仅仅因为你的“我知道事物是如何摆放的”而使得强制转型能工作在一个平台上,并不意味着它们也能在其它平台工作。这个世界被通过痛苦的道路学得这条经验的可怜的程序员所充满。 关于强制转型的一件有趣的事是很容易写出看起来对(在其它语言中也许是对的)实际上错的东西。例如,许多应用框架(application framework)要求在派生类中实现虚成员函数时要首先调用它们的基类对应物。假设我们有一个 Window 基类和一个 SpecialWindow 派生类,它们都定义了虚函数 onResize。进一步假设 SpecialWindow 的 onResize 被期望首先调用 Window 的 onResize。这就是实现这个的一种方法,它看起来正确实际并不正确:   

  class Window { // base class

  public:

  virtual void onResize() { ... } // base onResize impl

  ...

  };

  class SpecialWindow: public Window { // derived class

  public:

  virtual void onResize() { // derived onResize impl;

  static_cast(*this).onResize(); // cast *this to Window,

  // then call its onResize;

  // this doesn’t work!  

  ... // do SpecialWindow-

  } // specific stuff   

  ...   

  };

  我突出了代码中的强制转型。(这是一个新风格的强制转型,但是使用旧风格的强制转型也于事无补。)正像你所期望的,代码将 *this 强制转型为一个 Window。因此调用 onResize 的结果就是调用 Window::onResize。你也许并不期待它没有调用当前对象的那个函数!作为替代,强制转型创建了一个 *this 的基类部分的新的,临时的拷贝,然后调用这个拷贝的 onResize!上面的代码没有调用当前对象的 Window::onResize,然后再对这个对象执行 SpecialWindow 特有的动作——它在对当前对象执行 SpecialWindow 特有的动作之前,调用了当前对象的基类部分的一份拷贝的 Window::onResize。如果 Window::onResize 改变了当前对象(可能性并不小,因为 onResize 是一个 non-const 成员函数),当前对象并不会改变。作为替代,那个对象的一份拷贝被改变。如果 SpecialWindow::onResize 改变了当前对象,无论如何,当前对象将被改变,导致的境况是那些代码使当前对象进入一种病态,没有做基类的变更,却做了派生类的变更。

  解决方法就是消除强制转型,用你真正想表达的来代替它。你不应该哄骗编译器将 *this 当作一个基类对象来处理,你应该调用当前对象的 onResize 的基类版本。就是这样:   

  class SpecialWindow: public Window {

  public:

  virtual void onResize() {

  Window::onResize(); // call Window::onResize

  ... // on *this

  }

  ...   

  };

  这个例子也表明如果你发现自己要做强制转型,这就是你可能做错了某事的一个信号。在你想用 dynamic_cast 时尤其如此。

  在探究 dynamic_cast 的设计意图之前,值得留意的是很多 dynamic_cast 的实现都相当慢。例如,至少有一种通用的实现部分地基于对类名字进行字符串比较。如果你在一个位于四层深的单继承体系中的对象上执行 dynamic_cast,在这样一个实现下的每一个 dynamic_cast 都要付出相当于四次调用 strcmp 来比较类名字的成本。对于一个更深的或使用了多继承的继承体系,付出的代价会更加昂贵。一些实现用这种方法工作是有原因的(它们不得不这样做以支持动态链接)。尽管如此,除了在普遍意义上警惕强制转型外,在性能敏感的代码中,你应该特别警惕 dynamic_casts。

  对 dynamic_cast 的需要通常发生在这种情况下:你要在一个你确信为派生类的对象上执行派生类的操作,但是你只能通过一个基类的指针或引用来操控这个对象。有两个一般的方法可以避免这个问题。

第一个,使用存储着直接指向派生类对象的指针的容器,从而消除通过基类接口操控这个对象的需要。例如,如果在我们的 Window/SpecialWindow 继承体系中,只有 SpecialWindows 支持 blinking,对于这样的做法:   

  class Window { ... };   

  class SpecialWindow: public Window {

  public:

  void blink();

  ...

  };

  typedef // see Item 13 for info

  std::vector > VPW; // on tr1::shared_ptr   

  VPW winPtrs;

  ...   

  for (VPW::iterator iter = winPtrs.begin(); // undesirable code:

  iter != winPtrs.end(); // uses dynamic_cast

  ++iter) {

  if (SpecialWindow *psw = dynamic_cast(iter->get()))

  psw->blink();

  }

  设法用如下方法代替:   

  typedef std::vector > VPSW;   

  VPSW winPtrs;   

  ...   

  for (VPSW::iterator iter = winPtrs.begin(); // better code: uses

  iter != winPtrs.end(); // no dynamic_cast

  ++iter)

  (*iter)->blink();

  当然,这个方法不允许你在同一个容器中存储所有可能的 Window 的派生类的指针。为了与不同的窗口类型一起工作,你可能需要多个类型 安全 (type-safe)的容器。

  一个候选方法可以让你通过一个基类的接口操控所有可能的 Window 派生类,就是在基类中提供一个让你做你想做的事情的虚函数。例如,尽管只有 SpecialWindows 能 blink,在基类中声明这个函数,并提供一个什么都不做的缺省实现或许是有意义的:   

  class Window {

  public:

  virtual void blink() {} // default impl is no-op;

  ... // see Item 34 for why

  }; // a default impl may be

  // a bad idea   

  class SpecialWindow: public Window {

  public:

  virtual void blink() { ... }; // in this class, blink

  ... // does something

  };   

  typedef std::vector > VPW;   

  VPW winPtrs; // container holds

  // (ptrs to) all possible

  ... // Window types   

  for (VPW::iterator iter = winPtrs.begin();

  iter != winPtrs.end();

  ++iter) // note lack of

  (*iter)->blink(); // dynamic_cast

  无论哪种方法——使用类型 安全 的容器或在继承体系中上移虚函数——都不是到处适用的,但在很多情况下,它们提供了 dynamic_casting 之外另一个可行的候选方法。当它们可用时,你应该加以利用。

  你应该绝对避免的一件东西就是包含了极联 dynamic_casts 的设计,也就是说,看起来类似这样的任何东西:   

  class Window { ... };   

  ... // derived classes are defined here   

  typedef std::vector > VPW;   

  VPW winPtrs;   

  ...   

  for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)

  {

   if (SpecialWindow1 *psw1 = dynamic_cast(iter->get())) { ... }   

   else if (SpecialWindow2 *psw2 = dynamic_cast(iter->get())) { ... }   

   else if (SpecialWindow3 *psw3 = dynamic_cast(iter->get())) { ... }   

  ...

  }

  这样的 C++ 会生成的代码又大又慢,而且很脆弱,因为每次 Window 类继承体系发生变化,所有这样的代码都要必须被 检查 ,以确认是否需要更新。(例如,如果增加了一个新的派生类,在上面的极联中或许就需要加入一个新的条件分支。)看起来类似这样的代码应该总是用基于虚函数的调用的某种东西来替换。 好的 C++ 极少使用强制转型,但在通常情况下完全去除也不实际。例如,从 int 到 double 的强制转型,就是对强制转型的合理运用,虽然它并不是绝对必要。(那些代码应该被重写,声明一个新的类型为 double 的变量,并用 x 的值进行初始化。)就像大多数可疑的结构成分,强制转型应该被尽可能地隔离,典型情况是隐藏在函数内部,用函数的接口保护调用者远离内部的污秽的工作。

  Things to Remember

  ·避免强制转型的随时应用,特别是在性能敏感的代码中应用 dynamic_casts,如果一个设计需要强制转型,设法开发一个没有强制转型的侯选方案。

  ·如果必须要强制转型,设法将它隐藏在一个函数中。客户可以用调用那个函数来代替在他们自己的代码中加入强制转型。

  ·尽量用 C++ 风格的强制转型替换旧风格的强制转型。它们更容易被注意到,而且他们做的事情也更加明确。

没有评论: