1. 什么样的接口才是好的接口
C++中充斥着接口:函数接口,类接口,模板接口。每个接口都是客户同你的代码进行交互的一种方法。假设你正在面对的是一些“讲道理”的人员,这些客户尝试把工作做好,他们希望能够正确使用你的接口。在这种情况下,如果接口被误用,你的接口应该至少负一部分的责任。理想情况下,如果使用一个接口没有做到客户希望做到的,代码应该不能通过编译;如果代码通过了编译,那么它就能做到客户想要的。
2. 编写好的接口的方法列举
2.1 使接口不容易被误用——通过引入新的类型
开发出容易被正确使用不容易被误用的接口需要你考虑客户可能出现的所有类型的错误。举个例子,假设你正在为一个表示日期的类设计一个构造函数:
class Date { public: Date(int month, int day, int year); ... };
乍一看,这个接口可能看上去去合理的,但是客户很容易犯至少两种错误。
第一,他们可能搞错参数的传递顺序:
Date d(, , ); // Oops! Should be “3, 30” , not “30, 3”
第二,他们可能传递一个无效的月份或者天数(day number):
Date d(, , ); // Oops! Should be “3, 30” , not “3, 40”
(最后一个例子看上去很病态,但是不要忘了在键盘上,数字4和3是挨着的,将3错打成4这样的错误不是不常见。)
通过引入新的类型,许多客户错误就能被避免。确实,类型系统(type system)是你阻止不合要求的代码编译通过的主要盟友。在这种情况下,我们可以引入简单的包装类型来区分天,月和年,然后在Date构造函数中使用这些类型:
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(, , ); // error! wrong types
Date d(Day(), Month(), Year()); // error! wrong types
Date d(Month(), Day(), Year()); // okay, types are correct
将Day,Month和Year数据封装在羽翼丰满的类中比上面简单的使用struct要更好(Item 22),但是使用struct就足以证明,明智的引入新类型可以很好的阻止接口被误用的问题。
一旦正确的类型准备好了,就能够合理的约束这些类型的值。举个例子,只有12个月份应该能够通过Month类型反映出来。一种方法是使用一个枚举类型来表示月份,但是枚举不是我们喜欢的类型安全的类型。例如,枚举可以像int一样使用(Item 2)。一个更加安全的解决方案是预先将所有有效的月份都定义出来。
class Month { public: static Month Jan() { return Month(); } // functions returning all valid static Month Feb() { return Month(); } // Month values; see below for ... // why these are functions, not static Month Dec() { return Month(); } // objects ... // other member functions private: explicit Month(int m); // prevent creation of new // Month values ... // month-specific data }; Date d(Month::Mar(), Day(), Year());
如果使用函数代替对象来表示指定月份值会让你觉的奇怪的话,可能是因为你忘记了非本地static对象的初始化是有问题的(见 Item 4)。
2.2 使接口不容易被误用——对类型的操作进行限定
另外一种防止类似错误的方法是对类型能够做什么进行限制。进行限制的一般方法是添加const。举个例子,Item 3解释了对于用户自定义的类型,把operator*的返回类型加上const能够防止下面错误的发生:
if (a * b = c) ... // oops, meant to do a comparison!
2.3 使接口容易被正确使用——提供行为一致的接口
事实上,这只是“使类型容易正确使用不容易被误用”的另外一个指导方针的表现形式:除非有更好的理由,让你的自定义类型同内建类型的行为表现一致。客户已经知道像int一样的内建类型的行为是什么样子的,所以在任何合理的时候你应该努力使你的类型表现与其一致。举个例子,如果a和b是int类型,那么赋值给a*b是不合法的,所以除非有一个好的理由偏离这种行为,你应该使你的类型同样不合法。每当你不确定自定义类型的行为时,按照int来做就可以了。
防止自定义类型同内建类型无端不兼容的真正原因是提供行为一致的接口。没有特征比“一致性”更能使接口容易被使用了,也没有特征比“不一致性”更加导致接口容易被误用了。STL容器的接口大体上(虽然不是完全一致)是一致的,这使得它们使用起来相当容易。举个例子,每个STL容易有一个size成员函数,用来指出容器中的对象数量。与Java相比,arrays使用length属性(property)来表示对象数量,而String使用length方法(method)来表示,List使用size方法来表示;对于.NET来说,Array有一个Length属性,而ArrayList有一个Count属性。一些开发人员认为集成开发环境(IDE)使这种不一致性不再重要,但他们错了。不一致性会将精神摩擦强加到开发人员的工作中,没有任何IDE能够将其擦除。
2.4 使接口不容易被误用——使用shared_ptr消除客户管理资源的责任
2.4.1 让函数返回一个智能指针
一个要让客户记住做某事的接口比较容易被用错,因为客户有可能会忘记做。举个例子,Item 13中引入一个工厂函数,在一个Investment继承体系中返回指向动态分配内存的指针:
Investment* createInvestment(); // from Item 13; parameters omitted // for simplicity
为了防止资源泄漏,createInvesment返回的指针最后必须被delete,但是这为至少两类客户错误的出现创造了机会:delete指针失败,多次delete同一个指针。
Item 13展示了客户如何将createInvestment的返回值存入像auto_ptr或者tr1::shared_ptr一样的智能指针中,这样就将delete的责任交给智能指针。但是如果客户忘记使用智能指针该怎么办?在许多情况下,更好的接口是要先发制人,让函数首先返回一个智能指针:
std::tr1::shared_ptr<Investment> createInvestment();
这就强制客户将返回值保存在tr1::shared_ptr中,从而完全消除了忘记delete不再被使用的底层Investment对象的可能性。
2.4.2 返回绑定删除器的智能指针
事实上,对于一个接口设计者来说,返回tr1::shared_ptr能够避免许多其他的有关资源释放的客户错误,因为Item 14中解释道,在创建智能指针时,tr1::shared_ptr允许将一个资源释放函数——释放器(deleter)——绑定到智能指针上。
假设客户从createInvestment得到一个Investment*指针,我们通过将这个指针传递给一个叫做getRidOfInvestment的函数来释放资源而不是直接使用delete。这样的接口开启了另外一类客户错误的大门:客户可能会使用错误的资源析构机制(用delete而不是用提供的getRidOfInvestment接口)。createInvestment的实现者可以先发制人,返回一个tr1::shared_ptr,并将getRidOfInvestment绑定为删除器。
Tr1::shared_ptr提供了一个有两个参数的构造函数:需要被管理的指针和当引用计数为0时需要被调用的删除器。这就提供了一个创建用getRidOfInvestment作为删除器的空tr1::shared_ptr的方法:
std::tr1::shared_ptr<Investment> // attempt to create a null pInv(, getRidOfInvestment); // shared_ptr with a custom deleter; // this won’t compile
上面不是有效的c++,tr1::shared_ptr构造函数的第一个参数必须为指针,但是0不是指针。虽然它可以转换成指针,但是在这个例子中不够好;tr1::shared_ptr坚持使用真实的指针。一个cast就能解决问题:
std::tr1::shared_ptr<Investment> // create a null shared_ptr with pInv( static_cast<Investment*>(), // getRidOfInvestment as its getRidOfInvestment); // deleter; see Item 27 for info on // static_cast
这意味着实现一个createInvestment的代码如下(返回值为绑定了getRidOfInvestment作为删除器的tr1::shared_ptr):
std::tr1::shared_ptr<Investment> createInvestment() { std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(), getRidOfInvestment); ... // make retVal point to the // correct object return retVal; }
当然,如果在创建一个retVal之前就能够决定一个原生指针是不是由reVal来管理,将原生指针直接传递给retVal的构造函数比先将retVal初始化为null然后做一个赋值操作要好。为什么请看 Item 26。
2.5 使用智能指针消除交叉-DLL错误
Tr1::shared_ptr的一个特别好的性质是它可以用它的删除器来消除另外一个客户错误——交叉(cross)-DLL错误。当一个对象在一个DLL中使用new被创建,但是在另外一个DLL中被delete时这个问题就会出现。在许多平台中,这样的交叉-DLL new/delete对会导致运行时错误。使用tr1::shared_ptr可以避免这种错误,因为它使用的默认的删除器来自创建tr1::shared_ptr的DLL。这就意味着,例如,如果Stock是一个继承自Investment的类,createInvestment实现如下:
std::tr1::shared_ptr<Investment> createInvestment() { return std::tr1::shared_ptr<Investment>(new Stock); }
返回的tr1::shared_ptr可以在DLL之间被传递而不用考虑cross-DLL问题。在Stock的引用计数为0的时候,指向Stock的tr1::shared_ptr指针会追踪哪个DLL的删除器被用来释放资源。
3.使用智能指针的代价
这个Item不是关于tr1::shared_ptr的——它是关于“使接口容易被正确使用不容易被误用”这个议题的——但是使用tr1::shared_ptr是一个如此容易的消除客户错误的方法,所以值得将使用它的代价做一个概述。Tr1::shared_ptr的最一般的实现来自Boost(Item 55)。Boost中的shared_ptr占用内存是原生指针的两倍,为bookkeeping(引用计数)和deleter-specific(专属删除器) 数据分配动态内存,调用删除器的时候使用虚函数,当在一个应用中修改引用计数时,如果它认为自己是多线程的,会引发线程同步开销。(你可以通过定义一个预处理符号来disable多线程支持)一句话,它比原生指针占用内存多,比原生指针慢,并且使用了辅助的动态内存。但是在许多应用中,这些额外的运行时开销是不明显的,但是客户错误的消除对每个人来说都是显而易见的。
4.总结
- 好的接口容易被正确使用不容易被误用,你应该使所有的接口满足这两个特征。
- 接口被正确使用的方法包括接口的一致性和同内建类型的行为兼容。
- 接口不容易被误用的方法包括,创建新的类型,对类型上的操作进行限制,约束对象值,去除客户管理资源的责任。
- Tr1::shared_ptr支持个性化删除器。这避免了交叉-DLL问题,可以被用来自动unlock互斥器(Item 14)等等。