通過 Go 示例理解函數式編程思維

一個孩子要嘗試 10 次、20 次才肯接受一種新的食物,我們接受一種新的範式,大概不會比這個簡單。-- 郭曉剛 《函數式編程思維》譯者

函數式編程 (Functional Programming, 簡稱 fp) 是一種編程範式,與命令式編程(Imperative Programming)、面向對象編程(OOP)、泛型編程(Generics Programming)、邏輯編程 (logic Programming)[1] 等是一類的概念。

注:儘管面向對象範式引入了新的編程思想和技術,但它本質上與命令式編程一樣,都關注程序的狀態和如何通過改變狀態來控制程序的執行流程,因此,OOP 仍然屬於命令式編程的一個分支。OOP 可以看作是命令式編程的一種擴展和補充,它增強了代碼的模塊化、複用性和可維護性。在接下來,我會統一使用命令式編程範式來指代它們。

但幾十年的編程語言的演進和實踐已經證明:函數式編程並非銀彈,它有優勢,更有不足。從編程社區的實際反映來看,純函數式編程語言 (比如:CLisp[2]、Haskell[3]、Scala、Clojure、Erlang 等) 始終處於小衆地位。此外,即便很多主流命令式編程語言在近些年融入了一些函數式編程的語法特性,採用函數式風格的代碼依舊比例極低,且不易被廣泛接受。許多程序員在面對複雜的狀態管理和副作用時,依然傾向於使用傳統的命令式編程風格(包括 OOP)。。

注:Go 就原生提供了一些支持函數式範式編程的語法特性,比如:函數是一等公民 (first-class)、高階函數、閉包 [4]、函數迭代器 [5] 以及泛型 [6] 等。

造成這種局面的原因衆說紛紜,但我認爲有如下幾個:

首先從人類這個物種的大腦的認知和思維方式來看,命令式編程更接近於人類的自然思維方式,其邏輯與人類解決問題時的邏輯思維相似,即都是以步驟的形式理解問題,且有明確的控制流:命令式語言的控制結構(如條件語句、選擇語句和循環)使得程序的執行路徑清晰可見,符合人類的直覺理解,這也使得命令式語言更容易被人類大腦所掌握。

其次,命令式編程強調狀態的變化,程序員可以直接看到和控制變量的變化,這與人類處理現實世界事物的方式相似。

在上面原因的驅使下,久而久之,程序員便形成習慣與傳統,有了積澱,便可以促進命令式編程語言在教育和產業中的廣泛應用,使得大多數程序員習慣於這種編程方式(間接擠壓了函數式編程的使用空間)。進而使得命令式語言有更豐富的學習資源和社區支持,程序員也更容易找到幫助和示例。

也就是說,命令式編程範式佔據主流的根本原因是人類的大腦本身就是命令式的,而不是函數式的。不過也有極少數大腦是函數式思維的,比如發明了 TLA + 這門形式化建模和驗證語言 [7] 的 Leslie Lamport 老先生 [8]。

那麼問題來了!既然學習函數式編程思維是違反人類大腦直覺的,且較爲困難,那爲什麼還是有很多人學習函數式編程思維,並在實際開發中應用函數式編程範式呢?關於這個問題,我們可以從兩方面來看。

從主觀上說,程序員經常有探索新技術和新範式的內在動力,這種好奇心驅使他們嘗試函數式編程,也就是我們俗稱的 “玩膩了,嚐嚐鮮兒”。並且,許多程序員視學習函數式編程爲一種智力挑戰,一種來自舒適區之外的挑戰,這種挑戰能帶來成就感和個人成長。此外,在競爭激烈的 IT 行業,掌握多種編程範式可以使得個人技能多樣化,增加個人的職業競爭力。

從客觀上看,函數式編程也確實能幫助程序員提高抽象思維和系統設計能力,這種能力的提升不僅限於函數式編程,還能應用到其他編程範式中。並且,函數式編程爲程序員提供了一個新的解決問題的視角和方法,特別是在處理併發和並行計算、複雜數據轉換和流處理方面。

學習函數式編程範式,並不是說拋棄命令式範式 (或其他範式),而是融合,從主流編程語言對函數式編程的語法特性的支持也可窺見這般。

那麼,到底什麼是函數式編程範式?它與命令式範式對比又有怎麼樣的差異與優劣呢?在這篇文章中,我就來說說我的體會,並輔以 Go 示例來幫助大家理解。

  1. 思維差異:命令式編程 vs. 函數式編程

在看過很多函數式編程的資料後(見文後的參考資料一節),我問了自己一個問題:面對同一個實際的問題,用命令式編程範式和用函數式編程範式的核心思維差異在哪裏?爲此,我基於現實世界的一個典型問題模型 (數據輸入 -> 數據處理 -> 處理結果輸出),並根據自己的理解畫了下面兩幅圖:

命令式編程範式的思維

函數式編程範式的思維

我們先來描述一下上面兩幅圖中的數據處理流程:

基於上述流程的說明,我們可以看出兩種範式核心關注點的差異:

下面我們通過一個具體的問題來大致體會一下不同編程泛型在解決問題的實現上的思維差異。這個問題很簡單:編寫一個程序從 input.txt 文件中讀取數字 (每行一個數字),將每個數字乘以 2,然後將結果寫入 output.txt 文件中。

我們先來用命令式編程範式實現:

// fp-in-go/double/go/main.go

// NumberData represents the input data
type NumberData struct {
 numbers []int
}

// ProcessedData represents the processed output data
type ProcessedData struct {
 numbers []int
}

// NewNumberData creates and returns a new NumberData instance
func NewNumberData() *NumberData {
 return &NumberData{numbers: []int{}}
}

// AddNumber adds a number to NumberData
func (nd *NumberData) AddNumber(num int) {
 nd.numbers = append(nd.numbers, num)
}

// Process doubles all numbers in NumberData and returns ProcessedData
func (nd *NumberData) Process() ProcessedData {
 processed := ProcessedData{numbers: make([]int, len(nd.numbers))}
 for i, num := range nd.numbers {
  processed.numbers[i] = num * 2
 }
 return processed
}

// FileProcessor handles file operations and data processing
type FileProcessor struct {
 inputFile  string
 outputFile string
}

// NewFileProcessor creates and returns a new FileProcessor instance
func NewFileProcessor(input, output string) *FileProcessor {
 return &FileProcessor{
  inputFile:  input,
  outputFile: output,
 }
}

// ReadAndDeserialize reads data from input file and deserializes it into NumberData
func (fp *FileProcessor) ReadAndDeserialize() (*NumberData, error) {
 file, err := os.Open(fp.inputFile)
 if err != nil {
  return nil, fmt.Errorf("error opening input file: %w", err)
 }
 defer file.Close()

 data := NewNumberData()
 scanner := bufio.NewScanner(file)
 for scanner.Scan() {
  num, err := strconv.Atoi(scanner.Text())
  if err != nil {
   return nil, fmt.Errorf("error converting to number: %w", err)
  }
  data.AddNumber(num)
 }

 if err := scanner.Err(); err != nil {
  return nil, fmt.Errorf("error reading input file: %w", err)
 }

 return data, nil
}

// SerializeAndWrite serializes ProcessedData and writes it to output file
func (fp *FileProcessor) SerializeAndWrite(data ProcessedData) error {
 file, err := os.Create(fp.outputFile)
 if err != nil {
  return fmt.Errorf("error creating output file: %w", err)
 }
 defer file.Close()

 writer := bufio.NewWriter(file)
 defer writer.Flush()

 for _, num := range data.numbers {
  _, err := writer.WriteString(fmt.Sprintf("%d\n", num))
  if err != nil {
   return fmt.Errorf("error writing to output file: %w", err)
  }
 }

 return nil
}

// Process orchestrates the entire data processing workflow
func (fp *FileProcessor) Process() error {
 // Read and deserialize input data
 inputData, err := fp.ReadAndDeserialize()
 if err != nil {
  return err
 }

 // Process data
 processedData := inputData.Process()

 // Serialize and write output data
 err = fp.SerializeAndWrite(processedData)
 if err != nil {
  return err
 }

 return nil
}

func main() {
 processor := NewFileProcessor("input.txt""output.txt")
 if err := processor.Process(); err != nil {
  fmt.Fprintf(os.Stderr, "Error: %v\n", err)
  os.Exit(1)
 }
 fmt.Println("Processing completed successfully.")
}

這段代碼十分容易理解,在這段代碼中,我們建立了三個類型:NumberData、ProcessedData 和 FileProcessor。前兩個分別代表解碼後的輸入數據和編碼前的輸出數據,FileProcessor 則是封裝了文件操作和數據處理的邏輯的自定義類型。這段代碼將文件 I/O、數據處理和主要流程控制分離到不同的方法中。在讀取和寫入過程中,數據經歷了字符串 -> NumberData -> ProcessedData -> 字符串的轉換過程,同時數據也是在不同類型的方法間傳遞和變換狀態

接下來我們再來看看函數式範式版本,Go 雖然提供了一些函數式編程的基礎支持,比如一等公民的函數、支持高階函數、閉包等,但一些像 monad、monoid 等高級概念還需要手工實現。IBM 開源了一個 Go 的函數式編程基礎庫 fp-go[9],這裏就借用 fp-go 的便利實現上面的同等功能,我們看看風格上有何不同:

// fp-in-go/double/fp-go/main.go

package main

import (
 "bufio"
 "fmt"
 "os"
 "strconv"
 "strings"

 "github.com/IBM/fp-go/either"
 "github.com/IBM/fp-go/ioeither"
)

// 讀取文件內容
func readFile(filename string) ioeither.IOEither[error, string] {
 return ioeither.TryCatchError(func() (string, error) {
  content, err := os.ReadFile(filename)
  return string(content), err
 })
}

// 將字符串轉換爲數字列表
func parseNumbers(content string) either.Either[error, []int] {
 numbers := []int{}
 scanner := bufio.NewScanner(strings.NewReader(content))
 for scanner.Scan() {
  num, err := strconv.Atoi(scanner.Text())
  if err != nil {
   return either.Left[[]int](err "[]int")
  }
  numbers = append(numbers, num)
 }
 return either.Right[error](numbers "error")
}

// 將數字乘以2
func multiplyBy2(numbers []int) []int {
 result := make([]int, len(numbers))
 for i, num := range numbers {
  result[i] = num * 2
 }
 return result
}

// 將結果寫入文件
func writeFile(filename string, content string) ioeither.IOEither[error, string] {
 return ioeither.TryCatchError(func() (string, error) {
  return "", os.WriteFile(filename, []byte(content), 0644)
 })
}

func main() {
 program := ioeither.Chain(func(content string) ioeither.IOEither[error, string] {
  return ioeither.FromEither(
   either.Chain(func(numbers []int) either.Either[error, string] {
    multiplied := multiplyBy2(numbers)
    result := []string{}
    for _, num := range multiplied {
     result = append(result, strconv.Itoa(num))
    }
    return either.Of[error](strings.Join(result, "\n"))
   })(parseNumbers(content)),
  )
 })(readFile("input.txt"))

 program = ioeither.Chain(func(content string) ioeither.IOEither[error, string] {
  return writeFile("output.txt", content)
 })(program)

 result := program()
 err := either.ToError(result)

 if err != nil {
  fmt.Println("Program failed:", err)
 } else {
  fmt.Println("Program completed successfully")
 }
}

相對於前面使用命令式範式風格的代碼,這段函數式範式的代碼理解起來就要難上不少。

不過,這段代碼很好地詮釋了函數式編程中的函數組合理念,我們看到函數被當作值來傳遞和使用。例如,在 ioeither.Chain 中,我們傳遞了匿名函數作爲參數,這體現了函數式編程中函數作爲一等公民的概念。multiplyBy2 函數是一個純函數的例子,它沒有副作用,對於相同的輸入總是產生相同的輸出。這種純函數更容易測試和推理。

代碼中最明顯的函數組合例子是在 main 函數中,我們使用 ioeither.Chain 來組合多個函數操作。並且在這裏,我們將文件讀取、內容處理和文件寫入操作串聯在一起,形成一個更大的操作。而 ioeither.Chain 和 either.Chain 又都是高階函數的例子,它們接受其他函數作爲參數並返回新的函數。Either 和 IOEither 類型也是函數式編程中用於錯誤處理的主流方式,允許我們以更函數式的方式處理錯誤,將錯誤處理集成到函數組合中。

很多人好奇如果用純函數式編程語言實現這個示例會是什麼樣子的,下面我就貼一段 Haskell 語言的代碼,大家簡單瞭解一下,這裏就不對代碼進行解釋了:

// fp-in-go/double/fp-haskell/Main.hs

import System.IO
import Control.Monad (when)
import Text.Read (readMaybe)
import Data.Maybe (catMaybes)

-- Define a custom type for the result
data DoubledNumbers = DoubledNumbers { doubledNumbers :: [Int] } deriving (Show)

-- Function to read numbers from a file
readNumbers :: FilePath -> IO (Either String [Int])
readNumbers filePath = do
    content <- readFile filePath
    let numbers = catMaybes (map readMaybe (lines content))
    return $ if null numbers
             then Left "No valid numbers found."
             else Right numbers

-- Function to write result to a file
writeResult :: FilePath -> DoubledNumbers -> IO (Either String ())
writeResult filePath result = do
    let resultString = unlines (map show (doubledNumbers result))
    writeFile filePath resultString
    return $ Right ()

-- Function to double the numbers
doubleNumbers :: [Int] -> DoubledNumbers
doubleNumbers numbers = DoubledNumbers { doubledNumbers = map (* 2) numbers }

main :: IO ()
main = do
    -- Read numbers from input.txt
    readResult <- readNumbers "input.txt"
    case readResult of
        Left err -> putStrLn $ "Error: " ++ err
        Right numbers -> do
            let result = doubleNumbers numbers
            -- Write result to output.txt
            writeResultResult <- writeResult "output.txt" result
            case writeResultResult of
                Left err -> putStrLn $ "Error: " ++ err
                Right () -> putStrLn "Successfully written the result to output.txt."

注:安裝 ghc 後,執行 ghc --make Main 就可以將上面 Main.hs 編譯爲一個可執行程序。更多關於 haskell 編譯器的信息可以到 haskell 官網 [10] 查看。

從上面的示例我們大致也能感受到兩種範式在思維層面的差異,正如 Robert Martin 在《函數式設計》一書中說道的那樣:函數式程序更傾向於鋪設調節數據流轉換的管道結構,而可變的命令式程序更傾向於迭代地處理一個個類型對象

我們很難在一個例子中體現出函數式編程的所有概念和思維特點,接下來,我們就來逐個說說函數式編程範式中的要素,你也可以對應前面的圖中的內容,反覆感受函數式編程的思維特點。

  1. 函數式編程的要素

面向對象的編程通過封裝不確定因素來使代碼能被人理解,而函數式編程通過儘量減少不確定因素來使代碼能被人理解。—— Michael Feathers 《修改代碼的藝術 [11]》一書作者

函數式編程建立在幾個核心要素之上,這些要素共同構成了函數式編程的基礎。讓我們逐一探討這些要素。

2.1 純函數 (Pure Functions)

純函數是函數式編程的基石。一個純函數具有以下特性:

例如,前面 fp-go 示例中的 multiplyBy2 就是一個純函數:

func multiplyBy2(numbers []int) []int {
    result := make([]int, len(numbers))
    for i, num := range numbers {
        result[i] = num * 2
    }
    return result
}

這個函數總是爲相同的輸入返回相同的結果,並且不會修改任何外部狀態。

2.2 不可變性 (Immutability)

Robert Martin 在《函數式設計》一書爲函數式編程下一個理想的定義:沒有賦值語句的編程。實質是其強調了不可變性在函數式編程範式中的重要意義。在沒有賦值語句的情況下,代碼通常基於對原狀態的計算而得到新的狀態,而對原狀態沒有任何修改。

在 Go 語言中,由於不支持不可變變量 (很多語言用 val 關鍵字來聲明不可變變量,但 Go 並不支持),我們通常通過複製對象來實現不可變性,這可以幫助我們避免狀態變化帶來的複雜性,但也因爲複製而增加了內存開銷和性能成本。

// 定義一個不可變的結構體
type Point struct {
    x, y int
}

// 創建一個新的 Point,模擬不可變性
func NewPoint(x, y int) Point {
    return Point{x, y}
}

// 移動Point的方法,返回一個新的Point
func (p Point) Move(dx, dy int) Point {
    return NewPoint(p.x+dx, p.y+dy)
}

2.3 高階函數 (Higher-Order Functions) 與函數組合 (Function Composition)

Go 語言的一個內置特性讓它具備了使用函數式編程範式的前提,那就是在 Go 中,函數是一等公民。這意味着函數可以像其他類型變量一樣,被賦值、傳參和返回。

而接受其他函數作爲參數或返回函數的函數,被稱爲高階函數,這也是函數式編程的基石,如下面的 applyOperation 函數就是一個高階函數:

func applyOperation(x int, operation func(int) int) int {
    return operation(x)
}

func double(x int) int {
    return x * 2
}

result := applyOperation(5, double) // 結果爲10

而有了對高階函數的支持,我們才能運用函數式思維中的核心思維:函數組合,來鋪設調節數據流轉換的管道結構:

// fp-in-go/high-order-func/main.go

package main
  
import (
    "fmt"
)

// 定義一個類型爲函數的別名
type IntTransformer func(int) int

// 將多個轉換函數組合成一個管道
func pipe(value int, transformers ...IntTransformer) int {
    for _, transformer := range transformers {
        value = transformer(value)
    }
    return value
}

// 定義一些轉換函數
func addOne(x int) int {
    return x + 1
}

func square(x int) int {
    return x * x
}

func main() {
    // 使用管道處理數據
    result := pipe(3, addOne, square)
    fmt.Println("Result:", result) // 輸出 Result: 16
}

這個示例中的 pipe 函數接受一個初始值和多個轉換函數,並將其串聯執行。main 函數調用 pipe 函數,將 addOne 和 square 兩個轉換函數連接起來並執行輸出結果。

前面那個使用 fp-go 編寫的示例中,使用 ioeither.Chain 構建的 program 也是一個函數調用組合。

此外,鏈式調用也是一種在日常開發中常見的函數組合的使用形式,它融合了命令式的類型和函數式編程的函數組合,特別適用於集合類型數據的處理,通過鏈式調用,可以以更簡潔和直觀的方式進行數據轉換和處理。下面是一個基於泛型實現的通用的鏈式調用 (filter -> map -> reduce) 的示例:

// fp-in-go/func-composition/main.go

package main

import "fmt"

// Collection 接口定義了通用的集合操作
type Collection[T any] interface {
 Filter(predicate func(T) bool) Collection[T]
 Map(transform func(T) T) Collection[T]
 Reduce(initialValue T, reducer func(T, T) T) T
}

// SliceCollection 是基於切片的集合實現
type SliceCollection[T any] struct {
 data []T
}

// NewSliceCollection 創建一個新的 SliceCollection
func NewSliceCollection[T any](data []"T any") *SliceCollection[T] {
 return &SliceCollection[T]{data: data}
}

// Filter 實現了 Collection 接口的 Filter 方法
func (sc *SliceCollection[T]) Filter(predicate func(T) bool) Collection[T] {
 result := make([]T, 0)
 for _, item := range sc.data {
  if predicate(item) {
   result = append(result, item)
  }
 }
 return &SliceCollection[T]{data: result}
}

// Map 實現了 Collection 接口的 Map 方法
func (sc *SliceCollection[T]) Map(transform func(T) T) Collection[T] {
 result := make([]T, len(sc.data))
 for i, item := range sc.data {
  result[i] = transform(item)
 }
 return &SliceCollection[T]{data: result}
}

// Reduce 實現了 Collection 接口的 Reduce 方法
func (sc *SliceCollection[T]) Reduce(initialValue T, reducer func(T, T) T) T {
 result := initialValue
 for _, item := range sc.data {
  result = reducer(result, item)
 }
 return result
}

// SetCollection 是基於 map 的集合實現
type SetCollection[T comparable] struct {
 data map[T]struct{}
}

// NewSetCollection 創建一個新的 SetCollection
func NewSetCollection[T comparable]( "T comparable") *SetCollection[T] {
 return &SetCollection[T]{data: make(map[T]struct{})}
}

// Add 向 SetCollection 添加元素
func (sc *SetCollection[T]) Add(item T) {
 sc.data[item] = struct{}{}
}

// Filter 實現了 Collection 接口的 Filter 方法
func (sc *SetCollection[T]) Filter(predicate func(T) bool) Collection[T] {
 result := NewSetCollection[T]( "T")
 for item := range sc.data {
  if predicate(item) {
   result.Add(item)
  }
 }
 return result
}

// Map 實現了 Collection 接口的 Map 方法
func (sc *SetCollection[T]) Map(transform func(T) T) Collection[T] {
 result := NewSetCollection[T]( "T")
 for item := range sc.data {
  result.Add(transform(item))
 }
 return result
}

// Reduce 實現了 Collection 接口的 Reduce 方法
func (sc *SetCollection[T]) Reduce(initialValue T, reducer func(T, T) T) T {
 result := initialValue
 for item := range sc.data {
  result = reducer(result, item)
 }
 return result
}

// ToSlice 實現了 Collection 接口的 ToSlice 方法
func (sc *SetCollection[T]) ToSlice() []{
 result := make([]T, 0, len(sc.data))
 for item := range sc.data {
  result = append(result, item)
 }
 return result
}

func main() {
 // 使用 SliceCollection
 numbers := NewSliceCollection([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
 result := numbers.
  Filter(func(n int) bool { return n%2 == 0 }).
  Map(func(n int) int { return n * 2 }).
  Reduce(0, func(acc, n int) int { return acc + n })
 fmt.Println(result) // 輸出: 60

 // 使用 SetCollection
 set := NewSetCollection[int]( "int")
 for _, n := range []int{1, 2, 2, 3, 3, 3, 4, 5} {
  set.Add(n)
 }
 uniqueSum := set.
  Filter(func(n int) bool { return n > 2 }).
  Map(func(n int) int { return n * n }).
  Reduce(0, func(acc, n int) int { return acc + n })
 fmt.Println(uniqueSum) // 輸出: 50 (3^2 + 4^2 + 5^2)
}

這段代碼定義的泛型接口類型 Collection 包含三個方法:

其中 Filtre、Map 都是返回集合自身,這樣便允許實現 Collection 接口的集合類型 (如上面的 SetCollection 和 SliceCollection) 使用鏈式調用,代碼看起來也十分易於理解。

2.4 遞歸 (Recursion)

遞歸是函數式編程中常用的控制結構,常用來替代循環。例如下面是計算階乘的函數實現:

func factorial(n int) int {
    if n <= 1 {
        return 1
    }
    return n * factorial(n-1)
}

遞歸的優點十分明顯,代碼簡潔,易於理解 (相對於循環),特別適合處理分解問題(如樹結構、圖遍歷等)。但不足也很突出,比如可能導致棧溢出 (尤其是對那些不支持尾遞歸優化的語言,比如 Go),特別是對於較大的輸入。此外,由於每次遞歸調用都需要創建新棧幀,維護棧狀態,遞歸會有額外的性能開銷。調試遞歸函數也可能比循環更復雜,因爲需要跟蹤多個函數調用。

2.5 惰性求值 (Lazy Evaluation)

惰性求值是指延遲計算表達式的值,直到真正需要它的時候。這樣可以避免不必要的計算並有效管理內存,特別是在處理大集合或無限集合時。下面是用惰性求值實現迭代集合元素的示例:

注:Go 原生並不支持惰性求值的語法,但我們可以使用閉包來模擬。

// fp-in-go/lazy-evaluation/lazy-range/main.go

package main

import "fmt"

func lazyRange(start, end int) func() (int, bool) {
 current := start
 return func() (int, bool) {
  if current >= end {
   return 0, false
  }
  result := current
  current++
  return result, true
 }
}
func main() {
 next := lazyRange(1, 5)
 for {
  value, hasNext := next()
  if !hasNext {
   break
  }
  fmt.Println(value)
 }
}

我們看到這段代碼通過惰性求值方式生成從 1 到 4 的數字,避免了預先生成整個範圍的集合元素,節省了內存,並避免了不必要的計算。

我們再來看一個用惰性求值生成前 N 個斐波那契數列的示例:

// fp-in-go/lazy-evaluation/fibonacci/main.go

package main

import (
 "fmt"
)

// Fibonacci 返回一個生成無限斐波那契數列的函數
func Fibonacci() func() int {
 a, b := 0, 1
 return func() int {
  a, b = b, a+b
  return a
 }
}

func main() {
 fib := Fibonacci()
 for i := 0; i < 10; i++ { // 打印前10個斐波那契數
  fmt.Println(fib())
 }
}

我們看到 Fibonacci 函數返回一個閉包,每次調用時生成下一個斐波那契數,這樣我們在需要時生成下一個斐波那契數,而無需生成所有。

雖然函數式編程強調純函數和不可變性,但在實際應用中,我們不可避免地需要處理副作用,如 I/O 操作、數據庫交互等。接下來,我們就來看看在函數式編程範式中是如何處理帶有副作用的操作的。

  1. 函數式編程對副作用操作的處理

3.1 理解副作用

在函數式編程中,副作用是指函數或表達式在執行過程中對其周圍環境產生的任何可觀察到的變化。這些變化包括但不限於:

副作用使得程序的行爲變得難以預測和測試,因爲函數的輸出不僅依賴於其輸入,還依賴於程序的狀態和外部環境。函數式編程通過最小化副作用來提高程序的可預測性和可測試性。

3.2 Monad: 函數式編程中處理副作用的核心抽象

在函數式編程中,Monad 是一種用於處理副作用的核心抽象。它提供了一種結構化的方式來處理計算中的狀態、異常、輸入輸出等副作用,使得程序更加模塊化和可組合。

在範疇論中,Monad 被定義爲一個自函子 (endofunctor) 加上兩個自然變換(有點抽象了):

注:要入門範疇論,可以參考《Category Theory for Programmers[12]》這本書。

Monad 可以通過以下策略來處理副作用:

在實際應用中,我們可以根據具體需求選擇使用不同的 Monad 實現。每種 Monad 都有其適用場景,比如:

接下來,我們就結合 Go 示例來逐一探討這三種 Monad 實現。

3.3 Option (Maybe)

Option 用於表示一個值可能存在或不存在,避免了使用 null 或 undefined 帶來的問題。

// fp-in-go/side-effect/option/main.go

package main

import "fmt"

type Option[T any] struct {
 value   T
 present bool
}

func Some[T any](x T "T any") Option[T] {
 return Option[T]{value: x, present: true}
}

func None[T any]( "T any") Option[T] {
 return Option[T]{present: false}
}

func (o Option[T]) Bind(f func(T) Option[T]) Option[T] {
 if !o.present {
  return None[T]( "T")
 }
 return f(o.value)
}

// 使用示例
func safeDivide(a, b int) Option[int] {
 if b == 0 {
  return None[int]( "int")
 }
 return Some(a / b)
}

func main() {
 result := Some(10).Bind(func(x int) Option[int] {
  return safeDivide(x, 2)
 })
 fmt.Println(result) // {5 true}

 result = Some(10).Bind(func(x int) Option[int] {
  return safeDivide(x, 0)
 })
 fmt.Println(result) // {0 false}
}

這段示例程序定義了一個 Option 結構體:包含一個值和一個表示值是否存在的布爾變量。Some 和 None 函數是 Option 的創建函數,Some 函數:返回一個包含值的 Option。None 函數返回一個不包含值的 Option。Bind 方法對 Option 中的值應用一個函數,如果值不存在則返回 None。

3.4 Result (Either)

Result 可用於處理可能產生錯誤的操作,它比 Option 提供了更多的信息,它可以可以攜帶錯誤信息。

// fp-in-go/side-effect/result/main.go

package main

import (
 "fmt"
 "os"
 "strings"
)

type Result[T any] struct {
 value T
 err   error
 isOk  bool
}

func Ok[T any](value T "T any") Result[T] {
 return Result[T]{value: value, isOk: true}
}

func Err[T any](err error "T any") Result[T] {
 return Result[T]{err: err, isOk: false}
}

func (r Result[T]) Bind(f func(T) Result[T]) Result[T] {
 if !r.isOk {
  return Err[T](r.err "T")
 }
 return f(r.value)
}

// 使用示例
func readFile(filename string) Result[string] {
 content, err := os.ReadFile(filename)
 if err != nil {
  return Err[string](err "string")
 }
 return Ok(string(content))
}

func processContent(content string) Result[string] {
 // 處理內容...
 return Ok(strings.ToUpper(content))
}

func main() {
 result := readFile("input.txt").Bind(processContent)
 fmt.Println(result) // {HELLO, GOLANG <nil> true}
 result = readFile("input1.txt").Bind(processContent)
 fmt.Println(result) // { 0xc0000a0420 false}
}

這段示例程序定義了一個 Result 結構體:包含一個值、一個錯誤信息和一個表示操作是否成功的布爾變量。Ok 和 Err 函數是 Result 的創建函數,Ok 函數返回一個成功的 Result。Err 函數返回一個失敗的 Result。Bind 方法對成功的 Result 中的值應用一個函數,如果操作失敗則返回錯誤。

在示例中,我們分別用讀取 input.txt 和不存在的 input1.txt 來演示成功和錯誤的兩個情況,具體輸出結果見上面代碼中的註釋。

3.5 IO Monad

IO Monad 用於封裝所有的帶有副作用的輸入 / 輸出操作,使得這些操作在類型系統中可見,並且可以被推遲執行。

// fp-in-go/side-effect/io-monad/main.go

package main

import (
 "fmt"
 "os"
 "strings"
)

// IO represents an IO operation that, when run, produces a value of type any or an error
type IO struct {
 run func() (any, error)
}

// NewIO creates a new IO monad
func NewIO(f func() (any, error)) IO {
 return IO{run: f}
}

// Bind chains IO operations, allowing for type changes
func (io IO) Bind(f func(any) IO) IO {
 return NewIO(func() (any, error) {
  v, err := io.run()
  if err != nil {
   return nil, err
  }
  return f(v).run()
 })
}

// Map transforms the value inside IO
func (io IO) Map(f func(any) any) IO {
 return io.Bind(func(v any) IO {
  return NewIO(func() (any, error) {
   return f(v), nil
  })
 })
}

// Pure lifts a value into the IO context
func Pure(x any) IO {
 return NewIO(func() (any, error) { return x, nil })
}

// ReadFile is an IO operation that reads a file
func ReadFile(filename string) IO {
 return NewIO(func() (any, error) {
  content, err := os.ReadFile(filename)
  if err != nil {
   return nil, fmt.Errorf("failed to read file: %w", err)
  }
  return string(content), nil
 })
}

// WriteFile is an IO operation that writes to a file
func WriteFile(filename string, content string) IO {
 return NewIO(func() (any, error) {
  err := os.WriteFile(filename, []byte(content), 0644)
  if err != nil {
   return nil, fmt.Errorf("failed to write file: %w", err)
  }
  return true, nil
 })
}

// Print is an IO operation that prints to stdout
func Print(x any) IO {
 return NewIO(func() (any, error) {
  fmt.Println(x)
  return x, nil
 })
}

func main() {
 // Example: Read a file, transform its content, and write it back
 program := ReadFile("input.txt").
  Map(func(v any) any {
   return strings.ToUpper(v.(string))
  }).
  Bind(func(v any) IO {
   return WriteFile("output.txt", v.(string))
  }).
  Bind(func(v any) IO {
   success := v.(bool)
   if success {
    return Pure("File processed successfully")
   }
   return Pure("Failed to process file")
  }).
  Bind(func(v any) IO {
   return Print(v)
  })

 // Run the IO operation
 result, err := program.run()
 if err != nil {
  fmt.Printf("An error occurred: %v\n", err)
 } else {
  fmt.Printf("Program completed: %s\n", result)
 }
}

這個示例提供了一個非泛型版本的 IO Monad 的 Go 實現,它允許我們鏈式組合帶有副作用的 IO 操作,同時保持了一定程度的類型安全(儘管需要類型斷言)。在實際使用中,你完全不用自己實現 IO Monad,可以直接使用 IBM/fp-go 中的 ioeither,就像本文初那個示例那樣。

  1. 小結

到這裏,關於函數式編程思維的入門介紹就告一段落了!

通過上面的介紹,我們看到函數式編程提供了一種不同於傳統命令式編程的思維方式。它強調不可變性、純函數和函數的組合,爲數據流的處理搭建管道,這些特性使得代碼更易於理解、測試和並行化。然而,函數式編程也帶來了一些挑戰,如處理副作用和狀態管理的複雜性和難於理解。

學習函數式編程不僅可以擴展我們的編程技能,還能幫助我們以新的方式思考問題和設計解決方案。正如《函數式編程思維》一書中譯者所說,接受一種新的編程範式可能需要時間和耐心,但最終會帶來新的見解和能力。

在實際應用中,純粹的函數式編程並不常見,更常見的是將函數式編程的概念和技術與其他編程範式 (主要就是命令式範式) 相結合。

Go 語言雖然不是一個純函數式語言,但它提供了足夠的特性來支持函數式編程風格,如一等公民的函數、閉包和高階函數等。

最後要記住,編程範式是工具,而不是教條。好的程序員應該能夠根據具體問題和場景,靈活地選擇和組合不同的編程範式,以創造出最優雅、高效的解決方案。

本文涉及的源碼可以在這裏 [13] 下載 - https://github.com/bigwhite/experiments/blob/master/fp-in-go

本文部分源代碼由 Claude 3.5 sonnet 和 GPT-4o 生成。

  1. 參考資料


參考資料

[1] 

邏輯編程 (logic Programming): https://tonybai.com/2012/05/08/translate-seven-languages-in-seven-weeks/

[2] 

CLisp: https://tonybai.com/2011/08/30/c-programers-tame-common-lisp-series-introduction/

[3] 

Haskell: https://www.haskell.org

[4] 

閉包: https://tonybai.com/2021/08/09/when-variables-captured-by-closures-are-recycled-in-go

[5] 

函數迭代器: https://tonybai.com/2024/06/24/range-over-func-and-package-iter-in-go-1-23/

[6] 

泛型: https://tonybai.com/2022/05/20/solving-problems-in-generic-function-implementation-using-named-return-values

[7] 

TLA + 這門形式化建模和驗證語言: https://tonybai.com/2024/08/05/formally-verify-concurrent-go-programs-using-tla-plus/

[8] 

Leslie Lamport 老先生: https://lamport.azurewebsites.net

[9] 

IBM 開源了一個 Go 的函數式編程基礎庫 fp-go: https://github.com/IBM/fp-go

[10] 

haskell 官網: https://www.haskell.org

[11] 

修改代碼的藝術: https://book.douban.com/subject/2248759/

[12] 

Category Theory for Programmers: https://book.douban.com/subject/30357114/

[13] 

這裏: https://github.com/bigwhite/experiments/blob/master/fp-in-go

[14] 

函數式設計:原則、模式與實踐: https://book.douban.com/subject/36974785/

[15] 

函數式編程思維: https://book.douban.com/subject/26587213/

[16] 

計算機程序的構造和解釋: https://book.douban.com/subject/36787585/

[17] 

Learning Functional Programming in Go: https://book.douban.com/subject/30165168/

[18] 

Introduction to fp-go, functional programming for golang: https://www.youtube.com/watch?v=Jif3jL6DRdw

[19] 

Investigate Functional Programming Concepts in Go: https://betterprogramming.pub/investigate-functional-programming-concepts-in-go-1dada09bc913

[20] 

Investigating the I/O Monad in Go: https://medium.com/better-programming/investigating-the-i-o-monad-in-go-3c0fabbb4b3d

[21] 

Gopher 部落知識星球: https://public.zsxq.com/groups/51284458844544

[22] 

鏈接地址: https://m.do.co/c/bff6eed92687

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