指针和错误

你可以在这里找到本章的所有代码

我们在上一节中学习了结构体(structs),它可以组合与一个概念相关的一系列值。

你有时可能想用结构体来管理状态,通过将方法暴露给用户的方式,让他们在你可控的范围内修改状态。

金融科技行业都喜欢 Go 和比特币吧?那就来看看我们能创造出多么惊人的银行系统。

首先声明一个 Wallet(钱包)结构体,我们可以利用它存放 Bitcoin(比特币)

首先写测试

  1. func TestWallet(t *testing.T) {
  2. wallet := Wallet{}
  3. wallet.Deposit(10)
  4. got := wallet.Balance()
  5. want := 10
  6. if got != want {
  7. t.Errorf("got %d want %d", got, want)
  8. }
  9. }

在前面的示例中,我们直接使用字段名称访问字段,但是在 非常强调安全性的钱包 中,我们不想暴露自己的内部状态,而是通过方法来控制访问的权限。

尝试运行测试

./wallet_test.go:7:12: undefined: Wallet

为测试的运行编写最少量的代码并检查失败测试的输出

编译器不知道 Wallet 是什么,所以让我们告诉它。

  1. type Wallet struct { }

现在我们已经生成了自己的钱包,尝试再次运行测试

  1. ./wallet_test.go:9:8: wallet.Deposit undefined (type Wallet has no field or method Deposit)
  2. ./wallet_test.go:11:15: wallet.Balance undefined (type Wallet has no field or method Balance)

正如所料,我们需要定义这些方法以使测试通过。

请记住,只做足够让测试运行的事情。我们需要确保测试失败时,显示清晰的错误信息。

  1. func (w Wallet) Deposit(amount int) {
  2. }
  3. func (w Wallet) Balance() int {
  4. return 0
  5. }

如果你对此语法不熟悉,请重新阅读结构体章节。

测试现在应该编译通过了,然后运行

wallet_test.go:15: got 0 want 10

编写足够的代码使其通过

结构中需要一些 balance(余额)变量来存储状态

  1. type Wallet struct {
  2. balance int
  3. }

在 Go 中,如果一个符号(例如变量、类型、函数等)是以小写符号开头,那么它在 定义它的包之外 就是私有的。

在我们的例子中,我们只想让自己的方法修改这个值,而其他的不可以。

记住,我们可以使用「receiver」变量访问结构体内部的 balance 字段。

  1. func (w Wallet) Deposit(amount int) {
  2. w.balance += amount
  3. }
  4. func (w Wallet) Balance() int {
  5. return w.balance
  6. }

现在我们的事业在金融科技的保护下,将会运行并轻松的通过测试

wallet_test.go:15: got 0 want 10

为什么报错了?

这让人很困惑,我们的代码看上去没问题,我们在余额中添加了新的金额,然后余额的方法应该返回它当前的状态值。

在 Go 中,当调用一个函数或方法时,参数会被复制

当调用 func (w Wallet) Deposit(amount int) 时,w 是来自我们调用方法的副本。

不需要太过计算机化,当你创建一个值,例如一个 wallet,它就会被存储在内存的某处。你可以用 &myval 找到那块内存的地址

通过在代码中添加一些 prints 来试验一下

  1. func TestWallet(t *testing.T) {
  2. wallet := Wallet{}
  3. wallet.Deposit(10)
  4. got := wallet.Balance()
  5. fmt.Println("address of balance in test is", &wallet.balance)
  6. want := 10
  7. if got != want {
  8. t.Errorf("got %d want %d", got, want)
  9. }
  10. }
  1. func (w Wallet) Deposit(amount int) {
  2. fmt.Println("address of balance in Deposit is", &w.balance)
  3. w.balance += amount
  4. }

现在重新运行测试

  1. address of balance in Deposit is 0xc420012268
  2. address of balance in test is 0xc420012260

可以看出两个 balance 的地址是不同的。因此,当我们在代码中更改 balance 的值时,我们处理的是来自测试的副本。因此,balance 在测试中没有被改变。

我们可以用 指针 来解决这个问题。指针让我们 指向 某个值,然后修改它。所以,我们不是拿钱包的副本,而是拿一个指向钱包的指针,这样我们就可以改变它。

  1. func (w *Wallet) Deposit(amount int) {
  2. w.balance += amount
  3. }
  4. func (w *Wallet) Balance() int {
  5. return w.balance
  6. }

不同之处在于,接收者类型是 *Wallet 而不是 Wallet,你可以将其解读为「指向 wallet 的指针」。

尝试重新运行测试,它们应该可以通过了。

重构

我们曾说过我们正在制做一个比特币钱包,但到目前为止我们还没有提到它们。我们一直在使用 int,因为当用来计数时它是不错的类型!

为此创建一个结构体似乎有点过头了。就 int 的表现来说已经很好了,但问题是它不具有描述性。

Go 允许从现有的类型创建新的类型。

语法是 type MyName OriginalType

  1. type Bitcoin int
  2. type Wallet struct {
  3. balance Bitcoin
  4. }
  5. func (w *Wallet) Deposit(amount Bitcoin) {
  6. w.balance += amount
  7. }
  8. func (w *Wallet) Balance() Bitcoin {
  9. return w.balance
  10. }
  1. func TestWallet(t *testing.T) {
  2. wallet := Wallet{}
  3. wallet.Deposit(Bitcoin(10))
  4. got := wallet.Balance()
  5. want := Bitcoin(10)
  6. if got != want {
  7. t.Errorf("got %d want %d", got, want)
  8. }
  9. }

要生成 Bitcoin(比特币),你只需要用 Bitcoin(999) 的语法就可以了。

类型别名有一个有趣的特性,你还可以对它们声明 方法。当你希望在现有类型之上添加一些领域内特定的功能时,这将非常有用。

让我们实现 Bitcoin 的 Stringer 方法

  1. type Stringer interface {
  2. String() string
  3. }

这个接口是在 fmt 包中定义的。当使用 %s 打印格式化的字符串时,你可以定义此类型的打印方式。

  1. func (b Bitcoin) String() string {
  2. return fmt.Sprintf("%d BTC", b)
  3. }

如你所见,在类型别名上创建方法的语法与结构上的语法相同。

接下来,我们需要更新测试中的格式化字符串,以便它们将使用 String() 方法。

  1. if got != want {
  2. t.Errorf("got %s want %s", got, want)
  3. }

为了看到这一点,故意令测试失败我们就能看到

wallet_test.go:18: got 10 BTC want 20 BTC

这使得我们的测试更加清晰。

下一个需求是 Withdraw(提取)函数。

先写测试

几乎跟 Deposit() 相反

  1. func TestWallet(t *testing.T) {
  2. t.Run("Deposit", func(t *testing.T) {
  3. wallet := Wallet{}
  4. wallet.Deposit(Bitcoin(10))
  5. got := wallet.Balance()
  6. want := Bitcoin(10)
  7. if got != want {
  8. t.Errorf("got %s want %s", got, want)
  9. }
  10. })
  11. t.Run("Withdraw", func(t *testing.T) {
  12. wallet := Wallet{balance: Bitcoin(20)}
  13. wallet.Withdraw(10)
  14. got := wallet.Balance()
  15. want := Bitcoin(10)
  16. if got != want {
  17. t.Errorf("got %s want %s", got, want)
  18. }
  19. })
  20. }

尝试运行测试

./wallet_test.go:26:9: wallet.Withdraw undefined (type Wallet has no field or method Withdraw)

为测试的运行编写最少量的代码并检查失败测试的输出

  1. func (w *Wallet) Withdraw(amount Bitcoin) {
  2. }

wallet_test.go:33: got 20 BTC want 10 BTC

编写足够的代码使其通过

  1. func (w *Wallet) Withdraw(amount Bitcoin) {
  2. w.balance -= amount
  3. }

重构

在我们的测试中有一些重复部分,我们来重构一下。

  1. func TestWallet(t *testing.T) {
  2. assertBalance := func(t *testing.T, wallet Wallet, want Bitcoin) {
  3. got := wallet.Balance()
  4. if got != want {
  5. t.Errorf("got %s want %s", got, want)
  6. }
  7. }
  8. t.Run("Deposit", func(t *testing.T) {
  9. wallet := Wallet{}
  10. wallet.Deposit(Bitcoin(10))
  11. assertBalance(t, wallet, Bitcoin(10))
  12. })
  13. t.Run("Withdraw", func(t *testing.T) {
  14. wallet := Wallet{balance: Bitcoin(20)}
  15. wallet.Withdraw(Bitcoin(10))
  16. assertBalance(t, wallet, Bitcoin(10))
  17. })
  18. }

如果你试图从账户中取出更多的钱,会发生什么?目前,我们的要求是假定没有透支设备。

我们如何在使用 Withdraw 时标记出现的问题呢?

在 Go 中,如果你想指出一个错误,通常你的函数要返回一个 err,以便调用者检查并执行相应操作。

让我们在测试中试试。

先写测试

  1. t.Run("Withdraw insufficient funds", func(t *testing.T) {
  2. startingBalance := Bitcoin(20)
  3. wallet := Wallet{startingBalance}
  4. err := wallet.Withdraw(Bitcoin(100))
  5. assertBalance(t, wallet, startingBalance)
  6. if err == nil {
  7. t.Error("wanted an error but didn't get one")
  8. }
  9. })

如果 你尝试取出超过你余额的比特币,我们想让 Withdraw 返回一个错误,而余额应该保持不变。

然后,如果测试失败,我们检查错误是否为 nil

nil 是其他编程语言的 null。错误可以是 nil,因为返回类型是 error,这是一个接口。如果你看到一个函数,它接受参数或返回值的类型是接口,它们就可以是 nil

如果你尝试访问一个值为 nil 的值,它将会引发 运行时的 panic。这很糟糕!你应该确保你检查了 nil 的值。

尝试运行测试

./wallet_test.go:31:25: wallet.Withdraw(Bitcoin(100)) used as value

错误信息可能不太清楚,但我们之前对于 Withdraw 的意图只是调用它,它永远不会返回一个值。为了使它编译通过,我们需要更改它,以便它有一个返回类型。

为测试的运行编写最少量的代码并检查失败测试的输出

  1. func (w *Wallet) Withdraw(amount Bitcoin) error {
  2. w.balance -= amount
  3. return nil
  4. }

再次强调,编写足够的代码来满足编译器的要求是非常重要的。我们纠正了自己的 Withdraw 方法返回 error,现在我们必须返回 一些东西,所以我们就返回 nil 好了。

编写足够的代码使其通过

  1. func (w *Wallet) Withdraw(amount Bitcoin) error {
  2. if amount > w.balance {
  3. return errors.New("oh no")
  4. }
  5. w.balance -= amount
  6. return nil
  7. }

记住要将 errors 导入到代码中。

errors.New 创建了一个新的 error,并带有你选择的消息。

重构

让我们为错误检查做一个快速测试的助手方法,以帮助我们的测试读起来更清晰。

  1. assertError := func(t *testing.T, err error) {
  2. if err == nil {
  3. t.Error("wanted an error but didnt get one")
  4. }
  5. }

并且在我们的测试中

  1. t.Run("Withdraw insufficient funds", func(t *testing.T) {
  2. wallet := Wallet{Bitcoin(20)}
  3. err := wallet.Withdraw(Bitcoin(100))
  4. assertBalance(t, wallet, Bitcoin(20))
  5. assertError(t, err)
  6. })

希望在返回「oh no」的错误时,你会认为我们可能会迭代这个问题,因为它作为返回值看起来没什么用。

假设错误最终会返回给用户,让我们更新测试以断言某种错误消息,而不只是让错误存在。

先写测试

更新一个 string 的助手方法来比较。

  1. assertError := func(t *testing.T, got error, want string) {
  2. if got == nil {
  3. t.Fatal("didn't get an error but wanted one")
  4. }
  5. if got.Error() != want {
  6. t.Errorf("got '%s', want '%s'", got, want)
  7. }
  8. }

同时再更新调用者

  1. t.Run("Withdraw insufficient funds", func(t *testing.T) {
  2. startingBalance := Bitcoin(20)
  3. wallet := Wallet{startingBalance}
  4. err := wallet.Withdraw(Bitcoin(100))
  5. assertBalance(t, wallet, startingBalance)
  6. assertError(t, err, "cannot withdraw, insufficient funds")
  7. })

我们已经介绍了 t.Fatal。如果它被调用,它将停止测试。这是因为我们不希望对返回的错误进行更多断言。如果没有这个,测试将继续进行下一步,并且因为一个空指针而引起 panic。

尝试运行测试

wallet_test.go:61: got err 'oh no' want 'cannot withdraw, insufficient funds'

编写足够的代码使其通过

  1. func (w *Wallet) Withdraw(amount Bitcoin) error {
  2. if amount > w.balance {
  3. return errors.New("cannot withdraw, insufficient funds")
  4. }
  5. w.balance -= amount
  6. return nil
  7. }

重构

我们在测试代码和 Withdraw 代码中都有重复的错误消息。

如果有人想要重新定义这个错误,那么测试就会失败,这将是非常恼人的,而对于我们的测试来说,这里有太多的细节了。我们并不关心具体的措辞是什么,只是在给定条件的情况下返回一些有意义的错误。

在 Go 中,错误是值,因此我们可以将其重构为一个变量,并为其提供一个单一的事实来源。

  1. var InsufficientFundsError = errors.New("cannot withdraw, insufficient funds")
  2. func (w *Wallet) Withdraw(amount Bitcoin) error {
  3. if amount > w.balance {
  4. return InsufficientFundsError
  5. }
  6. w.balance -= amount
  7. return nil
  8. }

var 关键字允许我们定义包的全局值。

这是一个积极的变化,因为现在我们的 Withdraw 函数看起来很清晰。

接下来,我们可以重构我们的测试代码来使用这个值而不是特定的字符串。

  1. func TestWallet(t *testing.T) {
  2. t.Run("Deposit", func(t *testing.T) {
  3. wallet := Wallet{}
  4. wallet.Deposit(Bitcoin(10))
  5. assertBalance(t, wallet, Bitcoin(10))
  6. })
  7. t.Run("Withdraw with funds", func(t *testing.T) {
  8. wallet := Wallet{Bitcoin(20)}
  9. wallet.Withdraw(Bitcoin(10))
  10. assertBalance(t, wallet, Bitcoin(10))
  11. })
  12. t.Run("Withdraw insufficient funds", func(t *testing.T) {
  13. wallet := Wallet{Bitcoin(20)}
  14. err := wallet.Withdraw(Bitcoin(100))
  15. assertBalance(t, wallet, Bitcoin(20))
  16. assertError(t, err, InsufficientFundsError)
  17. })
  18. }
  19. func assertBalance(t *testing.T, wallet Wallet, want Bitcoin) {
  20. got := wallet.Balance()
  21. if got != want {
  22. t.Errorf("got '%s' want '%s'", got, want)
  23. }
  24. }
  25. func assertError(t *testing.T, got error, want error) {
  26. if got == nil {
  27. t.Fatal("didn't get an error but wanted one")
  28. }
  29. if got != want {
  30. t.Errorf("got '%s', want '%s'", got, want)
  31. }
  32. }

现在这个测试也更容易理解了。

我已经将助手函数从主测试函数中移出,这样当某人打开一个文件时,他们就可以开始读取我们的断言,而不是一些助手函数。

测试的另一个有用的特性是,它帮助我们理解代码的真实用途,从而使我们的代码更具交互性。我们可以看到,开发人员可以简单地调用我们的代码,并对 InsufficientFundsError 进行相等的检查,并采取相应的操作。

未经检查的错误

虽然 Go 编译器对你有很大帮助,但有时你仍然会忽略一些事情,错误处理有时会很棘手。

有一种情况我们还没有测试过。要找到它,在一个终端中运行以下命令来安装 errcheck,这是许多可用的 linters(代码检测工具)之一。

go get -u github.com/kisielk/errcheck

然后,在你的代码目录中运行 errcheck .

你应该会得到如下类似的内容:

wallet_test.go:17:18: wallet.Withdraw(Bitcoin(10))

这告诉我们的是,我们没有检查在代码行中返回的错误。我的计算机上的这行代码与我们的正常 withdraw 的场景相对应,因为我们没有检查 Withdraw 是否成功,因此没有返回错误。

这是最终的测试代码。

  1. func TestWallet(t *testing.T) {
  2. t.Run("Deposit", func(t *testing.T) {
  3. wallet := Wallet{}
  4. wallet.Deposit(Bitcoin(10))
  5. assertBalance(t, wallet, Bitcoin(10))
  6. })
  7. t.Run("Withdraw with funds", func(t *testing.T) {
  8. wallet := Wallet{Bitcoin(20)}
  9. err := wallet.Withdraw(Bitcoin(10))
  10. assertBalance(t, wallet, Bitcoin(10))
  11. assertNoError(t, err)
  12. })
  13. t.Run("Withdraw insufficient funds", func(t *testing.T) {
  14. wallet := Wallet{Bitcoin(20)}
  15. err := wallet.Withdraw(Bitcoin(100))
  16. assertBalance(t, wallet, Bitcoin(20))
  17. assertError(t, err, InsufficientFundsError)
  18. })
  19. }
  20. func assertBalance(t *testing.T, wallet Wallet, want Bitcoin) {
  21. got := wallet.Balance()
  22. if got != want {
  23. t.Errorf("got %s want %s", got, want)
  24. }
  25. }
  26. func assertNoError(t *testing.T, got error) {
  27. if got != nil {
  28. t.Fatal("got an error but didnt want one")
  29. }
  30. }
  31. func assertError(t *testing.T, got error, want error) {
  32. if got == nil {
  33. t.Fatal("didn't get an error but wanted one")
  34. }
  35. if got != want {
  36. t.Errorf("got %s, want %s", got, want)
  37. }
  38. }

总结

指针

  • 当你传值给函数或方法时,Go 会复制这些值。因此,如果你写的函数需要更改状态,你就需要用指针指向你想要更改的值
  • Go 取值的副本在大多数时候是有效的,但是有时候你不希望你的系统只使用副本,在这种情况下你需要传递一个引用。例如,非常庞大的数据或者你只想有一个实例(比如数据库连接池)

nil

  • 指针可以是 nil
  • 当函数返回一个的指针,你需要确保检查过它是否为 nil,否则你可能会抛出一个执行异常,编译器在这里不能帮到你
  • nil 非常适合描述一个可能丢失的值

错误

  • 错误是在调用函数或方法时表示失败的
  • 通过测试我们得出结论,在错误中检查字符串会导致测试不稳定。因此,我们用一个有意义的值重构了,这样就更容易测试代码,同时对于我们 API 的用户来说也更简单。
  • 错误处理的故事远远还没有结束,你可以做更复杂的事情,这里只是抛砖引玉。后面的部分将介绍更多的策略。
  • 不要只是检查错误,要优雅地处理它们

从现有的类型中创建新的类型。

  • 用于为值添加更多的领域内特定的含义
  • 可以让你实现接口

指针和错误是 Go 开发中重要的组成部分,你需要适应这些。幸运的是,如果你做错了,编译器通常会帮你解决问题,你只需要花点时间读一下错误信息。


作者:Chris James译者:Donng校对:polaris1119

本文由 GCTT 原创编译,Go 中文网 荣誉推出