Cats(一):從函數語言程式設計思維談起

ScalaCool發表於2019-02-13

本文由 Yison 發表在 ScalaCool 團隊部落格。

Cats logo

如果你看到一個開源類庫,logo 是四隻貓 + 五個箭頭,可能會略感不適 — 這是工程程式碼裡可以使用的玩意兒嗎?

沒錯,這是 TypeLevel 設計的一個函式式類庫,一群推崇函數語言程式設計的傢伙注入到 Scala 生態中的重磅炸彈,它是 Cats,源於 Category(範疇)的縮寫。

水滴 的系統中,我們大規模使用了 Cats 來解決一些業務問題,並且從中受益。但顯然這並不是 Scala 標準庫所支援的打法,所以本系列旨在系統地介紹這個類庫,讓更多人瞭解它。

我們最開始使用的是 Scalaz,它是 Cats 的前身,由於語法問題導致很多吐槽,之後採用 Cats 替代。

當然,很多瞭解過 Cats 的人知道,關於它已經有以下這些優秀的學習資料:

但顯然,如果之前並沒有函數語言程式設計經驗的同學,可能會在首次閱讀這些資料的時候犯困。因此,該系列希望在正式介紹 Cats 這個神器之前,先友好地探討一些關於「函數語言程式設計」的基本問題。如:

  • 什麼是函數語言程式設計
  • 函數語言程式設計有哪些特點
  • 函數語言程式設計有哪些優點

函數語言程式設計?

那麼,什麼才是函數語言程式設計呢?其實,關於這點並沒有準確權威的定義,本文也不想就此給出一個答案。

但是,我們希望來討論下什麼是「函數語言程式設計思維」,它跟我們熟知的「指令式程式設計」到底有哪些不同。

經常上知乎的朋友發現了,這是知乎上一個非常好的問題。

什麼是函數語言程式設計思維?— 知乎

本文推薦以下的回答:

@nameoverflow: 函數語言程式設計關心資料的對映,指令式程式設計關心解決問題的步驟。

更數學化的版本: @parker liu 函數語言程式設計關心型別(代數結構)之間的關係,指令式程式設計關心解決問題的步驟。

總結

函數語言程式設計的思維就是如何將型別(代數結構)之間的關係組合起來,用數學的構造主義構造出你設計的程式。

疑問

我們來解剖這個結論,直觀上函數語言程式設計是一件非常簡單的事情,我們只需做一件事情就夠了,那就是「組合」。

但此時,我們肯定又變得一頭霧水,以下問題緊接著就來了:

  • 真的有這麼簡單嗎?
  • 到底什麼是「組合」呢?
  • 在理論上,它真能做到完備性嗎?
  • 什麼才是所謂的「關係」呢?

別急,我們先來討論一個基本的問題 — 什麼是過程和資料?

過程和資料

看過 SICP 的朋友肯定已經發現,這是這本書第一章和第二章所研究的內容。

簡單來說,資料是一種我們希望去操作的東西,而過程就是有關操作這些資料的規則的描述。它們構成了程式設計的基本元素。

在 Lisp 這種函數語言程式設計語言中,過程和資料一樣,屬於第一級狀態,這也就意味著:

  • 可以用變數命名
  • 可以提供給過程作為引數
  • 可以作為過程的結果返回
  • 可以包含在其它的資料結構中

舉個例子,我們可以定義一個過程,它接受的引數是一個過程,返回的是另外一個過程,這似乎看起來有點怪。 換個例子,定義一個過程,它接受的引數是一個數,返回的是另外一個數,這是不是就熟悉多了?

在函數語言程式設計中,我們會發現其實「過程」和「資料」的界限有時候是模糊的。也就是說,有時我們可以把它們當作一個東西。

回到我們剛才的結論:「函數語言程式設計關心型別(代數結構)之間的關係」。

我們於是可以這麼理解,函數語言程式設計要解決的第一個問題:就是需要足夠高的抽象能力,能對各種資料和過程進行抽象,提供型別(代數結構)

這也同樣是後續我們在學習 Cats 過程中要獲得解答的一個疑問,它是如何幫助我們實現這一點。

推薦閱讀 @shaw 寫的 如何在 Scala 中利用 ADT 良好地組織業務

圖靈完備與 Lambda 演算

其次,我們再來討論下,到底什麼是所謂的「關係」?

我們肯定有這樣子的疑惑,如果函數語言程式設計思維僅靠「組合」就能夠描述所有的程式,那麼在理論上它必須具備完備性。

不少朋友知道所謂的 [圖靈完備](Turing completeness)。它聽起來逼格很高,其實這是一個很簡單的概念,意味著用圖靈機能做到的所有事情,可以解決所有的可計算問題。

另外一個可支援解決所有可計算問題的方案就是「Lambda 演算」。在 Lisp 這種函數語言程式設計語言中的基石,就是這個理論。

函數語言程式設計中的 lambda 可以看成是兩個型別之間的關係,一個輸入型別和一個輸出型別。lambda 演算就是給 lambda 表示式一個輸入型別的值,則可以得到一個輸出型別的值,這是一個計算,計算過程滿足 \alpha -等價和 \beta -規約。

關於圖靈完備和 Lambda 演算,有機會我們可以在後續的文章中繼續討論。

面向組合子程式設計

我們再來聊聊核心,所謂的「組合」。

「面向組合子程式設計」是十年前 javaeye 的牛人 @Ajoo 提出的概念。

首先,我們可以引用哲學的基本方法來比喻我們常見的「物件導向程式設計」與「面向組合子程式設計」差異。

前者是歸納法,後者是演繹法

也就說,我們在用 Java 這些物件導向的語言進行程式設計的時候,通常採用的是總結的方法,然而函數語言程式設計語言提倡的「組合」,更貼近數學的思維,它是一種推導。

所以,函數語言程式設計所關心的組合,更多做的是先高度抽象型別關係,然後對這些關係的轉化,所謂的 transformer。

於是,我們得出第二個關鍵的問題:即 Cats 如何提供足夠的 transformer,來幫助我們實現各種關係之間的組合

舉例

對於第一次接觸這些概念的朋友來說,還是有點抽象,下面我們來舉一個實際的例子來加深認識。

假設我們現在要設計一個抽獎活動的參與過程,涉及以下邏輯:

  • 獲取活動獎品資料
  • 判斷活動的開始、進行、結束、獎品是否搶光等狀態

命令式風格

import org.joda.time.DateTime
import scala.concurrent.Future

case class Activity(id: Long, start: DateTime, end: DateTime)
case class Prize(id: Long, name: String, count: Int)

val activity = syncGetActivity()
val prizes = syncGetPrizes(activity.id)

if (activity.start.isBefore(DateTime.now())) {
  println("activity not starts")
} else if (activity.end.isBefore(DateTime.now())) {
  println("activity ends")
} else if (prizes.map(_.count).sum < 1) {
  println("activity has no prizes")
} else {
  println("activity is running")
}
複製程式碼

函式式風格

import org.joda.time.DateTime
import scala.concurrent.Future

case class Activity(id: Long, start: DateTime, end: DateTime)
case class Prize(id: Long, name: String, count: Int)

sealed trait ActivityStatus {
  val activity: Activity
  val prizes: Seq[Prize]
}
case class ActivityNotStarts(activity: Activity, prizes: Seq[Prize]) extends ActivityStatus
case class ActivityEnds(activity: Activity, prizes: Seq[Prize]) extends ActivityStatus
case class ActivityPrizeEmpty(activity: Activity, prizes: Seq[Prize]) extends ActivityStatus
case class ActivityRunning(activity: Activity, prizes: Seq[Prize]) extends ActivityStatus

def getActivityStatus(): Future[ActivityStatus] = {
  for {
    activity <- asyncGetActivity()
    prizes <- asyncGetPrizes(activity.id)
  } yield (activity, prizes) match {
    case (a, pzs) if a.start.isBefore(DateTime.now()) => ActivityNotStarts(a, pzs)
    case (a, pzs) if a.end.isBefore(DateTime.now()) => ActivityNotStarts(a, pzs)
    case (a, pzs) if pzs.map(_.count).sum < 1 => ActivityPrizeEmpty(a, pzs)
    case (a, pzs) => ActivityRunning(a, pzs)
  }
}
複製程式碼

以上,我們可以發現函式式風格,會傾向於基於更高的業務層次進行抽象,直覺上是一個 describe what 的設計,而不是 how to do

值得一提的是,asyncGetActivity 這個從資料庫非同步獲取活動資料過程,它的型別是一個高階型別 Future[Activity],這也就是我們之前提到的對過程進行抽象,定義型別。

通過對 asyncGetActivityasyncGetPrizes 兩個非同步過程的組合,我們最終轉化得到了 ActivityStatus 這個型別的物件結果。

總結

Scala 是一門結合「物件導向」和「函式式」的程式語言,我們用它可以寫出截然不同的程式碼風格。很多人把它當作 better Java 來使用,但如果結合 Cats 這個函式式類庫,我們就可以更好地採用函數語言程式設計思維來設計程式,從而發揮 Scala 更大的威力。

通過該篇文章,我們對函數語言程式設計有了直覺上的感受。當然,你可能依舊雲裡霧裡,不要緊,我們會在後續的文章裡進一步的討論。在下一篇文章中,我們會介紹下函數語言程式設計所帶來的優勢。

Cats(一):從函數語言程式設計思維談起

相關文章