为什么 golang 提倡 「接收接口,返回结构体」这样的原则呢?

50 天前
 shinelamla

看到很多 go 代码在构造对象的时候,Newxxx()的时候,都喜欢接收接口,然后返回结构体,查阅了一些资料,始终无法理解这一操作的精髓,所以想问问大家,对这个 go 惯例的理解是怎么样的,希望得到一些指点

6941 次点击
所在节点    程序员
84 条回复
kuanat
43 天前
之前的话题让我给带跑偏了,前面解释了 accept interfaces ,这里回归到 return structs 上面总结一下。

这句话的应用场景应该是 API 兼容性方面的,即返回结构体的代码写法可以避免很多不兼容的改动。

我之前在 Python 包管理的一个帖子里 https://hk.v2ex.com/t/1007645 简单提到过,像 Go 这样设计先于实现的语言,都会将包管理作为工具链的一部分。但这里的大前提是广大开发者合作,所有开源项目的包都尽量支持 semantic versioning 的版本号原则。包的提供者通过版本号主动声明 API 兼容性,包管理可以以很低的成本(非 NP 算法)解决依赖计算问题。

另一方面,Go 在 OO 抽象层面选择了组合机制而不是继承,从客观事实上也鼓励了包的复用。作为 Go 的开发者需要一个思维转变,就是任何一个包都可能依赖别的包,也可能被别的包依赖。后者这个情况就需要开发者清楚了解,什么情况下会造成 API 无法向后兼容。

在之前的讨论里已经明确过,Go 的接口是由使用方定义的,当这个使用方 X 的包变成其他包的依赖之后,X 就很难对这个导出接口做改动了,因为给一个接口增加新的方法一定是个非兼容的改动。

所以对于一般的应用场景来说,既然接口是调用方来定义的,那么这个定义只对调用方有意义,它完全可以是非导出的形式。这样 X 对于接口的改动都不会影响到下游的使用者。

这句话隐含的意思是 return structs (not interfaces),针对的是从传统基于继承的语言转过来,习惯使用工厂方法而言的。

在 Java 中需要在多个实现中选择一个实例化的时候, 受到接口必须和实现在一个包里的限制,使用工厂方法实际上是暴露接口,隐藏对象(结构体)。在 Go 当中没有这个限制,实际是鼓励暴露结构体,隐藏接口。(当然技术上说一个非导出的接口只是形式上不可见,下游依旧可以根据源码隐式实现,这里不展开说了。)



Java 部分就不举例了,这里用 Go 模仿工厂方法模式来展示这样做的缺陷:

```go
type Storage interface {
____Get()
}
func NewStore(provider string) Storage {
____switch provider {
____case "A": ...
____case "B": ...
____default: ...
____}
}
```

项目使用过程中发现,还需要批量下载接口,于是想修改接口为以下形式:

```go
type Storage interface {
____Get()
____GetBatch()
}
```

无论在 Go 还是 Java 中,这个非兼容改动会导致大量的修改工作。

回到 Go idiomatic 的实现方式上:

```go
type storage interface {
____Get()
____GetBatch()
}
type MyStore struct {}
func NewStore(s storage) *MyStore {}
```

下游只依赖 MyStore ,上有对于 storage 的改动是 API 兼容的。

对于接口改动,需要对 A/B 的实现进行封装,改动也比工厂方法模式简单。比如可以独立另一个接口:

```go
type storageExt interface {
____GetBatch()
}
```

也可以用在结构体中嵌入( embedding )一个接口,其他部分封装一下:

```go
type MyStore interface {
____storage
}
```

这里有个技术层面的大前提,扩展结构体在绝大多数时候都是向后兼容的,而扩展接口永远都不是向后兼容的。所以暴露一个未来可能扩展的结构体,远比暴露一个接口更合理。关于这一点可以看我在另一个帖子 https://v2ex.com/t/1007845 当中的回复,中间提到两个讲座就是对这个问题的解释。

由于 V2EX 回复里面插代码太难读了,我这里就不举例展开了,顺着这个场景想象一下大概就知道增加功能这个需求所需要的工作量。就这个扩展接口的场景,Java 无论如何都要 X 主导这个修改,而 Go 里面 X 有需要就 X 来改; Y 如果有需要,把 X 的包引入进来,Y 也可以做这个修改。还是那句话,Go 的接口模式实现了工程层面(不仅仅是 API 层面)的解耦。


做个简单总结:

Java 工厂方法模式是为了解决只有 Java 才有的问题而形成的一般设计方法,而 Go 天然是不存在这个问题的。所以在 Go 中使用接口的原则和 Java 中是完全不一样的。

站在上游的角度,主动暴露接口一般是两个目的:一是规范使用,比如标准库把 Error 定义为接口;二是为文档服务,因为非导出接口是不会体现在 godoc 里面的。

站在下游的角度,只有在第一种情况才会主动使用上游接口,比如所有人都用 slog 的日志接口;使用上游接口等于主动为自己增加一个硬编码的依赖,正确的做法是使用上游暴露的结构体,然后封装并实现自己的接口。

顺便一提,由于太多下游错误使用上游接口的情况存在,很多上游开发者会在导出接口中包含一个非导出方法,这样下游就无法实现这个接口,上游就可以主动控制下游的使用方式,避免后期改动影响太多用户。

Accept interfaces, return structs 虽然只有四个字,但它代表的是思维模型的转变,想要说清楚实在是太困难了。这句话的核心思想我觉得 Rob Pike 的总结更恰当:Don't design with interfaces, discover them.

用我的话来总结就是,不要沿用 Java 面向接口编程的思路,先设计再实现,而是先实现,当发现有重复实现的需要时,再用接口来重构。在 Java 里面,没有设计到的功能重构起来代价非常大,所以变相要求预先做大量设计,而 Go 里面重构代价非常小,用到了再改。这是组合优于继承的体现。
chaleaochexist
6 天前
- 接口应该是最小的应该用到的方法的集合,不将不必要的方法添加进接口。例如,有 add()方法和 addAll()方法,add()可以被定义到 interface 中,而 addAll()应该定义到结构体中,因为 addAll()可以通过 多次循环 add()来实现

这条没太看懂, 能举个例子吗?
如果接收方使用接口想调用 addAll() 调不了啊.
chaleaochexist
4 天前
@kuanat #68
>>> 如果需要增加 A 作为云服务后端,而 A 的 sdk 只有单文件 put 功能,那么我适配的时候可以直接 func (a *A) batchUpload() { ... } 然后调用 a.put() 完成实现。

不是很理解, 在 package B 给 pkg A 的结构体加方法这件事本身语法上就不支持啊!!!
要不大佬你在本地实际运行一下,然后给一个例子?
chaleaochexist
4 天前
@kuanat #80
```go
type storage interface {
____Get()
____GetBatch()
}
type MyStore struct {}
func NewStore(s storage) *MyStore {}
```

这个例子没看懂, 大佬能展开一下`func NewStore(s storage) *MyStore {}` 这部分吗?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1040434

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX