[译] 如何在 Go 中使用接口

本文翻译自 How to use interfaces in Go 有部分删减,请以原文为准

在开始使用 Go 编程之前,我的大部分工作都是用 Python 完成的。作为一名 Python 程序员,我发现学习使用 Go 中的接口是非常困难的。基础很简单,而且我知道如何在标准库中使用接口,但是我做了很多练习之后才知道如何设计自己的接口。在本文中,我将讨论 Go 的类型系统,以解释如何有效地使用接口。

接口介绍

接口是什么?一个接口包含两层意思:它是一个方法的集合,同样是一个类型。让我们首先关注接口作为方法的集合这一方面。

通常,我们会用一些假设的例子来介绍接口。让我们来看看这个例子: Animal 类型是一个接口,我们将定义一个 Animal 作为任何可以说话的东西。这是 Go 类型系统的核心概念:我们根据类型可以执行的操作而不是其所能容纳的数据类型来设计抽象。

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!"
}

我们现在有四种不同类型的动物:DogCatLlamaJavaProgrammer。在我们的 main 函数中,我们创建了一个 []Animal{Dog{}, Cat{}, Llama{}, JavaProgrammer{}} ,看看每只动物都说了些什么:

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

很好,现在你知道如何使用接口了,我不需要再讨论它们了,对吧?不是的。让我们来看看一些不太明显的东西。

interface{} 类型

interface{} 类型,空接口,是导致很多混淆的根源。interface{} 类型是没有方法的接口。由于没有 implements 关键字,所以所有类型都至少实现了 0 个方法,所以 所有类型都实现了空接口。这意味着,如果您编写一个函数以 interface{} 值作为参数,那么您可以为该函数提供任何值。例如:

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

这里是让人困惑的地方:在 DoSomething 函数内部,v 的类型是什么?新手们会认为 v任意类型的,但这是错误的。v 不是任意类型,它是 interface{} 类型。对的,没错!当将值传递给DoSomething 函数时,Go 运行时将执行类型转换(如果需要),并将值转换为 interface{} 类型的值。所有值在运行时只有一个类型,而 v 的一个静态类型是 interface{}

这可能让您感到疑惑:好吧,如果发生了转换,到底是什么东西传入了函数作为 interface{} 的值呢?(具体到上例来说就是 []Animal 中存的是啥?)

一个接口值由两个字(32 位机器一个字是 32 bits,64 位机器一个字是 64 bits)组成;一个字用于指向该值底层类型的方法表,另一个字用于指向实际数据。我不想没完没了地谈论这个。如果您理解一个接口值是两个字,并且它包含指向底层数据的指针,那么这就足以避免常见的陷阱。如果您想了解更多关于接口实现的知识。这篇文章很有用:Russ Cox’s description of interfaces

在我们上面的例子中,当我们初始化变量 animals 时,我们不需要像这样 Animal(Dog{}) 来显示的转型,因为这是自动地。这些元素都是 Animal 类型,但是他们的底层类型却不相同。

为什么这很重要呢?理解接口是如何在内存中表示的,可以使得一些潜在的令人困惑的事情变得非常清楚。比如,像 “我可以将 []T 转换为 []interface{}
吗?” 这种问题就容易回答了。下面是一些烂代码的例子,它们代表了对 interface{} 类型的常见误解:

package main

import (
    "fmt"
)

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

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

运行这段代码你会得到如下错误: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)
}

很丑陋,但是生活就是这样,没有完美的事情。(事实上,这种情况不会经常发生,因为 []interface{} 并没有像你想象的那样有用)

指针和接口

接口的另一个微妙之处是接口定义没有规定一个实现者是否应该使用一个指针接收器或一个值接收器来实现接口。当给定一个接口值时,不能保证底层类型是否为指针。在前面的示例中,我们将方法定义在值接收者之上。让我们稍微改变一下,将 CatSpeak() 方法改为指针接收器:

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

运行上述代码,会得到如下错误:

cannot use Cat literal (type Cat) as type Animal in array or slice literal:
    Cat does not implement Animal (Speak method has pointer receiver)

该错误的意思是:你尝试将 Cat 转为 Animal ,但是只有 *Cat 类型实现了该接口。你可以通过传入一个指针 (new(Cat) 或者 &Cat{})来修复这个错误。

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

让我们做一些相反的事情:我们传入一个 *Dog 指针,但是不改变 DogSpeak() 方法:

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

这种方式可以正常工作,因为一个指针类型可以通过其相关的值类型来访问值类型的方法,但是反过来不行。即,一个 *Dog 类型的值可以使用定义在 Dog 类型上的 Speak() 方法,而 Cat 类型的值不能访问定义在 *Cat 类型上的方法。

这可能听起来很神秘,但当你记住以下内容时就清楚了:Go 中的所有东西都是按值传递的。每次调用函数时,传入的数据都会被复制。对于具有值接收者的方法,在调用该方法时将复制该值。例如下面的方法:

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

func(T, string) 类型的方法。方法接收器像其他参数一样通过值传递给函数。

因为所有的参数都是通过值传递的,这就可以解释为什么 *Cat 的方法不能被 Cat 类型的值调用了。任何一个 Cat 类型的值可能会有很多 *Cat 类型的指针指向它,如果我们尝试通过 Cat 类型的值来调用 *Cat 的方法,根本就不知道对应的是哪个指针。相反,如果 Dog 类型上有一个方法,通过 *Dog 来调用这个方法可以确切的找到该指针对应的 Gog 类型的值,从而调用上面的方法。运行时,Go 会自动帮我们做这些,所以我们不需要像 C语言中那样使用类似如下的语句 d->Speak()

例1:通过 Twitter API 获取正确的时间戳

Twitter API 使用下面的格式来展示时间戳:

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

Twitter API 返回的是一个 json 字符串,这里我们只考虑解析 created_at 字段:

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 target 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))
    }
}

运行上述代码,输出:

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

我们得到了解析后的结果,但是解析出来的时间是字符串类型的,作用有限,因此我们想把它解析成 time.Time 类型的,对代码做出如下修改:

var val map[string]interface{} -> var val map[string]time.Time

结果出错了:

panic: 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 中的时间格式不匹配(因为 Twitter's API 是用 Ruby 写的,其格式跟 Go 不同)。我们必须定义我们自己的类型来解析时间。encoding/json 在解析时会判断传入 json.Unmarshal 的值是否实现了 json.Unmarshaler 接口:

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

如果实现了,就会调用 UnmarshalJSON 方法来解析(参考),所以我们需要的是一个实现了 UnmarshalJSON([]byte) error 方法的类型:

type Timestamp time.Time

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

值得注意的是,我们使用一个指针作为方法接受者,因为我们希望在方法内对接受者进行更改。UnmarshalJSON 中,t 代表指向 Timestamp 类型值的指针,通过 *t 我们可以访问到这个值,这样就可以修改它了。

我们可以使用 time.Parse(layout, value string) (Time, error) 来解析时间,该函数的第一个参数是表示时间格式的字符串(更多字符串格式),第二个是我们要解析的字符串。返回 time.Time 类型的值以及 error(如果解析出错)。解析得到 time.Time 类型的值后,转换成 Timestamp 类型然后赋值给 *t

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 数据,其中包含有引号,所以这里需要切片去掉引号。

例2:从 HTTP 请求中得到对象

然我们设计一个接口来解决 web 开发中常见的一个问题:我们想解析 HTTP 请求体得到我们需要的对象数据。例如,我们这样定义我们的接口:

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

因为 interface{} 可以有任意的底层类型,所以我们可以解析得到任何我们需要的东西。但是这是一个不好的设计,我们将过多的逻辑引入到 GetEntity 函数中,GetEntity 函数现在需要针对每一种新类型进行修改,我们需要使用类型断言来处理返回的值。在实践中,返回 interface{} 的函数往往很烦人,作为一个经验法则,您只需要记住,将 interface{} 作为参数而不是返interface{} 值通常更好(Postel’s Law)。

我们也可能会尝试编写一些返回类型明确的函数,像这样:

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

但是这样又显得不够灵活,因为需要对不同的类型写不同的函数。我们真正需要的是像这样的一个设计:

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

func GetEntity(r *http.Request, v Entity) error {
    return v.UnmarshalerHTTP(r)
}

GetEntiry 方法需要传入一个参数,该参数为 Entity 接口类型,确保实现了 UnmarshalHTTP 方法。为了使用该方法,我们需要定义 User 类型并实现 UnmarshalHTTP 方法,并在方法中解析 HTTP 请求:

type User struct {
   ...
}

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 方法不能使用某些字段,这些字段也将包含有效的零数据,而不是垃圾数据。

结语

我希望读完此文后你可以更加得心应手地使用 Go 中的接口,记住下面这些结论:

  • 通过考虑数据类型之间的相同功能来创建抽象,而不是相同字段
  • interface{} 的值不是任意类型,而是 interface{} 类型
  • 接口包含两个字的大小,类似于 (type, value)
  • 函数可以接受 interface{} 作为参数,但最好不要返回 interface{}
  • 指针类型可以调用其所指向的值的方法,反过来不可以
  • 函数中的参数甚至接受者都是通过值传递
  • 一个接口的值就是就是接口而已,跟指针没什么关系
  • 如果你想在方法中修改指针所指向的值,使用 * 操作符
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,423评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,147评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,019评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,443评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,535评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,798评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,941评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,704评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,152评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,494评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,629评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,295评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,901评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,978评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,333评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,499评论 2 348

推荐阅读更多精彩内容

  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,690评论 0 9
  • Go入门 Go介绍 部落图鉴之Go:爹好还这么努力? 环境配置 安装 下载源码编译安装 下载相应平台的安装包安装 ...
    齐天大圣李圣杰阅读 4,575评论 0 26
  • 第5章 引用类型(返回首页) 本章内容 使用对象 创建并操作数组 理解基本的JavaScript类型 使用基本类型...
    大学一百阅读 3,216评论 0 4
  • 一个画技很烂但是喜欢绘画的医学渣
    三木成妖阅读 215评论 0 2
  • 一个欧洲风投总结的,通常会令项目海外扩张失利的三个因素。 问题一,错误地认为世界各地的客户都是相同的。 他们认为尽...
    十点三十八阅读 723评论 0 0