一級函數:Lambda函數和Map
什麼是一級函數?
你可能聽過它之前說過,一種特定的語言是功能性的,因爲它有“一流的功能”。正如我在本系列關於函數式編程的第一篇文章中所說的那樣,我不贊同這種流行的觀點。我同意一流的函數是任何函數式語言的基本特徵,但我不認爲這是語言具有功能性的充分條件。有許多命令式語言也具有這一特性。但是,什麼是一流的函數呢?功能描述爲頭等艙當它們可以像任何其他值一樣處理時-也就是說,它們可以在運行時被動態地分配給一個名稱或符號。它們可以存儲在數據結構中,通過函數參數傳入,並作爲函數返回值返回。
這其實不是一個新奇的想法。函數指針從1972年開始就一直是C的一個特性。在此之前,過程引用是Algol 68的一個特性,於1970年實現,當時,它們被認爲是程序性編程特性追溯到更久以前,Lisp(首次實現於1963年)是建立在程序代碼和數據是可互換的概念之上的。
這些也不是模糊的特性。在C語言中,我們通常使用函數作爲一流的對象。例如,在排序時:
char **array = randomStrings();
printf("Before sorting:\n");
for (int s = 0; s < NO_OF_STRINGS; s++)
printf("%s\n", array[s]);
qsort(array, NO_OF_STRINGS, sizeof(char *), compare);
printf("After sorting:\n");
for (int s = 0; s < NO_OF_STRINGS; s++)
printf("%s\n", array[s]);
這,這個,那,那個stdlib
C中的庫爲不同類型的排序例程提供了一組函數。它們都能夠對任何類型的數據進行排序:程序員所需要的唯一幫助就是提供一個比較數據集的兩個元素並返回的函數。-1
, 1
,或0
,指示哪個元素大於另一個元素或它們相等。
這本質上就是戰略模式!
指向字符串的指針數組的比較器函數可以是:
int compare(const void *a, const void *b)
{
char *str_a = *(char **) a;
char *str_b = *(char **) b;
return strcmp(str_a, str_b);
}
然後,我們將其傳遞給排序函數,如下所示:
qsort(array, NO_OF_STRINGS, sizeof(char *), compare);
控件上沒有括號。compare
函數名使編譯器發出函數指針,而不是函數調用。因此,在C中將函數視爲頭等對象是非常容易的,儘管接受函數指針的函數的簽名非常難看:
qsort(void *base, size_t nel, size_t width, int (*compar)(const void *, const void *));
函數指針不僅用於排序。早在.NET發明之前,就有用於編寫MicrosoftWindows應用程序的Win 32 API。在此之前,有Win16API。它自由地使用函數指針作爲回調。應用程序在調用窗口管理器時提供了指向其自身函數的指針,當應用程序需要通知某個已經發生的事件時,窗口管理器將調用該窗口管理器。您可以認爲這是應用程序(觀察者)與其窗口(可觀察的)之間的一個觀察者模式關係-應用程序接收到了發生在其窗口上的事件的通知,例如鼠標單擊和按鍵盤。在窗口管理器中抽象了管理窗口的工作-移動窗口,將它們堆疊在一起,決定哪個應用程序是用戶操作的接收者。這些應用程序對它們共享環境的其他應用程序一無所知。在面向對象的編程中,我們通常通過抽象類和接口來實現這種解耦,但也可以使用一流的函數來實現。
所以,我們使用一流的函數已經有很長時間了。但是,公平地說,沒有一種語言比簡陋的Javascript更能廣泛地推廣作爲一流公民的功能。
Lambda表達式
在Javascript中,將函數傳遞給用作回調的其他函數一直是一種標準做法,就像在Win 32 API中一樣。這個想法是HTML DOM的一個組成部分,其中第一類函數可以作爲事件偵聽器添加到DOM元素中:
function myEventListener() {
alert("I was clicked!")
}
...
var myBtn = document.getElementById("myBtn")
myBtn.addEventListener("click", myEventListener)
就像在C中一樣,myEventListener
函數名時,在調用addEventListener
意味着它不會立即執行。相反,該函數與click
事件中的DOM元素。當單擊元素時,然後將調用該函數併發生警報。
流行的jQuery庫通過證明通過查詢字符串選擇DOM元素的函數簡化了流程,並提供了操作元素和向元素添加事件偵聽器的有用函數:
$("#myBtn").click(function() {
alert("I was clicked!")
})
類中使用的第一類函數也是實現異步I/O的方法。XMLHttpRequest
對象,它是Ajax的基礎。同樣的想法在Node.js中也很普遍。當您想要進行一個非阻塞函數調用時,將它傳遞給一個函數的引用,以便在它完成時調用您。
但是,這裏還有別的東西。第二個例子不僅僅是一個一流函數的例子。它也是Lambda函數。具體而言,本部分:
function() {
alert("I was clicked!");
}
lambda函數(通常被稱爲蘭卜達)是一個未命名的函數。他們本可以叫他們匿名函數,這樣每個人都會立刻知道他們是什麼。但是,這聽起來不那麼令人印象深刻,所以lambda函數就是!lambda函數的要點是在那裏只需要一個函數;因爲它在任何地方都不需要,所以您只需要在那裏定義它。不需要名字。如果你做需要在其他地方重用它,然後考慮將它定義爲一個命名函數,然後按名稱引用它,就像我在第一個Javascript示例中所做的那樣。如果沒有lambda函數,使用jQuery和Node進行編程確實會令人厭煩。
LAMBDA函數以不同的方式以不同的語言定義:
在Javascript中:function(a, b) { return a + b }
在Java中:(a, b) -> a + b
在C#中:(a, b) => a + b
在Clojure中:(fn [a b] (+ a b))
在Clojure中-速記版本:#(+ %1 %2)
在Groovy中:{ a, b -> a + b }
在F#中:fun a b -> a + b
在Ruby中,所謂的“穩定”語法:-> (a, b) { return a + b }
正如我們所看到的,大多數語言都傾向於一種比Javascript更簡潔的表達lambdas的方式。
地圖
您可能已經在編程中使用了“map”一詞來表示將對象存儲爲鍵值對的數據結構(如果您的語言稱它爲“字典”,那麼就沒有問題了)。在函數式編程中,這個詞還有一個額外的含義。事實上,基本概念是一樣的。在這兩種情況下,一組事物被映射到另一組事物。在數據結構的意義上,映射是一個名詞-鍵被映射到值。在編程意義上,map是一個動詞-一個函數將一個值數組映射到另一個值數組。
假設你有一個功能f以及一系列的值A = [A1, A2, A3, A4]地圖f過關A手段應用f中的每一個元素A:
- A1 → f (A1) = a1‘
- A2 → f (A2) = a2‘
- A3 → f (A3) = A3‘
- A4 → f (A4) = A4‘
然後,按照與輸入相同的順序組裝結果數組:
A‘=地圖(f, A ) = [a1‘, a2‘, A3‘, A4‘]
逐個圖
好吧,這很有趣,但是位數學。你多久會這麼做一次?實際上,這比你想象的要頻繁得多。像往常一樣,有一個例子最能解釋事情,所以讓我們看一看我從下面舉出來的一個簡單的練習exercism.io當我學習Clojure的時候。這項運動被稱爲“RNA轉錄”,它非常簡單。我們將看一看需要轉錄成輸出字符串的輸入字符串。這些基礎是這樣翻譯的:
- C→G
- G→C
- →U
- T→A
除C、G、A、T以外的任何輸入都是無效的。JUnit 5中的測試可能如下所示:
class TranscriberShould {
@ParameterizedTest
@CsvSource({
"C,G",
"G,C",
"A,U",
"T,A",
"ACGTGGTCTTAA,UGCACCAGAAUU"
})
void transcribe_dna_to_rna(String dna, String rna) {
var transcriber = new Transcriber();
assertThat(transcriber.transcribe(dna), is(rna));
}
@Test
void reject_invalid_bases() {
var transcriber = new Transcriber();
assertThrows(
IllegalArgumentException.class,
() -> transcriber.transcribe("XCGFGGTDTTAA"));
}
}
而且,我們可以通過這個Java實現通過測試:
class Transcriber {
private Map<Character, Character> pairs = new HashMap<>();
Transcriber() {
pairs.put('C', 'G');
pairs.put('G', 'C');
pairs.put('A', 'U');
pairs.put('T', 'A');
}
String transcribe(String dna) {
var rna = new StringBuilder();
for (var base: dna.toCharArray()) {
if (pairs.containsKey(base)) {
var pair = pairs.get(base);
rna.append(pair);
} else
throw new IllegalArgumentException("Not a base: " + base);
}
return rna.toString();
}
}
用函數樣式編程的關鍵是,毫不奇怪地,將可能表示爲函數的所有內容轉換爲一個函數。所以,讓我們這樣做:
char basePair(char base) {
if (pairs.containsKey(base))
return pairs.get(base);
else
throw new IllegalArgumentException("Not a base " + base);
}
String transcribe(String dna) {
var rna = new StringBuilder();
for (var base : dna.toCharArray()) {
var pair = basePair(base);
rna.append(pair);
}
return rna.toString();
}
現在,我們可以用地圖作爲動詞了。在Java中,在Streams API中提供了一個函數:
char basePair(char base) {
if (pairs.containsKey(base))
return pairs.get(base);
else
throw new IllegalArgumentException("Not a base " + base);
}
String transcribe(String dna) {
return dna.codePoints()
.mapToObj(c -> (char) c)
.map(base -> basePair(base))
.collect(
StringBuilder::new,
StringBuilder::append,
StringBuilder::append)
.toString();
}
嗯
那麼,讓我們批評一下這個解決方案。可以說的最好的事情就是循環已經消失了。如果你想一想,循環是一種文書活動,我們真的不應該去關注大部分時間。通常,我們循環是因爲我們想爲集合中的每個元素做一些事情。我們真的這裏要做的是獲取這個輸入序列並從它生成一個輸出序列。流爲我們處理迭代的基本管理工作。事實上,它是一種設計模式-一種功能性設計模式-但是,我現在還不想提它的名字。我還不想把你嚇跑。
我不得不承認,代碼的其餘部分並沒有那麼好,這主要是因爲Java中的原語不是對象。第一點不偉大的地方是:
mapToObj(c -> (char) c)
我們必須這樣做,因爲Java對原語和對象的處理方式不同,而且儘管語言確實爲原語設置了包裝類,但是無法直接從字符串中獲取字符對象的集合。
另一個不那麼令人敬畏的地方是:
.collect(
StringBuilder::new,
StringBuilder::append,
StringBuilder::append)
還不清楚爲什麼要打電話append
兩次。我稍後會解釋,但現在時機不對。
我不打算爲這個密碼辯護-這太糟糕了。如果有一種方便的方法從字符串中獲取一個字符流對象,甚至是一個字符數組,那麼就沒有問題了,但我們還沒有得到一個。在Java中,處理原語不是FP的亮點。想想看,它甚至對OO編程都沒有好處。所以,也許我們不應該那麼癡迷於原語。如果我們把它們設計在代碼之外呢?我們可以爲基礎創建一個枚舉:
enum Base {
C, G, A, T, U;
}
而且,我們有一個類作爲一個包含一系列鹼基的一流集合:
class Sequence {
List<Base> bases;
Sequence(List<Base> bases) {
this.bases = bases;
}
Stream<Base> bases() {
return bases.stream();
}
}
現在,Transcriber
看起來是這樣的:
class Transcriber {
private Map<Base, Base> pairs = new HashMap<>();
Transcriber() {
pairs.put(C, G);
pairs.put(G, C);
pairs.put(A, U);
pairs.put(T, A);
}
Sequence transcribe(Sequence dna) {
return new Sequence(dna.bases()
.map(pairs::get)
.collect(toList()));
}
}
這樣好多了。這,這個,那,那個pairs::get
是方法引用;它引用get
方法的實例分配給pairs
變量。通過爲基創建類型,我們設計了無效輸入的可能性,因此需要basePair
方法消失,異常也會消失。這是Java的一個優勢,它本身不能在函數契約中強制執行類型。更重要的是,StringBuilder
也消失了。當您需要迭代一個集合、以某種方式處理每個元素以及構建一個包含結果的新集合時,Java流是很好的。這可能在你生命中寫的循環中佔了相當大的比例。大部分家務活,不是手頭真正工作的一部分,都是爲你做的。
在Clojure
撇開輸入不足不談,Clojure比Java版本要簡潔一些,並且它給我們提供了在字符串的字符上進行映射的難度。Clojure中最重要的抽象是序列;所有集合類型都可以視爲序列,字符串也不例外:
(def pairs {\C, "G",
\G, "C",
\A, "U",
\T, "A"})
(defn- base-pair [base]
(if-let [pair (get pairs base)]
pair
(throw (IllegalArgumentException. (str "Not a base: " base)))))
(defn transcribe [dna]
(map base-pair dna))
此代碼的業務端是最後一行。(map base-pair dna)
-這是值得指出的,因爲你可能已經錯過了。意思是map
這,這個,那,那個base-pair
函數對dna
字符串(表現爲序列)。如果我們希望它返回一個字符串而不是一個列表,這就是map
給我們,唯一需要的改變是:
(apply str (map base-pair dna))
在C#中
讓我們試試另一種語言。C#中解決方案的命令式方法如下所示:
namespace RnaTranscription
{
public class Transcriber
{
private readonly Dictionary<char, char> _pairs = new Dictionary<char, char>
{
{'C', 'G'},
{'G', 'C'},
{'A', 'U'},
{'T', 'A'}
};
public string Transcribe(string dna)
{
var rna = new StringBuilder();
foreach (char b in dna)
rna.Append(_pairs[b]);
return rna.ToString();
}
}
}
同樣,C#沒有向我們介紹我們在Java中遇到的問題,因爲C#中的字符串是可枚舉的,而且所有的“原語”都可以被視爲具有行爲的對象。
我們可以一種更實用的方式重寫程序,就像這樣,結果顯示它比JavaStreams版本要少得多。對於Java流中的“map”,請改爲C#中的“select”:
public string Transcribe(string dna)
{
return String.Join("", dna.Select(b => _pairs[b]));
}
或者,如果您願意,可以使用LINQ作爲其語法糖:
public string Transcribe(string dna)
{
return String.Join("", from b in dna select _pairs[b]);
}
我們爲什麼要循環?
你可能知道這個主意。如果您想到以前編寫循環的時間,通常您會嘗試完成以下工作之一:
- 將一種類型的數組映射爲另一種類型的數組。
- 通過查找滿足某種謂詞的數組中的所有項進行篩選。
- 確定數組中的任何項是否滿足某些謂詞。
- 從數組中累積計數、和或其他類型的累積結果。
- 將數組的元素按特定順序排序。
大多數現代語言中可用的函數式編程特性允許您完成所有這些功能,而無需編寫循環或創建集合來存儲結果。功能風格可以讓你省去那些家務工作,專注於真正的工作。此外,功能樣式允許您將操作鏈接在一起,例如,如果需要的話:
- 將數組的元素映射到另一種類型。
- 過濾掉一些映射的元素。
- 對過濾過的元素進行排序。
在命令式風格中,這需要多個循環或一個循環,其中包含大量代碼。不管是哪種方式,它都涉及大量的行政工作,這些工作掩蓋了項目的真正目的。在功能風格中,您可以分發管理工作,並直接表達您的意思。稍後,我們將看到更多的例子,功能風格可以使您的生活更輕鬆。
下次
當我學習函數式編程和習慣JavaStreams API時,每次我寫一個循環時,我會做的下一件事就是考慮如何將它重寫爲流。這通常是可能的。在C#中,ReSharper
VisualStudio插件自動建議您進行這種重構。既然我已經內化了功能風格,我就直奔流程,除非我真的需要一個循環,否則就不需要循環了。在下一篇文章中,我們將繼續探索一流的函數,以及如何使用函數樣式使代碼更具表現力。filter
和reduce
。繼續關注!