在學(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的代碼例子
- 這個(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
- 這個(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()
}
- 這個(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,而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 推導(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)景:
-
ShowinCats -
Encoder,Decoderincirce