写在正文之前
阅读本文之前我们来先熟悉以下的代码原则,如果你已经很熟悉这些内容,可以直接跳到正文。
-
接口隔离原则:绝不能强迫客户实现其不使用的接口,也不能强迫客户依赖其不使用的方法。 -
多态性:代码变化会根据接收到的具体数据改变其行为。 -
里氏替换原则:如果你的代码依赖于一个抽象概念,那么一个实现可以被另一个实现所替代,而无需更改你的代码。
抽象的目的不是为了含糊不清,而是为了创造一个新的语义层次,在这个层次上,我们可以做到绝对精确。- E.W.Dijkstra
有机代码是根据您在某一时刻所需的行为而增长的代码。它不会强迫你提前考虑类型以及它们之间的关系,因为你很可能无法正确地处理它们。这就是为什么说 Go 更倾向于组合而非继承。与预先定义由其他类型继承的类型并希望它们适合问题领域的做法相比,你有一小套行为,可以从中组合出任何你想要的东西。
理论讲得够多了,让我们开始正文,看下使用 interface 的时候最常犯的错误:
too many interfaces
拥有过多接口的术语叫做接口污染。当你在编写具体类型之前就开始抽象时,就会出现这种情况。由于无法预知需要哪些抽象,因此很容易编写出过多的接口,而这些接口在日后要么是错误的,要么是无用的。
Rob Pike 有一个很好建议,可以帮助我们避免接口污染:
Don’t design with interfaces, discover them. Rob Pike
Rob 在这里指出的是,你不需要提前考虑你需要什么样的抽象。您可以从具体的结构开始设计,只有在设计需要时才创建接口。这样,你的代码就会按照预期的设计有机地发展。
接口是有代价的:它是一个新的概念,你在推理代码时需要记住它。正如 Djikstra 所说,理想的接口必须是 "一个新的语义层次,在这个层次上,人们可以绝对精确"。
因此,在创建接口之前,先问问自己:你需要这么多接口吗?
too many methods
在 PHP 项目中,10 个方法的接口是很常见的。在 Go 中,接口的数量很少,标准库中所有接口的平均方法数量为 2 个。
The bigger the interface the weaker the abstraction
,接口越大,抽象越弱,这实际上是 Go 的谚语之一。正如 Rob Pike 所说,这是接口最重要的一点,这意味着接口越小越有用。
接口的实现越多,通用性就越强。如果一个接口有一大堆方法,就很难有多个实现。方法越多,接口就越具体。接口越具体,不同类型显示相同行为的可能性就越低。
io.Reader 和 io.Writer 就是有用接口的一个很好的例子,它们有数以百计的实现。或者是 error 接口,它非常强大,可以在 Go 中实现整个错误处理。
我们可以用其他接口组成一个接口。例如,这里的 ReadWriteCloser 由 3 个较小的接口组成:
type ReadWriteCloser interface {
Reader
Writer
Closer
}
非行为驱动的接口
在传统语言中,诸如 User(用户)、Request(请求)等名词性接口非常常见。而在 Go 语言中,大多数接口都有 er
后缀:Reader、Writer、Closer 等。这是因为,在 Go 中,接口暴露了行为,而它们的名称则指向该行为。
在 Go 中定义接口时,你定义的不是 "某物是什么",而是 "某物提供了什么"--是 "行为",而不是 "事物"!这就是为什么 Go 中没有 File 接口,但有 Reader 和 Writer:这些都是行为,而 File 是实现 Reader 和 Writer 的事物。
在 Effective Go[1] 中也有提到过:
Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.
在编写接口时,尽量考虑动作或行为。如果你定义了一个名为 "Thing "的接口,问问自己为什么这个 "Thing "不是一个结构体 😁。
producer 端实现接口
经常在 code review 中看到这种情况:人们在写具体实现的同一个包中定义接口:
但是,也许客户并不想使用生产者接口中的所有方法。请记住 "接口隔离原则 "中的一句话:“不应强迫客户端实现其不使用的方法”。下面是一个例子:
package main
// producer side
// This interface is not needed
type UsersRepository interface {
GetAllUsers()
GetUser(id string)
}
type UserRepository struct {
}
func (UserRepository) GetAllUsers() {}
func (UserRepository) GetUser(id string) {}
// client side
// Client only needs GetUser and
// can create this interface implicitly implemented
// by concrete UserRepository on his side
type UserGetter interface {
GetUser(id string)
}
如果客户想使用生产者的所有方法,可以使用具体的结构体。结构体方法已经提供了这些行为。
即使客户想要解耦代码并使用多种实现方法,他仍然可以在自己这边创建一个包含所有方法的接口:
由于 Go 中的接口是隐式实现的,所以可以这样实现。客户端代码不再需要导入某个接口并编写实现,因为 Go 中没有这样的关键字。如果实现(Implementation)与接口(Interface)有相同的方法,那么实现(Implementation)就已经满足了该接口,可以在客户代码中使用。
返回接口
如果一个方法返回的是接口而不是具体的结构,那么所有调用该方法的客户端都会被迫使用相同的抽象。你需要让客户决定他们需要什么样的抽象。
当你想使用结构体中的某项功能时,却因为接口不公开而无法使用,这是很恼人的。这种限制可能是有原因的,但并非总是如此。下面是一个人为的例子:
package main
import "math"
type Shape interface {
Area() float64
Perimeter() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
// NewCircle returns an interface instead of struct
func NewCircle(radius float64) Shape {
return Circle{Radius: radius}
}
func main() {
circle := NewCircle(5)
// we lose access to circle.Radius
}
在上面的示例中,我们不仅无法访问 circle.Radius,而且每次要访问它时都需要在代码中添加类型断言:
shape := NewCircle(5)
if circle, ok := shape.(Circle); ok {
fmt.Println(circle.Radius)
}
Dave Cheney 写的 Practical Go 一书中的一个例子很有说服力:
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
可以改进为:
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
粹为测试而创建接口
接口污染的另一个原因是:仅仅因为想模拟一个实现,就创建一个只有一个实现的接口。
如果通过创建许多模拟来滥用接口,最终测试的将是生产中从未使用过的模拟,而不是应用程序的实际逻辑。在您的实际代码中,您现在有两个概念(如 Djikstra 所说的语义层),而一个概念就可以了。而这只是为了测试你想要测试的东西。难道你想在每次创建新测试时都将语义级别加倍吗?可以使用 testcontainers 来代替模拟数据库。如果 testcontainers 不支持,也可以使用自己的容器。
没有验证接口的兼容性
比方说,你有一个导出名为 User 的类型的软件包,你实现了 Stringer 接口,因为出于某种原因,当你打印时,你不希望显示电子邮件:
package users
type User struct {
Name string
Email string
}
func (u User) String() string {
return u.Name
}
客户端的代码如下:
package main
import (
"fmt"
"pkg/users"
)
func main() {
u := users.User{
Name: "John Doe",
Email: "john.doe@gmail.com",
}
fmt.Printf("%s", u)
}
现在,假设你进行了重构,不小心删除或注释了 String() 的实现,你的代码看起来就像这样:
package users
type User struct {
Name string
Email string
}
在这种情况下,您的代码仍然可以编译和运行,但输出结果将是 {John Doe john.doe@gmail.com}
。没有任何反馈执行你之前的意图。当你的方法接受 User 时,编译器会帮助你,但在上述情况下,编译器不会帮助你。
要强制执行某个类型实现了某个接口,我们可以这样做:
package users
import "fmt"
type User struct {
Name string
Email string
}
var _ fmt.Stringer = User{} // User implements the fmt.Stringer
func (u User) String() string {
return u.Name
}
现在,如果我们删除 String() 方法,就会在构建时得到如下结果:
cannot use User{} (value of type User) as fmt.Stringer value in variable declaration: User does not implement fmt.Stringer (missing method String)
在该行中,我们试图将一个空的 User{} 赋值给一个 fmt.Stringer 类型的变量。由于 User{} 不再实现 fmt.Stringer,我们收到了投诉。我们在变量名中使用了 _,因为我们并没有真正使用它,所以不会进行分配。
上面我们看到用户实现了界面。User 和 *User 是不同的类型。因此,如果你想让 *User 实现它,你可以这样做:
var _ fmt.Stringer = (*User)(nil) // *User implements the fmt.Stringer
凡事没有绝对,我们在写代码时还是要具体情况具体分析,本文只是分享一些通识,欢迎大家广开讨论。
Effective Go: https://go.dev/doc/effective_go