前回のあらすじ📖
「入門Haskellプログラミング」という書籍を読み続けながら、理解を深めるために自分でFunctor
を実装してみました。今回は、Functor
に引き続き、Applicative
に挑戦してみます。
先に結論ですが、Elixir
でApplicative
を作るのは言語の制約上、かなり難しいです。マクロという機能を使えば、似たものには出来ますが、Haskell
のApplicative
のパワフルさを完全再現することは出来ませんでした。
マクロについてはこちらをどうぞ。
簡単におさらい / Functorとは🤔
前回、作ってみたFunctor
のfmap
はコンテナもしくはコンテキスト内の値に対して、関数を適応することが可能でした。Haskell
のfmap
は第1引数に適応させる関数((a -> b)
)を受け取り、第2引数で受け取った値(f a
)に対して、関数を適応した値(f b
)を返します。
Haskell
でのFunctor
のfmap
の定義
fmap :: (a -> b) -> f a -> f b
最も身近なfmap
がリストに対して定義されていました。それはmap
関数です。fmap
とmap
関数の定義を見比べてみれば明らかでした。
-- Functor [] fmap :: (a -> b) -> [a] -> [b] -- Data.List map map :: (a -> b) -> [a] -> [b]
Functorの制約⛓
しかしFunctor
はある制約を抱えています。それは適応する関数の引数が1つの場合にしか対応出来ないという点です。例を見てみましょう。
受け取った数値に対して、m
分だけ加算をする単純なaddN
という関数を定義したので、この関数をfmap
を使って、1から10の値を持つリストに適応してみます。ただし、この関数は引数を2つ受け取る必要があることに注目です。
addN :: Int -> Int -> Int addN n m = n + m applyAddN :: [Int] applyAddN = fmap addN [1 .. 10]
すると以下のようなエラーが発生するためコンパイルをすることが出来ません。
• Couldn't match type ‘Int -> Int’ with ‘Int’ Expected type: [Int] Actual type: [Int -> Int] • In the expression: fmap addN [1 .. 10] In an equation for ‘applyAddN’: applyAddN = fmap addN [1 .. 10]
fmap
の第1引数の定義が(a -> b)
となっているので当然ですが、引数が1つの関数以外は受け付けません。引数が1つの関数として定義してあげれば、このエラーは発生しなくなります。
add10 :: Int -> Int add10 n = n + 10 applyAdd10 :: [Int] applyAdd10 = fmap add10 [1 .. 10] -- [11,12,13,14,15,16,17,18,19,20]
この制約はHaskell
だけのものではありません。Elixir
のEnum.map
関数でも、同じ制約がなされています。JavaScript
は適応する関数の引数が可変で1, 2, 3の場合があるようですが、任意の値を渡すことが出来ないという視点では同様の制約があると判断出来ます。
map(t(), (element() -> any())) :: list() applyAdd10 = [1,2,3,4,5,6,7,8,9,10] |> Enum.map(fn n -> n + 10 end)
const applyAdd10 = [1,2,3,4,5,6,7,8,9,10].map(n => n + 10);
Applicativeの登場
このFunctor
の制約をクリアすることが出来るのがApplicative
というクラス(型)です。Applicative
はコンテナ、コンテキスト内(モナドのインスタンス)で部分適応された関数を適応させることが出来ます。
部分適応について知りたい方は過去の記事を読んでみてください。 www.okb-shelf.work
Applicative
には<*>
(app
と読むそう)という関数が定義されています。この関数の型の定義は以下のようになっています。
class Functor f => Applicative f where (<*>) :: f (a -> b) -> f a -> f b
Functor
のfmap
にかなり似ていますね。2つを比べてみます。
fmap :: (a -> b) -> [a] -> [b] (<*>) :: f (a -> b) -> f a -> f b
異なるのが、第1引数です。fmap
はコンテナ、コンテキスト外(モナドではない)の関数((a -> b)
)を引数に受けとりますが、app
はコンテナ、コンテキスト内(モナドのインスタンス)にある関数(f (a -> b)
)を引数に受け取ります。
コンテナ、コンテキスト内にある関数を引数に受け取れるというのは、コンテナ、コンテキスト内で関数を部分適応した場合に、その関数をそのまま受け取って、要素に対して適応が出来るということです。
いざApplicative
言葉で説明、理解するのは難しいので例を見ていきます。適応させる関数は先ほど挫折した、addN
関数です。この関数は2つの引数を受け取ります。
addN :: Int -> Int -> Int addN n m = n + m
以下が先ほどエラーとなった記述です。
applyAddN :: [Int] applyAddN = fmap addN [1 .. 10]
ここにapp(<*>)
を付け加えます。
applyAddN :: [Int] applyAddN = fmap addN [1 .. 10] <*> (pure 10)
これでエラーは発生せず、思った通りの値が返ってくるのですが、何やら見慣れない状態になってしまいました。
しかし順に値を追っていけば、そう難しいものではありません。まずfmap addN [1 .. 10]
が返す値について考えてみます。fmap
の定義fmap :: (a -> b) -> f a -> f b
より、返ってくる値はコンテナ、コンテキスト内に存在するb
の値です。
Haskell
ではデフォルトで全ての関数が部分適応されるので、addN
に対して、第1引数だけを受け渡して部分適応された関数がコンテナ、コンテキスト内でb
として返っています。
fmap addN [1 .. 10]
という呼び出しでエラーになっていないことからも明確です。
*Main> applyAddN = fmap addN [1 .. 10] *Main> :t applyAddN applyAddN :: (Num a, Enum a) => [a -> a]
現在、コンテナ、コンテキスト内に部分適応された関数が返っている状態です。もうお分かりですね。コンテナ、コンテキスト内の関数を引数に受け取り、処理を適応するのはapp(<*>)
の役目です。
applyAddN :: [Int] applyAddN = fmap addN [1 .. 10] <*> (pure 10)
先程の定義通りになりました。最後にpure
という構文について簡単に説明しておきます。Applicative
にはapp
の他にpure
が定義されています。
class Functor f => Applicative f where pure :: a -> f a
定義を見てわかるように、受け取った値をApplicative
のクラス(型)にするというだけのシンプルな関数です。要するにコンテナ、コンテキスト外の値をコンテナ、コンテキスト内に引きずり込むための関数です。addN
の第2引数に渡したい10
はそのままだとInt
型の値なので、app
の戻り値エラーになってしまいます。
applyAddN :: [Int] applyAddN = fmap addN [1 .. 10] <*> 10 • No instance for (Num [Int]) arising from the literal ‘10’ • In the second argument of ‘(<*>)’, namely ‘10’ In the expression: fmap addN [1 .. 10] <*> 10 In an equation for ‘applyAddN’: applyAddN = fmap addN [1 .. 10] <*> 10
app
とpure
という2つの関数を定義することでApplicative
は2つ以上の引数を持つ関数への処理の適応を可能とします。
いざElixirで作ってみる🧪
...。
最初に記述した通り、Elixir
では言語の制約上、Haskell
でのApplicative
と同等のものを作るのは非常に難しいです。なぜならHaskell
はデフォルトで関数を部分適応するからです。Haskell
のApplicative
が強力なのは、この仕様があるからこそです。デフォルトで部分適応がされない以上、自分で部分適応がされるようにカリー化した関数を定義するか、マクロを作成する他、ありません。
今回は簡単のため、カリー化した関数を用いました。引数2つの状態のままの関数では再現が出来ませんでした。
defmodule Applicative do def app(func, v), do: func.(v) def pure(v), do: [v] # 一例としてListに対しての定義 end defmodule Func do def addN(n), do: fn m -> n + m end end
Applicative
のapp
とpure
に関してはHaskell
に定義されていた引数に近しい状態に出来ました。適応させる関数は先程も登場したaddN
ですが、上記にもあるようにカリー化することでしか再現することが出来なかったので、元々、引数2つで定義していたものをカリー化して引数1つの関数を返すようにしています。
addN :: Int -> Int -> Int addN n m = n + m
def addN(n), do: fn m -> n + m end
実行してみます。
Enum.to_list(1..10) |> Enum.map(fn n -> Func.addN(n) end) |> Enum.map(fn func -> Applicative.app(func, 10) end) |> IO.inspect() # [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
重要なのは、Enum.map
の中で、Func.addN(n)
を呼び出した後の戻り値です。以下のように出力されます。この時点で、カリー化した関数が返り部分適応されていることが分かります。
Enum.to_list(1..10) |> Enum.map(fn n -> Func.addN(n) end) |> IO.inspect() # [#Function<0.17272158/1 in Func.addN/1>, #Function<0.17272158/1 in Func.addN/1>, # : # : # #Function<0.17272158/1 in Func.addN/1>, #Function<0.17272158/1 in Func.addN/1>]
そして、app
によって部分適応された関数を適応するだけです。
pure
については簡単のため受け取った値を要素に含めたリスト返すようにしました。実行結果は自明だと思うのです、割愛します。
まとめ📖
Applicative
はFunctor
の制約をクリアするために非常に便利なクラス(型)です。
Functor
のfmap
では引数が1つの関数のみしか適応することが出来ませんが、Applicative
のapp
は、コンテナ、コンテキスト内にある関数に対しても処理を適応することが出来るので、Functor
をより強力にしたクラス(型)と言えます。
しかし、Applicative
はHaskell
の言語仕様である全ての関数がデフォルトで部分適応されるからこそ、強力です。他言語でApplicative
を再現しようとしても、デフォルトと部分適応されない以上は同等の力を引き出すことは難しく、個別にカリー化した関数などを定義する必要があります。
非常に面白いですね。次は大本命のMonad
です。