吃透 Golang 基础:接口

文章目录

接口(Interface)

接口类型是对其它类型「行为的抽象和概括」。

Golang 中接口类型的独特之处在于它是满足隐式实现的 。也就是说,我们没必要指定一个具体类型必须要满足某个接口,简单地让它拥有接口拥有的方法就足够了。这也就意味着,如果我们某个函数的输入是一个接口类型,只要我们传入的具体类型实现了接口必要的方法(哪怕这个方法不做任何行为),那么这个具体的类型就可以作为实参传入这个函数。

接口约定

在使用接口之前,我们接触到的其它类型都是具体的类型,它们可能是 Golang 内置的类型,也可能是我们自定义的类型,它可以有自己的成员,也可以完全是一个空的 struct。基于具体的类型,我们可以为它们定义方法,也就是说,当我们拿到一个具体类型之后,我们就知道它本身是什么,以及可以通过它来做什么。

接口类型与具体类型不同,它是一种抽象类型,它不暴露它所代表的对象的内部值结构,以及这个对象支持的基础操作的集合,它只会表现出它们自己的方法。也就是说,基于接口类型的值,我们不知道它是什么,只知道可以用它来做什么。「需要强调的一点是,应该把接口视为一种类型,不过它是一种抽象类型,一个具体的类型可以通过实现接口来同时是接口的抽象类型。」

下例展示了 Golang 的一个内置接口的定义,即Writer

go 复制代码
package io

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

我们已经使用过的字符串格式化函数fmt.Printffmt.Sprintf,都是在函数中调用了fmt.FPrintf这个函数:

go 复制代码
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。由于fmt.FPrintf没有对具体操作的值做任何假设,而是仅仅通过io.Writer接口的约定来保证行为,所以第一个参数可以安全地传入一个只需要满足io.Writer接口的任意具体类型的值。一个类型可以自由地被另一个满足相同接口的类型替换,这被称作可替换性(LSP 里氏替换),它是面向对象的一个特性。「因此,接口是 Golang 面向对象编程的重要构件之一」

下例实现了一个ByteCounter类型,它实现了Write方法,所以它实现了io.Writer接口:

go 复制代码
type ByteCounter int

func (c *ByteCounter) Write(p []byte) (int, error) {
    *c += ByteCounter(len(p))
    return len(p), nil
}

因为*ByteCounter满足io.Writer接口的约定,所以我们可以把它传入到FPrintf函数当中:

go 复制代码
var c ByteCounter
c.Write([]byte("hello"))
fmt.Println(c)	// "5" = len("hello")
c = 0
var name = "Dolly"
fmt.FPrintf(&c, "hello, %s", name)	// FPrintf 会调用 Write 方法
fmt.Println(c)	// "12", = len("hello, Dolly")

接口类型

接口类型具体描述了一系列方法的集合,一个实现了这些方法的具体类型就是这个接口类型的实例。

io.Writer是最广泛使用的接口之一,因为它提供了所有类型的写入bytes的抽象,包括文件类型、内存缓冲区、网络链接、HTTP 客户端、压缩工具、哈希等。io包中定义了许多其它有用的接口类型,比如Reader代表任意可以读取bytes的类型,Closer代表任意关闭的值,例如一个文件或网络连接。

go 复制代码
package io
type Reader interface {
    Read (p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

一些接口类型可以通过组合已知的接口类型来进行定义,例如:

go 复制代码
type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

上述用法与结构体的内嵌类似。我们也可以不使用接口组合的方式来声明io.ReadWriter接口,甚至使用混合的方式:

go 复制代码
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
}	// 混用具体方法定义与接口组合

实现接口的条件

一个具体类型只要拥有了一个接口所需要的所有方法,那么这个类型就实现了这个接口。例如*os.File实现了io.Reader/Writer/Closer/ReadWriter等接口。Go 程序员通常简要地把一个具体的类型描述为一个特定的接口类型。

接口指定的规则非常简单:表达一个类型属于某个接口,主要这个类型实现了这个接口就可以。下例定义了两种接口类型的变量,并通过赋值的方式验证等式右侧的类型是否实现了该接口:

go 复制代码
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

在进一步学习之前,需要先解释一个细节。在刚才的例子中我们已经看到,在指定某个具体类型的值为某个接口类型时,我们显式地使用了取地址符号&以获取该具体类型的指针,原因在于以值T作为接收器的方法不拥有 以指针*T作为接收器的方法,只不过调用T在调用以*T作为接收器的方法时,编译器会隐式地帮助我们把T转为*T,这是 Golang 的语法糖。也就是说,T*T实际上是两个类型,如果一个接口需要同时实现方法A和方法B,那么T需要同时实现AB才算是T实现了这个接口,而如果T实现了A*T实现了B,则T*T各自少实现了一个接口。

以下例子可以强化我们对接口的理解。IntSet类型的String方法的接受者是指针类型的*IntSet。所以我们不能在一个不能寻址的 IntSet值上调用这个方法:

go 复制代码
type IntSet struct { /* ... ... ... */ }
func (*IntSet) String() string
var _ = IntSet{}.String()	// Compile Error: String requires *IntSet receiver

但是对于一个IntSet的值类型,如果它是一个确定的变量(可以被寻址),而不是临时变量,我们就可以在值类型上调用指针接收器方法,我之前已经提到过,编译器会隐式地帮助我们完成变量的取地址转换,这是一种语法糖:

go 复制代码
var s IntSet
var _ = s.String()	// ✅

然而,由于只有*IntSet类型有 String 方法,因此只有*IntSet类型实现了fmt.Stringer接口,而IntSet类型没有实现这个接口。

在确定具体类型是否实现了某个方法时,将一个类型的值类型和指针类型看作是两种不同的类型是一个较好的策略,我自己在做项目来学习时,经常会遇到类似的场景,比如一个指针类型实现了某个接口的方法,在将它的值类型作为实参传入到以接口为形参的函数当中时,需要对这个值进行取地址,明确"值类型"与"指针类型"作为接收器时不共享实现接口的方法,就不难理解在这些场景下为什么要这么做了。

接口类型会对具体类型进行封装,被绑定在接口类型上的具体类型的值及接口类型实现了的方法以外的方法会被隐藏,也就是说,如果类型T实现了接口A,接口A具有a/b/c方法,而类型T自己有自己的成员Tx/Ty/Tz以及独有的i/j/k方法,那么当T绑定到接口类型A上时,对外只能使用a/b/c方法,其他成员及方法会被隐藏。例如:

go 复制代码
os.Stdout.Write([]byte("hello"))	// ✅
os.Stdout.Close()					// ✅

var w io.Writer
w = os.Stdout						// w 仍是一个 io.Writer 接口的类型值
w.Write([]byte("hello"))			// ✅
w.Close()							// ❌ Compile Error: io.Writer lacks Close Method

接口的实现只依赖于判断具体类型是否实现了接口类型的方法,不需要在定义具体类型的时候指定这个类型与要实现的接口的关系。有意地在文档或程序中断言这种关系偶尔是有用的,但是 Golang 在语法层面上并不强制要求这么做。下面是一种具体类型与其要实现的抽象接口的断言,编译器会断言*bytes.Buffer必须实现io.Writer接口:

go 复制代码
var _ io.Writer = (*bytes.Buffer)(nil)

在 Geektutu 的项目当中,经常使用上述方法来断言某个具体类型需要实现哪个接口,这就可以让编译器帮助我们检查我们定义的具体类型还缺少要实现的接口的哪些方法。

空接口 interface{}

空接口interface{}是 Golang 中不可或缺的概念。空接口类型没有实现任何方法,所以任意类型都至少实现了空接口类型,我们可以将任意一个值赋给空接口类型,并且一个空接口类型的 slice 可以追加任意具体类型的元素:

go 复制代码
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)

一个空接口类型可以持有任意具体类型的值,但是不能通过其持有的值进行任何操作,因为空接口没有任何方法。可以通过类型断言来获取空接口中的值。

接口值

接口值(指的就是当前这个抽象的接口的值是那个具体的类型,以及这个具体类型的值是什么)由两部分组成,即一个具体的类型和那个类型的值。它们分别被称为接口的动态类型动态值 。对于 Go 这种静态类型的语言,类型是编译器的概念,因此一个类型不是一个值,可以将类型看作是一种"描述符",在一个接口值当中,动态类型代表与绑定的具体的变量的类型描述符。

下面四个语句中,变量 w 会获得 3 个不同的值:

go 复制代码
var w io.Writer	// nil
w = os.Stdout			// os.Stdout
w = new(bytes.Buffer)	// *bytes.Buffer
w = nil

第一个语句定义了变量 w。在 Go 当中,变量总是被一个定义明确的值初始化,即使接口类型也不例外。一个接口的零值,就是它类型和值两部分的零值,都是 nil。

如果接口值的动态类型为空,那么这个接口的值就是空的,可以让接口与 nil 进行比较来判断接口值是否为空,调用一个空的接口值上的方法会引发 panic 错误。

go 复制代码
var w io.Writer
w.Write([]byte("hello"))
// 在代码中总是好理解的, 此时 w 尚未与任何具体的类型相绑定, 所以它是 nil 的.

第二个语句将一个*os.File类型(os.Stdout)赋值给 w:

go 复制代码
w = os.Stdout

这个赋值过程调用了一个具体类型到接口类型的隐式转换 ,这和显式地使用io.Writer(os.Stdout)是等价的。接口值的动态类型被设定为*os.File指针的类型描述符,它的动态值持有os.Stdout的拷贝。

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

go 复制代码
w.Write([]byte("hello"))

通常在编译器,我们不知道接口值的动态类型是什么,所以一个接口上的调用必须使用动态分配(对于一个 web 服务尤其如此,以一个 TCP 服务器为例,我们很有可能不知道当前这个 socket 连接下,客户端发送给服务器的服务请求是什么,具体的服务请求需要根据字节流当中的消息解析来得知,这发生在运行时,因此处理这条请求的 Router 很可能是一个提供服务的接口类型,在运行时确定提供服务的具体类型是什么)。因为不是直接进行调用,所以编译器必须把代码生成在类型描述符的 Write 方法上,然后间接调用那个地址。这个调用的接受者是一个接口动态值的拷贝:os.Stdout。效果和下面的直接调用一样:

go 复制代码
// 在 w 的接口值与 os.Stdout 绑定后, 调用 w.Write([]byte("hello")) 就和调用下述语句一样
os.Stdout.Write([]byte("hello"))
复制代码
第三个语句将接口值赋给了`*bytes.Buffer`类型的值:
go 复制代码
w = new(bytes.Buffer)

此时,接口类型w的动态类型是*bytes.Buffer(因为使用了new语句),其动态值是一个只想新分配缓冲区的指针(同样是因为使用了new语句)。

Write 方法的调用与之前的机制类似:

go 复制代码
w.Write([]byte("hello"))	// writes "hello" to the bytes.Buffers

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

go 复制代码
w = nil

赋值 nil 会将接口值的两部分:动态类型和动态值都设为 nil 值。

一个接口值可以持有任意大的懂啊体制,例如:表示时间实例的time.Time类型。

接口值可以使用==!=来进行比较,两个接口值相等当且仅当它们都是 nil 值,或者它们的动态类型相同且动态值也根据这个动态类型的 ==判断相等。这就意味着一些接口类型是可以比较的,那么理论上接口就可以作为 map 的 key 或 switch 的 case。

需要注意的是,并非所有接口都是可以比较的,一种情况是接口的动态类型相同,但是动态类型是切片,使得动态值不可比较。也就是说,与一般的具体类型不同的是,接口类型同时由动态类型和动态值组成,在进行接口类型的比较时,需要注意潜在的 panic 风险。

警告:一个 nil 接口需要接口的动态类型与动态值均为 nil

《Go 语言圣经》当中给出了下面这个经典的案例:

go 复制代码
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,那么在 main 函数当中,不会执行buf = new(bytes.Buffer)这一步,但是仍然会执行f(buf),来将*bytes.Buffer类型的零值buf传递给函数f

上述代码想要实现的效果是,如果 debug 为 false,那么在函数f当中就不执行通过out进行文件写的逻辑,代码的原意是如果 debug 为 false,那么f中的out就是 nil。

但是这样做是不对的,因为在 main 函数中执行f时,传递进来的实参是*bytes.Buffer类型的 nil 值。由于传入了一个具体的类型,因此形参的接口类型获取了动态类型,而其动态值是 nil,但是这个 nil 是指针类型的 nil,而不是接口类型的 nil。out != nil判断的是out本身是否为空接口,但此时传入的 out 是一个有动态类型的接口,它的动态类型是指针,动态值是 nil,所以f中的out是一个包含空指针值的非空接口,out != nil的结果仍然为true

想要解决上述程序当中的 bug,一个明智的选择是在 main 当中调用f之前,就明确传入的值是一个io.Writer类型:

go 复制代码
var buf io.Writer
if debug {
    buf = new(bytes.Buffer)
}
f(buf)

http.Handler 接口

下例是 Golang 网络库 net 当中 http.Handler 的定义,它是一个接口:

go 复制代码
package http

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error

ListenAndServe需要一个形如localhost:8080的服务器地址,以及一个可以分派所有 HTTP 请求的 Handler 接口实例(当然,这个实参可以为 nil)。它会一直运行,直到这个服务因为一个错误而失败,返回值是一个非空的错误。

下例实现了一个"商品服务"的简单例子:

go 复制代码
func main() {
    db := database{"shoes": 50, "socks": 5}
    log.Fatal(http.ListenAndServe("localhost:8000", db))
    // ⬆️ 传入了一个 database 类型的实例, 因为它实现了 ServeHTTP 方法, 所以它是一个 http.Handler 接口
}

type dollars float32

func (d dollars) String() string { return fmt.Sprintf("$%.2f", d) }

type database map[string]dollars

func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

目前这个服务器不考虑分发不同的服务到不同的 URL,只能访问其 socket 并得到所有库存清单。更真实的服务会定义不同的 URL,访问不同的 URL 会触发不同的行为。

下例从ServeHTTPreq获取当前这条request访问 URL 的路径,通过switch/case完成路径解析并将请求转发到不同的服务:

go 复制代码
func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    switch req.URL.Path {
    case "/list":
        for item, price := range db {
            fmt.Fprintf(w, "%s: %s\n", item, price)
        }
    case "/price":
        item := req.URL.Query().Get("item")
        price, ok := db[item]
        if !ok {
            w.WriteHeader(http.StatusNotFound) // 404
            fmt.Fprintf(w, "no such item: %q\n", item)
            return
        }
        fmt.Fprintf(w, "%s\n", price)
    default:
        w.WriteHeader(http.StatusNotFound) // 404
        fmt.Fprintf(w, "no such page: %s\n", req.URL)
    }
}

显然我们可以继续向上面代码中的switch添加更多的case,但是在实际的应用中,将每个 case 中的逻辑定义到一个分开的方法或函数当中更有用。此外,相近的 URL 应该有相似的逻辑,例如几个图片文件可能有形如/images/*.png的 URL。基于上述原因,net/http包提供了一个请求多路器ServeMux来简化 URL 和 handlers 之间的联系。一个ServeMux可以将一批http.Handler聚集到单一一个http.Handler当中。

下例创建了一个ServeMux并使用它将处理相应 URL 的操作与 handler 联系起来,在ListenAndServe函数中,使用ServeMux作为主要的 Handler:

go 复制代码
func main() {
    db := database{"shoes": 50, "socks": 5}
    mux := http.NewServeMux()
    mux.Handle("/list", http.HandlerFunc(db.list))
    mux.Handle("/price", http.HandlerFunc(db.price))
    log.Fatal(http.ListenAndServe("localhost:8000", mux))
}

func (db database) list(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

func (db database) price(w http.ResponseWriter, req *http.Request) {
    item := req.URL.Query().Get("item")
    price, ok := db[item]
    if !ok {
        w.WriteHeader(http.StatusNotFound) // 404
        fmt.Fprintf(w, "no such item: %q\n", item)
        return
    }
    fmt.Fprintf(w, "%s\n", price)
}

上述代码当中有很多值得深挖的细节。首先,对于 main 函数当中的第三行mux.Handle("/list", http.HandlerFunc(db.list)),这一行代码的行为就是将后面处理 HTTP Request 逻辑绑定到了IP:Port/list这个 URL 上。

深入mux.Handle的源码,我发现它的形参由 string 类型的 pattern 和 Handler 类型的 handler 组成,前者没什么好说的,就是将 Handler 的逻辑绑定到 pattern 上,而后者是一个实现了 ServeHTTP 方法的接口类型。

显然,现在我们传入的实参是:http.HandlerFunc(db.list)db.list本身是一个database类型的方法,它的形参是http.ResponseWriter*http.Request,与ServeHTTP接口方法要求的形参相同,那么为什么一个函数可以经过http.handlerFunc的转换变成一个满足了ServeHTTP方法的接口类型呢?请看下面的http.HandlerFunc的源码:

go 复制代码
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// [Handler] that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

显然,HandlerFunc本身是一个"函数类型",这个函数类型实现了ServeHTTP方法,所以HandlerFunc这个类型实现了http.Handler接口。在业务逻辑当中,我们通过http.HandlerFunc(db.list),实际上首先做的是完成了一种「类型转换」,将db.list这个函数的类型转换到了HandlerFunc这个函数类型上。这就使得db.list获得了ServeHTTP这个方法,也就是说这个函数此时满足了Handler接口。

正如HandlerFunc源码上方的注释,可以将HandlerFunc这种类型看作是一种适配器(adapter),它使得普通的函数也可以满足http.Handler接口,前提是传入适配器的函数的形参列表与适配器的函数类型的形参列表相同。

至此,我们学习到了一个基于 Golang 接口实现适配器设计模式的技巧。

error 接口

在学习 Golang 的过程当中我们大量地接触到了错误处理,创建并使用了预定义的 error 类型。实际上 error 也是一个 interface 类型,这个类型有一个返回错误信息的单一方法:

go 复制代码
type error interface {
    Error() string
}

可以通过errors.New函数简单地创建一个 error,它会根据传入的错误信息返回一个新的 error:

go 复制代码
package errors
func New(text string) error { return &errorString{text} }

type errorString struct { text string }

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

承载 errorString 的类型是一个结构体而非字符串,这个结构体有一个唯一的非导出成员 text,可以避免错误信息被使用者篡改。Error方法的接收器是指针类型,这就意味着实现了 error 接口的是*errorString。每个New函数的返回值是error接口,由于*errorString实现了error,因此New的返回值应该是errorString的地址。

每次调用New都会分配一个与其他error都不相同的实例,就算errorString当中的text相同,但是*errorString指向的地址都不相同,所以通过New创建的错误之间是不可比较的:

go 复制代码
fmt.Println(errors.New("EOF") == errors.New("EOF"))	// false

我们通常不会调用errors.New,因为有一个封装好的更加方便的函数fmt.Errorf,它会处理字符串格式化:

go 复制代码
package fmt

import "errors"

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

*errorString可能是最简单的错误类型,但是实现了error接口的错误类型远非只有它一个。例如:

go 复制代码
package syscall

type Errno uintptr // operating system error code

var errors = [...]string{
    1:   "operation not permitted",   // EPERM
    2:   "no such file or directory", // ENOENT
    3:   "no such process",           // ESRCH
    // ...
}

func (e Errno) Error() string {
    if 0 <= int(e) && int(e) < len(errors) {
        return errors[e]
    }
    return fmt.Sprintf("errno %d", e)
}

类型断言

类型断言是一个在接口值上使用的操作,它的语法看起来像是x.(T),此处的x是接口类型,T是具体类型。类型断言检查的是它操作对象的动态类型是否和断言的类型匹配。

类型断言可以根据T的类型分为两种情况。第一种情况是,如果T是具体类型,那么断言会检查x的动态类型是否与T匹配。如果匹配,那么断言的结果是x的动态值,类型为T。否则,如果检查失败,那么会触发 panic:

go 复制代码
var w io.Writer
w = os.Stdout
f := w.(*os.File)	// success
c := w.(*bytes.Buffer)	// panic: interface holds *os.File, not *bytes.Buffer
// ⬇️ 可以使用双返回值的方式将断言成功或失败的结果进行保存, 此时不会 panic
c, ok := w.(*bytes.Buffer)

第二种情况是,如果断言的类型T是一个接口类型,类型断言会检查 **x**的动态值 是否满足接口T。如果检查成功,不会获取x的动态值,而是获取一个T的接口值,保留动态类型与动态值。下面的例子可以帮助我们更好地理解上面所说的内容:

go 复制代码
var w io.Writer
w = os.Stdout
rw := w.(io.ReadWriter)	// success
w = new(ByteCounter)
rw = w.(io.ReadWriter)	// panic: *ByteCounter has no Read method

第一个类型断言后,wrw都持有os.Stdout,因此它们的动态类型都是*os.File(动态类型指的就是当前接口所绑定的对象的具体类型),但是w是一个io.Writer类型,rw是一个io.ReadWriter类型。

如果T为接口对x这个接口进行断言失败了,那么也会触发 panic,可以使用双返回值接收断言的结果,这样就不会在断言失败的时候出发 panic,而是会将第二个返回值接收者置为 false。

需要再次强调的是:对一个接口进行断言时,如果断言值也是一个接口类型,比如x.(T)xT都是接口,那么断言的对象是x的动态类型,而不是x这个接口本身。一个例子是,假如现在接口x绑定的对象是具体类型yx实现了A/B/C三个方法,y实现了A/B/C/D/E五个方法,而T也实现了A/B/C/D/E五个方法,使用x.(T)进行断言是取x的动态类型y,判断它是否满足T接口,如果满足则断言成功。以下是实现上述判断的 demo,可以直接运行:

go 复制代码
package main

import "fmt"

type x interface {
	A()
	B()
	C()
}

type T interface {
	A()
	B()
	C()
	D()
	E()
}

type y struct{}

func (y) A() {}
func (y) B() {}
func (y) C() {}
func (y) D() {}
func (y) E() {}

func main() {
	var var_y y
	var var_x x
	var_x = var_y

	var_T := var_x.(T)
	fmt.Printf("%T\n", var_T) // 输出 main.y,证明断言成功
}

基于类型断言区别错误类型

这一节引入了类型断言在错误处理当中的应用。以os包为例,其中包含三种必须要处理的常见错误:文件已经存在、找不到文件、全县拒绝。该包中提供了三个函数来对给定的错误值进行分类:

go 复制代码
package os

func isExist(err error) bool
func isNotExist(err error) bool
func isPermission(err error) bool

一种缺乏经验的实现可能会去检查错误消息的字符串中是否包含特定的子串:

go 复制代码
func isNotExist(err error) bool {
    return strings.Contains(err.Error(), "file does not exist")
}

这类方法不够健壮,处理I/O错误的逻辑在平台之间风格不相同,并且对相同的失败可能会报出各种不同的错误消息。

更可靠的方式是使用一个专门的类型来描述这种结构化的错误。os包中定义了一个PathError类型来描述在文件路径中可能会涉及到的失败:

go 复制代码
package os

type PathError struct {
    Op string
    Path string
    Err error
}

func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

具体的类型可以比字符串提供更多的细节:

go 复制代码
_, err := os.Open("/no/such/file")
fmt.Println(err) // "open /no/such/file: No such file or directory"
fmt.Printf("%#v\n", err)
// Output:
// &os.PathError{Op:"open", Path:"/no/such/file", Err:0x2}

下例展示了如何通过类型断言来判断具体的错误:

go 复制代码
import (
    "errors"
    "syscall"
)

var ErrNotExist = errors.New("file does not exist")

// IsNotExist returns a boolean indicating whether the error is known to
// report that a file or directory does not exist. It is satisfied by
// ErrNotExist as well as some syscall errors.
func IsNotExist(err error) bool {
    if pe, ok := err.(*PathError); ok {
        err = pe.Err
    }
    return err == syscall.ENOENT || err == ErrNotExist
}

通过类型断言查询接口

我们在编写一个处理业务逻辑的函数时,函数的形参可能是一个接口类型,此时我们只知道这个接口类型具有哪些方法,但是不知道它的动态类型具有哪些额外的方法。

net/http包中的writeHeader为例:

go 复制代码
func writeHeader(w io.Writer, contentType string) error {
    if _, err := w.Write([]byte("Content-Type: ")); err != nil {
        return err
    }
    if _, err := w.Write([]byte(contentType)); err != nil {
        return err
    }
    // ...
}

io.Writer接口的Write方法的形参是[]byte,而我们想要写入的是string,此时就需要显式地进行转换,但显式地转换会分配一个临时的内存进行拷贝,拷贝完成后丢弃内存,如果上述逻辑出现在 web 服务器的核心部分,会影响内存,使服务器速度变慢。

我们能否避免这种内存分配?许多满足io.Writer接口的类型同时具有WriteString方法(但WriteString方法不是io.Writer提供的方法,我们调用接口类型不具备的方法),这个方法可以避免分配临时的内存拷贝。

我们不能假设任意io.Writer的实例w具有WriteString方法,但是我们可以定义一个新的接口,并通过接口的类型断言,来判断w的动态类型是否满足具有WriteString方法的接口。以下是实践:

go 复制代码
// writeString writes s to w.
// If w has a WriteString method, it is invoked instead of w.Write.
func writeString(w io.Writer, s string) (n int, err error) {
    type stringWriter interface {				// 定义了一个新的接口
        WriteString(string) (n int, err error)
    }
    if sw, ok := w.(stringWriter); ok {			// 通过接口类型断言判断 w 是否具有 WriteString 方法
        return sw.WriteString(s) // avoid a copy
    }
    return w.Write([]byte(s)) // allocate temporary copy
}

func writeHeader(w io.Writer, contentType string) error {
    if _, err := writeString(w, "Content-Type: "); err != nil {
        return err
    }
    if _, err := writeString(w, contentType); err != nil {
        return err
    }
    // ...
}

通过这个例子,我们了解到如何通过接口的类型断言来判断接口的动态类型是否满足一些其他方法。

类型分支

类型断言可以作为switch/case的条件,每一个case可以是具体的类型,比如:

go 复制代码
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))
    }
}