0%

[Effective C++ 笔记]条款 18. 让接口容易被正确使用并且不易被错误使用

简介

  • 在设计接口时应该尽可能使得接口不容易被误用
  • 提高正确使用接口的方法包括:接口一致性,与内置类型行为兼容
  • 防止误用的方法包括:建立新类型,限制类型操作,限制对象可选值,消除使用者的资源管理责任

引子

我们在让使用者使用我们的代码时,需要提供很多接口,包括函数接口,类接口等等。理想情况下使用者都能正确使用提供的接口,但我们必须做好心理准备:他们有可能会误用接口。我们应该尽可能避免这种情况(当接口被误用了但程序能够(不一定正常地)运行)的发生,当一个接口被误用时,理想情况是这段代码不能通过编译;而当代码通过编译时,它应该保证是被正确使用可以达到使用者需求的。本条款主要讨论一下有哪些方法可以降低接口被误用的可能。

导入新的类型

考虑以下例子,该类通过年/月/日来作为一个日期:

1
2
3
4
5
class Date {
public:
Date(int month, int day, int year);
...
};

看似这种构造方式没有问题,但是至少会有两种被误用的情况:

  • 参数传递顺序弄错:例如使用者本想使用 Date(3, 30, 2020),结果错误地使用了 Date(30, 3, 2020),这在接口处是不会导致编译错误的;
  • 无效参数传递,如:Date(2, 30, 2020),这里日期是无效的,同样月份的传递也有可能是无效值

要避免上述两种情况的出现,一个比较简单的方法是引入新的类型,例如导入简单的 wrapper types 来区分天数/月份和年份,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// use explicit to prevent inplicit transform
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;
};

通过这种方式,我们可以限制以下场景对接口的误用:

1
2
3
Date d(30, 3, 1995);                        // 编译错误,不运行隐式转换
Date d(Date(30), Month(3), Year(1995)); // 编译错误,类型错误
Date d(Month(3), Date(30), Year(1995)); // 正确

接下来我们应该限制每个类型可选的值,例如对于月份而言,只有 12 个有效值,我们应该从类型上让其体现出来。其中一个方法是使用 enum,但 enum 有可能被错误地作为 int 使用,因此更好的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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); // 禁止其他方式显式构造 Month
};

// 使用方法
Date d(Month::Mar(), Day(30), Year(1995));

通过这种方法,我们可以显式的要求使用者提供正确的月份。

限制类型内可以进行的操作

除了上述方法之外,防止使用者误用的另一种手段是限制自定类型内的运行的操作,常见的做法有:

  • 添加 const 限制

如条款 3 建议,使用 const 修饰 operator* 的返回类型可以防止下述错误的产生:

1
if (a * b = c) ... // 原意是比较,这里错误地使用了赋值操作

尽量使自定类型和内置类型的行为保持一致

避免上述误用情况的发生是:如非必要,尽可能保持自定类型和内置类型的的行为保持一致。假如这里的 ab 是自定类型 Integer 用来充当整型,我们已经知道对 int 使用上述操作是非法的,因此很自然地就会避免对 Integer 对象进行这样的操作。

这样的做法的理由还包括这样做可以提供给使用者与行为一致的接口。例如 STL 的容器的接口相当一致,使得他们较容易使用,对于每个 STL 容器都有一个名为 size() 的成员函数来返回当前容器包含对象的个数(虽然对于 std::string 还有 length() 等方法,但是同样可以使用 size() 获得相同效果)。

消除使用者的资源管理责任

除了上述操作之外,可以尽量少的让使用者记得对于某个类型必须要进行哪些操作,更多的记忆性操作会更容易导致误用接口。正如在条款 13 中提及的例子,我们需要使用一个工厂函数来构造一个指针指向 Investment 继承体系的一个动态分配对象(此例子的代码在条款 13 中已经有完整实现的例子,这里不再重复):

1
Investment* createInvestment();

通过以上的形式,工厂函数会返回一个裸指针,这要求使用者需要记住正确的清除其内存(包括记得要删除,并且只能删除一次)。除此之外还可以要求使用者将裸指针存储在一个智能指针中以避免自己手动维护资源,但是这同样需要使用者记得这一操作。因此,更好的做法是我们在设计接口就让工厂函数返回一个智能指针,通过这种方法来强行让使用者使用智能指针管理资源,如下所示:

1
std::shared_ptr<Investment> createInvestment();

值得一提的是,std::shared_ptr 支持自定义删除函数,因此如果我们要使用其他删除函数而不是 delete 的话,我们可以在构造时指定,如下所示:

1
2
3
4
std::shared_ptr<Investment> createinvestment() {
// getRidOfInvestement is custom delete function for Investment
return std::shared_ptr<Investment>(new Investment, getRidOfInvestment);
}

结论

  • 在设计接口时应该尽可能使得接口不容易被误用
  • 提高正确使用接口的方法包括:接口一致性,与内置类型行为兼容
  • 防止误用的方法包括:建立新类型,限制类型操作,限制对象可选值,消除使用者的资源管理责任

  • 完整可运行代码地址:Effective-Cpp-Reading-Note

  • Efftive C++ 阅读笔记:Effective C++