命令式语言编程
在过去的几年中,已经从功能编程(FP)语言的思想涌入主流命令性语言。 不仅lambda和高阶函数已进入Java,C ++和其他语言,而且甚至从最纯粹的FP语言Haskell导入的更高级的概念(如monad)也是如此。
纯函数是一个概念,它将函数的数学概念等同于子例程的软件概念(通常令人困惑,也称为函数)。 纯函数是子例程,其行为类似于数学函数:它的唯一输入是其参数,而其唯一的输出是其返回值,并且它可能对世界完全没有其他影响。 因此,可以说纯函数没有副作用:它们可能不会参与I / O,并且可能不会变异其他纯函数可能观察到的任何变量。
纯函数编程(PFP)语言是所有子例程都必须是纯函数的语言。 将此与非PFP或命令式语言进行对比,后者允许不纯函数,即子例程,与数学函数不同,子例程可能很好地影响外部状态。 因此,为了使事情进一步混乱,大多数功能语言(包括Clojure,OCaml和Erlang)都势在必行。 造成这种混淆的原因是,函数编程本身一词(自称为函数但不纯净的语言使用)甚至没有一个公认的定义。 但是可以确定的是,除纯语言外,功能语言是必不可少的-而且几乎没有纯语言。 Haskell是唯一在行业中得到使用的PFP语言,甚至Haskell在其存在的中也很少在行业中使用。 如果PFP如此稀有,为什么monad(它的旗舰概念之一)会进入越来越多的命令性语言(从C ++到Java,再到JavaScript)呢? 这与PFP针对命令式语言甚至没有的问题提供的巧妙解决方案有关。 问题是什么? 那有什么解决方案? 为什么命令式语言甚至认为他们现在遇到了以前没有的问题? 让我们尝试回答这些问题等等。
强大的单子
PFP方法提出了一个显而易见的挑战。 如果不允许纯函数执行任何I / O,并且在纯函数语言中,所有子例程都是纯函数,那么我们如何使PFP程序做有用的事情? PFP通过巧妙而优雅的想法(称为“ monad”)解决了这一难题。
长期以来,单子词一直都没有简单的解释。 据说一旦理解了单子,就会失去解释它们的能力。 我相信我已经找到了出路这个恶魔般的22条军规的:我不完全了解单子,所以因此我也许能解释他们,至少部分地。 而且,如果在以下说明之后您也不完全理解它们,那么您将处于令人羡慕的地位,能够将您的部分理解传递给其他人,希望总比没有好。 有关理解的帮助,请参阅功能强大的插图指南“函子,应用程序和图片中的Monads” 。
重申一下,monads试图解决的问题是使内在无能的纯函数完成一些有用的工作,这些纯函数根据定义仅限于从少量参数中计算单个结果。 让我们考虑:一个单子是两件事情:一个包装值的情况下,并说明如何说包裹值组合成的计算步骤序列的规则。 我将立即提供一些示例,但是想法是上下文包含一些有关如何使用值的信息,组合规则描述了如何将来自多个函数的单个返回值和上下文组合到一个有用的工作单元中。
我们的第一个示例将是一个monad,我将其称为[]
。[]
monad实现各种计数器。 它在[]上下文中包装一个值-例如3-:[3, 0]
。 第一个元素是包装后的值,第二个元素在合成规则的上下文中具有含义,即如果我们有一个现成的单调值[a, n]
并要求我们用单调函数x -> [y, k]
(不用担心,在一秒钟之内就会明白),我们通过向函数传递值a并将返回的k添加到原始n中来产生[y, n + k]
(即第二个元素是我们的计数器。)要使事情具体:
f(x) = [x + 1, 2]
(这是单子函数:它返回单子,即包装值)
g(x) = [x * 2, 3]
z() = [3, 1]
现在,我们应用monadic函数[](从左到右应用操作,如下段所述)
z .[] f .[] g => [8, 6]
我选择了“。[]
意思是“与[]
monad组成规则一起组成”。 结果为[8, 6]
因为z返回[3, 1]
,然后将f传递给3,以返回[4,2],根据我们的合成规则,该构图由[4,3]组成第二个要素。 最后,将g传递给4,然后返回[8, 3]
8,3[8, 3]
,它构成了最终结果[8, 6]
8,6[8, 6]
)
亲爱的读者,那是单子。 这些生物的美丽之处在于,由于我们包装的单子值也只是值,因此允许一个纯函数返回它们,但请注意,单子函数在不使用第二函数的情况下如何能够影响第二个累积分量的“状态”借助合成规则,将其作为参数传递给他们。
好的,这如何解决I / O问题? 为什么使用I / O monad,我称之为{}
。 此monadic在上下文中包装值x,即monadic值{io-op, x}
,其中io-op是I / O操作的名称,在我们的示例中为print
或read
(或,由于monad也需要中立操作,因此noop
)。 我们将立即演示的monadic合成规则将采用这些monadic值并实际执行I / O操作。 这就提出了一个明显的问题:如何允许规则以纯功能语言执行任何I / O操作? 这是技巧的第二部分:在PFP中,I / O monad的编写规则不是用PFP语言编写的,而是由语言的运行时作为内置结构提供的。
让我们在实践中使用一个简单的示例查看此消息,该示例将消息输出到屏幕上,读取输入,然后将其显示在屏幕上。 一对{print, x}
形式是通过将x
值打印到控制台并将任何内容(即一个空值,根据语言而定,可以称为null或void)传递给下一个monadic函数而构成的,一对的形式的{read, x}
忽略的值x
(因此其可以是空的,表示为“_”下面),从控制台读取值并传递到下一个一元函数(为了完整性,空操作不执行I / O操作,但只需将值传递给链中的下一个函数即可:
p() = {print, "What is your name?"}r() = {read, _}n(x) = {print, "Hello, " + x + "!"}
程序p .{} r .{} n
将显示“您叫什么名字?”。 在控制台上,阅读用户的响应-说“ Ron”-最后打印“ Hello,Ron!”
通过这种方式,纯函数产生它们的{io-op, x}
值,并且运行时通过执行实际的I / O来组合它们。 问题解决了!
精明的读者会问,难道函数不包含序列p .{} r .{} n
,那么I / O是否会有副作用,因此不是纯粹的函数吗? 好的,PFP语言不会实际评估顺序,而是使调用函数将顺序表达式一直返回到运行时,然后运行时(懒惰地)对其进行评估并执行实际的I / O操作。
现在,我们已经通过纯函数和monad解决了最棘手的副作用I / O(很好,是内置的monad),我们也可以解决其他问题。 例如,在许多命令式语言中,子例程可以返回一个值,但是它也可能引发异常。 在计算过程中引发的异常将跳过剩余的计算阶段,直到以某种方式捕获并处理该异常为止。 我们可以通过monad实现相同的结果:我们的monadic值将是包含函数正常返回值或错误的上下文,并且composition规则将正常值传递给下一个monadic函数,但将中止链-即,不如果一个函数返回错误,则按顺序调用其他函数。
一个相关的问题是,为什么为解决PFP问题而设计的PFP构造monad会进入某些命令性语言的核心,即按定义允许子例程自由执行I / O,抛出异常,更改状态以及一次完成以上所有操作?
线程问题
PFP将使用奇异的单子链来链接计算,而命令式语言则通过在任何标准线程中一个接一个地运行来链接它们。 当然,PFP语言的运行时也使用线程,但是线程和相关的堆栈语义在语言结构中的使用较少。 另一方面,线程和它们的堆栈在命令式语言中非常明显,即使仅支持单个线程JavaScript也是如此。
上面的p .{} r .{} n
程序通常用命令式语言编写,如下所示:
p(); name = r(); n(name).
线程抽象对该程序的最强大贡献是r函数内部发生的事情,该函数从控制台读取用户名:线程挂起自身–它阻止保留堆栈上任何局部变量的值,并产生其CPU其他线程或进程的核心。 I / O操作完成后,线程将解除阻塞,并从所有中断处继续,并保留所有局部变量。 正是这种阻止功能使命令性代码可以如此轻松地将程序中的调用链接起来。 但是问题就出在这里。
现代服务器需要处理大量并发连接的客户端。 他们需要处理的并发请求数由利特尔定律确定:
L =λW
其中λ是传入请求的平均速率,W是处理单个请求所需的平均时间,L是所需的服务器容量,即它必须服务的并发请求的数量。 如果服务器的容量降到L以下,它将变得不稳定且不可用(有关详细讨论和实验结果, 请参见此处 和此处 )。
有许多因素影响服务器的有效容量。 OS可以支持的TCP套接字数以及可用的处理能力都对并发请求数设置了上限。 因此,有效容量是各种系统组件(包括软件和硬件)设置的所有此类上限中的最小值。 事实证明,最受限制的上限之一是内核可以有效调度的线程数,该数量介于2,000到20,000之间。 这样做的原因是内核的线程实现带来了相对沉重的成本(RAM和调度延迟方面)。 相反,现代操作系统最多可以支持(如果配置正确)多达200万个打开的TCP连接。 然后,线程调度会将服务器的容量减少大约两个数量级。 (这种影响可能不会那么剧烈,因为其他因素(例如RAM带宽)比活动连接数更具约束力。)
因此,即使我们优雅的阻塞线程(就像在简单的控制台示例中一样)将是管理客户端和服务器之间的每个连接的理想方式,但它不能承受现代服务器要求的负担。 线程是并发的必要单位,不足以通过为每个连接分配一个线程来处理问题域的并发。 赋予命令式编程以计算能力的抽象能力已经失去了有效性,因为它在OS内核中的实现过于繁重!
那我们该怎么办? 为什么,我们必须抛弃阻塞式线程,这是命令式编程最强大的抽象! 我们不会使用线程来管理每个连接,而这会导致简单的命令性代码,我们将仅使用少量线程(仅与可用处理内核一样多),并且永远不会阻塞。 因此,非阻塞异步编程诞生了。
异步I / O样式在服务器端JavaScript平台Node.js的普及下,现已成为大多数现代I / O API的标准配置,有时还提供了传统的阻塞API(例如Java的标准JAX-RS API) RESTful服务的构建和使用提供了阻塞和异步形式)。
作为一个简单(且相当人为)的运行示例,让我们考虑通过执行计算和查询财务数据来执行财务操作的服务。 使用Java 8语法,假设该服务公开了一个(也有些人为设计的)API,该API包含一个方法:double compute(String op, double x)
。 然后作为示例,我们可以在computePensionPlan
或computeApartmentValue
操作中使用它:
try {double result;result = compute("USD -> euro", 100.0);result = compute("subtractTax", result * 1.3);result = compute("addInterest", result);publish(result);} catch(Exception ex) {log(ex);}
但是,该计算可能需要咨询远程金融数据服务以便检索当前的汇率,利率和税率,因此可能会暂时阻塞线程。 由于我们的代码是可能为许多用户服务的SaaS软件的一部分,因此我们不希望阻塞为请求提供服务的线程,因为正如我们所说,线程池的大小是有限的。
相反,让我们异步并使用回调。 API方法现在如下所示:
void compute(String op, double x, BiConsumer<Double, Exception> callback)
,我们的代码变为:
compute("USD -> euro", 100.0, (result, ex) -> {if (ex != null)log(ex);elsecompute("subtractTax", result * 1.3, (result, ex) -> {if (ex != null)log(ex);elsecompute("addInterest", result, (result, ex) -> {if (ex != null)log(ex);elsepublish(result);});});});
现在那不漂亮。 我们为服务提供了一个回调,而不是阻塞等待响应的线程,该回调将在操作完成时被调用。 一旦完成,我们可能会发出另一个操作,并为其提供另一个回调,依此类推。 这很快变得难以处理,因此这种基于回调的异步编程样式正确地赢得了“回调地狱”的称号。
我不会提供并行发出多个操作然后将其结果合并的示例-同步阻塞样式和异步样式都可以这样做,但是需要进行进一步讨论,这会使我们走得太远。 可以这么说,当并行管理多个I / O请求时,阻塞方法的简单性与异步方法难以理解的混乱之间的差距要大得多,而异步方法则需要相当复杂的并发同步构造。
莫纳德救援!
在没有我们的主要抽象的情况下,PFP处于这种茫然,漫不经心的游荡,迷失的状态下,找到了我们,以新的上帝,PFP的上帝–强大的monad的形式提供了帮助。 使用monad,我们可以将回调链接为计算中的各个阶段,而无需丑陋,不可读的嵌套。
用于使用monadic合成来链接I / O处理而不阻塞内核线程的库已经以命令式语言如雨后春笋般涌现,其中包括漂亮的ReactiveX (C#,Java,JS), Node.js Promises (JS),Facebook的C ++ Futures 。 Java 8中引入了CompletionStage(以及实现该功能的CompletableFuture),从而在JDK中找到了自己。
让我们看看Java 8的CompletionStage
如何为我们的金融服务提供帮助。 API现在看起来像这样:CompletionStage<Double> compute(String op, double x)
。 现在,compute
方法不再返回简单的双精度值,而是返回将结果包装在上下文中的单子值。 此特定上下文表示该值可能不会立即可用,而只有在冗长的操作完成后才可以访问。 现在,我们的代码如下所示:
compute("USD -> euro", 100.0).thenCompose(result -> compute("subtractTax", result * 1.3)).thenCompose(result -> compute("addInterest", result)).whenComplete((result, ex) -> {if (ex != null)log(ex);elsepublish(result);});
您看到单子组合了吗(提示:thenCompose
)? 这比单纯的基于回调的方法要好得多。 注意异常是如何传播的,只需要最后处理即可; 单子可以做到这一点。
因此,现在单子语在命令式语言中风靡一时(或者说,单子式风格的构成,因为关于它们是否是实际单子语存在一些争议;包装器CompletionStage
可能不是真正的价值,但是让我们离开这门学术性知识吧)实际上,尽管在大约二十年前的1996年Haskell中将monad作为核心抽象引入了monad,并且仅在过去的几年中才将其引入命令式语言,与在生产中使用Haskell的总人数相比,现在仅使用Java(或JavaScript)的单子合成的人数就更多了。
Monads问题(命令式语言)
尽管这种monadic(或类似monad)的方法比嵌套回调要好得多,但我发现它不适用于命令式编程。 即使我们超越了尘世的务实观点,也忽略了以下事实:这种样式要求我们放弃我们在代码中使用的几乎所有与I / O相关的API,而用新的(大多是不存在的且肯定是非标准的)替代它们API-即使我们回到基础知识并考虑我们交易的基本工具-我们也会发现严重的问题。
假设我们想这样做:
result = compute("USD -> euro", 100.0);while (!satisfactory(result)) {result = compute("subtractTax", result * 1.3);result = compute("addInterest", result);}publish(result);
在实际的财务用例中,此循环可能没有多大意义,但请原谅我对财务的无知,而只是将其视为实现异步循环所面临挑战的例证。 我们如何用monad做到这一点-不是在Haskell中,而是在Java / JS / C ++中? 可以做到,但是可能需要引入另一个命名函数或类似的技巧。 最重要的是,我们最好不要使用循环。
接下来,例外。 当我们确实使用传统的异常类型时,我们将它们视为值,完全忽略了如何引发和捕获异常。 正如我们将看到的,这使PFP员工感到高兴,但我们却没有。 如果compute要发信号通知错误,则不得抛出异常。 这不仅会违反单子函数没有副作用的要求,而且实际上甚至根本不起作用! 我们的线程不直接调用计算(第一次调用除外)。 然后,根据CompletionStage
的特定实现,将在某些工作线程中引发异常。 不,为了发出异常信号,compute
必须返回新的CompletableFuture().completeExceptionally(new Exception("bummer"))
。 如果愿意,可以将上述语句包装在一个名为throwish
的函数中,并进行throwish(new Exception("bummer"))
。 问题在于我们的客户代码不能使用try / catch; 简而言之-也不例外。
在放弃线程并阻塞之后,我们现在也放弃了命令式控制流(当然是其中的一些)和异常,并且需要使用库调用来复制简单的内置操作链,控制流和错误处理! “所以呢?” 会说PFP人。 “我们没有循环和异常,我们仅对monad和递归进行了改进!您应该感谢我们将这些基本语言概念抽象为高尚的高阶运算。”
这就是为什么我认为这种嫁接外国概念不合适的原因。 并不是因为现在大多数核心语言结构都以一种不兼容的方式复制,当我们从一个“世界”移动到另一个世界时,迫使我们包装和翻译事物,即一个单子世界中的一个和外面的一个世界,而是因为这些结构都与命令式编程的核心抽象(线程)交织在一起,这赋予了它们强大的功能。
考虑例外。 命令式语言中的异常不仅是表示错误的值; 它还带有上下文,可以告诉我们确切的错误发生位置。 它从哪里得到上下文? 从线程的堆栈。 但是,假设我们的单子计算在其其中一个阶段遇到错误,并将其渗透到单子链上供我们记录。 检查日志不会揭示在将代码作为computePensionPlan
操作或computeApartmentValue
操作的一部分调用时是否发生了故障; 堆栈上下文丢失。
堆栈上下文的丢失不仅对事后调试产生影响,而且对性能分析也有影响。 假设我们通过分析器运行程序,发现计算占用了应用程序90%的时间。 当我们从computePensionPlan
或computeApartmentValue
调用compute时,大部分时间都花在了吗? 我们永远不会知道,因为这些信息会丢失。 我们只会看到从某个匿名线程池进行的大量计算调用,而不知道为什么调用它们的位置以及调用它们的位置。
除了失去有价值的调试和配置信息之外,我们还失去了旧的阻塞样式给我们带来的其他更微妙的好处。 即使我们不再嵌套回调,当使用单子组合时,每个计算阶段仍然是回调:我们将前一阶段的结果作为函数参数而不是函数的返回值来接收。 代替:result = compute(...); doStuff(result); we have result -> doStuff(result)
result = compute(...); doStuff(result); we have result -> doStuff(result)
result = compute(...); doStuff(result); we have result -> doStuff(result)
。 两者之间的区别分别称为PullAPI与PushAPI。 为了说明为什么如此重要,让我们看一下以下情况。 我们有一个提供消息流的库。
该库可能会公开pull API:
m = receive()
或推送API:
onReceive(m) { ... }
两种风格都是对偶的,即彼此之间存在恒定的转换。 我鼓励您观看ReactiveX库的发明者Erik Meijer 撰写的内容丰富的演讲“ Contravariance是Covariance的对偶”,它详细探讨了这种对偶性。 但是,尽管推和拉毫无疑问是双重的,但我个人认为,相反,我认为对于埃里克·迈耶来说,拉总是比命令式语言中的推更好。
首先,它比较笼统。 如果该库为我们提供了pull API,并且由于某种原因我们想使用push API,则可以将前者轻松转换为后者:
while(true)onReceive(receive());
另一方面,如果不引入其他数据结构(队列),就无法将推式API转换为拉式API。
其次,pull API向程序员传达了更多信息,程序员确切地知道哪个线程将接收消息-称为接收的线程。 对于push API,事情并不是那么简单。 消息传递库是否总是在同一线程上调用onReceive? 如果不是,可以同时调用onReceive吗? 这些问题的答案不是立即显而易见的,但必须从库的(希望记录的)语义中收集。
最后,拉式方法将更多信息传达给图书馆本身。 调用m = receive()包含两个信号。 一个从我们的代码到库(调用接收),另一个从库回到我们的代码(返回结果)。 第一个信号很重要,因为它会自动产生背压。 消息库不会比我们处理消息的速度快。 只有在我们准备好并准备好要求时,我们才会收到一条消息。 推送方法不是这种情况。 确实,针对基于推送的API的JVM和JS的新标准称为“ 响应流 ”引入了显式的反压,而在拉方法中,这种自动处理对读取代码的任何人都是显而易见的。
推送API没有优点,也有很多缺点,即使使用pull API,我们仍然可以在消息流上使用相同的功能转换(例如,在功能性React式编程中使用的转换,例如RxJava)。 实际上,这些转换可能会更加强大(该讨论超出了本文的范围,但请参见此处的示例)。
总而言之,在命令式代码中使用单子组合时,我们:
失去使用所有现有API的能力,并将所有代码改型为新的外国风格。 在语言中已经内置的那些构造之上,在monadic库中重新创建控制流和异常。 调试和性能分析时丢失堆栈上下文。 丢失穿线信息和自动背压。
Monad很棒-对于PFP语言。 这些语言具有为monads量身定制的优雅语法,并且在它们之上实现了所有命令式计算。 他们只对单子例外。 I / O仅适用于monad。 在PFP中,单子是非常抽象的,用于很多目的。
在命令式语言中,它们只是用来补充另一种痛苦的一种痛苦。 我们正在两全其美,为什么呢? 仅一件事:它们使异步编程(基本上是无阻塞线程)变得不那么难看。 如果仅执行线程操作,那么没有人会以命令式语言在I / O代码中普及monad。
我们可以做得更好,更好
事实是,线程是一个简单而美丽的抽象。 –他们的唯一问题是内核对该抽象的特定实现过于繁重,无法支持现代服务器的需求。
为什么要放弃良好的抽象(命令式编程的核心抽象及其软件并发的基本单元),而采用由外来文化构建的,由于实施不充分而出于不同原因的外来抽象呢? 我们不能只修复实现吗? 事实证明我们可以做到,而且也不难。
操作系统提供的线程具有很高的RAM开销以及大量的调度成本的原因是它们必须完全通用。 它们必须支持所有语言以及行为迥异的行为,从一个线程播放视频或一种运行游戏的物理模拟,到一种服务于HTTP请求的语言。 如果针对特定的运行时/语言,我们可以针对较窄的用例进行控制和优化调度,则可以将RAM消耗和调度成本降低到几乎为零(相对于异步样式),同时仍保留这种有用的核心抽象。
如果我们针对特定的运行时,则可以使用可扩展的小型堆栈,而不是使用操作系统提供的固定大小的大型堆栈。 如果我们设计用户模式线程以最佳地处理以短暂的工作突发为特征的事务服务工作负载,然后触发其他线程并进行阻塞,则可以使用工作窃取调度程序,它特别适合这种行为通过产生正确的CPU缓存行为。
这正是Erlang,Go和Quasar中用户模式线程,又名轻量级线程,也就是光纤的设计– Parallel Universe的JVM开源光纤库。 Fibers是一种用户模式线程实现,使线程几乎可以自由创建和阻塞,它包含了命令式编程的核心抽象,而不以任何方式妨碍使用适当的功能技术,并且可以简单地优化其实现。 Fibers的低开销使这些轻量级线程再次适合作为直接对域的并发单元建模的并发软件单元。 分配光纤以处理单个HTTP请求,如果需要某种并行性,甚至可以分配多个光纤,都没有问题。 通过允许几乎无限数量的线程,并发变得更加简单,因为无需费力避免命令式编程的核心抽象。 您可以阻止所有想要的操作而不必担心抽象的成本。 我们可以使用该语言提供的循环和异常,并获得堆栈跟踪。 最重要的是,我们可以继续使用与线程阻塞相同的API。 我们的代码再次变为:
try {double result;result = compute("USD -> euro", 100.0);result = compute("subtractTax", result * 1.3);result = compute("addInterest", result);publish(result);} catch(Exception ex) {log(ex);}
其性能与单声道版本一样好。
光纤实现也可用于Node.js,Python和其他语言。 C#和ClojureScript分别具有async / await和core.async功能(即JVM上的Clojure可以使用Quasar的真正光纤),它们具有“半”光纤(即,通过无堆栈连续实现,可以暂停计算,但仅捕获单个堆栈帧)。 。
光纤并非完全没有问题。 它们是为特定的运行时量身定制的,因此,如果低级C代码的调用被阻止,则调用可能会干扰其操作(与C的互操作是Mozilla的Rust语言决定选择内核线程实现而不是用户空间线程退出的原因之一。的盒子)。 调试器和分析器之类的工具需要了解运行时对光纤的使用,尽管这是一个相对简单的问题要解决。
当将带有Quasar的光纤引入本机不支持它们的平台(JVM)时,我们面临的最大问题是需要使库了解它们。 这并不难-任何公开异步,基于回调的API的库都可以很容易地包装起来 ,以光纤友好的方式公开其阻塞API。 实际上,任何单子函数都可以自动转换为光纤阻塞函数。 例如,当使用Quasar时,调用get(compute(...))会将compad的monadic版本转换为光纤阻塞版本。 同样,我们已经包装了许多I / O库,因此它们中的许多都可以与光纤一起使用,而无需任何额外的工作。 直到现在,异步I / O库中投入的所有工作都得到了充分利用,并且具有更好的API和组成。
结论
我无意比较和对比PFP和命令式编程的优缺点; 他们每个人都有很多。 但是,它们构成一系列计算步骤的方法根本不同,并且与每个方法所基于的哲学紧密相关。 将PFP的单子组成方法用于命令式语言会产生两个方面的最坏情况:它不能完全享受纯环境中存在的优雅的单子,并且阻止我们享受命令式编程的优势以及与其他命令性代码的互操作性。 更糟糕的是,导入PFP抽象的唯一原因不是由于缺少更合适的抽象(线程),而是由于其通用实现中的缺陷,该缺陷在大多数命令运行时中很容易通过引入PDM来解决。纤维。
有关更多讨论,请参见从命令式到纯函数式,然后退回:Monads与作用域连续性
翻译自: /articles/Dont-graft-Monads-onto-Imperative-Languages/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1
命令式语言编程