やわらかテック

業務を通して得られた知見、知見。個人的に試してみたこと、興味のあることについてアウトプットしています。

数学全然わからないけどElixirでApplicativeを作ってみる

前回のあらすじ📖

www.okb-shelf.work

「入門Haskellプログラミング」という書籍を読み続けながら、理解を深めるために自分でFunctorを実装してみました。今回は、Functorに引き続き、Applicativeに挑戦してみます。
先に結論ですが、ElixirApplicativeを作るのは言語の制約上、かなり難しいです。マクロという機能を使えば、似たものには出来ますが、HaskellApplicativeのパワフルさを完全再現することは出来ませんでした。

マクロについてはこちらをどうぞ。
www.okb-shelf.work

簡単におさらい / Functorとは🤔

前回、作ってみたFunctorfmapはコンテナもしくはコンテキスト内の値に対して、関数を適応することが可能でした。Haskellfmapは第1引数に適応させる関数((a -> b))を受け取り、第2引数で受け取った値(f a)に対して、関数を適応した値(f b)を返します。

HaskellでのFunctorfmapの定義

fmap :: (a -> b) -> f a -> f b

最も身近なfmapがリストに対して定義されていました。それはmap関数です。fmapmap関数の定義を見比べてみれば明らかでした。

--  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だけのものではありません。ElixirEnum.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);

hexdocs.pm

developer.mozilla.org

Applicativeの登場

このFunctorの制約をクリアすることが出来るのがApplicativeというクラス(型)です。Applicativeはコンテナ、コンテキスト内(モナドインスタンス)で部分適応された関数を適応させることが出来ます。

hackage.haskell.org

部分適応について知りたい方は過去の記事を読んでみてください。 www.okb-shelf.work

Applicativeには<*>(appと読むそう)という関数が定義されています。この関数の型の定義は以下のようになっています。

class Functor f => Applicative f where
  (<*>) :: f (a -> b) -> f a -> f b

Functorfmapにかなり似ていますね。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

apppureという2つの関数を定義することでApplicativeは2つ以上の引数を持つ関数への処理の適応を可能とします。

いざElixirで作ってみる🧪

...。

最初に記述した通り、Elixirでは言語の制約上、HaskellでのApplicativeと同等のものを作るのは非常に難しいです。なぜならHaskellはデフォルトで関数を部分適応するからです。HaskellApplicativeが強力なのは、この仕様があるからこそです。デフォルトで部分適応がされない以上、自分で部分適応がされるようにカリー化した関数を定義するか、マクロを作成する他、ありません。

今回は簡単のため、カリー化した関数を用いました。引数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

Applicativeapppureに関しては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については簡単のため受け取った値を要素に含めたリスト返すようにしました。実行結果は自明だと思うのです、割愛します。

まとめ📖

ApplicativeFunctorの制約をクリアするために非常に便利なクラス(型)です。
Functorfmapでは引数が1つの関数のみしか適応することが出来ませんが、Applicativeappは、コンテナ、コンテキスト内にある関数に対しても処理を適応することが出来るので、Functorをより強力にしたクラス(型)と言えます。

しかし、ApplicativeHaskellの言語仕様である全ての関数がデフォルトで部分適応されるからこそ、強力です。他言語でApplicativeを再現しようとしても、デフォルトと部分適応されない以上は同等の力を引き出すことは難しく、個別にカリー化した関数などを定義する必要があります。

非常に面白いですね。次は大本命のMonadです。