接口
接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力。
很多面向对象的语言都有相似的接口概念,但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, arg...)
}
func Sprintf(format string, args ...interface{}) string {
var buf bytes.Buffer
Fprintf(&buf, format, args...)
return buf.String()
}
(笔者注:从实现上能看出三个函数之间的区别:Fprintf向某个参数指定的流输出格式化结果,而Printf则默认指定标准输出流作为Fprintf的流参数输出结果,而Sprintf则已字符串形式返回结果)
Fprintf的前缀F表示文件(File)也表明格式化输出结果应该被写入第一个参数提供的文件中。在Printf函数中的第一个参数os.Stdout是*os.File类型;在Sprintf函数中的第一个参数&buf是一个指向可以写入字节的内存缓冲区,虽然它并不是一个文件类型,但它在某种意义上和文件类型相似。
然而Fprintf函数中的第一个参数也不是一个文件类型。它是io.Writer类型,这是一个接口类型定义如下:
package io
// Writer is the interface that wraps the basic Write method
type Writer interface {
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0<=n<=len(p)) and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain 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接口的任意具体类型的值。
一个类型可以自由地被另一个满足相同接口的类型替换,被称作可替换性(LSP里氏替换)。这是一个面向对象的特征(笔者注:这是多态的体现)。
让我们通过一个新的类型来进行校验,下面*ByteCounter类型里的Write方法,仅仅在丢弃写向它的字节前统计它们的长度。(在这个+=赋值语句中,让len§的类型和*c的类型匹配的转换是必须的。)
type Bytecounter int
func (c *ByteCounter) Write(p []byte) (int, error) {
*c += ByteCounter(len(p)) // convert int to ByteCounter 声明类型需一致才能赋值 因此这步类型转换是必须的
return len(p), nil
}
由于*ByteCounter满足io.Writer的约定(笔者注:实现了io.Writer接口定义的Write方法),我们可以把它传入Fprintf函数中;Fprintf函数执行字符串格式化的过程不会去关注ByteCounter正确的累加结果的长度。
var c ByteCounter
c.Write([]byte("hello"))
fmt.Println(c) // 5 即len("hello")
c = 0
var name = "Dolly"
fmt.Fprintf(&c, "hello, %s", name) // c的类型ByteCounter实现了接口io.Writer所规定的Write方法,那么c就可以用在任何以io.Writer作为参数的地方
fmt.Println(c) // 12 即len(“hello, Dolly”)
除了io.Writer这个接口类型,还有另一个对fmt包很重要的接口类型。Fprintf和Fprintln函数向类型提供了一种控制它们值输出的途径。
在2.5节中,我们为Celsius类型提供了一个String方法以便于可以打印成这样"100°C" ,在6.5节中我们给*IntSet添加一个String方法,这样集合可以用传统的符号来进行表示就像"{1 2 3}"。给一个类型定义String方法,可以让它满足最广泛使用之一的接口类型fmt.Stringer:
package fmt
// The String method is used to print values passed
// as an operand to any foramt that accepts a string
// or to an unformatted printer such as Print
type Stringer interface {
String() string
}
(笔者注:任何实现了String()方法的命名类型都满足Stringer接口的规定,或者说实现了Stringer接口,从这一点上讲,满足接口规定的原则就是实现接口所规定的所有方法。上面的所有例子中,接口都只有一个方法,因而实现了这个方法就是实现了接口。)
接口类型
接口类型具体描述了一系列方法的集合,一个实现了集合中所有方法的具体类型就是这个接口类型的一个实例。
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
}
再往下看,我们发现有些新的接口类型通过组合已有的接口来定义。下面是两个例子:
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 []byter) (n int, err error) // 具体指出方法
Writer // 内嵌其他接口
}
上面3种定义方式都是一样的效果。方法顺序的变化也没有影响,唯一重要的就是这个集合里面的方法。
实现接口的条件
一个类型如果拥有一个接口需要的所有方法,那么这个类型就实现了这个接口。例如,*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.Budder has Write method
w = time.Second // compile error: time.Duration lacks write method
var rwc io.ReadWriteCloser
rwc = os.Stdout // ok: *os.File has Rea, Write, Close method
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指针的方法,这样它就可能只实现了更少的接口。
举个例子可能会更清晰一点。在第6.5章中,IntSet类型的String方法的接收者是一个指针类型,所以我们不能在一个不能寻址的IntSet值上调用这个方法:
type IntSet struct { /* ... */ }
func (*IntSet) String() string
var _ = IntSet{}.String() // compile error: String requires *IntSet receiver
但是我们可以在一个IntSet值上调用这个方法:
var s IntSet
var _ = s.String() // ok: s is variable and &s has s String method
// 这只是一颗语法糖,成功的原因在于编译器自动为变量s取了地址
// 相当于运行了 var _ = &s.String() 取地址的操作是编译器自己加的
然而,由于只有*IntSet类型有String方法,所以这也意味着只有*IntSet类型实现了fmt.Stringer接口:
var _ fmt.Stringer = &s // ok
var _ fmt.Stringer = s // compile error: IntSet lacks String method
12.8章包含了一个打印出任意值的所有方法的程序,然后可以使用godoc -analysis=type tool
(§10.7.4)展示每个类型的方法和具体类型和接口之间的关系。
就像信封封装和隐藏起信件来一样,接口类型封装和隐藏具体类型和它的值。即使具体类型有其它的方法,也只有接口类型暴露出来的方法会被调用到:
os.Stdout.Write([]byte("hello")) // ok: *os.File has Write method
os.Stdout.Close() // ok: *os.File has Close method
var w io.Writer
w = os.Stdout
w.Write([]byte("hello")) // ok: io.Writer has Write method
w.Close() // compile error: io.Writer lacks Close method
// 这个例子说明命令类型 是根据声明类型决定的 而不是实际类型
一个有更多方法的接口类型,比如io.ReadWriter,和少一些方法的接口类型例如io.Reader,进行对比;更多方法的接口类型会告诉我们更多关于它的值持有的信息,并且对实现它的类型要求更加严格。那么关于interface{}类型,它没有任何方法,请讲出哪些具体的类型实现了它?
这看上去好像没有用,但实际上interface{}被称为空接口类型是不可或缺的。因为空接口类型对实现它的类型没有要求,所以我们可以将任意一个值赋给空接口类型。
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)
尽管不是很明显,从本书最早的例子中我们就已经在使用空接口类型。它允许像fmt.Println或者errorf函数接受任何类型的参数。
对于创建的一个interface{}值持有一个boolean,float,string,map,pointer,或者任意其它的类型;我们当然不能直接对它持有的值做操作,因为interface{}没有任何方法。我们会在7.10章中学到一种用类型断言来获取interface{}中值的方法。
因为接口与实现只依赖于判断两个类型的方法,所以没有必要定义一个具体类型和它实现的接口之间的关系。也就是说,尝试文档化和断言这种关系几乎没有用,所以并没有通过程序强制定义。下面的定义在编译期断言一个*bytes.Buffer的值实现了io.Writer接口类型:
// *bytes.Buffer must satisfy io.Writer
var w io.Writer = new(bytes.Buffer)
因为任意*bytes.Buffer的值,甚至包括nil通过(*bytes.Buffer)(nil)进行显示的转换都实现了这个接口,所以我们不必分配一个新的变量。并且因为我们绝不会引用变量w,我们可以使用空标识符来进行代替。总的看,这些变化可以让我们得到一个更朴素的版本:
// *bytes.Buffer must satisfy io.Writer
var _ io.Writer = (*bytes.Buffer)(nil)
非空的接口类型比如io.Writer经常被指针类型实现,尤其当一个或多个接口方法像Write方法那样隐式的给接收者带来变化的时候。一个结构体的指针是非常常见的承载方法的类型。
但是并不意味着只有指针类型满足接口类型,甚至连一些有设置方法的接口类型也可能会被Go语言中其它的引用类型实现。我们已经看过slice类型的方法(geometry.Path, §6.1)和map类型的方法(url.Values, §6.2.1),后面还会看到函数类型的方法的例子(http.HandlerFunc, §7.7)。甚至基本的类型也可能会实现一些接口;就如我们在7.4章中看到的time.Duration类型实现了fmt.Stringer接口。
一个具体的类型可能实现了很多不相关的接口。考虑在一个组织出售数字文化产品比如音乐,电影和书籍的程序中可能定义了下列的具体类型:
Album
Book
Movie
Magazine
Podcast
TVEpisode
Track
我们可以把每个抽象的特点用接口来表示。一些特性对于所有的这些文化产品都是共通的,例如标题,创作日期和作者列表。
type Artiface interface {
Title() string
Creators() []string
Created() time.Time
}
其它的一些特性只对特定类型的文化产品才有。和文字排版特性相关的只有books和magazines,还有只有movies和TV剧集和屏幕分辨率相关。
type Text interface {
Pages() int
Words() int
PageSize() int
}
type Audio interface {
Stream() (io.ReadCloser, error)
RunningTime() time.Duration
Format() string // e.g., "mp3", "wav"
}
type Video interface {
Stream() (io.ReadCloser, error)
RunningTime() time.Duration
Format() string // e.g., "mp4", "wav"
Resolution() (x, y int)
}
这些接口不止是一种有用的方式来分组相关的具体类型和表示他们之间的共同特点。我们后面可能会发现其它的分组。举例,如果我们发现我们需要以同样的方式处理Audio和Video,我们可以定义一个Streamer接口来代表它们之间相同的部分而不必对已经存在的类型做改变。
type Streamer interface {
Stream() (io.ReadCloser, error)
RunningTime() time.Duration
Format() string
}
每一组具体类型基于它们相同的行为可以表示成一个接口类型。不像基于类的语言,他们一个类实现的接口集合需要进行显式的定义,在Go语言中我们可以在需要的时候定义一个新的抽象或者特定特征的组合(即接口),而不需要修改具体类型的定义。当具体的类型来自不同的作者时这种方式会特别有用。当然也确实没有必要在具体的类型中指出这些共性。
flag.Value接口
注:原文中的命令行标记已经被笔者改成命令行flag,flag指的是运行命令行附带的参数,标志可能是翻译直译过来的,因为会造成理解困难,这里保留了flag的英文。
在本章,我们会学到另一个标准的接口类型flag.Value是怎么帮助命令行flag定义新的符号的。思考下面这个会休眠特定时间的程序:
var period = flag.Duration("period", 1*time.Second, "sleep period") // 第一个字符串定义了一个可以在命令行中使用的参数
func main() {
flag.Parse()
fmt.Printf("sleep for %v...", *period)
time.Sleep(*period)
fmt.Println()
}
在它休眠前它会打印出休眠的时间周期。fmt包调用time.Duration的String方法打印这个时间周期是以用户友好的注解方式,而不是一个纳秒数字
$ go build gopl.io/ch7/sleep
$ ./sleep
Sleeping for 1s...
默认情况下,休眠周期是一秒,但是可以通过 -period 这个命令行flag来控制。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包中;如果想为我们自己的数据类型定义新的flag符号是简单容易的,只需要定义一个实现flag.Value接口的类型,如下:
package flag
// Value is the interface to the value stored in a flag.
type Value interface {
String() string
Set(string) error
}
String方法格式化flag的值用在命令行参数消息中;这样每一个flag.Value也是一个fmt.Stringer。Set方法解析它的字符串参数并且更新标记变量的值。实际上,Set方法和String是两个相反的操作(这里的String()相当于get方法),所以最好的办法就是对他们使用相同的注解方式。
让我们定义一个允许通过摄氏温度或者华氏温度变换的形式指定温度的celsiusFlag类型。注意celsiusFlag内嵌了一个Celsius类型(§2.5),因此不用实现本身就已经有String方法了。为了实现flag.Value,我们只需要定义Set方法:
// *celsiusFlag satisfies the flag.Value interface
// 因为celsiusFlag底层是Celsius类型 该类型已经实现了String()方法
// 我们下面又为它实现了Set方法,因此它满足flag.Value接口
type celsiusFlag struct { Celsius }
func (f *celsiusFlag) Set(s string) error {
var uint string
var value float64
fmt.Sscanf(s, "%f%s", &value, &unit) // no error check needed
switch unit {
case "C", "°C":
f.Celsius = Celsius(value)
return nil
case "F", "°F":
f.Celsius = FToC(Fahrenheit(value))
return nil
}
return fmt.Errorf("invalid temperature %q", s)
}
调用fmt.Sscanf函数从输入s中解析一个浮点数(value)和一个字符串(unit)。虽然通常必须检查Sscanf的错误返回,但是在这个例子中我们不需要因为如果有错误发生,就没有switch case会匹配到。
下面的CelsiusFlag函数将所有逻辑都封装在一起。它返回一个内嵌在celsiusFlag变量f中的Celsius指针给调用者。Celsius字段是一个会通过Set方法在标记处理的过程中更新的变量。调用Var方法将flag加入应用的命令行flag集合中,存在异常复杂命令行接口的全局变量flag.CommandLine.Programs,它可能有几个这个类型的变量。调用Var方法将一个*\celsiusFlag参数赋值给一个flag.Value参数,会导致编译器去检查*celsiusFlag是否有必须的方法。
// CelsiusFlag defines a Celsius flag with the specific name,
// default value, and usage, and returns the address of the flag variable
// The flag argument must have a quantity and a unit, e.g., "100C"
func CelsiusFlag(name string, value Celsius, usage string) *Celsius {
f := celsiusFlag{value}
flag.CommandLine.Var(&f, name, usage)
// 调用Var方法将标记加入应用的命令行flag集合中
// 全局变量flag.CommandLine可能有这些参数 因此把这些参数加进去
return *f.Celsius
}
现在我们可以开始在我们的程序中使用新的flag:
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 212°F
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°C)
$ ./tempflag -help
Usage of ./tempflag:
-temp value
the temperature (default 20°C)
接口值
概念上讲一个接口的值,接口值,由两个部分组成,一个具体的类型和那个类型的值。它们被称为接口的动态类型和动态值。
接口值 = 具体类型(动态类型,由类型描述符指定) + 该类型的值(动态值)
对于像Go语言这种静态类型的语言,类型是编译期的概念;因此一个类型不是一个值。在我们的概念模型中,那些提供每个类型信息的值被称为类型描述符,比如类型的名称和方法。在一个接口值中,类型部分代表与之相关类型的描述符。
下面4个语句中,变量w得到了3个不同的值。(开始和最后的值是相同的)
var w io.Writer // 开始指这里
w = os.Stdout
w = new(bytes.Buffer)
w = nil // 最后指这里
让我们进一步观察在每一个语句后的w变量的值和动态行为。第一个语句定义了变量w:
var w io.Writer
在Go语言中,变量总是被一个定义明确的值初始化,即使接口类型也不例外。一个接口的零值表示为它的类型和值的部分都是nil。
一个接口值基于它的动态类型被描述为空或非空,所以这是一个空的接口值。你可以通过使用w==nil或者w!=nil来判断接口值是否为空。调用一个空接口值上的任意方法都会产生panic:
w.Write([byte("hello")]) // panic: nil pointer dereference
第二个语句将一个*os.File类型的值赋给变量w:
w = os.Stdout // 等价于 w = io.Writer(os.Stdout)
这个赋值过程调用了一个具体类型到接口类型的隐式转换,这和显式的使用io.Writer(os.Stdout)是等价的。这类转换不管是显式的还是隐式的,都会刻画出操作到的类型和值。这个接口值的动态类型被设为*os.File指针的类型描述符,它的动态值持有os.Stdout的拷贝;这是一个代表处理标准输出的os.File类型变量的指针(图7.2)。
笔者注:即w的动态类型是*os.File,动态值是Stdout(stdout本身也是*os.File类型)。
调用一个包含*os.File类型指针的接口值的Write方法,使得(*os.File).Write方法被调用。这个调用输出“hello”。
w.Write([]byte("hello")) // hello
通常在编译期,我们不知道接口值的动态类型是什么,所以一个接口上的调用必须使用动态分配。因为不是直接进行调用,所以编译器必须把代码生成在类型描述符的方法Write上,然后间接调用那个地址。这个调用的接收者是一个接口动态值的拷贝,本例中即为os.Stdout。效果和下面这个直接调用一样:
os.Stdout.Write([]byte("hello")) // 效果一样是因为w的动态值为os.Stdout
第三个语句给接口值赋了一个*bytes.Buffer类型的值:
w = new(bytes.Buffer)
现在动态类型是*bytes.Buffer并且动态值是一个指向新分配的缓冲区的指针(图7.3)。
Write方法的调用也使用了和之前一样的机制:
w.Write([]byte("hello")) // writes "hello" to the bytes.Buffers
这次类型描述符是*bytes.Buffer,所以调用了(*bytes.Buffer).Write方法,并且接收者是该缓冲区的地址。这个调用把字符串“hello”添加到缓冲区中。
最后,第四个语句将nil赋给了接口值:
w = nil
这个重置将它所有的部分都设为nil值,把变量w恢复到和它之前定义时相同的状态,在图7.1中可以看到。
**一个接口值可以持有任意大的动态值。**例如,表示时间实例的time.Time类型,这个类型有几个对外不公开的字段。我们从它上面创建一个接口值,
var x interface{} = time.Now()
结果可能和图7.4相似。从概念上讲,不论接口值多大,动态值总是可以容下它。(这只是一个概念上的模型;具体的实现可能会非常不同
接口值可以使用==和!=来进行比较。两个接口值相等, 仅仅在它们都是nil值或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等这两种情况下成立。
因为接口值是可比较的,所以它们可以用在map的键或者作为switch语句的操作数。
然而,如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic:
var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // panic: comparing uncomparable type []int
考虑到这点,接口类型是非常与众不同的。其它类型要么是安全的可比较类型(如基本类型和指针)要么是完全不可比较的类型(如切片,映射类型,和函数),但是在比较接口值或者包含了接口值的聚合类型时,我们必须要意识到潜在的panic。同样的风险也存在于使用接口作为map的键或者switch的操作数。只能在你非常确定它们的动态值是可比较类型的接口值的情况下才能去比较接口的值。
当我们处理错误或者调试的过程中,得知接口值的动态类型是非常有帮助的。可以使用fmt包的%T操作来获取接口值的动态类型:
var w io.Writer
fmt.Printf("%T\n", w) // <nil>
w = os.Stdout
fmt.Printf("%T\n", w) // *os.File 因为os.Stdout属于该类型
w = new(bytes.Buffer)
fmt.Printf("%T\n", w) // *bytes.Buffer
在fmt包内部,它实际上使用了反射机制来获取接口动态类型的名称。我们会在第12章中学到反射相关的知识。
警告:一个包含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 实例化bytes.Buffer对象并将其指针赋值给buf
}
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:
// 笔者注:示例代码里debug = true 这里这个panic是在debug = false条件下发生的
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。
(笔者注:读者在这里可能会疑惑,这里解释一下,main函数调用f(buf)的时候,buf是一个*byte.Buffer变量类型的nil指针即文中所述包含空指针值的非空接口,它不等于没有类型的nil值nil,后者是type为nil,value也为nil。回顾本小节开头一个不包含任何值的nil接口值和一个包含nil指针的接口值是不同的,一个不包含任何值的nil接口值指的是type为nil,value也为nil的接口值,而一个包含nil指针的接口值指的是类型不是nil,value是nil的接口值,所以它们是不同的。从这里也能得出一个结论,nil是接口值,它的类型是nil,type也为nil。)
动态分配机制依然决定(*bytes.Buffer).Write的方法会被调用,但是这次的接收者的值是nil。对于一些如*os.File的类型,nil是一个有效的接收者(§6.2.1),但是*bytes.Buffer类型属于这种类型中。这个方法会被调用,但是当它尝试去获取缓冲区时会发生panic。
问题在于尽管一个nil的*bytes.Buffer指针有实现这个接口的方法,它也不满足这个接口具体的行为上的要求。特别是这个调用违反了(*bytes.Buffer).Write方法的接收者非空的隐含先觉条件,所以将nil指针赋给这个接口是错误的。解决方案就是将main函数中的变量buf的类型改为io.Writer,因此可以避免一开始就将一个不完全的值赋值给这个接口:
var buf io.Writer // io.Write属于*os.File类型 该类型允许接收nil
if debug {
buf = new(bytes.Buffer)
}
f(buf) // OK
现在我们已经把接口值的技巧都讲完了,让我们来看更多的一些在Go标准库中的重要接口类型。在下面的三章中,我们会看到接口类型是怎样用在排序,web服务,错误处理中的。
sort.Interface接口
排序操作和字符串格式化一样是很多程序经常使用的操作。尽管一个最短的快排程序只要15行就可以搞定,但是一个健壮的实现需要更多的代码,并且我们不希望每次我们需要的时候都重写或者拷贝这些代码。
幸运的是,sort包内置的函数中提供了根据一些排序算法来对任何序列排序的功能。它们的设计非常独到。在很多语言中,排序算法都是和序列数据类型关联,同时排序函数和具体类型元素关联(笔者注:意思其他语言针对不同类型的元素使用不同的排序算法)。相比之下,Go语言的sort.Sort函数不会对具体的序列和它的元素类型做任何假设。相反,它使用了一个接口类型sort.Interface来定义通用的排序算法和可能被排序到的序列类型之间的约定。该接口的实现由序列的具体表示和它希望排序的元素决定,序列的表示经常是一个切片。
一个内置的排序算法需要知道三个方面的东西:序列的长度,如何表示两个元素比较的结果,交换两个元素的方式;这也正是sort.Interface的三个方法:
package sort
type Interface interface {
Len() int
Less(i, j int) bool. // i, j are indices of sequence elements 相当于java的comparator或者是python的cmp
Swap(i, j, int)
}
为了对序列进行排序,我们需要定义一个实现了这三个方法的类型,然后对这个类型的实例应用sort.Sort函数。思考对一个字符串切片进行排序,这可能是最简单的例子了。下面是这个新的类型StringSlice和它的Len,Less和Swap方法
type StringSlice []string
func (p StringSlice) Len() int { return len(p) }
func (p StringSlice) Less(i, j int) bool { return p[i] < p[j] }
func (p StringSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i]}
现在我们可以通过像下面这样将一个切片转换为一个StringSlice类型来进行排序:
sort.Sort(StringSlice(names))
这个转换得到一个相同长度,容量,和基于names数组的切片值;并且这个切片值的类型有三个排序需要的方法。
对字符串切片的排序是很常用的需要,所以sort包提供了StringSlice类型,也提供了Strings函数能让上面这些调用简化成sort.Strings(names)
。
这里用到的技术很容易适用到其它排序序列中,例如我们可以忽略大小写或者含有的特殊字符。(本书使用Go程序对索引词和页码进行排序也用到了这个技术,对罗马数字做了额外逻辑处理。)对于更复杂的排序,我们使用相同的方法,但是会用更复杂的数据结构和更复杂地实现sort.Interface的方法。
我们会运行上面的例子来对一个表格中的音乐播放列表进行排序。每个track都是单独的一行,每一列都是这个track的属性像艺术家,标题,和运行时间。想象一个图形用户界面来呈现这个表格,并且点击一个属性的顶部会使这个列表按照这个属性进行排序;再一次点击相同属性的顶部会进行逆向排序。让我们看下每个点击会发生什么响应。
下面的变量tracks包含了一个播放列表。每个元素都不是Track本身而是指向它的指针。尽管我们在下面的代码中直接存储Tracks也可以工作(笔者注:想想为什么,如果不知道再去看一遍前面方法讲的内容),sort函数会交换很多对元素,所以如果每个元素都是指针而不是Track类型会更快,指针是一个机器字码长度而Track类型可能是八个或更多。
type Track struct {
Title string
Artist string
Album string
Year int
Length time.Duration
}
var tracks = []*Track{
{"Go", "Delilah", "From the Roots Up", 2012, length("3m38s")},
{"Go", "Moby", "Moby", 1992, length("3m37s")},
{"Go Ahead", "Alicia Keys", "As I Am", 2007, length("4m36s")},
{"Ready 2 Go", "Martin Solveig", "Smash", 2011, length("4m24s")},
}
func length(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
panic(s)
}
return d
}
printTracks函数将播放列表打印成一个表格。一个图形化的展示可能会更好点,但是这个小程序使用text/tabwriter包来生成一个每一列列整齐对齐并隔开的表格,像下面展示的这样。注意到*tabwriter.Writer是满足io.Writer接口的。它会收集每一片写向它的数据;它的Flush方法会格式化整个表格并且将它写向os.Stdout(标准输出)。
func printTracks(tracks []*Track) {
const format = "%v\t%v\t%v\t%v\t%v\t\n"
tw := new(tabwriter.Writer).Init(os.Stdout, 0, 8, 2, ' ', 0)
fmt.Fprintf(tw, format, "Title", "Artist", "Album", "Year", "Length")
fmt.Fprintf(tw, format, "-----", "------", "-----", "----", "------")
for _, t := range tracks {
fmt.Fprintf(tw, format, t.Title, t.Artist, t.Album, t.Year, t.Length)
}
tw.Flush() // calculate column widths and print table
}
为了能按照Artist字段对播放列表进行排序,我们会像对StringSlice那样定义一个新的带有必须的Len,Less和Swap方法的切片类型。
type byArtist []*Track
func(x byArtist) Len() int { return len(x) }
func(x byArtist) Less(i, j int) bool { return x[i].Artist < x[j].Artist}
func(x byArtist) Swap(i, j int) { x[i], x[j] = x[j], x[i]}
为了调用通用的排序程序,我们必须先将tracks转换为新的byArtist类型,它定义了具体的排序:
sort.Sort(byArtist(tracks))
在按照artist对这个切片进行排序后,printTrack的输出如下:
Title Artist Album Year Length
----- ------ ----- ---- ------
Go Ahead Alicia Keys As I Am 2007 4m36s
Go Delilah From the Roots Up 2012 3m38s
Ready 2 Go Martin Solveig Smash 2011 4m24s
Go Moby Moby 1992 3m37s
如果用户第二次请求“按照artist排序”,我们会对tracks进行逆向排序。然而我们不需要定义一个有逆序Less方法的新类型byReverseArtist,因为sort包中提供了Reverse函数将排序顺序转换成逆序。
sort.Sort(sort.Reverse(byArtist(tracks))) // 注意sort.Reverse的参数是定义类型
在按照artist对这个切片进行逆向排序后,printTrack的输出如下:
Title Artist Album Year Length
----- ------ ----- ---- ------
Go Moby Moby 1992 3m37s
Ready 2 Go Martin Solveig Smash 2011 4m24s
Go Delilah From the Roots Up 2012 3m38s
Go Ahead Alicia Keys As I Am 2007 4m36s
sort.Reverse函数值得进行更近一步的学习,因为它使用了(§6.3)章中的组合,这是一个重要的思路。sort包定义了一个不公开的struct类型reverse,它嵌入了一个sort.Interface。reverse的Less方法调用了内嵌的sort.Interface值的Less方法,但是通过交换索引的方式使排序结果变成逆序。
package sort
type reverse struct { Interface } // 结构体reverse内嵌了一个sort.Interface
func (r reverse) Less(i, j int) bool { return r.Interfaec.Less(j, i) } // 注意这步交换了索引来实现逆序
func Reverse(data Interface) Interface{ return reverse{data} }
(笔者注:它的实现方式相当于将数据又包装了一层,重写新的包装结构体时调换了比较时的索引,实现逆序)
reverse的另外两个方法Len和Swap隐式地由原有内嵌的sort.Interface提供。因为reverse是一个不公开的类型,所以导出函数Reverse返回一个包含原有sort.Interface值的reverse类型实例。
为了可以按照不同的列进行排序,我们必须定义一个新的类型例如byYear:
type byYear []*Track
func (x byYear) Len() int { return len(x) }
func (x byYear) Less(i, j int) bool { return x[i].Year < x[j].Year }
func (x byYear) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
在使用sort.Sort(byYear(tracks))按照年对tracks进行排序后,printTrack展示了一个按时间先后顺序的列表:
Title Artist Album Year Length
----- ------ ----- ---- ------
Go Moby Moby 1992 3m37s
Go Ahead Alicia Keys As I Am 2007 4m36s
Ready 2 Go Martin Solveig Smash 2011 4m24s
Go Delilah From the Roots Up 2012 3m38s
对于我们需要的每个切片元素类型和每个排序函数,我们需要定义一个新的sort.Interface实现。如你所见,Len和Swap方法对于所有的切片类型都有相同的定义。下个例子,具体的类型customSort会将一个切片和函数结合,使我们只需要写比较函数就可以定义一个新的排序。顺便说下,实现了sort.Interface的具体类型不一定是切片类型;customSort是一个结构体类型。
type customSort struct {
t []*track
less func(x, y *Track) bool
}
func (x customSort) Len() int { return len(x.t) }
func (x customSort) Less(i, j int) bool { return x.less(x.t[i], x.t[]j)}
func (x customSort) Swap(i, j int) { x.t[i], x.t[j] = x.t[j], x.t[i] }
让我们定义一个多层的排序函数,它主要的排序键是标题,第二个键是年,第三个键是运行时间Length。下面是该排序的调用,其中这个排序使用了匿名排序函数:
// 结构体中的less函数需要指定 这里使用了匿名函数
// 也声明了一个匿名结构体
sort.Sort(customeSort{tracks, func(x, y *Track) bool {
if x.Title != y.Title {
return x.Title < y.Title
}
if x.Year != y.Year {
return x.Year < y.Year
}
if x.Length != y.Length {
return x.Length < y.Length
}
return false
}})
这下面是排序的结果。注意到两个标题是“Go”的track按照标题排序是相同的顺序,但是在按照year排序上更久的那个track优先。
Title Artist Album Year Length
----- ------ ----- ---- ------
Go Moby Moby 1992 3m37s
Go Delilah From the Roots Up 2012 3m38s
Go Ahead Alicia Keys As I Am 2007 4m36s
Ready 2 Go Martin Solveig Smash 2011 4m24s
尽管对长度为n的序列排序需要 O(nlogn)次比较操作,检查一个序列是否已经有序至少需要n-1次比较。sort包中的IsSorted函数帮我们做这样的检查。像sort.Sort一样,它也使用sort.Interface对这个序列和它的排序函数进行抽象,但是它从不会调用Swap方法:这段代码示范了IntsAreSorted和Ints函数在IntSlice类型上的使用:
values := []int{3, 1, 4, 1}
fmt.Println(sort.IntsAreSorted(values)) // "false"
sort.Ints(values)
fmt.Println(values) // "[1 1 3 4]"
fmt.Println(sort.IntsAreSorted(values)) // "true"
sort.Sort(sort.Reverse(sort.IntSlice(values))) // reverse的使用是有点反逻辑的 注意 因为它返回的是包装好的替换了Less函数的切片
fmt.Println(values) // "[4 3 1 1]"
fmt.Println(sort.IntsAreSorted(values)) // "false"
为了使用方便,sort包为[]int,[]string和[]float64的正常排序提供了特定版本的函数和类型。对于其他类型,例如[]int64或者[]uint,尽管路径也很简单,还是依赖我们自己实现。
http.Handler接口
在第一章中,我们粗略的了解了怎么用net/http包去实现网络客户端(§1.5)和服务器(§1.7)。在这个小节中,我们会对那些基于http.Handler接口的服务器API做更进一步的学习:
package http
type Handler interface {
ServerHTTP(w ResponseWriter, r *Request)
}
func ListenAndServe(addres string, h Handler) error
ListenAndServe函数需要一个例如“localhost:8000”的服务器地址,和一个所有请求都可以分派的Handler接口实例。它会一直运行,直到这个服务因为一个错误而失败(或者启动失败),它的返回值一定是一个非空的错误。
想象一个电子商务网站,为了销售,将数据库中物品的价格映射成美元。下面这个程序可能是能想到的最简单的实现了。它将库存清单模型化为一个命名为database的map类型,我们给这个类型一个ServeHttp方法,这样它可以满足http.Handler接口。这个handler会遍历整个map并输出物品信息。
func main() {
db := database{"shoes": 50, "socks": 5}
log.Fatal(http.ListenAndServe("localhost:8000", db))
}
type dollars float32
func (d dollars) String() string { return fmt.Sprintf("$.2f", d)} // 向dollars类型上绑定了String方法
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)
}
}
如果我们启动这个服务:
$ go build gopl.io/ch7/http1
$ ./http1 &
然后用1.5节中的获取程序(如果你更喜欢可以使用web浏览器)来连接服务器,我们得到下面的输出:
$ go build gopl.io/ch1/fetch
$ ./fetch http://localhost:8000
shoes: $50.00
socks: $5.00
目前为止,这个服务器不考虑URL,只能为每个请求列出它全部的库存清单。更真实的服务器会定义多个不同的URL,每一个都会触发一个不同的行为。让我们使用/list来调用已经存在的这个行为并且增加另一个/price调用表明单个货品的价格,像这样/price?item=socks来指定一个请求参数。
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)
}
现在handler基于URL的路径部分(req.URL.Path)来决定执行什么逻辑。如果这个handler不能识别这个路径,它会通过调用w.WriteHeader(http.StatusNotFound)返回客户端一个HTTP错误;这个检查应该在向w写入任何值前完成。(顺便提一下,http.ResponseWriter是另一个接口。它在io.Writer上增加了发送HTTP相应头的方法。)等价的,我们可以使用实用的http.Error函数:
msg := fmt.Sprintf("no such page: %s\n", req.URL)
http.Error(w, msg, http.StatusNotFound) // 等价于switch的default语句执行的代码
/price的case会调用URL的Query方法来将HTTP请求参数解析为一个map,或者更准确地说一个net/url包中url.Values(§6.2.1)类型的多重映射。然后找到第一个item参数并查找它的价格。如果这个货品没有找到会返回一个错误。
这里是一个和新服务器会话的例子:
$ go build gopl.io/ch7/http2
$ go build gopl.io/ch1/fetch
$ ./http2 &
$ ./fetch http://localhost:8000/list
shoes: $50.00
socks: $5.00
$ ./fetch http://localhost:8000/price?item=socks
$5.00
$ ./fetch http://localhost:8000/price?item=shoes
$50.00
$ ./fetch http://localhost:8000/price?item=hat
no such item: "hat"
$ ./fetch http://localhost:8000/help
no such page: /help
显然我们可以继续向ServeHTTP方法中添加case,但在一个实际的应用中,将每个case中的逻辑定义到一个分开的方法或函数中会很实用。此外,相近的URL可能需要相似的逻辑;例如几个图片文件可能有形如/images/*.png的URL。因为这些原因,net/http包提供了一个请求多路器ServeMux来简化URL和handlers的联系。一个ServeMux将一批http.Handler聚集到一个单一的http.Handler中。再一次,我们可以看到满足同一接口的不同类型是可替换的:web服务器将请求指派给任意的http.Handler 而不需要考虑它后面的具体类型。
对于更复杂的应用,一些ServeMux可以通过组合来处理更加错综复杂的路由需求。Go语言目前没有一个权威的web框架,就像Ruby语言有Rails和python有Django。这并不是说这样的框架不存在,而是Go语言标准库中的构建模块就已经非常灵活以至于这些框架都是不必要的。此外,尽管在一个项目早期使用框架是非常方便的,但是它们带来额外的复杂度会使长期的维护更加困难。
在下面的程序中,我们创建一个ServeMux并且使用它将URL和相应处理/list和/price操作的handler联系起来,这些操作逻辑都已经被分到不同的方法中。然后我门在调用ListenAndServe函数中使用ServeMux为主要的handler。
func main() {
db := database{"shoes": 50, "socks": 5}
mux := http.NewServeMux()
mux.Handle("/list", http.HandlerFunc(db.list)) // db.list定义在下面
mux.Handle("/price", http.HanderFunc(db.price)) // db.price定义在下面
log.Fatal(http.ListenAndServe("localhost:8000", mux))
}
type database map[string]dollars
// 把原来switch的case语句写成独立的方法
// 主要目的是解耦
// 其次是 这样才满足http.NewServeMux的调用基础
// 另外http.Handlerfunc做的是类型转换下面有解释
// 为什么需要类型转换
// 因为db.list方法本身没有没有实现http.ServeHTTP方法
// 它不满于接口Handler
func (db database) list(w http.ResponWriter, req *http.Request) {
for item, price := range db {
fmt.Fprintf(w, "%s: %s\n", item, price)
}
}
func (db database) price(w http.ResponWriter, req *http.Request) {
item := req.URL.Query().Get("item")
price, ok = da[item]
if !ok {
w.WriteHeader(http.StatusNotFound) // 404
fmt.Fprintf(w, "no such item: %q\n", item)
return
}
fmt.Fprintf(w, "%s\n", price)
}
让我们关注这两个注册到handlers上的调用。第一个db.list是一个方法值 (§6.4),它是下面这个类型的值。
// db.list
func(w http.ResponWriter, req *http.Request)
也就是说db.list的调用会援引一个接收器是db的database.list方法。所以db.list是一个实现了handler类似行为的函数,但是因为它没有ServeHTTP方法,所以它不满足http.Handler接口并且不能直接传给mux.Handle。
语句http.HandlerFunc(db.list)是一个转换而非一个函数调用,因为http.HandlerFunc是一个类型。它有如下的定义:
// net/http
package http
type HandlerFunc(w ResponWriter, r *Request) // 注意 是函数值不是方法值
func (f HandlerFunc) ServeHTTP(w ResponWriter, r *Request) {
f(w, r)
}
HandlerFunc显示了在Go语言接口机制中一些不同寻常的特点。这是一个实现了接口http.Handler的方法的函数类型(笔者注:这里写的函数类型不是方法类型,请特别注意)。ServeHTTP方法的行为是调用了它的函数本身。因此HandlerFunc是一个让函数值满足一个接口的适配器,这里函数和这个接口仅有的方法有相同的函数签名。实际上,这个技巧让一个单一的类型例如database以多种方式满足http.Handler接口:一种通过它的list方法,一种通过它的price方法等等。
因为handler通过这种方式注册非常普遍,ServeMux有一个方便的HandleFunc方法,它帮我们简化handler注册代码成这样:
mux.HandlerFunc("/list", db.list)
mux.HandlerFunc("/price", db.price)
笔者注:推测mux.HandlerFunc实现应该等同于http.HandlerFunc
从上面的代码很容易看出应该怎么构建一个程序:由两个不同的web服务器监听不同的端口,并且定义不同的URL将它们指派到不同的handler。我们只要构建另外一个ServeMux并且再调用一次ListenAndServe(可能并行的)。但是在大多数程序中,一个web服务器就足够了。此外,在一个应用程序的多个文件中定义HTTP handler也是非常典型的,如果它们必须全部都显式地注册到这个应用的ServeMux实例上会比较麻烦。
所以为了方便,net/http包提供了一个全局的ServeMux实例DefaultServerMux和包级别的http.Handle和http.HandleFunc函数。现在,为了使用DefaultServeMux作为服务器的主handler,我们不需要将它传给ListenAndServe函数;nil值就可以工作。
然后服务器的主函数可以简化成:
func main() {
db := database{"shoes": 50, "socks": 5}
http.HandleFunc("/list", db.list)
http.HandleFunc("price", db.price)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}
最后,一个重要的提示:就像我们在1.7节中提到的,web服务器在一个新的协程中调用每一个handler,所以当handler获取其它协程或者这个handler本身的其它请求也可以访问到变量时,一定要使用预防措施,比如锁机制。我们后面的两章中将讲到并发相关的知识(Go语言的并发基于协程,并发必须解决对共享变量的访问冲突)。
error接口
从本书的开始,我们就已经创建和使用过神秘的预定义error类型,而且没有解释它究竟是什么。实际上它就是interface类型,这个类型有一个返回错误信息的单一方法:
type error interface {
Error() string
}
创建一个error最简单的方法就是调用errors.New函数,它会根据传入的错误信息返回一个新的error。整个errors包仅只有4行:
packages errors
func New(text string) error { return &errorString{text} }
type errorString struct { text string }
func (e *errorString) Error() string { return e.text }
// *errorString类型实现了error接口
承载errorString的类型是一个结构体而非一个字符串,这是为了保护它所表示的错误不会被粗心(或有意)地更新。
并且因为是指针类型*errorString满足error接口而非errorString类型,所以每个New函数的调用都分配了一个独特的和其他错误不相同的实例。我们也不想要重要的error例如io.EOF和一个刚好有相同错误消息的error比较后相等。
fmt.Println(errors.New("EOF") == errors.New("EOF")) // false
调用errors.New函数是非常稀少的,因为有一个方便的封装函数fmt.Errorf,它还会处理字符串格式化。我们曾多次在第5章中用到它。
package fmt
import "errors"
func Errorf(format string, args ...interface{}) error {
return errors.New(Sprintf(format, args...))
}
虽然*errorString可能是最简单的错误类型,但远非只有它一个。例如,syscall包提供了Go语言底层系统调用API。在多个平台上,它定义一个实现error接口的数字类型Errno,并且在Unix平台上,Errno的Error方法会从一个字符串表中查找错误消息,如下面展示的这样:
package syscall
type Errno uintptr // os 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)
}
下面的语句创建了一个持有Errno值为2的接口值,表示POSIX ENOENT状况:
var err error = syscall.Errno(2)
fmt.Println(err.Error()) // "no such file or directory"
fmt.Println(err) // "no such file or directory"
err的值图形化的呈现在图7.6中。
Errno是一个系统调用错误的高效表示方式,它通过一个有限的集合进行描述,并且它满足标准的错误接口。我们会在第7.11节了解到其它满足这个接口的类型。
表达式求值
在本节中,,我们会构建一个简单算术表达式的求值器。我们将使用一个接口Expr来表示Go语言中任意的表达式。现在这个接口不需要有方法,但是我们后面会为它增加一些。
// An Expr is an arithmetic expression.
type Expr interface{}
我们的表达式语言由浮点数符号(小数点);二元操作符+,-,*, 和/;一元操作符-x和+x;调用pow(x,y),sin(x),和sqrt(x)的函数;例如x和pi的变量;当然也有括号和标准的优先级运算符。所有的值都是float64类型。这下面是一些表达式的例子:
sqrt(A / pi)
pow(x, 3) + pow(y, 3)
(F - 32) * 5 / 9
下面的五个具体类型表示了具体的表达式类型。Var类型表示对一个变量的引用。(我们很快会知道为什么它可以被输出。)literal类型表示一个浮点型常量。unary和binary类型表示有一到两个运算对象的运算符表达式,这些操作数可以是任意的Expr类型。call类型表示对一个函数的调用;我们限制它的fn字段只能是pow,sin或者sqrt。
// A Var identifiess a variable, e.g., x.
type Var string
// A literal is numeric constant, e.g., 3.141
type literal float64
// A unary represents a unary operator expression, e.g., -x
type unary struct {
op rune // one of +, -
x Expr
}
// A binary represents a binary operator expression, e.g, x+y
type binary struct {
op, rune // one of +, -, *, /
x, y Expr
}
// A call represents a funciton call expression, e.g, sin(x)
type call struct {
fn string. // one of pow, sin, sqrt
args []Expr
}
为了计算一个包含变量的表达式,我们需要一个environment变量将变量的名字映射成对应的值:
type Env map[Var]float64
我们也需要每个表达式去定义一个Eval方法,这个方法会根据给定的environment变量返回表达式的值。因为每个表达式都必须提供这个方法,我们将它加入到Expr接口中。这个包只会对外公开Expr,Env,和Var类型。调用方不需要获取其它的表达式类型就可以使用这个求值器。
type Expr interface {
// Eval returns the value of this Expr in the enviroment env.
Eval(env Env) float64
}
下面给大家展示一个具体的Eval方法。Var类型的这个方法对一个environment变量进行查找,如果这个变量没有在environment中定义过这个方法会返回一个零值,literal类型的这个方法简单的返回它真实的值。
func (v Var) Eval(env Env) float64 {
return env[v] // return零值是map本身的机制
}
func (l literal) Eval(_ Env) float64 {
return float64(l)
}
unary和binary的Eval方法会递归的计算它的运算对象,然后将运算符op作用到它们上。我们不将被零或无穷数除作为一个错误,因为它们都会产生一个固定的结果——无限。最后,call的这个方法会计算对于pow,sin,或者sqrt函数的参数值,然后调用对应在math包中的函数。
func (u unary) Eval(env Env) float64 {
switch u.op {
case '+':
return +u.x.Eval(env)
case '-':
return -u.x.Eval(env)
}
panic(fmt.Sprintf("unsupported unary operator: %q", u.op))
}
func (b binary) Eval(env Env) float64 {
swith ..op {
case '+':
return b.x.Eval(env) + b.y.Eval(env)
case '-':
return b.x.Eval(env) - b.y.Eval(env)
case '*':
return b.x.Eval(env) * b.y.Eval(env)
case '/':
return b.x.Eval(env) / b.y.Eval(env)
}
panic(fmt.Sprintf("unsupported binary operator: %q", b.op))
}
func (c call) Eval(env Env) float64 {
switch c.fn {
case "pow":
return math.Pow(c.args[0].Eval(env), c.args[1].Eval(env))
}
case "sin":
return math.Sin(c.args[0].Eval(env)),
case "sqrt":
return math.Sqrt(c.args[0].Eval(env))
panic(fmt.Sprintf("unsupported func call: %s", c.fn))
}
一些方法会失败。例如,一个call表达式可能有未知的函数或者错误的参数个数。用一个无效的运算符如!或者<去构建一个unary或者binary表达式也是可能会发生的(尽管下面提到的Parse函数不会这样做)。这些错误会让Eval方法panic。其它的错误,像计算一个没有在environment变量中出现过的Var,只会让Eval方法返回一个错误的结果。所有的这些错误都可以通过在计算前检查Expr来发现。这是我们接下来要讲的Check方法的工作,但是让我们先来测试Eval方法。
下面的TestEval函数是对evaluator的一个测试。它使用了我们会在第11章讲解的testing包,但是现在知道调用t.Errof会报告一个错误就足够了。这个函数循环遍历一个表格中的输入,这个表格中定义了三个表达式和针对每个表达式不同的enviroment变量。第一个表达式根据给定圆的面积A计算它的半径,第二个表达式通过两个变量x和y计算两个立方体的体积之和,第三个表达式将华氏温度F转换成摄氏度。
func TestEval(t *testing.T) {
test := []struct {
expr string
env Env
want string
}{
{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 12, "y":1}, "1729"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 7}, "y":10}, "1729"},
{"5 / 9 * (F-32)", Env{"F": -40}, "-40"},
{"5 / 9 * (F-32)", Env{"F": 32}, "0"},
{"5 / 9 * (F-32)", Env{"F": 212}, "100"},
}
}
var preExpr string
for _, test := range tests {
// Print expr only when it changes
if test.expr != prevExpr {
fmt.Printf("\n%s\n", test.expr)
prevExpr = test.expr
}
expr, err := Parse(test.expr)
if err != nil {
t.Error(err) // parse error
continue
}
got := fmt.Sprintf("%.6g", expr.Eval(test.Env))
fmt.Printf("\t%v => %s\n", test.Env, got)
if got != test.want {
t.Errorf("%s.Eval() in %v = %q, want %q\n", test.expr, test.env, got, test.want)
}
}
}
对于表格中的每一条记录,这个测试会解析它的表达式然后在environment变量中计算它,输出结果。这里我们没有空间来展示Parse函数,但是如果你使用go get下载这个包你就可以看到这个函数。
go test(§11.1) 命令会运行一个包的测试用例:
go test -v gopl.io/ch7/eval
这个-v标识可以让我们看到测试用例打印的输出;正常情况下像这样一个成功的测试用例会阻止打印结果的输出。这里是测试用例里fmt.Printf语句的输出:
sqrt(A / pi)
map[A:87616 pi:3.141592653589793] => 167
pow(x, 3) + pow(y, 3)
map[x:12 y:1] => 1729
map[x:9 y:10] => 1729
5 / 9 * (F - 32)
map[F:-40] => -40
map[F:32] => 0
map[F:212] => 100
幸运的是目前为止所有的输入都是适合的格式,但是我们的运气不可能一直都有。甚至在解释型语言中,为了发现静态错误而检查语法是非常常见的;静态错误就是不用运行程序就可以检测出来的错误。通过将静态检查和动态的部分分开,我们可以快速的检查错误并且对于多次检查只执行一次而不是每次表达式计算的时候都进行检查。
让我们往Expr接口中增加另一个方法。Check方法对一个表达式语义树检查出静态错误。我们马上会说明它的vars参数。
type Expr interface {
Eval(env Env) float64
// Check reports errors in this Expr and adds its Vars to the set
Check(vars map[Var]bool) error
}
具体的Check方法展示在下面。literal和Var类型的计算不可能失败,所以这些类型的Check方法会返回一个nil值。对于unary和binary的Check方法会首先检查操作符是否有效,然后递归的检查运算单元。相似地对于call的这个方法首先检查调用的函数是否已知并且有没有正确个数的参数,然后递归的检查每一个参数。
func (v Var) Check(vars map[Var]bool) error {
vars[v] = true
return nil
}
func (literal) Check(vars map[Var]bool) error {
return nil
}
func (u nary) Check(vars map[Var]bool) error {
if !strings.ContainsRune("+-", u.op) {
return fmt.Errorf("unexpected unary op %q", u.op)
}
return u.x.Check(vars)
}
func (b binary) Check(vars map[Var]bool) error {
if !srings.ContainsRune("+-*/", b.op) {
return fmt.Errorf("unexpected binary op %q", b.op)
}
if err := b.x.Check(vars); err != nil {
return err
}
return b.y.Check(vars)
}
func (c call) Check(vars map[Var]bool) error {
arity, ok := numParams[c.fn]
if !ok {
return fmt.Errorf("unknown function %q", c.fn)
}
if len(c.args) != arity {
return fmt.Errorf("calls to %s has %d args, want %d", c.fn, len(c.args), arity)
}
for _, arg := range c.args {
if err := arg.Check; err != nil {
return err
}
}
return nil
}
var numParams = map[string]int{"pow": 2, "sin": 1, "sqrt": 1}
我们在两个组中有选择地列出有问题的输入和它们得出的错误。Parse函数(这里没有出现)会报出一个语法错误和Check函数会报出语义错误。
x % 2 unexpected '%'
math.Pi unexpected '.'
!true unexpected '!'
"hello" unexpected '"'
log(10) unknown function "log"
sqrt(1, 2) call to sqrt has 2 args, want 1
Check方法的参数是一个Var类型的集合,这个集合包含了从表达式中找到的变量名。为了保证成功的计算,这些变量中的每一个都必须出现在enviroment变量中。从逻辑上讲,这个集合就是调用Check方法返回的结果,但是因为这个方法是递归调用的,所以对于Check方法,填充结果到一个作为参数传入的集合中会更加的方便(保存以往计算结果以防止重复调用,典型地以空间换时间)。调用方在初始调用时必须提供一个空的集合。
在第3.2节中,我们绘制了一个在编译期才确定的函数f(x,y)。现在我们可以解析,检查和计算在字符串中的表达式,我们可以构建一个在运行时从客户端接收表达式的web应用并且它会绘制这个函数的表示的曲面。我们可以使用集合vars来检查表达式是否是一个只有两个变量x和y的函数——实际上是3个,因为除此之外为了方便会提供半径大小r。并且我们会在计算前使用Check方法拒绝有格式问题的表达式,这样我们就不会在下面函数的40000个计算过程(100x100个栅格,每一个有4个角)重复这些检查。
这个ParseAndCheck函数混合了解析和检查步骤的过程:
import "gopl.io/ch7/eval"
func parseAndCheck(s string) (eval.Expr, error) {
if s == "" {
return nil, fmt.Errorf("empty expression")
}
expr, err := eval.Parse(s)
if err != nil {
return nil, err
}
vars := make(map[eval.Var]bool)
if err := expr.Check(vars); err != nil {
return nil, err
}
for v := ranges vars {
if v != "x" && v != "y" && v != "r" {
return nil, fmt.Errorf("undefined variable: %s", v)
}
}
return expr, nil
}
为了编写这个web应用,所有我们需要做的就是下面这个plot函数,这个函数有和http.HandlerFunc相似的签名:
func plot(w http.ResponWriter, r *http.Request) {
r.ParseForm()
expr, err := parseAndCheck(r.Form.Get("expr"))
if err != nil {
http.Error(w, "bad expr: " + err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "iamge/svg+xml")
surface(w, func(x, y float64) float64 {
r := math.Hypot(x, y) // distance from original point (0, 0)
return expr.Eval(eval.Env{"x": x, "y": y, "r": r})
})
}
这个plot函数解析和检查在HTTP请求中指定的表达式并且用它来创建一个两个变量的匿名函数。这个匿名函数和来自原来surface-plotting程序中的固定函数f有相同的签名,但是它计算一个用户提供的表达式。environment变量中定义了x,y和半径r。最后plot调用surface函数,它就是gopl.io/ch3/surface中的主要函数,修改后它可以接受plot中的函数和输出io.Writer作为参数,而不是使用固定的函数f和os.Stdout。图7.7中显示了通过程序产生的3个曲面。
类型断言
类型断言是一个使用在接口值上的操作,它的语法它看起来像x.(T),被用来断言类型。这里的x表示一个接口类型的变量,T表示一个类型。一个类型断言检查它操作对象的动态类型是否和断言的类型匹配(注意,是对象的动态类型,不是声明类型)。
这里有两种可能。第一种,如果断言的类型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,但是变量w是一个io.Writer类型,只对外公开了文件的Write方法,而rw变量还公开了它的Read方法
var w io.Writer
w = os.Stdout
rw := w.(io.ReadWriter) // success: *os.File has both Read and Write method
w = new(ByteCounter)
rw = w.(io.Readwriter) // panic: *ByteCounter has no Read method
如果断言操作的对象是一个nil接口值,那么不论被断言的类型是什么这个类型断言都会失败。我们几乎不需要对一个更少限制性的接口类型(更少的方法集合)做断言,因为它表现的就像是赋值操作一样,除了对于nil接口值的情况。
w = rw // io.ReadWriter is assignable to io.Writer
w = rw(io.Writer) // fail if rw == nil
// 如前文所述 对nil接口值做类型断言会直接失败 所以赋值不会成功
// 但只要rw != nil rw的类型等于io.Writer w的类型都会被赋值成io.Writer
经常地,对一个接口值的动态类型我们是不确定的,并且我们更愿意去检验它是否是一些特定的类型。如果类型断言出现在一个预期有两个结果的赋值操作中,例如下面的定义,这个操作不会在失败的时候发生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
第二个结果通常赋值给一个命名为ok的变量。如果这个操作失败了,那么ok就是false值,第一个结果等于被断言类型的零值,在这个例子中就是一个nil的*bytes.Buffer类型。
这个ok结果经常立即用于决定程序下面做什么。if语句的扩展格式让这个变的很简洁:
if f, ok := w.(*os.File); ok {
// ...use f...
}
当类型断言的操作对象是一个变量,你有时会看见原来的变量名重用而不是声明一个新的本地变量名,这个重用的变量原来的值会被覆盖,如下面这样:
if w, ok := w.(*os.File); ok {
// ...use w...
}
基于类型断言区别错误类型
思考在os包中文件操作返回的错误集合。I/O可以因为很多原因而失败,但是有三种经常的错误必须进行不同的处理:文件已经存在(对于创建操作),找不到文件(对于读取操作),和权限拒绝。os包中提供了三个帮助函数来对给定的错误值表示的失败进行分类:
package os
func IsExist(err error) bool
func INotExit(err error) bool
func IsPermission(err error) bool
对这些判断的一个缺乏经验的实现可能会去检查错误消息是否包含了特定的子字符串,比如下面的实现:
func IsNotExist(err error) bool {
// Note: not robust!
return strings.Contains(err.Error(), "file does not exist")
}
但是针对I/O错误的处理逻辑不同平台之间的差异可能很大,所以这种方案并不健壮,并且对相同的失败可能会报出不同的错误信息。在测试的过程中,通过检查错误消息的子字符串来保证特定的函数以期望的方式失败是非常有用的,但对于线上的代码是不够的。
一个更可靠的方式是使用一个专门的类型来描述结构化的错误。os包中定义了一个PathError类型来描述在文件路径操作中涉及到的失败,像Open或者Delete操作;并且定义了一个叫LinkError的变体来描述涉及到两个文件路径的操作错误,像Symlink和Rename。这下面是os.PathError:
package os
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + " " + e.Err.Error()
}
大多数调用方都不知道PathError并且通过调用错误本身的Error方法来统一处理所有的错误。尽管PathError的Error方法简单地把这些字段连接起来生成错误消息,PathError的结构保护了内部的错误组件。调用方需要使用类型断言来检测错误的具体类型以便将一种失败和另一种区分开;具体的类型可以比字符串提供更多的细节。
_, 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}
这就是三个帮助函数是怎么工作的。例如下面展示的IsNotExist,它会报出是否一个错误和syscall.ENOENT(§7.8)或者和有名的错误os.ErrNotExist相等(可以在§5.4.2中找到io.EOF);或者是一个*PathError,它内部的错误是syscall.ENOENT和os.ErrNotExist其中之一。
import (
"errors"
"syscall"
)
var ErrNotExist = errors.New("file not exist")
// IsNotExist return 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
}
下面这里是它的实际使用:
_, err := os.Open("no/such/file")
fmt.Println(os.IsNotExist(err)) // true
如果错误消息结合成一个更大的字符串,当然PathError的结构就不再为人所知,例如通过一个对fmt.Errorf函数的调用。区别错误的操作通常必须在失败操作后,错误传回调用者前进行。
通过类型断言查询接口行为
下面这段逻辑和net/http包中web服务器负责写入HTTP头字段(例如:"Content-type:text/html)的部分相似。io.Writer接口类型的变量w代表HTTP响应;写入它的字节最终被发送到访问者的web浏览器上。
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
}
// ...
}
因为Write方法需要传入一个byte切片而我们希望写入的值是一个字符串,所以我们需要使用[]byte(…)进行转换。这个转换分配内存并且拷贝一个副本,但是这个副本在转换后几乎立马就被丢弃掉。让我们假装这是一个web服务器的核心部分并且我们的性能分析表示这个内存分配使服务器的速度变慢。这里我们可以避免掉内存分配么?
这个io.Writer接口告诉我们关于w持有的具体类型的唯一东西:就是可以向它写入的byte切片。如果我们回顾net/http包中的内部机制,我们知道在这个程序中的w变量持有的动态类型也有一个允许字符串高效写入的WriteString方法;这个方法会避免去分配一个临时的拷贝。(许多满足io.Writer接口的重要类型同时也有WriteString方法,包括*bytes.Buffer,*os.File和*bufio.Writer。)
我们不能对任意io.Writer类型的变量w,假设它也拥有WriteString方法。但是我们可以定义一个只有这个方法的新接口并且使用类型断言来检测是否w的动态类型满足这个新接口。
// 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 { // 这里使用类型断言的意思是 只要是sw对象有WriteString方法(即满足stringWriter接口),就调用这个方法
return sw.WriteString(s) // write string avoid using a copy
}
return w.Write([]byte(s)) // allocate tempoaray copy
// 这样代码的意思是 如果不满足stringWriter接口的条件下 采用原有的有capy的方式写入
}
func writeHeader(w io.Writer, contentType string) error {
if _, error := writeString(w, "ContentType: "); err != nil {
return err
}
if _, error := writeString(w, contentType); err != nil {
return err
}
}
为了避免重复定义,我们将这个检查移入到一个实用工具函数writeString中,因为它非常有用,所以在标准库将它作为io.WriteString函数提供。这是向一个io.Writer接口写入字符串的推荐方法。
这个例子的神奇之处在于,没有定义WriteString方法的标准接口,也没有指定它是一个所需行为的标准接口。一个具体类型只会通过它的方法决定它是否满足stringWriter接口,而不是任何它和这个接口类型所表达的关系(笔者注:我对此的解释已经写在代码注释里了)。
上面这段叙述的意思就是上面的技术依赖于一个假设,这个假设就是:如果一个类型满足下面的这个接口,然后WriteString(s)方法就必须和Write([]byte(s))有相同的效果。
interface {
io.Writer
WriteString(s string) (n int, err error)
}
尽管io.WriteString实现了这个假设,但是调用它的函数极少可能会去实现类似的假设。定义一个特定类型的方法隐式地获得了对特定行为的约束(笔者注:通过在方法定义是接口实现,本质上实现约束的还是接口)。对于Go语言的新手,特别是那些来自有强类型语言使用背景的新手,可能会发现它缺乏显式的意图令人感到混乱,但是在实战的过程中这几乎不是一个问题。除了空接口interface{},接口类型很少意外巧合地被实现。
上面的writeString函数使用一个类型断言来获知一个抽象的接口类型的值是否满足一个更加具体的接口类型;并且如果满足,它会使用这个更具体接口所具有的行为。这个技术可以被很好的使用,不论这个被询问的接口是一个标准如io.ReadWriter,或者用户定义的如stringWriter接口。
这是fmt.Fprintf函数从其它所有的值中将满足error或者fmt.Stringer接口的值区分出来的原理。在fmt.Fprintf内部,有一个将单个操作对象转换成一个字符串的步骤,像下面这样:
package fmt
func formatOneValue(x interface{}) string {
if err, ok := x.(error); ok {
returnn err.Error()
}
if str, ok := x.(Stringer); ok {
return str.String()
}
// ...all other types...
}
如果x满足这两个接口类型中的一个,具体满足的接口对应的方法将决定对值的格式化方式(笔者注:即调用err.Error()方法还是str.String()方法)。如果都不满足,默认的case或多或少会统一地使用反射来处理所有的其它类型;我们可以在第12章知道反射具体是怎么实现的。
再次强调,它假设任何有String方法的类型都满足fmt.Stringer中约定的行为,这个行为会返回一个适合打印的字符串。
类型分支
类型分支是类似于switch语句但是是根据类型做判断的选择结构(选择结构程序的三大基本结构“顺序、选择、循环”之一,if-else,switch都是选择结构)。
接口被以两种不同的方式使用。
在第一个方式中,以io.Reader,io.Writer,fmt.Stringer,sort.Interface,http.Handler和error为典型,一个接口的方法表达了实现这个接口的具体类型间的相似性,但是隐藏了代码的细节和这些具体类型本身的操作。在这个方式中,重点在于方法上,而不是具体的类型上。
(笔者注:接口也是一种约束,实现接口的对象必须为接口所包含的所有方法提供一个具体实现)
第二个方式是利用一个接口值可以持有各种具体类型值的能力,将接口认为是这些类型的联合(Unions)。类型断言用来动态地区别这些类型,使得对每一种情况的处理方式都不一样。在这个方式中,重点在于具体的类型满足这个接口,而不在于接口的方法(如果它确实有一些的话),并且没有任何的信息隐藏。我们将以这种方式使用的接口描述为可辨识联合(discriminated unions)。
(笔者注:这两种方式的区别在于,第一种是将接口作为一中约束使用的,要求满足该接口的方法必须具备某些行为;第二种则是将接口看作是数据类型的汇总,某些数据类型可以在逻辑上划分到某接口中,对这些数据类型有类似的处理行为)
如果你熟悉面向对象编程,你可能会将这两种方式当作是subtype polymorphism(子类型多态)和 ad hoc polymorphism(非参数多态),但是你不需要去记住这些术语。对于本章剩下的部分,我们将会呈现一些第二种方式的例子。
和其它那些语言一样,Go语言查询一个SQL数据库的API会干净地将查询中固定的部分和变化的部分分开。一个调用的例子可能看起来像这样:
import "database/sql"
func listTracks(db sql.DB, artist string, minYear, maxYear int) {
result, err := db.Exec(
"SElECT * FROM tracks WHERE artist = ? AND ? <= year And year <= ?", artist, minYear, maxYear)
// ...
)
}
Exec方法使用SQL字面量替换在查询字符串中的每个’?’;SQL字面量表示相应参数的值,它有可能是一个布尔值,一个数字,一个字符串,或者nil空值。用这种方式构造查询可以帮助避免SQL注入攻击;这种攻击就是对手可以通过利用输入内容中不正确的引号来控制查询语句。在Exec函数内部,我们可能会找到像下面这样的一个函数,它会将每一个参数值转换成它的SQL字面量符号。
func sqlQuote(x interface{}) string {
if x == nil {
return "NULL"
} else if _, ok = x.(int); ok {
return fmt.Sprintf("%d", x)
} else if _, ok = x.(uint); ok {
return fmt.Sprintf("%d", x)
} else if b, ok := x.(bool); ok {
if b {
return "TRUE"
} else {
return "FALSE"
}
} else if s, ok := x.(string); ok {
return sqlQuoteString(s) // 该方法在当前代码块中没有展示
} else {
panic(fmt.Sprintf("unexpected type %T: %v", x, x))
}
}
(笔者注:上面的代码展示的例子说明了类型断言不光能用在接口类型上,一般类型上也可以做类型断言,机制一样)
switch语句可以简化if-else链,如果这个if-else链对一连串值做相等测试。一个相似的type switch(类型分支)可以简化类型断言的if-else链。
在最简单的形式中,一个类型分支像普通的switch语句一样,它的运算对象是x.(type)——它使用了关键词字面量type——并且每个case有一到多个类型。一个类型分支基于这个接口值的动态类型使一个多路分支有效(笔者注:注意是多路分支有效,作为对比普通的switch语句在找到一个有效分支的前提下就不会再判断其他分支了)。这个nil的case和if x == nil匹配,并且这个default的case和如果其它case都不匹配的情况匹配。一个对sqlQuote的类型分支可能会有这些case:
switch x.(type) {
case nil: // ...
case int, uint: // ...
case bool: // ...
case string: // ...
default: //
}
和(§1.8)中的普通switch语句一样,每一个case会被顺序的进行考虑,并且当一个匹配找到时,这个case中的内容会被执行。当一个或多个case类型是接口时,case的顺序就会变得很重要,因为可能会有两个case同时匹配的情况。default case相对其它case的位置是无所谓的。它不会允许落空(即任何case都不满足)发生。
注意到在原来的函数中,对于bool和string情况的逻辑需要通过类型断言访问提取的值。因为这个做法很典型,类型分支语句有一个扩展的形式,它可以将提取的值绑定到一个在每个case范围内都有效的新变量。
switch x := x.(type) { /* ... */}
这里我们已经将新的变量也命名为x;和类型断言一样,重用变量名是很常见的。和一个switch语句相似地,一个类型分支隐式的创建了一个词法块,因此新变量x的定义不会和外面块中的x变量冲突。每一个case也会隐式的创建一个单独的词法块。
使用类型分支的扩展形式来重写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"
} else {
return "FALSE"
}
}
case string:
return sqlQuoteString(x)
default:
panic(fmt.Sprintf("unexpected type %T: %v", x, x))
}
在这个版本的函数中,在每个单一类型的case内部,变量x和这个case的类型相同。例如,变量x在bool的case中是bool类型和string的case中是string类型。在所有其它的情况中,变量x是switch运算对象的类型(接口);在这个例子中运算对象是一个interface{}。当多个case需要相同的操作时,比如int和uint的情况,类型分支可以很容易的合并这些情况。
尽管sqlQuote接受一个任意类型的参数,但是这个函数只会在它的参数匹配类型分支中的一个case时运行到结束;其它情况的它会panic出“unexpected type”消息。虽然x的类型是interface{},但是我们把它认为是一个int,uint,bool,string,和nil值的discriminated union(可识别联合)。
示例: 基于标记的XML解码
第4.5章节展示了如何使用encoding/json包中的Marshal和Unmarshal函数来将JSON文档转换成Go语言的数据结构。encoding/xml包提供了一个相似的API。当我们想构造一个文档树的表示时使用encoding/xml包会很方便,但是对于很多程序并不是必须的。encoding/xml包也提供了一个更低层的基于标记的API用于XML解码。在基于标记的样式中,解析器消费输入并产生一个标记流;四个主要的标记类型-StartElement,EndElement,CharData,和Comment-每一个都是encoding/xml包中的具体类型。每一个对(*xml.Decoder).Token
的调用都返回一个标记。
这里显示的是和这个API相关的部分:
package xml
type Name struct {
Local string // e.g., "Title" or "id"
}
type Attr struct { // e.g., name = "value"
Name Name
Value string
}
// A Token includes StartElement, EndElement, CharData
// and Comment, plus a few esoteric types (not shown).
// 后半句英文里有个生僻单词 esoteric 意为adj. 深奥的,只有内行才懂的
// 即一部分比较难懂的源码没有展示
type Token interface{}
type StartElement struct { // e.g., <name>
Name Name
Attr []Attr
}
type EndElement struct { Name Name } // e.g., </name>
type CharData []byte // e.g., <p>CharData</p>
type Comment []byte // e.g., <!-- Comment-->
type Decoder struct{ /*...*/ }
func NewDecoder(io.Reader) *Decoder
func (*Decoder) Token() (Token, error) // returns next Token in sequence
这个没有方法的Token接口也是一个可识别联合(discriminated union)的例子()笔者注:因为没有关联方法,所以一定是上一节讲的第二种用法,从这个意义上讲,任何空接口都是构建一个可识别联合这种用法)。传统的接口如io.Reader的目的是隐藏满足它的具体类型的细节,这样就可以创造出新的实现:在这个实现中每个具体类型都被统一地对待。相反,满足可识别联合的具体类型的集合被设计为确定的和暴露的,而不是隐藏的。可识别联合的类型几乎没有方法,操作它们的函数使用一个类型分支的case集合来进行表述,这个case集合中每一个case都有不同的逻辑。
下面的xmlselect程序获取和打印在一个XML文档树中确定的元素下找到的文本。使用上面的API,它可以在输入上一次完成它的工作而从来不要实例化这个文档树。
// Xmlselect prints the text of selected elements of an XML document
package main
import (
"encoding/xml"
"fmt"
"io"
"os"
"strings"
)
func main() {
dec := xml.NewDecoder(os.Stdin)
var stack []string // stack of element names
for {
tok, err := dec.Token()
if err == io.EOF {
break
} else if err != nil {
fmt.Fprintf(os.Stderr, "xmlselect: %v\n", err)
os.Exit(1)
}
switch tok := tok.(type) {
case xml.StartElement:
stack = append(stack, tok.Name.Local)
case xml.EndElement:
stack = stack[:len(stack)-1] // 直接把切片当作栈使用
case xml.CharData:
if containsAll(stack, os.Args[1:]) {
fmt.Printf("%s: %s\n", strings.Join(stack, " "), tok)
}
}
}
}
// containsAll reports whether x contains the elements of y, in order
func containsAll(x, y []string) bool {
for len(y) <= len(x) {
if len(y) == 0 {
return true
}
if x[0] == y[0] {
y = y[1:]
}
x = x[1:]
}
return false
}
// 注意这个for循环的条件 这一巧妙的判断讲原本需要n^2复杂度的双层循环
// 简化成了复杂度为n的单层循环
main函数中的循环每遇到一个StartElement时,它把这个元素的名称压到一个栈里,并且每次遇到EndElement时,它将名称从这个栈中推出。这个API保证了StartElement和EndElement的序列可以被完全的匹配,甚至在一个糟糕的文档格式中。而注释则会被忽略。当xmlselect遇到一个CharData时,只有当栈中有序地包含所有通过命令行参数传入的元素名称时,它才会输出相应的文本。
下面的命令打印出任意出现在两层div元素下的h2元素的文本。它的输入是XML的说明文档,并且它自己就是XML文档格式的。
$ go build gopl.io/ch1/fetch
$ ./fetch http://www.w3.org/TR/2006/REC-xml11-20060816 |
./xmlselect div div h2
html body div div h2: 1 Introduction
html body div div h2: 2 Documents
html body div div h2: 3 Logical Structures
html body div div h2: 4 Physical Structures
html body div div h2: 5 Conformance
html body div div h2: 6 Notation
html body div div h2: A References
html body div div h2: B Definitions for Character Normalization
...
Tips
当设计一个新的包时,新手Go程序员总是先创建一套接口,然后再定义一些满足它们的具体类型。这种方式的结果就是有很多的接口,它们中的每一个仅只有一个实现。请不要再这么做了。这种接口是不必要的抽象;每个接口也会产生一个运行时损耗。你可以使用导出机制(§6.6)来限制一个类型的方法或一个结构体的字段是否在包外可见。接口只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要。
当使用接口却只被一个单一的具体类型实现时有一个例外情况,就是由于它的依赖关系,这个具体类型不能和这个接口存在在一个相同的包中。这种情况下,一个接口是解耦这两个包的一个好方式。
因为在Go语言中只有当两个或更多的类型实现一个接口时才使用接口,它们必定会从任意特定的实现细节中抽象出来,结果就是一定有更少和更简单方法的更小的接口(经常和io.Writer或 fmt.Stringer一样只有一个)。当新的类型出现时,小的接口更容易满足。对于接口设计的一个好的标准就是 ask only for what you need(只要求你需要的东西)。
至此,我们完成了对方法和接口的学习过程。
Go语言对面向对象风格的编程支持良好,但这并不意味着你只能使用这一风格。不是任何事物都需要被当做一个对象;独立的函数有它们自己的用处,未封装的数据类型也是如此。观察一下,在本书前五章的例子中像input.Scan这样的方法被调用不超过二十次,与之相反的是普遍调用的函数如fmt.Printf。