C++中到处充满了接口。函数接口、类接口、模板接口,等等。每个接口都是实现客户与你的代码相交互的一种手段。假设你的客户都是完全理性的,他们致力于更优秀的完成当前项目,他们便会十分看重你的接口是否能够正确使用。这样一来,如果你的接口中的任意一个被他们误用了,那么这个接口便成了这一错误的“罪魁祸首”。在理想状态下,如果客户尝试使用一个接口,但是没有达到预期的效果,那么代码则不应通过编译。反之,如果代码通过了编译,则运行结果必须要符合客户的需求。
开发中我们应做到让接口更易于正确使用而不易被误用,这需要你考虑到客户会犯的各种错误。请参见下边的示例,假设你正在设计一个表示日期时间的类的构造函数:
class Date {
public:
Date(int month, int day, int year);
...
};
乍一看,这一接口设计得很合理(至少在美国很合理),但是当客户面对这样的接口时,很容易犯下两种错误。第一,他们可能会使用错误的传参顺序:
Date d(30, 3, 1995); // 啊哦,应该是“3, 30”而不是“30, 3”
第二,他们可能会传进一个无效的月份或日期:
Date d(2, 30, 1995); // 啊哦,应该是“3, 30”而不是“2, 30”
(这一示例看上去有些愚蠢,但是不要忘了,在键盘上2和3是紧挨着的。这种“擦肩而过”的错误在现实中并不少见)
客户犯下的许多错误是可以通过引入新类型来避免的。实际上,对于防止不合要求的代码通过编译,类型系统是你最得力的助手。在上述情况下,我们可以引入几个简单的“包装类型”来区分日期、月份、和年份,然后再在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(30, 3, 1995); // 报错!类型错误
Date d(Day(30), Month(3), Year(1995)); // 报错!类型错误
Date d(Month(3), Day(30), Year(1995)); // OK,类型正确
我们可以改善上边应用结构体的简单思路,让Day、Month、Year变得“羽翼丰满”,从而可以提供完善的数据封装性(参见条目22)。但是即使是结构体也足以说服我们:适时引入新的类型可以十分有效地防止接口误用。
只要你在恰当的地方使用了恰当的类型,你便可以合理地限制这些类型的值。比如说,一年有12个月,所以Month类型应该能够反映出这一点。一个途径是使用枚举类型来表示月份,但是枚举类型并不总能达到我们对于类型安全的需求。比如说,枚举类型可以像int一样使用(参见条目2)。一个更安全的解决方法是:预先定义好所有有效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));
如果上面代码中使用函数来代替具体月份的思路让你感到奇怪,那么可能是由于你已经忘记了声明非局部静态对象可能会带来可靠性问题。条目4可以唤醒你的记忆。
为防止客户犯下类似的错误,我们还可以采用另一个途径,那就是严格限制一个类型可以做的事情。加强限制的一个常用的手段就是添加const属性。比如说,条目3中曾解释过,const是如何通过限定operator*的返回值,从而防止客户对用户定义类型犯下以下的错误的:
if (a * b = c) ... // 啊哦,本来是想进行一次比较!
实际上,这仅仅是针对“让接口易于正确使用,而不易被误用”另一条一般性建议的一个表现形式,这条建议是:除非有更好的理由阻止你这样做,否则你应该保证你所创建类型的行为与内建数据类型保持一致。因为客户已经清楚int的行为,所以只要是合情合理,你就应该力求使你的类拥有与int一致的行为。比如说,如果a和b是int类型,那么为a*b赋值就是不合法的。所以除非你有好的理由拒绝这一规定,否则你自己创建的类型也应该将这一行为界定为不合法。当你举棋不定时,就让你的类型的行为与int保持一致。
设计接口时应避免与内建数据类型之间存在不必要的不兼容问题,这样做的真正目的是保持各类接口行为的一致性。很少有特征能像一致性这样,可以让接口如此易于正确使用;同时,也很少有特征能像不一致性那样,可以让接口变得那般糟糕。STL容器的接口大体上(但并不完美)是一致的,这就使得它们更易于使用。比如说每个STL容器都有一个名为size成员函数,它可以告诉我们当前这一容器中容纳了多少对象。这一点与Java和.NET是不同的,Java中使用length属性来表示数组的长度,length方法来表示字符串的长度,以及size方法来表示List的大小。而.NET中的Array拥有一个叫做Length的属性,而ArrayList中功能相类似的属性则叫做Count。一些开发人员认为,集成开发环境(IDE)使得这类不一致性问题变得不那么重要,但是实际上他们想错了。不一致性问题会给开发人员带来无穷尽的烦恼,没有哪个IDE是能够完美解决这些问题的。
任何接口都需要客户记忆一些易发生错误的内容,这是因为客户可能会把这些东西搞砸。比如说,条目13中曾引入一个工厂函数来返回一个指向Investment层中动态分配对象的指针:
Investment* createInvestment(); // 来自条目13,省略参数表以简化代码
为防止资源泄漏,由createInvestment返回的指针在最后必须被删除,但是这将会给客户留下至少两个犯错误的机会:忘记删除指针、多于一次删除同一指针。
条目13中介绍了客户如何将createInvestment的返回值保存在诸如auto_ptr或tr1::shared_ptr这样的智能指针中,然后让智能指针担负起调用delete的责任。但是如果客户忘记了使用智能指针,这该怎么办呢?通常情况下,更好的接口的设计方案是:让工厂函数返回一个智能指针,在一开始就不给问题任何发生的机会。
std::tr1::shared_ptr<Investment> createInvestment();
这样便可以从根本上强制客户使用tr1::shared_ptr来存储返回值,这一做法基本上可以排除“忘记删除当前不再有用的Investment对象”的可能。
事实上,返回tr1::shared_ptr让接口设计人员能够防止与资源释放相关的客户端错误,这是因为在创建tr1::shared_ptr智能指针时,允许存在一个与当前智能指针相绑定的资源释放函数(即一个“删除器”),而auto_ptr没有这一功能。(参见条目14)
假设客户从createInvestment中得到了一个Investment*指针,在进行删除操作时,我们期望这一客户将这个指针传给一个名为getRidOfInvestment的函数,而不是使用delete。在这里,如果客户会使用错误的资源析构机制(也就是使用delete而不是getRidOfInvestment),那么这样的接口就带来了新的客户端错误。实现createInvestment的程序员可以通过返回一个绑定getRidOfInvestment作为“删除器”的tr1::shared_ptr来预防此类错误。
tr1::shared_ptr提供了一个拥有两个参数的构造函数,这两个参数即:需要管理的指针,以及当引用计数值为零时需要调用的删除器。这使得我们可以创建使用getRidOfInvestment作为“删除器”的空tr1::shared_ptr,请看下面的做法:
std::tr1::shared_ptr<Investment> pInv(0, getRidOfInvestment);
// 尝试创建一个null的shared_ptr,
// 并且让其包含一个自定义的删除器;
// 这样的代码无法通过编译
然而,这并不是合法的C++语法。tr1::shared_ptr的构造函数的第一个参数必须是一个指针,而0则不是,它是一个int值。的确,数字可以当做指针使用,但是这种情况下该做法并不值得推荐,tr1::shared_ptr的第一个参数必须是一个实际的指针。通过一次转型可以解决这一问题:
std::tr1::shared_ptr<Investment>
pInv(static_cast<Investment*>(0), getRidOfInvestment);
// 创建一个null的shared_ptr,
// 并且让其包含一个自定义的删除器;
// static_cast的更多信息参见条目27
上面的代码意味着,在实现createInvestment时,可让其返回一个“绑定了getRidOfInvestment删除器的tr1::shared_ptr”:
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment>
retVal(static_cast<Investment*>(0), getRidOfInvestment);
retVal = ... ; // 让retVal指向恰当的对象
return retVal;
}
当然,如果在创建pInv之前就确定了其所管理的原始指针,那么,比起“将pInv初始化为空值然后对其赋值”而言,“将原始指针传递给pInv的构造函数”的方法更理想些。这是为什么呢?详情请参见条目26。
tr1::shared_ptr可以自动为每个指针预留一个删除器,它们可以排除另一类潜在的客户端错误,即所谓的“跨DLL问题”,这是tr1::shared_ptr的一项尤为显著的优点。如果一个动态链接库(DLL)中使用new创建了一个对象,而在另一个DLL中这个对象被delete语句删除了,那么此时将会引发“跨DLL问题”。在许多平台上,此类跨DLL的“new/delete对”将导致运行时错误。tr1::shared_ptr可以防止此类问题发生,因为如果创建了一个tr1::shared_ptr,它的默认删除器将在同一个DLL中使用delete。举例说,如果Stock继承自Investment,同时createInvestment是这样实现的:
std::tr1::shared_ptr<Investment> createInvestment()
{
return std::tr1::shared_ptr<Investment>(new Stock);
}
那么返回的tr1::shared_ptr能够在各DLL文件中自由穿梭,而不用考虑跨DLL问题。这一指向Stock的tr1::shared_ptr会始终追踪这一事件:当Stock的引用计数值为零时,需要使用哪一个DLL的delete语句来删除它。
本条目讲解的主要内容是如何让接口更加易于正确使用,而不易被误用,而不是tr1::shared_ptr,但是tr1::shared_ptr对于避免此类客户端错误却是一个不可多得的好工具,学会使用它是值得的。tr1::shared_ptr最为通用的实现来自Boost(参见条目55)。Boost中的shared_ptr有两个原始指针那么大,它在存储计数信息和删除器相关的数据时会使用动态分配的内存,在进行删除器调用时会使用虚函数,对于其识别为多线程的应用程序,在修改引用计数时会引入线程同步的开销。(你也可以通过定义一个预处理符号来禁用多线程。)简言之:它比原始指针的体积更大,执行速度更慢,并且使用辅助动态内存。在许多应用中,这些额外的运行时开销是微不足道的,但是它可以显著降低每个客户出错的可能,这一点绝对是振奋人心的。
时刻牢记
l 优秀的接口应该易于正确使用,而不易误用。你应该力争让你所有的接口都具备这一特征。
l 增加易用性的方法包括:让接口保持一致性,让代码与内建数据类型保持行为上的兼容性。
l 防止错误发生的方法包括:创建新的数据类型,严格限定类型的操作,约束对象的值,主动管理资源以消除客户的资源管理职责。
l tr1::shared_ptr支持自定义的删除功能。可以防止“跨DLL问题”,可以用于自动解开互斥锁(参见条目14)。