为什么我对效果系统(2025)感到兴奋

Lobsters Hottest 新闻

摘要

作者解释了为什么编程语言中的效果系统令人兴奋,与当前系统相比,它提供了对资源交互更好的控制、可组合性和可测试性。

<p><a href="https://lobste.rs/s/abkikp/why_i_m_excited_about_effect_systems_2025">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/02 06:22

# 我为何对效果系统感到兴奋 来源:https://osa1.net/posts/2025-06-28-why-effects.html **2025年6月28日** 标签:[en](https://osa1.net/tags/en.html),[plt](https://osa1.net/tags/plt.html) 想象一门编程语言,你能够完全控制函数、模块或库如何与共享资源交互——无论是用于线程调度的调度器、文件系统,还是其他操作系统级资源(如套接字和其他文件描述符),抑或是用于延迟当前线程进行定时更新或安排定时回调的定时器等等。在这门语言中,函数(或模块、库……)需要在类型中声明其对共享资源的交互方式。当函数访问文件系统时,调用方可以完全控制其访问方式。所有文件系统访问函数都可以由调用方指定(或者覆盖其默认实现)。 进一步假设,这门语言还能像当今许多语言中的 `async` 函数一样,暂停函数并在之后恢复——当例如 `Future` 的值变得可用时,这些函数会被暂停并在之后恢复。与我们目前拥有的任何系统相比,这门语言更容易构建出更具可组合性的系统。该系统默认就是可组合、灵活且可测试的。仔细想想,今天的现象确实有些奇怪:我可以导入一个库,而该库能够生成线程、使用文件系统、通过 `sleep` 或阻塞式 I/O 操作来阻塞当前线程,而我对此毫无控制权。大多数情况下,这类行为至少会有文档说明,但如果我使用了一个根本上需要这些功能的库,除非该库考虑到了我的用例,否则我可能无法在我的应用程序中使用它。 例如,它可能生成线程,但我希望它使用我自己的线程池——除了限制线程数量外,我还为线程附加了优先级,并基于优先级进行调度。或者,我有一个库,它通过读取文件、处理文件并生成文件来构建/编译内容。如果我能控制该库使用的文件系统 API,那么测试该库就毫不费力(例如无需提前规划)——可以使用内存文件系统进行并行测试,而不必担心竞争条件和 I/O 瓶颈。我不必考虑库中的测试场景并相应地构建我的代码。又或者,我有一段轮询某些资源并可能发布定期更新的代码。它创建一个线程来执行定期工作,并 `sleep`。通过控制线程、调度器和定时器,我可以在测试中将时间快进(到下一个事件),而无需实际等待 `sleep` 和任何其他定时事件,从而快速测试我的代码。 这些就是使用效果系统能够做到的事情的一部分。 ### 效果系统包含什么? 从高层来看,效果系统包含两个部分:(1)类型系统,(2)运行时特性。这两个部分在某种程度上是正交的:你可以根据需要只拥有其中一个。在当今可用的系统中,(1)通常涉及在函数类型中添加一个类型组件,用于表示函数可以调用的效果¹。例如,在 [Koka](https://koka-lang.github.io/) 中,如果你在一个名为 `console` 的效果中定义了标准输入/输出操作,并有一个使用 `console` 效果的函数,那么该函数的类型签名看起来像这样: ``` fun sayHi() -> console println("hi") ``` 这个类型说明 `sayHi` 返回单位类型(`()`)并使用 `console` 效果。 (2)通常涉及捕获效果调用的续延(continuation),并将其传递给一个“处理程序”(handler)。根据系统不同,处理程序随后可以执行操作(例如内存操作、调用其他效果),并“跳转”到(或“尾调用”)续延,并传入被调用效果返回的值。使用上述的 `console` 效果,一个处理程序可能只是将打印的字符串记录在数据结构中,该结构随后可用于测试。另一个处理程序可能实际写入 `stdout`,这将在你运行应用程序时使用。 根据具体的(1)和(2)特性,你可以做不同的事情。当前各种语言中的效果系统支持不同的(1)和(2)特性,并且有些系统完全省略了(1)或(2)之一。本文的目的,我们不考虑你可以拥有的全部特性范围及其允许的功能。 ### 示例:在 Koka 中实现一个简单的 grep 目前没有一门语言能完全满足我在开头描述的用例所需的一切。然而在我们已有的语言中,Koka 接近这一目标,因此我们将使用 Koka 进行一个简单的示例。想象一个简单的“grep”命令,它接受一个字符串和一个文件路径列表作为参数,在文件内容中查找该字符串的出现并报告它们。在 Koka 中,这些“效果”的标准库定义可能如下: ``` effect fs ctl read-file(path: path): string effect console ctl println(s: string): () ``` 使用这些效果,读取文件并搜索字符串的代码与它在任何其他“函数式”²语言中的样子并无不同: ``` fun search(pattern: string, files: list): () val pattern-size = pattern.count() files.foreach fn(file) val contents = read-file(file.path) val parts = contents.split(pattern) report-matches(file, pattern-size, parts) fun report-matches(file: string, pattern-size: int, parts: list): () if parts.length == 0 then return () println(file) var line := 0 var column := 0 parts.init.foreach fn(part) part.vector.foreach fn(char) if char == '\n' then line := line + 1 column := 0 else column := column + 1 println((line + 1).show ++ ":" ++ (column + 1).show) ``` 调用 `search` 时,我必须为 `fs` 和 `console` 效果提供处理程序。在为用户生成的可执行文件中,我可以使用执行实际文件系统操作并打印到 `stdout` 的处理程序: ``` val fs-io = handler ctl read-file(path: path) resume(read-text-file(path)) val console-terminal = handler ctl println(s: string) write-to-stdout(s) resume(()) ``` 在测试中,我可以使用一个从内存映射中读取的 `read-file` 处理程序,并将打印的行添加到一个列表中,以便与预期的测试输出进行比较: ``` struct test-case files: list pattern: string expected-output: list struct test-file path: path contents: string val test-cases: list = [ Test-case( files = [ Test-file("file1".path, "test\ntest"), Test-file("file2".path, "a\n test\nb") ], pattern = "test", expected-output = ["file1", "1:1", "2:1", "file2", "2:2"] ), ] fun test(): () var printed-lines := Nil test-cases.foreach fn (test) with handler ctl read-file(path_: path) match test.files.find(fn (file) file.path.string == path_.string) Just(file) -> resume(file.contents) Nothing -> throw("file not found", ExnAssert) with handler ctl println(s: string) printed-lines := Cons(s, printed-lines) resume(()) search(test.pattern, test.files.map(fn (file) file.path.string)) if printed-lines.reverse != test.expected-output then throw("unexpected test output", ExnAssert) ``` 你可以在此处查看完整示例(https://gist.github.com/osa1/a5e7fdfa30d69125970c0797c525ede2)。 ### 我已经可以用语言 X 通过库/框架 Y 做到这一点了? 效果系统的要点在于,你不是*为了它而设计*才能得到一个可组合和可测试的系统,而是*默认*就得到了它。如果我实现了一个使用文件系统的库,无论你是否为之设计,我都可以用内存文件系统运行它,或者拦截文件访问以防止某些事情发生,或者记录某些事情,等等。 上面的 Koka 代码并没有完全展示这一点,而且目前没有任何可用的系统能做到。我只是使用了今天可用的任何工具。在一个理想的系统中,你必须刻意为之才能在不使用效果的情况下访问文件系统,而不是相反。当我们比较语言时,我们从不讨论什么是可能的:几乎每一种通用编程语言中几乎一切皆有可能。我们讨论的是诸如:符合习惯用法且高效的方式。我所说的符合习惯用法且高效的语言今天还不存在。 ### 我们如何知道这种理想系统是可能的? 我们提到效果系统的两个组成部分在某种程度上是正交的。在我心目中(下文详述)的设计中,即使没有类型系统部分,你仍然能获得 90% 的好处。因此,让我们专注于运行时部分。 从概念上讲,你需要一个在调用效果时暂停栈的方法,将暂停的栈(你可能想称之为“续延”)传递给被调用效果的处理程序。这类事情在当今许多高级语言中已经可以实现。如果你的语言支持轻量级线程(绿色线程、纤程等)、协程、生成器或类似特性——当代码执行诸如 `await` 或 `yield` 之类的操作时暂停,然后在之后恢复——那么你已经拥有了灵活效果系统所需的运行时特性。 ### 对我而言,关键在于可组合且可测试的库 我故意到目前为止在本文中没有提及,效果系统泛化了异步/等待(async/await)、迭代器/生成器、异常以及许多其他特性。原因在于,作为用户,我不关心这些特性底层是使用效果系统实现的,还是通过其他方式实现的。例如,Dart 拥有所有这些特性,但它没有使用效果系统来实现它们。作为用户,只要我有这些特性,这对我来说并不重要。 相反,作为一个用户,我更感兴趣的是:它如何影响或作用于库设计,以及它在大型代码库中允许我做什么。然而,不提及这一点将是遗憾的:是的,效果系统泛化了所有这些特性,以及更多。论文《使用代数效果实现结构化异步》(Structured Asynchrony with Algebraic Effects)(https://www.microsoft.com/en-us/research/wp-content/uploads/2017/05/asynceffects-msr-tr-2017-21.pdf)展示了如何在 Koka 中实现这些特性。 ### 未完待续 最近网上关于效果系统的一些讨论让我有些不满意,因为大多数帖子似乎都集中在效果系统的小规模好处上,而我希望能分享我对效果系统的不完整(但希望并非不连贯)的视角。在未来的文章中,我希望能涵盖设计此类系统时的一些开放问题。 --- 感谢 [Tim Whiting](https://github.com/TimWhiting/) 审阅了本文草稿。 --- 1. 这是对函数类型中这些效果类型所指示内容的粗略估计。实际上它比“函数调用的效果”更复杂:如果你那样理解,你将无法解释某些类型错误,或者为什么某些代码能够通过类型检查。希望在未来的文章中进一步探讨这一点。↩︎ 2. “函数式”加引号是因为我认为这个词如今意义不大。也许以后会再谈。↩︎

相似文章

代数效应:给普通人的解释

Hacker News Top

这是一篇教育性博客文章,通过类比 try/catch 和 async/await 来解释编程中的代数效应概念,并讨论了它们与 React 及未来编程范式的潜在关联。