Scala 函式柯里化(Function currying)

NO IMAGE

部分應用的函式

和其他遵循函數語言程式設計正規化的語言一樣,Scala 允許部分應用一個函式。 呼叫一個函式時,不是把函式需要的所有引數都傳遞給它,而是僅僅傳遞一部分,其他引數留空; 這樣會生成一個新的函式,其引數列表由那些被留空的引數組成。(不要把這個概念和偏函式混淆)

為了具體說明這一概念,回到上一章的例子: 假想的免費郵件服務,能夠讓使用者配置篩選器,以使得滿足特定條件的郵件顯示在收件箱裡,其他的被過濾掉。

Email 類看起來仍然是這樣:

case class Email(
subject: String,
text: String,
sender: String,
recipient: String)
type EmailFilter = Email => Boolean

過濾郵件的條件用謂詞 Email => Boolean 表示, EmailFilter 是其別名。 呼叫適當的工廠方法可以生成這些謂詞。

上一章,我們建立了兩個這樣的工廠方法,它們檢查郵件內容長度是否滿足給定的最大值或最小值。 這一次,我們使用部分應用函式來實現這些工廠方法,做法是,修改 sizeConstraint ,固定某些引數可以建立更具體的限制條件:

其修改後的程式碼如下:

    type IntPairPred = (Int, Int) => Boolean
def sizeConstraint(pred: IntPairPred, n: Int, email: Email) =
pred(email.text.size, n)

上述程式碼為一個謂詞函式定義了別名 IntPairPred ,該函式接受一對整數(值 n 和郵件內容長度),檢查郵件長度對於 n 是否 OK。

請注意,不像上一章的 sizeConstraint ,這一個並不返回新的 EmailFilter,它只是簡單的用引數做計算,返回一個布林值。 祕訣在於,你可以部分應用這個 sizeConstraint 來得到一個 EmailFilter 。

遵循 DRY 原則,我們先來定義常用的 IntPairPred 例項,這樣,在呼叫 sizeConstraint 時,不需要重複的寫相同的匿名函式,只需傳遞下面這些:

    val gt: IntPairPred = _ > _
val ge: IntPairPred = _ >= _
val lt: IntPairPred = _ < _
val le: IntPairPred = _ <= _
val eq: IntPairPred = _ == _

最後,呼叫 sizeConstraint 函式,用上面的 IntPairPred 傳入第一個引數:

    val minimumSize: (Int, Email) => Boolean = sizeConstraint(ge, _: Int, _: Email)
val maximumSize: (Int, Email) => Boolean = sizeConstraint(le, _: Int, _: Email)

對所有沒有傳入值的引數,必須使用佔位符 _ ,還需要指定這些引數的型別,這使得函式的部分應用多少有些繁瑣。 Scala 編譯器無法推斷它們的型別,方法過載使編譯器不可能知道你想使用哪個方法。

不過,你可以繫結或漏掉任意個、任意位置的引數。比如,我們可以漏掉第一個值,只傳遞約束值 n :

    val constr20: (IntPairPred, Email) => Boolean =
sizeConstraint(_: IntPairPred, 20, _: Email)
val constr30: (IntPairPred, Email) => Boolean =
sizeConstraint(_: IntPairPred, 30, _: Email)

得到的兩個函式,接受一個 IntPairPred 和一個 Email 作為引數, 然後利用謂詞函式 IntPairPred 把郵件長度和 20 、 30 比較, 只不過比較方法的邏輯 IntPairPred 需要另外指定。

由此可見,雖然函式部分應用看起來比較冗長,但它要比 Clojure 的靈活,在 Clojure 裡,必須從左到右的傳遞引數,不能略掉中間的任何引數。

從方法到函式物件

在一個方法上做部分應用時,可以不繫結任何的引數,這樣做的效果是產生一個函式物件,並且其引數列表和原方法一模一樣。 通過這種方式可以將方法變成一個可賦值、可傳遞的函式!

    val sizeConstraintFn: (IntPairPred, Int, Email) => Boolean = sizeConstraint _

更有趣的函式

部分函式應用顯得太囉嗦,用起來不夠優雅,幸好還有其他的替代方法。

也許你已經知道 Scala 裡的方法可以有多個引數列表。 下面的程式碼用多個引數列表重新定義了sizeConstraint :

    def sizeConstraint(pred: IntPairPred)(n: Int)(email: Email): Boolean =
pred(email.text.size, n)

如果把它變成一個可賦值、可傳遞的函式物件,它的簽名看起來會像是這樣:

    val sizeConstraintFn: IntPairPred => Int => Email => Boolean = sizeConstraint _

這種單引數的鏈式函式稱做 柯里化函式 ,以發明人 Haskell Curry 命名。在 Haskell 程式語言裡,所有的函式預設都是柯里化的。

sizeConstraintFn 接受一個 IntPairPred ,返回一個函式,這個函式又接受 Int 型別的引數,返回另一個函式,最終的這個函式接受一個 Email ,返回布林值。

現在,可以把要傳入的 IntPairPred 傳遞給 sizeConstraint 得到:

    val minSize: Int => Email => Boolean = sizeConstraint(ge)
val maxSize: Int => Email => Boolean = sizeConstraint(le)

被留空的引數沒必要使用佔位符,因為這不是部分函式應用。

現在,可以通過這兩個柯里化函式來建立 EmailFilter 謂詞:

    val min20: Email => Boolean = minSize(20)
val max20: Email => Boolean = maxSize(20)

也可以在柯里化的函式上一次性繫結多個引數,直接得到上面的結果。 傳入第一個引數得到的函式會立即應用到第二個引數上:

    val min20: Email => Boolean = sizeConstraintFn(ge)(20)
val max20: Email => Boolean = sizeConstraintFn(le)(20)

函式柯里化

有時候,並不總是能提前知道要不要將一個函式寫成柯里化形式,畢竟,和只有單引數列表的函式相比,柯里化函式的使用並不清晰。 而且,偶爾還會想以柯里化的形式去使用第三方的函式,但這些函式的引數都在一個引數列表裡。

這就需要一種方法能對函式進行柯里化。 這種的柯里化行為本質上也是一個高階函式:接受現有的函式,返回新函式。 這個高階函式就是 curried :curried 方法存在於 Function2 、 Function3 這樣的多引數函式型別裡。 如果存在一個接受兩個引數的 sum ,可以通過呼叫 curried 方法得到它的柯里化版本:

    val sum: (Int, Int) => Int = _   _
val sumCurried: Int => Int => Int = sum.curried

使用 Funtion.uncurried 進行反向操作,可以將一個柯里化函式轉換成非柯里化版本。

函式化的依賴注入

在這一章的最後,我們來看看柯里化函式如何發揮其更大的作用。 來自 Java 或者 .NET 世界的人,或多或少都用過依賴注入容器,這些容器為使用者管理物件,以及物件之間的依賴關係。 在 Scala 裡,你並不真的需要這樣的外部工具,語言已經提供了許多功能,這些功能簡化了依賴注入的實現。

函數語言程式設計仍然需要注入依賴:應用程式中上層函式需要呼叫其他函式。 把要呼叫的函式硬編碼在上層函式裡,不利於它們的獨立測試。 從而需要把被依賴的函式以引數的形式傳遞給上層函式。

但是,每次呼叫都傳遞相同的依賴,是不符合 DRY 原則的,這時候,柯里化函式就有用了! 柯里化和部分函式應用是函數語言程式設計裡依賴注入的幾種方式之一。

下面這個簡化的例子說明了這項技術:

    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 方法的引數,並且每個依賴都在一個單獨的引數列表裡。

MailboxService 實現了這個方法,留空了欄位 newMails,這個欄位的型別是一個函式: User => Seq[Email],依賴於 MailboxService 的元件會呼叫這個函式。

擴充套件 MailboxService 時,實現 newMails 的方法就是應用 getNewMails 這個方法,把依賴EmailRepository 、 FilterRepository 的具體實現傳遞給它:

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

呼叫 MailboxServiceWithMockDeps.newMails(User("daniel") 無需指定要使用的儲存庫。 在實際的應用程式中,這個服務也可能是以依賴的方式被使用,而不是直接引用。

這可能不是最強大、可擴充套件的依賴注入實現方式,但依舊是一個非常不錯的選擇,對展示部分函式應用和柯里化更廣泛的功用來說,這也是一個不錯的例子。 如果你想知道更多關於這一點的知識,推薦看 Debasish Ghosh 的幻燈片 “Dependency
Injection in Scala
”。

總結

這一章討論了兩個附加的可以避免程式碼重複的函數語言程式設計技術, 並且在這個基礎上,得到了很大的靈活性,可以用多種不同的形式重用函式。 部分函式應用和柯里化,這兩者或多或少都可以實現同樣的效果,只是有時候,其中的某一個會更為優雅。