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

46 天前
 shinelamla

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

6887 次点击
所在节点    程序员
84 条回复
whitedroa
45 天前
@kuanat 我还是没太懂你的举例。你的第一个回复说的是"功能是对象存储中间件,它支持以 A 厂商云存储作为后端,实现了 get/put 的读写方法。",那么我认为,使用方应该实现一个存储,把这个存储作为入参传给库,通过库去调用。
你的 Golang 代码中,看起来似乎是直接在使用方的代码中使用了这个存储 B 以及库实现的存储 A ?库到底是是一个存储中间件还是一个云厂商的存储 sdk?
kuanat
45 天前
@aababc #38

楼上有个链接,也是提到了原文那个说法 accept interfaces, return structs 含义是很模糊的。

现在 reddit 上有个帖子,里面提到了这句话最原始的出处:
https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a

我上面的回答其实是个简化的版本,并没有非常正面回答 accept interfaces, return structs 的意义,因为这句话根本体现不出来接口对于 Go 的意义(况且很多场合并不适用)。

上面的解释是回归到本质,即它真正想解决的问题什么。我对这个问题的解释是,这样的写法不仅在代码层面把功能进行了解耦,也在工程层面对人的责任边界完成了切分。

就像你所说的,标准库里的接口是个指导作用,如果没有标准库的影响,下层写成任何形式都是有可能的。现在的写法是在当前语言表达能力下,最 idiomatic 那一个。
shinelamla
45 天前
@kuanat 大佬写得好,期望有文章写完的话,可以在这个帖子里面贴一下文章链接。
关于用到了接口再定义,而不用预先设计,这个点也是 go 代码评审里面有提到的,我觉得这样很好
shinelamla
45 天前
@kuanat 其实我之前一直都不是 oo 语言的选手,虽然学校里学的是 java ,但参加工作后写的是 php ,在公司里面使用公司自研的 go 的 mvc 框架来写项目,项目不能说小,但是用到用到接口的地方真的不多,面向业务的项目,没有用 oo 的方式来组织,够用,反而没有那么“啰嗦”
lxdlam
45 天前
不同意 #14 的说法,实际上 #14 所需要的只是 Composable 的 interface ,而并不是所谓的 duck typing 。

引用原文:

> - Don't implement Interfaces preemptively.

在这里提到的 case 实际上是因为,Java 由于无法对任意 External Type 实现 Internal interface ,所以如果原始的行为 Contract 没有被声明为 Interface ,我们既不能实现我们自己的替代品(有 interface 的情况下,实现一个 Adapter 不就可以了?),也无法使用我们的 interface 去将其替代;而这个限制在 Rust 中已经被放宽到不允许使用 External struct 去实现 External trait ( https://doc.rust-lang.org/book/ch10-02-traits.html#implementing-a-trait-on-a-type )。

而 Go 这种 Duck typing ,实际上比起 Java 的显式声明,是另一种推卸责任。举个例子,假定我存在两个 interface:

```go
type Visitor interface {
Saw() bool // If we have seen this node
}

type Sawyer interface {
Saw() bool // Can sawyer saw?
}
```

当我实现 `func (???) Saw() bool` 的时候,我究竟在实现谁?这大大加剧了误用几率,反而在工程上是一个 bad practice 。(这种 case 一定存在,参考 Hyrum's Law - https://www.hyrumslaw.com/ ,一个行为只要被足够多的人观察,无论是否跟你的设计和想法保持一致,一定会有人在依赖这个行为)。

如何回避上述行为?一种方案是实现一个空的 method ,限定其在特定 namespace 下面,比如:

```go
type Visitor interface {
Saw() bool

IsVisitorImpl()
}
// ...
```

但这跟 Java 的 `implements XXX` 相比,无非是把这个 Tag 下推到 `interface` 内部了,本质是完全一样的。

而如果实现细粒度的 `interface`,#40 提出了一个很好的例子,我们甚至可以:

```java
public interface Putter {
String put(Item item);
}

public interface Getter {
Item get(String key);
}

public class Storage {
private Putter putter;
private Getter getter;
}
```

虽然有点累赘,一样实现了类似的细粒度接口组合,并未有任何功能上的差异。
lxdlam
45 天前
@lxdlam 补充一点,Go 的基于 Signature 的 interface matching ,实际上 OCaml 早实现出来了,即使是 First class module ,也是 2011 年引入的,早于 Go 。

```ocaml
module type Foo = sig val foo : string end;;

class a = object method foo = "A" end;;
class b = object method foo = "B" end;;

let print bar = print_endline bar#foo;;

print (new a);; (* "A" *)
print (new b);; (* "B" *)
```
shinelamla
45 天前
@ChristopherWu 是的,几乎很少写单侧,我查的资料里面,几乎都提到了「接受接口,返回结构体」对单测很有用。其实你这个描述引起了我另一个问题:当我的服务支持了一个新的功能的时候,是提供一个新接口,下游再实现一遍,还是往旧接口新增方法,下游重新对接一遍?
lxdlam
45 天前
@lxdlam oops ,应该是 #10 ,看错楼了
cenbiq
45 天前
没用过 go ,但写过 ts ,看起来你们说的 go 接口似乎有点像 ts 的接口,也就是一种软性的接口,只是限定接口的成员就算做实现,比如说 interface A { fun get(): string; fun set(value: string); },那么不管你是否继承/实现自 interface A ,只要同时具备 get 和 set 两个相同签名的方法则视为 A 类型,你们说的 go 接口是这样吗?
chonh
45 天前
楼主能举个例子吗,没看懂 [接受接口,返回结构体] 具体指啥
shinelamla
45 天前
@chonh 那就搜一下关键词,golang 接受接口,返回结构体,best practice
kuanat
45 天前
@sagaxu #40

如果你认可要由使用者定义接口,那我们的立场是一致的。用到接口的时候再定义比预先设想就定义要好。

你举的例子正好就是 Go 风格接口的用法。区别在于如果你对 A/B 包的代码没有所有权的话(引入的第三方),并不能直接写 class A implements Storage 这样,所以一般要写一个子类 StorageA 然后你要手动完成 class StorageA implements Storage 内部的代码再封装一下。习惯上一般叫适配器模式吧。

编程语言在图灵完备层面是一样的,只是写法不同。这里的区别在于,Java 里面接口的实现和定义总是在一起的,或者说总是由同一个代码所有者完成的。我上面举的例子,接口定义和实现都是 A 的作者写的,你这个例子里实现和定义都是 main 的作者维护的。

在 Go 的例子里,接口和定义是分属不同的包,由不同的人实现的。
kuanat
45 天前
@lxdlam #46

这个是我孤陋寡闻了。

我有个问题,可能严格来说 OCaml 更接近于 TS 那一类 structural typing 类型的语言?
rming
45 天前
利用抽象实现多态,返回结构体是符合单一职责的原则
kuanat
45 天前
@lxdlam #45

之前走在路上手机回复了第一条,然后觉得不妥又举了代码的例子。经过反复讨论之后我觉得确实不合适,和我想表达的意思差得比较远了。

我这样重新总结一下,就用“推卸责任”这个说法,我觉得很恰当。

Duck typing 通过把类型检查推迟到运行时,达到了解耦接口与实现的目的。

在 Java 这类语言中,接口的定义和实现总是绑定在一起的。要么库的作者提前声明接口,然后给一个示例实现。要么调用方封装适配,把别人的代码封装到自己的接口里。

Go 里面把这个责任拆分了,写实现的就写实现,写接口的就写接口。都不用向对方负责。
Leviathann
45 天前
不就是输入只需要必要的信息,方便复用;返回尽可能详细的信息,方便使用么
kuanat
45 天前
@whitedroa #41

10 楼是走在路上手机回复的,感觉没说清楚,所以补了 11 楼的内容,后面的内容比较好理解一点。原意是 A/B 都是封装了对应厂商的 sdk 的实现,调用的时候是不关心具体是 A/B 哪个实例化的。
ChristopherWu
45 天前
@shinelamla

> 几乎都提到了「接受接口,返回结构体」对单测很有用。其实你这个描述引起了我另一个问题:当我的服务支持了一个新的功能的时候,是提供一个新接口,下游再实现一遍,还是往旧接口新增方法,下游重新对接一遍?

你既然返回结构体了, 为什么不是提供结构体? 我的话, 会直接废了接口, 直接让用户用结构体.
lxdlam
45 天前
@kuanat OCaml 有两种数据对象,一种是传统的函数式对象 Record ,这个是 nomimal 的,类型 tag 区分不同类型,而 object 和 module system 是 structural 的,实际上我例子中提到的函数可以这么写:

```ocaml
let print (bar : < foo : string; .. >) = print_endline bar#foo;;
```

注意到这个独特的签名,其实要求 `bar` 需要具有一个特定的 foo 即可,对其他的都不要求。
Hstar
45 天前
我的体会就这样设计的函数易用.

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

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

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

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

© 2021 V2EX