🦴

高階関数とは?関数型〜基本的な作り方まで(Haskell)

2022/05/23に公開

高階関数とは引数/戻り値として扱える関数のことですが、具体的な使い方が少しイメージしづらいですよね。関数型の基本からスタートして、高階関数の読み解きについて学んでいきたいと思います。(高階関数の読み解き方はこちら/代表的な高階関数についてはこちらからどうぞ)

関数の定義の基本

まずは関数の基本からです。関数型とその読み方の基本を学びます。Haskellの関数は引数/戻り値がごっちゃで読みづらいですが、まずは基本のルールを覚えていきます。

関数型とは?

関数型とは『ある型の引数を別の型へ変換する型』のことです。 Haskellでは関数にも型が付いています。

関数名 :: 型 -> 型

型の定義の中に(型 -> 型)という型が入っています。(型 -> 型)とは関数型の定義です。通常のデータ型のように「扱うデータを指定する」だけでなく、「ある型から別の型へ変換する型」という機能があります。(また引数/戻り値を必ず持つことも決まりとなっていましよね。)

戻り値と引数の基本

関数の定義を見ていきます。どれが引数か?どれが戻り値か?がすごくわかりづらいですよね。1番カンタンな覚え方は「1番最後に書かれているのが戻り値である」というモノです。それより前は全て引数となります。

関数名 :: 第1引数の型 -> 第2引数の型 -> .. -> 戻り値

実際の関数を例に考えてみます。。addtwoという関数の中にIntが3つ書いてあります。どれが引数で、どれが戻り値になるでしょうか?

addtwo :: Int -> Int -> Int

1番最後のIntは戻り値です。それより前は全て引数となります。つまりaddtwo関数は引数を2つ取る関数となります。以下のように引数を2つ足すことができます。

addtwo :: Int -> Int -> Int
addtwo a b = a + b
    
main = print (addtwo 10 20)
-- 実行結果「30」

命令型言語に慣れている人はInt -> Int -> Intという関数の定義に戸惑うようです。確かに引数/戻り値が区別されていないのでややこしいですよね。1番最後が戻り値、それより手前が引数..という基本をまずは押さえておきましょう。

高階関数(引数/戻り値に使える関数)

ここまで基本的な関数の定義を確認してきました。ここからが本題の高階関数です。高階関数とは引数/戻り値として扱える関数のことでした。

  • 関数を引数として扱えるとはどういうことか?
  • 関数を戻り値として扱えるとはどういうことか?

を解説するのが今回の記事の目的です。どちらも少し特殊ですがどちらかといえば「関数を戻り値として扱う」方がカンタンです。「関数を戻り値として扱う」というのは知らぬ間に色々な場面で使っているはずだからです(後述しますが複数の引数を処理する場合、関数が戻り値として返却されています)。「関数を引数として扱う」と方が読み解き方がややこしいです。そこでまずは「関数を戻り値として扱う」仕組みを学んでから、「関数を引数として扱う」方法を学んでいきたいと思います。

1、戻り値として扱える関数

みなさんはきっと今まで何度も関数を戻り値として使ってきたはずです。引数を2つ足し算するaddtwo関数を例に考えます。10、20と引数を2つ渡していますが2つは同時に処理されません。

addtwo :: Int -> Int -> Int
addtwo a b = a + b
    
main = print (addtwo 10 20)
-- 実行結果「30」

複数の引数であっても常に一引数化して処理しています。なぜ一引数化できるかといえば、関数を戻り値として返却できるからです。 2つの引数を処理するため、1つ1つ新しく関数を返却しています。図にすると以下のようなイメージです。

(1)addtwo関数が10へ適用される
(2)数値を返却する代わりに、新たに別の関数を返却する
 (新たに作られたこの関数は引数を1つとって、10と足し算する)
(3)新たに作られた別の関数が20へ適用される
→10+20の計算結果として30が返却される

関数の呼び出しは左結合です。addtwo関数を呼び出す場合、addtwo 10 20(addtwo 10) 20と同じです。addtwo 10は続く引数に対して20を加える関数として成立しています。そして20を引数として(add 10)を呼び出しており、結果として20という値が得られます。戻り値として使える関数(高階関数)というのは、こんな場面でもよく使われています。

2、引数として扱える関数

では「引数として扱える関数」とは何でしょうか。関数は引数として関数を受け取ることができます。引数として関数を受け取る関数とは関数型(型 -> 型)を型の中に定義を持つ関数のことです。

関数名 :: (型 -> 型) -> 型 -> ... -> 型

型の定義の中に(型 -> 型)という型が入っています。(型 -> 型)とは関数型の定義です。関数型とは『ある型の引数を別の型の結果へ変換する型』のことでした。関数型(型 -> 型)を型の中に定義を持つ関数が、引数として関数を受け取る関数です。

引数として関数を受け取る関数(apply関数)

引数として関数を受け取る関数の例として、apply関数を考えます。apply関数は1引数の関数を値へと適用します。(高階関数を使う必要もないですが..)

apply :: (Int -> Int) -> Int -> Int
apply f a = f a

main = print $ apply (+10) 20
-- 実行結果「30」

1行目:(Int -> Int)を含む関数

まずは1行目から読み解いていきます。どの関数も1番最後が戻り値です。それより前が引数となります。
ここでは1番右端にあるInt型の値が戻り値となり、引数として(Int -> Int)Intを受け取ります。()でひとまとまりとなっているので、(Int -> Int)はこれで1つです。定義に矢印->を含んでいるのでこれは関数型であることがわかります。 (矢印->を型の定義に含むモノは全て関数です。)つまり関数型とInt型の値を受け取り、Int型の値を返却する関数であることがわかります。

関数の処理内容については何も言及していない点に注意して下さい。ここでは「どのデータを受け取って何を返すか?」しか決めていません。実際の処理内容は2行目となります。

2行目:処理内容

2行目では処理内容を決めています。「applyは関数fと値aを受け取り、faへと適用すること」を表しています。fはfunciton(関数)の略です。Haskellでは関数適用はスペース で表されます。なのでf(x)のような関数呼び出しはf xと書けばOKです(スペースを書くだけで適用されます)。

1引数の関数とは?

1引数の関数とは何でしょうか?例えば(+10)などがよく使われる例です。+という関数へ引数を1つだけ渡して、1引数の関数を作ってみました。2項演算子は引数を2つ取る必要がありますが、片方だけ渡します。そうすれば引数を1つ取る関数を作ることができます。(+10)とは『引数を1つとって10と足し算する関数』ということになります。

()のあり/なしで全然違います!

apply関数で注目したいのが関数型を型の定義に含んでいる点です。仮にIntが4つあったとしても、以下の2つの定義は全く異なります。上はapply関数で、下は3つの引数を足すaddthree関数です。

apply :: (Int -> Int) -> Int -> Int
addtwo :: Int -> Int -> Int -> Int

()を付けることによってそれが明示的に関数であることを示しています。 2つがごっちゃにならないように気をつけて下さい。

関数の定義は同じで処理内容は違う(applytwice関数)

次にapplytwice関数を考えてみます。こちらは引数の値へ関数を2回適用します。applyの方は関数を一度だけ適用していましたよね。2つの間でどのような違いがあるかに注目して下さい。

apply :: (Int -> Int) -> Int -> Int
apply f a = f a

applytwice :: (Int -> Int) -> Int -> Int
applytwice f a = f (f a)

main = print $ applytwice (+10) 20
-- applytwiceの実行結果「40」

ほとんど同じですね..。違うのは2行目にある=以降の処理内容だけです。なぜならapply関数でもapplytwice関数でも、必要な引数/戻り値の数は変わっていないからです。

1行目の関数の定義では、原因と結果しかわかりません。どのような引数を受け取って何を返すか?しか決めていません。なので内部の処理は全くのブラックボックスです。
2行目では関数の処理内容を定義しています。受け取った関数や値などを元にどのような調理をするか?を決めています。1行目:関数の定義、2行目:処理内容をゴッチャにしないよう気を付けて下さい。

1行目:原因(引数)と結果(戻り値)だけ
2行目:具体的な処理内容(引数をどのように扱うか?)

という関数の定義における上記2点をしっかり区別しましょう。(1行目の型の定義は必ずしも必要ではありませんし、2行目の処理内容は何行にも渡ることがあります。ここでは極端な例で説明させて頂きました。)

高階関数は何がメリットなの?

結局のところ高階関数は何がメリットなのでしょうか。それは関数を部品として色々な場所で使い回せる点です。既存の関数同士を組み合わせて複雑な部品もカンタンに作れます。また代表的な高階関数では関数をリストなど色々なモノへ適用させています。(代表的な高階関数についてこちらの記事で紹介しています。)

関数名 機能
filter リストから関数の条件に合致するモノを返却する
map リストへ関数を適用する
zip/zipWith 複数のリストを1つのリストへまとめる
foldl/foldr リストを1つの値へまとめる
scanl/scanr foldl/rの途中経過を残すver

このように関数を強力な部品として再利用できることが高階関数の1番のメリットとなっています。

Discussion