談?wù)凷cala FP中那些基本又重要的概念

在學(xué)習(xí)和使用Scala FP的過程中,我們經(jīng)常發(fā)覺這條道路非常陡峭,但其實(shí)有的時(shí)候不是因?yàn)楫?dāng)前正在使用的庫或者代碼組織方式復(fù)雜,很多時(shí)候是我們對(duì)一些基本概念的理解不夠透徹。FP和Scala中有很多基本概念,這些概念可能一學(xué)就會(huì),但在實(shí)際代碼世界中卻一用就廢。

本文會(huì)首先對(duì)FP中常見的一些概念通過舉例的方式進(jìn)行澄清,然后對(duì)Scala中常用的ADT和Type Class這兩種類型系統(tǒng)通過推理的方式進(jìn)一步梳理。

Overview

  • Effect and Side Effect
  • Pure Function
  • Referential Transparency
  • What is functional programming?
  • Algebraic Data Type
  • Type Class

Effect and Side Effect

網(wǎng)上有很多資料通過舉例子的方式討論什么是Side Effect,但卻很少直接對(duì)Side Effect有一個(gè)明確的定義,也很少有討論Effect是什么。這里是我們多年在FP項(xiàng)目上工作的總結(jié)理解:

廣義的Effect即代碼塊和外部程序的交互,即函數(shù)的返回值向外部表達(dá)的信息(內(nèi)部對(duì)外部的交互),其可以分為兩種:

  • Effect:函數(shù)內(nèi)部對(duì)外部的交互全部表現(xiàn)在返回值中;
  • Side Effect:函數(shù)內(nèi)部對(duì)外部的交互除了函數(shù)返回值外,還表達(dá)了其他的信息,例如打日志,讀寫文件等;
常見的Side Effect例子
  • 修改一個(gè)變量
  • 修改數(shù)據(jù)結(jié)構(gòu)
  • 修改對(duì)象中的一個(gè)字段
  • 拋出異常
  • 打日志或者讀取用戶輸入
  • 讀寫文件
有Side Effect的代碼例子
  1. 這個(gè)方法division表達(dá)除法計(jì)算,但當(dāng)y為0的時(shí)候會(huì)拋出異常,但這個(gè)異常沒有在其返回值Double中體現(xiàn),所以存在Side Effect
def division(x: Double, y: Double): Double = x/y
  1. 這個(gè)方法saveToFile表達(dá)存儲(chǔ)文件的操作,但返回值卻只是一個(gè)Unit,從這個(gè)定義中沒有辦法體現(xiàn)“存儲(chǔ)文件”的操作,所以存在 Side Effect
def saveToFile(content: String):Unit = {
  val writer = new PrintWriter(new File("data/test.txt" ))
  writer.write(content)
  writer.close()
}
  1. 這個(gè)方法splitData表達(dá)對(duì)字符串的分割操作,返回值是List[String],可以體現(xiàn)最終的計(jì)算結(jié)果,但這個(gè)方法中除了分個(gè)字符串的計(jì)算,還進(jìn)行了打印一行字的操作,這個(gè)返回值無法完全體現(xiàn)所有對(duì)外部的交互,所以存在 Side Effect
def splitData(content: String):List[String] = {
  println(s"processing ${content}")
  content.split(",").toList
}

Pure Function

一個(gè)函數(shù)是否Pure,需要同時(shí)滿足這兩個(gè)條件:

  • 對(duì)所有的輸出,都會(huì)有相同的輸出
  • 沒有Side Effect

兩條要同時(shí)滿足,其中一條不滿足則不是純函數(shù),例如

val intProcessor = {
  case _:Int => "Ok"
}
def addRandom(x:Int):Int = {
  x + Random.nextInt
}

Referential Transparency

純函數(shù)的一個(gè)特性是引用透明,這兩個(gè)概念幾乎可以認(rèn)為是等價(jià)的。
引用透明:任何出現(xiàn)function的地方都可以用它的值替代;

這個(gè)概念聽起來簡單,但在實(shí)際開發(fā)過程中,有什么不同的變種,關(guān)于引用透明更多的理解可以參考這篇文章,里面有很多實(shí)際的例子來幫助我們更好的理解引用透明。

What is functional programming?

根據(jù)維基百科的定義,全部由函數(shù)來構(gòu)建程序就可以認(rèn)為是函數(shù)式編程。

但比較有意思的是,在Scala2時(shí),根據(jù)其文檔的描述,函數(shù)式編程是“全部由純函數(shù)構(gòu)建的程序”;而Scala3時(shí),根據(jù)其文檔的描述,函數(shù)式編程是“全部由函數(shù)構(gòu)建的程序”。其實(shí)這里也可以理解這個(gè)變化,我們編寫的程序一定是要完成某種操作,比如對(duì)狀態(tài)的改變、數(shù)據(jù)的持久化、打日志、處理異常等,這些都是Side Effect,要想實(shí)現(xiàn)業(yè)務(wù)價(jià)值就一定會(huì)引入Side Effect。所以不管是哪種定義,實(shí)際的處理方式都是盡可能讓大部分的代碼邏輯都是由純函數(shù)實(shí)現(xiàn)的,而我們會(huì)把包含Side Effect的操作盡可能延期,延到最后的Main函數(shù)中統(tǒng)一進(jìn)行處理。

Algebraic Data Type

這里先不下定義,通過一個(gè)例子,用推導(dǎo)的方式理解為什么需要ADT,什么情況下需要使用ADT。

假設(shè)一個(gè)場(chǎng)景:對(duì)于給定函數(shù) division 的例子,通過前面的分析,我們已經(jīng)知道這個(gè)方法存在Side Effect,所以他不是純函數(shù)。如何讓這個(gè)不純的方法變純呢?

def division(x:Double, y:Double):Double = x/y
1. 分析其存在幾種Effect

這里的Effect即廣義的Effect,即這個(gè)函數(shù)能表達(dá)幾種內(nèi)部對(duì)外部的影響?這里是兩種:

  • 正常的除法計(jì)算結(jié)果
  • 錯(cuò)誤異常(當(dāng)y為0的時(shí)候)
2. 用不同的數(shù)據(jù)結(jié)構(gòu)表達(dá)每種Effect
case class Result(v:Double)
case class DivisionError(error:String, input:(Double, Double))
3. 統(tǒng)一所有的Effect

沒有Side Effect即所有的輸出都能在函數(shù)的返回值中體現(xiàn)出來,我們要想辦法把這兩個(gè)Effect都能夠體現(xiàn)在返回值中,最簡單的辦法是給他們抽取一個(gè)最小化的父類,即:

sealed trait Response
case class Result(v:Double) extends Response
case class DivisionError(error:String, input: (Double, Double)) extends Response
4. 重構(gòu)函數(shù)返回統(tǒng)一的effect
def division(x:Double, y:Double):Response = 
 if(y==0) 
   DivisionError("exception happen", (x,y)) 
 else 
   Result(x/y)
Algebraic Data Type(ADT)

到這里為止,將一個(gè)存在Side Effect的函數(shù)改造成純函數(shù)的修改就已經(jīng)完成,這里使用的數(shù)據(jù)結(jié)構(gòu)就叫做 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 推導(dǎo)過程

和ADT類似,這里先不下定義,通過一個(gè)例子,用推導(dǎo)的方式理解為什么需要Type Class,什么情況下需要使用Type Class。

假設(shè)一個(gè)場(chǎng)景:對(duì)于已有的ADT結(jié)構(gòu)如何為其添加一個(gè)方法?

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的代碼中一般不會(huì)這樣實(shí)現(xiàn),何況這里的假設(shè)是我們希望在不改變已有類的前提下(假設(shè)這些類都是已有的第三方庫中的定義),該如何增加方法呢?

2. 如果在FP的世界

在不改變已有類的前提下,可以通過定義高階函數(shù)的方式來實(shí)現(xiàn),如下:

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)

上述方式是可以的,但存在一個(gè)痛點(diǎn),如果是這種定義方式,當(dāng)我們需要連續(xù)調(diào)用 add 時(shí),代碼如下:

val result = addAge(1)(addAge(2)(addAge(3)(Age(0))))

這種調(diào)用方式可讀性極差,嵌套很深,一不小心可能括號(hào)數(shù)量都會(huì)對(duì)不上,而對(duì)于一個(gè)需要處理特定業(yè)務(wù)場(chǎng)景的server來說,更會(huì)是災(zāi)難性的寫法。所以我們更希望的調(diào)用方式是可讀的,類似這樣的寫法:

val result = Age(0).addAge(1).addAge(2).addAge(3)
3. 改進(jìn)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的定義,這里可以實(shí)現(xiàn)調(diào)用方式:

val result = Age(0).add(1).add(2).add(3)

這個(gè)問題解決了,那么假如這里增加了一個(gè)新的需求:調(diào)用所有定了了add方法的類的add方法。那么實(shí)現(xiàn)代碼如下:

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}")
  }
}

這時(shí)就出現(xiàn)了一些痛點(diǎn):

  • 當(dāng)下 Age, Person, Point 確實(shí)都有方法 add的定義,但實(shí)際卻沒有任何限制它們必須要使用相同的名字來定義這些方法,比如 Add 是可以把它的 add 方法修改為 addAge的,這種修改并不會(huì)產(chǎn)生任何錯(cuò)誤;
  • 方法 processAdd 很丑,有很多重復(fù)代碼,會(huì)拋出異常,還使用了 asInstanceOf
4. 改進(jìn)ing:使用統(tǒng)一的隱式類定義接口

使用隱式類來統(tǒng)一定義add方法:

object AddSyntax {
  implicit class AddOps[A](v: A){
    def add(delta: Int): A = ???
  }
}

通過這種方式可以限制 Age, Person, Point 定義并使用名為 add的方法,但它們的 add 方法實(shí)現(xiàn)大概率是不同的,如何表達(dá)它們分別有自己的不同實(shí)現(xiàn)呢?我們可以把它們各自的實(shí)現(xiàn)作為二階參數(shù)傳入:

object AddSyntax {
  implicit class AddOps[A](v: A)(implicit f: (A, Int) => A){
    def add(delta: Int): A = f(v, delta)
  }
}

并分別為它們定義不同的實(shí)現(xiàn)方法:

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)

則簡化后的調(diào)用方式就變得很簡單:

def processAdd[A](a: A, delta: Int)(implicit f:(A, Int) => A): A =  a.add(delta)

這時(shí)又增加了一個(gè)新的業(yè)務(wù)需求,為 Age, Person, Point 增加 sub 方法,根據(jù)上面的改進(jìn),我們可以輕易的寫出如下實(shí)現(xiàn)代碼:

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)

看起來都很正確,但實(shí)際情況是:add方法和sub方法無法同時(shí)使用,因?yàn)樗鼈兊膇nstance實(shí)現(xiàn)方法的簽名是一樣的,且都使用了implicit,對(duì)與implicit方法,當(dāng)它們的簽名相同時(shí),編譯器無法推斷代碼到底想要使用哪一個(gè)方法,故出現(xiàn)錯(cuò)誤。

5. 改進(jìn)ing:使用Trait來封裝實(shí)現(xiàn)方法

即然編譯器因?yàn)楹灻嗤鵁o法推斷要使用的方法,那我們給要增加的方法封裝一個(gè)類型:

trait AddInterface[A] {
  def add(value: A, delta: Int): A
}

trait SubInterface[A] {
  def sub(value: A, delta: Int): A
}

相應(yīng)的修改:

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)

到這為止,一個(gè)Type Class就定義結(jié)束了,通過上面的推導(dǎo)過程,我們可以得到如下結(jié)論:

  • Type Class是由 Interface,Instance 和Syntax組成的
  • Type Class可以在不修改已有ADT的情況下,為其增加方法

Type Class

Type Class是由 Interface,Instance 和Syntax組成的,這里我們對(duì)他們進(jìn)行抽象。

Interface

抽象定義一組行為,這個(gè)行為可以添加到已有的ADT上:

  trait DoSomethingInterface[A] {
    def doSomething(a: A)
  }
Instance

對(duì)要增加方法的類 SomeType, 實(shí)現(xiàn)其 doSomething 方法的具體實(shí)現(xiàn):

  implicit val someTypeDoSomething = new DoSomethingInterface[SomeType] {
      def doSomething(a: SomeType) = ???
  }
Syntax

代碼中正真調(diào)用 doSomething 方法的地方,同時(shí)也使我們代碼的可讀性提高:

object DoSomeThingSyntax {
  implicit class DoSomethingOps[A: DoSomethingInterface](v: A) {
    def doSomething(a: A) = implicitly[DoSomethingInterface[A]].doSomething(a)
  }
}

Type Class 的使用

在實(shí)際代碼邏輯的實(shí)現(xiàn)中,自己從頭到尾Type Class的使用場(chǎng)景其實(shí)并不多,更多的場(chǎng)景是Type Class被Scala FP的第三方庫所重度使用,通常第三方庫會(huì)提供 Interface, Syntax和一部分Instance的實(shí)現(xiàn)。當(dāng)我們使用這些第三方庫時(shí),我們可以根據(jù)業(yè)務(wù)場(chǎng)景實(shí)現(xiàn)自己的Instance。列舉兩個(gè)自己定義Instance并使用Type Class的場(chǎng)景:

  • Show in Cats
  • Encoder, Decoder in circe

參考文獻(xiàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過簡信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容