高階函式

讓我們開始函數式編程

A lambda symbol with a sexy mustache

所有函數式程式語言中一個重要的部分是,能夠取得您定義的函式,然後將其作為參數傳遞給另一個函式。這反過來會將該函式參數綁定到一個變數,該變數可以像函式內的任何其他變數一樣使用。能夠以這種方式接收其他函式的函式稱為高階函式。高階函式是一種強大的抽象方法,也是在 Erlang 中必須掌握的最佳工具之一。

同樣地,這是一個根植於數學的概念,主要是λ 演算。我不會深入探討 λ 演算的細節,因為有些人難以理解它,而且它有點超出範圍。不過,我會簡短地將其定義為一個所有事物都是函式的系統,甚至是數字。因為所有事物都是函式,所以函式必須接受其他函式作為參數,並且可以用更多函式來操作它們!

好吧,這可能有點奇怪,所以讓我們從一個範例開始

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

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

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

現在開啟 Erlang shell,編譯模組並開始執行

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 中,原子 onetwo 被傳遞給 add/2,然後 add/2 將兩個原子都用作函式名稱 (X() + Y())。如果函式名稱在撰寫時沒有參數列表,則這些名稱會被解釋為原子,而原子不能是函式,因此呼叫會失敗。這也是表達式 3 也會失敗的原因:值 1 和 2 也無法被呼叫為函式,而函式才是我們所需要的!

這就是為什麼必須在語言中加入新的表示法,才能讓您從模組外部傳遞函式。這就是 fun Module:Function/Arity 的作用:它告訴 VM 使用該特定的函式,然後將其綁定到一個變數。

那麼以這種方式使用函式有什麼好處?為了理解它,可能需要一個小範例。我們將在 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.

然後可以在 shell 中測試

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 的每個函式放入模組中、命名它、匯出它,然後編譯它等等,這有點煩人。事實上,這根本不切實際。我們需要的是可以即時宣告的函式...

匿名函式

匿名函式或 fun 可讓您內嵌宣告一種特殊的函式,而無需命名它們,從而解決了這個問題。它們幾乎可以執行一般函式可以執行的所有操作,除了遞迴呼叫它們自己(如果它們是匿名的,它們怎麼能做到?)。它們的語法是

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]

現在您看到了讓許多人如此喜歡函數式編程的事情之一:在程式碼的非常低階層進行抽象的能力。因此,可以忽略迴圈等基本概念,讓您專注於完成什麼,而不是如何完成。

匿名函式對於這類抽象來說已經非常出色了,但它們仍然具有更多隱藏的能力

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"). 時執行。留著男子氣概鬍鬚的蝙蝠俠 屆時,將會評估對 io:format/2 的呼叫,並輸出「Alarm set」文字。第二個表達式(另一個匿名函式)會傳回給呼叫者,然後指派給 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 仍然可以存取它們。這是因為 F 會繼承 base/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]

透過將對 math:pow/2 的呼叫包裝在匿名函式內,並在其範圍中繫結 Base 變數,我們可以讓 hhfuns:map/2 中對 PowerOfTwo 的每個呼叫,都使用清單中的整數作為我們基準的指數。

撰寫匿名函式時,您可能會陷入一個小陷阱,那就是當您嘗試重新定義範圍時

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

這會宣告一個匿名函式,然後執行它。由於匿名函式會繼承 base/0 的範圍,因此嘗試使用 = 運算子會將 2 與變數 A(繫結到 1)進行比較。這保證會失敗。但是,如果是在巢狀函式的開頭進行,則可以重新定義變數

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

這樣就奏效了。如果您嘗試編譯它,您會收到有關遮蔽的警告 (「警告:在 'fun' 中遮蔽變數 'A'」)。遮蔽是描述定義一個與父範圍中的變數名稱相同的新變數的行為的術語。這是為了防止一些錯誤(通常是正確的),因此您可能會考慮在這些情況下重新命名您的變數。

更新
從 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 變數參考匿名函式本身,而且在該範圍內,可以像指向匿名函式的任何其他類似變數一樣使用它。這通常應該會讓 shell 中的許多操作在未來變得不那麼痛苦。

我們將暫時擱置匿名函式理論,並探索更常見的抽象,以避免必須撰寫更多遞迴函式,就像我在上一章結尾所承諾的那樣。

A map of Erland, the mystic Erlang island!

映射、篩選、折疊等

在本章的開頭,我簡要說明了如何抽象化兩個相似的函式以獲得 map/2 函式。我也確認,這樣的函式可以用於任何我們想要對每個元素進行操作的清單。該函式如下

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

但是,還有許多其他類似的抽象可以從常見的遞迴函式中建立。我們先來看看這兩個函式

%% 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).

第一個函式接受一個數字清單,並僅傳回偶數。第二個函式會遍歷一個形式為 {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}]

這兩個範例顯示,透過使用 filter/2 函式,程式設計人員只需擔心產生述詞和清單即可。不再需要考慮循環遍歷清單以丟棄不需要的項目的動作。這是抽象函數式程式碼的一個重要事項:嘗試擺脫始終相同的內容,並讓程式設計人員提供變化的部分。

在上一章中,我們對清單應用的另一種遞迴操作是以連續查看清單的每個元素,並將其減少為單一答案。這稱為折疊,可用於下列函式

%% 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

若要尋找折疊的行為方式,我們必須找出這些動作的所有共同點,然後找出不同之處。如上所述,我們始終會從清單減少到單一值。因此,我們的折疊只應考慮在保留單一項目的同時進行迭代,而不需要建立清單。然後,我們需要忽略保護子句,因為它們並非始終存在:這些需要在使用者函式中。在這方面,我們的折疊函式可能看起來很像總和。

所有三個函數都有一個微妙的共同點,那就是每個函數都需要一個初始值來開始計數。以 sum/2 來說,我們使用 0 作為初始值,因為我們正在做加法,而 X = X + 0,這個值是中性的,我們不會因為從這裡開始而搞砸計算。如果我們要做乘法,我們會使用 1,因為 X = X * 1。函數 min/1max/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)。

有趣的是,你可以將一個累加器表示為一個單一元素(或一個單一變數),而累加器可以是一個列表。因此,我們可以使用摺疊來建立一個列表。這表示摺疊是通用的,你可以用摺疊來實現幾乎任何其他的列表遞迴函數,甚至是 map 和 filter。

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 標準函式庫提供的眾多列表抽象之一(請參閱 lists:map/2lists:filter/2lists:foldl/3lists:foldr/3)。其他函數包括 all/2any/2,它們都接收一個謂詞,並分別測試是否所有元素都返回 true,或者是否至少有一個元素返回 true。然後你有 dropwhile/2,它會忽略列表中的元素,直到找到一個符合特定謂詞的元素,它的相反函數 takewhile/2,它會保留所有元素,直到有一個不返回 true 的謂詞。與前兩個函數互補的函數是 partition/2,它會接收一個列表並返回兩個列表:一個包含符合給定謂詞的項目,另一個列表包含其他項目。其他常用的列表函數包括 flatten/1flatlength/1flatmap/2merge/1nth/2nthtail/2split/2 和其他許多函數。

你也會發現其他函數,例如拉鍊(zippers,如上一章所述)、解拉鍊(unzippers)、map 和 fold 的組合等等。我鼓勵你閱讀列表的相關文件,看看可以做些什麼。你會發現,透過使用聰明人已經抽象化的東西,你很少需要自己編寫遞迴函數。