Effective C++ 笔记(4)
21. 必须返回对象时,别妄想返回其 reference
Don’t try to return a reference when you must return an object
class Rational { public: Rational(int numerator = 0, int denominator = 1) : n(numerator), d(denominator) {} private: int n, d; // 分子(numerator)和分母(denominator) // 返回 const Rational 可以预防"没意思的赋值动作": Rational a, b, c; (a * b) = // c; friend const Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.n * rhs.n, lhs.d * rhs.d); } };
所谓 reference 只是个名称,代表某个既有对象。任何时候看到一个 reference 声明式,你都应该立刻问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。任何函数如果返回一个 reference 指向某个 local 对象,都将一败涂地。(如果函数返回指针指向一个 local 对象,也是一样。)
请记住:
绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,或返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。
22. 将成员变量声明为 private
Declare data members private
请记住:
- 切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
- protected 并不比 public 更具封装性。
23. 宁以 non-member、non-friend 替换 member 函数
Prefer non-member non-friend functions to member functions
请记住:宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
24. 若所有参数皆需类型转换,请为此采用 non-member 函数
Declare non-member functions when type conversions should apply to all parameters
class Rational24 { public: Rational24(int numerator = 0, int denominator = 1) { } // 构造函数刻意不为 explicit,允许 int-to-Rational24 隐式转换 int numerator() const { return 1; } // 分子(numerator)的访问函数 int denominator() const { return 2; } // 分母(denominator)的访问函数 /*const Rational24 operator* (const Rational24& rhs) const { return Rational24(this->n * rhs.numerator(), this->d * rhs.denominator()); }*/ private: int n, d; }; const Rational24 operator*(const Rational24& lhs, const Rational24& rhs) // non-member 函数 { return Rational24(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); } int test_item_24() { Rational24 oneEighth(1, 8); Rational24 oneHalf(1, 2); Rational24 result = oneHalf * oneEighth; // 很好 result = result * oneEighth; // 很好 result = oneHalf * 2; // 很好,隐式类型转换(implicit type conversion) result = 2 * oneHalf; // 错误, only non-member function success // 以对应的函数形式重写上述两个式子 // result = oneHalf.operator*(2); // 很好, only member function success // result = 2.operator*(oneHalf); // 错误, only non-member function success result = operator*(2, oneHalf); // 错误, only non-member function success return 0; }
无论何时如果你可以避免 friend 函数就该避免。
请记住:如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member。
25. 考虑写出一个不抛异常的 swap 函数
Consider support for a non-throwing swap
class WidgetImpl { // 针对 Widget25 数据而设计的 class public: private: int a, b, c; // 可能有许多数据,意味复制时间很长 std::vector<double> v; }; class Widget25 { // 这个 class 使用 pimpl(pointer to implementation)手法 public: Widget25(const Widget25& rhs) {} Widget25& operator=( const Widget25& rhs) // 复制 Widget25 时,令它复制其 WidgetImpl 对象 { *pImpl = *(rhs.pImpl); return *this; } void swap(Widget25& other) { using std::swap; swap(pImpl, other.pImpl); // 若要置换 Widget25 就置换其 pImpl 指针 } private: WidgetImpl* pImpl; // 指针,所指对象内含 Widget25 数据 }; // std::swap 针对 Widget25 特化版本 namespace std { template <> void swap<effective_cplusplus_::Widget25>(effective_cplusplus_::Widget25& a, effective_cplusplus_::Widget25& b) { a.swap(b); // 若要置换 Widget25,调用其 swap 成员函数 } } // namespace std
所谓 swap(置换)两对象值,意思是将两对象的值彼此赋予对方。缺省情况下 swap 动作可由标准程序库提供的 swap 算法完成。
一般而言,重载 function template 没有问题,但 std 是个特殊的命名空间,其管理规则也比较特殊。客户可以全特化 std 内的 template,但不可以添加新的 template(或 class 或 function 或其它任何东西)到 std 里头。
首先,如果 swap 的缺省实现码对你的 class 或 class template 提供可接受的效率,你不需要额外做任何事。其次,如果 swap 缺省实现版的效率不足,试着做以下事情:(1). 提供一个 public swap 成员函数,让它高效地置换你的类型的两个对象值。这个函数不该抛出异常。(2). 在你的 class 或 template 所在的命名空间内提供一个 non-member swap,并令它调用上述 swap 成员函数。(3). 如果你正在编写一个 class(而非 class template),为你的 class 特化 std::swap。并令它调用你的 swap 成员函数。 最后,如果你调用 swap,请确定包含一个 using 声明式,以便让 std::swap 在你的函数内曝光可见,然后不加任何 namespace 修饰符,赤裸裸地调用 swap。
请记住:
- 当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。
- 如果你提供一个 member swap,也该提供一个 non-member swap 用来调用前者。对于 classes(而非 template),也请特化 std::swap。
- 调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何”命名空间资格修饰”。
- 为用户定义类型进行 std template 全特化是好的,但千万不要尝试在 std 内加入某些对 std 而言全新的东西。
26. 尽可能延后变量定义式的出现时间
Postpone variable definitions as long as possible
你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的 default 构造行为。
请记住:尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
27. 尽量少做转型动作
Minimize casting
const_cast 通常被用来将对象的常量性移除(cast away the constness)。它也是唯一有此能力的 C++-style 转型操作符。
dynamic_cast 主要用来执行”安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。
reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。例如将一个 pointer to int 转型为一个 int。
static_cast 用来强迫隐式转换(implicit conversions),例如将 non-const 对象转为 const 对象,或将 int 转为 double 等等。它也可以用来执行上述多种转换的反向转换,例如将 void*指针转为 typed 指针,将 pointer-to-base 转为 pointer-to-derived。但它无法将 const 转为 non-const,这个只有 const_cast 才办得到。
请记住:
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast。如果有个设计需要转型动作,试着发展无须转型的替代设计。
- 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
- 宁可使用 C++-style(新式)转型,不要使用旧式转型(C 风格转型)。前者很容易辨识出来,而且也比较有着分门别类的职掌。
28.避免返回 handles 指向对象内部成分
Avoid returning handles to object internals
class Point { // 这个 class 用来表述"点" public: Point(int x, int y) {} void setX(int newVal) {} void setY(int newVal) {} }; struct RectData { // 这些"点"数据用来表现一个矩形 Point ulhc; // ulhc = "upper left-hand corner"(左上角) Point lrhc; // lrhc = "lower right-hand corner"(右上角) }; class Rectangle { public: Rectangle(const Point&, const Point&) {} Point& upperLeft() const { return pData->ulhc; } Point& lowerRight() const { return pData->lrhc; } // 有了这样的改变,客户可以读取矩形的 Point,但不能涂写它们 // 但即使如此,也可能导致 dangling // handles(空悬的号码牌):这种 handles 所指东西(的所属对象)不复存在 // const Point& upperLeft() const { return pData->ulhc; } // const Point& lowerRight() const { return pData->lrhc; } private: std::shared_ptr<RectData> pData; }; int test_item_28() { Point coord1(0, 0); Point coord2(100, 100); const Rectangle rec(coord1, coord2); // rec 是个 const 矩形,从(0,0)到(100,100) // upperLeft 的调用者能够使用被返回的 reference(指向 rec 内部的 Point 成员变量) // 来更改成员,但 rec 其实应该是不可变的(const) rec.upperLeft().setX(50); // 现在 rec 却变成从(50,0)到(100,100) return 0; }
reference、指针和迭代器统统都是所谓的 handles(号码牌,用来取得某个对象),而返回一个”代表对象内部数据”的 handle,随之而来的便是”降低对象封装性”的风险。
通常我们认为,对象的”内部”就是指它的成员变量,但其实不被公开使用的成员函数(也就是被声明为 protected 或 private 者)也是对象”内部”的一部分。因此也应该留心不要返回它们的 handles。这意味你绝对不该令成员函数返回一个指针指向”访问级别较低”的成员函数。
请记住:避免返回 handles(包括 reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生”虚吊号码牌”(dangling handles)的可能性降至最低。
29. 为 异常安全 而努力是值得的
Strive for exception-safe code
“异常安全”有两个条件:(1).不泄漏任何资源。(2).不允许数据败坏。
异常安全函数(Exception-safe functions)提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的 class 约束条件都继续获得满足)。然而程序的现实状态(exact state)恐怕不可预料。
- 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功;如果函数失败,程序会回复到”调用函数之前”的状态。
- 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如 int,指针等等)身上的所有操作都提供 nothrow 保证。这是异常安全码中一个必不可少的关键基础材料。
异常安全码(Exception-safe code)必须提供上述三种保证之一。如果它不这样做,它就不具备异常安全性。
有个一般化的设计策略很典型地会导致强烈保证,这个策略被称为 copy and swap。原则很简单:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。但一般而言它并不保证整个函数有强烈的异常安全性。
请记住:
- 异常安全函数(Exception-safe function)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
- 强烈保证 往往能够以 copy-and-swap 实现出来,但 强烈保证 并非对所有函数都可实现或具备现实意义。
- 函数提供的”异常安全保证”通常最高只等于其所调用之各个函数的”异常安全保证”中的最弱者。
30. 透彻了解 inlining 的里里外外
Understand the ins and outs of inlining
inline void f() {} // 假设编译器有意愿 inline“对 f 的调用” int test_item_30() { void (*pf)() = f; // pf 指向 f f(); // 这个调用将被 inlined,因为它是一个正常调用 pf(); // 这个调用或许不被 inlined,因为它通过函数指针达成 return 0; }
inline 函数背后的整体观念是,将”对此函数的每一个调用”都以函数本体替换之。
过度热衷 inlining 会造成程序体积太大(对可用空间而言)。即使拥有虚内存,inline 造成的代码膨胀亦会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及伴随这些而来的效率损失。
inline 只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于 class 定义式内。这样的函数通常是成员函数。friend 函数如果被定义于 class 内,它们也是被隐喻声明为 inline。明确声明 inline 函数的做法则是在其定义式前加上关键字 inline。
inline 函数通常一定被置于头文件内,因为大多数建置环境(build environments)在编译过程中进行 inlining,而为了将一个”函数调用”替换为”被调用函数的本体”,编译器必须知道那个函数长什么样子。
inlining 在大多数 C++程序中是编译期行为。
templates 通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。(这其实也不是世界一统的准则。某些建置环境可以在链接期才执行 template 具现化。只不过编译期完成具现化动作比较常见。)template 的具现化与 inlining 无关。
大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数 inlining,而所有对 virtual 函数的调用(除非是最平淡无奇的)也都会使 inlining 落空。
一个表面上看似 inline 的函数是否真是 inline,取决于你的建置环境,主要取决于编译器。大多数编译器提供了一个诊断级别:如果它们无法将你要求的函数 inline 化,会给你一个警告信息。为数 ining 个函数长声明 entshit rate
编译器通常不对”通过函数指针而进行的调用”实施 inlining,这意味对 inline 函数的调用有可能被 inlined,也可能不被 inlined,取决于该调用的实施方式。
实际上构造函数和析构函数往往是 inlining 的糟糕候选人。
inline 函数无法随着程序库的升级而升级。换句话说如果 f 是程序库内的一个 inline 函数,客户将”f 函数本体”编进其程序中,一旦程序库设计者决定改变 f,所有用到 f 的客户端程序都必须重新编译。
一开始先不要将任何函数声明为 inline,或至少将 inlining 施行范围局限在那些”一定成为 inline”或”十分平淡无奇”的函数身上。慎重使用 inline 便是对日后使用调试器带来帮助。
请记住:
- 将大多数 inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
- 不要只因为 function templates 出现在头文件,就将它们声明为 inline。
31. 将文件间的编译依存关系降至最低
Minimize compilation dependencies between files
标准程序库组件不该被前置声明。
设计策略:
- 如果使用 object references 或 object pointers 可以完成任务,就不要使用 objects。你可以只靠一个类型声明式就定义出指向该类型的 references 和 pointers;但如果定义某类型的 objects,就需要用到该类型的定义式。
- 如果能够,尽量以 class 声明式替换 class 定义式。注意,当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义;纵使函数以 by value 方式传递该类型的参数(或返回值)亦然。
- 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。
Handle classes(使用 pimpl idiom(pimpl 是”pointer to implementation”的缩写))和 Interface classes(特殊的 abstract base class(抽象基类))解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性(compilation dependencies)。
在 Handle classes 身上,成员函数必须通过 implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加 implementation pointer 的大小。最后,implementation pointer 必须初始化(在 Handle class 构造函数内),指向一个动态分配得来的 implementation object,所以你将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇 bad_alloc 异常(内存不足)的可能性。
至于 interface classes,由于每个函数都是 virtual,所以你必须为每次函数调用付出一个间接跳跃(indirect jump)成本。此外 interface class 派生的对象必须内含一个 vptr(virtual table pointer),这个指针可能会增加存放对象所需的内存数量—-实际取决于这个对象除了 interface class 之外是否还有其它 virtual 函数来源。
最后,不论 handle classes 或 interface classes,一旦脱离 inline 函数都无法有太大作为。函数本体为了被 inlined 必须(很典型地)置于头文件内,但 handle classes 和 interface classes 正是被设计用来隐藏实现细节如函数本体。
然而,如果只因为若干额外成本便不考虑 handle classes 和 interface classes,将是严重的错误。
请记住:
- 支持 编译依存性最小化 的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 handle classes 和 interface classes。
- 程序库头文件应该以”完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及 templates 都适用。
32. 确定你的 public 继承塑模出 is-a 关系
Make sure public inheritance models is-a
public inheritance(公开继承)意味”is-a”(是一种)的关系。
如果你令 class D(“Derived”)以 public 形式继承 class B(“Base”),你便是告诉 C++编译器(以及你的代码读者)说,每一个类型为 D 的对象同时也是一个类型为 B 的对象,反之不成立。
请记住:public 继承 意味 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。
33. 避免遮挡继承而来的名称
Avoid hiding inherited names
class Base { private: int x; public: virtual void mf1() = 0; virtual void mf1(int) {} virtual void mf2() {} void mf3() {} void mf3(double) {} }; class Derived : public Base { public: virtual void mf1() {} void mf3() {} void mf4() {} }; class Derived33 : public Base { public: // 必须为那些原本会被遮掩的每个名称引入一个 using 声明式,否则某些你希望继承的名称会被遮掩 using Base:: mf1; // 让 Base // class 内名为 mf1 和 mf3 的所有东西在 Derived 作用域内都可见(并且 public) using Base::mf3; virtual void mf1() {} void mf3() {} void mf4() {} }; int test_item_33() { Derived d; int x = 0; d.mf1(); // 没问题,调用 Derived::mf1 // d.mf1(x); // 错误,因为 Derived::mf1 遮掩了 Base::mf1 d.mf2(); // 没问题,调用 Base::mf2 d.mf3(); // 没问题,调用 Derived::mf3 // Derived 内的函数 mf3 遮掩了一个名为 mf3 但类型不同的 Base 函数 // d.mf3(x); // 错误,因为 Derived::mf3 遮掩了 Base::mf3 Derived33 d2; d2.mf1(); // 仍然没问题,仍然调用 Derived::mf1 d2.mf1(x); // 现在没问题了,调用 Base::mf1 d2.mf2(); // 仍然没问题,调用 Base::mf2 d2.mf3(); // 没问题,调用 Derived::mf3 d2.mf3(x); // 现在没问题了,调用 Base::mf3 return 0; }
请记住:
- derived classes 内的名称会遮掩 base classes 内的名称。在 public 继承下从来没有人希望如此。
- 为了让被遮掩的名称再见天日,可使用 using 声明式或转交函数(forwarding functions)。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

上一篇: Effective C++ 笔记(5)
下一篇: 谈谈自己对于 AOP 的了解
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论