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

198 天前
 shinelamla

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

8713 次点击
所在节点    程序员
84 条回复
dobelee
198 天前
解耦。便于修改实现及打桩。
timethinker
198 天前
以接口接收实例,就可以根据构造函数( NewXXX )的参数不同,从而返回不同的结构,接口一般是稳定的,具体实现可以根据侧重不同实现不同的需求策略。
mcfog
198 天前
很多时候这是对的,但又不是所有时候,因此 robpike 并不认为这句话应该加入 go proverb

首先后半部分似乎认为一切皆结构体,这并非 golang 倡导的;其次返回接口和接受非接口的具体类型也并不一定就是错误或者不好的代码/设计

https://github.com/go-proverbs/go-proverbs.github.io/issues/37

至于怎么正向理解这句话,看 golang 接口设计和使用(并对比其他常见语言的接口或类似元素设计)就行
shinelamla
198 天前
希望大家可以给一下实际的代码例子帮助理解一下,看了 2 天资料了,脑子还是没有转过来...
shinelamla
198 天前
@mcfog 「看 golang 接口设计和使用」这个有推荐的吗,特别是正向应用这个原则的这一块的资料,我没找到合适的
placeholder
198 天前
就是一开始有人这么写,后来有人这么抄,抄来抄去抄多了,就成什么惯例了,能抄,抄完了能跑就行,

你要不想这么抄,那你造点儿别的写法也一样,抄的人多了,也会成什么惯例。

[狗头]保命
laikick
198 天前
1.解耦. 2.避免不必要的抽象
laikick
198 天前
@shinelamla 可以去看看 sing-box 的代码 写的挺好的.
lesismal
198 天前
我觉得最大原因是很多人需要 OO, 而 golang 本身不提供 class 语法糖, 所以当需要 OO 的时候, 只能用接口来实现近似的功能. 但并不是所有东西都需要 OO, 所以接口也并不是必需品.

接口虽然不是 OO , 但本质上它们提供了相同的东西, 主要是多态, 各自有优缺点, 例如
OO 方便共用继承共用代码, 在很多传统领域多年架构设计已经基本形成了行业/领域范式, 比如企业级或者电商, 或者需求明确较少变更的场景以及即使变更也不大影响系统抽象设计的, 比如管理后台, 所以我们也看到, 实际的技术社区也正是如此, 在企业级和电商等领域, Java 这种 OO 加上社区保姆框架的 ** 语言大行其道.
OO 的劣势是前期抽象设计成本高, 对于需求不明朗和鸭嘴兽等 OO 不太好解决的设计问题场景, 以及需求迭代非常快很难在前期做好日后的整体抽象设计的场景, 因为变来变去的, 抽象的 class 系统想改动成本比较高.

接口 方便解耦, 用接口也能实现动态调用过程中去执行具体对象/OBJ 的方法, 接口比 class 也轻便, 多大的系统也不需要一开始就对整个系统做大量抽 class 系统设计, 日后需要修改也比较容易, 模块之间的交互, 接口也比 class 要更轻便友好.
接口 因为不具备整体的 class 系统, 所以读代码可能不像 class 系统那样一下子就把各种继承链之类的搞清楚, 但影响也不大.

整体上, 接口轻便灵活, 不管是 OO 以前就擅长的场景, 还是 IT 互联网高速发展的这十几年的快速迭代场景, golang 都能轻松应对, 而且性能也 easy, 普通开发者也不至于写出性能太差的代码.
kuanat
198 天前
我刚好在写一模一样主题的文章,完成后会发上来。起因可以看我最近回复,当时我评价某个项目的代码“不能正确使用接口解耦”。

如果你在学习 Go 之前没有太多编程经验,这句话对你来说可能非常自然,自然到令人疑惑,因为你不知道反过来做是什么样子的。如果你的思维模型受 Java 的影响很深,那么理解这句话才能理解 Go 在解耦方面带来的巨大进步。(不抬杠,这里以 Go 和 Java 做对比纯粹因为最方便理解)

要说清楚这个问题需要比较大的篇幅,我这里简单概括一下。

1.
Go/Rust 这类现代语言都放弃了“继承”,这是设计思想的巨大进步,Java 这种旧时代的语言在设计的时候是没有意识到“组合优于继承”的。

理解组合优于继承对于中国人来说非常简单,汉语只需要几千个字就能描述整个宇宙,除此之外的其他语言的,那句台词怎么说的,不是针对谁,在座的诸位……

2.
基于组合的理念之上的 OO 抽象,才产生了 Accept Interfaces, return structs 这个 Go idiomatic 的范式。

我比较认同 Rob Pike 对这句话的评价,它不够准确,也不够精确。如果让我来表述,我会分成两句话:

- The bigger the interface, the weaker the abstraction. 这一句是纯引用作为铺垫,意在表达接口越小越好。

- Don't implement Interfaces preemptively. Preemptive 这个词一般翻译成“抢占式”,这里取其衍生含义,提前或者预先。Java 实现接口的代码范式就是 preemptive 的。

3.
Go 的隐式接口实现和 Java 显式接口实现,根本区别在于 Go 能够以接口为边界,从工程层面将开发工作解耦。

举个例子,你开发了一个库,功能是对象存储中间件,它支持以 A 厂商云存储作为后端,实现了 get/put 的读写方法。

如果有另外一个人需要增加对 B 厂商的支持:

- 用 Go 的话,他只需要引用你的包,然后定义一个包含 get/put 方法的接口,同时将 B 厂商的 sdk 做封装即可。调用的时候直接以接口为参数,而不需要关注具体实现接口的对象。

-用 Java 来实现的话,他要么把你的包以源码的形式复制一遍加入到项目里,要么就要向你提 PR ,来增加对 B 厂商的支持。这是因为 class B implements StorageInterface 只能写在你的包里。

这里就看出 Go 的先进之处了,你要做什么和原包的作者没有关系,原包的作者也不需要关心其他人是怎么用他的包的。而 Java 世界里,要么把上游的人拉进来,要么自己成为上游。代码上好像解耦了,但又没完全解耦,工程上是非常低效率的事情。

为了解决这个麻烦,Java 就有了 Preemptive 实现接口的惯例。考虑到需要增加适配,写类的时候有事没事先写个接口出来,不管用得到用不到。

但这样做的问题是,接口会变得巨大无比。一般的对象存储服务,少说也有二三十个方法。一个有二三十个接口的方法是什么概念?当你需要 mock 一下写测试的时候就有得受了,事实上你可能只会用到 get/put 两个接口而已。

然后 Java 提了一个叫 SOLID 的原则,其中 I 代表接口隔离,意思是要把接口拆分。问题是作为库的作者,你只有一次拆分机会,而包的使用者却有数不清的排列组合。


PS
补充几句题外话,我在 Go 语言主题里的回复经常会被人说是踩 Java 捧 Go ,但我依旧坚持有理有据地论证,而不是停留在嘴巴或者屁股上。

很容易看出来,如果设计思想落后了,想要模仿先进的东西是非常困难的。我不止一次重复过,考虑到 Java/Python 这些语言诞生的时间,从设计层面上评价它们落后是不公平的,毕竟这些语言作为先驱踩了坑,才会有后来现代语言的设计指导思想。

另外需要指出的是,Go 基于 duck typing 的隐士式接口的范式是少数不能通过语法糖等方式在 Java 中实现的机制。在一个静态类型语言上实现 weakly typed 的特性( duck typing ),Go 应该算是第一个。
kuanat
198 天前
Reply

上面解释得可能还不是很清晰,我加一点代码来说明吧,还是以对象存储支持两个后端为例。由于回复不支持 markdown ,所以手动排版了一下。



1.
第一个非接口的版本:

```java
// A.java 实现功能的部分
class A {
____void get();
____void put();
}

// main.java 调用的部分
class main {
____void use(a A);
}
```

这个版本如果别人引用去,是很难添加 B 服务商支持的。除非是复制粘贴拿来用,但如果是复杂的项目,要么只能长期手动维护,要么向上游提 PR 。

所以包作者往往会以 preemptive 的形式,改成接口的版本,方便下游使用:

```java
// A.java
interface Storage {
____void get();
____void put();
}
class A implements Storage { ... }

// main.java
class main {
____void use(s Storage);
}
```

这样下游的人可以自己去适配另一个类 B 来实现 Storage 接口了。

```java
// B.java
import A.Storage;
class B implements Storage { ... }
```

这样做的问题是,如果 Storage 接口方法非常多(正常云服务 sdk 少说都有二三十个方法),那么 B 也要适配同样数量的方法。实际上下游适配 B 可能仅仅需要 get/put 两个方法而已,这对于开发和测试都是非常不利的。



2.
再来看看 Go 的初始非接口版本:

```go
// package A
type A struct { ... }
func (a *A) Get() {}
func (a *A) Put() {}

// package main
func Use(a *A)
```

然后上游作者就什么都不用管了。下游用户看到,想要增加 B 支持:

```go
// package B
type B struct { ... }
func (b *B) Get() {}
func (b *B) Put() {}

// package main
type Storage interface {
____Get()
____Put()
}
type MyStorage struct {}
func NewStorage(s Storage) *MyStorage { ... }
// 没有写成 func Use(s Storage) 是为了体现后半句 return structs ,这个不重要
```

其他不用管了。假如上游发布了更新,只要 Get/Put 接口签名不变(即上有发布的新版 API 向后兼容),那就可以直接升级使用。整个过程里,上游下游的工作是完全独立的。

即便考虑现实中 Storage 有几十个接口,下游用户也只需要实现他所用到的少量方法即可。



二者区别其实是在于:谁来定义接口。Java 的机制决定了只能是 producer 说了算,而 Go 则是 consumer 主导。这样看 preemptive 这个词的意义就很清晰了:还不知道会被怎么用的情况下就把接口定好了,当然是“抢占”了,实际上 Go 这里根本没必要预先定义,等用到的时候再写就是了,用多少写多少。
mahaoqu
198 天前
函数抽象不就是这样嘛。。接受的参数越宽越好,返回的类型越窄越好

带有继承的语言里的协变逆变也是一样的道理,但是更复杂一些
mcfog
198 天前
@shinelamla
我觉得你都看了很多代码总结出主题的问题了,不缺看什么第三方或者常见代码了。要看更好的例子可以看看标准库,经典的例子有 io.Reader Writer ,sort.Interface 等
想想如果标准库不接受接口,对应的代码要改成什么样子,甚至能不能做出来,例如 golang 这个 sort.Interface 底层可以是个链表或别的什么结构,这超越了多数语言标准库的能力了
kingofzihua
198 天前
Newxxx 可以理解为其他语言的 构造函数, 接受接口是因为,我不关心外部的实现, 返回结构体是,返回一个具体的实现,给你用,其他语言的构造函数也是一样的道理啊
tairan2006
198 天前
这个算是提前抽象,也不用都这么做,尤其是写业务代码的,完全没必要都写个接口。

但是如果你是写给别人用的库,最好做一层抽象,这样后面修改起来不会损害接口的兼容性。
leonshaw
198 天前
接收接口就是最基本的抽象。把 NewXxx 看作构造函数就应该返回具体类型,看作抽象工厂就应该返回接口,大部分情况是前者。
GeruzoniAnsasu
198 天前
#14 非常本质。

其实就是 **接受约定,返回实现**


golang 的「接口」哲学比较像 tailwind css 这种颗粒化特性描述符。这也正是 composing 的体现。
NessajCN
198 天前
说一下我个人经验啊,刚开始只会 go 的时候我也不太理解为啥这么干
但是等我学完 Rust 的 trait 之后再看 go 这边的 interface 获取完全理解了...
那边是定义泛型函数的时候规定泛型有某个 trait 就能直接调方法了,
go 这边的接口虽然省了 impl 之类的语法但思路是完全一样的
sagaxu
198 天前
@kuanat

-用 Java 来实现的话,他要么把你的包以源码的形式复制一遍加入到项目里,要么就要向你提 PR ,来增加对 B 厂商的支持。这是因为 class B implements StorageInterface 只能写在你的包里。

---------------------------------------------------------------------------------------------------------

当我们某个功能有多个实现的时候,会习惯性的定义一个自己的接口,再把多个实现封装进去。把代码复制一遍和提 PR ,是不太常规的做法。
nicaiwss
198 天前
@NessajCN 那其实就是 c++的模版和 concept 那套概念

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

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

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

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

© 2021 V2EX