Extensibleのレコードを環境変数で上書き

Extensibleのレコードを食わせて、レコードのキーと一致する環境変数があったら上書きして返す、ということをやってみた(まあ同僚の案なんだけど)。 型の理解にめっちゃ苦労した、というか未だにモヤっとしているんだけど、頑張ったのでアウトプットしておく。

Extensibleの使ったことがないとわからない話になるので、それでも読みたいという善良な方はExtensible攻略Wikiを先に読もう。 私も使い始めで全然理解できてはいないけど、めっちゃ便利でイケてるということはわかる。

準備

レコードの型とデフォルトの値を定義する。 あとインポートとGHC拡張もここに全部載せちゃう。

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE FlexibleContexts  #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications  #-}
{-# LANGUAGE TypeOperators     #-}

module Main where

import           Control.Monad.Identity
import           Data.Extensible
import           Data.Maybe             (maybe)
import           Numeric.Natural        (Natural)
import           System.Environment     (getEnvironment, setEnv)
import           Text.Read              (readMaybe)

type MyRecord = Record
  '[ "HOGE_STR" :> String
   , "HOGE_NAT" :> Natural
   , "HOGE_BOOL" :> Bool]

-- System.Envrionment.getEnvironmentの戻り値の型のエイリアス
type EnvKeyValue = [(String, String)]

defRecord :: MyRecord
defRecord =
  itemAssoc (Proxy @ "HOGE_STR") @= "def"
  <: itemAssoc (Proxy @ "HOGE_NAT") @= 0
  <: itemAssoc (Proxy @ "HOGE_BOOL") @= False
  <: nil

環境変数のキーには慣習として大文字を用いるが、OverloadedLabels拡張では大文字始まりが許されていないので、あの便利な#hoge @= "fuga"という構文が使えない。 代わりにitemAssoc (Proxy @ "hoge") @= "fuga"を使う。記述量が増えるのはちょっと嫌だが仕方ない。

上書きする

そして本題の上書き部分。

merge  :: Forall (KeyValue KnownSymbol Read) xs => Record xs -> IO (Record xs)
merge r = do
  e <- getEnvironment
  pure $ hmapWithIndexFor p (f e) r
  where
    p = Proxy @ (KeyValue KnownSymbol Read)
    f :: KeyValue KnownSymbol Read x => EnvKeyValue -> Membership xs x -> Field Identity x -> Field Identity x
    f e m (Field idVal) = Field $ maybe idVal pure (readEnv (stringAssocKey m) e)

readEnv :: Read a => String -> EnvKeyValue -> Maybe a
readEnv k kvs = readMaybe =<< lookup k kvs

Forallの制約は、文字通り型レベルリストxsの任意の要素に関するもののよう。 ここではキーがKnownSymbol、つまり何らかの型レベル文字列、値がReadのインスタンスに限定されている。

hmapWithIndexForも名前から推測できるように、レコードに対するmap処理のための関数。 ここが結構難しかったので、まず型を書いておいてから引数について頑張って理解していく。

hmapWithIndexFor :: Forall c xs => proxy c -> (forall x. c x => Membership xs x -> g x -> h x) -> (g :* xs) -> h :* xs

第一引数

xsにはmergeの型宣言で制約を与えたので、第一引数にも同じ制約をProxyのかたちで与えてあげる。

第二引数

第二引数は処理の中身になる。 まず任意のxについてxはcを満たすので、xのキーは文字列で値はReadのインスタンスの型だ。 Membershipに関しては正直のところ攻略Wikiを読んでもあまりわかった気がしないんだけど、とりあえずstringAssocKeyを適用することでキーを文字列として取得できる。

そんでgとhって何者かってことなんだけど、戻り値の型にある:*のドキュメントを読んだ理解だと、この文脈ではg :* xsと書いたときはRecordOf g xsで、g xだとField g xという型になるっぽい。

MyRecordの定義に使ってるRecordRecordOf Indentityのエイリアスなので、g xh xは両方ともField Identity xとなる(h xField Maybe xとかになってもよいはず。種が* -> *ならおk)。

第二引数に与えてる関数fの具体的な中身をまとめると、

  1. フィールドのキーを文字列で取得
  2. 文字列に対応する環境変数を探し、無かったらもともとの値を、あったらそれをField . Identityで包んで返す

という流れになっている。

試す

実際に動かしてみる。

main :: IO ()
main = do
  print defRecord
  setEnv "HOGE_STR" "\"merged\""
  setEnv "HOGE_NAT" "100"
  setEnv "HOGE_BOOL" "True"
  merge defRecord >>= print

ちゃんと上書きされてる!

HOGE_STR @= "def" <: HOGE_NAT @= 0 <: HOGE_BOOL @= False <: nil
HOGE_STR @= "merged" <: HOGE_NAT @= 100 <: HOGE_BOOL @= True <: nil

ちなみに、このままだとreadMaybeでそのまま文字列として持っておけばいいものまでreadしているため、環境変数の指定時にダブルクォートが必要になっちゃうので、型クラス作ってreadEnvを多相化して対応した。 NaturalとかBoolまでインスタンス宣言する必要が生じるのでもっとうまいやり方あれば教えて欲しい。

comments powered by Disqus