如何在Go中使用接口

介绍

编写灵活、可复用和模块化的代码对于开发多功能的程序至关重要。这种工作方式确保代码更容易维护,因为它避免了在多个地方做出相同更改的需求。您如何实现这一点取决于语言的不同。例如,继承 是 Java、C++、C# 等语言中常用的方法。

开发者还可以通过 组合 实现同样的设计目标。组合是一种将对象或数据类型组合成更复杂的一个的方法。这是 Go 用来促进代码重用、模块化和灵活性的方法。Go 中的接口提供了一种组织复杂组合的方法,学习如何使用它们将允许您创建常见的可重用代码。

在本文中,我们将学习如何组合具有共同行为的自定义类型,这将允许我们重用我们的代码。我们还将学习如何为我们的自定义类型实现接口,以满足另一个包中定义的接口。

定义行为

一个核心的组成实现是使用接口。接口定义了一个类型的行为。Go标准库中最常用的接口之一是fmt.Stringer接口:

type Stringer interface {
    String() string
}

第一行代码定义了一个名为Stringertype。然后它声明它是一个interface。就像定义一个结构体,Go使用花括号({})来包围接口的定义。与定义结构体相比,我们只定义接口的行为;即“这个类型能做什么”。

Stringer接口的情况下,唯一的行为是String()方法。该方法不接受任何参数,并返回一个字符串。

接下来,让我们看一些具有fmt.Stringer行为的代码:

main.go
package main

import "fmt"

type Article struct {
	Title string
	Author string
}

func (a Article) String() string {
	return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author)
}

func main() {
	a := Article{
		Title: "Understanding Interfaces in Go",
		Author: "Sammy Shark",
	}
	fmt.Println(a.String())
}

我们首先创建一个名为Article的新类型。这个类型有一个Title和一个Author字段,两者都是字符串数据类型:

main.go
...
type Article struct {
	Title string
	Author string
}
...

接下来,我们在Article类型上定义一个方法,称为StringString方法将返回一个表示Article类型的字符串:

main.go
...
func (a Article) String() string {
	return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author)
}
...

然后在我们的main 函数中,我们创建了Article类型的一个实例,并将其赋值给名为a变量。我们为Title字段提供了"Understanding Interfaces in Go"的值,为Author字段提供了"Sammy Shark"的值:

main.go
...
a := Article{
	Title: "Understanding Interfaces in Go",
	Author: "Sammy Shark",
}
...

接着,我们通过调用fmt.Println并传入a.String()方法调用的结果,输出了String方法的结果:

main.go
...
fmt.Println(a.String())

运行程序后,您将看到以下输出:

Output
The "Understanding Interfaces in Go" article was written by Sammy Shark.

到目前为止,我们还没有使用接口,但我们确实创建了一个具有所需行为的数据类型。这种行为与fmt.Stringer接口相匹配。接下来,让我们看看如何使用这种行为来使我们的代码更加可重用。

定义接口

现在我们已经定义了具有所需行为的类型,我们可以看看如何使用这种行为。

但在我们这样做之前,让我们来看看如果我们想在函数中从Article类型调用String方法需要做些什么:

main.go
package main

import "fmt"

type Article struct {
	Title string
	Author string
}

func (a Article) String() string {
	return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author)
}

func main() {
	a := Article{
		Title: "Understanding Interfaces in Go",
		Author: "Sammy Shark",
	}
	Print(a)
}

func Print(a Article) {
	fmt.Println(a.String())
}

在这段代码中,我们添加了一个名为Print的新函数,它接收一个Article作为参数。注意,Print函数所做的唯一事情是调用String方法。因此,我们可以定义一个接口传递给该函数:

main.go
package main

import "fmt"

type Article struct {
	Title string
	Author string
}

func (a Article) String() string {
	return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author)
}

type Stringer interface {
	String() string
}

func main() {
	a := Article{
		Title: "Understanding Interfaces in Go",
		Author: "Sammy Shark",
	}
	Print(a)
}

func Print(s Stringer) {
	fmt.Println(s.String())
}

在这里,我们创建了一个名为Stringer的接口:

main.go
...
type Stringer interface {
	String() string
}
...

Stringer接口只有一个方法,名为String(),它返回一个string。一个方法是Go中针对特定类型的作用域的特殊函数。与函数不同,方法只能从它定义的类型实例上调用。

然后,我们将Print方法的签名更新为接受一个Stringer,而不是具体的Article类型。因为编译器知道Stringer接口定义了String方法,所以它只会接受也有String方法的类型。

现在,我们可以使用满足Stringer接口的任何东西调用Print方法。让我们创建另一个类型来演示这一点:

main.go
package main

import "fmt"

type Article struct {
	Title  string
	Author string
}

func (a Article) String() string {
	return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author)
}

type Book struct {
	Title  string
	Author string
	Pages  int
}

func (b Book) String() string {
	return fmt.Sprintf("The %q book was written by %s.", b.Title, b.Author)
}

type Stringer interface {
	String() string
}

func main() {
	a := Article{
		Title:  "Understanding Interfaces in Go",
		Author: "Sammy Shark",
	}
	Print(a)

	b := Book{
		Title:  "All About Go",
		Author: "Jenny Dolphin",
		Pages:  25,
	}
	Print(b)
}

func Print(s Stringer) {
	fmt.Println(s.String())
}

我们现在添加了第二个名为Book的类型。它也定义了String方法。这意味着它也满足了Stringer接口。因此,我们也可以将它发送到我们的Print函数:

Output
The "Understanding Interfaces in Go" article was written by Sammy Shark. The "All About Go" book was written by Jenny Dolphin. It has 25 pages.

目前为止,我们已经展示了如何使用单个接口。但是,接口可以定义多个行为。接下来,我们将看到如何使我们的接口更加灵活,通过声明更多方法。

接口中的多重行为

编写Go代码的一个核心原则是编写小而简洁的类型,然后将它们组合成更大、更复杂的类型。接口也是如此。要了解我们如何构建接口,首先我们需要定义一个接口,该接口只定义了一个方法。我们将定义两个形状,CircleSquare,它们都将定义一个名为Area的方法。这个方法将返回它们各自的几何面积:

main.go
package main

import (
	"fmt"
	"math"
)

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * math.Pow(c.Radius, 2)
}

type Square struct {
	Width  float64
	Height float64
}

func (s Square) Area() float64 {
	return s.Width * s.Height
}

type Sizer interface {
	Area() float64
}

func main() {
	c := Circle{Radius: 10}
	s := Square{Height: 10, Width: 5}

	l := Less(c, s)
	fmt.Printf("%+v is the smallest\n", l)
}

func Less(s1, s2 Sizer) Sizer {
	if s1.Area() < s2.Area() {
		return s1
	}
	return s2
}

由于每个类型都定义了Area方法,我们可以创建一个定义此行为的接口。我们创建以下Sizer接口:

main.go
...
type Sizer interface {
	Area() float64
}
...

然后我们定义一个名为Less的函数,它接受两个Sizer并返回最小的那个:

main.go
...
func Less(s1, s2 Sizer) Sizer {
	if s1.Area() < s2.Area() {
		return s1
	}
	return s2
}
...

注意我们不仅接受两个参数为Sizer类型,而且返回结果也为Sizer类型。这意味着我们不再返回一个Square或一个Circle,而是Sizer接口。

最后,我们打印出哪个具有最小的面积:

Output
{Width:5 Height:10} is the smallest

接下来,让我们为每种类型添加另一个行为。这次我们将添加一个返回字符串的 String() 方法。这将满足 fmt.Stringer 接口:

main.go
package main

import (
	"fmt"
	"math"
)

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * math.Pow(c.Radius, 2)
}

func (c Circle) String() string {
	return fmt.Sprintf("Circle {Radius: %.2f}", c.Radius)
}

type Square struct {
	Width  float64
	Height float64
}

func (s Square) Area() float64 {
	return s.Width * s.Height
}

func (s Square) String() string {
	return fmt.Sprintf("Square {Width: %.2f, Height: %.2f}", s.Width, s.Height)
}

type Sizer interface {
	Area() float64
}

type Shaper interface {
	Sizer
	fmt.Stringer
}

func main() {
	c := Circle{Radius: 10}
	PrintArea(c)

	s := Square{Height: 10, Width: 5}
	PrintArea(s)

	l := Less(c, s)
	fmt.Printf("%v is the smallest\n", l)

}

func Less(s1, s2 Sizer) Sizer {
	if s1.Area() < s2.Area() {
		return s1
	}
	return s2
}

func PrintArea(s Shaper) {
	fmt.Printf("area of %s is %.2f\n", s.String(), s.Area())
}

因为 CircleSquare 类型都实现了 AreaString 方法,所以现在我们可以创建另一个接口来描述更广泛的行为集合。为此,我们将创建一个名为 Shaper 的接口。我们将由 Sizer 接口和 fmt.Stringer 接口组成:

main.go
...
type Shaper interface {
	Sizer
	fmt.Stringer
}
...

注意: 通常认为以 er 结尾来命名接口是习惯用法,比如 fmt.Stringerio.Writer 等。这就是为什么我们将我们的接口命名为 Shaper,而不是 Shape

现在我们可以创建一个名为 PrintArea 的函数,它接收一个 Shaper 作为参数。这意味着我们可以对传入的值调用 AreaString 方法:

main.go
...
func PrintArea(s Shaper) {
	fmt.Printf("area of %s is %.2f\n", s.String(), s.Area())
}

如果我们运行程序,将收到以下输出:

Output
area of Circle {Radius: 10.00} is 314.16 area of Square {Width: 5.00, Height: 10.00} is 50.00 Square {Width: 5.00, Height: 10.00} is the smallest

我们现在已经看到了如何创建更小的接口并将它们构建成更大的接口。虽然我们可以从更大的接口开始,并将其传递给所有的函数,但通常认为最佳实践是只将所需的最小接口传递给函数。这通常会导致代码更清晰,因为任何接受特定更小接口的东西只打算与定义的行为一起工作。

例如,如果我们向 Less 函数传递了 Shaper,我们可能会假设它会调用其 AreaString 方法。然而,由于我们只想调用 Area 方法,那么 Less 函数就变得清晰明了,因为我们知道它只能调用任何传递给它的参数的 Area 方法。

结论

我们已经看到了如何创建更小的接口,并将其构建为更大的接口,从而允许我们在函数或方法中只共享我们需要的东西。我们还了解到,我们可以从其他包定义的接口中组合接口,而不仅仅是我们的包。

如果你想了解更多关于 Go 编程语言的知识,可以查看整个 《如何在 Go 中编码》系列

Source:
https://www.digitalocean.com/community/tutorials/how-to-use-interfaces-in-go