C++中的接口设计准则_干货


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锁等)。