基本的なアイデア
HaskellにおけるHasパターンとは、レコード型を抽象化するためのパターンだ。
下の型Person
と、もう一つの型Car
を考える。
data Person = Person
{ personName :: String
, personAge :: Int
}
data Car = Car
{ carName :: String
, carAge :: Int
}
人や物の名前を使った関数書こうと思ったら、その関数を上のレコード型それぞれについて書く必要がある。
showPersonName :: Person -> IO ()
showPersonName Person{personName} = print personName
showCarName :: Car -> IO ()
showCarName Car{carName} = print carName
ここで、name
フィールドを持つことを、型クラスを用いて抽象化する。
class HasName a where
name :: a -> String
instance HasName Person where
name Person{personName} = personName
instance HasName Car where
name Car{carName} = carName
これで名前を持つ任意のレコードを受け取ることのできる関数を書けるようになった。
showName :: HasName a => a -> IO ()
showName a = print $ name a
さらに、age
フィールドを持つことを、同様に型クラスを用いて抽象化してみよう。
class HasAge a where
age :: a -> Int
instance HasAge Person where
age Person{personAge} = personAge
instance HasAge Car where
age Car{carAge} = carAge
各フィールドを抽象化することで、柔軟な関数を定義することができるようになる。
introduce :: (HasName a, HasAge a) => a -> IO ()
introduce a = print $ "Hello, my name is " <> name a <> " and I'm " <> show (age a) <> " years old."
これがHasパターンの基本的なアイデアだ。
フィールドをLensで表現する
基本的なアイデアの章では、Has*
型クラスに持たせたのはgetterだけだった。レコード型を抽象化するのであれば、setterも持たせるべきだろう。
このようなところで役に立つのがLens
だ。HasName
を下の通りに書き換える。
class HasName a where
nameL :: Lens' a String
instance HasName Person where
nameL = lens personName $ \x y -> x {personName = y}
instance HasName Car where
nameL = lens carName $ \x y -> x {carName = y}
そうすると、名前を書き換える処理を含む一般的な関数を書けるようになる。もちろん、合成といったLensの特徴も享受することができる。
rename :: HasName a => String -> a -> a
rename name a = a & nameL .~ name
この章で紹介したコードは、こちらで動作を確認することができる。
Hasパターンの使用例
同じ名前のフィールドを持つ複数のレコードに共通のアクセサを提供する
上で紹介した通り、Hasパターンによるレコードの抽象化は、フィールドへのアクセサを型クラスで抽出することによって実現される。
Haskellはレコードのサポートが貧弱なため、これがそのまま役に立つ場合がある。
data Person = Person
{ personName :: String
, personAge :: Int
}
data Car = Car
{ carName :: String
, carAge :: Int
}
class HasName a where
nameL :: Lens' a String
instance HasName Person where
nameL = lens personName $ \x y -> x {personName = y}
instance HasName Car where
nameL = lens carName $ \x y -> x {carName = y}
printName :: (HasName a) => a -> IO ()
printName a = print $ a ^. nameL
尤も、この使い方をする場合、DuplicateRecordFields
と後で紹介するgeneric-lens が提供するHasField
を使う方がよさそうだ。
{-# LANGUAGE DuplicateRecordFields #-}
import Data.Generics.Product.Fields
data Person = Person
{ name :: String
, age :: Int
} deriving (Generic)
data Car = Car
{ name :: String
, age :: Int
} deriving (Generic)
printName :: (HasField' "name" a String) => a -> IO ()
printName a = print $ a ^. (field' @"name")
巨大なレコードへの依存を抽出する
Hasパターンは、巨大なレコードの一部のフィールドに依存する関数やアクションの依存を最低限に抑えるのに役に立つ。
例えば環境変数の集合を表現しようと思ったら、ReaderT
モナドをネストするか、巨大なレコード型を作ることになるだろう。どちらを採用するにしても、ある環境変数に依存するアクションがあったとき、その使用する環境変数にのみ依存させ、環境変数全体に依存することは避けたいはずだ。
ここでHasパターンが役に立つ。
よくある実用的なアプリがあるとする。
そのアプリはMonadReader
から環境を引ける文脈を持っていて、環境は必要に応じてHas*
型クラスを実装している。
-- The whole environment would be like:
data Env = Env
{ envPort :: Int
, envLandScape :: String
-- Other many fields
}
-- And your program runs on a monad like:
type MyAppT m a = ReaderT Env m a
class HasPort a where
envPortL :: Lens' a Int
instance HasPort Env where
envPortL = lens envPort $ \x y -> x {envPort = y}
-- (Has* boilerplates for each field follow here)
すると、環境の一部を使うアクションを次のように表現できる。このアクションは最低限の依存しか持たないため、環境や文脈の実装の変更に耐えることができる。
logPort :: (MonadReader env m, HasPort env, MonadIO m) => m ()
logPort = view envPortL >>= liftIO . print
当然、アクションは何も意識することなく使うことができる。
appMain :: MyAppT IO ()
appMain = do
logPort -- Prints envPort on stdout.
この章で紹介したコードは、こちらで動作を確認することができる。
DIを表現する
もう一つのHasパターンの使用例は、DIを表現することだ。
Haskellでは、レコード型を使ってオブジェクト指向でいうインターフェースを表現することができる。(参考: Java interfaces map to Haskell records)
例えば、logInfo
メソッドを持つLogger
インターフェースは下のように定義できる。
data Logger = Logger
{ logInfo :: String -> IO ()
}
class HasLogInfo a where
logInfoL :: Lens' a (String -> IO ())
instance HasLogInfo Logger where
logInfoL = lens logInfo $ \x y -> x {logInfo = y}
実装クラスに当たるものは、下のようなシンプルな値だ。
loggerImpl :: Logger
loggerImpl = Logger putStrLn
このLogger
の実装をアプリの環境に持たせよう。
data Env = Env
{ envPort :: Int
, envLandScape :: String
, envLogger :: Logger
-- Other many fields
}
-- And your program runs on a monad like:
type MyAppT m a = ReaderT Env m a
class HasLogger a where
loggerL :: Lens' a Logger
instance HasLogger Env where
loggerL = lens envLogger $ \x y -> x {envLogger = y}
そうすると、他の環境変数と同じようにして環境から実装クラスを引っ張ってこられるようになる。もちろん、実装クラスのメソッドを呼び出すことも可能だ。
logInfo' :: (MonadIO m, MonadReader env m, HasLogger env) => String -> m ()
logInfo' msg = do
logger <- view loggerL
liftIO $ logger ^. logInfoL $ msg
appMain :: ReaderT Env IO ()
appMain = do
logInfo' "Something"
依存オブジェクトは、main関数でアプリを実行する時に注入することになる。
main :: IO ()
main = runReaderT appMain (Env 8080 "dev" loggerImpl)
この章で紹介したコードは、こちらで動作を確認することができる。
ボイラープレートを減らす試み
さて、ここまででHasパターンというものを何となく理解してもらえたと思う。
しかしフィールドを一つ一つ型クラスにする都合で、Hasパターンは必要とするボイラープレートも多い。
そこでこの章では、Hasパターンからボイラープレートをなくそうとする試みを紹介しようと思う。
generic-lens
generic-lens は、HasField
型クラスを提供する。これは、Genericを使って各フィールドの抽象化を自動的にやってくれるものだ。
前準備は、レコードにGeneric
を導出させておくだけでいい。
import Data.Generics.Product.Fields
data Env = Env
{ landscape :: String
} deriving (Generic)
data AnotherEnv = AnotherEnv
{ landscape :: String
} deriving (Generic)
これだけで、抽象化したフィールドに依存する関数を書くことができるようになる。
printLandscape :: HasField' "landscape" a String => a -> IO ()
printLandscape a = print $ a ^. (field' @"landscape")
Has*
の定義とインスタンス化を全くやらなくてよくなり、記述がかなり簡潔になる。
ただし、HasField
を使って制約を書けるのは、そのレコード型がまさに持つフィールドだけだ。ネストしたフィールドにアクセスするには、Lensをネストさせてアクセスするしかなく、この点でHas*
を手書きする場合に比べて自由度に劣る。
例えば、下のようなEnv
と、Env
のフィールドApplicationConfig
があったとする。
data Env = Env
{ config :: ApplicationConfig
} deriving (Generic)
data ApplicationConfig = ApplicationConfig
{ landscape :: String
, port :: Int
} deriving (Generic)
type MyAppT m a = ReaderT Env m a
この時、landscape
のみに依存するMyAppT
のアクションを書こうと思ったら、次のように制約を書くしかない。
logLandscape :: (MonadIO m, MonadReader env m, HasField' "config" env ApplicationConfig) => m ()
logLandscape = do
landscape <- view (field' @"config" . field' @"landscape")
liftIO . print $ "Hello, " <> landscape
しかしこれではlogLandscape
がport
に対する依存も抱えることになり、必要最低限の依存というわけにはいかなくなってしまう。
HasField
型クラスは、重複したレコード名をうまく扱ったり、同名のフィールドを持つレコードを抽象化しようとしたときに威力を発揮することになるだろう。
何といってもGeneric
さえ実装していれば使えるのだから、Has*
みたいな大げさなものを書きたくない場合には手軽で便利だ。
data-has
data-has は、Has
型クラスを提供する。Has
型クラスは、今まで一つ一つ定義していたHas*
を一般化した型クラスだ。
Has
型クラスの実装は自分で行う必要がある。
import Data.Has
newtype Env = Env
{ _envConfig :: ApplicationConfig
}
data ApplicationConfig = ApplicationConfig
{ _acLandscape :: !Landscape
, _acPort :: !Port
}
newtype Landscape = Landscape String
newtype Port = Port Int
instance Has ApplicationConfig Env where
hasLens = lens _envConfig $ \x y -> x {_envConfig = y}
instance Has Landscape ApplicationConfig where
hasLens = lens _acLandscape $ \x y -> x {_acLandscape = y}
instance Has Port ApplicationConfig where
hasLens = lens _acPort $ \x y -> x {_acPort = y}
instance Has Landscape Env where
hasLens = (hasLens :: Lens' Env ApplicationConfig) . (hasLens :: Lens' ApplicationConfig Landscape)
instance Has Port Env where
hasLens = (hasLens :: Lens' Env ApplicationConfig) . (hasLens :: Lens' ApplicationConfig Port)
一般化されたHas
型クラスはフィールドを型で区別しているため、プリミティブな型のフィールドは使いづらくなっている。
MyAppT
のアクションは次のような感じで書ける。依存を最小限に抑えられるほか、Has*
を手書きしていた時に比べてインターフェースが統一されて読みやすくなっている。
printServerInfo :: (MonadIO m, MonadReader env m, Has Landscape env, Has Port env) => m ()
printServerInfo = do
Landscape landscape <- view hasLens
Port port <- view hasLens
liftIO . print $ "Landscape: " <> landscape
liftIO . print $ "Port: " <> show port
makeFields
lens や microlens-th で公開されているmakeFields
は、手書きしていたHas*
型クラス群をTemplateHaskellを用いてゴリゴリに生成してくれる。
{-# LANGUAGE TemplateHaskell #-}
import Lens.Micro.TH
newtype Env = Env
{ _envConfigL :: ApplicationConfig
}
data ApplicationConfig = ApplicationConfig
{ _applicationConfigLandscapeL :: !String
, _applicationConfigPortL :: !Int
}
makeFields ''Env
makeFields ''ApplicationConfig
instance HasLandscapeL Env String where
landscapeL = configL . landscapeL
instance HasPortL Env Int where
portL = configL . portL
見ての通り、makeFields
は指定したレコードのフィールド全てのHas*
クラスの生成と実装を行う。
レコードの定義が独自の命名規則(_{レコード名}{フィールド名}
)に従う必要があるが、最も単純で強力な方法と言えるだろう。
AppT
のアクションの定義も、自分でHas*
を定義したときとほとんど同じだ。
printServerInfo :: (MonadIO m, MonadReader env m, HasLandscapeL env String, HasPortL env Int) => m ()
printServerInfo = do
landscape <- view landscapeL
port <- view portL
liftIO . print $ "Landscape: " <> landscape
liftIO . print $ "Port: " <> show port
おわりに
HaskellにおけるHasパターンが、レコード型を抽象化する強力な方法であることを説明した。
また、Hasパターンのボイラープレートを減らすことを目的としたライブラリをいくつか紹介した。ライブラリは物によって達成したい目的が微妙に異なり、どれを使うかはHasパターンをどのような目的で使うかによって変わってくるだろう。