Go进阶篇

接口

接口类型

接口是一种由程序员来定义的类型,一个接口类型就是一组方法的集合,它规定了需要实现的所有方法。

相较于使用结构体类型,当我们使用接口类型说明相比于它是什么更关心它能做什么

接口的定义

每个接口类型由任意个方法签名组成,接口的定义格式如下:

type 接口类型名 interface{
    方法名1( 参数列表1 ) 返回值列表1
    方法名2( 参数列表2 ) 返回值列表2

}

其中:

  • 接口类型名:Go语言的接口在命名时,一般会在单词后面添加er,如有写操作的接口叫Writer,有关闭操作的接口叫closer等。接口名最好要能突出该接口的类型含义。

  • 方法名:当方法名首字母是大写且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。

  • 参数列表返回值列表:参数列表和返回值列表中的参数变量名可以省略。

举个例子,定义一个包含Write方法的Writer接口。

当你看到一个Writer接口类型的值时,你不知道它是什么,唯一知道的就是可以通过调用它的Write方法来做一些事情。

实现接口的条件

接口就是规定了一个需要实现的方法列表,在 Go 语言中一个类型只要实现了接口中规定的所有方法,那么我们就称它实现了这个接口。

我们定义的Singer接口类型,它包含一个Sing方法

我们有一个Bird结构体类型如下:

因为Singer接口只包含一个Sing方法,所以只需要给Bird结构体添加一个Sing方法就可以满足Singer接口的要求

这样就称为Bird实现了Singer接口

为什么要使用接口?

现在假设我们的代码世界里有很多小动物,下面的代码片段定义了猫和狗,它们饿了都会叫。

这个时候又跑来了一只羊,羊饿了也会发出叫声。

我们接下来定义一个饿肚子的场景。

接下来会有越来越多的小动物跑过来,我们的代码世界该怎么拓展呢?

在饿肚子这个场景下,我们可不可以把所有动物都当成一个“会叫的类型”来处理呢?当然可以!使用接口类型就可以实现这个目标。 我们的代码其实并不关心究竟是什么动物在叫,我们只是在代码中调用它的Say()方法,这就足够了。

我们可以约定一个Sayer类型,它必须实现一个Say()方法,只要饿肚子了,我们就调用Say()方法。

然后我们定义一个通用的MakeHungry函数,接收Sayer类型的参数。

我们通过使用接口类型,把所有会叫的动物当成Sayer类型来处理,只要实现了Say()方法都能当成Sayer类型的变量来处理。

在电商系统中我们允许用户使用多种支付方式(支付宝支付、微信支付、银联支付等),我们的交易流程中可能不太在乎用户究竟使用什么支付方式,只要它能提供一个实现支付功能的Pay方法让调用方调用就可以了。

再比如我们需要在某个程序中添加一个将某些指标数据向外输出的功能,根据不同的需求可能要将数据输出到终端、写入到文件或者通过网络连接发送出去。在这个场景下我们可以不关注最终输出的目的地是什么,只需要它能提供一个Write方法让我们把内容写入就可以了。

Go语言中为了解决类似上面的问题引入了接口的概念,接口类型区别于我们之前章节中介绍的那些具体类型,让我们专注于该类型提供的方法,而不是类型本身。使用接口类型通常能够让我们写出更加通用和灵活的代码。

面向接口编程

PHP、Java等语言中也有接口的概念,不过在PHP和Java语言中需要显式声明一个类实现了哪些接口,在Go语言中使用隐式声明的方式实现接口。只要一个类型实现了接口中规定的所有方法,那么它就实现了这个接口。

Go语言中的这种设计符合程序开发中抽象的一般规律,例如在下面的代码示例中,我们的电商系统最开始只设计了支付宝一种支付方式:

随着业务的发展,根据用户需求添加支持微信支付。

在实际的交易流程中,我们可以根据用户选择的支付方式来决定最终调用支付宝的Pay方法还是微信支付的Pay方法。

实际上,从上面的代码示例中我们可以看出,我们其实并不怎么关心用户选择的是什么支付方式,我们只关心调用Pay方法时能否正常运行。这就是典型的“不关心它是什么,只关心它能做什么”的场景。

在这种场景下我们可以将具体的支付方式抽象为一个名为Payer的接口类型,即任何实现了Pay方法的结构体类型都可以称为Payer类型。

此时只需要修改下原始的Checkout函数,它接收一个Payer类型的参数。这样就能够在不修改既有函数调用的基础上,支持新的支付方式。

像类似的例子在我们编程过程中会经常遇到:

  • 比如一个网上商城可能使用支付宝、微信、银联等方式去在线支付,我们能不能把它们当成“支付方式”来处理呢?

  • 比如三角形,四边形,圆形都能计算周长和面积,我们能不能把它们当成“图形”来处理呢?

  • 比如满减券、立减券、打折券都属于电商场景下常见的优惠方式,我们能不能把它们当成“优惠券”来处理呢?

接口类型是Go语言提供的一种工具,在实际的编码过程中是否使用它由你自己决定,但是通常使用接口类型可以使代码更清晰易读。

接口类型变量

那实现了接口又有什么用呢?一个接口类型的变量能够存储所有实现了该接口的类型变量。

例如在上面的示例中,DogCat类型均实现了Sayer接口,此时一个Sayer类型的变量就能够接收CatDog类型的变量。

值接收者和指针接收者

我们定义一个Mover接口,它包含一个Move方法。

值接收者实现接口

我们定义一个Dog结构体类型,并使用值接收者为其定义一个Move方法。

此时实现Mover接口的是Dog类型。

从上面的代码中我们可以发现,使用值接收者实现接口之后,不管是结构体类型还是对应的指针类型的变量都可以赋值给该接口变量。

指针接收者实现接口

我们再来测试一下使用指针接收者实现接口有什么区别。

此时实现Mover接口的是*Cat类型,我们可以将*Cat类型的变量直接赋值给Mover接口类型的变量x

但是不能给将Cat类型的变量赋值给Mover接口类型的变量x

由于Go语言中有对指针求值的语法糖,对于值接收者实现的接口,无论使用值类型还是指针类型都没有问题。但是我们并不总是能对一个值求址,所以对于指针接收者实现的接口要额外注意。

类型与接口的关系

一个类型实现多个接口

一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。例如狗不仅可以叫,还可以动。我们完全可以分别定义Sayer接口和Mover接口,具体代码示例如下。

Dog既可以实现Sayer接口,也可以实现Mover接口。

同一个类型实现不同的接口互相不影响使用。

多种类型实现同一接口

Go语言中不同的类型还可以实现同一接口。例如在我们的代码世界中不仅狗可以动,汽车也可以动。我们可以使用如下代码体现这个关系。

这样我们在代码中就可以把狗和汽车当成一个会动的类型来处理,不必关注它们具体是什么,只需要调用它们的Move方法就可以了。

上面的代码执行结果如下:

一个接口的所有方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。

接口组合

接口与接口之间可以通过互相嵌套形成新的接口类型,例如Go标准库io源码中就有很多接口之间互相组合的示例。

对于这种由多个接口类型组合形成的新接口类型,同样只需要实现新接口类型中规定的所有方法就算实现了该接口类型。

接口也可以作为结构体的一个字段,我们来看一段Go标准库sort源码中的示例。

通过在结构体中嵌入一个接口类型,从而让该结构体类型实现了该接口类型,并且还可以改写该接口的方法。

Interface类型原本的Less方法签名为Less(i, j int) bool,此处重写为r.Interface.Less(j, i),即通过将索引参数交换位置实现反转。

在这个示例中还有一个需要注意的地方是reverse结构体本身是不可导出的(结构体类型名称首字母小写),sort.go中通过定义一个可导出的Reverse函数来让使用者创建reverse结构体实例。

这样做的目的是保证得到的reverse结构体中的Interface属性一定不为nil,否者r.Interface.Less(j, i)就会出现空指针panic。

此外在Go内置标准库database/sql中也有很多类似的结构体内嵌接口类型的使用示例,各位读者可自行查阅。

空接口

空接口的定义

空接口是指没有定义任何方法的接口类型。因此任何类型都可以视为实现了空接口。也正是因为空接口类型的这个特性,空接口类型的变量可以存储任意类型的值。

通常我们在使用空接口类型时不必使用type关键字声明,可以像下面的代码一样直接使用interface{}

空接口的应用

空接口作为函数的参数

使用空接口实现可以接收任意类型的函数参数。

空接口作为map的值

使用空接口实现可以保存任意值的字典。

接口值

由于接口类型的值可以是任意一个实现了该接口的类型值,所以接口值除了需要记录具体之外,还需要记录这个值属于的类型。也就是说接口值由“类型”和“值”组成,鉴于这两部分会根据存入值的不同而发生变化,我们称之为接口的动态类型动态值

我们接下来通过一个示例来加深对接口值的理解。

下面的示例代码中,定义了一个Mover接口类型和两个实现了该接口的DogCar结构体类型。

首先,我们创建一个Mover接口类型的变量m

此时,接口变量m是接口类型的零值,也就是它的类型和值部分都是nil,就如下图所示。

我们可以使用m == nil来判断此时的接口值是否为空。

**注意:**我们不能对一个空接口值调用任何方法,否则会产生panic。

接下来,我们将一个*Dog结构体指针赋值给变量m

此时,接口值m的动态类型会被设置为*Dog,动态值为结构体变量的拷贝。

然后,我们给接口变量m赋值为一个*Car类型的值。

这一次,接口值的动态类型为*Car,动态值为nil

**注意:**此时接口变量mnil并不相等,因为它只是动态值的部分为nil,而动态类型部分保存着对应值的类型。

接口值是支持相互比较的,当且仅当接口值的动态类型动态值都相等时才相等。

但是有一种特殊情况需要特别注意,如果接口值的保存的动态类型相同,但是这个动态类型不支持互相比较(比如切片),那么对它们相互比较时就会引发panic。

类型断言

接口值可能赋值为任意类型的值,那我们如何从接口值获取其存储的具体数据呢?

我们可以借助标准库fmt包的格式化打印获取到接口值的动态类型。

fmt包内部其实是使用反射的机制在程序运行时获取到动态类型的名称。关于反射的内容我们会在后续章节详细介绍。

而想要从接口值中获取到对应的实际值需要使用类型断言,其语法格式如下。

其中:

  • x:表示接口类型的变量

  • T:表示断言x可能是的类型。

该语法返回两个参数,第一个参数是x转化为T类型后的变量,第二个值是一个布尔值,若为true则表示断言成功,为false则表示断言失败。

举个例子:

如果对一个接口值有多个实际类型需要判断,推荐使用switch语句来实现。

由于接口类型变量能够动态存储不同类型值的特点,所以很多初学者会滥用接口类型(特别是空接口)来实现编码过程中的便捷。只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要定义接口。切记不要为了使用接口类型而增加不必要的抽象,导致不必要的运行时损耗。

在 Go 语言中接口是一个非常重要的概念和特性,使用接口类型能够实现代码的抽象和解耦,也可以隐藏某个功能的内部实现,但是缺点就是在查看源码的时候,不太方便查找到具体实现接口的类型。

相信很多读者在刚接触到接口类型时都会有很多疑惑,请牢记接口是一种类型,一种抽象的类型。区别于我们在之前章节提到的那些具体类型(整型、数组、结构体类型等),它是一个只要求实现特定方法的抽象类型。

**小技巧:**下面的代码可以在程序编译阶段验证某一结构体是否满足特定的接口类型。

上面的代码中也可以使用var _ IRouter = (*RouterGroup)(nil)进行验证。

错误处理

Go 语言中的错误处理与其他语言不太一样,它把错误当成一种值来处理,更强调判断错误、处理错误,而不是一股脑的 catch 捕获异常。

error接口

Go 语言中把错误当成一种特殊的值来处理,不支持其他语言中使用try/catch捕获异常的方式。

Go 语言中使用一个名为 error 的接口来表示错误类型。

error 接口只包含一个方法Error(),这个函数需要返回一个描述错误信息的字符串。

当一个函数或方法需要返回错误时,我们通常是把错误作为最后一个返回值。例如下面标准库 os 中打开文件的函数。

由于 error 是一个接口类型,默认零值为nil。所以我们通常将调用函数返回的错误与nil进行比较,以此来判断函数是否返回错误。例如你会经常看到类似下面的错误判断代码。

注意:当我们使用fmt包打印错误时会自动调用 error 类型的 Error 方法,也就是会打印出错误的描述信息。

创建error

我们可以根据需求自定义 error,最简单的方式是使用errors 包提供的New函数创建一个错误。

errors.New

函数签名如下:

它接收一个字符串参数返回包含该字符串的错误。我们可以在函数返回时快速创建一个错误。

或者用来定义一个错误变量,例如标准库io.EOF错误定义如下。

fmt.Errorf

当我们需要传入格式化的错误描述信息时,使用fmt.Errorf是个更好的选择。

但是上面的方式会丢失原有的错误类型,只拿到错误描述的文本信息。

为了不丢失函数调用的错误链,使用fmt.Errorf时搭配使用特殊的格式化动词%w,可以实现基于已有的错误再包装得到一个新的错误。

对于这种二次包装的错误,errors包中提供了以下三个方法。

错误结构体类型

此外我们还可以自己定义结构体类型,实现error接口。

反射

反射是指在程序运行期间对程序本身进行访问和修改的能力。程序在编译时,变量被转换为内存地址,变量名不会被编译器写入到可执行部分。在运行程序时,程序无法获取自身的信息。

支持反射的语言可以在程序编译期将变量的反射信息,如字段名称、类型信息、结构体信息等整合到可执行文件中,并给程序提供接口访问反射信息,这样就可以在程序运行期获取类型的反射信息,并且有能力修改它们。

Go程序在运行期使用reflect包访问程序的反射信息。

在上一篇博客中我们介绍了空接口。空接口可以存储任意类型的变量,那我们如何知道这个空接口保存的数据是什么呢?反射就是在运行时动态的获取一个变量的类型信息和值信息。

在Go语言的反射机制中,任何接口值都由是一个具体类型具体类型的值两部分组成的。 在Go语言中反射的相关功能由内置的reflect包提供,任意接口值在反射中都可以理解为由reflect.Typereflect.Value两部分组成,并且reflect包提供了reflect.TypeOfreflect.ValueOf两个函数来获取任意对象的Value和Type。

TypeOf

在Go语言中,使用reflect.TypeOf()函数可以获得任意值的类型对象(reflect.Type),程序通过类型对象可以访问任意值的类型信息。

type name 和 type kind

在反射中关于类型还划分为两种:类型(Type)种类(Kind)。因为在Go语言中我们可以使用type关键字构造很多自定义类型,而种类(Kind)就是指底层的类型,但在反射中,当需要区分指针、结构体等大品种的类型时,就会用到种类(Kind)。举个例子,我们定义了两个指针类型和两个结构体类型,通过反射查看它们的类型和种类。

Go语言的反射中像数组、切片、Map、指针等类型的变量,它们的.Name()都是返回

reflect包中定义的Kind类型如下:

ValueOf

reflect.ValueOf()返回的是reflect.Value类型,其中包含了原始值的值信息。reflect.Value与原始值之间可以互相转换。

reflect.Value类型提供的获取原始值的方法如下:

方法
说明

Interface() interface {}

将值以 interface{} 类型返回,可以通过类型断言转换为指定类型

Int() int64

将值以 int 类型返回,所有有符号整型均可以此方式返回

Uint() uint64

将值以 uint 类型返回,所有无符号整型均可以此方式返回

Float() float64

将值以双精度(float64)类型返回,所有浮点数(float32、float64)均可以此方式返回

Bool() bool

将值以 bool 类型返回

Bytes() []bytes

将值以字节数组 []bytes 类型返回

String() string

将值以字符串类型返回

通过反射获取值

通过反射设置变量的值

想要在函数中通过反射修改变量的值,需要注意函数参数传递的是值拷贝,必须传递变量地址才能修改变量值。而反射中使用专有的Elem()方法来获取指针对应的值。

isNil()和isValid()

isNil()

IsNil()报告v持有的值是否为nil。v持有的值的分类必须是通道、函数、接口、映射、指针、切片之一;否则IsNil函数会导致panic。

isValid()

IsValid()返回v是否持有一个值。如果v是Value零值会返回假,此时v除了IsValid、String、Kind之外的方法都会导致panic。

举个例子

IsNil()常被用于判断指针是否为空;IsValid()常被用于判定返回值是否有效。

结构体反射

与结构体相关的方法

任意值通过reflect.TypeOf()获得反射对象信息后,如果它的类型是结构体,可以通过反射值对象(reflect.Type)的NumField()Field()方法获得结构体成员的详细信息。

reflect.Type中与获取结构体成员相关的的方法如下表所示。

方法
说明

Field(i int) StructField

根据索引,返回索引对应的结构体字段的信息。

NumField() int

返回结构体成员字段数量。

FieldByName(name string) (StructField, bool)

根据给定字符串返回字符串对应的结构体字段的信息。

FieldByIndex(index []int) StructField

多层成员访问时,根据 []int 提供的每个结构体的字段索引,返回字段的信息。

FieldByNameFunc(match func(string) bool) (StructField,bool)

根据传入的匹配函数匹配需要的字段。

NumMethod() int

返回该类型的方法集中方法的数目

Method(int) Method

返回该类型方法集中的第i个方法

MethodByName(string)(Method, bool)

根据方法名返回该类型方法集中的方法

StructField类型

StructField类型用来描述结构体中的一个字段的信息。

StructField的定义如下:

结构体反射示例

当我们使用反射得到一个结构体数据之后可以通过索引依次获取其字段信息,也可以通过字段名去获取指定的字段信息。

接下来编写一个函数printMethod(s interface{})来遍历打印s包含的方法。

反射是把双刃剑

反射是一个强大并富有表现力的工具,能让我们写出更灵活的代码。但是反射不应该被滥用,原因有以下三个。

  1. 基于反射的代码是极其脆弱的,反射中的类型错误会在真正运行的时候才会引发panic,那很可能是在代码写完的很长时间之后。

  2. 大量使用反射的代码通常难以理解。

  3. 反射的性能低下,基于反射实现的代码通常比正常代码运行速度慢一到两个数量级。

Last updated