【Golang】Go语言编程思想(一):接口

接口

接口的概念

现在我们要实现一个函数,用于对给定的 url 进行解析,具体的代码实现如下:

go 复制代码
package main

import (
	"fmt"
	"io"
	"net/http"
)

func retrieve(url string) string {
	resp, err := http.Get(url)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	bytes, _ := io.ReadAll(resp.Body)
	return string(bytes)
}

func main() {
	url := "https://www.baidu.com"
	fmt.Println(retrieve(url))
}

现在我们假设在写一个较大的工程,有一个专门负责网络架构的团队来完成网络请求、磁盘读写等需求的实现,保存在目录 infra 下,其中实现了一个 Retriever 保存在 urlretriever.go 文件下。

Retriever 的结构和方法的具体实现如下:

go 复制代码
package infra

import (
	"io"
	"net/http"
)

type Retriever struct{}

func (Retriever) Get(url string) string {
	// 接收者在不需要名字的时候可以只写类型
	resp, err := http.Get(url)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	bytes, _ := io.ReadAll(resp.Body)
	return string(bytes)
}

此时在 main 函数中,只需要新建一个 infra.Retriever 类型并使用其方法 Get 即可完成与上述代码等价的需求:

go 复制代码
package main

import (
	"fmt"
	"learngo/infra"
)

func main() {
	retriever := infra.Retriever{}
	url := "https://www.baidu.com"
	fmt.Println(retriever.Get(url))
}

上述代码还可以进一步地工程化,因为在大型项目当中,可能不止网络开发团队实现了 Retriever,测试团队可能同样开发了 Retriever 用于这部分代码的测试。所以显式的 infra.Retriever{ } 可以使用函数进行代替:

go 复制代码
package main

import (
	"fmt"
	"learngo/infra"
)

func getRetriever() infra.Retriever {
	return infra.Retriever{}
}

func main() {
	var retriever infra.Retriever
	retriever = getRetriever()
	url := "https://www.baidu.com"
	fmt.Println(retriever.Get(url))
}

此时我们注意到,上述代码还是不够好,因为就算将 infra.Retriever{ } 放到函数当中,main 函数当中的 retriever 的类型仍然是显式或隐式为 infra.Retriever 的。

瑕疵在于,main 函数当中的 retriever 必须是 infra.Retriever 类型的,main 函数与 infra.Retriever 的耦合较深。如果想要解耦,就需要用到 Go 的接口(interface)。

假如此时测试团队的 testing 目录下也有一个 Retriever,它同样有一个 Get 方法,但是行为与 infra 的 Retriever 完全不同,此时我们无法对 main 文件当中的 getRetriever 函数的返回类型进行修改。或者说,更换一个 Retriever,需要修改许多个地方,造成了很多工作量上的冗余。

go 复制代码
package main

import (
	"fmt"
	"learngo/testing"
)

func getRetriever() testing.Retriever {
	return testing.Retriever{}
}

func main() {
	var retriever testing.Retriever
	retriever = getRetriever()
	url := "https://www.baidu.com"
	fmt.Println(retriever.Get(url))
}

产生上述我们不满意的情况的原因是,Golang 是一个强类型的语言(或者说静态语言),而不是弱类型或动态绑定的系统,在写代码时,编译阶段我们就已经知道变量的类型,而对于 Python 等动态语言,在运行时才知道类型。

解决上述问题的方案是,让代码与逻辑相一致。在 main 函数中,变量 retriever 的类型不应该强绑定为某个类型的 retriever:

go 复制代码
var retriever ?
// ? is something that can get

retriever 的类型假设我们此时是不知道的,但是我们需要这个类型具有 Get 方法,才能使后面的:

go 复制代码
retriever.Get(url)

顺利运行。至于具体的类型是 infra 的 Retriever 还是 testing 的 Retriever,我们不需要关心。这个我们不知道的类型正是接口(interface)。

使用关键字 interface 来定义一个接口,它的声明语句与 struct 的声明非常类似:

go 复制代码
type retriever interface {
	Get(string) string
}

使用接口的具体实现如下:

go 复制代码
package main

import (
	"fmt"
	"learngo/testing"
)

func getRetriever() testing.Retriever {
	return testing.Retriever{}
}

type retriever interface {
	Get(string) string
}

func main() {
	var r retriever = getRetriever()
	url := "https://www.baidu.com"
	fmt.Println(r.Get(url))
}

函数 getRetriever() 的返回值类型也应该与 testing 解耦,直接更换为接口 retriever:

go 复制代码
func getRetriever() retriever {
	return testing.Retriever{}	// 返回的仍然是 testing.Retriever{}
}

使用 testing 的 Retriever 测试通过后,可以将代码业务上限,此时只需要更换 getRetriever 中的 testing 为 infra 即可。

鸭子类型(duck typing)

鸭子类型可以概括为:走路像鸭子,说话像鸭子,长得像鸭子,那么它就是鸭子。它强调的是描述事物的外部行为而非内部结构。

严格说 Go 属于结构化类型系统,类似 Duck Typing。

接口的定义和实现

接口由使用者 来定义。

下面是一个示例,此例需要我们新建一个 retriever 目录,并将下述代码写在目录下的 main.go 当中:

go 复制代码
package main

import "fmt"

type Retriever interface {
	Get(url string) string
}

func download(r Retriever) string {
	// download 是一个使用者, 使用者要 Get, 因此要定义一个 Retriever 接口
	return r.Get("https://www.baidu.com")
}

func main() {
	var r Retriever
	fmt.Println(download(r))
}

但是上述程序还无法直接运行,因为 r 还没有一个具体的实现。

在 retriever 目录下新建 mock 目录,并在 mock 目录下定义 mockretriever.go:

go 复制代码
package mock

type Retriever struct {
	Contents string
}

func (r Retriever) Get(url string) string {
	return r.Contents
}

编译器会发现我们定义的是可以用于接口实现的结构:

此时,修改我们的 main 函数体为:

go 复制代码
func main() {
	var r Retriever
	r = mock.Retriever{"this is a fake www.baidu.com"}
	fmt.Println(download(r))
}

👆此时,我们定义了一个接口的实现,相当于把一个可以匹配接口的结构对象赋予接口对象。

按照类似的方式,我们在 retriever 目录下新建一个 real 目录,在当中实现真实的 Retriever,其结构实现定义在 retriever.go 文件中:

Retriever 结构的定义和方法如下:

go 复制代码
package real

import (
	"net/http"
	"net/http/httputil"
	"time"
)

type Retriever struct {
	UserAgent string
	TimeOut   time.Duration // 代表时间长度
}

func (r *Retriever) Get(url string) string {
	resp, err := http.Get(url)
	if err != nil {
		panic(err)
	}
	result, err := httputil.DumpResponse(resp, true)

	resp.Body.Close()

	if err != nil {
		panic(err)
	}

	return string(result)
}

在 main.go 中使用 real 的 Retriever 实现接口:

go 复制代码
func main() {
	r := real.Retriever{}
	fmt.Println(download(r))
}

可以看到,由使用者(对应上述 main.go 当中的 download)来定义接口当中必须要有的方法,实现者不需要实现某个具体的接口,只需要实现接口当中的方法即可。

接口的值类型

现在我们想要查看接口当中究竟包含哪些成员,对 main 函数体进行如下修改:

go 复制代码
func main() {
	var r Retriever
	r = mock.Retriever{"this is a fake www.baidu.com"}
	fmt.Printf("%T %v\n", r, r)
	r = real.Retriever{}
	fmt.Printf("%T %v\n", r, r)
	fmt.Println(download(r))
}

输出的内容如下:

go 复制代码
mock.Retriever {this is a fake www.baidu.com}
real.Retriever { 0s}	// UserAgent 是 空格, TimeOut 是 0s

由于 Golang 的函数调用均为传值调用,当我们为 real.Retriever 定义成员时,使用指针接收者对方法进行定义可以加快方法的速度。如果单纯地将结构的方法改为指针接收者:func (r *Retriever) Get(url string) string { ... }

则接口使用的部分将会报错:

取一个地址即可修改上述错误:

此时运行 main 函数得到的结果如下:

go 复制代码
mock.Retriever {this is a fake www.baidu.com}
*real.Retriever &{Mozilla/5.0 1m0s}

因此,接口接收的可能是一个真实的值,也可能是一个指针。如果接收一个指针,那么指针指向的对象在实现接口的方法时应该使用指针接收者。

我们还想要知道接口当中结构的类型,获取接口中结构的类型有多种方法,定义一个 inspect 函数来完成,inspect 的参数是 Retriever 接口:

go 复制代码
func inspect(r Retriever) {
	switch v := r.(type) {
	case mock.Retriever:
		fmt.Printf("Contents:", v.Contents)
	case *real.Retriever:
		fmt.Printf("UserAgent:", v.UserAgent)
	}
}

修改 main 为:

go 复制代码
func main() {
	var r Retriever
	r = mock.Retriever{"this is a fake www.baidu.com"}
	inspect(r)

	r = &real.Retriever{
		UserAgent: "Mozilla/5.0",
		TimeOut:   time.Minute,
	}
	inspect(r)

	//fmt.Println(download(r))
}

结果如下:

go 复制代码
mock.Retriever {this is a fake www.baidu.com}
Contents: this is a fake www.baidu.com
*real.Retriever &{Mozilla/5.0 1m0s}
UserAgent: Mozilla/5.0

此外,我们还可以通过 Type Assertion 的方法来获取 interface 当中的类型:

go 复制代码
// Type Assertion
realRetriever := r.(*real.Retriever)
fmt.Println(realRetriever.TimeOut)
/* ... 修改 r 的类型为 mock.Retriever ... */
mockRetriever := r.(mock.Retriever)		// 注意, type assertion 必须添加圆括号, 无论是值还是指针
fmt.Println(mockRetriever.Contents)

通过上面的例子,可以将接口变量当中包含的内容总结为:实现者的类型 + 实现者的值/指针:

  • 接口变量自带指针;
  • 接口变量同样采用值传递,几乎不需要使用接口的指针;
  • 指针接收者的方法实现只能以指针方式使用,值接收者都可以。(正如上面 real.Retriever 的例子,该例将方法定义为指针接收者,在使用接口接收 real.Retriever 时,必须取 real.Retriever 的地址,否则会编译出错。而 mock.Retriever 对接口方法的实现使用值接收者,我们可以传递 mock.Retriever 的地址给接口,这样做不会产生编译错误,而直接使用 mock.Retriever 的值则更加方便。)

可以使用 interface{} 来表示任何类型

可以使用 interface{} 来表示任何类型,一个例子如下,该例尝试对我们之前实现的 queue 进行修改。先前实现的 queue 是对 []int 的别名,即 int 类型的 slice 的别名,并对 queue 定义了许多方法。如果我们不希望 queue 只能接受 int 类型的值,可以使用 interface{} 来对其进行改写:

go 复制代码
package queue

type Queue []interface{} // 使用 interface{} 表示任何类型

func (q *Queue) Push(v interface{}) {
	*q = append(*q, v) // q 指向的 slice 被改变了
}

func (q *Queue) Pop() interface{} {
	head := (*q)[0]
	*q = (*q)[1:]
	return head
}

func (q *Queue) IsEmpty() bool {
	return len(*q) == 0
}

此时的 Queue 类似于 Python,可以接受任何类型的变量。

此时使用 Queue:

go 复制代码
package main

import (
	"fmt"
	"learngo/queue"
)

func main() {
	q := queue.Queue{1}
	q.Push(2)
	q.Push(3)
	fmt.Println(q.Pop())
	fmt.Println(q.Pop())
	fmt.Println(q.IsEmpty())
	fmt.Println(q.Pop())
	fmt.Println(q.IsEmpty())
	q.Push("abc")
	q.Push(3.1415926)
	fmt.Println(q.Pop())
	fmt.Println(q.Pop())
}

输出为:

go 复制代码
1
2
false
3
true
abc
3.1415926

如果我们想要对 Queue 进行进一步的限定,使得它只能接受 int 类型的变量,可以进一步对 Queue 的定义进行修改:

go 复制代码
type Queue []interface{} // 使用 interface{} 表示任何类型

func (q *Queue) Push(v int) {	// 传入的参数限定为 int
	*q = append(*q, v) // q 指向的 slice 被改变了
}

func (q *Queue) Pop() int {		// 返回的参数限定为 int
	head := (*q)[0]
	*q = (*q)[1:]
	return head.(int) // 需要将返回值强制类型转换为 int
	// 👆 head 是一个 interface, 将 interface 当中的值强制转换为 int
}

func (q *Queue) IsEmpty() bool {
	return len(*q) == 0
}

接口的组合

有时我们希望一个接口既可以读又可以写,此时可以用到接口的组合。

首先我们定义一个 Poster 接口,将其一并定义在 retriever 目录下的 main.go 文件当中(与 Retriever 接口定义在同一个文件当中),并定义一个接口要执行的行为(放在函数 post 当中,正如 Retriever 接口要做的事情放在了 download 当中):

go 复制代码
type Poster interface {
	Post(url string, form map[string]string) string
}

func post(poster Poster) {
	poster.Post("https://www.baidu.com",
		map[string]string{
			"name": "baidu",
			"item": "BaiduNetDisk",
		})
}

我们希望定义一个既可以读也可以写的接口,可以将上述的 Retriever 接口和 Poster 接口定义在 RetrieverPoster 接口当中来完成接口的组合:

go 复制代码
type RetrieverPoster interface {
	Retriever
	Poster
}

当然,RetrieverPoster 当中可以定义其它的方法,但此时还用不上,只需要 Retriever 和 Poster 两个接口。进一步将接口 RetrieverPoster 要做的事情定义在 session 当中:

go 复制代码
func session(s RetrieverPoster) string {
	s.Get()
	s.Post()
}

在具体的实现部分,以 mock.Retriever 为例,如果想要 RetrieverPoster 接受 mock.Retriever,则它还需要再实现一个名为 Post 的接口:

go 复制代码
package mock

type Retriever struct {
	Contents string
}

func (r Retriever) Post(url string, form map[string]string) string {	// 实现 Post 方法
	r.Contents = form["contents"]										// 为了将结构传入 RetrieverPoster 接口
	return "ok"
}

func (r Retriever) Get(url string) string {
	return r.Contents
}

此时给 session 一个具体的实现:

go 复制代码
func session(s RetrieverPoster) string {
	s.Post(url, map[string]string{
		"contents": "another faked www.baidu.com",
	})
	return s.Get(url)
}

现在在 main 函数体当中,将 mock.Retriever 传递给 RetrieverPoster 接口:

go 复制代码
func main() {
	r := mock.Retriever{"this is a fake www.baidu.com"}

	fmt.Println()
	fmt.Println(session(r))
}

结果为:

go 复制代码
this is a fake www.baidu.com

与我们的预期不符,原因是 session 当中修改了 Contents 的内容,而结果没有显示修改后的值。原因在于最初定义的 Post 使用的是值接受者,需要对 mock.Retriever 的两个接口方法的定义进行进一步的修改,将 Post 和 Get 方法修改为指针接收者。修改后的结果为:

go 复制代码
func (r *Retriever) Post(url string, form map[string]string) string {
	r.Contents = form["contents"]
	return "ok"
}

func (r *Retriever) Get(url string) string {
	return r.Contents
}

可以预见的是,在 main 函数体中,也需要把 RetriverPoster 的接收值修改为地址:

go 复制代码
// ...
r := &mock.Retriever{"this is a fake www.baidu.com"}
// ...

此时得到的是正确的值:

go 复制代码
another faked www.baidu.com
相关推荐
dengjiayue4 小时前
golang实现简单的reids服务2
开发语言·golang
凡人的AI工具箱6 小时前
40分钟学 Go 语言高并发教程目录
开发语言·后端·微服务·性能优化·golang
每天写点bug6 小时前
【golang】匿名内部协程,值传递与参数传递
开发语言·后端·golang
m0_674031437 小时前
go语言的成神之路-筑基篇-gin常用功能
java·golang·gin
egekm_sefg7 小时前
【Golang】——Gin 框架中的模板渲染详解
开发语言·golang·gin
YGGP9 小时前
【Golang】Go语言编程思想(六):Channel,第四节,Select
golang
●VON11 小时前
go语言的成神之路-标准库篇-os标准库
linux·运维·服务器·开发语言·后端·学习·golang
灼华十一11 小时前
数据结构-树状数组
数据结构·leetcode·golang·树状数组
五味香14 小时前
Java学习,字符串搜索
java·c语言·开发语言·python·学习·golang·kotlin
大卡尔17 小时前
Reviewbot 开源 | 这些写 Go 代码的小技巧,你都知道吗?
golang·go·静态检查·reviewbot