现代化的 API 设计指南
如何写出易于维护的代码,阻止犯错?
类型就是最好的注释!
Type is all you need
—
结构体传参
—
void foo(string name, int age, int phone, int address);
foo("小彭老师", 24, 12345, 67890);
- 痛点:参数多,类型相似,容易顺序写错而自己不察觉
- 天书:阅读代码时看不见参数名,不清楚每个参数分别代表什么
怎么办?
struct FooOptions {
string name;
int age;
int phone;
int address;
};
void foo(FooOptions opts);
foo({.name = "小彭老师", .age = 24, .phone = 12345, .address = 67890});
✔️ 优雅,每个参数负责做什么一目了然
—
也有某些大厂推崇注释参数名来增强可读性:
foo(/*name=*/"小彭老师", /*age=*/24, /*phone=*/12345, /*address=*/67890);
但注释可以骗人:
foo(/*name=*/"小彭老师", /*phone=*/12345, /*age=*/24, /*address=*/67890);
这里 age 和 phone 参数写反了!阅读者如果不看下 foo 的定义,根本发现不了
而代码不会:
// 即使顺序写错,只要名字写对依然可以正常运行
foo({.name = "小彭老师", .phone = 12345, .age = 24, .address = 67890});
总之,好的 API 设计绝不会给人留下犯错的机会!
—
再来看一个场景,假设foo内部需要把所有参数转发给另一个函数bar:
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);
}
- 痛点:你需要不断地复制粘贴所有这些参数,非常容易抄错
- 痛点:一旦参数类型有所修改,或者要加新参数,需要每个地方都改一下
怎么办?
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 后面:
-void foo(string name, int age, int phone, int address);
+void foo(string name, int age, int sex, int phone, int address);
你手忙脚乱地打开所有调用了 foo 的文件,发现有大量地方需要修改…
而优雅的 API 总设计师小彭老师只需轻轻修改一处:
struct FooOptions {
string name;
int age;
int sex = 0; // 令 sex 默认为 0
int phone;
int address;
};
所有的老代码依然照常调用新的 foo 函数,未指定的 sex 会具有结构体里定义的默认值 0:
foo({.name = "小彭老师", .phone = 12345, .age = 24, .address = 67890});
—
返回一个结构体
当你需要多个返回值时:不要返回 pair 或 tuple!
一些 STL 容器的 API 设计是反面典型,例如:
std::pair<bool, iterator> insert(std::pair<K, V> entry);
用的时候每次都要想一下,到底第一个是 bool 还是第二个是 bool 来着?然后看一眼 IDE 提示,才反应过来。
auto result = map.insert({"hello", "world"});
cout << "是否成功: " << result.first << '\n';
cout << "插入到位置: " << result.second << '\n';
first?second?这算什么鬼?
更好的做法是返回一个定制的结构体:
struct insert_result_t {
bool success;
iterator position;
};
insert_result_t insert(std::pair<K, V> entry);
直接通过名字访问成员,语义清晰明确,我管你是第一个第二个,我只想要表示“是否成功(success)”的那个变量。
auto result = map.insert({"hello", "world"});
cout << "是否成功: " << result.success << '\n';
cout << "插入到位置: " << result.position << '\n';
最好当然是返回和参数类型都是结构体:
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
函数,他的返回类型就是一个定制的结构体:
struct from_chars_result {
const char *ptr;
errc ec;
};
from_chars_result from_chars(const char *first, const char *last, int &value);
这说明他们也已经意识到了以前动不动返回 pair 的设计是有问题的,已经在新标准中开始改用更好的设计。
—
类型即注释
你是一个新来的员工,看到下面这个函数:
void foo(char *x);
这里的 x 有可能是:
- 0结尾字符串,只读,但是作者忘了加 const
- 指向单个字符,用于返回单个 char(指针返回仙人)
- 指向一个字符数组缓冲区,用于返回字符串,但缓冲区大小的确定方式未知
如果作者没写文档,变量名又非常含糊,根本不知道这个 x 参数要怎么用。
类型写的好,能起到注释的作用!
void foo(string x);
这样就一目了然了,很明显,是字符串类型的参数。
void foo(string &x);
看起来是返回一个字符串,但是通过引用传参的方式来返回的
string foo();
通过常规方式直接返回一个字符串。
void foo(vector<uint8_t> x);
是一个 8 位无符号整数组成的数组!
void foo(span<uint8_t> x);
是一个 8 位无符号整数的数组切片。
void foo(string_view x);
是一个字符串的切片,可能是作者想要避免拷贝开销。
—
还可以使用类型别名:
using ISBN = string;
BookInfo foo(ISBN isbn);
这样用户一看就明白,这个函数是接收一个 ISBN 编号(出版刊物都有一个这种编号),返回关于这本书的详细信息。
尽管函数名 foo 让人摸不着头脑,但仅凭直观的类型标识,我们就能函数功能把猜的七七八八。
—
拒绝指针!
注意,这里 foo 返回了一个指针!
BookInfo * foo(ISBN isbn);
他代表什么意思呢?
- 指向一个内存中已经存在的书目项,由 foo 负责管理这片内存
- 返回一个 new 出来的 BookInfo 结构体,由用户负责 delete 释放内存
- 是否还有可能返回 NULL 表示找不到的情况?
- 甚至有可能返回的是一个 BookInfo 数组?指针指向数组的首个元素,数组长度的判定方式未知…
太多歧义了!
BookInfo & foo(ISBN isbn);
这就很清楚,foo 会负责管理 BookInfo 对象的生命周期,用户获得的只是一个临时的引用,并不持有所有权。
引用的特点:
- 一定不会是 NULL(排除可能返回 NULL 的疑虑)
- 无法 delete 一个引用(排除可能需要用户负责释放内存的疑虑)
- 不会用于表示数组(排除可能返回数组首元素指针的疑虑)
改用引用返回值,一下子思路就清晰了很多。没有那么多怀疑和猜测了,用途单一,用法明确,引用真是绝绝子。
std::unique_ptr<BookInfo> foo(ISBN isbn);
这就很清楚,foo 创建了一个新的 BookInfo,并把生命周期的所有权移交给了用户。
unique_ptr 的特点:
- 独占所有权,不会与其他线程共享(排除可能多线程竞争的疑虑)
- 生命周期已经移交给用户,unique_ptr 变量离开用户的作用域后会自动释放,无需手动 delete
- 不会用于表示数组(如果要表示数组,会用
unique_ptr<BookInfo[]>
或者vector<BookInfo>
)
但是 unique_ptr 有一个致命的焦虑点:他可以为 NULL! 所以当你看到一个函数返回 unique_ptr 或 shared_ptr,尽管减少了很多的疑虑,但“可能为NULL”的担忧仍然存在! 要么 foo 的作者在注释或文档里写明,“foo 不会返回 NULL”或者“foo 找不到时会返回 NULL”,打消你的疑虑。 但我们的诉求是通过类型,一眼就能看出函数所有的可能性,而不要去依赖可能骗人的注释。
为此微软实现了 gsl 库,通过类型修饰解决指针类语义含糊不清的问题:
他规定,所有套了一层 gsl::not_null
的原始指针或智能指针,里面都必然不会为 NULL。
在 not_null 类的构造函数中,有相应的断言检查传入的指针是否为空,如果为空会直接报错退出。
gsl::not_null<FILE *> p = nullptr; // 编译期报错,因为他里面写着 not_null(nullptr_t) = delete;
gsl::not_null<FILE *> p = fopen(...); // 如果 fopen 打开失败,且为 Debug 构建,运行时会触发断言错误
修改后的函数接口如下:
gsl::not_null<std::unique_ptr<BookInfo>> foo(ISBN isbn);
因为 gsl::not_null 的构造函数中会检测空指针,就向用户保证了我返回的不会是 NULL。
但是,有没有一种可能,你如果要转移所有权的话,我直接返回 BookInfo 本身不就行了? 除非 BookInfo 特别大,大到移动返回的开销都不得了。 直接返回类型本身,就是一定不可能为空的,且也能说明移交了对象所有权给用户。
BookInfo foo(ISBN isbn);
—
其实 GSL 里大量都是这种可有可无的玩意,比如 C++20 已经有了 std::span 和 std::byte,但是 GSL 还给你弄了个 gsl::span 和 gsl::byte,主要是为了兼容低版本编译器,如果你在新项目里能直接用上 C++20 标准的话,个人不是很推荐再去用了。
再比如 gsl::czstring 是 const char * 的类型别名,明确表示“0结尾字符串”,为的是和“指针返回仙人”区分开来,有必要吗?有没有一种可能,我们现在 const char * 基本上就“0结尾字符串”一种用法,而且我们大多也都是用 string 就可以了,const char * 又不安全,又语义模棱两可,何必再去为了用它专门引入个库,整个类型别名呢?
using czstring = const char *;
void foo(czstring s) { // 发明 GSL 的人真是个天才!
if (s == "小彭老师") // 错误!
if (strcmp(s, "小彭老师")) // 错误!
if (!strcmp(s, "小彭老师")) // 终于正确
// 然而我完全可以直接用 string,== 的运算符重载能直接比较字符串内容
// 还能随时随地 substr 切片,find 查找,size 常数复杂度查大小
}
使用各式各样功能明确的类型和容器,比如 string,vector,或引用。 而不是功能太多的指针,让用户学习你的 API 时产生误解,留下 BUG 隐患。 如果需要指针,也可以通过 const 限定,来告诉用户这个指针是只读的还是可写的。 总之,代码不会撒谎,代码层面能禁止的,能尽量限制用法的,就不要用注释和文档去协商解决。
—
强类型封装
假设你正在学习这个 Linux 系统 API 函数:
ssize_t read(int fd, char *buf, size_t len);
// fd - 文件句柄,int 类型
但是你没有看他的函数参数类型和名字。你是这样调用的:
int fd = open(...);
char buf[32];
read(32, buf, fd);
char buf[32];
read(32, buf, fd);
你这里的 32 本意是缓冲区的大小,却不幸地和 fd 参数写错了位置,而编译器毫无报错,你浑然不知。
—
仅仅只是装模作样的用 typedef 定义个好看的类型别名,并没有任何意义! 他连你的参数名 fd 都能看不见,你觉得他会看到你的参数类型是个别名?
用户一样可以用一个根本不是文件句柄的臭整数来调用你,而得不到任何警告或报错:
typedef int FileHandle;
ssize_t read(FileHandle fd, char *buf, size_t len);
read(32, buf, fd); // 照样编译通过!
如果我们把文件句柄定义为一个结构体:
struct FileHandle {
int handle;
explicit FileHandle(int handle) : handle(handle) {}
};
ssize_t read(FileHandle handle, char *buf, size_t len);
就能在用户犯马虎的时候,给他弹出一个编译错误:
read(32, buf, fd); // 编译报错:无法将 int 类型的 32 隐式转换为 FileHandle!
对于整数类型,也有的人喜欢用 C++11 的强类型枚举:
enum class FileHandle : int {};
这样一来,如果用户真的是想要读取“32号句柄”的文件,他就必须显式地写出完整类型才能编译通过:
read(FileHandle(32), buf, fd); // 编译通过了
强迫你写上类型名,就给了你一次再思考的机会,让你突然惊醒: 哦天哪,我怎么把缓冲区大小当成句柄来传递了! 从而减少睁着眼睛还犯错的可能。
然后,你的 open 函数也返回 FileHandle,整个代码中就不用强制类型转换了。
FileHandle fd = open(std::filesystem::path("路径"), OpenFlag::Read);
char buf[32];
read(fd, buf, 32);
—
span “胖指针”
—
假如你手一滑,或者老板需求改变,把 buf 缓冲区少留了两个字节:
char buf[30];
read(fd, buf, 32);
但你 read 的参数依然是 32,就产生了数组越界,又未定义行为了。
我们采用封装精神,把相关的 buf 和 size 封装成一个参数:
struct Span {
char *data;
size_t size;
};
ssize_t read(FileHandle fd, Span buf);
read(fd, Span{buf, 32});
注意:Span 不需要以引用形式传入函数!
void read(std::string &buf); // 如果是 string 类型,参数需要为引用,才能让 read 能够修改 buf 字符串
void read(Span buf); // Span 不需要,因为 Span 并不是独占资源的类,Span 本身就是个轻量级的引用
vector 和 string 这种具有“拷贝构造函数”的 RAII 封装类才需要传入引用 string &buf
,如果直接传入会发生深拷贝,导致 read 内部修改的是 string 的一份拷贝,无法影响到外界原来的 string。
如果是 Span 参数就不需要 Span &buf
引用了,Span 并不是 RAII 封装类,并不持有生命周期,并没有“拷贝构造函数”,他只是个对外部已有 vector、string、或 char[] 的引用。或者说 Span 本身就是一个对原缓冲区的引用,直接传入 read 内部一样可以修改你的缓冲区。
—
用 Span 结构体虽然看起来更明确了,但是依然不解决用户可能手滑写错缓冲区长度的问题:
char buf[30];
read(fd, Span{buf, 32});
为此,我们在 Span 里加入一个隐式构造函数:
struct Span {
char *data;
size_t size;
template <size_t N>
Span(char (&buf)[N]) : data(buf), size(N) {}
};
这将允许 char [N] 隐式转换为 Span,且长度自动就是 N 的值。
此处如果写 Span(char buf[N])
,会被 C 语言的某条沙雕规则,函数签名会等价于 Span(char *buf)
,从而只能获取起始地址,而推导不了长度。使用数组引用作为参数 Span(char (&buf)[N])
就不会被 C 语言自动退化成起始地址指针了。
用户只需要:
char buf[30];
read(fd, Span{buf});
等价于 Span{buf, 30}
,数组长度自动推导,非常方便。
由于我们是隐式构造函数,还可以省略 Span 不写:
char buf[30];
read(fd, buf); // 自动转换成 Span{buf, 30}
加入更多类型的支持:
struct Span {
char *data;
size_t size;
template <size_t N>
Span(char (&buf)[N]) : data(buf), size(N) {}
template <size_t N>
Span(std::array<char, N> &arr) : data(arr.data()), size(N) {}
Span(std::vector<char> &vec) : data(vec.data()), size(vec.size()) {}
// 如果有需要,也可以显式写出 Span(buf, 30) 从首地址和长度构造出一个 Span 来
explicit Span(char *data, size_t size) : data(data), size(size) {}
};
现在 C 数组、array、vector、都可以隐式转换为 Span 了:
char buf1[30];
Span span1 = buf1;
std::array<char, 30> buf2;
Span span2 = buf2;
std::vector<char> buf(30);
Span span3 = buf3;
const char *str = "hello";
Span span4 = Span(str, strlen(str));
运用模板元编程,自动支持任何具有 data 和 size 成员的各种标准库容器,包括第三方的,只要他提供 data 和 size 函数。
template <class Arr>
concept has_data_size = requires (Arr arr) {
{ arr.data() } -> std::convertible_to<char *>;
{ arr.size() } -> std::same_as<size_t>;
};
struct Span {
char *data;
size_t size;
template <size_t N>
Span(char (&buf)[N]) : data(buf), size(N) {}
template <has_data_size Arr>
Span(Arr &&arr) : data(arr.data()), size(arr.size()) {}
// 满足 has_data_size 的任何类型都可以构造出 Span
// 而标准库的 vector、string、array 容器都含有 .data() 和 .size() 成员函数
};
—
如果用户确实有修改长度的需要,可以通过 subspan 成员函数实现:
char buf[32];
read(fd, Span(buf).subspan(0, 10)); // 只读取前 10 个字节!
subspan 内部实现原理:
struct Span {
char *data;
size_t size;
Span subspan(size_t start, size_t length = (size_t)-1) const {
if (start > size) // 如果起始位置超出范围,则抛出异常
throw std::out_of_range("subspan start out of range");
auto restSize = size - start;
if (length > restSize) // 如果长度超过上限,则自动截断
length = restSize;
return Span(data + start, length);
}
};
—
可以把 Span 变成模板类,支持任意类型的数组,比如 Span<int>
。
template <class Arr, class T>
concept has_data_size = requires (Arr arr) {
{ std::data(arr) } -> std::convertible_to<T *>;
{ std::size(arr) } -> std::same_as<size_t>;
// 使用 std::data 而不是 .data() 的好处:
// std::data 对于 char (&buf)[N] 这种数组类型也有重载!
// 例如 std::size(buf) 会得到 int buf[N] 的正确长度 N
// 而 sizeof buf 会得到 N * sizeof(int)
// 类似于 sizeof(buf) / sizeof(buf[0]) 的效果
// 不过如果 buf 是普通 int * 指针,会重载失败,直接报错,没有安全隐患
};
template <class T>
struct Span {
T *data;
size_t size;
template <has_data_size<T> Arr>
Span(Arr &&arr) : data(std::data(arr)), size(std::size(arr)) {}
// 👆 同时囊括了 vector、string、array、原始数组
};
template <has_data_size Arr>
Span(Arr &&t) -> Span<std::remove_pointer_t<decltype(std::data(std::declval<Arr &&>()))>>;
—
Span<T>
表示可读写的数组。
对于只读的数组,用 Span<const T>
就可以。
ssize_t read(FileHandle fd, Span<char> buf); // buf 可读写!
ssize_t write(FileHandle fd, Span<const char> buf); // buf 只读!
—
好消息!这东西在 C++20 已经实装,那就是 std::span。 没有 C++20 开发环境的同学,也可以用 GSL 库的 gsl::span,或者 ABSL 库的 absl::Span 来体验。
C++17 还有专门针对字符串的区间类 std::string_view,可以从 std::string 隐式构造,用法类似,不过切片函数是 substr,还支持 find、find_first_of 等 std::string 有的字符串专属函数。
std::span<T>
- 任意类型 T 的可读可写数组std::span<const T>
- 任意类型 T 的只读数组std::string_view
- 任意字符串
在 read 函数内部,可以用 .data() 和 .size() 重新取出独立的首地址指针和缓冲区长度,用于伺候 C 语言的老函数:
ssize_t read(FileHandle fd, std::span<char> buf) {
memset(buf.data(), 0, buf.size()); // 课后作业,用所学知识,优化 C 语言的 memset 函数吧!
...
}
也可以用 range-based for 循环来遍历:
ssize_t read(FileHandle fd, std::span<char> buf) {
for (auto & c : buf) { // 注意这里一定要用 auto & 哦!否则无法修改 buf 内容
c = 'c';
...
}
}
—
空值语义
—
有的函数,比如刚才的 foo,会需要表示“可能找不到该书本”的情况。 粗糙的 API 设计者会返回一个指针,然后在文档里说“这个函数可能会返回 NULL!”
BookInfo *foo(ISBN isbn);
如果是这样的函数签名,是不是你很容易忘记 foo 有可能返回 NULL 表示“找不到书本”?
比如 malloc
函数在分配失败时,就会返回 NULL 并设置 errno 为 ENOMEM。
在 man malloc
文档中写的清清楚楚,但是谁会记得这个设定?
malloc 完随手就直接访问了(空指针解引用属未定义行为)。
在现代 C++17 中引入了 optional,他是个模板类型。
形如 optional<T>
的类型有两种可能的状态:
- 为空(nullopt)
- 有值(T)
如果一个函数可能成功返回 T,也可能失败,那就可以让他返回 optional<T>
,用 nullopt 来表示失败。
std::optional<BookInfo> foo(ISBN isbn) {
if (找到了) {
return BookInfo(...);
} else {
return std::nullopt;
}
}
nullopt 和指针的 nullptr 类似,但 optional 的用途更加单一,更具说明性。 如果你返回个指针人家不一定知道你的意思是可能返回 nullptr,可能还以为你是为了返回个 new 出来的数组,语义不明确。
调用的地方这样写:
auto book = foo(isbn);
if (book.has_value()) { // book.has_vlaue() 为 true,则表示有值
BookInfo realBook = book.value();
print("找到了:", realBook);
} else {
print("找不到这本书");
}
optional 类型可以在 if 条件中自动转换为 bool,判断是否有值,等价于 .has_value()
:
auto book = foo(isbn);
if (book) { // (bool)book 为 true,则表示有值
BookInfo realBook = book.value();
print("找到了:", realBook);
} else {
print("找不到这本书");
}
可以通过 * 运算符读取其中的值,等价于 .value()
):
auto book = foo(isbn);
if (book) {
BookInfo realBook = *book;
print("找到了:", realBook);
} else {
print("找不到这本书");
}
运用 C++17 的就地 if 语法:
if (auto book = foo(isbn); book.has_value()) {
BookInfo realBook = *book;
print("找到了:", realBook);
} else {
print("找不到这本书");
}
由于 auto 出来的 optional 变量可以转换为 bool,分号后面的条件可以省略:
if (auto book = foo(isbn)) {
print("找到了:", *book);
} else {
print("找不到这本书");
}
optional 也支持 ->
运算符访问成员:
if (auto book = foo(isbn)) {
print("找到了:", book->name);
book->readOnline();
}
optional 的 .value()
,如果没有值,会抛出 std::bad_optional_access
异常。
用这个方法可以便捷地把“找不到书本”转换为异常抛出给上游调用者,而不用成堆的 if 判断和返回。
BookInfo book = foo(isbn).value();
也可以通过 .value_or(默认值)
指定“找不到书本”时的默认值:
BookInfo defaultBook;
BookInfo book = foo(isbn).value_or(defaultBook);
—
你接手了一个字符串转整数(可能转换失败)的函数 API:
// 文档:如果字符串解析失败,会返回 -1 并设置 errno 为 EINVAL!记得检查!若你忘记检查后果自负!
// 当指定 n 为 0 时,str 为 C 语言经典款 0 结尾字符串。
// 当指定 n 不为 0 时,str 的长度固定为 n,用于照顾参数可能不为 0 结尾字符串的情况。
int parseInt(const char *str, size_t n);
那么我如果检测到 -1,鬼知道是字符串里的数字就是 -1,还是因为出错才返回 -1?还要我去检查 errno,万一上一个函数出错留下的 EINVAL 呢?万一我忘记检查呢?
运用本期课程所学知识优化:
std::optional<int> parseInt(std::string_view str);
是不是功能,返回值,可能存在的错误情况,一目了然了?根本不需要什么难懂的注释,文档。
如果调用者想假定字符串解析不会出错:
parseInt("233").value();
如果调用者想当出错时默认返回 0:
parseInt("233").value_or(0);
parseInt 内部实现可能如下:
std::optional<int> parseInt(std::string_view str) {
int value;
auto result = std::from_chars(str.data(), str.data() + str.size(), std::ref(value));
if (result.ec == std::errc())
return value;
else
return std::nullopt;
}
—
调用者的参数不论是 string 还是 C 语言风格的 const char *,都能隐式转换为通用的 string_view。
parseInt("-1");
string s;
cin >> s;
parseInt(s);
char perfGeek[2] = {'-', '1'};
parseInt(std::string_view{perfGeek, 2});
笑点解析:上面的代码有一处错误,你能发觉吗?
—
cin >> s;
cin >>
可能会失败!没 想 到 吧
要是 int 等 POD 类型,如果不检测,会出现未初始化的 int 值,产生未定义行为!
int i;
cin >> i;
return i; // 如果用户的输入值不是合法的整数,这里会产生典中典之内存中的随机数烫烫烫烤馄饨!
官方推荐的做法是每次都要检测是否失败!
int i;
if (!(cin >> i)) {
throw std::runtime_error("读入 int 变量失败!");
}
return i;
但是谁记得住?所以从一开始就不要设计这种糟糕的 API。
特别是 cin >>
这种通过引用返回 i,却要人记得判断返回 bool 表示成败,忘记判断还会给你留着未初始化的煞笔设计。
如果让我来设计 cin 的话:
std::optional<int> readInt();
int i = cin.readInt().value();
这样如果用户要读取到值的话,必然要 .value()
,从而如果 readInt 失败返回的是 nullopt,就必然抛出异常,避免了用户忘记判断错误的可能。
在小彭老师自主研发的一款 co_async 协程库中,就重新设计了自己的异步字符流类,例如其中 getline 函数会返回
std::expected<std::string, std::system_error>
。在错误处理专题中有进一步的详解。
—
BookInfo * foo(ISBN isbn);
这是个返回智能指针的函数,单从函数声明来看,你能否知道他有没有可能返回空指针?不确定。
std::optional<BookInfo *> foo(ISBN isbn);
现在是不是很明确了,如果返回的是 nullopt,则表示空,然后 optional 内部的 BookInfo *,大概是不会为 NULL 的?
std::optional<gsl::not_null<BookInfo *>> foo(ISBN isbn);
这下更明确了,如果返回的是 nullopt,则表示空,然后 optional 内部的 BookInfo * 因为套了一层 gsl::not_null,必定不能为 NULL(否则会被 gsl::not_null 的断言检测到),函数的作者是绝对不会故意返回个 NULL 的。 如果失败,会返回 nullopt,而不是意义不明还容易忘记的空指针。
—
还是不建议直接用原始指针,建议用智能指针或引用。
std::optional<gsl::not_null<std::unique_ptr<BookInfo>>> foo(ISBN isbn);
这个函数可能返回 nullopt 表示失败,成功则返回一个享有所有权的独占指针,指向单个对象。
小彭老师,我 optional<BookInfo &>
出错了怎么办?
std::optional<std::reference_wrapper<BookInfo>> foo(ISBN isbn);
这个函数可能返回 nullopt 表示失败,成功则返回一个不享有所有权的引用,指向单个对象。
reference_wrapper 是对引用的包装,可隐式转换为引用:
int i;
std::reference_wrapper<int> ref = i;
int &r = ref; // r 指向 i
使引用可以存到各种容器里: 且遇到 auto 不会自动退化(decay):
int i;
std::reference_wrapper<int> ref = i;
auto ref2 = ref; // ref2 推导为 std::reference_wrapper<int>
int &r = i;
auto r2 = r; // r2 推导为 int
且永远不会为 NULL:
std::reference_wrapper<int> ref; // 编译错误:引用必须初始化,reference_wrapper 当然也必须初始化
也可以通过 *
或 ->
解引用:
BookInfo book;
std::reference_wrapper<int> refBook = book;
refBook->readOnline();
BookInfo deepCopyBook = *refBook;
—
注意 .value()
和 *
是有区别的,*
不会检测是否为空,不会抛出异常,但更高效。
o.value(); // 如果 o 里没有值,会抛出异常
*o; // 如果 o 里没有值,会产生未定义行为!
o->readOnline(); // 如果 o 里没有值,会产生未定义行为!
因此一般会在判断了 optional 不为空以后才会去访问 *
和 ->
。而 .value()
可以直接访问。
print(foo().value()); // .value() 可以直接使用,不用判断
if (auto o = foo()) {
// 判断过确认不为空了,才能访问 *o
// 在已经判断过不为空的 if 分支中,用 * 比 .value() 更高效
print(*o);
}
共享所有权
* n - shared_ptr
独占所有权
* n - vector
没所有权
* n - span
—
接下来介绍 optional 的一些进阶用法。
std::optional<BookInfo> o = BookInfo(1, 2, 3); // 初始化为 BookInfo 值
std::optional<BookInfo> o; // 不写时默认初始化为空,等价于 o = std::nullopt
o.emplace(1, 2, 3); // 就地构造,等价于 o = BookInfo(1, 2, 3); 但不需要移动 BookInfo 了
o.reset(); // 就地销毁,等价于 o = std::nullopt;
—
当不为空时将其中的 int 值加 1,否则保持为空不变,怎么写?
std::optional<int> o = cin.readInt();
if (o) {
o = *o + 1;
}
运用 C++23 引入的新函数 transform:
std::optional<int> o = cin.readInt();
o = o.transform([] (int n) { return n + 1; });
—
当不为空时将其中的 string 值解析为 int,否则保持为空不变。且解析函数可能失败,失败则也要将 optional 置为空,怎么写?
std::optional<string> o = cin.readLine();
std::optional<int> o2;
if (o) {
o2 = parseInt(*o);
}
std::optional<int> parseInt(std::string_view sv) { ... }
运用 C++23 引入的新函数 and_then:
auto o = cin.readLine().and_then(parseInt);
—
当找不到指定书籍时,返回一本默认书籍作为替代:
auto o = findBook(isbn).value_or(getDefaultBook());
缺点:由于 value_or 的参数会提前被求值,即使 findBook 成功找到了书籍,也会执行 getDefaultBook 函数,然后将其作为死亡右值丢弃。如果创建默认书籍的过程很慢,那么就非常低效。
为此,C++23 引入了 or_else 函数。 只有 findBook 找不到时才会执行 lambda 中的函数体:
auto o = findBook(isbn).or_else([] -> std::optional<BookInfo> {
cout << "findBook 出错了,现在开始创建默认书籍,非常慢\n";
return getDefaultBook();
});
—
此类函数都可以反复嵌套:
int i = cin.readLine()
.or_else(getDefaultLine)
.and_then(parseInt)
.transform([] (auto i) { return i * 2; })
.value_or(0);
加入函数式神教吧,函门!
—
点名批评的 STL 设计
—
例如 std::stack 的设计就非常失败:
if (!stack.empty()) {
auto val = std::move(stack.top());
stack.pop();
}
我们必须判断 stack 不为空,才能弹出栈顶元素。对着一个空的栈 pop 是未定义行为。 而 pop() 又是一个返回 void 的函数,他只是删除栈顶元素,并不会返回元素。 我们必须先调用 top() 把栈顶取出来,然后才能 pop!
明明是同一个操作,却要拆成三个函数来完成,很烂。如果你不慎把判断条件写反:
if (stack.empty()) {
auto val = std::move(stack.top());
stack.pop();
}
就一个 Segmentation Fault 蹦你脸上,你找半天都找不到自己哪错了!
小彭老师重新设计,整合成一个函数:
std::optional<int> pop();
语义明确,用起来也方便,用户不容易犯错。
if (auto val = stack.pop()) {
...
}
把多个本就属于同一件事的函数,整合成一个,避免用户中间出纰漏。 从参数和返回值的类型上,限定自由度,减轻用户思考负担。
—
众所周知,vector 有两个函数用于访问指定位置的元素。
int &operator[](size_t index);
int &at(size_t index);
vec[3]; // 如果 vec 的大小不足 3,会发生数组越界!这是未定义行为
vec.at(3); // 如果 vec 的大小不足 3,会抛出 out_of_range 异常
用户通常会根据自己的需要,如果他们非常自信自己的索引不会越界,可以用高效的 [],不做检测。 如果不确定,可以用更安全的 at(),一旦越界自动抛出异常,方便调试。
我们可以重新设计一个 .get() 函数:
std::optional<int> get(size_t index);
当检测到数组越界时,返回 nullopt。
*vec.get(3); // 如果用户追求性能,可以把数组越界转化为未定义行为,从而让编译器自动优化掉越界的路径
vec.get(3).value(); // 如果用户追求安全,可以把数组越界转化为一个异常
vec.get(3).value_or(0); // 如果用户想要在越界时获得默认值 0
这样就只需要一个函数,不论用户想要的是什么,都只需要这一个统一的 get() 函数。
—
小彭老师,你这个只能 get,要如何 set 呀?
std::optional<int> get(size_t index);
bool set(size_t index, int value); // 如果越界,返回 false
- 缺点1:返回 bool 无法运用 optional 的小技巧:通过 value() 转化为异常,且用户容易忘记检查返回值。
- 缺点2:两个参数,一个是 size_t 一个是 int,还是很容易顺序搞混。
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 部分
—
void Sleep(int delay);
谁知道这个 delay 的单位是什么?秒?毫秒?
void Sleep(int ms);
好吧,是毫秒。可是除非看一眼函数定义或文档,谁想得到这是个毫秒?
一个用户想要睡 3 秒,他写道:
Sleep(3);
编译器没有任何报错,一运行只睡了 3 毫秒。 用户大发雷霆以为你的 Sleep 函数有 BUG,我让他睡 3 秒怎么好像根本没睡啊。
—
void SleepMilliSeconds(int ms);
改个函数名可以解决一部分问题,当用户调用时,他需要手动打出 MilliSeconds
,从而强迫他清醒一下,自己给的 3 到底是不是自己想要的。
—
struct MilliSeconds {
int count;
explicit MilliSeconds(int count) : count(count) {}
};
void Sleep(MilliSeconds delay);
现在,如果用户写出
Sleep(3);
编译器会报错。 他必须明确写出
Sleep(MilliSeconds(3));
才能通过编译。
—
标准库的 chrono 模块就大量运用了这种强类型封装:
this_thread::sleep_for(chrono::seconds(3));
如果你 using namespace std::literials;
还可以这样快捷地创建字面量:
this_thread::sleep_for(3ms); // 3 毫秒
this_thread::sleep_for(3s); // 3 秒
this_thread::sleep_for(3m); // 3 分钟
this_thread::sleep_for(3h); // 3 小时
且支持运算符重载,不同单位之间还可以互相转换:
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
的类型别名来表示时间戳,但它有一个严重的问题:
它可以被当成时间点,也可以被当成时间段,这就造成了巨大的混乱。
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 函数,会出现“睡美人”的奇观:
time_t t = time(NULL); // 返回 1715853968 表示当前时间点
sleep(t); // 不小心把时间点当成时间段来用了!
这个程序会睡 1715853968 秒后才醒,即 54 年后!
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 年去吗?
- 时间点 - 时间点 = 时间段
- 时间点 + 时间段 = 时间点
- 时间点 - 时间段 = 时间点
- 时间段 + 时间段 = 时间段
- 时间段 - 时间段 = 时间段
- 时间段 × 常数 = 时间段
- 时间段 / 常数 = 时间段
这就是本期课程的主题,通过强大的类型系统,对可能的用法加以严格的限制,最大限度阻止用户不经意间写出错误的代码。
—
枚举类型
—
你的老板要求一个设定客户性别的函数:
void foo(int sex);
老板口头和员工约定说,0表示女,1表示男,2表示自定义。
这谁记得住?设想你是一个新来的员工,看到下面的代码:
foo(1);
你能猜到这个 1 是什么意思吗?
解决方法是使用枚举类型,给每个数值一个唯一的名字:
enum Sex {
Female = 0,
Male = 1,
Custom = 2,
};
void foo(Sex sex);
再假设你是一个新来的员工,看到:
foo(Male);
是不是就一目了然啦?
—
枚举的值也可以不用写,让编译器自动按 0、1、2 的顺序分配值:
enum Sex {
Female, // 0
Male, // 1
Custom, // 2
};
可以指定从 1 开始计数:
enum Sex {
Female = 1,
Male, // 2
Custom, // 3
};
—
但枚举类型还是可以骗人,再假设你是新来的,看到:
foo(Male, 24);
是不是想当然的感觉这个代码没问题?
但当你看到 foo 准确的函数定义时,傻眼了:
void foo(int age, Sex sex);
相当于注册了一个 1 岁,性别是 24 的伪人。且程序员很容易看不出问题,编译器也不报错。
为此,C++11 引入了强类型枚举:
enum class Sex {
Female = 0,
Male = 1,
Custom = 2,
};
现在,如果你再不小心把 sex 传入 age 的话,编译器会报错!因为强类型枚举不允许与 int 隐式转换。
而且强类型枚举会需要显式写出 Sex::
类型前缀,当你有很多枚举类型时不容易混淆:
foo(24, Sex::Male);
如果你的 Sex 范围很小,只需要 uint8_t 的内存就够,可以用这个语法指定枚举的“后台类型”:
enum class Sex : uint8_t {
Female = 0,
Male = 1,
Custom = 2,
};
static_assert(sizeof(Sex) == 1);
—
假如你的所有 age 都是 int 类型的,但是现在,老板突然心血来潮:
说为了“优化存储空间”,想要把所有 age 改成 uint8_t 类型的!
为了预防未来可能需要改变类型的需求,也是为了可读性,我们可以使用类型别名:
using Age = int;
void foo(Age age, Sex sex);
这样当老板需要改变底层类型时,只需要改动一行:
using Age = uint8_t;
就能自动让所有代码都使用 uint8_t 作为 age 了。
—
但是类型别名毕竟只是别名,并没有强制保障:
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 也做成枚举,但没有定义任何值:
enum class Age : int {};
enum class Phone : int {};
这样用到的时候就只能通过强制转换的语法:
foo(Age(42), Phone(12345));
并且如果写错顺序,尝试把 Phone 传入 Age 类型的参数,编译器会立即报错,阻止你埋下 BUG 隐患。
—
小彭老师,我用了你的方法以后,不能做加法了怎么办?
Age(42) + Age(1) // 编译器错误!
这是因为 Age 是强类型枚举,不能隐式转换为 int 后做加法。
可以定义一个运算符重载:
enum class Age : int {};
inline Age operator+(Age a, Age b) {
return Age((int)a + (int)b);
}
或者运用模板元编程,直接让加法运算符对于所有枚举类型都默认生效:
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 不支持乘法,需要手动转换后才能乘,避免无意中犯错的可能。
—
小彭老师,我用了你推荐的强类型枚举,不支持我最爱的或运算 |
了怎么办?
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);
}
—
其他类型套皮
—
小彭老师,我很喜欢强类型枚举这一套,但我的参数不是整数类型,而是 double、string 等类型,怎么办?
struct Name {
private:
std::string value;
public:
explicit operator std::string() const {
return value;
}
explicit Name(std::string value) : value(value) {}
};
这里我们写 explicit 就可以阻止隐式类型转换,起到与强类型枚举类似的作用。
或者运用模板元编程:
// 此处使用 CRTP 模式是为了让 Typed 每次都实例化出不同的基类,阻止 object-slicing
template <class CRTP, class T>
struct Typed {
protected:
T value;
public:
explicit operator T() const {
return value;
}
explicit Typed(T value) : value(value) {}
};
struct Name : Typed<Name, std::string> {};
struct Meter : Typed<Meter, double> {
using Typed<Kilometer, double>::Typed;
};
struct Kilometer : Typed<Kilometer, double> {
using Typed<Kilometer, double>::Typed;
operator Meter() const {
// 允许隐式转换为米
return Meter(value * 1000);
}
};
Meter m = Kilometer(1);
// m = Meter(1000);
foo(m);
—
RAII 封装
—
小彭老师,我的函数就是涉及“开始”和“结束”两个操作,用户的操作需要穿插在其中间,怎么整合呢?
mysql_connection *conn = mysql_connect("127.0.0.1");
mysql_execute(conn, "drop database paolu");
mysql_close(conn); // 用户可能忘记关闭连接!破坏库设计者想要的用法
这种大多是获取资源,和释放资源两个操作。
因为 mysql 是个 C 语言的库,他没有 RAII 封装,让他手动封装有的同学又嫌弃麻烦。
这时我会告诉他们一个 shared_ptr 小妙招:构造函数的第二个参数可以指定释放函数,代替默认的 delete
auto conn = std::shared_ptr<mysql_connection>(mysql_connect("127.0.0.1"), mysql_close);
mysql_execute(conn.get(), "drop database paolu");
// conn 离开作用域时,会自动调用 mysql_close,杜绝了一个出错的可能
—
以封装 C 语言的 FILE 为例。
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *file);
这个 size 和 nmemb 真是太糟糕了,本意是为了支持不同元素类型的数组。 size 是元素本身大小,nmemb 是元素数量。实际读取的字节数是 size * nmemb。 我就经常把 ptr 和 file 顺序写反,这个莫名其妙的参数顺序太反直觉了!
小彭老师运用现代 C++ 思想封装之:
using FileHandle = std::shared_ptr<FILE>;
enum class OpenMode {
Read,
Write,
Append,
};
inline OpenMode operator|(OpenMode a, OpenMode b) {
return OpenMode((int)a | (int)b);
}
auto modeLut = std::map<OpenMode, std::string>{
{OpenMode::Read, "r"},
{OpenMode::Write, "w"},
{OpenMode::Append, "a"},
{OpenMode::Read | OpenMode::Write, "w+"},
{OpenMode::Read | OpenMode::Append, "a+"},
};
FileHandle file_open(std::filesystem::path path, OpenMode mode) {
#ifdef _WIN32
return std::shared_ptr<FILE>(_wfopen(path.wstring().c_str(), modeLut.at(mode).c_str()), fclose);
#else
return std::shared_ptr<FILE>(fopen(path.string().c_str(), modeLut.at(mode).c_str()), fclose);
#endif
}
struct [[nodiscard]] FileResult {
std::optional<size_t> numElements;
std::errc errorCode; // std::errc 是个强类型枚举,用于取代 C 语言 errno 的 int 类型
bool isEndOfFile;
};
template <class T>
FileResult file_read(FileHandle file, std::span<T> elements) {
auto n = fread(elements.data(), sizeof(T), elements.size(), file.get());
return {
.numElements = n == 0 ? std::optional(n) : std::nullopt,
.errorCode = std::errc(ferror(file.get())),
.isEndOfFile = (bool)feof(file.get()),
};
}
是不是接口更加简单易懂,没有犯错的机会了?
FileHandle file = file_open("hello.txt", OpenMode::Read);
int arr[32];
file_read(file, arr).numElements.value(); // 如果没有读到东西,这里会抛出异常
// 退出作用域时,shared_ptr 会自动为你关闭文件,无需再提供 file_close 函数
—
Mutex 封装
—
—
彩蛋:CUDA 封装实战
—
变量名与作用域限制
—
—
你真的需要 get/set 吗?
—
TODO