読者です 読者をやめる 読者になる 読者になる

claustrophobia

一般λがなんか書くとか

Haskellのレコード更新構文を詳しく

スタック・オーバーフローにて


haskell - Text.Parser.Token.StyleのemptyIdentsの使い方について - スタック・オーバーフロー

といった質問をしたところ、このような回答を頂き、レコード更新構文についての詳細を知る必要を感じたので私的まとめ。
といってもほとんどHaskell Language Reportを見ていろいろ実験しただけなんですが。

レコード更新構文の解釈

3.15.3 Updates Using Field Labelsより。

{C}コンストラクタ{bs}を束縛の列、{v_{default}}を値として、{pick^C_i(bs, v_{default})}を次のように定義します:

コンストラクタ{C}{i}番目のフィールドの名前が{f}であり、かつ束縛の列{bs}{f}に関する束縛{f=v_{updated}}が含まれるならば{pick^C_i(bs, v_{default}) = v_{updated}}である。そうでないならば{pick^C_i(bs, v_{default})=v_{default}}である。」

データ型Tを

data T = {C_1} {{f^1_1} :: {T^1_1} ... {f^1_{k_1}} :: {T^1_{k_1}}}
       | ...
       | {C_m} {{f^m_1} :: {T^m_1} ... {f^m_{k_m}} :: {T^m_{k_m}}}

とすると、T型の式{e}に対するレコード更新構文{e} {{bs}}は

{e} {{bs}} = case {e} of
       {C_1} {v^1_1} ... {v^1_{k_1}} -> {C_1} {pick^{C_1}_1(bs, v^1_1)} ... {pick^{C_1}_{k_1}(bs, v^1_{k_1})}
       ...
       {C_m} {v^m_1} ... {v^m_{k_m}} -> {C_m} {pick^{C_m}_1(bs, v^m_1)} ... {pick^{C_m}_{k_m}(bs, v^m_{k_m})}

に変換されます。

多相的な値と組合せるときの注意点

変換された式を見ると、要するにただのパターンマッチになっていることがわかります。
マッチされる式 e の型が多相的で、かつユーザが型シグネチャを供給していないならば e の型は精密化されません。

となると、例えば e が多相的な型を持ち、かつ e {bs} に単相的な型をユーザが与えていた場合、 e の値によって e {bs} の値が一意に決まらなかった場合などは困るわけで、このような場合曖昧性エラーが生じます。

具体的には以下のようなコードで曖昧性エラーが発生します。

data T a = T {element :: a, typeStr :: String}

class F a where mes :: a -> String
instance F Int where mes _ = "Int"
instance F Double where mes _ = "Double"

mkDefaultT :: F a => a -> T a
mkDefaultT x = T x (mes x)

defaultT :: (F a, Num a) => T a
defaultT = mkDefaultT 0

-- error!
modifiedT :: T Double
modifiedT = defaultT {element = 0.0}

defaultT は T Int 型のとき typeStr = "Int" で、 T Double 型のとき typeStr = "Double" です。なので、上の modifiedT の定義では defaultT {element = 0.0} の typeStr フィールドが "Int" なのか "Double" なのかが決定できす、曖昧性エラーとなります。

GHCはどこまで賢くやってくれるのか、という問題

上のような事情があるにはあるのですが、例えば以下のようなコードはコンパイルを通ります。

data T a = T {element :: String, typeStr :: String}

defaultT :: T a
defaultT = T "" "a"

-- error!
modifiedT :: T Double
modifiedT = defaultT {element = ""}

このケースでは defaultT にはいかなる制約もかかっていない forall a . T a という型がついており、このような場合は a がどんな型に具体化されようと e の値は変わらず T "" "a" です。
なので、 defaultT の型が曖昧でも modifiedT の定義は上のままでうまくいきます。

では、「型変数がどの型に具体化されても値が同じ」場合につねにうまくいくかというとそういうわけにもいかず、以下のコードはエラーになります。

data T a = T {element :: String, typeStr :: String}

class F a where mes :: a -> String
instance F Int where mes _ = "Int"
instance F Double where mes _ = "Double"

defaultT :: F a => T a
defaultT = T "" "a"

-- error!
modifiedT :: T Double
modifiedT = defaultT {element = ""}

単に defaultT の型に制約を増やしただけですが、これはエラーになります。
これはおそらく「型変数がどの型に具体化されても値が同じ」かどうかは型によってのみ判断されるからです。
forall a . F a => T a の型の値は「型変数 a が何に具体化されても同じ値」とは限らず、例えば

polyT :: F a => T a
polyT = mkPoly undefined
  where
    mkPoly :: F a => a -> T a
    mkPoly x = T "" (mes x)

のような値がありえます。

同じフィールド名に対するレコード更新構文

知ってた人には「今更」って感じなんでしょうが、

data User = Registered {uid :: Int, name :: String} | Guest {name :: String} deriving (Show)

updateName :: User -> String -> User
updateName u n = u {name = n}

-- 動作:
-- *Main> updateName (Registered {uid=0, name="Bob"}) "Alice"
-- Registered {uid = 0, name = "Alice"}
-- *Main> updateName (Guest {name="Alice"}) "Bob"
-- Guest {name = "Bob"}

みたいなの書けたんですね。知りませんでした。*1

*1:というかそもそも同じデータ型の定義中でならフィールド名被っててもいいのだということを初めて知りました。