使用 Cabal hook 構建複雜 Haskell 項目

首先說說 「複雜」 Haskell 項目是怎麼回事。嗯,並不是下圖這種意義上的複雜:

這裡的複雜指的不是語言特性,而是 build process 的複雜性。對於 Haskell 初學者和普通用戶而言,只要掌握一些基本的工具鏈常識(我在@大魔頭-諾鐵 的專欄有簡單講過:紅塵里的Haskell(之一)--Haskell工具鏈科普 - 知乎專欄),以及幾個簡單 stack/cabal 命令的用法,就可以創建和管理 Haskell 項目。

stack/cabal 之類工具的核心是 Cabal 庫,它提供了描述 Haskell 的 package/module 元數據的格式,以及一些內建的 builder,在 configure/build/generate haddock/... 等流程中解析和處理這些元數據,並根據需要調用 GHC、linker、C 編譯器等工具完成構建。按照 Cabal 文檔的描述(3. Package Concepts and Development),提供一個 .cabal 文件,以及一個簡單的 Setup.hs 腳本(對於大多數 package,對應的 Cabal build type 是默認的 Simple, 這個腳本只有2行),stack/cabal 就可以自動幫你完成其他工作。

然而 Cabal 默認特性並不總是夠用的。考慮以下2個例子:

  • 實現 Haskell 綁定 C/C++ 庫。這個庫可能在系統中裝了多個版本,需要一些特殊的邏輯去尋找和設定相關的 flag,甚至有可能需要把這個庫的開發版打包到 Haskell 項目中自行構建和鏈接。
  • 實現 Haskell 元編程,輸出一堆 Haskell module 的源文件(也許還帶著 haddock 文檔)並將其作為 Cabal build target

對前一個例子,Cabal 本身提供了用 pkg-config 找配置,以及編譯 Haskell 項目中的(不需要什麼奇怪腳本預處理、不涉及 make 時吐出新代碼的)C 源文件。然而不支持 C++,不支持編譯動態生成新的 C/C++ 代碼,也不能搞些奇怪的預處理(根據 Haskell 項目要求去 patch 原來庫的代碼),或者使用 ninja 之類的非主流 build 工具……

對後一個例子,Haskell 元編程的標準做法是 Template Haskell,可以在編譯期執行任意計算並生成各種聲明。然而 Template Haskell 暫不能生成新 module,而且其語法樹中並未考慮 haddock 文檔的支持。

歸根結底,我們需要在 Cabal 的 setup 程序里用 Haskell 實現自定義的 build 邏輯,比如調用奇怪的工具,或者魔改自己的 package 元數據。考慮到這樣的需求,Cabal 提供了 hook 介面,可以在默認的 Simple builder 上面用自己的 callback 來取得數據並做一些微小的工作。

hook 的使用方法:首先將 your-package-name.cabal(或者如果你趕時髦用 hpack 的話,package.yaml)中的 build-type 一欄從 Simple 改為 Custom。另外,強烈推薦增加 custom-setup 信息,用於描述 Setup.hs 腳本自身的 Haskell 依賴,這些依賴與項目自身的各個 build target 的依賴是完全獨立的,也不會共享默認編譯選項、語言擴展等 flag 。改完以後,就可以開始動手魔改 Setup.hs 腳本了。

參考 Distribution.Simple 的文檔,原本我們 Setup.hs 的 main 直接使用了 defaultMain,現在可以換成 defaultMainWithHooks。UserHooks 類型包含了一堆 callback ,從 callback 的類型和名字就可以看出它們分別在什麼階段會被觸發,輸入/輸出信息的類型是什麼。現在,支持用 Haskell 代碼來自定 Haskell 項目的 build 邏輯以後,前面所說的問題也就可以解決了:比如需要 build C++ 庫的話,可以在 preConf 或者 confHook 上面加上「調用 make,並將 .so/.a 複製到本 package 的默認 libdir」的邏輯;要是有閒情逸緻的話,甚至 make 也不必調,用 Shake Build System 把 build 邏輯自己重寫一下也是完全 OK 的。至於 Haskell 代碼生成,在生成代碼並複製到源碼目錄中以後,暴力修改 LocalBuildInfo 中的 localPkgDescr ,把私貨夾帶進去就可以開開心心地 build 啦。

當然,最糙快猛的做法,無疑是用其他語言寫個腳本來生成和打包所有你需要的項目文件,這樣做的話 SAN 值會掉90%;或者善用 C 預處理器,在項目里到處 #include,然後像操作文本一樣用宏吐出一堆 Haskell 代碼,這樣做 SAN 值會掉 50%。不過,最理想的做法,當然是——所有在其他系統里用到的奇技淫巧,都能用 Haskell 波瀾不驚地實現出來。Best magic is no magic。

考慮到 Cabal 的 hook API 並不是一個非常 well-documented 的東西,而且行為在 stack/cabal-install 下表現還不完全一致,初學者為了避免一些陷阱,也許需要跟蹤整個 Cabal build process,尤其是偷看這些 hook 在什麼時機被觸發、傳入的參數和計算結果分別是什麼。所以我寫了一個簡單的打日誌的工具:TerrorJack/Cabal-playground 。這個項目包含 foreign C code/library/executable/test-suite/benchmark 各一個,以及一個用於詳細觀測 Cabal build process 的 Setup.hs 腳本(比原本最 verbose 的選項更 verbose),只要設定一個環境變數並啟動 build ,就可以從日誌讀到每一個 hook 的觸發時機以及調用參數/結果。這個 Setup.hs 腳本很容易通過簡單修改遷移到其他的 Haskell 項目,用於 debug 其他項目的構建流程。

P.S. 為什麼想到寫這玩意呢?嘛,我最近寫的 Haskell 代碼和開頭的圖一比,畫風完全不一樣。。

其中大多數 Haskell 代碼是我用 Python 腳本吐出來的。不能用 Haskell 完成整個 workflow 的每一個環節讓我掉了很多 SAN 值。所以有了這篇專欄。

推薦閱讀:

GHC擴展-XRankNTypes是什麼?如何理解forall .?
Kotlin到Dart的簡單翻譯器的坑基本上填完了
Haskell的遞歸
將 Haskell 翻譯為 Rust, C# (上)標準庫
Haskell的>>是如何實現的?如果是\_->i d,那第一個參數豈不是會因為惰性求值而不被求值?

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