跳转到主要内容

介绍


函数允许您将逻辑组织成可重复的过程,每次运行时可以使用不同的参数。 在定义函数的过程中,您经常会发现多个函数每次都可能对同一条数据进行操作。 Go 识别这种模式并允许您定义称为方法的特殊函数,其目的是对某种特定类型的实例进行操作,称为接收器。 向类型添加方法使您不仅可以传达数据是什么,还可以传达应该如何使用该数据。

定义一个方法


定义方法的语法类似于定义函数的语法。 唯一的区别是在 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)
}

如果运行此代码,输出将是:

Output
Sammy says Hello!


我们创建了一个名为 Creature 的结构,其中包含 Name 和 Greeting 的字符串字段。此 Creature 定义了一个方法,即 Greet。在接收者声明中,我们将 Creature 的实例分配给变量 c,以便我们在 fmt.Printf 中组装问候消息时可以引用 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()
}

如果您运行它,输出将与前面的示例相同:

Output
Sammy 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")
}

如果运行此代码,输出将如下所示:

Output
Sammy says Hello!!
Farewell gophers !
Sammy says Hello!!
Farewell gophers !


我们修改了前面的示例,引入了另一个名为 SayGoodbye 的方法,还更改了 Greet 以返回一个 Creature,以便我们可以在该实例上调用更多方法。在 main 的主体中,我们首先使用点表示法然后使用函数调用样式在 sammy 变量上调用方法 Greet 和 SayGoodbye。

两种样式都输出相同的结果,但使用点符号的示例更具可读性。点链还告诉我们调用方法的顺序,函数式风格将这个顺序颠倒过来。向 SayGoodbye 调用添加参数进一步模糊了方法调用的顺序。点表示法的清晰性是它成为 Go 中调用方法的首选样式的原因,无论是在标准库中还是在您将在整个 Go 生态系统中找到的第三方包中。

在类型上定义方法,而不是定义对某个值进行操作的函数,对 Go 编程语言具有其他特殊意义。方法是接口背后的核心概念。

接口


当您在 Go 中为任何类型定义方法时,该方法将添加到该类型的方法集中。方法集是与该类型关联的作为方法的函数的集合,Go 编译器使用它来确定是否可以将某种类型分配给具有接口类型的变量。接口类型是编译器用来保证类型为这些方法提供实现的方法规范。任何具有与接口定义中相同名称、相同参数和相同返回值的方法的类型都被称为实现了该接口,并允许将其分配给具有该接口类型的变量。以下是标准库中 fmt.Stringer 接口的定义:

type Stringer interface {
  String() string
}


对于实现 fmt.Stringer 接口的类型,它需要提供一个返回字符串的 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)
}

运行代码时,您将看到以下输出:

Output
ocean contains : sea urchin, lobster, shark


此示例定义了一个名为 Ocean 的新结构类型。据说 Ocean 实现了 fmt.Stringer 接口,因为 Ocean 定义了一个名为 String 的方法,该方法不接受任何参数并返回一个字符串。在 main 中,我们定义了一个新的 Ocean 并将其传递给 log 函数,该函数首先打印一个字符串,然后是实现 fmt.Stringer 的任何内容。 Go 编译器允许我们在这里传递 o,因为 Ocean 实现了 fmt.Stringer 请求的所有方法。在 log 中,我们使用 fmt.Println,它在遇到 fmt.Stringer 作为其参数之一时调用 Ocean 的 String 方法。

如果 Ocean 没有提供 String() 方法,Go 会产生编译错误,因为 log 方法请求 fmt.Stringer 作为其参数。错误如下所示:

Output
src/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 接口请求的方法完全匹配。如果没有,它将产生如下所示的错误:

Output
src/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()
}

运行此示例时,您将看到以下输出:

Output
The S.S. DigitalOcean has the following occupants:
     Sammy the Shark
     Larry the Lobster


此示例定义了具有名称和乘员的船类型。我们希望强制其他包中的代码仅使用 AddOccupant 方法添加占用者,因此我们通过将字段名称的第一个字母小写来使占用者字段不导出。我们还想确保调用 AddOccupant 会导致 Boat 的实例被修改,这就是我们在指针接收器上定义 AddOccupant 的原因。指针充当对类型的特定实例的引用,而不是该类型的副本。知道将使用指向 Boat 的指针调用 AddOccupant 可以保证任何修改都将持续存在。

在 main 中,我们定义了一个新变量 b,它将保存一个指向船 (*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)
}

运行代码时,您将看到以下输出:

Output
Sammy is on the surface
Sammy is underwater


此示例定义了一个名为 Submersible 的接口,该接口需要具有 Dive() 方法的类型。然后我们定义了一个带有 Name 字段的 Shark 类型和一个 isUnderwater 方法来跟踪 Shark 的状态。我们在 Shark 的指针接收器上定义了一个 Dive() 方法,该方法将 isUnderwater 修改为 true。我们还定义了值接收器的 String() 方法,以便它可以使用我们之前看到的 fmt.Println 接受的 fmt.Stringer 接口使用 fmt.Println 干净地打印 Shark 的状态。我们还使用了一个带有 Submersible 参数的函数 submerge。

使用 Submersible 接口而不是 *Shark 允许 submerge 函数仅依赖于类型提供的行为。这使得 submerge 函数更具可重用性,因为您不必为 Submarine、Whale 或我们尚未想到的任何其他未来水生​​生物编写新的 submerge 函数。只要它们定义了一个 Dive() 方法,它们就可以与 submerge 函数一起使用。

在 main 中,我们定义了一个变量 s,它是指向 Shark 的指针,并立即使用 fmt.Println 打印 s。这显示了输出的第一部分,Sammy 在表面上。我们将 s 传递给 submerge,然后以 s 作为参数再次调用 fmt.Println 以查看输出的第二部分,Sammy 在水下。

如果我们将 s 更改为 Shark 而不是 *Shark,Go 编译器将产生错误:

Output
cannot 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 为这个极其常见的函数定义提供了一些便利,并将它们收集到可以通过接口类型推理的方法集中。有效地使用方法将允许您使用代码中的接口来提高可测试性,并为您的代码的未来读者留下更好的组织。

文章链接