第四章.
设计规划与声明方式
软件设计方案是用来帮助规划软件功能的,通常,它在一开始是一系列优秀的思想,但最终都会被具体化,直至其成为可用于开发的一系列具体接口。之后,这些接口必须转换为
C++
的声明。本章中,我们将解决“如何设计和声明优秀的
C++
接口”这一问题。我们以下面的建议开始(它可能是设计接口时要注意的最为重要的一点):接口应该易于正确使用,而不宜被误用。这引出了一系列更为具体的建议,其中我们将讨论十分宽泛的议题,包括正确性、效率、封装、可维护性、可扩展性,还有你需要遵循的惯例。
本章并不期望对于优秀接口设计的建议做到包罗万象,取而代之的是精选出一部分最为重要的内容。各种错误和问题对于类
/
函数
/
模板的设计者来说是家常便饭,本章就是用来时刻警示你远离这些常见的错误,并为你提供这些问题的解决方法。
第18条:
要让接口易于正确使用,而不易被误用
C++
中到处充满了接口。函数接口、类接口、模板接口,等等。每个接口都是实现客户端程序员与你的代码相交互的一种手段。假设你的客户通情达理,他们的项目也十分优秀,他们便会十分看重你的接口是否易于正确使用。这是千真万确的,如果他们误用了你的接口中的任一个,那么你也难推其咎。在理想状态下,如果客户端程序员尝试使用一个接口,但是没有达到预期的效果,那么代码则不应通过编译。反之,如果代码通过了编译,那么则必须要符合客户端程序员的需求。
开发中我们应做到让接口更易于正确使用而不易被误用,这需要你考虑到客户端程序员会犯的各种错误。请参见下边的示例,假设你正在编写一个表示日期时间的类的构造函数:
class Date {
public:
Date(int month, int day, int year);
...
};
乍一看,这一接口设计得很合理(至少在美国很合理),但是当客户端程序员面对这样的接口时,很容易犯下两种错误。第一,他们可能会使用错误的传参顺序:
Date d(30, 3, 1995); //
啊哦,应该是“
3, 30
”而不是“
30, 3
”
第二,他们可能会传进一个无效的月份或日期:
Date d(3, 40, 1995); //
啊哦,应该是“
3, 30
”而不是
“3, 40”
(这一示例看上去有些愚蠢,但是不要忘了,在键盘上
4
和
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
条中曾解释过,对于用户自定义类型
operator*
的返回值来说,具备怎样程度的
const
特性就可以防止客户端程序员犯下下面的错误:
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
中,在一个原始
Invesement
对象再有用之后,这样做可以防止你忘记删除这一无用的对象。
事实上,返回
tr1::shared_ptr
让接口设计人员能够防止由资源释放所造成的客户端错误,这是因为
tr1::shared_ptr
允许在智能指针创建时,将资源释放函数(一个“删除器”)绑定在这一智能指针中,而
auto_ptr
没有这一功能。(参见第
14
条)
假设一个客户端程序员从一个
createInvestment
中得到了一个
Investment*
指针,他可能会将这个指针传给一个名为
getRidOfInvestment
的函数,而不是将其删除。这样的接口将会为客户端程序员带来新的错误,客户端程序员将会使用错误的资源析构机制(也就是使用了
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
问题”。这一问题在下面的情况中会发生:一个动态链接库(
DLL
)中使用
new
创建了一个对象,而这个对象在另一个
DLL
中由
delete
语句删除了。在许多平台上,此类跨
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
有两个裸指针那么大,它为记录和删除专用数据使用动态分配内存,当调用函数的删除器时使用虚函数,如果它认为一个应用程序是多线程的,那么当修改该程序的一个引用计数值时,将引入线程同步的开销。(你也可以通过定义一个预处理记号来禁用多线程。)
Boost
中的
shared_ptr
也是有缺点的:它比裸指针更大,更慢,而且使用辅助的动态内存。在许多应用程序中,这些额外的运行时开销并不那么显著,但是它可以降低客户端程序员出错的可能,这一点对每个人来说都是十分显著的。
牢记在心
l
优秀的接口应该易于正确使用,而不易误用。对所有的接口都应该力争做到这一点。
l
保持与内置数据类型有一致的行为,是使接口易于正确使用的一种可行的方法
l
防止错误发生的方法有:创建新的数据类型,严格限定类型的操作,约束对象的值,不要将管理资源的任务留给客户端程序员。
l
tr1::shared_ptr
支持自定义的删除功
能。这可以防止
“
跨
DLL
问题
”
,可以应用在自动解开互斥锁等情况下(参见第
14
条)。