从本条款开始,就进入到全书的第四部分:设计与声明。
程序员设计接口时应本的对用户负责的态度,“如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不应该通过编译;如果代码通过了编译,那它的作为就该是客户所想要的”。
举一个书上的例子:
1 class Date
2 {
3 public:
4 Date(int month, int day, int year);
5 …
6 };
这个类看上去好像没有问题,提供的构造函数接口简单直观,然而它却做了一个假设——用户都能够按月、日、年的顺序来传参。
但事实上,一定会有不少用户记错这个顺序,比如我们常用的顺序是年、月、日,所以有同学会下意识地这样调用:
Date d(2013, 5, 28);
也许还有同学随便输入,导致传参不符合实际,比如:
Date d(5, 32, 2013);
其实当你设计的程序需要假定用户都能按你想像来进行操作的话,这个程序就存在隐患。
一种好的解决方法,是假定用户输入的数据都是不可靠的,需要对输入进行严格的检测,这是防御式编程的思想,这对那个心怀不轨的用户来说是很好的处理方法。但对于一般的用户,不合法的输入只是缺乏正确的引导。
如何去引导用户,如何让用户动最少的脑筋却能最佳地使用接口,是我们接下来要讨论的。
一种好的引导方式,是让用户在传参的时候,知道自己传的是什么,之前用是一个int,这显然不能提醒用户。那就创建相应的类,像这样:
1 class Month
2 {
3 private:
4 int m_month;
5 public:
6 explicit Month(int month): m_month(month){}
7 };
8
9 class Day
10 {
11 private:
12 int m_day;
13 public:
14 explicit Day(int day): m_day(day){}
15 };
16
17 class Year
18 {
19 private:
20 int m_year;
21 public:
22 explicit Year(int year): m_year(year){}
23 };
24
25 class Date
26 {
27 private:
28 Year m_year;
29 Month m_month;
30 Day m_day;
31
32 public:
33 Date(Year year, Month month, Day day): m_year(year), m_month(month), m_day(day){}
34
35 };
36
37 int main()
38 {
39 Date date(Year(2013), Month(5), Day(28));
40 }
注意Year、Month和Day类中构造函数前有explicit关键字,也就是不允许隐式构造,诸如Date date(2013, 5, 28)等会报错;
允许的写法就是像main()函数中所示,为年、月、日提供了类封装的好处还不仅仅有这些,当用户输入一个非法数值后,可以在类中进行判断。
比如我们可以进一步对Month类的构造函数进行扩充,像这样:
explicit Month(int month): m_month(month){assert(m_month >= 1 && m_month <= 12);}
这样就限制了输入Month数据的合法性。
当用户试图传入一个Month(15)的时候,断言失败就会报下面的错:
同时控制台会打印失败的代码至控制台:
还有一种好的设计,就是在Month类中给出月份的枚举类型,这样用户可以更直观地使用:
1 class Month
2 {
3 private:
4 int m_month;
5 public:
6 explicit Month(int month): m_month(month){assert(m_month >= 1 && m_month <= 12);}
7 enum
8 {
9 Jan = 1,
10 Feb,
11 Mar,
12 Apr,
13 May,
14 Jun,
15 July,
16 Aus,
17 Sep,
18 Oct,
19 Nov,
20 Dec
21 };
22 int GetMonth() const
23 {
24 return m_month;
25 };
26 };
然后在main函数中可以这样写:
1 int main()
2 {
3 Date date(Year(2013), Month(Month::May), Day(28));
4 }
总之就是时刻提醒用户知道自己传的参数是什么。
预防用户不正确使用的另一个办法是,让编译器对不正确的行为予以阻止,常见的方法是加上const,比如初学者常常这样写:
if(a = b * c){…}
很明显,初学者想表达的意思是比较两者是否相等,但代码却变成了赋值。这时如果在运算符重载时用const作为返回值,像这样:
const Object operator* (const Object& a, const Object& b);
注意这里的返回值是const,这时编译器就会识别出赋值运算符的不恰当了。
书上还提到很重要的一点,就是“尽量令你的自定义类型的行为与内置类型行为一致”,举个夸张的例子就是,不要重载乘号运算符,但里面做的却是加法。自定义类型同时也要形成统一的风格,比如长度,不要有的类型用size表示,有的类型用length表示,这会使得哪怕是一个程序老手也会犯糊涂。虽然有些IDE插件能够自动去寻找相应的方法名,但“不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除”。
最后再谈谈前几节说到的智能指针,“任何接口如果要求客户必须记得做某些事情,就有着不正使使用的倾向,因为客户可能会忘记做那件事”。所以像:
Investment* createInvestment();
就是不好的接口,容易造成资源泄露(对于像服务器一样长时运作的机器而言,资源泄露无疑是致命的)。
学习了智能指针之后,就可以这样做接口,让专门的资源管理类来掌握资源的回收:
auto_ptr<Investment> createInvestment();
或
shared_ptr<Investment> createInvestment();
使用shared_ptr会更好一些,因为它允许存在多个副本,不会在传递过程中改变原有资源管理者所持的资源,且支持自定义的删除器。书上说,自定义删除器可以有效解决”cross-DLL problem”,这个问题发生于在不同的DLL中出生(new)和删除(delete)的情况(对象生命周期横跨两个DLL,但在第二个DLL中结束生命的时候却希望调用的是第一个DLL的析构函数),自定义删除器则会在删除时仍然调用诞生时所在的那个DLL的析构函数。
当然,使用智能指针也是需要代价的,它比原始指针大(Boost库中实现的shared_ptr体积是原始指针的2倍)且慢,而且使用辅助动态内存。任何事物都具有两面性,权衡一下就会发现,智能指针能避免的资源泄露问题(好的接口),相较于它的空间和时间代价而言,都是值得的。
最后总结一下:
- 好的接口容易被正确使用,不容易被误用;
- 促进正确使用的办法包括接口的一致性,以及与内置类型的行为兼容;
- 阻止误用的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任;
- 多多使用shared_ptr来代替原始指针