自動printfデバッグ

関数をデバッグするために、引数と戻り値をそれぞれ表示するというのを誰しもやったことがあると思う。今回はそれを自動化するからくりをHaskellで実装してみる。

目標となるのは、関数が与えられたとき、その引数と結果をターミナルに出力する関数に変換する高階関数probe :: Traceable a => String -> a -> aである。

testDelay :: Double -> Double -> IO ()
testDelay dur dur' = threadDelay $ round $ (dur + dur') * 1000000
*Probe> probe "testDelay" testDelay 1 2
testDelay 1.0 2.0: ()

これは型クラスを活用すればお茶の子さいさいである。以下のように型によって挙動を切り替える関数withTraceDataを定義すればよい。

  • 関数r -> aは、rの値を文字列にしてリストに積み、aについても再帰的にwithTraceDataを適用する。
  • IOアクションからは結果が返されるとみなし、与えられた引数のリストと結果を表示する。

実装するとこのようになる。

module Probe (Traceable(..), probe) where

import System.IO
class Traceable a where
    withTraceData :: String -- 名前
      -> [String] -- 文字列化した引数
      -> a -> a

instance (Show r, Traceable a) => Traceable (r -> a) where
    withTraceData name args cont arg = withTraceData name
      (showsPrec 11 arg "" : args) -- showsPrecを使うことで適切に括弧を表示する
      (cont arg)

instance Show a => Traceable (IO a) where
    withTraceData name args m = do
        hPutStr stderr $ unwords (name : args) ++ ": "
        hFlush stderr
        a <- m
        hPrint stderr a
        pure a

probe :: Traceable a => String -> a -> a
probe name = withTraceData name []

このままでは純粋な関数には使えないので、unsafePerformIOを使ったインスタンスも作っておこう。

newtype Result a = Result { unResult :: a }
instance Show a => Traceable (Result a) where
    withTraceData name args (Result a) = Result $ unsafePerformIO $ withTraceData name args $ pure a

Traceable Intなどを直接定義せず、まずnewtypeへのインスタンスとして定義するのには理由がある。 Haskellの多引数関数はカリー化されており、「a -> b -> cb -> cを結果として返す関数」でもあるため、どこで区切るかがwell-definedではない。そのため、明示的な型によって区別することは理にかなっている。 さらに、他のプリミティブ型の定義をDerivingViaとStandaloneDerivingによって簡潔に書けるという最近のGHCならではの利点もある。

deriving via Result Bool instance Traceable Bool
deriving via Result Int instance Traceable Int
deriving via Result Double instance Traceable Double
...

小さなコードではあるが、ここで紹介したテクニックは日常で役に立つに違いない。 この手法は色々と応用が利く。例えば、printする代わりにシリアライズして外部のファイルに書き込めば、probe関数を刺して動かすだけでテストケースを抽出できる道具にもなる。

最近やる気が出てきたので、これらのアイデアをそのうちライブラリにまとめるかもしれない。