談談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

參考文獻

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。

推薦閱讀更多精彩內容