1.接口设计的重要性
软件设计就是让软件做你想做的事,软件设计一定需要接口(interface)设计,最后用C++实现。今天讨论的可能是其中最重要的一条守则,把你的接口设计得容易用对,不容易用错。
所谓的接口即你提供给用户使用你代码的途径。C++到处都充满了接口的概念,比如函数接口,类接口,模板接口。理想条件下,如果我们用错了接口,编译器会报错,而如果编译器没有报错,那么我们用的接口就是对的。
2.接口设计准则
准则1
要把接口设计得好用对、难用错,就需要预先考虑到用户可能犯的各种错误。假如你正在设计一个表示日期的类:
1class Date{ 2public: 3 Date(int month, int day, int year); // 美式标准表示年、月、日顺序 4};
第一眼看起来是没问题的,但用户可能会出现这样的错误。例如,有个英国人输入了错误的格式;有个美国人打错了字,输入了非法日期。
1Date d(30, 3, 1995); // 规定输入美式标准的月,日,年 2Date d(3, 40, 1995); // 把3打成了4
对于上面出现的问题,我们可以定义新的类型,使用简单的包装类(wrapper class),让编译器对错误类型进行报错:
1// 3个包装类 2struct Day{ 3 explicit Day(int d):val(d){} 4 int val; 5}; 6 7struct Month{ 8 explicit Month(int m): val(m){} 9 int val; 10}; 11 12struct Year{ 13 explicit Year(int y): val(y){} 14 int val; 15}; 16 17// 下面开始使用 18class Date{ 19public: 20 Date(const Month& m, const Day& d, const Year& y); 21 // ... 22}; 23 24Date d(30, 3, 1995); // int类型不正确报错 25Date d(Day(30), Month(3), Year(1995)); // 格式错误报错 26Date d(Month(3), Day(30), Year(1995)); // 正确
上面接口中我们保证了格式的正确,下一步就是要对取值做出规范,例如月份只能有1到12。使用enum可以满足功能上的要求,但enum不是类型安全的(type safe),因为在前面的文章尽量以const、enum、inline替换#define中已经展示过enum可以被用来当作int类型使用。因此,我们需要定义包含所有月份的集合。
1class Month{ 2public: 3 static Month Jan(){ return Month(1);} 4 static Month Feb(){return Month(2);} 5 // ... 6 static Month Dec(){return Month(12);} 7 // ... 8private: 9 explicit Month(int m); // explicit禁止参数隐式转换,private禁止用户生成自定义的月份 10 // ... 11}; 12 13Date d(Month::Feb(), Day(29), Year(2020)); // 正确
上面的这种方法虽然略显繁琐,但保证了数据的正确性,并且在提升网页脚本安全性的实践中,这是一种常用的防止恶意用户注入代码的思路。
准则2
要把接口设计得具有一致性。C++STL容器接口相比其它语言可以说是达到了高度一致性,因此这些接口也相比更加易用,例如每一个STL容器类都有一个成员函数size()来返回容器当前包含的对象数量。
不像Java,对于数组要使用length属性;对于字符串要使用length方法;对于List要使用size方法,总之各种各样的接口。在.NET中,对于数组要使用Length属性,对于ArrayList又要使用Count属性。可能有些开发者会认为,使用了集成开发环境(IDE),这些不一致性就显得不那么重要。但是,不一致的接口仍然会带来心理上的困难感,因为明显你需要记住更多东西,这是IDE不能弥补的。
接口设计具有一致性也有另外一层意思,是指行为上的一致性,就是要把功能做得与原始类型或是其它标准类型的逻辑一致。前面的文章尽可能使用const修饰符,展示了*运算符用const修饰返回值来避免因为打错字所带来的无意义赋值。
1if(a * b = c) // 应该是a * b == c
像上面那样无意义的赋值错误难以察觉,我们当然希望这样的错误在编译时就能被发现。要做到这样的一致性,我们只需要跟着原始类型的逻辑走,例如不允许给右值赋值,来防止可能造成的一系列误用。
准则3
任何要求用户记住东西的接口都更容易造成误用,因为用户也不是电脑,只要是人类就会忘掉东西。例如动态分配了一个资源,要求用户以某种特定的方式释放资源。在前面的文章C++中基于对象来管理资源中,我们引入了一个工厂函数createInvestment()。
1Investment* createInvestment();
为了防止资源泄漏,这个动态分配的资源必须在使用完后删除,但要求用户这样做可能会产生两种情景:
a.用户忘记删除
b.多次删除同一个指针
在前面的文章C++中基于对象来管理资源中解决方法是使用智能指针自动管理资源,但如果用户忘记把这个函数的返回值封装在智能指针内呢?所以,我们最好让这个函数直接返回一个智能指针对象:
1std::shared_ptr<Investment> createInvestment();
事实上,返回一个智能指针还解决了一系列用户端资源泄漏的问题。前面的文章C++当心资源管理类中的拷贝行为中提到,如果默认的删除器(deleter)不好用,我们可以给shared_ptr绑定一个自定义的删除器,从而来自动实现我们想要的析构功能。不仅仅是内存资源,通过绑定删除器,我们还可以管理更多种类的资源。例如,前面的文章C++当心资源管理类中的拷贝行为中提及的Mutex锁。
假设我们规定:如果用户从这个工厂函数得到了一个Investment*对象,在析构时要用另一个getRidOfInvestment()函数来释放资源,而不是单独使用delete。这就可能会导致用户由于忘记而使用了错误的释放机制。要防止这种错误,我们把getRidOfInvestment()绑定到shared_ptr的删除器,这样shared_ptr就会在使用完成后自动帮用户调用释放函数。
绑定删除器另一个好处是避免了DLL交叉问题(cross-DLL problem)。这个问题是发生在当一个对象从一个DLL(动态链接库)中生成,在另一个DLL中释放时,在许多平台上就会导致运行时的问题。这是由于不同DLL的new和delete可能会被链接到不同代码。但是,shared_ptr的删除器则是固定绑定在创建它的DLL中。例如,我们有Stock类继承自Investment:
1std::shared_ptr<Investment> createInvestment(){ 2 return std::shared_ptr<Investment>(new Stock); 3}
上面代码段中,createInvestment()工厂函数返回的Stock类型智能指针就能在各个DLL中传递,智能指针会在构造时就固定好当引用计数为零时调用哪一个DLL的删除器,因此不必担心DLL交叉问题。
3.总结
(1) 好的接口很容易被正确使用,不容易被误用。在所有接口设计中都应该秉行这条准则。
(2) 让接口更容易用对,就要把接口做得一致;易于记忆,逻辑上也要与原始类型和标准类型保持一致。
(3) 预防接口误用的方法:包括定义新的包装类型、限制运算符操作、限制取值范围、不要让用户负责管理资源。
(4) shared_ptr支持自定义的删除器,实现我们想要的析构机制。此外,它还能防止DLL交叉问题,而且也能被用来管理其它资源(例如Mutex锁等)。