编译时类型断言来检查接口

作为一个热身,来看一个在 Go 中熟知的编译期断言:接口满意度检查。

在这段代码中,var _ stringWriter = W{}行确保类型 W 是一个 stringWriter,其由 io.WriteString 检查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "io"

type W struct{}

func (w W) Write(b []byte) (int, error)       { return len(b), nil }
func (w W) WriteString(s string) (int, error) { return len(s), nil }

type stringWriter interface {
    WriteString(string) (int, error)
}

var _ stringWriter = W{}

func main() {
    var w W
    io.WriteString(w, "very long string")
}

如果你注释掉了 W 的 WriteString 方法,代码将无法编译:

1
2
main.go:14: cannot use W literal (type W) as type stringWriter in assignment:
    W does not implement stringWriter (missing WriteString method)

这是很有用的。对于大多数同时满足 io.Writer 和 stringWriter 的类型,如果你删除 WriteString 方法,一切都会像以前一样继续工作,但性能较差。

你可以使用编译期断言保护你的代码,而不是试图使用`testing.T.AllocsPerRun’为性能回归编写一个脆弱的测试。

这是一个实际的 io 包中的技术例子。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package io

type eofReader struct{}

func (eofReader) Read([]byte) (int, error) {
	return 0, EOF
}

type multiReader struct {
	readers []Reader
}

func (mr *multiReader) Read(p []byte) (n int, err error) {
	for len(mr.readers) > 0 {
		// Optimization to flatten nested multiReaders (Issue 13558).
		if len(mr.readers) == 1 {
			if r, ok := mr.readers[0].(*multiReader); ok {
				mr.readers = r.readers
				continue
			}
		}
		n, err = mr.readers[0].Read(p)
		if err == EOF {
			// Use eofReader instead of nil to avoid nil panic
			// after performing flatten (Issue 18232).
			mr.readers[0] = eofReader{} // permit earlier GC
			mr.readers = mr.readers[1:]
		}
		if n > 0 || err != EOF {
			if err == EOF && len(mr.readers) > 0 {
				// Don't return EOF yet. More readers remain.
				err = nil
			}
			return
		}
	}
	return 0, EOF
}

// MultiReader returns a Reader that's the logical concatenation of
// the provided input readers. They're read sequentially. Once all
// inputs have returned EOF, Read will return EOF.  If any of the readers
// return a non-nil, non-EOF error, Read will return that error.
func MultiReader(readers ...Reader) Reader {
	r := make([]Reader, len(readers))
	copy(r, readers)
	return &multiReader{r}
}

type multiWriter struct {
	writers []Writer
}

func (t *multiWriter) Write(p []byte) (n int, err error) {
	for _, w := range t.writers {
		n, err = w.Write(p)
		if err != nil {
			return
		}
		if n != len(p) {
			err = ErrShortWrite
			return
		}
	}
	return len(p), nil
}

var _ stringWriter = (*multiWriter)(nil)

func (t *multiWriter) WriteString(s string) (n int, err error) {
	var p []byte // lazily initialized if/when needed
	for _, w := range t.writers {
		if sw, ok := w.(stringWriter); ok {
			n, err = sw.WriteString(s)
		} else {
			if p == nil {
				p = []byte(s)
			}
			n, err = w.Write(p)
		}
		if err != nil {
			return
		}
		if n != len(s) {
			err = ErrShortWrite
			return
		}
	}
	return len(s), nil
}

// MultiWriter creates a writer that duplicates its writes to all the
// provided writers, similar to the Unix tee(1) command.
func MultiWriter(writers ...Writer) Writer {
	w := make([]Writer, len(writers))
	copy(w, writers)
	return &multiWriter{w}
}

好的,让我们低调一点!

接口满意检查是很棒的。但是如果你想检查一个简单的布尔表达式,如 1 + 1 == 2 ?

考虑这个代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import "crypto/md5"

type Hash [16]byte

func init() {
    if len(Hash{}) < md5.Size {
        panic("Hash is too small")
    }
}

func main() {
    // ...
}

Hash 可能是某种抽象的哈希结果。init 函数确保它将与 crypto/md5 一起工作。如果你改变 Hash 为(比如说)[8]byte,它会在进程启动时发生崩溃。但是,这是一个运行时检查。如果我们想要早点发现怎么办?

如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

package main

import "crypto/md5"

type Hash [16]byte

func hashIsTooSmall()

func init() {
	if len(Hash{}) < md5.Size {
		hashIsTooSmall()
	}
}

func main() {
	// ...
}

现在如果你改变 Hash 为 [8]byte,它将在编译过程中失败。(实际上,它在链接过程中失败。足够接近我们的目标了。)

1
2
3
4
$ go build .

# demo
missing function body

这里发生了什么?

hashIsTooSmall 是一个没有函数体的声明。编译器假定别人将提供一个实现,也许是一个汇编程序。

当编译器可以证明 len(Hash {})< md5.Size时,它消除了 if 语句中的代码。结果,没有人使用函数 hashIsTooSmall,所以链接器会消除它。没有其他损害。一旦断言失败,if 语句中的代码将被保留。不会消除 hashIsTooSmall。链接器然后注意到没有人提供了函数的实现然后链接失败,并出现错误,这是我们的目标。

编译时类型断言以进行救援

编译时类型断言是在编译器能够成功编译其代码之前,逻辑上必须满足它们的语句。我们主要使用它们来确保类型符合如下接口:

  • http.Pusher具有Push(string, *http.PushOptions) error方法
  • json.Unmarshaler具有UnmarshalJSON([]byte) error方法
  • fmt.Stringer具有String() string方法

我们可以通过这样的语句来符合接口:

1
var _ <要满足的接口> = <将要满足它的类型的实例>

例如:

1
var _ fmt.Stringer = (*it)(nil)

代码

Go承诺任何定义了自定义String()方法的类型都将在每次我们需要使用它时调用该方法,例如在格式说明符中,即“%s”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (t *Animal) String() string {
        switch *t {
        default:
                return "unknown"
        case 1:
                return "dog"
        case 2:
                return "cat"
        }
}
fmt.Printf(This is one %s, Animal(1))

因此,为确保保持此构型并确保我们的代码正确满足fmt.Stringer,我们将在这样的编译类型下进行断言

1
var _ fmt.Stringer = (*Animal)(nil)

当然这不是强制性的,但是如果我们不小心忘记了定义string() string,那么我们将感到非常惊讶,其输出为:

1
This is one %!s(main.Animal=1)

这可能是因为我们编写了string() string。尽管有很多其他的目光,但这完全可能发生,即使在代码审查中也很容易被遗漏,因为大写错误很难发现。

Go标准库中的实际用例

有这个问题

https://github.com/golang/go/issues/17391

我们在其中承诺math/big.Float符合fmt.Scanner,但实际上我们从未实施过。

我通过CL https://go-review.googlesource.com/c/30723提交了此修复程序,其中涉及实现fmt.Scanner的逻辑。但是,为了确保我们永远会兑现这一诺言,我在下面添加以下语句:

1
2
3
var _ fmt.Scanner = &floatZero
// or better
var _ fmt.Scanner = (*Float)(nil)

我为什么要关心

我个人必须处理筛选非常大的代码量以及伴随这种情况的错误。同样由于我做的后端工程的性质,我必须尝试使事情变得更简单,并容易地捕获此类错误。

通常,在处理多个API时,我们将定义自定义的序列化/反序列化格式,以处理来自这些API的数据,以便我们可以以自己的方式使用该数据。与传播方式不同。通过网络,数据可能会以某种形式发送,以减少冗余或出于某些特殊原因,但是在代码中,您希望提取某些属性并以不同方式使用它,因此我们可能需要对其进行分解。

我的直接用例

我目前正在为USGS Earthquakes网站编写一个小型API客户端,以便可以获取数据来测试我的k均值和在线k均值实现,然后可视化结果。

从以下位置的GeoJSON api数据中: https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php “坐标”定义为

1
2
3
4
5
coordinates: [
  -122.7683334,
  38.8025017,
  0.38
]

这里要解决的问题是,我需要将JSON数据转换为易于寻址和访问的格式,例如

1
2
3
4
5
{
  "latitude": 38.8025017,
  "longitude": -122.7683334,
  "depth": 0.38
}

然后将这些数据交给易于使用的内部API。这有助于避免为每个使用该数据的内部服务按索引弹出列而烦躁不安。

我该如何处理

很高兴您询问:Go允许我们为每种类型定义自定义JSON deserializer/unmarshaler,只要它们完全符合方法即可:

1
UnmarshalJSON[] byteerror

https://golang.org/pkg/encoding/json/#Unmarshal中指定

代码展示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type Coordinate struct {
	Latitude  float32 `json:"latitude"`
	Longitude float32 `json:"longitude"`
	Depth     float32 `json:"depth"`
}

var (
	lBrace = []byte("[")
	rBrace = []byte("]")
	comma  = []byte(",")
)

var _ json.Unmarshaler = (*Coordinate)(nil)

func (c *Coordinate) UnmarshalJSON(b []byte) error {
	b = bytes.TrimSpace(b)
	b = bytes.TrimPrefix(b, lBrace)
	b = bytes.TrimSuffix(b, rBrace)

	splits := bytes.Split(b, comma)
	var cleaned [][]byte
	for _, split := range splits {
		cleaned = append(cleaned, bytes.TrimSpace(split))
	}

	ptrsToSet := [...]*float32{
		0: &c.Longitude,
		1: &c.Latitude,
		2: &c.Depth,
	}

	for i, ptr := range ptrsToSet {
		if i >= len(cleaned) { // Done receiving values
			break
		}

		f64, err := strconv.ParseFloat(string(cleaned[i]), 32)
		if err != nil {
			return err
		}
		*ptr = float32(f64)
	}
	return nil
}

在紧绳上行走

如果我错误地犯了一个印刷错误,并将UnmarshalJSON写为我以前做过的Unmarshal,或者键入了unmarshalJSON,则结果如下:

没有编译类型断言:部署服务后,在运行时会出现错误。

1
json: cannot unmarshal array into Go value of type main.Coordinate

我们无法顺利通过代码审查,这很糟糕,即使在语义上正确,我们的代码仍然存在错误。您也可以编写测试,但是对于像拼写错误这样简单而灾难性的事情,简单的编译时助手可以节省过多的时间。

使用编译类型断言

编译器会正确警告我我不满意该接口,并出现如下错误:

1
2
3
4
cannot use (*Coordinate)(nil) (type *Coordinate) as type json.Unmarshaler in assignment:
	*Coordinate does not implement json.Unmarshaler (missing UnmarshalJSON method)
		have unmarshalJSON([]byte) error
		want UnmarshalJSON([]byte) error

谁需要这个

我认为,每个人都在编写Go。如果像我一样,您处理大量的微服务和API,那么您就会知道可能在部署之前捕获到但在运行时出现的错误的痛苦是:通常必须开始猜测服务失败了,停止一些服务以查看反应如何,调试几个小时,浪费时间,还浪费公司资源,我必须继续吗?

结论

Go编译器是您的朋友,它可以帮助您提高生产力。让我们利用其功能,进行编译时类型断言来履行合同和对接口的承诺! 仅通过预先进行编译类型声明,这种做法将提高您的工程效率,节省收入,减少服务脱机的时间。

参考: Go 语言编译期断言 compile time type assertions to the rescue