翻译自:A Beginner-Friendly Tour through Functional Programming in Scala
函数式编程的基本核心十分简单:通过组合函数(function)来构建程序。
这里,“function”并非指“计算机科学”中的函数,它指一段机器代码,而是一个“数理函数(mathematical function)”:
所有这些属性,给你一种前所未有的能力来解释你的代码:调用函数并传入任何输入,你总会得到一个有效的值,而且相同的输入总是会得到相同的结果,同时函数不会再做其他任何事,比如发射核导弹….
这种微小的想法对于大型软件工程有着深刻的简化作用,因为这意味着,你的大脑只需要追踪更少的东西就能理解程序的行为。事实上,你可以通过理解程序的个别部分来理解程序的整个行为 - 而无需一次在脑子中掌握一切!
目前,函数式编程并不总是拥有一个“简单”的声誉,但我认为是由于以下几个因素:
有时函数式编程通常被关联为“高等静态类型函数式编程(advanced statically-typed functional programming)”,这时,学习“函数式编程”的人其实在一次学习两种东西:函数式编程和一个高级类型系统(多数命令式语言拥有相对简单的类型系统)。
然而,有些人可以用弱类型语言进行函数式编程,或者使用高级类型系统进行命令式编程,因此这二者并无关联。
很多函数式开发者喜欢展示我们为何对函数式编程如此兴奋。然而,我所写的大部分内容并非对函数式的好奇,甚至不是面向新手函数式开发者。相反,我写的都是我感兴趣的东西,这通常是高级函数编程的中间形式,对于那些多年来一直在进行函数式编程的人比较有用。
所以我在做一个实验:我将展示一些函数式编程的思想以帮助我们构建一些实际程序。但是我会以一种有助于前面提到的那些因素的方式进行。
这是一篇为非函数式开发者准备的文章,或者那些了解一点但想知道更多的开发者。
希望你发现它会有用 - 但相比有用,我更希望你发现它鼓舞人心。激发足够的投入和必要的努力,来推动你的函数式编程知识,尽管看起来似乎会有点难。
或者也不一定,看完这篇文章之后一切看起来都会变得很容易!
展示函数式编程能力的一个典型例子就是泛型排序函数:
1 | def sort[A: Orderig](as:List[A]):List[A] = as match{ |
这个例子很漂亮,因为它展示了函数式编程如何以如此简化的效果来表达一个程序。
看完代码,你自己可能会确认下面这几点:
该函数的每个部分都可以独立推理。如果相信一段是正确的,那么就可以相信整体的正确性。
此外,这个sort
函数,因为它是数学意义上的函数,更容易测试和复用。我们可以传入任何列表并预期得到一个排序后的列表。因为我们知道对于同一个输入,函数总是会返回相同的结果。
因此我们的测试表示起来也会非常简单:
1 | assert(sort[Int](Nil) == Nil) |
(事实上,可以以函数式编程的方式来更加有力的表示,不过这就是另外一篇文章的主题了。)
虽然在这个例子中解释的函数式编程的好处看起来很简单,但这是一个很大的延伸,想象如果或如何对这些好处进行扩展以超越我们的玩具样例。
事实上,函数式编程的头号反对者就声称它只适合这些玩具例子,在”现实世界“编程中完全失败。
让我们找一个我们想到的现实世界中最简单的例子:一个函数失败时并不返回一个值。
我以完整性和正确性定义了一个例子,如果丢掉完整性要求会发生什么呢?
好吧,这个函数不需要返回任何东西。更实际的说,函数会一直运行,或者通过宿主语言支持的其他方式来转义这个返回值的需求 - 通常是抛出一个异常。
一个永不返回的函数是因为没有跳出而一直运行(脱离循环的边缘条件,或类似其他原因),但异常又是什么呢?
在异常出现之前,程序员使用一个返回值来表示函数的成功或失败。在 C 代码中,比如:
1 | int parse_config(config *cfg){ |
在这种世界里,引入异常处理似乎不可思议。程序员可以避免混乱的错误应用逻辑处理问题,从异常的短路行为中获益。
异常的主要问题是,在一个支持它的语言中,你无法保证他们可能会发生的地方。这意味着他们可能在任何地方触发。这表示,如果时间够长,他们可以无处不在(甚至在 Java 中,未检异常可以随处抛出,其中经常还包含受检异常!)。
同时由于 null 的存在,导致激增了大量防御性、攻击性的异常处理,尽管有些并没有什么意义,但导致了更多错误的发生和怪异的边缘问题。
幸运的是,我们可以轻松的同时实现完整性和整洁代码,但比老旧语言需要更多设施的支持。
我了实现这个想法,我们定义一个函数来返回列表的第一个元素:
1 | def head[A](as: List[A]): A = as.head |
这个函数并不完整,取决于你传入的是一个什么列表,可能会返回可能也不会返回列表的首元素。如果你传入一个空列表,函数永远都不会返回,而不是抛出一个异常。
如果想要该函数完整,仅需要引入一个数据结构来建模optionality的概念 - 一个东西可能有也可能没有。
让我们称之为Maybe
:
1 | sealed trait Maybe[A] |
通过这个数据结构,我们可以把这个”伪函数“head
转换为真的函数:
1 | def head[A](as:List[A]):Maybe[A] = as match { |
现在当我们考虑使用该函数的代码时,不再需要考虑该函数没有返回的可能性。因为该函数总是能够返回。由于不需考虑这种可能性,使用head
函数的代码表述起来也更为简单,包含更少需要分享的场景。
Maybe
数据结构并没有提供跟异常一样的能力。有一条,它不会包含任何对于head
函数来说意味着 OK 的错误信息(因为我们知道错误是什么-空列表),但对于其他函数来说可能并不有效。
为了解决这个问题,我们可以引入一个新的数据结构,称为Resullt
,对exceptionality进行建模:
1 | sealed trait Result[E, A] |
这种类型支持我们创建一个file_open
这样的完整性函数:
1 | def file_open(path:String):Result[FileError, FileHandle] = ??? |
现在我们可以拥有跟异常一行的信息。然而,如果我们需要很多操作需要执行,同时每个操作都返回一个Result
,这看起来我们会拥有相当多的模板代码,不免让人回忆起异常之间的日子:
1 | def parse_config:Result[FileError, Config] = { |
我们已经创建了file_open
、file_read
函数,等等,简化了我们的表述,同时也引入了不少模板代码,使代码难以阅读。
为了夺回之前异常的优势,我们需要识别上面代码的模式。如果你研究几分钟,则会发现下面的模式:
1 | doX match { |
你会发现,doY
、doZ
、doW
都会从上一个操作产生的Result[E, A]
那接收一个 A,然后生成一个新的Result[E, A]
。
这暗示我们可以通过一个chain
方法分解重复的代码:
1 | def chain[E, A, B](result:Result[E, A])(f: A => Result[E, B]): Result[E, B] = |
现在,可以使用chain
方法来重新实现原来的parse_config
:
1 | def parse_config: Result[FileError, Config] = { |
这样通过在Result[E,A]
上调用chain
来减少模板代码。通过这种方式,我们既可以拥有异常的短路优势,错误处理逻辑又由chain
方法拆分,应用逻辑就无需再关注这些。
如果你使用Result
这样的结构来建模异常场景,通常你会发现需要一个下面这样的工具方法:
1 | // change the A in a Result[E, A] into a B by using the provided function f |
这是一个类 map 的函数(将列表中的元素映射为其他类型)。如果你喜欢 OO 风格,可以把chain
、change
方法包括在Result
类之中:
1 | sealed trait Result[E, A] { |
这样一来代码就会更可读:
1 | def parse_config:Result[FileError, Config] = { |
更进一步,如果你把change
、chain
方法称作map
、flatMap
,Scala 则会提供更加灵巧的方式来进一步简化代码格式:
1 | def parse_config:Result[FileError, Config] = { |
最终,我们首先了函数的完整性,同时也拥有异常的短路特性和关注点分离。
我们所需要的也就是chain
函数(即 flatMap)和Result
数据结构。其余的则自然引入。
注意,chain
函数接收一个函数作为参数。这个参数作为一个匿名函数(在其上下文中捕获任意引用)提供,这论证了为什么这种技术永远不会出现在 C 代码中。如果 C 拥有一类函数、垃圾回收或者引用计数,很可能这种模式会自行出现而无需函数式编程社区的任何输入。
函数的其他要求是确定性和纯洁性,下面的章节会讲到。
一个函数,如果拥有不确定性,或者所做的并非仅仅是计算一个返回值,那它就会变得非常难以推理。
比如我用过的一个库,会在构造器中执行 IO:
1 | class Logging{ |
该构造器可能会抛出异常,并且难以预料!
另一个例子,当你把 Java 中的URL
类放入一个数据结构时,它会执行一个网络连接。原因是 equals 和 hash 编码方法会触发地址的识别。
除了不纯函数的意味性质外(谁知道他们什么时候会干些什么!),非确定性(non-determinism)导致函数的测试和表述变得尤其困难。你可能会被迫对影响函数行为的那些状态的表述代码进行 mock。
纯函数,确定性函数,在现场之外不会做任何不正当的事。你可以期望他们每次都会根据相同的输入返回相同的结果,这意味着代码容易测试且易于理解。
但如果你尝试使所有的函数都是完整的、确定的,并且是纯的,当你执行一些输入输出或副作用操作时则会很快撞到墙上 - 一堵说明了太多函数式编程与真实软件不切实际的墙。
事实上,这墙早就被粉碎了,而不是沿着这条路走下去,让我们看一下一个 IO 的例子,看能不能推荐一种方案。
假如我们正开发一个控制台程序,改程序需要从控制台读取输入,然后再会写数据到控制台上。这样的程序能解决很多有用的问题,如果我们能想出如何以确定性、纯函数的方式来构建一个控制台程序,那就能推广到其他类型的程序了。
如果你针对该问题思考了一会,可能会得出下面的想法:可以定义一些描述控制台副作用的数据结构来构建程序,而不是调用那些不确定或不纯的函数。
假如这个ConsoleIo
是我们这个程序的说明书,首先,我们需要一种方式来描述”写入输入到控制台“这种副作用。
一种方式看起来可能会是这样:
1 | sealed trait ConsoleIO |
这种方式未免也太简单了,因为这样我们只能将文本的一行写入到控制台:
1 | def helloWorld: ConsoleIO = WriteLine("Hello World!") |
这是一个完整的、确定的纯函数 – 但是并没有什么卵用。它顶多能描述一个程序将文本的一行写入到控制台。文本可能会变,当然,甚至是函数的一个参数,但最终,程序只会对控制台有一个副作用。
幸运的是,将该结构扩展为支持多个顺序的副作用也很简单:
1 | sealed trait ConsoleIO |
通过这种方式,我们可以描述更加复杂的”副作用化“程序,比如:
1 | def infiniteHelloWorld:ConsoleIO = WriteLine("Hello World", infiniteHelloWorld) |
如果我们引入一个”thunk“以避免在构造中压栈(blow stack),该结构就能够描述一个向控制台写入无限个”Hello World“的程序。像这种无限制的程序并没有什么用,不过我们可以给ConsoleIO
添加另一项,让他可以支持终止:
1 | sealed trait ConsoleIO |
现在我们可以描述一个将文本行打印指定次数到控制台的程序:
1 | def printTextNTimes(text:String, n:Int):ConsoleIO = { |
该函数也是一个完整的、确定的纯函数。当然,他实际上不会打印任何东西到控制台,但我们很快就能做到。
目前,我们只能描述一个写入文本到控制台的程序。因此扩展一个从控制台读取输入的程序也相当简单:
1 | sealed trait ConsoleIO |
注意构造一个ReadLine
的值时我们需要提供一个函数,传入从控制台读到的行,返回另一个ConsoleIO
,代表该副作用程序的剩余部分。
我们向ReadLine
传入一个函数,它作为一个保证,将来的某个时间某人会通过控制台给我们一行输入,然后我们再将其返回给程序的”剩余“部分。
该结构有足够的能力来描述我们的交互程序。比如,下面的程序会问你的名字并向你 Say hello:
1 | def socialProgram:ConsoleIO = WriteLine( |
记得这是一个完整的、确定的纯函数。人们想象使用这个结构来描述非常复杂的副作用程序。事实上,任何仅需要控制台 IO 的程序都可以使用该结构来描述。
注意任何使用ConsoleIO
描述的程序都会在某个点终止。这些程序不能返回一个值。
如果我们需要这样的”控制台式程序“:该程序生成的东西在其他程序又能够使用。这样我们需要泛型化End
来接收一些类型为A
的值,这迫使我们需要给ConsoleIO
添加一个新的类型参数A
,并贯穿于其他项。
最终的结果看起来稍微有点复杂:
1 | sealed trait ConsoleIO[A] |
现在,ConsoleIO[A]
能够描述一个读写控制台并被一个类型为A
的值终止的程序。这支持我们构建生成值的程序,然后这些值再被其他程序消费。
我们能够使用该结构创建之前的”Hello, !“程序,但这次,我们能从程序中返回用户的名字:
1 | val userNameProgram:ConsoleIO[String] = WriteLine( |
ConsoleIO
中我们唯一丧失的是修改返回值类型的能力。比如,你想构建另一个程序并不是返回用户名,而是名字的长度,如何复用userNameProgram
呢?
当前这种方式是不可能的。我们需要一些更强大的东西来实现这个打算。如果我们拥有一个 List 则可以使用 map 来改变结果类型。而这正是ConsoleIO
所需要的。
我们可以直接给他添加一个 map 函数:
1 | sealed trait ConsoleIO[A]{ |
现在给出任何一个ConsoleIO[A]
,我们都可以通过函数A => B
将其转换为ConsoleIO[B]
。因此现在我们可以编写一个新的userNameLenProgram
,计算用户名字的字符长度:
1 | def userNameLenProgram:ConsoleIO[Int] = userNameProgram.map(_.length) |
随着map
的加入,EndWith
的作用发生了改变:我们不再需要它从程序中返回一个值,因为我们可以把拥有的任何值转换为返回值。比如你有一个ConsoleIO[String]
,我们可以通过一个String => Int
函数转换为Console[Int]
。
然后,EndWith
仍然可以用于构造一个不执行任何副作用的“纯“程序(但能够与其他程序组合)。因此它仍然是有用的,虽然与其最初的目的不同。因此,我们可以将其重新命名为Pure
:
1 | sealed trait ConsoleIO[A] { |
通过这些封装,我们可以没有任何限制和约束的构建并复用这个控制台 IO 程序。所有这些描述都是拥有确定性、完整性的纯函数,因此也获得了函数式代码的强大优势:易于理解、易于测试、易于安全的调整,且易于组合。
最终 ,我们需要把一个程序的描述转换为实际的程序-一个能真正执行副作用的程序(这意味这没有确定性、也不纯)。通常这个程序会叫做interpretation。
可以写一个简单的,如果没有确定性、也不纯洁,解释器可以基于ConsoleIO[A]
使用一下类似的代码:
1 | def interpret[A](program:ConsoleIO[A]):A = program match { |
还有更好的方式,不过这里就不再过多演示了。到目前为止,我认为这已经相当有意思了,我们同时能够描述一个充满副作用的,但是又满足完整性、确定性、纯函数的要求,又能很方便的转换为一个真实执行副作用的程序。
注意,这种转换需要、也非常必要在程序的最后进行(将副作用尽可能推迟到最后)!在 Haskell 中,这会发生在 Haskell 运行时的主函数之外。然而在其他的语言中,背后并没有这些函数式级别的机制支持,你总是可以在程序的入口点以副作用的方式来解释你的程序。
这么做是为了确保你在程序中拥有最大程度的完整性、确定性以及纯洁性。在你边缘的地方,你可能有个小层很难去表示,但正是在这里来基于底层将程序转换为可执行副作用的描述。
这种方式在一个固定副作用集的世界里会运行的很多好。在我们目前的用例中,控制台 IO—从一个控制台读写文本行。但是在实际的程序中,多种原因之下的副作用要复杂的多,我们受益于使用不同的副作用组合进所有副作用来构建程序的能力。
第一步是识别附加结构。实质上,我们可以把对控制台程序的描述拆分成生成值(WriteLine)和接收值(ReadLine)。程序剩余的部分则是由纯模板组成:要么是通过映射(map)返回值将一个程序转换为另外一个,要么是,一个程序依赖另外一个程序的结果,将这两个程序进行链接(chain/flatMap)。
如果这没有任何意义,可以研究一下下面的例子:
1 | sealed trait ConsoleIO[A]{ |
这里只有一点不同,增加了更多令人迷惑的方式来表示同一个东西。使用这个结构,我们的交互程序会表示的更复杂一点:
1 | def userNameProgram:ConsoleIO[String] = |
在这个模型中,ConsoleIO
拥有一组看上去泛型的项,chain、Map、Pure,他们不会跟我们控制台程序的副作用打交道,另外又有两个额外的项用来描述这些副作用:ReadLine、WriteLine,他们在这个模型中则被简化了。
这种模型构建的解释器也会有一点复杂:
1 | def interpret[A](program: ConsoleIO[A]):A = program match{ |
这种简明的描述看起来不会完全不同,但关键点在于只有两项用来描述副作用,剩余则都是纯函数装置。这些装置可以被抽象到另一个类然后复用于其他所有的副作用类型。比如:
1 | sealed trait Sequential[F[_], A] { |
Sequential
来并不直接引用ConsoleIO
,因此可以复用与其他不同的副作用类型。
这允许我们清晰地将副作用从计算拆分开。因此新版本的 hello world 程序看起来会是这样:
1 | def userNameProgram:ConsoleIO[String] = { |
如果我们利用 Scala 的 for 符号,然后添加一个隐式类来更方便的将副作用包含到Effect
的构造器中:
1 | def userNameProgram:ConsoleIO[String] = { |
这种方式可以进一步简化,比如,为所有项添加帮助函数。这些函数使程序更加简洁:
1 | def userNameProgram:ConsoleIO[String] = { |
这与上一种实现没有什么不同。结构也大致相同,仅有一点语法不同。在我们的例子中,我们构建了一个程序的description,完整、确定的纯函数,他本身对外部世界没有任何副作用(除了利用 CPU 和内存来计算结构)。
此外,使用这种清晰的分离,实际的副作用集也可以进行扩展。这意味着我们可以在两个程序中使用采用不同的副作用,然后组合成一个程序来同时拥有两种副作用。
为了达到这种效果,你仅需要一些”EitherOr“来表达这是一种副作用(控制台 IO)或是另一种副作用(文件 IO):
1 | sealed trait EitherOr[F[_], G[_], A] |
可以基于这个结构来时间简洁的解释器,并可以根据指定的副作用类型(控制台或文件)复用到其他的解释器中。
现在我们已经从第一个原则开发到这,而且没有任何术语,你看到的这种抽象实际上称为著名的”Free monad“,它让不知情的 Scala 程序员无处不在害怕!
这看起来不是太糟,对吧?
在我们结束整个教程之前,看一下这种抽象的其他好处。
通常对这种副作用的编码方式的反应是,”有什么意义?“
使用这种风格来描述副作用化程序确实有一些非常实际的好处,
除了所有的这些好处之外,你还可以得到非常明显的好处,能够很好的推理,完整、确定、纯函数,即使是存在副作用的情况下。这些好处可以是新的开发者收益,维护已有的代码、解决 bug,引进新的团队成员等等。
我希望至少文章中涵盖的部分创造了一些意义。更重要的是,我希望你们看到我们是如何使用完整的、确定的纯函数编写完整程序,以及这种方式带来的好处,其中一些也是我们刚刚发现的。
如果没有别的,这是一个学习更多函数式编程的呼唤!尽管拥有奇怪的名字、不完整的文档、混乱的类型也要坚持下去。
函数式编程非常有力,拥有巨大的力量。根据我的经验,那些花时间学习函数式编程的人最终会变得对他充满热情,永远不会回到过去的老路上。函数式编程给了开发者强大的力量—以简化的方式编写软件并输出简洁代码的能力,维护成本更低,更易组合、更易推理,等等等等。
如果你有兴趣,我期待你坚持下去,如果你卡主了,你要知道我还有社区的其他很多成员都站在你背后。我们会帮助你达到下一个层次,从值到值,从类型到类型,从 lambda 到 lambda。