第6章 面向对象编程

本章的目的是讲解在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 接口的方法来实现,即一个满足签名String()string的方法。

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)。我们会在接下来的两个节中讲解这两个接口。

两种情况下函数的函数体及其功能都是一样的。该函数接受一个用于画图的draw.Image参数,一个位置参数(以x和y坐标的形式)以及0个或者更多个Shaper(或者Drawer)值。在循环里面,调用每一个形状来在给定的位置绘制其自身。x和y坐标的值在更底层的形状相关的Draw()函数中检查,如果它们是非法的,那么我们就会得到一个非空的错误值,然后立即将其返回给调用者。

对于图 6-3,我们使用一个该函数的修改版,它会将图形画 3 遍,一遍是在给定的x和y坐标,另一遍是在往右偏移一个像素的地方,最后一遍是在往下偏移一个像素的地方。这是为了让截图中的边线显得更粗。

func SaveImage(img image.Image, filename string) error {

file, err := os.Create(filename)

if err != nil {

return err

}

defer file.Close()

switch strings.ToLower(filepath.Ext(filename)){

case ".jpg", ".jpeg":

return jpeg.Encode(file, img, nil)

case ".png":

return png.Encode(file, img)

}

return fmt.Errorf("shapes.SaveImage(): '%s' has an unrecognized " +

"suffix", filename)

}

这是最后一个可导出的便捷函数。给定一个满足 image.Image 接口的图像(因为该接口嵌套了一个image.Image接口,它包含了任何满足draw.Image接口的方法),该函数尝试将图像保存在一个给定名字的文件中。如果os.Create()调用失败(例如,由于文件名为空或者 I/O 错误),或者其文件名后缀不可识别,或者图像编码失败,那么函数就会返回一个非空的错误值。

在撰写本书时,Go语言的标准库支持读和写两种格式的图像:.png(Portable Network Graphics)和.jpg(Joint Photographic Experts Group)。支持更多图像格式的包可以从godashboard.appsport.com/project获取。jpeg.Encode()函数有一个额外的参数,可用于微调图像是如何存储的,我们传入nil值表示使用默认的设置。

这些编码器可能引起异常(例如传入一个空的image.Image时),因此如果我们要使程序能够容错,就得要么在本函数中要么在调用链的上层函数中延迟调用recover()(参见5.5.1节)。我们选择不添加这些保护函数,因为测试套件(这里没给出)会调用该函数足够多次来保证这样的编程错误一旦出现就会被触发并且会导致程序终止,因此几乎不会错过任何错误。

基于传入的draw.Image 接口,我们可以声明一些其像素值可以设为任何我们想要的颜色值的图像。同时,使用DrawShapes()函数我们可以在这种图像上画出图形(满足Shaper或者Drawer接口的图形)。我们可以使用SaveImage()函数将图片保存在磁盘里。有了这些便捷函数后,我们所需要做的就剩下创建接口(例如Shaper和Drawer接口等)和具体的类型和方法以满足这些接口了。

6.5.2.2 嵌套接口的层次结构

有传统的面向对象编程背景的程序员可能倾向于使用 Go语言的嵌套接口的能力来创建具有层次结构的接口。我们将在下一节看到,推荐方式是使用组合。下面是在基于层次结构的shapes1形状包中所使用的接口。

type Shaper interface {

Fill() color.Color

SetFill(fill color.Color)

Draw(img draw.Image, x, y int) error

}

type CircularShaper interface {

Shaper   // Fill(); SetFill(); Draw()

Radius()  int

SetRadius(radius int)

}

type RegularPolygonalShaper interface {

CIrcularShaper // Fill(); SetFill(); Draw(); Radius(); SetRadius()

Sides() int

SetSides(sides int)

}

我们创建了一个由 3 个接口组成的层次结构(使用嵌套而非继承),这 3 个接口声明了我们希望定义的形状值具备的方法。

Shaper 接口定义了获得和设置类型为 color.Color的填充色的方法,以及在一个draw.Image的给定位置上绘制其自身的方法。CircularShaper接口嵌套一个匿名的Shaper,同时为int型的半径添加了一个getter和setter方法。类似地,RegularPolygonalShaper接口嵌套了一个匿名CircularShaper接口(因此也是一个Shaper类型),并为一系列类型为int的边添加了getter和setter方法。

虽然像这样创建层次结构可能更熟悉,并且也确实能够完成工作,但在 Go语言中它并不是完成工作的最好方式。这是因为在我们根本没必要使用层次结构的时候它也能将我们锁在层次结构的世界里,我们真正需要的仅仅是声明下这些特定类型的接口支持一些相关接口。下一节我们将看到,这给了我们更多的灵活性。

6.5.2.3 自由组合的相互独立接口

不失一般性,对于这些形状而言,我们最想描述的是它们所能做的事情(绘制、获取或设置填充颜色、获取或设置半径值等)。下面是组合的shapes2形状包中的接口。

type Shaper interface{

Drawer // Draw()

Filler // Fill(); SetFill()

}

type Drawer interface {

Draw(img draw.Image, x, y int) error

}

type Filler interface {

Fill() color.Color

SetFill(fill color.Color)

}

type Radiuser interface {

Radius() int

SetRadius(radius int)

}

type Sideser interface {

Sides() int

SetSides(sides int)

}

该包的Shaper接口是一个描述形状的便利途径,即声明该形状可以被绘制且可以获取和设置填充色。每一个其他的接口都声明了一个非常具体的行为(将获取和设置算作一个)。

声明许多独立的接口比使用层次结构灵活得多。例如,与使用层次结构相比,我们可以传入更为具体的类型给DrawShapes()函数。同时,因为无需保持层次结构,我们可以更加自由地添加其他接口。当然,正如我们创建Shaper接口时一样,使用这些细粒度的接口让我们可以更容易组合。

这两个版本的形状包接口完全不一样(虽然都有一个Shaper接口,但它们的接口体不一样)。然而,由于接口和具体类型是完全分离且独立的,这些区别并不影响满足它们的任何具体类型的实现。

6.5.2.4 具体类型与方法

这是讲解形状包的最后一节。本节中,我们会讲解满足上面两节中所述接口的具体实现。

type shape struct { fill color.Color }

func newShape(fill color.Color) shape {

if fill == nil { // 默认将空值颜色设置为黑色

fill = color.Black

}

return shape{ fill }

}

func (shape shape) Fill() color.Color { return shape.fill }

func (shape *shape) SetFill(fill color.Color) {

if fill == nil { // 默认将空值颜色设置为黑色

fill = color.Black

}

shape.fill = fill

}

该简单类型是未导出的,因此只能在相同的形状包内访问。这也意味着在包外无法创建该形状的值。

在层次结构的shaper1形状包中,该类型没有满足任何接口,因为它没提供一个Draw()方法。但是在组合类型的shaper2形状包中,它能够满足Filler接口。

正如代码所示,只有Circle类型(我们稍后讲解)直接嵌套了一个shape值。因此,理论上我们可以将 color.Color 值组合进 Circle 类型中,并让该颜色值的getter和setter函数使用*Circle值而非shape值作为接收者,这样就完全不必使用shape类型。然而,我们更希望保持shape类型,因为它允许我们直接基于该shape类型(为了有颜色)而非Circle类型(因为它们没有半径)创建额外形状接口和类型。后面有个练习可以应用这种灵活性。

type Circle struct{

shape

radius int

}

func NewCircle(fill color.Color, radius int) *Circle {

return &Circle{newShape(fill), saneRadius(radius)}

}

func (circle *Circle) Radius() int {

return circle.Radius

}

func (circle *Circle) SetRadius(radius int) {

circle.radius = saneRadius(radius)

}

func (circle *Circle) Draw(img draw.Image, x, y int) error {

//...省略了大约30行代码

}

func (circle *Circle) String() string {

return fmt.Sprintf("circle(fill=%v, radius=%d)", circle.fill, circle.radius)

}

这是 Circle 类型的完全实现。虽然我们可以创建具体的*Circle 值,但也可以以接口的形式传递它们,这给我们带来很大的便利性。例如,DrawShapes()函数(参见6.5.2.1节)接受Shaper(或者Drawer),而不管其底层具体类型是什么。

在基于层次结构的shaper1形状包中,该类型满足CircularShaper和Shaper接口。在基于组合的shaper2包中,它满足Filler、Radiuser、Drawer和Shaper接口。在两种情形下,该类型都满足fmt.Stringer接口。

由于Go语言没有构造函数,而我们有未导出字段,因此我们必须提供构造函数以被显式调用。Circle的构造函数是NewCircle(),稍后我们将看到该包还有一个New()函数可以用于创建该包中任意形状的值。在前文创建 saneRadius()函数时我们就看到,如果传入的整型参数在某个给定的范围内,saneRadius()辅助函数会直接返回该值,否则会返回另一个合理的值。

Draw()方法的代码被省略了(但是在本书附带的源代码中有给出),因为本章所关心的重点是创建自定义的接口以及类型而非图形处理相关的内容。

type RegularPolygon struct {

*Circle

sides int

}

func NewRegularPolygon(fill color.Color, radius, sides int) *RegularPolygon {

return &RegularPolygon{NewCircle(fill, radius), saneSides(sides)}

}

func (polygon *RegularPolygon) Sides() int {

return polygon.sides

}

func (polygon *RegularPolygon) SetSides(sides int) {

polygon.sides = sansSides(sides)

}

func (polygon *RegularPolygon) Draw(img draw.Image, x, y int) error {

//...这里省略了大概55行代码,其中包括两个帮助函数...

}

func (polygon *RegularPolygon) String() string {

return fmt.Sprintf("polygon(fill=%v, radius=%d, side=%d)",

polygon.Fill(), polygon.Radius(), polygon.sides)

}

这里是 RegularPolygon 类型的完全实现,它提供了常规多边形类型。该类型与Circle 类型非常类似,只是它多了一个更为复杂的Draw()方法(其方法体被省略了)。由于RegularPolygon嵌套了一个*Circle,我们使用NewCircle()函数(该函数会处理验证)为该值赋值。saneSides()辅助函数类似于saneRadius()函数和saneLength()函数。

在基于层次结构的shaper1 图形包中,该类型满足 RegularPolygonShaper、CircularShaper、Shaper和fmt.Stringer接口。在基于组合的shaper2图形包中,它满足Filler、Radiuser、Sideser、Drawer、Shaper和fmt.Stringer接口。

NewCircle()函数和NewRegularPolygon()函数允许我们创建*Circle和*RegularPolygon值,同时由于它们的类型满足Shaper和其他接口,我们可以以Shaper或者它们所满足的其他任何接口类型的值的形式传递。我们可以在这些值上调用任何 Shaper方法(即Fill()、SetFill()和Draw()等方法)。同时如果我们希望在一个Shaper 值上调用一个非Shaper方法,我们可以使用类型断言或者类型开关以将该值转换为某个包含目标方法的接口形式。讲解showShapeDetails()函数的时候我们会看一个例子。

不难发现,我们可以创建许多其他的形状类型,有些是在 shape 之上创建的,有些则是基于Circle或者RegularPolyon。此外,有时我们也希望根据运行时环境来创建形状类型,例如,通过使用一个形状名字。为此,我们可以创建一个工厂函数,即一个返回形状类型的函数,其中返回值的类型取决于一个参数。

type Option struct {

Fill  color.Color

Radius int

}

func New(shape string, option Option) (Shaper, error) {

sidesForShape := map[string]int{"triangle": 3, "square": 4,

"pentagon": 5, "hexagon": 6, "heptagon": 7, "octagon": 8,

"enneagon": 9, "nonagon": 9, "decagon": 10}

if sides, found := sidesForShape[shape]; found {

return NewRegularPolygon(option.Fill, option.Radius, sides), nil

}

if shape != "circle" {

return nil, fmt.Errorf("shapes.New(): invalid shape '%s'", shape)

}

return NewCircle(option.Fill, option.Radius), nil

}

该工厂函数需两个参数,即所需创建形状的名字和一个自定义的选项值,其中选项值中可以声明可选的特定形状的参数。(使用结构体来创建可以处理多个可选参数的内容已在第5章阐述。参见5.6.1.3节。)该函数返回一个满足Shaper接口的形状以及空的错误值,或者如果给定的形状名非法则返回空值和一个错误值。(回想一下两个形状包中Shaper接口的不同实现,参见 6.5.2.2 节和6.5.2.3 节。)所创建的特殊形状取决于传入的形状字符串参数。这里没必要验证颜色和半径值,因为这些都交由 shapes.shape.SetFill()方法和shapes.SaneRadius()函数处理了,它们最终又被 NewRegularPolygon()和NewCircle()以及类似的关于多边形的方法调用。

polygon := shapes.NewRegularPolygon(color.RGBA{0, 0x7f, 0, 0x7f}, 65, 4)

showShapeDetails(polygon) ①

y = 30

for i, radius := range []int{60, 55, 50, 45, 40} {

polygon.SetRadius(radius)

polygon.SetSides(i+5)

x += radius

y += height / 8

if err := shapes.DrawShapes(img, x, y, polygon); err != nil {

fmt.Println(err)

}

}

上面的代码片段给出了图 6-3 中展示的多边形是如何使用 DrawShapes()函数创建的。showShapeDetails()函数(①)用于打印任何形状的详细信息。这样做是可能的,因为该函数接受满足Shaper接口的任意类型的值(即任何我们定义的形状),而非一个具体的形状类型(例如一个*Circle或者*RegularPolygon)。

由于两个类型包中的Shaper接口不一样,因此showShapeDetails()函数的实现也有两种。下面这种是针对基于层次结构的shaper1的版本。

func showShapeDetails(shape shapes.Shaper) {

fmt.Print("fill=", shape.Fill(), " ") // 所有图形都有一个填充色

if shape, ok := shape.(shapes.CircularShaper); ok { // 影子变量

fmt.Print("radius=", shape.Radius(), " ")

if shape, ok := shape.(shapes.RegularPolygonalShaper); ok{ // 影子变量

fmt.Print("sides=", shape.Sides(), " ")

}

}

fmt.Println()

}

嵌套不是继承

本小节中,shaper例子解释了如何使用结构体嵌套来达到类似于继承的效果。该技术可能对于将C++或者Java代码转换成Go代码的人(或者那些来自于C++或者Java背景的Go程序员)比较有吸引力。然而,虽然这种方法可行,但Go语言的方式并不是为了模拟继承,而是为了完全避免继承。

根据例子的上下文,这意味着定义相对独立的结构体:

type Circle struct { type RegularPolygon struct {color.Color color.Color Radius int Radius int} Sides int}

这样做仍然允许我们传递通用的图形值。毕竟,如果两个图形都有能够满足Drawer接口的Draw()方法,那么Circle和RegularPolygon都可以以Drawer值的形式传递。

另一点需要注意的是,我们让所有字段都是导出的,并没有任何验证。这意味着我们必须在使用时验证其字段,而非在它们被设置时。这两种验证的方式都合理,具体哪种更好取决于环境。

本书的shaper3 例子使用上面给出的结构体,并且其功能与本小节给出的shaper1和shaper2例子相同。然而,shaper3更有Go语言的味道,它没有嵌套,并且在使用时做了验证。

在shaper1图形包的接口层次结构中,Shaper接口声明了Fill()和SetFill()方法,因此可以立即使用。但是对于其他方法,我们必须先确认它的类型,看看所传入的类型是否满足声明了我们所需调用函数的接口。例如,在这里,只有当该图形满足 CircularShaper 接口时才能访问Radius()方法,RegularPolygonalShaper接口的Sides()方法也类似。(回想一下,RegularPolygonalShaper嵌套了一个CircularShaper。)

shaper2版本的showShapeDetails()函数类似于shaper1版本。

func showShapeDetails(shape shapes.Shaper) {

fmt.Print("fill=", shape.Fill(), " ") // 所有图形都有一个填充色

if shape, ok := shape.(shapes.Radiuser); ok { // 影子变量

fmt.Print("radius=", shape.Radius(), " ")

}

if shape, ok := shape.(shapes.Sideser); ok { // 影子变量

fmt.Print("sides=", shapes.Sides(), " ")

}

fmt.Println()

}

基于组合的shaper2图形包中有一个便捷的Shaper接口,它嵌套了Drawer和Filler接口,因此我们知道所传入的图形有一个Fill()方法。与shaper1层次接口不同的是,这里我们可以使用非常具体的类型断言来访问图形所支持的Radius()和Sides()方法。

如果shape、Circle或者RegularPolygon中添加了新方法或者新字段,我们的代码无需更改就能够继续工作。但是如果我们为其中的任何一个接口添加了新方法,那么我们就必须更新受影响的图形类型来提供相应的方法,否则我们的代码就会被破坏。一个更好的可选方案是创建一个新接口以包含新方法,并将其已有的接口嵌套在里面。这不会破坏任何已有的代码,同时让我们选择是否往已有类型中添加新方法,这取决于我们是否希望它们满足已有接口的同时也满足新接口。

对于接口,我们推荐使用组合而非继承的方式。我们推荐使用 Go语言风格来做结构体嵌套,也就是定义相互独立的结构体,而非试图模拟继承。当然,一旦有了足够多的Go语言编程经验,作出这样的决定就是出于技术优势而非移植的便利性或者纯粹是习惯问题。

除了本节给出的shaper1和shaper2示例外,本书的例子中包含了shaper3,它展示了“更纯”的Go语言风格。shaper3版本只有一个接口Drawer,以及独立的Circle和RegularPolygon结构体(见本节的“嵌套不是继承”部分所述)。同时,shaper3使用了图形值而非指针,并且在使用时进行验证。shaper2/shapes/shapes.go文件和shaper3/shapes/shapes.go文件都值得一看,比较一下两种实现方式。

6.5.3 有序映射——一个通用的集合类型

本章的最后一个例子是一个通用的有序映射类型,它能够像Go语言内置的map类型一样保存“键/值”对,只是每一对按键序存储。该有序映射使用了一个左倾的红黑树,因此速度非常快,其查找的时间复杂度为O(log2 n)。 [6] 通过比较发现,如果其项以有序的方式添加,一个非平衡二叉树的性能可以降级到一个链表的性能(O(n))。平衡树之所以没有这种缺陷,是因为它们在添加和删除节点的时候维持了树的平衡,因此能够保留良好性能。

来自于基于继承的面向对象编程(如C++、Java和Python)背景的程序员更倾向于让有序映射支持小于操作符(<操作),或者是一个签名为Less(other) bool的方法。这很容易通过定义一个声明了该方法的Lesser接口,并为int、string或者MyType这样的类型提供一个实现了这些方法的包装器类型来实现。然而,在Go语言中,正确的实现方式有点不同。

对于我们实现的Go语言有序映射,我们不对键的类型做直接的限制。相反,我们给每一个映射一个“小于”比较函数以支持按键比较。这意味着无论我们的键类型是否支持<操作符都没关系,只要我们能为其提供一个合适的小于比较函数。

在看具体的实现之前,让我们来看一个使用案例,从创建和填充一个有序映射开始。

words := []string{"Puttering", "About", "in", "a", "Small", "Land"}

wordForWord := omap.NewCaseFoldedKeyed()

for _, word := range words {

wordForWord.Insert(word, strings.ToUpper(word))

}

我们自定义的有序映射在omap包中,其类型为Map。由于该映射的零值没什么实用的地方,因此要创建一个Map,我们必须使用omap.New()函数,或者其他的Map构造函数,如我们这里所使用的omap.NewCaseFoldedKeyed()函数。该特殊构造函数创建了一个空Map并返回一个指向该字典的指针(即一个*Map),其预定义的小于比较函数不区分大小写,按键比较。

每一个“键/值”对都使用omap.Map.Insert()方法添加。该方法接受两个interface{}值,即一个任意类型的键和一个任意类型的值。(然而,其中的键必须是兼容小于比较函数的类型,因此本例中的键必须是字符串。)如果新元素被成功插入映射中,那么Insert()方法返回true,否则如果给定的元素的键在映射中已经存在(在这种情况下元素的值会被新元素的值替代,这与内置的map类型的做法一样),则返回false。

wordForWord.Do(func(key, value interface{}){

fmt.Printf("%v→%v\n", key, value)

})

a→A

About→ABOUT

in→IN

Land→LAND

Puttering→PUTTERING

Small→SMALL

omap.Map.Do()方法接受一个签名为 func(interface{}, interface{})的函数作为参数,对于按键排序的有序映射的每一个元素都调用该函数,将元素的键和值作为参数传递给该函数。这里我们使用Do()方法打印wordForWord中的所有键和值。

除了插入元素和对所有元素都调用方法之外,我们也可以查询映射中有多少个元素,查找元素以及删除元素。

fmt.Println("length before deleting:", wordForWord.Len())

_, containsSmall := wordForWord.Find("small")

fmt.Println("contains small:", containsSmall)

for _, key := range []string{"big", "medium", "small"} {

fmt.Printf("%t ", wordForWord.Delete(key))

}

_, containsSmall = wordForWord.Find("small")

fmt.Println("\nlength after deleting: ", wordForWord.Len())

fmt.Println("contains smail:", containsSmall)

length before deleting: 6

contains small: true

false false true length after deleting: 5

contains small: false

omap.Map.Len()方法返回有序映射中元素的个数。omap.Map.Find()方法使用以interface{}的形式给定的键值查找元素,如果找到则返回元素的值和true,否则返回nil和false值。omap.Map.Delete()方法使用给定的键删除元素并返回true,否则如果有序映射中不含该元素则什么也不做并返回false。

如果要存储某自定义类型的键,我们可以使用omap.New()函数来创建Map,并给它提供一个合适的小于比较函数。

例如,这里是一个非常简单的自定义类型的实现。

type Point struct{X, Y, int}

func (point Point) String() string {

return fmt.Sprintf("(%d, %d)", point.X, point.Y)

}

现在我们就可以创建一个有序映射,存储将*Point 作为键,以它们与原点之间的距离作为值的元素。

在下面的代码片段中,我们创建了一个空的Map,并给它传入了一个小于比较函数用于比较*Point 键。然后,我们创建了一个*Point 切片,并用其中的点来填充映射。最后,我们使用omap.Map.Do()方法按键的顺序来打印映射的键和值。

distanceForPoint := omap.New(func(a, b interface{}) bool {

α, β := a.(*Point), b.(*Point)

if α.X != β.X {

returnα.X < β.X

}

returnα.Y < β.Y

})

points := []*Point{{3, 1}, {1, 2}, {2, 3}, {1, 3}, {3, 2}, {2, 1}, {2, 2}}

for _, point := range points {

distance := math.Hypot(float64(point.X), float64(point.Y))

distanceForPoint.Insert(point, distance)

}

distanceForPoint.Do(func(key, value interface{}) {

fmt.Printf("%v→ %.2v\n", key, value)

})

(1, 2) →2.2

(1, 3) →3.2

(2, 1) →2.2

(2, 2) →2.8

(2, 3) →3.6

(3, 1) →3.2

(3, 2) →3.6

回想下第4章中我们提到的,Go语言非常智能,允许我们在创建切片字面量的时候去掉内层的类型名和符号,因此在这里 points 切片的创建是这条语句的缩写:points :=[]*Point{&Point{3, 1}, &Point{1, 2}, …}。

虽然还没有给出,我们仍然可以像wordForWord映射中那样使用distanceForPoint映射中的Delete()、Find()和Len()等方法,只是前两个方法必须使用*Point 值(因为小于比较操作函数工作在*Point上,而非Point)。

既然我们知道了如何使用有序映射,接下来就让我们检查下它的具体实现。我们不会阐述Delete()方法的辅助方法及函数,因为其中有些函数或者方法非常具有技巧性,而对它们的阐述并不涉及Go语言编程方面的知识。(当然,所有这些函数都可以从本书的源代码中找到,参见文件qtrac.eu/omap/omap.go。)我们首先看看用于实现有序映射的两个类型(Map和node),再看看一些构造函数。然后,我们会看看Map的方法以及相应的辅助函数。在Go语言编程中非常常见的是,大部分方法都非常简短,而将更为复杂的处理交由辅助函数完成。

type Map struct {

root  *node

less  func(interface{}, interface{}) bool

length  int

}

type node struct {

key, value  interface{}

red      bool

left, right *node

}

该有序映射使用两个自定义的结构体类型实现。第一个结构体类型是Map结构体,它保存着左倾红黑树的根、一个用于比较键的小于比较函数,以及一个长度值用于存储映射中的元素个数。该类型的字段都是非导出的,并且小于比较函数的初始零值为nil,因此直接创建一个Map变量会产生一个非法的Map。Map类型的文档说明了这点,并引导用户使用omap包中的构造函数来创建合法的Map。

第二个结构体类型是 node 结构体,它表示一个单一的“键/值”项。除了它的键和值字段之外,node结构体还有3个额外的字段,用于实现树。red字段是布尔类型的,用于表示一个节点是“红”(true)还是“黑”(false),这用于当树的部分需要旋转以保持平衡时。left 字段和right字段是*node类型的,它们保存着指向节点左子树及右子树的指针(可能为空值nil)。

omap包提供了几个构造函数。这里,让我们看一下通用的omap.New()函数及几个其他的函数。

func New(less func(interface{}, interface{}) bool) *Map {

return &Map{less: less}

}

这是该包中用于创建任何内置或者自定义类型的有序映射的通用函数,因为我们可以提供一个合适的小于比较函数。

func NewCaseFoldedKeyed() *Map {

return &Map{less: func(a, b interface{}) bool {

return strings.ToLower(a.(string)) < strings.ToLower(b.(string))

}}

}

该构造函数创建了一个空的有序映射,其键为字符串类型,比较时大小写不敏感。

func NewIntKeyed() *Map {

return &Map{less: func(a, b interface{}) bool {

return a.(int) < b.(int)

}}

}

该构造函数创建了一个空的有序映射,其键类型为int型。

omap 包中也有一个 omap.NewStringKeyed()函数,用于创建其键为区分大小写的字符串的有序映射(其实现与 omap.NewCaseFoldedKeyed()几乎完全相同,只是没有调用strings.ToLower()),还有一个omap.NewFloat64Keyed()函数与omap.NewIntKeyed()函数一样,只是它使用的是float64型数据作为键而非int类型。

func (m *Map) Insert(key, value interface{}) (inserted bool) {

m.root, insterted = m.insert(m.root, key, value)

m.root.red = false

if insterted {

m.length++

}

return inserted

}

该方法在结构上是一个典型的Go语言方法,因为它将大部分工作都交由一个辅助函数完成,在这里是未导出的insert()方法。随着元素的插入,树的根可能被改变,这可能是因为树原本为空而现在包含了一个单节点,该节点必为根,或者因为插入元素后为了维持根节点在内的树平衡必须将树旋转。

无论树的根是否改变,insert()方法都会返回树的根及一个布尔值。其中,如果插入了新元素,那么布尔值为 true,同时将映射的长度加 1。如果布尔值为 false,则意味着给定键所对应的新元素已经在映射中了,因此所做的工作就是用给定的新值替换树中元素的当前值,而映射的长度保持不变。(我们不去解释为什么节点被设置成红色或者黑色,或者为什么需要将它们旋转。这些内容在Robert Sedgewrick的论文中有完整的解释,详情参见前面的备注。)

func (m *Map) insert(root *node, key, value interface{}) (*node, bool) {

inserted := false

if root == nil { // 键已经在树中的情况也属于这里

return &node{key: key, value: value, red: true}, true

}

if isRed(root.left) && isRed(root.right) {

colorFlip(root)

}

if m.less(key, root.key) {

root.left, inserted = m.insert(root.left, key, value)

} else if m.less(root.key, key) {

root.right, inserted = m.insert(root.right, key, value)

} else { // 键已经在树中了,因此只需使用新值替换旧值

root.value = value

}

if isRed(root.right) && !isRed(root.left) {

root = rotateLeft(root)

}

if isRed(root.left) && isRed(root.left.left) {

root = rotateRight(root)

}

return root, inserted

}

这是一个递归函数,它会遍历整棵树并查找给定键所在的节点,如有必要会将子树旋转来维持树的平衡。当Insert()方法调用该方法的时候,传入的root是整棵树的根节点(如果树为空则为nil),但随后的递归调用的root则为子树的根(可能为nil)。

如果新键与已存在的键都不相同,那么遍历会到达一个正确的地方插入该新键,而该地方是一个空的叶子。在这点上我们创建并返回一个新的*node 作为子树,而其叶子为空 nil。我们不必显式地初始化新节点的left和right字段(即它的叶子),因为Go语言会自动地将其设为默认的零值(即nil值),因此我们使用结构体的“键:值”语法只初始化那些非零值的字段。

如果新键与已有的某个键相同,我们重用该已存在键的节点,并简单地将其值替换为新值(其做法与内置的map类型一样)。这样做的结果是,一个有序映射中的每个项的键都是唯一的。

func isRed(root *node) bool { return root != nil && root.red }

该简短的辅助函数返回一个给定的节点是否为红,它把空节点当做黑节点。

func colorFlip(root *node) {

root.red = !root.red

if root.left != nil {

root.left.red = !root.left.red

}

if root.right != nil {

root.right.red = !root.right.red

}

}

该辅助函数倒置给定节点及其非空叶子节点的颜色。

func rotateLeft(root *node) *node {x := root.right root.right = x.left x.left = root x.red = root.red func rotateRight(root *node) *node {x := root.left root.left = x.fight x.right = root x.red = root.red
root.red = true root.red = true return x}return x}

该函数旋转root的子树并保持其子树平衡。

func (m *Map) Find(key interface{}) (value interface{}, found bool) {

root := m.root

for root != nil {

if m.less(key, root.key) {

root = root.left

} else if m.less(root.key, key) {

root = root.right

} else {

return root.value, true

}

}

return nil, false

}

由于该函数的实现比较直接,并且使用的是迭代而非递归,因此没必要创建一个辅助函数。

该Find()方法通过使用less()函数将当前根的键(因为该方法会遍历整棵树)和目标键进行比较以定位目标元素。这通过使用逻辑等于比较x = y⇔¬(x < y∨y < x)来完成。这种比较对于int、float64、string、自定义的Point类型以及其他许多类型都有效,但不是对所有类型都有效。如果需要,也可以很容易地扩展omap.Map类型来接受一个独立的比较函数。

需注意的是,我们这里使用了具名返回值,但是从来没有显式地为其赋值。当然,它们在return 语句中被隐式地赋值了。像这样对返回值进行命名对于函数或者方法的文档来说是个有用的补充。例如,这里可以从Find(key interface{}) (value interface{}, found bool)签名很明显地了解返回值是什么。但是,如果其签名是 Find(key interface{}) (interface{}, bool),就没那么明显了。

func (m *Map) Delete(key interface{}) (deleted bool) {

if m.root != nil{

if m.root, deleted = m.remove(m.root, key); m.root != nil {

m.root.red = false

}

}

if deleted {

m.length--

}

return deleted

}

从一个左倾的红黑树中删除一个元素有一定的技巧性,因此我们将其主要工作交由一个未导出的remove()方法以及该方法的辅助函数来完成,这里没有给出remove()方法也没给出其辅助函数。如果有序映射是空的,或者如果映射中不含给定的键,那么Delete()方法就会安全地不执行任何操作并返回false。如果该树只包含一个元素,并且该元素就是需要被删除的,那么*omap.Map接收者的根会被设置成空值nil(并且树也为空)。如果进行了删除操作,我们就会返回true,同时将映射的长度减1。

顺便提一下,remove()方法使用类似于 Find()方法中所使用的比较函数来定位所需删除的元素。

func (m *Map) Do(function func(interface{}, interface{})){

do(m.root, function)

}

func do(root *node, function func(interface{}, interface{})) {

if root != nil {

do(root.left, function)

function(root.key, root.value)

do(root.right, function)

}

}

Do()方法及其do()辅助函数用于遍历有序表中的所有元素——按键排序,并针对每个元素将其键和值作为参数以调用传入的函数。

func (m *Map) Len() int{

return m.length

}

该方法简单地返回映射的长度。前面看到过,其长度会在 omap.Map.Insert()方法和omap.Map.Delete()方法中增加或者减少。

这样就完成了对有序映射这个自定义集合类型的阐述,也到了结束面向对象 Go语言编程讲解的时候。

如果对于某自定义类型而言任何值都是合法的,我们可以简单地创建该类型(例如,使用一个结构图),并将该类型及其字段导出(以大写字母开头)就足够了。(例如,参考标准库中的image.Point和image.Rectangle类型。)

对于需要验证的自定义类型(例如,那些包含一个或者多个字段的基于结构体的类型,并且要求至少一个字段经过验证),Go语言有个特定的编程惯例。必须验证的字段设置成不可导出的(以小写字母开始),同时为其提供getter和setter访问方法。

在当其零值为非法值的类型中,我们将相关字段设为不可导出的,并为其提供访问器方法。我们也提到过,零值为非法时,为其提供一个导出的构造函数(通常叫做 New())。该构造函数通常返回一个指向该类型值的指针,其字段都被设置为合法值。

我们可以传递包含导出以及非导出字段的值以及指向该值的指针。当然,如果类型满足一个或者多个接口,当传递接口有用处时,我们也可以以接口的形式传递该值,也就是说,我们关心的只是该值所能完成的功能,而非该值的类型。

很明显,那些来自于基于继承的面向对象编程背景的程序员(如C++、Java或者Python)需调整他们的思考方式。然而,Go语言中鸭子类型和接口的强大和便利性以及不再需要痛苦地维持继承层次结构,使得投入精力学习是非常值得的。如果按照Go语言的方式来进行,Go语言对面向对象编程方式的效果会非常好。

6.6 练习

本章有3个练习。第一个练习涉及创建一个小的自定义类型,其字段必须是经验证的。第二个练习涉及为本章讲到的某个自定义类型添加新功能。第三个练习需要创建一个小的自定义集合类型。前两个练习不难,但是第三个练习非常有挑战。

(1)创建一个叫做font的包(例如,在文件my_font/fong.go中)。该包的目的是提供表示字体属性的值(例如,字体的属性和大小)。该包中应该有个 New()函数,它接受一个属性值和一个大小值(两个都必须被验证),返回一个*Font(其字段是合法的非导出字段)。同时提供一些getter和能够验证的setter方法。对于验证,字体的属性名不能为空,其大小必须在5~144 个点之间。如果所给定的值是非法的,就为其设置默认的合法值(或者前一个给 setter提供的值),并记录下问题。同时必须提供一个满足fmt.Stringer接口的方法。

这里有个例子演示了如何创建、操作以及使用该包来打印字体。

titleFont := font.New("serif", 11)

titleFont.SetFamily("Helvetica")

titleFont.SetSize(20)

fmt.Println(titleFont)

{font-family: "Helvetica"; font-size: 20pt;}

该包准备好后,将例子中的font/font_test.go文件复制至my_font目录中,然后运行go test来做一些基本的测试。

文件font/font.go是一个参考答案。整个包大约50行代码。顺便提一下,我们的做法是让String()方法以CSS格式返回字体的细节。这样做可能有点乏味,但是可以直接地将该包扩展至处理所有的CSS字体属性,如weight、style和variant等。

(2)将整个shaper例子(分层级的shaper1、基于组合的shaper2或者更具Go语言风格的shaper3,包括它们的子目录,随便你喜欢哪个都可以,但是我们更推荐shaper2和shaper3)拷进一个新目录中,例如my_shaper。编辑my_shaper/shaper[123].go文件:删除除了 image和shapes 之外所导入的包,删除 main()函数中的所有语句。编辑my_shaper/shapes/shapes.go 文件,添加支持一种叫做 Rectangle的新形状的代码。该形状需有一个起点和长度宽度(所有image.Rectangle类型所提供的),一个填充色以及一个布尔类型值以表示图形是否需要被填充。像添加其他形状一样添加 Rectangle 来声明该类型的API,也就是说,使用非导出的字段和接口(例如,基于层次结构的RectangularShaper或者基于组合类型的Rectangler和Filleder),或者不使用接口而使用可导出的字段(Go语言风格)。Draw()方法并不难,特别是如果你使用该图形包中的未导出的drawLine()函数以及draw.Draw()函数的情况下。同时记住更新 New()函数以便能够创建矩形,扩展相应的Option类型。

一旦矩形类型添加完后,my_shaper/shaper[123].go 文件中的main()函数创建和保存的图形就如图6-4所示。

图6-4 一个用矩形类型创建的图形

我们提供了3个参考答案,基于层次结构的实现在shaper_ans1目录下,基于组合类型的实现在shaper_ans2目录下,而更具Go语言风格的实现在shaper_ans3目录下。下面是shaper_ans1方案中用到的RectangularShaper接口:

type RectangularShaper interface {

Shaper // Fill(); SetFill(); Draw()

Rect() image.Rectangle

SetRect(image.Rectangle)

Filled() bool

SetFilled(bool)

}

对于shaper_ans2,我们定义了Rectangler和Filleder接口:

type Rectangler interface {

Rect() image.Rectangle

SetRect(image.Rectangle)

}

type Filleder interface {

Filled() bool

SetFilled(bool)

}

而更具Go语言风格的解决方案中没有添加新接口。

具体的Rectangle类型本身的代码与基于层次结构和组合结构的代码组织方式相同,并为非导出的字段设置getter和setter方法。但是对于更具Go语言风格的版本,则使用可导出的字段,同时只在使用时进行验证。

type Rectangle struct {

color.Color

image.Rectangle

Filled bool

}

在文件shaper_ans/shapes/shapes.go中,Rectangle类型及其支持方法一共少于50 行代码。Option 类型中需多添加几行代码,New()函数中需多添加 5 行代码。在文件shaper_ans1/shaper1.go中,新的main()函数少于20行代码,和shaper_ans2的实现类似。shaper_ans3实现中额外添加的代码是最少的。

更具创意的读者可能会将该例子展开,为其提供独立的填充色和轮廓色。如果所提供的颜色值为空,则意味着无需绘制,否则使用该颜色进行填充或者勾画轮廓。

(3)在my_oslice包中创建一个名为Slice的自定义集合类型。该类型必须实现一个有序的切片,提供几个构造函数,如接受一个小于比较函数的New(func(interface{}, interface{}) bool),以及其他预定义了小于比较函数的构造函数如NewStringSlice()和NewIntSlice()。除此之外,*oslice.Slice 类型还必须实现几个方法,Clear()方法用于清空切片, Add(interface{})方法用于往切片的恰当位置插入元素,Remove(interface{}) bool 方法用于移除第一次出现的给定元素并返回是否移除成功,Index(interface{}) int方法用于返回给定元素在切片中首次出现的位置(如果不存在返回−1),At(int)interface{}方法用于返回给定索引位置下的元素(如果所给的索引位置超出范围则抛出异常),以及一个Len() int方法返回切片中元素的个数。

func bisectLeft(slice []interface{},

less func(interface{}, interface{}) bool, x interface{}) int {

left, right := 0, len(slice)

for left < right {

middle := int((left + right) / 2)

if less(slice[middle], x) {

left = middle + 1

} else {

right = middle

}

}

return left

}

bisectLeft()函数用于该解决方案中,并且可能非常实用。如果它返回len(slice),则表示给定的元素不在切片中,并且应该放在切片的末尾。任何其他返回值都表示该元素要么在所返回的位置,要么不在切片中而应该放置在所返回的位置中。

有些读者可能会将oslice/slice_test.go文件复制到他们的my_oslice目录下来测试它们的答案。此外,我们还在 oslice/slice.go 文件中里给出了一个参考答案。Add()方法非常具有技巧性,但第 4 章中的InsertStringSlice()函数(参见 4.2.3 节)也非常有用。


[1].在Go语言的术语中叫做嵌入的东西在其他某些语言中叫做委托——delegation。

[2].在C++、Java中接受者统一叫做this,Python中叫做self,在Go语言实践中你可以给它们更有实际意义的名字。

[3].回想一下,在Go语言中,如果标识符以小写字母开头,那么它是非导出的(即只在定义它的包中可见)。如果标识符是以大写字母开头的,那么它是导出的(即在任何导入了定义该标识符的包的包中)。

[4].如果我们刚才是在创建一个框架,我们可能创建一个接口,但是不创建实现该接口的类型,而让使用该框架的用户自己通过创建这些类型来使用框架。

[5].事实上,我们可以返回一个FuzzyBool值,它仍然是一个可变的类型,本书源代码中的fuzzy_value例子有解释。

[6].我们的有序映射是基于左倾的红黑树实现的,关于它的描述请参考 Robert Sedgewick的www.cs.princeton.edu/~rs/talks/LLRB/LLRB.pdf和www.cs.princeton.edu/~rs/talks/LLRB/RedBlack.pdf。本书撰写时,论文中所给出的Java实现是不完整的并且有些错误,因此我们使用Lee Stanaza的C++代码中的思想将其完整地实现了,C++代码请参考www.teachsolaisgames.com/articles/balanced_left_leaning.html。