标签 Haskell 下的文章

通过Go示例理解函数式编程思维

本文永久链接 – https://tonybai.com/2024/08/11/understand-functional-programming-in-go

一个孩子要尝试10次、20次才肯接受一种新的食物,我们接受一种新的范式,大概不会比这个简单。– 郭晓刚 《函数式编程思维》译者

函数式编程(Functional Programming, 简称fp)是一种编程范式,与命令式编程(Imperative Programming)、面向对象编程(OOP)、泛型编程(Generics Programming)、逻辑编程(logic Programming)等是一类的概念。

注:尽管面向对象范式引入了新的编程思想和技术,但它本质上与命令式编程一样,都关注程序的状态和如何通过改变状态来控制程序的执行流程,因此,OOP仍然属于命令式编程的一个分支。OOP可以看作是命令式编程的一种扩展和补充,它增强了代码的模块化、复用性和可维护性。在接下来,我会统一使用命令式编程范式来指代它们。

但几十年的编程语言的演进和实践已经证明:函数式编程并非银弹,它有优势,更有不足。从编程社区的实际反映来看,纯函数式编程语言(比如:CLispHaskell、Scala、Clojure、Erlang等)始终处于小众地位。此外,即便很多主流命令式编程语言在近些年融入了一些函数式编程的语法特性,采用函数式风格的代码依旧比例极低,且不易被广泛接受。许多程序员在面对复杂的状态管理和副作用时,依然倾向于使用传统的命令式编程风格(包括OOP)。。

注:Go就原生提供了一些支持函数式范式编程的语法特性,比如:函数是一等公民(first-class)、高阶函数、闭包函数迭代器以及泛型等。

造成这种局面的原因众说纷纭,但我认为有如下几个:

首先从人类这个物种的大脑的认知和思维方式来看,命令式编程更接近于人类的自然思维方式,其逻辑与人类解决问题时的逻辑思维相似,即都是以步骤的形式理解问题,且有明确的控制流:命令式语言的控制结构(如条件语句、选择语句和循环)使得程序的执行路径清晰可见,符合人类的直觉理解,这也使得命令式语言更容易被人类大脑所掌握。

其次,命令式编程强调状态的变化,程序员可以直接看到和控制变量的变化,这与人类处理现实世界事物的方式相似。

在上面原因的驱使下,久而久之,程序员便形成习惯与传统,有了积淀,便可以促进命令式编程语言在教育和产业中的广泛应用,使得大多数程序员习惯于这种编程方式(间接挤压了函数式编程的使用空间)。进而使得命令式语言有更丰富的学习资源和社区支持,程序员也更容易找到帮助和示例。

也就是说,命令式编程范式占据主流的根本原因是人类的大脑本身就是命令式的,而不是函数式的。不过也有极少数大脑是函数式思维的,比如发明了TLA+这门形式化建模和验证语言Leslie Lamport老先生

那么问题来了!既然学习函数式编程思维是违反人类大脑直觉的,且较为困难,那为什么还是有很多人学习函数式编程思维,并在实际开发中应用函数式编程范式呢?关于这个问题,我们可以从两方面来看。

从主观上说,程序员经常有探索新技术和新范式的内在动力,这种好奇心驱使他们尝试函数式编程,也就是我们俗称的“玩腻了,尝尝鲜儿”。并且,许多程序员视学习函数式编程为一种智力挑战,一种来自舒适区之外的挑战,这种挑战能带来成就感和个人成长。此外,在竞争激烈的IT行业,掌握多种编程范式可以使得个人技能多样化,增加个人的职业竞争力。

从客观上看,函数式编程也确实能帮助程序员提高抽象思维和系统设计能力,这种能力的提升不仅限于函数式编程,还能应用到其他编程范式中。并且,函数式编程为程序员提供了一个新的解决问题的视角和方法,特别是在处理并发和并行计算、复杂数据转换和流处理方面。

学习函数式编程范式,并不是说抛弃命令式范式(或其他范式),而是融合,从主流编程语言对函数式编程的语法特性的支持也可窥见这般。

那么,到底什么是函数式编程范式?它与命令式范式对比又有怎么样的差异与优劣呢?在这篇文章中,我就来说说我的体会,并辅以Go示例来帮助大家理解。

1. 思维差异:命令式编程 vs. 函数式编程

在看过很多函数式编程的资料后(见文后的参考资料一节),我问了自己一个问题:面对同一个实际的问题,用命令式编程范式和用函数式编程范式的核心思维差异在哪里?为此,我基于现实世界的一个典型问题模型(数据输入 -> 数据处理 -> 处理结果输出),并根据自己的理解画了下面两幅图:


命令式编程范式的思维


函数式编程范式的思维

我们先来描述一下上面两幅图中的数据处理流程:

  • 命令式编程:通过I/O操作获取数据,然后解码为自定义类型进行处理,再编码为自定义类型以便I/O操作输出。处理过程中使用函数、带方法的类型和控制流结构(如for、if、switch等)。

  • 函数式编程:通过带有副作用的操作(如I/O操作)获取数据,然后解码数据放入通用数据结构(如列表、元组、映射)进行处理,再放入通用数据结构以便通过副作用操作输出。处理过程中会使用纯函数、高阶函数以及它们的函数组合。

基于上述流程的说明,我们可以看出两种范式核心关注点的差异:

  • 命令式编程范式:更关注类型的封装、类型间的耦合关系、行为集合的抽象(接口)以及对数据在类型实例间的传递的显式控制(if/for/switch)。
  • 函数式编程范式:弱化类型的概念,使用通用数据结构,专注于通过纯函数/高阶函数、不可变数据和函数组合来实现对数据的处理逻辑。“控制流”更加隐含,比如会通过递归、模式匹配和惰性求值等方式实现。建立专门的抽象来应对与真实世界交互时的带有副作用(side effect)的操作。

下面我们通过一个具体的问题来大致体会一下不同编程泛型在解决问题的实现上的思维差异。这个问题很简单:编写一个程序从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,这里就借用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)
        }
        numbers = append(numbers, num)
    }
    return either.Right[error](numbers)
}

// 将数字乘以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官网查看。

从上面的示例我们大致也能感受到两种范式在思维层面的差异,正如Robert Martin在《函数式设计》一书中说道的那样:函数式程序更倾向于铺设调节数据流转换的管道结构,而可变的命令式程序更倾向于迭代地处理一个个类型对象

我们很难在一个例子中体现出函数式编程的所有概念和思维特点,接下来,我们就来逐个说说函数式编程范式中的要素,你也可以对应前面的图中的内容,反复感受函数式编程的思维特点。

2. 函数式编程的要素

面向对象的编程通过封装不确定因素来使代码能被人理解,而函数式编程通过尽量减少不确定因素来使代码能被人理解。—— Michael Feathers 《修改代码的艺术》一书作者

函数式编程建立在几个核心要素之上,这些要素共同构成了函数式编程的基础。让我们逐一探讨这些要素。

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) *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]() *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]()
    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]()
    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() []T {
    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]()
    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包含三个方法:

  • Filter:根据条件过滤集合中的元素。
  • Map:对集合中的每个元素应用转换函数。
  • Reduce:对集合中的元素进行归约操作,比如求和。

其中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操作、数据库交互等。接下来,我们就来看看在函数式编程范式中是如何处理带有副作用的操作的。

3. 函数式编程对副作用操作的处理

3.1 理解副作用

在函数式编程中,副作用是指函数或表达式在执行过程中对其周围环境产生的任何可观察到的变化。这些变化包括但不限于:

  • 修改全局变量或静态局部变量
  • 修改函数参数
  • 执行I/O操作(读写文件、网络通信等)
  • 抛出异常或错误
  • 调用其他具有副作用的函数

副作用使得程序的行为变得难以预测和测试,因为函数的输出不仅依赖于其输入,还依赖于程序的状态和外部环境。函数式编程通过最小化副作用来提高程序的可预测性和可测试性。

3.2 Monad: 函数式编程中处理副作用的核心抽象

在函数式编程中,Monad是一种用于处理副作用的核心抽象。它提供了一种结构化的方式来处理计算中的状态、异常、输入输出等副作用,使得程序更加模块化和可组合。

在范畴论中,Monad被定义为一个自函子(endofunctor)加上两个自然变换(有点抽象了):

  • return (也称为unit):将一个值封装到Monad中。
  • bind (也称为flatMap或>>=):将一个Monad中的值应用到一个函数中,并返回一个新的Monad。

注:要入门范畴论,可以参考《Category Theory for Programmers》这本书。

Monad可以通过以下策略来处理副作用:

  • 延迟执行:将副作用操作封装在Monad中,但不立即执行,这样可以将副作用推迟到程序的边缘。
  • 显式表示:使副作用成为类型系统的一部分,迫使开发者显式地处理这些效果。
  • 组合性:提供了一种方式来组合包含副作用的操作,而不破坏函数的纯粹性。
  • 错误处理:提供了一种统一的方式来处理可能失败的操作。
  • 状态管理:允许在一系列操作中传递和修改状态,而不需要使用可变变量。

在实际应用中,我们可以根据具体需求选择使用不同的Monad实现。每种Monad都有其适用场景,比如:

  • 使用Option(Maybe) Monad处理可能缺失的值,避免空指针异常。
  • 使用Result(Either) Monad 处理可能失败的操作,提供更丰富的错误信息。
  • 使用IO Monad封装所有的I/O操作,将副作用推迟到程序的边缘。

接下来,我们就结合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) Option[T] {
    return Option[T]{value: x, present: true}
}

func None[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]()
    }
    return f(o.value)
}

// 使用示例
func safeDivide(a, b int) Option[int] {
    if b == 0 {
        return None[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) Result[T] {
    return Result[T]{value: value, isOk: true}
}

func Err[T any](err error) 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)
    }
    return f(r.value)
}

// 使用示例
func readFile(filename string) Result[string] {
    content, err := os.ReadFile(filename)
    if err != nil {
        return Err[string](err)
    }
    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,就像本文初那个示例那样。

4. 小结

到这里,关于函数式编程思维的入门介绍就告一段落了!

通过上面的介绍,我们看到函数式编程提供了一种不同于传统命令式编程的思维方式。它强调不可变性、纯函数和函数的组合,为数据流的处理搭建管道,这些特性使得代码更易于理解、测试和并行化。然而,函数式编程也带来了一些挑战,如处理副作用和状态管理的复杂性和难于理解。

学习函数式编程不仅可以扩展我们的编程技能,还能帮助我们以新的方式思考问题和设计解决方案。正如《函数式编程思维》一书中译者所说,接受一种新的编程范式可能需要时间和耐心,但最终会带来新的见解和能力。

在实际应用中,纯粹的函数式编程并不常见,更常见的是将函数式编程的概念和技术与其他编程范式(主要就是命令式范式)相结合。

Go语言虽然不是一个纯函数式语言,但它提供了足够的特性来支持函数式编程风格,如一等公民的函数、闭包和高阶函数等。

最后要记住,编程范式是工具,而不是教条。好的程序员应该能够根据具体问题和场景,灵活地选择和组合不同的编程范式,以创造出最优雅、高效的解决方案。

本文涉及的源码可以在这里下载 – https://github.com/bigwhite/experiments/blob/master/fp-in-go

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

5. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

GoCN社区Go读书会第二期:《Go语言精进之路》

本文永久链接 – https://tonybai.com/2022/07/07/gocn-community-go-book-club-issue2-go-programming-from-beginner-to-master

本文是2022年6月26日我在GoCN社区的Go读书会第二期《Go语言精进之路》直播的文字稿。本文对直播的内容做了重新整理与修订,供喜欢阅读文字的朋友们在收看直播后的揣摩和参考。视频控的童鞋可以关注GoCN公众号和视频号看剪辑后的视频,也可以在B站GopherChina专区下收看视频回放(https://www.bilibili.com/video/BV1p94y1R7jg)。


大家晚上好,我叫白明,是《Go语言精进之路》一书的作者,也是tonybai.com的博主,很荣幸今天参加GoCN社区Go读书会第二期,分享一下我个人在写书和读书方面的经验和体会。

今天的分享包括三方面内容:

  • 写书的历程。一些Gopher可能比较好奇,这么厚的一套书是怎么写出来的,今天就和大家聊一聊。
  • 《Go语言精进之路》导读。主要是把这本书的整体构思与大家聊聊,希望通过这个导读帮助读者更好地阅读和理解这套书。
  • 我个人的读书方法与经验的简要分享。

首先和大家分享一下写书的历程。

一. 写书的历程

1. 程序员的“小目标”与写书三要素

今天收看直播的童鞋都是有追求的技术人员,可能心底都有写一本属于自己的书的小目标。这样可以把自己学习到的知识、技能和经验以比较系统的方式输出给其他人,可以帮助其他人快速学习和掌握本领域的知识、技能和经验。

当然写书还有其他好处,比如:提升名气、更容易混技术圈子、可能给你带来更好的职业发展机会,当然也会给你带来一些额外的副业收入,至于多少,还要看书籍的口碑与销量。

那怎么才能写书呢?作为“过来人”,我总结了三个要素,也是三个条件。

第一个要素是能力

这个很容易理解。以Go为例,如果你没有在Go语言方面的知识、技能的沉淀,没有对Go语言方方面面的较为深入的理解,你很难写出一本口碑很好的书籍。尤其是那种有原创性、独到见解的著书。而不是对前人资料做系统整理摘抄的编书。编书更常见于教材、字典等。显然著书对作者水平的要求更高。

第二个要素是意愿

写过书的同学都有体会,写书是一件辛苦活。需要你在正式工作之余付出大量业余时间伏案创作。并且对于小众技术类书籍来说,写书能带来的金钱上的收益和你付出的时长和精力不成正比。就这个问题,我曾与机械工业出版社的营销编辑老师聊过,得到的信息是:Go技术书籍的市场与Java、Python还没法比,即便是像Go语言圣经《Go程序设计语言》的销量也没法与Java、Python的头部书籍销量相比。

第三个要素是机会

记得小时候十分羡慕那些能出书的人,觉得都是大神级的人物。不过那个时候出书的确很难,机会应该很少,你要不是在学术圈里混很难出书。如今就容易地多了,渠道也多了。每年出版社都有自己的出版计划,各个出版社的编辑老师也在根据计划在各种自媒体上、技术圈子中寻觅匹配的技术作者。

如果你有自己的思路,也可以整理出大纲,并通过某种方式联系到出版社老师,如果匹配就可以出。

另外国外流行电子自助出版,这也给很多技术作者很好的出版机会。比如国内作者老貘写的Go 101系列就是在亚马逊leanpub上做的自助出版,效果还不错。

以上就是我总结的出书的三个要素,一旦集齐这三个要素呢,出书实际就是自然而然的一件事了。以我为例。

从能力方面来说呢,我大约从2011年开始接触和学习Go语言,算是国内较早的一批Go语言接纳者。Go语言2012年才发布1.0版本,因此那时我接触的Go时还是r60版本,还不是正式的1.0版本。从那时起就一直在跟踪Go演化,日常写一些Go项目的小程序。

Go 1.5实现自举并大幅降低GC延迟,我于是开始在一些生产环境使用Go,并逐渐将知识和经验做了沉淀,在自己的博客上不断做着Go相关内容的输出,反响也不错。

随着输出Go内容的增多,我发现以博客的形式输出,内容组织零散,于是我第一次有了将自己的Go知识系统整理并输出的意愿和想法。

我在实践Go的过程中收到很多Go初学者的提问:Go入门容易,但精进难,怎么才能像Go开发团队那样写出符合Go思维和语言惯例的高质量代码呢?这个问题引发了我的思考。在2017年GopherChina大会我以《go coding in go way》为主题,以演讲的形式尝试回答这个问题,但鉴于演讲的时长有限,很多内容没能展开,效果不甚理想。这进一步增强了我通过书籍的形式系统解答这个问题的意愿。

而当时我家大宝已经长大了,我也希望通过写书这个行动身体力行地给孩子树立一个正面的榜样。中国古语有云:言传身教,我也想践行一下。

机会就这样自然而然的来了!2018年初,机械工业出版社副总编杨福川老师在微信联系到我,和我探讨一下是否可以写一本类似于“Effective Go”的书,当时机械工业出版社华章出版社策划了Effective XXX(编写高质量XXX)系列图书,当时已经出版了C、Python等语言版本的书籍,还差Go语言的。我的出书意愿与出版社的需求甚是匹配,于是我答应的杨老师的要求,成为了这套丛书的Go版本的作者。

2. 写书的过程

我是2018下旬开始真正动笔的。

真正开始码字的时候,我才意识到,写书真不容易,要写出高质量书稿,的确需付出大量时间和汗水。每天晚上、早上都在构思、码字、写代码示例、画插图,睡眠时间很少。记得当时每周末都在奋笔疾书,陪伴家人尤其是孩子的时间很少。

另外我这个人还习惯于把一个知识点讲细讲透,这样每一节的篇幅都不小。因此,写作进展是很缓慢的,就这样,进度一再延期。好在编辑老师比较nice,考虑到书稿质量,没有狠狠催进度。

2020年11月末,我正式向出版社交了初稿,记得初稿有66条,近40w字。

又经过一年的排期、编辑、修订、排版,2021年12月下旬正式出版。

2022年1月《Go语言精进之路》正式上架到各个渠道货架。

到今天为止,出版了近六个月,这本书收获了还不错的口碑,在各个平台上的口碑都在8分以上(注:口碑分数还在动态变化,下图仅为当时的快照,不代表如今的分数)。


能获得大家的认可,让我很是欣慰,觉得写书过程付出的辛苦没有白费。

以上就是我的写书历程。总的来说一句话:写书不易,写高质量的书更难

接下来我来进行一下《Go语言精进之路》一书的导读。

二. 《Go语言精进之路》导读

也许是“用力过猛”,《Go语言精进之路》一书写的太厚了,无法装订为一册。编辑老师建议装订为两册,即1、2册。很多同学好奇为什么不是上下册而是1、2册,这里是编辑老师的“高瞻远瞩”,目的是为后续可能的“续写”(比如第3册)留足空间,毕竟Go语言还在快速演进,目前的版本还不包含像泛型这样的新语法。不过,目前第3册还尚未列入计划。

本套书共分为10个部分,66个主题。第一册包含了前7个部分,后3部分在第二册中。

1. 整体写作思路

整套书围绕着两个前后关联的思路循序展开。

第一个思路我叫它:精进之路,思维先行

第二个思路称为:践行哲学,遵循惯例,认清本质,理解原理

我们先来看看第一个思路。

2. 精进之路,思维先行

收看直播的童鞋都不止学过一门编程语言。大家可能都有过这样的经历:你已经精通A语言,然后在学习B语言的时候用A语言的思维去写B代码,你会觉得写出的B代码很别扭,写出的代码总是感觉不是很地道,总觉得不是那种高质量的B语言代码。

其实,不仅学习编程语言是这样,学自然语言也是一样。最典型的一个例子,大家都学过十几年的英语,但毕业后能用地道的英语表达自己观点的人却不多,为什么呢?那就是我们总用中文的思维方式去组织英语的句子,去说英语,这样再怎么努力也很难上一个层次。

其实,很多语言大师早就意识到了这一点。下面是我收集的这些大师的关于语言与思维的论点,这里和大家分享一下:

“语言决定思维方式” – 萨丕尔假说

“我的语言之局限,即我的世界之局限” – 路德维希·维特根斯坦,语言哲学的奠基人

“不能改变你思维方式的语言,不值得学习” – Alan Perlis(首届ACM图灵奖得主)

我们看到:无论是自然语言界的大师,还是IT界的大佬,他们的观点异曲同工。总之一句话:语言要精进,思维要先行

3. Part1:进入Go语言编程思维导引

正是因为意识到语言与思维的紧密关系,我在书的第一部分就安排了Go语言编程思维导引,希望大家意识到Go编程思维在语言精进之路上的重要性。

一门编程语言的思维也不是与生俱来的,而是在演进中逐步形成的。所以在这一部分,我安排了Go诞生与演进、Go设计哲学:简单、组合、并发、面向工程。这样做的目的是让大家一起了解Go语言设计者在设计Go语言时的所思所想,让读者站在语言设计者的高度理解Go语言与众不同的设计,认同Go语言的设计理念。因为这些是Go编程语言思维形成的“土壤”

这一部分最后一节是Go编程思维举例导引,书中给出了C, Haskell和Go程序员在面对同一个问题时,首先考虑到的思维方式以及不同思维下代码设计方式的差异。

知道Go编程思维的重要性后,我们应该怎么做呢?

4. 怎么学习Go编程思维?

学习的本质是一种模仿。要学习Go思维,就要去模仿Go团队、Go社区的优秀项目和代码,看看他们怎么做的。这套书后面的部分讲的就是这个。而“践行哲学,遵循惯例,认清本质,理解原理”就是对后面内容的写作思路的概要性总结。

  • 践行哲学

把Go设计哲学用于自己的项目的设计实践中,而不是仅停留在口头知道上。

  • 遵循惯例

遵循Go团队的一些语言惯例,比如“comma,ok”、使用复合字面值初始化等,使用这些惯例你可以让你的代码显得很地道,别人一看就懂。

  • 认清本质

为了更高效地利用语言机制,我们要认清一些语言机制背后的本质,比如切片、字符串在运行时的表示,这样一来既能帮助开发人员正确使用这些语法元素,同时也能避免入坑。

  • 理解原理

Go带有运行时。运行时全程参与Go应用生命周期,因此,只有对Goroutine调度、GC等原理做适当了解,才能更好的发挥Go的威力。

这套书的part2-part10 就是基于对Go团队、Go社区优秀实践与惯例的梳理,用系统化的思路构建出来并循序渐进呈现给大家的。

5. Part2 – 项目基础:布局、代码风格与命名

这部门的内容是每个gopher在开启一个Go项目时都要考虑的事情。

  • 项目布局

我见过很多Gopher问项目布局的事情,因为Go官方没有给出标准布局。本书讲解了Go项目的结构布局的演进历程以及Go社区的事实标准,希望能给大家提供足够的参考信息。

  • 代码风格

针对Go代码风格,由于代码风格在Go中已经弱化,所以这里主要还是带大家理解gofmt存在的意义和使用方法。

  • 命名惯例

关于命名,我不知道大家是否觉得命名难,但对我来说是挺难的,我总是绞尽脑汁在想用啥名(手动允悲)。所以我的原则是“代码未动,命名先行”。 对于Go中变量、标识符等的命名惯例这样的“关键的问题”,我使用了“笨方法”:我统计了Go标准库、Docker库、k8s库的命名情况,并分门别类给出不同语法元素的命名惯例,具体内容大家可以看书了解 。

6. Part3 – 语法基础:声明、类型、语句与控制结构

第三部分讲的很基础,但内容还是要高于基础的。

  • 一致的变量声明

我们知道Go提供多种变量声明方式,但是在不同位置该用哪种声明方式可读性好又不容易造坑呢(尤其要注意短变量声明)?书中给出了系统阐述。

  • 无类型常量与iota

大家都用过常量,但很多人对于无类型常量与有类型常量区别不了解,书中帮你做了总结。还有,很多人用过iota,但却不理解iota的真正含义以及它能帮你做啥。书中对iota的语义做了说明,对常见用途做了梳理。

  • 零值可用

Go提倡零值可用,也内置了有很多零值可用类型,用起来很爽,比如:切片(不全是,仅在append时是零值可用,当用下标访问时,不具备零值可用)、sync包中的Mutex、RDMutex等

其实类比于线程(thread),goroutine也是一种零值可用的“类型”,只是Go没有goroutine这个类型罢了。

如果我们是包的设计者,如果提供零值可用的类型,可以提升包的使用者的体验。

  • 复合字面值来初始化

使用复合字面值对相应的变量进行初始化是一个Go语言的惯例, Go虽然提供了new和make,但日常很少用,尤其是new。

  • 切片、字符串、map的原理、惯用法与坑

Go是带有runtime的语言,语法层面展示的很多语法元素和runtime层真实的表示并不一致。要想高效利用这些类型,如果不了解runtime层表示还真不行。有时候还有很严重的“坑”。懂了,自然就能绕过坑。

  • 包导入

Go源文件的import语句后面跟着的是包名还是包路径?Go编译是不是必须要有依赖项的源码才可以,只有.a是否可以?这些问题书中都有系统说明

  • 代码块与作用域

代码块与作用域是Go语言的基础概念,虽然基础,如果理解不好,也是有“坑”的,比如最常见的变量遮蔽等。一旦理解透了,还可以帮你解决意想不到的语法问题和执行语义错误问题。

  • 控制语句

Go倡导“一个问题只有一种解决方法”。Go针对每种控制语句仅提供一种语法形式。虽然仅有一种形式,用不好,一样容器掉坑。本套书总结了Go控制语句的惯用法与使用注意事项。

7. Part4 – 语法基础:函数与方法

我们日常编写的Go代码逻辑都在函数或方法中,函数/方法是Go程序逻辑的基本承载单元。

  • init函数

init函数是包初始化过程中执行的函数,它有很多特殊用途。并且其初始化顺序对程序执行语义也有影响,这方面要搞清楚。书中对init函数的常见用途做了梳理,比如database/sql包的驱动自注册模式等。

  • 成为“一等公民”

在Go中,函数成为了“一等公民”。函数成为一等公民后可以像变量一样,被作为参数传递到函数中、作为返回值从函数中返回、作为右值赋值给其他变量等,书中系统讲解了这个特性都有哪些性质和特殊应用,比如函数式编程等。

  • defer语句的惯用法与坑

defer就是帮你简化代码逻辑的,书中总结了defer语句的应用模式。以及使用defer的注意事项,比如函数求值时机、使用开销等。

  • 变长参数函数

Go支持变长参数函数。大家可以没有意识到:变长参数函数是我们日常用的最多的一类函数,比如append函数、fmt.Printf系列、log包中提供的按日志严重级别输出日志的函数等。

但变长参数函数可能也是我们自己设计与实现较少的一类函数形式。 变长参数函数能帮我们做什么呢?书中讲解了变长参数函数的常见用途,比如实现功能选项模式等。

  • 方法的本质、receiver参数类型选择、方法集合

方法的本质其实是函数,弄清楚方法的本质可以帮助我们解决很多难题,书中以实例方式帮助大家理解这一点。

方法receiver参数类型的选择也是Go初学者的常见困惑,这里书中给出三个原则,参照这三个原则,receiver类型选择就不是问题了。

怎么确定一个类型是否实现接口?我们需要看类型的方法集合。那么确定一个类型方法集合就十分重要,尤其是那些包括类型嵌入的类型的方法集合,书中对这块内容做了系统的讲解。

8. Part5 – 语法核心:接口

  • 接口的内部表示

接口是Go语言中的重要语法。Russ Cox曾说过:“如果要从Go语言中挑选出一个特性放入其他语言,我会选择接口”。可见接口的重要性。不过,用好接口类型的前提是理解接口在runtime层的表示,这一节会详细说明空接口与非空接口的内部表示。

  • 接口的设计惯例

我们应该设计什么样的接口呢? 大接口有何弊端?小接口有何优势?多小的接口算是合理的呢?这些在本节都有说明。

  • 接口与组合

组合是Go的设计哲学,Go是关于组合的语言。接口在面向组合编程时将发挥重要作用。这里我将提到Go的两种组合方式:垂直组合和水平组合。其中接口类型在水平组合中起到的关键性的作用。书中还讲解了通过接口进行水平组合的几种模式:包裹模式、适配器函数、中间件等。

很多初学者告诉我,他们做了一段时间Go编码了,但还没有自己设计过接口,我建议这样的同学好好读读这一部分。

9. Part6 – 语法核心:并发编程

  • 并发设计vs并行设计

学习并发编程首先要搞懂并发与并行的概念,书中用了一个很形象的机场安检的例子,来告诉大家并发与并行的区别。并发关乎结构,并行关注执行

  • 并发原语的原理与应用模式

Go实现了csp模型,提供了goroutine、channel、select并发原语。

理解go并发编程。首先要深入理解基于goroutine的并发模型与调度方式。书中对这方面做了深入浅出的讲解,不涉及太多代码,相信大家都能看懂。

书中还对比了go并发模型,一种是csp,一种是传统的基于共享内存方式,并列举了Go并发的常见模式,比如创建、取消、超时、管道模式等。

另外,channel作为goroutine间通信的标准原语,有很多玩法,这里列举了常见的模式和使用注意事项。

  • 低级同步原语(sync和atomic)

虽然有了CSP模型的并发原语,极大简化并发编程,但是sync包和原子操作也不能忘记,很多性能敏感的临界区还需要sync包/atomic这样的低级同步原语来同步。

10. Part7 – 错误处理

单独将错误处理拎出来,是因为很多人尤其是来自java的童鞋,习惯了try-catch-finally的结构化错误处理,看到go的错误处理就让其头疼。

Go语言十分重视错误处理,但它也的确有着相对保守的设计和显式处理错误的惯例。

本部分涵盖常见Go错误处理的策略、避免if err != nil写太多的方案,更为重要的是panic与错误处理的差别。我见过太多将panic用作正常处理的同学了。尤其是来自java阵营的童鞋。

11. Part8 – 编程实践:测试、调试与性能剖析

本部分聚焦编码之外的Go工具链工程实践。

  • Go测试惯例与组织形式

这部分首先和大家聊聊go test包的组织形式,包括是选择包内测试还是包外测试?何时采用符合go惯例的表驱动的测试用例组织形式?如何管理测试依赖的外部数据文件等。

  • 模糊测试(fuzzing test)。

这里的模糊测试并非基于go 1.18的原生fuzzing test进行,写书的时候go 1.18版本尚未发布,而是基于德米特里-维尤科夫的go-fuzz工具

  • 性能基准测试、度量数据与pprof性能剖析

Go原生提供性能基准测试。这一节讲解了如何做性能基准测试、如何编写串行与并行的测试、性能基准测试结果比较工具以及如何排除额外干扰,让结果更准确等方面内容。在讲解pprof性能剖析工具时,我使用一个实例进行剖析讲解,这样理解起来更为直观。

  • Go调试

说到Go调试,我们日常使用最多的估计还是print大法。但在print大法之外,其实有一个事实标准的Go调试工具,它就是delve。在这一节中,我讲解了delve的工作原理以及使用delve如何实现并发调试、coredump调试以及在线挂接(attach)进程的调试。

12. Part9 – 标准库、反射与cgo

go是自带电池,开箱即用的语言,拥有高质量的标准库。在国外有些Gopher甚至倡导仅依赖标准库实现go应用。

  • 高频使用的标准库包(net、http、strings、time、crypto等)

在这一节,我对高频使用的标准库包的原理和使用进行拆解分析,net、http、标准库io模型、strings、time、crypto等以帮助大家更高效的运用标准库。

  • reflect包使用的三大法则

reflect包为go提供了反射能力,书中对反射的实现原理做了讲解,重点是reflect使用的三大法则。

  • cgo使用

cgo不是go,但是cgo机制是使用go与c交互的唯一手段。书中对cgo的用法与约束做了详细讲解,尤其是在cgo开启的情况下如何做静态编译值得大家细读。

  • unsafe包的安全使用法则

事实证明unsafe包很有用,但要做到安全使用unsafe包,尤其是unsafe.Pointer,需要遵循一定的安全使用法则。书中对此做了举例详细说明。

反射、cgo、unsafe算是高级话题,要透彻理解,需要多阅读几遍书中内容并结合实践。

13. Part10 – 工程实践

  • go module

go module在go 1.11版本中引入go,在go 1.16版本中成为go官方默认构建模式。go程序员入门go,精进go都跨不过go module这道坎儿。书中对go module构建模式做了超级系统的讲解:从go构建模式演进历史、go module的概念、原理、惯例、升降级major版本的操作,到使用注意事项等。不过这里还有有一些瑕疵,那就是go module这一节放置的位置太靠后了,应该往往前面提提。如果后面有修订版,可以考虑这么做。

  • 自定义go包导入路径

书中还给出了一个自定义go包导入路径的一种实现方案,十分适合组织内部的私有仓库,有兴趣的同学可以重点看看。

  • go命令的使用模式详解

这一节将go命令分门别类地进行详细说明。包括:

- 获取与安装的go get/go install
- go包检视的go list
- go包构建的go build
- 运行与诊断的GODEBUG、GOGC等环境变量的功用
- 代码静态检查与重构
- 文档查看
- go代码生成go generate
  • Go常见的“坑”

这一节将Go常见的“坑”进行了一次检阅。我这里将坑分为“语法类”和“标准库类”,并借鉴了央视五套天下足球top10节目,对每个坑的“遇坑指数”与“坑害指数”做了点评。

14. 具备完整的示例代码与勘误表

这套书拥有具备完整的示例代码与勘误表,它们都被持续维护,让大家没有读书的后顾之忧。

三. 读书的实践与体会

下面我再分享一下我个人是怎么读书的,包括go技术书籍的读书历程,以及关于读书的一些实践体会。

读书是千人千面的事,没有固定标准的。我的读书方法也不见得适合诸位。大家听听即可,觉得还不错,能借鉴上就最好了。

今天收看直播估计以gopher为主,所以首先说说Go语言书籍的阅读历程

1. Go语言书籍阅读历程:先外后内

对于IT技术类图书,初期还是要看原版的。这个没办法,因为it编程技术绝大多数来自国外。

我读的第一本Go技术书就是《the way to go》,至今这本书也没有引入国内。这是一本Go语言百科全书,大多数内容如今仍适用。唯一不足是该书成书于Go 1.0发布之前,使用的好像是r60版本,有少部分内容已经不适用。

后来Go 1.0发布后,我还陆续读过Addison-Wesley出版的《programming in go》和《The Go Programming Language Phrasebook》,两本书都还不错。

2015年末的布莱恩.克尼根和go核心团队的多诺万联合编写的《The Go Programming Language》,国内称之为Go圣经的书出版了,这让外文go技术书籍达到了巅峰,后来虽然也有go书籍书籍陆续出版,但都无法触及go圣经的地位。

说完外文图书,我再来说说中文Go图书的阅读历程。

我读过的第一本中文Go书籍是2012年许式伟老师的《Go语言编程》,很佩服许老师的眼光和魄力,七牛云很早就在生产用go。

第二本中文Go书籍是雨痕老师的《go学习笔记》,这也是国内第一本深入到go底层原理的书籍(后半部分),遗憾的是书籍停留在go 1.5(还是go 1.6)的实现上,没有随Go版本演进而持续更新。

柴大和曹大合著的《go高级编程》也是一本不错的go技术书籍,如果你要深入学习cgo和go汇编,建议阅读此书。

后面的《Go语言底层原理剖析》和《Go语言设计与实现》也都是以深入了解Go运行机制为目标的书籍,口碑都很好,对这方面内容感兴趣的gopher,可以任意挑一本学习。

2. 自己的读书方法

我的读书方法其实不复杂,主要分为精读和泛读。

  • 阅读方式:好书精读,闲书泛读

好书,集中一大段时间内进行阅读。 闲书(不烧脑),通常是 碎片化阅读。

  • 精读方法:摘录+脑图+行动清单

摘录就是将书中的观点和细节摘录出来,放到读书笔记,最好能用自己的语言重新描述出来,这样印象深刻,理解更为透彻。

脑图,概括书的思维脉络,防止读完就忘记。 通过脑图,我至少看着脉络能想起来。

行动清单:如果没有能输出行动清单,那这本书对你来说意义就不大。 什么是好书,好书就是那种看完后很迫切的想基于书中的观点做点什么。行动清单将有助于我在后续的行动中反复理解书中内容,提高知识的消化率和理解深度。

  • 泛读方法:碎片化+听书

泛读主要是碎片化快读或听书,主要是坐地铁,坐公交,散步时。开车时在保证安全的前提下,可以用听书的方式。

四. 小结

本次分享了三块内容,这里小结一下:

  • 写书历程和写书三要素:能力 + 意愿 + 机会;
  • Go精进之路导读:思维先行,践行哲学,遵循惯例,认清本质,理解原理;
  • 读书方法:选高质量图书精读(脑图+细节摘录+行动清单)。

五. Q&A

  • 在实际开发中有没有什么优雅的处理error的方法?

建议看《Go语言精进之路》第一册第七部分中关于error处理的内容。

  • 是否在工作中使用过六边形架构以及依赖注入的处理经验?

暂没有使用过六边形架构,生产中没有使用过Go第三方依赖注入的方案。

  • 后面会有泛型和模糊测试的补充么?

从书籍内容覆盖全面性的角度而言,我个人有补充上述内容的想法,但还要看现在这套书的销售情况以及出版社的计划。目前还没列入个人工作计划。

  • 作者总结一系列go方法论、惯例等很实用,这种有逻辑的思考和见解是怎么形成的?

没有特意考虑过是怎么形成的。个人平时喜欢多问自己几个为什么,形成让自己信服的工作和学习逻辑。(文字稿补充:同理心、多总结、多复盘、多输出)。

学习Go惯例、方法论,可以多多看Go语言开源项目自身的代码评审,看看Go contributor写代码的思路和如何评审其他贡献者的代码的。(文字稿补充:在这一过程中,潜移默化的感受Go编程思维)。

  • 如何阅读大型go项目的源码?

我个人的方法就是自上而下。先拆分结构,然后找入口。如果是一个可执行的go程序,还是从入口层层的向后看。然后通过一些工具,比如我个人之前开发的函数调用跟踪工具,查看程序执行过程中的函数调用次序。

更细节的内容,还是要深入到代码中去查看。

  • 对Go项目中的一些设计模式的看法?如何使用设计模式,使用时注意哪些事项?

设计模式在go语言中并不是一个经常拿出来提的东西。我之前的一个观点:在其他语言中,需要大家通过一些额外细心的设计构建出来的设计模式,在Go语言中是自然而然就有的东西。

我在自己的日常编码过程中,不会太多从如何应用设计模式的角度思考,而是按照go设计哲学,去考虑并发设计、组合的设计,而不是非要套用那23个经典设计模式。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats