Effective C++ 学习笔记 条款27 尽量少做转型动作

📅 2026/7/11 3:35:18
Effective C++ 学习笔记 条款27 尽量少做转型动作
C 的规则设计旨在保证类型错误不可能发生。理论上如果你的程序能干净地通过编译就说明它没有试图对任何对象执行不安全或无意义的操作。这是一份可贵的保证你不应轻易放弃。遗憾的是转型操作会破坏类型系统进而可能导致各种麻烦——有些容易察觉有些却极为隐蔽。如果你是从 C、Java 或 C# 转向 C 的请特别注意因为在这些语言中转型比在 C 中更常用危险性也更低。但 C 不是 C不是 Java也不是 C#。在这门语言里转型是一个你应当抱着极大敬意去使用的特性。我们先来回顾一下转型的语法因为同一个转型通常有三种不同的写法。C 风格的转型写法如下函数风格的转型使用这种语法这两种形式在含义上没有区别只是括号放置的位置不同。我把这两种形式称为旧式转型old-style casts。C 还提供了四种新的转型形式常称为新式转型或 C 风格转型每一种都有其特定用途1.const_cast 通常用于移除对象的常量性constness。它是唯一能做到这一点的 C 风格转型。2.dynamic_cast 主要用于执行“安全的向下转型”safe downcasting即判断一个对象在继承体系中是否属于某个特定类型。它是唯一不能用旧式语法完成的转型也是唯一可能产生显著运行时开销的转型稍后我会详细说明。3.reinterpret_cast 用于执行底层转型其结果依赖于具体实现即不可移植例如将指针转型为 int。这类转型在底层代码之外应当极少出现。本书中我只用了一次而且是在讨论如何为原始内存编写调试分配器时见条款 50。4.static_cast 可用于强制进行隐式转换例如将非 const 对象转为 const 对象见条款 3将 int 转为 double 等。它也可以用于执行许多此类转换的逆操作例如将 void* 指针转为有类型指针将基类指针转为派生类指针但它无法将 const 对象转为非 const 对象只有 const_cast 能做到这一点。旧式转型仍然合法但新式形式更可取。首先它们在代码中更容易识别无论是对于人还是对于像 grep 这样的工具从而简化了查找破坏类型系统之处的过程。其次每种转型的用途更加明确这使得编译器能够诊断使用错误。例如如果你试图使用 const_cast 之外的任何新式转型来移除常量性你的代码将无法编译。差不多唯一一种我会使用旧式转型的情况是当我想调用一个显式构造函数来向函数传递对象时。例如不知怎的刻意地创建对象在直觉上“感觉”不像是一次转型所以在这种情况下我可能会使用函数风格转型而不是 static_cast。话又说回来那些最终导致核心转储core dump的代码在刚写出来的时候通常感觉也挺合理的——所以或许你最好还是别跟着感觉走一律使用新式转型。许多程序员认为转型除了告诉编译器“把一种类型当作另一种类型”之外什么都不做但这是错误的。任何类型的类型转换无论是通过转型显式进行还是由编译器隐式进行往往都会导致在运行时实际执行的代码。例如在下面这段代码片段中将 int x 转型为 double 几乎肯定会生成实际的代码因为在大多数架构上int 的底层表示与 double 不同。这或许并不那么令人惊讶但下面这个例子可能会让你眼界大开这里我们只是创建了一个基类指针指向派生类对象但有时这两个指针值并不相同。在这种情况下运行时会对 Derived* 指针应用一个偏移量以得到正确的 Base* 指针值。最后一个例子表明同一个对象例如 Derived 类型的对象可能拥有多个地址例如当它被 Base* 指针指向时有一个地址被 Derived* 指针指向时有另一个地址。这在 C 中不会发生在 Java 中不会发生在 C# 中也不会发生但它在 C 中确实会发生。实际上当使用多重继承时这种情况几乎总是发生但在单继承下也可能发生。这意味着你通常应该避免对 C 中的对象布局做任何假设当然更不应该基于此类假设进行转型。例如将对象地址转型为 char* 指针然后对它们进行指针算术运算几乎总会导致未定义行为。但请注意我说的是“有时”需要偏移量。对象的布局方式以及地址的计算方式因编译器而异。这意味着仅仅因为你的“我知道对象如何布局”的转型在一个平台上有效并不代表它们在其他平台上也能工作。这个世界充满了以惨痛方式学到这一教训的程序员。关于转型有趣的一点是很容易写出看起来正确而且在其他语言中可能确实正确但实际上错误的代码。例如许多应用程序框架要求派生类中的虚成员函数实现首先调用其基类的对应版本。假设我们有一个 Window 基类和一个 SpecialWindow 派生类两者都定义了虚函数 onResize。再进一步假设 SpecialWindow 的 onResize 被期望首先调用 Window 的 onResize。下面是一种看起来像是在做正确事情的实现方式但实际上它做不到我已经在代码中高亮了那个转型它是新式转型但即便用旧式转型也不会改变任何东西。正如你所料这段代码将 *this 转型为 Window因此对 onResize 的调用会执行 Window::onResize。但你可能意料不到的是它并不是在当前对象上调用该函数相反这个转型创建了 *this 的基类部分的一份新的临时副本然后在这个副本上调用 onResize上述代码并没有在当前对象上调用 Window::onResize然后再对当前对象执行 SpecialWindow 特有的操作——它是在当前对象的基类部分的副本上调用了 Window::onResize然后才在当前对象上执行 SpecialWindow 特有的操作。如果 Window::onResize 修改了当前对象这几乎算不上牵强因为 onResize 是一个非 const 成员函数那么当前对象根本不会被修改被修改的是那个副本。然而如果 SpecialWindow::onResize 修改了当前对象那么当前对象就会被修改。这会导致代码可能使当前对象处于一种无效状态——基类部分的修改没有被执行但派生类部分的修改却被执行了。解决方案是消除这个转型用你真正想表达的话来替换它。你并不是想骗编译器把 *this 当作基类对象来处理而是想在当前对象上调用 onResize 的基类版本。所以你就直接这么写这个例子同时也表明如果你发现自己想要使用转型那很可能说明你的做法本身就有问题。如果你想要用的是 dynamic_cast就尤其如此。在深入探讨 dynamic_cast 的设计含义之前值得指出的是许多实现中的 dynamic_cast 运行速度可能相当慢。例如至少有一种常见的实现部分依赖于对类名字符串的比较。如果你对单继承体系中向下四层的一个对象执行 dynamic_cast在这种实现下每次转换可能最多需要四次 strcmp 调用来比较类名。继承层次越深或使用多重继承时开销会更大。某些实现之所以采用这种方式是有其原因的这与对动态链接的支持有关。不过除了要对转型本身保持警惕之外在性能敏感的代码中你尤其应当对 dynamic_cast 保持高度警惕。之所以常常需要 dynamic_cast通常是因为你想在某个对象上执行派生类操作而你手头却只有指向基类的指针或引用只能通过它们来操作该对象——尽管你确信它实际上是派生类对象。要绕开这种问题有两种通用做法。第一种使用直接存储派生类对象指针通常为智能指针参见条款13的容器这样一来就无需再通过基类接口来操作这些对象。举个例子在我们的 Window/SpecialWindow 继承体系中如果只有 SpecialWindow 支持 blink就不应像下面这样写你应该改成这样当然这种做法无法让你把所有可能的 Window 派生类对象指针都放进同一个容器。如果程序要处理多种窗口类型你可能需要准备多个类型安全的容器。另一种方案仍允许你通过基类接口操作所有可能的 Window 派生类那就是在基类中提供虚函数让你完成所需操作。举个例子虽然只有 SpecialWindow 能 blink但把该函数声明在基类中并给出一个什么都不做的默认实现也许也是合理的这两种方案——要么使用类型安全的容器要么把虚函数向上迁移到基类——都不是放之四海而皆准的但很多情况下它们确实能代替 dynamic_cast。只要能用你就该优先采用。另有一点务必避免设计中出现层层级联的 dynamic_cast也就是类似下面这种写法这样的 C 代码生成的程序又臃肿又缓慢而且非常脆弱——因为每次 Window 继承体系发生变化所有这些代码都需要重新检查一遍看是否需要更新比如新增了一个派生类上面的级联分支很可能就要相应地增加一条。这种代码几乎总该被替换成基于虚函数调用的写法。优秀的 C 代码极少使用类型转换但要把它们全部清除通常也不现实。比如第118页从 int 到 double 的转换虽然并非严格必要但也算合理其实可以另声明一个 double 变量用 x 的值来初始化。跟大多数可疑的写法一样类型转换应该尽可能隔离通常隐藏在函数内部让接口把调用者与内部那些繁琐细节隔离开。切记1.只要可行就尽量少用类型转换尤其在性能敏感的代码中要避免 dynamic_cast。如果设计离不开类型转换就试着找到一种不需要它的替代方案。2.若必须进行类型转换尽量把它隐藏在函数内部。这样客户代码直接调用该函数即可不必亲自写类型转换。3.优先使用 C 风格的类型转换而非旧式 C 风格转换。前者更醒目功能也更明确。