技術ブログを開設しました。

関数型プログラミング入門~Contravariantについて~

こんにちは。G・B・S第2システム開発部の小路です。

Haskellの入門本には、Functorについての解説は載っているものの、Contravariantについては触れないことが多いので、学習がてら概要をまとめてみようと思います。

Functorについておさらい

Contravariantについて触れる前に、Functorを振り返っておきます。
HaskellにおけるFunctor型クラスの定義は以下のようになっています。

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型クラスの定義は以下のようになっています。

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 (入力の消費者)」と表現されることがあります。)

では、動かしてみましょう。

Main.hs

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の例を見て終わります。

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(入力の消費者)」と表現されたりします。