在Golang中使用多个接口

4

我正在学习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())
}

我想要实现的目标:

  • 隐藏我的结构体类型,只导出接口,使代码的客户端只使用 "Card" 接口
  • 希望有一个结构体的字符串表示,因此实现了接口并使用 "String()" 方法,以便能够在实例化我的结构体时调用 "fmt.Println()"

问题出现在当我尝试通过 "Card" 接口使用新卡并尝试获取字符串表示时。 我无法将接口作为 "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 未定义 (类型 card 没有字段或方法 string)"。我可以在 "Card" 接口中添加 "String()" 方法,但我认为这样做不够简洁:我使用相同模型实现了其他实体,我必须在每个实现中都添加冗余代码;而且已经有一个带有该方法的接口。
对于上述问题,有什么好的解决方案吗? 编辑:(回应一些非常好的评论)
  • 我不希望有另一个 Card 接口的实现;我不确定为什么要这样做,即更改接口。
  • 我想让 Card 接口隐藏实现细节,并使客户端针对接口编程而不是具体类型。
  • 我想始终为所有 "card 结构" 实例(包括通过 Card 接口实例化的实例)提供 String() 接口。我不想只为客户端提供 String 接口。在其他一些语言中,可以通过实现两个接口来实现这一点——多重继承。我不是说这是好还是坏,也不想引发关于这个问题的辩论,我只是陈述一个事实!
  • 我的意图是找出语言是否有机制可以同时满足这些要求。如果不可能,或者从设计的角度来看,应该以不同的方式解决问题,那么我准备接受教育。
  • 类型断言非常冗长和显式,并且会暴露实现细节——它们有自己的用途,但我认为在我所面临的情况下并不合适。

1
你可以使用 类型断言 将你的对象转换为 Stringer。 - Sergio Tulentsev
7
我想隐藏我的结构体类型,只导出接口,以便代码的客户端仅使用“Card”接口。同时,您希望使用不属于“Card”接口的方法,这是一个矛盾,导致了问题。首先,您需要解决这个矛盾,然后继续操作就会更容易了。 - mkopriva
1
有几个选项,但适用于不同的目的。其中一个是 type assert,应该被视为某种接口升级,就像新的 fs.FS 在 stdlib 中所做的那样;另一个是在客户端代码和您的代码之间定义一个完整而清晰的接口,这意味着添加 String();还有一个是拥有一个函数 formatCard(card Card) string,它接受接口并提供一个工具来对抽象进行操作 - 如果您期望另一种 Card 的实现,则应使用此选项。 - leaf bebop
2
如mkopriva所指出的那样,@celavek您最初的意图是有矛盾的。您可能想要限制客户端仅使用此接口的方法,或者您并不想这样做。 - Sergio Tulentsev
1
我希望所有“卡片结构”实例(包括通过Card接口实例化的实例)的所有客户端始终可以访问String()接口。我不想只有String接口的客户端。在其他一些语言中,可以通过实现两个接口 - 多重继承来实现这一点。-- 我真的很想知道您如何在那些其他语言中使其工作,而不需要明确String()添加到Card的方法集中,或者明确从另一个接口继承Card。@celavek - mkopriva
显示剩余12条评论
4个回答

11

首先,我需要介绍一些前置知识:

  • 在Go语言中,接口与其他编程语言中的接口不同。您不应该假设其他语言中的所有思想都可以自动转换到Go语言中,其中很多思想是不适用的。
  • Go语言既没有类也没有对象。
  • Go语言不是Java,也不是C++。它的类型系统与这些语言有着明显而有意义的差异。

来自您的问题:

我希望Card接口可以隐藏实现细节,让客户端针对接口进行编程而不是具体类型

这是您其他问题的根源。

如评论中所述,我在多个其他包中看到了这种情况,并将其视为一种特别麻烦的反模式。首先,我将解释为什么这种模式是“反”的。

  • 首先且最重要的是,您的例子证明了这一点。您使用了这种模式,结果产生了不良影响。正如mkopriva指出的那样,它已经创建了一个您必须解决的矛盾。
  • 这种接口的用法与其预期用途相反,您并没有通过这种方式获得任何好处。
接口是 Go 实现多态的机制。 在参数中使用接口可以使您的代码更加通用。想一想无处不在的 io.Readerio.Writer。它们是接口的绝佳示例。这就是为什么您可以将两个看似不相关的库拼合在一起,并让它们正常工作的原因。例如,您可以记录到 stderr,记录到磁盘文件或记录到 http 响应中。所有这些都以完全相同的方式工作,因为 log.New 接受一个 io.Writer 参数,并且磁盘文件、stderr 和 http 响应写入器都实现了 io.Writer。 仅仅是使用接口来“隐藏实现细节”(稍后我会解释为什么这一点失败)并没有为您的代码增加任何灵活性。如果有什么,那就是滥用接口,利用它们来完成它们不应完成的任务。

观点 / 反观点

  1. “隐藏我的实现通过确保所有细节都被隐藏,提供更好的封装和安全性。”
  • 通过将struct字段设置为未导出的(小写),您已经防止了包的任何客户端干扰struct的内部。包的客户端只能访问您导出的字段或方法。导出结构并隐藏所有字段没有问题。
  1. "结构体值是肮脏和原始的,我不喜欢随意传递它们。"
  • 那么不要传递结构体,而是传递指向结构体的指针。这就是你在这里已经做的。传递结构体本身并没有什么本质的问题。如果您的类型的行为类似于可变对象,则指向结构体的指针可能是适当的。如果您的类型更像是不可变数据点,则结构体可能是适当的。
  1. "如果我的包导出package.Struct,但客户端必须始终使用*package.Struct,这会不会令人困惑?如果他们犯错误会怎么样?复制我的结构值是不安全的;事情会崩溃!"
  • 为了避免问题,你唯一需要做的就是确保你的包只返回*package.Struct值。这就是你在这里已经做到的。绝大多数情况下,人们会使用短赋值:=,所以他们不必担心类型是否正确。如果他们手动设置类型,并且意外选择了package.Struct,那么在尝试将*package.Struct分配给它时,他们将收到编译错误。
  1. "这有助于将客户端代码与包代码解耦"
也许吧。但是,除非你有一个现实的期望,即你有多个存在的这种类型的实现,否则这就是一种过早的优化形式(是的,它确实会产生后果)。即使你确实有多个接口实现,仍然没有充分的理由为什么你应该实际上返回该接口的值。大多数情况下,只返回具体类型仍然更加适当。要了解我的意思,请查看标准库中的image包。
何时实际上有用呢?
主要的实际情况是,制作过早的接口并返回它可能有助于客户端的情况是:
- 您的软件包引入了接口的第二个实现 - 客户端已经在他们的函数或类型中静态地和显式地(不是:=)使用了这种数据类型 - 客户端希望为新实现也重用那些类型或函数。
请注意,即使您不返回早期接口,这也不会成为API破坏性更改,因为您只是添加了一个新类型和构造函数。
如果您决定仅声明这个早期接口,并仍然返回具体类型(如在“image”包中所做的那样),那么客户端可能需要做的所有事情就是花费几分钟使用其IDE的重构工具将*package.Struct替换为package.Interface。
这显着阻碍了包文档的可用性。
Go拥有一个有用的工具,叫做Godoc。Godoc可以根据源代码自动生成包的文档。当您在包中导出一个类型时,Godoc会显示一些有用的内容:
- 类型、该类型的所有导出方法以及返回该类型的所有函数在文档索引中归类在一起。 - 该类型及其每个方法都有专门的部分,在其中显示其签名以及解释它的用法的注释。
一旦将您的结构体包装到接口中,您的Godoc表示会受到损害。您的类型的方法不再显示在包索引中,因此包索引不再是包的准确概述,因为它缺少许多关键信息。此外,每个方法不再有自己专用的页面空间,使其文档难以找到和阅读。最后,这也意味着您不再能够单击doc页面上的方法名称查看源代码。同样,在许多使用此模式的包中,这些被弱化的方法常常没有doc注释,即使该包的其余部分已经很好地记录了。

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.Type 的实现。还要注意的是,在这种情况下,即使它是必要的,也没有人会对此感到满意,因为对客户端来说,唯一的真正影响就是文档更加混乱。

https://golang.org/pkg/reflect/#Type

简述

这种模式会损害您的文档,而在此过程中没有完成任何事情,除非您可能会在将来引入并行实现此类型的特定情况下使客户更快地使用。

这些接口设计原则是为了客户的利益,对吧?请站在客户的角度思考:我真正获得了什么?


太棒了!感谢您详细的解释,它确实让我走上了正确的道路。 - celavek
1
@celavek,很高兴它对您有所帮助。如果您对Go的设计哲学感兴趣,包括其故意背离Java和C ++,我建议您观看Rob Pike的这个演讲。他在YouTube上有几个非常棒的演讲,我偶尔会回来看看。 - Hymns For Disco

1

我不确定这是否符合您的要求,但您可以尝试按照下面所示将其他接口嵌入到卡接口中。

type Printer interface {
    String() string
}

type Card interface {
    Printer // embed printer interface
    GetFace() string
    GetSuit() string
}

这基本上就像添加String方法一样。我还需要为每个需要它的实体进行这种组合。 - celavek

1

接口 Card 没有方法 String,但这并不重要,因为底层类型 card 有它,只是该方法对你隐藏了(除非你通过反射访问它)。 在 Card 中添加 String() string 方法将解决问题:

type Card interface {
    GetFace() string
    GetSuit() string
    String()  string
}

0

Go语言没有子类型多态性。因此,你想要实现的模式并不被语言的基础所鼓励。虽然你可以通过组合结构体和接口来实现这种不良模式。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接