北海道苫小牧市出身の初老PGが書くブログ

永遠のプログラマを夢見る、苫小牧市出身のおじさんのちらしの裏

IOモナドで使うときだけログを吐く関数を定義する

純粋な関数として定義できるんだけど内部でやってることが複雑な場合、何が起きてるかわからないと心配だからとログを吐く機能をつけると、その時点でそいつは IO アクションになってしまう。ログを吐くという副作用を持つのだから IO になるのは当たり前でそれを避けるべきではないのだけど、ログを吐かなくていいいシチュエーションでは、その計算を純粋な関数として使えたほうが理想的ではある。

そんなことを Identity型クラス 使えば簡単にできるんじゃねと思いついたんだけど、 monad-loggerそもそも機能が提供されてた。

runLoggingTrunNoLoggingTモナドclass MonadLogger が持つロギング用のアクションを追加できるのだけど、前者はモナドclass MonadIO のとき、後者は任意の class Monad について使えるようインスタンスが定義されている。

以下の例で addM は足し算するだけのアクションだが、引数をロギングするようになっている。 addaddMIdentity モナドを代入してそれを純粋な関数にしたもの1

{-# OPTIONS_GHC -Wall #-}
{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.Logger
import Criterion
import Criterion.Main
import Data.Monoid ((<>))
import qualified Data.Text as TX
import Data.Functor.Identity

showT :: Show a => a -> TX.Text
showT = TX.pack . show

addM :: (MonadLogger m, Num a, Show a) => a -> a -> m a
addM x y = do
  logDebugN $ "x=" <> showT x <> ", y=" <> showT y
  return $ x + y

add :: (Num a, Show a) => a -> a -> a
add x y = runIdentity . runNoLoggingT $ addM x y

main :: IO ()
main = do
  runStdoutLoggingT $ do
    n <- addM 1 2
    logDebugN $ showT (n :: Int)
  
  defaultMain
    [ bgroup "add" 
      [ bench "add" $ whnf (add 1) (2 :: Int)
      , bench "(+)" $ whnf (1 +) (2 :: Int)
      ]
    ]

素の足し算と比べるとオーバヘッドはある。

$ stack ghc -- -o pure-logger pure-logger.hs
[1 of 1] Compiling Main             ( pure-logger.hs, pure-logger.o )
Linking pure-logger ...
$ ./pure-logger
[Debug] x=1, y=2
[Debug] 3
benchmarking add/add
time                 87.08 ns   (83.77 ns .. 91.27 ns)
                     0.988 R²   (0.975 R² .. 0.999 R²)
mean                 84.59 ns   (82.95 ns .. 87.74 ns)
std dev              7.355 ns   (4.199 ns .. 12.95 ns)
variance introduced by outliers: 88% (severely inflated)

benchmarking add/(+)
time                 22.31 ns   (20.54 ns .. 23.96 ns)
                     0.970 R²   (0.964 R² .. 0.980 R²)
mean                 21.62 ns   (20.51 ns .. 22.97 ns)
std dev              3.736 ns   (3.185 ns .. 4.343 ns)
variance introduced by outliers: 97% (severely inflated)

が、 -Oコンパイルしたところ、オーバヘッドはきれいに消え去った。やはり最適化がきちんと効くとGHCは強い。

$ stack ghc -- -O -o pure-logger pure-logger.hs
[1 of 1] Compiling Main             ( pure-logger.hs, pure-logger.o )
Linking pure-logger ...
$ ./pure-logger
[Debug] x=1, y=2
[Debug] 3
benchmarking add/add
time                 5.809 ns   (5.663 ns .. 5.981 ns)
                     0.997 R²   (0.995 R² .. 0.999 R²)
mean                 5.728 ns   (5.662 ns .. 5.835 ns)
std dev              277.3 ps   (193.9 ps .. 404.2 ps)
variance introduced by outliers: 74% (severely inflated)

benchmarking add/(+)
time                 6.036 ns   (5.986 ns .. 6.096 ns)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 6.163 ns   (6.090 ns .. 6.334 ns)
std dev              367.0 ps   (221.5 ps .. 643.8 ps)
variance introduced by outliers: 81% (severely inflated)

これでログを吐きつつ計算するI/Oアクションと純粋な関数が、一切のオーバヘッドなしに同時に手に入った。


  1. Show 制約がはずれないのは悲しいが、ロギングするための項が入ってしまっているので仕方ないだろう。あるいは、この項が型ごと差し替えられるようになれば可能なのかな?