首页 Go接口
文章
取消

Go接口

接口类型是对其他类型行为的概括与抽象。通过使用接口,我们可以写出更加灵活和通用的函数,这些函数不用绑定在一个特定的类型实现上。

很多面向对象的语言都有接口这个概念,Go语言的接口的独特之处在于它是隐式实现。换句话说,对于一个具体的类型,无须声明它实现了哪些接口,只要提供接口所必需的方法即可。这种设计让你无须改变已有类型的实现,就可以为这些类型创建新的接口,对于那些不能修改包的类型,这一点特别有用。

本章首先会介绍接口类型的基本机制类型价值。然后会讨论标准库中的几种重要接口。因为在很多Go程序中,相对于新创建的接口,标准库中的接口使用得并不少。最后,我们还要了解一下类型断言以及类型分支,以及它们如何实现另一种类型的通用化。

接口即约定

之前介绍的类型都是具体类型。具体类型指定了它所含数据的精确布局,还暴露了基于这个精确布局的内部操作。比如对于数值有算术操作,对于slice类型我们有索引、appendrange等操作。具体类型还会通过其方法来提供额外的能力。总之,如果你知道了一个具体类型的数据,那么你就精确地知道了它是什么以及它能干什么。

Go语言中还有另外一种类型称为接口类型。接口是一种抽象类型,它并没有暴露所含数据的布局或者内部结构,当然也没有那些数据的基本操作,它所提供的仅仅是一些方法而已。如果你拿到一个接口类型的值,你无从知道它是什么,你能知道的仅仅是它能做什么,或者更精确地讲,仅仅是它提供了哪些方法。

本书通篇使用两个类似的函数实现字符串的格式化:fmt.Printffmt.Sprintf。前者把结果发到标准输出(标准输出其实是一个文件),后者把结果以string类型返回。格式化是两个函数中最复杂的部分,如果仅仅因为两个函数在输出方式上的轻微差异,就需要把格式化部分在两个函数中重复一遍,那么就太糟糕了。幸运的是,通过接口机制可以解决这个问题。其实,两个函数都封装了第三个函数fmt.Fprintf,而这个函数对结果实际输出到哪里毫不关心:

1
2
3
4
5
6
7
8
9
10
11
12
13
package fmt

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

func Printf(format string, args ...interface{}) (int, error) {
    return Fprintf(os.Stdout, format, args...)
}

func Sprintf(format string, args...interface{}) string {
    var buf bytes.Buffer
    Fprintf(&buf, format, args...)
    return buf.String()
}

Fpringf的前缀F指文件,表示格式化的输出会写入第一个实参所指代的文件。对于Printf,第一个实参就是os.Stdout,它属于*os.File类型。对于Sprintf,尽管第一个实参不是文件,但它模拟了一个文件:&buF就是一个指向内存缓冲区的指针,与文件类似,这个缓冲区也可以写入多个字节。

Fprintf的第一个形参也不是文件类型,而是io.Writer接口类型,其声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
package io
// Writer接口封装了基础的写入方法
type Writer interface {
// Write从p向底层数据流写入len(p)个字节的数据
// 返回实际写入的字节数(0 <= n <= len(p))
// 如果没有写完,那么会返回遇到的错误
// 在Write返回n < len(p) 时,err必须为非nil
// Write不允许修改p的数据,即使是临时修改
//
// 实现时不允许残留p的引用
Write(p []byte) (n int, err error)
}

io.Writer接口定义了Fprintf和调用者之间的约定。一方面,这个约定要求调用者提供的具体类型(比如*os.File或者*bytes.Buffer)包含一个与其签名和行为一致的Write方法。另一方面,这个约定保证了Fprintf能使用任何满足io.Writer接口的参数。Fprintf只需要能调用参数的Write函数,无须假设它写入的是一个文件还是一段内存。

因为fmt.Fprintf仅依赖于io.Writer接口所约定的方法,对参数的具体类型没有要求,所以我们可以用任何满足io.Writer接口的具体类型作为fmt.Fprintf的第一个实参。这种可以把一种类型替换为满足同一接口的另一种类型的特性称为可取代性(substitutability),这也是面向对象语言的典型特征。

  • 简而言之就是通道接口来屏蔽掉不同参数的类型,实现统一。

让我们创建一个新类型来测试这个特性。如下所示的*ByteCounter类型的Write方法仅仅统计传入数据的字节数,然后就不管那些数据了。

  • 代码中出现的类型转换是为了让len(p)*c满足+=操作。
1
2
3
4
func (c *ByteCounter) Write(p []byte)(int, error) {
    *c += ByteCounter(len(p))   // 转换int为ByteCounter类型
    return len(p), nil
}

因为*ByteCounter满足io.Writer接口的约定,所以可以在Fprintf中使用它,Fprintf察觉不到这种类型差异,ByteCounter也能正确地累积格式化后结果的长度。

1
2
3
4
5
6
7
8
var c ByteCounter
c.Write([]byte("hello"))
fmt.Println(c)  // "5", = len("hello")

c = 0 // 重置计数器
var name "Dolly"
fmt.Fprintf(&c,"hello, %s", name)
fmt.Println(c) // "12", = len("hello, Dolly")

除之io.Writer之外,fmt包还有另一个重要的接口。FprintfFprintln提供了一个让类型控制如何输出自己的机制。如果给先前的Celsius类型定义一个string方法,这样可以输出100℃这样的结果。在Go方法中,也给*Intset类型加了一个String方法,这样可以输出类似{1 2 3}的传统集合表示形式。定义一个String方法就可以让类型满足这个广泛使用的接口fmt.Stringer:

1
2
3
4
5
6
7
package fmt
// 在字符串格式化时如果需要一个字符串
// 那么就调用这个方法来把当前值转化为字符串
// Print这种不带格式化参数的输出方式也是调用这个方法
type Stringer interface {
    String() string // 2024年5月17日没看懂
}
  • 上述的形式好像是一种方法签名的写法
  • 这一节内容只是对接口的初步介绍

接口类型

这部分将具体讲解接口的类型,一个接口类型定义了一套方法,如果一个具体类型要实现该接口,那么必须实现接口类型定义中的所有方法。

io.Writer是一个广泛使用的接口,它负责所有可以写入字节的类型的抽象,包括文件、内存缓冲区、网络连接、HTTP客户端、打包器(archiver)、散列器(hasher)等。io包还定义了很多有用的接口。Reader就抽象了所有可以读取字节的类型,Closer抽象了所有可以关闭的类型,比如文件或者网络连接。(这里大概已经注意到Go语言的单方法接口的命名约定了)

1
2
3
4
5
6
7
8
9
package io

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Closer interface {
    Close() error   // 又是方法签名
}

另外,我们还可以发现通过组合已有接口得到的新接口,比如下面两个例子:

1
2
3
4
5
6
7
8
9
type Readwriter interface {
    Reader
    Writer
}
type ReadwriteCloser interface {
    Reader
    Writer
    Closer
}

如上的语法称为嵌入式接口,与嵌入式结构类似,让我们可以直接使用一个接口,而不用逐一写出这个接口所包含的方法。如果不用嵌入式来声明io.ReadWriter:

1
2
3
4
type Readwriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

也可以混合使用两种方式:

1
2
3
4
type Readwriter interface {
    Read(p []byte)(n int, err error)
    Writer
}

三种声明的效果都是一致的。方法定义的顺序也是无意义的,真正有意义的只有接口的集合。

接口实现

如果一个类型实现了一个接口要求的所有方法,那么这个类型实现了这个接口。比如*os.File类型实现了io.ReaderWriterCloserReaderWriter接口。*bytes.Buffer实现了ReaderWriterReaderWriter,但没有实现Closer,因为它没有Close方法。为了简化表述,Go程序员通常说一个具体类型是一个(is-a)特定的接口类型,这其实代表着该具体类型实现了该接口。比如,*bytes.Buffer是一个io.Writer*os.File是一个io.ReaderWriter

接口的赋值规则(参考2.4.2节)很简单,仅当一个表达式实现了一个接口时,这个表达式才可以赋给该接口。所以:

  • 因此,接口类型中并没有将方法进行定义。
1
2
3
4
5
6
7
8
var w io.Writer
w = os.Stdout   // OK:*os.File有Write方法
w = new(bytes.Buffer)   // OK: *bytes.Buffer有Write方法
w = time.Second         // 编译错误:time.Duration缺少Write方法

var rwc io.ReadWriteCloser
rwc = os.Stdout         // OK: *os.File有Read、Write、Close方法
rwc = new(bytes.Buffer) // 编译错误:*bytes.Buffer缺少Close方法

当右侧表达式也是一个接口时,该规则也有效:

1
2
w = rwc // OK: io.ReadWriteCloser有Write方法
rwc = W // 编译错误:io.Writer缺少Close方法

因为ReadWriterReadWritercloser接口包含了Writer的所有方法,所以任何实现了ReadWriterReadWriterCloser类型的方法都必然实现了Writer

在进一步讨论之前,我们先解释一下一个类型有某一个方法的具体含义:

之前提过,对每一个具体类型T,部分方法的接收者就是T,而其他方法的接收者则是*T指针。同时我们对类型T的变量直接调用*T的方法也可以是合法的,只要改变量是可变的,编译器隐式地帮你完成了取地址的操作。但这仅仅是一个语法糖,类型T的方法没有对应的指针*T多,所以实现的接口也可能比对应的指针少。

比如,之前提到的Intset类型的String方法,需要一个指针接收者,所以我们无法从一个无地址的Intset值上调用该方法:

1
2
3
type IntSet struct { /* ... */ }
func (*Intset) String() string
var _ = IntSet{}.String()   // 编译错误:String方法需要*IntSet接收者(无地址值)

但可以从一个Intset变量上调用该方法:

1
2
var s Intset
var _ = s.String()  // OK: s是一个变量,&s有 String 方法

因为只有*IntSetString方法,所以也只有*IntSet实现了fmt.Stringer接口:

1
2
var _ fmt.Stringer = &s     // OK,定义了一个指针变量,名为_,并初始化为s的地址
var _ fmt.Stringer = s      // 编译错误:IntSet缺少String方法

正如信封封装了信件,接口也封装了所对应的类型和数据,只有通过接口暴露的方法才可以调用,类型的其他方法则无法通过接口来调用:

1
2
3
4
5
6
7
os.Stdout.Write([]byte("hello'"))   // oK: *os.File 有 Write 方法
os.Stdout.close()                   // oK: *os.Fi1e 有 C1ose 方法

var w io.Writer
w = os.Stdout
w.Write([]byte("hello"))    // OK:io.Writer有Write方法
w.close()   // 编译错误:io.Writer缺少Close方法

一个拥有更多方法的接口,比如io.ReadWriter,与io.Reader相比,给了我们它所指向数据的更多信息,当然也给实现这个接口提出更高的门槛。那么对于接口类型interface{},它完全不包含任何方法,通过这个接口能得到对应具体类型的什么信息呢?

  • 什么信息也得不到。
  • 看起来这个接口没有任何用途,但实际上称为空接口类型的interface{}是不可缺少的。
  • 因为空接口类型对其实现类型没有任何要求,所以我们可以把任何值赋给空接口类型。
    1
    2
    3
    4
    5
    6
    
      var any interface{}
      any = true
      any = 12.34
      any = "hello"
      any = map[string]int{"one": 1}
      any = new(bytes.Buffer)
    

其实在本书的第一个示例中就用了空接口类型,靠它才可以让fmt.Printlnerrorf这类的函数能够接受任意类型的参数。

当然,即使我们创建了一个指向布尔值、浮点数、字符串、map、指针或者其他类型的interface{}接口,也无法直接使用其中的值,毕竟这个接口不包含任何方法。我们需要一个方法从空接口中还原出实际值,后续我们可以看到如何用类型断言来实现该功能。

判定是否实现接口只需要比较具体类型和接口类型的方法,所以没有必要在具体类型的定义中声明这种关系。也就是说,偶尔在注释中标注也不坏,但对于程序来讲,这种关系声明不是必需的。如下声明在编译器就断言了*byte.Buffer类型的一个值必然实现了io.Writer:

1
2
// *bytes.Buffer必须实现io.Writer
var w io.Writer = new(bytes.Buffer)

我们甚至不需要创建一个新的变量,因为*bytes.Buffer的任意值都实现了这个接口,甚至nil,在我们用(*bytes.Buffer)(nil)来强制类型转换后,也实现了这个接口。当然,既然我们不想引用w,那么我们可以把它替换为空白标识符。基于这两点,修改后的代码可以节省不少变量:

1
2
// *bytes.Buffer必须实现io.Writer
var _ io.Writer = (*bytes.Buffer)(nil)

非空的接口类型(比如io.Writer)通常由一个指针类型来实现,特别是当接口类型的一个或多个方法暗示会修改接收者的情形(比如Write方法)。一个指向结构的指针才是最常见的方法接收者。

指针类型肯定不是实现接口的唯一类型,即使是那些包含了会改变接收者方法的接口类型,也可以由Go的其他引用类型来实现。我们已经见过slice类型的方法(geometry.Path参考链接),以及map类型的方法,后续还可以看到函数类型的方法。基础类型也可以实现方法,比如为time.Duration类型实现了fmt.Stringer

一个具体类型可以实现很多不相关的接口。比如一个程序管理或者销售数字文化商品,比如音乐、电影和图书。那么它可能定义了如下具体类型:

Album
Book
Movie
Magazine
Podcast
TVEpisode
Track

我们可以把感兴趣的每一种抽象都用一种接口类型来表示。一些属性是所有商品都具备的,比如标题、创建日期以及创建者列表(作者或者艺术家)。

1
2
3
4
5
type Artifact interface {
    Title() string      // string类型的接口方法
    Creators() []string // 同上
    Created() time.Time
}

其他属性则局限于特定类型的商品。比如字数这个属性只与书和杂志相关,而屏幕分辨率则只与电影和电视剧相关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Text interface {
    Pages() int
    Words() int
    PageSize() int
}

type Audio interface {  
    Stream() (io.Readcloser, error)
    RunningTime() time.Duration
    Format() string // 比如"MP3"、"WAV"
}

type Video interface {
    Stream() (io.Readcloser, error)
    RunningTime() time.Duration
    Format() string // 比如"MP4"、"wMN"
    Resolution() (x, y int)
}

这些接口只是一种把具体类型分组并暴露它们共性的方式,未来我们也可以发现其他的分组方式。比如,如果我们要把AudioVideo按照同样的方式来处理,就可以定义一个Streamer接口来呈现它们的共性,而不用修改现有的类型定义。

1
2
3
4
5
type Streamer interface {
    Stream() (io.Readcloser, error)
    RunningTime() time.Duration
    Format() string
}

从具体类型出发、提取其共性而得出的每一种分组方式都可以表示为一种接口类型。与基于类的语言(它们显式地声明了一个类型实现的所有接口)不同的是,在Go语言里我们可以在需要时才定义新的抽象和分组,并且不用修改原有类型的定义。当需要使用另一个作者写的包里的具体类型时,这一点特别有用。当然,还需要这些具体类型在底层是真正有共性的。

一个例子:使用flag.Value来解析参数

在本节中,我们将看到如何使用另外一个标准接口flag.Value来帮助我们定义命令行标志。考虑如下一个程序,它实现了睡眠指定时间的功能。

1
2
3
4
5
6
7
8
var period = flag.Duration("period", 1*time.Second, "sleep period") // period为参数名,sleep period为参数说明,这是用法

func main() {
    flag.Parse()
    fmt.Printf("Sleeping for %v...", *period)
    time.Sleep(*period)
    fmt.Println()
}

在程序进入睡眠前输出了睡眠时长。fmt包调用了time.DurationString方法,可以按照一个用户友好的方式来输出,而不是输出一个以纳秒为单位的数字。

$ go build gopl.io/ch7/sleep
$ ./sleep
Sleeping for 1s...

默认的睡眠时间是1s,但可以用-period命令行标志来控制。flag.Duration函数创建了一个time.Duration类型的标志变量,并且允许用户用一种友好的方式来指定时长,比如可以用String方法对应的记录方法。这种对称的设计提供了一个良好的用户接口。

$ ./sleep -period 50ms
Sleeping for 50ms...
$ ./sleep -period 2m30s
Sleeping for 2m30s...
$ ./sleep -period 1.5h
Sleeping for 1h30m0s...
$ ./sleep -period "1 day"
invalid value "1 day" for flag -period: time: invalid duration 1 day(和我电脑报错不一致)

因为时间长度类的命令行标志广泛应用,所以这个功能内置到了flag包。支持自定义类型其实也不难,只须定义一个满足flag.Value接口的类型,其定义如下所示:

1
2
3
4
5
6
package flag
// Value接口代表了存储在标志内的值
type Value interface {
    String() string
    Set(string) error
}

String方法用于格式化标志对应的值,可用于输出命令行帮助消息。由于有了该方法,因此每个flag.Value其实也是fmt.Stringerset方法解析了传入的字符串参数并更新标志值。可以认为Set方法是String方法的逆操作,两个方法使用同样的记法规格是一个很好的实践。

下面定义了celsiusFlag类型来允许在参数中使用摄氏温度或华氏温度。注意celsiusFlag内嵌了一个Celsius类型,所以已经有String方法了。为了满足flag.Value接口,只须再定义一个set方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type celsiusFlag struct { Celsius } // 已经有了String方法
func (f *celsiusFlag) Set(s string) error {
    var unit string
    var value float64
    fmt.Sscanf(s, "%f%s", &value, &unit)    // 无须检查错误,%f为float类型
    switch unit {
    case "C", "℃":
        f.Celsius = Celsius(value)
        return nil
    case "F", "℉":
        f.Celsius = FToC(Fahrenheit(value)) // 假设定义过了
        return nil
    }
    return fmt.Errorf("invalid temperature %q", s)
}

fmt.Sscanf函数用于从输入s解析一个浮点值(value)和一个字符串(unit)。尽管通常都必须检查Sscanf的错误结果,但在这种情况下我们无须检查。因为如果出现错误,那么接下来的跳转条件没有一个会满足。

如下CelsuisFlag函数封装了上面的逻辑。这个函数返回了一个Celsius指针,它指向嵌入在celsuisFlag变量f中的一个字段。Celsuis字段在标志处理过程中会发生变化(经由Set方法)。调用Var方法可以把这个标志加入到程序的命令行标记集合中,即全局变量flag.CommandLine。如果一个程序有非常复杂的命令行接口,那么单个全局变量flag.CommandLine就不够用了,需要有多个类似的变量来支撑。调用Var方法时会把*celsuisFlag实参赋给flag.Value形参,编译器会在此时检查*celsuisFlag类型是否有flag.Value所必需的方法。

1
2
3
4
5
6
7
8
9
// CelsiusFlag根据给定的name、默认值和使用方法
// 定义了一个Celsius标志,返回了标志值的指针
// 标志必须包含一个数值和一个单位,比如:"100C”

func CelsiusFlag(name string, value Celsius, usage string) *Celsius {
    f := celsiusFlag{value}
    flag.CommandLine.Var(&f, name, usage)
    return &f.Celsius
}

现在可以在程序中使用这个新标志了:

1
2
3
4
5
var temp = tempconv.CelsiusFlag("temp", 20.0, "the temperature")
func main() {
    flag.Parse()
    fmt.Println(*temp)
}

使用效果:

go build gopl.io/ch7/tempflag
$ ./tempflag
20°C
$ ./tempflag -temp -18C
-18°C
$ ./tempflag -temp 212F
100°C
$ ./tempflag -temp 273.15K
invalid value "273.15K"for flag -temp:invalid temperature "273.15K"
Usage of ./tempflag:
    -temp value
        the temperature (default 20℃)
$ ./tempflag -help
Usage of ./tempflag:
    -temp value
        the temperature (default 20℃)

这一节内容只教会了如何自己定义一个类型,使得能正常实现命令行解析,但是更底层的flag.Value如何实现的则并未提及。

本文由作者按照 CC BY 4.0 进行授权

Go方法

-