F# 中的纯函数式编程
成分
互动
未选择的路
结论
结构化编程与函数式编程的结合
最近一段时间,我一直在用 F# 练习简化版的函数式编程。到目前为止,这种风格的函数式编程对我的同事来说相对容易上手,而且维护起来也相当方便。接下来,我将详细解释我是如何保持这种风格的。
除非另有说明,所有代码均采用 F# 编写。
成分
函数式编程的核心在于用数据类型和函数解决问题。这两个元素以三种源自结构化编程的基本方式进行交互:排序、选择和迭代。
数据类型
数据类型特指记录类型和可区分联合类型。你可以将这两种类型组合在一起,来表示几乎任何数据结构。
🔔 不使用类
。我不使用面向对象类编写代码。F# 传达的信息是,在合理的情况下使用对象编程是可以的。但我只在需要与现有 .NET 库集成时才使用对象。
记录
记录用于将多条数据捆绑在一起。它们是不可变的(一旦创建就无法更改),并且默认情况下具有值相等性。例如:
type Person =
{
FirstName: string
LastName: string
}
的每个实例Person
都同时包含FirstName
和LastName
。如果不提供这两条数据,则无法创建人员记录。
let joebob = { FirstName = "Joe"; LastName = "Bob" }
let jimbob = { FirstName = "Jim"; LastName = "Bob" }
此外,记录具有“更新”语法。由于原始记录无法更改,此更新语法会根据您指定的属性创建包含不同数据的记录新副本。例如,我们可以jimbob
像这样定义:
let joebob = { FirstName = "Joe"; LastName = "Bob" }
// jimbob created with update syntax
let jimbob = { joebob with FirstName = "Jim" }
// { FirstName = "Jim"; LastName = "Bob" } = jimbob
歧视联盟
DU 用于表示几种不同可能性中的一种。每种 DU 情况可以携带不同类型的数据。Scott Wlaschin 在他的DDD 视频中给出了一个很好的例子,那就是支付信息。
type PaymentType =
| Cash
| Check of checkNumber:int
| CreditCard of cardNumber:string * expiration:string
可能有几种不同的付款方式,但给定的付款方式只能是其中一种。
let payment1 = Cash
let payment2 = Check 12345
// even though payment1 and payment2 are different cases,
// they are the same type
let payments : List<PaymentType> =
[ payment1; payment2 ]
🔔 与其他语言概念的比较:
DU 的用途与其他语言中的枚举非常相似。区别在于枚举不能携带任何额外数据。而 DU 的用例可以各自保存不同类型的数据。在面向对象编程中,此数据类型类似于具有浅继承的抽象类。区别在于,每个子对象通常都携带数据和行为,而 DU 类型仅携带值。
功能
函数接收值,执行一些计算,然后返回值。关于函数,我想提出一个主要区别:函数是否具有确定性?确定性意味着函数不产生任何副作用,其输出值仅取决于其输入值。确定性函数也被称为“纯函数”或“引用透明函数”。
这是 F# 中非确定性函数与确定性函数的一个很好的例子。
// non-deterministic
let sortableTimeStamp () =
DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmss")
// deterministic
let sortableTimeStamp (now : DateTimeOffset) =
now.UtcDateTime.ToString("yyyyMMddHHmmss")
第一个函数每次都会给出不同的结果,因为它读取的是不断变化的全局时钟。但第二个函数每次输入相同的输入都会给出相同的结果。因此,它非常容易测试。
总是需要一些具有副作用的函数。但所有重要的决策都应该由确定性函数来做出。这样,它们的行为就可以通过简单的测试来验证。
这个想法听起来很简单,但生活很少是一帆风顺的。我经常发现我需要将副作用与决策交织在一起。例如,做出初步决策,然后加载一些数据(副作用),然后使用这些数据做出进一步的决策。寻找实现确定性决策目标的方法是一个有趣的旅程。我有一个策略,我会在以后的文章中介绍。那篇文章可以在这里找到。
互动
类型和函数是基本要素。它们之间有着显而易见的核心交互。也就是说,你将值传递给函数,函数就会返回一个值。除此之外,我们还可以使用结构化编程中定义的交互来建模程序。让我们来详细了解一下。
结构化编程
根据结构化程序设计定理,有3种基本的编程模式:
- 顺序执行——先执行一条语句,再执行另一条语句
- 选择 - 又称分支,通常使用
if
和switch
/case
- 迭代 - 又称循环,通常使用
for
和while
这三者的组合足以表达任何可计算函数。该定理最初是在命令式编程中发展起来的。为了适应函数式编程,需要进行一些翻译。
语句与表达式
结构化编程的设计初衷是使用“语句”。这些语句会产生副作用——尤其是在修改变量时。但在函数式编程中,我们使用表达式而不是语句。主要区别在于,表达式总是返回一个值,而语句会修改变量并返回 void。即使return
在命令式编程中,也存在一个执行修改(设置堆栈框架的返回值)并退出的语句。
基于表达式的函数调用会说:这里有一些值——计算一个答案并返回给我。而基于语句的函数调用会说:这里有一些位置,从这些位置读取值进行计算,并将结果放在另一个位置,完成后我会检查结果位置。
当然,现代命令式语言已经抽象出了基于位置的思维,并且似乎已经足够接近返回值了。但核心语言原语仍然需要你运行语句,从而鼓励基于变异的思维。相反,在 F# 中,所有函数都会返回值,这种做法非常普遍,以至于*return
不再是一个标准的关键字。因为这就像每次呼气时都说“呼气”一样。
*从技术上讲,
return
是 F# 中的一个功能级关键字,仅在计算表达式中使用。否则,它隐含地返回一个值。为了进行比较:// C# int add(int a, int b) { return a + b; } // add(1,2) == 3
// F# let add a b = a + b // add 1 2 = 3
测序
在结构化编程中,排序意味着一条语句接一条语句地运行。为了理解函数式编程的排序,我必须先解释一下语句和表达式的区别。在命令式语言中,顺序非常明显。在代码块中,我可以执行任意数量的语句。但在函数式编程中,表达式总是返回一个值。这导致了两种主要的“排序”表示方式:let
语句和管道。
和let
let calcHyp a b =
let aSquared = a ** 2.0
let bSquared = b ** 2.0
let cSquared = aSquared + bSquared
sqrt cSquared
// calcHyp 3.0 4.0 = 5.0
上面,所有缩进的代码都calcHyp
被视为单个表达式。每个中间值都使用关键字命名let
。最后一行是返回值。
带管道
|>
下面是使用管道运算符 ( ) 执行一系列步骤的示例。
let totalLength stringList =
stringList
|> List.map String.length
|> List.sum
// totalLength [ "foo"; "bazaar" ] = 9
管道运算符 ( |>
) 会获取其前面的内容,并将其作为最后一个参数传递给下一个函数。这对于处理列表非常有用,因为它们通常是人类思考步骤顺序的方式。另一种方法可能更难阅读:
// without pipes
let totalLength stringList =
List.sum (List.map String.length stringList)
选择
在结构化编程中,通常会使用“选择”或分支语句,在特定条件下执行一组语句,否则执行另一组语句。这些语句通常采用if
and switch
/case
语句的形式。
因此,就像上面的“排序”一样,FP 中的分支不会执行语句。相反,每个分支都会返回一个表达式(一个值,或者返回值的代码)。
let isEven i =
if i % 2 = 0 then
"affirmative"
else
"negative"
// isEven 2 = "affirmative"
// isEven 3 = "negative"
但是正如您在“排序”中看到的那样,表达式仍然可以在分支内执行多个步骤。
let weirdIsEven i =
if i % 2 = 0 then
let digits = [ "1"; "0"; "0" ]
let s = String.concat "" digits
sprintf "%s percent affirmative" s
else
"negatory"
// weirdIsEven 2 = "100 percent affirmative"
// weirdIsEven 3 = "negatory"
F# 借助 DU 将分支功能提升到了一个全新的水平match
。您无需再费力地将数据分解成一系列布尔值检查。您可以在 DU 中声明所有分支,然后使用它们match
进行分支。
type My2dShape =
| Circle of radius:float
| Square of side:float
| RightTriangle of base:float * height:float
let area shape =
match shape with
| Circle radius ->
Math.PI * (radius ** 2.0)
| Square side ->
side ** 2.0
| RightTriangle (base, height) ->
0.5 * base * height
实际上,你可以将其用作match
唯一的分支原语。它适用于布尔值、枚举和其他原始类型。
let isEven i =
match i % 2 = 0 with
| true -> "affirmative"
| false -> "negative"
// isEven 2 = "affirmative"
// isEven 3 = "negative"
我不会在这里介绍它们,但它们确实match
具有非常强大的模式匹配功能。其中包括使用 的保护语句when
、记录/DU 分解等等。
迭代
循环遍历集合非常常见,因此有一整套预置函数可用于此目的。包括常见的map
、filter
和reduce
。
🔔
for
Fwhile
# 有命令式循环结构for
和while
。但我避免使用这些。
有些时候我需要不断执行某些操作,直到遇到退出条件。在这种情况下,我会使用递归函数。我记得在计算机科学课上我讨厌递归函数,但在函数式编程中,我发现它们并不那么繁琐。尤其是当我用确定性函数编写它们时。这里有一个简单的例子。rec
递归函数必须使用关键字。
/// Sums the first N positive integers, starting with an initial sum.
let rec sumInts sum n =
match n <= 0 with
| true ->
sum
| false ->
let nextSum = sum + n
let nextN = n - 1
sumInts nextSum nextN
// sumInts 0 3 = 6
此函数要求你提供一个初始和(此处为零)以及要求和的整数个数。有时,提供初始值并非理想情况。因此,你经常会看到将递归循环包装在隐藏初始值的外部函数中。
/// Sums the first N positive integers.
let sumInts n =
let rec loop sum i =
match i <= 0 with
| true ->
sum
| false ->
let nextSum = sum + i
let nextI = i - 1
loop nextSum nextI
// initiate the loop starting with sum = 0
loop 0 n
// sumInts 3 = 6
过去,如果迭代次数过多,递归循环可能会导致堆栈溢出错误。但此递归函数的两个版本while
在编译期间都会转换为循环。因此,它们可以循环任意次数而不会溢出。这被称为“尾调用优化”(Tail-Call Optimization,简称 TCO)。
确保你的递归函数得到尾调用优化非常容易。只需预先计算循环下一次迭代所需的值,就像我在本例中使用
nextSum
和所做的那样nextI
。然后使用这些值调用循环。
结果
我相信这种纯粹的函数式编程正是结构化编程的初衷。但是,结构化编程使得编写带有可变共享状态的问题代码变得异常容易。而命令式、基于语句的语言进一步助长了这种情况。函数式编程通过使错误变得更加困难,从而改善了这种状况。尤其当你用不可变类型和确定性函数来编写所有重要决策时,更是如此。
未选择的路
我上面描述的内容可以支持很多不同的模式。因此,我想明确列出一些我倾向于避免在我的代码库中添加的内容。
范畴论
在我在网上看到的很多函数式编程资料中,学习范畴论被认为是先决条件。但它并不是完成实际工作的必要知识。当我向开发人员展示如何对整数列表求和时,我不会先解释环理论。(你会吗?)那么,为什么我需要先解释幺半群和单子,然后再向开发人员展示如何使用reduce
和呢map
?
到目前为止,在我工作的FP部门,我们已经从零开始培训了开发人员(现在有三四名)。范畴论不在我们的培训流程中,我们也没有在代码库中为它创建抽象。而且,我们的开发人员中没有一个人注意到它的缺失。它甚至不会成为我们完成实际工作道路上的障碍。
这并不是说范畴论不重要。我确实期待开发者注意到一个模式并提出相关问题。向一颗求知若渴的头脑揭示宇宙的奥秘是多么令人兴奋啊!但实际的软件编写工作并不需要它。更糟糕的是,引入范畴论往往会让初学者的学习曲线更加陡峭,并且代码也过于精巧。
private
关键词
开发人员可能出于各种原因想要使用这个private
关键字。我会逐一介绍我能想到的几种。大多数情况下,我都找到了更好的替代方案。我甚至没有真正把它作为一个功能来介绍,所以我的开发人员还没有用过它。
关于数据
私有数据总是会创建一个对象。也就是说,私有数据必须与公共行为绑定;数据和行为绑定在一起就形成了一个对象。
私有数据通常表示为一个类,其中包含用于公共行为(用于修改私有数据)的方法。但您也可以在函数式 F# 中使用私有数据,方法是将记录或 DU 详细信息设为私有。这样,定义数据的模块就可以拥有可以访问私有数据的函数,而其他地方则无法访问。即使这是函数式表达,它仍然是行为与数据的绑定,并且由于访问模式的变化,它最终会使使用它的代码变得更加基于对象。
函数式风格相较于面向对象的优势之一是数据和行为是分离的。因此,我可以将数据与其他数据组合,也可以将行为与其他行为单独组合。但对于对象而言,一个对象中数据和行为的组合必须与另一个对象的组合组合。随着程序的增长,这种二维组合变得越来越难以理解,甚至难以解决。
因此,我们避免使用私人数据。
关于功能
我倾向于使用私有函数的常见情况是为了避免辅助函数污染命名空间。(基本上是为了限制 Intellisense 的显示内容。)你也可以使用 .FSI 文件来隐藏内容,但我觉得这样做会把一种污染转化为另一种污染(额外的文件)。因此,我通常使用嵌套模块来实现此目的。
module Cool =
module Helpers =
// stuff that won't show up
...
// shows up in intellisense as "Cool.<whatever>"
let trick x =
...
let idea y =
...
如果您习惯于面向对象编程的“封装”,您可能会指出,这实际上并没有隐藏辅助函数。它们仍然可以通过调用从外部访问Cool.Helpers.<whatever>
。您说得对,而且(对我来说)这是理想的行为。
在使用库时,尤其是在大多数库都是基于 OO 的 .NET 上,我经常会想“重新组合”一下这个库。我想使用真正执行工作的核心函数,但又想以不同的方式打包它。大多数时候,我都会失望地发现,真正执行工作的核心步骤都被隐藏了起来private
。
将函数保持公开状态,但将其合理地组织在主命名空间之外,有助于解决此问题。它避免了命名空间污染,同时实现了真正的重用。
更不用说私有函数的可测试性这一经典问题了。
结论
亲爱的读者,感谢您的耐心等待。我很高兴有机会与您分享我直截了当的函数式编程方法。我们在 Web 应用程序的前端和后端都使用它。对于新手开发人员来说,它并不难掌握。实施解决方案的过程也非常简单。它在我的团队中取得了巨大的成功。
除了这些简单的模式之外,我们还使用了其他一些模式。我强烈推荐用于 UI 编程的 MVU 模式(又名 Elm 架构)。MVU 的改编版本甚至可以用来表示后端工作流。
这篇文章很长,我肯定还遗漏了一些问题。欢迎在下方提问。
/∞
结语
我将这篇文章提交给了2019 年应用 F# 挑战赛。它被选为“开箱即用主题”类别的获奖者之一!
鏂囩珷鏉ユ簮锛�https://dev.to/kspeakman/mere-function-programming-in-f-do8