Haskellの入門本には、Functorについての解説は載っているものの、Contravariantについては触れないことが多いので、学習がてら概要をまとめてみようと思います。
Functorについておさらい
Contravariantについて触れる前に、Functorを振り返っておきます。
HaskellにおけるFunctor型クラスの定義は以下のようになっています。
class Functor (f :: * -> *) where
fmap :: (a -> b) -> f a -> f b
fmapを使用することによって、通常の関数をFunctorインスタンスの文脈に持ち上げることができました。
fmapの型注釈から読み取れるのは、第1引数の「(a -> b)」と第2引数の「f a」において、型変数aの型は同じでなければならないということです。
コンパイル可能なfmapの使用例を見てみます。例として、Functor型クラスのインスタンスは((->) r)を使います。
((->) r)のfmapの定義は(.)と等しいです。つまり関数合成です。
-- instance Functor ((->) r) where
-- fmap = (.)
intToString :: Int -> String
intToString = show
doubleToInt :: Double -> Int
doubleToInt = floor
main :: IO ()
main = do
xs <- return $ f 100.0
print xs
where
f :: Double -> String
f = intToString <$> doubleToInt -- fmap intToString doubleToInt
"100"
期待通りの結果です。
では、次はコンパイルできない例を見てみます。
import Data.Ratio
main :: IO ()
main = do
xs <- return $ f (100 % 3)
print xs
where
f :: Double -> String
f = intToString <$> doubleToInt
これがコンパイルできないのは、関数fの第1引数はDouble型の値を要求するにも関わらず、Ratio Integer型の値に適用してしまっているからです。
• Couldn't match expected type ‘Double’
with actual type ‘Ratio Integer’
• In the first argument of ‘f’, namely ‘(100 % 3)’
In the second argument of ‘($)’, namely ‘f (100 % 3)’
In a stmt of a 'do' block: xs <- return $ f (100 % 3)
(以下略)
fmapでは、通常の関数fをFunctor型クラスのインスタンスに適用することにより、最終的に得られる型の値を任意のFunctor型クラスのインスタンスの値に持ち上げることができました。
(このことから、fmapは「producer of output (出力の生産者)」と表現されることがあります。)
しかし、fmapで「出力」の型を変化させることができますが、「入力」の型を変えることはできません。
「入力」の型を変える一般化された計算の概念はないのでしょうか。そんな時に登場するのがContravariantです。
Contravariant
Contravariant型クラスの定義は以下のようになっています。
class Contravariant (f :: * -> *) where
contramap :: (b -> a) -> f a -> f b
contramapはfmapの型注釈とよく似た形をしていますが、第1引数が(b -> a)となっています。
この(b -> a)は、型変数bの型から型変数aの型へ変換する方法を示しているとも読めます。
contramapを、型bから型aへの変換方法と、中身に型aの値を持つContravariant型クラスのインスタンスに適用することで、型bの値を持つContravariant型クラスのインスタンスが得られます。
この手の概念を理解する手っ取り早い方法は具体例を見ることです。ということで、Contravariantの使用例を見てみましょう。
と、その前に、新たな型を定義しておきます。
newtype Op z a = Op { getOp :: a -> z }
以下のコードはHaskellではコンパイルできませんので、newtypeとして型変数の順序を逆にしたラッパー型Opを作成しました。
instance Contravariant (-> r) where -- これはコンパイルエラー
これをContravariant型クラスのインスタンスにします。
instance Contravariant (Op z) where
-- contramap :: (b -> a) -> Op z a -> Op z b
contramap f (Op g) = Op (g . f)
では、実際に使用例を見てみます。
まず、先ほどの(Double -> String)型の関数をOp型の値として持たせ、contramapの適用対象にします。
f :: Double -> String
f = intToString <$> doubleToInt
opF :: Op String Double
opF = Op f
opFの型はOp String Doubleです。
contramapとopFを使用して、Op String Rational型の値を得る例を見てみましょう。
rationalToDouble :: Rational -> Double
rationalToDouble = fromRational
g :: Op String Rational -- getOpでアンラップすると、(Rational -> String)型の関数が得られる
g = contramap rationalToDouble opF -- rationalToDouble >$< opF
Op String Rationalをアンラップすると、(Rational -> String)型の関数が得られます。
ここで、関数fは元々の定義から変更を加えていません。contramapを使うことにより入力の型をDoubleからRationalに変更することができました。
(Contravariantは「consumer of input (入力の消費者)」と表現されることがあります。)
では、動かしてみましょう。
import Data.Ratio
import Data.Functor.Contravariant hiding (Op, getOp)
newtype Op z a = Op { getOp :: (a -> z) }
instance Contravariant (Op z) where
contramap f (Op g) = Op (g . f)
intToString :: Int -> String
intToString = show
doubleToInt :: Double -> Int
doubleToInt = floor
rationalToDouble :: Rational -> Double
rationalToDouble = fromRational
f :: Double -> String
f = intToString <$> doubleToInt
opF :: Op String Double
opF = Op f
g :: Op String Rational -- getOpでアンラップすると、(Rational -> String)型の関数が得られる
g = contramap rationalToDouble opF -- rationalToDouble >$< opF
main :: IO ()
main = do
xs <- return $ f 100.0
print xs
xs' <- return $ getOp g (100 % 3)
print xs'
"100"
"33"
期待通りです。
Op型はcontramapの使用方法を学ぶ上で(個人的に)分かりやすいと思ったので、こちらを使用しましたが、お気付きの通り、上記の例はわざわざOp String Rationalにしなくても、通常の関数合成で目的は達成できます。
g' :: Rational -> String
g' = f . fromRational
なんにせよ、contramapの使い方のイメージはつかめたのではないでしょうか。
Op以外にもContravariant型クラスのインスタンスは他にも色々あります。
そろそろ力尽きてきたので、Contravariant型クラスのインスタンスの一つであるEquivalenceの例を見て終わります。
newtype Equivalence a = Equivalence { getEquivalence :: a -> a -> Bool }
instance Contravariant Equivalence where
contramap f g = Equivalence $ on (getEquivalence g) f
-- (==)を使って等値判定を行うEquivalence型の値
defaultEquivalence :: Eq a => Equivalence a
defaultEquivalence = Equivalence (==)
通常のタプルの等値判定では、第1要素と第2要素の両方の等値判定を行いますが、
contramapを使って、1つの要素だけに対して、等値判定を行うようにする例を見てみます。
import Data.Functor.Contravariant
-- (>$<)はcontramapの中置演算子版
fstEquivalence :: Eq a => Equivalence (a, b)
fstEquivalence = fst >$< defaultEquivalence -- contramap fst defaultEquivalence
sndEquivalence :: Equivalence (a, String)
sndEquivalence = snd >$< defaultEquivalence
sndEquivalence' :: Equivalence (a, String)
sndEquivalence' = (show . convertString2Int . snd) >$< defaultEquivalence
convertString2Int :: String -> Int
convertString2Int xs
| xs == "1" = 1
| xs == "one" = 1
| xs == "2" = 2
| xs == "two" = 2
| otherwise = 0
main :: IO ()
main = do
print $ getEquivalence defaultEquivalence (1, 2) (1, 2)
print $ getEquivalence defaultEquivalence (1, 2) (1, 3)
print $ getEquivalence fstEquivalence (1, 2) (1, 3)
print $ getEquivalence fstEquivalence (2, 2) (1, 3)
print $ getEquivalence sndEquivalence (1, "1") (2, "one")
print $ getEquivalence sndEquivalence (1, "2") (1, "two")
print $ getEquivalence sndEquivalence' (1, "1") (2, "one")
print $ getEquivalence sndEquivalence' (1, "2") (2, "two")
True
False
True
False
False
False
True
True
contramapでは、型が合えば、入力の型を消費する方法(型bから型aへの変換)は問うていません。
sndEquivalence、sndEquivalence'ともに型注釈はEquivalence (a, String)となっていますが、
出力結果は異なるものとなっていることに注目しましょう。
まとめ
-
contramapを(b -> a)(第1引数)とf a(第2引数)に適用すると、f bの型の値が得られます。これはContravariant型クラスのインスタンスfに対する入力の型をaからbに変換していると見ることができます。
-
**contramapの第1引数(b -> a)では、型bから型aに変換する方法は制限していません。**なので、上記のsndEquivalence、sndEquivalence'のように、同一のタプル値を入力に与えても、出力結果は異なるものとなり得ます。
-
上記のような性質から、Contravariantは「consumer of input(入力の消費者)」と表現されたりします。