洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目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。同时,由于即使在你写下的代码可能会导致核心转储时,你仍然会“感觉”你有充足的理由那样做,因此你可能要忽略你的直觉,自始至终使用现代风格的转型。

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

int x, y;

...

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

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

class Base { ... };

 

class Derived: public Base { ... };

 

Derived d;

 

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

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

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

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

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

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++代码中使用转型应该是十分谨慎的,但是一般说来并不是要全盘否定。比如说,前文中的doSomeWork(Widget(15))
中从intdouble的转型,就是一次合理而有用的转型,尽管它并不是必需的。(代码可以这样重写:声明一个新的double类型的变量,并且用x的值对其进行初始化。)和其他绝大多数可以结构一样,转型应该尽可能的与其它代码隔离,典型的方法是将其隐藏在函数中,这些函数的的接口就可以防止调用者接触其内部复杂的操作。

时刻牢记

尽可能避免使用转型,尤其是在对性能敏感的代码中不要使用动态转型dynamic_cast。如果一个设计方案需要使用转型,要尝试寻求一条不需要转型的方案来取代。

在必须使用转型时,要尝试将其隐藏在一个函数中。这样客户就可以调用这些函数,而不是在他们自己的代码中使用转型。

要多用C++风格的转型,少用怀旧风格的转型。现代的转型更易读,而且功能更为具体化。

posted on 2007-09-13 22:27 ★ROY★ 阅读(1260) 评论(3)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【读书笔记】[Effective C++第3版][第27条]尽量不要使用类型转换  回复  更多评论   

写得不错!

# re: 【读书笔记】[Effective C++第3版][第27条]尽量不要使用类型转换  回复  更多评论   

"转型"这一词是首创吗?“强制类型转换”太长了点,“转型”是个好词。
2007-09-27 09:11 | 金庆

# re: 【读书笔记】[Effective C++第3版][第27条]尽量不要使用类型转换  回复  更多评论   

转型是惯用词汇。我只是拿来而已。合合。
2007-09-27 17:59 | tiandejian

只有注册用户登录后才能发表评论。
【推荐】超50万行VC++源码: 大型组态工控、电力仿真CAD与GIS源码库
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理