Haskell 狂信徒的 Scala 入坑筆記(1)

First things first..

  • 這不是一篇 self-contained 的 Scala 教程,也不是一篇教人如何開始學習 Scala 或者函數式編程的資源貼。這只是一個 SAN 值歸零的 Haskell 信徒的一些月夜囈語。包含不系統、不客觀、不友善的功能列舉和點評。長期更新。歡迎評論各種吐槽。
  • 作者背景:Scala 入坑一月有餘。之前則專註於 Haskell 和靜態類型函數式編程,有時候寫點 Python 或者 JavaScript。對 JVM 系語言零基礎。對面向對象編程。。算了這種 ill-defined 的概念咱們先不管。先交代到這兒。
  • 開始學習 Scala 之前,我把那個 Martin Odersky 的 「Haskellator」 的梗當真了(Scala 是 Haskell 的 gateway drug)!!我以為學會 Haskell 以後 Scala 什麼的就可以橫掃啊,分分鐘就會啊!!被徹底教做人了(不過更有可能是我已經學傻了)。另外誰說 Scala 是函數式語言我跟誰急。

配置開發環境

  • 首先,可以選擇的 Scala 編譯器版本很多。在 JVM 上跑的有 Lightbend Scala / Typelevel Scala / Dotty,另外還有 scalajs 和 Scala Native。不同實現的對比網上很多,這裡就不贅述了,上手的話選擇官方實現 Lightbend Scala 即可。
  • 兩個 Scala 相關的在線服務推薦一下:在線運行一段 Scala 代碼,可以用 ScalaFiddle;瀏覽 GitHub 上的 Scala 代碼,可以裝一下 Insight.io 的 Chrome 插件,這個平台上對很多 Scala 的 repo 進行過索引,瀏覽代碼時可以點擊跳轉。(對 sbt 構建時在 src_managed 里生成的 Scala 代碼也做了索引,不過點擊看不到,有點小遺憾)
  • Scala 可以用 sbt 或者 maven 管理依賴,兩個工具我都不熟,不過 sbt 看上去可定製性要好得多,因為 sbt 的配置文件本身就是 Scala 腳本,可以用普通 Scala 語法,而且 sbt 本身可以做可重用的插件。IntelliJ IDEA 官方有 Scala 和 sbt 插件,對我這樣的小白上手起來相當方便。可以用 scalafmt 格式化 Scala 代碼,scalafmt 像 clang-format 一樣可以用配置文件配置 code style。
  • Scala 官方編譯器帶有一個 REPL,另外一些 Scala 項目(比如 Spark)也提供了自定義的 REPL,但是最好用的 REPL 還是 jupyter-scala。這是一個 Jupyter Notebook 的 Scala 內核,底層基於 Ammonite 定製,所以支持 Ammonite 的一些神奇的特性,比如 magic import 語法,可以在一個 notebook 裡面動態裝新的依賴,不需要寫 sbt 配置之類的,使用體驗極佳。現在我的 workflow 基本就是開一個 notebook 把雛形做得差不多以後再把代碼片段遷移到 IntelliJ IDEA 裡面做後續的開發。

學習資源(我看過的)

  • Scala for the Impatient。比較精鍊的 Scala 教材,適合剛入坑時快速瀏覽一遍。國內有翻譯版。
  • Tour of Scala。Scala 官網的短教程系列,一篇一個特性。
  • Scala』s Types of Types。各種 Scala 類型特性的簡介。
  • Scalable Component Abstractions。一篇 Martin Odersky 的早年論文,可以一窺 Scala 為什麼要長成這個樣子。

關於副作用

普通語言里有 block statement,而 Scala 則有 block expression,一個 code block 本身即是一個合法的表達式,可以嵌套在其他表達式中,而 block expression 最後一個表達式的類型則為整個表達式的類型。

block expression 的根源:Scala 是一門 non-pure,默認 strict 求值的語言,與 Haskell 針鋒相對。一個 expression 被求值可能觸發任意副作用。在 Haskell 中,副作用通常使用某個 kind 為 (* -> *) 的 DSL 來表示(比如最簡單的 IO),這個 DSL 可以通過 monad class 的 return 和 bind 的操作進行組合。然而 Scala 里只需要在 block expression 中順序寫下帶副作用的表達式,block expression 被求值時,這些表達式的副作用即會被依次觸發,底層並沒有用到 monad bind,類型系統也並不參與副作用的管理。

兩門語言對於賦值的處理也不一樣。Haskell 中沒有賦值,只有一次性的綁定,所謂的賦值操作只是針對某個 ref 類型的 monadic 值而已;OCaml 之類的 ML 系語言還算矜持,有 ref/array 類型,有針對這些類型的非純的 read/write 操作;Scala 就完全放開了,聲明一個綁定時可以用 val/var 加以區分,使用 var 則直接支持賦值(val 聲明的綁定不支持賦值,但是仍然可以用對象本身的帶副作用的方法修改對象狀態)。

Scala 雖然默認 strict 求值,但是支持局部 lazy 求值。一個綁定被聲明為 lazy val 時不會被立即計算,而只會在第一次被用到時計算一次並緩存(call by need)。被聲明為 def 時,按照零參函數處理,每次被用到時都會被求值(call by name)。函數的參數列表裡,如果是 f(x: A),代表 apply 時 x 被即刻求值,如果是 f(x: => A),代表 apply 時 x 不被即刻求值(call by name)。這些局部 lazy 的聲明使用起來還是很方便的,也不需要手動到處插入 delay/force 之類的調用。

不過,使用一個 DSL 管理副作用的做法有時還是會用到。比如需要非同步編程時,可以使用 scala 標準庫提供的 Future 或者 scalaz 提供的 Task,它們也都支持使用 flatMap 等組合子顯式地進行組合。這時可以使用 ThoughtWorksInc/each 提供的語法糖,用寫普通帶副作用表達式的寫法來寫 monadic 的表達式。

個人觀點,懶惰不懶惰事小,純潔不純潔事大。默認求值策略是 strict 或者 non-strict,都可以很方便地添加一些語言構造在局部轉換求值策略,但是如果允許任意位置插入副作用,那麼再想提倡使用類型系統管理副作用的風格就很難辦了,這個設定不僅會阻止很多編譯優化,也容易讓不稱職的程序員寫出依賴某些迷之全局狀態的糟糕代碼。類型是人類的好朋友,類型不能阻止程序員作死,但可以讓作死的代碼寫起來更費勁、看起來更刺眼。

關於函數

Scala 里的函數類型很豐(hun)富(luan),原生支持多參、帶名字參數和變長參數。參數也可以帶上 lazy 的聲明(見上一節)或者 implicit 的聲明。如果懷念 Curry-style 的話。。一個 Scala 函數簽名是可以帶上多個參數列表的,所以寫成 Curry-style 也沒毛病。

個人還是更加鍾愛 Haskell 里只提供單參函數的做法。需要多參可以傳 tuple 啊,需要帶名字參數可以傳 record 啊,需要變長參數可以傳 List 或者 HList 啊。。而且 Haskell 也無需針對零參函數有特殊的處理(因為默認 pure 所以很容易做到)。類型和語法更簡潔了,但是並沒有失去什麼力量。

Scala 的 method call 語法有個語法糖:a.f(b) 可以寫成 a f b 的寫法,這樣一來可以有限度地定製自己的 binary operator,比如 a -> b,只需要實現 a.-> 的 method 即可。operator 定製上 Haskell 靈活很多,支持自定義 operator 的優先順序和結合性。不過跟走火入魔的 Agda 相比也還算矜持的。

Scala 中的 function call 語法,只要實現 apply method 即可使用,類似 C++ 中函數對象實現 operator ()。實際上 Scala 中,你看見 A => B 類型,以為是一個原生的函數類型,實際上只是個 Function1[A, B] 這個 trait 的實例而已,所謂的 lambda 表達式也只不過是「免得手動創建 FunctionN 的 trait 實例並提供 apply method 實現」的一個語法糖而已。可以,這很面向對象.jpg

最後一提:Scala 函數的類型推導就是個戰鬥力 0.5 的渣渣,誰用誰知道。

關於 newtype 和 Value Type

Haskell 裡面有 type alias 和 newtype 可以對一個已有類型進行標記,其中前者不會創造一個新類型,後者則會(newtype 的 Constructor 應用過的值不能作為原類型值使用)。newtype 功能的美妙之處在於:

  • 可以通過 GeneralizedNewtypeDeriving 機制,用普通的 deriving 語法無縫獲取原類型的任意 type class 的 instance,而需要自定義行為的 instance 手動實現即可
  • newtype 嚴格保證 runtime representation 與原類型一致,這樣一來可以進行 zero-cost type-safe 的 coerce,並且這種 coerce 不僅針對 newtype 和 原類型,也針對任何複雜類型中 newtype 和原類型的相互替換,比如 (forall r . (Stream NInt -> IO r) -> IO r) 和 (forall r . (Stream Int -> IO r) -> IO r) 之類的東西可以直接相互 coerce,假設 newtype NInt = Int

Scala 里有一個類似的優化,叫 Value Type。將單個類型的值實現一個 wrapper class 時,指定讓 wrapper class extends AnyVal 即可,編譯器會嘗試去掉額外的對象分配的開銷。但是優化不總能成功,而且也沒有 zero-cost coerce 和獲取原類型的任意 type class instance 這兩大好處。

關於 case class 與模式匹配

Haskell 和 ML 系語言的共同點是大量使用 ADT(Algebraic Data Types)及針對 ADT 的模式匹配。 ADT 的一大特點是 sealed,添加或者刪除其中的 variant 會使得所有對該 ADT 進行匹配的函數需要重新編譯,而 sealed 使得針對模式匹配的 exhaustiveness check 容易實現。

Scala 里的 case class 支持模式匹配。如果每一個 case class 所 extends 的那個 base class/trait 帶有 sealed 的聲明,那就可以模擬普通的 sealed ADT。如果不帶 sealed 聲明,那麼實際上可以當作一個類型不安全的 expression problem 解決方案:當某一個 datatype 的新的 variant 在另一個 package 中被聲明時,原來的那些對該 datatype 進行模式匹配的函數仍然可以通過編譯,並且可以給這些函數傳入新的 variant,這個新的 variant 會在運行時報告匹配失敗。。。

OCaml 的 polymorphic variant 是同時確保了可擴展性和類型安全的解決方案,一個函數如果對 polymorphic variant 進行了類型匹配,那麼函數的類型簽名可以告訴你它支持哪些 variant,傳入不支持的 variant 會造成類型錯誤。在 Haskell 里可以用類似實現 HList 的手段實現 HSum,模擬 polymorphic variant 的效果。不清楚 Scala 裡面有沒有人這麼做過,原理上講是辦得到的。

另外,既然是 case class,而且仍然是用 class 語法聲明,所以可以在 base class/trait 裡面聲明這個 ADT 支持的方法,然後用普通 method call 的語法調用。

What comes next

今天先寫最簡單的幾個點吧。之後有空再慢慢更新深一點的內容,比如 HKT/RankN 函數、implicit 和 type class 相關、variance、path-dependent type、反射、scalaz 和 shapeless 相關,等等。晚安。


推薦閱讀:

仙境里的Haskell(之四)—— Functor類型類
函數式編程在Redux/React中的應用
為什麼諸多編程語言都將模式匹配作為重要構成?
Haskell中的foldl和foldr的聯繫?
紅塵里的Haskell(之一)——Haskell工具鏈科普

TAG:Haskell | Scala | 函数式编程 |