API 设计的艺术¶
标题:The Art of API Design
日期:2026/06/16
作者:Christoph Stiller
链接:https://www.youtube.com/watch?v=d5djrT4qfHc
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:换了新电脑,whisper 暂时还没装上,先直接用油管字幕处理,后续有空替换。似乎效果挺好的,不替换了。
大家好,欢迎收听我的演讲:理解 API 设计的艺术(The Art of API Design)。
首先,我是谁?我是 Chris。这些年来,我曾有幸构建过非常多的代码库。在与年轻工程师共事的过程中,我发现他们在成长之路上似乎经常犯一些相同的错误。因此,我想把这次演讲当作一次经验的累积分享,给大家一个领先的起点,让大家知道在设计时应该注意些什么。
因为情况往往是这样的:人们在大学里学习编程,学习整洁代码(clean coding)和代码审查;但是,当他们第一次被委以重任去进行架构设计和 API 设计时,他们的初次尝试往往并不顺利。接着大家就会觉得:“哦,这个人不能被信任去设计 API”,所以每当发生这种情况时,其他人就会接手。这就剥夺了他们通过经验进行学习的机会。
这就是为什么这件事对我来说如此重要:一个优秀的 API 不仅仅是功能的副产品或某种事后的想法,它必须是被主动设计出来以帮助用户的,而不是仅仅作为功能的某种门面(facade)。
构建 API 的四大重任¶
我认为你在构建 API 时肩负着巨大的责任,主要有四个关键原因:
第一,你的 API 塑造了用户代码的样貌。 如果我们希望代码具有可读性,那么你的 API 在很大程度上就设定了用户代码究竟能呈现出何种样貌的边界。
第二,如果你在构建功能时引入了一个 bug,那通常是个极易修复的问题——修改一个文件,重新审查一下,就完成了。然而,如果你的接口(interfaces)出了问题,那么每个人的代码都需要修改。 不仅是你自己的代码,所有使用你 API 的人的代码都得改。
第三,糟糕的 API 会浪费大量人的时间。 人们不仅要在研究它应该长什么样、应该如何工作、它具体做了什么以及能做什么上浪费时间;而且,糟糕的 API 往往会引导人们一遍又一遍地犯同样的错误。这些 bug 固然可以被发现并修复,但这所有的过程都是昂贵的,因为耗费了大量的时间。
最后,代码通常会被频繁地评估,人们会在代码审查中仔细查看代码;但是,如果人们不刻意留意,架构问题有时会隐藏在表面之下。 因此,构建正确的 API 是至关重要的,因为如果你不主动去寻找潜在问题,它就会蔓延到你的整个项目中。最终你会突然意识到自己面临着一个巨大的麻烦,而不是像通常处理糟糕代码那样在早期就能抓住它。
在本次演讲中,我想带大家看看一个优秀的 API 应该具备哪些特质,然后我们将逐一探讨这些要点,看看如何才能把它们做好。
优秀 API 的核心特征¶
简化心智模型:优秀的 API 能够简化你构建功能所需的心智模型。
易于理解其能力:优秀的 API 让人很容易理解它究竟能做什么、它能提供什么。
提供切实所需:它应该能够完成你实际需要做的事情,否则这个 API 对你毫无用处。
易于理解如何使用:显然,它应该让人很容易明白如何使用该 API 来实现目标。
鼓励可读的代码:它将鼓励你的用户编写出具有可读性的代码。
难以被错误使用:它应该让你很难把它用错。
没有令人讨厌的意外:当你为一个完整的项目采用某个 API,使用了一个月后,你不会发现它引发了你事先没预料到的巨大麻烦,也不会让你后悔迁移到它。
让我们先来看看第一点。
1. 优秀的 API 能够简化心智模型¶
功能(Functionality)是指你的程序为了达成目标所需执行的所有不可简化的事情。我们可以通过对它进行以下三种操作,来实现对心智模型的简化:
首先,我们将属于同一类的部分组合在一起,并将不相关的部分分离开来。
其次,我们将内部工作原理的复杂性隐藏在具有固定边界的某个对象之后。这就是我们所说的抽象(abstraction)。复杂性应该存在于对象的内部,而从外部来看,如何使用这个对象应该是非常清晰的。
第三(也是最后一点),通信(communication)。为了让功能确实发挥其应有的作用,这些组件需要能够相互通信,但这需要以一种透明的方式进行,而不是仅仅去修改彼此的值。
现在,一个 API 结合了一系列经过抽象、分离的功能,这些功能服务于一个组合在一起但定义明确的目的。实际上,系统工程(systems engineering)会影响这些 API 最终的形态。 例如,如果你使用消息来进行通信,那么你唯一的 API 就是这种消息交互,其结构是非常扁平的。这可能是一件好事,也可能是一件坏事。我只是想说明,系统工程对 API 有着非常显著的影响。
现在,你程序的架构是通过将 API 分层、将抽象级别从(大规模的信息层)分层到(针对特定主题的极具体层)来构建的。如果你的某个 API 现在需要与不直接连接的更底层的 API 进行通信,它就打破了这些抽象层。这就是我们所说的意大利面式架构(spaghetti architecture)。因此,这种直接通信应该被禁止,你应该尊重抽象级别,并以一种让人容易正确使用的方式来构建你的 API。
如果你确实需要访问更底层的抽象,更好的做法是去掉中间的那层抽象。因为此时你不再依赖中间层提供的功能了,如果你还要在脑海中记住中间层抽象实际做了什么,你的心智模型就不再被简化了。
显然,非常重要的一点是,API 不仅在水平方向上分离,在垂直方向上也是分离的。这意味着某个抽象层可能会在底层使用多个不同的 API。尽管它们处于同一层级,它们也不应该相互交谈。即使它们处于比你更低的层级,它们也不应该直接通信。如果外部部分需要与“降采样器(decimator)”通信,所有的通信都必须通过“模型加载器(model loader)”来进行。
好了,太棒了,我们了解了抽象。我们是抽象的忠实粉丝。那么是不是抽象越多越好呢?
其实并非如此。这有点像洗一个舒服的温水澡。如果温度恰到好处,一切都很完美;如果稍微热一点或冷一点,你会有感觉,但还能应付;如果水太热或太冷,你应该立刻跳出来,以减少物理伤害。抽象也是同样的道理。
如果架构抽象太少,仅仅因为要在你当前的抽象层级上追踪所有正在发生的事情,心智模型就会变得过于复杂。对于对象来说也是一样,如果它们包含大量复杂且难以追踪的状态,它们就变得非常难以处理,你的心智模型会被同时发生的所有事情给污染。因此,你通常应该将它们分离,并引入更多的抽象层。
另一方面,如果抽象太多,实际的功能就会被分散在不同的层级之间。这样就非常难以追踪:当你需要做一个修改时,你需要触碰多个不同的地方,通过这种方式来追踪心智模型就会变得非常复杂。所以你不仅没有在第一时间消除任何复杂性,反而适得其反。
因此,为了简化心智模型,我们首先问自己:我们现有或新的 API 的目的是什么?它抽象或分离了什么? 其次,我们在垂直和水平方向上分离各个层级。第三,我们禁止意大利面式的调用(即打破我们抽象层级的调用)。最后,我们为我们的用例找到适度的分离和抽象。
2. 优秀的 API 让人容易理解它能做什么¶
既然我们在这里谈论的是“理解”,实际上我们已经有一个非常好的心理类比。你知道,API 可能是抽象的心智概念,但归根结底,它们只是包含文本的文件和文件夹。这就像是一本书的章节标记和目录,我们至少需要略读一下才能弄清楚它包含什么内容。所以让我们用大脑中“自然语言处理”的类比来更好地看待这个问题。
这段文本容易理解吗?(假设它是一门外语)。既然是外语,就很难说,对吧?即使我为自己提供了在理解层面上弄懂它的工具,要学习它的含义仍然会很繁琐。但这不仅仅适用于单词。如果换个例子,我们从书架上拿下一本旧书并阅读:“If some have compassion making a difference…(如果某些人有同情心,就能有所作为…)”
通常你会觉得:“大概我知道那是什么意思——这是在呼吁某人变得友善,在这个世界上做出一些积极的改变之类的。‘making a difference(有所作为)’意味着让情况变得更好。一切都很清楚。”
但是,如果这段文字写于 1611 年呢?在那个年代,“making a difference”实际上有着完全不同的含义(译注:指“区别对待”)。所以,我们是在把原作者或翻译者根本没有打算表达的含义强加到这段文本中。
当然,这不能一对一地完全翻译到编程中,但我们在 C++ 中有类似的情况。struct(结构体)和 class(类)在 C++ 中功能上是非常相似的。然而,在 C++ 程序中它们通常不以相同的方式被使用。
struct带有 C 语言的历史包袱,所以我们通常期望它是 POD(Plain Old Data,简型数据)。但
class带有更多来自 Java 方面的影响。我们期望它包含成员函数,并潜在地包含继承等特性。
人们通常使用 struct 的方式是:当你修改一个 struct 的成员时,这仅仅是与 struct 最普通、最正常的交互。然而,如果你在 class 的原生环境中直接修改它的成员,这通常会引发一些疑问:首先,为什么那个成员不是私有的(private)?为什么没有提供某种接口方式来进行交互,而是让你手动修改它的成员?
实际上,在 C++ 中我们还有更多这样的例子,比如指针(pointers)和引用(references)。它们在功能上几乎是相同的,但在我们的代码库中,它们的使用方式并不相同。我们在它们之间做选择的方式,通常受到我们公司和项目的编码指南与政策的影响。
因此,如果在实际代码中看到类似这样的定义,通常会引发疑问:
为什么这个返回一个
int?这是一个错误码吗?如果它是一个索引,那它为什么是有符号的(signed)?关于这一项的指针参数呢?他们期望我们保持对象的地址可访问(因为只是传递了指针),还是说他们会从这个指针复制或移动对象?
但是,如果我们知道这家公司的 文化背景(cultural context) 是:根据政策,返回码就是错误码且均为整数;并且他们从不使用引用,只使用指针。那么这段代码的作用就清晰多了。
如你所见,就像任何其他文本一样,我们的 API 和接口都有一个特定的目标受众。这个受众范围可能很广,也可能很窄。有时这很难定义,但你定义得越清晰,为他们做出正确决策就越容易。
这显然不仅仅是 struct 与 class 的对比,还包括:特定领域的专家 vs 普通开发者 vs 编程新手;需要对所有细节进行精细控制的人 vs 只是想把事情做完的人;可能是现代 C++ 风格 vs 某特定年份的 C++ 风格 vs C 风格的 C++;或者是非常熟悉 STL 及其运作方式的人 vs 不熟悉的人。
幸运的是,作为开发者,我们所拥有的许多文化理解不仅仅是作为(C++)开发者才有的,也是作为人类所固有的理解。例如,我们的大脑一次能记住的事物数量是有上限的;或者,我们期望相似的事物能够放在一起,而不同的事物放在别处。
为了让人们了解我们的 API 能做什么,我们手头可以使用的工具有:
文件、文件夹及文件结构:即使只看这些,人们也能大致了解我们的 API 能做什么。
当他们打开这些文件时,他们会看到命名空间(namespaces)、模块(modules)(如果你已经在使用的话)、对象、枚举(enums)、函数。
显然还有它们的名字。
它们在文件和文件夹中的位置分布;找到人们最常寻找的功能有多容易。
也许还有关于这些事物的注释,至少是宏观维度的注释;以及不一定紧密相关但仍属于同一个文件的事物之间的分隔注释。
显然还有模板(templates)、概念(concepts),或许还有公共的类型定义(public typedefs),这些能够告诉我们该 API 旨在与其他类型协同工作,或者旨在与模板、迭代等一起使用。
为了让我们的对象容易被理解它们是做什么的,它们应该有一个聚焦的作用域(focused scope),也就是对外的单一职责。从架构的角度来看,这显然也是非常重要的,因为它减少了思维的混乱。但同时,如果人们只是在略读我们的 API,这也使得他们更容易理解一个对象的作用。
因此,我们希望限制一个单元可以拥有的可能性和方向的数量。如果很难说清它到底是做什么的,我们就应该把它拆分成多个部分。如果你需要某些功能,你仍然可以把它移到不同的对象中。
举个例子,如果我们有一个 File(文件)类:它可能有 exists(存在判断)、size(大小)、获取一些属性、或许还有读(read)和写(write),也许你还想复制它;然后也许你想分析它的读写吞吐量;为什么我们不加入事务性文件系统交互以及内存映射(memory map)的功能呢?
你可以看到,在某个节点上,这里存在一条界线:这些功能不再仅仅应该属于这个文件对象,而是放在其他某个地方会合理得多。这样才能更容易地理解这个 File 对象的实际目的。所以,你的对象应该只做一件事,并且把这件事做好,这也应该反映在它的名字上。
话虽如此,你应该根据事物能做什么来命名它们,而不是根据它们是什么来命名。 你应该能够用一句话把它的功能表达出来,然后从中推导出一个简明扼要的名字。不过,对于你的命名来说,长而精确,要好过短而通用或具有误导性。 要考虑人们看到这个名字时会产生怎样的期望,他们希望它能做什么?
最后,让你的名字具有特定的含义。 如果你可以用多个不同的词来表达某件事,挑一个,赋予它特定的含义。因为有时你会同时需要这两个词,那么你可以让其中一个词在你的架构中代表某种含义,让另一个词代表另一种含义。这样只需看名字就能非常清晰地知道你处于架构的哪个部分。
总结一下: 我们希望让人们容易理解我们的 API 能做什么,方法是:
了解我们的目标受众,并考虑他们的理解能力;
通过文件和文件夹结构,以及文件内的排序来帮助用户;
让我们的对象只做一件事并做到极致;
让我们的命名根据事物能做什么(而不是它们是什么)来保持简明且精确;
并让这些术语和名称在整个架构中保持统一且具有特定含义。
3. 优秀的 API 提供你实际需要的功能¶
这一点其实很棘手。在这里,用户反馈是至关重要的。
所以,再次强调,了解你的目标受众。如果可以的话,和他们交流,理解他们的问题,弄清楚他们实际需要什么。这可能与他们告诉你他们需要什么截然不同。
考虑与你功能的所有必要交互,不要仅仅为了简化你的 API 就限制交互。
不要只做心胸狭隘的特定用例支持。 如果你提供了一个异步(async)方法来做某事,可能有人会需要它是同步(synchronous)的。如果你强制要求使用回调(callback),也许有些人会更希望采用相反的方式。
但在这里,我能告诉你的并不多。最主要的还是:与你的用户交谈,弄清楚他们真正需要什么。
4. 优秀的 API 让人容易理解如何使用它¶
太好了,现在我们知道你的 API 是做什么的了,并且它提供了我们实际需要的功能。接下来,它应该让我们很容易理解如何使用它。
要做到这一点,实际上包含两个部分: 第一,我们希望让用户能够依靠他们的直觉和文化背景来驱动自己,这样他们甚至不需要专门去为你的 API 学习任何新东西。 第二,当他们确实需要学习某些东西时,我们需要尊重他们学到的知识,并在各处反复利用这些知识。
那么,我们如何让人们被他们的文化理解所引导呢? 答案是:我们有意使用编程语言中的构造体,以及人们对它们的固有理解。
例如对于
struct,人们通常期望它是 POD(简型数据)或至少是可以平凡拷贝(trivially copyable)的。像在这个例子中,它只包含一个枚举和两个整数。它也可能被用作标签类型(tag type),或者可能用于模板特化。这是你通常看到struct被使用的地方。然而,
class通常封装了生命周期。通常类也具有自定义的拷贝和移动语义。所以人们通常会把这些因素考虑进去。此外,类通常继承自其他类。某些函数可能会被重写或是虚函数(virtual)。也许这个类是纯虚的,里面的所有函数实际上都没有实现,诸如此类。例如,枚举(enums)对于传达具有固定可能性的状态(基本上就是所有的状态)来说表现得非常出色。即使只有两个不同的选项(你本可以使用一个布尔值
bool),使用枚举通常更具可读性,尤其是对代码审查者而言。比如在这种情况下,在下面那个(使用布尔值)的函数调用中,如果在代码审查时,我们实际上并不知道第二个参数是否代表“追加模式(append mode)”。但当我们看到枚举明确指定时,一切就清楚得多了。枚举也非常适合用作返回值。如果你想表示发生了一种不属于普通类型的“状态”,它们在清晰传达这一点上是非常出色的。
现在让我们看看当你看到一个对象,并问自己“我该如何获取它的实例?”的情况:
公共构造函数(public constructor) 告诉你:你可以自己创建一个这样的对象。
静态创建函数(static creation functions) 告诉你:你至少可以获取一个这样的对象。
make_something函数 提供了一些其他的创建方式。
当涉及到成员函数时:
静态成员函数(static member functions) 通常只与这个对象类有关,而与具体的实例无关。
const成员函数更像是一种“展示给我看 / 告诉我某些信息(show me/tell me something)”的操作。非
const成员函数则是一种“请把这个对象改变成这样(please change the object into this)”的操作。自由悬浮的函数(free floating functions) 通常是纯函数,或者至少在某种程度上独立于对象,有时只是用作实用工具(utility)。
关于参数与返回值:
const引用作为参数通常意味着:“在调用的这段时间里,让我看看这个东西。”如果是返回
const引用,那就好像在说:“哦,这可能是用玻璃做的,请小心点。”const指针通常意味着:“让我贯穿我的整个生命周期来看这个东西。”(也就是在对象的整个持续时间内)。非
const引用更偏向于:“在调用的这段时间里,让我暂时戳一戳它(修改它)。” 如果它们被返回,它们同样是“玻璃做的”,所以当你改变它们时需要小心。非
const指针则更倾向于:“让我无限期地,或在我的整个生命周期内戳一戳它。”右值引用(&&) 是在说:“现在这是你的了”,或者“现在这是我的了”。
双重指针(
**) 是在说:“请让我看看你的内脏。” 或者如果作为返回值:“别,千万别返回这个。”
我们也可以通过使用常见的模式来运用人们的文化理解。
例如,当你采用 std::span 或 std::vector,甚至只是一个指向数量的指针模式时,那是人们通常非常熟悉的用于接受“事物列表”的模式。
或者如果你提供 begin 和 end,人们就知道这代表着迭代(iteration)。
std::optional、std::variant 和 std::expected 则传达了诸多可能性中哪一种选项实际发生了。
std::unique_ptr 和 std::shared_ptr 传达了所有权(ownership)。
通过在 size(大小)、count(数量)、bytes(字节数)和 capacity(容量)之间做出明智的选择,我们让人们很容易理解他们实际得到的是哪一个。
让人容易理解如何使用你的 API 的第二部分是尊重已学的知识,这意味着一致性(consistency)。
命名的一致性。 一个特定的术语在我们的整个架构中应该意味着相同的含义,并且即使该术语在自然语言中可以互换使用,它也应该能告诉我们当前处于什么位置。
方法的一致性。 例如,如果我们有一种概念:对象的成员函数在配置完成时会返回对对象自身的引用(链式调用),我们就不应该只在一个对象上使用这种方法。我们应该一致地使用它,或者根本不使用。或者如果你有“接收器(sinks)”和“源(sources)”的概念,并且我们允许其中一个向另一个推送数据,我们就不应该只把这个用于两个边缘对象,而应该用于所有可能适用的地方。
行为的一致性。 例如,当我们遇到非法输入时,我们是试图猜测用户到底想干什么,还是直接“在他们脸上引爆(抛出异常/报错)”以告诉他们这不是使用此 API 的方式?这在你的整个 API(最好是整个架构)中应该保持一致。
类型的一致性。 如果你使用一种类型作为返回值,你应该也能再次接收该类型作为参数,以此类推。如果你的架构定义得真的很好,这最好能贯穿你的整个架构,以至少允许人们能够留在你的系统内操作。
总结一下:如何让人们容易理解如何使用你的 API? 利用他们预先存在的知识为你带来优势。在命名、方法、行为和类型上保持一致。让人们只需学习一次,并允许他们一旦进入你的系统就能停留在其中。
我在这里还有两个小补充点: 首先,提供合理的默认值,特别是在涉及到模板类型或者类型需要广泛配置的情况下。给人们一个合理的起点开始。 其次,提供文档,以便人们需要时能找到。他们可能不总是需要文档,但当他们需要时,应该能够找得到。
5. 优秀的 API 鼓励编写可读的代码¶
通过使用你的 API,所产生的结果代码应该非常易于阅读和进行代码审查,无论交互的复杂性如何。
总的来说,越简单越好。但要注意:这不仅仅适用于你的代码,它适用于所有受到你 API 影响的代码。 这包括你的实现代码、接口代码,同时也包括你的 API 的所有使用场景。
因此,除非收益惊人且该交互非常普遍,否则不要仅仅在用户对他们的对象做了一些特殊操作时才让事情变得简单。如果你这样做了,一定要进行极其充分的沟通。并且,通过概念(concepts)、断言(asserts)等,为错误使用它的用户提供出色的反馈。
关于 控制反转(inversion of control) 和回调(callbacks)的情况也是类似的。它们可能会暂时降低复杂性,但如果大量使用,它们将在一瞬间摧毁你整个架构中任何关于分离和抽象的感受。这不仅仅是指 lambda 表达式和函数指针,还包括对象将自己传递给其他对象,然后由其他对象修改它们或调用它们的某个函数等等。
人们在尝试构建简单事物时往往会陷入一个常见的陷阱,那就是仅仅考虑那 95% 的简单情况。但有些交互本质上就是复杂的,你需要承认它们。简单的交互应该是极其容易的(trivial),但复杂的合法交互仍然必须是可能的。
你的高度可配置、多阶段转换的管道(pipeline)设置如果不是一行代码就能搞定,这是完全可以接受的,它甚至不应该只有一行。但这并不意味着与你的 API 进行的最基础的交互,竟然需要两个不同的命令队列、目标缓冲区、继承的句柄、工厂类以及高级线程队列设置。基础交互就应该是简单的。但这并不意味着复杂的用例是无效的。处理那 95% 的简单情况是最重要的,但是你不能忽略掉那些极端困难的用例,并且它们不能仅仅因为严重依赖实现细节才能勉强工作。
我通常喜欢称之为 “复杂度斜率(complexity slope)”。任务越简单,API 就应该让其实现变得越简单;任务越复杂,只要在合理范围内,该交互变得更复杂也是没问题的。这个复杂度斜率的底线应当是合理的,这样琐碎的交互就不会要求用户去考个博士学位才能弄懂;斜率本身也应该是合理的,不至于让实现复杂功能变得不可能。
这真是一个很棒的要求!但我们究竟该如何构建一个长成这样的 API 呢? 答案很简单:通过尝试去设计它。
在构建 API 之前,试着考虑使用它会是什么样子。我们要用到的方法很简单,那就是伪代码(pseudo code)。就这么简单。你的 API 还没有存在,但伪代码能给你提供任何你能梦寐以求的魔法接口。所以,尝试一种方法,看看它会引向何方。不要指望你的第一次尝试就是完美的,完美需要时间。如果经过几次修改后你还是没法把它变得优雅,那就换一种方法,看看效果如何。考虑人们使用你的 API 将会进行的所有简单和复杂的常见交互,并快速为它们建立原型。
实际上,在这里,测试驱动开发(TDD, test-driven development) 能在构建 API 方面为你赢得一些加分。但是,首先,你只需要用一点伪代码就能完成同样的事情;其次,我们不一定要优化“测试代码”的可读性,我们追求的是“生产代码”的可读性。
好了,现在你发现了一个神奇的新 API,它运行得非常漂亮,能完成你想做的一切。坚持使用它。 我无数次看到人们想出了一个很好的解决方案,然后因为用他们现有的代码去实现起来太复杂就将其抛弃,这实在是令人震惊。这里的重点就在于无视现有的代码。重点是忽略除了“硬性需求”之外的一切(也就是那些必须作为参数传递的东西,或者你确实需要访问的东西之类)。
目的不是为了让 API 在内部实现起来变得简单。这里追求的是整个系统(也就是所有的代码)的简单性。因此,如果你 API 内部的实现代码最终变得更加复杂和丑陋,那可能是值得的。因为你不仅仅是在考虑你的 API 本身,你也在考虑使用它的所有人。如果你让其他所有人的代码都变得简单多了,那么为此付出 API 内部更复杂的代价就是值得的。
当然了,如果你为了区区 2% 的提升而去过度设计(overengineering)一切,那是很不划算的。复杂 API 背后隐藏着整洁代码,这比在美丽简单的 API 背后隐藏着大量手动优化的汇编代码要糟糕得多。你可以很容易地更改实现代码,但你无法轻易地更改 API。
现在,如果你有一个行之有效的方法,你不需要在第一次尝试时就把所有的名字都取得很完美。当你对采用的设计方法感到满意后,你可以慢慢来挑选合适的名字。
使用你 API 的代码应该易于实现、易于阅读并且易于审查。你不应该要求人们去理解实现原理或任何其他内部细节,并且他们应该只需要阅读尽可能少的人类可读的文档。否则,当人们试图学习这个 API 并审查使用了这个 API 的代码时,你就是在浪费大量人的时间。
自下而上 vs 自上而下的设计¶
人们经常在没有事先思考的情况下使用一种我称之为 “自下而上(bottom up)” 的设计方法。这种方法在构建底层功能时很棒,但它并不特别适合 API 设计,因为在这种情况下,API 更像是事后的产物。它的工作方式是:例如,当构建某种神经网络时,他们会首先构建神经元和激活函数的实现细节,然后为它们构建接口,接着在它们之上构建某些利用了两者的层(layers)。接着他们意识到:“啊,我们其实需要一种方法来维持缓冲区,以便复用它们。” 于是他们在之上构建了某种训练引擎(training engine)。最后,如果我们够幸运的话,在最顶层构建了“神经网络(neural network)”类型的 API 供终端用户使用。
这种方法的问题在于,它让底层功能塑造了上层 API。而我们不希望 API 只是个事后的想法。所以这对于终端用户将实际使用的神经网络来说,基本上是能产生的最糟糕的 API。
我通常向大家推荐的方法是我所说的 “自上而下(top down)”。它的运作方式是:
你首先使用伪代码构建你的虚拟用户代码。
你从这些伪代码中推导出神经网络的接口。
然后你意识到:“我需要某种训练执行器来在时间推移中维持状态。”所以你构建了训练引擎。
再往下,“我需要实际的网络层。”所以你构建实际的 Layer API。
最后在最底部,你真正开始实现你的神经元和激活函数。
这种方法有时会遇到的麻烦是:人们此时还不知道他们的 硬性需求(hard requirements) 是什么。所以当他们真正去构建神经元和激活函数,甚至是 Layer 或训练引擎时,他们会发现:“哦,我实际上需要访问这个参数,或者我需要这里的这个配置。” 于是他们就会在这里或那里添加一个参数,这会开始污染接口。所以你得到的实际上并不是他们最初设计的那个接口,而是经过了五六次迭代后的接口——这里的“迭代”我并不是指好的方面,而是指在第一次尝试中就已经积累了各种历史遗留问题的接口。
为了避开这个问题,我通常推荐一种我称之为 “自下、自上而下(bottom top down)” 的方法。在这种方法中:
你首先构建你的神经元和激活函数。(自下)这样你就能明确知道硬性需求是什么。
然后,在这一切之上,你构建你的虚拟用户代码。所以你是在原型化实际神经网络的顶级 API。
使用这个 API,你构建出实际的神经网络。(自上而下)
在认识到下层需要什么之后,你再次构建某种训练引擎,然后是层。
这样,你就有机会从上到下构建大部分的接口,这往往能产生非常好的接口;同时,你确保了你已经提前知道了所有的硬性需求。因此,你不需要频繁地对你的接口进行修修补补式的迭代,因为你对硬性约束已经了如指掌。最后,显然的,你会用真实的、实际的用户代码替换掉当初的伪代码。
然而,即使采用了这一切,有时依然很难弄清楚最底层的抽象到底是什么,或者它该做什么。如果出现这种情况,我通常建议大家做一个 API 的截面(cross-section)分析。从你想在最终实现的一个具体交互开始,仅仅针对这一个交互,想象它的接口会是什么样子。从最顶层的用户代码与最高层的 API 交互开始,最高层与某个低层抽象对话,以此类推。当你完成了一个交互的设计后,基本上再尝试下一个交互,再下一个交互。你最初计划的一些交互可能不会按你预期的方式工作,但你会对你实际需要做的东西有一个大得多、好得多的全局视野。当你对各个层级需要成为什么样子有了很好的理解后,你就可以开始实现你的最底层了。
时间的流逝与 API 的演变¶
太棒了。现在我们已经为一个潜在的完整子系统构建了一个非常出色、易于使用的 API。接下来呢?
甚至连优秀的 API 也会面临一种常见的死亡原因,那就是时间(time)。
你的 API 在刚开始时可能令人惊叹,但随着时间的推移,人们可能需要对它进行微小的修改,因为他们需要这里的一个配置参数,或者那里的一个附加选项。函数的参数数量不断增加、增加、再增加,过了一段时间,它就变成了一个你从未预想过的怪物。通常到了这个时候,就只能选择重写,或者你需要保留旧版支持并在相同的后端上构建一个新的 API 等等。
因此,如果你对 API 进行添加或即使是微小的更改,始终要考虑长期的后果。
有时只需设立一个截断点:与其盲目添加,不如添加一个新的抽象层来提供这个功能,或者该功能应当诉诸于一个更底层的抽象,或是你在中间引入的一个新层。因此,在审查对 API 进行更改的代码时一定要小心,因为它可能会随着时间的推移,从一个原本优秀的 API 退化成一个糟糕的 API。
总结一下: 我们通过努力让整个系统(而不仅仅是我们的 API)变得更简单,以此鼓励 API 产生高可读性的代码。简单的事情依然应该易如反掌,而复杂的事情只要是合法的,就仍然应该是可行的。我们通过使用伪代码并对 API 进行原型设计,采用自上而下(或在必要时采用自下、自上而下)的设计方法来实现这一点。当发生更改时,我们要评估接口,以确保我们的修改不会把一个好的 API 变成坏的 API。
6. 优秀的 API 难以被错误使用¶
这乍听起来可能不是最相关的,但它绝对是至关重要的。如果你曾经有幸构建过被很多人使用的 API,你就会明白为什么。你见识过人们使用你 API 的代码。如果存在某些不清晰的交互,或者实际上暗示了与你意图相反的东西,那么就会出现大量的 bug 和问题,或者出现用于解决你根本没打算让他们遇到的情况的变通方法。这些重复出现的 bug 浪费了大量人的时间,而这绝对是不必要的。
那么,我们该如何做到这一点呢?如果你的应用程序中存在意料之外的行为(这几乎是肯定的),我们能否移除它?
这不一定意味着我们能否移除该功能本身,而是我们能否让它不再具有“出乎意料”的特性?也许这只是通过将其移动到不同的地方等方式来实现的。但如果那不可行,这里有一系列不断升级的防范级别(levels of escalation):
注释:显然,你可以写一段注释。但这通常是不够的,别指望人们会随便去读一段注释。如果底层代码存在某种复杂性,你想警告大家,这也许足够了,但它并不能阻止人们做一些愚蠢的事情。
参数名称:你知道,人们通常会看到它们并将它们输入到代码中。所以参数名能澄清这个东西是什么。但这可能仍不足够,人们可能不读参数名,或者只是复制粘贴代码,并在脑海中主观假设这个参数是什么。
函数或枚举值名称:这是下一级的防范,因为人们实际需要键入并看到它们。在这个层面上,你已经能把很多事情阐述得清楚得多。
使用带有显式构造函数或直接构建函数的类型:如果那还不够,如果你真的需要让人们停下来思考,最简单的方法通常是设计一种类型。这能澄清某种特定的交互。这通常是你为了让人们思考“为什么它是这个样子的?为什么这个整数要被一个类型包裹?为什么我不能直接传给它一个普通的整数?”所需要做的最严格的手段。
强制拼写出危险操作:其实还有最后一级防范手段,那就是基本上强制人们“打字声明(type out)”他们正在做的事情是很危险的。如果某些人为了执行某种必要操作而不得不打破抽象级别,并且你确实没有更简单的方法让他们去做,这有时是必要的。让他们手写出这种危险。但我通常会认为,到了那种地步,很可能已经存在某种更简单的方法来实现那个目的了。
话虽如此,鼓励在你的 API 中正确使用抽象层。那将从源头上防止无数个 bug 的发生。
让内部辅助工具(internal helpers)变得无法访问。 它们不是为其他人设计的,我们不希望他们使用。它们只是为你背后的一些实现细节准备的。如果你确实打算让其他人执行那些操作,那就通过某种方式将其添加到你的公共 API 中。但是允许别人使用你的内部工具绝对是个糟糕的主意。所以,尽可能让它们不可访问,或者让它们使用起来非常繁琐,亦或是从名字上让人一眼看出那是纯内部的东西。
最后,检查参数值和配置选项的有效性(validity)。让人们能尽早发现是他们的错误导致某事不起作用,而不是让你的 API 在系统深处、深处、最深处崩溃,那样用户甚至会认为是你的错而不是他们的错。这将会很难追踪,并浪费他们和你大量的时间。与其如此,不如直接干脆地告诉他们:这个配置参数是无效的。
7. 优秀的 API 没有令人讨厌的意外¶
我们前面已经提到了这一点:当人们采用了你的 API,别让他们后悔。
有些事情是属于系统工程级别的范畴,但如果它们含糊不清,你仍然应该澄清它们,以确保每个人都在一致地使用它们。
了解错误处理(error handling) 在你的 API 以及你的架构中是如何工作的。
了解内存分配(memory allocation)以及内存分配失败在你的 API 中是如何工作的。
适当地处理并检测边缘情况(edge cases)。比如,如果你接收一个
const char*指针,要考虑到这可能是一个 UTF-8 编码的字符串(译注:视频原词为 UDF8,实为 UTF-8 之误)。不要过度限制用户在过渡到你的 API 之后所能做的事情。如果人们有正当的理由想做某事,让他们去做。如果你的用户无法修改源码,或者他们缺乏清晰的沟通渠道,这点就尤为重要。
不要做虚假的承诺。 例如,“迭代”不应该仅仅意味着某个单一用户一次只能迭代一次,人们理应能创建多个协同工作的迭代器。所以,只有当你确实提供了所有必要的底层支持功能时,才去暴露迭代接口。
你的抽象层应该是兼容且一致的。 如果用户需要过渡到更低迷的层级,他们不应该为了做同样的事情而突然被迫开始使用截然不同的类型。
这里我要再次强烈警告:不要过度使用控制反转(Inversion of control)。它在瞬间打破所有分离的表面假象上表现得太过“出色”了,以至于我怎么警告人们(至少是警告他们不要过度使用)都不为过。
当涉及到这类事情时,从别人的错误中学习。你不需要亲自把雷都踩一遍。把你能想到的所有属于“讨厌的意外”的问题在脑海里画一张图。当你处于构建类似功能的位置时,在你的初始设计阶段就去解决它们。
好的,太棒了!这就是一个优秀 API 所包含的所有部分:
简化心智模型。
让人很容易理解能做什么。
提供你所需的功能。
易于理解如何去做。
鼓励编写高可读性的代码。
难以被错误使用。
没有后悔,没有令人讨厌的意外。
如何评估现有的 API¶
现在,你可能已经有大量的遗留代码了。我们刚刚讨论了关于如何重构代码、如何构建新代码和新 API 的策略。但是,我们该如何判断我们现有的 API 算是一个好的 API 还是一个糟糕的 API 呢?
我们可以从三个方面的证据来进行考察:
第一,你的架构和相应的心智模型。
第二,你的 API 以及它们的接口本身。
第三,你的 API 是如何被使用的,也就是客户端代码(client code)。
让我们首先来看看如何从心智模型和架构的角度来判断 API 的质量。
心智模型与架构层面:
你知道你的抽象层在哪里开始,又在哪里结束吗? 如果不知道,停下来,把它弄清楚。
它们的目的是什么? 如果你不能用一句话轻松地说出或解释每一个抽象层的目的,那你很可能有太多的抽象层,或者划分得非常糟糕。
跟踪依赖关系的流向是否很困难? 也就是说,当你只看文件名或类名时,是否很难判断它们在你的架构中属于什么位置?你可以通过保持一致的命名来解决这个问题。如果你真的觉得很难,那你可能过度分离了。
例如:Storage(存储)、Buffer(缓冲区)和 Data(数据)。它们的意思都差不多,但如果我们在架构中明智地分配它们——假设在图像管道(image pipeline)中,我们规定 Buffer 始终与文件和输入/输出相关联,而 Data 始终与图像(images)相关联。这样,尽管它们是意思非常相近的词,但当我们看到 Data 或 Buffer 这个词时,我们总能知道它们属于架构的哪个部分。
在你的架构中是否有很多类似的东西,以至于很难追踪每一个到底是做什么的以及它如何与其他的交互? 那你很可能同样存在分离过度的问题,你也许可以把一堆抽象层合并成一个或两个。
你是否有内部状态复杂、难以跟踪的对象? 这通常发生在许多 API 在同一个抽象层面上汇聚的时候。我通常会建议将它们分解为多个位于“中间层(in between)”的抽象,这些中间层负责处理 API 的相应部分;然后在你的实际实现中,只与这些中间层打交道,从而保持心智模型的简单。
当你知道某几个状态的值总是同时变化时,这招特别好用。你可以利用对象的生命周期来封装这一点。或者如果有许多函数操作相同的部分:比如你“分配(allocate)”一个图像,你“更新(update)”一个图像,你“销毁(destroy)”一个图像。你就会想:“等等,为什么这不直接就是一个 Image(图像)类呢?” 如果它是 Image 类,那么分配图像就是构造函数,销毁图像就是析构函数。我通过上下文就传达了这一切,没有任何额外的负担。然后我只需要一个 update 函数给它,这就非常简单了。这个 Image 对象不一定需要是你整个代码库里通用的宏大图像功能,它可能只是为了某个局部交互设计的一个内部辅助类型。
接下来,我们可以通过查看你的 API 代码来评估 API:
首先,当你查看这些代码时,我们应该问自己:你的目标受众是谁?(在大屏幕示例中,带有各种指针和配置项的代码显然更针对熟悉底层逻辑或需要广泛配置选项的人,而那种只需一行单纯调用的函数则针对那些“只是想播放个声音,完全不想看到任何配置选项”的受众)。
当你打开你的项目目录,看看目录结构时,找到人们实际正在寻找的功能有多容易? 如果我们的文件夹和文件名非常通用化,当人们没有按类别去找时,可能很难找到特定功能。但如果我们将文件夹按子类别命名妥当,并将文件名取得功能区分度很高,人们就更容易找到从哪里开始使用你的 API。
当你打开那些头文件时,它们易于导航吗?找到你真正要找的组件容易吗? 这仅仅是一个塞满了海量实现细节的巨大文件,以至于连一个对象从哪里开始到哪里结束都很难跟踪吗?还是说它有良好的文档记录,结构清晰,是专为那些将使用该 API 的人阅读而设计的?
事物是按它们“是什么”还是按它们“做什么”命名的? 比如
BackendHolderBase(后端持有者基类),除了告诉我它持有一个后端之外,没有告诉我任何关于它具体做什么是信息。这究竟是什么意思?也许这实际上只是一个“通知接收器(Notification recipient)”。使用常见的语言结构会简化你的 API 吗? 接收一个
const char*指针和一个int并不能告诉任何人这是什么。但如果我们把它改成一个string_view(字符串视图)和一个表示优先级的enum(枚举),这就立刻清晰多了,对吧?你的 API 在行为、类型和命名上具有一致性吗? 比如,当你从
read_file返回file_data时,当我调用write_file时,我也能将那个file_data传进去吗?或者也许你有很多不同的辅助对象,它们做的事情和其他辅助对象做的事情差不多,也许你可以统一它们,这样它们在整个架构中就是一致的。
最后,让我们评估使用你的 API 的人的代码(客户端代码):
这可能是公司内部的代码,如果你在使用某种其他级别的抽象,这也可能是你自己的代码,或者是外部代码。
是否有任何客户端代码规避了 API 边界或对象边界,或依赖于内部实现细节? 例如,用户为了推送一些随机字节去做某事,强行从
playback_engine(播放引擎)内部获取底层的decoder(解码器)对象。也许我们应该支持那种行为,如果那就是他们希望你做的,那就添加一个“跳过帧(skip frame)”的选项。也许通过重构你的代码就能满足需求。或者也许你无论如何都需要过渡到更底层的抽象,因为你本质上需要访问更底层的功能,而又不想将这些功能作为高层 API 的一部分暴露出来。如果你不想依赖那个中间层抽象的实现细节,你就基本上没有其他选择。
客户端代码在调用你的 API 时,是否在进行你未曾预料到的、令人费解的操作(jumping through unexpected hoops)? 如果是,那你可能可以使这种交互变得更简单,或至少让他们能够合法地做到这一点,而不需要各种“杂技动作”。
例如,用户看起来只是想将他们的 YUV 图像转换成黑白版本,但他们目前需要通过将每一个像素单独变成单色(monochrome)版本来实现。也许你只需要提供一个功能:如果你已经有一个黑白图像,我们允许你直接从 YUV 版本块传送(blit)过去;或者我们在图像类上提供一个执行转换的函数,单色化只是其中的一种转换选项。
如果看一眼竞争对手提供的类似功能的 API,人们是否经常执行某些你不提供支持的操作? 是否导致他们突然开始使用其他一些随机的功能来达成目标?如果是,也许你应该提供支持。
例如,似乎我们不支持在播放声音时设置音量。如果看看竞争对手的 API,他们显然会在构造函数中将音量与其他配置参数一起接收。也许我们可以调整我们的 API,在调用
play时接收一个默认为满音量的音量参数。
在查看这些用例时,只要思考:仅针对这一个具体场景,最佳的 API 应该是什么样子? 然后收集它们,看看是否有常见的模式,是否有一些人们普遍想要的功能。你可以为这些场景引入一些辅助工具,甚至引入一个新的抽象层来为他们完成这些工作。
但很显然,你不应该随意地用随机功能污染你的 API。比如,如果人们只是试图获取一个渐变效果,也许我们可以在图像类中添加一个仅仅用来初始化渐变的静态成员函数(如果这是一个非常常用的功能的话)。
最后,你是否经常遇到一些常见问题甚至 bug?
例如,我们的“图像大小”返回的其实是总像素数,而不是字节大小;但是通过“数据(data)”函数检索出的原始数据是一个标准字节数组。所以当每个像素的大小不是 1 个字节时,人们可能意识不到这一点,从而开始写入比总图像大小(字节)更少的数据。也就是说,人们理应采取的正确使用方式太违反直觉了。我们该如何修改我们的 API,让用户难以用这种错误的方式做事?如何让他们思考“我这样做对吗?”,或者甚至在他们脑海中产生用错误方式做事的想法之前,就阻止他们的脚步?
总结¶
总而言之,通过整个演讲我想表达的是:一致地构建优秀的 API 需要主动地去设计(active design)。它不是心血来潮就能凭空产生的。
了解你的目标受众是谁,以及他们正在寻找什么。否则,你将无法为他们量身打造 API。
问问你自己,你的对象或 API 是如何简化心智模型的?
自上而下地设计系统,请使用伪代码。API 不应该是底层实现细节的副产品。
根据事物能做什么(对用户来说它们代表什么)来命名事物,而不是根据它们是什么来命名。
鼓励简单和可读的客户端代码。由你来做复杂的那部分(而不是让用户去做),这就是构建 API 的全部意义所在。
一致性至关重要:体现在命名、方法、类型和行为上。
最后,消除意外的行为。消除一切不符合预期的东西。
太棒了。这就是我的演讲内容。非常感谢大家的聆听。我稍后会在 gather town(线上虚拟空间)和大家见面。保重。再见!