首页 » 99链接平台 » Scala函数式编程:1 什么是函数式编程?(函数程序副作用表达式信用卡)

Scala函数式编程:1 什么是函数式编程?(函数程序副作用表达式信用卡)

南宫静远 2024-11-02 18:03:41 0

扫一扫用手机浏览

文章目录 [+]

本章涵盖

了解函数式编程的好处定义纯函数参考透明度、纯度和替换模型

函数式编程(FP)基于一个简单的前提,具有深远的影响:我们仅使用纯函数构建程序 - 换句话说,没有副作用的函数。
但是什么是副作用?如果函数执行的操作不是简单地返回结果,则函数会产生副作用。
例如,这包括以下情况:

修改变量就地修改数据结构在对象上设置字段引发异常或因错误而停止打印到控制台或读取用户输入读取或写入文件在屏幕上绘图

我们将在本章后面提供副作用的更精确定义,但请考虑如果没有执行这些操作的能力或对这些操作何时以及如何发生有重大限制的编程会是什么样子。
可能很难想象。
怎么可能写出有用的程序呢?如果我们不能重新分配变量,我们将如何编写像循环这样的简单程序?如何处理更改的数据或处理错误而不引发异常?我们如何编写必须执行输入/输出(I / O)的程序,例如绘制到屏幕或从文件中读取?

Scala函数式编程:1 什么是函数式编程?(函数程序副作用表达式信用卡) 99链接平台
(图片来自网络侵删)

答案是,函数式编程限制了我们如何编写程序,而不是我们可以表达的程序
在本书的过程中,我们将学习如何表达我们所有的程序而没有副作用,包括执行I / O,处理错误和修改数据的程序。
我们将了解遵循FP的纪律是如何非常有益的,因为我们从纯函数编程中获得的模块化的增加。
由于纯函数的模块化和无副作用,纯函数更易于测试、重用、并行化、泛化和推理。
此外,纯函数更不容易出现错误。

在本章中,我们将研究一个简单的副作用程序,并通过消除这些副作用来演示FP的一些好处。
我们还将更广泛地讨论FP的好处,并定义两个重要概念:参考透明度替换模型

1.1 理解函数式编程的好处

让我们看一个示例,该示例演示了使用纯函数编程的一些好处。
這裡的重點只是為了說明一些基本的想法,我們將在整本書中回到這些思想。
这也是你第一次接触Scala的语法。
我们将在下一章更深入地讨论 Scala 的语法,所以不要太担心遵循每个细节。
本章的目标只是对代码的作用有一个基本的了解。

1.1.1 有副作用的程序

假设我们正在实施一个程序来处理咖啡店的购买。
我们将从一个 Scala 31 程序开始,该程序在其实现中使用副作用(也称为不纯程序)。
在这一点上不要太担心 Scala 语法;我们将在下一章中仔细研究这一点。

清单 1.1 带有副作用的 Scala 程序

class Cafe: ① def buyCoffee(cc: CreditCard): Coffee = ② val cup = Coffee() ③ cc.charge(cup.price) ④ cup ⑤ class CreditCard: ⑥ def charge(price: Double): Unit = ⑦ println("charging " + price) ⑧ class Coffee: val price: Double = 2.0 val cc = CreditCard() ⑨val cafe = Cafe()val cup = cafe.buyCoffee(cc)

(1)class关键字引入了一个类,就像在Java中一样。
类的成员缩进一级。

(2)由def关键字引入类的方法。
cc:信用卡定义一个名为 cc 的数据类型为信用卡的参数。
buyCoffee 方法的 Coffee 返回类型在参数列表之后给出,方法主体由一个缩进在 = 符号后的块组成。

(3) 不需要分号。
换行符分隔块中的语句。

(4)实际向信用卡收费的副作用

(5)我们不需要说回归。
由于 Cup 是块中的最后一个语句,因此会自动返回。

(6) 在与 Cafe 相同的缩进级别定义的另一个类定义

(7)收费方法的返回类型是Unit,这就像Java中的void。
返回 void 的方法通常具有副作用。

(8) 我们通过打印到控制台来模拟这里的收费,但在真实程序中,这将与信用卡公司交互。

(9) 信用卡和咖啡馆类的实例是通过在其名称后添加括号来创建的。

行cc.charge(cup.price)是副作用的一个例子。
向信用卡收费涉及与外界的一些互动;假设它需要通过某些网络服务联系信用卡公司,授权交易,向卡收费,并(如果成功)保留一些交易记录以供以后参考。
但是我们的函数只返回一个咖啡,而这些其他动作发生在侧面(因此称为副作用)。
同样,我们将在本章后面更正式地定义副作用。

由于这种副作用,代码很难测试。
我们不希望我们的测试实际联系信用卡公司并向信用卡收费!
这种缺乏可测试性表明设计发生了变化;可以说,信用卡不应该知道如何联系信用卡公司来执行其中的收费,也不应该知道如何在我们的内部系统中保留这笔费用的记录。
我们可以通过让信用卡对这些问题一无所知并将 Payments 对象传递给 buyCoffee 来使代码更加模块化和可测试。

清单 1.2 添加支付对象

class Cafe: def buyCoffee(cc: CreditCard, p: Payments): Coffee = val cup = Coffee() p.charge(cc, cup.price) cup class CreditCard ① trait Payments: ② def charge(cc: CreditCard, price: Double): Unit ③ class SimulatedPayments extends Payments: ④ def charge(cc: CreditCard, price: Double): Unit = println("charging " + price + " to " + cc) class Coffee: val price: Double = 2.0 val cc = CreditCard()val p = Payments()val cafe = Cafe()val cup = cafe.buyCoffee(cc, p)

(1)信用卡类不再有任何方法,所以我们不需要类名后面的冒号。

(2)特质关键字引入了一个新的界面。
特征比其他语言的接口更强大,但我们将在本书后面介绍这些细节。

(3) 我们在支付特征上定义收费方式的签名。
然后,付款的实现必须提供收费行为。

(4) 类使用 extend 关键字实现 Payments 特征。

我们已将收费逻辑提取到支付界面中,实质上是使用依赖注入
虽然当我们调用p.charge(cc,cup.price)时仍然会出现副作用,但我们至少恢复了一些可测试性。
我们可以编写适合测试的支付接口的存根实现,但这也不理想。
我们被迫将 Payments 作为一个接口,否则一个具体的类可能很好,任何存根实现使用起来都会很尴尬。
例如,它可能包含一些内部状态,跟踪已提出的费用。
我们必须在调用 buyCoffee 后检查该状态,并且我们的测试必须确保此状态已被调用充电适当修改(更改)。
我们可以使用模拟框架或类似的框架来为我们处理这个细节,但是如果我们只想测试 buyCoffee 会产生等于一杯咖啡价格的费用,这一切都感觉有点矫枉过正。
阿拉伯数字

除了测试的担忧之外,还有另一个问题:很难重用buyCoffee。
假设客户 Alice 想要订购 12 杯咖啡。
理想情况下,我们可以为此重复使用buyCoffee,也许在一个循环中调用它12次。
但根据目前的实施,这将涉及联系支付系统12次,授权对爱丽丝的信用卡进行12次单独的收费!
这增加了更多的处理费用,对爱丽丝或咖啡店不利。

我们能做些什么呢?我们可以编写一个全新的函数, 买咖啡 ,具有用于批处理费用的特殊逻辑。
3 在这里,这可能没什么大不了的,因为 buyCoffee 的逻辑是如此简单,但在其他情况下,我们需要复制的逻辑可能并不平凡,我们应该哀悼代码重用和组合的损失!

1.1.2 功能性解决方案:消除副作用

功能解决方案是消除副作用,并让buyCoffee除了返回咖啡之外,还以值的形式返回费用。
通过将其发送给信用卡公司来处理费用、保留记录等问题将在其他地方处理。
功能解决方案可能如下所示:

class Cafe: def buyCoffee(cc: CreditCard): (Coffee, Charge) = ① val cup = Coffee() (cup, Charge(cc, cup.price)) ②

(1) buyCoffee 现在返回一对咖啡和收费,用类型(咖啡,收费)指示。
这里根本不涉及处理付款的系统。

(2)要创建一对,我们将杯子和Charge放在括号中,用逗号分隔。

在这里,我们将创建费用的关注与该费用的处理解释分开。
buyCoffee 函数现在将 Charge 作为值与 Coffee 一起返回。
我们将很快看到这如何让我们更轻松地重复使用它,通过单笔交易购买多种咖啡。
但什么是收费?这是我们刚刚发明的一种数据类型,包含信用卡和配备方便功能的金额,组合,用于将费用与同一张信用卡合并:

case class Charge(cc: CreditCard, amount: Double): ① def combine(other: Charge): Charge = if cc == other.cc then ② Charge(cc, amount + other.amount) ③ else throw Exception("Can't combine charges with different cards") ④

(1) 案例类有一个主构造函数,其参数列表位于类名之后(在本例中为 Charge)。
此列表中的参数成为类的公共、不可修改(不可变)字段,并且可以使用通常的面向对象的点表示法进行访问,如 other.cc。

(2) if 表达式的语法与 Java 中的语法相似,但它也返回一个等于所采用分支的结果的值。
如果 cc == other.cc,则组合将返回 Charge(..);否则,将引发 else 分支中的异常。

(3) 可以在没有关键字 new 的情况下创建案例类。
我们只使用类名,后跟其主构造函数的参数列表。

(4)抛出异常的语法与Java和许多其他语言中的语法相同。
我们将在后面的章节中讨论处理错误条件的更多功能方法。

现在让我们看一下 buyCoffees(图 1.1)来实现购买 n 杯咖啡。
与以前不同的是,现在可以在 买咖啡 ,正如我们所希望的那样。
请注意,此实现中有许多新的语法和方法,我们将在接下来的几章中逐渐熟悉这些语法和方法。

图1.1 来电买咖啡

1.3 用 buyCoffee 购买多个杯子

class Cafe: def buyCoffee(cc: CreditCard): (Coffee, Charge) = ... def buyCoffees( cc: CreditCard, n: Int ): (List[Coffee], Charge) = ① val purchases: List[(Coffee, Charge)] = List.fill(n)(buyCoffee(cc)) ② val (coffees, charges) = purchases.unzip ③ val reduced = charges.reduce((c1, c2) => c1.combine(c2)) ④ (coffees, reduced)

(3) 列表[咖啡]是一个不可变的、单向链接的咖啡值列表。
我们将在第 <> 章中详细讨论此数据类型。

(5) List.fill(n)(x) 创建一个包含 x 的 n 个副本的列表。
更准确地说,fill(n)(x) 实际上返回了一个包含 n 次连续 x 求值的列表。
我们将在第<>章中更多地讨论评估策略。

(3) 解压缩将一对列表拆分为一对列表。
在这里,我们将解构这个货币对,以在一行上声明两个值(咖啡和电荷)。

(4)我们在这里通过打印到控制台来模拟费用,但在实际程序中,这将与信用卡公司交互。

总体而言,此解决方案是一个显着的改进;我们现在可以直接重用buyCoffee来定义buyCoffees函数,并且这两个函数都可以轻松测试,而无需定义某些支付接口的复杂存根实现。
事实上,咖啡馆现在完全不知道如何处理 Charge 值。
当然,我们仍然可以有一个用于实际处理费用的支付类,但 Cafe 不需要知道它。

将 Charge 变成一流的值还有其他我们可能没有预料到的好处,因为我们可以更轻松地组装业务逻辑来处理这些费用。
例如,爱丽丝可能会把她的笔记本电脑带到咖啡店,在那里工作几个小时,偶尔购物。
如果咖啡店可以将爱丽丝的这些购买合并为一次收费,再次节省信用卡处理费用,那就太好了。
由于 Charge 是一流的,我们可以编写以下函数来合并 List[Charge] 中的任何同卡费用:

def coalesce(charges: List[Charge]): List[Charge] = charges.groupBy(_.cc).values.map(_.reduce(_.combine(_))).toList

我们将函数作为值传递给 groupBy 、map 和 reduce 方法。
在接下来的几章中,您将学习阅读和编写这样的单行代码。
_.cc 和 _.combine(_) 是匿名函数的语法,我们将在下一章中介绍。
作为预览,_.cc 相当于 c => c.cc ,_.combine(_) 相当于 (c1, c2) => c1.combine(c2) 。

您可能会发现这种代码难以阅读,因为表示法非常紧凑。
但是当你读完这本书时,像这样阅读和编写Scala代码将很快成为第二天性。
此函数获取费用列表,按使用的信用卡对费用进行分组,然后将它们合并为每张卡的单个费用。
它是完全可重用和可测试的,无需任何其他模拟对象或接口。
想象一下,尝试在我们第一次实现 buyCoffee 时实现相同的逻辑!

这只是函数式编程的好处的一个尝试,这个例子故意很简单。
如果这里使用的一系列重构看起来很自然、明显、平淡无奇或标准做法,那就太好了。
FP是一门学科,它只是将许多人认为的好主意带到其逻辑终点,即使在其适用性不太明显的情况下也应用该学科。
正如您将在本书的过程中了解到的那样,始终遵循FP纪律的后果是深远的,并且好处是巨大的。
FP 是程序组织方式在各个级别(从最简单的循环到高级程序架构)的真正根本性转变。
出现的风格完全不同,但这是一种美丽而有凝聚力的编程方法,我们希望您能欣赏。

现实世界呢?

在buyCoffee的案例中,我们看到了如何将费用的创建与该费用的解释或处理分开。
一般来说,我们将学习如何将这种转换应用于任何具有副作用的函数,以将这些效果推送到程序的外层。
函数式程序员通常将其称为实现具有纯核心和外部处理效果的薄层的程序。

但即便如此,在某些时候,我们肯定必须真正对世界产生影响,并提交收费供某个外部系统处理。
难道没有其他有用的程序需要副作用或突变吗?我们如何编写这样的程序?当我们阅读本书时,我们将发现有多少似乎需要副作用的程序具有一些功能类似物。
在其他情况下,我们将找到构建代码的方法,以便效果发生但不可观察。
(例如,如果我们确保不能在某个函数的主体中引用本地声明的数据,我们可以改变该函数主体中本地声明的数据,或者只要没有封闭函数可以观察到这种情况,我们就可以写入文件。

1.2 究竟什么是(纯)函数?

我们之前讨论过,FP指的是纯函数的编程,而纯函数是没有副作用的函数。
在我们对咖啡店示例的讨论中,我们提出了副作用和纯度的非正式概念。
在这里,我们将正式化这个概念,以更精确地指出功能编程的含义。
这也将让我们进一步了解函数式编程的好处之一:纯函数更容易推理。

输入类型 A 和输出类型 B 的函数 f(在 Scala 中写为单个类型,A => B,发音为 A 到 B)是一种计算,它将 A 类型的每个值 a 与 B 类型的一个值 b 相关联,使得 b 仅由 a 的值决定。
内部或外部过程的任何变化状态都与计算结果 f(a) 无关。
例如,类型为 Int => String 的函数 intToString 会将每个整数转换为相应的字符串。
此外,如果它真的是一个函数,它不会做任何其他事情。

换句话说,一个函数除了计算给定其输入的结果之外,对程序的执行没有可观察到的影响;我们说它没有副作用。
我们有时会将这些函数限定为函数,以使其更加明确,但这有些多余。
除非我们另有说明,否则我们通常会使用函数来暗示没有副作用。

您应该已经熟悉许多纯函数。
考虑整数上的加法(+)函数。
它接受两个整数值并返回一个整数值。
对于任何两个给定的整数值,它将始终返回相同的整数值
另一个例子是Java,Scala和许多其他语言中字符串的长度函数,其中字符串不能被修改(是不可变的)。
对于任何给定的字符串,始终返回相同的长度,并且不会发生任何其他情况。

我们可以使用引用透明度(RT)的概念来形式化纯函数的概念。
这是一般表达式的属性,而不仅仅是函数。
出于我们的讨论目的,将表达式视为程序的任何部分,可以计算结果 - 您可以在 Scala 解释器中键入并获得答案的任何内容。
例如,2 + 3 是一个表达式,它将纯函数 + 应用于值 2 和 3(也是表达式)。
这没有副作用。
此表达式的求值每次都会产生相同的值 5。
事实上,如果我们在一个程序中看到 2 + 3,我们可以简单地用值替换它 5 ,它不会改变我们程序的含义。

这就是表达式在引用上透明的全部含义 - 在任何程序中,表达式都可以由其结果替换,而无需更改程序的含义。
我们说,如果使用 RT 参数调用一个函数也是 RT,那么它就是函数。
接下来我们将看一些示例。

参考透明度和纯度

表达式 e 是引用透明的,如果对于所有程序 p,p 中 e 的所有出现都可以替换为计算 e 的结果而不影响 p 的含义。
如果表达式 f(x) 对所有引用透明的 x 都是参照透明的,则函数 f 是函数。
5

1.3 参照透明度、纯度和替代模型

让我们看看RT的定义如何应用于我们原来的buyCoffee示例:

def buyCoffee(cc: CreditCard): Coffee = val cup = Coffee() cc.charge(cup.price) cup

无论 cc.charge(cup.price) 的返回类型是什么(也许它是 Unit,Scala 在其他语言中的 void 等价物),它都被 buyCoffee 丢弃了。
因此,评估 buyCoffee(aliceCreditCard) 的结果将仅仅是杯子,这相当于一个新的 Coffee()。
为了使buyCoffee是纯净的,根据我们对RT的定义,对于任何程序p,p(buyCoffee(aliceCreditCard))的行为必须与p(Coffee())相同。
这显然不成立;程序Coffee()不做任何事情,而buyCoffee(aliceCreditCard)将联系信用卡公司并授权收费。
我们已经在两个程序之间有了明显的差异。

引用透明度强制函数执行的所有操作都由函数根据函数的结果类型返回的值表示不变。
此约束支持一种简单而自然的程序评估推理模式,称为替换模型
当表达式在引用上是透明的时,我们可以想象计算的过程很像我们求解代数方程的方式。
我们完全扩展表达式的每个部分,将所有变量替换为它们的引用,然后将其简化为最简单的形式。
在每一步中,我们用等效的术语替换一个术语;计算通过用等于代替等于来进行。
换句话说,RT支持对程序进行等式推理

让我们再看两个示例 — 一个是所有表达式都是 RT 的,并且可以推理使用替换模型,另一个是某些表达式违反 RT。
这里没有什么复杂的;我们只是在说明您可能已经理解的内容。

让我们在 Scala 解释器中尝试以下内容(也称为读取-评估-打印循环 (REPL),发音像 ripple,但用 e 而不是 i)。
解释器是一个交互式提示,让我们进入一个程序。
然后,解释器评估该程序,打印结果,并提示我们输入另一个程序。
当解释器准备好输入程序时,它会使用 scala 进行提示> .打印程序的结果时,解释器显示结果的名称、结果的类型以及程序的值。

请注意,在Java和Scala中,字符串是不可变的。
修改后的字符串实际上是新字符串,旧字符串保持不变:

scala> val x = "Hello, World"x: java.lang.String = Hello, World scala> val r1 = x.reverser1: String = dlroW ,olleH scala> val r2 = x.reverse ①r2: String = dlroW ,olleH

(1) R2 和 R<> 相同。

假设我们将术语 x 的所有出现替换为 x 引用的表达式(其定义),如下所示:

scala> val r1 = "Hello, World".reverser1: String = dlroW ,olleH scala> val r2 = "Hello, World".reverse ①r2: String = dlroW ,olleH

(1) R2 和 R<> 仍然相同。

此转换不会影响结果。
r1 和 r2 的值与以前相同,因此 x 在引用上是透明的。
更重要的是,r1 和 r2 在引用上也是透明的,所以如果它们出现在更大程序的其他部分中,它们反过来又会被替换为它们的值,并且对程序没有影响。

现在让我们看一个引用透明的函数。
考虑 java.lang.StringBuilder 类上的 append 函数。
此函数在 StringBuilder 上运行。
字符串生成器的先前状态在调用追加 .让我们试试这个:

scala> val x = new StringBuilder("Hello")x: java.lang.StringBuilder = Hello scala> val y = x.append(", World")y: java.lang.StringBuilder = Hello, World scala> val r1 = y.toStringr1: java.lang.String = Hello, World scala> val r2 = y.toStringr2: java.lang.String = Hello, World ①

(1) R2 和 R<> 相同。

目前为止,一切都好。
假设我们像之前一样替换追加调用,将 y 的所有出现替换为 y 引用的表达式:

scala> val x = new StringBuilder("Hello")x: java.lang.StringBuilder = Hello scala> val r1 = x.append(", World").toStringr1: java.lang.String = Hello, World scala> val r2 = x.append(", World").toStringr2: java.lang.String = Hello, World, World ①

(1) R2 和 R<> 不再相同。

程序的这种转变导致了不同的结果。
因此,我们得出结论,StringBuilder.append 是一个纯函数。
这里发生的事情是,尽管 r1 和 r2 看起来像是同一个表达式,但它们实际上引用了同一个 StringBuilder 的两个不同值。
当 r2 调用 x.append 时,r1 已经改变了 x 引用的对象。
如果这看起来很难想,那是因为它确实如此。
副作用使对程序行为的推理更加困难。

相反,替换模型很容易推理,因为评估的影响纯粹是局部的(它们只影响被评估的表达式),我们不需要在脑海中模拟状态更新序列来理解代码块。
理解只需要局部推理
我们不需要在脑海中跟踪函数执行之前或之后可能发生的所有状态变化来了解我们的函数将做什么;我们只需查看函数的定义,并将参数代入其主体。
即使您没有使用名称替换模型,在考虑代码时也肯定使用了这种推理模式。
6

以这种方式形式化纯度的概念可以深入了解为什么函数式程序通常更加模块化
模块化程序由可以独立于整体理解和重用的组件组成,因此整体的含义仅取决于组件的含义和管理其组成的规则;也就是说,它们是可组合的
纯函数是模块化和可组合的,因为它将计算本身的逻辑与如何处理结果以及如何获取输入分开;这是一个黑匣子。
输入仅以一种方式获得:通过函数的参数。
输出只是简单地计算和返回。
通过将这些问题中的每一个分开,计算的逻辑更具可重用性;我们可以在任何地方重用逻辑,而不必担心对结果执行的副作用或请求输入的副作用是否适用于所有上下文。
我们在buyCoffee的例子中看到了这一点:通过消除使用输出进行支付处理的副作用,我们更容易重用函数的逻辑,无论是为了测试和进一步的组合(就像我们编写buyCoffees和coalesce一样)。

1.4 结论

在本章中,我们介绍了函数式编程,并解释了FP是什么以及为什么可能使用它。
虽然在本书的过程中,函数式风格的全部好处将变得更加清晰,但我们用一个简单的例子说明了FP的一些好处。
我们还讨论了引用透明度和替换模型,并讨论了FP如何使程序推理更简单,模块化程度更高。

在本书中,您将学习FP的概念和原理,因为它们适用于编程的每个级别,从最简单的任务开始并在此基础上进行构建。
在后续章节中,我们将介绍一些基础知识:我们如何在 FP 中编写循环?我们如何实现数据结构?如何处理错误和异常?我们需要学习如何做这些事情,并熟悉FP的低级习语。
当我们在第 2、3 和 4 部分中探索功能设计技术时,我们将基于这种理解。

总结函数式编程是仅使用纯函数(没有副作用的函数)来构造程序。
副作用是函数除了简单地返回结果之外所做的操作。
副作用的示例包括修改对象上的字段、引发异常以及访问网络或文件系统。
函数式编程限制了我们编写程序的方式,但并不限制我们的表达能力。
副作用限制了我们理解、编写、测试和重构部分程序的能力。
将副作用移动到程序的外边缘会产生纯核心和薄的外层,从而处理效果并导致更好的可测试性。
引用透明度定义表达式是纯表达式还是包含副作用。
替换模型提供了一种测试表达式是否具有引用透明的方法。
函数式编程支持局部推理,并允许在较大的程序中嵌入较小的程序。

1 在本书中,我们使用 Scala 3,它在某些情况下与 Scala 2 有很大不同。
本书中探讨的概念与 Scala 3 和 Scala 2 相关,但将代码片段移植到 Scala 2 需要一些语法转换。

2 一些模拟框架甚至支持模拟具体类,这使我们能够完全避免创建支付接口,而是拦截对信用卡类收费的调用。
这种技术有各种权衡,我们在这里不探讨。

3 我们还可以编写一个专门的 BatchingPayments 接口实现,以某种方式尝试将连续收费批处理到同一张信用卡。
不过,这变得很复杂。
它应该尝试批量收取多少费用?它应该等多久?我们是否强制 buyCoffee 指示批处理已完成,也许通过调用 closeBatch ?无论如何,它怎么知道什么时候适合这样做呢?

4 过程通常用于引用一些可能有副作用的参数化代码块。

5 這個定義有一些微妙的地方,我們將在本文的後面加以完善。
有关更多讨论,请参阅我们 GitHub 站点 (https://github.com/fpinscala/fpinscala/wiki) 上的章节注释。

6 在实践中,程序员不会花时间机械地应用替换来确定代码是否纯 - 这通常是显而易见的。

标签:

相关文章