C++ 如何设计好用的API

本文我们还是来转述下cppcon的一篇演讲- “Back to Basics: C++ API Design - Jason Turner - CppCon 2022”,这篇演讲主要是讲述C++中设计API的一些细则,帮助我们写不容易出错的代码。Jason感觉很活跃,会场上到处跑,讲的东西也比较有用,请大家继续看下去。

EX1: vector

Jason的演讲中反复强调的是想要设计的api很难被使用错误,这也是他所设计api的基本准则。

template
class vector {
public:
    bool empty() const;
};

首先抛出了这个例子,因为演讲中有大量的互动,这里大家也可以停下来想一想这个api的设计是不是很难使用错误?

有一些地方需要指出:

  • empty里边是做了什么?(这里是说这个命名不太好)
  • 如果丢弃掉返回值会怎么样?
  • 这个api有怎样的错误处理?

那么如何重写这个呢?

[[nodiscard]] bool is_empty() const;

那么错误处理呢,修改后的这个没有明确指出,还需要再次改动:

template
class vector {
public:
    [[nodiscard]] bool is_empty() const noexcept;
};

小结1

由上边的例子可以看出,设计一个好的api有哪些点注意。

使用更好的名字

计算机科学中最难的两件事是:

  • 缓存失效
  • 命名
  • off-by-one 错误
  • 范围蠕变(scope creep)
  • 边界检查
    哈哈,这里两件事是Jason开的一个小玩笑。

[[nodiscard]]

如果返回值被丢弃掉(不使用),编译器后产生一个警告,他可以应用函数声明和类型声明。

应用在函数
[[nodiscard]] int get_value();

int main() {
	get_value(); // wanrning   
}

也就是忽略掉get_value的返回值就会有一个警告,可以使用返回值接收或者传递参数等就不会报警告。

C++23 修复了一个小的漏洞,就是应用在lambda表达式上的[[nodiscard]

int main() {
    auto l = [][[nodiscard]]() -> int {return 42;};

    l(); // warning
}
应用在类型
struct [[nodiscard]] ErrorType{};
ErrorType get_value();

int main() {
    get_value(); // warning
}

这里在结构体声明应用了[[nodiscard]]关键字,当ErrorType作为函数返回值时,对于该函数的调用,如果忽略了返回值则编译器会报一个警告。

应用在构造函数

[[nodiscard]]还可以应用在构造函数(C++20),来看一个例子:

struct FDHolder {
    [[nodiscard]] FDHolder(int FD);
    FDHolder();
};

int main() {
    FDHolder{42}; // warning
    FDHolder h{42}; // object no discard, no warning
    FDHolder{}; // use default constructed, no warning
}

FDHolder有两个构造函数,一个是int参数,一个是默认构造函数。
当调用时,如果使用第一个构造函数构造对象,如果忽略构造的结构体对象,则会报警告,如果不忽略则不会报警告。同样使用没有被[[nodiscard]] 修饰的构造函数来构造对象也不会产生警告。

除此之外,使用[[nodiscard(“Lock object should never be discard”)]]用法来输出警告的内容。

noexcept

noexcept会提示用户及编译器该函数不会抛出异常,但是如果这个函数运行过程中抛出异常了,那么std::terminate会被调用来结束进程。

void myfunc() noexcept {
    throw 42;
}

int main() {
    try {
        myfunc();
    } catch (...) {
        // catch is irrelevant. 'terminate' is called
    }
}

如果是这段代码在程序中,编译器可能会忽略掉try-catch,或者留try-catch在那里,但是都是会调用terminate来结束程序。

EX2: 一个工厂函数

Widget* make_widget(int widget_type);

这个接口定义的怎么样,表意是否够明确,使用起来是否方便?

  • 如果忽略返回值会怎样?(这里是想说,返回的widget并不知道是个怎么样的对象,生命周期是否需要使用者释放等等)
  • 输入的范围是多少呢?(这里仅仅一个int,不具备表述要输入的范围,很容易写错)

那么如何重写它呢?大家也可以考虑上边的总结和日常开发的经验做一下思考:

[[nodiscard]] std::unique_ptr make_widget(int widget_type);

首先改写成以上,使用[[nodiscard]]修饰,返回值改成unique_ptr,这样可以传达的是,该函数创建的对象会将所有权进行转移到用户这里。

还可以做优化的;

enum class WidgetType {
    Slider = 0,
    Button = 1
};
[[nodiscard]] std::unique_ptr make_widget(WidgetType type);

这里有话把输入改成了枚举,这样就限制了输入的范围。
哈,不过这里Jason又举了一个例子:

auto widget = make_widget(static_cast(-42));

这真的是,猴子请来搞破坏的。然后Jason没有继续做优化,后边还会谈到。

截止到改写的目前为止,除了上边的问题,Jason抛出了错误处理:

  • 错误处理是怎样的?
  • 有没有可能创建失败?
  • 是否会抛一个异常呢?
  • 还是返回一个空指针?

小结2

不要返回裸指针

  • 虽然简单但是却引入了很多问题,谁拥有它,谁来释放,是否是一个全局变量等等问题
  • 考虑使用有指针所有权的对象

一致的错误处理机制

错误处理机制分为抛出类的和非抛出类的,api设计的过程中要保持一致性。

  • 强烈建议避免使用错误检测(error reporting),这里所说的是get_last_error或者errno类似的形式,Jason这里给出的理由是多线程获取错误可能会不准确。
  • 让错误不容易被忽略,Jason这里就是建议直接使用异常,这样不会被忽略,抛出异常即可。
  • 不要使用optional来传递一个错误(他不会传达错误的原因)
  • 考虑std::expected (C++23)或者其他更简单的

EX3: fopen

FILE* fopen(const char *pathname, const char* mode);

最开始也还是抛出例子,大家先看这个原始api设计,再想想如何对其优化?

  • 错误处理是怎样的?
  • 忽略返回值会怎样?
  • mode的是应该传递啥?(这个确实是,我一般都记不得mode的每一个表示,还要现查资料)
  • fopen("w", "/my/file/path") 如果这样调用会怎么样?(参数顺序调换位置)
  • fopen("/my/file/path", 0) 如果这样调用呢?

那么如何重写呢?

using FilePtr = std::unique_ptr;
[[nodiscard]] FilePtr fopen(const char* pathname, const char* mode);

这里首先对返回值进行优化,使用unique_ptr封装裸指针,保证其所有权转移,同时指定了该指针的析构函数。

可以再进一步优化:

using FilePtr = std::unique_ptr;
[[nodiscard]] FilePtr fopen(const std::filesystem::path &path, std::string_view mode);

这样避免用户在调用的时候可以很明显的提示用户参数的类型不同,视觉上对参数的具体位置有一些提示。但是这样还是会有问题,因为无论是std::filesystem::path还是std::string_view都可以传递字符串隐式转化。

auto file = fopen("w", "/my/file/path")

类似这样还是可以调用成功的。那么假设std::filesystem::path还是std::string_view是最正确的类型了,那么我们该如何优化呢?

void fopen(const auto&, const auto&) = delete;

这个是c++20引入的隐式模板,也就是说不需要写template等关键字,其实他这里表述的意思是,这个隐式模板会捕捉到所有的类型,除了我们前边声明的fopen函数,进一步说就是如果不是明确的传递参数类型是std::filesystem::pathstd::string_view那么就会被这个隐式模板捕捉到,但是它是个delete的函数,那么就会编译不过。

那么这样就能很明显的避免掉类型传递容易被交换位置的问题了。

小结3

避免参数容易交换的场景

  • 两个或者多个相同类型的参数会很容易让用户对参数的位置把握不好,很容易位置就传错了
  • clang-tidy有一个这个[bugprone-easily-swappable-parameters]可以帮助到大家

参数避免隐式转换

  • std::filesystem::pathstd::string_view 不是强类型,即可以发生隐式转换
  • const char*,string,string_viewpath之间的隐式转化会打破类型的安全性。
  • 转换操作符以及一个参数的构造函数都应该被标记为explicit。

使用=delete删除有问题的重载

  • 任何函数都可以被=delete
  • 如果=delete掉一个模板,他就会匹配所有不明确的参数,阻止隐式转化

再返回去看下

这里Jason又回到了工厂函数那里,看是否还可以优化下,因为遗留了一个问题:

auto widget = make_widget(static_cast(-42));

这里能够以强类型的方法再一次对其优化:

template
[[nodiscard]] WidgetType make_widget() requires (std::is_base_of_v);

使用requires限定了WidgetType必须是Widget的子类,这样就强类型限制了创建的对象,用户只能传递Widget的子类来构造。

然后再回来看fopen的最开始的例子?

FILE* fopen(const char *pathname, const char* mode);
  • 这里pathname和mode是否是可选(optional)的呢?(也就是说是不是可传可不传)
  • 不传的话,传递nullptr是不是可以呢?
  • 如果传递任意的指针,可能就会出现为定义的行为了。
    Jason这里说的optional,我理解应该是是否有值,因为下一个例子中是这样:
void use_string(std::string * const * str) {
    if (str) { // str is optional
        // do something
    } else {
        // do other things
    }
}

其实这里想要表达的是使用裸指针错误函数参数,你的API内部就需要做判断,不做判断直接使用就会有不安全的问题。同时如果用户使用该api传递了错误的指针,这里就会有未定义的错误。

所以好的方法是传递引用:

// no-trivial. pass by (const) reference
void use_string(const std::string& str) {
    puts(str.c_str());
}

// trivial and small, copy it
void use_int(const int i) {
    // use i
}

最后Jason又回到了优化后的fopen这里:

using FilePtr = std::unique_ptr;
[[nodiscard]] FilePtr fopen(const std::filesystem::path &path, std::string_view mode);

这里讨论到针对mode的可能的输入集,我们如何对这个api进行优化呢,这里是一个开放的结局,他提了几个方案参考:

  • 做成编译期检查的类型集合(应该是类似于我们上边widget子类的写法)
  • 使用一个flag来针对集合中每个元素作为一位(bit)存放数据
  • 等等吧

最后的总结

最后我把Jason总结原则罗列在这里:

  • 使用一个好的名字(明确的)
  • 尽可能使用[[nodiscard]](可以加上原因)
  • 不要返回一个裸指针
  • 使用noexcept来表示函数没有异常抛出
  • 提供一致的,无法忽略的错误处理机制
  • 使用强类型
  • 避免隐式转换
  • 使用=delete来阻止转换
  • 参数避免使用裸指针
  • 避免传递智能指针

到这里结束了,感谢阅读,搬运不易,点个赞吧

ref

  • https://www.youtube.com/watch?v=zL-vn_pGGgY

你可能感兴趣的:(C++,base,c++,java,开发语言)