当前位置: 欣欣网 > 码农

软件开发中的抽象泄露法则

2024-04-28码农

在这篇 2002 的文章中,Joel Spolsky,StackOverflow 的联合创始人,探讨了抽象泄漏的法则。他指出,许多开发工具致力于通过抽象化简化我们的工作流程,意在隐藏背后的复杂性。

然而,尽管抽象旨在遮掩底层的复杂性,实际应用中经常会暴露这些复杂性。这主要是因为抽象本身固有的复杂性以及在具体实施过程中遇到的多样问题。Spolsky 强调,虽然抽象可以节省我们的工作时间,但它并不减少必须投入的学习时间。

01

互联网工程中的抽象

互联网工程中有一部分神奇的机制,你每天都在依赖它。这种机制发生在 TCP (Transmission Control Protocol) 协议 中,这是构建互联网的基础之一。

TCP 提供了一种可靠的数据传输方式。这意味着:如果你通过网络使用 TCP 发送消息,它将被准确无误地送达。我们依靠 TCP 来完成许多任务,比如获取网页和发送电子邮件。正是因为 TCP 的可靠性,每封电子邮件都能完美无缺地到达,即便它只是一些无聊的垃圾邮件。

相较之下,还有一种叫做 IP (Internet Protocol) 的数据传输方法,它是不可靠的。没有人保证你的数据一定会到达,它可能在途中被扰乱。如果你通过 IP 发送一系列消息,不要惊讶于只有一半能到达,有些可能顺序错乱,有些可能被替换成其他内容,可能包含可爱的小猩猩图片的消息,或者更可能是一堆类似你收到的外文垃圾邮件的不可读内容。

这里有一个神奇的地方:TCP 是建立在 IP 之上的。也就是说,TCP 需要依赖一个本质上不可靠的工具来实现可靠的数据传输。

想象我们有一种将演员从百老汇送到好莱坞的方法,通过把他们放在汽车里驾驶穿越美国。有些汽车事故导致了演员的不幸身亡。有时演员们路上喝醉酒,剃光头或在鼻子上纹身,变得无法在好莱坞工作。而且,因为他们选择了不同的路线,演员们常常不按原计划的顺序到达。

现在想象有一个名为 好莱坞快车 的新服务,它承诺送达演员时,他们会:(a)安全抵达(b)保持正确的顺序(c)状态完好。

神奇之处在于,好莱坞快车没有比用汽车驾驶横穿全国更可靠的传送方法。而是通过确认每位演员都完好无损地到达,如果有问题,就联系总部派出演员的同卵双胞胎。

如果演员顺序错乱,好莱坞快车将重新排序。如果一个巨大的 UFO 在内华达的某条高速公路上坠毁,使该路不通,所有经过那里的演员都会改道通过亚利桑那,而好莱坞快车不会告诉加州的电影导演这些变故。对导演来说,演员似乎只是到达得比平时慢一些,他们甚至永远不会知道 UFO 坠毁的事情。

这就是 TCP 的神奇之处。它是计算机科学家所称的「抽象」(abstraction):一种简化复杂底层操作的方式。

02

抽象的漏洞

实际上,许多计算机编程工作都涉及到抽象的构建。什么是字符串库 (string library)?它让计算机处理字符串像处理数字一样简单。文件系统 (file system) 又是什么呢?它让我们觉得硬盘不是一个存储比特的旋转磁盘堆积,而是一个层次分明的文件夹系统,其中的每个文件都由一个或多个字节串构成。

回到 TCP 的话题。我之前为了简化问题而说了一个小谎,可能已经让一些人感到不快。我说 TCP 能保证消息的送达,但实际上并非如此。如果你的宠物蛇咬断了电脑的网络线,导致 IP (Internet Protocol) 数据包无法传送,那么 TCP 也无法奏效,你的信息将无法送达。

如果你对公司的系统管理员态度不友好,他们可能会通过将你连接到一个负载过重的集线器来进行惩罚,这样你的 IP 数据包只有一部分能够传输成功,TCP 虽然仍然会尝试工作,但一切都会变得异常缓慢。

这正是我所描述的「抽象泄漏」(leaky abstraction)。TCP 试图提供一个完全的抽象,掩盖底层不可靠的网络;然而,有时网络的实际问题会穿透这层抽象,使你感受到那些抽象无法完全遮蔽的问题。这只是我称之为「抽象泄漏法则」的众多示例之一:

所有复杂的抽象,都在某种程度上存在漏洞。

抽象有时会出现问题。有的时候问题不大,有的时候问题很严重。这就是所谓的「泄漏」。在使用抽象的过程中,错误难免会发生。这里有一些实例。

03

开发中常见的抽象泄露

一个看似简单的操作,比如在一个大型二维数组上进行遍历,如果你选择横向而非纵向进行,性能差异可能会非常大。这就像木头的纹理方向,不同的遍历方向可能导致的系统错误(页面错误)数量大不相同,而这些错误会让程序运行变慢。即使是编写底层程序代码的汇编语言程序员,也通常假设他们可以操作一个大而平坦的内存空间。然而,由于虚拟内存的存在,这只是一个理论上的设想。当出现页面错误时,这种抽象就会显露出漏洞,某些内存访问会比其他访问消耗更多的时间。

SQL 语言的设计初衷是为了简化数据库查询过程中所需的具体步骤,它允许你只定义想要的结果,而由数据库自动确定如何具体执行。然而,在某些情况下,一些 SQL 查询可能比其他逻辑上等效的查询慢上千倍。一个广为人知的例子是,在某些 SQL 服务器上,如果你指定「where a=b and b=c and a=c」,查询速度会显著快于只指定「where a=b and b=c」的情况,即使最终的结果集相同。理论上,你不应需要关心具体的查询过程,只需要关注所需的结果。但有时候,这种抽象层会出现问题,导致性能严重下降,这时你可能需要使用查询计划分析器 (query plan analyzer) 来检查问题所在,并找出加快查询速度的方法。

虽然像 NFS (Network File System) 和 SMB (Server Message Block) 这样的网络库能让你把远程机器上的文件当作本地文件来处理,但有时候网络连接可能变得非常慢或直接断开,这使得文件无法像本地文件那样操作。作为开发者,你需要编写代码来应对这种情况。这种将「远程文件视为本地文件」的抽象就出现了漏洞。这里有一个具体的例子适用于 Unix 系统管理员。如果你把用户的主目录设置在通过 NFS 挂载的驱动器上(一种抽象),而用户创建了 .forward 文件以将所有邮件转发到其他地方(另一种抽象),当 NFS 服务器在新邮件到达时出现故障,这些邮件就不会被转发,因为 .forward 文件无法被找到。这种抽象的漏洞实际上导致了一些邮件的丢失。

C++ 字符串类的设计初衷是让字符串处理变得简单,好像字符串是基本数据类型一样。这些类尝试隐藏字符串处理的复杂性,让操作字符串像操作整数那样简单。几乎所有的 C++ 字符串类都重载了加号 (+) 运算符,使得你可以简单地通过 s + 「bar」 来拼接字符串。但有一个问题:无论这些类尝试得多么努力,目前还没有任何一个 C++ 字符串类能支持直接使用 「foo」 + 「bar」 来拼接字符串,因为在 C++ 中,字符串字面量实际上是 const char* 类型,而不是字符串对象。这揭示了一个语言层面的漏洞,这个漏洞是语言设计本身所无法解决的。(有趣的是,C++ 的发展历史在很大程度上可以看作是不断尝试修补这种字符串抽象漏洞的过程。至于为什么不直接在语言中增加一个本地的字符串类,目前我还不清楚原因。)

即使你的汽车配备了雨刷、前灯、车顶和加热器,这些设备帮你忽略了下雨天气的直接影响(它们试图抽象掉天气因素),但在下雨时,你仍然不能像晴天那样快速驾驶。这是因为你需要考虑到车辆打滑的风险,而且有时雨势太大,前方的视线非常有限,迫使你不得不减速。因此,天气的实际影响永远无法完全被忽略,这正是抽象泄漏法则所描述的现象。

04

抽象泄露的挑战

抽象泄漏法则带来的一个问题是,它并没有像我们预期的那样真正简化我们的生活。比如,当我在教导新手成为 C++ 程序员时,我本希望他们无需了解复杂的 char* 和指针运算。我希望能直接教授他们如何使用 STL 的字符串类。然而,当他们尝试执行如「foo」 + 「bar」这样的操作时,程序会出现意外的行为,这时我不得不解释 char* 相关的所有复杂细节。或者,当他们需要调用一个 Windows API 函数,该函数的文档说明它有一个输出类型为 LPTSTR 的参数,他们在理解 char*、指针、Unicode、wchar_t 以及 TCHAR 头文件等多个概念之前,是无法正确调用此函数的。这些底层的细节和概念就是所谓的「泄漏」,它们不断浮出水面,使得抽象的本意——简化编程——受到了挑战。

在教授别人进行 COM 编程的过程中,如果我只需要向他们展示如何使用 Visual Studio 的向导和各种代码生成工具就够了,那该多好。然而,一旦出现任何问题,他们往往对发生了什么、如何进行调试和修复都一头雾水。因此,我不得不向他们详细讲解 IUnknown、CLSIDs(类标识符)、ProgIDs(程序标识符)等核心概念……哎,这真是对人性的一种折磨!

在教授 ASP.NET 编程时,最理想的情况是我只需要告诉学生们双击元素,然后编写一些代码,这些代码会在用户点击这些元素时在服务器端执行。ASP.NET 实际上简化了编写处理超链接( <a> )点击和按钮点击的 HTML 代码的差异。然而,问题在于:ASP.NET 的设计者们需要掩盖一个事实,即在 HTML 中,超链接本身不能用来提交表单。为了解决这个问题,他们生成了一些 JavaScript 代码,并为超链接添加了一个点击事件处理器(onclick handler)。但是,这种抽象存在漏洞。如果最终用户禁用了 JavaScript,那么 ASP.NET 应用程序将无法正常工作。如果程序员不了解 ASP.NET 抽象化了哪些细节,他们将完全不知道出了什么问题。

05

总结与思考

抽象泄漏法则表明,每当出现一种新的、被宣称能大幅提高效率的代码生成工具时,你总会听到很多人建议:「先学会手动操作,再用这些先进工具来节省时间。」这些试图简化任务的代码生成工具,就像所有抽象一样,总是存在一些缺陷。唯一能够有效处理这些缺陷的方法是,深入了解这些工具的工作原理和它们试图简化的具体内容。

所以,虽然这些工具可以减少我们的工作量,但它们并不能减少我们学习的时间。这一切表明了一个矛盾:尽管我们拥有更高级的编程工具和更精细的抽象方法,成为一名熟练的程序员的难度却在不断增加。

在我第一次在微软的实习期间,我负责编写用于麦金塔电脑的字符串库。一个典型的任务是编写一个 strcat 函数的变体,该变体能返回指向新字符串末尾的指针。这只需要几行 C 代码。我所依据的全部知识都来自于一本简单的书籍,由 Kernighan 和 Ritchie 编写的【C Programming Language】。

今天,要在 CityDesk 上进行工作,我需要掌握一系列高级工具,包括 Visual Basic、COM、ATL、C++、InnoSetup、Internet Explorer 的内部机制、正则表达式、DOM、HTML、CSS 和 XML。这些都是相对于旧的 K&R 材料来说的高级工具,但我仍然必须熟悉 K&R 的基础内容,否则我就完蛋了。

十年前,我们可能以为新的编程范式会让编程到今天变得更简单。确实,我们多年来开发的抽象化技术让我们能够应对软件开发中之前未曾遇到的复杂问题,如图形用户界面(GUI)编程和网络编程。这些如现代面向对象的基于表单的语言等强大工具,使我们能够迅速完成大量工作。然而,突然间,我们可能遇到一个因抽象漏洞而产生的问题,解决它可能需要两周时间。此外,当你需要聘请一名主要进行 VB 编程的程序员时,仅有 VB 编程技能是不够的,因为每当 VB 的抽象层出现问题时,他们可能会感到束手无策。

抽象泄漏法则正成为我们发展的一个拖累。

作者:JOEL SPOLSKY

原文地址:点击阅读原文

最近文章列表:

[1]

[2]