谈谈Scala FP中那些基本又重要的概念
在学习和使用Scala FP的过程中,我们经常发觉这条道路非常陡峭,但其实有的时候不是因为当前正在使用的库或者代码组织方式复杂,很多时候是我们对一些基本概念的理解不够透彻。FP和Scala中有很多基本概念,这些概念可能一学就会,但在实际代码世界中却一用就废。
本文会首先对FP中常见的一些概念通过举例的方式进行澄清,然后对Scala中常用的ADT和Type Class这两种类型系统通过推理的方式进一步梳理。
Overview
- Effect and Side Effect
- Pure Function
- Referential Transparency
- What is functional programming?
- Algebraic Data Type
- Type Class
Effect and Side Effect
网上有很多资料通过举例子的方式讨论什么是Side Effect,但却很少直接对Side Effect有一个明确的定义,也很少有讨论Effect是什么。这里是我们多年在FP项目上工作的总结理解:
广义的Effect即代码块和外部程序的交互,即函数的返回值向外部表达的信息(内部对外部的交互),其可以分为两种:
- Effect:函数内部对外部的交互全部表现在返回值中;
- Side Effect:函数内部对外部的交互除了函数返回值外,还表达了其他的信息,例如打日志,读写文件等;
常见的Side Effect例子
- 修改一个变量
- 修改数据结构
- 修改对象中的一个字段
- 抛出异常
- 打日志或者读取用户输入
- 读写文件
有Side Effect的代码例子
- 这个方法
division
表达除法计算,但当y为0的时候会抛出异常,但这个异常没有在其返回值Double
中体现,所以存在Side Effect
def division(x: Double, y: Double): Double = x/y
- 这个方法
saveToFile
表达存储文件的操作,但返回值却只是一个Unit
,从这个定义中没有办法体现“存储文件”的操作,所以存在 Side Effect
def saveToFile(content: String):Unit = {
val writer = new PrintWriter(new File("data/test.txt" ))
writer.write(content)
writer.close()
}
- 这个方法
splitData
表达对字符串的分割操作,返回值是List[String]
,可以体现最终的计算结果,但这个方法中除了分个字符串的计算,还进行了打印一行字的操作,这个返回值无法完全体现所有对外部的交互,所以存在 Side Effect
def splitData(content: String):List[String] = {
println(s"processing ${content}")
content.split(",").toList
}
Pure Function
一个函数是否Pure,需要同时满足这两个条件:
- 对所有的输出,都会有相同的输出
- 没有Side Effect
两条要同时满足,其中一条不满足则不是纯函数,例如
val intProcessor = {
case _:Int => "Ok"
}
def addRandom(x:Int):Int = {
x + Random.nextInt
}
Referential Transparency
纯函数的一个特性是引用透明,这两个概念几乎可以认为是等价的。
引用透明:任何出现function的地方都可以用它的值替代;
这个概念听起来简单,但在实际开发过程中,有什么不同的变种,关于引用透明更多的理解可以参考这篇文章,里面有很多实际的例子来帮助我们更好的理解引用透明。
What is functional programming?
根据维基百科的定义,全部由函数来构建程序就可以认为是函数式编程。
但比较有意思的是,在Scala2时,根据其文档的描述,函数式编程是“全部由纯函数构建的程序”;而Scala3时,根据其文档的描述,函数式编程是“全部由函数构建的程序”。其实这里也可以理解这个变化,我们编写的程序一定是要完成某种操作,比如对状态的改变、数据的持久化、打日志、处理异常等,这些都是Side Effect,要想实现业务价值就一定会引入Side Effect。所以不管是哪种定义,实际的处理方式都是尽可能让大部分的代码逻辑都是由纯函数实现的,而我们会把包含Side Effect的操作尽可能延期,延到最后的Main函数中统一进行处理。
Algebraic Data Type
这里先不下定义,通过一个例子,用推导的方式理解为什么需要ADT,什么情况下需要使用ADT。
假设一个场景:对于给定函数 division
的例子,通过前面的分析,我们已经知道这个方法存在Side Effect,所以他不是纯函数。如何让这个不纯的方法变纯呢?
def division(x:Double, y:Double):Double = x/y
1. 分析其存在几种Effect
这里的Effect即广义的Effect,即这个函数能表达几种内部对外部的影响?这里是两种:
- 正常的除法计算结果
- 错误异常(当y为0的时候)
2. 用不同的数据结构表达每种Effect
case class Result(v:Double)
case class DivisionError(error:String, input:(Double, Double))
3. 统一所有的Effect
没有Side Effect即所有的输出都能在函数的返回值中体现出来,我们要想办法把这两个Effect都能够体现在返回值中,最简单的办法是给他们抽取一个最小化的父类,即:
sealed trait Response
case class Result(v:Double) extends Response
case class DivisionError(error:String, input: (Double, Double)) extends Response
4. 重构函数返回统一的effect
def division(x:Double, y:Double):Response =
if(y==0)
DivisionError("exception happen", (x,y))
else
Result(x/y)
Algebraic Data Type(ADT)
到这里为止,将一个存在Side Effect的函数改造成纯函数的修改就已经完成,这里使用的数据结构就叫做 Algebraic Data Type(ADT),且 ADT是由Sum Type或者Product Type组成的,比如这里的Product
就是Sum Type,而Result
和Division Error
是Product Type。
即: Algebraic Data Type = Product Type || Sum Type
sealed trait Response // sum type
case class Result(v:Double) extends Response // product type
case class DivisionError(error:String, input(Double, Double)) extends Response // product type
Type Class 推导过程
和ADT类似,这里先不下定义,通过一个例子,用推导的方式理解为什么需要Type Class,什么情况下需要使用Type Class。
假设一个场景:对于已有的ADT结构如何为其添加一个方法?
case class Age(value: Int)
case class Person(name: String, age: Age)
case class Point(x: Int, y: Int)
1. 如果在OO的世界
通常的做法是直接在已有的类中定义需要增加的方法,如下:
case class Age(var value: Int){
def add(delta: Int):Unit =
value += delta
}
case class Person(var name: String, var age: Age){
def add(delta: Int):Unit =
age.add(delta)
}
case class Point(var x: Int, var y: Int){
def add(delta: Int):Unit = {
x += delta
y += delta
}
}
但在FP的代码中一般不会这样实现,何况这里的假设是我们希望在不改变已有类的前提下(假设这些类都是已有的第三方库中的定义),该如何增加方法呢?
2. 如果在FP的世界
在不改变已有类的前提下,可以通过定义高阶函数的方式来实现,如下:
def addAge(delta: Int)(v: Age): Age = Age(v.value + delta)
def addPerson(delta: Int)(v: Person): Person = Person(v.name, addAge(delta)(v.age))
def addPoint(delta: Int)(v: Point): Point = Point(v.x + delta, v.y + delta)
上述方式是可以的,但存在一个痛点,如果是这种定义方式,当我们需要连续调用 add
时,代码如下:
val result = addAge(1)(addAge(2)(addAge(3)(Age(0))))
这种调用方式可读性极差,嵌套很深,一不小心可能括号数量都会对不上,而对于一个需要处理特定业务场景的server来说,更会是灾难性的写法。所以我们更希望的调用方式是可读的,类似这样的写法:
val result = Age(0).addAge(1).addAge(2).addAge(3)
3. 改进ing:模拟OO的写法
为了增加可读性,这里引入Scala中Implicit的使用:
object Age {
implicit class AgeOps(v: Age){
def add(delta: Int): Age = Age(v.value + delta)
}
}
object Person {
implicit class PersonOps(v: Person){
def add(delta: Int): Person = Person(v.name, v.age.add(delta))
}
}
object Point {
implicit class PointOps(v: Point){
def add(delta: Int): Point = Point(v.x + delta, v.y + delta)
}
}
通过implicit class的定义,这里可以实现调用方式:
val result = Age(0).add(1).add(2).add(3)
这个问题解决了,那么假如这里增加了一个新的需求:调用所有定了了add
方法的类的add
方法。那么实现代码如下:
def processAdd[A](a: A, delta: Int): A = {
a match {
case x: Age => x.add(delta).asInstanceOf[A]
case x: Person => x.add(delta).asInstanceOf[A]
case x: Point => x.add(delta).asInstanceOf[A]
case _ => throw new Exception(s"Can not process ${a}")
}
}
这时就出现了一些痛点:
- 当下
Age
,Person
,Point
确实都有方法add
的定义,但实际却没有任何限制它们必须要使用相同的名字来定义这些方法,比如Add
是可以把它的add
方法修改为addAge
的,这种修改并不会产生任何错误; - 方法
processAdd
很丑,有很多重复代码,会抛出异常,还使用了asInstanceOf
4. 改进ing:使用统一的隐式类定义接口
使用隐式类来统一定义add
方法:
object AddSyntax {
implicit class AddOps[A](v: A){
def add(delta: Int): A = ???
}
}
通过这种方式可以限制 Age
, Person
, Point
定义并使用名为 add
的方法,但它们的 add
方法实现大概率是不同的,如何表达它们分别有自己的不同实现呢?我们可以把它们各自的实现作为二阶参数传入:
object AddSyntax {
implicit class AddOps[A](v: A)(implicit f: (A, Int) => A){
def add(delta: Int): A = f(v, delta)
}
}
并分别为它们定义不同的实现方法:
implicit def ageAddFunction(age: Age, delta: Int) = Age(age.value + delta)
implicit def personAddFunction(person: Person, delta: Int): Person = Person(person.name, person.age.add(delta))
implicit def pointAddFunction(point: Point, delta: Int): Point = Point(point.x + delta, point.y + delta)
则简化后的调用方式就变得很简单:
def processAdd[A](a: A, delta: Int)(implicit f:(A, Int) => A): A = a.add(delta)
这时又增加了一个新的业务需求,为 Age
, Person
, Point
增加 sub
方法,根据上面的改进,我们可以轻易的写出如下实现代码:
object SubSyntax {
implicit class SubOps[A](v: A)(implicit f: (A, Int) => A){
def sub(delta: Int): A = f(v, delta)
}
}
implicit def ageSubFunction(age: Age, delta: Int) = Age(age.value - delta)
implicit def personSubFunction(person: Person, delta: Int): Person = Person(person.name, person.age.sub(delta))
implicit def pointSubFunction(point: Point, delta: Int): Point = Point(point.x - delta, point.y - delta)
看起来都很正确,但实际情况是:add
方法和sub
方法无法同时使用,因为它们的instance实现方法的签名是一样的,且都使用了implicit,对与implicit方法,当它们的签名相同时,编译器无法推断代码到底想要使用哪一个方法,故出现错误。
5. 改进ing:使用Trait来封装实现方法
即然编译器因为签名相同而无法推断要使用的方法,那我们给要增加的方法封装一个类型:
trait AddInterface[A] {
def add(value: A, delta: Int): A
}
trait SubInterface[A] {
def sub(value: A, delta: Int): A
}
相应的修改:
object AddSyntax {
implicit class AddOps[A](v: A)(implicit addInstance: AddInterface[A]){
def add(delta: Int): A = addInstance.add(v, delta)
}
}
object SubSyntax {
implicit class SubOps[A](v: A)(implicit subInstance: SubInterface[A]){
def sub(delta: Int): A = subInstance.sub(v, delta)
}
}
implicit val ageAddInstance = new AddInterface[Age] {
override def add(age: Age, delta: Int): Age = Age(age.value + delta)
}
implicit val personAddInstance = new AddInterface[Person] {
override def add(person: Person, delta: Int): Person = Person(person.name, person.age.add(delta))
}
implicit val pointAddInstance = new AddInterface[Point] {
override def add(point: Point, delta: Int): Point = Point(point.x + delta, point.y + delta)
}
implicit val ageSubInstance = new SubInterface[Age] {
override def sub(age: Age, delta: Int): Age = Age(age.value - delta)
}
implicit val personSubInstance = new SubInterface[Person] {
override def sub(person: Person, delta: Int): Person = Person(person.name, person.age.sub(delta))
implicit val pointSubInstance = new SubInterface[Point] {
override def sub(point: Point, delta: Int): Point = Point(point.x - delta, point.y - delta)
}
def processAdd[A: AddInterface](a: A, delta: Int): A = a.add(delta)
到这为止,一个Type Class就定义结束了,通过上面的推导过程,我们可以得到如下结论:
- Type Class是由 Interface,Instance 和Syntax组成的
- Type Class可以在不修改已有ADT的情况下,为其增加方法
Type Class
Type Class是由 Interface,Instance 和Syntax组成的,这里我们对他们进行抽象。
Interface
抽象定义一组行为,这个行为可以添加到已有的ADT上:
trait DoSomethingInterface[A] {
def doSomething(a: A)
}
Instance
对要增加方法的类 SomeType
, 实现其 doSomething
方法的具体实现:
implicit val someTypeDoSomething = new DoSomethingInterface[SomeType] {
def doSomething(a: SomeType) = ???
}
Syntax
代码中正真调用 doSomething
方法的地方,同时也使我们代码的可读性提高:
object DoSomeThingSyntax {
implicit class DoSomethingOps[A: DoSomethingInterface](v: A) {
def doSomething(a: A) = implicitly[DoSomethingInterface[A]].doSomething(a)
}
}
Type Class 的使用
在实际代码逻辑的实现中,自己从头到尾Type Class的使用场景其实并不多,更多的场景是Type Class被Scala FP的第三方库所重度使用,通常第三方库会提供 Interface, Syntax和一部分Instance的实现。当我们使用这些第三方库时,我们可以根据业务场景实现自己的Instance。列举两个自己定义Instance并使用Type Class的场景:
-
Show
inCats
-
Encoder
,Decoder
incirce