18:让接口容易被正确使用,不易被误用
1:理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。
2:许多客户端的错误可以因为导入新类型而得到预防。比如下面的接口:
class Date { public: Date(int month, int day, int year); ... };
这个接口很容易使客户犯错,比如:
Date d(30, 3, 1995); // Oops! Should be "3, 30" , not "30, 3" Date d(2, 20, 1995); // Oops! Should be "3, 30" , not "2, 20"
通过引入新的类型来区分年、月、日,可以避免犯这样的错误:
struct Day { struct Month { struct Year { explicit Day(int d) explicit Month(int m) explicit Year(int y) :val(d) {} :val(m) {} :val(y){} int val; int val; int val; }; }; }; class Date { public: Date(const Month& m, const Day& d, const Year& y); ... }; Date d(30, 3, 1995); // error! wrong types Date d(Day(30), Month(3), Year(1995)); // error! wrong types Date d(Month(3), Day(30), Year(1995)); // okay, types are correct
引入新的类型之后,还可以限制该类型的值的合理范围。
3:除非有好理由,否则应该尽量令你的types的行为与内置types一致。
4:任何接口如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那件事。例如下面的函数,内部动态分配对象并返回其指针,要求客户必须最终delete该指针:
Investment* createInvestment();
客户可以将createInvestment的返回值存储于一个智能指针如std::shared_ptr内,因而将delete责任推给智能指针。但万一客户忘记使用智能指针怎么办?许多时候,较佳接口的设计原则是先发制人,就令该函数返回一个智能指针:
std::shared_ptr<Investment> createInvestment();
19:设计class犹如设计type
当你定义一个新class,也就定义了一个新type。如何设计高效的classes呢?几乎每一个class都要求你面对以下提问,而你的回答往往导致你的设计规范:
1:新type的对象应该如何被创建和销毁?
2:对象的初始化和对象的赋值该有什么样的差别?
3:新type的对象如果被passed by value(以值传递),意味着什么?
4:什么是新type的“合法值”?
5:你的新type需要配合某个继承图系(inheritance graph )吗?
6:你的新type需要什么样的转换?
如果你希望允许类型T1之物被隐式转换为类型T2之物,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument的构造函数。
7:什么样的操作符和函数对此新type而言是合理的?
8:什么样的标准函数应该驳回?
9:谁该取用新type的成员?
10:什么是新type的“未声明接口”?
11:你的新type有多么一般化?
12:你真的需要一个新type吗?
20:宁以pass-by-reference-to-const替换pass-by-value
1:以pass-by-reference-to-const的方式可以获得更高的效率。
2:以by reference方式传递参数也可以避免slicing(对象切割)问题。当一个derived class对象以by value方式传递并被视为一个base class对象,base class的copy构造函数会被调用,而“造成此对象的行为像个derived class对象”的那些特化性质全被切割掉了,仅仅留下一个base class对象。
3:如果窥视C++编译器的底层,你会发现,references往往以指针实现出来,因此pass by reference通常意味真正传递的是指针。因此如果你有个对象属于内置类型,pass by value往往比pass by reference的效率高些。对内置类型而言,当你有机会选择采用pass-by-value或pass-by-reference-to-const时,选择pass-by-value并非没有道理。这个忠告也适用于STL的迭代器和函数对象,因为习惯上它们都被设计为passed by value。
4:一般而言,你可以合理假设“pass-by-value并不昂贵”的唯一对象就是内置类型和STL的迭代器和函数对象。至于其他任何东西(比如用户自定义的小型类型)都请遵守本条款的忠告,尽量以pass-by-reference-to-const替换pass-by-value。
21:必须返回对象时,别妄想返回其reference
1:所谓reference只是个名称,代表某个既有对象。任何时候看到一个reference声明式,都应该问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。
2:如果函数返回一个reference指向一个local对象,这是一种未定义行为;
22:将成员变量声明为private
1:如果成员变量不是public,客户唯一能够访问对象的办法就是通过成员函数。使用函数可以让你对成员变量的处理有更精确的控制。如果你令成员变量为public,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出“不准访问”、“只读访问”以及“读写访问”。你甚至可以实现“惟写访问”,如果你想要的话:
class AccessLevels { public: int getReadOnly() const { return readOnly; } void setReadWrite(int value) { readWrite = value; } int getReadWrite() const { return readWrite; } void setWriteOnly(int value) { writeOnly = value; } private: int noAccess; // no access to this int int readOnly; // read-only access to this int int readWrite; // read-write access to this int int writeOnly; // write-only access to this int };
2:将成员变量声明为private,提高了封装性。Public意味不封装,而几乎可以说,不封装意味不可改变。
某些东西的封装性与“当其内容改变时可能造成的代码破坏量”成反比。因此,成员变量的封装性与“成员变量的内容改变时所破坏的代码数量”成反比。
3:protected成员变量的论点十分类似。
假设我们有一个public成员变量,而我们最终取消了它。所有使用它的客户码都会被破坏,而那是一个不可知的大量。因此public成员变量完全没有封装性。
假设我们有一个protected成员变量,而我们最终取消了它,则所有使用它的derived classes都会被破坏,那往往也是个不可知的大量。
因此,protected成员变量就像public成员变量一样缺乏封装性,一旦你将一个成员变量声明为public或protected而客户开始使用它,就很难改变那个成员变量所涉及的一切。太多代码需要重写、重新测试、重新编写文档、重新编译。
23:宁以non-member, non-friend替换member函数
假设有个class用来表示网页浏览器。这样的class可能提供的众多函数中,有一些用来清除下载缓存区、清除访问过的URLs的历史记录、以及移除系统中的所有cookies:
class WebBrowser { public: ... void clearCache(); void clearHistory(); void removeCookies(); ... };
如果用户想要执行所有这些清除动作,要么就是WebBrowser提供一个成员函数:
class WebBrowser { public: ... void clearEverything(); // calls clearCache, clearHistory and removeCookies };
要么就是有一个非成员函数来实现:
void clearBrowser(WebBrowser& wb) { wb.clearCache(); wb.clearHistory(); wb.removeCookies(); }
哪一种比较好呢?
a:面向对象守则要求数据应该尽可能被封装,然而与直观相反地,member函数clearEverything带来的封装性比non-member函数clearBrowser低。
b:如果某些东西被封装,它就不再可见。愈多东西被封装,愈少人可以看到它。而愈少人看到它,我们就有愈大的弹性去变化它。因此,愈多东西被封装,我们改变那些东西的能力也就愈大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。
c:考虑对象内的数据,愈少代码可以看到数据(也就是访问它),愈多的数据可被封装。如何量测“有多少代码可以看到某一块数据”呢?我们计算能够访问该数据的函数数量,作为一种粗糙的量测。愈多函数可访问它,数据的封装性就愈低。
因此,如果要你在一个member函数和一个non-member,non-friend函数之间做抉择,而且两者提供相同机能,那么,导致较大封装性的是non-member non-friend函数,因为它并不增加“能够访问class内之private成分”的函数数量。这就解释了为什么clearBrowse比clearEverything更受欢迎的原因:它导致WebBrowser 有较大的封装性。
这个论述只适合于non member non friend函数,因为friend函数对class private成员的访问能力和member函数相同,两者对封装的冲击力也相同。
4:在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内:
namespace WebBrowserStuff { class WebBrowser { ... }; void clearBrowser(WebBrowser& wb); ... }
namespace和classes不同,前者可跨越多个源码文件而后者不能。比如像WebBrowser这样的class可能拥有大量便利函数,某些与书签有关,某些与打印有关,还有一些与cookie的管理有关,通常大多数客户只对其中某些感兴趣。分离它们最直接的做法就是将不同功能的函数声明在不同的头文件中:
// header "webbrowser.h" - header for class WebBrowser itself // as well as "core" WebBrowser-related functionality namespace WebBrowserStuff { class WebBrowser { ... }; ... // "core" related functionality, e.g.non-member // functions almost all clients need } // header "webbrowserbookmarks.h" namespace WebBrowserStuff { ... // bookmark-related convenience } // functions // header "webbrowsercookies.h" namespace WebBrowserStuff { ... // cookie-related convenience } // functions ...
这也正是C++标准程序库的组织方式。标准程序库有数十个头文件,每个头文件声明std命名空间内的某些机能。如果客户只想使用vector相关机能,他不需要#include <memory>;如果客户不想使用list,也不需要#include <list>。这就是namespace可跨越多个源码文件的好处。
将所有便利函数放在多个头文件内但隶属同一个命名空间,还意味着客户可以轻松扩展一组便利函数。他们需要做的就是添加更多non-member non-friend函数到此命名空间内即可。
24:若所有参数皆需类型转换,请为此采用non-member函数
考虑下面的有理数类:
class Rational { public: Rational(int numerator = 0, // ctor is deliberately not explicit; int denominator = 1); // allows implicit int-to-Rational // conversions int numerator() const; // accessors for numerator and int denominator() const; // denominator - see Item 22 private: ... };
上面这个有理数类,支持从int到该类的隐式转换。考虑一下有理数的乘法运算应该是member函数还是non member函数?
如果乘法操作符是个member函数的话:
class Rational { public: ... const Rational operator*(const Rational& rhs) const; };
可能会有这样的运算:
Rational oneEighth(1, 8); Rational oneHalf(1, 2); Rational result = oneHalf * oneEighth; // fine result = result * oneEighth; // fine
result = oneHalf * 2; // fine result = 2 * oneHalf; // error!
最后两个语句只有一个是可行的。当以对应的函数形式重写上述两个式子,问题便一目了然:
result = oneHalf.operator*(2); // fine result = 2.operator*(oneHalf); // error!
因为Rational支持从int到该类的隐式转换,所以result = oneHalf * 2; 是合法的,但是整数2不是class,也就没有operator*成员函数。编译器也尝试寻找non-member operator*函数:
result = operator*(2, oneHalf);
但是不存在这样的non-member operator*,所以编译失败。
因此,需要让operator*成为一个non-member函数,从而允许编译器在每一个实参身上执行隐式类型转换:
const Rational operator*(const Rational& lhs, // now a non-member const Rational& rhs) // function { return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); }
25:考虑写出一个不抛异常的swap函数
1:标准库提供的swap算法比较简单,典型实现就是利用临时对象进行两个对象的交换。它涉及三个对象的复制:a复制到temp, b复制到a,以及temp复制到b。但是对某些类型而言,这些复制动作无一必要。其中最主要的就是“以指针指向一个对象,内含真正数据”那种类型。这种设计的常见表现形式是所谓“pimpl手法”(pointer to implementation),比如下面的例子:
class WidgetImpl { // class for Widget data; public: // details are unimportant ... private: int a, b, c; // possibly lots of data - std::vector<double> v; // expensive to copy! ... }; class Widget { // class using the pimpl idiom public: Widget(const Widget& rhs); Widget& operator=(const Widget& rhs) // to copy a Widget, copy its { // WidgetImpl object. For ... // details on implementing *pImpl = *(rhs.pImpl); // operator= in general, ... // see Items 10, 11, and 12. } ... private: WidgetImpl *pImpl; // ptr to object with this };
一旦要置换两个Widget对象值,我们唯一需要做的就是置换其plmpl指针,但缺省的swap算法不知道这一点。它不只复制三个Widgets,还复制三个WidgetImpl 对象。非常缺乏效率!
2:解决方法是令Widget声明一个名为swap的public成员函数做真正的置换工作,然后将std::swap特化(通常我们不能够改变std命名空间内的任何东西,但可以为标准templates,如swap制造特化版本),令它调用该成员函数:
class Widget { // same as above, except for the public: // addition of the swap mem func ... void swap(Widget& other) { using std::swap; // the need for this declaration // is explained later in this Item swap(pImpl, other.pImpl); // to swap Widgets, swap their } // pImpl pointers ... }; namespace std { template<> // revised specialization of void swap<Widget>(Widget& a, // std::swap Widget& b) { a.swap(b); // to swap Widgets, call their } // swap member function }
实际上STL中的容器也是这么做的,它们提供有public swap成员函数和std::swap特化版本(用以调用前者)。
3:如果Widget和WidgetImpl都是class templates而非classes,那么在特化std::swap时就可能发生错误:
namespace std { template<typename T> void swap<Widget<T> >(Widget<T>& a, // error! illegal code! Widget<T>& b) { a.swap(b); } }
这种写法企图偏特化一个function template(std::swap),但C++只允许对class templates偏特化,在function templates身上偏特化是行不通的。这段代码编译不通过。
当打算偏特化一个函数模板时,通常的做法是为他添加一个重载版本,就像这样:
namespace std { template<typename T> // an overloading of std::swap void swap(Widget<T>& a, // (note the lack of "<...>" after Widget<T>& b) // "swap"), but see below for { a.swap(b); } // why this isn't valid code }
虽然可以重载函数模板,但是std是个特殊的命名空间,我们可以全特化std内的template,但是不可以添加新的template,因为std的内容完全由C++标准委员会决定。
因此,最终的解决办法是:还是声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。为求简化起见,假设Widget的所有相关机能都被置于命名空间WidgetStuff内,整个结果看起来便像这样:
namespace WidgetStuff { ... // templatized WidgetImpl, etc. template<typename T> // as before, including the swap class Widget { ... }; // member function ... template<typename T> // non-member swap function; void swap(Widget<T>& a, // not part of the std namespace Widget<T>& b) { a.swap(b); } }
这里在命名空间WidgetStuff 中定义了一个Wdiget专属的swap版本。现在,任何地点的任何代码如果打算置换两个Widget对象,调用swap时, C++的名称查找法则,更具体地说是所谓argument-dependent lookup或Koenig lookup法则,会找到WidgetStuff内的Widget专属版本。那正是我们所要的。
4:上面这种做法对于classes和class templates都适用。从客户角度来看,当他需要交换两个对象时,应该这样写:
template<typename T> void doSomething(T& obj1, T& obj2) { using std::swap; // make std::swap available in this function ... swap(obj1, obj2); // call the best swap for objects of type T ... }
一旦编译器看到对swap的调用,它们便查找适当的swap并调用之。C++的名称查找法则确保将找到global作用域或T所在之命名空间内的任何T专属的swap。如果T是Widget并位于命名空间WidgetStuff内,编译器会使用“实参取决之查找规则”(argument-dependent lookup)找出WidgetStuff内的swap。如果没有T专属之swap存在,编译器就使用std内的swap,这就是using声明的作用,它让std::swap在函数内曝光。然而即便如此编译器还是比较喜欢T专属的swap,只要特化版存在,特化版会被编译器挑中。
5:如果客户端调用时,错误的使用了下面的方式:
std::swap(obj1, obj2); // the wrong way to call swap
这便强迫编译器只认std内的swap(包括其任何template特化版本),因而不再可能调用一个定义于它处的T专属版本。
因此,如果你正在写的是class而非template class的话,建议在编写该class的专属版本的同时,还写一个class对std::swap全特化的版本,这样即使有程序员这样调用std::swap的情况,也能提供更好的版本。但是这对于template class是不成立的,因为无法针对一个template class全特化一个std::swap。
6:总结如下:
首先,如果swap的缺省实现码对你的class或class template提供可接受的效率,你不需要额外做任何事。
其次,如果swap缺省实现版的效率不足(那几乎总是意味你的class或template使用了某种pimpl手法),试着做以下事情:
a.提供一个public swap成员函数,让它高效地置换你的类型的两个对象值。注意,这个函数绝不该抛出异常,那是因为swap的一个最好的应用是帮助classes(和class templates)提供强烈的异常安全性。
b.在你的class或template所在的命名空间内提供一个non-member swap,也就是该class或template的专属swap,并令它调用上述swap成员函数。
c.如果你正编写一个class(而非class template ),为你的class特化std::swap。并令它调用你的swap成员函数。
最后,如果你调用swap,请确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,赤裸裸地调用swap。