厳密に言えば、Higher Order Functionとは、次のうち少なくとも一方を満たす関数にすぎません。
ただし、関数型プログラミングの世界では、使われ方の幅はもっと狭いのがふつうです。この言葉はたいてい、渡された関数をコレクションの要素に適用するfilter、map、reduceのような関数を指します。
シラバスのここまでで、Vectorのような反復可能なコレクションのすべての要素に操作を適用する方法を、すでにいくつか見てきました。
このコンセプトでは、高階関数(HaskellやF#など、関数型言語に触れたことがあればおなじみのもの)に焦点を当てます。
ほかにも、次のような方法が考えられます。
filter()関数は、真偽値の戻り値を持つ関数を渡され、それをコレクションに適用します。戻り値に含まれるのは、trueを返す要素だけです。戻り値は、入力と_基本的に_同じ型になります(後述)。
julia> filter(iseven, 1:6)
3-element Vector{Int64}:
2
4
6
# String is a collection of Chars, so String in -> String out
julia> filter(!isascii, "Hrōðgār")
"ōðā"
# tuple input -> tuple output
julia> filter(iseven, (1, 2, 3, 4, 5))
(2, 4)
多次元配列の場合、filterは入力の次元を平坦化してVectorを返します。出力の型が入力の型と一致するというルールの、主な例外です。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> filter(isodd, m)
3-element Vector{Int64}:
1
5
3
上の例では組み込み関数を使っていますが、この文脈では無名関数を使うこともとてもよくあります。
julia> filter(x -> x % 3 == 0, 1:20)
6-element Vector{Int64}:
3
6
9
12
15
18
このコンセプトで扱う多くの関数と同じく、その場で書き換えるバージョンのfilter!()もあります。
map()関数は、各要素に関数を適用してコレクションを変換します。単純なケースではブロードキャストに似ていて、出力の形は入力と一致します。
julia> map(√, [1, 4, 9])
3-element Vector{Float64}:
1.0
2.0
3.0
julia> map(x -> x^2 + 1, 1:4)
4-element Vector{Int64}:
2
5
10
17
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> map(√, m)
2×3 Matrix{Float64}:
1.0 1.41421 1.73205
2.0 2.23607 2.44949
map()は、複数のコレクションに対しても要素ごとに働きます。
julia> map(*, [1, 2], [3, 4])
2-element Vector{Int64}:
3
8
概念的には、複数の入力コレクションにzip()を実行し、その中間結果の各要素にmap()を実行するのと同じだと考えることができます。これはあくまで大まかなたとえで、実装について何も示すものではありません!
zip()と同様、形の合わないコレクションは、最も小さいものの次元に切り詰められます。
渡した関数の副作用だけが必要な場合もあります。たとえば、データベースへの書き込みや配列へのpush!です。そんなときは高階関数のforeach()が使えます。これは常にnothingを返します。
reduce()関数は、2引数の関数を受け取り、それをコレクションに適用して、次元の削減につなげます。
抽象的に聞くと混乱するかもしれませんが、コレクションを受け取って単一の値を返すsum()やprod()のような関数を考えてみましょう。
julia> sum(1:4) # add
10
julia> prod(1:4) # multiply
24
これらの専用関数は高度に最適化されているので、使えるときは必ず使いましょう。 ほかの例としては、maximum()やminimum()、論理関数のall()やany()、そして多くの統計関数があります。
あくまで例として、同じ機能をより汎用的なreduce()で実装したものを考えてみましょう(中置演算子の+と*も、内部的には本当は関数であることを思い出してください)。
julia> reduce(+, 1:4) # add
10
julia> reduce(*, 1:4) # multiply
24
sum()やその他の集約関数と同様、reduce()も、減らす次元を指定する省略可能なキーワード引数dimsを取ることができます。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> reduce(+, m; dims=1)
1×3 Matrix{Int64}:
5 7 9
これらは簡単な例です。足し算とかけ算はどちらも可換(1+2 == 2+1)で、結合法則も成り立つ((1+2)+3 == 1+(2+3))からです。
しかし、これは決して一般的ではありません!引き算や割り算のようなありふれた演算でさえ、結合法則が成り立ちません。
さらに、大きなコレクションでは浮動小数点の誤差が積み重なるという問題もあります。そのため、左から右へreduceした場合と右から左へreduceした場合とで、答えが少し違うことがあります。
Juliaのreduce関数の方向は実装に依存していて、保証されていません。
方向を明示的に制御するには、foldl()とfoldr()という関数があります。それぞれ名目上は「左」と「右」から始まります(Vectorの場合は、実際には上と下です)。
julia> foldl(-, 1:3) # (1 - 2) - 3
-4
julia> foldr(-, 1:3) # 1 - (2 - 3)
2
これらは1次元として扱えるコレクションを対象としていて、スカラーの結果を返すことに注意してください。dims引数はfoldlとfoldrでは使えません。使えるのはreduceだけです。
map操作とreduceを組み合わせることは、さまざまなプログラミングの分野でとてもよく行われます。
mapを実行してから、中間のコレクションに対してreduceを実行する、という順番の方法も考えられます。しかし、これはよくても効率が悪く、コレクションが大きくなるにつれて性能がひどく悪化します。
代わりに、組み合わせたmapreduce()関数を使うことを_強く_おすすめします。この関数は、mapとreduceの操作を交互に行う、はるかに効率的なアルゴリズムを実装できます。
第1引数はmapに使う関数、第2引数はreduceの演算子です。
julia> mapreduce(x -> x^2 + 1, +, 1:3)
17
# equivalent to (2 + 5 + 10)
julia> sum(map(x -> x^2 + 1, 1:3))
17
予想どおり、Juliaには方向が重要な場面のためのmapfoldl()とmapfoldr()関数もあります。
チーズクラブを立ち上げます。このクラブでは、機械学習を使って、チーズ好きのお客様の履歴と好みに基づき、新しく提供するチーズを選びます。
新規会員には、まず最初のアンケートに答えてもらいます。こうして、手始めに基本となるデータを集めます。 その中で、評価にきめ細かさを欠く、極端な評価をする顧客が一部いることが分かりました。 こうした評価は、よりきめ細かなアルゴリズムに取り返しのつかない偏りをもたらしかねないため、そうした顧客に対応するための専用のアルゴリズムが別に用意されています。 そこで、そうした顧客のデータを扱うためのヘルパー関数をいくつか用意することが求められています。
以下の課題にはいくつかの解き方がありますが、それぞれを異なる単一の高階関数で解くことができます。
評価システムは5段階で、単純に整数1:5で表されます。
極端な評価をする顧客は1か5しか付けません。そこで、ある顧客がこの傾向を示すかどうかを知りたいのです。
all_15()を実装してください。これは評価のベクターを受け取り、すべての評価が1か5であればtrueを、そうでなければfalseを返します。
julia> ratings = [2, 3, 4, 4, 1];
julia> all_15(ratings)
false
julia> ratings = [1, 5, 5, 1, 5];
julia> all_15(ratings)
true
極端な評価をする顧客を、それ以外の顧客から選別する必要があります。
emphatics()を実装してください。これは顧客と評価の辞書を受け取ります。
1か5の評価しか付けない顧客だけを含む、同じ形式の辞書を返します。
julia> ratings = ([2, 3, 5, 1, 1], [1, 1, 5, 5, 1], [4, 5, 5, 3, 2], [5, 5, 1, 1, 5]);
julia> names = ("c1", "c2", "c3", "c4");
julia> customers = Dict(zip(names, ratings))
Dict{String, Vector{Int64}} with 4 entries:
"c2" => [1, 1, 5, 5, 1]
"c1" => [2, 3, 5, 1, 1]
"c3" => [4, 5, 5, 3, 2]
"c4" => [5, 5, 1, 1, 5]
julia> emphatics(customers)
Dict{String, Vector{Int64}} with 2 entries:
"c2" => [1, 1, 5, 5, 1]
"c4" => [5, 5, 1, 1, 5]
極端な評価をする顧客は1と5の評価しか使わないため、これらを0と1に変換しておくと、計算上より都合がよくなります。
tobinary()を実装してください。これは極端な評価のベクターを受け取ります。
1を0に、5を1に変換した2値の評価を返します。
julia> ratings = [1, 1, 5, 5, 1];
julia> tobinary(ratings)
5-element Vector{Int64}:
0
0
1
1
0
アルゴリズムはMatrixを入力として使うので、データをそれに変換する必要があります。
tobinarymatrix()を実装してください。これは極端な評価のベクターのベクターを受け取ります。
変換したデータをMatrixとして返します。各評価のベクターが、行列の行になります。
julia> customersratings = [[1, 1, 5, 5, 1],[5, 5, 1, 1, 5]];
julia> tobinarymatrix(customersratings)
2×5 Matrix{Int64}:
0 0 1 1 0
1 1 0 0 1