¶主要内容
- 为什么函数式编程
- FP和OOP
- 各种形式的函数
- Monads以及应用实例
前面部分或多或少介绍了Scala的函数式编程,或在Scala的面向对象结构中混入了函数式编程。本章将专注于函数式编程概念,以及它们是如何在Scala中实现的。本章主要目的是使你对函数式编程有个清晰的感知,并帮助你编写函数式编程风格代码。
函数式编程 是一个编程范式,将计算行为模拟为表达式的求值。而这个表达式使用函数构建,不带有可变状态和副作用。函数式编程的起源是值得探索的1 。相信与否,函数式编程始于约1930,Alonzo Church 介绍的 λ演算2 。一个λ演算是一套用于研究函数定义、函数应用和递归的形式系统。λ演算的函数都是第一类值(first-class value);函数可以接收其它函数作为参数,返回函数作为输出(高阶函数)。
一个带有两个参数的函数,在λ演算中可以写作:
这个的λx. λy. x + y
表示一个匿名函数,它接收x
参数,返回新的匿名函数λy. x + y
,新的匿名函数带有一个y
参数,并返回结果x + y
。在λ演算中,所有函数都是匿名的,并由符号 λ
表示(因而得名lambda)。
λ演算是函数式编程的主要灵感所在。函数式编程语言以一些约束和类型来实现λ演算。不是所有的编程语言有诸如第一类函数(first-class functions),模式匹配(pattern matching)之类的特性,但却可能在几乎所有编程语言中作函数式编程。接下来将介绍函数式编程的详细内容。
¶5.1 什么是函数式编程?
函数式编程,顾名思义就是以函数的形式实现编程。在这里不必要花太多来理解函数是什么。根据数学的定义,函数就是存在x∈D
,总存在唯一的f(x)
与之对应。因此,在Scala中,你可以将一个函数作如下标识:
1 | def f: X => Y |
一个函数提供了可预测性,对于一个给定的输入你总会得到相同的输出。如下:
1 | def add(a: Int, b: Int): Int = a + b |
这里函数add:(Int,Int)=>Int
匹配了函数的定义,因为对于给定的输入,它总是返回相同的结果。
但是函数由内部状态决定使得并不能总是返回相同的结果又是怎么样?这类函数也是函数,但是它们不是纯的函数。纯函数不会有副作用。像更新一个全局的或静态的变量,将一个数据写入文件系统,在屏幕上展示这类称之为“副作用(side-effecting)”函数,抛出异常等都是术语副作用例子。纯函数的行为是不依赖于任何内部行为或状态。不允许改变参数的状态或者调用一个带有副作用的函数到另一个纯函数,如下的weather函数不是一个纯函数,因为weather状态会在调用的时候改变:
1 | def weather(zipCode: String) = { |
为什么要如此关心纯函数?纯函数编程的值(value)又是什么?
值是引用透明性的。引用透明(Referential transparency)是指一个属性即一个表达式可以取代它的值而不影响它的程序。下面例子让我们看看引用透明是如何工作的。假设下面是函数程序的一部分:
1 | ... |
因为add是一个纯函数,我可以用函数的调用add(10, 10)
来替代它的值20,并且不改变程序的行为。相似地,我也可以用add(5, 5)
来代替结果10而不影响程序。那我们为什么要如此关系引用透明?它有什么优势?
¶5.1.1 引用透明的好处
引用透明提供了代码的推导能力。通过替换纯函数以及使用纯函数的值,将一个复杂表达式简化为一个简单的表达式。甚至你可以在你的脑海里计算出一个程序的结果。这种代码的思考能力帮助编程工作者更好地调试和解决复杂的问题。它正是函数式编程之所在。面对各种不同的困难,你可以在任何编程语言中做函数式编程。函数式编程的本质是引用透明,它的好处是引用透明——更容易找的问题并修复。因为Scala是类型安全的,你可以在编译之前捕获到更多的异常。
在Scala中,函数式编程被烤制带有面向对象的特性,因此有时难于区分编程语言中你定义了方法还是定义了函数。这将在本节阐述,但现在需要记住的是,Scala中的方法没有任何类型;类型只关联闭包类,相反函数则由类型和对象所表示。
不幸的是,目前提出一个函数式语言的定义仍然显得困难。每个人都有他自己的定义机制,但是尽管函数式编程可能会出现在所有语言中,也并不意味着你应该使用它。它可能会出现在OOP编程语言中,但是也可能非常困难地实现,并且用起来很痛苦。在Scala中,编写函数式编程则是非常简单的,下一小节开始掌握。
¶5.1.2 一个纯函数式编程
一个纯函数式编程是一个单引用透明表达式(single referentially transparent expression)。一个表达式由一个子表达式的组合创建。一个表达式总是求一个结果。如下面是一个纯函数的例子:
1 | object PureFunctionalProgram{ |
这里的main方法是我们纯函数的入口,剩余部分定义了一个单表达式,它接收一个字符集合参数并返回一个Tuple
。返回内容有两个子表达式组成: a map (_.toInt)
和 <result of first sub expression> partition(_ < 30)
。你可以认为是由一个子表达式集合组合成为一个单引用透明的表达式,你实现了一个纯函数程式。当然,你还需要从控制台或者系统中读取输入参数,但是不管输入内容是什么,纯函数的行为是不会改变的。
¶5.2 从OOP到FP
Java、C#或者C++编程开发者已经早已熟悉类和对象的概念并更习惯于OOP。那如何从更多的OOP经验过渡到更多的函数式编程风格上呢?在这方面,Scala允许你组合一个优雅的和设计良好的代码风格。它可以从只关注的面向对象开始,你可能慢慢地过渡到一个更多的函数式编程风格。在这个章节将强调一些技术关于函数式编程风格以及目前保留的OO技术和风格支持实现。
¶5.2.1 纯与不纯编程
咋看之下,在纯与不纯的层面上比较面向对象编程与函数式编程,可能会觉得奇怪。尽管你可以编写面向对象代码而不会有side effects,但在编写OOP中更容易充斥着不良的负面效果。典型地,OO-style的应用程序在程序内部会通过大量的对象构建了一系列的可变状态的概念(冗余作用)。
面向对象的解决方案是一个环绕着类和对象的模型,这些模型倾向于承载方法的集合,以及共享这些方法或偶尔使这些数据发生变异。 函数编程风格仅仅解决在函数处理数据中问题的值。因为数据只由值表示,每一个应用中的函数将产生一个新的值而不会带有任何负面效果。
另一个区别就是函数式编程提高了在OOP中的抽象层次。OOP有时感觉像机器从属的(machine-dependent)——值传递、引用传递、相等性以及同一性,并基于程式如何在运行时被解析和执行的定义。如果你仅仅对值(values)作处理,那么你的函数式编程如何被解析和执行变得无关紧要。记住,你可以通过纸和笔计算纯函数程序的结果;使用机器运行则是细节实现。
在诸如Haskell和Clojure语言中,当你仅仅只有函数,你不需要担心不纯问题。但Scala是一门同时包含面向对象和函数编程的语言,因此你必须特别小心副作用(side effects)。在Scala中你仍然需要定义类(或特质traits)和对象来组织你的方法和函数值。并且,你作为一个开发者,你的职责应该确定你不在类内部定义依赖于可变数据。
如下面例子,一个矩形带有一个方法用于计算面积:
1 | class Square(var side: Int) { |
这个类的问题是side的属性是可变的,area方法依赖于side计算的值。很明显它不是一个纯的处理,因为area方法的结果依赖于外部的状态——这里是side属性。同时也很难推出area方法的值,因为你必须时刻保持与side属性的联系。要实现Square的纯函数,你会用到Successor Value pattern
模式5 ——也叫函数对象模式,即每个状态的改变都返回自身新状态的一个拷贝。
1 | class PureSquare(val side: Int) { |
在这里,每次side属性被修改,一个新的PureSquare拷贝会被返回,因此你不需要担心可变状态和area方法的结果,因为它已经被关联到一个新的对象了,PureSquare。这个常用的模式被用于模拟一个可能会更改的状态。Java的String类在通篇全书中使用的例子就是一个函数对象模式。现在你的挑战是以相似的形式设计你的对象,因为它很容易地降低了副作用的无意产生。注意所有你的方法中依赖的vars和setters并尽量使他们是纯的。以后我们要记住: 引用透明是一个良好的设计标准。
¶5.2.2 函数式编程的面向对象设计模式
设计模式在OOP作为交流的工具,在FP中同样重要。一些设计模式,诸如单例(Singleton)、工厂(Factory)、观察者(Vistor)早已作为语言的一部分实现。你可以使用Scala对象轻松地实现单例和工厂模式。你可以使用Scala的模式匹配来实现观察者模式。看看策略模式(Strategy pattern)6 。该模式允许你在运行时选择算法,并简单地使用高阶函数实现:
1 | def calculatePrice(product: String, taxingStrategy: String => Double) = { |
因为taxingStrategy
被定义为一个函数,你可以传递不同的实现strategy。类似地,你也可以使用高阶函数来实现模板方法(template method)模式。
高阶函数对于处理依赖注入(DI)也很有帮助。你可以使用函数科里化来注入依赖。例如,你可以为tax strategy定义一个类型,以及一个基于strategy和product code的函数,用于计算:
1 | trait TaxStrategy {def taxIt(product: String): Double } |
现在我有两个TaxStrategy
的实现,ATaxStrategy
和BTaxStrategy
。其中taxIt
为封装的返回String=>Double
类型的方法。基于这些设置,你可以更容易地创建一个新函数实现注入不同的策略:
1 | def taxIt_a: String => Double = taxIt(new ATaxStrategy) |
你有关OO设计模式的知识,在Scala中依然很有用,但是它的实现方式发生了改变。函数式编程带来了一些你之前没有遇到过的设计模式,而这些主要和迭代编程有关。
¶5.2.3 建模纯函数式程序
前面已经关注过实现纯函数的解决方案,但像编写socket或database这些,如何处理副作用?你不能避免这些问题,实际上任何企业开发的程序都可能有副作用。就此放弃吗?不!一个自欺的做法就是尽量将副作用推向更远一些。你可以创建一个不纯的层,如图,并尽量使应用剩余的部分作为纯函数。在5.5小节你会学习如何在包含必要副作用的代码中建立抽象。
为了说明它是如何工作的,你要创建一个简单的HTTP服务器,它用于处理从服务端开启的目录文件。你需要实现HTTP的GET命令。任何服务器一样,HTTP服务器充斥了各种副作用(side effects),比如写入socket,从文件系统读取文件,等等。现在你对这个服务器设计目标是:
- 代码分层,纯代码从副作用代码中分离。
- 返回基于HTTP GET请求的一个文件的上下文。
- 当请求missing时,返回一个404信息。
这个问题的实质上是:解析请求,获取请求的文件的名称,定位资源,并返回响应。让我们用适当的类型和函数来表示。Http 请求是一个字节流客户端,建立纯模式(pure model)中不需要担心如何接收,我们只需要关心字节集合:
1 | type Request = Iterator[Char] |
相似地,响应也可以用一个字符串集合表示。为了简化,用List[String]
:
1 | type Response = List[String] |
资源定位符类型应该能够检测文件是否存在、接收文件内容、并检测内容长度。第一个功能用于确定是否返回200还是404页码。下面是ResourceLocator
:
1 | type ResourceLocator = String => Resource |
ResourceLocator
作为一个函数类型,String => Resource
传入资源名称并返回Resource。Resource表示定义中的Resource,他提供了将被用于HTTP响应的所有方法。这里关键的是,你创建了一个抽象层,且抽象层允许你通过 值 和 纯函数 设计你的应用。如下代码为完整的纯实现,并在GET成功时返回 HTTP 200,失败时返回 HTTP 404。
1 | object Pure{ |
首先,GET方法会接收请求的文件名,然后根据给定的定位器locator
对文件进行定位。偏函数_200
和_400
被定义为表示成功和失败。如果文件存在则调用_200
,否则调用_400
。
现在,服务点的核心部分已经实现了,你需要将它至于真实案例中。首先,你需要开启一个socket端口用于监听请求,你也需要异步处理每个线程,以使当处理旧的请求时监听一个新的请求。在Scala中,你可以找到NanoHttpServer.scala对此的完全实现。下面让我们来关注Resource和ResourceLocator的实现:
1 | import Pure._ |
其中IOResource
使用scala.io.Source
从本地文件系统读取文件,ResourceLocator
函数接收文件名,并创建一个IOResource实例。唯一剩下的问题是对socket进行读写。可以看到你已经成功地将side effects 从纯函数代码中分离出来了。这是一项重要的技术,记住,当你设计你的应用时: push the side
effects to the edge of the world。
¶5.3 各种各样的函数
函数式编程就是关于函数方面的,你能在Scala中创建各种形式的函数。函数在Scala中是第一类值(first-class values)。这意味着可以把函数看作如Int
或String
类型值一样。因此函数可以作为值创建、作为参数传递到其它函数、以及组合这些函数来创建新的函数。
¶5.3.1 方法 vs. 函数
通常被定义为一个类成员的函数。被称为方法:
1 | class UseResourece{ |
这里的use
是类UseResource
定义的一个方法。使用方法时有个缺点是,它容易依赖于封闭类定义的状态,该状态指没有显式传递其依赖的参数,因此要小心,它会使你远离纯函数。和函数不同,方法不会有任何类型的关联。Scala通过将函数转换为对象来实现函数式编程的OOP注入。
例如,你可以赋值一个函数字面量(匿名函数)的值:
1 | val succ = (x:Int) => x + 1 |
这里succ函数的关联类型为Int => Int
,它是如下定义的一种简写:
1 | val succFunction = new Function1[Int,Int]{ |
上面两个定义是等价的。函数在Scala中通过一个类型和对象表示,但是方法不是。方法仅仅关联它的封闭类。好消息是,Scala允许你使用一个称为Eta扩展的转换过程将方法转换为函数。你可以将任意存在的方法后加 _
下划线来创建函数。如下面代码创建了一个函数:
1 | val use_func:Resource => Boolean = (new UseResource).use _ |
在Scala中,将方法转换为函数,并传递给另外一个函数的情况,在Scala中非常普遍。下一小节你将看到有关高阶函数的例子,以及它是如何帮助解决问题。
¶5.3.2 高阶函数
高阶函数就是,接收函数参数或以函数作为返回值的函数。在前面已经介绍了大量有关高阶函数的内容。尤其在Scala集合中每一个地方都用到高阶函数。例如,过滤掉List中的所有偶数,你会如下写:
1 | val l = List(1, 2, 3, 5, 7, 10, 15) |
这里的 % 2 == 0
是一个函数字面量。现在让我们看看如何用高阶函数来处理日积月累的问题。一个最常见的编程问题是资源管理。例如,在TCP连接上发送数据,你必须打开一个socket,发送数据,然后关闭socket。类似地,在一个文件系统中读取文件,你必须要打开文件,读数据,然后关闭文件的句柄。典型的处理资源的方法是将它们包装在try-finally块里面:
1 | val r: Resource = getResource() |
你获得了对资源操作的一个句柄,并在try-finally(有时包含catch)块里面操作。现在让我们看看如何将该资源管理部分分离出来,并通过高阶函数use实现:
1 | def use[A, B <: Resource ](r: Resource)(f: Resource => A): A = { |
这里的use函数接入资源管理,以及函数参数f
允许你使用资源而不用担心释放或处理的问题。现在socket的发送代码变为:
1 | use(socketResource) { r=> sendData(r) } |
资源管理的抽象实现移除了代码的重复并在资源暴露之后进行集中管理,而不会混乱try-finally块的代码。要实现这个抽象,你需要定义一个通用的类型Resource。在Scala中这是一个常见的模式 借贷模式Loan pattern7 (在面向对象中被称为模版方法模式)。
下面一个例子会证明高阶函数对复杂设计编程的强大之处。下面常见的几点逻辑可能是你处理问题的步骤:
- 创建或查找某些存在的实例。
- 处理某些包含副作用的操作。
- 在其他部分代码中使用实例。
在你开始使用实例之前你会进行1、2步,问题是,包含副作用的代码有数据结构关联,并且它和步骤1、3混在一起使用。让我们看看下面的伪代码:
1 | val x = Person(firstName, lastName) |
在使用实例之前,你需要先完成1、2步,问题是在访问时可能没有调用者命名空间的上下文。例如,mailer的引用在前面的代码片段仅仅在调用者的上下文有效,而在Person实例内部无效。处理该问题的一个方法是使用高阶函数。这里定义一个函数tap
,该函数接收一个实例和一个包含副作用的函数。该函数提供了副作用函数到实例的处理并返回实例。如下:
1 | def tap[A](a: A)(sideEffect: A => Unit): A = { |
通过这个tap
函数,你的代码将获得某些结构:
1 | val x = Person(firstName, lastName) |
这正是你想要的,但你仍然可以通过隐式转化进行增强使用。因为它非常普遍,你需要使该函数对所有类型生效。下面给出它的完整实现例子。
1 | object Combinators { |
比较先前的伪代码有所不同,现在代码更简洁和结构良好的,并且副作用不会泄漏。该模式设计最好的部分是可以让你不再依赖指令序列的组合。这同时也是一个常见的组合器(高阶函数),在函数式编程中被称为Kestrel8 。Kestrel是To Mock a Mockingbird书中定义的多个组合器中的一个。组合逻辑包含在本书范围,但是我更推荐To Mock a Mockingbird。我在这里强调,一旦你开始使用高阶函数,你将有机会提取可重用的代码,这在之前还认为是不可能的事情。让我们想一想,例如定义在Scala集合库中的foldRight
和foldLeft
提供的二元函数。高阶函数的应用程序让你编写不重复的代码(don’t-repeat-yourself DRY)并可以尽可能地使用。下一小节将讨论偏函数,以及它们如何在函数组合起到帮助。
¶5.3.3 函数科里化
函数科里化是一门技术,指的是将原来带多个参数的函数,转换为带一个参数的函数。如下面定义的接收两个参数的函数:
1 | trait TaxStrategy { def taxIt(product: String): Double } |
函数taxIt
接收TaxStrategy
和String
参数并返回Double
。要将该函数科里化,你可以调用函数类型的curried
方法:
1 | taxIt.curried |
它将taxIt
函数转换为一个接收一个参数并返回另外一个接收第二个参数函数的函数:
1 | class TaxFree extends TaxStrategy { override def taxIt(product: |
使用函数科里化的好处是什么?它将一个普通的函数转换为一个特殊的。例如,将taxIt函数科里化后得到一个taxFree。这和OOP中的DI(Dependency injection,依赖注入)相似。这里我将taxStrategy
作为依赖注入到科里化函数,并使用依赖创建一个新的函数。你也可以使用占位符_
来转换为科里化函数。如下实现所示:
1 | def taxIt(s: TaxStrategy, product: String) = { s.taxIt(product) } |
你也可以使用多参数集来定义科里化风格的方法:
1 | def taxIt(s: TaxStrategy)(product: String) = { s.taxIt(product) } |
你已经使用多参数集为高阶函数传递匿名函数,现在还学习了函数科里化的另一个内容:依赖注入。
¶5.3.4 函数组合和偏函数
一个偏函数是指,一个函数仅仅为输入值子串定义。它和一个纯函数的定义不同,纯函数为所有输入参数定义。如图5.3所示,一个偏函数f: X->Y
,表示仅定义了X=1
和X=3
,不包含X=2
。
在Scala中,偏函数通过PartialFunction[-A,+B]
以及继承scala.Function1
特质被定义。和所有函数类型一样,PartialFunction
声明了apply方法以及一个额外的方法def isDefinedAt(a:A):Boolean
。这个isDefinedAt
方法决定了给定的偏函数是否为一个给定的参数定义。
创建一个偏函数的最简单的方法是通过使用模式匹配定义一个匿名函数。如下代码例子定义了上图所述的偏函数:
1 | def intToChar:PartialFunction[Int,Char] = { |
这里,Scala编译器会将前面的代码片段转换为如下内容:
1 | new PartialFunction[Int,Char]{ |
特质PartialFunction
提供了两个有趣的结合方法orElse
和andThen
。方法orElse
可以将当前的偏函数和其他偏函数组合。它和if-else很相似,当当前的偏函数没有被定义时,则另外一个将被调用。你可以创建if-else if来匹配多项。另外一个andThen
可以组合转换一个偏函数作用于一个由偏函数产生的结果。一个例子会证明函数组合的强大。
注意:
理解偏函数的有用性是重要的。它让你编写更聪明的函数,记住单一职责原则,然后将它们组合在一起来创建一个完整的函数。小心性能的损耗。当组合偏函数时,要记住每个组合偏函数中的isDefinedAt可以被调用了多次。
假设你要构建一个价格系统来为所有的病人提供发票。典型地,这个系统是复杂的,因此我将简化一些内容。价格由索票的类型和区域决定,此外,区域被分为state code或ZIP code。每个这些因素会影响最终发票的价格。同时,也不是所有的索票有具体的计价逻辑关联,因此你必须有一个捕获所有默认情况,以至于你可以总是能够计算得到发票的价钱。我可以肯定这听起来和你的业务逻辑很相似。让我们用偏函数来实现这个小问题。首先定义索票类型:
1 | sealed trait Claim { val claimId: Int } |
每一个claim包含一个claimId的唯一标识并有可选的某些属性关联claim。要索取发票,对方应该提供相应的索取信息,地址和标识。这里可以使用case classes实现:
1 | case class Location(stateCode: Option[String], zipCode: Option[String]) |
处理Generic索赔,每个索赔的计价由具体的业务逻辑决定,以及所有的计价开始于一些基本的价格关联。为了决定最终的产品索赔价格,你需要提供请求信息和基本的价格。你可以捕获这个类型变量:
1 | type PC = Tuple2[Req,Option[Double]] |
这个的Option[Double]
表示产品价格的基础。下列代码则实现业务逻辑关联的每个Full
和Partial
索赔:
1 | def handleFullClaim: PartialFunction[PC, PC] = { |
类似地,最终的价格受区域影响。区域基础的逻辑也可以用偏函数实现,如下:
1 | def handleZipCode: PartialFunction[PC, PC] = { |
要为计价提供一个最终的解决方案,你可以组合这些偏函数来解决问题。根据商业规则,你应该首先决定索赔的价格基础,然后完善基于区域的价格计算。使用前面学习的orElse
和andThen
组合器,你可以非常容易的组合这些函数。
1 | def claimHandlers = handleFullClaim orElse handlePartialClaim |
上面的代码实现了你所描述的业务规则。计算索赔价格、然后完善区域的计算。当业务规则或新的索赔类型被添加到系统中,你可以容易地修改组合然后添加新的偏函数。例如,你还没有处理Generic索赔类型,你可以通过在claimHandlers中添加另外一个orElse块就很容易地将它添加到最终解决方案中。
偏函数可以被应用到更多你可能想到的场景中。例如,从一个函数或一个方法中抛出异常可以考虑偏函数。总之,在Scala中,使用了函数组合后的偏函数是强大的,在编写代码时要时刻记住。
¶5.3.5 递归
递归就是调用自身的函数。在函数式编程工具箱中递归是一个有用的工具。它可以让你将问题分解成子问题,子问题再分解成子问题。这样就可以将解决的子问题组合在一起并产生最终结果。可以认为递归是函数式编程语言的拼接。
递归的好处是可以让你创建不会变异的解决方案。在下面的小练习中,你需要计算出List中所有元素的和而不使用任何变异。你可以通过使用函数库得到许多方法来解决这个问题,但是让我们重新开始构建。这个问题的命令式解决方案如下:
1 | var sum = 0 |
你可以声明一个可变变量并对集合的元素结果进行迭代累加。因此递归的解决方案为:
1 | def sum(xs: List[Int]): Int = xs match { |
不同的是,基于递归的解决方案没有用到任何可变的临时变量,并将问题分解称为若干小块。在Scala中,典型的实现递归的方法就是使用模式匹配了。模式匹配可以实现将问题分解成为子问题,每个case就表示一个子问题。递归通常看起来容易,使用起来困难。下小节将介绍如何通过简单的步骤递归地思考问题。
¶递归思想
假设给你一个元素list,你需要将list中的重复元素去掉,例如,给你List(0,1,2,3,2,1,0)
,那么输出的结果应该是List(0,1,2,3)
。下面将一步一步来说明如何通过基于递归来解决11 。
第一步是确定类型。你需要根据输入参数和函数值返回类型类确定递归的类型。因此removeDups
方法的类型应该是:
1 | removeDups: List[Int] => List[Int] |
下一步就是声明你需要处理的所有case,这里removeDups的case有:
1 | case Nil => |
第一个case为list空的情况,第二个为有重复的情况,第三个case表示没有重复的情况。
下一步就是实现各个case案例,因为递归需要有两个必要条件,即迭代实现和终止条件,显然,第二个case案例为迭代实现,第三个case为终止条件。
1 | case Nil => Nil |
加上类型声明后的完整代码为:
1 | def removeDups[A](xs: List[A]): List[A] = xs match { |
熟练使用递归的需要多进行练习,熟悉这些步骤后,便可自然地编写递归的解决方案了。
¶5.4.1 尾递归
在讨论尾递归之前,让我们解析一下头递归是如何工作的。Head recursion
是大多数传统递归的实现方式,即方法中首先进行递归,然后接收返回值后再执行其他计算。
尾递归(tail recursion)是先执行计算处理,然后再结果传递给递归操作处理。我会提供一个例子来证明你要如何编写递归函数,以及尾递归实现。
一般地,当你调用一个函数时,一个实体被添加到了当前运行的线程堆栈中,堆栈的下限是它定义了大小,一旦超出了边界会抛出StackOverflowError异常。这就是为什么Java开发者更喜欢用迭代容器而不用递归。因为Scala运行在JVM上,Scala同样也有这样的问题。一个好消息是从Scala2.8.1开始,Scala通过尾部调用优化克服了这个限制。如下面是尾部调用优化的例子,代码片段将计算List的长度:
1 | def length[A](xs: List[A]): Int = xs match { |
这是个典型的头递归调用例子,当你执行的List较大时,你会获得一个StackOverflowError异常。现在用尾递归重写该方法:
1 | def length2[A](xs: List[A]): Int = { |
在这里,计算操作不是在递归调用之后,计算处理发生在每一步,并向下一步传递。问题是,那个更好,在Scala中更推荐使用尾递归,因为Scala实现了尾递归函数的优化工作。Scala的尾部调用优化发生在编译时期,编译器会将一个尾递归转换为一个循环。这样就不需要添加额外的实体到堆栈中。但是Scala不能优化每一个尾递归 —— 只能优化函数不能优化类变量的方法。要知道Scala是否对你的尾递归进行了优化,最好的方法是在尾递归中使用@tailrec注解,因为当Scala编译器不能优化你的函数或者方法时,编译器会在此注解的方法中抛出一个异常。例如下面:
1 | def length2[A](xs: List[A]): Int = { |
实现尾递归函数的常见方式是,使用上面如_length
这样的本地函数来构建。本地函数访问允许你添加一个额外的参数来传递当前的结果到下一步操作。记住,实现一个调用优化的尾递归函数,你的递归操作总是在最后一步做处理。
¶5.5 代数数据类型
代数数据类型(Algebraic data type,ADT)是一种分类方法。通常地,一个数据类型是值的集合(例如Int类型作为所有整形值的标识)。你可以通过枚举集合中的所有值来定义一个代数类型,除非每个值都有它自己的数据结构。当然,你可以通过模式匹配来对类型进行解组。这听起来像是一个抽象的概念,我们来看看一个例子。不久你将学会ADT就是一种表示值集合的类型。ADTs可以表示一个有限的或无限的值集合。首先,让我们看看一个闭合ADT(有限值集合)的例子,以及探索为什么它们是有价值的。
在Scala中,定义代数类型的最简单方式是使用case classes。如下例子代码定义了一个Account类型以及它的可能的值:
1 | object ADT { |
这里定义了三个account类型,每个类型都带有自己的构造器以及接收不同的参数。同时也声明了Account特质是sealed的。这意味着不可以在trait外部创建一个新的Account类型,只能在Account的同一个文件中创建。你已经创建的一个有限的ADT,但是仍然不明白为什么case classes是ADTs实现的最好选择。原因是模式匹配。一旦你创建了ADTs,你在函数中使用。如果实现了case classes,ADTs将变得更容易处理,因为模式匹配工作在外部。下面代码片段printAccountDetails打印出每个account的详情:
1 | object ADT { |
伴随着值和构造器,ADTs可以通过模式匹配进行解组,这样你可以在函数中更容易地使用他们。一个强大的概念:一旦你创建一个代数数据类型,你应该准备提供模式匹配来在函数中使用。
在函数printAccountDetails
中,我有意地忽略PremiumAccount
的代码,目的是让你在编译前面的代码时会发生什么。你将会看到如下警告:
1 | [warn] missing combination PremiumAccount |
有限的代数数据类型的另外一个好处是,Scala编译器会检测函数是否对所有可能的case代数数据类型进行了处理,并提供提示。有两种方法让你避免这些告警:提供PremiumAccount的case实现,或者将Account类型为nonsealed不进行密封。但是移除sealed关键字的负面影响 —— 任何人都可以继承Account来创建一个新的类型。在这种情况,你应该如何编写像printAccountDetails
一样处理所有account类型的实现?我喜欢有限的(闭合的)代数类型,并更推荐使用,因为我可以在Scala编译期走得更远。
最大的一个好处是编写全函数(total functions)。全函数是指对一个代数数据类型的所有值进行处理,并总是产生返回的结果。这意味着在编译期函数可以处理所有输入。在本书,你将会使用ADT一段时间,而不仅仅是知道它。在Scala中两个最好的著名的例子是scala.Either
和scala.Option
。
¶5.6 Why does functional programming matter?
你已经探索了有关函数式编程的大量理论和例子。函数式编程,和命令式编程不同。这是另一种编程思想。为什么费心去学习这个新技术?这将给你带来什么?
首先,学习一门新的编程范式是好事,因为可以让你成为更好的程序员。另外一个益处是函数式编程所带来的并发和多核编程。函数式编程可以让你编写更简单、更高效的并发程式。企业级软件开发者要处理复杂的业务问题和大规模的软件开发,并发是其重要的一部分。这或许不足以说服所有的开发者,但在剩余的小节将举例说明,为什么函数式编程这么重要,以及它如何帮助你处理复杂的问题。
著名的计算机科学家John Hughes,在他的《Why Functional Programming Matters》一书中描述了函数式编程是如何帮助处理复杂问题。事实上,本小节的标题正是受该书的启发。
以Unix的管道为例。Unix管道即指像管道一样当程序被链接成为一个管道(通过它们的输入输出流) 时。例如,下面命令接收来自通过URL的标识文件的大小:
1 | curl -s "htttp://www.manning.com/raychaudhuri/" | wc -c |
这里使用curl
进程来获取来自服务器的数据,并通过wc
进程来计算我从服务器获得的字节。符号 |
是Unix的管道命名,表示从一个进程得到的输出内容被作为管道输入到另外一个命令中执行。可以肯定,curl
的作者和w
c的作者不会想到某人会组合这两个进程来处理一个操作。事实上,你甚至可以使用任何多个Unix进程,组合这些进程来创建一个新的命令。这就是类Unix系统中最有用和最强大的思想。所有这些Unix程序背后的设计哲学是什么?所有Unix程序遵循下面两个简单的规则:
- Write programs that do one thing and do it well. 编写做一件事的程式并做好。
- Write programs to work together. 编写程式来一起工作。
从这简单的规则中你收获了什么?答案是 可组合性 composability。Unix程序给我们展示了可组合性的强大。Unix程序就像乐高积木(LEGO blocks)。你可以以任意顺序组装它们,组合他们来创建新的程序,给这些新程序命名,在这些新程序之上再构建新的程序,…。那么这些内容如何映射到函数式编程上?一个Unix管道就像一个函数式编程语言一样。如果你把每个进程看作一个函数,一个Unix管道让你使用 |
符号来组合这些函数;在Scala,它就是函数式组合。相似地,如下有一系列函数在你的Scala代码中:
1 | def even: Int => Boolean = _ % 2 == 0 |
这些函数就像Unix的进程一样,每个只处理一件事。函数even
用于当给定的整数为偶数时返回true;not
用于切换输入的Boolean参数;filter
函数用于接收Traversable
类型集合和criteria
函数,并根据criteria函数的规则进行对Traversable参数的过滤;map
函数则用于对给定的方法f
对Traversable
参数进行遍历。现在,假设你的问题是你需要查找给定集合中的所有偶数并对其乘2。用这些给定的函数,你可以组合这些函数多步处理,就可以容易地构建问题的解决方案。首先,构建一个filter来处理偶数,以及构建一个double函数:
1 | def evenFilter = filter(even) _ |
这里的evenFilter我使用了函数科里化来创建一个指定的filter版本。为了将这两个函数组合在一起,Scala提供了一个andThen
方法,可以作用于所有函数类型,但不包含参数的函数除外。这个andThen
方法行为和Unix管道相似——结合了两种函数序列并创建一个函数。因为所有Scala函数被编译为一个scala. Function
特质,你可以使用这个组合方法来合并两个函数。为了过滤掉奇元素并乘2,创建下面函数:
1 | def doubleAllEven = evenFilter andThen map(double) |
evenFilter函数过滤后,map函数接着遍历结果内容并进行double处理。任务完成。但是如果你需要对所有的奇数乘2呢?你有现成的可以用了,只需要对其进行不一样的组合:
1 | def odd: Int => Boolean = not compose even |
这里提供给odd
的组合方法是compose
。andThen
和compose
的唯一不同的是执行顺序,compose
是从右往左的。odd
函数会找到所有的偶元素并去掉。
手头上的这个例子是幼稚的、简单的。但是目标明确: 可组合性允许你从小小的功能块组合构成解决方案,而这些小块的功能正是你解决复杂问题的关键。反过来,当你设计一个大型的、复杂的问题解决方案时,你将问题分解成各个小问题,又甚至将小问题再分解成更小的问题,直到你到达可以更容易理解这个问题的点上。解决这些独立的小块,再收集在一起构建最终的大块,这是一个古老的技术。
这种分解、消化问题跨越软件开发的所有层面。函数式组合让你在你的应用中构建了数学微观世界。在这些数学微观世界中你能够明确问题,因为他们都是由纯函数创建,你可以轻易地使用函数组合构建。在微观世界中,你可以实现大部分应用程序的复杂性,以及函数组合给你一个清晰的、定义明确的方式来将问题分解成小块的函数,并之后对其组合。
在今天的企业,软件分离已经不足够。你需要尽可能分离得更快。这就是抽象(abstraction)和组合(composability)的益处所在。你作为一名开发者可以不通过改造和复制的实现组合小函数,从而节省时间。
纯函数世界的另外一个益处是调试。你不再需要担心事件序列问题的发生,因为纯函数没有副作用(side effect)。你也不用担心序列中哪个函数被执行了,因为函数的行为只通过输入参数集合驱动,即惰性执行。和命令式编程相比,函数式编程微观世界更容易找到瑕疵。为了使所有这些可能,遵循Unix的设计哲学:
- Write pure functions that do one thing and do it well.
- Write functions that can compose with other functions.
第一条规则是单一职责原则。第二条规则作为第一条的附加补充。所以在设计时,应当使函数功能尽量小、纯,这样组合就更容易。实现函数尽量小的一个方法就是使其只接收一个参数,如上面使其实现科里化(尽管事实上你会接收很多参数来实现组合多样性的事情)。
第二条规则是编写函数时,应当时刻记住使其实现科里化(currying),或者使用偏函数。当声明函数时,要确保你的参数顺序从更具体的,到更通用的。它可以帮助其他人需要用到其他地方的场景,替换为通用的参数,或者最好使其只包含一个参数。
作为一门面向对象语言,函数式编程在处理核心和复杂部分时也使得你的应用更容易编写和维护。
那么,函数的副作用(side effects)部分怎么办?难道它们在组合方面毫无希望?不,在下一小节,将展示如何围绕副作用部分内容创建抽象,以使得它们可以参与到组合(composition)中来。
¶5.7 基于Monad的高级抽象构建
如果你从OOP背景走来,你可能涉足过设计模式。本小节将讲解一个函数式编程设计模式,monads。monads的出现有点神秘。关于monads的通常误解来源于难理解,你需要有足够好的数学底子才能完全适应它们。的确,monads来源于范畴论14 (category theory)的一个分支,范畴论正式化抽象数学概念为集合(collections)和态射(arrows)。但它提供了一个好的抽象层来帮助结构化你的代码。
有许多实现monads的例子,并且每个用于解决一类具体的问题。事实上,你已经用过了monads,两个最常见的就是List
和Option
。List monad抽象了计算可能返回0,1,或者更多可能结果的情况。Option monad则抽象了计算可能不返回的情况(Some or None)。Monads通常被认为是一个先进的函数式编程概念。作为一名开发者,我强烈认为有必要将它引入到本书内容中,因为它有足够的使用益处。
- Monads可以让你组合你那些组合不好的函数,如有副作用的函数。
- Monads可以让你不用函数式编程就可以模拟动作队列的计算顺序。
使用函数式编程技术设计应用程序时,这两点是重要的、强大的,应当一起关注。我会先从第二点入手,因为它经常被用到,甚至即使你的数学微观世界中没有副作用。在最后这个小节,我会向你展示如何在函数式编程风格中组合包含副作用的函数。
¶5.7.1 Managing state using monads
当我介绍函数式编程时,我提及到不用关心函数或操作的顺序,因为函数都是纯的。让我们挑战另外一个零售价格的例子。这个应用需要计算一个产品的售价,计算按如下要求:
- 找到产品的原价。
- 提供对原价特定代码的打折。
- 提供对上一步操作中,进行指定产品的折扣打折。
- 基于以上步骤后,提供扣税得到最终价格。
这个模式在企业软件中比较普遍。你需要在序列中通过每个步骤操作的结果传递到下一个操作,怎么做?命令式的回答是使用一个可变变量在每个动作之间进行共享,这是一个差的想法,原因我已经在通篇全书中提及到。那试试将所有动作在一个函数中实现怎么样?是的,这个可以在一个大的函数中得到结果,因为每个步骤可能有10到20行代码。所以,一个最好的回答是将每个步骤以函数的形式实现,并以管道将当前动作的结果传递到下一个动作。下面列出这些实现。
1 | object PriceCalculatorWithoutMonad { |
我将无关紧要的Stubs代码部分放置到另外一个文件中,下面是该文件的硬编码:
1 | object Stubs { |
在上述代码中,最有趣的部分是calculatePrice
方法,他调用了每一个独立函数,每一个方法的结果传递到下一个方法中,形成一个队列。命名变量a,b,c
不是一个好的想法,但是它很好的展示了PriceState
实例的传递。这种解决方法的实现使用了函数式编程风格,但是每个独立函数的API则显得丑陋。为了返回唯一个price价格,方法applyStateSpecificDiscount
,applyProductSpecificDiscount
和applyTax
就必须返回PriceState的实例。每个方法的最后一行方法apply显示了问题。
另外一个问题是方法calculatePrice
。它容易在处理PriceState时出错,在更复杂的问题中,这种方式变得十分混乱。当然一个高阶的抽象或许对这种状态管理会有所帮助。这就出现了State monad
。之所以被称为State monad
是因为它在多个操作状态改变时实现了透明。在这个例子中,你将实现一个State monad
,这样你就不需要在多个方法调用PriceState时对其进行管理。但是你需要有足够的通用实现以使得它可以在其它相似问题的地方被使用。
在Scalaz库中实现了大量的这些monads,考虑到直接使用不会达到学习的效果。让我们自己实现一个State monad。
在实现State monad之前,先将原来的Stubs中的方法改一下,让API看起来更清晰一些:
1 | object Stubs { |
所有这些方法将接收一个PriceState实例参数,并返回计算结果。你的工作就是实现一个State monad来序列这些方法,并计算出最终价格。
这个State monad封装一个转换函数,将初始状态转换为一个(newState,value)对。这在Scala中很容易表示:
1 | trait State[S, +A] { |
方法apply表示函数转换,为了实现这个特质,你需要提供一个方法,用于接收S并返回(S,A)。你可以很容易就实现
1 | object State { |
这个对象将用在你需要使用State monad的地方。在这里,添加两个方法使生命周期更易于管理:
1 | object State { |
方法init
为State monads的创建提供了转换函数(s => (S,S))
骨骼。可以把它认为是State monad的默认构造器。方法modify
则让你在monad的内容对当前的状态进行修改,它用一个新的状态修改并用Unit返回给定函数和值的(S,A)
对。你可以使用这个方法来实现你的解决方案。
为了把State特质看作是第一类的monad,你要实现map
方法和flatMap
方法。记住map
和flatMap
是monad接口至关重要的部分,没有它们,Scala中任何函数都不可能成为monad。
实现map和flatMap方法是容易的,因为你知道如何创建一个State monad的实例。下面为表示State moand的特质
1 | object StateMonad { |
方法map用于在State monad内部实现值的转换。另一方面,flatMap则用于从一个状态,到另一个状态的转换。如果所有这些感到有些抽象,不用担心,当你使用这些结构时便会觉得很有意义。
不久你将学习到State monad通过方法调用来处理线程状态的变化,这样你就不用担心线程调用的问题。但是具体的业务规则中你仍然要在序列中调用individual pricing方法。序列一系列方法调用的最好地方是一个for-comprehension。这保证了每步特定的进入都会执行;同时也可以将每个需要运行的内容独立起来。在这个案例中,会像如下代码所示:
1 | import PriceCalculatorWithMonad._ |
许多事情都在这段小代码中开始,让我们从头到尾看看。方法modifyPriceState是一段极好的方法,它接收一个有关pricing的方法,并将该方法搬运入箱成为一个新的方法,这样你就可以调用对象State内部的方法modify。
每个modifyPriceState方法创建一个State monad实例。当你在for-comprehension内部调用时,你获得一个State monad返回,该返回封装了方法被调用的序列以及知道如何创建一个最终的价格状态。注意现在stateMonad持有一个转换函数,它是一个被定义在for-comprehension中的所有计价方法的组合函数。这种方式的好处是,在应用编码中线程的状态总是不可见的,它隐藏在monad内部。通过State monad实例,当你的程式执行至此时,可以通过初始状态得到最终计算的价格:
1 | val initialPriceState = PriceState(productId, stateCode, 0.0) |
它是如何工作的?秘密在map和flatMap。for-comprehension不是别的而是map/flatMap
的语法糖。你已经在List和Option用过了for-comprehension表达式 —— 因为它们都实现了map和flatMap。第四章可以获得更多详细的内容,而在本章我将仔细剖析上面的for-comprehension,并向你展示它是如何转换成为map/flatMap组合。
注意上面代码for-comprenhension中左边的下划线_
。它表示键值对中的值,这个例子中你不需要关心它们。我将会用另外一个例子来说明这个key-value中的value将被更高效地使用——下面的代码列表展示了完整的使用StateMonad重新实现的零售计价。
1 | object PriceCalculatorWithMonad { |
StateMonad是一个通用的抽象层,它允许你为需要共享状态的动作序列构建计算。
你可以看到状态键-值对 和 值在State monad中的实现具有一定的相关性。尽管你不总是需要它们,当你的计算需要依赖当前的monad状态时,你可以用状态的值来处理你的计算。假设你需要为零售计价实现日志,以及为每一步的结果输出日志。
为了实现logging,你需要暴露State对象超过一个的方法,该方法称为gets
。这个方法让你方法当前的state,因此你可以创建log message,并作为value存储到monad的内部。下面为它的实现:
1 | def gets[S,A](f: S => A): State[S, A] = |
和方法modify相似,但允许你提供一个接收S
和放回A
的函数。方法gets同时也使用value和返回给定的函数 f
创建了一个新的State monad实例。现在你可以序列这些计价步骤操作,并记录。
1 | def calculatePriceWithLog(productId: String, stateCode: String): Double = { |
首先你创建了一个logStep方法来封装gets方法,然后你在每个状态被修改后序列这个logStep,这样你就可以跟踪状态的改变。最后你将每个步骤的信息组合成为一个List。可以看到使用State mond来添加依赖于状态改变的行为是如此的简单。
¶5.7.2 Building blocks for monads
在Scala中,Monads的构建块即是flatMap
和 map
的组合。如果你把一个Monad看作是一个容器,那么flatMap 和 map 就是唯一两个可能的存储当前值的内部容器。flatMap 和 map 都接收一个函数参数,并通过提供函数组合创建一个新的monad实例,两者最终得到另外一个monad实例。为了从monad中接收值,你需要用不同的技术。在我们的例子中,我使用了apply方法来定义StateMonad。在有些类型monad中,你可以使用模式匹配。例如,scala.Option
是一个monad,你可以使用模式匹配来接收来自Some实例的值。
现在,最重要的部分是要理解为什么需要flatMap 和 map 方法。两者看起来有相似的行为。为了阐明为什么,我们试试将calculatePrice改为不用for-comprehension实现:
1 | def calculatePrice2(productId: String, stateCode: String): Double = { |
这里的价格状态使用flatMap来进行链接,而不是for-comprehension语法糖。你可以看到,我这里同时使用了flatMap 和 map。这实际上就是Scala对for-comprehension表达式如何转换的一种替代。我们现在比较一下map 和 flatMap的方法签名:
1 | def map[B](f: A => B): State[S, B] |
方法map让你创建一个StateMonad实例;方法flatMap让你遍历这个嵌套状态。没有flatMap你最终会得到一个嵌套的State monad,因为每个modifyPriceState的调用会返回一个State monad的实例。可以改变上面的代码,用map 来代替 flatMap 看看效果的不同。
这里有一些创建一个monad的诀窍:
- 为接口同时定义flatMap 和 map。
- 设计一个获取monad值的方式(模式匹配或者apply)。
- 符合一元法则,monadic laws。
Monads几乎无处不在——可能用了也不知道。一个最常的monad你未有见过的 I/O monad。它可以让你组合带有副作用的函数。可以在Scalaz库中一探它的实现。现在你知道如何创建monads,以及在外面认出monad。Moands是一个很好的提升抽象层次的方式。你可以发明monads。
¶5.8 Summary
本章介绍了Scala的函数式编程。尽管你已经在前面的章节中使用函数式编程,这章则着重解析函数式编程的详细内容。可以从本书开始到本章看到函数式编程以及一些纯函数编程的例子。
企业开发者很难会不担心副作用的发生,因为任何业务编程之外都有或多或少讨论到外界接触。你已经学会了如何在代码中构建纯函数的模块,并尽可能将副作用放置离核心代码更远的地方,这将有助于你构建充满自信的、正确的应用程式。以及更少时间的debugging调试、必要的孤立可变状态避免更多的错误发生。
函数式编程最至关重要的得益之处是组合,即你通过提供基础属性构建大的编程。通过Scala的强大的抽象类型,最终你可以构建一个代码复用的组件。
同时,我们还学习了函数式编程的一种设计模式——Monad。它可以让你的组合直面副作用。最初它们看起来是复杂的,当你使用时,你会发现在很多地方都用到了这种设计模式,包括标准Scala库。使用Monads,你仅仅接触了函数式编程设计模式、概念的表面。我强烈推荐更进一步探索函数式编程的概念。最好的一个开始是阅读《Scala in Depth》。
函数式编程并不仅局限于Scala。你可以在你学过的任何编程语言中使用。时刻记住函数式编程的关键是创建一个引用透明的表达式,如果在你的语言中可以实现,那么go for it。函数式编程也应该能够组合。简言之,一门语言比其他编程语言更加函数式在于它比其它编程语言更容易组合。
第六章将会探索Java代码基于集成Scala的更多优势。
- Slava Akhmechet, “Functional Programming for the Rest of Us,” June 19, 2006, ↩
- Lloyd Anderson, “Lambda Calculus ↩
- Michael Feathers, “The Successor Value Pattern, March 22, 2009 ↩
- “Strategy pattern,” February 2011 ↩
- Kevin Wright (added), “Loan pattern,” last edited May 25, 2011 ↩
- Reg Braithwaite, “Kestrels” ↩
- Graham Hutton, Programming in Haskell (Cambridge University Press, 2007). ↩
- “Category Theory” ↩