在Go中定义方法
###介绍
Functions 允许您将逻辑组织成可重复的过程,每次运行时可以使用不同的参数。 在定义函数的过程中,您经常会发现多个函数每次都可能对同一条数据进行操作。 Go 识别这种模式并允许您定义特殊函数,称为 methods,其目的是对某些特定类型的实例进行操作,称为 receiver。 向类型添加方法使您不仅可以传达数据是什么,还可以传达应该如何使用该数据。
定义一个方法
定义方法的语法类似于定义函数的语法。 唯一的区别是在 func
关键字之后添加了一个额外的参数,用于指定方法的接收者。 接收者是您希望在其上定义方法的类型的声明。 以下示例在结构类型上定义了一个方法:
package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() { fmt.Printf("%s says %s", c.Name, c.Greeting) } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } Creature.Greet(sammy) }
如果运行此代码,输出将是:
OutputSammy says Hello!
我们为 Name
和 Greeting
创建了一个名为 Creature
的结构体,其中包含 string
字段。 这个 Creature
定义了一个方法,Greet
。 在接收者声明中,我们将 Creature
的实例分配给变量 c
以便我们在 [X190X 中组装问候消息时可以引用 Creature
的字段]。
在其他语言中,方法调用的接收者通常由关键字(例如 this
或 self
)。 Go 将接收器视为与其他任何变量一样的变量,因此您可以随意命名它。 社区对此参数的首选样式是接收器类型的第一个字符的小写版本。 在此示例中,我们使用 c
,因为接收器类型是 Creature
。
在 main
的主体中,我们创建了 Creature
的一个实例,并为其 Name
和 Greeting
字段指定了值。 我们在这里调用了 Greet
方法,方法是将类型名称和方法名称与 .
连接起来,并提供 Creature
的实例作为第一个参数。
Go 提供了另一种更方便的方法来调用结构实例的方法,如下例所示:
package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() { fmt.Printf("%s says %s", c.Name, c.Greeting) } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } sammy.Greet() }
如果您运行它,输出将与前面的示例相同:
OutputSammy says Hello!
此示例与上一个示例相同,但这次我们使用 点表示法 调用 Greet
方法,使用存储在 sammy
中的 Creature
变量作为接收器。 这是第一个示例中函数调用的简写符号。 标准库和 Go 社区非常喜欢这种风格,以至于你很少会看到前面展示的函数调用风格。
下一个示例显示了点表示法更普遍的一个原因:
package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() Creature { fmt.Printf("%s says %s!\n", c.Name, c.Greeting) return c } func (c Creature) SayGoodbye(name string) { fmt.Println("Farewell", name, "!") } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } sammy.Greet().SayGoodbye("gophers") Creature.SayGoodbye(Creature.Greet(sammy), "gophers") }
如果运行此代码,输出将如下所示:
OutputSammy says Hello!! Farewell gophers ! Sammy says Hello!! Farewell gophers !
我们修改了前面的示例以引入另一个名为 SayGoodbye
的方法,并且还更改了 Greet
以返回 Creature
以便我们可以在该实例上调用更多方法。 在 main
的主体中,我们首先使用点表示法然后使用函数调用样式在 sammy
变量上调用方法 Greet
和 SayGoodbye
。
两种样式都输出相同的结果,但使用点符号的示例更具可读性。 点链还告诉我们调用方法的顺序,函数式风格将这个顺序颠倒过来。 在 SayGoodbye
调用中添加参数进一步模糊了方法调用的顺序。 点表示法的清晰性是它成为 Go 中调用方法的首选样式的原因,无论是在标准库中还是在您将在整个 Go 生态系统中找到的第三方包中。
在类型上定义方法,而不是定义对某个值进行操作的函数,对 Go 编程语言具有其他特殊意义。 方法是接口背后的核心概念。
接口
当您在 Go 中为任何类型定义方法时,该方法将添加到该类型的 方法集 。 方法集是与该类型关联的作为方法的函数的集合,Go 编译器使用它来确定是否可以将某种类型分配给具有接口类型的变量。 接口类型 是编译器使用的方法规范,以保证类型为这些方法提供实现。 任何具有与接口定义中相同名称、相同参数和相同返回值的方法的类型都被称为 implement 该接口,并且允许将其分配给具有该接口类型的变量。 以下是标准库中fmt.Stringer
接口的定义:
type Stringer interface { String() string }
对于实现 fmt.Stringer
接口的类型,它需要提供返回 string
的 String()
方法。 当您将类型的实例传递给 fmt
包中定义的函数时,实现此接口将允许您的类型完全按照您的意愿打印(有时称为“漂亮打印”)。 以下示例定义了实现此接口的类型:
package main import ( "fmt" "strings" ) type Ocean struct { Creatures []string } func (o Ocean) String() string { return strings.Join(o.Creatures, ", ") } func log(header string, s fmt.Stringer) { fmt.Println(header, ":", s) } func main() { o := Ocean{ Creatures: []string{ "sea urchin", "lobster", "shark", }, } log("ocean contains", o) }
运行代码时,您将看到以下输出:
Outputocean contains : sea urchin, lobster, shark
此示例定义了一个名为 Ocean
的新结构类型。 Ocean
被称为 implement fmt.Stringer
接口,因为 Ocean
定义了一个名为 String
的方法,它不接受任何参数并返回一个 [ X157X]。 在 main
中,我们定义了一个新的 Ocean
并将其传递给 log
函数,该函数首先打印出 string
,然后是任何实现fmt.Stringer
。 Go 编译器允许我们在此处传递 o
,因为 Ocean
实现了 fmt.Stringer
请求的所有方法。 在 log
中,我们使用 fmt.Println
,它在遇到 fmt.Stringer
作为其参数之一时调用 Ocean
的 String
方法。
如果 Ocean
没有提供 String()
方法,Go 会产生编译错误,因为 log
方法请求 fmt.Stringer
作为其参数。 错误如下所示:
Outputsrc/e4/main.go:24:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log: Ocean does not implement fmt.Stringer (missing String method)
Go 还将确保提供的 String()
方法与 fmt.Stringer
接口请求的方法完全匹配。 如果没有,它将产生如下所示的错误:
Outputsrc/e4/main.go:26:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log: Ocean does not implement fmt.Stringer (wrong type for String method) have String() want String() string
在到目前为止的示例中,我们已经在值接收器上定义了方法。 也就是说,如果我们使用方法的函数调用,第一个参数,指的是定义方法的类型,将是该类型的值,而不是 指针。 因此,当方法完成执行时,我们对提供给方法的实例所做的任何修改都将被丢弃,因为接收到的值是数据的副本。 也可以在类型的指针接收器上定义方法。
指针接收器
在指针接收器上定义方法的语法几乎与在值接收器上定义方法相同。 不同之处在于在接收者声明中的类型名称前加上星号 (*
)。 以下示例在指向类型的指针接收器上定义了一个方法:
package main import "fmt" type Boat struct { Name string occupants []string } func (b *Boat) AddOccupant(name string) *Boat { b.occupants = append(b.occupants, name) return b } func (b Boat) Manifest() { fmt.Println("The", b.Name, "has the following occupants:") for _, n := range b.occupants { fmt.Println("\t", n) } } func main() { b := &Boat{ Name: "S.S. DigitalOcean", } b.AddOccupant("Sammy the Shark") b.AddOccupant("Larry the Lobster") b.Manifest() }
运行此示例时,您将看到以下输出:
OutputThe S.S. DigitalOcean has the following occupants: Sammy the Shark Larry the Lobster
此示例使用 Name
和 occupants
定义了 Boat
类型。 我们希望强制其他包中的代码仅使用 AddOccupant
方法添加占用者,因此我们通过小写字段名称的第一个字母使 occupants
字段不导出。 我们还想确保调用 AddOccupant
将导致 Boat
的实例被修改,这就是我们在指针接收器上定义 AddOccupant
的原因。 指针充当对类型的特定实例的引用,而不是该类型的副本。 知道将使用指向 Boat
的指针调用 AddOccupant
可以保证任何修改都将持续存在。
在 main
中,我们定义了一个新变量 b
,它将保存指向 Boat
(*Boat
) 的指针。 我们在这个实例上调用了两次 AddOccupant
方法来添加两个乘客。 Manifest
方法是在 Boat
值上定义的,因为在其定义中,接收者被指定为 (b Boat)
。 在 main
中,我们仍然可以调用 Manifest
,因为 Go 能够自动取消引用指针以获取 Boat
值。 这里的 b.Manifest()
等价于 (*b).Manifest()
。
在尝试将值分配给接口类型的变量时,是否在指针接收器或值接收器上定义方法具有重要意义。
指针接收器和接口
当您为具有接口类型的变量分配值时,Go 编译器将检查所分配类型的方法集,以确保它具有接口期望的方法。 指针接收器和值接收器的方法集是不同的,因为接收指针的方法可以修改它们的接收器,而那些接收值的方法不能。
下面的示例演示了定义两种方法:一种在类型的指针接收器上和在其值接收器上。 但是,只有指针接收器才能满足同样在此示例中定义的接口:
package main import "fmt" type Submersible interface { Dive() } type Shark struct { Name string isUnderwater bool } func (s Shark) String() string { if s.isUnderwater { return fmt.Sprintf("%s is underwater", s.Name) } return fmt.Sprintf("%s is on the surface", s.Name) } func (s *Shark) Dive() { s.isUnderwater = true } func submerge(s Submersible) { s.Dive() } func main() { s := &Shark{ Name: "Sammy", } fmt.Println(s) submerge(s) fmt.Println(s) }
运行代码时,您将看到以下输出:
OutputSammy is on the surface Sammy is underwater
此示例定义了一个名为 Submersible
的接口,该接口需要具有 Dive()
方法的类型。 然后我们定义了一个带有 Name
字段的 Shark
类型和一个 isUnderwater
方法来跟踪 Shark
的状态。 我们在指向 Shark
的指针接收器上定义了一个 Dive()
方法,它将 isUnderwater
修改为 true
。 我们还定义了值接收器的 String()
方法,以便它可以使用 fmt.Println
干净地打印 Shark
的状态,方法是使用接受的 fmt.Stringer
接口我们之前看过的fmt.Println
。 我们还使用了一个带有 Submersible
参数的函数 submerge
。
使用 Submersible
接口而不是 *Shark
允许 submerge
函数仅依赖于类型提供的行为。 这使得 submerge
函数更具可重用性,因为您不必为 Submarine
、Whale
或任何其他未来的水生生物编写新的 submerge
函数我们还没有想到的居民。 只要定义了Dive()
方法,就可以和submerge
函数一起使用。
在 main
中,我们定义了一个变量 s
,它是指向 Shark
的指针,并立即用 fmt.Println
打印 s
。 这显示了输出的第一部分,Sammy is on the surface
。 我们将 s
传递给 submerge
然后再次调用 fmt.Println
并使用 s
作为参数来查看输出的第二部分,[X147X ]。
如果我们将 s
更改为 Shark
而不是 *Shark
,Go 编译器将产生错误:
Outputcannot use s (type Shark) as type Submersible in argument to submerge: Shark does not implement Submersible (Dive method has pointer receiver)
Go 编译器有用地告诉我们 Shark
确实有一个 Dive
方法,它只是在指针接收器上定义的。 当您在自己的代码中看到此消息时,解决方法是在分配值类型的变量之前使用 &
运算符传递指向接口类型的指针。
结论
在 Go 中声明方法最终与定义接收不同类型变量的函数没有什么不同。 使用指针 的相同规则适用。 Go 为这个极其常见的函数定义提供了一些便利,并将它们收集到可以通过接口类型推理的方法集中。 有效地使用方法将允许您使用代码中的接口来提高可测试性,并为您的代码的未来读者留下更好的组织。
如果您想全面了解 Go 编程语言的更多信息,请查看我们的 如何在 Go 中编写代码系列 。