如何写出易于维护的代码,阻止犯错?
类型就是最好的注释!
Type is all you need
结构体传参
1 | void foo(string name, int age, int phone, int address); |
- 痛点:参数多,类型相似,容易顺序写错而自己不察觉
- 天书:阅读代码时看不见参数名,不清楚每个参数分别代表什么
怎么办?
1 | struct FooOptions { |
✔️ 优雅,每个参数负责做什么一目了然
也有某些大厂推崇注释参数名来增强可读性:
1 | foo(/*name=*/"小彭老师", /*age=*/24, /*phone=*/12345, /*address=*/67890); |
但注释可以骗人:
1 | foo(/*name=*/"小彭老师", /*phone=*/12345, /*age=*/24, /*address=*/67890); |
这里 age 和 phone 参数写反了!阅读者如果不看下 foo 的定义,根本发现不了
而代码不会:
1 | // 即使顺序写错,只要名字写对依然可以正常运行 |
总之,好的 API 设计绝不会给人留下犯错的机会!
再来看一个场景,假设foo内部需要把所有参数转发给另一个函数bar:
1 | void bar(int index, string name, int age, int phone, int address); |
- 痛点:你需要不断地复制粘贴所有这些参数,非常容易抄错
- 痛点:一旦参数类型有所修改,或者要加新参数,需要每个地方都改一下
怎么办?
1 | struct FooOptions { |
✔️ 优雅
当老板要求你增加一个参数 sex,加在 age 后面:
1 | -void foo(string name, int age, int phone, int address); |
你手忙脚乱地打开所有调用了 foo 的文件,发现有大量地方需要修改…
而优雅的 API 总设计师小彭老师只需轻轻修改一处:
1 | struct FooOptions { |
所有的老代码依然照常调用新的 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 | auto result = map.insert({"hello", "world"}); |
first?second?这算什么鬼?
更好的做法是返回一个定制的结构体:
1 | struct insert_result_t { |
直接通过名字访问成员,语义清晰明确,我管你是第一个第二个,我只想要表示“是否成功(success)”的那个变量。
1 | auto result = map.insert({"hello", "world"}); |
最好当然是返回和参数类型都是结构体(这在我们的Java中其实体现的非常充分了,返回Result,传入DTO):
1 | struct insert_result_t { |
这里说的都比较激进,你可能暂时不会认同,等你大手大脚犯了几个错以后,你自然会心服口服。 小彭老师以前也和你一样是指针仙人,不喜欢强类型,喜欢 void *
满天飞,然后随便改两行就蹦出个 Segmentation Fault,指针一时爽,调试火葬场,然后才开始反思。
STL 中依然在大量用 pair 是因为 map 容器出现的很早,历史原因。 我们自己项目的 API 就不要设计成这熊样了。
当然,和某些二级指针返回仙人相比
cudaError_t cudaMalloc(void **pret);
,返回 pair 已经算先进的了
例如 C++17 中的 from_chars
函数,他的返回类型就是一个定制的结构体:(嗯,很Java)
1 | struct from_chars_result { |
这说明他们也已经意识到了以前动不动返回 pair 的设计是有问题的,已经在新标准中开始改用更好的设计。
类型即注释
你是一个新来的员工,看到下面这个函数:
1 | void foo(char *x); |
这里的 x 有可能是:
- 0结尾字符串,只读,但是作者忘了加 const
- 指向单个字符,用于返回单个 char(指针返回仙人)
- 指向一个字符数组缓冲区,用于返回字符串,但缓冲区大小的确定方式未知
如果作者没写文档,变量名又非常含糊,根本不知道这个 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 | using ISBN = string; |
这样用户一看就明白,这个函数是接收一个 ISBN 编号(出版刊物都有一个这种编号),返回关于这本书的详细信息。
尽管函数名 foo 让人摸不着头脑,但仅凭直观的类型标识,我们就能函数功能把猜的七七八八。
强类型封装
假设你正在学习这个 Linux 系统 API 函数:
1 | ssize_t read(int fd, char *buf, size_t len); |
但是你没有看他的函数参数类型和名字。你是这样调用的:
1 | int fd = open(...); |
你这里的 32 本意是缓冲区的大小,却不幸地和 fd 参数写错了位置,而编译器毫无报错,你浑然不知。
仅仅只是装模作样的用 typedef 定义个好看的类型别名,并没有任何意义! 他连你的参数名 fd 都能看不见,你觉得他会看到你的参数类型是个别名?
用户一样可以用一个根本不是文件句柄的臭整数来调用你,而得不到任何警告或报错:
1 | typedef int FileHandle; |
如果我们把文件句柄定义为一个结构体:
1 | struct FileHandle { |
就能在用户犯马虎的时候,给他弹出一个编译错误:
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 | FileHandle fd = open(std::filesystem::path("路径"), OpenFlag::Read); |
点名批评的 STL 设计
例如 std::stack 的设计就非常失败:
1 | if (!stack.empty()) { |
我们必须判断 stack 不为空,才能弹出栈顶元素。对着一个空的栈 pop 是未定义行为。 而 pop() 又是一个返回 void 的函数,他只是删除栈顶元素,并不会返回元素。 我们必须先调用 top() 把栈顶取出来,然后才能 pop!
明明是同一个操作,却要拆成三个函数来完成,很烂。如果你不慎把判断条件写反:
1 | if (stack.empty()) { |
就一个 Segmentation Fault 蹦你脸上,你找半天都找不到自己哪错了!
小彭老师重新设计,整合成一个函数:
1 | std::optional<int> pop(); |
语义明确,用起来也方便,用户不容易犯错。
1 | if (auto val = stack.pop()) { |
把多个本就属于同一件事的函数,整合成一个,避免用户中间出纰漏。 从参数和返回值的类型上,限定自由度,减轻用户思考负担。
众所周知,vector 有两个函数用于访问指定位置的元素。
1 | int &operator[](size_t index); |
用户通常会根据自己的需要,如果他们非常自信自己的索引不会越界,可以用高效的 [],不做检测。 如果不确定,可以用更安全的 at(),一旦越界自动抛出异常,方便调试。
我们可以重新设计一个 .get() 函数:
1 | std::optional<int> get(size_t index); |
当检测到数组越界时,返回 nullopt。
1 | *vec.get(3); // 如果用户追求性能,可以把数组越界转化为未定义行为,从而让编译器自动优化掉越界的路径 |
这样就只需要一个函数,不论用户想要的是什么,都只需要这一个统一的 get() 函数。
小彭老师,你这个只能 get,要如何 set 呀?
1 | std::optional<int> get(size_t index); |
- 缺点1:返回 bool 无法运用 optional 的小技巧:通过 value() 转化为异常,且用户容易忘记检查返回值。
- 缺点2:两个参数,一个是 size_t 一个是 int,还是很容易顺序搞混。
1 | std::optional<std::reference_wrapper<int>> get(size_t index); |
点名表扬的 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 | struct MilliSeconds { |
现在,如果用户写出
1 | Sleep(3); |
编译器会报错。 他必须明确写出
1 | Sleep(MilliSeconds(3)); |
才能通过编译。
标准库的 chrono 模块就大量运用了这种强类型封装:
1 | this_thread::sleep_for(chrono::seconds(3)); |
如果你 using namespace std::literials;
还可以这样快捷地创建字面量:
1 | this_thread::sleep_for(3ms); // 3 毫秒 |
且支持运算符重载,不同单位之间还可以互相转换:
1 | this_thread::sleep_for(1s + 200ms); |
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 | time_t t0 = time(NULL); // 时间点 |
- 痛点:如果这里的负号写错,写成
t1 + t0
,编译器不会报错,你可能根本没发现,浪费大量时间调试最后只发现一个低级错误。 - 模糊:时间点(t0、t1)和时间段(dt)都是 time_t,初次阅读代码很容易分不清哪个是时间点,哪个是时间段。
如果不慎把“时间点”的 time_t 传入到本应只支持“时间段”的 sleep 函数,会出现“睡美人”的奇观:
1 | time_t t = time(NULL); // 返回 1715853968 表示当前时间点 |
这个程序会睡 1715853968 秒后才醒,即 54 年后!
1 | chrono::system_clock::time_point last = chrono::system_clock::now(); |
- 一看就知道哪个是时间点,哪个是时间段
- 用错了编译器会报错
- 单位转换不会混淆
- 时间点 + 时间点 = 编译出错!因为时间点之间不允许相加,2024 + 2024,你是想加到 4048 年去吗?
- 时间点 - 时间点 = 时间段
- 时间点 + 时间段 = 时间点
- 时间点 - 时间段 = 时间点
- 时间段 + 时间段 = 时间段
- 时间段 - 时间段 = 时间段
- 时间段 × 常数 = 时间段
- 时间段 / 常数 = 时间段
这就是本期课程的主题,通过强大的类型系统,对可能的用法加以严格的限制,最大限度阻止用户不经意间写出错误的代码。
枚举类型
你的老板要求一个设定客户性别的函数:
1 | void foo(int sex); |
老板口头和员工约定说,0表示女,1表示男,2表示自定义。
这谁记得住?设想你是一个新来的员工,看到下面的代码:
1 | foo(1); |
你能猜到这个 1 是什么意思吗?
解决方法是使用枚举类型,给每个数值一个唯一的名字:
1 | enum Sex { |
再假设你是一个新来的员工,看到:
1 | foo(Male); |
是不是就一目了然啦?
枚举的值也可以不用写,让编译器自动按 0、1、2 的顺序分配值:
1 | enum Sex { |
可以指定从 1 开始计数:
1 | enum Sex { |
但枚举类型还是可以骗人,再假设你是新来的,看到:
1 | foo(Male, 24); |
是不是想当然的感觉这个代码没问题?
但当你看到 foo 准确的函数定义时,傻眼了:
1 | void foo(int age, Sex sex); |
相当于注册了一个 1 岁,性别是 24 的伪人。且程序员很容易看不出问题,编译器也不报错。
为此,C++11 引入了强类型枚举:
1 | enum class Sex { |
现在,如果你再不小心把 sex 传入 age 的话,编译器会报错!因为强类型枚举不允许与 int 隐式转换。
而且强类型枚举会需要显式写出 Sex::
类型前缀,当你有很多枚举类型时不容易混淆:
1 | foo(24, Sex::Male); |
如果你的 Sex 范围很小,只需要 uint8_t 的内存就够,可以用这个语法指定枚举的“后台类型”:
1 | enum class Sex : uint8_t { |
假如你的所有 age 都是 int 类型的,但是现在,老板突然心血来潮:
说为了“优化存储空间”,想要把所有 age 改成 uint8_t 类型的!
为了预防未来可能需要改变类型的需求,也是为了可读性,我们可以使用类型别名:
1 | using Age = int; |
这样当老板需要改变底层类型时,只需要改动一行:
1 | using Age = uint8_t; |
就能自动让所有代码都使用 uint8_t 作为 age 了。
但是类型别名毕竟只是别名,并没有强制保障:
1 | using Age = int; |
因为 Age 和 Phone 只是类型别名,实际上还是同样的 int 类型…所以编译器甚至不会有任何警告。
有一种很极端的做法是把 Age 和 Phone 也做成枚举,但没有定义任何值:
1 | enum class Age : int {}; |
这样用到的时候就只能通过强制转换的语法:
1 | foo(Age(42), Phone(12345)); |
并且如果写错顺序,尝试把 Phone 传入 Age 类型的参数,编译器会立即报错,阻止你埋下 BUG 隐患。
小彭老师,我用了你的方法以后,不能做加法了怎么办?
1 | Age(42) + Age(1) // 编译器错误! |
这是因为 Age 是强类型枚举,不能隐式转换为 int 后做加法。
可以定义一个运算符重载:
1 | enum class Age : int {}; |
或者运用模板元编程,直接让加法运算符对于所有枚举类型都默认生效:
1 | template <class T> requires std::is_enum_v<T> |
有时这反而是个优点,比如你可以只定义加法运算符,就可以让 Age 不支持乘法,需要手动转换后才能乘,避免无意中犯错的可能。
小彭老师,我用了你推荐的强类型枚举,不支持我最爱的或运算 |
了怎么办?
1 | enum class OpenFlag { |