现代化的 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 有可能是:

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

如果作者没写文档,变量名又非常含糊,根本不知道这个 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);

他代表什么意思呢?

  1. 指向一个内存中已经存在的书目项,由 foo 负责管理这片内存
  2. 返回一个 new 出来的 BookInfo 结构体,由用户负责 delete 释放内存
  3. 是否还有可能返回 NULL 表示找不到的情况?
  4. 甚至有可能返回的是一个 BookInfo 数组?指针指向数组的首个元素,数组长度的判定方式未知…

太多歧义了!

BookInfo & foo(ISBN isbn);

这就很清楚,foo 会负责管理 BookInfo 对象的生命周期,用户获得的只是一个临时的引用,并不持有所有权。

引用的特点:

  1. 一定不会是 NULL(排除可能返回 NULL 的疑虑)
  2. 无法 delete 一个引用(排除可能需要用户负责释放内存的疑虑)
  3. 不会用于表示数组(排除可能返回数组首元素指针的疑虑)

改用引用返回值,一下子思路就清晰了很多。没有那么多怀疑和猜测了,用途单一,用法明确,引用真是绝绝子。

std::unique_ptr<BookInfo> foo(ISBN isbn);

这就很清楚,foo 创建了一个新的 BookInfo,并把生命周期的所有权移交给了用户。

unique_ptr 的特点:

  1. 独占所有权,不会与其他线程共享(排除可能多线程竞争的疑虑)
  2. 生命周期已经移交给用户,unique_ptr 变量离开用户的作用域后会自动释放,无需手动 delete
  3. 不会用于表示数组(如果要表示数组,会用 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, restSize + 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> 的类型有两种可能的状态:

  1. 为空(nullopt)
  2. 有值(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 sv) {
    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 * 1 - shared_ptr

独占所有权 * n - vector, unique_ptr * 1 - unique_ptr

没所有权 * n - span * 1 - reference_wrapper, T &


接下来介绍 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> {};

struct Kilometer : Typed<Kilometer, double> {
    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)), fclose);
#else
    return std::shared_ptr<FILE>(fopen(path.string().c_str(), modeLut.at(mode)), 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 ? 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