Skip to content

Latest commit

 

History

History
530 lines (361 loc) · 16.5 KB

maps.md

File metadata and controls

530 lines (361 loc) · 16.5 KB

字典Maps

本章代码

数组和切片章节,我们学习了如何顺序存储数据。现在,我们来学习如何通过键key来存储数据,然后快速查找数据。

Map这种数据类型和字典类似,它支持以key/value对方式存取数据。你可以把key看成是字典里头的字(或词),value可以看成是字典里头的对字(或词)的定义。下面我们将实际动手创建Map这种数据结构,来进一步学习它。

首先,假设我们的字典里头已经存在一些字和对应的定义,如果我们按字搜索,字典就会返回对应的定义。

先写测试

dictionary_test.go

package main

import "testing"

func TestSearch(t *testing.T) {
    dictionary := map[string]string{"test": "this is just a test"}

    got := Search(dictionary, "test")
    expected := "this is just a test"

    if got != expected {
        t.Errorf("got %q expected %q given, %q", got, expected, "test")
    }
}

声明字典的方式和声明数组有点类似,只不过字典是以map关键字开始声明,并且需要两个类型。第一个是键的类型,这个键key写在方括号[]中。第二个是值的类型,写在方括号之后。

键的类型比较特殊,它只能是可以比较的类型,显然,如果无法比较两个key是否相等,我们就无法确保获得正确的值。可比较类型在语言规范中有详细解释。

而值value可以是任意类型,甚至可以是另一个map。

测试中的其它部分你应该已经熟悉了。

写程序逻辑

dictionary.go

func Search(dictionary map[string]string, word string) string {
    return dictionary[word]
}

从字典中获取值的语法map[key]

重构

func TestSearch(t *testing.T) {
    dictionary := map[string]string{"test": "this is just a test"}

    got := Search(dictionary, "test")
    expected := "this is just a test"

    assertStrings(t, got, expected)
}

func assertStrings(t *testing.T, got, expected string) {
    t.Helper()

    if got != expected {
        t.Errorf("got %q expected %q", got, expected)
    }
}

我把assertStrings助手函数抽取出来,让测试更清晰。

使用一个定制类型

We can improve our dictionary's usage by creating a new type around map and making Search a method. 我们可以用类型别名改进代码,在map基础上创建一个新类型,然后在新类型上添加Search方法。

dictionary_test.go文件中:

func TestSearch(t *testing.T) {
    dictionary := Dictionary{"test": "this is just a test"}

    got := dictionary.Search("test")
    expected := "this is just a test"

    assertStrings(t, got, expected)
}

上面的测试中我们用了Dictionary类型,然后在这个类型的实例dictionary上调用了Search方法。assertStrings无需变化。

下面我们来定义Dictionary类型。

dictionary.go文件中:

type Dictionary map[string]string

func (d Dictionary) Search(word string) string {
    return d[word]
}

我们创建了一个Dictionary类型,它实际上是map的一个封装类型。有了定制类型以后,我们就可以创建Search方法。

先写测试

对字典的基本查找很容易实现,但是如果查找的字在字典中不存在会怎样?我们应该什么也拿不到。这是OK的,程序可以继续运行,但是还有一个更好的做法 ~ 函数可以明确报告该字在字典中不存在,这样,用户不至于疑惑。

我们先写测试:

func TestSearch(t *testing.T) {
    dictionary := Dictionary{"test": "this is just a test"}

    t.Run("known word", func(t *testing.T) {
        got, _ := dictionary.Search("test")
        expected := "this is just a test"

        assertStrings(t, got, expected)
    })

    t.Run("unknown word", func(t *testing.T) {
        _, err := dictionary.Search("unknown")
        expected := "could not find the word you were looking for"

        if err == nil {
            t.Fatal("expected to get an error.")
        }

        assertStrings(t, err.Error(), expected)
    })
}

Go语言中处理这种场景的方式,就是返回第二个类型为Error的返回值。

通过调用Error实例的.Error()方法,Error可以被转换成一个字符串,我们在断言中就是这样转的。我们也对assertStrings加了一个if判断作为保护,确保我们不会在nil上调用.Error()

写程序逻辑

func (d Dictionary) Search(word string) (string, error) {
    definition, ok := d[word]
    if !ok {
        return "", errors.New("could not find the word you were looking for")
    }

    return definition, nil
}

为了让测试通过,我们使用了map的一种特别的查找语法,它可以返回2个值。第二个值是一个布尔值,表明对应的键是否存在。这样,我们就可以区分某个键是不存在,还是没有对应的定义。

重构

dictionary.go

var ErrNotFound = errors.New("could not find the word you were looking for")

func (d Dictionary) Search(word string) (string, error) {
    definition, ok := d[word]
    if !ok {
        return "", ErrNotFound
    }

    return definition, nil
}

通过把error抽取为一个常量,我们的测试代码会变得更清晰。

dictionary_test.go

t.Run("unknown word", func(t *testing.T) {
    _, got := dictionary.Search("unknown")

    assertError(t, got, ErrNotFound)
})
}

func assertError(t *testing.T, got, expected error) {
    t.Helper()

    if got == nil {
        t.Fatal("expected to get an error.")
    }
    
    if got != expected {
        t.Errorf("got error %q expected %q", got, expected)
    }
}

再重构下测试代码,把assertError抽取出来,这样可以简化测试。通过重用ErrNotFound变量,我们的测试代码可维护性增强了(后续修改错误消息只需集中修改一个地方)。

先写测试

我们已经可以搜索字典了,但我们还需要支持向字典添加新字。

func TestAdd(t *testing.T) {
    dictionary := Dictionary{}
    dictionary.Add("test", "this is just a test")

    expected := "this is just a test"
    got, err := dictionary.Search("test")
    if err != nil {
        t.Fatal("should find added word:", err)
    }

    if expected != got {
        t.Errorf("got %q expected %q", got, expected)
    }
}

先添加新字和定义,然后查找,再断言。

编写代码逻辑

dictionary.go

func (d Dictionary) Add(word, definition string) {
    d[word] = definition
}

Adding to a map is also similar to an array. You just need to specify a key and set it equal to a value.

引用类型

字典的一个特性是你可以直接修改它们,而无需传递指针。因为map是引用类型 ~ 它对底层数据结构有一个引用,非常像一个指针。底层数据结构是一个哈希表,关于哈希表,可以参考这里

字典属于引用类型非常有用,因为不管字典长多大,它始终只有一份拷贝。

关于引用类型要注意的一点是,字典可能为nil。当试图读取的时候,nil字典的行为和空字典是一样的,但是如果试图写入一个nil字典,那么程序会抛runtime panic。关于字典的更多信息,可以参考这里

因此,你不应该以如下方式初始化一个空字典变量:

var m map[string]string

而是应该用下面的方式,或者使用make关键字初始化空字典:

var dictionary = map[string]string{}

// OR

var dictionary = make(map[string]string)

上面两种方法都可以创建空字典(和指向空字典的指针),这两种初始化方法可以确保不会产生runtime panic

重构

dictionary_test.go

代码无需重构,但是测试可以再简化一下。

func TestAdd(t *testing.T) {
    dictionary := Dictionary{}
    word := "test"
    definition := "this is just a test"

    dictionary.Add(word, definition)

    assertDefinition(t, dictionary, word, definition)
}

func assertDefinition(t *testing.T, dictionary Dictionary, word, definition string) {
    t.Helper()

    got, err := dictionary.Search(word)
    if err != nil {
        t.Fatal("should find added word:", err)
    }

    if definition != got {
        t.Errorf("got %q expected %q", got, definition)
    }
}

我们为worddefinition创建了变量,并且把对definition的断言移到了助手函数中。

我们的Add方法看起来可以了。但是,我们还没有考虑试图添加已经存在的键的情况!

如果键已经存在,向字典添加重复键不会抛错,它只会用新值覆盖现有的值。实践中这一行为是蛮方便的,但让我们的函数名变得意义不明确 ~ Add不应该修改现有的值,它应该只向字典添加新的键值对。

先写测试

dictionary_test.go

func TestAdd(t *testing.T) {
    t.Run("new word", func(t *testing.T) {
        dictionary := Dictionary{}
        word := "test"
        definition := "this is just a test"

        err := dictionary.Add(word, definition)

        assertError(t, err, nil)
        assertDefinition(t, dictionary, word, definition)
    })

    t.Run("existing word", func(t *testing.T) {
        word := "test"
        definition := "this is just a test"
        dictionary := Dictionary{word: definition}
        err := dictionary.Add(word, "new test")

        assertError(t, err, ErrWordExists)
        assertDefinition(t, dictionary, word, definition)
    })
}

For this test, we modified Add to return an error, which we are validating against a new error variable, ErrWordExists. We also modified the previous test to check for a nil error.

为了让这个测试通过,我们需要修改Add方法返回一个错误,然后在测试中,将错误和一个新的错误变量ErrWordExists进行比对。之前的测试我们也修改了一下,检查err是nil

写代码逻辑

var (
    ErrNotFound   = errors.New("could not find the word you were looking for")
    ErrWordExists = errors.New("cannot add word because it already exists")
)

func (d Dictionary) Add(word, definition string) error {
    _, err := d.Search(word)

    switch err {
    case ErrNotFound:
        d[word] = definition
    case nil:
        return ErrWordExists
    default:
        return err
    }

    return nil
}

这里我们用了switch语句来匹配错误,如果Search返回一个除ErrNotFound以外的错误,switch语句提供了额外的检查和返回,这样更简洁安全。

重构

没有太多需要重构,但是因为用了几个error,我们可以做些小修改。

dictionary.go

const (
    ErrNotFound   = DictionaryErr("could not find the word you were looking for")
    ErrWordExists = DictionaryErr("cannot add word because it already exists")
)

type DictionaryErr string

func (e DictionaryErr) Error() string {
    return string(e)
}

我们把errors改成了常量,这要求我们创建定制的DictionaryErr类型,这个类型要实现error接口。关于这种用法的细节,Dave Cheney写了一篇很不错的文章。简单讲,它让错误变得可重用,并且是不可变的(immutable)。

下一步,我们来创建一个Update方法,可以更新字典中字的定义。

先写测试

dictionary_test.go

func TestUpdate(t *testing.T) {
    word := "test"
    definition := "this is just a test"
    dictionary := Dictionary{word: definition}
    newDefinition := "new definition"

    dictionary.Update(word, newDefinition)

    assertDefinition(t, dictionary, word, newDefinition)
}

Update is very closely related to Add and will be our next implementation.

UpdateAdd类似,我们马上来实现。

写程序逻辑

dictionary.go

func (d Dictionary) Update(word, definition string) {
    d[word] = definition
}

代码很少,但是我们有一个和之前Add类似的问题 ~ 如果我们传入一个新字,Update也会把它添加到字典中.

先写测试

dictionary_test.go

t.Run("existing word", func(t *testing.T) {
    word := "test"
    definition := "this is just a test"
    newDefinition := "new definition"
    dictionary := Dictionary{word: definition}

    err := dictionary.Update(word, newDefinition)

    assertError(t, err, nil)
    assertDefinition(t, dictionary, word, newDefinition)
})

t.Run("new word", func(t *testing.T) {
    word := "test"
    definition := "this is just a test"
    dictionary := Dictionary{}

    err := dictionary.Update(word, definition)

    assertError(t, err, ErrWordDoesNotExist)
})

我们需要新加一个错误类型ErrWordDoesNotExist,如果更新时键key不存在,就返回这个错误。

写程序逻辑

dictionary.go

const (
    ErrNotFound         = DictionaryErr("could not find the word you were looking for")
    ErrWordExists       = DictionaryErr("cannot add word because it already exists")
    ErrWordDoesNotExist = DictionaryErr("cannot update word because it does not exist")
)

func (d Dictionary) Update(word, definition string) error {
    _, err := d.Search(word)

    switch err {
    case ErrNotFound:
        return ErrWordDoesNotExist
    case nil:
        d[word] = definition
    default:
        return err
    }

    return nil
}

这个函数和Add很像,只是字典更新和错误返回逻辑有调整。

为更新声明一个新错误类型

我们可以重用ErrNotFound,但最好再创建一个新的错误类型,这样在更新失败时可以获得更明确错误提示。

在出错时,明确的错误会给你更多提示信息。例如在一个web应用中:

如果碰到一个ErrNotFound错误,你可以将用户重定向,而当碰到一个ErrWordDoesNotExist,你可以显示一个明确错误消息。

下面,我们来为字典创建一个Delete功能。

先写测试

dictionary_test.go

func TestDelete(t *testing.T) {
    word := "test"
    dictionary := Dictionary{word: "test definition"}

    dictionary.Delete(word)

    _, err := dictionary.Search(word)
    if err != ErrNotFound {
        t.Errorf("Expected %q to be deleted", word)
    }
}

先创建一个Dictionary,初始化一个字,然后删除这个字,最后检查这个字确实被删除。

写程序逻辑

dictionary.go

func (d Dictionary) Delete(word string) {
    delete(d, word)
}

Go内置支持delete函数,它可以应用于字典。它接收两个参数,第一个是字典(map),第二个是要删除的键(key)。

delete函数没有返回,所以我们的Delete方法也没有返回。因为删除一个不存在的键是没有效果的,所以我们没必要像UpdateDelete那样再写switch判断逻辑。

总结

本章我们讲了很多东西,为我们自己定义的字典开发了完整的增删改查(CRUD,Create/Read/Update/Delete)API,通过这个过程我们学到:

  • 创建字典
  • 在字典中查找项
  • 给字典添加新项
  • 更新字典中的项
  • 从字典中删除项
  • 学习了更多错误处理技术
    • 如何创建常量型错我
    • 编写错误封装