リテラルをコンパイラ時にチェックする
Posted on 月 28 10月 2019 in フレームワーク
Haskell はいくつかのリテラルで型クラスを使用したオーバーロードを許容している.例えば,Haskell で 1 と書いた場合,この型は 1 :: Num a => a になり, Num のインスタンスを定義することで,リテラルに対する実体をユーザも制御できるようになっている.GHC拡張で文字列に対してのオーバーロードなども提供されている.ただ,提供するデータ型とリテラルによっては,プログラムが意図通りに動かなかったりクラッシュしてしまったりすることがある.これを事前にチェックする仕組みを,コンパイラプラグインで実装する方法を紹介する.
なお,環境として以下を想定している.
GHC のバージョン | 8.8.1 |
Cabal のバージョン | 3.0.0.0 |
Haskell のオーバーロードリテラル
Haskell では整数及び浮動小数点数リテラルにおいて,オーバーロードが許容されている.それぞれ,
- 整数リテラル: Num のインスタンス
- 浮動小数点数リテラル: Fractional のインスタンス
でオーバーロードされる.さらに, GHC 拡張で
- OverloadedStrings: 文字列リテラルを IsString のインスタンスでオーバーロードする
基本的に,この実装は何か基盤となるデータ型に対し,そこからキャストするようになっている.例えば,整数リテラル 1 は fromInteger (1 :: Integer) に変換されている.リテラルそれぞれで変換の仕方を挙げると,
- 整数リテラル: Integer から fromInteger メソッドを使って変換
- 浮動小数点数リテラル: Rational から fromRational メソッドを使って変換
- 文字列リテラル: String から fromString メソッドを使って変換
のようになる.ただ,このキャストは必ずしも安全とは限らない.例えば, 300 :: Data.Int.Int8 というリテラルを考えると, 300 は 符号付き8bit 表現の域を超えてしまっている.実際に GHCi 上で試してみると,以下の出力が得られる:
>>> 300 :: Data.Int.Int8 <interactive>:1:1: warning: [-Woverflowed-literals] Literal 300 is out of the GHC.Int.Int8 range -128..127 44
このような意図しない挙動を防ぐため,GHC では標準で提供するデータ型のリテラルに対しては上のようなオーバーフローチェックをしてくれる.ただ,標準で提供されてないデータ型については,このようなチェックは行われない.有名な例が ByteString に対する文字列リテラルのチェックだ. ByteString は利便性のため IsString のインスタンスを提供している.ByteString は 8bit 文字の列で,バイト列の管理に使われる.ただ String は Unicode 文字の列で, ByteString は直接対応しない.そのため IsString インスタンスの実装は,特定のエンコードを決めてバイト列に変換するか,全ての文字が 8bit 内に収まっていると仮定しての変換の二通りということになる.ByteString では後者が選ばれている.そのため,以下のようなことが起こる:
>>> "あ" :: Data.ByteString.ByteString "B"
このような予期しない動作をするリテラルを,コンパイル時に判定して警告を出したいことは良くあるので,それを実装してみる.
GHC Compiler Plugin
GHC にはコンパイルフェーズを挟み込めるプラグインシステムが存在する.型検査や Core-to-Core の変換が主だったが, 8.6 からは Source Plugin と呼ばれる構文解析や型検査後に環境を弄れるプラグインが作れるようになった.今回はこのうち,型検査後にフェーズを挟み込める機能を使用し,リテラルに対しての警告を表示する.
プラグインの作り方のドキュメントは, GHCユーザガイド を参照すると良いだろう.また, mpickering 先生による まとめサイト も参考になると思う.基本的には, ghc パッケージの GhcPlugins.Plugin 型のデータを plugin という名前でエクスポートしたモジュールを作り,そのモジュールを -fplugin=<module> で指定してコンパイルを行えば良い.例えば,
1 2 3 4 5 6 | module PluginExample where import qualified GhcPlugins plugin :: GhcPlugins.Plugin plugin = GhcPlugins.defaultPlugin |
のようなモジュールを作り, -fplugin=PluginExample をオプションに加えてコンパイルすれば良い.型検査後にフェーズを挟み込む場合,次のように書く:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | module PluginExample where import qualified GhcPlugins import qualified TcRnTypes as TcM plugin :: GhcPlugins.Plugin plugin = GhcPlugins.defaultPlugin { GhcPlugins.typeCheckResultAction = pluginAction } pluginAction :: [GhcPlugins.CommandLineOption] -> GhcPlugins.ModSummary -> TcM.TcGblEnv -> TcM.TcM TcM.TcGblEnv pluginAction args mods tcEnv = doSomething |
doSomething の部分はデフォルトでは pure tcEnv で定義されている.それぞれの引数の内容は,
- args :: [CommandLineOption]
- プラグインのコマンドライン引数. -fplugin オプションで渡せるようになっている.
- mods :: ModSummary
- 対象モジュールの概要
- tcEnv :: TcM.TcGblEnv
- 型情報の解析に必要なものがいろいろ入っている.
みたいな感じ.後,プラグインは再コンパイルの必要性などを指定できて,デフォルトでは強制的に再コンパイルされるようになっている.特に IO 処理に依存しないプラグインを書く場合は,次のようにしておくと良い:
1 2 3 4 5 6 7 8 9 10 | module PluginExample where import qualified GhcPlugins import qualified TcRnTypes as TcM plugin :: GhcPlugins.Plugin plugin = GhcPlugins.defaultPlugin { GhcPlugins.pluginRecompile = GhcPlugins.purePlugin , GhcPlugins.typeCheckResultAction = pluginAction } |
デフォルトでは GhcPlugins.impurePlugin が指定されている.フラグが変化した際に再コンパイルする GhcPlugins.flagRecompile なども用意されている.
AST を再帰的に探索する
では早速プラグインを作っていく.TcGblEnv の tcg_binds フィールドには,トップレベルの束縛の型検査済の AST が入っている.まず,この AST を再帰的に辿ってリテラル部分を見つけていく.再帰的に式を辿る関数を書いてもいいが,今回は SYB (Scrap Your Boilerplate) を使う.こいつは, Data のインスタンスであれば,再帰関数を動的な型キャストなどを使ってうまく実装し,提供してくれるパッケージだ.こいつを使って,以下のようにすると,簡単にリテラル部分を全探索するコードを書ける:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | import qualified Generics.SYB as SYB import qualified HsExtension as HsSyn import qualified HsBinds as HsSyn import qualified HsExpr as HsSyn import qualified HsLit as HsSyn pluginAction :: [GhcPlugins.CommandLineOption] -> GhcPlugins.ModSummary -> TcM.TcGblEnv -> TcM.TcM TcM.TcGblEnv pluginAction _args _mods tcEnv = do let lbinds = TcM.tcg_binds tcEnv lintLHsBinds lbinds pure tcEnv lintLHsBinds :: HsSyn.LHsBinds HsSyn.GhcTc -> TcM.TcM () lintLHsBinds lbinds = sequence_ $ listify go lbinds where go (GhcPlugins.L loc expr) = case expr of HsSyn.HsWrap _ _ e -> go $ GhcPlugins.L loc e HsSyn.HsOverLit _ l -> Just $ lintHsOverLit loc l _ -> Nothing listify :: Typeable r => (r -> Maybe a) -> SYB.GenericQ [a] listify f = SYB.everything (++) $ [] `SYB.mkQ` \x -> toList $ f x lintHsOverLit :: GhcPlugins.SrcSpan -> HsSyn.HsOverLit HsSyn.GhcTc -> TcM.TcM () lintHsOverLit loc lit = doSomething |
listify は与えられた Data のインスタンスのデータ型を再帰的に辿り,該当するデータ型の部分を見つけたら与えられた関数を適用し,適用結果をリストで集約して返す.これを使って, LHsExpr GhcTc の部分の探索を行なっている. LHsExpr GhcTc は式を表す型 HsExpr GhcTc にソースの位置情報がついたものになっている. HsExpr a のうち,リテラルに関するコンストラクタは次のものになる:
1 2 3 4 5 | data HsExpr p = ... | HsLit (XLitE p) (HsLit p) | HsOverLit (XOverLitE p) (HsOverLit p) | ... |
HsLit はオーバーロードされていないリテラル,例えば unboxed なリテラルや OverloadedStrings 拡張のない環境下での文字列リテラルなどを表す. HsOverLit は逆にオーバーロードされたリテラルを表す. X~ みたいな部分は Trees that Grow の実装部分.後からコンストラクタを拡張したり,フェーズによって情報を付加したりできるようになっている.まあ,だいたいは無視して良くて, HsOverLit コンストラクタの本体は HsOverLit p の部分.中身は,次のようになっている:
1 2 3 4 5 6 | data HsOverLit p = OverLit { ol_ext :: XOverLit p, ol_val :: OverLitVal, ol_witness :: HsExpr p} | XOverLit (XXOverLit p) |
XOverLit コンストラクタの部分はやっぱり Trees that Grow の部分になっていて,現在はまだ使われていない.なので, OverLit コンストラクタが主要部分になる.それぞれのフィールドは,
- ol_ext
- Trees that Grow の適用部分.型検査後はリテラルの型が入っている.
- ol_val
- 本体.リテラルの内容が入っている.
- ol_witness
- 脱糖先として想定される式の AST が入っている.例えば文字列リテラル "str" なら fromString "str" みたいなもの.
みたいな感じ. ol_val の中身は
1 2 3 4 | data OverLitVal = HsIntegral !IntegralLit | HsFractional !FractionalLit | HsIsString !SourceText !FastString |
とほぼそのままの形の物が入っている. FastString は GHC 内部で使用されている文字列を表すためのデータ型で,中身はちょっと情報を付加した ByteString になる.今回の場合は UTF-8 エンコードされたリテラルの文字列が入っている.
ByteString リテラルをチェックする
後はこのリテラル情報を適当にチェックすれば良い.例えば, ByteString のリテラルをチェックしてみる.まず,概形は以下のようになる:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import Control.Monad lintHsOverLit :: GhcPlugins.SrcSpan -> HsSyn.HsOverLit HsSyn.GhcTc -> TcM.TcM () lintHsOverLit loc lit = case lit of OverLit { HsSyn.ol_val = HsSyn.HsIsString _ l, HsSyn.ol_ext = HsSyn.OverLitTc _ ty} -> go l ty _ -> pure () where go l ty = do b <- checkValidLiteral l ty unless b $ throwWarning l checkValidLiteral :: GhcPlugins.FastString -> GhcPlugins.Type -> TcM.TcM Bool checkValidLiteral l ty = doSomething throwWarning :: GhcPlugins.FastString -> TcM.TcM () throwWarning l = doSomething |
後は, ByteString の正常なリテラルかを判定する checkValidLiteral と,警告を発生させる throwWarning をそれぞれ実装していく. checkValidLiteral の概形は以下のようになる:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import qualified Data.Char as Char checkValidLiteral :: GhcPlugins.FastString -> GhcPlugins.Type -> TcM.TcM Bool checkValidLiteral l ty = case GhcPlugins.tyConAppTyCon_maybe ty of Nothing -> pure False Just tc -> do b <- isByteStringTyCon tc if b then pure isValidByteStringLiteral l else pure False where isByteStringTyCon :: GhcPlugins.TyCon -> TcM.TcM Bool isByteStringTyCon = doSomething isValidByteStringLiteral :: GhcPlugins.FastString -> Bool isValidByteStringLiteral l = all (\c -> Char.ord c < 256) $ GhcPlugins.unpackFS l |
流れとしては,
- リテラルの型を見て,その型が具体的な型でかつ
- ByteString の型コンストラクタで構築されている時,
- 8bit の文字だけで構築されている場合
真を返すようにしている. isByteStringTyCon では ByteString の型コンストラクタかを判定する必要がある.この操作はちょっとめんどくさくて,以下のような実装になる:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | import qualified TcRnMonad as TcM import qualified Finder import qualified IfaceEnv import Control.Monad.IO.Class isByteStringTyCon :: GhcPlugins.TyCon -> TcM.TcM Bool isByteStringTyCon tc = do tns <- findByteStringTyConNames pure $ elem (GhcPlugins.tyConName tyCon) tns where findByteStringTyConNames = traverse findByteStringTyConNameByModule [ "Data.ByteString.Internal" , "Data.ByteString.Lazy.Internal" ] findByteStringTyConNameByModule moduleName = do hscEnv <- TcM.getTopEnv let mn = GhcPlugins.mkModuleName moduleName fr <- liftIO $ Finder.findImportedModule hscEnv mn bytestringPackage case fr of Finder.Found _ md -> do tn <- IfaceEnv.lookupOrig md $ GhcPlugins.mkTcOcc "ByteString" pure $ Just tn _ -> pure Nothing bytestringPackage = Just $ GhcPlugins.fsLit "bytestring" |
このような実装で,依存している bytestring パッケージのモジュールから, ByteString という名前だった型名のものをうまく探し出して,型コンストラクタの名前がその名前と一致するかをチェックすることができる.TcM モナドは, MonadIO のインスタンスなので, IO 操作を普通に実行できる.なので,必要な操作が TcM の操作で見つからなかったら, IO の操作を探してみるか自分で実装するかすると良い.これで checkValidLiteral は実装できたので,後は警告を出す部分を実装する. throwWarning の実装は以下のようになる:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | {-# LANGUAGE OverloadedStrings #-} import qualified Bag import qualified ErrUtils throwWarning :: GhcPlugins.FastString -> TcM.TcM () throwWarning l = do dynFlags <- GhcPlugins.getDynFlags liftIO $ GhcPlugins.printOrThrowWarnings dynFlags $ warnMsgs dynFlags l where warnMsgs dynFlags l = let errDoc = ErrUtils.errDoc [ GhcPlugins.ftext $ "Literal \"" <> l <> "\" contains illegal characters for ByteString" ] [ ] [ GhcPlugins.text "Avoid to use non-8bit characters or may use Text instead." ] msg = ErrUtils.formatErrDoc dynFlags errDoc warnMsg = ErrUtils.mkPlainWarnMsg dynFlags loc msg in Bag.unitBag warnMsg |
単純に警告を一つ作ってそれを投げてるだけ. Bag は GHC 内で使われている順序なしのコレクションで,内部は単なる二分木になっていて,挿入や結合が定数時間のデータ構造になっている.これで警告のコレクションを作って, printOrThrowWarnings に渡すと,警告の内容を見ていい感じに出力をしたりコンパイルを中断したりしてくれる.以上で実装できる.このプラグインを次のような例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | {-# LANGUAGE OverloadedStrings #-} module Main where import qualified Data.ByteString.Char8 as Char8 import qualified Data.ByteString.Lazy.Char8 as LazyChar8 import qualified System.IO as System main :: IO () main = do putBSStrLn "ascii string" putBSStrLn "のっとばいとすとりんぐ" putStrLn "すとりんぐ" putLBSStrLn "のっとれいじーばいとすとりんぐ" where putBSStrLn = Char8.hPutStrLn System.stdout putLBSStrLn = LazyChar8.hPutStrLn System.stdout |
で動かしてみると,以下のような出力が得られる:
Main.hs:12:16: warning: • Literal "のっとばいとすとりんぐ" contains illegal characters for ByteString • Avoid to use non-8bit characters or may use Text instead. | 12 | putBSStrLn "のっとばいとすとりんぐ" | ^^^^^^^^^^^^^ Main.hs:14:17: warning: • Literal "のっとれいじーばいとすとりんぐ" contains illegal characters for ByteString • Avoid to use non-8bit characters or may use Text instead. | 14 | putLBSStrLn "のっとれいじーばいとすとりんぐ" | ^^^^^^^^^^^^^^^^^
なお,解説のコードとはちょっと違うが, ByteString のリテラルをチェックするプラグインの全体は, https://github.com/mizunashi-mana/ghc-bslit-linter に上がっているので参考にして欲しい.