条款18:Make interfaces easy to use correctly and hard to use incorrectly
C++中充斥着各种各样的接口,function接口、class接口、template接口……每一种接口都是客户与你的代码互动的手段。假设你面对的是一群通情达理的客户,如果他们用错了某个接口,你也应该为此负一部分责任。理想状态下,如果客户试图使用某个接口但却无法获得他预期的行为,那么客户代码就不该通过编译;一旦代码通过编译,其行为就应该是客户所想要的。
要开发一个不易被误用的接口,首先必须考虑客户可能犯下什么样的错误。假设你为一个用来表现日期的class设计构造函数:
class Date {
public:
Date(int month, int day, int year);
...
};
乍看之下,这个接口合情合理,其实客户很容易犯下至少两个错误。
第一,他们也许会以错误的顺序传递参数:
Date d(30, 3, 1995); //应该是“3,30”而不是“30,3”
第二,他们也许会传递一个无效的月份或天数:
Date d(2, 30, 1995); //二月没有30日
许多客户端错误可以通过导入新类型而获得预防。现在让我们导入三种简单的类型来区分年、月与日,然后在Date构造函数中使用这些类型:
struct Day {
explicit Day(ind d) : val(d) {}
int val;
};
struct Month {
explicit Month(ind 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)); //正确!
当然,上述structs并不成熟,但是作为示范已经足够能说明:导入新类型可以预防接口被误用。
一旦导入新类型,有时候限制其值是合情合理的。例如,一年只有12个月,所以Month就该反映这一事实。我们可以用enum表现月份,但enum不具备类型安全性。比较安全的做法是预先定义所有有效的Months:
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。
除非有什么好理由,否则应该尽量让你的types的行为与内置types一致,因为客户已经知道内置types有什么样的行为,避免与内置类型的不兼容,就能提供行为一致的接口。很少有什么其他性质比“一致性”更能导致“接口容易被正确使用”,也很少有其他性质比“不一致性”更加剧接口的恶化。STL容器在这方面做得很好,例如每个STL容易都有一个名叫size的成员函数。与此对比的是Java,它允许你对数组使用lengthproperty,对Strings使用lengthmethod,而对Lists使用sizemethod;.NET也一样混乱,其Arrays有个property名为Length,其ArrayLists有个property名为Count。有些开发人员会以为IDE能使这些不一致性不重要,但他们错了。不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除。
任何接口如果要求客户必须记得做某些事情,就是有着“被误用”的倾向,因为客户可能会忘记做那件事。例如条款13导入了一个factory函数,它返回一个指针指向Investment继承体系内的一个动态分配对象:
Investment* createInvestment();
为避免资源泄漏,客户可以将该函数的返回值存储于一个智能指针内,但万一客户忘记使用智能指针怎么办?许多时候,较佳接口的设计原则是先发制人,即令factory函数返回一个智能指针:
std::tr1::shared_ptr<Investment> createInvestment();
总结:
- 好的接口不易被误用
- 促进接口被正确使用的办法包括保持接口的一致性,以及使接口与内置类型兼容
- 阻止接口被误用的办法包括建立新类型、限制类型上的操作,约束对象值,以及消除客户的资源管理责任
条款19:Treat class design as type design
设计优秀的classes是一项艰巨的工作,因为设计好的types是一项艰巨的工作。好的types有自然的语法,直观的语义,以及一或多个高效实现品。
如何设计高效的classes呢?首先你必须了解你面对的问题。几乎每个classes都要求你面对以下提问
- 新type的对象应该如何被创建和销毁?这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数(operator new,operator new[],operator delete,和operator delete[])的设计
- 象的初始化和对象的赋值该有什么样的差别?这决定了你的构造函数和赋值操作符的行为以及其间的差异
- 新type的对象如果被passed by value,意味着什么?copy构造函数用来定义一个type的pass-by-value该如何实现
- 什么是新type的“合法值”?对class的成员变量来说,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件,也就决定了你的成员函数必须进行的错误检查工作
- 你的新type需要配合某个继承体系吗?如果你继承自某些既有的classes,你就受到那些classes的设计的约束,特别是受到“它们的函数是virtual或non-virtual”的影响。如果你允许其他classes继承你的class,那会影响你声明的函数——尤其是析构函数——是否为virtual
- 你的新type需要什么样的转换?如果你需要T1类型的对象可以被隐式转换为T2类型,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument的构造函数
- 什么样的操作符和函数对此新type而言是合理的?这个问题的答案决定你将为你的class声明哪些函数。其中哪些该是member函数,哪些则不是
- 什么样的标准函数应该驳回?那是你该声明为private的函数
- 谁该取用新type的成员?这个问题帮助你决定成员的访问级别,以及哪些classes或者functions该是friends
- 什么是新type的“未声明接口”?
- 你的新type有多么一般化?也许你并非定义一个新type,而是定义一整个types家族。这样,你就该定义新的class template
- 你真的需要一个新type吗?如果只是定义新的derived class以便为既有的class添加新功能,或许你只需定义一或多个non-member函数或templates就能达成目的
条款20:Prefer pass-by-reference-to-const to pass-by-value
缺省情况下C++以by value方式(继承自C)传递对象至函数。除非你另外指出,否则函数参数都是以实参的复件为初值,而调用端获得的也是函数返回值的一个复件。这些复件由对象的copy构造函数产生,因此可能造成昂贵的成本。为了避免不必要的构造和析构动作,我们可以采用pass by reference-to-const。
以by reference方式传递参数还可以避免对象切割的问题。当一个derived class对象以by value方式传递并被视为一个base class对象时,base class的copy构造函数会被调用。
对于内置类型,STL的迭代器和函数对象,pass-by-value比较适当。
条款21:Don't try to return a reference when you must return an object
当程序员意识到pass-by-value的种种不利因素之后,他们会开始一心一意地追求pass-by-reference,这往往会导致一个致命错误:传递一些references指向并不存在的对象。
当你必须在“返回一个reference和返回一个object”之间抉择时,你的工作就是挑出行为正确的那个。
条款22:Declare data members private
首先,我们来看看为什么成员变量不该是public。
让我们从语法一致性开始。如果成员变量是public的话,客户在访问class成员时就必须考虑访问的是一个变量还是函数,是否该加括号;反之,客户只能访问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; //对该成员无任何访问
int readOnly; //对该成员只读访问
int readWrite; //对该成员读写访问
int writeOnly; //对该成员只写访问
};
如果上述理由还不能说服你,那只好祭出杀手锏了:封装。
如果你通过函数访问成员变量,那么以后你可以修改内部实现,而不用担心客户代码需要更改,因为class的接口没有变化。
将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。例如这可是的成员变量被读或被写时轻松通知其他对象、可以验证class的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制……等等。
封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量(也就是封装它们),你可以确保class的约束条件总是会获得维护,因为只有成员函数可以影响它们。更重要的是,你保留了日后更改实现的权利。public意味着不封装,而不封装几乎意味着不可改变。
protected成员变量的论点十分类似。语法一致性和更精确的访问控制显然也适用于protected数据,那么封装呢?protected成员变量的封装性是否高于public成员变量?答案出人意料:并非如此。
一般来说,某样东西的封装性与其内容改变时造成的代码破坏量成反比。当public成员变量改变时,比如说取消了它,那么所有使用它的客户代码都会被破坏。而protected成员变量改变时,所有使用它的derived classes都会被破坏。因此,protected成员变量和public成员变量一样缺乏封装性。这一反直觉的结论告诉我们,从封装的角度来看,其实只有两种访问权限:private(提供封装)和其他(不提供封装)。
条款23:Prefer non-member non-friend functions to member functions
假设有个class表示网页浏览器。在它提供的众多函数中,有一些用来清除缓存、清除历史记录以及清除cookies:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
许多用户会希望同时执行以上所有动作,因此WebBrowser也提供这样一个函数:
class WebBrowser {
public:
...
void clearEverything(); //调用clearCache,clearHistory和removeCookies
...
};
当然,这一功能也可由一个non-member函数调用member函数来实现:
void clearBrowser(WebBrowser& wb) {
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
那么,哪一个比较好呢?
从面向对象的角度来看,数据以及操作数据的函数应该被捆绑在一起,这意味着member函数是较好的选择。不幸的是,这其实是对面向对象真实含义的误解。面向对象的原则要求数据尽可能地被封装,与直觉相悖,member函数clearEverything的封装性比non-member函数clearBrowser更低。此外,提供non-member函数使得WebBrowser的相关功能有较大的包装弹性,而那最终导致较低的编译依赖度,从而增加WebBrowser的可扩展性。因此在很多方面non-member做法比member做法好。下面我们将仔细探讨其原因。
我们从封装说起。如果某样东西被封装,它的可见性就会降低。封装性越高,可见性就越低。可见性越低,我们改变它的能力就越大,因为改变它仅仅直接影响能看见它的事物。因此, 某样东西的封装性越高,它被改变的弹性就越大。这正是我们推崇封装的原因:它使我们改变事物而只影响有限客户。
现在考虑对象内的数据。越少函数可以访问数据,数据的封装性就越高。在上面的例子中,采用non-member函数clearBrowser减少了能够访问class内private成员的函数数量,因而带来了更高的封装性。
值得注意的是,这里的选择关键并不在member和non-member函数之间,而是在member和non-member non-friend函数之间,因为friend函数对private成员的访问权限和member函数相同。另外,因为封装而让某函数成为class的non-member函数,并不意味着它不可以是另一个class的member函数。例如我们可以让clearBrowser成为某个工具类的一个static member函数。
在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内:
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
条款24:Declare non-member functions when type conversions should apply to all parameters
通常我们不该让classes支持隐式类型转换。但是也有例外,最常见的例外是在建立数值类型时。假设你设计一个class来表示有理数,那么允许整数隐式转换为有理数相当合理。你的Rational class如下:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1); //non-explicit构造函数,允许int-to-Rational隐式转换
int numerator() const;
int denominator() const;
private:
...
};
你希望支持算数运算,但你不确定该由member函数、non-member函数抑或是friend函数中的哪个来实现它们。面向对象的直觉告诉你,你应该让它们成为member,但前一条款曾经反直觉得得出member函数可能会违反面向对象的原则。让我们暂且撇开这些,先来看看让算数运算成为成员函数会发生什么(以乘法为例):
class Rational {
public:
...
const Rational operator* (const Rational& rhs) const;
};
支持Rational和int的混合类型运算再合理不过了,但是当你尝试时,却发现只有一半行得通:
Rational oneHalf(1, 2), result;
result = oneHalf * 2; //正确
result = 2 * oneHalf; //错误
前次调用实际上等价于:
result = oneHalf.operator*(2);
也就是oneHalf对象调用其成员函数operator*,并且由于int-to-Rational的隐式转换,故调用合法。
而后次调用不成功是因为2并不是对象,并且不存在non-member版本的operator*。
让operator* 成为non-member,就可解决上述问题。
总结:如果你需要为某个函数的所有参数进行类型转换,那么这个函数必须是个non-member
条款25:Consider support for a non-throwing swap
swap函数原本是STL的一部分,但它在异常安全性编程中扮演着重要角色,同时也可用于处理自我赋值。
缺省情况下swap由标准库提供的swap算法完成。其典型实现如下:
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a);
a = b;
b = temp;
}
}
只要类型T支持copying,缺省的swap实现就会帮你完成置换动作。
但是,对于某些类型而言,缺省的swap实现代价太大。其中最主要的就是采用“pimpl”手法的类型。如果以这种手法设计Widget class,看起来会像这样:
class WidgetImpl {
public:
...
private:
int a, b, c;
std::vector<double> v;
..
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl* pImpl;
};
一旦要置换两个Widget对象,其实我们只需置换其pImpl指针。相比std::swap调用多次copying函数,这显然要高效得多。
通常,我们为Widget声明一个名为swap的public成员函数,然后将std::swap特化,让它调用该成员函数:
class Widget {
public:
...
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl);
}
...
};
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b);
}
}
这种做法还保持了与STL容器的一致性,因为所有STL容器也都提供public swap成员函数和std::swap特化版本。
现在假设Widget和WidgetImpl都是class templates而非classes,当我们试着特化std::swap时却出现了错误:
namespace std {
template<typename T>
void swap< Widget<T> >(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}
}
这是因为C++只允许对class templates偏特化,而不允许对function templates偏特化。
如果你需要偏特化一个function template,通常可以改为为它添加一个重载版本,像这样:
namespace std {
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}
}
一般而言,重载function template没有问题。但std是个特殊的命名空间,C++禁止客户向其内添加新内容,尽管你可以全特化std内的templates。所以上述代码并不正确。那该怎么办呢?很简单,我们只需要把该function template移出std空间。假设Widget相关功能均置于命名空间WidgetStuff内,我们可以这样处理swap:
namespace WidgetStuff {
...
template<typename T>
class Widget {...}; //内含swap成员函数
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}
}
这个做法对classes和template classes都行得通,所以似乎我们应该在任何时候都这么做。但是也有例外会使得你应该为classes特化std::swap(稍候会提到),所以如果想让class专属swap实现尽可能多地被使用,你需要同时写一个non-member版本和一个std::swap特化本版。
下面我们来看看swap的调用。假设你写了一个function template,它需要置换两个对象值。那么它该调用哪个swap呢?我们希望的应该是优先调用类型专属swap,并在其不存在的情况下调用std::swap,所以我们的代码应该看起来像这样:
template<typename T>
void func(T& a, T& b) {
using std::swap; //令std::swap在作用域中可见
...
swap(a, b);
...
}
假设你以这种方式调用swap:
std::swap(a, b);
这会迫使编译器使用std内的swap版本。这正是你为你的classes全特化std::swap的原因:它使得类型专属的swap版本也可以被不太适当的代码所用。
值得注意的是,成员版的swap绝不可抛出异常。毕竟,成员swap函数的其中一个应用就是为classes提供异常安全性的保障,如果其本身都不是异常安全的,何来保障呢?
总结:
- 当std::swap效率不高时,提供一个不抛出异常的swap成员函数
- 如果你提供一个member swap,也应该提供一个non-member swap用来调用前者。对于classes,请同时特化std::swap
- 调用swap时应使用using声明使std::swap可见