Effective C++
条款 01:视 C++ 为一个语言联邦
为了理解 C++ 需要认识其主要的次语言
- C (C 语言)
- Object-Oriented C++ (面向对象 C++)
- Template C++ (模板 C++)
- STL (标准模板库)
请记住
- C++ 高效编程守则视状况而变化,取决于你使用 C++ 的哪一部分。
条款 02:尽量以 const,enum,inline 替换 #define
#define ASPECT_RATIO 1.653 /* * 记号名称 ASPECT_RATIO 也许从未被编译器看见:也许在编译器开始处理源代码之前它就被预处理器移走了。 * 获得的一个编译错误信息可能会提到 1.653 (而你可能对 1.653 毫无概念) */ --------------------------------------------------------------------------------- /* * 解决之道是以一个常量替换上述的宏 */ const double AspectRatio = 1.653; // 大写名称通常用于宏,因此这里改变名称写法
当我们以常量替换 #defines,有两种特殊情况值得说一说
定义常量指针
有必要将指针声明为 const(两个 const:const 字符串,const 指针)
const char* const authorName = "Scott Meyers";
string 对象通常比其前辈(char*-based)合宜
const std::string authorName("Scott Meyers"); // 更好些
class 专属常量
为了将常量的作用域限制于 class内,你必须让它成为 class 的一个成员;
为了确保此常量至多只有一份实体,你必须让他成为一个 static 成员;
class GamePlayer { private: static const int NumTurns = 5; // 常量声明式 int scores[NumTurns]; // 使用该常量 };
如果你取某个 class 专属常量的地址,或纵使你不取其地址而你的编译器却(不正确地)坚持要看到一个定义式,你就必须另外提供定义式如下。
const int GamePlayer::NumTurns; // 由于 class 常量已在声明时获得初值,因此定义时不可以再设初值
所谓的 “in class 初值设定” 也只允许对整数常量进行。
如果编译器不支持上述语法,你可以将初值放在定义式。
class CostEstimate { private: static const double FudgeFactor; // static class 常量声明 位于头文件内 ... }; const double CostEstimate::FudgeFactor = 1.35; // static class 常量定义 位于实现文件内
唯一例外是当你在 class 编译期间需要一个 class 常量值,例如上述 GamePlayer::scores 的数组声明式中。这时候万一你的编译器不允许 “static 整数型 class 常量” 完成 “in class 初值设定”,可改用所谓的 “the enum hack” 补偿做法。
class GamePlayer { private: enum { NumTurns = 5 }; // "the enum hack" —— 令 NumTurns 成为 5 的一个记号名称 int scores[NumTurns]; // 这就没问题了 ... };
基于数个理由 enum hack 值得我们认识
- 第一,enum hack 的行为某方面说比较像 #define 而不像 const。
- 第二,实用主义
另一个常见的 #define 误用情况是以它实现宏
宏看起来像函数,但不会招致函数调用带来的额外开销
// 以 a 和 b 的较大值调用 f #define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b)) // 但是仍然会有很多情况 int a = 5, b = 0; CALL_WITH_MAX(++ a, b); // a 被累加二次 CALL_WITH_MAX(++ a, b + 10); // a 被累加一次 //(取决于它被拿来和谁比较 ???)
但是可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性——只要你写出 template inline 函数
template<typename T> inline void callWithMax(const T& a, const T& b) { f(a > b ? a : b); } // 由于我们不知道 T 是什么,所以采用 pass by reference-to-const
请记住
- 对于单纯常量,最好以 const 对象或 enums 替换 #defines
- 对于形似函数的宏,最好改用 inline 函数替换 #defines
条款 03:尽可能使用 const
char greating[] = "Hello"; char* p = greating; const char* p = greating; char* const p = greating; const char* const p = greating;
如果关键字 const 出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量。
void f1(const Widget* pw); // f1 获得一个指针,指向一个常量的 Widget 对象。 void f2(Widget const * pw); // 等价
STL 迭代器系以指针为根据塑模出来,所以迭代器的作用就像一个 T* 指针。
std::vector<int> vec; ... const std::vector<int>::iterator iter = vec.begin(); // iter 的作用像个 T* const *iter = 10; // 没问题,改变 iter 所指物 ++ iter; // 错误!iter 是 const std::vector<int>::const_iterator cIter = vec.begin(); // cIter 的作用像个 const T* *cIter = 10; // 错误!*cIter 是 const ++ cIter; // 没问题,改变 cIter
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。
class Rational { ... }; const Rational operator* (const Rational& lhs, const Rational& rhs); /\ || 为什么返回一个 const 对象??? 因为客户可能会实现成这样 Rational a, b, c; ... (a * b) = c; // 在 a * b 的成果上调用 operator= /\ || 可能会无意识这样做 例如 if (a * b = c) ... // 少打一个等号
至于 const 参数,除非你有需要改动参数或 local 对象,否则请将它们声明为 const。
const 成员函数
将 const 实施于成员函数的目的,是为了确认该成员函数可作用于 const 对象身上。
class TextBloack { public: ... const char& operator[](std::size_t position) const // operator[] for const { return text[position]; } char& operator[](std::size_t position) // operator[] for non-const { return text[position]; } private: std::string text; }; /* *TextBlock 的 operator[]s可被这么使用 */ TextBlock tb("Hello"); std::cout << tb[0]; // 调用 non-const TextBlock::operator[] const TextBlock ctb("Hello"); std::cout << ctb[0]; // 调用 const TextBlock::operator[]
只要重载 operator[] 并对不同的版本给予不同的返回类型,就可以令 const 和 non-const TextBlock 获得不同的处理。
std::cout << tb[0]; // 没问题:读 tb[0] = 'X'; // 没问题:写 std::cout << ctb[0]; // 没问题:读 ctb[0] = 'X'; // 有问题:写一个 const TextBlock 如果 non-const operator[] 返回类型不是 reference to char,而是 char,那么 tb[0] = 'x' 错误 如果能改动,被改动的也只是 tb.text[0] 的一个副本,不是 tb.text[0] 自身。
两个流行性概念
- bitwise constness:成员函数只有在不更改对象之任何成员变量时才可以说是 const
- logical constness:一个 const 成员函数可以修改它所处理的对象内的某些 bits,但只有在客户端侦测不出的情况下才得如此。
bitwise const
很容易侦测反点:编译器只需寻找成员变量的赋值动作即可。
不幸的是许多成员函数虽然不十足具备 const 性质却能通过 bitwise 测试。
class CTextBlock { public: ... char& operator[](std::size_t position) const // bitwise const 声明但其实很不恰当 { return pText[position]; } private: char* pText; }; || \/ const CTextBlock cctb("Hello"); char* pc = &cctb[0]; // 调用 const operator[] 取得一个指针,指向 cctb 的数据 *pc = 'J'; // cctb 现在有了 "Jello" 这样的内容
这种情况导出所谓的 logical constness
class CTextBlock { public: ... std::size_t length() const; private: char* pText; std::size_t textLength; // 最近一次计算的文本区块长度 bool lengthIsValid; // 目前的长度是否有效 }; std::size_t CTextBlock::length() const { if (!lengthIsValid) { // 错误:const 成员函数内不能赋值 textLength = std::strlen(pText); lengthIsValid = true; } return textLength; }
编译器坚持 bitwise constness 怎么办?
使用 mutable:mutable 释放掉 non-static 成员变量的 bitwise constness 约束。
class CTextBlock { public: ... std::size_t length() const; private: char* pText; mutable std::size_t textLength; // 最近一次计算的文本区块长度 mutable bool lengthIsValid; // 目前的长度是否有效 }; std::size_t CTextBlock::length() const { if (!lengthIsValid) { // 正确 textLength = std::strlen(pText); lengthIsValid = true; } return textLength; }
在 const 和 non-const 成员函数中避免重复
class TextBlock { public: ... const char& operator[](std::size_t position) const { ... // 边界检查 ... // 志记数据访问 ... // 检验数据完整性 return text[position]; } char& operator[](std::size_t position) { ... // 边界检查 ... // 志记数据访问 ... // 检验数据完整性 return text[position]; } private: std::string text; };
令 non-const operator[] 调用其 const 兄弟是一个避免代码重复的安全做法
class TextBlock { public: ... const char& operator[](std::size_t position) const { ... ... ... return text[position]; } char& operator[](std::size_t position) { return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]); } // *this: TextBlock& -> const TextBlock& const operator[] -> operator[] ... };
请记住
- 将某些东西声明为 const 可帮助编译器侦测出错误用法。const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
- 编译器强制实施 bitwise constness,但你编写程序时应该使用“概念上的常量性”
- 当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本可避免代码重复。
条款 04:确定对象被使用前已先被初始化
永远在使用对象之前先将它初始化
对于无任何成员的内置类型,你必须手工完成此事。
int x = 0; // 对 int 进行手工初始化 const char* text = "A C-style string"; // 对指针进行手工初始化 double d; std::cin >> d; // 以读取 input stream 的方式完成初始化
至于内置类型以外的任何其他东西,初始化责任落在构造函数身上。
规则:确保每一个构造函数都将对象的每一个成员初始化。
注意:不要混淆赋值和初始化
class PhoneNumber { ... }; class ABEntry { public: ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones); private: std::string theName; std::string theAddress; std::list<PhoneNumber> thePhones; int numTimesConsulted; }; ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones) { theName = name; // 这些都是赋值而非初始化 theAddress = address; thePhones = phones; numTimesConsulted = 0; } /* * 最佳写法:使用成员初值列替换赋值动作 */ ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones) : theName(name), theAddress(address), thePhones(phones), numTimesConsulted(0) { }
同样道理,甚至当你想要 default 构造一个成员变量,你都可以使用成员初值列,只要指定无物作为初始化实参即可。
ABEntry::ABEntry() : theName(), theAddress(), thePhones(), numTimesConsulted(0) { }
最简单的做法是:总是使用成员初值列,总是在初值列中列出所有成员变量。
C++ 有着十分固定的“成员初始化次序”:base class 更早于 derived class 被初始化,而 class 的成员变量总是以其声明次序被初始化。
定义于不同的编译单元内的 non-local static 对象
C++ 对“定义于不同的编译单元内的 non-local static 对象”的初始化相对次序并无明确定义。
决定他们的初始化次序相当困难,非常困难,根本无解。
将每个 non-local static 对象搬到自己的专属函数内可消除这个问题
这个手法的基础在于:C++ 保证,函数内的 local static 对象会在 “该函数被调用期间”“首次遇上该对象之定义式”时被初始化。
请记住
- 为内置型对象进行手工初始化,因为 C++ 不保证初始化它们。
- 构造函数最好使用成员初值列,而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在 class 中的声明次序相同。
- 为免除“跨编译单元之初始化次序”问题,请以 local static 对象替换 non-local static 对象。
条款 05:了解 C++ 默默编写并调用哪些函数
当 C++ 处理过它之后,如果你自己没声明,编译器就会为它声明(编译器版本的)一个 copy 构造函数、一个 copy assignment 操作符和一个析构函数。此外如果你没有声明任何构造函数,编译器也会为你声明一个 default 构造函数。
class Empty { }; || 就好像写下了这样的代码 \/ class Empty { public: Empty() { ... } Empty(const Empty& rhs) { ... } ~Empty() { ... } Empty& operator = (const Empty& rhs) { ... } };
唯有当这些函数被需要(被调用),它们才会被编译器创造出来。
Empty e1; // default 构造函数 // 析构函数 Empty e2(e1); // copy 创造函数 e2 = e1; // copy assignment 操作符
请记住
- 编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy assignment 操作符,以及析构函数。
条款 06:若不想使用编译器自动生成的函数,就该明确拒绝
藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本;而令这些函数为 private,使你得以成功阻止人们调用它。(不慎调用任何一个会获得连接错误)
class HomeForSale { public: ... private: ... HomeForSale(const HomeForSale&); HomeForSale& operator = (const HomeForSale&); };
将连接期错误移至编译期是可能的,只要将 copy 构造函数和 copy assignment 操作符声明为 private 就可以办到,但不是在 HomeForSale 自身,而是在一个专门为了阻止 copying 动作而设计的 base class 内。这个 base class 非常简单。
class Uncopyable { protected: Uncopyable() { } // 允许 derived 对象构造和析构 ~Uncopyable() { } private: Uncopyable(const Uncopyable&); // 但阻止 copying Uncopyable& operator = (const Uncopyable&); };
为求阻止 HomeForSale 对象被拷贝,我们唯一需要做的就是继承 Uncopyable:
class HomeForSale : private Uncopyable { // class 不再声明 copy 构造函数或 copy assign 操作符 };
请记住
- 为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyable 这样的 base class 也是一种做法。
条款 07:为多态基类声明 virtual 析构函数
设计一个 TimeKeeper base class和一些 derived classes 作为不同的计时方法。
class TimeKeeper { public: TimeKeeper(); ~TimeKeeper(); ... }; class AtomicClock : public TimeKeeper { ... }; // 原子钟 class WaterColck : public TimeKeeper { ... }; // 水钟 class WristWatch : public TimeKeeper { ... }; // 腕表 TimeKeeper* getTimeKeeper(); // 返回一个指针,指向一个 TimeKeeper 派生类的动态分配对象 // 为遵守 factory函数的规矩,被 getTimeKeeper() 返回的对象必须位于 heap。因此为了避免泄露内存和其他资源,将 factory 函数返回的每一个对象适当地 delete 掉很重要: TimeKeeper* ptk = getTimeKeeper(); ... delete ptk; /* * C++ 明白指出,当 derived class 对象经由一个 base class 指针被删除,而该 base class 带着一个 non-virtual 析构函数,其结果未有定义——实际执行时通常发生的是对象的 derived 成分没被销毁。 */
消除这个问题的做法很简单:给 base class 一个 virtual 析构函数。此后删除的 derived class 对象就会如你想要的那般。是的,它会销毁整个对象,包括所有 derived class 成分
class TimeKeeper { public: TimeKeeper(); virtual ~TimeKeeper(); ... }; TimeKeeper* ptk = getTimeKeeper(); ... delete ptk; // 行为正确
任何 class 只要带有 virtual 函数都几乎确定应该也有一个 virtual 析构函数。
如果 class 内含 virtual 函数,其对象的体积会增加。
因此,无端地将所有 classes 的析构函数声明为 virtual,就像从未声明它们为 virtual 一样,都是错误的。许多人的心得是:只有当 class 内含至少一个 virtual 函数,才为它声明 virtual 析构函数。
请记住
- polymorphic(带多态性质的)base class 应该声明一个 virtual 析构函数。如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
- Classes 的设计目的如果不是作为 base class 使用,或不是为了具备多态性(polymorphically),就不该声明 virtual 析构函数。
条款 08:别让异常逃离析构函数
假设使用一个 class 负责数据库连接
class DBConnection { public: ... static DBConnection create(); // 这个函数返回 DBConnection 对象;为求简化暂略参数 void close(); // 关闭联机:失败则抛出异常 };
为确保客户不忘记在 DBConnection 对象身上调用 close(),一个合理的想法是创建一个用来管理 DBConnection 资源的 class,并在其析构函数中调用 close.
class DBConn { public: ... ~DBConn() { // 确保数据库连接总是会被关闭 db.close(); } private: DBConnection db; }; /* * 这允许用户写出这样的代码 */ { DBConn dbc(DBConnnection::create()); } /* * 如果该调用导致异常,DBConn 析构函数会传播该异常,也就是允许它离开这个析构函数。 * 两个办法可以避免这一问题。DBConn 的析构函数可以: */
- 如果 close 抛出异常就结束程序。通常通过调用 abort 完成:
DBConn::~DBConn() { try { db.close(); } catch (...) { // 制作运转记录,记下对 close 的调用失败; std::abort(); } }
- 吞下因调用 close 而发生的异常:
DBConn::~DBConn() { try { db.close(); } catch (...) { // 制作运转记录,记下对 close 的调用失败; } }
这些办法都没有什么吸引力。问题在于两者都无法对“导致 close 抛出异常”的情况做出反应。
一个较佳策略是重新设计 DBConn 接口,使其客户有机会对可能出现的问题作出反应。
class DBConn { public: ... void close() { db.close(); closed = true; } ~DBConn() { if (!closed) { try { db.close(); } catch (...) { // 制作运转记录,记下对 close 调用失败; ... } } } private: DBConnection db; bool closed; };
本例要说的是,由客户自己调用 close 并不会对他们带来负担,而是给他们一个处理错误的机会,否则他们没机会响应。
请记住
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通函数(而非在析构函数中)执行该操作。
条款09:绝不在构造和析构过程中调用 virtual 函数
假设你有一个 class 继承体系,用来塑造股市交易如买进、卖出的订单等等。这样的交易一定要经过审计,所以每当创建一个交易对象,在审计日志中也需要创建一笔适当记录。下面是一个看起来颇为合理的做法。
class Transaction { // 所有交易的 base class public: Transaction(); virtual void logTransaction() const = 0; // 做出一份因类型不同而不同的日志记录 ... }; Transaction::Transaction() { // base class 构造函数之实现 ... logTransaction(); // 最后动作是标记这笔交易 } class BuyTransaction : public Transaction { // derived class public: virtual void logTransaction() const; // 志记(log)此型交易 ... }; class SellTransaction : public Transaction { // derived class public: virtual void logTransaction() const; // 志记(log)此型交易 ... };
但是这个时候调用的 logTransaction 是 Transaction 内的版本,不是 BuyTransaction 内的版本 —— 即使目前即将建立的对象类型是 BuyTransaction。
非正式的说法可能比较传神:在 base class 构造期间,virtual 函数不是 virtual 函数
这个对象内的 “BuyTransaction 专属成分” 尚未被初始化,所以面对它们,最安全的做法就是视它们不存在。
相同的道理也适用于析构函数。一旦 derived class 析构函数开始执行,对象内的 derived class 成员变量便呈现未定义值,所以 C++ 视它们仿佛不存在。
潜藏的示例:
// 同样的错误做法 class Transaction { // 所有交易的 base class public: Transaction() { init(); // 调用 non-virtual... } virtual void logTransaction() const = 0; ... private: void init() { ... logTransaction(); // 这里调用 virtual! } };
唯一能避免此问题的做法就是:
确定你的构造函数和析构函数都没有(在对象被创建和被销毁期间)调用 virtual 函数,而它们调用的所有函数也都服从同一约束。
如何确保每次一有 Transaction 继承体系上的对象被创建,就会有适当版本的 logTransaction 被调用呢?
一种做法是在 class Transaction 内将 logTransaction 函数改为 non-virtual,然后要求 dervied class 构造函数传递必要信息给 Transaction 构造函数,而后那个构造函数便可安全地调用 non-virtual logTransaction 像这样:
class Transaction { public: explicit Transaction(const std::string& logInfo); void logTransaction(const std::string& logInfo) const; // 如今是个 non-virtual 函数 ... }; Transaction::Transaction(const std::string& logInfo) { ... logTransaction(logInfo); // 如今是个 non-virtual 调用 } class BuyTransaction : public Transaction { public: BuyTransaction(parameters) : Transaction(createLogString(parameters)) { ... } ... private: static std::string createLogString(parameters); };
请记住
- 在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至 derived class(比起当前执行构造函数和析构函数的那层)
条款10:令 operator= 返回一个 reference to *this
赋值连锁
int x, y, z; x = y = z = 15; // 赋值连锁形式 相当于: x = (y = (z = 15));
为了实现连锁赋值,赋值操作符必须返回一个 reference 指向操作符的左侧实参。这是你为 classes 实现赋值操作符时应该遵循的协议:
class Widget { public: ... Widget& operator = (const Widget& rhs) { ... return this; } ... };
这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,例如:
class Widget { public: ... Widget& operator += (const Widget& rhs) { ... return *this; } Widget& operator = (int rhs) { // 此操作也适用,即使此一操作符的参数类型不符协定 ... return *this; } };
请记住
- 令赋值操作符返回一个 reference to *this
条款11:在 operator= 中处理“自我赋值”
假设建立一个 class 用来保存一个指针指向一块动态分配的内存
class Bitmap { ... }; class Widget { ... private: Bitmap* pb; // 指针,指向一个从 heap 分配而得的对象 }; Widget& Widget::operator = (const Widget& rhs) { // 一份不安全的 operator = 实现版本 delete pb; pb = new Bitmap(*rhs.pb); return *this; }
若是自我赋值,它会持有一个指针指向一个已被删除的对象
欲阻止这种错误,传统做法是藉由 operator= 最前面的一个“证同测试”达到“自我赋值”的检验目的:
Widget& Widget::operator = (const Widget& rhs) { if (this == &rhs) return *this; // 证同测试,如果是自我赋值,就不做任何事 delete pb; pb = new Bitmap(*rhs.pb); return *this; } /* * 如果 “new Bitmap” 导致异常,Widget 最终会有一个指针指向一块已经被删除的 Bitmap * 这样的指针有害,你无法安全地删除它们,甚至无法安全地读取它们。唯一能对它们做的安全事情是付出许多调试能量找出错误的起源 */
令人高兴的是,让 operator= 具备“异常安全性”往往自动获得“自我安全赋值”的回报。
Widget& Widget::operator = (const Widget& rhs) { Bitmap* pOrig = pb; // 记住原先的 pb pb = new Bitmap(*rhs.pb); // 令 pb 指向 *pb 的一个复件(副本) delete pOrig; // 删除原先的 pb return *this; } // 此方法或许不是处理“自我赋值”的最高效方法,但是行得通。
在 operator= 函数内手工排列语句的一个替代方案是,使用所谓的 copy and swap 技术。这个技术和“异常安全性”有密切关系。
class Widget { ... void swap(Widget& rhs); // 交换 *this 和 rhs 的数据 ... }; Widget& Widget::operator = (const Widget& rhs) { Widget temp(rhs); swap(temp); return *this; }
这个主题的另一个变奏曲乃利用以下事实:
(1)某 class 的 copy assignment 操作符可能被声明为“以 by value 方式接受实参”
(2)以 by value 方式传递东西会造成一份复件/副本
Widget& Widget::operator = (Widget rhs) { // rhs 是被传对象的一份附件(副本),注意这里是 pass by value swap(rhs); // 将 *this 的数据和复件/副本的数据互换 return *this; }
请记住
- 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及 copy-and-swap。
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
条款12:复制对象时勿忘其中每一个成分
编译器会在必要时候为我们的 classes 创建 copying 函数,并说明这些“编译器生成版”的行为:将被拷对象的所有成员变量都做一份拷贝
如果你声明自己的 copying 函数,意思就是告诉编译器你并不喜欢缺省实现中的某些行为。编译器会回敬:当你的实现代码几乎必然出错时却不告诉你。
考虑一个 class 表示客户,其中手工写出 copying 函数,使得外界对它们的调用会被志记下来。
void logCall(const std::string& funcName); // 制造一个 log entry class Customer { public: ... Customer(const Customer& rhs); Customer& operator = (const Customer& rhs); ... private: std::string name; }; Customer::Customer(const Customer& rhs) : name(rhs.name) { // 复制 rhs 的数据 logCall("Customer copy constructor"); } Customer& Customer::operator = (const Customer& rhs) { // 复制 rhs 的数据 logCall("Customer copy assignment operator"); name = rhs.name; return *this; }
这里的每一件事情看起来都很好,而实际上每件事情也的确都好,直到另一个成员变量加入战局。
class Date { ... }; // 日期 class Customer { public: ... private: std::string name; Date lastTransaction; };
这时候既有的 copying 函数执行的是局部拷贝:它们的确复制了顾客的 name,但没有复制新添加的 lastTransaction。如果你为 class 添加一个成员变量,你必须同时修改 copying 函数(你也需要修改 class 的所有构造函数以及任何非标准形式的 operator=。如果你忘记,编译器不太可能提醒你。)
一旦发生继承,可能会造成此一主题中最暗中肆虐的一个潜藏危机。请考虑:
class PriorityCustomer : public Customer { public: ... PriorityCustomer(const PriorityCustomer& rhs); PriorityCustomer& operator = (const PriorityCustomer& rhs); ... private: int priority; }; PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority) { logCall("PriorityCustomer copy constructor"); } PriorityCustomer& PriorityCustomer::operator = (const PriorityCustomer& rhs) { logCall("PriorityCustomer copy assignment operator"); priority = rhs.priority; return *this; }
PriorityCustomer 的 copy 构造函数并没有指定实参传给其 base class 构造函数,因此 PriorityCustomer 对象的 Customer 成分会被不带实参之 Customer 构造函数初始化。default 构造函数将针对 name 和 lastTransaction 执行缺省的初始化动作。
任何时候只要你承担起“为 derived class 撰写 copying 函数”的重责大任,必须很小心地也复制其 base class 成分。那些成分往往是 private,所以你无法直接访问它们,你应该让 derived class 的 copying 函数调用相应的 base class 函数:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : Customer(rhs), priority(rhs.priority) { logCall("PriorityCustomer copy constructor"); } PriorityCustomer& PriorityCustomer::operator = (const PriorityCustomer& rhs) { logCall("PriorityCustomer copy assignment operator"); Customer::operator = (rhs); priority = rhs.priority; return *this; }
请记住
- copying 函数应该确保复制“对象内的所有成员变量”及“所有 base class 成分”。
- 不要尝试以某个 copying 函数实现另一个 copying 函数。应该将共机能放进第三个函数中,并由两个 copying 函数共同调用。
条款13:以对象管理资源
假设有一个工厂函数
Investment* createInvestment(); /* * 返回指针,指向 Investment 继承体系内的动态分配对象。 * 调用者有责任删除它。这里为了简化,可以不写参数 */ void f() { Investment* pInv = createInvestment(); // 调用 factory 函数 ... delete pInv; // 释放 pInv 所指对象 } /* * 在 ... 中可能含有 return 或 continue 或 抛出异常等语句,果真如此控制流将再次不会幸临 delete * 无论 delete 如何被略过去,我们泄露的不只是内含投资对象的那块内存,还包括那些投资对象所保存的任何资源 */
本条款背后的半边想法:把资源放进对象内,我们便可依赖 C++ 的“析构函数自动调用机制”确保资源被释放。
void f() { std::auto_ptr<Investment> pInv(createInvestment()); ... /* * 调用 factory 函数一如既往地使用 pInv 经由 auto_ptr 的析构函数自动删除 pInv */ }
- RAII:“以对象管理资源”的观念常被称为“资源取得时机便是初始化时机”(Resource Acquisition Is Initialization;RAII)
这个简单的例子示范“以对象管理资源”的两个关键想法:
- 获得资源后立刻放进管理对象
- 管理对象运用析构函数确保资源被释放
auto_ptr 有一个不寻常的性质:若通过 copy 构造函数或 copy assignment 操作符复制它们,它们会变成 null,而复制所得的指针将取得资源的唯一拥有权。
auto_ptr 的替代方案是“引用计数型智慧指针”(reference-coounting smart pointer;RCSP)。
auto_ptr 和 tr1::shared_ptr 两者都在其析构函数内做 delete 而不是 delete[] 动作。
std::auto_ptr<std::string> aps(new std::string[10]); // 馊主意!会用上错误的 delete 形式 std::tr1::shared_ptr<int> spi(new int[1024]); // 同上
请记住
- 为防止资源泄露,请使用 RAII 对象,它们在构造函数中获得资源并在析构函数中释放资源
- 两个常被使用的 RAII classes 分别是 tr1::shared_ptr 和 auto_ptr。前者通常是较佳选择,因为其 copy 行为比较直观。若选择 auto_ptr,复制动作会使它(被复制物)指向 null。
条款14:在资源管理类中小心 copying 行为
假设我们使用 C API 函数处理类型为 Mutex 的互斥器对象,共有 lock 和 unlock 两函数可用。
void lock(Mutex *pm); // 锁定 pm 所指的互斥器 void unlock(Mutex* pm); // 将互斥器解除锁定
为确保绝不会忘记将一个被锁住的 Mutex 解锁,你可能会希望建立一个 class 用来管理机锁。这样的 class 的基本结构由 RAII 守则支配。
class Lock { public: explicit Lock(Mutex* pm) : mutexptr(pm) { // 获得资源 lock(mutexptr); } ~Lock() { unlock(mutexptr); } // 释放资源 private: Mutex mutexptr; };
客户对 Lock 的用法符合 RAII 方式。
Mutex m; ... { lock ml(&m); ... } // 但如果 lock 对象被复制,会发生什么事 Lock ml1(&m); Lock ml2(ml1); // 将 ml1 复制到 ml2 身上会发生什么事
- 禁止复制
- 对底层资源祭出“引用计数法”
- 复制底部资源
- 转移底部资源的拥有权
请记住
- 复制 RAII 对象必须一并复制它所管理的资源,所以资源的 copying 行为决定 RAII 对象的 copying 行为。
- 普遍而常见的 RAII class copying 行为是:抑制 copying、施行引用计数法。不过其他行为也都可能被实现。
条款15:在资源管理类中提供对原始资源的访问
有时候需要一个函数可将 RAII class 对象转换为其所内含之原始资源。有两种做法可以达成目标:显式转换和隐式转换
显式转换
tr1::shared_ptr
和 auto_ptr
都提供一个 get 成员函数,用来执行显式转化,也就是它会返回智能指针内部的原始指针。
int days = daysHeld(pInv.get());
tr1::shared_ptr
和 auto_ptr
也重载了指针取值操作符。
class Investment { public: bool isTaxFree() const; ... }; Investment* createInvestment(); // factory 函数 std::shared_ptr<Investment> pi1(createInvestment()); // 令 shared_ptr 管理一笔资源 bool taxable1 = !(pi1->isTaxFree()); // 经由 operator-> 访问资源 std::auto_ptr<Investment> pi2(createInvestment()); // 令 auto_ptr 管理一笔资源 bool taxable2 = !((*pi2).isTaxFree()); // 经由 operator* 访问资源
隐式转换
考虑下面这个用于字体的 RAII class:
FontHandle getFont(); // 这是个 C API。为求简化暂略参数 void releaseFont(FontHandle fh); // 来自同一组 C API class Font { public: explicit Font(FontHandle fh) : f(fh) { } ~Font() { releaseFont(); } private: FontHandle f; // 原始(raw)字体资源 };
可能将 Font 对象转换为 FontHandle 会是一种很频繁的需求。Font class 可为此提供一个显式转换函数,像 get 那样:
class Font { public: ... FontHandle get() const { return f; } // 显式转换函数 ... }; void changeFontSize(FontHandle f, int newSize); // C API Font f(getFont()); int newFontSize; ... changeFontSize(f.get(), newFontSize); // 明白地将 Font 转换为 FontHandle
另一种办法是令 Font 提供隐式转换函数,转型为 FontHandle:
class Font { ... operator FontHandle() const { return f; } // 隐式转换函数 ... }; Font f(getFont()); int newFontSize; ... changeFontSize(f, newFontSize);
但是这个隐式转换会增加错误发生机会。例如客户可能会在需要 Font 时意外创建一个 FontHandle。
Font f1(getFont()); ... FontHandle f2 = f1; // 原意是要拷贝一个 Font 对象,却反而将 f1 隐式转换为其底部的 FontHandle 然后才复制他。
请记住
- APIs 往往要求访问原始资源,所以每一个 RAII class 应该提供一个“取得其所管理之资源”的办法。
- 对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但隐式转换对客户比较方便。
条款16:成对使用 new 和 delete 时要采取相同形式
new xxx -> delete x new xxx[] -> delete [] xxx
考虑下面 typedef
typedef std::string AddressLines[4]; // 每个人的地址有 4 行,每行是一个 string std::string* pal = new AddressLines; // new AddressLines 返回一个 string*,就像 new string[4] 一样 // delete pal; // 错误:行为未有定义 delete [] pal; // 很好
请记住
- 如果你在 new 表达式中使用 [],必须在相应的 delete 表达式中也使用 []。如果你在 new 表达式中不适用 [],一定不要在相应的 delete 表达式中使用 []。
条款17:以独立语句将 newed 对象置于智能指针
tr1::shared_ptr
构造函数需要一个原始指针,但该构造函数是个 explicit
构造函数。
int priority(); void processWidget(std::tr1::shared_ptr<Widget> pw, int priority); processWidget(new Widget, priority()); // 不能通过编译,如上所述 processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority()); // 可以通过编译,但是可能会参数资源泄露
第一个实参:
- 执行 “new Widget” 表达式
- 调用 tr1::shared_ptr 构造函数
第二个参数:
- 调用 priority
C++ 编译器完成这些事情的顺序的弹性很大:
若:
- 执行 “new Widget” 表达式
- 调用 priority
- 调用 tr1::shared_ptr 构造函数
在 priority 的调用产生异常,就会造成资源泄露
避免这类问题的方法也很简单
std::tr1::shared_ptr<Widget> pw(new Widget); // 在单独语句以内智能指针存储 newed 所得对象 processWidget(pw, priority()); // 这个调用动作绝不至于造成泄露
请记住
- 以独立语句将 newed 存储于(置入)智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄露。
条款18:让接口容易被正确使用,不易被误用
首先必须考虑客户可能做出什么样的错误。假设你为一个用来表现日期的 class 设计构造函数。
class Date { public: Date(int month, int day, int year); ... }; // 但是客户可能会以错误的次序传递参数 Date d(30, 3, 1995); // 也可能传递一个无效的月份或天数 Date d(2, 30, 1995);
许多客户端错误可以因为导入新类型而获得预防。
struct Day { explicit Day(int d) : val(d) { } int val; }; struct Month { explicit Month(int m) : val(m) { } int val; }; struct Year { explicit Year(int y) : val(y) { } int val; }; class Date { public: Date(const Month &m, const Day &d, const Year &y); ... }; Date d(30, 3, 1995); // 错误!不正确的类型 Date d(Day(30), Month(3), Year(1995)); // 错误!不正确的类型 Date d(Month(3), Day(30), Year(1995)); // 正确!
一旦正确的类型就定位,限制其值有时候是通情达理的。例如一年只有 12 个有效月份,所以 Month 应该反应这一事实。
class Month { public: static Month Jan() { return Month(1); } // 函数,返回有效月份 static Month Feb() { return Month(2); } ... static Month Dec() { return Month(12); } ... // 其他成员函数 private: explicit Month(int m); // 阻止生成新的月份 ... }; Date d(Month::Mar(), Day(30), Year(1995));
预防客户错误的另一个办法是,限制类型内什么事可做,什么事不可做。常见的限制是加上 const。(例如条款03)if (a * b = c) ...
下面是另一个一般性准则“让 types 容易被正确使用,不容易被误用”的表现形式:“除非有好理由,否则应该尽量令你的 types 的行为与内置 types 一致”。
请记住
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
- “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
- “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
- tr1::shared_ptr 支持定制型删除器。这可防范 DLL 问题,可被用来自动解除互斥锁等等。
条款19:设计 class 犹如设计 type
- 新 type 对象应该如何被创建和销毁
- 对象的初始化和对象的赋值该有什么样的差别
- 新 type 的对象如果被 passed by value(以值传递),意味着什么
- 什么是新 type 的“合法值”
- 你的新 type 需要配合某个继承图系吗
- 你的新 type 需要什么样的转换
- 什么样的操作符和函数对此新 type 而言是合理的
- 什么样的标准函数应该驳回
- 谁该取用新 type 的成员
- 什么是新 type 的“未声明接口”
- 你的新 type 有多么一般化
- 你真的需要一个新 type 吗
请记住
- Class 的设计就是 type 的设计。在定义一个新 type 之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
缺省情况下 C++ 以 by value 方式(一个继承自 C 的方式)传递对象至(或来自)函数。
class Person { public: Person(); virtual ~Person(); ... private: std::string name; std::string address; }; class Student : public Person { public: Student(); ~Student(); ... private: std::string schoolName; std::string schoolAddress; }; bool validateStudent(Student s); // 函数以 by value 方式接受学生 Student plato; bool platoIsOk = validateStudent(plato); /* * 总成本是:六次构造函数和六次析构函数 * 有什么办法可以回避所有那些构造函数和析构函数 */ bool validateStudent(const Student& s); /* * 老版本:值传递 -> 原 Student 不会被更改 * 新版本:const 引用 -> 不这样做的话调用者会忧虑 validateStudent 会不会改变他们传入的那个 Student */
以 by reference 方式传递参数也可以避免 slicing(对象切割)问题
当一个 derived class 对象以 by value 方式传递并被视为一个 base class 对象,base class 的 copy 构造函数会被调用,而“造成此对象的行为像个 derived class 对象”的那些特化性质全被切割掉了,仅仅留下一个 base class 对象。
class Window { public: ... std::string name() const; // 返回窗口名称 virtual void display() const; // 显示窗口和其他内容 }; class WindowWithScrollBars : public Window { public: ... virtual void display() const; }; void printNameAndDisplay(Window w) { // 不正确!参数可能被切割 std::cout << w.name(); w.display(); } /* * 当调用上述函数并交给它一个 WindowWithScrollBars 对象,会发生什么呢? */ WindowWithScrollBars wwsb; printNameAndDisplay(wwsb); /* * 调用的总是 Window::display 绝不会是 WindowWithScrollBars::display * 解决方法 */ void printNameAndDisplay(const Window& w) { // 很好,参数不会被切割 std::cout << w.name(); w.display(); }
请记住
- 尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可避免切割问题。
- 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。
条款21:必须返回对象时,别妄想返回其 reference
考虑一个用以表现有理数的 class,内含一个函数用来计算两个有理数的乘积:
class Rational { public: Rational(int numerator = 0, int denominator = 1); ... private: int n, d; friend const Rational operator* (const Rational& lhs, const Rational& rhs); // 条款 03 说明为什么返回类型是 const }; /* * 以上述 operator* 为例,如果它返回一个 reference,后者一定指向某个既有的 Rational 对象,内含两个 Rational 对象的乘积 * 如果 operator* 要返回一个 reference,它必须自己创建那个 Rational 对象 */
stack 空间
const Rational& operator* (const Rational& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.d & rhs.d); // 糟糕的代码! return result; // local 对象在函数退出之前被销毁了 }
heap 空间
const Rational& operator* (const Rational& lhs, const Rational& rhs) { Rational* result = new Rational(lhs.n * rhs.n, lhs.d & rhs.d); // 更糟糕的写法! return *result; } // 谁该对着被你 new 出来的对象实施 delete??? Rational w, x, y, z; w = x * y * z; // 与 operator*(operator*(x, y), z); 相同 // 怎么进行那些 delete 调用??? --> 内存泄露
static Rational 对象
const Rational& operator* (const Rational& lhs, const Rational& rhs) { static Rational result; // 警告,又一堆烂代码 result = ...; // 把乘积置于 result 内 return result; } bool operator== (const Rational& lhs, const Rational& rhs); // 一个针对 Rational 而写的 operator== Rational a, b, c, d; ... if ((a * b) == (c * d)) { } else { } // 永远为 true
因为两次 operator* 调用的确各自改变了 static Rational 对象值,但由于它们返回的都是 reference,因此调用端看到的永远是 static Rational 对象的“现值”。
正确写法
// 一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象呗。 inline const Rational operator* (const Rational& lhs, const Rational& rhs) { return Rational(lhs.n * rhs.n, lhs.d & rhs.d); }
请记住
- 绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,或返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。条款 04 已经为“在单线程环境中合理返回 reference 指向一个 local static 对象”提供了一份设计实例。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!