在过去的几个月中,我一直在进行一项
研究,询问人们在Go中难于理解的内容。 而且我注意到答案中经常提到接口的概念。 Go是我使用的第一种界面语言,我记得那时这个概念似乎很混乱。 在本指南中,我想这样做:
- 用人类语言解释什么是接口。
- 解释它们的用处以及如何在代码中使用它们。
- 讨论
interface{}
什么(一个空接口)。 - 并逐步介绍一些在标准库中可以找到的有用的接口类型。
那么什么是接口?
Go中的接口类型是一种
定义 。 它定义并描述了
某些其他类型应具有的特定方法。
标准库中的接口类型之一是
fmt.Stringer接口:
type Stringer interface { String() string }
我们说,如果某个“东西”具有带有特定签名字符串值
String()
的方法,则
满足该接口 (或
实现此接口 )的东西。
例如,
Book
类型满足接口,因为它具有
String()
字符串方法:
type Book struct { Title string Author string } func (b Book) String() string { return fmt.Sprintf("Book: %s - %s", b.Title, b.Author) }
Book
类型或功能无关紧要。 重要的是它具有一个称为
String()
的方法,该方法返回字符串值。
这是另一个例子。
Count
类型也
满足 fmt.Stringer
接口,因为它具有具有相同签名字符串值
String()
。
type Count int func (c Count) String() string { return strconv.Itoa(int(c)) }
在这里必须了解,我们有两种不同类型的
Book
和
Count
,它们的行为不同。 但是它们都满足于
fmt.Stringer
接口这一事实使它们团结在一起。
您可以从另一侧看它。 如果知道对象满足
fmt.Stringer
接口,则可以假定它具有可以调用的带有签名字符串值
String()
的方法。
现在最重要的是。
当您在Go(变量,函数参数或结构字段的声明)中看到具有接口类型的声明时,可以使用任何类型的对象,只要它满足该接口。假设我们有一个函数:
func WriteLog(s fmt.Stringer) { log.Println(s.String()) }
由于
WriteLog()
在参数
fmt.Stringer
使用接口类型
fmt.Stringer
,因此我们可以传递任何满足
fmt.Stringer
接口的对象。 例如,我们可以传递之前在
WriteLog()
方法中创建的
Book
和
Count
类型,代码可以正常工作。
另外,由于传递的对象满足
fmt.Stringer
接口,因此我们
知道它具有
String()
方法,可以通过
WriteLog()
函数安全地调用该方法。
让我们将其汇总为一个示例,以演示接口的功能。
package main import ( "fmt" "strconv" "log" )
太棒了 在主函数中,我们创建了不同类型的
Book
和
Count
,但是将它们传递给了
相同的 WriteLog()
函数。 她调用了适当的
String()
函数,并将结果写入日志。
如果
执行代码 ,您将得到类似的结果:
2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol 2009/11/10 23:00:00 3
我们将不对此进行详细介绍。 要记住的主要事情:在
WriteLog()
函数的声明中使用接口类型,我们使函数对接收到的对象的
类型无动于衷(或灵活)。 重要的是
他有什么方法 。
什么是有用的界面?
您可以在Go中开始使用界面的原因有很多。 以我的经验,最重要的是:
- 接口有助于减少重复,即减少样板代码的数量。
- 它们使在单元测试中代替实际对象更容易使用存根。
- 作为一种架构工具,界面可帮助解开部分代码库。
让我们仔细看看这些使用接口的方式。
减少样板代码量
假设我们有一个包含某种客户数据的
Customer
结构。 在代码的一部分中,我们希望将此信息写入
bytes.Buffer ,在另一部分中,我们要将客户端数据写入磁盘上的
os.File 。 但是,在两种情况下,我们都希望首先将
ustomer
结构序列化为JSON。
在这种情况下,我们可以使用Go接口减少样板代码的数量。
Go具有
io.Writer接口类型:
type Writer interface { Write(p []byte) (n int, err error) }
我们可以利用
bytes.Buffer和
os.File类型满足此接口的事实,因为它们分别具有
bytes.Buffer.Write()和
os.File.Write()方法。
简单实施:
package main import ( "encoding/json" "io" "log" "os" )
当然,这只是一个虚构的示例(我们可以以不同的方式构造代码以获得相同的结果)。 但这很好地说明了使用接口的优点:我们可以创建一次
Customer.WriteJSON()
方法,并在每次需要写入满足
io.Writer
接口的内容时调用它。
但是,如果您
不熟悉 Go,您将有两个问题:“
我怎么知道io.Writer接口是否存在? 以及您如何预先知道他对bytes.Buffer
和os.File
感到满意? ”
恐怕没有简单的解决方案。 您只需要获取经验,熟悉标准库中的接口和不同类型即可。 这将有助于阅读该库的文档并查看其他人的代码。 为了快速参考,我在文章结尾添加了最有用的接口类型。
但是,即使您不使用标准库中的接口,也不会阻止您创建和使用
自己的接口类型 。 我们将在下面讨论。
单元测试和存根
要了解接口如何在单元测试中提供帮助,我们来看一个更复杂的示例。
假设您有一家商店,并在PostgreSQL中存储有关销售和客户数量的信息。 您想编写一个代码,计算最后一天的销售份额(每个客户的特定销售数量),四舍五入到小数点后两位。
最小的实现如下所示:
现在,我们要为
calculateSalesRate()
函数创建一个单元测试,以验证计算是否正确。
现在有问题了。 我们将需要配置PostgreSQL的测试实例,以及创建和删除脚本以用假数据填充数据库。 如果我们真的想测试我们的计算,我们还有很多工作要做。
接口可以解救!
我们将创建自己的接口类型,以描述
CountSales()
和
CountCustomers()
方法,
calculateSalesRate()
函数所依赖的接口类型。 然后更新签名
calculateSalesRate()
以将此接口类型用作参数,而不是指定的
*ShopDB
类型。
像这样:
完成此操作后,我们将只创建一个满足
ShopModel
接口的存根。 然后,您可以在函数
calculateSalesRate()
对数学逻辑的正确操作进行单元测试时使用它。 像这样:
现在运行测试,一切正常。
应用架构
在前面的示例中,我们看到了如何使用接口将代码的某些部分与使用特定类型分离。 例如,只要满足
ShopModel
接口,那么
calculateSalesRate()
函数对您传递的内容
ShopModel
。
您可以扩展这个想法,并在大型项目中创建整个“统一”级别。
假设您正在创建一个与数据库交互的Web应用程序。 如果您创建一个描述与数据库交互的特定方法的接口,则可以通过HTTP处理程序而不是特定类型来引用该接口。 由于HTTP处理程序仅引用该接口,因此这将有助于彼此断开HTTP级别和与数据库的交互级别的链接。 独立处理关卡将更加容易,并且将来您将能够替换某些关卡而不会影响其他关卡的工作。
我
在之前的一篇文章中介绍了这种模式,
其中有更多详细信息和实际示例。
什么是空接口?
如果您已经在Go上编程了一段时间,那么您可能会遇到一个
空的接口类型 interface{}
。 我将尝试解释它是什么。 在本文开头,我写道:
Go中的接口类型是一种定义 。 它定义并描述了某些其他类型应具有的特定方法。
空的接口类型
不能描述方法 。 他没有规矩。 因此,任何对象都满足一个空接口。
本质上,空接口类型
interface{}
是一种玩笑。 如果在声明(变量,函数参数或结构字段)中遇到它,则可以使用
任何类型的对象。
考虑以下代码:
package main import "fmt" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 fmt.Printf("%+v", person) }
在这里,我们将地图初始化为
person
,它使用字符串类型的键和空接口类型的
interface{}
作为值。 我们为映射值分配了三种不同类型(字符串,整数和float32),这没有问题。 由于任何类型的对象都满足空接口,因此代码可以很好地工作。
您可以
在此处运行此代码 ,您将看到类似的结果:
map[age:21 height:167.64 name:Alice]
在从地图中提取和使用值时,请务必牢记这一点。 假设您要获取
age
值并将其增加1。如果您编写类似的代码,则它将无法编译:
package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 person["age"] = person["age"] + 1 fmt.Printf("%+v", person) }
您将收到一条错误消息:
invalid operation: person["age"] + 1 (mismatched types interface {} and int)
原因是存储在map中的值采用
interface{}
类型,并失去了其原始的基本int类型。 并且由于该值不再是整数,因此我们无法对其加1。
为了解决这个问题,您需要再次使该值成为整数,然后才可以使用它:
package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 age, ok := person["age"].(int) if !ok { log.Fatal("could not assert value to int") return } person["age"] = age + 1 log.Printf("%+v", person) }
如果
运行this ,一切将按预期工作:
2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice]
那么什么时候应该使用空接口类型呢?
也许
不是太经常 。 如果您遇到这种情况,请停下来考虑
interface{}
使用
interface{}
是否正确。 作为一般建议,我可以说,使用特定类型(即非空接口类型)将更加清晰,安全和高效。 在上面的示例中,最好使用适当类型的字段定义
Person
结构:
type Person struct { Name string Age int Height float32 }
另一方面,当您需要访问和使用不可预测或用户定义的类型时,空接口非常有用。 出于某种原因,此类接口在标准库的不同位置使用,例如
gob.Encode ,
fmt.Print和
template.Execute函数。
有用的接口类型
这是标准库中最需要和最有用的接口类型的简短列表。 如果您还不熟悉它们,则建议阅读相关文档。
标准库的详细列表也可以
在此处找到 。