使用 Golang 進行函數式編程

爲什麼要用 Go 練習函數式編程?簡而言之,正是由於缺少狀態和可變數據,函數式編程使您的代碼更易讀,更易於測試且不太複雜。如果遇到錯誤,只要不違反函數式編程規則,就可以快速調試應用程序。當函數被隔離時,您不必處理影響輸出的隱藏狀態的更改。

軟件工程師兼作者 Eric Elliot 定義了以下函數編程。

函數式編程是通過組合純函數,避免共享狀態,可變數據和副作用來構建軟件的過程。函數式編程是聲明性的,而不是命令性的,應用程序狀態通過純函數流動。與面向對象的編程相反,後者通常將應用程序狀態與對象中的方法共享並放置在對象中。

我將更進一步:函數式編程(如面向對象和過程式編程)代表着範式的轉變。它在編寫代碼時採用了獨特的思維方式,並引入了一套全新的規則。

4 個重要概念

要完全掌握函數式編程,必須首先了解以下相關概念。

  1. 純函數和冪等

  2. 副作用

  3. 函數構成

  4. 共享狀態和不變數據

讓我們快速回顧一下。

純函數和冪等

如果給純函數提供相同的輸入,則它總是會返回相同的輸出。此屬性也稱爲冪等。冪等意味着函數應始終返回相同的輸出,而與調用次數無關。

副作用

純函數不能有任何副作用。換句話說,您的函數無法與外部環境進行交互。

例如,函數式編程將 API 調用視爲副作用。爲什麼?因爲 API 調用被認爲是不受您直接控制的外部環境。一個 API 可能有幾個不一致的地方,例如超時或失敗,或者甚至可能返回意外的值。它不適合純函數的定義,因爲每次調用 API 時都需要一致的結果。

其他常見的副作用包括:

函數構成

函數構成的基本思想很簡單:將兩個純函數組合在一起以創建一個新函數。這意味着爲相同輸入產生相同輸出的概念在這裏仍然適用。因此,從簡單的純函數開始創建更高級的函數很重要。

共享狀態和不變數據

函數式編程的目的是創建不保持狀態的函數。共享狀態尤其會在純函數中引入副作用或可變性問題,使它們變得不純粹。

但是,並非所有狀態都不好。有時,必須有一個狀態才能解決特定的軟件問題。函數式編程的目的是使狀態可見和顯式,以消除任何副作用。程序使用不可變數據結構從純函數中派生新數據。這樣,就不需要可能引起副作用的可變數據。


現在我們已經涵蓋了基礎,讓我們定義一些在 Go 中編寫功能代碼時要遵循的規則。

功能編程規則

如前所述,函數式編程是一種範例。因此,很難爲這種編程風格定義確切的規則。也不一定總是遵循這些規則。有時,您確實需要依賴擁有狀態的功能。

但是,爲了儘可能嚴格地遵循函數式編程範例,我建議堅持以下準則。

我們在函數式編程中經常遇到的一個好的 “副作用” 是強大的模塊化。函數式編程不是自上而下地進行軟件工程,而是鼓勵自下而上的編程風格。首先定義模塊,把將來可能使用的同類純函數組合起來。接下來,開始編寫那些小的,無狀態的獨立函數,以創建您的第一個模塊。

實質上我們是在創建黑匣子。稍後,我們將按照自下而上的方式將各個塊捆綁在一起。這使您可以建立強大的測試基礎,尤其是可以驗證純函數正確性的單元測試。

一旦您可以信任您的模塊,就可以將模塊捆綁在一起了。開發過程中的這一步還涉及編寫集成測試,以確保兩個組件的正確集成。

5 個示例

爲了更全面地描述 Go 函數編程的工作原理,讓我們探索五個基本示例。

  1. 更新字符串

這是純函數的最簡單示例。通常,當您要更新字符串時,請執行以下操作。

    name:= "first name"
    name:= name + "last name"

上面的代碼片段不符合函數式編程的規則,因爲不能在函數內修改變量。因此,我們應該重寫代碼段,以便每個值都具有自己的變量。

下面的代碼段中的代碼更具可讀性。

    firstname := "first"
    lastname := "last"
    fullname := firstname + " " + lastname

在查看非函數式代碼段時,我們必須瀏覽程序以確定最新狀態,纔可以找到 name 變量的結果值。這需要更多的精力和時間來了解該功能的作用。

  1. 避免更新數組

如前所述,函數式編程的目的是使用不變數據通過純函數得出新的不變數據狀態。我們可以在每次需要更新數組時創建一個新數組來實現

在非函數式編程中,更新數組如下:

    names := [3]string{"Tom""Ben"}

    // Add Lucas to the array
    names[2] = "Lucas"

讓我們根據功能編程範例進行嘗試。

    names := []string{"Tom""Ben"}
    allNames := append(names, "Lucas")
  1. 避免更新 map

這是函數編程的極端示例。想象一下,我們有一個帶有字符串類型的鍵和整數類型的值的 map。該 map 包含我們仍然留在家中的水果數量。但是,我們剛購買了蘋果,並希望將其添加到列表中。

    fruits := map[string]int{"bananas": 11}

    // Buy five apples
    fruits["apples"] = 5

我們可以在功能編程範例下完成相同的功能。

    fruits := map[string]int{"bananas": 11}
    newFruits := map[string]int{"apples": 5}

    allFruits := make(map[string]int, len(fruits) + len(newFruits))


    for k, v := range fruits {
        allFruits[k] = v
    }


    for k, v := range newFruits {
        allFruits[k] = v
    }

由於我們不想修改原始 map,因此代碼會遍歷兩個 map,並將值添加到新 map。這樣,數據保持不變。

正如您可能通過代碼的長度可以看出的那樣,此代碼段的性能比對 map 進行簡單的可變更新要差得多,因爲我們要遍歷兩個 map。這是您爲代碼性能交換更好的代碼質量的時間。

  1. 高階函數和柯里化

大多數程序員在他們的代碼中通常不會使用高階函數,但是在函數式編程中柯里化很方便。

假設我們有一個簡單的函數,將兩個整數相加。儘管這已經是一個純粹的功能,但我們希望詳細說明該示例,以展示如何通過 curring 創建更高級的功能。

在這種情況下,我們只能接受一個參數。接下來,該函數返回另一個函數作爲閉包。因爲該函數返回一個閉包,所以它將記住外部範圍,該範圍包含初始輸入參數。

    func add (x int)func (y int)int {
        return func(y int)int {
            return x + y
        }
    }

現在,讓我們嘗試 currying 並創建更多高級純函數。

    func main() {
        // Create more variations
        add10 := add(10)
        add20 := add(20)

        // Currying
        fmt.Println(add10(1)) // 11
        fmt.Println(add20(1)) // 21
}

這種方法在函數式編程中很常見,儘管您通常不會在範式之外看到它。

  1. 遞歸

遞歸是一種通常用於規避循環使用的軟件模式。因爲循環始終保持內部狀態以明確循環在哪一輪,所以我們不能在函數式編程範式下使用循環。

例如,下面的代碼片段嘗試計算數字的階乘。階乘是一個整數與其下所有整數的乘積。因此,階乘 4 等於 24(= 4 * 3 * 2 * 1)。

通常,您將爲此使用循環。

    func factorial(fac int) int {
        result := 1
        for ; fac > 0; fac-- {
            result *= fac
        }
        return result
    }

爲了在函數式編程範例中完成此任務,我們需要使用遞歸。換句話說,我們將一遍又一遍地調用相同的函數,直到達到階乘的最低整數爲止。

    func calculateFactorial(fac int) int {
        if fac == 0 {
            return 1
        }
        return fac * calculateFactorial(fac - 1)
    }

結論

讓我們總結一下我們從函數式編程中學到的知識:

轉自:

lvsq.net/2020/03/fp-in-go/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/p84Vus6xeDYWhVtVqr3UuQ