公告:本站提供编程开发方面的技术交流与分享,打造最佳教程网,希望能为您排忧解难!

Effective C++第27条:尽量不要使用类型转换

Effective C++第27条:尽量不要使用类型转换

更新时间:2013-05-26 12:18:05 |

第27条:尽量不要使用类型转换

C++ 的设计初衷之一就是:确保代码远离类型错误。从理论上来讲,如果你的程序顺利地通过了编译,那么它就不会对任何对象尝试去做任何不安全或无意义的操作。这是一项非常有价值的保证。你不应该轻易放弃它。

然而遗憾的是,转型扰乱了原本井然有序的类型系统。它可以带来无穷无尽的问题,一些是显而易见的,但另一些则是极难察觉的。如果你是一名从 C 、 Java 或者 C# 转向 C++ 的程序员的话,那么请注意了,因为相对 C++ 而言,转型在这些语言中更加重要,而且带来的危险也小得多。但是 C++ 不是 C ,也不是 Java 、 C# 。在 C++ 中,转型是需要你格外注意的议题。

让我们从复习转型的语法开始,因为实现转型有三种不同但是等价的方式。 C 风格的转型是这样的:

(T) 表达式  // 将表达式转型为 T 类型的

函数风格的转型如下:

T( 表达式 )  // 将表达式转型为 T 类型的

两者之间在含义上没有任何的区别。这仅仅是你把括号放在哪儿的问题。我把这两种形式称为“怀旧风格的转型”。

C++ 还提供了四种新的转型的形式(通常称为“现代风格”或“ C++ 风格”的转型):

const_cast< T>( 表达式 )
dynamic_cast< T>( 表达式 )
reinterpret_cast< T>( 表达式 )
static_cast< T>( 表达式 )

四者各司其职:

const_cast 通常用来脱去对象的恒定性。 C++ 风格转型中只有它能做到这一点。
dynamic_cast 主要用于进行“安全的向下转型”,也就是说,它可以决定一个对象的类型是否属于某一个特定的类型继承层次结构中。它是唯一一种怀旧风格语法所无法替代的转型。它也是唯一一种可能会带来显著运行时开销的转型。(稍候会具体讲解。) 
 reinterpret_cast 是为底层转型而特别设置的,这类转型可能会依赖于实现方式,比如说,将一个指针转型为一个 int 值。除了底层代码以外,要格外注意避免这类转型。此类转型在这本书中我只用过一次,而也是在讨论如何编写一个针对未分配内存的调试分配器(参见第 50 条)用到。
static_cast 可以用于强制隐式转换(比如说,将一个非 const 的对象转换为 const 对象(就像第 3 条中的一样), int 转换为 double ,等等)。它可以用于大多数这类转换的逆操作(比如说, void* 指针转换为包含类型的指针,指向基类的指针转换为指向继承类的指针),但是它不能进行从 const 到非 const 对象的转型。(只有 const_cast 可以。)
怀旧风格的转型在 C++ 中仍然是合法的,但是这里更推荐使用新形式。首先,它们在代码中更加易于辨认(不仅对人,而且对 grep 这样的工具也是如此),对于那些类型系统乱成一团的代码,这样做可以减少我们为类型头疼的时间。其次,对每次转型的目的更加细化,使得编译器主动诊断用法错误成为可能。比如说,如果你尝试通过转型脱去恒定性的话,你只能使用 const_cast , 如果你尝试使用其它现代风格的转型,你的代码就不会通过编译。

需要使用怀旧风格转型的唯一一个地方就是:调用一 个 explicit 的构造函数来为一个函数传递一个对象。比如:

class Widget {
public:
  explicit Widget(int size);
  ...
};

void doSomeWork(const Widget& w); 
doSomeWork(Widget(15)); // 使用函数风格转型创建一个int的Widget

doSomeWork(static_cast<Widget>(15));  // 使用 C++ 风格转型创建一个int的Widget  

出于某些原因,手动创建一个对象“感觉上”并不类似于一次转型,所以在这种情况下应更趋向于使用函数风格的 转型而不是 static_cast 。同时,当你写下的代码可能会导致 core dump[1] 时,你仍然会感觉你有充足的理由那样做,因此你可能要忽略你的直觉,自始至终使用现代风格的转型。

许多程序员相信转型只是告诉编译器将一个类型作为另一种来对待,仅此而已,但殊不知任何种类的类型转换(无论是显式的还是通过编译器隐式进行的)通常会在运行时引入一些新的需要执行的代码。比如说,再下面的代码片断中:

int x, y;
...
double d = static_cast<double>(x)/y;  // x 除以 y ,用浮点数保存商值

int x 向 double 的转型几乎一定要引入新的代码,因为在绝大多数架构中, int 与 double 的底层表示模式是不同的。这并不那么令人吃惊,但是下面的示例也许会让你的眼界更加开阔些:

class Base { ... }; 

class Derived: public Base { ... };

Derived d;

Base *pb = &d;          // 隐式转换: Derived* => base*

这里我们创建了一基类的指针,并让其指向了一个派生类的对象,但是某些时候,这两个指针值并不会保持一致。如果真的这样了,系统会为 Derived* 指针应用一个偏移值来取得正确的 Base* 指针的值。

最近的一个实例显示了一个单独的对象(比如一个 Derived 的对象)可能会拥有一个以上的地址(比如,一个 Base* 指针指向它的地址和一个 Derived* 指针指向它的地址)。这件事在 C 语言中是绝不会发生的。同样在 Java 或 C# 中均不会发生。但在 C++ 中的的确确的发生了。实际上,在使用多重继承时这件事是必然的,但在单继承环境下也有可能发生。这意味着你应该避免去假设或推定 C++ 放置对象的方式,同时你应该避免基于这样的假设来进行转型。比如,如果你将对象地址转型为 char* 指针,然后再对其进行指针运算,通常都会使程序陷入无法预知的行为。

但是请注意,我说过“某些时候”才需要引入偏移值。对象放置的方法、它们的地址的计算方法都是因编译器而异的。这就意味着,仅仅由于你“知道对象如何放置”,你对在某一个平台上转型的做法可能充满信心,但它在另一些平台上却是一钱不值。世界上有许多程序员为此付出了惨痛的代价。

关于转型的一件有趣事情是:你很容易编写一些 “看上去正确”的东西,但实际上它们是错误的。举例说,许多应用程序框架需要在派生类中实现一个虚拟成员函数,并首先让这些函数去调用基类中对应的函数。假设我们有一个 Window 基类和一个 SpecialWindow 派生类,两者都定义了虚函数 onResize 。继续假设: SpecialWindow 的 onResize 首先会调用 Window 的 onResize 。以下是实现方法,它乍看上去是正确的,其实不然:

class Window {  // 基类
public:
  virtual void onResize() { ... } // 基类 onResize 的实现
  ...
}; 

class SpecialWindow: public Window { // 派生类
public:
  virtual void onResize() {  // 派生类 onResize 的实现
    static_cast<Window>(*this).onResize();
    // 将 *this 转型为Window ,然后调用它的onResize ,这样不会正常工作!
    ...   // 完成 SpecialWindow 独有的任务
  }
  ...
};

上面代码中的转型操作已经用黑体字标出。(这是一个现代风格的转型,但是如果使用怀旧风格的话也不会带来任何影响。)如果一切如你所愿,代码将会 将 *this 转型为一个 Window ,同时此过程带来一次 onResize 的调用,将会是 Window::onResize 。你一定没有想到,当前对象并没有调用这一函数。取而代之的是,转型过程创建了一个新的, *this 中基类部分的一个临时副本,然后调用这一副本的 onResize 。上面的代码将不会调用当前对象的 Window::onResize ,然后进行对象中的具体到 SpecialWindow 的动作;而是再对当前对象进行 SpecialWindow 行为之前,去调用当前对象的基类部分的副本中的 Window::onResize 。如果 Window::onResize 希望修改当前对象(这也不是完全不可行,因为 onResize 是一个非 const 的成员函数),实际上当前对象不会受到任何影响。取而代之的是,这一对象的那个副本将会被修改。然而,如果 SpecialWindow::onResize 希望修改当前对象,当前对象将会被修改,这将导致下面的情景:代码将会使当前对象处于病态之中——它基类部分的修改没有进行,而派生类部分的修改却完成了。

解决方案就是:避免转型。转而使用你真正需要的类型。你并不希望欺骗编译器将一个 *this 识别为一个基类对象;你需要做的是:对当前对象调用 onResize 的基类版本。所以你应该这样编写:

class SpecialWindow: public Window {
public:
  virtual void onResize() {
    Window::onResize();  // 对 *this 调用 Window::onResize
    ...
  }
  ...
};

这个示例同时告诉我们:如果你需要进行转型,上面的代码就会发出警告:你可能正在以错误的方式工作。尤其是 dynamic_cast 。

在深入探究 dynamic_cast 的实现设计方式之前,有必要先了解一下大多数 dynamic_cast 的实现运行的速度是非常缓慢的。比如说,至少有一种通用实现是通过比较各个类名的字符串。如果你正在针对一个四层深的单一继承层次结构中的一个对象进行 dynamic_cast ,那么这种实现方式下,每一次 dynamic_cast 都会占用四次调用 strcmp 的时间用于比较类名。显然地,更深的或者使用多重继承的层次结构的开销将会更为显著。一些实现以这种方式运行也是有它的根据的(它们这样做是为了支持动态链接)。在对性能要求较高的代码中,要在整体上时刻对转型持谨慎的态度,你应该特别谨慎地使用 dynamic_cast 。

一般说来,在你期望对那些你确认属于派生类的对象进行派生类操作,但此时你只有一个指针或者一个指向基类的引用能操作这一对象, dynamic_cast 将派上用场。一般有两条途径来避免这一问题。

首先,可以使用容器来保存直接指向派生类对象的指针(通常是指能指针,参见第 13 条),这样就在对这些对象进行操作时就无需通过基类接口。比如说,在我们的 Window/SpecialWindow 层次结构中,如果只有 SpecialWindow 支持闪烁效果,我们也许可以这样做:

class Window { ... };

class SpecialWindow: public Window {
public:
  void blink();
  ...
};

 

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
// 关于 tr1::shared_ptr 的信息请参见第 13 条 

VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();  // 不好的代码。
     iter != winPtrs.end();  // 使用 dynamic_cast
     ++iter) {
  if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()))
     psw->blink();
}

但是有更好的解决方案:

typedef std::vector<std::tr1::shared_ptr<SpecialWindow> > VPSW;

VPSW winPtrs;
...
for (VPSW::iterator iter = winPtrs.begin();// 更好的代码
     iter != winPtrs.end();    // 无需 dynamic_cast
     ++iter)
  (*iter)->blink();

当然,在使用这一方案时,在同一容器中放置 的 Window 派生对象的类型是受到限制的。为了使用更多的 Window 类型,你可能需要多个类型安全的容器。

一个可行的替代方案是:在基类中提供虚函数,然后按需配置。这样对于所有可能的 Window 派生类型,你都可以通过基类接口来进行操作了。比如说,尽管只有 SpecialWindow 可以闪烁,但是在基类中声明这一函数也是有意义的,可以提供一个默认的实现,但不去做任何事情:

class Window {
public:
  virtual void blink() {}  // 默认实现不做任何事情;
  ...    // 第 34 条将介绍:提供默认实现可能是个坏主意
};

class SpecialWindow: public Window {
public:
  virtual void blink() { ... };  // 在这一类型中blink 函数会做一些事情
  ...
};

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs; // 容器中保存着(指向)所有可能的 Window 类型
... 

for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
    // 请注意这里没有 dynamic_cast
  (*iter)->blink();

上面的这两种实现(使用类型安全的容器,或者在层次结构的顶端添加虚函数)都不是万能的。但在大多数情况下,它们是 dynamic_cast 良好的替代方案。如果你发现其中一种方案可行,大可以欣然接受。

关于 dynamic_cast 有一件事情自始至终都要注意,那就是:避免级联使用。就是说要避免类似下面的代码出现:

class Window { ... };
...   // 此处定义派生类

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;

VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
  if (SpecialWindow1 *psw1 =
       dynamic_cast<SpecialWindow1*>(iter->get())) { ... }
  else if (SpecialWindow2 *psw2 =
            dynamic_cast<SpecialWindow2*>(iter->get())) { ... }
  else if (SpecialWindow3 *psw3 =
            dynamic_cast<SpecialWindow3*>(iter->get())) { ... }
  ...
}

这样的代码经编译后得到的可执行代码将是冗长而性能低下的,并且十分脆弱,这是因为每当 Window 的类层次结构有所改变时,就需要检查所有这样的代码,以断定它们是否需要更新。(比如说,如果添加了一个新的派生类,上面的级联操作中就需要添加一个新的条件判断分支。)这样的代码还是由虚函数调用的方式取代为好。

优秀的 C++ 代码中使用转型应该是十分谨慎的,但是一般说来并不是要全盘否定。比如说,本书 118 页 [2] 中从 int 向 double 的转型,就是一次合理而有用的转型,尽管它并不是必需的。(代码可以这样重写:生命一个新的 double 类型的变量,并且用 x 的值对其进行初始化。)和其他绝大多数可以结构一样,转型应该尽可能的与其它代码隔离,典型的方法是将其隐藏在函数中,这些函数的的接口就可以防止调用者接触其内部复杂的操作。

铭记在心

尽可能避免使用转型,尤其是在对性能敏感的代码中不要使用动态转型 dynamic_cast 。如果一个设计方案需要使用转型,要尝试寻求一条不需要转型的方案来取代。
在必须使用转型时,要尝试将其隐藏在一个函数中。这样客户端程序员就可以调用这些函数,而不是在他们自己的代码中使用转型。
要尽量使用 C++ 风格的转型,避免使用怀旧风格的转型。现代的转型更易读,而且功能更为具体化。

[1] 在编写一个程序时,出于种种原因经常会自动关闭或出错。虽然操作系统没出问题,但考虑到下次仍可能遇到相同的问题,操作系统就会把程序出错时的内存( core )中的内容转移( dump )出来供调试人员参考。此为 core dump 。——译注。
[2] 前文中的 doSomeWork(Widget(15)) , 使用函数风格转型创建一个 int 的 Widget 。 —— 译注

最佳教程网

最大的技术交流平台 www.goodxyx.com© CopyRight 2011-2013, All Rights Reserved

浙ICP备11033019号