相依注入 - Dependency Injection ,對於 OOP 來說是一個很重要的概念,各框架、語言也都有非常多的函式庫可以用。主流的注入的方法主要有兩種:setter, constructor ,但這兩種方法都是必須要有“Object”才能夠達成,對於一個不會有“Object”的 functional programming 來說,要怎麼做到 Dependency Injection 呢?今天就來介紹 functional programming 界的 Dependency Injection - Reader Monad。
看到這邊讀者可能有疑問,之前介紹的 Algebraic Data Type 不是使用 class 做的嗎?在 Kotlin 中產生一個 class 的實例不就是是一個“Object”嗎?其實在 Functional Programming 的世界,這個 class 比較像是被當成 "Struct" 來使用,Algebraic Data Type 不會有任何的 function ,沒有任何行為,就只是一個資料。
如果要在 function 中注入實例,最直覺的想法當然是從參數注入了:
class Point(val x: Int, val y: Int)
val add: (Point, Point) -> Point = { first, second ->
Point(first.x + second.x, first.y + second.y)
}
那如果現在注入的是一個複雜的類別呢?下面是一個 Github 的應用程式的範例。
// 1
interface GithubRepository {
fun login(email: String): Either<Throwable, String>
...
fun getRepos(userId: String): List<Repo>
fun getIssues(repoId: String): List<Issue>
...
}
// 2
interface GithubService {
fun searchIssue(userId: String, keyword: String, githubRepository: GithubRepository): List<Issue>
fun login(email: String, githubRepository: GithubRepository): Maybe<User>
fun addComment(issueId: String, comment: String, githubRepository: GithubRepository): Boolean
}
//3
class User(val userId: String)
class Repo(val repoId: String, val title: String)
class Issue(val issueId: String, val content: String)
GithubRepository
提供了各式各樣的 github API 操作,這些 API 都是最基本的操作,不會有商業邏輯在這個類別中。GithubService
包含了各種商業邏輯的操作,例如 searchIssue
就可能需要打 GithubRepository
的兩到三個 API ,再經過一連串的計算,最後返回結果。為了跟 GithubRepository
做互動,所以必須將實例以 setter 的方式做注入,同時這麼做也是為了讓這些 function 都是 Pure function假設現在我要做一個機器人,想要對某些符合條件的 Issue 都加上 Comment ,那麼實作可能會像下面這樣:
fun robot(githubService: GithubService, githubRepository: GithubRepository) {
val keyword = "master"
val comment = "No more master/slave"
// 1
githubService.login("yanbin@hotmail.com", githubRepository)
// 2
.map { user -> githubService.searchIssue(user.userId, keyword, githubRepository) }
.map { issues: List<Issue> ->
issues.map { issue ->
// 3
githubService.addComment(issue.issueId, comment, githubRepository)
}
}
// 4
.fold(someFun = {
// Do something
}, noneFun = {
// Do something for user not find
})
}
githubService.login()
來做登入,由於這個 function 回傳的是一個 Maybe ,有可能結果是空的,為了對有值的情況下做操作,就要使用 map
這個 operator。GithubService
與 GithubRepository
。map
,別慌張!第一層是對 Maybe 做操作,第二層是對 List 做操作,關注第二層即可,上一個結果回傳了 List ,這些是我們要搜尋的結果,最後就可以針對這些 Issue 一個一個加上 comment,就完成我們的需求了。最後這裡還是出現了 GithubService
與 GithubRepository
。上面的程式碼中, GithubService
與 GithubRepository
各出現了三次,其實寫久了會有點煩,會想要用更簡潔的方式來處理,而且為了要執行 robot
,還必須要有 GithubService
與 GithubRepository
的實例才行,既然從參數注入這個方法這麼麻煩,現在我們來換另一種做法:
interface GithubService {
// 為了簡化問題把 Maybe 拿掉了,關於這部分之後有機會會在回過頭來做給大家看
fun login(email: String): (GithubRepository) -> User
// 一樣為了簡化問題把 List 拿掉了,關於這部分在章節的最後會回過頭來做給大家看
fun searchIssue(userId: String, keyword: String): (GithubRepository) -> Issue
fun addComment(issueId: String, comment: String): (GithubRepository) -> Boolean
}
請看每個 function 的回傳型別,居然把型別往後移了!?還串起來變成了一個 function type ,這是什麼概念?其實這就是柯里化(curried),目前可以先不用在意柯里化是什麼,這概念在後面的章節會再詳細介紹。目前要在意的是,這個 function type 給予了我們一個額外的能力,一個不需要有 GithubRepository
實例的能力。這裡運用 lazy execution 的概念,在執行上述 GithubService
的任何 function 時,他們回傳的是一個 lambda function ( 型別為 (GithunRepository) -> T
),而這個 lambda function 可以再傳給一個 higher order function,之後就可以在任意的時間點執行這個 function,也可以在任意的時間點再注入 GithubRepository
。
接下來,我們還希望這些流程還是可以很清楚的表現在程式碼上面,我們的最終目的是一個清晰,好讀的程式碼:
githubService.login(email)
.???{ user -> githubService.searchIssue(user.userId, keyword)}
.???{ issue -> githubService.addComment(issue.issueId, comment)}
上面的這個流程是順的,看起來非常舒服,也不用看到 GithubRepository 了,但是這件事做得到嗎?我們有辦法組合這些 function 嗎?於是接下來就要輪到 Reader 出場了
class Reader<D, out A>(val run: (D) -> A) {
inline fun <B> map(crossinline fa: (A) -> B): Reader<D, B> = Reader {
d -> fa(run(d))
}
inline fun <B> flatMap(crossinline fa: (A) -> Reader<D, B>): Reader<D, B> = Reader {
d -> fa(run(d)).run(d)
}
}
Reader 的建構子是一個 function ,一個 input D 配上一個 output A ,D 對應到的就是需要的 Dependency , A 對應到的是目前正在使用的 Type 。這些剛好都可以取代上面的 (GithubRepository) -> User
為 Reader<GithubRepository, User>
,(GithubRepository) -> Issue
為 Reader<GithubRepository, Issue>
與 (GithubRepository) -> Boolean
為 Reader<GithubRepository, Boolean>
。
interface GithubService {
fun login(email: String): Reader<GithubRepository, User>
fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}
接下來再來看看 map
做的是什麼事, map
建立了一個新的 Reader , 裡面做的事情是 fa
這個 function 與 run
- 建構子傳進來的參數來做組合,所以 function 組合的結果會是一個 Type B 的變數,於是就會得到一個 Reader<D, B>
的結果。寫的更加直覺一點就是 Reader<D, A> map (A -> B) => Reader<D, B>
,那這不是跟 List, Maybe 還有 Either 是一樣的模式嗎? 所以一樣的,也會有一個 flatMap
是 Reader<D, A> flatMap (A -> Reader<D, B>) => Reader<D, B>
。
好了,在回過頭來看看我們希望的樣子吧:
githubService.login(email)
.???{ user -> githubService.searchIssue(user.userId, keyword)}
.???{ issue -> githubService.addComment(issue.issueId, comment)}
來分析一下每一行的型別分別是什麼,第一行很簡單是 Reader<GithubRepository, User>
,第二行是一個 higher order function ,裡面只有一個參數,型別是 function type,而這個 function 呢,輸入是 User ,輸出是 Reader<GithubRepository, Issue>
,所以這個 function type 的型別是 (User) -> Reader<GithubRepository, Issue>
,依此類推,第三行的 function type 的型別是 (Issue) -> Reader<GithubRepository, Boolean>
,所以答案已經很明顯了, flatMap
剛好就是一個 higher order function ,唯一的參數還是一個輸入 T 配上 Reader 的 Function Type , ??? 要填入的正是 flatMap
。
val result: Reader<GithubRepository, Boolean> = githubService.login(email)
.flatMap{ user -> githubService.searchIssue(user.userId, keyword)}
.flatMap{ issue -> githubService.addComment(issue.issueId, comment)}
// When we have githubReposiotry...
val boolean: Boolean = result.run(githubRepository)
跟往常不一樣的,今天介紹了一個用 "function" 當做“容器” 的 Reader ,正因為有了這個 function ,才使得我們有能力延後做 Dependency Injection ,不用在一開始就提供。同時今天的範例也強調了一個重點,“Functional programming 應該要讓流程完整呈現,不應該讓其他的工程師看不懂”。
但是實際情況還是複雜很多,像今天的範例就把 Maybe 跟 List 拿掉了,萬一我真的需要他們怎麼辦?別擔心,在後面的章節會看到的。這邊先提前爆雷一下,像是 List 就可以這樣做:
interface GithubService {
fun login(email: String): Reader<GithubRepository, User>
fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}
val keyword = "master"
val comment = "No more master/slave"
fun robot() {
val addCommentReader = githubService.login("yanbin@hotmail.com")
.flatMap { user -> githubService.searchIssue(user.userId, keyword) }
.flatMap { issues: List<Issue> ->
issues.map { issue ->
githubService.addComment(issue.issueId, comment)
}.liftReader()
}
val result: List<Boolean> = addCommentReader.run(githubRepository)
}
fun <D, A> List<Reader<D, A>>.liftReader(): Reader<D, List<A>> {
...
}
在 searchIssue
之後回傳的型別是 Reader<GithubRepository, List<Issue>>
。但是在下一行中,如果對 issues 使用 map
卻會得到 List<Reader<GithubRepository, Boolean>>
,這不是我們喜歡看到的型別, 如果是 Reader 在前面會好處理很多。所以 liftReader
就將容器的順序調換過來了!如此一來就可以專心的做 Dependency Injection 而不用擔心 List 。
還有,這邊故意不實作應該知道我的用意了...沒錯!這邊的實作留給大家來挑戰看看!
https://jorgecastillo.dev/kotlin-dependency-injection-with-the-reader-monad