[Go 入门] 第七章 创建方法和接口

Go 入门系列参考于互联网资料与 人民邮电出版社 《Go 语言入门经典》 与 《Effective Go》,编写目的在于学习交流,如有侵权,请联系删除

在遇到更复杂的操作时, Go提供了另外一种操作数据的方式——通过方法来操作, 本文内容:

使用方法

方法类似于函数,但有一点不同:在关键字func后面添加了另一个参数部分,用于接收单个参数,下面给结构体Movie添加了一个方法

1
2
3
4
5
6
7
8
type Movie struct {
Name string
Rating float32
}

func (m *Movie) summary() string {
//code
}

在方法声明中,关键字func后面多了一个参数——接收者。严格地说,方法接收者是一种类型,这里是指向结构体Movie指针。接下来是方法名、参数以及返回类型。除多了包含接收者的参数部分外,方法函数完全相同。可将接收者视为与方法相关联的东西。通过声明方法summary,让结构体Movie的任何实例都可以使用它,为何使用方法,而不是用函数呢?例如,下面是方法和函数的等价声明

1
2
3
4
5
6
7
8
type Movie struct {
Name string
Rating float64
}

func summary(m *Movie) string {
//code
}

函数summary和结构体Movie相互依赖,但它们之间没有直接关系。例如,如果不能访问结构体Movie的定义,就无法声明函数summary。如果使用函数,则在每个使用函数或者结构体的地方,都需要包含函数和结构体的定义,这回导致代码重复

方法summary的实现将float64转换为字符串并设置其格式。使用方法的优点就在于,只需编写方法实现一次,就可以对结构体的任何实例进行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"strconv"
)

type Movie struct {
Name string
Rating float64
}

func (m *Movie) summary() string {
r := strconv.FormatFloat(m.Rating, 'f', 1, 64)
return m.Name + "," + r
}

func main() {
m := Movie{
Name: "Spider man",
Rating: 3.2,
}
fmt.Println(m.summary())
}

创建方法集

方法集是可对特定数据类型进行调用的一组方法。Go语言中,任何数据类型都可有相关联的方法集,这可以在数据类型和方法之间建立关系。方法集可包含的方法数量不受限制,这是一种封装功能和创建库代码的有效方式

处理球体时,计算其表面积和体积。在这种情况下,非常适合使用结构体和方法集,通过使用方法集,只需要创建一次计算代码,就可以适用于任何球体计算,可声明结构体Sphere,再声明两个将结构体Sphere作为接收者的方法

1
2
3
4
5
6
7
8
9
10
11
12
type Sphere struct {
Radius float64
}

func (s *Sphere) SurfaceArea() float64 {
return float64(4) * math.Pi * (s.Radius * s.Radius)
}

func (s *Sphere) Volume() float64 {
radiusCubed := s.Radius * s.Radius * s.Radius
return (float64(4) / float64(3)) * math.Pi * radiusCubed
}

这里声明了计算球体表面积和体积的方法,并像通常定义函数签名。唯一不同的是添加了一个接收者的参数,这里是一个指向Sphere实例的指针。

使用方法和指针

与结构体一样,明白如何使用方法和指针也很重要。方法是一个接收被称为接收者的特殊参数的函数,接收者可以是指针,也可以是值,但两者的差别非常微妙。设有一个储存三角形数据的结构体

1
2
3
4
type Triangle struct {
width float64
height float64
}

为了计算出三角形的面积,一个简单的公式是将高度和底相乘,再乘以 。方法area返回前述公式的结果,接收者是指向结构体Triangle的指针,这是由星号指定的

1
2
3
func (t *Triangle) area() float64 {
return 0.5 * (t.width * t.height)
}

为了理解接收者参数声明为指针引用和值引用的差别,修改结构体中定义的三角形的底值,添加方法changeBase来实现

1
2
3
4
func (t Triangle) changeBase(f float64) {
t.base = f
return
}

这个示例中,没有在Triangle前面加上星号,这意味着接收者参数是值而不是指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

type Triangle struct {
base float64
height float64
}

func (t Triangle) changeBase (f float64) {
t.base = f
return
}

func main() {
t := Triangle{
base: 3,
height: 1,
}
t.changeBase(4)
fmt.Println(t.base)
}

上述代码的执行输出为 3,这是因为方法changeBase接收的是一个值引用,意味着这个方法操作的是结构体Triangle的副本,而原始的结构体不受影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

type Triangle struct {
base float64
height float64
}

func (t *Triangle) changeBase (f float64) {
t.base = f
return
}

func main() {
t := Triangle{
base: 3,
height: 1,
}
t.changeBase(4)
fmt.Println(t.base)
}

将接收者参数的类型声明为指针,则输出为4,这是因为使用了指向原始结构体内存单元的指针,因此操作的不是原始结构体的副本。

选择使用指针还是值很简单:如果需要修改原始结构体,就使用指针,如果不需要,就使用值

使用接口

Go 语言中,接口指定了一个方法集,这是实现模块化的强大方式。可视接口为方法集的蓝本,它描述了方法集中的所有方法,但没有实现它们。接口功能强大,因为它充当了方法集的规范,这意味着可在符合接口要求的前提下随便更换实现

接口描述了方法集中的所有方法, 并指定了每个方法的函数签名。下面假设需要编写一些控制机器人的代码,可假定有多重类型的机器人,控制的方式存在着差别。通过使用接口,可将代码复用于任何相同行为的实体,下面接口描述了开关机器人的方式

1
2
3
type Robot interface {
PowerOn() error
}

接口 Robot 只包含了一种方法——PowerOn。这个接口描述了方法 PowerOn 的函数签名:不接收任何参数且返回一种错误类型。

如何使用接口呢?接口是方法集的蓝本,要使用接口,必须先实现它。如果代码满足了接口的要求,就实现了接口。要实现接口 Robot,可声明一个满足其要求的方法集

1
2
3
4
5
6
7
type T850 struct {
Name stirng
}

func (a *T850) PowerOn() error {
return nil
}

这个实现很简单,但满足了接口 Robot 的要求,因为它包含方法 PowerOn,且这个方法的函数签名与接口 Robot 的要求一致。接口的强大之处在于,它们支持多种的实现。例如,也可以像下面这样来实现接口 Robot

1
2
3
4
5
6
7
8
9
10
11
type R2D2 struct {
Broken bool
}

func (r *R2D2) PowerOn() error {
if r.Broken {
return errors.New("R2D2 is broken")
} else {
return nil
}
}

这也满足了接口 Robot 的要求,因为它符合这个方法集的定义——包含方法 PowerOn,同时,函数签名也相同,这里与方法集相关联的结构体是 R2D2,它包含的数据字段与 T850 不一样,方法 PowerOn 的代码也完全不同,但是函数签名一样

要满足接口的要求,只要实现了它指定的方法集,且函数签名正确无误就可以了。

当前,接口 Robot 有两种实现,虽然有相同的 Robot 定义很有用,但没有可同时用于 T850 和 R2D2 实例的代码。接口也是一种类型,可作为参数传递给函数,因此可编写可复用于多个接口实现的函数

编写一个可用于启动任何机器人的函数

1
2
3
func Boot(r Robot) error {
return r.Poweron()
}

这个函数将接口 Robot 的实现作为参数,并返回调用方法 PowerOn 的结果。这个函数可用于启动任何机器人,而不管方法是如何实现的,下面是完整的启动 T850 和 R2D2 的复用代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
"errors"
"fmt"
)

type Robot interface {
PowerOn() error
}

type T850 struct {
Name string
}

func (a *T850) PowerOn() error {
return nil
}

type R2D2 struct {
Broken bool
}

func (r *R2D2) PowerOn() error {
if r.Broken {
return errors.New("R2D2 is broken")
} else {
return nil
}
}

func Boot(r Robot) error {
return r.PowerOn()
}

func main() {
t := T850{Name: "The Terminator"}
r := R2D2{Broken: true}

err := Boot(&r)

if err != nil {
fmt.Println(err)
} else {
fmt.Println("Robot is powered on !!!")
}

err = Boot(&t)

if err != nil {
fmt.Println(err)
} else {
fmt.Println("Robot is powered on !!!")
}
}

接口提供的抽象层可能有点复杂,但它有助于代码复用。如果现在使用 MySQL 数据库来编写计算机程序,如果不适用接口,那么代码是完全针对 MySQL 的,这种情况,如果后来要将 MySQL 换成其他数据库,可能就需要重写大量的代码。

通过定义一个数据库接口,该接口的实现将比使用的数据库更重要。理论上说,只要实现满足接口的要求,就可以用任何数据库,因为可以轻松的更换数据库,数据库接口可包含多个实现,就引入了多态的概念

多态意味着多种形式,它能让接口有多种实现。Go语言中,接口以声明的方式提供了多台,因为接口描述了必须提供的方法集以及这些方法的函数签名。如果一个方法集实现了一个接口,就可以说它与另一个实现了该接口的方法集互为多态。编译器也会验证接口:检查方法集并确保接口确定是多态的。通过将接口正式化,可确保接口的两种实现是多态的,这无疑会让代码可验证、可测试是灵活的。

Go 语言是面向对象的吗

对结构体和方法有基本认识后,如果熟悉其他语言,可能会想:Go 是面向对象的吗?面向对象编程是这样一种编程范式:使用具体特定行为的对象来建立数据模型。通常,面向对象语言提供允许一种对象集成另一种对象的功能。虽然Go语言没有提供类和类继承等面向对象功能,但结构体和方法集弥补了这部分不足,提供了一些面向对象元素。由此可以说Go在不使用类和继承的情况下提供了类似于面向对象编程的功能,虽然这一点还存在着争议。

问题列表

  • 函数和方法有何不同 ?

    严格地说,方法和函数的唯一差别在于,方法多了一个指定接收者的参数,这让您能够对数据类型调用方法,从而提供代码复用性和模块化程度

  • 接口的实现可包含接口中没有的方法吗?

    可以。可在接口的实现中,添加额外的方法,但这适用于结构体,而不是用于接口。