云原生系列Go语言篇-类型、方法和接口 Part 1

1,313 阅读17分钟

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

通过前面章节的学习,我们知道Go是一种静态类型语言,包含有内置类型和用户定义类型。和大部分现代编程语言一样,Go允许我们对类型关联方法。它也具备类型抽象,可以编写没有显式实现的方法。

然而,Go处理方法、接口和类型的方式与现行大部分其它语言大相径庭。Go的设计者鼓励软件开发者所提倡的最佳实践,避免继承、鼓励组合。本章中我们会学习类型、方法和接口,了解如何使用它们来构建可测试、易维护的程序。

Go的类型

复合类型一章中我们学习过如何定义结构体类型:

type Person struct {
    FirstName string
    LastName string
    Age int
}

读作声明名为Person的用户自定义类型,紧接着的是结构体字面量的底层类型。除结构体字面量为,也可以使用任意原始类型或复合类型来定义一个类型。举例如下:

type Score int
type Converter func(string)Score
type TeamScores map[string]Score

Go语言允许我们在包以下的块级声明类型。但仅能在其作用域内访问该类型。唯一的特例是导出的包级类型。我们在模块、包和导入一章中会深入讨论。

注:为更易于讨论类型,我们先做一些名词解释。抽象类型abstract type)指定类型的功能,但不包含如何实现。具象类型concrete type)指定了做什么以及如何做。也就是说它存在存储数据的方式并且提供了对该类型所声明的所有方法的实现。虽然在Go中都是抽象或具象的,但有些语言是允许混合类型的,比如Java中带默认方法的抽象类或接口。

方法

和大部分现代语言一样,Go支持对用户定义类型添加方法。

类型的方法在包级定义:

type Person struct {
    FirstName string
    LastName string
    Age int
}

func (p Person) String() string {
    return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}

方法声明和函数声明一样,只是加了一个接收器(receiver)说明。接收器位于func关键字和方法名之间。和所有其它变量声明一样,接收器名称位于类型之前。按惯例,接收器名称为类型名的缩写,通常是其第一个字母。使用thisself被视为不地道的做法。

和函数一样,方法名称不能重载。对不同类型可使用相同的方法名,但对相同类型的不同方法不能使用相同名称。从带方法重载的编程语言转过来的会觉得这存在局限,但不复用名称是Go哲学的一部分,以使代码保持清晰。

我们会在模块、包和导入一章中讨论包,注意方法必须其关联类型的同一包中声明,Go不允许对不由你控制的类型添加方法。虽然可以在相同包下的不同文件中的按类型声明定义方法,最好是把类型定义和关联方法放在一起以便更容易跟进实现。

方法调用对于熟悉其它语言的开发者应当不会陌生:

p := Person {
    FirstName: "Fred",
    LastName:"Fredson",
    Age: 52,
}
output := p.String()

指针接收器和值接收器

指针一章中讲到了,Go使用指针类型的参数表示函数中可能会修改参数。对于方法接收器也同样适用。存在指针接收器(类型为指针)或值接收器(类型为值类型)。通过以下规则可协助决定使用哪种接收器:

  • 如果方法会修改接收器,则必须使用指针接收器。
  • 如果方法需要处理nil实例(参见为nil实例编写方法一节),则必须使用指针接收器。
  • 如果方法不修改接收器,则可使用值接收器。

对于不修改接收器的方法是否使用值接收器取决于该类型上所声明的其它方法。只要该类型有一个指针接收器的方法,通常会保持连续性对所有方法都使用指针接收器,不管具体的方法是否修改接收器。

下面有一些简单代码演示指针和值接收器。我们先看一个带两个方法的类型,一个使用值接收器,另一个使用指针接收器:

type Counter struct {
    total             int
    lastUpdated time.Time
}

func (c *Counter) Increment() {
    c.total++
    c.lastUpdated = time.Now()
}

func (c Counter) String() string {
    return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}

可以使用如下代码测试这些方法。读者可在The Go Playground运行这段代码:

var c Counter
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())

得到的输出结果如下:

total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

读者可能注意到了即使c是值类型也能调用指针接收器方法。在通过值类型本地变量使用指针接收器时,Go会自动将其转化为指针类型,c.Increment()被相应地转化为了(&c).Increment()

但对函数传值的规则不变。如果对变量传递值类型,再通过传入值调用指针接收器方法,会使用拷贝来调用方法。可在The Go Playground中调试如下代码:

func doUpdateWrong(c Counter) {
    c.Increment()
    fmt.Println("in doUpdateWrong:", c.String())
}

func doUpdateRight(c *Counter) {
    c.Increment()
    fmt.Println("in doUpdateRight:", c.String())
}

func main() {
    var c Counter
    doUpdateWrong(c)
    fmt.Println("in main:", c.String())
    doUpdateRight(&c)
    fmt.Println("in main:", c.String())
}

运行代码输出结果如下:

in doUpdateWrong: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
in doUpdateRight: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

doUpdateRight的参数为*Counter类型,这是一个指针实例。可以看到对其可调用IncrementString方法。Go中指针接收器和值接收器都被看作是指针实例的方法集。对值实例,只有值接收器方法才在方法集中。现在听上去有点绕,但讨论接口时我们还会回看这一概念。

最后一点,不要为Go结构体编写getter和setter方法,除非是实现接口需要(我们会在接口快速教程一节中讲到接口)。Go语言鼓励直接访问字段。把方法留给业务逻辑。在想要通过单次操作更新多个字段或更新不是直接赋新值的情况例外。前面定义的Increment方法演示了这两种情况。

为nil实例编写方法

刚刚讲到了指针实例,读者可能会想如果对nil实例调用方法会出现什么情况。对于大部分编程语言,这都会导致报错。(Objective-C允许对nil实例调用方法,但什么也不会做。)

Go则有些不一样。它会尝试调用这一方法。如果方法带值接收器,会panic(我们在错误处理一章的panic和recover中会讨论),原因是该指针没有指向的值。但如查方法带的是指针接收器,如果编写了方法处理nil实例则可正常执行。

在部分情况下,nil接收器会让代码简化。下面是二叉树的一个实现,使用了nil作为接收器:

type IntTree struct {
    val         int
    left, right *IntTree
}

func (it *IntTree) Insert(val int) *IntTree {
    if it == nil {
        return &IntTree{val: val}
    }
    if val < it.val {
        it.left = it.left.Insert(val)
    } else if val > it.val {
        it.right = it.right.Insert(val)
    }
    return it
}

func (it *IntTree) Contains(val int) bool {
    switch {
    case it == nil:
        return false
    case val < it.val:
        return it.left.Contains(val)
    case val > it.val:
        return it.right.Contains(val)
    default:
        return true
    }
}

注:Contains方法不修改*IntTree,但使用指针接收品进行了声明。这是为了演示前面所讲的对nil接收的支持。带值接收器的方法无法检测nil,在出现了nil接收器时会像前面说的那样panic。

下面是使用这个二叉树的代码。可在The Go Playground中执行:

func main() {
    var it *IntTree
    it = it.Insert(5)
    it = it.Insert(3)
    it = it.Insert(10)
    it = it.Insert(2)
    fmt.Println(it.Contains(2))  // true
    fmt.Println(it.Contains(12)) // false
}

Go支持对nil接收器调用方法是非常聪明的做法,在某些场景也非常有用,比如二叉树节点的示例。但大部分时候用处没有那么大。指针接收器类似于函数指针参数,是传入方法中的指针拷贝。就像传入函数中的nil参数,如果修改了指针副本,并不会改变原始值。也就是说不能编写指针接收器方法处理nil让原始指针变为非nil。如方法带指针接收器又不支持nil,请进行nil检测并返回错误(在错误处理一章讨论错误)。

方法也是函数

Go语言中的方法和函数很像,可以在有函数类型变量或参数时随时可用方法替换。

下面看一个简单示例:

type Adder struct {
    start int
}

func (a Adder) AddTo(val int) int {
    return a.start + val
}

我们按通常的方式创建一个该类型的实例并调用其方法:

myAdder := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // prints 15

也可以将方法赋给变量或传给func(int)int类型的参数。这称为方法值(method value):

f1 := myAdder.AddTo
fmt.Println(f1(10))           // prints 20

方法值和闭包有点像,因其可访问所创建实例中的字段值。

可通过该类型本身创建一个函数。这称为方法表达式:

f2 := Adder.AddTo
fmt.Println(f2(myAdder, 15))  // prints 25

在方法表达式中第一个参数是方法的接收器,函数签名为func(Adder, int) int

方法值和方法表达式不是对特殊情况的灵光乍现。我们会在隐式接口让依赖注入更简单一节中学习到如何依赖注入时使用它们。

函数 vs. 方法

因为可以将方法用作函数,读者可能会想什么时候用方法、什么时候用函数。

区别在于函数是否依赖于其它数据。我们已多次提到,包级状态应是不可变的。在逻辑中出现值是在启用时配置并在执行过程中发生改变时,这些值应放到结构体中,这段逻辑应使用方法进行实现。如果逻辑只依赖于入参,则应使用函数。

类型、包、模块、测试和依赖注入是相互关联的概念。本章稍后会讲到依赖注入。有关包和模块参见模块、包和导入一章,测试参见编写测试一章。

类型声明不是继承

除了根据内置Go类型和结构体字面量声明类型外,也可以根据另一个自定义类型声明用户自定义类型:

type HighScore Score
type Employee Person

很多概念会被看成是面向对象,尤其是继承。这时父类型中声明的方法和状态可在子类型中使用,也可使用子类型的值来替换父类型。(那些计算机科学家的读者,我知道子类型不是继承。但大部分编程语言使用继承来实现子类型,所以在日常使用中经常会混为一谈。)

根据另一种类型声明类型看起来像是继承,但并不是。这两种类型有共同的底层类型,但仅此而已。这些类型没有等级之分。在具有继承的语言中,子实例可用在任何使用父级实例的地方。但在Go语言中并不是这样。不能将HighScore类型的实例赋给Score类型的变量,反过来也是,除非进行类型转换,也不能在没有做类型转换的情况下将它们赋值给int类型的变量。此外,为Score所定义的方法并没在HighScore上进行定义:

// 使用无类型常量赋值没有问题
var i int = 300
var s Score = 100
var hs HighScore = 200
hs = s                  // compilation error!
s = i                   // compilation error!
s = Score(i)            // ok
hs = HighScore(s)       // ok

对于底层类型为内置类型的自定义类型,可以使用这些内置类型的运算符。上例中可以看到,可对它们赋与底层类型兼容的字面量以及常量。

小贴士:对底层类型相同的类型做类型转化会保留同样的底层存储,但关联的方法不同。

类型是可执行文档

虽然都清楚应声明结构体类型来存储一组关联数据,但何时声明基于内置类型或其它自定义类型的自定义类型就不那么清楚了。简单的回答是类型即文档。通过为一个概念提供名称并描述所需数据类型让代码更清晰。对方法传入Percentage类型的参数会比int类型让读代码的人更清楚用途,这样在调用时就不太可能传入无效值。

这一逻辑同样适用基于另一个自定义类型声明新的自定义类型。在底层数据相同,但所执行的操作不同时,使用两种类型。基于另一个进行声明会避免重复并且也会让人清楚这两种类型是相关联的。

ioto(有时)用于枚举

很多编程语言都有枚举的概念,可用于指定具有一组有限值的类型。Go语言没有枚举类型。它有一个iota,可用于对一组常量赋递增的值。

注:iota的概念来自于APL语言(A Programming Language的简写)。APL严重依赖于自己的标记法,因此要求电脑使用特制键盘。比如(~R∊R∘.×R)/R←1↓ιR是一段APL程序,用于查找变量R的值之内的质数。

对于Go这种关注可读性的语言,从一种将极简发挥到病态的语言中借用概念可能看起来很讽刺,但这正是我们应该学习不同编程语言的原因:灵感无处不在。

在使用iota时,最佳实践是基于int定义一个用于表示所有有效值的类型:

type MailCategory int

接着使用const代码块来定义一组该类型的值:

const (
    Uncategorized MailCategory = iota
    Personal
    Spam
    Social
    Advertisements
)

const代码块中的第一个常量指定了类型并将值设置为iota。随后的各行既没有指定类型也没有赋值。Go编译器遇到这种情况时,会为随后的 所有变量赋值,每一行中对iota做递增。也就是对第一个常量(Uncategorized)赋值0,第二个常量(Personal)赋值1,以此类推。在新的const代码中,iota又重新设置为0.

下面是作者见过的有关iota最好的建议:

不要使用iota定义(在各处)显式定义了值的常量。例如,来实现某部分规格而其中又明确哪个常量的值为多少时,应当显式地写下常量值。仅将iota用作“内部”使用。换句话说,通过名称而不是值进行引用的常量。这样可以享受在任何时间或列表的任意位置插入新变量的好处,而又不会产生任何风险。

Danny van Heumen

需要知道Go不会阻止你(或其他人)为自定义类型创建其它值。此外如果在字面量列表中间插入新的标识符,所有后续的都会重新编号。如这些常量表示其它系统或数据库中的值时可能会让程序出现不易察觉的问题。由于存在这两个限制,基于iota的枚举仅在希望区分一组值而不在意背后的值时才有意义。如果实际的值很重要,则应显式定义。

警告:因可对常量赋字面量表达式,你可能会看到如下这种建议使用iota的示例代码:

type BitField int

const (
    Field1 BitField = 1 << iota // assigned 1
    Field2                      // assigned 2
    Field3                      // assigned 4
    Field4                      // assigned 8
)

虽然很聪明,但在使用这种模式时要小心。如果这么做,应写好注释。前面提到过,如果在意值的话使用iota可能会易错。你一定不希望未来的维护者在列表中间插入一个常量,导致代码崩溃。

注意iota是从0开始。如果使用一组常量表示不同一配置状态,零值可能会有用。在前面的MailCategory类型中就是这样。邮件到达时为未分类,因此零值正好适用。如果对于常量没有能自圆其说的默认值,通常的做法是将常量代码块中的第一个iota赋值给_,它表示值无效。这样更容易发现未正常初始化的变量。

使用内嵌实现组合

软件工程关于“组合优于继承”的建议可追溯到1994年的由Gamma、Helm、Johnson和Vlissides所著《设计模式》一书(艾迪生-韦斯利出版社),他们还有一个响当当的名号Gang of Four(或 GoF)。Go语言中没有继承,鼓励通过内置的组合和改进实现代码复用:

type Employee struct {
    Name         string
    ID           string
}

func (e Employee) Description() string {
    return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
    Employee
    Reports []Employee
}

func (m Manager) FindNewEmployees() []Employee {
    // do business logic
}

注意Manager中包含一个Employee类型字段,但没有给该字段命名。这样Employee就成为了内嵌字段。内嵌字段中声明的字段或方法在包含它的结构体中可直接进行调用。这样下面就是合法代码;

m := Manager{
    Employee: Employee{
        Name:         "Bob Bobson",
        ID:             "12345",
    },
    Reports: []Employee{},
}
fmt.Println(m.ID)            // prints 12345
fmt.Println(m.Description()) // prints Bob Bobson (12345)

注:在结构体中不止可以内嵌结构体,还可以嵌套其它任意类型。这样会将嵌套类型的方法上发给外层结构体。

如果外层结构体中有同名字段或方法,需要使用嵌套字段类型来调用被隐藏的方法。比如有如下的类型:

type Inner struct {
    X int
}

type Outer struct {
    Inner
    X int
}

只能显式地指定Inner才能访问Inner内的X

o := Outer{
    Inner: Inner{
        X: 10,
    },
    X: 20,
}
fmt.Println(o.X)       // prints 20
fmt.Println(o.Inner.X) // prints 10

嵌套不是继承

编程语言中内置嵌套是很罕见的(作者并不了解有支持它的知名语言)。很多熟知继承(在很多语言中都支持)的开发者会按照继承来理解嵌套。这背后是坑。并不能将Manager类型的变量赋值给Employee类型的变量。如果要访问Manager中的Employee字段,必须显式指定。可在The Go Playground中运行如下代码:

var eFail Employee = m        // compilation error!
var eOK Employee = m.Employee // ok!

得到的错误如下:

cannot use m (type Manager) as type Employee in assignment

此外,Go对具象类型没有动态调度(dynamic dispatch)的支持。嵌套字段的方法并不知道它是嵌套的。如果嵌套字段方法调用了该字段的另一个方法,而恰巧外层结构体具有同名方法,嵌套字段的方法并不会调用外层结构中的方法。我们在如下代码中进行了演示,请在The Go Playground中运行:

type Inner struct {
    A int
}

func (i Inner) IntPrinter(val int) string {
    return fmt.Sprintf("Inner: %d", val)
}

func (i Inner) Double() string {
    return i.IntPrinter(i.A * 2)
}

type Outer struct {
    Inner
    S string
}

func (o Outer) IntPrinter(val int) string {
    return fmt.Sprintf("Outer: %d", val)
}

func main() {
    o := Outer{
        Inner: Inner{
            A: 10,
        },
        S: "Hello",
    }
    fmt.Println(o.Double())
}

运行以上代码的输出如下:

Inner: 20

虽然在具象类型中嵌套另一种具象类型不能将外层类型当成内层类型处理,嵌套字段方法却能成为外层结构体的方法集。这样外层结构体就可以实现接口了。