在Golang中使用多个接口
我正在学习 Golang,作为使用接口的练习,我正在构建一个玩具程序。我在尝试使用“应该实现”两个接口的类型时遇到了一些问题 - 在 C++ 和 Java 中解决这个问题的一种方法是使用继承(还有其他技术,但我认为这是最常见的)。由于我在 Golang 中缺乏这种机制,我不确定如何进行。下面是代码:
var (
faces = []string{"Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Jack", "Queen", "King"}
suits = []string{"Hearts", "Diamonds", "Spades", "Clubs"}
)
type Card interface {
GetFace() string
GetSuit() string
}
type card struct {
cardNum int
face string
suit string
}
func NewCard(num int) Card {
newCard := card{
cardNum: num,
face: faces[num%len(faces)],
suit: suits[num/len(faces)],
}
return &newCard
}
func (c *card) GetFace() string {
return c.face
}
func (c *card) GetSuit() string {
return c.suit
}
func (c *card) String() string {
return fmt.Sprintf("%s%s ", c.GetFace(), c.GetSuit())
}
我正在努力实现的目标:
- 我想隐藏我的结构类型,只导出接口,以便代码的客户端只使用“卡”接口
- 我想要结构的字符串表示,因此使用“String()”方法实现接口,以便能够在我的结构实例化时调用“fmt.Println()”
当我尝试通过“卡片”界面使用新卡片并尝试获取字符串表示时,问题就出现了。我无法将接口作为“String()”方法实现的参数传递,因为存在与核心语言级别的接口可寻址性相关的编译器错误(仍在深入研究该文档)。一个非常简单的测试示例暴露了这个问题:
func TestString(t *testing.T) {
card := NewCard(0)
assert.EqualValues(t, "AceHearts ", card.String(), " newly created card's string repr should be 'AceHearts '")
}
编译器告诉我,有充分的理由,“card.String undefined (type card has no field or method string)”。我可以将“String()”方法添加到我的“Card”接口中,但我认为这并不干净:我已经用相同的模型实现了其他实体,我必须在任何地方添加冗余;已经有一个带有该方法的接口。
对于我遇到的上述问题,什么是好的解决方案?
编辑:(解决一些非常好的评论)
- 我不希望有Card接口的另一个实现;我不确定我是否理解为什么要这样做,那就是更改界面
- 我想让Card接口隐藏实现细节,让客户端根据接口而不是具体类型进行编程
- 我希望始终可以访问“卡片结构”实例化(包括通过 Card 接口实例化的那些)的所有客户端的 String() 接口。我对仅使用 String 接口的客户端不感兴趣。在其他一些语言中,这可以通过实现两个接口 - 多重继承来实现。我不是说这是好是错,也不是试图就此展开辩论,我只是在陈述一个事实!
- 我的目的是找出语言是否有任何机制可以同时满足这些要求。如果这是不可能的,或者从设计的角度来看,问题应该以不同的方式解决,那么我准备接受教育
- 类型断言非常冗长和明确,会暴露实现细节——它们有它们的位置,但我认为它们不适合我的情况
回答
我应该先回顾一些序言:
- Go 中的接口与其他语言中的接口不同。您不应该假设来自其他语言的每个想法都应该自动转移。他们中的很多人没有。
- Go 既没有类也没有对象。
- Go 不是 Java,Go 也不是 C++。它的类型系统与那些语言有着显着且有意义的不同。
从你的问题:
我想让 Card 接口隐藏实现细节,让客户端根据接口而不是具体类型进行编程
这是你其他问题的根源。
正如评论中提到的,我在多个其他包中看到了这一点,并将其视为一种特别讨厌的反模式。首先,我将解释这种模式本质上是“反”的原因。
- 首先也是最相关的一点,你的例子证明了这一点。你采用了这种模式,它产生了不好的影响。正如 mkopriva 所指出的,它造成了一个你必须解决的矛盾。
- 接口的这种用法与其预期用途相反,并且您不会通过这样做获得任何好处。
接口是 Go 的多态机制。参数中接口的使用使您的代码更加通用。想想无处不在的io.Reader和io.Writer。它们是接口的绝佳示例。它们是您可以将两个看似无关的库拼凑在一起并让它们正常工作的原因。例如,您可以登录到 stderr,或登录到磁盘文件,或登录到 http 响应。它们中的每一个都以完全相同的方式工作,因为log.New接受一个io.Writer参数,并且一个磁盘文件、stderr 和 http 响应编写器都实现了io.Writer. 简单地使用接口来“隐藏实现细节”(我稍后会解释为什么这点失败),不会给您的代码增加任何灵活性。如果有的话,那就是滥用接口,将它们用于它们不打算完成的任务。
点/对位
- “通过确保隐藏所有细节,隐藏我的实现提供了更好的封装和安全性。”
- 您没有实现任何更大的封装或安全性。通过使结构字段不导出(小写),您已经防止包的任何客户端弄乱结构的内部。包的客户端只能访问您导出的字段或方法。导出结构并隐藏每个字段没有错。
- “结构值是肮脏和原始的,我不喜欢传递它们。”
- 然后不要传递结构,将指针传递给结构。这就是你已经在这里做的事情。传递结构本身并没有什么问题。如果您的类型表现得像一个可变对象,那么指向 struct 的指针可能是合适的。如果您的类型更像是一个不可变的数据点,那么 struct 可能是合适的。
- “如果我的包导出是不是很混乱
package.Struct,但客户必须总是使用*package.Struct?如果他们犯了错误怎么办?复制我的结构值是不安全的;事情会崩溃!”
- 为了防止出现问题,您实际上需要做的就是确保您的包只返回
*package.Struct值。这就是你已经在这里做的事情。大多数情况下,人们会使用 short assignment:=,因此他们不必担心类型是否正确。如果他们确实手动设置了类型,并且package.Struct不小心选择了类型,那么在尝试为其分配 a 时会出现编译错误*package.Struct。
- “它有助于将客户端代码与包代码分离”
- 也许。但是,除非您有一个现实的期望,即您有多个这种类型的现有实现,否则这是一种过早优化的形式(是的,它确实会产生后果)。即使您确实有多个接口实现,这仍然不是您应该实际返回该接口值的充分理由。大多数情况下,只返回具体类型更合适。要了解我的意思,请查看
image标准库中的包。
什么时候真正有用?
制作一个过早的界面并返回它可能对客户有帮助的主要现实情况是:
- 您的包引入了接口的第二个实现
- AND 客户端已
:=在其函数或类型中静态且显式地(未)使用此数据类型 - AND 客户端也希望为新实现重用这些类型或函数。
- AND 客户端已
请注意,即使您没有返回过早的接口,这也不会是破坏性的 API 更改,因为您只是添加了新类型和构造函数。
如果您决定只声明这个过早的接口,并且仍然返回具体类型(如image包中所做的那样),那么所有客户端可能需要做的就是使用他们的 IDE 的重构工具花几分钟来替换*package.Struct为package.Interface.
它显着阻碍了包文档的可用性
Go 拥有一个名为 Godoc 的有用工具。Godoc 从源代码自动生成包的文档。当你在你的包中导出一个类型时,Godoc 会向你展示一些有用的东西:
- 类型、该类型的所有导出方法以及返回该类型的所有函数都在 doc 索引中组织在一起。
- 该类型及其每个方法在显示签名的页面中都有一个专用部分,以及解释其用法的注释。
一旦你将你的结构泡沫包装到一个接口中,你的 Godoc 表示就会受到伤害。您的类型的方法不再显示在包索引中,因此包索引不再是包的准确概述,因为它缺少很多关键信息。此外,每个方法在页面上不再有自己的专用空间,这使得查找和阅读文档变得更加困难。最后,这也意味着您不再能够单击文档页面上的方法名称来查看源代码。在许多采用这种模式的包中,这些不强调的方法通常没有文档注释,这也并非巧合,即使包的其余部分都有很好的文档记录。
在野外
https://pkg.go.dev/github.com/zserge/lorca
https://pkg.go.dev/github.com/googollee/go-socket.io
在这两种情况下,我们都看到了误导性的包概述,以及大多数未记录的接口方法。
(请注意,我不反对这些开发人员中的任何一个;显然每个包都有它的缺点,这些示例都是精心挑选的。我也不是说他们没有理由使用这种模式,只是他们的包文档受到了阻碍)
标准库中的示例
如果您对接口如何“打算使用”感到好奇,我建议您查看标准库的文档,并注意接口的声明、作为参数和返回的位置。
https://golang.org/pkg/net/http/
https://golang.org/pkg/io/
https://golang.org/pkg/crypto/
https://golang.org/pkg/image/
这是我所知道的唯一一个与“接口隐藏”模式相当的标准库示例。在这种情况下,reflect 是一个非常复杂的包,reflect.Type内部有多种实现。还要注意,在这种情况下,即使是必要的,也没有人应该对此感到高兴,因为对客户来说唯一真正的影响是更混乱的文档。
https://golang.org/pkg/reflect/#Type
tl;博士
这种模式会损害您的文档,同时在此过程中不会完成任何工作,除非您可能会在非常特定的情况下稍微加快客户端使用您将来可能会或可能不会引入的这种类型的并行实现的速度。
这些界面设计原则是为了客户的利益,对吧?站在客户的角度思考:我真正获得了什么?