Component Simplicity

2025-03-17

Programming

BlogBook的一部分: Functional Programming Lessons in Imperative Code

在科幻小说中,有一种常见的超空间旅行的比喻,科学家会拿出一张纸,画一条线来演示“正常”旅行,然后折叠纸张,将起点和终点连接在一起,以此解释超光速旅行的工作原理。

命令式代码提供了解决问题的直线方法。我有一些代码。它执行 73 件事。我需要它执行第 74 件事,就在它当前正在执行的某件事的中间,而这件事本身又是其他 72 件事的复杂混合。最直接的方法就是直接在执行这件事的代码中间插入一些代码来完成新的事情。我见过许多代码库,它们是人们花费了数百年时间以这种编程风格编写的结果。尽管表面上具有函数和模块,但在架构上它只是一系列需要做的事情的列表,通常是多个列表随意地混合在一起。

命令式代码之所以允许这种编程风格,是因为它通常在当下是最容易的事情,并且命令式编程不会阻止你这样做。如果你无法在你想要的地方执行你想要的操作,通常不会太难修改代码直到你可以做到。 你是否需要添加一些全局变量,向十几个函数添加参数,并添加一些功能标志来与已经存在的十几个标志进行交互?没问题。有些命令式编程文化甚至会庆祝他们的语言提供了多少种方法来以这种方式强制代码就范。他们可以随意庆祝,但在这一点上,我在哲学上与他们分道扬镳。

如果你尝试以这种方式编写 Haskell 程序,它充其量只是一个劣质的命令式语言,最坏的情况是,它会用撬棍殴打你,这只是暂时的,直到它找到真正能让你屈服的东西。

相反,你需要像拥有超光速演示的科学家那样思考。遗憾的是,你无法一步到位地直接折叠到目的地,但看待一个结构良好的 Haskell 程序的一种方式是将其视为折叠问题空间的一系列方法的集合,在这里削减一点维度,在那里削减更多维度,做一些你绝对不能在命令式语言中侥幸逃脱的折叠,并由强大的类型系统提供安全保障,直到最终你的整个程序崩溃为几行代码,这些代码接收输入并将其转换为输出,每个 token 都深深地蕴含着你在此域上操作所需的确切语义和含义。

命令式代码通常会演变成编写那些呈指数级扩展你的程序状态空间 的东西,并希望你可以在你新扩展的空间中找到一条快乐的路径,而不会破坏太多的其他快乐路径。 函数式编程通常涉及切除状态空间,直到它只是你需要的状态空间。

并不是说命令式代码禁止这种方法,而且实际上有许多命令式代码库是按照“折叠问题空间”的原则编写的,至少在提供工具来执行切片的命令式语言中是这样。(动态脚本语言很难以这种方式使用;它们从第一天起就诞生于对基于限制的思维方式的拒绝,因此如果你尝试限制状态空间,它们会在一个深刻的层面上与你对抗。) 但是,命令式语言最多只会_允许_这种方法,而 Haskell 几乎_要求_它这样做。 漫长、缓慢、详尽且令人疲惫的指令列表几乎是线性编写的,并且经过多年的努力和来自现场的错误报告而被强制符合要求,这些指令列表是如此多的命令式程序自然地演变成的,在 Haskell 中效果不佳。

Haskell 程序通常通过自下而上构建小事物,然后创建丰富的生态系统来组合它们来实现这一点。 例如,这是 “monads”的主要优点之一,也是 Haskell 在任何地方都使用它们的原因。这并不是因为任何特定的 monad 接口实现是什么,而是因为一旦你使数据结构符合 monad 接口,你就可以“免费”获得整个 monad 生态系统,就像实现一个 iterator 可以让你免费获得该实现的整个 iterator 生态系统一样。

请注意,在我之前的章节中,当我展示一个“free monad”时,数据结构只需要实现我们想要的特定驱动程序的特定方面。一旦我们有了它,我用该驱动程序编写的“程序”做了很多事情,而不仅仅是获取网页。你将获得整个 Control.Monad 世界,你将获得 monad transformers 和各种其他将小块组合成大型结构的方式。 尽管这可能是一个很小的例子,但它演示了该代码不仅仅是“使用 IO”,而是可以与 IO 组合、与软件事务内存_组合_或与许多其他事物以非常精细的级别_组合_。

当然,命令式代码允许组合代码。如果我们在字面上被迫编写除了以详尽的细节一次执行一件事情的直线脚本之外的其他任何东西,那么我们在编程方面就不会取得太大的进展,正如我上面对命令式编程所提出的异议一样。 但同样,Haskell 允许和/或强制你以比命令式语言细得多得多的粒度将代码编织在一起。 我最喜欢的 Haskell 类型签名之一是 STM (IO a),不是因为我经常使用它,而是因为它的含义,“真正”纯粹的代码与保证安全的事务性纯粹命令式外观的代码融合在一起,最终产生产生一些真实世界效果的指令,所有这些都能够非常紧密但安全地编织在一起。

在写完这篇文章后,我不得不提醒自己,实际上它并不像我说的那么田园诗般。 如果你想混合其他效果系统或 monads,就会开始出现尖锐的角落。 尽管如此,如果这是一个失败,那也是一项命令式编程语言甚至无法想象的任务的失败。

同样,如果命令式语言无法达到函数式编程语言可以达到的流畅程度,那么这里的教训是什么? 这里的教训是,即使在使用复杂的用例的情况下,也可以像那样进行编程,而实际的意义是,命令式语言仍然可以在更大的架构规模上实现这一点。

我最喜欢的一段代码是我编写的可以针对我们公司的 Wiki(本质上是 XML)运行的代码,并将页面的各个部分提取为 JSON。没什么大不了的。这听起来像是你会让实习生写的东西。 但它不是作为抓取 XML 的大型脚本编写的,而是以各种临时的方式提取我想要的任何临时数据,然后将其输出为临时 JSON 格式。它被编写为一组运算符,你可以将这些运算符定义为数据,然后解释这些运算符以将其转换为 JSON,其格式与所需的 JSON 文件格式相呼应。

它使用 XPath 导航到一些所需的 XML 节点集,然后指定如何从这些节点中提取所需的数据。 因此,假设你有一个简单的表想要从 Wiki 页面中提取,其中包含第一列中的用户名和右侧的电子邮件地址。 整个事情可以指定如下:

object:
 users:
  target: //[id="target_table"]/tr
  for_each:
   array:
    - target: //td:1/
     extract: text
    - target: //td:2/
     extract: text

这将创建一个具有键 users 的 JSON 对象,该对象会获取由给定 ID 标识的表的表行,然后为每一行创建一个数组,每个成员都是一个包含单元格文本的数组,从而产生如下结果:

{
 "users": [
  ["user1", "user1@company.com"],
  ["user2", "user2@othercompany.com"]
 ]
}

大约有十几种提取和操作 XPath 返回的节点集的方法。 将这些组合在一起,再加上 XPath 的一般能力,使得仅从这些简单部分构建甚至一些相对复杂的提取变得非常容易。 当我编写此测试用例时,我知道我做得很好,即使在 Go 中,我也开始更喜欢将其中一个程序作为字符串嵌入到我的代码中并执行它,而不是手动编写等效的代码。 随后,它已被以多种方式使用,编写这些提取程序比编写临时代码来执行一次更容易。 最重要的是,它使事情发生变得更容易,否则这些事情就不会发生。

我目前正在处理的另一个代码库涉及一个漫长的临时指令列表,这些指令是几十年间编写的,所有这些指令都旨在对特定实体进行操作。 在替换中,没有复制相同的方法,而是很容易创建一个统一的接口(稍后我将把它作为自己独特的课程),并将其变成一组可以单独理解的离散任务,而不是数十件事情以准随机顺序完成的整个大型混合体。

一个真实的例子是 Web 服务器世界中流行的“middleware”概念。 以这样一种方式提取出常见的关注点,以便你可以创建处理程序的“堆栈”,这是一种非常强大的编写 Web 服务器的方式。 我特别喜欢在你可以将 middleware 附加到路径的特定组件的架构中,它允许你通过正确构建 middleware 堆栈来做出像“除非他们是管理员,否则任何人都不能访问 /admin URL 或其下的任何内容”这样的可靠保证,因此即使有人稍后添加新的管理页面,他们也不会忘记检查用户是否是管理员。

拥有基于插件或解释器的架构通常不是什么新闻。 当我在我的介绍中写道,我所涵盖的内容并非都是“全新的”时,这是我想到的最大的例子之一。 在函数式编程还只是研究人员眼中的一线曙光之前,命令式代码就是这样运作的。 编程语言本身就是这个概念最纯粹的实例化之一,因为组合被简化到核心。 可以说,不可能创建一个真正大型的代码库而不将这个想法融入其中,这仅仅是为了程序员的理智,因此诸如“Linux kernel modules”和“image editor plugins”以及声音合成模块化的标准化格式自编程语言发明后不久就出现了。

但是,函数式编程通过迫使其用户更频繁地使用它,迫使你习惯它。 我可以通过我在野外找到的代码看到,程序员通常需要很长时间才能自己弄清楚这个概念。 更多的命令式代码库应该有这个概念。 此外,通过在非常严格的上下文中强制执行它,迫使你擅长处理这种架构的缺陷。 以错误的方式拆分模块非常容易。 在命令式语言中,你可能只是根据需要穿透它; 函数式编程迫使你修复架构。 如果你的插件有时需要打印一些东西,那么你_必须_修改架构以适应偶尔的 IO 需求。 如果你必须通过各种模块传递数据,你需要在设计中明确说明这一点。

函数式编程表明,你确实可以将更多的代码编写为组合在一起的更简单的单个模块,并且具有比你可能从数十年的纯命令式编码中自然获得的更丰富的“组合”概念。 它还擅长教你如何编写各种形状和大小的解释器,甚至像我为 free monad 示例显示的解释器一样小,以便当你回到命令式编程时,你会更清楚你需要做什么才能拥有一个解释器。