谈谈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的代码例子
  1. 这个方法division表达除法计算,但当y为0的时候会抛出异常,但这个异常没有在其返回值Double中体现,所以存在Side Effect
def division(x: Double, y: Double): Double = x/y
  1. 这个方法saveToFile表达存储文件的操作,但返回值却只是一个Unit,从这个定义中没有办法体现“存储文件”的操作,所以存在 Side Effect
def saveToFile(content: String):Unit = {
  val writer = new PrintWriter(new File("data/test.txt" ))
  writer.write(content)
  writer.close()
}
  1. 这个方法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,而ResultDivision 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 in Cats
  • Encoder, Decoder in circe

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,783评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,396评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,834评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,036评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,035评论 5 362
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,242评论 1 278
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,727评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,376评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,508评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,415评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,463评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,140评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,734评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,809评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,028评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,521评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,119评论 2 341

推荐阅读更多精彩内容