|>(パイプ)と>>(関数合成)の違い(Elm)


はじめに

こんにちは!

Elmを書いたことのある人なら誰しもパイプ演算子を使って以下のように関数を合成しようとしたことありませんか?ありますよね!(食い気味) 
私はありました!

initHoge:Hoge
initHoge =
   { stringToBool = stringToInt |> intToBool
   }

もちろん,こちらはエラーになります。

これはそもそもパイプ演算子(|>)と関数合成(>>)の違いをちゃんと理解していなかったことが原因でした。
なのでこの記事を通してパイプ演算子と関数合成の違いについてのみなさんの理解が少しでも深まれば!と思います!

結論を端的に工場の流れ作業で表すと

関数を作業員
パイプ演算子をベルトコンベア
値を部品
に例えると以下のように表せます。(ちゃんと自分で描いてますよ!)

パイプ演算子

関数合成

自分が起こしたエラー(パイプで関数を繋げた場合)

もうわかりましたね!!

え?わからない?!

では一体どういうことなのか、自分のした勘違いを紹介しつつ説明します。

自分のした勘違い

ある日、以下のように関数をフィールドに持つレコード(Hoge)を初期化する関数(initHoge)を作る必要がありました。

※stringToBool はここでは、何かの文字列を受け取って、Bool値を返す関数とします.
また、この時点でStringをIntにする関数(stringToInt)IntをBoolにする関数(intToBool)はすでに別の用途で使われて定義されていたとします。

type alias Hoge =
    { stringToBool : String -> Bool
    }

stringToInt: String -> Int
stringToInt = -- 実装はよしなに

intToBool:Int -> Bool
intToBool = -- 実装はよしなに

initHoge:Hoge
initHoge =
   { stringToBool = -- 定義したい
   }

ワイ :あーはい、はい。なるほどね。
じゃぁ、StringをIntにする関数(stringToInt)IntをBoolにする関数(intToBool)を合体させれば楽勝でしょ!
関数をつなげる演算子といえばこれ, |>(パイプ) に限りますよ!

initHoge:Hoge
initHoge =
   { stringToBool = stringToInt |> intToBool
   }

終わり! 楽勝!!

エラー

え!どうして???

しかしこれを >> (関数合成)を使って記述すると、

initHoge:Hoge
initHoge =
   { stringToBool = stringToInt >> intToBool
   }
エラーなし

ばんなそがな!
一体なぜ!!!??

だって, |>って普段以下みたいな使われ方をしていて,エラーが起きないじゃん。
これってつまり、|>で繋がれた stringToInt |> intToBoolString → Boolな関数を形成していたってことじゃなかったの???

"1" -- (:String)
 |> stringToInt --( String -> Int な関数 実装は省略)
 |> intToBool --( Int -> Bool な関数 実装は省略)
    -- -> True(:Bool)

なぜ|>では関数を合成できないのか

Elmで何かが期待と違う動きをしたら,
とにかく型定義をみる!カタはウソつかない!

|>の型定義を振り返る

(|>) : a -> (a -> b) -> b

出典: https://package.elm-lang.org/packages/elm/core/latest/Basics#|

ん?
あれ?これって型定義(a→b)の関数に引数aを渡してるだけでは??

そう,そもそもパイプは本来関数の後ろに書くべき引数を前に書くことを許容して,
複数の関数を使うときに()の入れ子を避けるために使われる演算子なのだ!(pipeってそういうもんでしょといわれればそう)

-- |>を使わないとき
func5 (func4 (func3 (func2 (func1 arg)))

-- |>をつかうと以下のようにできる
arg
 |> func1
 |> func2
 |> func3
 |> func4
 |> func5

つまり,もし引数の必要な関数同士を|>で繋いだら,

"関数""その関数を引数に取る関数"を繋がないと型定義違反になる!

なぜなら func1|>func2func2 func1 と同じだから! (func2はfunc1を引数に取る関数)

今回の自分の定義を見直す

ここで今回の自分の定義がなぜダメだったのか見直す.

自分が行った定義

stringToBool : String -> Bool
stringToBool = stringToInt |> intToBool

|>の左辺は stringToInt : String -> Int

ここで、もう一度|>の型定義を思い出す。

(|>) : a -> (a -> b) -> b

これを踏まえると、

|>の右辺、つまり第二引数は((String → Int ) → Bool ) な関数でないと型が合わない.(a == (String → Int )だから)

型で表すとこう.これが|>の求めているもの。

(String -> Int ) -> ((String -> Int ) -> Bool ) -> Bool

しかし実際に右辺に渡されているのは((String → Int ) → Bool ) な関数ではなくintToBool:Int→Bool

だからtoBoolean : String -> Bool の定義として stringToInt |> intToBoolは成り立たない。

以下のようにしたときに同様のエラーが起こらないのは,

"1" -- (:String)
 |> stringToInt --( String -> Int な関数 実装は省略)
 |> intToBool --( Int -> Bool な関数 実装は省略)
    -- -> True(:Bool)

"1" |> stringToInt |> intToBool としたときに,左から演算が行われていき,intToBoolの演算が行われるときには1|> intToBoolとなってすでに|>の左辺が関数ではなくIntに変わっているため!

|>のまとめ

結局のところ,

|>"その左辺を右辺に引数として渡す演算子"に過ぎないため,

"左辺の関数の出力の型""右辺の関数の入力の型"が一致していても

左辺が引数を渡されていないまだ関数の状態のままで両者を繋ぐと,

値を期待する右辺の関数に対して,関数を渡すことになるため型が一致せずエラーになる.

したがって,|>で複数の関数の処理を繋ぐ際は引数が必須であることがわかる.

だから|>でも以下のように書けば,複数の関数の処理をまとめた関数を作ることはできる.

stringToBool : String -> Bool
stringToBool str = 
   str
     |> stringToInt
     |> intToBool

え、文字だらけでわからない?
ここで序盤で登場した絵の出番。

関数を作業員
パイプ演算子をベルトコンベア
値を部品
とすると

正常な時

今回自分がやろうとしたこと(エラー)の再掲

ここまでで,なぜ今回|>で関数合成をしようとしたときにエラーが起きたのかわかったと思います。

じゃぁ>>って要るの??なにしてんの?となるので以下で>>ができることを紹介します。

>>(関数合成)の役割

まずは型定義から!

>>の型定義

>>の型を見てみる

(>>) : (a -> b) -> (b -> c) -> a -> c

これはつまり,

(>>) : (a -> b) -> (b -> c) -> (a -> c)

ということ!

したがって,>>を使うと,

"左辺の関数の出力の型(b)""右辺の関数の入力の型(b)"が一致していれば,

定義の時点で左辺の関数に引数aが渡されていなくても,処理を繋げて書くことができる

だから|>では以下のように書いていた処理も

stringToBool : String -> Bool
stringToBool str = 
    str
     |> stringToInt
     |> intToBool

>>なら次のように引数なしで書ける.

stringToBool : String -> Bool
stringToBool = stringToInt >> intToBool 

また文字だけでわかりづらい?
安心してください。ちゃんと絵があります。

>>のイメージ

「でもあんまり、>>って見かけないような。。どこで使えるんだ?」と思ったそこのあなた
いくつかの使い所を紹介します。

>>の使いどころ

まずは記事の冒頭の例のように引数を渡されることはなく定義だけを要求される場合に,

>>ではワンラインで定義ができます。

|>であれば init関数の外側,あるいはletinでstringToBool を引数付きで定義しないといけません。

type alias Sample = {
        stringToBool : String -> Bool
    }

init :Sample
init = {
    stringToBool = stringToInt >> intToBool
}

{-
以下が定義されてる前提
 stringToInt:String -> Int
 intToBool:Int -> Bool
-}

そのほかには以下のような記述が

List.map (\x -> x|> (+) 2 |> String.fromInt) [1,2,3,4] -- ["3","4","5","6"]

次のように書けたりします。

List.map ((+) 2 >> String.fromInt) [1,2,3,4] --["3","4","5","6"]

いずれにせよ|>で代替可能であり,>>でないと実現できない実装というものはなさそうではあります。(もし記事を見た方でこんな使い方もあるよ!という方がいればぜひコメント欄にお願いします!)

結論:

|>と>>の違い

|>は引数と関数を繋ぐもの!

>>は関数同士を繋いで巨大な関数をつくるもの!

別の言い方をすると、
前者は処理の流れを作るもの,後者は流れの中の処理自体を改造するものであり,そもそも目的が違う.

もし|>>>の真似事をするには引数が必要!

パイプ演算子のイメージ

関数合成のイメージ

となります。
ここまで長々とお付き合いありがとうございました。コメント、指摘お待ちしております。

参考