看到很多 go 代码在构造对象的时候,Newxxx()的时候,都喜欢接收接口,然后返回结构体,查阅了一些资料,始终无法理解这一操作的精髓,所以想问问大家,对这个 go 惯例的理解是怎么样的,希望得到一些指点
package post
type Service interface {
ListPosts() ([]*Post, error)
}
type service struct {
conn *grpc.ClientConn
}
func NewService(conn *grpc.ClientConn) Service {
return &service{
conn: conn,
}
}
func (s *service) ListPosts() ([]*Post, error) {
posts, err := s.conn.ListPosts(...)
if err != nil {
return []*Post{}, err
}
return posts, nil
}
像这样子的代码,NewService()是接收结构体,返回接口,这种代码组织方式,我总觉得很奇怪,但我说不清楚。很多项目和一些开发自己写的框架都是这样风格的构造函数,为什么不直接返回一个结构体呢?相关的讨论可以看看这个post: https://draveness.me/golang-101/, 面向接口编程的部分
1
dobelee 190 天前 1
解耦。便于修改实现及打桩。
|
2
timethinker 190 天前
以接口接收实例,就可以根据构造函数( NewXXX )的参数不同,从而返回不同的结构,接口一般是稳定的,具体实现可以根据侧重不同实现不同的需求策略。
|
3
mcfog 190 天前 via Android
很多时候这是对的,但又不是所有时候,因此 robpike 并不认为这句话应该加入 go proverb
首先后半部分似乎认为一切皆结构体,这并非 golang 倡导的;其次返回接口和接受非接口的具体类型也并不一定就是错误或者不好的代码/设计 https://github.com/go-proverbs/go-proverbs.github.io/issues/37 至于怎么正向理解这句话,看 golang 接口设计和使用(并对比其他常见语言的接口或类似元素设计)就行 |
4
shinelamla OP 希望大家可以给一下实际的代码例子帮助理解一下,看了 2 天资料了,脑子还是没有转过来...
|
5
shinelamla OP @mcfog 「看 golang 接口设计和使用」这个有推荐的吗,特别是正向应用这个原则的这一块的资料,我没找到合适的
|
6
placeholder 190 天前
就是一开始有人这么写,后来有人这么抄,抄来抄去抄多了,就成什么惯例了,能抄,抄完了能跑就行,
你要不想这么抄,那你造点儿别的写法也一样,抄的人多了,也会成什么惯例。 [狗头]保命 |
7
laikick 190 天前
1.解耦. 2.避免不必要的抽象
|
8
laikick 190 天前
@shinelamla 可以去看看 sing-box 的代码 写的挺好的.
|
9
lesismal 190 天前
我觉得最大原因是很多人需要 OO, 而 golang 本身不提供 class 语法糖, 所以当需要 OO 的时候, 只能用接口来实现近似的功能. 但并不是所有东西都需要 OO, 所以接口也并不是必需品.
接口虽然不是 OO , 但本质上它们提供了相同的东西, 主要是多态, 各自有优缺点, 例如 OO 方便共用继承共用代码, 在很多传统领域多年架构设计已经基本形成了行业/领域范式, 比如企业级或者电商, 或者需求明确较少变更的场景以及即使变更也不大影响系统抽象设计的, 比如管理后台, 所以我们也看到, 实际的技术社区也正是如此, 在企业级和电商等领域, Java 这种 OO 加上社区保姆框架的 ** 语言大行其道. OO 的劣势是前期抽象设计成本高, 对于需求不明朗和鸭嘴兽等 OO 不太好解决的设计问题场景, 以及需求迭代非常快很难在前期做好日后的整体抽象设计的场景, 因为变来变去的, 抽象的 class 系统想改动成本比较高. 接口 方便解耦, 用接口也能实现动态调用过程中去执行具体对象/OBJ 的方法, 接口比 class 也轻便, 多大的系统也不需要一开始就对整个系统做大量抽 class 系统设计, 日后需要修改也比较容易, 模块之间的交互, 接口也比 class 要更轻便友好. 接口 因为不具备整体的 class 系统, 所以读代码可能不像 class 系统那样一下子就把各种继承链之类的搞清楚, 但影响也不大. 整体上, 接口轻便灵活, 不管是 OO 以前就擅长的场景, 还是 IT 互联网高速发展的这十几年的快速迭代场景, golang 都能轻松应对, 而且性能也 easy, 普通开发者也不至于写出性能太差的代码. |
10
kuanat 190 天前 via Android 12
我刚好在写一模一样主题的文章,完成后会发上来。起因可以看我最近回复,当时我评价某个项目的代码“不能正确使用接口解耦”。
如果你在学习 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 应该算是第一个。 |
11
kuanat 190 天前 4
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 这里根本没必要预先定义,等用到的时候再写就是了,用多少写多少。 |
12
mahaoqu 190 天前
函数抽象不就是这样嘛。。接受的参数越宽越好,返回的类型越窄越好
带有继承的语言里的协变逆变也是一样的道理,但是更复杂一些 |
13
mcfog 190 天前 via Android
@shinelamla
我觉得你都看了很多代码总结出主题的问题了,不缺看什么第三方或者常见代码了。要看更好的例子可以看看标准库,经典的例子有 io.Reader Writer ,sort.Interface 等 想想如果标准库不接受接口,对应的代码要改成什么样子,甚至能不能做出来,例如 golang 这个 sort.Interface 底层可以是个链表或别的什么结构,这超越了多数语言标准库的能力了 |
14
kingofzihua 190 天前
Newxxx 可以理解为其他语言的 构造函数, 接受接口是因为,我不关心外部的实现, 返回结构体是,返回一个具体的实现,给你用,其他语言的构造函数也是一样的道理啊
|
15
tairan2006 190 天前
这个算是提前抽象,也不用都这么做,尤其是写业务代码的,完全没必要都写个接口。
但是如果你是写给别人用的库,最好做一层抽象,这样后面修改起来不会损害接口的兼容性。 |
16
leonshaw 190 天前 via Android 1
接收接口就是最基本的抽象。把 NewXxx 看作构造函数就应该返回具体类型,看作抽象工厂就应该返回接口,大部分情况是前者。
|
17
GeruzoniAnsasu 190 天前
#14 非常本质。
其实就是 **接受约定,返回实现** golang 的「接口」哲学比较像 tailwind css 这种颗粒化特性描述符。这也正是 composing 的体现。 |
18
NessajCN 190 天前
说一下我个人经验啊,刚开始只会 go 的时候我也不太理解为啥这么干
但是等我学完 Rust 的 trait 之后再看 go 这边的 interface 获取完全理解了... 那边是定义泛型函数的时候规定泛型有某个 trait 就能直接调方法了, go 这边的接口虽然省了 impl 之类的语法但思路是完全一样的 |
19
sagaxu 190 天前
@kuanat
-用 Java 来实现的话,他要么把你的包以源码的形式复制一遍加入到项目里,要么就要向你提 PR ,来增加对 B 厂商的支持。这是因为 class B implements StorageInterface 只能写在你的包里。 --------------------------------------------------------------------------------------------------------- 当我们某个功能有多个实现的时候,会习惯性的定义一个自己的接口,再把多个实现封装进去。把代码复制一遍和提 PR ,是不太常规的做法。 |
21
kaf 190 天前
不是 Newxxx()的时候,都喜欢接收接口,然后返回结构体,而是接口类型推荐这样使用
|
22
lasuar 190 天前
不是固定模式,如果场景不需要抽象,Newxxx() 返回的是结构体,反之是接口。Newxxx()也是 Go 的工厂函数。
|
23
gowk 190 天前 1
@kuanat #10
看完你的回复挺感慨的,Go 和 Java 都写过,特别能理解你说的这些 所以当许式伟说 Go 开启了现代编程语言的先河的时候 下面跟帖一堆冷嘲热讽的。根本没有完美的语言,每个语言都有长处和短处 说实在的,现在你不掌握个几门语言,出门都不好意思打招呼 不要限定自己是 Java 程序员、Go 程序员、JavaScript 程序员。。。 用适合的语言去实现你的业务,你的想法和创意,这些才是最重要的 语言只是实现手段而已,其实真的没有那么重要。。起码没有你想象中的重要 https://twitter.com/xushiwei/status/1783302674492055863 |
24
lolizeppelin 190 天前
对应现实中标准的自动化生成流程
非标转标 非标不能太宽泛..所以有接口限制 |
25
wwhontheway 190 天前
弱弱的问一句,如果是为了解藕,接收接口返回接口不是更好?
|
26
yuancoder 190 天前
你可以理解就是构造函数,接受接口类型就不是 go 独有的了,换别的语言也可以这样
|
27
kkbblzq 190 天前
@wwhontheway 因为组合,一个结构体里是可以实现多个接口的
|
28
yusheng88 190 天前
@kuanat
当你不熟悉其它语言( Java )时,就不要硬扯在一起了。 1 、Java 的类型是在变量前面的 2 、Java 对于接口的实现,做兼容升级或变更时,通常是修改 maven 的 pom.xml 中依赖包的版本号就好了。 下游(服务|接口调用方)不用关注接口实现的变动点,一般是上游(服务|接口提供方)通知变更,下游再在 pom.xml 中修改依赖包的版本号即可 |
29
ChristopherWu 190 天前
我也理解为什么提问者不清楚这个问题的答案, 因为不写 mock, 不写测试, 尤其是写库给其他服务用, 根本不会遇到问题, 接口跟结构体使用上没两样.
答案是: 当用接口时, 其他服务使用你的接口, 接口一但改变时, 其他服务也需要重新更新 mock. 而结构不用 当公司几十个上百个服务都用, 又没有统一的 mock 脚本时..是大灾难 |
30
yusheng88 190 天前
所有的设计模式,都是有适用场景的。
在写类库,框架之类的场景中,使用设计模式,会更方便阅读、拓展性更好,更容易维护。 在写简单 curd 业务代码的场景中, 由于这些代码基本不会拓展,复用 [可以在变复杂,需要复用时,再使用设计模式相关写法] ,使用设计模式就没啥用,写接口可能也就是方便做单元测试,切面相关的操作了。 golang 提倡使用 接口 ? 这个是不是真的?保留疑问。 |
31
whitedroa 190 天前
@kuanat 没太懂你写的 Go 的代码,库提供了一个 Use 方法,入参是*A 类型,那使用这个库的调用方,入参不也应该是*A 类型吗。NewStorage 又是给谁用的呢
|
33
kuanat 190 天前 1
@sagaxu #19
你提到“我们”的时候,还是没有跳出固有的思维。 在 Go 的设计思想里,包或者说库的作者,应当( should )假定自己的包有一天可能成为别人的依赖。然而你并不需要假想别人要以何种方式使用你的包,你只管写你的实现完成功能就可以了。别人可能仅仅因为一个结构定义,或者一个导出方法就引用你的包作为依赖。 如果我自己要同时完成某个功能的 A/B/C 三个实现,我当然会提前把接口写好。但是如果我只有 A 的需求,那我完全可以不写接口,写成接口的形式是为了某一天我要添加 B/C 支持、或者我知道这个包可能被别人拿去用而做的预先设计。 无论如何,受限于 Java 的类型检查机制,如果最初没有写成接口,任何人除了包的作者都没有很简单的办法复用一个没有接口的包,因为把接口抽象出来这个操作只能由包的所有者完成。虽然你觉得原作者对于 A 功能的实现写得很好,但想拿过来用,才不得不选择复制粘贴,否则就要求助原包的作者将代码改成接口的形式。复制粘贴的问题是一旦上游更新,你就会面临是不是要手动跟随更新的问题。提 PR 是考虑到,减轻原作者的工作量,提高原作者接口化的意愿,避免你自己跟随更新的麻烦。 这两个都是在尽量减少对原包作者的依赖,但是在人的层面解耦不够彻底。 |
34
kuanat 190 天前 1
@yusheng88 #28
你有没有考虑过,为什么上游要提供接口呢?这是下游的事情。上游 preemptively 接口化是个违反工程实践的行为。 你的思路还停留在“Java 可以用 XXX 的方式来做同样的事情”,不好意思,就本文讨论的话题,Java 真做不到。 |
35
kuanat 190 天前 1
@whitedroa #31
我尝试用注释里带文件名的方式来区分是谁写的某个文件,看起来还是不够清晰表达意图。 现在假设我是一个包的作者,我只关心赶快完成我的功能。这时候 A.go 和 main.go 都是我自己写的,我的 use 方法入参就用我的 *A ,简单粗暴。 现在另一个人看到我写的包,他觉得你竟然把这么复杂的 A 业务给抽象出来了,那他就借你写的 A.go 里面的 get/put/... 方法一用吧,这样他就只需要写个 B 支持,就能同时支持 A/B 了。这里的重点是,他其实只想用我写的 get/put 方法,对其他的不感兴趣。 考虑到对他来说,他的 main 里面不希望为 A/B 写不同的调用方法,于是就写成了 func New(s Storage) *MyStorage 的形式,需要调用 get/put 就在 *MyStorage 上面调用。这个 type Storage interface 里面只包含 get/put 两个方法即可。 这样他的 B.go 就可以简单封装一下 sdk 满足接口就可以了。 这个事情还可以继续下去,第三个人看见了第二个人的代码,还可以添加 C 支持。甚至当他需要 get/put 之外第三个 delete 方法的时候,可以用到 embedding 机制: ```go type C struct { a A } func (c C) delete() { ... } ``` 调用的时候接受一个三个方法 get/put/delete 的接口即可。 全程下游都不需要上游配合。 |
37
yusheng88 190 天前
@kuanat
1 、我不推荐你说上下游,这个东西是有歧义的。 2 、我熟悉 Java , 所以我反驳你用错误的方案( code )做示例,这没问题吧。 3 、我遇到的业务场景都是上游提供接口|接口依赖包的,你的上游是什么我就不知道了。 你写的挺多的,但我真不知道你想说什么。 你的案例,我是真看不懂 Java 有什么不能实现的? 我也不明白为什么 go 的话题下,你要引用 Java 干嘛。 你的意思是调用方先定义接口, 服务方根据接口去实现? 这个只是 maven 中 接口包 谁提供的权责问题。 这个话题讨论的是 goland 为什么提倡使用接口。 我个人理解: 接口设计涉及的原则,都是为了方便后续维护(可拓展、阅读性等),这些是设计模式的内容,是等你无意中使用后,体验到了其好处之后才会有深刻体验的,你才会明白什么场景中使用比较合适,写个 hello world 都用这些东西的话,就是脱裤子放屁 |
38
aababc 190 天前
@kuanat #34
个人感觉这个说法怪怪的,从 golang 的 interface 来看 io.Reader, io.Writer 的设计也是自顶向下,如果没有这个从顶层开始的约束,下层在使用的时候根本就不知道会设计成啥样。 |
39
jqknono 190 天前 via Android
这种做法应该是为了实现两个设计原则:
1. 里式替换 2. 依赖倒置 你需要想象在团队开发里,先约定接口,再各自针对接口实现业务。返回不总是结构体,也可能还是接口,要看下游是自己还是别的开发团队。 以单线程个人开发体验是不那么容易理解,使用接口属于为了协作而多做的事,并行任务虽然总是降低单线程任务的效率,但能降低整体任务的成本。 |
40
sagaxu 190 天前
@kuanat #11
你预设了 Java 一定要改造 A 或者 B 去适配另一个类,实际上很少有人这么用。 现实场景经常更复杂,A 和 B 的方法签名可能不同,甚至 A 中的一个方法,在 B 中要分成多个方法调用,所以一般由使用者抽象定义接口。 // A 包 class A { ____void getFile(Arg a, Arg b, Arg c); ____void putFile(Arg a, Arg b); } // B 包 class B { ____void download(Arg a, Arg b); ____void upload(Arg a, Arg b, Arg c); } // main.java 调用的部分 interface Storage { ____default void get(...) {} ____default void put(...) {} } class StorageA implements Storage {...} class StorageB implements Storage {...} class main { ____void use(Storage a); } 这跟 Go 有很大区别吗? |
41
whitedroa 190 天前
@kuanat 我还是没太懂你的举例。你的第一个回复说的是"功能是对象存储中间件,它支持以 A 厂商云存储作为后端,实现了 get/put 的读写方法。",那么我认为,使用方应该实现一个存储,把这个存储作为入参传给库,通过库去调用。
你的 Golang 代码中,看起来似乎是直接在使用方的代码中使用了这个存储 B 以及库实现的存储 A ?库到底是是一个存储中间件还是一个云厂商的存储 sdk? |
42
kuanat 190 天前 1
@aababc #38
楼上有个链接,也是提到了原文那个说法 accept interfaces, return structs 含义是很模糊的。 现在 reddit 上有个帖子,里面提到了这句话最原始的出处: https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a 我上面的回答其实是个简化的版本,并没有非常正面回答 accept interfaces, return structs 的意义,因为这句话根本体现不出来接口对于 Go 的意义(况且很多场合并不适用)。 上面的解释是回归到本质,即它真正想解决的问题什么。我对这个问题的解释是,这样的写法不仅在代码层面把功能进行了解耦,也在工程层面对人的责任边界完成了切分。 就像你所说的,标准库里的接口是个指导作用,如果没有标准库的影响,下层写成任何形式都是有可能的。现在的写法是在当前语言表达能力下,最 idiomatic 那一个。 |
43
shinelamla OP @kuanat 大佬写得好,期望有文章写完的话,可以在这个帖子里面贴一下文章链接。
关于用到了接口再定义,而不用预先设计,这个点也是 go 代码评审里面有提到的,我觉得这样很好 |
44
shinelamla OP @kuanat 其实我之前一直都不是 oo 语言的选手,虽然学校里学的是 java ,但参加工作后写的是 php ,在公司里面使用公司自研的 go 的 mvc 框架来写项目,项目不能说小,但是用到用到接口的地方真的不多,面向业务的项目,没有用 oo 的方式来组织,够用,反而没有那么“啰嗦”
|
45
lxdlam 190 天前 1
不同意 #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; } ``` 虽然有点累赘,一样实现了类似的细粒度接口组合,并未有任何功能上的差异。 |
46
lxdlam 190 天前 1
@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" *) ``` |
47
shinelamla OP @ChristopherWu 是的,几乎很少写单侧,我查的资料里面,几乎都提到了「接受接口,返回结构体」对单测很有用。其实你这个描述引起了我另一个问题:当我的服务支持了一个新的功能的时候,是提供一个新接口,下游再实现一遍,还是往旧接口新增方法,下游重新对接一遍?
|
49
cenbiq 190 天前
没用过 go ,但写过 ts ,看起来你们说的 go 接口似乎有点像 ts 的接口,也就是一种软性的接口,只是限定接口的成员就算做实现,比如说 interface A { fun get(): string; fun set(value: string); },那么不管你是否继承/实现自 interface A ,只要同时具备 get 和 set 两个相同签名的方法则视为 A 类型,你们说的 go 接口是这样吗?
|
50
chonh 190 天前 via iPhone
楼主能举个例子吗,没看懂 [接受接口,返回结构体] 具体指啥
|
51
shinelamla OP @chonh 那就搜一下关键词,golang 接受接口,返回结构体,best practice
|
52
kuanat 190 天前 1
@sagaxu #40
如果你认可要由使用者定义接口,那我们的立场是一致的。用到接口的时候再定义比预先设想就定义要好。 你举的例子正好就是 Go 风格接口的用法。区别在于如果你对 A/B 包的代码没有所有权的话(引入的第三方),并不能直接写 class A implements Storage 这样,所以一般要写一个子类 StorageA 然后你要手动完成 class StorageA implements Storage 内部的代码再封装一下。习惯上一般叫适配器模式吧。 编程语言在图灵完备层面是一样的,只是写法不同。这里的区别在于,Java 里面接口的实现和定义总是在一起的,或者说总是由同一个代码所有者完成的。我上面举的例子,接口定义和实现都是 A 的作者写的,你这个例子里实现和定义都是 main 的作者维护的。 在 Go 的例子里,接口和定义是分属不同的包,由不同的人实现的。 |
54
rming 190 天前
利用抽象实现多态,返回结构体是符合单一职责的原则
|
55
kuanat 190 天前 1
@lxdlam #45
之前走在路上手机回复了第一条,然后觉得不妥又举了代码的例子。经过反复讨论之后我觉得确实不合适,和我想表达的意思差得比较远了。 我这样重新总结一下,就用“推卸责任”这个说法,我觉得很恰当。 Duck typing 通过把类型检查推迟到运行时,达到了解耦接口与实现的目的。 在 Java 这类语言中,接口的定义和实现总是绑定在一起的。要么库的作者提前声明接口,然后给一个示例实现。要么调用方封装适配,把别人的代码封装到自己的接口里。 Go 里面把这个责任拆分了,写实现的就写实现,写接口的就写接口。都不用向对方负责。 |
56
Leviathann 190 天前
不就是输入只需要必要的信息,方便复用;返回尽可能详细的信息,方便使用么
|
57
kuanat 190 天前
@whitedroa #41
10 楼是走在路上手机回复的,感觉没说清楚,所以补了 11 楼的内容,后面的内容比较好理解一点。原意是 A/B 都是封装了对应厂商的 sdk 的实现,调用的时候是不关心具体是 A/B 哪个实例化的。 |
58
ChristopherWu 190 天前
@shinelamla
> 几乎都提到了「接受接口,返回结构体」对单测很有用。其实你这个描述引起了我另一个问题:当我的服务支持了一个新的功能的时候,是提供一个新接口,下游再实现一遍,还是往旧接口新增方法,下游重新对接一遍? 你既然返回结构体了, 为什么不是提供结构体? 我的话, 会直接废了接口, 直接让用户用结构体. |
59
lxdlam 189 天前
@kuanat OCaml 有两种数据对象,一种是传统的函数式对象 Record ,这个是 nomimal 的,类型 tag 区分不同类型,而 object 和 module system 是 structural 的,实际上我例子中提到的函数可以这么写:
```ocaml let print (bar : < foo : string; .. >) = print_endline bar#foo;; ``` 注意到这个独特的签名,其实要求 `bar` 需要具有一个特定的 foo 即可,对其他的都不要求。 |
60
Hstar 189 天前
我的体会就这样设计的函数易用.
|
61
xguanren 189 天前
@cenbiq 是的 差不多 你自定义的结构体 B 不管他有别的方法.比如 C()D()
和 C 结构体 有 M()B() 各种方法 但是只要满足了接口 A 的 get()set() 这两个接口. 那我这个函数 NewStruct(a A) 就可以用做这个 A 接口来当做参数类型,我只需要去保证我传参进去的不管是 A 还是 B 还是 C. 满足了 get 和 set 这两个方法.那么我就直接通过参数 a 去调用.返回一个我自己的结构体. |
62
leonshaw 189 天前
@kuanat 没有预定义的情况下,两个独立的包不约而同地实现了一组相同签名的方法,从而能让调用者用自己定义的 interface 引用—— 这种情况我从来没遇到过。
如果一个包在开发时没有想到要实现某种通用接口,那它方法的签名大概率会包含某个具体类型(更不要说五花八门的命名)。例如你举的 A.Get 很可能是这样: func (a *A) Get() *AObject {} 这个签名几乎不可能在 B 包里复用,最后不得不加一层适配。 |
63
xguanren 189 天前
就比如我有一个签到程序.或者爬虫程序.
我写了一个任务池.可以抽象为 3 大功能 登陆() 查询() 签到() 我把这个任务池 我内部实现的毕竟完美.我可以定时.判断是否签到成功.种种的实现我感觉比较好.这个时候我发给你.你需要 new 进去一个你自己的签到任务. 登陆.查询.签到 这三个就可以视为接口的三个方法 这样具体的三个方法的实现.我不去操心.因为每个人的网站不同.你可能是签到 v2ee.你的登陆接口可能是直接返回一个 cookie.可能签到梯子网站.你是账号密码直接登陆.然后返回 cookie. 这样我的任务池.我只需要去执行你传参进来的任务.我就可以去签到了. |
64
aababc 189 天前
@kuanat #55 这里怎么感觉有点矛盾,这里如果互相不负责,那么怎么确定实现实现了接口。可以去掉 implement 关键字,但是还是要实现同一个接口。golang 采用的是 标准库的方式提供了一个接口,php 现在的做法是针对通用组件在 PSR 提供了接口,不同的组件可以基于相同的一个接口了互通。 个人感觉工程上没有办法做了 接口和实现完全独立。
|
65
xguanren 189 天前
@xguanren 至于我说什么内部实现毕竟完美..只是举个例子..我的意思是对于开发者来说.只需要去操心我的登陆是返回 cookie 还是账号密码登陆.我的查询是 get 还是 post.我的签到是日签还是几个小时一次.(如果是需要不同定时的话.还需要一个 Interval() time.Duration 接口.来返回定时)
这样你只需要满足这三个方法.你可以传递进来.这样我的任务池到最后.只需要返回一个结构体. 你调用的时候直接 Task.start(). 这样具体的实现部分.就是交给我了我通过你的登陆.拿到 cookie.我就可以不用关心 cookie 是怎么来的.然后如果是定时类的话 我可以通过 Interval()拿到时间.比如说是 time.Hour * 24 一天.time.Minute * 10 十分钟一次.我就可以通过这个去循环了.剩下的查询用户信息.你是希望返回银币铜币.还是返回余额.剩余流量.这个你自己实现.我只需要去负责循环.签到.即可.剩下的比如.断网.签到失败推送.签到成功推送.然后多协程并发处理任务而不是轮询.种种这种小功能吧.你就不用操心了.. 不过有人可能说那我直接定义一个结构体.包含这三个方法.不是也可以吗.但是这样的话.这个类的成员.方法.不就被局限死了吗.我如果用结构的话.那我可以给我的比如 v2ee 签到.我可以给他封装进去个滑块识别.或者其他各种方法. 唔 这是我自己的一点认知.不知道对不对 |
66
sagaxu 189 天前
@kuanat
“Java 里面接口的实现和定义总是在一起的,或者说总是由同一个代码所有者完成的” Java 的世界里,很多接口是权威定义的,如 JDK 和 J2EE 。JDBC 和 JPA 都是 J2EE 定义的接口,实现则由不同厂商提供。JDK 中定义的数据结构如 List ,Map ,Set 等也都是接口,API 签名中一般也用接口类型,像 Hibernate 中的 List 就有支持 lazyload 的特定实现版。slf4j 的实现,有 logback 也有 log4j2 。 在这里,Go 比 Java 省事的例子也有,比如 AutoCloseable ,在 Java 中,即使某个类有 close()方法,也不能隐式的转换成 AutoCloseable ,需要自己写 wrapper 。使用 try-with-resources 的时候,就很不方便了。但是隐式转换也有利弊,好处是方便,弊端是这个 close()未必适用于 try-with-resources 场景,使用者可能要经过仔细研究才知道是不是合适。 |
67
kuanat 189 天前 1
@shinelamla #44
你说的不啰嗦就是我也感同身受,随便举几个例子。 从读的方面说,项目选型的时候有多个开源库备选,选哪个总要花很久调研。Go 在做同样的事情的时候,再复杂的项目,很快就能梳理清楚架构,了解代码质量。 我也是 Java 过来的人,写 Java 的时候我很讨厌写测试。原因是项目依赖很多都是非接口化的,真正用的时候要自己再封装一层。没有接口化的代码是很难做 mock 测试的,所以有很多测试框架使用了运行时动态生成 mock 代码的方式来解决这个问题,但是我内心还是不情愿写。 接触 Go 之后我反倒非常习惯写测试,不论依赖质量高低如何,mock 就是接口套一下的事情,代码很少。很早之前标准库想要提供 mock 的,后来废弃了,原因就是 mock 这个事情其实用不到再搞个库。还有个意外的副作用是甩锅的时候很有底气,接口内侧是我负责,外侧该找谁找谁。 所以我体会到最重要的事情是,只有机制上足够简洁便利,大家才会愿意用主动用,人性使然。大多数时间我并不想辩论“XXX 也可以”这种能不能的问题,大家都是图灵完备的换个表达方式而已,但是好不好用愿不愿意用才更重要。 |
69
kuanat 189 天前 1
@leonshaw #62
经过这么一整个帖子的讨论,我越来越意识到之前的举例不恰当。 不论是 Go 还是 Java 都需要适配,区别更多是在难易程度上。 我设想了一个新例子,比如我一个已经存在的项目,实现了批量上传功能,调用方法入参是个包含 batchUpload() 方法的接口。 如果需要增加 A 作为云服务后端,而 A 的 sdk 只有单文件 put 功能,那么我适配的时候可以直接 func (a *A) batchUpload() { ... } 然后调用 a.put() 完成实现。 也就是说 Go 支持给我并没有所有权的代码里的结构体添加新的方法。换到 Java 里不能修改 A 的实现,就需要子类实现接口过渡一下。 在 Go 里 A 永远是那个 A ,而 Java 里子类和父类就要额外考虑类型兼容的问题。如果再有下游项目引用了我的包,或者需要 mock 一下做测试,Go 都是肉眼可见比 Java 简单很多。 |
70
kuanat 189 天前 1
@sagaxu #66
我在这个帖子反复讨论中突然意识到一个问题,就是 Go 的接口其实并不是等价于 Java 中的接口的。在 Go 实现泛型之前,Go 的接口承担了很大的抽象作用,而这个问题在 Java 中并不存在。 我在构思文章的时候一直很纠结,总感觉说不到重点上。现在看我更应该回答的问题是,Go 这样的设计到底带来了哪些实质的好处,而不是执着于辩论这个设计是否先进。 |
71
kuanat 189 天前 1
@aababc #64
没办法确定“实现”了接口。 在 Java 这种 strongly typed 语言中,这个判定过程发生在编译时,implements 就是告诉编译器做这个验证工作的。在 Go 这种 weakly typed 语言中,这个判定被推迟到运行时,如果没能真正实现,调用的那一刻会产生运行时错误。 于是 Java 的思维模型就是要先说清楚,即库和包的作者主动声明并接口化。而 Go 的思维模型是用到的时候再说,即调用方来定义到底需要什么接口(我定义的我自己当然知道谁实现了谁没实现)。 我前面举的例子可能不是特别恰当,但是由于 Go 的接口声明在调用方,而实现在上游的包和库,这个隔离或者独立已经是非常大的进步了。从各种开源项目看,引用上游依赖几乎是毫无副作用的事情。 |
72
xywanghb 189 天前 1
@kuanat 感觉长脑子了, 我如下理解大佬看看对不对
如果我是提供方 1. go, 我只需要关注我能提供什么能力, 专注于做好自己的东西, 你消费方爱咋用咋用 2. java, 我提供一个功能这个东西以后可能会怎么演变, 我希望提供一个尽可能考虑很多场景的功能或者标准, 更丰富的易用性和扩展性, 并且考虑做大做强以后其他类似厂商都参考我这个标准来实现, 有点像市面上很多厂商提供的功能慢慢都是一些大而全的东西, 占领市场建立标准 如果我是消费方 1. go, 我就像一个海淘客, 找到各种各样的功能提供方, 自己非常灵活的进行组合调用, 自由度更强 2. java, 我就像一个傻子, 更多的是学习标准, 然后找到标准中占有率更多更成熟的, 或者说提供的功能更丰富的, 因为我也不知道以后会扩展成啥样, 所以我现在可以不用, 但是你提供的内容多, 也降低了我以后因为你无法支持造成的改造成本大的隐患, 比较突出的例子就是用 springboot, 很多三方库的实现我不关心, 只需要看 springboot 的一些标准, 具体实现只用依赖进来即可 |
73
kuanat 189 天前 1
@xywanghb #72
我也是到了 70 楼的回复才意识到关键所在,你说的就是我想表达的。 Java 的接口和 Go 的接口只是有一样的名字,实际上作用完全不一样,根本不能拿来类比的。Java 的接口是用来解决多重继承问题的,而 Go 天然基于组合而非继承,接口的能力和责任范围都更大。 Java 的思维模型里,抽象(动词)设计这个行为越早越好,而且机制上鼓励你尽可能考虑易用性和扩展性,原因是后期做调整很麻烦。这让我想起了上学的时候,万物皆对象,想把整个宇宙都用对象和类描述出来。这个思路导致了 Java 在工程方面是有过度设计和复杂化倾向的,现实里 java 团队往往也比较大。 Go 的思维模型里,越简单越好,不需要考虑额外的东西。责任划分非常清晰,抽象这个行为局限在非常小的业务层面。 这中间的区别我认为可以上升到哲学层面,就是我开头提到的汉语和其他语言的区别。汉语是建立在组合的哲学上的,把全宇宙所有具象、抽象的概念都解构归纳成最基础的元素,大概只有几千个汉字。任何人学会这几千个字,就可以尝试自行描述整个世界。 换到其他语言,简单举例几个,化学、医学和植物学,每个都有自己无限衍生的词汇表,在一个领域的词汇积累是无法平移到另一个领域的(多继承失败)。 从这个意义上说,我认为以 Go/Rust 等等现代语言就是先进生产力的代表,减轻了开发者的心智负担,也就解放了生产力。 |
74
shinelamla OP @kuanat 看下我最新的 append ,这样的代码组织虽然能通,但是否是好的?
|
75
Leviathann 189 天前
@kuanat rust trait 也是 nominal 的,只是 trait 是类似 scala 的那种 typeclass
|
76
sagaxu 189 天前
@xywanghb #72
你这总结过于极端了,实际上不同语言思考过程没有那么大的差异。“接收接口,返回结构体”本身就是思考了扩展和演变的结论,否则为何要额外定义一个接口呢?直接“接收结构体,返回结构体”代码量更低。 “接收接口,返回结构体”是一种跟语言无关的模式,Java 也经常这么干。 |
77
Rehtt 189 天前
|
78
loolac 189 天前
规范类的原则看看就行,你可以理解为是为了方便你阅读代码的。不同的项目可以不同,只要不是语法上禁止或警告的没必要深究。
|
79
chonh 188 天前
感谢楼主贴的代码,基本认同#14 楼的。
1. 主要是隐藏实现。小写的 service struct 是具体实现不会对外暴露。 2. Service interface 一看就知道提供了哪些功能,方便别人使用。而 struct 可能实现多个接口,会显示“多余”的方法。 3. interface 好扩展。将实现了 Y interface 的 B struct 嵌套在 A struct 里,A 自动实现 Y 。但 A 并不会成为 B 。 4. 如果返回 interface ,那只能 return struct (或 func )。因为 interface 不能实现 interface 。 5. 如果你的 struct 只是充当 data model ,一般会直接写 struct literal ,不需要额外加个 NewXXX 方法。 6. 这跟继承不继承没关系。 |
80
kuanat 185 天前 1
@shinelamla #74
回复比较晚……单就引文那个代码来看,我觉得没有必要写接口,因为还没用到。直接写成 func (c *Conn) ListPosts() []*Post { ... } 就行,Conn 可以 embed 一个 grpc.ClientConn 这样。 等我写完文章吧,这个话题确实不太容易说清楚。 |
81
kuanat 184 天前 1
之前的话题让我给带跑偏了,前面解释了 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 里面重构代价非常小,用到了再改。这是组合优于继承的体现。 |
82
chaleaochexist 147 天前
- 接口应该是最小的应该用到的方法的集合,不将不必要的方法添加进接口。例如,有 add()方法和 addAll()方法,add()可以被定义到 interface 中,而 addAll()应该定义到结构体中,因为 addAll()可以通过 多次循环 add()来实现
这条没太看懂, 能举个例子吗? 如果接收方使用接口想调用 addAll() 调不了啊. |
83
chaleaochexist 145 天前
@kuanat #68
>>> 如果需要增加 A 作为云服务后端,而 A 的 sdk 只有单文件 put 功能,那么我适配的时候可以直接 func (a *A) batchUpload() { ... } 然后调用 a.put() 完成实现。 不是很理解, 在 package B 给 pkg A 的结构体加方法这件事本身语法上就不支持啊!!! 要不大佬你在本地实际运行一下,然后给一个例子? |
84
chaleaochexist 145 天前
@kuanat #80
```go type storage interface { ____Get() ____GetBatch() } type MyStore struct {} func NewStore(s storage) *MyStore {} ``` 这个例子没看懂, 大佬能展开一下`func NewStore(s storage) *MyStore {}` 这部分吗? |