现代化的 API 设计指南


如何写出易于维护的代码,阻止犯错?

类型就是最好的注释!

Type is all you need

结构体传参

1
2
3
void foo(string name, int age, int phone, int address);

foo("小彭老师", 24, 12345, 67890);
  • 痛点:参数多,类型相似,容易顺序写错而自己不察觉
  • 天书:阅读代码时看不见参数名,不清楚每个参数分别代表什么

怎么办?

1
2
3
4
5
6
7
8
9
10
struct FooOptions {
string name;
int age;
int phone;
int address;
};

void foo(FooOptions opts);

foo({.name = "小彭老师", .age = 24, .phone = 12345, .address = 67890});

✔️ 优雅,每个参数负责做什么一目了然

也有某些大厂推崇注释参数名来增强可读性:

1
foo(/*name=*/"小彭老师", /*age=*/24, /*phone=*/12345, /*address=*/67890);

但注释可以骗人:

1
foo(/*name=*/"小彭老师", /*phone=*/12345, /*age=*/24, /*address=*/67890);

这里 age 和 phone 参数写反了!阅读者如果不看下 foo 的定义,根本发现不了

而代码不会:

1
2
// 即使顺序写错,只要名字写对依然可以正常运行
foo({.name = "小彭老师", .phone = 12345, .age = 24, .address = 67890});

总之,好的 API 设计绝不会给人留下犯错的机会!

再来看一个场景,假设foo内部需要把所有参数转发给另一个函数bar

1
2
3
4
5
void bar(int index, string name, int age, int phone, int address);

void foo(string name, int age, int phone, int address) {
bar(get_hash_index(name), name, age, phone, address);
}
  • 痛点:你需要不断地复制粘贴所有这些参数,非常容易抄错
  • 痛点:一旦参数类型有所修改,或者要加新参数,需要每个地方都改一下

怎么办?

1
2
3
4
5
6
7
8
9
10
11
12
13
struct FooOptions {
string name;
int age;
int phone;
int address;
};

void bar(int index, FooOptions opts);

void foo(FooOptions opts) {
// 所有逻辑上相关的参数全合并成一个结构体,方便使用更方便阅读
bar(get_hash_index(opts.name), opts);
}

✔️ 优雅

当老板要求你增加一个参数 sex,加在 age 后面:

1
2
-void foo(string name, int age, int phone, int address);
+void foo(string name, int age, int sex, int phone, int address);

你手忙脚乱地打开所有调用了 foo 的文件,发现有大量地方需要修改…

而优雅的 API 总设计师小彭老师只需轻轻修改一处:

1
2
3
4
5
6
7
struct FooOptions {
string name;
int age;
int sex = 0; // 令 sex 默认为 0
int phone;
int address;
};

所有的老代码依然照常调用新的 foo 函数,未指定的 sex 会具有结构体里定义的默认值 0:

1
foo({.name = "小彭老师", .phone = 12345, .age = 24, .address = 67890});

返回一个结构体

当你需要多个返回值时:不要返回 pair 或 tuple

一些 STL 容器的 API 设计是反面典型,例如:

1
std::pair<bool, iterator> insert(std::pair<K, V> entry);

用的时候每次都要想一下,到底第一个是 bool 还是第二个是 bool 来着?然后看一眼 IDE 提示,才反应过来

1
2
3
4
auto result = map.insert({"hello", "world"});

cout << "是否成功: " << result.first << '\n';
cout << "插入到位置: " << result.second << '\n';

first?second?这算什么鬼?

更好的做法是返回一个定制的结构体

1
2
3
4
5
6
struct insert_result_t {
bool success;
iterator position;
};

insert_result_t insert(std::pair<K, V> entry);

直接通过名字访问成员,语义清晰明确,我管你是第一个第二个,我只想要表示“是否成功(success)”的那个变量。

1
2
3
4
auto result = map.insert({"hello", "world"});

cout << "是否成功: " << result.success << '\n';
cout << "插入到位置: " << result.position << '\n';

最好当然是返回和参数类型都是结构体(这在我们的Java中其实体现的非常充分了,返回Result,传入DTO)

1
2
3
4
5
6
7
8
9
10
11
struct insert_result_t {
bool success;
iterator position;
};

struct map_entry_t {
K key;
V value;
};

insert_result_t insert(map_entry_t entry);

这里说的都比较激进,你可能暂时不会认同,等你大手大脚犯了几个错以后,你自然会心服口服。 小彭老师以前也和你一样是指针仙人,不喜欢强类型,喜欢 void * 满天飞,然后随便改两行就蹦出个 Segmentation Fault,指针一时爽,调试火葬场,然后才开始反思。

STL 中依然在大量用 pair 是因为 map 容器出现的很早,历史原因。 我们自己项目的 API 就不要设计成这熊样了。

当然,和某些二级指针返回仙人相比 cudaError_t cudaMalloc(void **pret);,返回 pair 已经算先进的了

例如 C++17 中的 from_chars 函数,他的返回类型就是一个定制的结构体:(嗯,很Java)

1
2
3
4
5
6
struct from_chars_result {
const char *ptr;
errc ec;
};

from_chars_result from_chars(const char *first, const char *last, int &value);

这说明他们也已经意识到了以前动不动返回 pair 的设计是有问题的,已经在新标准中开始改用更好的设计。

类型即注释

你是一个新来的员工,看到下面这个函数:

1
void foo(char *x);

这里的 x 有可能是:

  1. 0结尾字符串,只读,但是作者忘了加 const
  2. 指向单个字符,用于返回单个 char(指针返回仙人)
  3. 指向一个字符数组缓冲区,用于返回字符串,但缓冲区大小的确定方式未知

如果作者没写文档,变量名又非常含糊,根本不知道这个 x 参数要怎么用。

类型写的好,能起到注释的作用!

1
void foo(string x);

这样就一目了然了,很明显,是字符串类型的参数。

1
void foo(string &x);

看起来是返回一个字符串,但是通过引用传参的方式来返回的

1
string foo();

通过常规方式直接返回一个字符串。

1
void foo(vector<uint8_t> x);

是一个 8 位无符号整数组成的数组!

1
void foo(span<uint8_t> x);

是一个 8 位无符号整数的数组切片。

1
void foo(string_view x);

是一个字符串的切片,可能是作者想要避免拷贝开销。

还可以使用类型别名:

1
2
3
using ISBN = string;

BookInfo foo(ISBN isbn);

这样用户一看就明白,这个函数是接收一个 ISBN 编号(出版刊物都有一个这种编号),返回关于这本书的详细信息。

尽管函数名 foo 让人摸不着头脑,但仅凭直观的类型标识,我们就能函数功能把猜的七七八八。

强类型封装

假设你正在学习这个 Linux 系统 API 函数:

1
2
ssize_t read(int fd, char *buf, size_t len);
// fd - 文件句柄,int 类型

但是你没有看他的函数参数类型和名字。你是这样调用的:

1
2
3
4
5
int fd = open(...);
char buf[32];
read(32, buf, fd);
char buf[32];
read(32, buf, fd);

你这里的 32 本意是缓冲区的大小,却不幸地和 fd 参数写错了位置,而编译器毫无报错,你浑然不知。

仅仅只是装模作样的用 typedef 定义个好看的类型别名,并没有任何意义! 他连你的参数名 fd 都能看不见,你觉得他会看到你的参数类型是个别名?

用户一样可以用一个根本不是文件句柄的臭整数来调用你,而得不到任何警告或报错:

1
2
3
4
typedef int FileHandle;
ssize_t read(FileHandle fd, char *buf, size_t len);

read(32, buf, fd); // 照样编译通过!

如果我们把文件句柄定义为一个结构体:

1
2
3
4
5
6
7
struct FileHandle {
int handle;

explicit FileHandle(int handle) : handle(handle) {}
};

ssize_t read(FileHandle handle, char *buf, size_t len);

就能在用户犯马虎的时候,给他弹出一个编译错误:

1
read(32, buf, fd);  // 编译报错:无法将 int 类型的 32 隐式转换为 FileHandle!

对于整数类型,也有的人喜欢用 C++11 的强类型枚举:

1
enum class FileHandle : int {};

这样一来,如果用户真的是想要读取“32号句柄”的文件,他就必须显式地写出完整类型才能编译通过:

1
read(FileHandle(32), buf, fd);  // 编译通过了

强迫你写上类型名,就给了你一次再思考的机会,让你突然惊醒: 哦天哪,我怎么把缓冲区大小当成句柄来传递了! 从而减少睁着眼睛还犯错的可能。

然后,你的 open 函数也返回 FileHandle,整个代码中就不用强制类型转换了。

1
2
3
FileHandle fd = open(std::filesystem::path("路径"), OpenFlag::Read);
char buf[32];
read(fd, buf, 32);

点名批评的 STL 设计

例如 std::stack 的设计就非常失败:

1
2
3
4
if (!stack.empty()) {
auto val = std::move(stack.top());
stack.pop();
}

我们必须判断 stack 不为空,才能弹出栈顶元素。对着一个空的栈 pop 是未定义行为。 而 pop() 又是一个返回 void 的函数,他只是删除栈顶元素,并不会返回元素。 我们必须先调用 top() 把栈顶取出来,然后才能 pop!

明明是同一个操作,却要拆成三个函数来完成,很烂。如果你不慎把判断条件写反:

1
2
3
4
if (stack.empty()) {
auto val = std::move(stack.top());
stack.pop();
}

就一个 Segmentation Fault 蹦你脸上,你找半天都找不到自己哪错了!

小彭老师重新设计,整合成一个函数:

1
std::optional<int> pop();

语义明确,用起来也方便,用户不容易犯错。

1
2
3
if (auto val = stack.pop()) {
...
}

把多个本就属于同一件事的函数,整合成一个,避免用户中间出纰漏。 从参数和返回值的类型上,限定自由度,减轻用户思考负担。

众所周知,vector 有两个函数用于访问指定位置的元素。

1
2
3
4
5
int &operator[](size_t index);
int &at(size_t index);

vec[3]; // 如果 vec 的大小不足 3,会发生数组越界!这是未定义行为
vec.at(3); // 如果 vec 的大小不足 3,会抛出 out_of_range 异常

用户通常会根据自己的需要,如果他们非常自信自己的索引不会越界,可以用高效的 [],不做检测。 如果不确定,可以用更安全的 at(),一旦越界自动抛出异常,方便调试。

我们可以重新设计一个 .get() 函数:

1
std::optional<int> get(size_t index);

当检测到数组越界时,返回 nullopt。

1
2
3
*vec.get(3);             // 如果用户追求性能,可以把数组越界转化为未定义行为,从而让编译器自动优化掉越界的路径
vec.get(3).value(); // 如果用户追求安全,可以把数组越界转化为一个异常
vec.get(3).value_or(0); // 如果用户想要在越界时获得默认值 0

这样就只需要一个函数,不论用户想要的是什么,都只需要这一个统一的 get() 函数。

小彭老师,你这个只能 get,要如何 set 呀?

1
2
std::optional<int> get(size_t index);
bool set(size_t index, int value); // 如果越界,返回 false
  • 缺点1:返回 bool 无法运用 optional 的小技巧:通过 value() 转化为异常,且用户容易忘记检查返回值。
  • 缺点2:两个参数,一个是 size_t 一个是 int,还是很容易顺序搞混。
1
2
3
4
5
6
std::optional<std::reference_wrapper<int>> get(size_t index);

auto x = **vec.get(3); // 性能读
auto x = *vec.get(3).value(); // 安全读
*vec.get(3) = 42; // 性能写
vec.get(3).value() = 42; // 安全写

点名表扬的 STL 部分

1
void Sleep(int delay);

谁知道这个 delay 的单位是什么?秒?毫秒?

1
void Sleep(int ms);

好吧,是毫秒。可是除非看一眼函数定义或文档,谁想得到这是个毫秒?

一个用户想要睡 3 秒,他写道:

1
Sleep(3);

编译器没有任何报错,一运行只睡了 3 毫秒。 用户大发雷霆以为你的 Sleep 函数有 BUG,我让他睡 3 秒怎么好像根本没睡啊。

1
void SleepMilliSeconds(int ms);

改个函数名可以解决一部分问题,当用户调用时,他需要手动打出 MilliSeconds,从而强迫他清醒一下,自己给的 3 到底是不是自己想要的。

1
2
3
4
5
6
7
struct MilliSeconds {
int count;

explicit MilliSeconds(int count) : count(count) {}
};

void Sleep(MilliSeconds delay);

现在,如果用户写出

1
Sleep(3);

编译器会报错。 他必须明确写出

1
Sleep(MilliSeconds(3));

才能通过编译。

标准库的 chrono 模块就大量运用了这种强类型封装

1
this_thread::sleep_for(chrono::seconds(3));

如果你 using namespace std::literials; 还可以这样快捷地创建字面量:

1
2
3
4
this_thread::sleep_for(3ms);  // 3 毫秒
this_thread::sleep_for(3s); // 3 秒
this_thread::sleep_for(3m); // 3 分钟
this_thread::sleep_for(3h); // 3 小时

且支持运算符重载,不同单位之间还可以互相转换:

1
2
this_thread::sleep_for(1s + 200ms);
chrono::minutes three_minutes = 180s;

chrono 是一个优秀的类型封装案例,把 time_t 类型封装成了强类型的 duration 和 time_point。

时间点(time_point)表示某个具体的时间,例如 2024 年 5 月 16 日 18:06:28。 时间段(duration)表示一段时间的长度,例如 1 天,2 小时,3 分钟,4 秒。

时间段很容易表示,只需要指定一个单位,比如秒,然后用一个数字就可以表示多少秒的时间段。

Unix 时间戳用一个数字来表示时间点,数字的含义是从当前时间到 1970 年 1 月 1 日 00:00:00 的秒数。 例如写作这篇文章的时间戳是 1715853968 (2024/5/16 18:06)。 C 语言用一个 time_t,实际上是 long 的类型别名来表示时间戳,但它有一个严重的问题: 它可以被当成时间点,也可以被当成时间段,这就造成了巨大的混乱。

1
2
3
4
time_t t0 = time(NULL);  // 时间点
...
time_t t1 = time(NULL); // 时间点
time_t dt = t1 - t0; // 时间段
  • 痛点:如果这里的负号写错,写成 t1 + t0,编译器不会报错,你可能根本没发现,浪费大量时间调试最后只发现一个低级错误。
  • 模糊:时间点(t0、t1)和时间段(dt)都是 time_t,初次阅读代码很容易分不清哪个是时间点,哪个是时间段。

如果不慎把“时间点”的 time_t 传入到本应只支持“时间段”的 sleep 函数,会出现“睡美人”的奇观:

1
2
time_t t = time(NULL);  // 返回 1715853968 表示当前时间点
sleep(t); // 不小心把时间点当成时间段来用了!

这个程序会睡 1715853968 秒后才醒,即 54 年后!

1
2
3
4
5
chrono::system_clock::time_point last = chrono::system_clock::now();
...
chrono::system_clock::time_point now = chrono::system_clock::now();
chrono::system_clock::duration dt = now - last;
cout << "用了 " << duration_cast<chrono::seconds>(dt).count() << " 秒\n";
  • 一看就知道哪个是时间点,哪个是时间段
  • 用错了编译器会报错
  • 单位转换不会混淆
  • 时间点 + 时间点 = 编译出错!因为时间点之间不允许相加,2024 + 2024,你是想加到 4048 年去吗?
  • 时间点 - 时间点 = 时间段
  • 时间点 + 时间段 = 时间点
  • 时间点 - 时间段 = 时间点
  • 时间段 + 时间段 = 时间段
  • 时间段 - 时间段 = 时间段
  • 时间段 × 常数 = 时间段
  • 时间段 / 常数 = 时间段

这就是本期课程的主题,通过强大的类型系统,对可能的用法加以严格的限制,最大限度阻止用户不经意间写出错误的代码

枚举类型

你的老板要求一个设定客户性别的函数:

1
void foo(int sex);

老板口头和员工约定说,0表示女,1表示男,2表示自定义。

这谁记得住?设想你是一个新来的员工,看到下面的代码:

1
foo(1);

你能猜到这个 1 是什么意思吗?

解决方法是使用枚举类型,给每个数值一个唯一的名字

1
2
3
4
5
6
7
enum Sex {
Female = 0,
Male = 1,
Custom = 2,
};

void foo(Sex sex);

再假设你是一个新来的员工,看到:

1
foo(Male);

是不是就一目了然啦?

枚举的值也可以不用写,让编译器自动按 0、1、2 的顺序分配值:

1
2
3
4
5
enum Sex {
Female, // 0
Male, // 1
Custom, // 2
};

可以指定从 1 开始计数:

1
2
3
4
5
enum Sex {
Female = 1,
Male, // 2
Custom, // 3
};

但枚举类型还是可以骗人,再假设你是新来的,看到:

1
foo(Male, 24);

是不是想当然的感觉这个代码没问题?

但当你看到 foo 准确的函数定义时,傻眼了:

1
void foo(int age, Sex sex);

相当于注册了一个 1 岁,性别是 24 的伪人。且程序员很容易看不出问题,编译器也不报错。

为此,C++11 引入了强类型枚举

1
2
3
4
5
enum class Sex {
Female = 0,
Male = 1,
Custom = 2,
};

现在,如果你再不小心把 sex 传入 age 的话,编译器会报错!因为强类型枚举不允许与 int 隐式转换。

而且强类型枚举会需要显式写出 Sex:: 类型前缀,当你有很多枚举类型时不容易混淆:

1
foo(24, Sex::Male);

如果你的 Sex 范围很小,只需要 uint8_t 的内存就够,可以用这个语法指定枚举的“后台类型”:

1
2
3
4
5
6
7
enum class Sex : uint8_t {
Female = 0,
Male = 1,
Custom = 2,
};

static_assert(sizeof(Sex) == 1);

假如你的所有 age 都是 int 类型的,但是现在,老板突然心血来潮:

说为了“优化存储空间”,想要把所有 age 改成 uint8_t 类型的!

为了预防未来可能需要改变类型的需求,也是为了可读性,我们可以使用类型别名:

1
2
3
using Age = int;

void foo(Age age, Sex sex);

这样当老板需要改变底层类型时,只需要改动一行:

1
using Age = uint8_t;

就能自动让所有代码都使用 uint8_t 作为 age 了。

但是类型别名毕竟只是别名,并没有强制保障:

1
2
3
4
5
6
7
8
9
10
11
using Age = int;
using Phone = int;

foo(Age age, Phone phone);

void bar() {
Age age = 42;
Phone phone = 12345;

foo(phone, age); // 不小心写反了!而编译器不会提醒你!
}

因为 Age 和 Phone 只是类型别名,实际上还是同样的 int 类型…所以编译器甚至不会有任何警告。

有一种很极端的做法是把 Age 和 Phone 也做成枚举,但没有定义任何值:

1
2
enum class Age : int {};
enum class Phone : int {};

这样用到的时候就只能通过强制转换的语法

1
foo(Age(42), Phone(12345));

并且如果写错顺序,尝试把 Phone 传入 Age 类型的参数,编译器会立即报错,阻止你埋下 BUG 隐患。

小彭老师,我用了你的方法以后,不能做加法了怎么办?

1
Age(42) + Age(1) // 编译器错误!

这是因为 Age 是强类型枚举,不能隐式转换为 int 后做加法。

可以定义一个运算符重载:

1
2
3
4
5
enum class Age : int {};

inline Age operator+(Age a, Age b) {
return Age((int)a + (int)b);
}

或者运用模板元编程,直接让加法运算符对于所有枚举类型都默认生效:

1
2
3
4
5
template <class T> requires std::is_enum_v<T>
T operator+(T a, T b) {
using U = std::underlying_type_t<T>;
return T((U)a + (U)b);
}

有时这反而是个优点,比如你可以只定义加法运算符,就可以让 Age 不支持乘法,需要手动转换后才能乘,避免无意中犯错的可能。

小彭老师,我用了你推荐的强类型枚举,不支持我最爱的或运算 | 了怎么办?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum class OpenFlag {
Create = 1,
Read = 2,
Write = 4,
Truncate = 8,
Append = 16,
Binary = 32,
};

inline OpenFlag operator|(OpenFlag a, OpenFlag b) {
return OpenFlag((int)a | (int)b);
}

inline OpenFlag operator&(OpenFlag a, OpenFlag b) {
return OpenFlag((int)a & (int)b);
}

inline OpenFlag operator~(OpenFlag a) {
return OpenFlag(~(int)a);
}

节选自:现代化的 API 设计指南 - ✝️小彭大典✝️ (parallel101.github.io)


文章作者: zoloy
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 zoloy !
评论
  目录