上一章重點在于代碼重復:提升現(xiàn)有的函數(shù)功能、或者將函數(shù)進行組合。這一章,我們來看看另外兩種函數(shù)重用的機制:函數(shù)的部分應用(Partial Application of Functions) 、 柯里化(Currying) 。
和其他遵循函數(shù)式編程范式的語言一樣,Scala 允許_部分_應用一個函數(shù)。調(diào)用一個函數(shù)時,不是把函數(shù)需要的所有參數(shù)都傳遞給它,而是僅僅傳遞一部分,其他參數(shù)留空;這樣會生成一個新的函數(shù),其參數(shù)列表由那些被留空的參數(shù)組成。(不要把這個概念和偏函數(shù)混淆)
為了具體說明這一概念,回到上一章的例子:假想的免費郵件服務,能夠讓用戶配置篩選器,以使得滿足特定條件的郵件顯示在收件箱里,其他的被過濾掉。
Email
類看起來仍然是這樣:
case class Email(
subject: String,
text: String,
sender: String,
recipient: String)
type EmailFilter = Email => Boolean
過濾郵件的條件用謂詞 Email => Boolean
表示, EmailFilter
是其別名。調(diào)用適當?shù)墓S方法可以生成這些謂詞。
上一章,我們創(chuàng)建了兩個這樣的工廠方法,它們檢查郵件內(nèi)容長度是否滿足給定的最大值或最小值。這一次,我們使用部分應用函數(shù)來實現(xiàn)這些工廠方法,做法是,修改 sizeConstraint
,固定某些參數(shù)可以創(chuàng)建更具體的限制條件:
其修改后的代碼如下:
type IntPairPred = (Int, Int) => Boolean
def sizeConstraint(pred: IntPairPred, n: Int, email: Email) =
pred(email.text.size, n)
上述代碼為一個謂詞函數(shù)定義了別名 IntPairPred
,該函數(shù)接受一對整數(shù)(值 n
和郵件內(nèi)容長度),檢查郵件長度對于 n
是否 OK。
請注意,不像上一章的 sizeConstraint
,這一個并不返回新的 EmailFilter
,它只是簡單的用參數(shù)做計算,返回一個布爾值。秘訣在于,你可以部分應用這個 sizeConstraint
來得到一個 EmailFilter
。
遵循 DRY 原則,我們先來定義常用的 IntPairPred
實例,這樣,在調(diào)用 sizeConstraint
時,不需要重復的寫相同的匿名函數(shù),只需傳遞下面這些:
val gt: IntPairPred = _ > _
val ge: IntPairPred = _ >= _
val lt: IntPairPred = _ < _
val le: IntPairPred = _ <= _
val eq: IntPairPred = _ == _
最后,調(diào)用 sizeConstraint
函數(shù),用上面的 IntPairPred
傳入第一個參數(shù):
val minimumSize: (Int, Email) => Boolean = sizeConstraint(ge, _: Int, _: Email)
val maximumSize: (Int, Email) => Boolean = sizeConstraint(le, _: Int, _: Email)
對所有沒有傳入值的參數(shù),必須使用占位符 _
,還需要指定這些參數(shù)的類型,這使得函數(shù)的部分應用多少有些繁瑣。Scala 編譯器無法推斷它們的類型,方法重載使編譯器不可能知道你想使用哪個方法。
不過,你可以綁定或漏掉任意個、任意位置的參數(shù)。比如,我們可以漏掉第一個值,只傳遞約束值 n
:
val constr20: (IntPairPred, Email) => Boolean =
sizeConstraint(_: IntPairPred, 20, _: Email)
val constr30: (IntPairPred, Email) => Boolean =
sizeConstraint(_: IntPairPred, 30, _: Email)
得到的兩個函數(shù),接受一個 IntPairPred
和一個 Email
作為參數(shù),然后利用謂詞函數(shù) IntPairPred
把郵件長度和 20
、 30
比較,只不過比較方法的邏輯 IntPairPred
需要另外指定。
由此可見,雖然函數(shù)部分應用看起來比較冗長,但它要比 Clojure 的靈活,在 Clojure 里,必須從左到右的傳遞參數(shù),不能略掉中間的任何參數(shù)。
在一個方法上做部分應用時,可以不綁定任何的參數(shù),這樣做的效果是產(chǎn)生一個函數(shù)對象,并且其參數(shù)列表和原方法一模一樣。通過這種方式可以將方法變成一個可賦值、可傳遞的函數(shù)!
val sizeConstraintFn: (IntPairPred, Int, Email) => Boolean = sizeConstraint _
部分函數(shù)應用顯得太啰嗦,用起來不夠優(yōu)雅,幸好還有其他的替代方法。
也許你已經(jīng)知道 Scala 里的方法可以有多個參數(shù)列表。下面的代碼用多個參數(shù)列表重新定義了 sizeConstraint
:
def sizeConstraint(pred: IntPairPred)(n: Int)(email: Email): Boolean =
pred(email.text.size, n)
如果把它變成一個可賦值、可傳遞的函數(shù)對象,它的簽名看起來會像是這樣:
val sizeConstraintFn: IntPairPred => Int => Email => Boolean = sizeConstraint _
這種單參數(shù)的鏈式函數(shù)稱做 柯里化函數(shù) ,以發(fā)明人 Haskell Curry 命名。在 Haskell 編程語言里,所有的函數(shù)默認都是柯里化的。
sizeConstraintFn
接受一個 IntPairPred
,返回一個函數(shù),這個函數(shù)又接受 Int
類型的參數(shù),返回另一個函數(shù),最終的這個函數(shù)接受一個 Email
,返回布爾值。
現(xiàn)在,可以把要傳入的 IntPairPred
傳遞給 sizeConstraint
得到:
val minSize: Int => Email => Boolean = sizeConstraint(ge)
val maxSize: Int => Email => Boolean = sizeConstraint(le)
被留空的參數(shù)沒必要使用占位符,因為這不是部分函數(shù)應用。
現(xiàn)在,可以通過這兩個柯里化函數(shù)來創(chuàng)建 EmailFilter
謂詞:
val min20: Email => Boolean = minSize(20)
val max20: Email => Boolean = maxSize(20)
也可以在柯里化的函數(shù)上一次性綁定多個參數(shù),直接得到上面的結果。傳入第一個參數(shù)得到的函數(shù)會立即應用到第二個參數(shù)上:
val min20: Email => Boolean = sizeConstraintFn(ge)(20)
val max20: Email => Boolean = sizeConstraintFn(le)(20)
有時候,并不總是能提前知道要不要將一個函數(shù)寫成柯里化形式,畢竟,和只有單參數(shù)列表的函數(shù)相比,柯里化函數(shù)的使用并不清晰。而且,偶爾還會想以柯里化的形式去使用第三方的函數(shù),但這些函數(shù)的參數(shù)都在一個參數(shù)列表里。
這就需要一種方法能對函數(shù)進行柯里化。這種的柯里化行為本質(zhì)上也是一個高階函數(shù):接受現(xiàn)有的函數(shù),返回新函數(shù)。這個高階函數(shù)就是 curried
:curried
方法存在于 Function2
、 Function3
這樣的多參數(shù)函數(shù)類型里。如果存在一個接受兩個參數(shù)的 sum
,可以通過調(diào)用 curried
方法得到它的柯里化版本:
val sum: (Int, Int) => Int = _ + _
val sumCurried: Int => Int => Int = sum.curried
使用 Funtion.uncurried
進行反向操作,可以將一個柯里化函數(shù)轉換成非柯里化版本。
在這一章的最后,我們來看看柯里化函數(shù)如何發(fā)揮其更大的作用。來自 Java 或者 .NET 世界的人,或多或少都用過依賴注入容器,這些容器為使用者管理對象,以及對象之間的依賴關系。在 Scala 里,你并不真的需要這樣的外部工具,語言已經(jīng)提供了許多功能,這些功能簡化了依賴注入的實現(xiàn)。
函數(shù)式編程仍然需要注入依賴:應用程序中上層函數(shù)需要調(diào)用其他函數(shù)。把要調(diào)用的函數(shù)硬編碼在上層函數(shù)里,不利于它們的獨立測試。從而需要把被依賴的函數(shù)以參數(shù)的形式傳遞給上層函數(shù)。
但是,每次調(diào)用都傳遞相同的依賴,是不符合 DRY 原則的,這時候,柯里化函數(shù)就有用了!柯里化和部分函數(shù)應用是函數(shù)式編程里依賴注入的幾種方式之一。
下面這個簡化的例子說明了這項技術:
case class User(name: String)
trait EmailRepository {
def getMails(user: User, unread: Boolean): Seq[Email]
}
trait FilterRepository {
def getEmailFilter(user: User): EmailFilter
}
trait MailboxService {
def getNewMails(emailRepo: EmailRepository)(filterRepo: FilterRepository)(user: User) =
emailRepo.getMails(user, true).filter(filterRepo.getEmailFilter(user))
val newMails: User => Seq[Email]
}
這個例子有一個依賴兩個不同存儲庫的服務,這些依賴被聲明為 getNewMails
方法的參數(shù),并且每個依賴都在一個單獨的參數(shù)列表里。
MailboxService
實現(xiàn)了這個方法,留空了字段 newMails
,這個字段的類型是一個函數(shù): User => Seq[Email]
,依賴于 MailboxService
的組件會調(diào)用這個函數(shù)。
擴展 MailboxService
時,實現(xiàn) newMails
的方法就是應用 getNewMails
這個方法,把依賴 EmailRepository
、 FilterRepository
的具體實現(xiàn)傳遞給它:
object MockEmailRepository extends EmailRepository {
def getMails(user: User, unread: Boolean): Seq[Email] = Nil
}
object MockFilterRepository extends FilterRepository {
def getEmailFilter(user: User): EmailFilter = _ => true
}
object MailboxServiceWithMockDeps extends MailboxService {
val newMails: (User) => Seq[Email] =
getNewMails(MockEmailRepository)(MockFilterRepository) _
}
調(diào)用 MailboxServiceWithMockDeps.newMails(User("daniel")
無需指定要使用的存儲庫。在實際的應用程序中,這個服務也可能是以依賴的方式被使用,而不是直接引用。
這可能不是最強大、可擴展的依賴注入實現(xiàn)方式,但依舊是一個非常不錯的選擇,對展示部分函數(shù)應用和柯里化更廣泛的功用來說,這也是一個不錯的例子。如果你想知道更多關于這一點的知識,推薦看 Debasish Ghosh 的幻燈片“Dependency Injection in Scala”。
這一章討論了兩個附加的可以避免代碼重復的函數(shù)式編程技術,并且在這個基礎上,得到了很大的靈活性,可以用多種不同的形式重用函數(shù)。部分函數(shù)應用和柯里化,這兩者或多或少都可以實現(xiàn)同樣的效果,只是有時候,其中的某一個會更為優(yōu)雅。下一章會繼續(xù)探討保持靈活性的方法:類型類(type class)。
更多建議: