作为一名软件工程师超过25年,我仍然发现自己低估了完成特定编程任务所需的时间。 有时,由此导致的进度延误是由于我自身的缺点:当我深入研究一个问题时,我只是发现它比我最初想象的要困难得多,因此解决问题需要更长的时间——这就是程序员的生活。 同样,我经常确切地知道我想实现什么以及如何实现它,但它仍然比预期的要花费更长的时间。 当这种情况发生时,通常是因为我正在与一个API作斗争,这个API似乎竭尽全力在我的道路上投掷石块并使我的生活变得困难。 我发现令人惊讶的是,在软件工程进步了25年之后,这种情况仍然发生。 更糟糕的是,用现代编程语言实现的最新API犯了与二十年前用C语言编写的API相同的错误。 API设计似乎有一些难以捉摸的东西,尽管多年来取得了进步,但我们尚未掌握。
当我们使用优秀的API时,我们都能认出来。 优秀的API使用起来是一种乐趣。 它们工作起来没有摩擦,几乎消失在视野之外:特定工作的正确调用在恰当的时间可用,可以轻松找到和记住,文档齐全,界面使用直观,并且可以正确处理边界条件。
那么,为什么周围有这么多糟糕的API呢? 主要原因是,对于每一种正确设计API的方法,通常有几十种错误设计API的方法。 简而言之,创建糟糕的API非常容易,而创建优秀的API则相当困难。 即使是微小的、相当无辜的设计缺陷也往往会被不成比例地放大,因为API只提供一次,但会被调用很多次。 如果设计缺陷导致笨拙或低效的代码,那么由此产生的问题会在每次调用API时都出现。 此外,单独来看很小的独立设计缺陷可能会以令人惊讶的破坏性方式相互作用,并迅速导致大量的附带损害。
在继续之前,让我通过一个例子向您展示看似无害的设计选择如何产生深远的影响。 这个例子是我在日常工作中遇到的,很好地说明了糟糕设计的后果。 (实际上,几乎每个平台都可以找到数百个类似的例子;我的目的不是特别挑出.NET。)
图1显示了C#中.NET套接字Select()函数的接口。 该调用接受三个要监视的套接字列表:一个用于检查可读性的套接字列表,一个用于检查可写性的套接字列表,以及一个用于检查错误的套接字列表。 Select()的典型用途是在接受来自多个客户端的传入请求的服务器中; 服务器在一个循环中调用Select(),并在循环的每次迭代中,处理任何已准备好的套接字,然后再调用Select()。 这个循环看起来有点像图1中所示的循环。
第一个观察结果是Select()覆盖了它的参数:传递给调用的列表被替换为仅包含那些已准备好套接字的列表。 然而,通常情况下,要监视的套接字集合很少更改,最常见的情况是服务器在每次迭代中传递相同的列表。 因为Select()覆盖了它的参数,所以调用者必须在将每个列表传递给Select()之前复制一份。 这很不方便,并且不能很好地扩展:服务器经常需要监视数百个套接字,因此,在每次迭代中,代码都必须在调用Select()之前复制列表。 这样做的成本是相当大的。
第二个观察结果是,几乎总是,要监视错误的套接字列表只是要监视读取和写入的套接字的并集。 (调用者很少只想监视套接字的错误条件,而不监视其可读性或可写性。)如果服务器分别监视100个套接字用于读取和写入,则最终在每次迭代中复制300个列表元素:读取、写入和错误列表各100个。 如果监视读取的套接字与监视写入的套接字不相同,但在某些套接字上重叠,则构建错误列表会变得更加困难,因为需要避免将同一套接字多次放在错误列表上(或者如果接受此类重复项,则效率会更低)。
另一个观察结果是Select()接受以微秒为单位的超时值:如果在指定的超时时间内没有套接字准备就绪,Select()将返回。 但是请注意,该函数具有void返回类型——也就是说,它不会在返回时指示是否有任何套接字准备就绪。 为了确定是否有任何套接字准备就绪,调用者必须测试所有三个列表的长度; 只有当所有三个列表的长度都为零时,才表示没有套接字准备就绪。 如果调用者碰巧对这种情况感兴趣,则必须编写一个相当笨拙的测试。 更糟糕的是,如果Select()超时且没有套接字准备就绪,它会覆盖调用者的参数:即使什么都没发生,调用者也需要在每次迭代中复制三个列表!
.NET 1.1中Select()的文档对超时参数的描述是:“等待响应的时间,以微秒为单位。” 它没有进一步解释此参数的含义。 当然,问题立即出现,“我如何无限期地等待?” 看到.NET Select()只是底层Win32 API的一个薄包装器,调用者可能会认为负超时值表示Select()应该永远等待。 然而,一个快速实验证实,任何等于或小于零的超时值都被理解为“如果没有套接字准备就绪,则立即返回”。 (此问题已在.NET 2.0版本的Select()中修复。)要“永远”等待,调用者可以做的最好的事情是传递Int.MaxValue (231-1)。 结果证明这略超过35分钟,这远非“永远”。 此外,如果需要超时时间不是无限的,而是超过35分钟,应该如何使用Select()?
当我第一次遇到这个问题时,我想,“嗯,这很不幸,但没什么大不了的。 我只需为Select()编写一个包装器,如果它在35分钟后超时,则透明地重新启动调用。 然后我将代码中对Select()的所有调用更改为调用该包装器代替。”
因此,让我们看一下创建这个即插即用替代品,称为doSelect(),如图2所示。 调用的签名(原型)与普通Select()相同,但我们希望确保负超时值导致它永远等待,并且可以等待超过35分钟。 对于超时使用毫秒的粒度允许超时略多于24天,我认为这已经足够了。
请注意图2中代码中do循环的终止条件:有必要检查所有三个列表的长度,因为Select()不指示它是由于超时还是由于套接字已准备好而返回。 此外,如果调用者对使用三个列表中的一个或两个不感兴趣,则可以传递null或空列表。 这迫使代码使用笨拙的测试来控制循环,因为当Select()返回时,三个列表中的一个或两个可能为null(如果调用者传递了null)或可能不为null,但为空。
这里的问题是,对于同一件事有两个合法的参数值:null和空列表都表示调用者对监视传递的列表之一不感兴趣。 就其本身而言,这没什么大不了的,但是,如果我想像前面的代码中那样重用Select(),则会非常不方便。
代码的第二部分,处理重新启动Select()以处理超过35分钟的超时,也变得相当复杂,这既是因为需要笨拙的测试来检测是否确实发生了超时,也是因为需要处理毫秒 * 1000 不能整除 Int.MaxValue 而不留下余数的情况。
我们还没有完成:前面的代码仍然包含注释,以代替复制输入参数并将结果复制回这些参数。 人们会认为这很容易:只需调用Clone()方法,就像在Java中一样。 然而,与Java不同,.NET的Object类型(它是所有类型的最终基类型)不提供Clone方法; 相反,为了使类型可克隆,它必须显式派生自ICloneable接口。 传递给Select()的列表的形式参数类型是IList,它是一个接口,因此是抽象的:我不能实例化IList类型的对象,只能实例化派生自IList的对象。 问题是IList不派生自ICloneable,因此除了显式迭代列表内容并逐个元素地完成工作之外,没有方便的方法来复制IList。 同样,IList上没有方法可以使其容易被另一个列表的内容覆盖(这对于在doSelect()返回之前将结果复制回参数是必要的)。 同样,实现此目的的唯一方法是迭代并一次复制一个元素。
Select()的另一个问题是它接受套接字列表。 列表允许同一个套接字在每个列表中出现多次,但这样做没有意义:从概念上讲,传递的是套接字集合。 那么,为什么Select()使用列表呢? 答案很简单:.NET集合类不包含集合抽象。 使用IList来建模集合是不幸的:它会产生一个语义问题,因为列表允许重复项。 (Select()在存在重复项时的行为是任何人都可以猜测的,因为它没有文档记录; 根据实现的实际行为进行检查并不是很有用,因为在没有文档的情况下,行为可能会在没有警告的情况下更改。) 使用IList来建模集合在其他方面也是有害的:当连接关闭时,服务器必须从其列表中删除相应的套接字。 这样做需要服务器执行线性搜索(这不能很好地扩展)或以排序顺序维护列表,以便它可以使用拆分搜索(这需要更多工作)。 这是一个很好的例子,说明设计缺陷如何倾向于传播并造成附带损害:一个API中的疏忽会导致不相关的API中的问题。
我将省略有关如何完成包装器代码的详细信息。 可以说,我着手编写的这个据称简单的包装器,到我添加参数复制、错误处理和一些注释时,已经达到了近100行相当复杂的代码。 所有这一切都是因为一些看似微小的设计缺陷
以下是Select()可以改为的样子
public static int
Select(ISet checkRead, ISet checkWrite,
Timespan seconds, out ISet readable, out ISet writeable,
out ISet error);
在这个版本中,调用者提供集合来监视用于读取和写入的套接字,但没有错误集合:读取集合和写入集合中的套接字都会自动监视错误。 超时以Timespan(.NET提供的一种类型)的形式提供,分辨率低至100纳秒,范围超过1000万天,并且可以为负数(或null)以覆盖“永远等待”的情况。 此版本不会覆盖其参数,而是将准备好读取、写入和遇到错误的套接字作为单独的集合返回,并返回准备就绪的套接字数量或零,在这种情况下,调用返回是因为已达到超时。 通过这种简单的更改,可用性问题消失了,并且由于调用者不再需要复制参数,因此代码也更加高效。
还有许多其他方法可以解决Select()的问题(例如epoll()使用的方法)。 本示例的重点不是提出Select()的最终版本,而是演示少量微小的疏忽如何迅速累积起来,从而创建混乱、难以维护、容易出错且效率低下的代码。 如果Select()的接口稍微好一点,那么我在这里概述的所有代码都是不必要的,我(以及可能许多其他程序员)将节省大量时间和精力。
糟糕的API设计的后果是 многочисленные 且严重的。 糟糕的API很难使用编程,并且经常需要编写额外的代码,就像前面的例子一样。 如果没有其他原因,这些额外的代码会使程序更大且效率更低,因为每一行不必要的代码都会增加工作集大小并减少CPU缓存命中率。 此外,正如前面的例子一样,糟糕的设计可能会通过强制不必要的数据复制而导致本质上效率低下的代码。 (另一个流行的设计缺陷——即,为预期结果抛出异常——也会导致效率低下,因为捕获和处理异常几乎总是比测试返回值慢。)
然而,糟糕API的影响远远超出了效率低下的代码:糟糕的API比优秀的API更难理解和更难使用。 换句话说,程序员使用糟糕的API编写代码的时间比使用优秀的API编写代码的时间更长,因此糟糕的API直接导致开发成本增加。 糟糕的API通常不仅需要额外的代码,还需要更复杂的代码,这提供了更多隐藏错误的地方。 成本是增加了测试工作量,并且增加了错误在软件部署到现场之前未被检测到的可能性,而此时修复错误的成本最高。
软件开发的大部分内容是创建抽象,而API是这些抽象的可见接口。 抽象降低了复杂性,因为它们抛弃了不相关的细节,只保留了特定工作所需的信息。 抽象不是孤立存在的; 相反,我们将抽象层层叠加在一起。 应用程序代码调用更高级别的库,而这些库反过来通常通过调用由更低级别的库提供的服务来实现,而更低级别的库反过来又调用由操作系统系统调用接口提供的服务。 这种抽象层次结构是一个极其强大且有用的概念。 没有它,我们所知的软件就不可能存在,因为程序员会被复杂性完全淹没。
API缺陷在抽象层次结构中发生的级别越低,后果就越严重。 如果我在自己的代码中错误地设计了一个函数,唯一受到影响的人是我自己,因为我是该函数的唯一调用者。 如果我在我们的一个项目库中错误地设计了一个函数,可能会影响我所有的同事。 如果我在一个广泛发布的库中错误地设计了一个函数,可能会影响数万名程序员。
当然,最终用户也会遭受损失。 痛苦可能有很多种形式,但累积成本总是很高。 例如,如果Microsoft Word包含一个错误,该错误由于API设计不当而导致偶尔崩溃,则成千上万或数十万的最终用户会损失宝贵的时间。 同样,考虑无数应用程序和系统软件中的众多安全漏洞,这些漏洞最终是由标准C库中不安全的I/O和字符串操作函数(例如scanf()和strcpy())引起的。 这些设计不良的API的影响在我们创建它们30多年后仍然存在,并且设计缺陷的累积成本很容易达到数百亿美元。
奇怪的是,抽象分层经常被用来淡化糟糕API的影响:“没关系——我们可以编写一个包装器来隐藏问题。” 这种说法再错误不过了,因为它忽略了这样做的成本。 首先,即使是最有效的包装器也会增加一些内存和执行速度方面的成本(并且包装器通常远非高效)。 其次,对于广泛使用的API,包装器将被编写数千次,而首先正确地获得API只需要做一次。 第三,通常情况下,包装器会创建自己的一系列问题:.NET Select()函数是围绕底层C函数的包装器; .NET版本首先未能修复原始版本的糟糕接口,然后通过省略返回值、超时错误以及传递列表而不是集合来添加自己的问题。 因此,虽然创建包装器可以帮助使糟糕的API更易于使用,但这并不意味着糟糕的API无关紧要:两个错误不会变成正确,不必要的包装器只会导致臃肿软件。
在设计API时,可以使用一些指导原则。 这些不是保证成功的万无一失的方法,但了解这些指导原则并在设计过程中明确考虑它们,会使结果更有可能变得可用。 该列表必然是不完整的——要充分说明这个主题,需要一本厚厚的书。 尽管如此,以下是我在创建API时喜欢考虑的一些事项。
API必须为调用者提供足够的功能以完成其任务。 这似乎很明显:功能不足的API是不完整的。 然而,正如Select()无法等待超过35分钟所说明的那样,这种不足可能会被忽略。 在设计过程中检查功能清单并询问“我遗漏了什么吗?”是值得的。
API应该尽可能简洁,同时又不会给调用者带来不必要的麻烦。 此指导原则只是说“越小越好”。 API使用的类型、函数和参数越少,就越容易学习、记住和正确使用。 这种极简主义很重要。 许多API最终成为便利函数的集合,这些便利函数可以由其他更基本的功能组成。 (C++标准字符串类及其100多个成员函数就是一个例子。 在C++编程多年之后,我仍然发现自己无法在不查阅手册的情况下将标准字符串用于任何重要的用途。) 此指导原则的限定条件,即不给调用者带来不必要的麻烦,非常重要,因为它引起了人们对实际用例的关注。 为了设计好API,设计者必须了解API将要使用的环境,并针对该环境进行设计。 是否提供非基本的便利函数取决于设计者预计该函数将被需要的频率。 如果该函数将被频繁使用,则值得添加; 如果它只是偶尔使用,则增加的复杂性不太可能值得罕见的便利性提升。
Unix内核使用wait()、waitpid()、wait3()和wait4()违反了此指导原则。 wait4()函数是足够的,因为它可以用于实现前三个函数的功能。 还有waitid(),它几乎可以用wait4()来实现,但并不完全可以。 调用者必须阅读所有五个函数的文档,才能弄清楚要使用哪个函数。 如果调用者使用单个组合函数,则会更简单更容易。 这也是一个例子,说明了对向后兼容性的担忧如何随着时间的推移侵蚀API:API积累了污垢,最终,与通过保持向后兼容性所做的任何好处相比,它造成的损害更大。 (而且,设计上的丑陋历史依然历历在目。)
在不了解其上下文的情况下,无法设计API。 考虑一个提供对一组字符串的名称-值对的访问的类,例如环境变量
class NVPairs {
public string lookup(string name);
// ...
}
查找方法提供对命名变量存储的值的访问。 显然,如果设置了具有给定名称的变量,则该函数将返回其值。 如果未设置变量,该函数应如何表现? 有几个选项
如果设计者预计查找不存在的变量不是常见情况,并且可能表明调用者会将其视为错误,则抛出异常是合适的。 如果是这样,则抛出异常是完全正确的,因为异常会强制调用者处理错误。 另一方面,调用者可能会查找一个变量,如果未设置该变量,则替换为默认值。 如果是这样,则抛出异常是完全错误的,因为处理异常会中断正常的控制流,并且比测试 null 或空返回值更困难。
假设我们决定在未设置变量时不抛出异常,则有两个明显的选择表明查找失败:返回 null 或空字符串。 哪个是正确的? 同样,答案取决于预期的用例。 返回 null 允许调用者区分根本未设置的变量和设置为空字符串的变量,而为未设置的变量返回空字符串则使区分从未设置的变量与显式设置为空字符串的变量变得不可能。 如果认为能够进行这种区分很重要,则返回 null 是必要的; 但是,如果这种区分不重要,则最好返回空字符串,并且永远不要返回 null。
通用API应该是“无策略的”; 特殊用途的API应该是“策略丰富的”。 在前面的指导原则中,我提到API的正确设计取决于其上下文。 这导致了一个更根本的设计问题——即API不可避免地会规定策略:只有当调用者对API的使用与设计者预期的用例一致时,API才能最佳地执行。 相反,API的设计者不可避免地会向调用者规定一组特定的语义和一种特定的编程风格。 设计者必须意识到这一点:API设置策略的程度对其可用性具有深远的影响。
如果对API将要使用的上下文知之甚少,则设计者别无选择,只能保持所有选项都打开,并使API尽可能广泛地适用。 在前面的查找示例中,这要求为未设置的变量返回 null,因为该选择允许调用者在API之上分层自己的策略; 通过几行额外的代码,调用者可以将查找不存在的变量视为硬错误,替换为默认值,或将未设置和空变量视为等效。 然而,对于那些不需要灵活性的调用者来说,这种通用性是有代价的,因为它使调用者更难以将查找不存在的变量视为错误。
这种设计张力几乎存在于每个API中——应该成为错误和不应该成为错误之间的界限非常细微,并且错误地放置界限会很快引起重大痛苦。 对API的上下文了解得越多,API就越“专制”——也就是说,它可以设置的策略就越多。 这样做是对调用者的一种帮助,因为它捕获了原本会被忽略的错误。 通过仔细设计类型和参数,通常可以在编译时捕获错误,而不是延迟到运行时。 努力这样做是值得的,因为在编译时捕获的每个错误都减少了一个可能在测试或现场产生额外成本的错误。
Select() API未能遵循此指导原则,因为它通过覆盖其参数,设置了与最常见的用例直接冲突的策略。 同样,.NET Receive() API对非阻塞套接字犯下了这种罪行:如果调用工作但没有数据准备就绪,则抛出异常,如果连接丢失,则返回零而不抛出异常。 这与调用者需要的完全相反,并且令人清醒地看到这给调用者造成的控制流混乱。
有时,尽管设计者尽了最大努力,但设计张力仍然无法解决。 当对上下文知之甚少时,通常会出现这种情况,因为API是低级的,或者本质上必须在许多不同的上下文中工作(例如,通用集合类就是这种情况)。 在这种情况下,策略模式通常可以很好地使用。 它允许调用者提供策略(例如,以调用者提供的比较函数的形式,该函数用于维护有序集合),从而保持设计的开放性。 根据编程语言的不同,调用者提供的策略可以使用回调、虚函数、委托或模板参数(等等)来实现。 如果API提供合理的默认值,则此类外部化策略可以带来更大的灵活性,而不会损害可用性和清晰度。 (但是请注意,不要像本文后面描述的那样“推卸责任”。)
API的设计应从调用者的角度出发。 当程序员被赋予创建API的工作时,他或她通常会立即进入解决问题的模式:这项工作需要什么数据结构和算法?完成这项工作需要哪些输入和输出参数? 从那时起,一切都变得糟糕:实现者专注于解决问题,而调用者的担忧很快就被遗忘了。 这是一个典型的例子
makeTV(false, true);
这显然是一个创建电视的函数调用。 但是参数的含义是什么? 与以下内容进行比较
makeTV(Color, FlatScreen);
第二个版本对于调用者来说更具可读性:即使不阅读手册,也很明显该调用创建了一台彩色平板电视。 然而,对于实现者来说,第一个版本同样可用
void makeTV(bool isBlackAndWhite,
bool isFlatScreen)
{ /* ... */ }
实现者获得了命名良好的变量,这些变量指示电视是黑白还是彩色,以及它是否具有平板屏幕还是传统屏幕,但是这些信息对于调用者来说会丢失。 第二个版本要求实现者做更多的工作——即添加枚举定义并更改函数签名
enum ColorType { Color, BlackAndWhite };
enum ScreenType { CRT, FlatScreen };
void makeTV(ColorType col, ScreenType st);
这种替代定义要求实现者从调用者的角度考虑问题。 然而,实现者专注于创建电视,因此实现者的脑海中几乎没有空间担心别人的问题。
获得可用API的一个好方法是让客户(即调用者)编写函数签名,并将该签名交给程序员来实现。 仅此一步就消除了至少一半的糟糕API:API的实现者常常从不使用自己的作品,这对可用性造成了灾难性的后果。 此外,API不是关于编程、数据结构或算法——API是用户界面,就像GUI一样。 API使用端的用户是程序员——即人类。 尽管我们倾向于将API视为机器接口,但它们不是:它们是人机接口。
驱动API设计的不应该是实现者的需求。 毕竟,实现者只需要实现一次API,但是API的调用者需要调用它数百或数千次。 这意味着优秀的API的设计考虑了调用者的需求,即使这会使实现者的工作更加复杂。
优秀的API不会推卸责任。 在设计API时,有很多方法可以“推卸责任”。 最喜欢的方法是害怕设置策略:“嗯,调用者可能想这样做或那样做,我不确定是哪个,所以我将其设置为可配置的。” 这种方法的典型结果是具有五个或十个参数的函数。 由于设计者没有胆量设置策略并明确API应该做什么和不应该做什么,因此API最终变得比必要的复杂得多。 这种方法也违反了极简主义和“我不应该为我不使用的东西付费”的原则:如果一个函数有十个参数,其中五个与大多数用例无关,则调用者每次调用时都要付出提供十个参数的代价,即使他们可能根本不在乎额外的五个参数提供的功能。 优秀的API清楚地表明它想要实现什么以及它不想要实现什么,并且不怕直言不讳。 由此产生的简单性通常充分弥补了功能的少量损失,特别是如果API具有精心选择的基本操作,这些操作可以轻松地组合成更复杂的操作。
另一种推卸责任的方式是在效率祭坛上牺牲可用性。 例如,CORBA C++ 映射要求调用者一丝不苟地跟踪内存分配和释放责任; 结果是一个API,它使腐败内存变得异常容易。 在对映射进行基准测试时,发现它非常快,因为它避免了许多内存分配和释放。 然而,性能提升是一种错觉,因为API没有做脏活,而是让调用者负责做脏活——总的来说,无论如何都会发生相同数量的内存分配。 换句话说,可以提供零运行时开销的更安全的API。 通过仅对API内部完成的工作(而不是调用者和API完成的总体工作)进行基准测试,设计者可以声称创建了性能更好的API,即使性能优势仅归因于选择性核算。
原始C版本的select()也表现出相同的方法
int select(int nfds, fd_set *readfds,
fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
与 .NET 版本一样,C 版本也会覆盖其参数。这再次反映了实现者的需求而非调用者的需求:覆盖参数比分配单独的文件描述符输出数组更简单高效,并且避免了再次释放输出数组的问题。然而,所有这些实际上只是将负担从实现者转移到调用者身上——净效率收益为零。
Unix 内核也并非完美无瑕,偶尔也会推卸责任:许多程序员都曾诅咒允许某些系统调用被中断的决定,这迫使程序员必须显式地处理 EINTR 并手动重启被中断的系统调用,而不是让内核透明地完成这些。
推卸责任可以采取多种不同的形式,具体细节因 API 而异。设计者的关键问题是:对于调用者,有没有我应该做但没有合理理由不做的事情? 明确地提出这些问题可以使设计成为有意识过程的结果,并避免“意外设计”。
API 应该在实现之前进行文档化。API 文档编写的一个大问题是,它通常是在 API 实现之后编写的,而且通常是由实现者编写的。然而,实现者在心理上会受到实现的影响,并且倾向于简单地写下他或她所做的事情。这通常会导致文档不完整,因为实现者对 API 太熟悉了,并假设有些事情是显而易见的,但实际上并非如此。更糟糕的是,这通常会导致 API 完全忽略重要的用例。另一方面,如果调用者(而不是实现者)编写文档,则调用者可以从“这就是我需要的”的角度来处理问题,而不受实现问题的困扰。这使得 API 更有可能满足调用者的需求,并在第一时间防止许多设计缺陷的产生。
当然,调用者可能会要求一些从实现角度来看不合理的东西。然后,调用者和实现者可以迭代设计,直到他们达成一致。这样,调用者和实现方面的考虑都不会被忽视。
一旦文档化和实现,API 应该由不熟悉它的人进行试用。最初,这个人应该检查在不查看文档的情况下可以理解多少 API。如果一个 API 可以在没有文档的情况下使用,那么它很可能是一个好的 API:自文档化的 API 是最好的 API。在试用 API 及其文档时,用户很可能会提出重要的“假设”问题:如果第三个参数为空会怎么样?这合法吗?如果我想无限期地等待套接字准备就绪怎么办?我可以这样做吗?这些问题通常会指出设计缺陷,而与文档的交叉检查将确认问题是否有答案,以及答案是否合理。
确保文档是完整的,尤其是在错误行为方面。当事情出错时,API 的行为与事情正常运行时一样,都是正式合同的一部分。文档是否说明 API 是否维护了强异常保证?它是否详细说明了发生错误时 out 和 in-out 参数的状态?它是否详细说明了错误发生后可能存在的任何副作用?它是否提供了足够的信息供调用者理解错误?(从所有套接字操作中抛出一个 DidntWork 异常是行不通的!)程序员确实需要知道 API 在出现问题时的行为方式,并且他们确实需要获得他们可以以编程方式处理的详细错误信息。(人类可读的错误消息对于诊断和调试来说很好,但如果它们是唯一可用的东西就不好——没有什么比必须为错误字符串编写解析器以便我可以控制我的程序流程更糟糕的了。)
单元测试和系统测试也会对 API 产生影响,因为它们可以暴露早期没有人想到的事情。测试结果可以帮助改进文档,从而改进 API。(是的,文档是 API 的一部分。)
编写文档最糟糕的人是实现者,编写文档最糟糕的时间是在实现之后。这样做会大大增加接口、实现和文档都存在问题的可能性。
好的 API 是符合人体工程学的。 人体工程学本身就是一个主要的学科领域,并且可能是 API 设计中最难确定的部分之一。关于这个主题已经有很多文章,以风格指南的形式定义了命名约定、代码布局、文档风格等等。然而,除了单纯的风格问题之外,实现良好的人体工程学是困难的,因为它引发了复杂的认知和心理问题。程序员是人,不是用饼干切割器创造出来的,所以一个程序员觉得不错的 API 可能另一个程序员会觉得一般般。
特别是对于大型和复杂的 API,人体工程学的一个主要部分与一致性有关。例如,如果 API 的函数总是将特定类型的参数放在相同的顺序中,则 API 更容易使用。同样,如果 API 建立命名主题,将相关函数与特定的命名风格组合在一起,则 API 更容易使用。对于为相关任务建立简单且统一的约定以及使用统一的错误处理的 API 也是如此。
一致性很重要,因为它不仅使事物更易于使用和记忆,而且还实现了学习的迁移:在学习了 API 的一部分之后,调用者也学习了 API 的大部分其余部分,因此体验到的摩擦最小。迁移不仅在 API 内部很重要,而且在 API 之间也很重要——API 可以从彼此采用的概念越多,掌握所有这些概念就越容易。(Unix 标准 I/O 库在许多地方违反了这个想法。例如,read() 和 write() 系统调用将文件描述符放在最前面,但是标准库 I/O 调用,例如 fgets() 和 fputs(),将流指针放在最后,除了 fscanf() 和 fprintf(),它们将流指针放在最前面。这种缺乏并行性让很多人感到不适。)
良好的人体工程学和使 API “感觉”正确需要大量的专业知识,因为设计者必须权衡众多且经常冲突的需求。在这些需求之间找到正确的权衡是良好设计的标志。
我确信在 API 设计方面可以做得更好。除了细枝末节的技术问题之外,我认为我们需要解决一些文化问题,才能掌控 API 问题。我们需要的不仅是技术智慧,还需要改变我们教授和实践软件工程的方式。
回到 70 年代末和 80 年代初,当我还是一个刚入门的程序员并在攻读学位时,对一个有抱负的程序员的教育重点主要放在数据结构和算法上。它们是编程的面包和黄油,对列表、平衡树和哈希表等数据结构的良好理解至关重要,对常用算法及其性能权衡的良好理解也是如此。这些日子也是系统库仅提供最基本功能的日子,例如简单的 I/O 和字符串操作; bsearch() 和 qsort() 等更高级别的函数是例外而不是规则。这意味着,对于一个合格的程序员来说,知道如何编写各种数据结构并有效地操作它们是理所当然的。
自那时以来,我们已经取得了长足的进步。实际上,今天每个主要的开发平台都配备了包含预制数据结构和算法的库。事实上,这些天,如果我发现一个程序员在编写链表,这个人最好有一个非常好的理由这样做,而不是使用系统库提供的实现。
同样,在 70 年代和 80 年代,如果我想创建软件,我必须几乎从头开始编写所有内容:如果我需要加密,我就从头开始编写;如果我需要压缩,我就从头开始编写;如果我需要进程间通信,我就从头开始编写。随着开源运动,这一切都发生了巨大的变化。今天,几乎每种可以想象的可重用功能都有开源可用。因此,创建软件的过程发生了很大的变化:今天的软件工程不再是创建功能,而更多的是关于集成现有功能或以某种方式重新打包它。换句话说:今天的 API 设计比 20 年前重要得多,不仅因为我们正在设计更多的 API,还因为这些 API 倾向于提供对以前更丰富和更复杂的功能的访问。
纵观许多大学的课程,似乎这种重点的转移在很大程度上被忽视了。在我读本科的时候,从来没有人费心解释如何决定某件事应该是返回值还是输出参数,如何在引发异常和返回错误代码之间做出选择,或者如何决定函数修改其参数是否合适。自那时以来,似乎没有什么改变:我的儿子目前正在我获得学位的同一所大学攻读软件工程学位,他告诉我仍然没有人费心解释这些事情。难怪我们看到如此多设计糟糕的 API:期望程序员擅长他们从未被教导过的东西是不合理的。
然而,良好的 API 设计,即使很复杂,也是可以教授的。如果本科生可以学习如何编写哈希表,他们也可以学习何时适合抛出异常而不是返回错误代码,并且他们可以学会区分糟糕的 API 和好的 API。我们需要的是认识到这个主题的重要性;大部分研究和智慧已经存在——我们所需要做的就是将它们传递下去。
我 47 岁,我还在编写代码。环顾四周,我意识到这是多么不寻常:在我的公司里,我所有的编程同事都比我年轻,当我看着以前的编程同事时,他们中的大多数人都不再编写代码了;相反,他们已经转向不同的职位(例如项目经理)或完全离开了这个行业。我在软件行业的各个地方都看到了这种趋势:年长的程序员很少见,通常是因为在他们达到某个年龄之后就没有职业发展道路了。我记得我曾经费了多大的力气才抵制住一家前公司强迫我“晋升”到管理职位的压力——我最终还是留在了程序员的岗位上,但被告知如果我不愿意转向管理岗位,未来的加薪基本上是不可能的。
还有一种观点认为,年长的程序员会“失去优势”,不再胜任工作。在我看来,这种观点是错误的:年长的程序员可能不会像年轻程序员那样熬夜加班,但这并不是因为他们老了,而是因为他们无需熬夜就能完成工作。
年长程序员的流失是不幸的,尤其是在 API 设计方面。虽然良好的 API 设计可以学习,但经验是无法替代的。许多优秀的 API 是由那些不得不忍受糟糕的 API,然后决定重新完成工作,但这次做得更好的人员创建的。需要时间和健康的“一朝被蛇咬,十年怕井绳”的心态来积累必要的专业知识,以便做得更好。不幸的是,行业趋势是将经验最丰富的人员从编程岗位上调离,而这恰恰是他们可以将积累的专业知识用于实际用途的时候。
另一个趋势是公司将他们最优秀的程序员晋升为设计师或系统架构师。通常,这些程序员被外派到各个项目中担任顾问,目的是确保项目走上正确的轨道,并避免在没有顾问智慧的情况下可能犯的错误。这种做法的意图是值得称赞的,但结果通常令人清醒:由于顾问非常宝贵,在给出他们的建议后,他们在实现完成之前很久就被调到下一个项目,更不用说测试和交付了。当顾问离开后,他们早期明智的建议中的任何问题都不再是他们的问题,而是他们早已离开的项目的问题。换句话说,顾问们永远无法体验到他们自己的设计决策带来的后果,这正是让他们变得无能的完美方式。保持设计师敏锐和诚实的方法是让他们吃自己的狗粮。任何剥夺设计师这种反馈的过程最终都注定要失败。
多年前,我正在参与一个大型开发项目,由于合同原因,该项目在交付截止日期前不久的关键阶段被迫进行操作系统升级。升级后,以前工作正常的系统开始表现异常,偶尔会产生随机且无法解释的故障。追踪问题原因的过程花费了近两天时间,在此期间,一大群程序员基本上是在无所事事。最终,原因被证明是 awk 的 index() 函数的行为发生了变化。一旦我们确定了问题,修复就变得微不足道——我们只是安装了以前版本的 awk。关键是,API 的一个小部分的语义发生了一个微小的变化,就给项目造成了数千美元的损失,而这个变化是一个程序员修复一个不相关的错误造成的副作用。
这个轶事暗示了一个我们在未来将不得不面对的问题。随着计算重要性的日益提高,有些 API 的正确运行几乎重要到无法形容。例如,考虑一下 Unix 系统调用接口、C 库、Win32 或 OpenSSL 等 API 的重要性。对这些 API 的接口或语义的任何更改都会带来巨大的经济成本,并可能引入漏洞。允许一家公司(更不用说一个开发人员)在没有外部控制的情况下更改如此关键的 API 是不负责任的。
作为一个类比,建筑承包商不能简单地尝试一种新的混凝土混合物,看看它的性能如何。要使用一种新的混凝土混合物,必须遵循漫长的测试和审批流程,并且不遵循该流程会受到刑事处罚。至少对于任务关键型 API,类似的流程是必要的,作为一种自卫手段:如果世界经济的很大一部分取决于某些 API 的安全性和正确运行,那么对这些 API 的任何更改都应该受到仔细监控,这是合乎情理的。
这种控制是否应该采取立法和刑事处罚的形式是值得商榷的。立法可能会引入一套全新的问题,例如扼杀创新和使软件更昂贵。(微软和欧盟之间正在进行的法律战就是一个例子。)我看到了这种情况发生的真正危险。到目前为止,我们一直很幸运,蠕虫病毒等恶意软件造成的损害相对较小。我们不会永远幸运:第一个利用 API 漏洞擦除全球超过 10% 的 PC 的蠕虫病毒将造成如此大规模的经济和人员损害,以至于立法者将被迫采取行动。如果这种情况发生,我们可能会用另一组更糟糕的问题来换取一组问题。
立法的替代方案是什么?开源社区多年来已经指明了方向:对 API 和实现的公开同行评审已被证明是一种非常有效的方法,可以找出设计缺陷、效率低下和安全漏洞。这个过程避免了与立法相关的问题,在 API 广泛使用之前就发现了许多缺陷,并使在发现零日漏洞时更有可能及时修复并分发补丁。
在未来,我们可能会看到更严格的立法控制和更开放的同行评审相结合。在这两者之间找到正确的平衡对于计算和我们经济的未来至关重要。API 设计确实很重要——但我们最好在事态失控并消除任何选择之前意识到这一点。
最初发表于 Queue vol. 5, no. 4——
在 数字图书馆 中评论本文
Luigi Rizzo - 重新审视网络 I/O API:netmap 框架
如今,万兆接口在数据中心和服务器中被越来越广泛地使用。在这些链路上,数据包的流速高达每 67.2 纳秒一个,但现代操作系统仅将一个数据包在网线和应用程序之间移动就需要 10-20 倍的时间。我们可以做得更好,不是通过更强大的硬件,而是通过修改很久以前关于设备驱动程序和网络堆栈设计的架构决策。