紅塵里的Haskell(之一)——Haskell工具鏈科普

受@大魔頭-諾鐵邀請,我在這個專欄會寫一些偏應用向的Haskell相關教程,內容大致參考24 Days of Hackage和What I Wish I Knew When Learning Haskell,標題就取作老司機帶逛Hackage,不對,紅塵里的Haskell好了(仙境里的部分還是大魔頭自己寫)~

第一期先科普一下Haskell工具鏈的一些基礎知識。這些在Haskell相關的課程和教材里一般著墨甚少,而且不一定符合最新的開發實踐。為了順利配置開發環境和爽快擼碼,這點微小的工作也是很重要的。

首先說說Haskell語言與實現。Haskell有語言標準:Haskell Language Report,目前出了98和2010兩版,後者變化不大,有少量增補。這個語言標準里規定的是一個「經典」的Haskell語言,基於ML家族語言常用的Hindley-Milner類型系統,加入了type class特性,默認惰性求值,標準庫里規定IO monad管理副作用。這個「經典」Haskell也正是大多數入門Haskell課程與教材所覆蓋的範圍。

Haskell有多個實現,具體可查閱Haskell wiki。我們重點關注的是GHC,因為這個項目已經是Haskell語言的事實標準。它實現了大量Language extension,在98/2010標準的基礎上極大豐富了語言特性,同時也是學界前沿成果的集散地。一般而言,社區談論的Haskell,特指GHC Haskell而非標準Haskell。

GHC是一個Haskell編譯器,有編譯模式和解釋模式。在編譯模式下,將輸入Haskell代碼進行一系列變換,通過assembler後端或LLVM後端生成可鏈接的原生機器碼。在解釋模式下,生成ghci bytecode,然後由ghci解釋執行。兩種模式共用一個前端,支持的語言特性相同,解釋模式下性能更差(不支持優化選項),不過編譯更快,可以在REPL下互動式運行代碼,可以斷點調試。

講到這裡,最簡單的配環境方案呼之欲出:直接用平台相關的包管理器裝一發ghc,然後直接用ghc或ghci運行程序。雖然簡單,不過這時程序只能使用ghc boot libraries(ghc自帶庫),用不上之後帶逛的各路Hackage庫。所以安裝ghc的事暫且按下不表,先繼續科普~

接下來說Module和Package。Haskell的一個源文件對應一個Module,文件名與Module層級對應,比如module Network.HTTP.Client.Conduit,其實現就在Network/HTTP/Client/Conduit.hs。一個Module的頂層namespace里,默認已經import了Prelude(標準庫)的定義,當然可以import其他Module,最後export一些定義(默認export所有本地定義,當然也可以隱藏一些本地定義,或者re-export其他Module的定義)。

不過我們談論Haskell「庫」時,指的不是Module,而是Package。ghc的package system名叫Cabal(Common Architecture for Building Applications and Libraries),文檔在Cabal User Guide。一個Cabal package有若干build target,每個target需要指明種類(library/executable/test-suite/benchmark之一)、依賴的其他Cabal package名字與版本號範圍、暴露給本package的第三方使用者的Module、不暴露的Module、編譯選項、可執行文件名與入口(library無需)等等。

開發者除了按Module層級組織好源文件以外,額外寫一個package-name.cabal文件指明這些元數據,附上LICENSE和README等物,打包,即完成了一個Cabal package,可以上傳到Hackage(Haskell社區的package集散地)供他人使用。Hackage自身除了託管這些package的tarball以外,自身還是一個CI(Continuous Integration)系統,會自動嘗試編譯上傳的package,之後從源代碼生成haddock文檔頁面(Haskell原生支持的文檔格式)。由於非Haskell依賴等各種原因,Hackage的自動build並不可靠,所以看見某個package報告build failed時不必因此質疑package質量。值得一提的是,Hackage現在在國內已經有TUNA託管的鏡像了,親測穩定好用!使用方法參考這裡。

ghc安裝時自帶了少量的boot libraries,這些自帶庫的版本號與ghc版本號一般是綁定好的,不會rebuild。在編譯/解釋源代碼時,ghc會讀取Package Database,這個database是所有註冊過的Cabal package的索引。需要使用第三方庫時,我們使用cabal-install或stack之類的包管理器,它們會識別所有依賴,計算出build plan,並將依賴依次編譯後註冊到某個合適的Package Database(cabal有全局database和sandbox database,stack則有snapshot database,之後再敘),之後即可順利import第三方Module。Package Database不是唯一的,同一個ghc可以基於不同的database工作,這是一個很重要的設計,之後我們會看到原因。

我們已經具備了開發Haskell項目需要的基礎知識,現在該落實到工具的使用了。理論上,不依賴任何包管理器,僅使用ghc-pkg等ghc自帶工具來管理Haskell項目的依賴也是可以的——如果閑得蛋疼。另一種「省事」的辦法是使用平台的包管理器安裝Haskell包,優點是快(多數平台的包管理器分發二進位,無需編譯),缺點是難以控制包版本號,也難保某個發行版的打包者一抽風搞出版本號根本不兼容的情況,而需要多個版本的編譯器或庫時就更難勝任。因此,強烈不推薦使用平台的包管理器安裝各種Haskell包,建議使用cabal-install或者stack這樣的Haskell「原生」包管理器。

cabal-install曾經是伴隨Cabal的標配工具。它提供了一個命令行工具cabal,可以用於安裝本地和Hackage上的Haskell項目,具體用法可以運行cabal --help或查看Haskell wiki。首次運行或者需要用到Hackage上新發布的包時,需要運行cabal update,更新本地的Hackage目錄。

提起cabal-install,順便黑一把cabal hell好了——cabal-install過去常導致的問題。cabal-install默認會在~/.cabal維護全局的Package Database,本地開發多個Haskell項目時,其依賴是共享的。共享依賴的問題在於,不同項目計算出的build plan可能對同一個包擁有衝突的版本號;而即使支持安裝同一個包的不同版本,在鏈接階段(7.8及之前的)ghc仍只識別一個版本,在菱形依賴中,一個被用到不同版本的base package將導致無法編譯。cabal hell難以提防的另一地方在於,cabal-install計算build plan的依據是Hackage目錄,而這個目錄在時刻更新,因此build plan結果是非確定性的,一個今天work的build也許明天不work;或者在build完另一個項目以後,不再work了。

為了解決cabal hell的問題,ghc和cabal-install的新版本都加入了一些workaround,比如ghc 7.10起支持import指明package和版本號、Package Database中版本號帶上依賴Hash、cabal-install引入sandbox機制(類似Python的virtualenv,使得在願意浪費空間的前提下hell問題可以說解決了)和模仿nix的new-build機制。

不過,FP Complete提出了另一種方案——既然cabal-install的dependency solving帶來的非確定性是問題源頭,那我們維護一個snapshot好了:這個snapshot是Hackage包集合的子集,為每個包指定了唯一的版本;每個snapshot發布時用CI build確保這些包在這些版本號下都能成功編譯、構建文檔和通過測試。當Hackage包發布新版本時,自動嘗試將新版本加入下一個snapshot。這樣一來,對於只依賴snapshot包的項目,編譯時多個項目可以共享同一個snapshot的Package Database,速度快而且可靠、可重現,這對生產環境而言很重要。這就是Stackage項目的由來。Stackage snapshot有LTS和Nightly頻道的區別,目前LTS major version為6,對應ghc 7.10.3,Nightly對應ghc 8.0.1,具體哪個snapshot包括了哪些包可以在Stackage官網查看。

作為Stackage項目的延伸,stack包管理器應運而生。用stack開發Haskell項目時,除了.cabal配置文件以外,需要另外維護一個stack.yaml配置文件,指定項目編譯用的Stackage snapshot。而stack編譯時不會像cabal一樣維護全局的Package Database,而是每個snapshot對應自己的Package Database。如果不開發Cabal package,只是普通地通過stack調用ghc或ghci的話,stack會自動生成和使用一個「global project」的stack.yaml,這個global project的snapshot也是可以指定的。除了管理多個snapshot以外,stack可以自行安裝和管理多個版本的ghc。需要用到不在snapshot中的Hackage包時,可以通過將其添加到stack.yaml的方式來「擴展」某個snapshot。

最後,對Windows下的Haskell開發者,需要提一下msys2。這是一個MinGW-w64的分支,移植了Arch Linux的pacman管理器,可以在Windows下使用Unix shell以及安裝不少移植版的Unix庫與工具。stack在windows下使用時,stack setup不僅會安裝ghc,也會安裝msys2,之後可以用stack exec執行pacman命令,安裝某個Haskell包所用的Unix依賴,比如pkg-config/gtk/gtk3/glfw/sdl2,等等。不過如果本機已裝過msys2,可以用--skip-msys選項跳過其安裝,然後在全局的msys2的shell里使用stack。

稍作總結:

  • 我是不懂Haskell的吃瓜群眾,需要使用pandoc、shellcheck、git-annex之類用Haskell寫的工具——用平台自帶的包管理器裝。
  • 我是Haskell小白,想要敲一些書上的代碼,怎麼裝Haskell——裝stack,然後用stack setup安裝ghc,stack install安裝所需庫。
  • 我是用cabal的小白,剛才裝庫裝不上,好像是什麼版本號衝突來著——執行rm -rf ~/.cabal && rm -rf ~/.ghc,然後參考上一條。

先敲這麼多吧,這是先行版,講了一些我認為最重要的工具鏈常識,希望能對Haskell初學者有所助益。如果有不明白的地方,歡迎在評論區提出,我會適量增補。另外這裡只涉及build的問題,還有一些其他的有用開發工具——haddock(文檔構建)、ghcid/intero/ghc-mod(編輯器插件)、hpc(code coverage),日後有空慢慢補上吧。

祝大家學習愉快!下期預告:帶逛Hackage。


推薦閱讀:

用哪些編程語言寫出的代碼,讀著能感受到美?
函數式編程的早期歷史
程序語言設計理論有哪些優秀的在線課程?
Philip Wadler,Simon Peyton Jones,John Hughes能得圖靈獎嗎?

TAG:Haskell | 函数式编程 | GHC编程套件 |