ペンギン村 Tech Blog

技術をこよなく愛するエンジニア集団が在住するペンギン村から、世界へ役立つ(かもしれない)技術情報を発信する技術系ブログです。某アラレちゃんが済む村とは一切関係ありません。んちゃ!

4. リストと演算子と関数合成 :: Swift プログラマのための Haskell 入門

さて、前回は Haskell におけるデータ型の宣言やパターンマッチについて見てきました。

今回は Swift でよくある配列操作について、Haskell の場合にどのような書き方になるのか見ていきます。

リスト

Swift で同じ型が繰り返すデータ構造として配列(Array)が用意されています。数値の配列から偶数のみを抜き取り、それを2倍して、それらの合計を計算するコードは、みんな大好きな filtermapreduce を使って以下のように書けます。

let isOdd: (Int) -> Bool = { $0 % 2 == 0 } // 偶数か判定する関数

let xs: [Int] = [1, 2, 3, 4, 5]

xs.filter(isOdd).map { x in x * 2 }.reduce(0, +) // => 12

Haskell ではリスト(単方向リスト)が用意されており、次のように記述できます。

isOdd :: Int -> Bool
isOdd x = x `mod` 2 == 0 -- mod関数は余りを計算するもの

xs :: [Int]
xs = [1, 2, 3, 4, 5]

foldl (+) 0 (map (\x -> x * 2) (filter isOdd xs)) -- => 12

これまでよりコードの差が大きいのでパッとは読み取れないかもしれません。比較すると以下のような違いがあるのが分かります。

  • 関数呼び出しになるので、右から左に向かって処理が進む。
  • reduce の代わりに foldl という関数を使用している。
  • foldl は第1引数と第2引数の順番が reduce と逆。
  • クロージャの記法は { 引数 in 式 } ではなく \引数 -> 式 という表記になっている。

慣れないうちは非常に読みづらいかと思いますが、右側から順に

  1. filter isOdd ... で絞り込んで、
  2. map (\x -> x * 2) ... でフィルタして、
  3. foldl (+) 0 ... で畳み込み1

と対応関係を見ていけば、処理自体は難しくないことに気づきます。

なお、map に渡している関数式は、Swift との対比のためにクロージャと表現しましたが、Haskell ではラムダ式と呼ばれています。

ところで、ここでは変数名に xs という名前を使用しています。これは x が複数あるから xs(item -> items)という命名なのですが、これは Haskell において非常によく使われる命名パターンです。さらにリストのリスト(Swift では配列の配列)のような場合は xss という命名が使用されます。これを覚えておくと、Haskell のコードを読みやすくなるでしょう。

関数と演算子

さて、先ほどのコードでは Swift / Haskell ともに畳み込み用の関数として演算子 + を渡していました。

// Swift
xs.reduce(0, +)
-- Haskell
foldl (+) 0 xs

実は Haskell の演算子は Swift と同じように、2つの引数をとる単なる関数として実装されています。すなわち、型としては次のようになっていると考えることが出来ます。

(+) :: Int -> Int -> Int -- 実際にはより汎用的な実装になっている 

演算子が単なる関数であるならば、通常の関数のように add 1 2 のように呼び出すことは可能なのでしょうか?

実は Swift も Haskell も演算子を () で囲むことで通常の関数のように使用できます(Swift でこのような書き方が出来るということは案外知らない方が多いのではないでしょうか?)。

// Swift
(+)(1, 2) // => 3
-- Haskell
(+) 1 2 -- => 3

どちらも通常の関数のように使用できているのが分かりますね。

関数を演算子として使用する

さて、演算子が単なる関数であるならば、2引数を取る関数を演算子として使用できても良いのではないでしょうか?

実はそれを行っているのが isOdd 関数内で使用している mod 関数です。

isOdd x = x `mod` 2 == 0

このように ` で2引数の関数を囲むことで、演算子のように中置記法で書くことができます。 これまでの記事でadd` 関数が度々登場してきましたが、以下のように中置演算子として使用できます。

add :: Int -> Int -> Int
add x y = f x y

1 `add` 2 -- `add 1 2` と等価

このように(二項)演算子の実態は単なる(2引数の)関数であり、関数でも演算子のように振る舞えることが分かります。

セクション記法(演算子の部分適用)

さて、演算子が単なる関数であるならば、演算子も部分適用が可能であると考えることができます。

例えば、以下のようなことが可能です。

plusOne :: Int -> Int
plusOne = (1 +)

plusOne 2 -- 3

add 関数を用いて部分適用を解説したときと同じように、+ 演算子の左辺(第1引数)を 1 で埋めることで、新しく(残った右辺を引数として受け取る) Int -> Int 型の関数 plusOne を定義しています。 このように演算子を部分適用する書き方をセクション記法と呼んだりします。

このセクション記法を利用すると、リスト操作の map 部分をよりスマートに記述できます。

-- Before
foldl (+) 0 (map (\x -> x * 2) (filter isOdd xs))

-- After
foldl (+) 0 (map (* 2) (filter isOdd xs))

map に渡している関数は Int を受け取って Int を返す関数です。そのことはラムダ式の \x -> x * 2 からも明白です。 それならば * 2 として、 * 演算子の右辺を 2 で埋めて部分適用した * 2 を渡しても同等であることが分かります。

部分適用に慣れていないうちは、こうしたコードの書き方は分かりづらいと感じるかもしれません。しかし、関数型プログラミングに慣れれば慣れるほど、部分適用が当たり前という思考に変わっていくと思います。

ところで、Swift で同じように書けるかというと実はできません。

xs.filter(isOdd).map { * 2 }.reduce(0, +) // コンパイルエラー

コンパイルエラーの内容自体は * が単項演算子として使用されているのに、実際には単項演算子ではないという内容なのですが、その本質には Swift の関数はカリー化されていないため部分適用できないという事実があります。reduce では + をそのまま渡せていますが、これは元々2引数を受け取る関数を要求されているためですね。

演算子 $

さて、演算子も部分適用できるという事実により、以下のようによりスマートなコードになりました。

foldl (+) 0 (map (* 2) (filter isOdd xs))

しかし、() がネストされていて、なんだか読みづらいと感じるかもしれません。

そのようなときに便利なのが $ という演算子です。$ を利用すると、以下のように () のネストを減らすことができます。

-- Before
foldl (+) 0 (map (* 2) (filter isOdd xs))

-- After
foldl (+) 0 $ map (* 2) $ filter isOdd xs

イメージとしては $( として捉えて、対応する ) が式の一番右側にあると考えると分かりやすいでしょう(ちょうど式の一番右側にある ) が無くなっていますね?)。

以下のように下線を引くと少し分かりやすいでしょうか?

f:id:yu_dotnet2004:20210305201912p:plain

これだけ見ると、$ は Haskell に事前に用意された非常に特別な演算子のように感じます。しかし、$ も単なる関数(演算子)として実装されているため自前で定義することも可能です。

$ 演算子がどのように機能するかの説明は後日に回したいと思いますが、とりあえず () のネストが邪魔だと感じたときに使えると覚えておくとよいでしょう。

関数合成

さて、Haskell では関数がたくさん登場するわけですが、それらを組み合わせる方法として関数合成というものが存在します。

関数合成は2つの関数を張り合わせるもので、ある関数 f返り値の型(出力)と、ある関数 g引数の型(入力)が一致すれば、それらは結合して一つの関数にできるという考え方です。RxSwift や Combine を触ったことのある方であれば、ストリームの合成と似たようなものだと考えることができます。

文章ではややこしいので、例として引数に与えられた数値が何桁かを数える処理を考えてみます。

数値を文字列にする関数 show と、文字列の長さを返す関数 length があった場合、Swift では以下のように実装できます。

// 数値を文字列にする関数
func show(_ x: Int) -> String {
    "\(x)"
}

// 文字列の桁数を返す関数
func length(_ s: String) -> Int {
    s.count
}

length(show(462)) // => 3

ここでは説明のため、あえて関数を自前で実装しています(普通に書くのであれば x.description.count などのように書けますね)。

Haskell では以下のようになります。

show :: Int -> String 
show = ... -- 省略

length :: String -> Int 
length = ... -- 省略

length (show 462) -- => 3

さて、ここで注目したいのは show の返り値(出力)と、length の引数(入力)が、共に String 型で一致していることです。すなわち showlength のようにパイプを繋げることができると考えます。

Swift では標準で関数合成の仕組みは用意されていませんが、自分で作ることも出来ます。関数合成用の compose 関数を作成してみたいと思います。

func compose<A, B, C>(
    _ g: @escaping (B) -> C,
    _ f: @escaping (A) -> B
) -> (A) -> C {
    { x in g(f(x)) }
}

let f: (Int) -> Int = compose(length, show)

compose の実装がややこしく見えるかもしれませんが、一つずつ意味を理解しながら読むと難しいことはやっていないことが分かります。そして、重要なのは、

  1. compose(length, show) によって、
  2. show: (Int) -> String してから、
  3. length: (String) -> Int する、
  4. 新たな関数 f: (Int) -> Int を作り出せているということです。

このように2つの関数を張り合わせることを関数合成と言ったりします。

Haskell では関数合成のために標準で演算子 . が用意されており、次のようにシンプルに記述できます。

f :: Int -> Int
f = length . show

明示的な引数が無くなって奇妙に見えるかも知れませんが、f は 0個の引数を受け取り(つまり引数を受け取らず)、単なる Int -> Int な関数を返す、と読むと分かりやすいかもしれません。カリー化と部分適用のところでも触れましたが、すべての関数がカリー化されている Haskell において、引数の数という概念はあって無いようなものなのです。

さて話を戻しますと、Haskell では . で関数合成が行えるのでした。これは一般的に、

  • g(f(x)) という式を (g . f) x
  • h(g(f(x))) という式を (h . g . f) x

と書き直せると考えることができます。

初見だと単に () の数が減って読みやすくなっただけに感じるかもしれませんが、関数が主役である関数型言語においては、関数を部品として大きなプログラムを作り上げる上で非常に重要な概念となっています。

なお、Haskell の関数合成は引数が1つのものに限定されています。これは大きな制限に感じるかもしれませんが、Haskell の関数は部分適用によって引数を減らすことができるため、実際には問題になりません。

リスト処理を関数合成で書き直す

せっかく関数合成を覚えたので、$ を使ったリスト処理のコードを書き直してみましょう。

-- Before
foldl (+) 0 $ map (* 2) $ filter isOdd xs

-- After
(foldl (+) 0 . map (* 2) . filter isOdd) xs

合成した関数を () で囲むのが面倒であるならば $ を併用して、次のように書くことも出来ます。

foldl (+) 0 . map (* 2) . filter isOdd $ xs

これは単にシンタックスが変わっただけのように感じるかもしれませんが、実際にはコードの読み方が大きく変化していることに注意してください。

関数適用によるコードは、

  1. リスト xsfilter isOdd でフィルタして、
  2. その結果を map (* 2) で変換して、
  3. さらにその結果を foldl (+) 0 で畳み込む

という読み方でした。

それに比べて関数合成によるコードは、

  1. filter isOdd して map (*2) して foldl (+) 0 する関数を用意し、
  2. それを xs に適用する

という読み方になります。

関数を一種のパイプとみなすと、関数適用によるコードは3つのパイプを順に通していくもので、関数合成によるコードは事前に3つのパイプを結合した長いパイプに通すものと考えると分かりやすいかもしれません。

そのように考えると関数適用によるコードは毎回パイプの結果値を取り出して、次のパイプの入り口にを流すという作業をやっていることが分かります。一方で、関数合成によるコードは連結された長いパイプを通っている時は値を確認していない、すなわち外部から見えない状態で流れていると考えることが出来ます。

関数に流れるのは当然ながら値ですが、このように関数合成によって値を外部から見えないようなコードでプログラムを記述することをポイントフリースタイルと言います。ここでいう”ポイント”とはのことで、それを意識しない(=フリー)ためにポイントフリースタイルと呼ばれているようです。

ちなみにこれは余談ですが、ポイントフリースタイルは圏論的なプログラミングスタイルとも考えることが出来ます。圏論では具体的な値に着目しないためですね(ちょっと端折り過ぎな気もしますが、まぁ余談ですので)。

どうやって読んだらいいの?

ここまでで、関数適用、$ 演算子、関数合成、という主に3つの概念が出てきたことになります。

これらは個別に考えると、それほど難しく感じないかもしれませんが、実際に自分で Haskell コードを記述しようとすると、慣れないうちは思った以上に混乱させられると思います。ここで個人的なコードの読み方のコツ的なものを書いてみたいと思います。

まず、意識するべきは関数適用は「左結合」で「優先順位が最強」ということです。以下のコードはコンパイルエラーになるのですが、その原因が分かるでしょうか?

add 1 add 2 3

これは以下のように解釈されているためです。

(add 1 add) 2 3

add の第2引数としては Int が期待されているのに、add 関数(Int -> Int -> Int)が渡されたために型が一致せずにコンパイルエラーということですね。

これを防ぐには () で優先順位を明示することになります。

add 1 (add 2 3)

これは $ 演算子を利用して以下のように書いても変わりません。

add 1 $ add 2 3

$ 演算子は () の代わりに使用できる優先順位が最低のものと考えると分かりやすいでしょう。また $ 演算子は右結合であるため add が連続する場合でも次のように記述できます。

add 1 (add 2 (add 3 (add 4 5)))

add 1 $ add 2 $ add 3 $ add 4 5

単なる () のシンタックスシュガーとして使える、と覚えておくのが最初はイメージしやすいかと思います。

最後に関数合成ですが、1引数の関数しか合成できないため、. で合成される前に(2引数以上の関数は)必ず「部分適用」が行われている、ということを意識しておきましょう。

以下のコードを例に見てみます。

add 1 . add 2 . add 3 . add 4 $ 5

関数適用の優先順位が最強であることを思い出しましょう。ここでは add 1 add 2 add 3 add 4 が最初に評価されると考えることができます。すなわち、いずれも Int -> Int な関数に置き換わっているということですね。演算子に置き換えると以下のようになります。

(1+) . (2+) . (3+) . (4+) $ 5

次に $ の優先順位は最低 であることを思い出しましょう。すなわち次に行われるのは . による関数合成です。ここでは関数合成によって新たな関数が生み出されていると考えることができます。

f :: Int -> Int
f x = 1 + 2 + 3 + 4 + x

f $ 5

ここまでくれば何も難しくありません。$() のシンタックスシュガーのようなものなのですから、 f (5) と無駄に () で囲まれているだけなので f 5 と等価です。

最後にまとめると、

  1. 関数適用(によって部分適用された関数、または値になる)
  2. . によって合成された新たな関数が作られる
  3. $ 演算子は右結合で優先順位を指定するだけのもの

と順にコードを見ていくと分かりやすいでしょう。逆にもっとも泥沼にハマるのが、すべてを一気に読もうとすることです。

まとめ

さて今回のまとめです。

  • Haskell では配列の代わりにリストを使用する。
  • Swift と似たような感じで filtermapfoldl が使用できる。
  • クロージャの代わりにラムダ式 \引数 -> 式 を使用できる。
  • 演算子は単なる関数である。
  • 2引数の関数は「`」で囲むことで中置演算子として利用できる。
  • (+1)といったようにセクション記法を利用することで演算子も部分適用できる。
  • 演算子 $ を使って () のネストを減らすことができる。
  • 関数をパイプとみなすことで、入力と出力の型が一致すれば合成できる。
  • Haskell では演算子 .関数合成ができる。
  • 関数合成を使ったコードスタイルはポイントフリースタイルと呼ばれる。
  • コードを読むのが難しいと感じたら優先順位を意識して読むこと。

記事タイトルに「リスト」とあるのに、関数合成や $ 演算子を解説するための材料として使われただけな気がしなくもありません。

次回こそはリストについて深く見ていきたいと思います。

blog.penginmura.tech

あとがき

Haskell 初心者はポイントフリースタイル病を発症するってそれ一番言われてるから。

用語解説:
ポイントフリースタイル病=何でもかんでも関数合成を使ってコードを書きたくなる症状のこと。


  1. Haskell には他にも畳み込み用の関数が用意されているのですが、ここでは Swift の reduce との対応が分かりやすい foldl のみを取り上げています。