嚴格來說,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()函式會接收一個有兩個引數的函式,並把它套用到集合上,藉此降低維度。
抽象聽起來可能有點難懂,但想想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))。
但這絕不是通例! 就連減法和除法這麼常見的運算,也不滿足結合律。
還有一個額外的問題:在大型集合上,浮點誤差會累積,所以從左到右歸約的結果,可能和從右到左的結果略有不同。
Julia 的reduce函式往哪個方向進行,取決於實作,並沒有保證。
如果想明確控制方向,可以用foldl()和foldr()這兩個函式,顧名思義分別從「左」和「右」開始(實際上對 Vector 來說是從最上面和最下面)。
julia> foldl(-, 1:3) # (1 - 2) - 3
-4
julia> foldr(-, 1:3) # 1 - (2 - 3)
2
請注意,這兩個函式是給可以視為一維的集合用的,會回傳純量結果。
foldl和foldr不支援使用dims引數,只有reduce支援。
在各種程式設計領域裡,把map操作和reduce結合起來都很常見。
我們可以依序先執行map,再對中繼集合執行reduce。
但這麼做充其量只是效率不彰,而且集合一大,擴展性就會非常糟糕。
因此_強烈_建議改用合併後的mapreduce()函式。
它可以實作效能好上許多的演算法,把 map 和 reduce 的操作交錯進行。
第一個引數是要用來 map 的函式,第二個引數是 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()函式,用在方向很重要的情況。
我們正在籌辦一個起司俱樂部,它會運用機器學習,根據顧客的歷史紀錄與口味,挑選出新的起司來提供給熱愛起司的顧客。
新會員必須填寫一份初步問卷,讓我們能先蒐集一些基本資料。 從中我們發現,有一部分的顧客評價方式特別極端,批評時缺乏細膩的區分。 由於這種情況可能會無可挽回地讓更細膩的演算法產生偏差,因此我們另外設置了一套演算法來處理他們的需求。 你需要提供一些輔助函式來整理他們的資料。
雖然解決下列任務的方式可能不只一種,但每一項都能用一個不同的單一高階函式來解決。
評分系統以五顆星為基準,也就是由整數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。
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