高階関数

関数型プログラミングを始めよう

A lambda symbol with a sexy mustache

すべての関数型プログラミング言語の重要な部分は、定義した関数を別の関数のパラメータとして渡すことができることです。 これにより、その関数パラメータは、関数内で他の変数と同様に使用できる変数にバインドされます。 このようにして他の関数を受け入れることができる関数は、高階関数と呼ばれます。 高階関数は強力な抽象化手段であり、Erlangで習得すべき最高のツールの1つです。

繰り返しますが、これは数学、主にラムダ計算に根ざした概念です。 ラムダ計算については、理解するのが難しい人もいるため、また範囲外でもあるため、詳しく説明しません。 ただし、すべてが関数、つまり数値でさえも関数であるシステムとして簡単に定義します。 すべてが関数であるため、関数は他の関数をパラメータとして受け入れ、さらに多くの関数でそれらを操作する必要があります!

少し奇妙に聞こえるかもしれないので、例から始めましょう。

-module(hhfuns).
-compile(export_all).

one() -> 1.
two() -> 2.

add(X,Y) -> X() + Y().

Erlangシェルを開き、モジュールをコンパイルして、実行してみましょう。

1> c(hhfuns).
{ok, hhfuns}
2> hhfuns:add(one,two).
** exception error: bad function one
     in function  hhfuns:add/2
3> hhfuns:add(1,2).
** exception error: bad function 1
     in function  hhfuns:add/2
4> hhfuns:add(fun hhfuns:one/0, fun hhfuns:two/0).
3

混乱しますか? その仕組みがわかれば、それほどではありません(いつもそうではありませんか?)。 コマンド2では、アトムonetwoadd/2に渡され、add/2は両方のアトムを関数名(X() + Y())として使用します。 関数名がパラメータリストなしで記述されている場合、それらの名前はアトムとして解釈され、アトムは関数になることはできないため、呼び出しは失敗します。 これが式3も失敗する理由です。値1と2も関数として呼び出すことができず、必要なのは関数です!

これが、モジュールの外部から関数を渡せるように、言語に新しい表記法を追加する必要がある理由です。 これがfun Module:Function/Arityの役割です。VMに特定の関数を使用し、それを変数にバインドするように指示します。

それでは、この方法で関数を使用することのメリットは何でしょうか? それを理解するには、小さな例が必要かもしれません。 リストの各整数に1を加算または減算するために、リストに対して再帰的に動作する関数をいくつかhhfunsに追加します。

increment([]) -> [];
increment([H|T]) -> [H+1|increment(T)].

decrement([]) -> [];
decrement([H|T]) -> [H-1|decrement(T)].

これらの関数がどれほど似ているか見てください。 基本的に同じことを行います。リストを循環し、各要素に関数(+または-)を適用してから、自分自身を再び呼び出します。 そのコードではほとんど何も変更されていません。適用される関数と再帰呼び出しのみが異なります。 そのようなリストに対する再帰呼び出しのコアは常に同じです。 別の関数を引数として取る単一の関数(map/2)ですべての類似部分を抽象化します。

map(_, []) -> [];
map(F, [H|T]) -> [F(H)|map(F,T)].

incr(X) -> X + 1.
decr(X) -> X - 1.

シェルでテストできます。

1> c(hhfuns).
{ok, hhfuns}
2> L = [1,2,3,4,5].
[1,2,3,4,5]
3> hhfuns:increment(L).
[2,3,4,5,6]
4> hhfuns:decrement(L).
[0,1,2,3,4]
5> hhfuns:map(fun hhfuns:incr/1, L).
[2,3,4,5,6]
6> hhfuns:map(fun hhfuns:decr/1, L).
[0,1,2,3,4]

ここでは結果は同じですが、非常にスマートな抽象化を作成しました! リストの各要素に関数を適用したい場合は常に、関数と一緒にmap/2をパラメータとして呼び出すだけです。 ただし、map/2にパラメータとして渡したいすべての関数をモジュールに配置し、名前を付け、エクスポートしてからコンパイルする必要があるのは少し面倒です。 実際、それは明らかに非現実的です。 必要なのは、オンザフライで宣言できる関数です...

匿名関数

匿名関数、またはfunsは、名前を付けずに特別な種類の関数をインラインで宣言できるようにすることで、その問題に対処します。 通常の関数が実行できるほとんどすべてを実行できますが、自分自身を再帰的に呼び出すことはできません(匿名の場合、どのように実行できますか?)。構文は次のとおりです。

fun(Args1) ->
    Expression1, Exp2, ..., ExpN;
   (Args2) ->
    Expression1, Exp2, ..., ExpN;
   (Args3) ->
    Expression1, Exp2, ..., ExpN
end

そして、次のように使用できます。

7> Fn = fun() -> a end.
#Fun<erl_eval.20.67289768>
8> Fn().
a
9> hhfuns:map(fun(X) -> X + 1 end, L).
[2,3,4,5,6]
10> hhfuns:map(fun(X) -> X - 1 end, L).
[0,1,2,3,4]

これで、人々が関数型プログラミングをそれほど好む理由の1つがわかります。非常に低いレベルのコードで抽象化を行う機能です。 したがって、ループなどの基本的な概念は無視できるため、実行方法ではなく、実行内容に集中できます。

匿名関数は、このような抽象化にはすでに非常に優れていますが、まだ隠れた力があります。

11> PrepareAlarm = fun(Room) ->
11>                      io:format("Alarm set in ~s.~n",[Room]),
11>                      fun() -> io:format("Alarm tripped in ~s! Call Batman!~n",[Room]) end
11>                   end.
#Fun<erl_eval.20.67289768>
12> AlarmReady = PrepareAlarm("bathroom").
Alarm set in bathroom.
#Fun<erl_eval.6.13229925>
13> AlarmReady().
Alarm tripped in bathroom! Call Batman!
ok

ちょっと待ってください、バットマン! ここで何が起こっているのですか? まず、PrepareAlarmに割り当てられた匿名関数を宣言します。 この関数はまだ実行されていません。PrepareAlarm("bathroom").が呼び出されたときにのみ実行されます。 Batman with a manly mustache その時点で、io:format/2の呼び出しが評価され、「アラーム設定」テキストが出力されます。 2番目の式(別の匿名関数)は呼び出し元に返され、AlarmReadyに割り当てられます。 この関数では、変数Roomの値は「親」関数(PrepareAlarm)から取得されることに注意してください。 これは、クロージャと呼ばれる概念に関連しています。

クロージャを理解するには、まずスコープを理解する必要があります。 関数のスコープは、すべての変数とその値が格納されている場所として想像できます。 関数base(A) -> B = A + 1.では、ABはどちらもbase/1のスコープの一部として定義されています。 これは、base/1内のどこでも、ABを参照して、値がそれらにバインドされることを期待できることを意味します。 そして、「どこでも」と言うとき、私は冗談を言っていません。これには匿名関数も含まれます。

base(A) ->
    B = A + 1,
    F = fun() -> A * B end,
    F().

BAは引き続きbase/1のスコープにバインドされているため、関数Fは引き続きアクセスできます。 これは、Fbase/1のスコープを継承するためです。 ほとんどの種類の現実の継承と同様に、親は子供が持っているものを手に入れることができません。

base(A) ->
    B = A + 1,
    F = fun() -> C = A * B end,
    F(),
    C.

このバージョンの関数では、Bは引き続きA + 1と等しく、Fは引き続き正常に実行されます。 ただし、変数Cは、Fの匿名関数のスコープ内にのみあります。 base/1が最後の行でCの値にアクセスしようとすると、バインドされていない変数しか見つかりません。 実際、この関数をコンパイルしようとすると、コンパイラは適合しませんでした。 継承は一方向にのみ行われます。

継承されたスコープは、別の関数に渡された場合でも、匿名関数にどこまでも付随することに注意することが重要です。

a() ->
    Secret = "pony",
    fun() -> Secret end.

b(F) ->
    "a/0's password is "++F().

次に、コンパイルします。

14> c(hhfuns).
{ok, hhfuns}
15> hhfuns:b(hhfuns:a()).
"a/0's password is pony"

誰がa/0のパスワードを言ったのですか? ええと、a/0がしました。 匿名関数は、そこで宣言されているときはa/0のスコープを持ちますが、上記のようにb/1で実行されるときでもそれを持ち運ぶことができます。 これは、コンテキスト全体が不要になった場合でも、パラメータとコンテンツを元のコンテキストから持ち運ぶことができるため、非常に便利です(前の例のバットマンで行ったのとまったく同じです)。

多くの引数を取る関数を定義しているが、定数がある場合は、状態を持ち運ぶために匿名関数を使用する可能性が最も高くなります。

16> math:pow(5,2).
25.0
17> Base = 2.
2
18> PowerOfTwo = fun(X) -> math:pow(Base,X) end.
#Fun<erl_eval.6.13229925>
17> hhfuns:map(PowerOfTwo, [1,2,3,4]).
[2.0,4.0,8.0,16.0]

Base変数がスコープにバインドされた匿名関数内でmath:pow/2の呼び出しをラップすることにより、hhfuns:map/2PowerOfTwoへの各呼び出しで、リストの整数を指数の底として使用できるようにしました。

匿名関数を記述するときに陥る可能性のある小さな落とし穴は、スコープを再定義しようとするときです。

base() ->
    A = 1,
    (fun() -> A = 2 end)().

これは匿名関数を宣言してから実行します。 匿名関数はbase/0のスコープを継承するため、=演算子を使用しようとすると、2と変数A(1にバインドされている)が比較されます。 これは確実に失敗します。 ただし、ネストされた関数のヘッドで実行される場合は、変数を再定義できます。

base() ->
    A = 1,
    (fun(A) -> A = 2 end)(2).

そして、これはうまくいきます。 コンパイルしようとすると、シャドウイングに関する警告が表示されます(「警告:変数 'A' は 'fun' でシャドウされています」)。 シャドウイングとは、親スコープにあった変数と同じ名前の新しい変数を定義する行為を表す用語です。 これは、いくつかの間違いを防ぐためにあるので(通常はそうです)、これらの状況では変数の名前を変更することを検討してください。

更新
バージョン17.0以降、この言語は内部名を持つ匿名関数の使用をサポートしています。 そのとおり、匿名ですが名前付き関数です。

コツは、名前が関数のスコープ内でのみ表示され、スコープ外では表示されないことです。 これの主な利点は、匿名の再帰関数を定義できることです。 たとえば、永遠に大きな音を出し続ける匿名関数を作成できます。

18> f(PrepareAlarm), f(AlarmReady).
ok
19> PrepareAlarm = fun(Room) ->
19>     io:format("Alarm set in ~s.~n",[Room]),
19>     fun Loop() ->
19>         io:format("Alarm tripped in ~s! Call Batman!~n",[Room]),
19>         timer:sleep(500),
19>         Loop()
19>     end
19> end.
#Fun<erl_eval.6.71889879>
20> AlarmReady = PrepareAlarm("bathroom").
Alarm set in bathroom.
#Fun<erl_eval.44.71889879>
21> AlarmReady().
Alarm tripped in bathroom! Call Batman!
Alarm tripped in bathroom! Call Batman!
Alarm tripped in bathroom! Call Batman!
...

Loop変数は匿名関数自体を参照し、そのスコープ内では、匿名関数を指す他の同様の変数と同様に使用できます。 これにより、一般的に、シェルでの多くの操作が、前進するにつれてはるかに楽になるはずです。

前の章の終わりに約束したように、匿名関数の理論は少し脇に置いて、より一般的な抽象化を調べて、より多くの再帰関数を記述する必要がないようにします。

A map of Erland, the mystic Erlang island!

マップ、フィルター、フォールドなど

この章の冒頭で、2つの類似した関数を抽象化してmap/2関数を取得する方法を簡単に示しました。 また、このような関数は、各要素に対して作用させたいリストに使用できるとも断言しました。 関数は次のとおりです。

map(_, []) -> [];
map(F, [H|T]) -> [F(H)|map(F,T)].

ただし、一般的に発生する再帰関数から構築できる、他にも多くの同様の抽象化があります。 まず、次の2つの関数を見てみましょう。

%% only keep even numbers
even(L) -> lists:reverse(even(L,[])).

even([], Acc) -> Acc;
even([H|T], Acc) when H rem 2 == 0 ->
    even(T, [H|Acc]);
even([_|T], Acc) ->
    even(T, Acc).

%% only keep men older than 60
old_men(L) -> lists:reverse(old_men(L,[])).

old_men([], Acc) -> Acc;
old_men([Person = {male, Age}|People], Acc) when Age > 60 ->
    old_men(People, [Person|Acc]);
old_men([_|People], Acc) ->
    old_men(People, Acc).

最初の関数は数値のリストを取り、偶数のものだけを返します。 2番目の関数は、{Gender, Age}の形式の人々のリストを調べ、60歳以上の男性のみを保持します。類似点はここでは少し見つけにくいですが、共通点はいくつかあります。 両方の関数はリストで動作し、いくつかのテスト(述語とも呼ばれます)に合格した要素を保持し、他の要素を削除するという同じ目的を持っています。 この一般化から、必要なすべての有用な情報を抽出して抽象化できます。

filter(Pred, L) -> lists:reverse(filter(Pred, L,[])).

filter(_, [], Acc) -> Acc;
filter(Pred, [H|T], Acc) ->
    case Pred(H) of
        true  -> filter(Pred, T, [H|Acc]);
        false -> filter(Pred, T, Acc)
    end.

フィルタリング関数を使用するには、関数の外部でテストを取得するだけです。 hhfunsモジュールをコンパイルして、試してみてください。

1> c(hhfuns).
{ok, hhfuns}
2> Numbers = lists:seq(1,10).
[1,2,3,4,5,6,7,8,9,10]
3> hhfuns:filter(fun(X) -> X rem 2 == 0 end, Numbers).
[2,4,6,8,10]
4> People = [{male,45},{female,67},{male,66},{female,12},{unknown,174},{male,74}].
[{male,45},{female,67},{male,66},{female,12},{unknown,174},{male,74}]
5> hhfuns:filter(fun({Gender,Age}) -> Gender == male andalso Age > 60 end, People).
[{male,66},{male,74}]

これら2つの例は、filter/2関数を使用すると、プログラマーは述語とリストの作成についてのみ心配する必要があることを示しています。 不要なアイテムを削除するためにリストを循環する行為は、もはや考える必要はありません。 これは、関数型コードを抽象化することの重要な点の1つです。常に同じであるものを取り除き、プログラマーに変更部分を供給させます。

前の章では、リストに適用した別の種類の再帰的操作として、リストの各要素を順番に見て、単一の回答に縮約する方法を学びました。これはfoldと呼ばれ、以下の関数で使用できます。

%% find the maximum of a list
max([H|T]) -> max2(T, H).

max2([], Max) -> Max;
max2([H|T], Max) when H > Max -> max2(T, H);
max2([_|T], Max) -> max2(T, Max).

%% find the minimum of a list
min([H|T]) -> min2(T,H).

min2([], Min) -> Min;
min2([H|T], Min) when H < Min -> min2(T,H);
min2([_|T], Min) -> min2(T, Min).

%% sum of all the elements of a list
sum(L) -> sum(L,0).

sum([], Sum) -> Sum;
sum([H|T], Sum) -> sum(T, H+Sum).
A playing card with 'Joker' replaced by 'Foldr'. The joker has huge glasses, a hook and hairy legs

foldの動作を理解するには、これらのアクションの共通点と相違点をすべて見つける必要があります。上記のように、常にリストから単一の値への縮約があります。したがって、foldは単一のアイテムを保持しながら反復処理することのみを考慮する必要があり、リスト構築は必要ありません。次に、ガードは常に存在するわけではないため、無視する必要があります。ガードはユーザーの関数に含める必要があります。この点で、fold関数はsumと非常によく似ているでしょう。

まだ言及されていない3つの関数すべてに共通する微妙な要素は、すべての関数にカウントを開始するための初期値が必要であることです。 sum/2 の場合、加算を行っており、X = X + 0 であるため、値は中立であり、そこから開始しても計算を混乱させることはありません。乗算を行う場合は、X = X * 1 であるため、1を使用します。 min/1 および max/1 関数にはデフォルトの開始値を設定できません。リストが負の数のみで、0から開始した場合、答えは間違っています。そのため、リストの最初の要素を開始点として使用する必要があります。残念ながら、常にこの方法で決定できるわけではないため、その決定はプログラマーに任せます。これらの要素をすべて考慮することで、次の抽象化を構築できます。

fold(_, Start, []) -> Start;
fold(F, Start, [H|T]) -> fold(F, F(H,Start), T).

そして、試してみると

6> c(hhfuns).
{ok, hhfuns}
7> [H|T] = [1,7,3,5,9,0,2,3].    
[1,7,3,5,9,0,2,3]
8> hhfuns:fold(fun(A,B) when A > B -> A; (_,B) -> B end, H, T).
9
9> hhfuns:fold(fun(A,B) when A < B -> A; (_,B) -> B end, H, T).
0
10> hhfuns:fold(fun(A,B) -> A + B end, 0, lists:seq(1,6)).
21

リストを1つの要素に縮約する、考えられるほぼすべての関数は、foldとして表現できます。

面白いのは、アキュムレータを単一の要素(または単一の変数)として表現でき、アキュムレータはリストにすることができるということです。したがって、foldを使用してリストを作成できます。これは、foldが普遍的であることを意味します。つまり、mapやfilterなど、リスト上の他のほぼすべての再帰関数をfoldで実装できます。

reverse(L) ->
    fold(fun(X,Acc) -> [X|Acc] end, [], L).

map2(F,L) ->
    reverse(fold(fun(X,Acc) -> [F(X)|Acc] end, [], L)).

filter2(Pred, L) ->
    F = fun(X,Acc) ->
            case Pred(X) of
                true  -> [X|Acc];
                false -> Acc
            end
        end,
    reverse(fold(F, [], L)).

そして、それらはすべて、以前手書きで書いたものと同じように機能します。これは強力な抽象化ではありませんか?

Map、filter、foldは、Erlang標準ライブラリによって提供されるリストに対する多くの抽象化の1つにすぎません(lists:map/2lists:filter/2lists:foldl/3lists:foldr/3 を参照)。その他の関数には、述語を取り、すべての要素がtrueを返すかどうか、または少なくとも1つの要素がtrueを返すかどうかをそれぞれテストする all/2any/2 があります。次に、特定の述語に一致する要素が見つかるまでリストの要素を無視する dropwhile/2 と、その逆の takewhile/2 があります。これは、述語に対してtrueを返さない要素があるまで、すべての要素を保持します。前の2つの関数に補足的な関数は partition/2 で、リストを取り、2つのリストを返します。1つは指定された述語を満たす項を持つリスト、もう1つは他の項を持つリストです。その他の頻繁に使用されるリスト関数には、flatten/1flatlength/1flatmap/2merge/1nth/2nthtail/2split/2 などがあります。

また、ジッパー(前の章で説明)、アンジッパー、mapとfoldの組み合わせなどの他の関数も見つかります。何ができるかを確認するために、リストに関するドキュメントを読むことをお勧めします。賢い人々によってすでに抽象化されているものを使用することで、再帰関数を自分で書く必要があることはめったにないでしょう。