Go语言系列(三)

728 阅读3分钟

31. 自定义错误

使用 New 函数创建自定义错误

创建自定义错误最简单的方法是使用 [errors]包中的 [New]函数。

在使用 New [函数]创建自定义错误之前,我们先来看看 New 是如何实现的。如下所示,是 [errors 包]中的 New 函数的实现。

// Package errors implements functions to manipulate errors.
package errors

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

New 函数的实现很简单。errorString 是一个[结构体]类型,只有一个字符串字段 s。第 14 行使用了 errorString 指针接受者(Pointer Receiver),来实现 error 接口的 Error() string [方法]。

第 5 行的 New 函数有一个字符串参数,通过这个参数创建了 errorString 类型的变量,并返回了它的地址。于是它就创建并返回了一个新的错误。

现在我们已经知道了 New 函数是如何工作的,我们开始在程序里使用 New 来创建自定义错误吧。

我们将创建一个计算圆半径的简单程序,如果半径为负,它会返回一个错误。

package main

import (  
    "errors"
    "fmt"
    "math"
)

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, errors.New("Area calculation failed, radius is less than zero")
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

在上面的程序中,我们检查半径是否小于零(第 10 行)。如果半径小于零,我们会返回等于 0 的面积,以及相应的错误信息。如果半径大于零,则会计算出面积,并返回值为 nil 的错误(第 13 行)。

main 函数里,我们在第 19 行检查错误是否等于 nil。如果不是 nil,我们会打印出错误并返回,否则我们会打印出圆的面积。

在我们的程序中,半径小于零,因此打印出:

Area calculation failed, radius is less than zero

使用 Errorf 给错误添加更多信息

上面的程序效果不错,但是如果我们能够打印出当前圆的半径,那就更好了。这就要用到 [fmt]包中的 [Errorf] 函数了。Errorf 函数会根据格式说明符,规定错误的格式,并返回一个符合该错误的[字符串]。

接下来我们使用 Errorf 函数来改进我们的程序。

package main

import (  
    "fmt"
    "math"
)

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, fmt.Errorf("Area calculation failed, radius %0.2f is less than zero", radius)
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

在上面的程序中,我们使用 Errorf(第 10 行)打印了发生错误的半径。程序运行后会输出:

Area calculation failed, radius -20.00 is less than zero

使用结构体类型和字段提供错误的更多信息

错误还可以用实现了 error [接口]的结构体来表示。这种方式可以更加灵活地处理错误。在上面例子中,如果我们希望访问引发错误的半径,现在唯一的方法就是解析错误的描述信息 Area calculation failed, radius -20.00 is less than zero。这样做不太好,因为一旦描述信息发生变化,程序就会出错。

我们会使用标准库里采用的方法,在上一教程中“断言底层结构体类型,使用结构体字段获取更多信息”这一节,我们讲解了这一方法,可以使用结构体字段来访问引发错误的半径。我们会创建一个实现 error 接口的结构体类型,并使用它的字段来提供关于错误的更多信息。

第一步就是创建一个表示错误的结构体类型。错误类型的命名约定是名称以 Error 结尾。因此我们不妨把结构体类型命名为 areaError

type areaError struct {  
    err    string
    radius float64
}

上面的结构体类型有一个 radius 字段,它存储了与错误有关的半径,而 err 字段存储了实际的错误信息。

下一步是实现 error 接口。

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

在上面的代码中,我们使用指针接收者 *areaError,实现了 error 接口的 Error() string 方法。该方法打印出半径和关于错误的描述。

现在我们来编写 main 函数和 circleArea 函数来完成整个程序。

package main

import (  
    "fmt"
    "math"
)

type areaError struct {  
    err    string
    radius float64
}

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, &areaError{"radius is negative", radius}
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            fmt.Printf("Radius %0.2f is less than zero", err.radius)
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of rectangle1 %0.2f", area)
}

在 playground 上运行

在上面的程序中,circleArea(第 17 行)用于计算圆的面积。该函数首先检查半径是否小于零,如果小于零,它会通过错误半径和对应错误信息,创建一个 areaError 类型的值,然后返回 areaError 值的地址,与此同时 area 等于 0(第 19 行)。于是我们提供了更多的错误信息(即导致错误的半径),我们使用了自定义错误的结构体字段来定义它

如果半径是非负数,该函数会在第 21 行计算并返回面积,同时错误值为 nil

main 函数的 26 行,我们试图计算半径为 -20 的圆的面积。由于半径小于零,因此会导致一个错误。

我们在第 27 行检查了错误是否为 nil,并在下一行断言了 *areaError 类型。如果错误是 *areaError 类型,我们就可以用 err.radius 来获取错误的半径(第 29 行),打印出自定义错误的消息,最后程序返回退出

如果断言错误,我们就在第 32 行打印该错误,并返回。如果没有发生错误,在第 35 行会打印出面积。

该程序会输出:

Radius -20.00 is less than zero

下面我们来使用上一教程提到的[第二种方法],使用自定义错误类型的方法来提供错误的更多信息。

使用结构体类型的方法来提供错误的更多信息

在本节里,我们会编写一个计算矩形面积的程序。如果长或宽小于零,程序就会打印出错误。

第一步就是创建一个表示错误的结构体。

type areaError struct {  
    err    string //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

上面的结构体类型除了有一个错误描述字段,还有可能引发错误的宽和高。

现在我们有了错误类型,我们来实现 error 接口,并给该错误类型添加两个方法,使它提供了更多的错误信息。

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

在上面的代码片段中,我们从 Error() string 方法中返回了关于错误的描述。当 length 小于零时,lengthNegative() bool 方法返回 true,而当 width 小于零时,widthNegative() bool 方法返回 true这两个方法都提供了关于错误的更多信息,在这里,它提示我们计算面积失败的原因(长度为负数或者宽度为负数)。于是我们就有了两个错误类型结构体的方法,来提供更多的错误信息

下一步就是编写计算面积的函数。

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

上面的 rectArea 函数检查了长或宽是否小于零,如果小于零,rectArea 会返回一个错误信息,否则 rectArea 会返回矩形的面积和一个值为 nil 的错误。

让我们创建 main 函数来完成整个程序。

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

main 程序中,我们检查了错误是否为 nil(第 4 行)。如果错误值不是 nil,我们会在下一行断言 *areaError 类型。然后,我们使用 lengthNegative()widthNegative() 方法,检查错误的原因是长度小于零还是宽度小于零。这样我们就使用了错误结构体类型的方法,来提供更多的错误信息。

如果没有错误发生,就会打印矩形的面积。

下面是整个程序的代码供你参考。

package main

import "fmt"

type areaError struct {  
    err    string  //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

该程序会打印输出:

error: length -5.00 is less than zero  
error: width -9.00 is less than zero

在上一教程[错误处理]中,我们介绍了三种提供更多错误信息的方法,现在我们已经看了其中两个示例。

第三种方法使用的是直接比较,比较简单。我留给读者作为练习,你们可以试着使用这种方法来给出自定义错误的更多信息。

本教程到此结束。

简单概括一下本教程讨论的内容:

  • 使用 New 函数创建自定义错误
  • 使用 Error 添加更多错误信息
  • 使用结构体类型和字段,提供更多错误信息
  • 使用结构体类型和方法,提供更多错误信息

32. panic 和 recover

什么是 panic?

在 Go 语言中,程序中一般是使用[错误]来处理异常情况。对于程序中出现的大部分异常情况,错误就已经够用了。

但在有些情况,当程序发生异常时,无法继续运行。在这种情况下,我们会使用 panic 来终止程序。当[函数]发生 panic 时,它会终止运行,在执行完所有的[延迟]函数后,程序控制返回到该函数的调用方。这样的过程会一直持续下去,直到当前[协程]的所有函数都返回退出,然后程序会打印出 panic 信息,接着打印出堆栈跟踪(Stack Trace),最后程序终止。在编写一个示例程序后,我们就能很好地理解这个概念了。

在本教程里,我们还会接着讨论,当程序发生 panic 时,使用 recover 可以重新获得对该程序的控制。

可以认为 panicrecover 与其他语言中的 try-catch-finally 语句类似,只不过一般我们很少使用 panicrecover。而当我们使用了 panicrecover 时,也会比 try-catch-finally 更加优雅,代码更加整洁。

什么时候应该使用 panic?

需要注意的是,你应该尽可能地使用错误,而不是使用 panic 和 recover。只有当程序不能继续运行的时候,才应该使用 panic 和 recover 机制

panic 有两个合理的用例。

  1. 发生了一个不能恢复的错误,此时程序不能继续运行。 一个例子就是 web 服务器无法绑定所要求的端口。在这种情况下,就应该使用 panic,因为如果不能绑定端口,啥也做不了。
  2. 发生了一个编程上的错误。 假如我们有一个接收指针参数的方法,而其他人使用 nil 作为参数调用了它。在这种情况下,我们可以使用 panic,因为这是一个编程错误:用 nil 参数调用了一个只能接收合法指针的方法。

panic 示例

内建函数 panic 的签名如下所示:

func panic(interface{})

当程序终止时,会打印传入 panic 的参数。我们写一个示例,你就会清楚它的用途了。我们现在就开始吧。

我们会写一个例子,来展示 panic 如何工作。

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

上面的程序很简单,会打印一个人的全名。第 7 行的 fullName 函数会打印出一个人的全名。该函数在第 8 行和第 11 行分别检查了 firstNamelastName 的指针是否为 nil。如果是 nilfullName 函数会调用含有不同的错误信息的 panic。当程序终止时,会打印出该错误信息。

运行该程序,会有如下输出:

panic: runtime error: last name cannot be nil

goroutine 1 [running]:  
main.fullName(0x1040c128, 0x0)  
    /tmp/sandbox135038844/main.go:12 +0x120
main.main()  
    /tmp/sandbox135038844/main.go:20 +0x80

我们来分析这个输出,理解一下 panic 是如何工作的,并且思考当程序发生 panic 时,会怎样打印堆栈跟踪。

在第 19 行,我们将 Elon 赋值给了 firstName。在第 20 行,我们调用了 fullName 函数,其中 lastName 等于 nil。因此,满足了第 11 行的条件,程序发生 panic。当出现了 panic 时,程序就会终止运行,打印出传入 panic 的参数,接着打印出堆栈跟踪。因此,第 14 行和第 15 行的代码并不会在发生 panic 之后执行。程序首先会打印出传入 panic 函数的信息:

panic: runtime error: last name cannot be empty

接着打印出堆栈跟踪。

程序在 fullName 函数的第 12 行发生 panic,因此,首先会打印出如下所示的输出。

main.fullName(0x1040c128, 0x0)  
    /tmp/sandbox135038844/main.go:12 +0x120

接着会打印出堆栈的下一项。在本例中,堆栈跟踪中的下一项是第 20 行(因为发生 panic 的 fullName 调用就在这一行),因此接下来会打印出:

main.main()  
    /tmp/sandbox135038844/main.go:20 +0x80

现在我们已经到达了导致 panic 的顶层函数,这里没有更多的层级,因此结束打印。

发生 panic 时的 defer

我们重新总结一下 panic 做了什么。当函数发生 panic 时,它会终止运行,在执行完所有的延迟函数后,程序控制返回到该函数的调用方。这样的过程会一直持续下去,直到当前协程的所有函数都返回退出,然后程序会打印出 panic 信息,接着打印出堆栈跟踪,最后程序终止

在上面的例子中,我们没有延迟调用任何函数。如果有延迟函数,会先调用它,然后程序控制返回到函数调用方。

我们来修改上面的示例,使用一个延迟语句。

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    defer fmt.Println("deferred call in fullName")
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

上述代码中,我们只修改了两处,分别在第 8 行和第 20 行添加了延迟函数的调用。

该函数会打印:

This program prints,

deferred call in fullName  
deferred call in main  
panic: runtime error: last name cannot be nil

goroutine 1 [running]:  
main.fullName(0x1042bf90, 0x0)  
    /tmp/sandbox060731990/main.go:13 +0x280
main.main()  
    /tmp/sandbox060731990/main.go:22 +0xc0

当程序在第 13 行发生 panic 时,首先执行了延迟函数,接着控制返回到函数调用方,调用方的延迟函数继续运行,直到到达顶层调用函数。

在我们的例子中,首先执行 fullName 函数中的 defer 语句(第 8 行)。程序打印出:

deferred call in fullName

接着程序返回到 main 函数,执行了 main 函数的延迟调用,因此会输出:

deferred call in main

现在程序控制到达了顶层函数,因此该函数会打印出 panic 信息,然后是堆栈跟踪,最后终止程序。

recover

recover 是一个内建函数,用于重新获得 panic 协程的控制。

recover 函数的标签如下所示:

func recover() interface{}

只有在延迟函数的内部,调用 recover 才有用。在延迟函数内调用 recover,可以取到 panic 的错误信息,并且停止 panic 续发事件(Panicking Sequence),程序运行恢复正常。如果在延迟函数的外部调用 recover,就不能停止 panic 续发事件。

我们来修改一下程序,在发生 panic 之后,使用 recover 来恢复正常的运行。

package main

import (  
    "fmt"
)

func recoverName() {  
    if r := recover(); r!= nil {
        fmt.Println("recovered from ", r)
    }
}

func fullName(firstName *string, lastName *string) {  
    defer recoverName()
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

在第 7 行,recoverName() 函数调用了 recover(),返回了调用 panic 的传参。在这里,我们只是打印出 recover 的返回值(第 8 行)。在 fullName 函数内,我们在第 14 行延迟调用了 recoverNames()

fullName 发生 panic 时,会调用延迟函数 recoverName(),它使用了 recover() 来停止 panic 续发事件。

该程序会输出:

recovered from  runtime error: last name cannot be nil  
returned normally from main  
deferred call in main

当程序在第 19 行发生 panic 时,会调用延迟函数 recoverName,它反过来会调用 recover() 来重新获得 panic 协程的控制。第 8 行调用了 recover,返回了 panic 的传参,因此会打印:

recovered from  runtime error: last name cannot be nil

在执行完 recover() 之后,panic 会停止,程序控制返回到调用方(在这里就是 main 函数),程序在发生 panic 之后,从第 29 行开始会继续正常地运行。程序会打印 returned normally from main,之后是 deferred call in main

panic,recover 和 Go 协程

只有在相同的 [Go 协程]中调用 recover 才管用。recover 不能恢复一个不同协程的 panic。我们用一个例子来理解这一点。

package main

import (  
    "fmt"
    "time"
)

func recovery() {  
    if r := recover(); r != nil {
        fmt.Println("recovered:", r)
    }
}

func a() {  
    defer recovery()
    fmt.Println("Inside A")
    go b()
    time.Sleep(1 * time.Second)
}

func b() {  
    fmt.Println("Inside B")
    panic("oh! B panicked")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

在上面的程序中,函数 b() 在第 23 行发生 panic。函数 a() 调用了一个延迟函数 recovery(),用于恢复 panic。在第 17 行,函数 b() 作为一个不同的协程来调用。下一行的 Sleep 只是保证 a()b() 运行结束之后才退出。

你认为程序会输出什么?panic 能够恢复吗?答案是否定的,panic 并不会恢复。因为调用 recovery 的协程和 b() 中发生 panic 的协程并不相同,因此不可能恢复 panic。

运行该程序会输出:

Inside A  
Inside B  
panic: oh! B panicked

goroutine 5 [running]:  
main.b()  
    /tmp/sandbox388039916/main.go:23 +0x80
created by main.a  
    /tmp/sandbox388039916/main.go:17 +0xc0

从输出可以看出,panic 没有恢复。

如果函数 b() 在相同的协程里调用,panic 就可以恢复。

如果程序的第 17 行由 go b() 修改为 b(),就可以恢复 panic 了,因为 panic 发生在与 recover 相同的协程里。如果运行这个修改后的程序,会输出:

Inside A  
Inside B  
recovered: oh! B panicked  
normally returned from main

运行时 panic

运行时错误(如数组越界)也会导致 panic。这等价于调用了内置函数 panic,其参数由接口类型 [runtime.Error]给出。runtime.Error 接口的定义如下:

type Error interface {  
    error
    // RuntimeError is a no-op function but
    // serves to distinguish types that are run time
    // errors from ordinary errors: a type is a
    // run time error if it has a RuntimeError method.
    RuntimeError()
}

runtime.Error 接口满足内建接口类型 [error]。

我们来编写一个示例,创建一个运行时 panic。

package main

import (  
    "fmt"
)

func a() {  
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}
func main() {  
    a()
    fmt.Println("normally returned from main")
}

在上面的程序中,第 9 行我们试图访问 n[3],这是一个对[切片]的错误引用。该程序会发生 panic,输出如下:

panic: runtime error: index out of range

goroutine 1 [running]:  
main.a()  
    /tmp/sandbox780439659/main.go:9 +0x40
main.main()  
    /tmp/sandbox780439659/main.go:13 +0x20

你也许想知道,是否可以恢复一个运行时 panic?当然可以!我们来修改一下上面的代码,恢复这个 panic。

package main

import (  
    "fmt"
)

func r() {  
    if r := recover(); r != nil {
        fmt.Println("Recovered", r)
    }
}

func a() {  
    defer r()
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

运行上面程序会输出:

Recovered runtime error: index out of range  
normally returned from main

从输出可以知道,我们已经恢复了这个 panic。

恢复后获得堆栈跟踪

当我们恢复 panic 时,我们就释放了它的堆栈跟踪。实际上,在上述程序里,恢复 panic 之后,我们就失去了堆栈跟踪。

有办法可以打印出堆栈跟踪,就是使用 [Debug]包中的 [PrintStack]函数。

package main

import (  
    "fmt"
    "runtime/debug"
)

func r() {  
    if r := recover(); r != nil {
        fmt.Println("Recovered", r)
        debug.PrintStack()
    }
}

func a() {  
    defer r()
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

在上面的程序中,我们在第 11 行使用了 debug.PrintStack() 打印堆栈跟踪。

该程序会输出:

Recovered runtime error: index out of range  
goroutine 1 [running]:  
runtime/debug.Stack(0x1042beb8, 0x2, 0x2, 0x1c)  
    /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
runtime/debug.PrintStack()  
    /usr/local/go/src/runtime/debug/stack.go:16 +0x20
main.r()  
    /tmp/sandbox949178097/main.go:11 +0xe0
panic(0xf0a80, 0x17cd50)  
    /usr/local/go/src/runtime/panic.go:491 +0x2c0
main.a()  
    /tmp/sandbox949178097/main.go:18 +0x80
main.main()  
    /tmp/sandbox949178097/main.go:23 +0x20
normally returned from main

从输出我们可以看出,首先已经恢复了 panic,打印出 Recovered runtime error: index out of range。此外,我们也打印出了堆栈跟踪。在恢复了 panic 之后,还打印出 normally returned from main

本教程到此结束。

简单概括一下本教程讨论的内容:

  • 什么是 panic?
  • 什么时候应该使用 panic?
  • panic 示例
  • 发生 panic 时的 defer
  • recover
  • panic,recover 和 Go 协程
  • 运行时 panic
  • 恢复后获得堆栈跟踪

33. 函数是一等公民(头等函数)

现在简单概括一下本教程讨论的内容:

  • 什么是头等函数?
  • 匿名函数
  • 用户自定义的函数类型
  • 高阶函数
    • 把函数作为参数,传递给其它函数
    • 在其它函数中返回函数
  • 闭包
  • 头等函数的实际用途

什么是头等函数?

支持头等函数(First Class Function)的编程语言,可以把函数赋值给变量,也可以把函数作为其它函数的参数或者返回值。Go 语言支持头等函数的机制

本教程我们会讨论头等函数的语法和用例。

匿名函数

我们来编写一个简单的示例,把函数赋值给一个变量。

package main

import (  
    "fmt"
)

func main() {  
    a := func() {
        fmt.Println("hello world first class function")
    }
    a()
    fmt.Printf("%T", a)
}

在上面的程序中,我们将一个函数赋值给了变量 a(第 8 行)。这是把函数赋值给变量的语法。你如果观察得仔细的话,会发现赋值给 a 的函数没有名称。由于没有名称,这类函数称为匿名函数(Anonymous Function)

调用该函数的唯一方法就是使用变量 a。我们在下一行调用了它。a() 调用了这个函数,打印出 hello world first class function。在第 12 行,我们打印出 a 的类型。这会输出 func()

运行该程序,会输出:

hello world first class function
func()

要调用一个匿名函数,可以不用赋值给变量。通过下面的例子,我们看看这是怎么做到的。

package main

import (  
    "fmt"
)

func main() {  
    func() {
        fmt.Println("hello world first class function")
    }()
}

在上面的程序中,第 8 行定义了一个匿名函数,并在定义之后,我们使用 () 立即调用了该函数(第 10 行)。该程序会输出:

hello world first class function

就像其它函数一样,还可以向匿名函数传递参数。

package main

import (  
    "fmt"
)

func main() {  
    func(n string) {
        fmt.Println("Welcome", n)
    }("Gophers")
}

在上面的程序中,我们向匿名函数传递了一个字符串参数(第 10 行)。运行该程序后会输出:

Welcome Gophers

用户自定义的函数类型

正如我们定义自己的结构体类型一样,我们可以定义自己的函数类型。

type add func(a int, b int) int

以上代码片段创建了一个新的函数类型 add,它接收两个整型参数,并返回一个整型。现在我们来定义 add 类型的变量。

我们来编写一个程序,定义一个 add 类型的变量。

package main

import (  
    "fmt"
)

type add func(a int, b int) int

func main() {  
    var a add = func(a int, b int) int {
        return a + b
    }
    s := a(5, 6)
    fmt.Println("Sum", s)
}

在上面程序的第 10 行,我们定义了一个 add 类型的变量 a,并向它赋值了一个符合 add 类型签名的函数。我们在第 13 行调用了该函数,并将结果赋值给 s。该程序会输出:

Sum 11

高阶函数

高阶函数(Hiher-order Function)定义为:满足下列条件之一的函数

  • 接收一个或多个函数作为参数
  • 返回值是一个函数

针对上述两种情况,我们看看一些简单实例。

把函数作为参数,传递给其它函数

package main

import (  
    "fmt"
)

func simple(a func(a, b int) int) {  
    fmt.Println(a(60, 7))
}

func main() {  
    f := func(a, b int) int {
        return a + b
    }
    simple(f)
}

在上面的实例中,第 7 行我们定义了一个函数 simplesimple 接收一个函数参数(该函数接收两个 int 参数,返回一个 a 整型)。在 main 函数的第 12 行,我们创建了一个匿名函数 f,其签名符合 simple 函数的参数。我们在下一行调用了 simple,并传递了参数 f。该程序打印输出 67。

在其它函数中返回函数

现在我们重写上面的代码,在 simple 函数中返回一个函数。

package main

import (  
    "fmt"
)

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

func main() {  
    s := simple()
    fmt.Println(s(60, 7))
}

在上面程序中,第 7 行的 simple 函数返回了一个函数,并接受两个 int 参数,返回一个 int

在第 15 行,我们调用了 simple 函数。我们把 simple 的返回值赋值给了 s。现在 s 包含了 simple 函数返回的函数。我们调用了 s,并向它传递了两个 int 参数(第 16 行)。该程序输出 67。

闭包

闭包(Closure)是匿名函数的一个特例。当一个匿名函数所访问的变量定义在函数体的外部时,就称这样的匿名函数为闭包。

看看一个示例就明白了。

package main

import (  
    "fmt"
)

func main() {  
    a := 5
    func() {
        fmt.Println("a =", a)
    }()
}

在上面的程序中,匿名函数在第 10 行访问了变量 a,而 a 存在于函数体的外部。因此这个匿名函数就是闭包。

每一个闭包都会绑定一个它自己的外围变量(Surrounding Variable)。我们通过一个简单示例来体会这句话的含义。

package main

import (  
    "fmt"
)

func appendStr() func(string) string {  
    t := "Hello"
    c := func(b string) string {
        t = t + " " + b
        return t
    }
    return c
}

func main() {  
    a := appendStr()
    b := appendStr()
    fmt.Println(a("World"))
    fmt.Println(b("Everyone"))

    fmt.Println(a("Gopher"))
    fmt.Println(b("!"))
}

在上面程序中,函数 appendStr 返回了一个闭包。这个闭包绑定了变量 t。我们来理解这是什么意思。

在第 17 行和第 18 行声明的变量 ab 都是闭包,它们绑定了各自的 t 值。

我们首先用参数 World 调用了 a。现在 at 值变为了 Hello World

在第 20 行,我们又用参数 Everyone 调用了 b。由于 b 绑定了自己的变量 t,因此 b 中的 t 还是等于初始值 Hello。于是该函数调用之后,b 中的 t 变为了 Hello Everyone。程序的其他部分很简单,不再解释。

该程序会输出:

Hello World  
Hello Everyone  
Hello World Gopher  
Hello Everyone !

头等函数的实际用途

迄今为止,我们已经定义了什么是头等函数,也看了一些专门设计的示例,来学习它们如何工作。现在我们来编写一些实际的程序,来展现头等函数的实际用处。

我们会创建一个程序,基于一些条件,来过滤一个 students 切片。现在我们来逐步实现它。

首先定义一个 student 类型。

type student struct {  
    firstName string
    lastName string
    grade string
    country string
}

下一步是编写一个 filter 函数。该函数接收一个 students 切片和一个函数作为参数,这个函数会计算一个学生是否满足筛选条件。写出这个函数后,你很快就会明白,我们继续吧。

func filter(s []student, f func(student) bool) []student {  
    var r []student
    for _, v := range s {
        if f(v) == true {
            r = append(r, v)
        }
    }
    return r
}

在上面的函数中,filter 的第二个参数是一个函数。这个函数接收 student 参数,返回一个 bool 值。这个函数计算了某一学生是否满足筛选条件。我们在第 3 行遍历了 student 切片,将每个学生作为参数传递给了函数 f。如果该函数返回 true,就表示该学生通过了筛选条件,接着将该学生添加到了结果切片 r 中。你可能会很困惑这个函数的实际用途,等我们完成程序你就知道了。我添加了 main 函数,整个程序如下所示:

package main

import (  
    "fmt"
)

type student struct {  
    firstName string
    lastName  string
    grade     string
    country   string
}

func filter(s []student, f func(student) bool) []student {  
    var r []student
    for _, v := range s {
        if f(v) == true {
            r = append(r, v)
        }
    }
    return r
}

func main() {  
    s1 := student{
        firstName: "Naveen",
        lastName:  "Ramanathan",
        grade:     "A",
        country:   "India",
    }
    s2 := student{
        firstName: "Samuel",
        lastName:  "Johnson",
        grade:     "B",
        country:   "USA",
    }
    s := []student{s1, s2}
    f := filter(s, func(s student) bool {
        if s.grade == "B" {
            return true
        }
        return false
    })
    fmt.Println(f)
}

main 函数中,我们首先创建了两个学生 s1s2,并将他们添加到了切片 s。现在假设我们想要查询所有成绩为 B 的学生。为了实现这样的功能,我们传递了一个检查学生成绩是否为 B 的函数,如果是,该函数会返回 true。我们把这个函数作为参数传递给了 filter 函数(第 38 行)。上述程序会输出:

[{Samuel Johnson B USA}]

假设我们想要查找所有来自印度的学生。通过修改传递给 filter 的函数参数,就很容易地实现了。

实现它的代码如下所示:

c := filter(s, func(s student) bool {  
    if s.country == "India" {
        return true
    }
    return false
})
fmt.Println(c)

请将该函数添加到 main 函数,并检查它的输出。

我们最后再编写一个程序,来结束这一节的讨论。这个程序会对切片的每个元素执行相同的操作,并返回结果。例如,如果我们希望将切片中的所有整数乘以 5,并返回出结果,那么通过头等函数可以很轻松地实现。我们把这种对集合中的每个元素进行操作的函数称为 map 函数。相关代码如下所示,它们很容易看懂。

package main

import (  
    "fmt"
)

func iMap(s []int, f func(int) int) []int {  
    var r []int
    for _, v := range s {
        r = append(r, f(v))
    }
    return r
}
func main() {  
    a := []int{5, 6, 7, 8, 9}
    r := iMap(a, func(n int) int {
        return n * 5
    })
    fmt.Println(r)
}

该程序会输出:

[25 30 35 40 45]

34. 反射

反射是 Go 语言的高级主题之一。

分为如下小节。

  • 什么是反射?
  • 为何需要检查变量,确定变量的类型?
  • reflect 包
    • reflect.Type 和 reflect.Value
    • reflect.Kind
    • NumField() 和 Field() 方法
    • Int() 和 String() 方法
  • 完整的程序
  • 我们应该使用反射吗?

什么是反射?

反射就是程序能够在运行时检查变量和值,求出它们的类型。你可能还不太懂,这没关系。在本教程结束后,你就会清楚地理解反射,所以跟着我们的教程学习吧。

为何需要检查变量,确定变量的类型?

在学习反射时,所有人首先面临的疑惑就是:如果程序中每个变量都是我们自己定义的,那么在编译时就可以知道变量类型了,为什么我们还需要在运行时检查变量,求出它的类型呢?没错,在大多数时候都是这样,但并非总是如此。

我来解释一下吧。下面我们编写一个简单的程序。

package main

import (
    "fmt"
)

func main() {
    i := 10
    fmt.Printf("%d %T", i, i)
}

在上面的程序中,i 的类型在编译时就知道了,然后我们在下一行打印出 i。这里没什么特别之处。

现在了解一下,需要在运行时求得变量类型的情况。假如我们要编写一个简单的函数,它接收结构体作为参数,并用它来创建一个 SQL 插入查询。

考虑下面的程序:

package main

import (
    "fmt"
)

type order struct {
    ordId      int
    customerId int
}

func main() {
    o := order{
        ordId:      1234,
        customerId: 567,
    }
    fmt.Println(o)
}

在上面的程序中,我们需要编写一个函数,接收结构体变量 o 作为参数,返回下面的 SQL 插入查询。

insert into order values(1234, 567)

这个函数写起来很简单。我们现在编写这个函数。

package main

import (
    "fmt"
)

type order struct {
    ordId      int
    customerId int
}

func createQuery(o order) string {
    i := fmt.Sprintf("insert into order values(%d, %d)", o.ordId, o.customerId)
    return i
}

func main() {
    o := order{
        ordId:      1234,
        customerId: 567,
    }
    fmt.Println(createQuery(o))
}

在第 12 行,createQuery 函数用 o 的两个字段(ordIdcustomerId),创建了插入查询。该程序会输出:

insert into order values(1234, 567)

现在我们来升级这个查询生成器。如果我们想让它变得通用,可以适用于任何结构体类型,该怎么办呢?我们用程序来理解一下。

package main

type order struct {
    ordId      int
    customerId int
}

type employee struct {
    name string
    id int
    address string
    salary int
    country string
}

func createQuery(q interface{}) string {
}

func main() {

}

我们的目标就是完成 createQuery 函数(上述程序中的第 16 行),它可以接收任何结构体作为参数,根据结构体的字段创建插入查询。

例如,如果我们传入下面的结构体:

o := order {
    ordId: 1234,
    customerId: 567
}

createQuery 函数应该返回:

insert into order values (1234, 567)

类似地,如果我们传入:

 e := employee {
        name: "Naveen",
        id: 565,
        address: "Science Park Road, Singapore",
        salary: 90000,
        country: "Singapore",
    }

该函数会返回:

insert into employee values("Naveen", 565, "Science Park Road, Singapore", 90000, "Singapore")

由于 createQuery 函数应该适用于任何结构体,因此它接收 interface{} 作为参数。为了简单起见,我们只处理包含 stringint 类型字段的结构体,但可以扩展为包含任何类型的字段。

createQuery 函数应该适用于所有的结构体。因此,要编写这个函数,就必须在运行时检查传递过来的结构体参数的类型,找到结构体字段,接着创建查询。这时就需要用到反射了。在本教程的下一步,我们将会学习如何使用 reflect 包来实现它。

reflect 包

在 Go 语言中,reflect实现了运行时反射。reflect 包会帮助识别 interface{}变量的底层具体类型和具体值。这正是我们所需要的。createQuery 函数接收 interface{} 参数,根据它的具体类型和具体值,创建 SQL 查询。这正是 reflect 包能够帮助我们的地方。

在编写我们通用的查询生成器之前,我们首先需要了解 reflect 包中的几种类型和方法。让我们来逐个了解。

reflect.Type 和 reflect.Value

reflect.Type 表示 interface{} 的具体类型,而 reflect.Value 表示它的具体值。reflect.TypeOf()reflect.ValueOf() 两个函数可以分别返回 reflect.Typereflect.Value。这两种类型是我们创建查询生成器的基础。我们现在用一个简单的例子来理解这两种类型。

package main

import (
    "fmt"
    "reflect"
)

type order struct {
    ordId      int
    customerId int
}

func createQuery(q interface{}) {
    t := reflect.TypeOf(q)
    v := reflect.ValueOf(q)
    fmt.Println("Type ", t)
    fmt.Println("Value ", v)


}
func main() {
    o := order{
        ordId:      456,
        customerId: 56,
    }
    createQuery(o)

}

在上面的程序中,第 13 行的 createQuery 函数接收 interface{} 作为参数。在第 14 行,reflect.TypeOf 接收了参数 interface{},返回了reflect.Type,它包含了传入的 interface{} 参数的具体类型。同样地,在第 15 行,reflect.ValueOf函数接收参数 interface{},并返回了 reflect.Value,它包含了传来的 interface{} 的具体值。

上述程序会打印:

Type  main.order
Value  {456 56}

从输出我们可以看到,程序打印了接口的具体类型和具体值。

relfect.Kind

reflect 包中还有一个重要的类型:Kind

在反射包中,KindType 的类型可能看起来很相似,但在下面程序中,可以很清楚地看出它们的不同之处。

package main

import (
    "fmt"
    "reflect"
)

type order struct {
    ordId      int
    customerId int
}

func createQuery(q interface{}) {
    t := reflect.TypeOf(q)
    k := t.Kind()
    fmt.Println("Type ", t)
    fmt.Println("Kind ", k)


}
func main() {
    o := order{
        ordId:      456,
        customerId: 56,
    }
    createQuery(o)

}

上述程序会输出:

Type  main.order
Kind  struct

我想你应该很清楚两者的区别了。Type 表示 interface{} 的实际类型(在这里是 main.Order),而 Kind 表示该类型的特定类别(在这里是 struct)。

NumField() 和 Field() 方法

NumField()方法返回结构体中字段的数量,而 Field(i int)方法返回字段 ireflect.Value

package main

import (
    "fmt"
    "reflect"
)

type order struct {
    ordId      int
    customerId int
}

func createQuery(q interface{}) {
    if reflect.ValueOf(q).Kind() == reflect.Struct {
        v := reflect.ValueOf(q)
        fmt.Println("Number of fields", v.NumField())
        for i := 0; i < v.NumField(); i++ {
            fmt.Printf("Field:%d type:%T value:%v\n", i, v.Field(i), v.Field(i))
        }
    }

}
func main() {
    o := order{
        ordId:      456,
        customerId: 56,
    }
    createQuery(o)
}

在上面的程序中,因为 NumField 方法只能在结构体上使用,我们在第 14 行首先检查了 q 的类别是 struct。程序的其他代码很容易看懂,不作解释。该程序会输出:

Number of fields 2
Field:0 type:reflect.Value value:456
Field:1 type:reflect.Value value:56

Int() 和 String() 方法

IntString可以帮助我们分别取出 reflect.Value 作为 int64string

package main

import (
    "fmt"
    "reflect"
)

func main() {
    a := 56
    x := reflect.ValueOf(a).Int()
    fmt.Printf("type:%T value:%v\n", x, x)
    b := "Naveen"
    y := reflect.ValueOf(b).String()
    fmt.Printf("type:%T value:%v\n", y, y)

}

在上面程序中的第 10 行,我们取出 reflect.Value,并转换为 int64,而在第 13 行,我们取出 reflect.Value 并将其转换为 string。该程序会输出:

type:int64 value:56
type:string value:Naveen

完整的程序

现在我们已经具备足够多的知识,来完成我们的查询生成器了,我们来实现它把。

package main

import (
    "fmt"
    "reflect"
)

type order struct {
    ordId      int
    customerId int
}

type employee struct {
    name    string
    id      int
    address string
    salary  int
    country string
}

func createQuery(q interface{}) {
    if reflect.ValueOf(q).Kind() == reflect.Struct {
        t := reflect.TypeOf(q).Name()
        query := fmt.Sprintf("insert into %s values(", t)
        v := reflect.ValueOf(q)
        for i := 0; i < v.NumField(); i++ {
            switch v.Field(i).Kind() {
            case reflect.Int:
                if i == 0 {
                    query = fmt.Sprintf("%s%d", query, v.Field(i).Int())
                } else {
                    query = fmt.Sprintf("%s, %d", query, v.Field(i).Int())
                }
            case reflect.String:
                if i == 0 {
                    query = fmt.Sprintf("%s\"%s\"", query, v.Field(i).String())
                } else {
                    query = fmt.Sprintf("%s, \"%s\"", query, v.Field(i).String())
                }
            default:
                fmt.Println("Unsupported type")
                return
            }
        }
        query = fmt.Sprintf("%s)", query)
        fmt.Println(query)
        return

    }
    fmt.Println("unsupported type")
}

func main() {
    o := order{
        ordId:      456,
        customerId: 56,
    }
    createQuery(o)

    e := employee{
        name:    "Naveen",
        id:      565,
        address: "Coimbatore",
        salary:  90000,
        country: "India",
    }
    createQuery(e)
    i := 90
    createQuery(i)

}

在第 22 行,我们首先检查了传来的参数是否是一个结构体。在第 23 行,我们使用了 Name() 方法,从该结构体的 reflect.Type 获取了结构体的名字。接下来一行,我们用 t 来创建查询。

在第 28 行,case 语句检查了当前字段是否为 reflect.Int,如果是的话,我们会取到该字段的值,并使用 Int() 方法转换为 int64。if else 语句用于处理边界情况。请添加日志来理解为什么需要它。在第 34 行,我们用来相同的逻辑来取到 string

我们还作了额外的检查,以防止 createQuery 函数传入不支持的类型时,程序发生崩溃。程序的其他代码是自解释性的。我建议你在合适的地方添加日志,检查输出,来更好地理解这个程序。

该程序会输出:

insert into order values(456, 56)
insert into employee values("Naveen", 565, "Coimbatore", 90000, "India")
unsupported type

至于向输出的查询中添加字段名,我们把它留给读者作为练习。请尝试着修改程序,打印出以下格式的查询。

insert into order(ordId, customerId) values(456, 56)

我们应该使用反射吗?

我们已经展示了反射的实际应用,现在考虑一个很现实的问题。我们应该使用反射吗?我想引用 Rob Pike关于使用反射的格言,来回答这个问题。

清晰优于聪明。而反射并不是一目了然的。

反射是 Go 语言中非常强大和高级的概念,我们应该小心谨慎地使用它。使用反射编写清晰和可维护的代码是十分困难的。你应该尽可能避免使用它,只在必须用到它时,才使用反射。

35. 读取文件

文件读取是所有编程语言中最常见的操作之一。本教程我们会学习如何使用 Go 读取文件。

本教程分为如下小节。

  • 将整个文件读取到内存
    • 使用绝对文件路径
    • 使用命令行标记来传递文件路径
    • 将文件绑定在二进制文件中
  • 分块读取文件
  • 逐行读取文件

将整个文件读取到内存

将整个文件读取到内存是最基本的文件操作之一。这需要使用 ioutil]包中的 ReadFile 函数。

让我们在 Go 程序所在的目录中,读取一个文件。我已经在 GOPATH(译注:原文是 GOROOT,应该是笔误)中创建了文件夹,在该文件夹内部,有一个文本文件 test.txt,我们会使用 Go 程序 filehandling.go 来读取它。test.txt 包含文本 “Hello World. Welcome to file handling in Go”。我的文件夹结构如下:

src
    filehandling
        filehandling.go
        test.txt

接下来我们来看看代码。

package main

import (
    "fmt"
    "io/ioutil"
)

func main() {
    data, err := ioutil.ReadFile("test.txt")
    if err != nil {
        fmt.Println("File reading error", err)
        return
    }
    fmt.Println("Contents of file:", string(data))
}

由于无法在 playground 上读取文件,因此请在你的本地环境运行这个程序。

在上述程序的第 9 行,程序会读取文件,并返回一个字节切片,而这个切片保存在 data 中。在第 14 行,我们将 data 转换为 string,显示出文件的内容。

请在 test.txt 所在的位置运行该程序。

例如,对于 linux/mac,如果 test.txt 位于 /home/naveen/go/src/filehandling,可以使用下列步骤来运行程序。

$ cd /home/naveen/go/src/filehandling/
$ go install filehandling
$ workspacepath/bin/filehandling

对于 windows,如果 test.txt 位于 C:\Users\naveen.r\go\src\filehandling,则使用下列步骤。

> cd C:\Users\naveen.r\go\src\filehandling
> go install filehandling
> workspacepath\bin\filehandling.exe

该程序会输出:

Contents of file: Hello World. Welcome to file handling in Go.

如果在其他位置运行这个程序(比如 /home/userdirectory),会打印下面的错误。

File reading error open test.txt: The system cannot find the file specified.

这是因为 Go 是编译型语言。go install 会根据源代码创建一个二进制文件。二进制文件独立于源代码,可以在任何位置上运行。由于在运行二进制文件的位置上没有找到 test.txt,因此程序会报错,提示无法找到指定的文件。

有三种方法可以解决这个问题。

  1. 使用绝对文件路径
  2. 使用命令行标记来传递文件路径
  3. 将文件绑定在二进制文件中

让我们来依次介绍。

1. 使用绝对文件路径

要解决问题,最简单的方法就是传入绝对文件路径。我已经修改了程序,把路径改成了绝对路径。

package main

import (
    "fmt"
    "io/ioutil"
)

func main() {
    data, err := ioutil.ReadFile("/home/naveen/go/src/filehandling/test.txt")
    if err != nil {
        fmt.Println("File reading error", err)
        return
    }
    fmt.Println("Contents of file:", string(data))
}

现在可以在任何位置上运行程序,打印出 test.txt 的内容。

例如,可以在我的家目录运行。

$ cd $HOME
$ go install filehandling
$ workspacepath/bin/filehandling

该程序打印出了 test.txt 的内容。

看似这是一个简单的方法,但它的缺点是:文件必须放在程序指定的路径中,否则就会出错。

2. 使用命令行标记来传递文件路径

另一种解决方案是使用命令行标记来传递文件路径。使用 flag 包,我们可以从输入的命令行获取到文件路径,接着读取文件内容。

首先我们来看看 flag 包是如何工作的。flag 包有一个名为 String函数。该函数接收三个参数。第一个参数是标记名,第二个是默认值,第三个是标记的简短描述。

让我们来编写程序,从命令行读取文件名。将 filehandling.go 的内容替换如下:

package main
import (
    "flag"
    "fmt"
)

func main() {
    fptr := flag.String("fpath", "test.txt", "file path to read from")
    flag.Parse()
    fmt.Println("value of fpath is", *fptr)
}

在上述程序中第 8 行,通过 String 函数,创建了一个字符串标记,名称是 fpath,默认值是 test.txt,描述为 file path to read from。这个函数返回存储 flag 值的字符串变量的地址。

在程序访问 flag 之前,必须先调用 flag.Parse()

在第 10 行,程序会打印出 flag 值。

使用下面命令运行程序。

wrkspacepath/bin/filehandling -fpath=/path-of-file/test.txt

我们传入 /path-of-file/test.txt,赋值给了 fpath 标记。

该程序输出:

value of fpath is /path-of-file/test.txt

这是因为 fpath 的默认值是 test.txt

现在我们知道如何从命令行读取文件路径了,让我们继续完成我们的文件读取程序。

package main
import (
    "flag"
    "fmt"
    "io/ioutil"
)

func main() {
    fptr := flag.String("fpath", "test.txt", "file path to read from")
    flag.Parse()
    data, err := ioutil.ReadFile(*fptr)
    if err != nil {
        fmt.Println("File reading error", err)
        return
    }
    fmt.Println("Contents of file:", string(data))
}

在上述程序里,命令行传入文件路径,程序读取了该文件的内容。使用下面命令运行该程序。

wrkspacepath/bin/filehandling -fpath=/path-of-file/test.txt

请将 /path-of-file/ 替换为 test.txt 的真实路径。该程序将打印:

Contents of file: Hello World. Welcome to file handling in Go.

3. 将文件绑定在二进制文件中

虽然从命令行获取文件路径的方法很好,但还有一种更好的解决方法。如果我们能够将文本文件捆绑在二进制文件,岂不是很棒?这就是我们下面要做的事情。

有很多可以帮助我们实现。我们会使用 packr,因为它很简单,并且我在项目中使用它时,没有出现任何问题。

第一步就是安装 packr 包。

在命令提示符中输入下面命令,安装 packr 包。

go get -u github.com/gobuffalo/packr/...

packr 会把静态文件(例如 .txt 文件)转换为 .go 文件,接下来,.go 文件会直接嵌入到二进制文件中。packer 非常智能,在开发过程中,可以从磁盘而非二进制文件中获取静态文件。在开发过程中,当仅仅静态文件变化时,可以不必重新编译。

我们通过程序来更好地理解它。用以下内容来替换 handling.go 文件。

package main

import (
    "fmt"

    "github.com/gobuffalo/packr"
)

func main() {
    box := packr.NewBox("../filehandling")
    data := box.String("test.txt")
    fmt.Println("Contents of file:", data)
}

在上面程序的第 10 行,我们创建了一个新盒子(New Box)。盒子表示一个文件夹,其内容会嵌入到二进制中。在这里,我指定了 filehandling 文件夹,其内容包含 test.txt。在下一行,我们读取了文件内容,并打印出来。

在开发阶段时,我们可以使用 go install 命令来运行程序。程序可以正常运行。packr 非常智能,在开发阶段可以从磁盘加载文件。

使用下面命令来运行程序。

go install filehandling
workspacepath/bin/filehandling

该命令可以在其他位置运行。packr 很聪明,可以获取传递给 NewBox 命令的目录的绝对路径。

该程序会输出:

Contents of file: Hello World. Welcome to file handling in Go.

你可以试着改变 test.txt 的内容,然后再运行 filehandling。可以看到,无需再次编译,程序打印出了 test.txt 的更新内容。完美!

现在我们来看看如何将 test.txt 打包到我们的二进制文件中。我们使用 packr 命令来实现。

运行下面的命令:

packr install -v filehandling

它会打印:

building box ../filehandling
packing file filehandling.go
packed file filehandling.go
packing file test.txt
packed file test.txt
built box ../filehandling with ["filehandling.go" "test.txt"]
filehandling

该命令将静态文件绑定到了二进制文件中。

在运行上述命令之后,使用命令 workspacepath/bin/filehandling 来运行程序。程序会打印出 test.txt 的内容。于是从二进制文件中,我们读取了 test.txt 的内容。

如果你不知道文件到底是由二进制还是磁盘来提供,我建议你删除 test.txt,并在此运行 filehandling 命令。你将看到,程序打印出了 test.txt 的内容。太棒了:D。我们已经成功将静态文件嵌入到了二进制文件中。

分块读取文件

在前面的章节,我们学习了如何把整个文件读取到内存。当文件非常大时,尤其在 RAM 存储量不足的情况下,把整个文件都读入内存是没有意义的。更好的方法是分块读取文件。这可以使用 bufio 包来完成。

让我们来编写一个程序,以 3 个字节的块为单位读取 test.txt 文件。如下所示,替换 filehandling.go 的内容。

package main

import (
    "bufio"
    "flag"
    "fmt"
    "log"
    "os"
)

func main() {
    fptr := flag.String("fpath", "test.txt", "file path to read from")
    flag.Parse()

    f, err := os.Open(*fptr)
    if err != nil {
        log.Fatal(err)
    }
    defer func() {
        if err = f.Close(); err != nil {
            log.Fatal(err)
        }
    }()
    r := bufio.NewReader(f)
    b := make([]byte, 3)
    for {
        _, err := r.Read(b)
        if err != nil {
            fmt.Println("Error reading file:", err)
            break
        }
        fmt.Println(string(b))
    }
}

在上述程序的第 15 行,我们使用命令行标记传递的路径,打开文件。

在第 19 行,我们延迟了文件的关闭操作。

在上面程序的第 24 行,我们新建了一个缓冲读取器(buffered reader)。在下一行,我们创建了长度和容量为 3 的字节切片,程序会把文件的字节读取到切片中。

第 27 行的 Read 方法会读取 len(b) 个字节(达到 3 字节),并返回所读取的字节数。当到达文件最后时,它会返回一个 EOF 错误。程序的其他地方比较简单,不做解释。

如果我们使用下面命令来运行程序:

$ go install filehandling
$ wrkspacepath/bin/filehandling -fpath=/path-of-file/test.txt

会得到以下输出:

Hel
lo
Wor
ld.
 We
lco
me
to
fil
e h
and
lin
g i
n G
o.
Error reading file: EOF

逐行读取文件

本节我们讨论如何使用 Go 逐行读取文件。这可以使用 bufio 来实现。

请将 test.txt 替换为以下内容。

Hello World. Welcome to file handling in Go.
This is the second line of the file.
We have reached the end of the file.

逐行读取文件涉及到以下步骤。

  1. 打开文件;
  2. 在文件上新建一个 scanner;
  3. 扫描文件并且逐行读取。

filehandling.go 替换为以下内容。

package main

import (
    "bufio"
    "flag"
    "fmt"
    "log"
    "os"
)

func main() {
    fptr := flag.String("fpath", "test.txt", "file path to read from")
    flag.Parse()

    f, err := os.Open(*fptr)
    if err != nil {
        log.Fatal(err)
    }
    defer func() {
        if err = f.Close(); err != nil {
        log.Fatal(err)
    }
    }()
    s := bufio.NewScanner(f)
    for s.Scan() {
        fmt.Println(s.Text())
    }
    err = s.Err()
    if err != nil {
        log.Fatal(err)
    }
}

在上述程序的第 15 行,我们用命令行标记传入的路径,打开文件。在第 24 行,我们用文件创建了一个新的 scanner。第 25 行的 Scan() 方法读取文件的下一行,如果可以读取,就可以使用 Text() 方法。

Scan 返回 false 时,除非已经到达文件末尾(此时 Err() 返回 nil),否则 Err() 就会返回扫描过程中出现的错误。

如果我使用下面命令来运行程序:

$ go install filehandling
$ workspacepath/bin/filehandling -fpath=/path-of-file/test.txt

程序会输出:

Hello World. Welcome to file handling in Go.
This is the second line of the file.
We have reached the end of the file.