2

如何使用 Go 接口

 2 years ago
source link: https://iswade.github.io/translate/go_interface/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

如何使用 Go 接口?

在开始 Go 语言编程之前,我所有的工作基本都是用 Python 完成的。作为一个 Python 程序员,我发现在 Go 语言中如何使用接口是非常困难的。基础部分非常简单,我也知道如何使用标准库中的接口,但是在设计自己的接口之前,我还是花费了一些时间来做练习的。在这篇文章中,我会讨论一下 Go 的类型系统,以解释如何有效地使用接口。

什么是接口呢?接口是由两部分组成:一个方法集合,以及一个类型。首先我们将关注点集中到方法集合上。

通常情况下,我们都会介绍一些例子。让我们写一些程序,这些程序定义了 Animial 数据类型,这也是现实生活中经常发生的事情,Animal 类型是一个接口,定义 Animal 类型为任何可以说话的东西。这是 Go 语言类型系统的核心:我们不是以可以容纳的数据类型的形式定义我们的抽象,而是根据我们的类型可以执行的动作类设计我们的抽象。

我们用定义我们的 Animal 接口作为开始:

type Animal interface {
    Speak() string
}

非常简单:我们定义 Animal 可以是任何有一个名为 Speak 方法的类型。Speak 方法没有入参,返回值是一个字符串。任何定义了这个方法的类型都满足 Animal 接口。在 Go 语言中没有 implements 关键字:一个类型是否满足一个接口是自动确定的。让我们创建一些类型,这些类型都满足这个接口:

type Dog struct {
}

func (d Dog) Speak() string {
    return "Woof~~"
}

type Cat struct {
}

func (c Cat) Speak() string {
    return "Meow~~"
}

type Llama struct {
}

func (l Llama) Speak() string {
    return "~~~~~~"
}

type JavaProgrammer struct {
}

func (j JavaProgrammer) Speak() string {
    return "Design patterns~~"
}

现在我们定义了5个不同的动物类型:一条狗、一个猫、一个骆驼以及一个 Java 程序员。在 main() 函数中,可以创建动物类型的切片,然后将每个类型中的一个放到切片中,我们来看看每个动物会说什么。让我们现在开始:

func main() {
    animals := []Animal{Dow{}, Cat{}, Llama{}, JavaProgrammer{}}
    for _, animal := range animals {
        fmt.Println(animal.Speak())
    }
}

可以在这个链接中查看示例:https://play.golang.org/p/fZIzZHEYsj8

非常好,你现在知道如何使用接口了,我不再需要讨论了,确实如此?实际并非如此。让我们看一些对于新手 Go 程序员并不是那么明显的内容。

interface{} 类型

interface{} 类型(空接口)是混乱的根源。interface{} 类型是一个没有任何方法的接口。由于没有 implements 关键字,所有类型至少实现了零个方法,并且自动满足一个接口,所有类型都满足空接口。这意味着,如果你写了一个以 interface{} 做为入参类型,你可以给该函数任何值。所以,这个函数:

func DoSomething(v interface{}) {
    // ...
}

可以接受任何参数。

这里会令人困惑:在 DoSomething 函数的内部,v 的类型是什么?新手认为“v 是任何类型”,但这是错误的。v 不是任何类型:它是 interface{} 类型。什么?当给 DoSomething 函数传入一个值,如有必要,Go 运行时会进行类型转换,将一个值转换为 interface{} 值。所有的值在运行时被转换为一个类型,v 的一个静态类型是 interface{}

这会让你有点怀疑:如果发生了转换,实际被传递给这个函数的是什么(或者说,存储在 []Animal 切片中的实际内容是什么)?一个接口值由两个字段构成:一个字段用来指向值的底层类型的方法表,另一个字段指向保存值的实际内容。我不想无休止地地谈论这件事了。如果你理解一个接口的值是两个字的大小,并且它包含一个指向底层数据的指针,这就足以避免常见的陷阱。如果你对接口的实现感兴趣,Russ Cox 的接口描述很有帮助。

前面的例子中,当构造一个 Animal 值得切片的时候,不需要构造 Animal(Dog{}) ,将一个 Dog 类型的值存到 Animal 值的切片中,转换对我们来说是自动处理的。在 animals 切片的内部,每个元素都是 Animal 类型,但是不同的值有不同的底层类型。

所以…… 为什么这很重要呢?理解接口在内存中如何表示会使得一些令人困惑的事情变得非常明显。例如这个问题“我可以将 []T 转换为 []interface 吗?”,就很容易回答了,如果你已经理解了接口如何在内存中表示。 有一个例子可以代表对常见的误解 interface{} 类型:

package main

import "fmt"

func PrintAll(vals []interface{}) {
    for _, val := range vals {
        fmt.Println(val)
    }
}

func main () {
    names := []string{"standley", "david", "oscar"}
    PrintAll(names)
}

在这里运行:https://play.golang.org/p/YRCLmkxLZ-v

通过运行,你可以看到会有如下的报错信息:cannot use names (type []string) as type []interface {} in argument to PrintAll。如果你想实际将代码运行其阿里,可以转换 []string[]interface{}:

package main

import (
    "fmt"
)

func PrintAll(vals []interface{}) {
    for _, val := range vals {
        fmt.Println(val)
    }
}

func main() {
    names := []string{"stanley", "david", "oscar"}
    vals := make([]interface{}, len(names))
    for i, v := range names {
        vals[i] = v
    }
    PrintAll(vals)
}

在这里运行:https://play.golang.org/p/GwJQV4aVjJ1

这些代码很丑,但这是实际情况。不是一切事情都是完美的。(实际情况是,这不经常出现,因为 []interface{} 比你最初期望的用处少)

指针和接口

接口的另一个微妙之处在于接口定义并不规定实现者是否应该使用指针接受者或者值接受者来实现接口。当你给一个接口值的时候,这里并没有保证底层类型是或者不是一个指针。在前面的例子中,我们在值接受者上定义了所有的方法。我们稍作更改并且将猫的 Speak() 方法变为指针接受者:

func (c *Cat) Speak() string {
    return "Meow~"
}

如果改变了一个签名,然后尝试运行同样的程序(https://play.golang.org/p/TvR758rfre),你会看到下面的报错信息:

prog.go:40: cannot use Cat literal (type Cat) as type Animal in array element:
    Cat does not implement Animal (Speak method requires pointer receiver)

说实话,这条报错信息乍一看有点令人困惑。不是说接口 Animal 满足你定义的指针接受者的方法,而是你尝试将 Cat 结构转换为一个 Animal 接口值,但是只有 *Cat 满足这个接口。如果你想修改这个 bug,可以传递一个 *Cat 指针给 Animal 切片,而不是 Cat 值,通过使用 new(Cat)(也可以使用 &Cat,我更喜欢用 new(Cat)):

animals := []Animal{Dog{}, new(Cat), Llama{}, JavaProgrammer{}}

现在我们的程序又正常工作了:https://play.golang.org/p/x5VwyExxBM

我们朝相反的方向看一下:使用 *Dog 指针替代 Dog 值,但是我们不变更 Dog 类型的 Speak 方法:

animals := []Animal{new(Dog), new(Cat), Llama{}, JavaProgrammer{}}

这也是正常工作的(https://play.golang.org/p/UZ618qbPkj),但是需要注意一些细微的差异:我们没有必要变更 Speak 方法的接受者的类型。能正常工作的原因是指针类型可以访问关联值类型的方法,但反之却不行。一个 *Dog 值可以使用 Speak 方法,但正如我们前面看到的,不能使用定义在 *Cat 上的 Speak 方法。

这听起来有点神秘,但是当你记下如下内容时就有意义了:Go 中一切都是按照值来传递的。每次调用方法的时候,都会拷贝传递的数据。在有一个值接受者的场景下,当调用方法的时候值被拷贝。当你理解如下方法签名的时候,就会更加明白了:

func (t T)MyMethod(s string) {
    // ...
}

是一个类型 func(T, string) 的函数;方法接受者被传递给函数就像另一个参数一样。

在一个值类型上定义的方法,对于接受者的任何变更都是在方法的内部(例如:func (d Dob) Speak() { … }),所以不会被任何调用者看到,因为调用者在处理完全分离的 Dog 值。由于一切都是按值传递,所以一个 *Cat 方法不能被 Cat 值类型使用;任何 Cat 值可以有任意个数的 *Cat 指针指向它。如果我们尝试通过 Cat 值来调用 *Cat 方法,我们没有一个 *Cat 指针开始。反过来,如果我们有一个 Dog 类型的方法,我们有一个 *Dog 指针,当调用这个方法的时候,我们会很准确地知道哪个 Dog 值来使用。因为 *Dog 指针只会指向一个 Dog 值。如有必要,Go运行时解引用指针为其关联的 Dog 值。这就意味着,给定一个 *Dogd 和一个 Dog 类型上的 Speak 方法,我们可以通过 d.Speak() 这种方式调用方法,不需要跟其它语言一样使用 d->Speak() 进行调用。

真实的示例

从 Twitter API 获取正确的时间戳。Twitter API 使用一个如下格式的字符串来表示时间戳:

"Thu May 31 00:00:01 +0000 2012"

当然,在 JSON 文档中,时间戳可以用其它的一些方法表示,因为时间戳不是 JSON 规范的一部分。为了保持简洁,我们不会放置一条 tweet 的所有 JSON 表示,让我们仅看一下 create_at 字段如何通过 encoding/json 处理:

package main
import (
    "encoding/json"
    "fmt"
    "reflect"
)

// start with a string representation of our JSON data
var input = `
{
    "created_at": "Thu May 31 00:00:01 +0000 2012"
}
`

func main() {
    // our taarget will be of type map[string]interface{}, which is a 
    // pretty generic type that will give us a hashtable whose keys
    // are strings, and whose values are of type interface{}
    var val map[string]interface{}

    if err := json.Unmarshal([]byte(input), &val); err != nil {
        panic(err)
    }

    fmt.Println(val)
    for k, v := range val {
        fmt.Println(k, reflect.TypeOf(v))
    }
}

在这里运行:https://play.golang.org/p/VJAyqO3hTF

运行这个应用程序,我们会看到如下的输出:

map[created_at:Thu May 31 00:00:01 +0000 2012]
created_at string

可以看到,我们正确接受到了 key,但是获得了并不是很有用的字符串格式。如果我们想比较哪个时间戳更早,或者我们想知道给定一个值之后,跟当前时间比较过去了多久,使用纯字符串不会有任何帮助。

我们将尝试将字符串转换为一个时间类型:time.Time 值,这是标准库中表示时间的方式 ,我们会获得 error 类型,做如下的变更:

   var val map[string]time.Time

    if err := json.Unmarshal([]byte(input), &val); err != nil {
        panic(err)
    }

运行后,我们会获得如下的错误:

parsing time ""Thu May 31 00:00:01 +0000 2012"" as ""2006-01-02T15:04:05Z07:00"":
    cannot parse "Thu May 31 00:00:01 +0000 2012"" as "2006"

上面有点令人困惑的错误信息是由于 Go 中处理 time.Time 类型时间转换的的方式。简而言之,我们给出的字符串的表示方式与标准时间格式不一致(因为 Twitter 的 API 最初是由 Ruby 语言编写的,Ruby 中默认的格式与 Go 中默认的格式不一致)。为了正确解码,我们需要定义自己的类型。encoding/json 包会查看传递给 json.Unmarshal 的值是否满足 json.Unmarshaler 接口,看起来像这样:

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

引用自文档:https://golang.org/pkg/encoding/json/#Unmarshaler

所以我们需要的是一个带有 UnmarshalJSON([]byte) error 方法的 time.Time 值:

type Timestamp time.Time
func (t *Timestamp) UnmarshalJSON(b []byte) error {
    // ....
}

通过实现这个方法,满足了 json.Unmarshaler 接口,当看到 Timestamp 值时,json.Unmarshal 会调用我们实现的代码。这种情况下,使用一个指针方法,因为我们想要调用者看到变更的内容。为了设置一个指针指向的值,我们可以通过手动使用 * 操作符进行解引用。在 UnmarshalJSON 方法内部,t 表示一个指向 Timestamp 类型的指。通过使用 *t,我们可以解引用指针 t,可以访问 t 指向的值。记住:在 Go 中一切都是通过值传递。在 UnmarshalJSON 方法内部,指针 t 跟调用上下文中的指针是不一样的:是一个拷贝。如果我们给 t 赋值为另一个值,你仅仅是给一个函数内部的变量赋值,变更不会被调用者看到。但是,在方法内部的指针跟调用上下文中的指针指向同一块数据,通过解引用指针,我们可以将变更传递给调用者。

我们可以使用 time.Parse 方法,带有签名信息 func(layout, value string) (Time, error)。有两个字符串入参:第一个字符串是一个布局字符串,描述了我们如何格式化时间戳,第二个是希望解析的值。返回一个 time.Time 值,以及一个错误信息(在出现解析失败的时候使用)。你可以在时间包文档中阅读关于布局字符串语义的更多信息,但在本例中,我们不需要手动给出布局字符串,因为这些布局信息已经在标准包中存在了,例如值 time.RubyDate。所以实际上,我们可以解析字符串 "Thu May 31 00:00:01 +0000 2012" 为一个 time.Time 值,通过调用 time.Parse(time.RubyDate, "Thu May 31 00:00:01 +0000 2012")。我们接受到的值是一个 time.Time 类型的。我们可以转换 time.Time 值为 Timestamp 值通过调用 Timestamp(v),这里 v 是我们的 time.Time 值。最终,我们可以使用的 UnmarshalJSON 函数如下所示:

func (t *Timestamp) UnmarshalJSON(b []byte) error {
    v, err := time.Parse(time.RubyDate, string(b[1:len(b)-1])))
    if err != nil {
        return err
    }
    *t = Timestamp(v)
    return nil
}

在这个例子中,我们将入参的 byte 切片处理成子切片,因为输入的子切片是 JSON 元素的原始数据,包含的引号,在传入 time.Parse 之前,我们将引号删除掉。

完整的时间戳类型的例子可以在这里查看(也可以运行):http://play.golang.org/p/QpiFsJi-nZ

实际的接口

从一个 http 请求中获取一个对象。让我们来看看如何设计一个接口来解决一个常见的 web 开发问题:我们希望将 HTTP 请求的正文解析为一些对象数据。首先,这不是一个非常明确的定义接口。我们或许可以这样说:我们要这样从 HTTP 请求获取资源:

GetEntity(*http.Request) (interface{}, error)

因为一个 interface{} 可以用来处理任何底层类型,所以我们可以仅仅将我们的请求解析,然后返回我们需要的内容。这被认为是一个非常不好的策略,原因是我们在 GetEntity 函数中粘贴了太多的逻辑,这个函数对于每个新的类型都需要做修改,并且我们需要使用类型断言来处理返回的 interface{} 值。在实践中,一个返回 interface{} 值的函数会令人非常讨厌,作为一个经验法则,您可以记住,接受 interface{} 值作为参数通常比返回接口值更好。(Postel 定律,也适用于接口)

我们或许会尝试使用如下类型固定的函数:

GetUser(*http.Request) (User, error)

这也被证明是非常不灵活的,因为每个类型我们都需要不同的函数,但是没有合理的方法来将他们通用化。相反,我们真真需要的是像下面的这样:

type Entity interface {
    UnmarshalHTTP(*http.Request) error
}

func GetEntity(r *Http.Request, v Entity) error {
    return v.UnmarshalHTTP(r)
}

这里的 GetEntity 函数采用了一个具有 UnmarshalHTTP 方法的接口值。为了利用这一点,我们将在对象上定义一些方法,允许用户描述如何从 HTTP 请求中获得自己的信息:

func (u *User) UnmarshalHTTP(r *http.Request) error {
   // ...
}

在我们的应用代码中,你会定义一个 User 类型的变量,然后将指针传递给 GetEntity 函数:

var u User
if err := GetEntity(req, &u); err != nil {
    // ...
}

这跟你如何解析 JSON 数据非常相似。这种类型使用的方式可以正常安全地工作,因为语句 var u User 会自动将 User 结构体归零。Go 不像其它语言需要什么的初始化分开,不初始化的申明变量会导致访问到无效数据,当申明变量的时候,运行时会自动将对应的内存内容清零,即使我们的 UnmarshalHTTP 方法处理一些字段失败,这些字段也会包含零值而不是垃圾数据。

如果你是一名 Python 程序员,这对你来说肯定很奇怪,因为 Python 中通常不会这么做。这种形式变得很方便的原因是我们可以定义任意数量的数据类型,每个类型都负责从 http 请求中自行解析。现在由实体定义决定如何表示它们,然后我们可以围绕 Entity 类型来创建类似于通用 HTTP 的处理程序。

我希望,在阅读这篇文章后,你会在 Go 中使用接口感觉更加顺畅。请记住如下几点:

  • 通过考虑数据类型之间通用的功能来创建抽象,而不是数据类型之间的通用字段
  • interface{} 接口不是任何类型:它是一个 interface{} 类型
  • 接口是两个字的宽度:示意图如(type, value)
  • 接受 interface{} 值作为入参比返回一个 interface{} 值更好
  • 一个指针类型可以调用关联值类型的方法,但是反之却不行
  • 一切都是通过值传递,及时是一个方法的接受者
  • 一个借口类型不是严格的指针或者非指针,仅仅是一个接口
  • 如果你需要完全重写一个方法的内部的值,使用 * 操作符来手动解应用一个指针

我认为已经总结了我个人觉得所有关于接口的令人困惑的场景。编码快乐 :)

原文链接:http://jordanorelli.com/post/32665860244/how-to-use-interfaces-in-go

作者:Jordan Orelli

翻译:王世德


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK