整理一下关于现代化的C++函数API设计的笔记, 我们关注如何设计可读性好,使用安全的函数接口:设计函数接口的名称、参数类型、返回值等。

部分参考现代化的 API 设计指南

基本原则

函数API的设计要满足如下的原则:

  • 向用户传达清晰的语义,保证接口的可读性,避免因为语义不清晰导致用户的错误调用;
  • 向编译器提供更多的信息,尽可能让错误的使用在编译时就被发现,由编译器发出警告或直接导致编译失败。

如果需要考虑:

  • 跨编译器使用:混用不同编译器编译得到的库
  • 跨语言使用:混用C语言和C++,或者将当前的C++库提供给其它语言调用(例如Python)

那么必须保证使用extern "C"提供C语言形式的接口。

函数名称

保持统一的函数名称风格:大驼峰或使用下划线,例如

1
2
3
void get_date();

void SetDate();

函数名称通常使用动词加名称的形式,避免提供名称相似的函数接口,尤其是不要出现仅有大小写不同的函数接口。

匈牙利命名法已经过时了,尤其对于C++这种变量类型非常明确的语言,我们可以借助IDE或clangd等工具很方便地检查变量类型。

函数参数

函数重载

仅函数参数不一样的多个同名函数会形成函数重载,具体调用其中哪一个函数由编译器根据传入参数的类型进行匹配判定,程序会调用匹配优先级最高的版本(具体的匹配规则非常繁琐,这里不作讨论)

1
2
int add(int,int);
double add(double,double);

我们应该尽量少用函数重载,尤其是在对外提供函数接口时,因为函数重载会极大地破坏代码的可读性, 用户需要自行判断调用的到底是哪一个函数,如果和编译器实际匹配的版本不一致,并且恰好通过编译了,那么就会产生无法预期的错误。

参数默认值

我们应当避免在参数中提供默认值,因为默认值的语义不够清晰,可能被调用者忽略,例如

1
void func(int a, bool flag = true);

更好的做法是使用多个相似的函数来提供间接调用,例如

1
2
3
4
5
6
7
8
9
void func(int a, bool flag);

void func_t(int a){
func(a,true);
}

void func_f(int a){
func(a,false);
}

参数类型选取

首先我们将函数参数分成两类:

  • 只读参数:对参数的修改不会对调用者提供的实参产生影响
    • 参数可能具有const属性,导致在函数体内部也不允许修改
    • 参数也可能是基于实参自动产生的副本,在函数体内对副本的修改对外部无效的
  • 读写参数:对参数的修改会影响到调用者提供的实参,这需要通过非const的引用类型或指针类型实现

对于读写参数的语义其实不是那么清晰,我们明确地知道函数将会通过读写参数返回信息,但是读写参数在传入时的值会被函数使用还是直接忽略,这是不确定的。

对于只读参数的设计遵循如下原则:

  • 直接传值:对于基本数据类型以及某些简单的自定义数据类型(取决于拷贝构造的成本),可以直接传值
  • 传递const引用:对于某些复杂的自定义数据类型,尤其是拷贝构造成本很大时,建议使用const引用传递
  • 传递右值引用:如果只允许传递字面量等右值,则可以使用右值引用。

对于读写参数的类型选择是有争议的:

  • 有的人认为非const引用的效果更好,因为使用更简单,省去了指针判空的检查;
  • 有的人则认为指针的效果更好,因为调用方使用地址的传递,具有更清晰的语义。

例如

1
2
3
4
5
6
7
8
9
void update_ptr(int *p) {
if (p != nullptr) {
// ...
}
}

void update_ref(int &s) {
// ...
}

两种方案的使用效果如下

1
2
3
4
5
int a = 100;

update_ptr(&a);

update_ref(a);

不要混乱地排列这两种参数,我们应当在函数参数列表中将其清晰地区分开,例如将所有的只读函数放在前面,将所有的读写参数放在后面。

多个参数打包

如果我们需要给一个函数提供很多的参数,那么有必要将参数进行打包,以优化代码的可读性,降低函数的使用难度。

1
void func(T1 arg1, T2 arg2, T3 arg3, T4 arg4);

例如直接将其打包为一个简单的结构体

1
2
3
4
5
6
7
8
struct ArgType {
T1 arg1;
T2 arg2;
T3 arg3;
T4 arg4;
};

void func(ArgType arg);

在使用时我们可以临时构造结构体

1
func({value1, value2, value3, value4});

这种方式仍然可能将各个参数的语义搞混,可以通过注释加上语义

1
func({/*arg1*/ value1, /*arg2*/ value2, /*arg3*/ value3, /*arg4*/ value4});

但是注释的对应关系只是起到了提示作用,并不是语法上的保证。

更好的方式是使用Linux风格的结构体初始化语法

1
func({.arg1 = value1, .arg2 = value2, .arg3 = value3, .arg4 = value4})

这种写法不需要保持各个分量的初始化顺序,并且允许缺省某些分量。

C/C++对于结构体支持上述的具名初始化,但是对于函数参数却不支持具名参数。

参数的强类型封装

有时我们需要明确参数的语义以避免误用,但是参数类型无法提供有价值的信息,例如

1
2
3
4
// fd 文件句柄,int 类型
// buf 缓冲区
// len 缓冲区长度
size_t read(int fd, char *buf, size_t len);

使用例如

1
size_t s = read(1, buff, 64);

因为这里的fd文件句柄和len缓冲区长度都可以提供整数参数,可能在调用时出现顺序混乱, 我们可以定义强类型来分别替代它们

1
2
3
4
5
6
7
8
9
10
11
12
13
struct FileHandle {
int value;

explicit FileHandle(int fd) : value(fd) {}
};

struct Length {
size_t value;

explicit Length(size_t len) : value(len) {}
};

size_t read(FileHandle fd, char *buf, Length len);

此时就需要显式构造才能顺序调用,构造的类型向使用者提供了更清晰的语义(还可以在构造函数中对参数合法性进行检查)

1
size_t s = read(FileHandle(1), buff, Length(64));

一个例子是在标准库的时间模块<chrono>中大量运用了这种强类型封装

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

选项参数——强枚举类型

在参数类型的选择中,使用强枚举类型替换弱枚举类型和布尔类型,也是提升语义和调用安全性的有效措施。

如果我们使用布尔参数,调用方就无法获取参数的语义

1
void func1(bool rewrite_flag, bool check_flag);

使用例如

1
2
3
func1(true,true);
func1(true,false);
func1(false,false);

但是改成强枚举类型就可以提升调用方的可读性,避免传参错误

1
2
3
4
enum class rewrite { on, off };
enum class check { on, off };

void func2(rewrite flag1, check flag2);

使用例如

1
2
3
func2(rewrite::on, check::on);
func2(rewrite::on, check::off);
func2(rewrite::off, check::off);

字符串参数

传递字符串是很常见的需求,并且通常我们只需要读取字符串内容而不需要修改, 我们有很多种选择,它们具有不同的特点:

  1. const char*:C语言风格字符串,兼容性最强,传递效率最高,但是不含有长度信息
  2. const std::string &:通过const引用传递的C++字符串,含有长度信息,避免了不必要的拷贝,但是从字符串字面值直接传递也会发生std::string的构造
  3. std::string_view:C++17提供的更轻量的字符串视图,作为参数传递时的开销很小(指针+长度),效率比const引用更高

不要混用这几种方式,否则传递过程中会产生很多额外的开销。

函数返回值

引用返回与指针返回

可变的引用类型返回通常使用在自定义类型的方法中,通过返回自身的引用可以达到链式调用效果,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <string>

class Person {
public:
Person& setName(const std::string& name) {
this->name = name;
return *this;
}

Person& setAge(int age) {
this->age = age;
return *this;
}

void display() const {
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}

private:
std::string name;
int age;
};

int main() {
Person john;
john.setName("John").setAge(30).display();

return 0;
}

除此之外的情况都不建议使用引用类型作为返回类型。

指针类型也不适合作为函数的返回类型,接收者还需要额外的指针判空操作,并且指针指向的内存可能在使用时已经失效了。 在涉及到动态内存的管理时还有更严重的问题: 裸指针无法清晰地表明动态内存的管理权限, 可能会引发内存泄漏和悬空指针等麻烦

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

int* createInt() {
int* p = new int(42);
return p;
}

int main() {
int* myInt = createInt();
std::cout << *myInt << std::endl;
delete myInt;
return 0;
}

我们只能寄希望于调用者正确地释放内存。

使用智能指针通常是更好的替代方案

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <memory>

std::unique_ptr<int> createInt() {
return std::make_unique<int>(42);
}

int main() {
auto myInt = createInt();
std::cout << *myInt << std::endl;

return 0;
}

多个返回值

对于需要提供多个返回值,通常有两类方案:

  • 返回值打包
    • 打包为结构体
    • 打包为std::tuple
  • 通过读写参数返回

最常见的方案是将多个返回值打包为一个结构体或一个std::tuple整体作为函数返回值。 这两种选择各有优劣:前者的优点是每一个分量有明确语义,后者的优点是代码更简洁。

打包为结构体返回例如

1
2
3
4
5
6
struct ReturnType{
T1 value1;
T2 value2;
};

ReturnType func(T in);

打包为一个std::tuple返回例如

1
std::tuple<T1,T2> func(T in);

除此之外,也可以通过读写参数进行返回,这种做法的可读性最差

1
2
3
void func(T in, T1 &out1, T2 &out2, T3 &out3);

void func(T in, T1 *out1, T2 *out2, T3 *out3);

函数状态返回

对于某些函数,我们除了在正常情况下需要获取结果,还需要考虑异常情况,此时将不会得到合法的结果。

我们考虑一个尝试将字符串转换为整数的函数:

  • 如果传入合适的值并且转换成功,返回对应整数值;
  • 否则转换失败(例如,字符串中包含非数字字符),反馈错误状态。

下面以此为例,解释并比较不同的方案。

错误码

函数只允许有一个返回值,但是我们既需要在成功时获取返回值,还需要在失败时返回错误信息,有很多种具体方案。

我们可以将错误信息通过约定的错误码返回:错误码通常为整数类型,例如约定0代表成功,其它均为失败,具体的,以-1代表参数不合法等)。 有几种具体的实现:

  • 函数正常结果通过返回值返回,错误码通过读写参数返回;
  • 错误码通过返回值返回,函数正常结果通过读写参数返回;
  • 将函数正常结果和错误码打包为一个返回值返回,例如使用一个结构体;

前两种方案其实没什么区别,只是交换了个位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int string2int_1(const std::string& str, int& result) {
result = 0;
for (char ch : str) {
if (!std::isdigit(ch)) {
result = 0;
return -1;
}
result = result * 10 + (ch - '0');
}
return 0;
}

int string2int_2(const std::string& str, int& status) {
int result = 0;
status = 0;
for (char ch : str) {
if (!std::isdigit(ch)) {
status = -1;
return 0;
}
result = result * 10 + (ch - '0');
}
return result;
}

使用结构体也是很常用的方案,比前两者的语义更加清晰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct RetType {
int result;
int status;
};

RetType string2int_3(const std::string& str) {
int result = 0;
for (char ch : str) {
if (!std::isdigit(ch)) {
return {0, -1};
}
result = result * 10 + (ch - '0');
}
return {result, 0};
}

这一类做法有很多不足:

  • 错误码的对应关系太复杂,使用者必须逐个判断来处理失败的情况。
  • 使用者可能忘记检查错误码,直接获取结果。

我们可以精简上面的方案,使用布尔类型的错误码,或者要求错误码只能取值为0或1,至于具体的错误信息,可以通过某个约定的全局变量传递。

为了避免使用者忘记检查错误,我们可以使用C++17提供的std::optional作为返回值

1
2
3
4
5
6
7
8
9
10
std::optional<int> string2int_4(const std::string& str) {
int result = 0;
for (char ch : str) {
if (!std::isdigit(ch)) {
return std::nullopt;
}
result = result * 10 + (ch - '0');
}
return result;
}

std::optional会提醒调用者:返回值可能是正常的结果,也可能是空的。 但是有一点不足就是,std::optional只能返回空值来表示失败,无法返回具体的失败信息。

为了解决这个问题,C++23提供了std::expected,它相当于std::optionalstd::varient的结合体,在语义上更适合作为返回值:可能是正常的结果,也可能是错误码类型。

抛异常

除了返回错误码,更直接的办法是在错误情况下直接抛出异常

1
2
3
4
5
6
7
8
9
10
int string2int_5(const std::string& str) {
int result = 0;
for (char ch : str) {
if (!std::isdigit(ch)) {
throw std::invalid_argument("Invalid character in input string");
}
result = result * 10 + (ch - '0');
}
return result;
}

这种方案会给使用者引入额外的负担,因为调用时需要考虑异常的捕获与处理。 并且到目前为止,C++的异常机制并没有被开发者普遍采用,很多情况下甚至是明确禁止使用异常的。

set/get方法

对于自定义类型的数据成员,在什么时候下需要提供set/get接口:

  • 对于平凡的情况通常是不需要的,不要滥用set/get接口;
  • 如果某些属性的修改相互关联,则需要屏蔽外界对它的直接修改,将其设置为隐藏成员,对外提供set方法,在set方法里面负责维护相关的关系。由于C++不能像MATLAB一样精细地单独控制读取和写入的权限,在提供set方法时通常也需要提供get方法。

我们需要保证类的对象在外部始终处于合法状态,在类的接口内部可能处于临时的非法状态,但是需要保证跳出public接口时对象已经回到了正常状态。