《Go 语言程序设计》读书笔记(四)接口

接口概述#

  • 一个具体的类型可以准确的描述它所代表的值并且展示出对类型本身的一些操作方式就像数字类型的算术操作,切片类型的索引、附加和取范围操作。总的来说,当你拿到一个具体的类型时你就知道它的本身是什么和你可以用它来做什么。

  • 在 Go 语言中还存在着另外一种类型:接口类型。接口类型是一种抽象的类型。它不会暴露出它所代表的对象的内部结构和这个对象支持的基础操作的集合;它只会展示出自己的方法。也就是说当你有看到一个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的方法来做什么。

  • fmt.Printf 它会把结果写到标准输出和 fmt.Sprintf 它会把结果以字符串的形式返回,实际上,这两个函数都使用了另一个函数 fmt.Fprintf 来进行封装。fmt.Fprintf 这个函数对它的计算结果会被怎么使用是完全不知道的。

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

​ Fprintf 函数中的第一个参数也不是一个文件类型。它是 io.Writer 类型这是一个接口类型定义如下:

package io

type Writer interface {
    Write(p []byte) (n int, err error)
}

io.Writer 类型定义了函数 Fprintf 和这个函数调用者之间的约定,只要是实现了 io.Writer 接口的类型都可以作为 Fprintf 函数的第一个参数。

  • 一个类型可以自由的使用另一个满足相同接口的类型来进行替换被称作可替换性 (LSP 里氏替换)。这是一个面向对象的特征。

接口定义#

  • io.Writer 类型是用的最广泛的接口之一,因为它提供了所有的类型写入 bytes 的抽象,包括文件类型,内存缓冲区,网络链接,HTTP 客户端,压缩工具,哈希等等。io 包中定义了很多其它有用的接口类型。Reader 可以代表任意可以读取 bytes 的类型,Closer 可以是任意可以关闭的值,例如一个文件或是网络链接。
package io
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}
  • 可以通过组合已有接口类型来定义新的接口类型,比如 io 包中的
  type ReadWriter interface {
      Reader
      Writer
  }
  type ReadWriteCloser interface {
      Reader
      Writer
      Closer
  }

上面用到的语法和结构内嵌相似,我们可以用这种方式命名另一个接口,而不用声明它所有的方法。这种方式称为接口内嵌,我们可以像下面这样,不使用内嵌来声明 io.ReadWriter 接口。

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

或者甚至使用种混合的风格:

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

这三种方式定义的 io.ReadWriter 是完全一样的。

接口实现#

  • 一个类型如果拥有一个接口需要的所有方法,那么这个类型就实现了这个接口。例如,os.File 类型实现了 io.Reader,Writer,Closer,和 ReadWriter 接口。bytes.Buffer 实现了 Reader,Writer,和 ReadWriter 这些接口,但是它没有实现 Closer 接口因为它不具有 Close 方法。Go 的程序员经常会简要的把一个具体的类型描述成一个特定的接口类型。举个例子,bytes.Buffer 是 io.Writer;os.Files 是 io.ReadWriter。
  • 接口实现的规则非常简单:表达一个类型属于某个接口只要这个类型实现这个接口。
var w io.Writer
w = os.Stdout           // OK: *os.File has Write method
w = new(bytes.Buffer)   // OK: *bytes.Buffer has Write method
w = time.Second         // compile error: time.Duration lacks Write method

var rwc io.ReadWriteCloser
rwc = os.Stdout         // OK: *os.File has Read, Write, Close methods
rwc = new(bytes.Buffer) // compile error: *bytes.Buffer lacks Close method
  • 这个规则甚至适用于等式右边本身也是一个接口类型
w = rwc                 // OK: io.ReadWriteCloser has Write method
rwc = w                 // compile error: io.Writer lacks Close method
  • 因为 ReadWriter 和 ReadWriteCloser 包含 Writer 的方法,所以任何实现了 ReadWriter 和 ReadWriteCloser 的类型必定也实现了 Writer 接口
  • 对于一些命名的具体类型 T;它一些方法的接收者是类型 T 本身然而另一些则是一个 *T 的指针。在 T 类型的变量上调用一个 *T 的方法是合法的,编译器隐式的获取了它的地址。但这仅仅是一个语法糖:T 类型的值不拥有所有 * T 指针的方法。
  • interface {} 类型,它没有任何方法,但实际上 interface {} 被称为空接口类型是不可或缺的。因为空接口类型对实现它的类型没有要求,所以所有类型都实现了 interface {},我们可以将任意一个值赋给空接口类型。
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)

接口值#

  • 接口值由两个部分组成,一个具体的类型和那个类型的值。它们被称为接口的动态类型和动态值。

  • 像 Go 语言这种静态类型的语言,类型是编译期的概念;因此一个类型不是一个值,提供每个类型信息的值被称为类型描述符。

  • 在 Go 语言中,变量总是被一个定义明确的值初始化,一个接口的零值就是它的类型和值的部分都是 nil。

    img

  • 在你非常确定接口值的动态类型是可比较类型时(比如基本类型)才可以使用 ==!= 对两个接口值进行比较。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且 panic:

var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // panic: comparing uncomparable type []int
  • 下面 4 个语句中,变量 w 得到了 3 个不同的值。(开始和最后的值是相同的)
var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil

第一个语句定义了变量 w:

var w io.Writer

在 Go 语言中,变量总是被一个定义明确的值初始化,即使接口类型也不例外。对于一个接口的零值就是它的类型和值的部分都是 nil,如图 7.1。

一个接口值基于它的动态类型被描述为空或非空,所以这是一个空的接口值。你可以通过使用 w==nil 或者 w!=nil 来判读接口值是否为空。调用一个空接口值上的任意方法都会产生 panic:

w.Write([]byte("hello")) // panic: nil pointer dereference

第二个语句将一个 * os.File 类型的值赋给变量 w:

w = os.Stdout

这个赋值过程调用了一个具体类型到接口类型的隐式转换,这和显式的使用 io.Writer (os.Stdout) 是等价的。这类转换不管是显式的还是隐式的,都会刻画出操作到的类型和值。这个接口值的动态类型被设为 *os.File 指针的类型描述符(os.Stdout 是指向 os.File 的指针),它的动态值持有 os.Stdout 的拷贝;这是一个指向处理标准输出的 os.File 类型变量的指针。

img

调用一个包含 *os.File 类型指针的接口值的 Write 方法,使得 (*os.File).Write 方法被调用。这个调用输出 “hello”。

w.Write([]byte("hello")) // "hello"

第三个语句给接口值赋了一个 * bytes.Buffer 类型的值

w = new(bytes.Buffer)

现在动态类型是 * bytes.Buffer 并且动态值是一个指向新分配的缓冲区的指针(图 7.3)。

img

Write 方法的调用也使用了和之前一样的机制:

w.Write([]byte("hello")) // writes "hello" to the bytes.Buffers

这次类型描述符是 *bytes.Buffer,所以调用了 (*bytes.Buffer).Write 方法,并且接收者是该缓冲区的地址。这个调用把字符串 “hello” 添加到缓冲区中。

最后,第四个语句将 nil 赋给了接口值:

w = nil

这个重置将它所有的部分都设为 nil 值,把变量 w 恢复到和它之前定义时相同的状态图,在图 7.1 中可以看到。

一个包含 nil 指针的接口不是 nil 接口#

一个不包含任何值的 nil 接口值和一个刚好包含 nil 指针的接口值是不同的。这个细微区别产生了一个容易绊倒每个 Go 程序员的陷阱。

思考下面的程序。当 debug 变量设置为 true 时,main 函数会将 f 函数的输出收集到一个 bytes.Buffer 类型中。

const debug = true

func main() {
    var buf *bytes.Buffer
    if debug {
        buf = new(bytes.Buffer) // enable collection of output
    }
    f(buf) // NOTE: subtly incorrect!
    if debug {
        // ...use buf...
    }
}

// If out is non-nil, output will be written to it.
func f(out io.Writer) {
    // ...do something...
    if out != nil {
        out.Write([]byte("done!\n"))
    }
}

我们可能会预计当把变量 debug 设置为 false 时可以禁止对输出的收集,但是实际上在 out.Write 方法调用时程序发生了 panic:

if out != nil {
    out.Write([]byte("done!\n")) // panic: nil pointer dereference
}

当 main 函数调用函数 f 时,它给 f 函数的 out 参数赋了一个 *bytes.Buffer 的空指针,所以 out 的动值是 nil。然而,它的动态类型是 *bytes.Buffer,意思就是 out 变量是一个包含空指针值的非空接口(如图 7.5),所以防御性检查 out!=nil 的结果依然是 true。

img

动态分配机制依然决定 (*bytes.Buffer).Write 的方法会被调用,但是这次的接收者的值是 nil。对于一些如 *os.File 的类型,nil 是一个有效的接收者 (§6.2.1),但是 *bytes.Buffer 类型不在这些类型中。这个方法会被调用,但是当它尝试去获取缓冲区时会发生 panic。

问题在于尽管一个 nil 的 *bytes.Buffer 指针有实现这个接口的方法,它也不满足这个接口具体的行为上的要求。特别是这个调用违反了 (*bytes.Buffer).Write 方法的接收者非空的隐含先觉条件,所以将 nil 指针赋给这个接口是错误的。解决方案就是将 main 函数中的变量 buf 声明的类型改为 io.Writer,(它的零值动态类型和动态值都为 nil)因此可以避免一开始就将一个不完全的值赋值给这个接口:

var buf io.Writer
if debug {
    buf = new(bytes.Buffer) // enable collection of output
}
f(buf) // OK

error 接口#

  • 预定义的 error 类型实际上就是 interface 类型,这个类型有一个返回错误信息的单一方法:

    type error interface {
        Error() string
    }
  • 创建一个 error 最简单的方法就是调用 errors.New 函数,它会根据传入的错误信息返回一个新的 error。整个 errors 包仅只有 4 行:

package errors

func New(text string) error { return &errorString{text} }

type errorString struct { text string }

func (e *errorString) Error() string { return e.text }

每个 New 函数的调用都分配了一个独特的和其他错误不相同的实例。我们也不想要重要的 error 例如 io.EOF 和一个刚好有相同错误消息的 error 比较后相等。

fmt.Println(errors.New("EOF") == errors.New("EOF")) // "false"

调用 errors.New 函数是非常稀少的,因为有一个方便的封装函数 fmt.Errorf,它还会处理字符串格式化。

package fmt

import "errors"

func Errorf(format string, args ...interface{}) error {
    return errors.New(Sprintf(format, args...))
}

类型断言#

  • 类型断言是一个使用在接口值上的操作。语法上它看起来像 x.(T) 被称为断言类型。这里 x 表示一个接口值,T 表示一个类型(接口类型或者具体类型)。一个类型断言会检查操作对象的动态类型是否和断言类型匹配。

  • x.(T) 中如果断言的类型 T 是一个具体类型,类型断言检查 x 的动态类型是否和 T 相同。如果是,类型断言的结果是 x 的动态值,当然它的类型是 T。换句话说,具体类型的类型断言从它的操作对象中获得具体的值。如果 x 的动态类型与 T 不相同,会抛出 panic。

var w io.Writer
w = os.Stdout
f := w.(*os.File)      // success: f == os.Stdout
c := w.(*bytes.Buffer) // panic: interface holds *os.File, not *bytes.Buffer
  • 相反断言的类型 T 是一个接口类型,然后类型断言检查是否 x 的动态类型满足 T。如果这个检查成功了,这个结果仍然是一个有相同类型和值部分的接口值,但是结果接口值的动态类型为 T。换句话说,对一个接口类型的类型断言改变了类型的表述方式,改变了可以获取的方法集合(通常更大),但是它保护了接口值内部的动态类型和值的部分。

  • 在下面的第一个类型断言后,w 和 rw 都持有 os.Stdout 因为它们每个值的动态类型都是 *os.File,但是变量的类型是 io.Writer 只对外公开出文件的 Write 方法,变量 rw 的类型为 io.ReadWriter,只对外公开文件的 Read 方法。

var w io.Writer
w = os.Stdout
rw := w.(io.ReadWriter) // success: *os.File has both Read and Write
w = new(ByteCounter)
rw = w.(io.ReadWriter) // panic: *ByteCounter has no Read method
  • 如果断言操作的对象是一个 nil 接口值,那么不论被断言的类型是什么这个类型断言都会失败。
  • 经常地我们对一个接口值的动态类型是不确定的,并且我们更愿意去检验它是否是一些特定的类型。如果类型断言出现在一个有两个结果的赋值表达式中,例如如下的定义,这个类型断言不会在失败的时候发生 panic,代替地返回的第二个返回值是一个标识类型断言是否成功的布尔值:
var w io.Writer = os.Stdout
f, ok := w.(*os.File)      // success:  ok, f == os.Stdout
b, ok := w.(*bytes.Buffer) // failure: !ok, b == nil

type switch#

接口被以两种不同的方式使用。在第一个方式中,以 io.Reader,io.Writer,fmt.Stringer,sort.Interface,http.Handler,和 error 为典型,一个接口的方法表达了实现这个接口的具体类型间的相似性,但是隐藏了代表的细节和这些具体类型本身的操作。重点在于方法上,而不是具体的类型上。

第二个方式利用一个接口值可以持有各种具体类型值的能力并且将这个接口认为是这些类型的 union(联合)。类型断言用来动态地区别这些类型。在这个方式中,重点在于具体的类型满足这个接口,而不是在于接口的方法(如果它确实有一些的话),并且没有任何的信息隐藏。我们将以这种方式使用的接口描述为 discriminated unions(可辨识联合)。

一个类型开关像普通的 switch 语句一样,它的运算对象是 x.(type)-它使用了关键词字面量 type-并且每个 case 有一到多个类型。一个类型开关基于这个接口值的动态类型使一个多路分支有效。这个 nil 的 case 和 if x == nil 匹配,并且这个 default 的 case 和如果其它 case 都不匹配的情况匹配。一个对 sqlQuote 的类型开关可能会有这些 case

switch x.(type) {
    case nil:       // ...
    case int, uint: // ...
    case bool:      // ...
    case string:    // ...
    default:        // ...
}

类型开关语句有一个扩展的形式,它可以将提取的值绑定到一个在每个 case 范围内的新变量上。

switch x := x.(type) { /* ... */ }

使用类型开关的扩展形式来重写 sqlQuote 函数会让这个函数更加的清晰:

func sqlQuote(x interface{}) string {
    switch x := x.(type) {
    case nil:
        return "NULL"
    case int, uint:
        return fmt.Sprintf("%d", x) // x has type interface{} here.
    case bool:
        if x {
            return "TRUE"
        }
        return "FALSE"
    case string:
        return sqlQuoteString(x) // (not shown)
    default:
        panic(fmt.Sprintf("unexpected type %T: %v", x, x))
    }
}

尽管 sqlQuote 接受一个任意类型的参数,但是这个函数只会在它的参数匹配类型开关中的一个 case 时运行到结束;其它情况的它会 panic 出 “unexpected type” 消息。虽然 x 的类型是 interface {},但是我们把它认为是一个 int,uint,bool,string,和 nil 值的 discriminated union(可识别联合)

使用建议#

  • 接口只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要。

  • 当一个接口只被一个单一的具体类型实现时有一个例外,就是由于它的依赖,这个具体类型不能和这个接口存在在一个相同的包中。这种情况下,一个接口是解耦这两个包的一个好的方式。

  • 因为在 Go 语言中只有当两个或更多的类型须以相同的方式进行处理时才有必要使用接口,它们必定会从任意特定的实现细节中抽象出来。结果就是有更少和更简单方法(经常和 io.Writer 或 fmt.Stringer 一样只有一个)的更小的接口。当新的类型出现时,小的接口更容易满足。对于接口设计的一个好的标准就是 ask only for what you need(只考虑你需要的东西)。

本作品采用《CC 协议》,转载必须注明作者和本文链接
公众号:网管叨 bi 叨 | Golang、Laravel、Docker、K8s 等学习经验分享
未填写
文章
113
粉丝
368
喜欢
487
收藏
317
排名:34
访问:20.4 万
私信
所有博文
社区赞助商