第6章 面向对象编程
04-13Ctrl+D 收藏本站
本章的目的是讲解在Go语言中如何进行面向对象编程。来自于其他过程式编程背景的程序员可能会发现,本章的所有内容都建立在他们所学以及本书前面章节的基础之上。但是来自于其他基于继承到面向对象编程背景(如C++、Java和Python)的程序员可能需要将许多曾经常用的概念和习惯放在一边,特别是继承相关的,因为Go语言的面向对象编程方式与它们的完全不同。
Go语言的标准库大部分情况下提供的都是函数包,但也适当地提供了包含方法的自定义类型。在前面的章节中,我们创建了一些自定义类型(如regexp.Regexp和os.File)的值,并也调用了它们的方法。此外,我们甚至创建了一些简单的自定义类型,以及相应的方法。例如,支持打印和排序。因此,我们已经熟悉了Go语言类型的基本使用以及类型方法的调用。
本章第一节用非常简短的篇幅描述了一些 Go语言面向对象编程中的关键概念。第二节包含了创建无方法的自定义类型的内容。接下来我们往自定义类型中添加了方法,创建了构造函数,以及验证字段数据,总之,讲解了创建一个独立的自定义类型所需的所有基础内容。第三节讲解了接口,这是 Go语言实现类型安全的鸭子类型的基础。第四节讲解了结构体,介绍了许多前面章节中未曾涉及的细节。
本章的最后一节给出了3个关于自定义类型的完整示例,它们覆盖了本章前面各节中的大部分内容以及本书中前面章节中的相当一部分内容。其中,第一个例子是一个简单的只包含单值数据类型的自定义类型,第二个例子是一小部数据类型的集合,第三个例子是一个通用集合类型。
6.1 几个关键概念
Go语言的面向对象之所以与C++、Java以及(较小程度上的)Python这些语言如此不同,是因为它不支持继承。面向对象编程刚流行的时候,继承是它首先被捧吹的最大优点之一。但是历经几十载的实践之后,事实证明该特性也有些明显的缺点,特别是当用于维护大系统时。与其他大部分同时使用聚合和继承的面向对象语言不同的是,Go语言只支持聚合(也叫做组合)和嵌入。为了弄明白聚合与嵌入的区别,让我们看一小段代码。
type ColoredPoint struct{
color.Color // 匿名字段(嵌入)
x, y int// 具名字段(聚合)
}
这里,color.Color是来自image/color包的类型,x和y则是整型。在Go语言的术语中,color.Color、x和y,都是ColoredPoint结构体的字段。color.Color字段是匿名的(因为它没有变量名),因此是嵌入字段。x和y字段是具名的聚合字段。如果我们创建一个 ColoredPoint 值(例如,point := ColoredPoint{}),其字段可以通过point.Color、point.x和point.y 来访问。需注意的是,当访问来自于其他包中的类型的字段时,我们只用到了其名字的最后一部分,即Color而非color.Color(我们会在6.2.1.1节、6.3节及6.4节详细讨论这些内容)。
术语“类”(class)、“对象”(object)以及“实例”(instance)在传统的多层次继承式面向对象编程中已经定义的非常清晰,但在Go语言中我们完全避开使用它们。相反,我们使用“类型”和“值”,其中自定义类型的值可以包含方法。
由于没有继承,因此也就没有虚函数。Go语言对此的支持则是采用类型安全的鸭子类型(duck type)。在 Go语言中,参数可以被声明为一个具体类型(例如,int、string、或者*os.File以及MyType),也可以是接口(interface),即提供了具有满足该接口的方法的值。对于一个声明为接口的参数,我们可以传入任意值,只要该值包含该接口所声明的方法。例如,如果我们有一个值提供了一个Write(byte)(int, error)方法,我们就可以将该值当做一个io.Writer(即作为一个满足io.Writer接口的值)提供给任何一个需要io.Writer参数的函数,无论该值的实际类型是什么。这点非常灵活而强大,特别是当它与 Go语言所支持的访问嵌入字段的方法相结合时。
继承的一个优点是,有些方法只需在基类中实现一次,即可在子类中方便地使用。Go语言为此提供了两种解决方案。其中一种解决方案是使用嵌入。如果我们嵌入了一个类型,方法只需在所嵌入的类型中实现一次,即可在所有包含该嵌入类型的类型中使用[1]。另一种解决方案是,为每一种类型提供独立的方法,但是只是简单地将包装(通常都只有一行)了功能性作用的代码放进一个函数中,然后让所有类的方法都调用这个函数。
Go语言面向对象编程中的另一个与众不同点是它的接口、值和方法都相互保持独立。接口用于声明方法签名,结构体用于声明聚合或者嵌入的值,而方法用于声明在自定义类型(通常为结构体)上的操作。在一个自定义类型的方法和任何特殊接口之间没有显式的联系。但是如果该类型的方法满足一个或者多个接口,那么该类型的值可以用于任何接受该接口的值的地方。当然,每一个类型都满足空接口(interface{}),因此任何值都可以用于声明了空接口的地方。
一种按Go语言的方式思考的方法是,把is-a关系看成由接口来定义,也就是方法的签名。因此,一个满足 io.Reader 接口(即有一个签名为 Read(byte)(int, error)的方法)的值就叫做 Reader,这并不是因为它是什么(一个文件、一个缓冲区或者一些其他自定义类型),而是因为它提供了什么方法,在这里是Read方法。如图6-1中的解释。而has-a关系可以使用聚合或者嵌入特定类型值的结构体来表达,这些类型构成自定义类型。
图6-1 用于读写字节切片的接口和类型
虽然没法为内置类型添加方法,但可以很容易地基于内置类型创建自定义的类型,然后为其添加任何我们想要的方法。该类型的值可以调用我们提供的方法,同时也可以与它们底层类型提供的任何函数、方法以及操作符一起使用。例如,假设我们有个类型声明为type Integer int,我们可以不拘形式地使用整型的+操作符将这两种类型的值相加。并且,一旦我们有了一个自定义类型,我们也可以添加自定义的方法。例如,func (i Integer) Double Integer{ return i * 2 },稍后将会看到(参见6.2.1节)。
基于内置类型的自定义类型不但容易创建,运行时效率也非常高。将基于内置类型的自定义类型与该内置类型相互转换无需耗费运行时代价,因为这种转换能够在编译时高效完成。鉴于此,要使用自定义类型的方法时将内置类型“升级”成自定义类型,或者要将一个类型传入给一个只接收内置类型参数的函数时将自定义类型“降级”成内置类型,都是非常实用的做法。我们在前文中曾看过一个“升级”的例子,在那里我们将一个string类型转换成一个FoldedStrings类型(参见4.2.4节)的值,在本章末尾我们讲解到Count类型的时候我们会举一个“降级”的例子。
6.2 自定义类型
自定义类型使用Go语言的如下语法创建:
type typeName typeSpecification
typeName可以是一个包或者函数内唯一的任何合法的Go标识符。typeSpecification可以是任何内置的类型(如string、int、切片、映射或者通道)、一个接口(参见6.3节)、一个结构体(参见前面章节,本书后面将介绍更多相关内容,参见6.4节)或者一个函数签名。
在有些情况下创建一个自定义类型就足够了,但有些情况下我们需要给自定义类型添加一些方法来让它更实用。下面是一些没有方法的自定义类型例子。
type Count int
type StringMap map[string]string
type FloatChan chan float64
这些自定义类型就其自身而言,虽然使用这样的类型可以提升程序的可读性,同时也可以在后面改变其底层类型,但是没一个看起来有用,因此只把它们当做基本的抽象机制。
var i Count = 7
i++
fmt.Println(i)
sm := make(StringMap)
sm["key1"] = "value1"
sm["key2"] = "value2"
fmt.Println(sm)
fc := make(FloatChan, 1)
fc <- 2.29558714939
fmt.Println(<-fc)
8
map[key2:value2 key1:value1]
2.29558714939
像Count、StringMap和FloatChan这样的类型,它们是直接基于内置类型创建的,因此可以拿来当做内置类型一样使用。例如,我们可以使用内置的append函数来操作type StringSlice string类型。但是如果要将其传递给一个接受其底层类型的函数,就必须先将其转换成底层类型(无需成本,因为这是在编译时完成的)。有时,我们可能需要进行相反的操作,将一个内置类型的值升级成一个自定义类型的值,以使用其自定义类型的方法。我们已经见过一个这样的例子,在 SortFoldedStrings函数中将一个string 转换成一个FoldedStrings值(参见4.2.4节)。
type RuneForRuneFunc func(rune) rune
当使用高阶函数(参见 5.6.7 节)时,通过自定义类型来声明我们要传入的函数的签名更为方便。这里我们声明了一个接收和返回rune值的函数签名。
var removePunctuation RuneForRuneFunc
上面创建的removePunctuation变量引用一个RuneForRuneFunc类型的函数(即其签名为func(rune) rune)。与所有Go变量一样,它也被自动初始化为零值,因此在这里它被初始化成nil值。
phrases := string{"Day; dusk, and night.", "All day long"}
removePunctuation = func(char rune) rune {
if unicode.Is(unicode.Terminal_punctuation, char){
return -1
}
return char
}
processPhrases(phrases, removePunctuation)
这里我们创建了一个匹配 RuneForRuneFunc 签名的匿名函数,并将其传给自定义的processPhrases函数。
func processPhrases(phrases string, function RuneForRuneFunc) {
for _, phrase := range phrases {
fmt.Println(strings.Map(function, phrase))
}
}
Day dust and night
All day long
对读者来说,将RuneForRuneFunc当成一个类型而非底层的func(rune) rune更为有意义,同时它也提供了一些抽象。(strings.Map函数已在第3章中讲解过。)
基于内置类型或者函数签名创建自定义的类型非常有用,但对我们来说还远远不够。我们需要的是自定义的方法,即下一节的内容。
6.2.1 添加方法
方法是作用在自定义类型的值上的一类特殊函数,通常自定义类型的值会被传递给该函数。该值可以以指针或者值的形式传递,这取决于方法如何定义。定义方法的语法几乎等同于定义函数,除了需要在 func 关键字和方法名之间必须写上接收者(写入括号中)之外,该接收者既可以以该方法所属于的类型的形式出现,也可以以一个变量名及类型的形式出现。当调用方法的时候,其接收者变量被自动设为该方法调用所对应的值或者指针。
我们可以为任何自定义类型添加一个或者多个方法。一个方法的接收者总是一个该类型的值,或者只是该类型值的指针。然而,对于任何一个给定的类型,每个方法名必须唯一。唯一名字要求的结果是,我们不能同时定义两个相同名字的方法,让其中一个的接收者为指针类型而另一个为值类型。另一个结果是,不支持重载方法,也就是说,不能定义名字相同但是不同签名的方法。一种提供等价方法的方式是使用可变参数(也就是说,接受可变数目参数,参见本书的第5.6节)。不过,Go语言推荐的方式是使用名字唯一的函数。例如,strings.Reader类型提供 3 个不同的方法:strings.Reader.Read、strings.Reader.ReadByte和strings.Reader.ReadRune。
type Count int
func (count *Count) Increment { *count++ }
func (count *Count) Decrement { *count-- }
func (count Count) IsZero bool { return count == 0 }
这个简单的基于整型的自定义类型支持3个方法,其中前两个声明为接受一个指针类型的接收者(receiver,也就是方法施加的目标对象),因为这两个函数都修改了它们的值[2]。
var count Count
i := int(count)
count.Increment
j := int(count)
count.Decrement
k := int(count)
fmt.Println(count, i, j, k, count.IsZero)
0 0 1 0 true
上面的代码片段展示了 Count 类型的实际使用。它看起来没什么,但我们会将其用于本章的第4节。
让我们再稍微多看一个更详细的自定义类型,这回是基于一个结构体定义的(我们会在6.3节中回来再看这个例子)。
type Part struct {
Id int // 具名字段(聚合)
Name string // 具名字段(聚合)
}
func (part *Part) LowerCase {
part.Name = strings.ToLower(part.Name)
}
func (part *Part) UpperCase {
part.Name = strings.ToUpper(part.Name)
}
func (part Part) String string {
return fmt.Sprintf("<<%d %q>>", part.Id, part.Name)
}
func (part Part) HasPrefix(prefix string) bool {
return strings.HasPrefix(part.Name, prefix)
}
为了演示它是如何工作的,我们创建了接收者为值类型的String和HasPrefix方法。当然,传值的话无法修改原始数据,而传递指针的话可以。
part := Part{5, "wrench"}
part.UpperCase
part.Id += 11
fmt.Println(part, part.HasPrefix("w"))
«16 "WRENCH"»false
当创建的自定义类型是基于结构体时,我们可以使用其名字及一对大括号包围的初始值来创建该类型的值。(我们在下一节将看到,Go语言提供了一种语法,让我们只提供想要的值,而让Go自己去初始化剩余的值。)
一旦创建了part值,我们可以在其上调用方法(如Part.UpperCase),访问它导出的(公开的)字段(如Part.Id),以及安全地打印它,因为如果自定义的类型中定义了String方法,Go语言的打印函数足够智能会自动调用该方法进行打印。
类型的方法集是指可以被该类型的值调用的所有方法的集合。
一个指向自定义类型的值的指针,它的方法集由为该类型定义的所有方法组成,无论这些方法接受的是一个值还是一个指针。如果在指针上调用一个接受值的方法,Go语言会聪明地将该指针解引用,并将指针所指的底层值作为方法的接收者。
一个自定义类型值的方法集则由为该类型定义的接收者类型为值类型的方法组成,但是不包括那些接收者类型为指针的方法。但这种限制通常并不像这里所说的那样,因为如果我们只有一个值,仍然可以调用一个接收者为指针类型的方法,这可以借助于 Go语言传值的地址的能力实现,前提是该值是可寻址的(即它是一个变量、一个解引用指针、一个数组或切片项,或者结构体中的一个可寻址字段)。因此,假设我们这样调用value.Method,其中Method需要一个指针接收者,而 value 是一个可寻址的值,Go语言会把这个调用等同于(&value).Mehtod。
*Count类型的方法集包含3个方法:Increment、Decrement和IsZero。然而Count类型的方法集则只有一个方法:IsZero。所有这些方法都可以在*Count上调用。同时,正如我们在前面的代码片段中所看到的,只要Count值是可寻址的,这些函数也可以在Count值上调用。*Part类型的方法集包含4个方法:LowerCase、UpperCase、String和HasPrefix,而 Part 类型的方法集则只包含 String和HasPrefix方法。然而,LowerCase和UpperCase函数也可以作用于可寻址的Part值,正如我们在上面代码片段中所看到的。
将方法的接收者定义为值类型对于小数据类型来说是可行的,如数值类型。这些方法不能修改它们所调用的值,因为只能得到接收者的一份副本。如果我们的数据类型的值很大,或者需要修改该值,则需要让方法接受一个指针类型的接收者。这样可以使得方法调用的开销尽可能的小(因为接收者是以32位或者64位的形式传递,无论调用该方法的值多大)。
6.2.1.1 重写方法
本章末尾我们将看到,可以创建包含一个或者多个类型作为嵌入字段的自定义结构体(参见6.4节)。这种方法非常方便的一点是,任何嵌入类型中的方法都可以当做该自定义结构体自身的方法被调用,并且可以将其内置类型作为其接收者。
type Item struct {
id string // 具名字段(聚合)
price float64 // 具名字段(聚合)
quantity int // 具名字段(聚合)
}
func (item *Item) Cost float64 {
return item.price * float64(item.quantity)
}
type SpecialItem struct {
Item // 匿名字段(嵌入)
catalogId int// 具名字段(聚合)
}
这里,SpecialItem嵌入了一个Item类型。这意味着我们可以在一个SpecialItem上调用Item的Cost方法。
special := SpecialItem{Item{"Green", 3, 5}, 207}
fmt.Println(special.id, special.price, special.quantity, special.catalogId)
fmt.Println(special.Cost)
Green 3 5 207
15
当调用 special.Cost的时候,SpecialItem 类型没有它自身的Cost方法,Go语言使用 Item.Cost方法。同时,传入其嵌入的Item 值,而非整个调用该方法的SpecialItem值。
稍后我们将看到,如果嵌入的Item中有任何字段与SpecialItem的字段同名,那么我们仍然可以通过使用类型作为该名字的一部分来调用Item的字段。例如,special.Item.price。
同时也可以在自定义的结构体中创建与所嵌入的字段中的方法同名的方法,来覆盖被嵌入字段中的方法。例如,假设我们有一个新的item类型:
type LuxuryItem struct {
Item // 匿名字段(嵌入)
markup float64 // 具名字段(聚合)
}
如上所述,如果我们在LuxuryItem上调用Cost方法,就会使用嵌入的Item.Cost方法,就像SpecialItems中一样。下面提供了3种不同的覆盖嵌入方法的实现(当然,只使用了其中的一种!)。
/*
func (item *LuxuryItem) Cost float64 { // 没必要这么冗长!
return item.Item.price * float64(item.Item.quantity) * item.markup
}
func (item *LuxuryItem) Cost float64 { // 没必要的重复!
return item.price * float64(item.quantity) * item.markup
}
*/
func (item *LuxyryItem) Cost float64{ // 完美
return item.Item.Cost * item.markup
}
最后一个实现充分利用了嵌入的Cost方法。当然,如果我们不希望这样做,也没必要使用嵌入类型的方法来重写方法(嵌入字段将在稍后讲解结构体时讲到,参见6.4节)。
6.2.1.2 方法表达式
就像我们可以对函数进行赋值和传递一样,我们也可以对方法表达式进行赋值和传递。方法表达式是一个必须将方法类型作为第一个参数的函数。(在其他语言中常常使用术语“未绑定方法”(unbound method)来表示类似的概念。)
asStringV := Part.String // 有效签名:func(Part) string
sv := asStringV(part)
hasPrefix := Part.HasPrefix // 有效签名:func(Part, string) bool
asStringP := (*Part).String // 有效签名:func(*Part) string
sp := asStringP(&part)
lower := (*Part).LowerCase // 有效签名:func(*Part)
lower(&part)
fmt.Println(sv, sp, hasPrefix(part, "w"), part)
«16 "WRENCH"» «16 "WRENCH"» true «16 "wrench"»
这里我们创建了 4 个方法表达式:asStringV接受一个 Part 值作为其唯一的参数, hasPrefix接受一个 Part 值作为其第一个参数以及一个字符串作为其第二个参数, asStringP和lower都接受一个*Part值作为其唯一参数。
方法表达式是一种高级特性,在关键时刻非常有用。
目前为止我们所创建的自定义类型都有一个潜在的致命错误。没有一个自定义类型可以保证它们初始化的数据是有效的(或者说强制有效),也没有任何方法可以保证这些类型的数据(或者说结构体类型中的字段)不会被赋值为非法数据。例如,Part.Id和Part.Name字段可以设置为任何我们想设置的值。但如果我们想为其设置限制呢?例如,只允许ID为正整数,而且只允许名字为某固定格式?我们将在下一节讨论该问题,届时我们会创建一个小而全的其字段经验证的自定义类型。
6.2.2 验证类型
对于许多简单的自定义类型来说,没必要进行验证。例如,我们可能这样定义一个类型type Point {x, y int},其中任何x和y值都是合法的。此外,由于Go语言保证初始化所有变量(包括结构体的字段)为它们的零值,因此显式的构造函数就是多余的。
对于其零值构造函数不能满足条件的情况下,我们可以创建一个构造函数。Go语言不支持构造函数,因此我们必须显式地调用构造函数。为了支持这些,我们必须假设该类型有一个非法的零值,同时提供一个或者多个构造函数用于创建合法的值。
当碰到其字段必须被验证时,我们也可以使用类似的方法。我们可以将这些字段设为非导出的,同时使用导出的访问函数来做一些必要的验证。[3]
让我们来看一个短小但完整的自定义类型来解释这些要点。
type Place struct {
latitude, longitude float64
Name string
}
func New(latitude, longitude float64, name string) *Place {
return &Place{ saneAngle(0, latitude), saneAngle(0, longitude), name }
}
func (place *Place) Latitude float64 { return place.latitude }
func (place *Place) SetLatitude(latitude float64) {
place.latitude = saneAngle(place.latitude, latitude)
}
func (place *Place) Longitude float64{ return place.longitude }
func (place *Place) SetLongitude(longitude float64) {
place.longitude = saneAngle(place.longitude, longitude)
}
func (place *Place) String string {
return fmt.Sprintf("(%.3f°, %.3f°) %q", place.latitude, place.longitude, place.Name)
}
func (original *Place) Copy *Place {
return &Place{ original.latitude, original.longitude, original.Name }
}
类型Place是导出的(从place包中),但是它的latitude和longitude字段是非导出的,因为它们需要验证。我们创建了一个构造函数New来保证总是能够创建一个合法的*place.Place。Go语言的惯例是调用New构造函数,如果定义了多个构造函数,则调用以“New”开头的那些。(由于有点跑题,我们还没给出saneAngle函数。它接受一个旧的角度值和一个新的角度值,如果新值在其范围内则返回新值。否则返回旧值。)同时通过提供未导出字段的getter和setter函数,我们可以保证只为其设置合法的值。
String方法的定义意味着*Place值满足fmt.Stringer接口,因此*Place会按照我们想要的方式而非Go语言的默认格式进行打印。同时我们也提供了一个Copy方法,但并未为它提供任何验证机制,因为我们知道被复制的原始值是合法的。
newYork := place.New(40.716667, -74, "New York") // newYork是一个*Place
fmt.Println(newYork)
baltimore := newYork.Copy // baltimore是一个*Place
baltimore.SetLatitude(newYork.Latitude - 1.43333)
baltimore.SetLongitude(newYork.Longitude - 2.61667)
baltimore.Name = "Baltimore"
fmt.Println(baltimore)
(40.717°, -74.000°) "New York"
(39.283°, -76.617°) "Baltimore"
我们将Place类型放在place包中,并调用place.New函数来创建一个*Place的值。一旦创建了一个*Place,我们就可以像调用任何标准库中自定义类型的方法一样调用该*Place值的方法。
6.3 接口
在 Go语言中,接口是一个自定义类型,它声明了一个或者多个方法签名。接口是完全抽象的,因此不能将其实例化。然而,可以创建一个其类型为接口的变量,它可以被赋值为任何满足该接口类型的实际类型的值。
interface{}类型是声明了空方法集的接口类型。无论包含不包含方法,任何一个值都满足 interface{}类型。毕竟,如果一个值有方法,那么其方法集包含空的方法集以及它实际包含的方法。这也是 interface{}类型可以用于任意值的原因。我们不能直接在一个以interface{}类型值传入的参数上调用方法(虽然该值可能有一些方法),因为该值满足的接口没有方法。因此,通常而言,最好以实际类型的形式传入值,或者传入一个包含我们想要的方法的接口。当然,如果我们不为有方法的值使用接口类型,我们就可以使用类型断言(参见5.1.2节)、类型开关(参见5.2.2.2节)或者甚至是反射(参见9.4.9节)等方式来访问方法。
这里有个非常简单的接口。
type Exchanger interface {
Exchange
}
Exchanger接口声明了一个方法Exchange,它不接受输入值也不返回输出。根据Go语言的惯例,定义接口时接口名字需以er结尾。定义只包含一个方法的接口是非常普遍的。例如,标准库中的io.Reader和io.Writer接口,每一个都只声明了一个方法。需注意的是,接口实际上声明的是一个API(Application Programming Interface,程序编程接口),即0个或者多个方法,虽然并不明确规定这些方法所需的功能。
一个非空接口自身并没什么用处。为了让它发挥作用,我们必须创建一些自定义的类型,其中定义了一些接口所需的方法[4]。这里有两个自定义类型。
type StringPair struct { first, second string }
func (pair *StringPair) Exchange {
pair.first, pair.second = pair.second, pair.first
}
type Point [2]int
func (point *Point) Exchange { point[0], point[1] = point[1], point[0] }
自定义的类型StringPair和Point完全不同,但是由于它们都提供了Exchange方法,因此两个都能够满足Exchanger接口。这意味着我们可以创建StringPair和Point值,并将它们传给接受Exchanger的函数。
需注意的是,虽然StringPair和Point类型都能够满足Exchanger接口,但是我们并没有这样显式地声明,我们也没有写任何implements或者inherits语句。StringPair和Point类型提供了该接口所声明的方法(在这里只有一个方法),这一事实足够让Go语言知道它们满足该接口。
方法的接收者声明为指向其类型的指针,以便我们可以修改调用该方法的(指针所指向的)值。
虽然 Go语言足够聪明会以合理的方式打印自定义类型,我们更希望通过它们的字符串表示来控制打印。这可以很容易地通过为其添加一个满足 fmt.Stringer 接口的方法来实现,即一个满足签名Stringstring的方法。
func (pair StringPair) String string {
return fmt.Sprintf("%q+%q", pair.first, pair.second)
}
该方法返回一个字符串,该字符串由两个用双引号包围的字符串组合而成,中间用“+”号连接。该方法定义好后,Go语言的fmt包的打印函数就会使用它来打印StringPair值。当然也包括*StringPair的值,因为Go语言会自动将其解引用,以得到其所指向的值。
下面有个代码片段,展示了一些Exchanger值的创建、它们对Exchange方法的调用,以及对接受Exchanger值的自定义方法exchangeThese函数的调用。
jekyll := StringPair{"Henry", "Jekyll"}
hyde := StringPair{"Edward", "Hyde"}
point := Point{5, -3}
fmt.Println("Before: ", jekyll, hyde, point)
jekyll.Exchange // 当做: (&jekyll).Exchange
hyde.Exchange // 当做: (&hyde).Exchange
point.Exchange // 当做: (&point).Exchange
fmt.Println("After #1:", jekyll, hyde, point)
exchangeThese(&jekyll, &hyde, &point)
fmt.Println("After #2:", jekyll, hyde, point)
Before: "Henry"+"Jekyll" "Edward"+"Hyde" [5 -3]
After #1: "Jekyll"+"Henry" "Hyde"+"Edward" [-3 5]
After #2: "Henry"+"Jekyll" "Edward"+"Hyde" [5 -3]
上面所创建的变量都是值,然而Exchange方法需要的是一个指针类型接收者。我们之前也注意到,这并不是什么问题,因为当我们调用一个需要指针参数的方法而实际传入的只是可寻址的值时,Go语言会智能地将该值的地址传给方法。因此,在上面的代码片段中,jekyll.Exchange会自动被当做(&jekyll).Exchange用,其他的方法调用情况也类似。
在调用exchangeThese函数的时候,我们必须显式地传入值的地址。假如我们传入的是StringPair类型的值hyde,Go编译器会发现StringPair不能满足Exchanger接口,因为在StringPair接收者上并未定义方法,从而停止编译并报告错误。然而,如果我们传入一个*StringPair(如&hyde),编译就能成功。之所以这样,是因为有一个接受*StringPair接收者的方法Exchange,也意味着*StringPair满足Exchanger接口。
这里是exchangeThese函数。
func exchangeThese(exchangers…Exchanger) {
for _, exchanger := range exchangers {
exchanger.Exchange
}
}
这个函数并不关心我们传入的是什么类型(实际上我们传入的是两个*StringPair 值和一个*Point 值),只要它满足 Exchanger 接口即可(编译器检查),所以这里用的鸭子类型是类型安全的。
正如我们在定义StringPair.String方法以满足fmt.Stringer接口时所看到的一样,除了满足我们自定义的接口之外,我们也可以满足标准库中或者任何其他我们需要的接口。另一个例子io.Reader接口,它声明了Read(byte)(int, error)方法签名,当被调用时,它会将调用它的值的数据写入给定的byte 切片中。这种写是破坏式的,也就是说,写入的每一字节都从其调用处被删除。
func (pair *StringPair) Read(data byte) (n int, err error) {
if pair.first == "" && pair.second == "" {
return 0, io.EOF
}
if pair.first != "" {
n = copy(data, pair.first)
pair.first = pair.first[n:]
}
if n < len(data) && pair.second != "" {
m := copy(data[n:], pair.second)
pair.second = pair.second[m:]
n += m
}
return n, nil
}
只要实现了这个Read方法,StringPair类型就满足了io.Reader接口的定义。因此,现在StringPair(或者准确地说是*StringPair,因为有些方法需要指针类型的接收者)既是Exchanger和fmt.Stringer,也是io.Reader。不用说,*StringPair肯定实现了这些接口所定义的所有方法了。当然,我们也可以添加更多的方法以满足更多我们想要的接口。
该方法使用了内置的copy函数(参见4.2.3节)。该函数可以用于将数据从一个切片复制到另一个切片。但是这里我们以另外一种形式使用它,将字符串拷进byte。函数copy复制的数据不会超出目标byte的容量,同时返回其复制的字节数。自定义的StringPair.Read方法从其第一个字符串写数据(同时将已写的数据删除),然后对第二个字符串做同样的操作。如果两个字符串都是空的,则方法返回一个字节数0以及io.EOF。值得一提的是,如果第二条if语句的声明无条件地执行了,而第三个if语句的第二个条件删除了,该方法仍能够完美地运行,只是损失了一些(也许是微不足道的)效率。
这里有必要使用一个指针接收者,因为 Read方法会修改调用它的值。通常而言,除小数据外,我们更倾向于使用指针接收者,因为传指针比传值更为高效。
定义了Read方法之后,我们就可以使用它了。
const size = 16
robert := &StringPair{"Robert L.", "Stevenson"}
david := StringPair{"David", "Balfour"}
for _, reader := range io.Reader{robert, &david} {
raw, err := ToBytes(reader, size)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%q\n", raw)
}
"Robert L.Stevens"
"DavidBalfour"
该代码片段创建了两个io.Reader。由于我们实现StringPair.Read方法的时候接收者是一个指针类型,因此只有*StringPair 类型才能满足 io.Reader接口,而StringPair值不能满足。对于第一个StringPair,我们创建了它的值,并将robert变量赋值为指向它的指针,对于第二个StringPair,我们将david变量赋值为一个StringPair值,因此在io.Reader切片中使用了它的地址。
一旦变量设置好后,我们就可以迭代它们,对于每一个变量,我们使用自定义的ToBytes函数将其数据复制到byte中,然后将其原始字节以双引号括起来的字符串的形式打印出来。
该 ToBytes函数接受一个 io.Reader(即任何包含签名为 Read(byte)(int,error)的方法的值,例如*os.File 值)和一个大小限制,同时返回一个包含所读数据的byte切片和一个error值。
func ToBytes(reader io.Reader, size int) (byte, error) {
data := make(byte, size)
n, err := reader.Read(data)
if err != nil {
return data, err
}
return data[:n], nil // 清除无用的字节
}
就像我们之前所看到的exchangeThese函数一样,该函数不知道也不关心所传入值的具体类型,只要它是某种类型的io.Reader。
如果数据读成功,该数据切片会被重新切片以将其长度减至实际所读数据的字节数。如果我们不这样做,并且其预设的大小值太大,那么最终得到的数据也会包含所读数据之外的字节(每个字节的值为 0x00)。例如,如果不重新切片,david 变量的值可能是这样的"DavidBalfour\ x00\x00\x00\x00"。
需注意的是,接口和满足该接口的任何类型之间没有显式的连接。我们无需声明一个自定义的类型inherits、extends或者implements一个接口,只需给某个类型定义所需的方法就足够了。这使得Go语言非常灵活。我们可以很容易地随时添加新接口、类型以及方法,而无需破坏继承树。
接口嵌入
Go语言的接口(也包括我们将在下一节看到的结构体)对嵌入的支持非常好。接口可以嵌入其他接口,其效果与在接口中直接添加被嵌入接口的方法一样。让我们以一个简单的例子来解释。
type LowerCaser interface {
LowerCase
}
type UpperCaser interface {
UpperCase
}
type LowerUpperCaser interface {
LowerCaser // 就像在这里写了LowerCase函数一样
UpperCaser // 就像在这里写了UpperCase函数一样
}
LowerCaser 接口声明了一个方法 LowerCase,它不接受参数,也没有返回值。UpperCaser 接口也类似。而 LowerUpperCaser 接口则将这两个接口嵌套进来。这也意味着对于一个具体的类型,如果要满足LowerUpperCaser接口,就必须定义LowerCase和UpperCase方法。
这个小例子的嵌入可能看起来没多大优势。然而,如果我们要为前两个接口添加额外的方法(例如,LowerCaseSpecial方法和UpperCaseSpecial方法),那么LowerUpperCaser接口也会自动地将其包含进来,而无需修改自己的代码。
type FixCaser interface {
FixCase
}
type ChangeCaser interface {
LowerUpperCaser // 就像在这里写了LowerCase函数和UpperCase函数一样
FixCaser //就像在这里写了FixCase函数一样
}
这里我们再添加两个接口,因此现在得到了一个分等级的嵌套接口,如图6-2所示。
图6-2 Caser接口、类型和示例值
当然,这些接口本身并没多大用处。为了让它们发挥作用,我们需要定义具体的类型来实现它们。
func (part *Part) FixCase {
part.Name = fixCase(part.Name)
}
我们在前面已给出了自定义类型 Part(参见 6.2.1 节)。这里,为其添加了一个额外的方法FixCase,它工作于Part的Name字段,就像前文的LowerCase和UpperCase方法一样。所有这些大小写转换方法都接受一个指针类型的接收者,因为它们需要修改调用它的值。LowerCase方法和UpperCase方法通过标准库来实现,而FixCase方法则依赖于自定义的fixCase函数。这种简短方法依赖于函数来实现具体功能的模式在Go语言中非常普遍。
Part.String方法满足标准库中的fmt.Stringer接口,这意味着任何Part(或者*Part)类型的值都可以使用该方法返回的字符串进行打印。
func fixCase(s string) string {
var chars rune
upper := true
for _, char := range s {
if upper {
char = unicode.ToUpper(char)
} else {
char = unicode.ToLower(char)
}
chars = append(chars, char)
upper = unicode.IsSpace(char) || unicode.Is(unicode.Hyphen, char)
}
return string(chars)
}
这个简单的函数返回给定字符串的一份副本,其中除了字符串的首字母及空格或者连字符后面的第一个字母大写之外,其他所有字母都是小写的。例如,给定字符串“lobelia sackville-baggins”,该函数会将其转换成“Lobelia Sackville-Baggins”。
自然,我们可以让所有自定义类型都满足这些大小写转换接口。
func (pair *StringPair) UpperCase {
pair.first = strings.ToUpper(pair.first)
pair.second = strings.ToUpper(pair.second)
}
func (pair *StringPair) FixCase {
pair.first = fixCase(pair.first)
pair.second = fixCase(pair.second)
}
这里我们为之前所创建的StringPair类型添加了两个方法,使它满足LowerCaser、UpperCaser和FixCaser接口。我们没有列出StringPair.LowerCase方法,因为它与StringPair.UpperCase方法的代码结构完全相同。
*Part和*StringPair两种类型都能够满足caser接口,包括ChangeCaser接口,因为这些类型满足其所有嵌入的接口。它们也同时满足标准库中的fmt.Stringer 接口。而*StringPair类型满足我们的Exchanger接口以及标准库中的io.Reader接口。
我们并不是强制要求满足每个接口。例如,如果我们选择不实现StringPair.FixCase接口,*StringPair类型就只能满足LowerCaser、UpperCaser、LowerUpperCaser、Exchanger、fmt.Stringer和io.Reader接口。
下面让我们创建一些值,看看它们的方法。
toaskRack := Part{8427, "TOAST RACK"}
toastRack.LowerCase
lobelia := StringPair{"LOBELIA", "SACKVILLE-BAGGINS"}lobelia.FixCase
fmt.Println(toastRack, lobelia)
«8427 "toast rack"» "Lobelia"+"Sackville-Baggins"
这些方法被调用时其行为如我们所料。但如果我们有一堆这样的值而想在它们之上调用方法呢?下面的做法不太好。
for _, x := range interface{}{&toastRack, &lobelia} { // 不安全!
x.(LowerUpperCaser).UpperCase // 未经检查的类型断言
}
由于所有的大小写转换方法都会修改调用它的值,因此我们必须使用指向值的指针,因此需要传入指针接收者。
这里所使用的方法有两点缺陷。相对较小的一个缺陷是该未经检查的类型断言是作用于LowerUpperCaser接口的,它比我们实际所需要的接口更泛化。更糟糕的一种做法是使用更为泛化的ChangeCaser接口。但是我们不能使用FixCaser接口,因为它只提供了FixCase方法。我们应该采用刚好能满足条件的特定接口,这个例子中是UpperCaser接口。该方法最主要的缺陷是使用了一个未经检查的类型断言,可能导致抛出异常!
for _, x := range interface{}{&toastRack, &lobelia} {
if x, ok := x.(LowerCaser); ok { // 影子变量
x.LowerCase
}
}
上面的代码片段使用了一种更为安全的方式且使用了最合适的特定接口来完成工作,但这相当笨拙。这里的问题是,我们使用的是一个通用的interface{}值的切片,而非一个具体类型的值或者满足某个特殊类型接口的切片。当然,如果所给的都是interface{},那么这种做法是我们所能做到的最好的。
for _, x := range FixCaser { &toastRack, &lobelia } { // 完美的做法
x.FixCase}
上面代码所示的方式是最好的。我们将切片声明为符合我们需求的FixCaser而不是对原始的interface{}接口做类型检查,从而把类型检查工作交给编译器。
接口的灵活性的另一方面是,它们可以在事后创建。例如,假设我们创建了一些自定义的类型,其中有一些有一个 IsValid bool 方法。如果后面我们有一个函数需要检查其所接收到的某个值是不是我们定义的,通过检查它是否支持 IsValid方法来调用该方法,这就很容易做到。
type IsValider interface {
IsValid bool
}
首先,我们创建了一个接口,它声明了一个我们希望检查的方法。
if thing, ok := x.(IsValider); ok {
if !thing.IsValid{
reportInvalid(thing)
} else {
//...处理有效的thing...
}
}
创建了该接口之后,我们现在就可以检查任意自定义类型看它是否提供IsValid bool方法了,如果提供了,我们就调用该方法。
接口提供了一种高度抽象的机制。当某些函数或者方法只关心该传入的值能完成什么功能,而不关心该值的实际类型时,接口允许我们声明一个方法集合,并让这些函数或者方法使用接口参数。本章的后面节中我们将进一步讨论它们的使用(参见6.5.2节)。
6.4 结构体
在Go语言中创建自定义结构体最简单的方式是基于Go语言的内置类型创建。例如,type Integer int创建了一个自定义的Integer类型,其中我们可以添加自己的方法。自定义类型也可以基于结构体创建,用于聚合和嵌入。这种方式非常有用,因为当值(在结构体中叫做字段)来自不同类型时,它不能存储在一个切片中(除非我们使用interface{})。与C++的结构体相比,Go语言的结构体更接近于C的结构体(例如,它们不是类),并且由于对嵌入的完美支持,它更容易使用。
在前面的章节以及本章中,我们已经看过了很多关于结构体的例子,本书接下来还有更多关于结构体的例子。但是,有些结构体的特性我们还没看到过,因此让我们从一些说明性的例子开始讲解。
points := [2]int{{4, 6}, {}, {-7, 11}, {15, 17}, {14, -8}}
for _, point := range points {
fmt.Printf("(%d, %d)", point[0], point[1])
}
上面代码片段中的points变量是一个[2]int类型的切片,因此我们必须使用索引操作符来获得每一个坐标。(顺便提一下,得益于Go语言的自动零值初始化功能,{}项与{0, 0}项等价。)对于小而简单的数据而言,这段代码能够工作得很好,但还有一种使用匿名结构体的更好的方法。
points := struct{x, y int} {{4, 6}, {},{-7,11},{15,17},{14,-8}}
for _, point := range points {
fmt.Printf("(%d, %d)", point.x, point.y)
}
在这里,上面的代码片段中的points变量是一个struct{x, y int}结构体。虽然该结构体本身是匿名的,我们仍然可以通过具名字段来访问其数据,这比前面所使用的数组索引更为简便和安全。
结构体的聚合与嵌入
我们可以像嵌入接口或者其他类型的方式那样来嵌入结构体,也就是通过将一个结构体的名字以匿名字段的方式放入另一个结构体中来实现。(当然,如果我们给内部结构体一个名字,那该结构体就成了一个聚合的具名字段,而非一个嵌入的匿名字段。)
通常一个嵌入字段的字段可以通过使用.(点)操作符来访问,而无需提及其类型名,但是如果外部结构体有一个字段的名字与嵌入的结构体中某个字段名字相同,那么为了避免歧义,我们使用时必须带上嵌入结构体的类型名。
结构体中的每一个字段的名字都必须是唯一的。对于嵌入的(即匿名的)字段,唯一性要求足以保证避免歧义。例如,如果我们有一个类型为 Integer的匿名字段,那么我们还可以包含名字为比如Integer2或者BigInteger的字段,因为它们有明显的区别,但却不能包含像Matrix.Integer或者*Integer这样的字段,因为这些名字的最后部分字段嵌入的Integer 字段完全一样,而字段的名字的唯一性要求是基于它们的最后部分的。
嵌入值
让我们看一个简单的例子,它涉及了两个结构体。
type Person struct {
Title string // 具名字段(聚合)
Forenames string // 具名字段(聚合)
Surname string // 具名字段(聚合)
}
type Author1 struct {
Names Person // 具名字段(聚合)
Title string // 具名字段(聚合)
YearBorn int // 具名字段(聚合)
}
在前面的章节中,我们看到过许多类似的例子。这里,Author1结构体的字段都是具名的。下面演示了如何使用这些结构体,并给出了它们的输出(使用一个自定义的Author1.String方法,这里未给出)。
author1 := Author1{ Person{"Mr", string{"Robert", "Louis","Balfour"}, "Stevenson"},
string{"Kidnapped", "Treasure Island"}, 1850}
fmt.Println(author1)
author1.Names.Title = ""
author1.Names.Forenames = string{"Oscar", "Fingal", "O'Flahertie","Wills"}
author1.Names.Surname = "Wilde"
author1.Title = string{"The Picture of Dorian Gray"}
author1.YearBorn += 4
fmt.Println(author1)
Stevenson, Robert Louis Balfour, Mr (1850) "Kidnapped" "Treasure Island"
Wilde, Oscar Fingal O'Flahertie Wills (1854) "The Picture of Dorian Gray"
上面代码开始时创建了一个 Author1 值,并将其所有字段都填充上,然后打印。然后,我们更改了该值的字段并再次将其输出。
type Author2 struct {
Person // 匿名字段(嵌入)
Title string // 具名字段(聚合)
YearBorn int // 具名字段(聚合)
}
为了嵌入一个匿名字段,我们使用了要嵌入类型(或者接口,稍后看到)的名字而未声明一个变量名。我们可以直接访问这些字段的字段(即无需声明类型或者接口名),或者为了与外围结构体的字段的名字区分开,使用类型或者接口的名字访问嵌入字段的字段。
下面给出的Author2结构体嵌入了一个Person结构体作为其匿名字段。这意味着我们可以直接访问Person字段(除非我们需要避免歧义)。
author2 := Author2{Person{"Mr", string{"Robert", "Louis", "Balfour"},
"Stevenson"}, string{"Kidnapped", "Treasure Island"}, 1850}
fmt.Println(author2)
author2.Title = string{"The Picture of Dorian Gray"}
author2.Person.Title = "" // 必须使用类型名以消除歧义
author2.Forenames = string{"Oscar", "Fingal", "O'Flahertie", "Wills"}
author2.Surname = "Wilde" // 等同于:author2.Person.Surname = "Wilde"
author2.YearBorn += 4
fmt.Println(author2)
上面演示Author1结构体使用的代码在这里重复了一遍,用于演示Author2结构体的使用。它的输出与上例相同(假设我们创建了一个功能与 Author1.String方法相同的Author2.String方法)。
通过嵌入Person作为匿名字段,我们所得到的效果与直接添加Person结构体的字段所得到的效果几乎相同。但也不全是,因为如果我们把这些字段添加进来,就得到两个Title字段了,从而不能通过编译。
创建 Author2 值的效果等价于创建 Author1的效果,除非需要消除歧义(author2.Persion.Title与author2.Title的歧义),我们可以直接引用Person中的字段(例如, author2.Forenames)。
嵌入带方法的匿名值
如果一个嵌入字段带方法,那我们就可以在外部结构体中直接调用它,并且只有嵌入的字段(而不是整个外部结构体)会作为接收者传递给这些方法。
type Tasks struct {
slice string // 具名字段(聚合)
Count // 匿名字段(嵌入)
}
func (tasks *Tasks) Add(task string) {
task.slice = append(tasks.slice, task)
task.Increment // 就像写tasks.Count.Increment一样
}
func (tasks *Tasks) Tally int {
return int(tasks.Count)
}
我们前面讲过Count类型。Tasks结构体有两个字段:一个聚合的字符串切片和一个嵌入的Count值。正如Tasks.Add方法的实现所说明的那样,我们可以直接访问匿名的Count值的方法。
tasks := Takss{}
fmt.Println(tasks.IsZero, tasks.Tally, tasks)
tasks.Add("One")
tasks.Add("Two")
fmt.Println(tasks.IsZero, tasks.Tally, tasks)
true 0 { 0}
false 2 {[One Two] 2}
这里我们创建了两个 Tasks 值,并调用了它们的Tasks.Add、Tasks.Tally和Tasks.Count.IsZero(以 Tasks.IsZero的形式)方法。虽然我们没有定义Tasks.String方法,但是当要打印Tasks变量的时候,Go语言仍然能够智能地将其打印出来。(值得注意的是,我们没有把Tally方法叫做Count,是因为嵌入的Tasks.Count值与此有冲突,会导致程序无法编译。)
需重点注意的是,当调用嵌入字段的某个方法时,传递给该方法的只是嵌入字段自身。因此,当我们调用Tasks.IsZero、Tasks.Increment,或者任何其他在某个Tasks值上调用的Count方法时,这些方法接受到的是一个Count值(或者*Count值),而非Tasks值。
本例中Tasks类型定义了它自己的方法(Add和Tally),同时也有嵌入的Count类型的方法(Increment、Decrement和IsZero方法)。当然,也可以让Tasks类型覆盖任何Count类型中的方法,只需以相同的名字实现该方法就行。(前面我们已经看过了一个相关的例子,参见6.2.1.1节)。
嵌入接口
结构体除了可以聚合和嵌入具体的类型外,也可以聚合和嵌入接口。(自然地,反之在接口中聚合或者嵌入结构体是行不通的,因为接口是完全抽象的概念,所以这样的聚合与嵌入毫无意义)。当一个结构体包含聚合(具名的)或者嵌入(匿名的)接口类型的字段时,这意味着该结构体可以将任意满足该接口规格的值存储在该字段中。
让我们以一个简单的例子结束对结构体的讨论,该例子展示了如何让“选项”支持长名字和短名字(例如,“-o”和“-outfile”)且规定选项值为某特定类型(int、float64和string),以及一些通用的方法。(该例子主要用于做说明用,而非为了其优雅性。如果需要一个全功能的选项解析器,可以查看标准库中的flag包,或者godashboard.appspot.com/project上的某个第三方选项解析器。)
type Optioner interface {
Name string
IsValid bool
}
type OptionCommon struct {
ShortName string "short option name"
LongName string "long option name"
}
Optioner 接口声明了所有选项类型都必须提供的通用方法。OptionCommon 结构体定义了每一个选项常用到的字段。Go语言允许我们用字符串(用Go语言的术语来说是标签)对结构体的字段进行注释。这些标签并没有什么功能性的作用,但与注释不同的是,它们可以通过Go语言的反射支持来访问(参见9.4.9 节)。有些程序员使用标签来声明字段验证。例如,对字符串使用像“check:len(2, 30)”这样的标签,或者对数字使用“check:range(0, 500)”这样的标签,或者使用程序员自定义的任何语义。
type IntOption struct {
OptionCommon // 匿名字段(嵌入)
Value, Min, Max int // 具名字段(聚合)
}
func (option IntOption) Name string {
return name(option.ShortName, option.LongName)
}
func (option IntOption) IsValid bool {
return option.Min <= option.Value && option.Value <= option.Max
}
func name(shortName, longName string) string {
if longName == "" {
return shortName
}
return longName
}
上面代码片段包括IntOption自定义类型和一个辅助函数name的完全实现。由于嵌入了OptionCommon结构体,我们可以直接访问它的字段,正如我们在IntOption.Name方法中所使用的那样。IntOption 满足 Optioner 接口(因为它提供了一个 Name和IsValid方法,而其签名也一样)。
虽然 name所做的处理非常简单,我们还是选择将其功能独立出来,而非在IntOption.Name中实现。这使得IntOpiton.Name函数非常简短,并且也让我们可以在其他自定义选项中重用这些功能。因此,像GenericOption.Name和StringOption.Name这样的方法其方法体等价于IntOption.Name中的单语句方法体,而这3条语句都依赖于name函数完成实质性的工作。这是Go语言中非常普通的模式,我们将在本章的最后一节中再次看到这种模式。
StringOption的实现非常类似于IntOption的实现,因此我们没有给出。(不同点在于,它的Value字段是string类型的,而它的IsValid方法在Value值为非空的情况下返回true。)对于FloatOption类型,我们使用了嵌入的接口,下面给出它是如何实现的。
type FloatOption struct {
Optioner // 匿名字段(接口嵌入:需要具体的类型)
Value float64 // 具名字段(聚合)
}
这是 FloatOpiton 类型的完全实现。嵌入的Optioner 字段意味着当我们创建一个FloatOption值时,必须给该字段赋一个满足该接口的值。
type GenericOption struct {
OptionCommon // 匿名字段(嵌入)
}
func (option GenericOption) Name string {
return name(option.ShortName, option.LongName)
}
func (option GenericOption) IsValid bool {
return true
}
这是GenericOption类型的完全实现,它满足Optioner接口。
FloatOption类型有一个嵌入的Optioner类型的字段,因此FloatOption值需要一个具体的类型来满足该字段的Optioner接口。这可以通过给FloatOption值的Optioner字段赋一个GenericOption类型的值来实现。
现在我们定义了所需的类型(IntOption和FloatOption等),让我们看看如何创建并使用它们。
fileOption := StringOption{OptionCommon{"f", "file"}, "index.html"}
topOption := IntOption {
OptionCommon: OptionCommon{"t", "top"},
Max: 100,
}
sizeOption := FloatOption{
GenericOption{OptionCOmmon{"s", "size"}}, 19.5}
for _, option := range Optioner{topOption, fileOption, sizeOption} {
fmt.Print("name=", option.Name, "•valid=", option.IsValid)
fmt.Print(" •value=")
switch option := option.(type) { // 影子变量
case IntOption:
fmt.Print(option.Value, "•min=", option.Min, " •max= ", optiuon.Max, "\n")
case StringOption:
fmt.Println(option.Value)
case FloatOption:
fmt.Println(option.Value)
}
}
name=top•valid=true•value=0•min=0•max=100
name=file•valid=true•value=index.html
name=size•valid=true•value=19.5
StringOption类型的fileOption值使用传统的方式创建,并且每一个字段都按顺序被赋以一个合适值。但是对于IntOpiton类型的topOption值,我们只为OptionCommon和Max字段赋值,而其他字段只需零值就够了(即Value字段和Min字段只需零值就够了)。Go语言允许我们使用fieldName: fieldValue的形式初始化我们创建的结构体的值中的字段。使用这种语法后,任何没有显式赋值的字段都被自动赋值为零值。
FloatOption类型的sizeOption值的第一个字段是一个Optioner接口,因此我们必须提供一个满足该接口的具体类型。为此,我们在这里创建了一个GenericOption值。
创建了3个不同的选项后我们就可以使用Optioner,即一个保存满足Optioner接口的值的切片来迭代它们。在循环中,option变量轮流保存每个选项(其类型为Optioner)。我们可以通过 option 变量来调用 Optioner 接口中声明的任何方法,这里我们调用了Option.Name和Option.IsValid方法。
每一个选项类型都有一个Value字段,但是它们是属于不同类型的。例如,IntOption.Value是一个int类型,而StringOption.Value是一个string类型。因此,为了访问特定类型的Value字段(任何其他特定类型的字段或者方法也类似),我们必须将给定的选项转换为正确的类型。这可以通过使用一个类型开关(参见5.2.2.2节)来轻松完成。在上面的类型开发代码片段中,我们创建了一个影子变量(option),它在case语句中执行时总是拥有正确的类型(例如,在IntOption case语句中,option是IntOption类型,等等),因此在每个case语句中,我们都能够访问任何特定类型的字段或者方法。
6.5 例子
既然我们知道了如何创建自定义类型,就让我们来看一些更为实际和复杂的例子。第一个例子展示了如何创建一个简单的自定义类型。第二个例子展示了如何使用嵌入来创建一系列相关接口和结构体,以及如何提供类型构造函数和创建包中所有导出类型的值的工厂函数。第三个例子展示了如何实现一个完整的自定义通用集合类型。
6.5.1 FuzzyBool——一个单值自定义类型
在本节中,让我们看看如何创建一个基于单值的自定义类型及其支撑方法。这个示例基于一个结构体,保存在文件fuzzy/fuzzybool/fuzzybool.go中。
内置的布尔类型是双值的(true和false),但在一些人工智能领域中,使用的是模糊(fuzzy)布尔类型。它们的值与“true”和“false”相关,并且是介于它们之间的中间体。在我们的实现,我们使用一个浮点值,0.0表示false而1.0表示true。在这个系统中,0.5表示50%的真(50%的假),而0.25表示0.25%的真(75%的假),依次类推。这里有些使用示例及其产生的结果。
func main {
a, _ := fuzzybool.New(0) // 使用时可以安全地忽略err值
b, _ := fuzzybool.New(.25) // 已确定是合法的值。使用时需确认
c, _ := fuzzybool.New(.75) // 仍是变量
d := c.Copy
if err := d.Set(1); err != nil {
fmt.Println(err)
}
process(a, b, c, d)
s := *fuzzybool.FuzzyBool{a, b, c, d}
fmt.Println(s)
}
func process(a, b, c, d *fuzzybool.FuzzyBool) {
fmt.Println("Original:", a, b, c, d)
fmt.Println("Not: ", a.Not, b.Not, c.Not, d.Not)
fmt.Println("Not Not: ", a.Not.Not, b.Not.Not, c.Not.Not,
d.Not.Not)
fmt.Print("0.And(.25)→", a.And(b), "•.25.And(.75)→", b.And(c),
"•.75.And(1)→", c.And(d), " •.25.And(.75,1)→", b.And(c, d), "\n")
fmt.Print("0.Or(.25)→", a.Or(b), "•.25.Or(.75)→", b.Or(c),
"•.75.Or(1)→", c.Or(d), " •.25.Or(.75,1)→", b.Or(c, d), "\n")
fmt.Println("a < c, a == c, a > c:", a.Less(c), a.Equal(c), c.Less(a))
fmt.Println("Bool: ", a.Bool, b.Bool, c.Bool, d.Bool)
fmt.Println("Float: ", a.Float, b.Float, c.Float, d.Float)
}
Original: 0% 25% 75% 100%
Not: 100% 75% 25% 0%
Not Not: 0% 25% 75% 100%
0.And(.25)→0%.25.And(.75)→25%.75.And(1)→75% 0.And(.25,.75,1)→0%
0.Or(.25)→25%.25.Or(.75)→75%.75.Or(1)→100% 0.Or(.25,.75,1)→100%
a < c, a == c, a > c: true false false
Bool: false false true true
Float: 0 0.25 0.75 1
[0% 25% 75% 100%]
该自定义类型叫做 FuzzyBool。我们从类型定义开始看起,然后再看其构造函数。最后再看看它的方法定义。
type FuzzyBool struct{ value float32 }
FuzzyBool类型基于一个包含单float32值的结构体。该值是不可导出的,因此任何导入fuzzybool包的用户都必须使用构造函数(按照Go语言的惯例,我们将其定义为New)来创建模糊布尔值。当然,这意味着我们可以保证只创建包含合法值的模糊布尔值。
由于FuzzyBool类型是基于结构体的,而该结构体所包含的值的类型在结构体中是独一无二的,因此我们可以将其定义简化为type FuzzyBool struct{ float32 }。这意味着需要将访问该值的代码从fuzzy.value更改为fuzzy.float32,包括下面我们将看到的一些方法中的代码。我们更倾向于使用具名变量,部分是因为这样更为美观,部分是因为如果我们要更改该结构体的底层类型(如改成float64),我们只需做少量的更改。
往后的更改也有可能,因为该结构体只包含一个单值。例如,我们可以将其类型更改为type FuzzyBool float32,使它直接基于float32。这样做能够很好地工作,但稍微需要多点代码,并且与基于结构体的方式相比较,实现起来也稍微麻烦。然而,如果将我们自己局限于创建不可变的模糊布尔值(唯一的区别在于,不是使用Set方法来设置新值,而是直接使用一个新的模糊布尔值赋值),通过直接基于float32类型的方式,我们可以极大地简化代码。
func New(value interface{}) (*FuzzyBool, error) {
amount, err := float32ForValue(value)
return &FuzzyBool{amount}, err
}
为了方便模糊布尔值的用户,除了只接受一个 float32 值作为初始值之外,我们也可以接受float64型(Go语言的默认浮点类型)、int型(默认的整型)以及布尔值。这种灵活性是通过使用 float32ForValue函数来达到的,对应给定的值,它会返回一个 float32和nil,或者如果的给定值没法处理则返回0.0和一个错误值。
如果我们传入了一个非法值,就犯了一个编程错误,我们希望马上知道该错误。但我们并不希望程序在用户那里崩溃。因此,除了返回一个*FuzzyBool值外,我们也返回错误值。如果我们给New函数传入一个合法的字面量(正如前文代码片段中所见,),我们可以安全地忽略错误。但是如果我们传入的是一个变量,就必须检查返回的错误值,以防它不是非空值。
New函数返回一个指向FuzzyBool类型值的指针而非一个值,因为我们在实现中让模糊布尔值是可更改的。这也意味着这些修改模糊布尔值的方法(本例中只有一个Set)必须接受一个指针接收者,而非一个值[5]。
一个合理的经验法则是,对于不可变的类型创建只接受值接收者的方法,而为可变的类型创建接受指针接收者的方法。(对于可变类型,让部分方法接受值而让其他方法接受指针是完全可行的,但是在实际使用中可能不太方便。)同时,对于大的结构体类型(例如,那些包含两个或者更多个字段的类型),最好使用指针,这样就能将开销保持在只传递一个指针的程度。
func float32ForValue(value interface{}) (fuzzy float32, err error) {
switch value := value.(type) { // 影子变量
case float32:
fuzzy = value
case float64:
fuzzy = float32(value)
case int:
fuzzy = float32(value)
case bool:
fuzzy = 0
if value {
fuzzy = 1
}
default:
return 0, fmt.Errorf("float32ForValue: %v is not a " +
"number or Boolean", value)
}
if fuzzy < 0 {
fuzzy = 0
} else if fuzzy > 1 {
fuzzy = 1
}
return fuzzy, nil
}
该非导出的辅助函数用于在 New和Set方法中将一个值导出为[0.0, 1.0]范围内的float32值。通过使用类型开关(参见5.2.2.2节)来处理不同的类型非常简单。
如果该函数以一个非法值调用,我们就返回一个非空值错误。调用者有责任检查返回值并在错误发生时采取相应处理。调用者可以抛出异常以让应用程序崩溃,或者自己来处理问题。出现问题时,这样的底层函数返回错误值是种很好的做法,因为它们没有足够多关于程序逻辑的信息,来了解如何或者是否处理错误,而只是将错误向上推给调用者,而调用者更清楚应该如何处理。
虽然我们将传入非法值当做一种编程错误且认为应该返回一个非空的错误值,我们对超出预期的值采取从简处理,只将其转换成最接近的合法值。
func (fuzzy *FuzzyBool) String string {
return fmt.Sprintf("%.0f%%", 100*fuzzy.value)
}
该方法满足 fmt.Stringer 接口。这意味着模糊布尔值会按声明的方式输出,而模糊布尔值可以传递给任何接受fmt.Stringer值的地方。
我们让模糊布尔值的字符串表示成数字百分比。(回想一下,“%.0f”字符串格式声明了一个没有小数点也没有小数位的浮点类型数字,而“%%”格式声明了字面量%字母。字符串格式相关的内容在前文已有阐述,参见3.5节。)
func (fuzzy *FuzzyBool) Set(value interface{}) (err error) {
fuzzy.value, err = float32ForValue(value)
return err
}
该方法使得我们的模糊布尔变量变得可更改。该方法与New函数非常类似,只是这里我们工作于一个已存在的*FuzzyBool,而非创建一个新的。如果返回的错误值非空,那么模糊布尔值就是非法的,因此我们希望调用者检查返回值。
func (fuzzy *FuzzyBool) Copy *FuzzyBool {
return &FuzzyBool(fuzzy.value)
}
对于需将自定义类型以指针的形式传来传去的情况,提供Copy方法会更为方便。这里,我们简单创建了一个新的FuzzyBool值,其值与接收者的值相同,并返回一个指向它的指针。这里不用做任何验证,因为我们知道接收者的值一定是合法的。这里假设原始值使用New函数创建时其返回的错误值为空,对于后续Set方法调用也有类似的假设。
func (fuzzy *FuzzyBool) Not *FuzzyBool {
return &FuzzyBool{1 - fuzzy.value}
}
这是第一个逻辑运算方法,并且与其他所有方法一样,它也工作于一个*FuzzyBool接收者。
对于该方法我们本可以有3种合理的设计方式。第一种方式是直接更改调用该方法的值而不返回任何东西。另一种方式是修改调用该方法的值并将修改后的值返回,这是标准库中大多数big.Int和big.Rat类型的方法所采用的方式。这种方式意味着操作可以被链接(例如, b.Not.Not)。这也可以节省内存(因为值被重用而非重新创建),但也容易让我们在忘记了返回值与其自身是同一个值并且已被改过时措手不及。还有一种方式跟我们这里所采取的方式一样:不改变其值本身,但是返回一个新的经过逻辑运算的模糊布尔值。这很容易理解和使用,并且也支持链式,代价是创建了更多值。我们在所有的逻辑运算函数中都使用最后一种方式。
顺便提一下,模糊的"非"逻辑非常简单,对于 1.0 值返回 0.0,对于 0.0 值返回 1.0,对于0.75值返回0.25,对于0.25返回0.75,对于0.5值返回0.5,依次类推。
func (fuzzy *FuzzyBool) And(first *FuzzyBool, rest...*FuzzyBool) *FuzzyBool {
minimum := fuzzy.value
rest = append(rest, first)
for _, other := range rest {
if minimum > other.value {
minimum = other.value
}
}
return &FuzzyBool{minimum}
}
模糊的“与”操作的逻辑是返回给定模糊值中最小的那个。该方法的签名保证调用该方法时,调用者至少会传入一个别的*FuzzyBool值(first),另外,还接受零到多个同类型的值(rest)。该方法只是简单地将first值添加进(可能为空的)rest切片的末尾,然后迭代该切片,如果发现minimum值比迭代过程中的值大,则将minimum值设为当前迭代的值。同时,就像Not方法一样,我们会返回一个新的*FuzzyBool值,并将原始的调用方法的模糊布尔值保持不变。
模糊的“或”操作的逻辑是返回给定模糊值中最大的那个。我们没有给出 Or方法是因为它结构上与 And方法相同。唯一的区别就是 Or方法使用一个 maximum 变量而非一个minimum变量,并且比较的时候使用的是<小于操作符而非>大于操作符。
func (fuzzy *FuzzyBool) Less(other *FuzzyBool) bool {
return fuzzy.value < other.value
}
func (fuzzy *FuzzyBool) Equal(other *FuzzyBool) bool {
return fuzzy.value == other.value
}
这两个方法允许我们以它们所包含的float32 值的形式比较模糊布尔值。两个方法的返回值都为布尔值。
func (fuzzy *FuzzyBool) Bool bool {
return fuzzy.value >=.5
}
func (fuzzy *FuzzyBool) Float float64{
return float64(fuzzy.value)
}
可以将fuzzybool.New构造函数看成一个转换函数,因为给定float32、float64、int和bool型的值,它都能够输出一个*FuzzyBool值。这两个方法采用别的方式进行类似的转换。
FuzzyBool 类型提供了一个完整的模糊布尔数据类型,可以像其他所有自定义类型一样使用。因此,*FuzzyBool可以存储在切片中,或者以键或值甚至既是键也是值的形式存储在映射(map)中。当然,如果我们使用*FuzzyBool 来做一个映射(map)的键值,我们就可以存储多个模糊布尔值,哪怕它们值是相同的,因为它们每个都含有不同的地址。一种解决方案是采用基于值的模糊布尔值(例如本书源代码中的fuzzy_value例子)。另一种方法是,我们可以定义自定义集合类型,使用指针来存储,但使用它们的值来进行比较。自定义的omap.Map类型也能完成这些功能,只要提供一个合适的小于函数(参见6.5.3节)。
除了本节给出的模糊布尔类型外,本书的例子中也包含3个备选的模糊布尔实现供比较。这些备选方案没在本书中给出也未详细讨论。第一个可选的实现在文件 fuzzy_value/fuzzybool/fuzzybool.go和fuzzy_mutable/fuzzybool/fuzzybool.go中,其功能与本节给出的版本完全一样(在文件fuzzy/fuzzybool/fuzzybool.go中)。fuzzy_value版本是基于值的,而非*FuzzyBool,而fuzzy_mutable版本则直接基于一个float32值而非结构体。fuzzy_mutable的代码稍微比基于结构体的版本冗长而且难懂。第三个可选的版本提供的功能稍微比其他的少,因为它提供的是一个不可变的模糊布尔类型。它也是直接基于float32类型的,该版本的代码在文件fuzzy_immutable/fuzzybool/ fuzzybool.go中。这是3个可选实现中最简单的一种。
6.5.2 Shapes——一系列自定义类型
当我们希望在一系列相关的类型(例如各种形状)之上应用一些通用的操作时(例如,让一个形状把它们自身画出来),可以采取两种用的比较广泛的实现方法。熟悉C++、Java以及Python的程序员可能会使用层次结构,在Go语言中是嵌套接口。然而,通常更为方便而强大的做法是创建一系列能够相互独立的结构体。在本节中,我们两种方式都会给出,第一种方式在文件shaper1/shapes/shapes.go中,而第二种方式在文件shaper2/shapes/shapes.go中。(值得注意的是,由于大多数包的类型、函数和方法名都是一样的,我们简单地使用“形状包”来指代它们。自然地,当提到具体到某个例子的代码时,我们会以“shaper1形状包”和“shaper2形状包”来区分它们。)
图 6-3 给出了个示例,展示了我们的形状包所能做的事情。这里创建了一个白色的矩形,并在其上画了一个圆,以及一些边数和颜色不一的多边形。
图6-3 shaper示例的shapes.png文件
该形状包提供了3个操作图像的可导出函数,以及3种创建图像的类型,其中两种是可导出的。分层次的shapes1形状包提供了5个可导出接口。我们从图像相关的代码(便捷函数)开始,然后再看看其中的接口(在两个小节中),最后再回顾一下具体形状相关的代码。
6.5.2.1 包级便捷函数
标准库中的image包提供了image.Image接口。该接口声明了3个方法:image.Image.ColorModel返回图像的颜色模型(以color.Model的形式),image.Image.Bounds返回图像的边界盒子(以image.Rectangle的形式),而image.Image.At(x, y)返回对应像素的color.Color值。需注意的是,接口image.Image中没有声明设置像素的方法,虽然多个图像类型都提供了Set(x, y int, fill color.Color)方法。不过image/draw包提供了draw.Image接口,它嵌套了image.Image接口也包含了一个Set方法。标准库中的image.Graw和image.RGBA类型以及其他类型都满足draw.Image接口。
func FilledImage(width, height int, fill color.Color) draw.Image {
if fill == nil { // 默认将空的颜色值设为黑色
fill = color.Black
}
width = saneLength(width)
height = saneLength(height)
img := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(img, img.Bounds, &image.Uniform{fill}, image.ZP, draw.Src)
return img
}
该导出的便捷函数以给定的规格及统一的填充色创建图像。
函数开始处我们将零值的颜色替换为黑色,并且保证宽度和高度两个维度的值都是合理的。然后创建了一个 image.RGBA 值(一个使用红色、绿色、蓝色以及α-透明度值创建的图像),并将其以draw.Image类型返回,因为我们只关心拿它来做什么,而不关心它的实际值是什么。
draw.Draw函数接受的参数包括一个目标图像(类型为draw.Image)、一个声明在哪画图的矩形(在本例中是整个目标图像)、一个用于复制的源图像(本例中是一张以给定颜色填充大小无限的图像)、一个声明模板矩形从哪开始画图的点(image.ZP是一个0点,即点(0,0)),以及如何绘制该图的参数。这里,我们声明了draw.Src,因此该函数会简单地将原图复制至目标图。因此,我们这里得到的效果是将给定颜色复制至目标图像中的每一个像素中。(draw包也有一个draw.DrawMask函数,它支持一些Porter-Daff合成运算。)
var saneLength, saneRadius, saneSides func(int) int
func init {
saneLength = makeBoundedIntFunc(1, 4096)
saneRadius = makeBoundedIntFunc(1, 1024)
saneSides = makeBoundedIntFunc(3, 60)
}
我们定义了 3 个未导出的变量来保存辅助函数,这些函数都接受一个 int 值并返回一个int值。同时我们给该包定义了一个init函数,其中这些变量被赋值成合适的匿名函数。
func makeBoundedIntFunc(minimum, maximim int) func(int) int {
return func(x int) int {
valid := x
switch {
case x < minimum:
valid = minimum
case x > maximum:
valid = maximum
}
if valid != x {
log.Printf("%s: replaced %d width %d\n", caller(1), x, valid)
}
return valid
}
}
该函数返回一个函数。在返回的函数中,对于给定的值x,如果它在minimum和maximum之间(包含这两个值)则返回它,否则返回最接近的边界值。
如果x值不合法,除了返回合法的替代值,我们也将相应的问题记录下来。然而,我们并不想报告成在此处创建的函数(即saneLength、saneRadius和saneSides函数)中存在该问题,因为问题属于其调用者。因此,这里我们不记录此处创建函数的名字,而是用一个自定义的caller函数记录了调用者的名字。
func caller(steps int) string{
name := "?"
if pc, _, _, ok := runtime.Caller(steps + 1); ok {
name = filepath.Base(runtime.FuncForPC(pc).Name)
}
return name
}
runtime.Caller函数返回当前被调用函数的信息,并且也不是在当前goroutine中返回。int参数定义了往回退多远(即多少层函数)。如果传入的参数值为0,那么只查看当前函数信息(即shapes.caller函数),而如果传入的值为1,则查看该函数的调用者信息,等等。我们加上1以便从函数的调用者开始查看。
函数runtime.Caller能够返回4块信息:程序计数器(我们将其保存在变量pc中了)、文件名以及当前调用发生处所在的行(两个都使用空标识符忽略了),以及一个汇报信息是否可以获取得到的布尔标识(我们将其保存在ok变量中)。
如果成功获取到程序计数器,那么我们就调用 runtime.FuncForPC函数以返回一个*runtime.Func 值,然后在其之上调用 runtime.Func.Name方法以获得主调函数的方法名。其返回的名字像一条路径,例如,对于函数返回/home/mark/goeg/src/shaper1/shapes.FilledRectangle,而对于方法则返回/home/mark/goeg/src/ shaper1/shapes.*shape•SetFill。对于小项目而言,该路径没必要,因此我们使用filepath.Base函数将其剥离掉。然后我们将其名字返回。
例如,如果我们传入一个超界的宽度值和高度值如5000来调用shapes.FilledImage函数,则saneLength函数会将问题修正。另外,由于存在问题,就会产生一个记录,本例中该记录是“shapes.FilledRectangle: replaced 5000 with 4096”。之所以产生这样的结果,是因为saneLength函数使用参数1调用caller函数,在caller内部该值被设为2,因此caller函数会向上回溯3层:它自己(0层)、saneLength(1层)以及FilledImage(2层)。
func DrawShapes(img draw.Image, x, y int, shapes..Shaper) error {
for _, shape := range shapes {
if err := shape.Draw(img, x, y); err != nil {
return err
}
}
return nil
}
这是另一个导出的便捷函数,也是形状包的两种实现中的唯一区别。这里给出的函数来自于层次结构的shapes1 形状包。组合型的shapes2 形状包区别在于其函数签名中接受的是Drawer值,即满足Drawer接口(它有一个Draw方法)的值,而非必须包含Draw、Fill和SetFill方法的Shaper类型的值。因此,在本例中,与层次结构的Shaper类型相比,组合的方式意味着我们使用一个更加具体且所需参数更少的类型(Drawer)。我们会在接下来的两个节中讲解这两个接口。