Golang 函數式編程簡述

2021-03-06 Go語言中文網
先吐一吐

一般而言,Golang 的 Functional 編程都會呈現出惡形。表面上看,惡形是因為 Golang 缺少一些必要的語法糖;本質上說,惡形源於它沒有高級抽象能力,正如泛型的缺失。

惡形

醜在何處?這裡有個例子:

func main() {
    var list = []string{"Orange", "Apple", "Banana", "Grape"}
    // we are passing the array and a function as arguments to mapForEach method.
    var out = mapForEach(list, func(it string) int {
        return len(it)
    })
    fmt.Println(out) // [6, 5, 6, 5]
}

// The higher-order-function takes an array and a function as arguments
func mapForEach(arr []string, fn func(it string) int) []int {
    var newArray = []int{}
    for _, it := range arr {
        // We are executing the method passed
        newArray = append(newArray, fn(it))
    }
    return newArray
}

很好,此包裝看起來不錯,是不是?fp形態看起來看著也比較舒服。我想……嗯,我想包裝一下,令其通用化,給別人用。於是就糟了,支持int64需要這樣:

func mapInt64ForEach(arr []int64, fn func(it int64) int) []int {
    var newArray = []int{}
    for _, it := range arr {
        // We are executing the method passed
        newArray = append(newArray, fn(it))
    }
    return newArray
}

這才剛剛開始,你開始為 bool,uint64,……寫出 n 個版本,記住,函數名也要改。

對照:C++模板實現

所以我會說,golang 的高階函數,functional,實際上真的會順理成章地惡形。

天知道,現在我多數情況下都會採用 golang 進行架構設計,然而我心裡一直有一種難以言說的失望。如果在 C++11:

class Print {
public:
    void operator()(int elem) const {
        std::cout << elem << " ";
    }
};

func a(){
    std::vector<int> vect;
    for (int i=1; i<10; ++i) {
        vect.push_back(i);
    }

    Print print_it;
    std::for_each (vect.begin(), vect.end(), print_it);
    std::cout << std::endl;
}

為了節約字節,這裡借用 stdlib 的 for_each 而不是自行實現一份,但 foreach 的實現其實也真心簡單。

重點在於,我現在要操作 string 了,只需要重寫一份 Print 就可以了,我並不需要做 n 份 for_each 實現。如果有必要,我可以實現一份泛型的 Print 模板類,於是什麼都不必重新實現副本,直接使用就可以了。

收結

還沒有開始研究 Golang Functional Programming 的美麗的地方,反而先貶損了一番,真是情非得已啊!

好,現在來講 functional 的好的用法。

雖然 functional 並不易於泛型復用,但在具體類型,又或者是通過 interface 抽象後的間接泛型模型中,它是改善程序結構、外觀、內涵、質量的最佳手段。

所以你會看到,在成熟的類庫中,無論是標準庫還是第三方庫,functional 模式被廣泛地採用。

所以,下面會對這些應用作一番歸納和展示,目的在於提供一系列最佳實踐的陳列並希望籍此有助於提高你的具體編碼能力。

什麼是 Functional Programming

首先我們需要研究一下什麼是高階函數編程?所謂的 Functional Programming,一般被譯作函數式編程(以 λ演算 為根基)。

函數式編程,是指忽略(通常是不允許)可變數據(以避免它處可改變的數據引發的邊際效應),忽略程序執行狀態(不允許隱式的、隱藏的、不可見的狀態),通過函數作為入參,函數作為返回值的方式進行計算,通過不斷的推進(迭代、遞歸)這種計算,從而從輸入得到輸出的編程範式。在函數式編程範式中,沒有過程式編程所常見的概念:語句,過程控制(條件,循環等等)。此外,在函數式編程範式中,具有引用透明(Referential Transparency)的特性,此概念的含義是函數的運行僅僅和入參有關,入參相同則出參必然總是相同,函數本身(被視作f(x))所完成的變換是確定的。

順便一提,柯裡化 是函數式編程中相當重要的一個理論和技術。完全拋棄過程式編程的 if、then、while 之類的東西,完全的函數迭代,一般是純函數式支持者最為喜愛的,而諸如 Start(...).Then(...).Then(...).Else(...).Finally(...).Stop() 這類風格往往會被視為異教徒。

這確實很有意思。原教旨主義(按:非指該術語的宗教性原意,僅用於在此處引申以指代 Pure 黨)在任何地方都是確定及存在的。

表徵

總結一下,函數式編程具有以下的表徵:

No Data mutations 沒有數據易變性No side effects 沒有邊際效應(沒有副作用)Pure functions only 只有純粹的函數,沒有過程控制或者語句First-class function 頭等函數身份First-class citizen 函數具有一等公民身份Higher-order functions[1] 高階函數,可以出現在任何地方[Closures](https://en.wikipedia.org/wiki/Closure_(computer_programming "Closures")) 閉包 - 具有上級環境捕俘能力的函數實例Currying[2] 柯裡化演算 - 規約多個入參到單個,等等[Recursion](https://en.wikipedia.org/wiki/Recursion_(computer_science "Recursion")) 遞歸運算 - 函數嵌套迭代以求值,沒有過程控制的概念Lazy evaluations[3] / Evaluation strategy[4] 惰性求值 - 延遲被捕俘變量的求值到使用時Referential transparency[5] 引用透明性 - 對於相同的輸入,表達式的值必須相同,並且其評估必須沒有副作用

由於重心不在高級 FP 編程和相關學習,因此無法深入討論純種的 FP 柯裡化變換,這是個傳統 C 程式設計師較難轉彎的東西。

Golang 中的函數式編程:高階函數

在 Golang 中,函數式編程這個概念已經被重新包裝和闡釋過了,諸如一切都是函數,函數是值,等等。所以本文中可能會避免函數式編程的提法,往往會以高階函數編程的提法代替之。

需要強調的是,函數式編程並非僅僅是高階函數編程,高階函數編程也不能包容函數式編程,這是兩種不同的概念,只是在表現形式上彼此之間有所交集。而對於 Golang 來說,既沒有真正的純粹的函數式編程,當然其實 Golang 也沒有純粹的面向對象編程,Golang 對這兩者都採用不同的、略有極端的手法進行了改頭換面、也包含一些與時俱靜的先進性理論的融合。當然,在大多數場景上,我們還是認同 Golang 採用自己的哲學支持這樣的多範式編程。

在 Golang 中,高階函數很多時候是為了實現某種算法的關鍵粘合劑。

例如,

基本的閉包(Closure)結構

在函數、高階函數身屬一階公民的程式語言中,你當然可以將函數賦值為一個變量、複製給一個成員,作為另一函數的參數(或之一)進行傳參,作為另一函數的返回值(或之一)。

Golang 具備上述支持。

然而,Golang 沒有匿名函數外擴或縮減的語法糖,實際上,Golang 沒有大多數的語法糖,這是它的設計哲學所決定的。所以你必須採用有點冗長的代碼書寫,而無法讓語法顯得簡潔。在這一點上,C++ 使用 operator() 的方式能夠縮寫,採用 [] 捕俘語法能夠簡寫閉包函數,Java 8 以後在匿名閉包的簡化語法上行進的很厲害,但還比不上 Kotlin,Kotlin 則更進一步允許函數調用的最後一個閉包被外擴到調用語法之後並以語句塊的形式而存在:

fun invoker(p1 string, fn fun(it int)) {
  // ...
}

invoker("ok") { /* it int */ ->
  // ...
}

但在 Golang 中,你需要完整地編寫高階函數的原型,哪怕你對其作了 type 定義也沒用:

type Handler func (a int)

func xc(pa int, handler Handler) {
  handler(pa)
}

func Test1(){
  xc(1, func(a int){ // <- 老老實實地再寫一遍原型吧
    print (a)
  })
}

值得注意的是,一旦 Handler 的原型發生變化,庫作者和庫使用者都會很痛苦地到處查找和修改。

對的,你將在這裡學到一個編程的重要原則,接口設計必須考慮穩固性。只要接口穩固,當然不會有 Handler 的原型需要調整的可能性,對不對?呵呵。

吐糟並不是我的愛好,所以點到為止。

運算子 Functor

算子通常是一個簡單函數(但也未必如此),總控部分通過替換不同算子來達到替換業務邏輯的實際實現算法:

func add(a, b int) int { return a+b }
func sub(a, b int) int { return a-b }

var operators map[string]func(a, b int) int

func init(){
  operators = map[string]func(a, b int) int {
    "+": add,
    "-": sub,
  }
}

func calculator(a, b int, op string) int {
  if fn, ok := operators[op]; op && fn!=nil{
    return fn(a, b)
  }
  return 0
}

遞歸 Recursion

斐波拉契,階乘,Hanoi 塔,分形等是典型的遞歸問題。

在支持遞歸的程式語言中,怎麼運用遞歸往往是一個較難的知識點。個人的經驗而言,日思夜想,豁然開朗是完全掌握遞歸的必然過程。

函數式編程中,遞歸是個遍地走的概念。這在 Golang 中被具現為高階函數返回值。

下面這個示例簡單地實現了階乘運算:

package main

import "fmt"

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

func main() {
    fmt.Println(factorial(10)) // 3628800
}

但我們應該採用 Functional Programming 的風格重新實現它:

package main

import "fmt"

func factorialTailRecursive(num int) int {
    return factorial(1, num)
}

func factorial(accumulator, val int) int {
    if val == 1 {
        return accumulator
    }
    return factorial(accumulator*val, val-1)
}

func main() {
    fmt.Println(factorialTailRecursive(10)) // 3628800
}

大多數現代程式語言對於尾遞歸都能夠很好地在編譯階段進行隱含性地優化,這是一個編譯原理中的重要的優化點:尾遞歸總是能夠退化為無需嵌套函數調用的循環結構。

所以我們在上面進行了一定的改寫,從而將階乘運算實現為了 Functional 的方式,在令其具備良好的可讀性的同時,還能令其避開嵌套函數調用時的棧消耗問題。

採用高階函數的遞歸

借用 fibonacci 的實現我們簡單地示例返回一個函數的方式來實現遞歸:

package main

import "fmt"

func fibonacci() func() int {
    a, b := 0, 1

    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    f := fibonacci()

    for i := 0; i < 10; i++ {
        fmt.Println(f())
    }
}

// 依次輸出:1 1 2 3 5 8 13 21 34 55

延遲計算 Delayed Calculating

使用高階/匿名函數的一個重要用途是捕俘變量和延遲計算,也即所謂的惰性計算(Lazy evaluations[6])。

在下面這個例子中,

func doSth(){
  var err error
  defer func(){
    if err != nil {
      println(err.Error())
    }
  }()
  
  // ...
  err = io.EOF
  return
}

doSth() // printed: EOF

在 defer 的高階函數中,捕俘了外部作用域中的 err 變量,doSth 的整個運行周期中對 err 的設定,最終能夠在 defer 函數體中被正確計算得到。如果沒有捕俘和延遲計算機制的話,高階函數體中對 err 的訪問就只會得到 nil 值,因為這是捕俘時刻 err 的具體值。請注意為了縮減示例代碼規模我們採用了 defer 來演示,實際上使用 go routines 可以得到同樣的效果,換句話說,在高階函數中對外部作用域的訪問是動態地延遲地計算的。

例外:循環變量

當然在這裡有一個著名的坑:循環變量並不被延遲計算(由於總是會發生循環被優化的動作,因而循環變量在某種角度看是不存在的偽變量)。

func a(){
  for i:=0; i<10; i++ {
    go func(){
      println(i)
    }()
  }
}

func main(){ a() }
// 1. 結果會是 全部的 0
// 2. 在新版本的 Golang 中,將無法通過編譯,報錯為:
// loop variable i captured by func literal

想要得到符合直覺的結果,你需要傳參該循環變量:

func a(){
  for i:=0; i<10; i++ {
    go func(ix int){
      println(ix)
    }(i)
  }
}

我老實交待,這個坑我踩過,單步調試才發現。在一個大型系統中,找到這麼一個錯誤,你會充滿疲憊感。而它是表示你的編程水平不行嗎?放心,這並不是,我不是因為自己啃過才放低標準的,實在是 Golang 有夠噁心的。

Functional Options

作為一個類庫作者,遲早會面臨到接口變更問題。或者是因為外部環境變化,或者是因為功能升級而擴大了外延,或者是因為需要廢棄掉過去的不完善的設計,或者是因為個人水平的提升,無論哪一種理由,你都可能會發現必須要修改掉原有的接口,替換之以一個更完美的新接口。

舊的方式

想像下有一個早期的類庫:

package tut

func New(a int) *Holder {
  return &Holder{
    a: a,
  }
}

type Holder struct {
  a int
}

後來,我們發現需要增加一個布爾量 b,於是修改 tut 庫為:

package tut

func New(a int, b bool) *Holder {
  return &Holder{
    a: a,
    b: b,
  }
}

type Holder struct {
  a int
  b bool
}

沒過幾天,現在我們認為有必要增加一個字符串變量,tut 庫不得不被修改為:

package tut

func New(a int, b bool, c string) *Holder {
  return &Holder{
    a: a,
    b: b,
    c: c,
  }
}

type Holder struct {
  a int
  b bool
  c string
}

想像一下,tut 庫的使用者在面對三次接口 New() 的升級時,會有多少 MMP 要拋出來。

對此我們需要 Functional Options 模式來解救之。

新的方式

假設 tut 的第一版我們是這樣實現的:

package tut

type Opt func (holder *Holder)

func New(opts ...Opt) *Holder {
  h := &Holder{ a: -1, }
  for _, opt := range opts {
    opt(h)
  }
  return h
}

func WithA(a int) Opt {
  return func (holder *Holder) {
    holder.a = a
  }
}

type Holder struct {
  a int
}

//...
// You can:
func vv(){
  holder := tut.New(tut.WithA(1))
  // ...
}

同樣地需求變更發生後,我們將 b 和 c 增加到現有版本上,那麼現在的 tut 看起來是這樣的:

package tut

type Opt func (holder *Holder)

func New(opts ...Opt) *Holder {
  h := &Holder{ a: -1, }
  for _, opt := range opts {
    opt(h)
  }
  return h
}

func WithA(a int) Opt {
  return func (holder *Holder) {
    holder.a = a
  }
}

func WithB(b bool) Opt {
  return func (holder *Holder) {
    holder.b = b
  }
}

func WithC(c string) Opt {
  return func (holder *Holder) {
    holder.c = c
  }
}

type Holder struct {
  a int
  b bool
  c string
}

//...
// You can:
func vv(){
  holder := tut.New(tut.WithA(1), tut.WithB(true), tut.WithC("hello"))
  // ...
}

由於代碼沒有什麼複雜度,所以我不必逐行解說實例代碼了。你將會得到一個直觀的感受是,原有的 tut 的用戶端遺留代碼(例如 vv() )實際上可以完全不變,透明地應對 tut 庫本身的升級動作。

這裡要提到這種編碼範式的特點和作用包括:

a. 在實例化 Holder 時,我們現在可以變相地使用不同數據類型的任意多可變參數了。

b. 藉助既有的範式模型,我們還可以實現任意的複雜的初始化操作,用以為 Holder 進行不同的構建操作。

c. 既然是範式,那麼其可讀性、可拓展性需要被研究——很明顯,現在的這一範式能得到高分。

d. 在大版本升級時,New(...) 的接口穩固性相當好,無論你如何調整內在算法及其實現,對這樣的第三方庫的調用者來說,沒有什麼需要改變的。

小結

本文參考了 dcode 提到的一些知識,此外,7 Easy functional programming techniques in Go[7] 也介紹了很多 FP 知識。

本文沒有打算在 FP 方面進行展開,因為在筆者的認識中,Lisp,Haskell 之類的語言環境下討論 FP 才是有意義的,Golang 當中雖然對 FP 有很多的傾向,但它當然是過程式的 PL ,只是說對 FP 有很強的支持而已。

但這些細緻的看法分野,只是學術上的辨析。所以本文只是在具體實作方面歸納一些具有相關性的慣用法。

或許以後就這個方面會再做歸納,也許會有更深入的認識。

本文作者:hedzr原文連結:https://hedzr.github.io/golang/fp/golang-functional-programming-in-brief/

參考資料[1]

Higher-order functions: https://en.wikipedia.org/wiki/Higher-order_function

[2]

Currying: https://en.wikipedia.org/wiki/Currying

[3]

Lazy evaluations: https://en.wikipedia.org/wiki/Lazy_evaluation

[4]

Evaluation strategy: https://en.wikipedia.org/wiki/Evaluation_strategy

[5]

Referential transparency: https://en.wikipedia.org/wiki/Referential_transparency

[6]

Lazy evaluations: https://en.wikipedia.org/wiki/Lazy_evaluation

[7]

7 Easy functional programming techniques in Go: https://deepu.tech/functional-programming-in-go/

相關焦點

  • 函數式編程
    ,我們會看到如下函數式編程的長相:函數式編程的三大特性:immutable data 不可變數據:像Clojure一樣,默認上變量是不可變的,如果你要改變變量,你需要把變量copy出去修改。函數式編程的幾個技術map & reduce :這個技術不用多說了,函數式編程最常見的技術就是對一個集合做Map和Reduce操作。這比起過程式的語言來說,在代碼上要更容易閱讀。
  • 函數式編程聖經
    上帝看到約翰·麥卡錫發明了表處理語言 Lisp,卻只用來學術研究,很是傷心,就把 Lisp 解釋器的秘密告訴了他的學生史蒂芬·羅素,史蒂芬·羅素將eval函數在IBM 704機器上實現後,函數式編程的大門第一次向人類打開了。
  • 高階函數與函數式編程
    根據程式語言理論,一等對象必須滿足以下條件:Python 函數同時滿足這幾個條件,因而也被稱為 一等函數 。高階函數 則是指那些以函數為參數,或者將函數作為結果返回的函數。對高階函數稍加利用,便能玩出很多花樣來。本節從一些典型的案例入手,講解 Python 函數高級用法。
  • 函數式編程,真香
    最開始接觸函數式編程的時候是在小米工作的時候,那個時候看老大以前寫的代碼各種 compose,然後一些 ramda 的一些工具函數,看著很吃力,然後極力吐槽函數式編程,現在回想起來,那個時候的自己真的是見識短淺,只想說,'真香'。
  • Python中的函數式編程
    (英語:functional programming)或稱函數程序設計,又稱泛函編程,是一種編程範型,它將電腦運算視為數學上的函數計算,並且避免使用程序狀態以及易變對象。函數程式語言最重要的基礎是λ演算(lambda calculus)。而且λ演算的函數可以接受函數當作輸入(引數)和輸出(傳出值)。
  • Java如何支持函數式編程?
    Java 8開始,引入了函數式編程接口與Lambda表達式,便於開發者寫出更少更優雅的代碼。什麼是函數式編程?函數式編程的特點是什麼?本文通過代碼實例,從Stream類、Lambda表達式和函數接口這三個語法概念來分享Java對函數式編程的支持。文末福利:Java微服務沙箱體驗挑戰。
  • Kotlin函數式編程
    那麼在函數式編程中當然一切皆是函數。在Kotlin中函數式的地位和對象一樣高,你可以在方法中輸入函數,也可以返回函數。函數式編程FP特徵:函數式編程核心概念:函數是「一等公民」:是指函數與其他數據類型是一樣的,處於平等地位。函數可以作為其他函數的參數傳入,也可以作為其他函數的返回值返回。
  • 大數據入門:Scala函數式編程
    提到Scala,首先會提到的一個概念,就是函數式編程,這也是Scala語言區別與其他程式語言的典型特徵。Scala是一門多範式(multi-paradigm)的程式語言,設計初衷是要集成面向對象編程和函數式編程的各種特性。
  • 白話 Python 的函數式編程
    今天和大家聊聊 Python 的函數式編程特性。
  • 在線學習Go編程的頂級Golang教程
    Top Golang Tutorials2.學習如何編碼:Google的Go(golang)程式語言向具有15年以上教學經驗的計算機科學大學教授學習編程。Go是您今天可以學習的最好的程式語言。Go還是當今收入最高的程式語言。快來了解有史以來最偉大的程式語言。您將留下資源和代碼示例,開始真正使用所有軟體和應用程式。3.使用Google的Go(golang)程式語言進行Web開發向具有15年以上教學經驗的計算機科學大學教授學習Web編程。
  • 函數式編程,我心中的 C 位!
    最常見的三種範式分別是面向對象程序設計、命令式程序設計和函數式程序設計。這三種思想體系並無優劣之分,通常我們都需要選擇正確的工具來完成工作。大多數軟體工程師對於函數式編程的概念並不太熟悉。實際上,歷史上的第二個程式語言Lisp就屬於函數式範式。
  • 【第1679其】函數式編程淺析
    說到抽象,我們再回到編程,從某一種角度看,編程和詩歌還挺像的,都是為了講一個「故事」。只不過在編程中,我們用的是代碼的組合。在閱讀代碼時,我們都希望能儘快了解其主旨,這要求代碼具備較好的可讀性,函數式編程為提高代碼的可讀性提供了一種方案。為什麼要使用函數式編程在我看來,使用函數式編程最大的好處在於其能提高我們代碼的可讀性。
  • java8的函數式編程解析
    其實在java8就已經有java的函數式編程寫法,只是難度較大,大家都習慣了對象式用法,但在其它語言中都有函數式的用法,如js,scala,函數式其實是抽象到極致的思想。什麼是函數式編程 函數式編程並不是Java新提出的概念,其與指令編程相比,強調函數的計算比指令的計算更重要;與過程化編程相比,其中函數的計算可以隨時調用。
  • 為什麼函數式編程在Java中很危險?
    在我的日常工作中,我身邊的開發者大多是畢業於CS編程頂級院校比如MIT、CMU以及Chicago,他們初次涉及的語言是Haskell、Scheme及Lisp。他們認為函數式編程是一種自然的、直觀的、美麗的且高效的編程樣式。
  • 現代C++函數式編程
    導讀: 本文作者從介紹函數式編程的概念入手,分析了函數式編程的表現形式和特性,最終通過現代C++的新特性以及一些模板雲技巧實現了一個非常靈活的pipeline
  • 函數式編程中的副作用概念
    前言為了清楚起見,請記住,副作用不是必需的壞事,有時副作用是有用的(尤其是在函數式編程範式之外)。今天聊一聊函數式編程中的隔離思想,它所想隔離的就是「副作用」我們先從其他角度來聊一聊副作用這個概念。函數式編程中的副作用概念如果函數有副作用,我們將其稱為過程函數式編程是基於沒有副作用的這樣一個簡單的前提。在這種範例中,副作用是被排斥的。如果函數有副作用,我們將其稱為過程,或者命令式。因此函數沒有副作用。
  • 10分鐘學會python函數式編程
    一旦你設置了一個變量,它就永遠保持這種狀態(注意,在純函數式語言中,它們不是變量)。因此,函數式編程沒有副作用。副作用指的是函數改變它自己以外的東西。讓我們看一些典型Python代碼的示例:開頭我說過純函數式程式語言沒有變量。更高階的函數使這變得更容易。Python中的所有函數都是一等公民。
  • 函數式編程二 異常處理
    基礎函數式編程一在java中用函數式的方式去做事情,Happy Path確實很好玩,但是編程中最不好玩的就是異常的情況。
  • golang每日一題(init函數和main函數)
    後來才發現這個方法是golang中特殊的init()函數。當一個包被導入時,如果該包還導入了其它的包,那麼會先將其它包導入進來,然後再對這些包中的包級常量和變量進行初始化,接著執行init函數。如果所有的導入包加載完畢,最後會對main包的包級常量和變量初始化,然後執行main包的init()函數,最後在main方法中作為程序的起點執行。
  • golang之context使用
    背景golang中並發編程的三種實現方式:chan管道、waitGroup和Context。本篇將重點介紹context的使用,告訴大家基本的使用方式,做到會用。Context概念介紹context譯為上下文,golang在1.6.2的時候還沒有自己的context,在1.7的版本中就把golang.org/x/net/context包被加入到了官方的庫中。golang 的 Context包,是專門用來處理多個goroutine之間與請求域的數據、取消信號、截止時間等相關操作。