Maps
数组和切片的章节中,你学会了如何按顺序存储值。现在,我们再来看看如何通过存储值,并快速查找它们。
Maps 允许你以类似于字典的方式存储值。你可以将视为单词,将视为定义。难道还有比构建我们自己的字典更好的学习 map 的方式吗?

首先编写测试

dictionary_test.go 中编写代码:
1
package main
2
3
import "testing"
4
5
func TestSearch(t *testing.T) {
6
dictionary := map[string]string{"test": "this is just a test"}
7
8
got := Search(dictionary, "test")
9
want := "this is just a test"
10
11
if got != want {
12
t.Errorf("got '%s' want '%s' given, '%s'", got, want, "test")
13
}
14
}
Copied!
声明 map 的方式有点儿类似于数组。不同之处是,它以 map 关键字开头,需要两种类型。第一个是键的类型,写在 [] 中。第二个是值的类型,跟在 [] 之后。
键的类型很特别,它只能是一个可比较的类型,因为如果不能判断两个键是否相等,我们就无法确保我们得到的是正确的值。可比类型在语言规范中有详细解释。
另一方面,值的类型可以是任意类型,它甚至可以是另一个 map。
测试中的其他内容应该都很熟悉了。

尝试运行测试

运行 go test 后编译器会提示失败信息 ./dictionary_test.go:8:9: undefined: Search

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

dictionary.go 中:
1
package main
2
3
func Search(dictionary map[string]string, word string) string {
4
return ""
5
}
Copied!
测试应该失败并显示明确的错误信息
dictionary_test.go:12: got '' want 'this is just a test' given, 'test'

编写足够的代码使测试通过

1
func Search(dictionary map[string]string, word string) string {
2
return dictionary[word]
3
}
Copied!
从 map 中获取值和数组相同,都是通过 map[key] 的方式。

重构

1
func TestSearch(t *testing.T) {
2
dictionary := map[string]string{"test": "this is just a test"}
3
4
got := Search(dictionary, "test")
5
want := "this is just a test"
6
7
assertStrings(t, got, want)
8
}
9
10
func assertStrings(t *testing.T, got, want string) {
11
t.Helper()
12
13
if got != want {
14
t.Errorf("got '%s' want '%s'", got, want)
15
}
16
}
Copied!
我决定创建一个 assertStrings 辅助函数并删除 given 的部分让实现更通用。

使用自定义的类型

我们可以通过为 map 创建新的类型并使用 Search 方法改进字典的使用。
dictionary_test.go 中:
1
func TestSearch(t *testing.T) {
2
dictionary := Dictionary{"test": "this is just a test"}
3
4
got := dictionary.Search("test")
5
want := "this is just a test"
6
7
assertStrings(t, got, want)
8
}
Copied!
我们已经开始使用 Dictionary 类型了,但是我们还没有定义它。然后要在 Dictionary 实例上调用 Search 方法。
我们不需要更改 assertStrings
dictionary.go 中:
1
type Dictionary map[string]string
2
3
func (d Dictionary) Search(word string) string {
4
return d[word]
5
}
Copied!
在这里,我们创建了一个 Dictionary 类型,它是对 map 的简单封装。定义了自定义类型后,我们可以创建 Search 方法。

首先编写测试

基本的搜索很容易实现,但是如果我们提供一个不在我们字典中的单词,会发生什么呢?
我们实际上得不到任何返回。这很好,因为程序可以继续运行,但还有更好的方法。这个函数可以证明该单词不在字典中。这样,用户就不用猜测这个单词是不存在还是未定义了(这看起来可能对于字典没有用。但是,这可能是其他用例的关键场景)。
1
func TestSearch(t *testing.T) {
2
dictionary := Dictionary{"test": "this is just a test"}
3
4
t.Run("known word", func(t *testing.T) {
5
got, _ := dictionary.Search("test")
6
want := "this is just a test"
7
8
assertStrings(t, got, want)
9
})
10
11
t.Run("unknown word", func(t *testing.T) {
12
_, err := dictionary.Search("unknown")
13
want := "could not find the word you were looking for"
14
15
if err == nil {
16
t.Fatal("expected to get an error.")
17
}
18
19
assertStrings(t, err.Error(), want)
20
})
21
}
Copied!
在 Go 中处理这种情况的方法是返回第二个参数,它是一个 Error 类型。
Error 类型可以使用 .Error() 方法转换为字符串,我们将其传递给断言时会执行此操作。我们也用 if 来保护 assertStrings,以确保我们不在 nil 上调用 .Error()

尝试运行测试

这不会通过编译
1
./dictionary_test.go:18:10: assignment mismatch: 2 variables but 1 values
Copied!

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

1
func (d Dictionary) Search(word string) (string, error) {
2
return d[word], nil
3
}
Copied!
现在你的测试将会失败,并显示更加清晰的错误信息
dictionary_test.go:22: expected to get an error.

编写足够的代码使测试通过

1
func (d Dictionary) Search(word string) (string, error) {
2
definition, ok := d[word]
3
if !ok {
4
return "", errors.New("could not find the word you were looking for")
5
}
6
7
return definition, nil
8
}
Copied!
为了使测试通过,我们使用了一个 map 查找的有趣特性。它可以返回两个值。第二个值是一个布尔值,表示是否成功找到 key
此特性允许我们区分单词不存在还是未定义。

重构

1
var ErrNotFound = errors.New("could not find the word you were looking for")
2
3
func (d Dictionary) Search(word string) (string, error) {
4
definition, ok := d[word]
5
if !ok {
6
return "", ErrNotFound
7
}
8
9
return definition, nil
10
}
Copied!
我们通过将错误提取为变量的方式,摆脱 Search 中魔术错误(magic error)。这也会使我们获得更好的测试。
1
t.Run("unknown word", func(t *testing.T) {
2
_, got := dictionary.Search("unknown")
3
4
assertError(t, got, ErrNotFound)
5
})
6
7
func assertError(t *testing.T, got, want error) {
8
t.Helper()
9
10
if got != want {
11
t.Errorf("got error '%s' want '%s'", got, want)
12
}
13
}
Copied!
通过创建一个新的辅助函数,我们能够简化测试,并使用 ErrNotFound 变量,如果我们将来更改显示错误的文字,测试也不会失败。

首先编写测试

我们现在有很好的方法来搜索字典。但是,我们无法在字典中添加新单词。
1
func TestAdd(t *testing.T) {
2
dictionary := Dictionary{}
3
dictionary.Add("test", "this is just a test")
4
5
want := "this is just a test"
6
got, err := dictionary.Search("test")
7
if err != nil {
8
t.Fatal("should find added word:", err)
9
}
10
11
if want != got {
12
t.Errorf("got '%s' want '%s'", got, want)
13
}
14
}
Copied!
在这个测试中,我们利用 Search 方法使字典的验证更加容易。

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

dictionary.go 中:
1
func (d Dictionary) Add(word, definition string) {
2
}
Copied!
测试现在应该会失败:
1
dictionary_test.go:31: should find added word: could not find the word you were looking for
Copied!

编写足够的代码使测试通过

1
func (d Dictionary) Add(word, definition string) {
2
d[word] = definition
3
}
Copied!
向 map 添加元素也类似于数组。你只需指定键并给它赋一个值。

引用类型

Map 有一个有趣的特性,不使用指针传递你就可以修改它们。这是因为 map 是引用类型。这意味着它拥有对底层数据结构的引用,就像指针一样。它底层的数据结构是 hash tablehash map,你可以在这里阅读有关 hash tables 的更多信息。
Map 作为引用类型是非常好的,因为无论 map 有多大,都只会有一个副本。
引用类型引入了 maps 可以是 nil 值。如果你尝试使用一个 nil 的 map,你会得到一个 nil 指针异常,这将导致程序终止运行。
由于 nil 指针异常,你永远不应该初始化一个空的 map 变量:
1
var m map[string]string
Copied!
相反,你可以像我们上面那样初始化空 map,或使用 make 关键字创建 map:
1
dictionary = map[string]string{}
2
3
// OR
4
5
dictionary = make(map[string]string)
Copied!
这两种方法都可以创建一个空的 hash map 并指向 dictionary。这确保永远不会获得 nil 指针异常

重构

在我们的实现中没有太多可以重构的地方,但测试可以简化一点。
1
func TestAdd(t *testing.T) {
2
dictionary := Dictionary{}
3
word := "test"
4
definition := "this is just a test"
5
6
dictionary.Add(word, definition)
7
8
assertDefinition(t, dictionary, word, definition)
9
}
10
11
func assertDefinition(t *testing.T, dictionary Dictionary, word, definition string) {
12
t.Helper()
13
14
got, err := dictionary.Search(word)
15
if err != nil {
16
t.Fatal("should find added word:", err)
17
}
18
19
if definition != got {
20
t.Errorf("got '%s' want '%s'", got, definition)
21
}
22
}
Copied!
我们为单词和定义创建了变量,并将定义断言移到了自己的辅助函数中。
我们的 Add 看起来不错。除此之外,我们没有考虑当我们尝试添加的值已经存在时会发生什么!
如果值已存在,map 不会抛出错误。相反,它们将继续并使用新提供的值覆盖该值。这在实践中很方便,但会导致我们的函数名称不准确。Add 不应修改现有值。它应该只在我们的字典中添加新单词。

首先编写测试

1
func TestAdd(t *testing.T) {
2
t.Run("new word", func(t *testing.T) {
3
dictionary := Dictionary{}
4
word := "test"
5
definition := "this is just a test"
6
7
err := dictionary.Add(word, definition)
8
9
assertError(t, err, nil)
10
assertDefinition(t, dictionary, word, definition)
11
})
12
13
t.Run("existing word", func(t *testing.T) {
14
word := "test"
15
definition := "this is just a test"
16
dictionary := Dictionary{word: definition}
17
err := dictionary.Add(word, "new test")
18
19
assertError(t, err, ErrWordExists)
20
assertDefinition(t, dictionary, word, definition)
21
})
22
}
Copied!
对于此测试,我们修改了 Add 以返回错误,我们将针对新的错误变量 ErrWordExists 进行验证。我们还修改了之前的测试以检查是否为 nil 错误。

尝试运行测试

编译将失败,因为我们没有为 Add 返回值。
1
./dictionary_test.go:30:13: dictionary.Add(word, definition) used as value
2
./dictionary_test.go:41:13: dictionary.Add(word, "new test") used as value
Copied!

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

dictionary.go 中:
1
var (
2
ErrNotFound = errors.New("could not find the word you were looking for")
3
ErrWordExists = errors.New("cannot add word because it already exists")
4
)
5
6
func (d Dictionary) Add(word, definition string) error {
7
d[word] = definition
8
return nil
9
}
Copied!
现在我们又得到两个错误。我们仍在修改值,并返回 nil 错误。
1
dictionary_test.go:43: got error '%!s(<nil>)' want 'cannot add word because it already exists'
2
dictionary_test.go:44: got 'new test' want 'this is just a test'
Copied!

编写足够的代码使测试通过

1
func (d Dictionary) Add(word, definition string) error {
2
_, err := d.Search(word)
3
4
switch err {
5
case ErrNotFound:
6
d[word] = definition
7
case nil:
8
return ErrWordExists
9
default:
10
return err
11
}
12
13
return nil
14
}
Copied!
这里我们使用 switch 语句来匹配错误。如上使用 switch 提供了额外的安全,以防 Search 返回错误而不是 ErrNotFound

重构

我们没有太多需要重构的地方,但随着对错误使用的增多,我们还可以做一些修改。
1
const (
2
ErrNotFound = DictionaryErr("could not find the word you were looking for")
3
ErrWordExists = DictionaryErr("cannot add word because it already exists")
4
)
5
6
type DictionaryErr string
7
8
func (e DictionaryErr) Error() string {
9
return string(e)
10
}
Copied!
我们将错误声明为常量,这需要我们创建自己的 DictionaryErr 类型来实现 error 接口。你可以在 Dave Cheney 的这篇优秀文章中了解更多相关的细节。简而言之,它使错误更具可重用性和不可变性。

首先编写测试

1
func TestUpdate(t *testing.T) {
2
word := "test"
3
definition := "this is just a test"
4
dictionary := Dictionary{word: definition}
5
newDefinition := "new definition"
6
7
dictionary.Update(word, newDefinition)
8
9
assertDefinition(t, dictionary, word, newDefinition)
10
}
Copied!
UpdateCreate 密切相关,这是下一个需要我们实现的方法。

尝试运行测试

1
./dictionary_test.go:53:2: dictionary.Update undefined (type Dictionary has no field or method Update)
Copied!

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

我们已经知道如何处理这样的错误。我们需要定义我们的函数。
1
func (d Dictionary) Update(word, definition string) {}
Copied!
从这里可以看出我们需要改变这个词的定义。
1
dictionary_test.go:55: got 'this is just a test' want 'new definition'
Copied!

编写足够的代码使测试通过

当我们用 Create 解决问题时就明白了如何处理这个问题。所以让我们实现一个与 Create 非常相似的方法。
1
func (d Dictionary) Update(word, definition string) {
2
d[word] = definition
3
}
Copied!
我们不需要对此进行重构,因为更改很简单。但是,我们现在遇到与 Create 相同的问题。如果我们传入一个新单词,Update 会将它添加到字典中。

首先编写测试

1
t.Run("existing word", func(t *testing.T) {
2
word := "test"
3
definition := "this is just a test"
4
newDefinition := "new definition"
5
dictionary := Dictionary{word: definition}
6
7
err := dictionary.Update(word, newDefinition)
8
9
assertError(t, err, nil)
10
assertDefinition(t, dictionary, word, newDefinition)
11
})
12
13
t.Run("new word", func(t *testing.T) {
14
word := "test"
15
definition := "this is just a test"
16
dictionary := Dictionary{}
17
18
err := dictionary.Update(word, definition)
19
20
assertError(t, err, ErrWordDoesNotExist)
21
})
Copied!
我们在单词不存在时添加了另一种错误类型。我们还修改了 Update 以返回 error 值。

尝试运行测试

1
./dictionary_test.go:53:16: dictionary.Update(word, "new test") used as value
2
./dictionary_test.go:64:16: dictionary.Update(word, definition) used as value
3
./dictionary_test.go:66:23: undefined: ErrWordDoesNotExists
Copied!
这次我们得到 3 个错误,但我们知道如何处理这些错误。

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

1
const (
2
ErrNotFound = DictionaryErr("could not find the word you were looking for")
3
ErrWordExists = DictionaryErr("cannot add word because it already exists")
4
ErrWordDoesNotExist = DictionaryErr("cannot update word because it does not exist")
5
)
6
7
func (d Dictionary) Update(word, definition string) error {
8
d[word] = definition
9
return nil
10
}
Copied!
我们添加了自己的错误类型并返回 nil 错误。
通过这些更改,我们现在得到一个非常明确的错误:
1
dictionary_test.go:66: got error '%!s(<nil>)' want 'cannot update word because it does not exist'
Copied!

编写足够的代码使测试通过

1
func (d Dictionary) Update(word, definition string) error {
2
_, err := d.Search(word)
3
4
switch err {
5
case ErrNotFound:
6
return ErrWordDoesNotExist
7
case nil:
8
d[word] = definition
9
default:
10
return err
11
}
12
13
return nil
14
}
Copied!
除了在更新 dictionary 和返回错误时切换之外,这个函数看起来几乎与 Add 完全相同。

关于声明 Update 的新错误的注意事项

我们可以重用 ErrNotFound 而不添加新错误。但是,更新失败时有更精确的错误通常更好。
特定的错误描述可以为你提供有关错误的更多信息。以下是一个 Web 应用中的示例:
遇到 ErrNotFound 时可以重定向用户,但遇到 ErrWordDoesNotExist 时会显示错误消息。

首先编写测试

1
func TestDelete(t *testing.T) {
2
word := "test"
3
dictionary := Dictionary{word: "test definition"}
4
5
dictionary.Delete(word)
6
7
_, err := dictionary.Search(word)
8
if err != ErrNotFound {
9
t.Errorf("Expected '%s' to be deleted", word)
10
}
11
}
Copied!
我们的测试创建一个带有单词的 Dictionary,然后检查该单词是否已被删除。

尝试运行测试

通过运行 go test 我们得到:
1
./dictionary_test.go:74:6: dictionary.Delete undefined (type Dictionary has no field or method Delete)
Copied!

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

1
func (d Dictionary) Delete(word string) {
2
3
}
Copied!
添加这个之后,测试告诉我们没有删除这个单词。
1
dictionary_test.go:78: Expected 'test' to be deleted
Copied!

编写足够的代码使测试通过

1
func (d Dictionary) Delete(word string) {
2
delete(d, word)
3
}
Copied!
Go 的 map 有一个内置函数 delete。它需要两个参数。第一个是这个 map,第二个是要删除的键。
delete 函数不返回任何内容,我们基于相同的概念构建 Delete 方法。由于删除一个不存在的值是没有影响的,与我们的 UpdateCreate 方法不同,我们不需要用错误复杂化 API。

总结

在本节中,我们介绍了很多内容。我们为一个字典应用创建了完整的 CRUD API。在整个过程中,我们学会了如何:
    创建 map
    在 map 中搜索值
    向 map 添加新值
    更新 map 中的值
    从 map 中删除值
    了解更多错误相关的知识
      如何创建常量类型的错误
      对错误进行封装
作者:hackeryarn 译者:Donng 校对:polaris1119pityonline
本文由 GCTT 原创编译,Go 中文网 荣誉推出
Last modified 3yr ago