1. 程式人生 > >功能風格:Lambda函式和地圖

功能風格:Lambda函式和地圖

一級函式: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]);

這,這個,那,那個stdlibC中的庫為不同型別的排序例程提供了一組函式。它們都能夠對任何型別的資料進行排序:程式設計師所需要的唯一幫助就是提供一個比較資料集的兩個元素並返回的函式。-11,或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 = [A1A2A3A4]地圖f過關A手段應用f中的每一個元素A:

  • A1 → f (A1) = a1‘
  • A2 → f (A2) = a2‘
  • A3 → f (A3) = A3‘
  • A4 → f (A4) = A4‘

然後,按照與輸入相同的順序組裝結果陣列:

A‘=地圖(fA ) = [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]);
}

我們為什麼要迴圈?

你可能知道這個主意。如果您想到以前編寫迴圈的時間,通常您會嘗試完成以下工作之一:

  • 將一種型別的陣列對映為另一種型別的陣列。
  • 通過查詢滿足某種謂詞的陣列中的所有項進行篩選。
  • 確定陣列中的任何項是否滿足某些謂詞。
  • 從陣列中累積計數、和或其他型別的累積結果。
  • 將陣列的元素按特定順序排序。

大多數現代語言中可用的函數語言程式設計特性允許您完成所有這些功能,而無需編寫迴圈或建立集合來儲存結果。功能風格可以讓你省去那些家務工作,專注於真正的工作。此外,功能樣式允許您將操作連結在一起,例如,如果需要的話:

  1. 將陣列的元素對映到另一種型別。
  2. 過濾掉一些對映的元素。
  3. 對過濾過的元素進行排序。

在命令式風格中,這需要多個迴圈或一個迴圈,其中包含大量程式碼。不管是哪種方式,它都涉及大量的行政工作,這些工作掩蓋了專案的真正目的。在功能風格中,您可以分發管理工作,並直接表達您的意思。稍後,我們將看到更多的例子,功能風格可以使您的生活更輕鬆。

下次

當我學習函數語言程式設計和習慣JavaStreams API時,每次我寫一個迴圈時,我會做的下一件事就是考慮如何將它重寫為流。這通常是可能的。在C#中,ReSharper VisualStudio外掛自動建議您進行這種重構。既然我已經內化了功能風格,我就直奔流程,除非我真的需要一個迴圈,否則就不需要迴圈了。在下一篇文章中,我們將繼續探索一流的函式,以及如何使用函式樣式使程式碼更具表現力。filterreduce。繼續關注!