Golang 函數式編程簡述

【導讀】 go 語言的函數式編程要怎麼做?本文介紹了 golang 中函數式編程的實踐。

先吐一吐

一般而言,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,一般被譯作函數式編程(以 λ演算 1 爲根基)。

函數式編程,是指忽略(通常是不允許)可變數據(以避免它處可改變的數據引發的邊際效應),忽略程序執行狀態(不允許隱式的、隱藏的、不可見的狀態),通過函數作爲入參,函數作爲返回值的方式進行計算,通過不斷的推進(迭代、遞歸)這種計算,從而從輸入得到輸出的編程範式。在函數式編程範式中,沒有過程式編程所常見的概念:語句,過程控制(條件,循環等等)。

此外,在函數式編程範式中,具有引用透明(Referential Transparency)的特性,此概念的含義是函數的運行僅僅和入參有關,入參相同則出參必然總是相同,函數本身(被視作 * f(x)*)所完成的變換是確定的。

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

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

表徵

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

  1. No Data mutations 沒有數據易變性

  2. No implicit state 沒有隱式狀態

  3. No side effects 沒有邊際效應(沒有副作用)

  4. Pure functions only 只有純粹的函數,沒有過程控制或者語句

  5. First-class function 頭等函數身份

  6. First-class citizen 函數具有一等公民身份

  7. Higher-order functions 高階函數,可以出現在任何地方

  8. Closures 閉包 - 具有上級環境捕俘能力的函數實例

  9. Currying 柯里化演算 2 - 規約多個入參到單個,等等

  10. Recursion 遞歸運算 - 函數嵌套迭代以求值,沒有過程控制的概念

  11. Lazy evaluations / Evaluation strategy 惰性求值 - 延遲被捕俘變量的求值到使用時

  12. Referential transparency 引用透明性 - 對於相同的輸入,表達式的值必須相同,並且其評估必須沒有副作用

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

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

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

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

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

例如,

  1. 基本的閉包結構

  2. 遞歸

  3. 函子 / 運算子

  4. 惰性計算

  5. 可變參數:Functional Options

基本的閉包(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(https://en.wikipedia.org/wiki/Lazy_evaluation))。

在下面這個例子中,

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(…) 的接口穩固性相當好,無論你如何調整內在算法及其實現,對這樣的第三方庫的調用者來說,沒有什麼需要改變的。

小結

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

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

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

轉自:

hedzr.com/golang/fp/golang-functional-programming-in-brief/

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

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