13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Hasパターンとは

Last updated at Posted at 2021-04-25

基本的なアイデア

 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

 しかしこれではlogLandscapeportに対する依存も抱えることになり、必要最低限の依存というわけにはいかなくなってしまう。

 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

 lensmicrolens-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パターンをどのような目的で使うかによって変わってくるだろう。

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?