人类语言中的“代数效应”

译者评论:这是React贡献者Dan Abramov的一篇很棒的文章的翻译。 他的示例是为JS编写的,但是对于开发人员来说,无论使用哪种语言,它们都同样清楚。 这个想法是所有人共同的。

您听说过代数效应吗?


我最初试图弄清他们是谁,以及为什么他们应该让我兴奋的尝试都没有成功。 我找到了几个 PDF ,但它们使我更加困惑。 (由于某种原因,我在阅读学术文章时睡着了。)


但是我的同事塞巴斯蒂安继续 他们为我们在React中所做的某些事情的心理模型。 (Sebastian在React团队中工作,并提出了很多想法,包括Hooks和Suspense。)在某个时候,它成为React团队的本地模因,我们的许多对话以以下内容结束:



事实证明,代数效应是一个很酷的概念,它并不像我最初阅读这些PDF时所看到的那样可怕。 如果您仅使用React,则不需要了解它们,但如果您像我一样有兴趣,请继续阅读。


(免责声明:我不是编程语言领域的研究人员,可能在我的解释中搞砸了。因此,如果我错了,请告诉我!)


还处于生产初期


代数效应目前是编程语言研究领域的实验性概念。 这意味着与iffor甚至async/await表达式不同,您很可能现在无法在生产环境中使用它们。 它们仅由专门为研究此思想而创建的几种 语言支持。 它们在OCaml中的实施方面正在取得进展,该进展仍在进行中 。 换句话说,注意但不要用手触摸。


为什么要打扰我?


想象一下,您正在使用goto编写代码,并且有人在告诉您iffor构造的存在。 或者,也许您陷入了回调地狱,有人向您显示async/await 。 很酷,不是吗?


如果您是喜欢在编程流行几年之前学习编程创新的人,那么也许是时候对代数效应感兴趣了。 虽然没有必要。 这就是1999年谈论async/await的方式。


那么,这些是什么样的效果?


名称可能有点混乱,但是想法很简单。 如果您熟悉try/catch块,您将很快了解代数效应。


让我们回想一下try/catch 。 假设您有一个引发异常的函数。 在它和catch之间也许有几个嵌套的调用:


 function getName(user) { let name = user.name; if (name === null) { throw new Error('  '); } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } catch (err) { console.log(",   : ", err); } 

我们在getName内抛出了一个异常,但是它通过makeFriends “弹出”到最近的catch 。 这是try/catch的主要属性。 不需要中间代码即可处理错误。


与C之类的语言中的错误代码不同,使用try/catch您不必手动在每个中间级别传递错误来处理顶级错误。 异常自动弹出。


这与代数效应有什么关系?


在上面的示例中,一旦看到错误,我们将无法继续执行程序。 当我们发现自己处于catch ,正常的程序执行将停止。


都结束了 为时已晚。 我们能做的最好的事情就是从失败中恢复过来,也许以某种方式重复我们所做的事情,但是我们不能神奇地“回到”我们曾经做过的事情。 借助代数效应,我们可以做到。


这是一个用假设的JavaScript方言编写的示例(我们将它称为ES2025为好玩),它使我们能够在丢失了user.name之后继续工作:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

(我向2025年以来在Internet上搜索“ ES2025”并加入本文的所有读者致歉。如果到那时,代数效应将成为JavaScript的一部分,我将很乐意更新本文!)


除了使用throw我们使用假设关键字perform 。 同样,我们使用假设的try/handle代替try/catch 。 确切的语法在这里无关紧要 -我只是想出了一些办法来说明这个想法。


那么这是怎么回事? 让我们仔细看看。


我们不会抛出错误,而是执行效果 。 正如我们可以抛出任何对象一样,在这里我们可以传递一些值进行处理 。 在此示例中,我传递了一个字符串,但它可以是对象或任何其他数据类型:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } 

当我们抛出异常时,引擎会在调用堆栈中查找最接近的try/catch处理程序。 类似地,当我们执行一个effect时 ,引擎将在栈顶寻找最接近的try/handle effect处理程序:


 try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

这种效果使我们可以决定未指定名称时如何处理情况。 这里的新resume with (与例外相比)是假设的resume with


 try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

这是您无法使用try/catch 。 它使我们可以返回执行效果的地方,并将内容从处理程序中传回 。 :-O


 function getName(user) { let name = user.name; if (name === null) { // 1.     name = perform 'ask_name'; // 4. ...     (name   ' ') } return name; } // ... try { makeFriends(arya, gendry); } handle(effect) { // 2.    ( try/catch)  (effect === 'ask_name') { // 3. ,      (    try/catch!) resume with ' '; } } 

花费一些时间才能感到舒适,但是从概念上讲,这与try/catch并返回没有太大区别。


但是请注意,代数效应是一个比try/catch更强大的工具。 错误恢复只是许多可能的用例之一。 我从这个例子开始只是因为它对我来说是最容易理解的。



功能没有颜色


代数效应对异步代码具有有趣的含义。


在具有async/await语言中async/await函数通常具有“颜色”俄语 )。 例如,在JavaScript中,我们不能仅使getName异步而不用async感染makeFriends及其调用函数。 如果部分代码有时需要同步,有时需要异步,这可能是一个很大的痛苦。



 //       ... async getName(user) { // ... } //       ... async function makeFriends(user1, user2) { user1.friendNames.add(await getName(user2)); user2.friendNames.add(await getName(user1)); } //   ... async getName(user) { // ... } 

JavaScript生成器以类似的方式工作:如果您使用生成器,则所有中间代码也应了解生成器。


好吧,这和它有什么关系?


一会儿,让我们忘记异步/等待,回到我们的示例:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } } 

如果我们的效果处理程序无法同步返回“备用名称”怎么办? 如果我们想从数据库中获取该怎么办?


事实证明,我们可以从效果处理程序异步调用resume with而无需对getNamemakeFriends进行任何更改:


 function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { setTimeout(() => { resume with ' '; }, 1000); } } 

在此示例中,我们仅在一秒钟后调用resume with 。 您可以考虑resume with回调进行resume with回调只能调用一次。 (您也可以通过将其称为“一次性限时延续 ”向朋友炫耀(“ 限界延续 ”一词尚未获得稳定的俄语翻译-大约翻译)。)


现在,代数效应的机理应该更清晰一些。 当我们抛出错误时,JavaScript引擎通过破坏进程中的局部变量来旋转堆栈。 但是,当我们执行该效果时,我们的假设引擎会使用我们函数的其余部分创建一个回调(实际上是一个“连续框架”,大约为Transl。),然后resume with调用该函数。


再次提醒您:仅针对本文完全发明了特定的语法和特定的关键字。 关键不在于此,而在于力学。



清洁注意事项


值得注意的是,代数效应是功能编程研究的结果。 他们解决的某些问题仅是函数式编程所独有的。 例如,在不允许任意副作用的语言(例如Haskell)中,应使用诸如monad之类的概念在程序中拖动效果。 如果您曾经阅读过monad教程,那么您就会知道它可能很难理解。 代数效应有助于以更少的精力完成类似的任务。


这就是为什么大多数关于代数效应的讨论对我来说是完全不可理解的。 (我不知道Haskell和他的“朋友”。)但是,我认为,即使在JavaScript这样的不洁语言中,代数效应也可以成为将代码中“内容”与“方式”分开的非常强大的工具。


它们允许您编写描述您正在做什么的代码:


 function enumerateFiles(dir) { const contents = perform OpenDirectory(dir); perform Log('Enumerating files in ', dir); for (let file of contents.files) { perform HandleFile(file); } perform Log('Enumerating subdirectories in ', dir); for (let directory of contents.dir) { //           enumerateFiles(directory); } perform Log('Done'); } 

然后,用描述“如何”执行的操作将其包装:


 let files = []; try { enumerateFiles('C:\\'); } handle(effect) { if (effect instanceof Log) { myLoggingLibrary.log(effect.message); resume; } else if (effect instanceof OpenDirectory) { myFileSystemImpl.openDir(effect.dirName, (contents) => { resume with contents; }); } else if (effect instanceof HandleFile) { files.push(effect.fileName); resume; } } //  `files`     

这意味着这些部分可以成为一个库:


 import { withMyLoggingLibrary } from 'my-log'; import { withMyFileSystem } from 'my-fs'; function ourProgram() { enumerateFiles('C:\\'); } withMyLoggingLibrary(() => { withMyFileSystem(() => { ourProgram(); }); }); 

与异步/等待或生成器不同,代数效应不需要“中间”函数的复杂化。 我们对enumerateFiles调用可能在我们的程序中很深,但是只要有一个可以在上面某个地方执行的每种效果的效果处理程序,我们的代码就会继续起作用。


效果处理程序使我们能够将程序逻辑与效果的特定实现分开,而无需不必要的舞步和样板代码。 例如,我们可以完全重新定义测试中的行为,以便使用伪造的文件系统并制作日志快照,而不是在控制台上显示它们:


 import { withFakeFileSystem } from 'fake-fs'; function withLogSnapshot(fn) { let logs = []; try { fn(); } handle(effect) { if (effect instanceof Log) { logs.push(effect.message); resume; } } // Snapshot  . expect(logs).toMatchSnapshot(); } test('my program', () => { const fakeFiles = [ /* ... */ ]; withFakeFileSystem(fakeFiles, () => { withLogSnapshot(() => { ourProgram(); }); }); }); 

由于函数没有“颜色”(中间代码不必了解效果),并且可以组成效果处理程序(它们可以嵌套),因此可以使用它们创建非常有表现力的抽象。



类型注


由于代数效应来自静态类型的语言,因此有关它们的大多数争论都集中在如何用类型表示它们上。 这无疑是重要的,但也会使对该概念的理解复杂化。 这就是为什么本文根本不讨论类型的原因。 但是,我应该注意,通常函数可以执行效果的事实将以其类型的签名进行编码。 因此,当发生无法预料的效果或无法追踪效果来自何处时,您将受到保护。


在这里,您可以说技术代数效应会“赋予颜色”静态类型化语言中的函数,因为效果是类型签名的一部分。 真的是 但是,将中间函数的类型注释固定为包括新的效果本身并不是语义上的更改-与添加异步或将函数转换为生成器不同。 类型推断还可以帮助避免级联更改的需要。 一个重要的区别是,您可以通过插入空的存根或临时实现(例如,对异步效果的同步调用)来“抑制”效果,这在必要时可以防止其对外部代码的影响-或将其转变为另一种效果。



我需要JavaScript中的代数效应吗?


老实说,我不知道。 它们非常强大,可以说它们对于像JavaScript这样的语言来说太强大了。


我认为它们对于可变性很少且标准库完全支持效果的语言非常有用。 如果您首先执行perform Timeout(1000), perform Fetch('http://google.com')perform ReadFile('file.txt') ,并且您的语言具有“模式匹配”和静态类型的效果,那么这可能是一个非常不错的编程环境。


也许这种语言甚至可以用JavaScript编译!



这与React有什么关系?


不是很大 您甚至可以说我在地球仪上拔了一只猫头鹰。


如果您观看了我关于时间片和暂停的讨论,那么第二部分将包括从缓存中读取数据的组件:


 function MovieDetails({ id }) { //         ? const movie = movieCache.read(id); } 

(该报告使用的API稍有不同,但这不是重点。)


该代码基于名为“ Suspense ”的数据样本的React函数,该函数目前正在积极开发中。 当然,这里有趣的是,数据可能还没有保存在movieCache中-在这种情况下,我们需要先做一些事情,因为我们无法继续执行。 从技术上讲,在这种情况下,对read()的调用会抛出Promise(是的,抛出Promise-您必须吞下这个事实)。 这将暂停执行。 React截获了这个Promise,并记住在抛出的Promise完成后有必要重复渲染组件树。


尽管此技巧的创建是他们的启发 ,但它本身并不是代数效应。 这个技巧达到了相同的目的:调用堆栈下面的某些代码暂时不如调用堆栈中的更高代码(在这种情况下为React),而所有中间函数都不必知道它,也不必被异步或生成器“毒化”。 当然,我们不能“实际”恢复用JavaScript执行,但是从React的角度来看,在Promise许可后重新显示组件树几乎是相同的。 当您的编程模型假定幂等时,您可以作弊!


钩子是另一个可以使您想起代数效应的示例。 人们问的第一个问题是:useState在哪里调用“知道”它指的是哪个组件?


 function LikeButton() { //  useState ,    ? const [isLiked, setIsLiked] = useState(false); } 

我已经在本文末尾解释了这一点 :在React对象中,有一个可变的状态“当前分派器”,它指示您当前正在使用的实现(例如,在react-dom )。 同样,有一个当前的组件属性指向LikeButton内部数据结构。 这是useState找出要做什么的方式。


在习惯之前,人们经常认为它有一个明显的原因,看起来像是肮脏的骇客。 依靠一般的可变状态是错误的。 (注意:您认为在JavaScript引擎中如何实现try / catch?)


但是,从概念上讲,您可以将useState()视为执行State()的结果,该状态在组件执行时由React处理。 这“解释了” React(组件调用的东西)可以为其提供状态的原因(它在调用堆栈中较高,因此它可以提供效果处理程序)。 实际上,显式状态实现是我遇到的关于代数效应的教科书中最常见的示例之一。


同样,这当然不是React实际的工作方式,因为我们在JavaScript中没有代数效应。 取而代之的是,有一个隐藏的字段供我们保存当前组件,还有一个字段通过useState实现指向当前的“调度程序”。 作为性能优化,甚至有单独的useState实现用于安装和更新 。 但是,如果您现在对这段代码感到非常困惑,那么可以考虑将它们视为普通的效果处理程序。


综上所述,我们可以说在JavaScript中, throw可以作为I / O效果的第一近似值(前提是该代码可以在以后安全地重新执行,并且只要不与CPU绑定即可),并且变量字段“在try /中恢复的“ dispatcher”最终可以用作同步效果处理程序的近似值。


您可以使用generators获得质量更高的效果实现,但这意味着您必须放弃JavaScript函数的“透明”性质,并且必须使用generators进行所有操作。 这就是“嗯,那是...”


在哪里找到更多


就个人而言,我很惊讶我获得了多少代数效应。 我总是尽力理解诸如monad之类的抽象概念,但是代数效应只是产生并“打开”了头部。 我希望本文能帮助他们“加入”您的行列。


我不知道它们是否会开始大量使用。 我认为,如果他们在2025年之前不扎根任何一种主要语言,我将感到失望。 提醒我检查五年!


我相信您可以对它们做更多的事情,但是要真正开始编写代码并使用它们,要感受到它们的力量确实很难。 如果这篇文章引起了您的好奇心,这里有一些其他资源,您可以在其中更详细地阅读:



许多人还指出,如果您省略了打字方面(就像我在本文中所做的那样),则可以在Common Lisp的条件系统中找到这种技术的较早使用。 , , call/cc .

Source: https://habr.com/ru/post/zh-CN470718/


All Articles