GHC API 系列筆記(1):入門篇
為什麼需要 GHC API
- 實現 Haskell 的 eval 功能 —— 不是 Template Haskell 那種躲在編譯期運行的弱雞擴展,而是最混沌、最不安全的那種,輸入 String、輸出 Haskell 值的 eval,而且可以動態檢視其類型。在實現諸如 Online REPL、Online Judge 等面向 Haskell 學習者的工具時相當有用。
- 實現 Haskell 模塊的熱切換。Lisp 能做,Erlang 能做,憑什麼 Haskell 不能做(
- 撈取 GHC Pipeline 中的各種中間表示,從而將 GHC 的前端挪用來實現自己的 Haskell 編譯器。像 haste 和 ghcjs 就依賴 GHC API 實現從 Haskell 源代碼編譯到 STG 的邏輯,然後通過自己的代碼生成器從 STG 編譯到 JavaScript。當然,想要用 Haskell 搞前端開發,除了真的開坑做 Haskell 的 JavaScript 編譯器以外,另一條道路是設計用 Haskell 來表示 JavaScript 的 monadic DSL,具體做法及其優缺點得等我另外寫一篇講 remote monad 設計模式的專欄了。
- 實現 GHC Plugin。可以實現自定義的 Core optimization pass 或 type checker,比如 liquidhaskell 通過 Plugin API 調用 SMT solver,為 Haskell 實現 refinement type 系統。
- 實現 Haskell 的 IDE,比如實時類型檢查、自動補全等。Haskell IDE 不發達,我們都要支援它(
GHC 的架構中,大多數類型和相關函數都通過 GHC API 暴露給用戶,而頂層的 ghc 程序只是一個 GHC API 的 wrapper 而已,這使得 GHC 能做到的所有事情,我們可以通過調用 GHC API 來做到。這裡通過一個將 Haskell 代碼用 native code generator 編譯到 object code 並載入後對 String 進行 eval 的例子,演示 GHC API 的基本用法、一些相關類型與函數的含義。
一個最簡單的 demo
下面這段代碼,從 ./test/case/Fact.hs 中編譯並載入 Fact 模塊,然後將表達式 "fact 5" 求值後輸出。
import Control.Monad.IO.Classnimport Data.Functornimport DynFlagsnimport GHCnimport GHC.Pathsnimport Unsafe.Coercennmain :: IO ()nmain =n defaultErrorHandler defaultFatalMessager defaultFlushOut $n runGhc (Just libdir) $ don dflags <- getSessionDynFlagsn void $n setSessionDynFlagsn dflagsn { ghcLink = LinkInMemoryn , hscTarget = HscAsmn , importPaths = ["./test/case"]n }n setTargetsn [ Targetn { targetId = TargetFile "./test/case/Fact.hs" Nothingn , targetAllowObjCode = Truen , targetContents = Nothingn }n ]n void $ load LoadAllTargetsn setContext [IIDecl $ simpleImportDecl $ mkModuleName m | m <- ["Fact"]]n v <- unsafeCoerce <$> compileExpr "fact 5"n liftIO $ print $ (v :: Int)n
./test/case/Fact.hs 的內容(一個簡單的階乘函數):
module Fact wherennfact :: Int -> Intnfact nn | n < 0 = error "Expects non-negative n"n | n == 0 = 1n | otherwise = n * fact (n - 1)n
編譯運行的結果:
$ stack ghc -- ghci.hs -package ghc -package ghc-pathsn[1 of 1] Compiling Main ( ghci.hs, ghci.o )nLinking ghci ...nn$ ./ghcin120nn$ ls ./test/casenFact.hi Fact.hs Fact.on
可以看到表達式求值的結果。./test/case 目錄中新增的 .hi 和 .o 文件是 native code generator 編譯 Fact.hs 的產物。
demo 代碼講解
首先,如果需要用到 GHC API 的話,我們的依賴會增加兩個包:ghc 和 ghc-paths,如果是 Cabal project,需要將其補充到 .cabal 中。ghc 包是 GHC 在 booting 階段自動生成的,其版本號與 GHC 版本號嚴格對應,並不在 Hackage 上提供下載和文檔,也並不支持通過普通的 Cabal 機制編譯安裝。ghc-paths 包是一個簡單的 wrapper,通過 C 預處理器擴展來提供「編譯 ghc-paths 所用到的 GHC 相關路徑」,這個路徑在使用 GHC API 時需要用到。
ghc 包的文檔可以在線查看,也可以查看本地的版本(通過 stack 安裝的 ghc 在 ~/.stack/programs 中自尋,而通過 hvr/ghc 安裝的 ghc 需要單獨安裝 ghc-$GHCVER-htmldocs 包,$GHCVER 為 GHC 版本號)
ghc 包中的模塊較多。入門時首先從 GHC 模塊的文檔看起。首先關注兩個類型——Ghc 和 GhcT,以及一個類型類 GhcMonad 。GhcMonad 抽象了「維護 GHC Session、支持異常處理和日誌輸出、支持嵌入 IO action」的功能,幾乎所有 GHC API 的介面基於 GhcMonad 提供,而 Ghc 和 GhcT 是提供了 GhcMonad 功能的 monad 和 monad transformer。在這個簡單的 demo 中,我們只用到了 Ghc 類型。
運行一個 Ghc action 需要使用 runGhc 函數初始化一個 GHC Session,函數參數為 ghc-paths 提供的 GHC library directory。GHC Session 的類型是 HscEnv,這個 Session 不可以被多線程使用,但是單個 Haskell 程序可以初始化多個不同的 GHC Session 運行。runGhc 以外還需要一個 wrapper 來安裝 exception handler,沒有特殊需求的話使用 default 開頭的默認 handler 即可。
接下來解釋 Ghc monad 中做了什麼工作。首先,我們需要設置 DynFlags 。一個 GHC Session 包含兩個 DynFlags,分別代表 program/interactive 模式下的 GHC 設置,通過命令行調用 GHC 時的一堆 flags 的用途就是設定 DynFlags。GHC Session 初始化時默認的 DynFlags 值通過 defaultDynFlags 設定,不過這時的 DynFlags 值並不能直接用於後續的其他 GHC API 介面,需要我們手動先 getSessionDynFlags,然後 setSessionDynFlags,哪怕並不需要修改默認選項也要這樣做一次,一些額外的初始化工作會在此時進行並將結果合併到 DynFlags 中(比如讀取 package database)。getSessionDynFlags/setSessionDynFlags 會同時對 program/interactive DynFlags 進行設置,除此之外我們也可以單獨設置 program/interactive DynFlags,這裡使用 getSessionDynFlags/setSessionDynFlags 是為了省事。
我們的 demo 中對 GHC 選項稍作了一些修改。具體有哪些選項可以參考 DynFlags 的類型定義,這裡我們指定使用 native code generator 後端、使用 in-memory linker,並將 Fact.hs 所在的目錄加到 import 路徑列表中。
後端通過 HscTarget 類型指定,除了 native code 後端以外,我們還可以選擇 C 後端、LLVM 後端、bytecode 後端、啥也不生成後端。C 後端就是將 Cmm 代碼轉換為 C 然後調用 gcc 編譯和鏈接,是古代的 GHC 使用的默認後端,優點是在將 GHC port 到未知平台上時有用,缺點是後端自身性能和生成代碼性能都不如 native code 後端。LLVM 後端通過命令行調用 LLVM 的 llc 和 opt 工具,將 LLVM IR 進行編譯和優化,優點是生成代碼的性能較高,尤其是在進行數值計算的 Haskell 代碼上提升明顯,能夠使用 SIMD primop;缺點是後端自身較慢,而且不同 GHC 版本支持的 LLVM 版本不一。bytecode 後端生成 ghci 解釋器專用的 bytecode,速度較快。而啥也不生成後端的用途就是做類型檢查,在實現 IDE 時有用,不過如果代碼用到了 Template Haskell 就不行了,畢竟需要走 2 個 pass,第一個 pass 總不能啥也不生成。
在其他 GHC API 的 tutorial 中,需要演示實現 eval 時,通常選擇 bytecode 後端,這裡我選擇 native code 後端以保證之後載入的模塊行為與單獨編譯時完全一致,另外後端生成的 native code 性能比 bytecode 更佳。不過這樣一來就無法使用 ghci 的斷點調試了,demo 沒有用到這個功能所以無妨。linker 選項的含義參考 GhcLink 說明,這裡選擇 in-memory linker,因為不需要生成 executable 或者 static/dynamic library。另一個選項是 importPaths,默認為當前目錄,因為 Fact.hs 放在 ./test/case 目錄中,所以需要加上這一項。
有一個選項這裡沒有設置,使用默認值:GhcMode。通過命令行調用 GHC 編譯 Haskell 代碼時,一般有 2 種模式選擇—— one-shot 模式,以及 make 模式。one-shot 模式通過 -c 選項使用,用途為編譯單個 Haskell 模塊;make 模式通過 --make 選項使用,用途為將指定 Haskell 模塊及其依賴模塊一次性編譯。通過 make 模式進行編譯的性能,超過 one-shot 模式+第三方 make 工具,因為即使在 one-shot 模式下,ghc 也會自行做 dependency tracking,在內建的 make 模式下工作時可以避免許多冗餘的 tracking 工作。這裡我們使用默認的 make 模式,如果有其他未編譯的模塊被 Fact 依賴,它們會被一併編譯。
以上是 GHC API 初始化相關的工作。接下來我們需要指定 GHC 的 target。setTargets 函數設定當前的 Target list,至於獲取 Target 的方法,可以像 demo 中一樣,通過 Target 類型定義手動指定,或者通過 guessTarget 函數生成。
指定完 target 以後,就可以啟動 GHC Pipeline 進行編譯和載入了。可以手動運行 dependency tracking、preprocessing、parsing、type checking、desugaring、code generating、linking 等 pass,不過最簡單的方法是使用 load 函數自動運行整個 pipeline。load 成功後,Fact.hs 已經被編譯完畢,interface file 和 object file 在同一目錄下生成,可以載入。
接下來要載入 Fact 模塊並 eval 表達式。首先要設置 interactive context,命令行下啟動 ghci 時,默認的 context 包含 Prelude 模塊,不過這裡默認 context 是空的,如果用到了 Prelude 中的聲明,需要手動將其加入。setContext 支持兩種不同類型的 import,通過 InteractiveImport 類型指定,其中 IIDecl 將 module export 的定義 import 到當前 context,而 IIModule 則將整個 module 源代碼的頂層環境 import 到當前 context。後一種 import 對應 ghci 中的 :load 命令,如果我們選擇通過 bytecode 模式載入 Fact,則選擇後一種,否則選擇前一種。
interactive context 設置完畢後,總算可以做 ghci 也能做的各種事情了——這裡先 eval 一個表達式試試看。compileExpr 將一個 String 編譯成我們想要的 Haskell value。返回的類型是 HValue,而 HValue 是 Any 的 newtype wrapper,在我們知道表達式類型的前提下,可以通過 unsafeCoerce 強制轉換回我們需要的類型。
如果有潔癖,覺得 unsafeCoerce 太辣眼,那麼在表達式類型非多態、是 Typeable 實例的前提下,可以使用 dynCompileExpr 將 String 編譯成 Dynamic。Haskell 有一定程度的動態類型支持,通過 Typeable class 實現運行時類型信息,通過 Data.Dynamic 提供了帶運行時類型檢查的普通 Haskell 值與 Dynamic 之間的轉換。
demo 講解完畢!
其他 GHC API 使用姿勢
- 剛才的 demo 中,我們通過調用 load 函數,一次性完成了整個編譯 pipeline。有時我們並不希望運行整個 pipeline,而是希望運行到某一個 pass 為止,將輸出的中間表示進行處理,比如實現自己的 Haskell 編譯器。為了在 pipeline 中撈到我們想要的中間表示,我們可以選擇手動實現自己的 load,不過工作量不小,而 GHC API 提供了 Hooks 機制,就像先前介紹的 Cabal hooks 一樣,可以通過初始化時在 DynFlags 中安插一些 callback,從而 load 過程中運行到特定 pass 時會觸發 callback,允許我們獲取和處理中間表示。詳細可以參考 Ghc/Hooks - GHC
- 從 GHC 8 開始,ghci 提供了一個「remote interpreter」功能,相關代碼來自 Luite Stegeman 在 ghcjs 項目中的相關工作,可以通過跨進程的 RPC 啟動單獨的 ghci 進程進行計算。remote interpreter 的特點參考 GHC 文檔,這個 demo 里使用的是 old-style 的 ghci API,如果希望通過 remote interpreter 實現 interactive evaluation,可以參考 ghci 包的相關文檔。
- GHC 提供了 Plugin API,包含 frontend plugin、typechecker plugin、core plugin,分別可以在 init 、type check、desugar 三個階段搞事情。考慮到 Cabal 架構的複雜性,實現 plugin 一般比實現 standalone 的調用 GHC API 的程序工作量更小。
相關輪子
有不少人基於 GHC API 做了一些 wrapper 和有趣的應用,這裡列舉幾個典型的:
- hint 和 mueval。實現 eval 功能,後者特地增強了安全性,在 Try Haskell! An interactive tutorial in your browser 有用到
- ghc-simple。實現了「帶緩存的將一系列 Haskell 源代碼編譯到 Core/STG/Cmm」的功能,在 haste 編譯器中有用到
我自己也寫了一個 wrapper(還沒有上 Hackage,施工現場在 TerrorJack/ghc-bones),目前提供的功能有:
- SessionT。這是一個提供了 GHC API 功能的 monad transformer,與 ghc 自帶的 GhcT 相比,特點是與 mtl-style 的 monad transformer stack 兼容性好,實現了不少有用的 instance,同時調用相對簡單一些,只要在 SessionPref 中指定好各種 handler 和選項即可;
- eval 函數。這個 eval 函數將單個 module 源代碼在臨時目錄中編譯成 object code 並載入到 interactive context,在該 context 下對表達式進行求值。支持限制求值所用時間和內存,同時會將表達式求值到 normal form,避免 time leak。這個 eval 是打算給以後的 Online Judge 坑用的。
具體用法可以參考 test suite 里的 test case。
參考資料
ghc(library)文檔:ghc-8.0.2: The GHC API
ghc(compiler)文檔:Welcome to the GHC Users Guide
The Architecture of Open Source Applications 書中 GHC 的章節,有點老不過仍然推薦:The Glasgow Haskell Compiler
GHC wiki,深入了解 GHC:Commentary - GHC
另一篇不錯的 GHC API 教程:GHC API tutorial
Stephen Diehl 的 GHC API 教程:Dive into GHC: Pipeline
Edward Z. Yang 的 GHC API 集成 Cabal 教程:How to integrate GHC API programs with Cabal
祝大家。。什麼節快樂好呢(沉思
推薦閱讀: