Custom Types

自定义类型的创建使用 type 语句:

type typeName typeSpecification

typeName可以为任何有效的Go标识符,但必须是package或一个function内唯一。typeSpecification 可以是任意的内置类型(比如一个string, int, slice, map, or channel),一个interface, 一个struct, 或者是一个方法的签名。

虽然给自定义类型添加方法会使自定义类型更加有用,但在很多情况下创建一个自定义类类型就足够。以下是没有方法的自定义类型

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()函数用于自定义类型StringSlice []string——但是必须将它们转换为底层的内置类型(没有开销,因为在编译时完成了),才能传递给明确要求是底层类型的函数。有时,我们需要做相反的事情,升级一个内置类型到一个自定义类型,以获得自定义类型的方法。我们可以看看之前的SortFoldedStrings()函数例子,将[]string转换为FoldedStrings.

type RuneForRuneFunc func(rune) rune

当使用高阶函数时,通常为了方便,需要将我们要传递的函数签名定义为一个自定义类型。这里我们指定了一个函数的签名,它接收和返回一个rune.

var removePunctuation RuneForRuneFunc

removePunctuation变量引用一个类型为RuneForRuneFunc的函数(i.e., 签名为 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)

这里我们创建了一个匿名函数,它跟RuneForRuneFuc的签名相匹配,并且将它传递给processPhrases()函数。

func processPhrases(phrases []string, function RuneForRuneFunc) {
    for _, phrase := range phrases {
        fmt.Println(strings.Map(function, phrase))
    }
}
/*
Day dusk and night
All day long
*/

使用RuneForRuneFunc类型,而不是它底层的 func(rune) rune,更具可读性。

创建基于内置类型或者函数签名的自定义类型非常有用,但不仅限于此,我们还需要自定义方法,这是下一节的主题。

Adding Methods

一个方法是一个特殊的函数,它可以被一个自定义类型的值调用,并且在调用时,会传递一个值给它。这个值可以作为指值传递或者是值本身,这取决于方法是如何定义的。定义方法的语法跟定义函数是一样了,除了在func关键词和方法名之前,我们必须写接受者(receiver)——写在一个方括号内,可以作为这个方法所属的类型的值也可以是一个变量名和它的类型。当一个方法被调用,接受者的变量(如果存在)将被自动设置为调用这个方法的值或者指针。

我们可以给任何自定义类型添加一个或者多个方法。一个方法的接受者可以为这个类型的值,也可以为指针。可是每一个类型的方法名称必须是唯一的。名字唯一的一个要求是我们不能有两个相同名字的方法,一个方法的接受者是一个指针,而另一个接受者是一个值。另一个要求是,这里不支持方法重载,即方法有相同的名称,但是有不同的签名。其中为了提供跟方法重载的一个路径,使用可变参数的方法(i.e.,方法接收任意数量的参数); 但是,Go使用的是另一种,唯一命名函数。 比如, strings.Reader类型提供了三个不同的读取方法: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 }

这是一个简单的基于int的自定义类型,它提供了三个方法,前两个声明的接受者为指针,这是因为我们需要修改调用这个方法的值。

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类型,看起来非常简单,但我们会在第四节中用到。

让我们看一个更复杂点的例子,这次我们的自定义类型是建立在struct之上。

type Part struct {
    Id int // Named field (aggregation)
    Name string // Named field (aggregation)
}
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)
}

part := Part{5, "wrench"}
part.UpperCase()
part.Id += 11
fmt.Println(part, part.HasPrefix("w"))
//«16 "WRENCH"» false

这个自定义类型是基于structs之上的,所以我们可以在创建值时,可以使用type加花括号,在花括号中指定初始值的方法创建。

一旦part变量的值被创建完成时,我们调用它上面的方法(e.g., Part.UpperCase()), 访问它的导出字段(public)(e.g., Part.Id), 并且打印它(Go的打印函数会调用自定义类型的String()方法)。

一个类型的方法集是这个类型的值可调用的所有方法集合。

如果我们有一个指针,指向一个自定义类型的值,它的方法集是由此类型的所有方法组成——不管这些方法的接受者是一个值或者是指针。如果我们在一个指针上调用方法,而这个方法的接受者是一个值。Go可以足够聪名的对这个指针进行取值,并且将这个取得的值作为方法的接受者。

如果我们有一个自定义类型的值,它的方法集则由于此类型接受者为值的方法组成——不包括接受者为指针的方法。这听看起来也没有什么限制,因为我们依然接受者为指针的方法传递这个值的地址给它——当然提供的值必须是可寻址的(i.e.,一个变量,间接指针,数组或者slice中的元素, struct中的可寻址字段). 所以当调用的方法的接受者是指针,而值又是可以寻址的,那么Go对待这样的代码像(&value).Method()一样处理。

*Count 类型的指针由三个方法组成,Increment(), Decrement(), and IsZero(); 而Count类型的值对只有一个方法IsZero(). 所有的这些方法都可以在*Count上调用——也可以在Count的值上调用,但这个值必须是可寻址的,如我们在上面看到的Count的使用。 *Part类型的方法集是由四个方法组成,LowerCase(), UperrCase(), String(), 和HasPrefix(); 虽然Part类型的方法只有String()和HasPrefix(). 可是,LowerCase()和UpperCase()方法也可以应用于可址的Part类型的值,如Part代码所示的那样。

接受器为值的方法非常适用于那些小的类型,比如数字。这些方法不可以改变这个值,因为它们只是获得这个值的一个备份。如是要我们的值是非常大的类型或者我们想要个修改这个值,我们需要使用接受器为指针的方法。这使得方法的调用非常廉价。

Overriding Methods

正如我们将在本章后面看到的,可以创建一个struct类型,它包含一个或者多个类型(包含接口类型)作为嵌入字段。这种方式最方便的在于,任何在被嵌入的类型中定义的方法可以在自定义struct中调用,就好像是struct自已定义的方法,当这个方法被调用时,会传递被嵌入的字段,作为这个方法的接受者。

type Item struct {
    id string // Named field (aggregation)
    price float64 // Named field (aggregation)
    quantity int // Named field (aggregation)
}
func (item *Item) Cost() float64 {
    return item.price * float64(item.quantity)
}
type SpecialItem struct {
    Item // Anonymous field (embedding)
    catalogId int // Named field (aggregation)
}

在这里,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类型没有这个方法。Go使用Item.Count()方法,并且传递它被嵌入的Item值,而不是整个SpecialItem.

如果被嵌入的Item字段跟SpecialItem字段一样,我们依然可以访问被嵌入的Item的字段,比如special.Item.price.

也可以在嵌入的struct中创建一个新的方法,覆盖被嵌入字段的方法。如下所示

type LuxuryItem struct {
    Item // Anonymous field (embedding)
    markup float64 // Named field (aggregation)
}

现在来说,如果我们在LuxuryItem上调用Count()方法,被嵌入的Item.Cost()方法将被使用。以下是三种不同的实现方式,覆盖被嵌入的方法。

/*
func (item *LuxuryItem) Cost() float64 { // Needlessly verbose!
    return item.Item.price * float64(item.Item.quantity) * item.markup
}
func (item *LuxuryItem) Cost() float64 { // Needless duplication!
    return item.price * float64(item.quantity) * item.markup
}
*/
func (item *LuxuryItem) Cost() float64 { // Ideal ✓
return item.Item.Cost() * item.markup
}

最后一个方法利用了被嵌入的Cost()方法。当然这里没有要求说覆盖的方法必须使用被嵌入的方法(被覆盖的方法)。

方法表达式

正如我们可以赋值和传递函数一样,我们也可以赋值和传递方法表达式。一个方法表达式是一个函数,这个函数的第一个参数必须是这个类型的值(哪种类型定义上定义的方法值)。

asStringV := Part.String // Effective signature: func(Part) string
sv := asStringV(part)
hasPrefix := Part.HasPrefix // Effective signature: func(Part, string) bool
asStringP := (*Part).String // Effective signature: func(*Part) string
sp := asStringP(&part)
lower := (*Part).LowerCase // Effective signature: func(*Part)
lower(&part)
fmt.Println(sv, sp, hasPrefix(part, "w"), part)
//«16 "WRENCH"» «16 "WRENCH"» true «16 "wrench"»

在这里我们创建了四个方法表达式:asStringV()它接收一个Part的值,作为它唯一的参数,hasPrefix()接收一个Part的值作为它第一个参数,一个字符串作为它的第二个参数, asStringP()和lower()这两个函数接收*Part类型的指针作为它们唯一的参数。

方法表达式是一个高级功能,在一些非常罕见的情况下,它们非常有用。

至目前为此,我们创建的自定义类型有一个严重的缺陷。它们都没有提供任何手段以保证初始化的数据是有效的(或者强制为有效),也没有任何方法可以保证类型的数据(struct类型中的字段)不能给定非有效的数据。 比如Part.Id和Part.Name字段可以被设置为任何的值。但如果我们想应用一些约束——比如,只允许IDs只能为大于0的正数,或者只允许names为指定的格式。我们将在下一节中讨论。

Validated Types

对于很多简单的自定义类型来说,没有必须进行校验。比如我们有一个类型Point{X, Y int}, 对于这种情况,任何x,y的值都是有效的。此外,由于Go可以保证所有的变量初始化为零值,所以不需要指明构造函数。

对于那些默认初始化零值是不够的情况下,我们就需要创建一个构造函数。Go语言不支持构造函数,所以我们必须明确的调用构造函数。为了支持这一点,我们必须记录此类型有无效的零值,并且提供一个或者多个构造函数,用于创建有效的值。

我们可以使用一个相似的方式用于字段的校验。我们可以让这些字段为非导出字段,然后提供一个可以访问的方法用于校验。

让我们看一个小而完成的自定义类型,来说明这些观点。

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 package), 但是它的latitude 和 longitude字段都是非导出的,因为它们要求检验。我们提供了一个构造函数,New(),用来保证我们一直创建有效的*place.Places。 Go的惯例是使用New作为构造函数的名字,如果有多个构造函数,则以New作为函数名字的开并没有。(我们没有显示saneAngle()函数,是否为它不是重点——这个函数接受一个旧的角度和一个新的角度,如果这个新的角度在指定的范围内,则返回这个新的解度,否则返回旧的解度)。同时为非导出字段,提供getters和setters方法,这样就能保证只能设置有效的值。

String()方法意味着*Place的指针满足于fmt.Stringer接口,所以*Places将按照我们的要求进行打印,而不是Go默认的格式。我们也提供了一个Copy()方法。

newYork := place.New(40.716667, -74, "New York") // newYork is a *Place
fmt.Println(newYork)
baltimore := newYork.Copy() // baltimore is a *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 package,然后调用place.New()来创建一个*Places. 一旦我们有了一个*Place,我们可以调用它的方法。