Effective C++ —— 设计与声明(四)
条款18 : 让接口容易被正确使用,不易被误用
欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误操作。
1. 明智而审慎地导入新类型对预防“接口被误用”有神奇疗效。同时也就可以再新类型中对值进行限制。如下:
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); //错误,类型(需要Day,Month,Year类型)不正确 Date d(Day(30), Month(3), Year(1995)); //错误,类型(顺序)不正确 Date d(Month(3), Day(30), Year(1995)); //ok,顺序和声明式一致,类型正确
上述中未加入对值的限制,下面以Month为例说明:
class Month { public: static Month Jan() { return Month(1); } // 函数,返回有效月份 static Month Feb() { return Month(2); } // "以函数替换对象",基于条款04:non-local static对象的初始化次序有可能出现问题 ...... private: explicit Month(int m); ...... }; Date d(Month::Jan(), Day(30), Year(1995));
2. 预防客户错误的另一个办法是,限制类型内什么事可做,什么事不可做。常见的限制是加上const。这样,下面语句便不会导致错误:
if (a * b) = c ...... //原意是要做一次比较动作
3. 除非有好的理由,否则应该尽量令你的types的行为与内置types一致。
4. 任何接口如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那件事情。如条款13.
5. tr1::shared_ptr 有一个特别好的性质:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的“cross-DLL problem”。这个问题发生于“对象在动态连接程序库(DLL)中被new创建,却在另一个DLL内被delete销毁”。在许多平台上,这类“跨DLL之new/delete成对运用”会导致运行期错误。tr1::shared_ptr没有这个问题,因为它缺省的删除器是来自“tr1::shared_ptr诞生所在的那个DLL”的delete。
故而:
1. 好的接口很容器被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
2. “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
3. “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
4. tr1::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解锁互斥所(条款14).
条款19 : 设计class犹如设计type
请记住:
Class的设计就是type的设计。在定义一个新type之前,请谨慎考虑本条款覆盖的所有讨论主题。详见原著。
条款20 : 宁以pass-by-reference-to-const替换pass-by-value
1. 缺省情况下C++以by value方式(一个继承自C的方式)传递对象至(或来自)函数。除非你另外指定,否则函数参数都是以实际实参的复件为初值,而调用端所获得的亦是函数返回值的一个复件。这些复件系由对象的copy构造函数产出,这可能使得pass-by-value成为昂贵的(费时的)操作。
而是用pass-by-reference-to-const 方式,则不会有任何构造函数或析构函数被调用,因为没有任何新对象被创建。const非常重要,原先以by value方式接受一个对象参数,因此调用者知道他们受到保护,函数内绝对不会对传入的对象作任何改变,函数只能够对其复件做修改。若对象以by reference方式传递,将它声明为const是必要的,因为不这样做的话调用者会忧虑函数会不会改变他们传入的那个对象。
2. 以by reference方式传递参数也可以避免slicing(对象切割)问题。当一个derived class对象以by value方式传递并被视为一个base class对象,base class的copy构造函数会被调用,而“造成此对象的行为像个derived class对象”的那些特化性质(derived class专属的成员变量)全被切割掉了,仅仅留下一个base class对象。例如:
void printNameAndDisplay(Window w) { std::cout << w.name(); w.display(); } //下面调用 WindowWithScrollBars wwsb; printNameAndDisplay(wwsb);
上面代码中,参数w会被构造成为一个Window对象;它是passed-by-value,而造成wwsb“之所以是个WindowWithScrollBars对象”的所有特化信息都会被切除。在printNameAndDisplay函数内不论传递过来的对象原本是什么类型,参数w就像一个Window对象(因为其类型就是Window)。因此在printNameAndDisplay内调用display调用的总是Window::display,绝对不会是WindowWithScrollBars::display。
解决“对象切割”问题的办法,就是以by reference-to-const 的方式传递w;
void printNameAndDisplay(const Window& w) { std::cout << w.name(); w.display(); }
注意:窥视C++编译器的底层,你会发现,reference往往以指针实现出来,因此pass by refefence通常意味真正传递的是指针。因此如果你有个对象属于内置类型(例如int),pass by value往往比pass by reference的效率高些。对内置类型而言,当你有机会选择采用pass by value或pass by reference to const时,选择pass by value并非没有道理。这个忠告也适用于STL的迭代器和函数对象,因为习惯上它们都被设计为passed by value。
故而:
1. 尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免对象切割问题。
2. 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适当。
条款21 : 必须返回对象时,别妄想返回其reference
先考虑下面三种情况:
1. 定义local 变量,在stack空间创建对象:
const Rational& operator* ( const Rational& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.d * rhs.d); // 糟糕的代码 return result; }
上述代码,其一,使用构造函数构造新对象;其二,函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前就被销毁了。任何函数如果返回一个reference指向某个local对象,都将一败涂地。
2. 使用new在heap-based上创建对象:
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 ?没有合理的办法让operator* 使用者进行那些delete调用,因为没有合理的办法让他们取得operator*返回的reference背后隐藏的那个指针。
3. 让operator* 返回的reference指向一个被定义于函数内部的static Rational对象:
const Rational& operator* ( const Rational& lhs, const Rational& rhs) { static Rational result; //static对象,此函数将返回其reference result = .... ; return result; }
就像所有用上static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑虑。然而,更深层的弊端在于:
bool operator== ( const Rational& lhs, const Rational& rhs) // 一个针对Rationals而写的operator== Rational a, b, c, d; ..... if ((a * b) == (c * d)) { // do something } else { // do something }
上述表达式 (a * b) == (c * d) 总是为true。考虑下面等价形式:
if (operator==(operator*(a, b), operator*(c, d))
注意,在operator==被调用前,已有两个operator*调用式起作用,每一个都返回reference指向operator*内部定义的static Rational对象。因此operator==被要求将“operator*内的static Rational对象值”拿来和“operator*内的static Rational对象值”比较。(两次operator*调用的确各自改变了static Rational对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Rational对象的现值)。
综上,对于试图返回函数内对象的种种方法都宣告失败,即如此,一个“必须返回新对象”的函数的正确写法:就让那个函数返回一个新对象。即便需要承担operator*返回值的构造成本和析构成本也是值得的。
inline const Rational operator* ( const Rational& lhs, const Rational& rhs) { return result(lhs.n * rhs.n, lhs.d * rhs.d); }
故而:
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款04已经为“在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。
条款22 : 将成员变量声明为private
基于以下三个理由,你有必要将成员变量声明为private。
1. 语法一致性(条款18)。使成员变量唯一的访问办法是通过成员函数(public接口)。
2. 使用函数,可以让你对成员变量的处理有更精确的控制。可以实现出“不准访问”,“只读”,“只写”,“可读可写”的访问限制。
3. 最重要的,实现类的封装性。如果通过函数访问成员变量,日后对成员变量的更改便不会影响到客户。
条款23将会告诉你,某些东西的封装性与“当其内容改变时可能造成的代码破坏量”成反比。改变(所谓改变,最极端的做法是把它从class中移除)public成员变量,所有使用它的客户代码都会被破坏。而改变protected成员变量,所有使用它的derived class都会被破坏。这两者对客户代码的破坏量都是巨大的。
故而:
1. 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class 作者以充分的实现弹性。
2. protected并不比public更具封装性。
条款23 : 宁以non-member、non-friend替换member函数
考虑如下代码:
class WebBrowser { public: ..... void clearCache(); void clearHistory(); void removeCookies(); ...... // 许多用户可能会想要有一个函数,调用则执行所有清除动作 // clearEverything函数就是这样一个提供便利的函数 void clearEverything(); // 调用clearCache,clearHistory,removeCookies }; // 当然,也可由non-member函数调用适当的member函数提供: void clearBrowser(WebBrowser& wb) { wb.clearCache(); wb.clearHistory(); wb.removeCookies(); }
基于以下理由,non-member做法比member做法好:
1. member函数clearEverything带来的封装性比non-member函数clearBrowser低;如果你要在一个member函数(可访问private成员)和一个non-member函数,non-friend函数(无法访问),而且两者提供相同机能,那么,导致较大封装性的是non-member函数,non-friend函数,因为它并不增加“能够访问class内之private成分”的函数数量。
2. 提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性,而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。
注意:
1. 上述论述只适用于non-member函数,non-friend函数。
2. 只因在意封装性而让函数“成为class的non-member”,并不意味它“不可以是另一个class的member”。
在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace(命名空间)内:
namespace WebBrowserStuff { class WebBrowser { ... }; void clearBrowser(WebBrowser& wb); ..... }
namespace和classes不同,前者可跨越多个源码文件而后者不能。若class拥有大量便利函数,某些书签(bookmarks)有关,某些与打印有关,某些与cookie管理有关……通常大多数客户只对其中某些感兴趣。没道理一个只对书签相关便利函数感兴趣的客户却与一个cookie相关便利函数发生编译相依关系。分离它们最直接做法就是将书签相关便利函数声明于一个头文件,将cookie相关便利函数声明于另一个头文件,再将打印相关便利函数声明于第三个头文件,依此类推:
// 头文件“webbrowser.h”——这个头文件针对class WebBrowser自身以及WebBrowser核心机能 namespace WebBrowserStuff { class WebBrowser { .... }; ... // 核心机能实现 } // 头文件“webbrowserbookmarks.h” namespace WebBrowserStuff { ... // 与书签相关的便利函数 } // 头文件“webbrowsercookie.h” namespace WebBrowserStuff { ... // 与cookie相关的便利函数 } ........
注意:这正是C++标准程序库的组织方式。标准程序库并不是拥有单一、整理、庞大的<C++ StandardLibrary>头文件并在其中内含std命名空间内的每一样东西,而是有数十个头文件(<vector>, <algorithm>,<memory>等等),每个头文件声明std的某些机能。如果客户只想使用vector相关机能,他不需要#include<memory>;如果客户不想使用list,也不需要#include<list>。这允许客户只对他们所用的那一小部分系统形式编译相依(条款31)。以这种方式切割机能并不适用于class成员函数,因为一个class必须整体定义,不能被分割为片片段段,也即是说,成员函数无法通过这种方式降低编译相依,所以,如果成员函数和non-member函数实现了相同机能,那么从编译相依的角度,优先使用non-member函数。
将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。只需要在命名空间内建立一个头文件,内含那些函数的声明即可。
故而:
宁拿non-member、non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充。
条款24 : 若所有参数皆需类型转换,请为此采用non-member函数
考虑如下代码:
class Rational { public: ...... Rational(int numerator = 0, int denominator = 1); // 构造函数刻意不为explicit,允许隐式转换 int numerator() const; //分子分母访问函数 int denominator() const; const Rational operator* (const Rational& rhs) const; // 参见条款3、20、21。lhs参数为this指针所指的隐喻参数。 }; Rational oneEight(1, 8); Rational oneHalf(1, 2); Rational result = oneHalf * oneEight; //ok Rational result = result * oneEight; //ok // 不同类型混合运算 result = oneHalf * 2; // (1) ok result = 2 * oneHalf; // (2) Error // (1)、(2)语句可等价于 result = oneHalf.operator*(2); // ok result = 2.operator*(oneHalf); // Error
oneHalf是一个内含operator*函数的class对象,所以编译器调用该函数。然而整数2并没有相应的class,也就没有operator*成员函数。编译器也会尝试寻找可被以下这般调用的non-member operator*(也就是在命名空间内或在global作用域内):
result = operator*(2, oneHalf); //错误
但本例并不存在这样一个接受int和Rational作为参数的non-member operator*,因此查找失败。
仔细考究:上面语句(1)之所以调用成功,是由于Rational类允许构造函数的隐式转换。(只有在涉及non-explicit构造函数,编译器才允许这样做)。并且,只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者。
class Rational { public: ...... }; const Rational operator* (const Rational& lhs, const Rational& rhs) //non-member函数 { return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); } Rational oneFourth(1, 4); Rational result; result = oneFourth * 2; //ok result = 2 * oneFourth; // ok
上述语句都顺利的通过编译。那么,operator*是否应该成为Rational class 的一个friend函数呢 ?本例的答案是否定的。成为friend函数的目的无非是访问class内部成员变量,operator*可以完全藉由Rational的public接口完成任务。member函数的反面是non-member函数,不是friend函数。
故而:
如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member(因为其显式操作所有参数)。
条款25: 考虑写出一个不抛异常的swap函数
swap是个有趣的函数。原本它只是STL的一部分,而后成为异常安全性编程的脊柱,以及用来处理自我赋值可能性(条款11)的一个常见机制。所谓swap(置换)两个对象值,意思是将两对象的值彼此赋予对方。缺省情况下swap动作可由标准程序库提供的swap算法完成。
namespace std { template<typename T> void swap(T& a, T& b) { T temp(a); // 类型T必须支持copying a = b; b = temp; } }
现在试着考虑:“以指针指向一个对象,内含真正数据”那种类型。这种设计的常见表现形式是所谓“pimpl手法”(pimpl是“pointer to implementation” 的缩写,条款31)。如下:
class WidgetImpl { public: .... private: int a, b, c; std::vector<double> v; //意味复制时间很长 ..... }; class Widget // 使用pimpl手法 { public: Widget(const Widget& rhs); Widget& operator=(const Widget& rhs) // 复制Widget时,令它复制其WidgetImpl对象 { .... // 关于operator=的一般性实现细节,见条款10、11、12 *pImpl = *(rhs.pImpl); .... } .... private: WidgetImpl * pImpl; };
一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的swap算法不知道这一点。它不只复制三个Widgets,还复制三个WidgetImpl对象。非常低效。这时我们需要实现自己的swap函数:
情况1:将std::swap针对Widget特化,如下构想代码(目前仍无法通过编译)
namespace std { // 这是std::swap针对“T是Widget”的特化版本,目前还无法通过编译 template<> void swap<Widget>(Widget& a, Widget& b) { swap(a.pImpl, b.pImpl); // 只需置换指针 } }
这个函数以开始的“template<>”表示它是std::swap的一个全特化版本,函数名称之后的“<Widget>”表示这一特化版本系针对“T是Widget”而设计。换句话说当一般性的swap template施行于Widgets身上便会启用这个版本。通常我们不能够(不被允许)改变std命名空间内的任何东西,但可以(被允许)为标准template制造特化版本,使它专属于我们自己的classes。
上述代码之所以无法通过编译,是因为它企图访问a和b内的pImpl指针,而那却是private。可修改如下:
class Widget { public: ... void swap(Widget& other) { using std::swap; //这个声明之所以必要,稍后解释(详见本条款末尾备注) swap(pImpl, other.pImpl); } ........ }; namespace std { // 修订后的std::swap特化版本,可通过编译 template<> void swap<Widget>(Widget& a, Widget& b) { a.swap(b); //调用其swap成员函数 } }
这种做法不只能够通过编译,还与STL容器有一致性,因为所有STL容器也都提供有public swap成员函数和std::swap特化版本(用以调用前者)。
情况2:假设Widget和WidgetImpl都是class templates而非classes,我们可以试将WidgetImpl内的数据类型加以参数化:
template<typename T> class WidgetImpl { ..... }; template<typename T> class Widget { ...... };
在Widget内(以及WidgetImpl内,如果需要的话)放个swap成员函数就像以往一样简单,但我们却在特化std::swap时遇上乱流。如下:
namespace std { template<typename T> void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) // 偏特化模板函数,错误 { a.swap(b); } }
上述代码,我们企图偏特化一个function template(std::swap),但C++只允许对class template偏特化,在function template身上偏特化是行不通的,详见模版的特化与偏特化。这段代码不该通过编译。如果你打算偏特化一个function template时,惯常做法是简单地为它添加一个重载版本。如下:
namespace std { template<typename T> // std::swap的一个重载版本 void swap(Widget<T>& a, Widget<T>& b) // swap之后没有“< ... >” { a.swap(b); } }
一般,重载function templates没有问题,但std是个特殊的命名空间,其管理规则也比较特殊。客户可以全特化std内的templates,但不可以添加新的templates(或classes或functions或其他任何东西)到std里头。
解决方案:我们还是声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。而是在另一个命名空间中声明non-member swap函数。如下:
namespace WidgetStuff { // 这里不属于std命名空间 ... template<typename T> class Widget { ... }; ... template<typename T> void swap(Widget<T>& a, Widget<T>& b) //non-member swap函数 { a.swap(b); } }
这种做法对classes和class templates都行得通,所以似乎我们应该在任何时候都使用它。
不幸的是有一个理由使你应该为classes特化std::swap(对于classes,编译器还是比较喜欢std::swap的T专属特化版,而非一般化的那个template,所以如果你已针对T将std::swap特化,特化版会被编译器挑中)。所以如果你想让你的“class专属版”swap在尽可能多的语境下被调用,你需要同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。
备注:从客户角度,当我们调用swap置换两对象值的时候,我们希望应该是调用T专属版本,并在该版本不存在的情况下调用std内的一般化版本。下面是你希望发生的事:
template<typename T> void doSomething(T& obj1, T& obj2) { using std::swap; // 令std::swap在此函数内可用 ..... swap(obj1, obj2); // 为T型对象调用最佳swap版本 ..... }
小结:
首先,如果swap的缺省实现对你的class或class template提供可接受的效率,你不需要额外做任何事。任何尝试置换那种对象的人都会取得缺省版本,而那将有良好的运作。
其次,如果swap缺省实现的效率不足(那几乎总是意味你的class或template使用了某种pimpl手法),试着做一下事情:
1. 提供一个public swap成员函数,让它高效的置换你的类型的两个对象值。稍后我将解释,这个函数绝不该抛出异常。
2. 在你的class或template所在的命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
3. 如果你正编写一个class(而非class template),为你的class特化std::swap。并令它调用你的wap成员函数。
最后,如果你调用成员版swap,请确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,赤裸裸地调用swap。
切记:成员版swap绝不可抛出异常。那是因为swap的一个最好的应用是帮助classes(和class template)提供强烈的异常安全性保障,条款29对此主题提供了所有细节,但此技术基于一个假设:成员版swap绝不抛出异常。这一约束只施行于成员版!不可施行于非成员版,因为swap缺省版本(非成员版)是以copying函数为基础,而一般情况下copying函数两者都允许抛出异常。因此当你写下一个自定版本的swap,往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般而言这两个swap特性是连在一起的,因为高效率的swaps几乎总是基于对内置类型的操作(例如pimpl手法的底层指针),而内置类型上的操作绝不会抛出异常。
故而:
1. 当std::swap对你的类型效率不高是,提供一个swap成员函数,并确保这个函数不抛出异常。
2. 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请特化std::swap。
3. 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何”命名空间资格修饰“。
4. 为”用户定义类型“进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。