文章目录
- [#4 过度使用getter和setter](#4 过度使用getter和setter)
- [#5 接口污染](#5 接口污染)
#4 过度使用getter和setter
在 Go 语言中,强制使用 getter 和 setter 并不符合惯用语法。务实的做法是在效率和盲目遵循某些惯用语法之间找到合适的平衡点。
数据封装指的是隐藏对象的值或状态。Getter 和 Setter 通过在未导出的对象字段之上提供导出方法来实现封装。
在 Go 语言中,不像某些语言那样自动支持 Getter 和 Setter。使用 Getter 和 Setter 访问结构体字段既非强制性的,也非惯用做法。如果 Getter 和 Setter 没有实际意义,我们不应该在结构体上堆砌过多的 Getter 和 Setter。我们应该务实,努力在效率和遵循某些在其他编程范式中被认为是不可动摇的惯用做法之间找到合适的平衡点。
请记住,Go 是一种独特的语言,其设计理念包含许多特点,其中之一就是简洁性。但是,如果我们发现需要使用 Getter 和 Setter,或者如前所述,预见到未来在保证向前兼容性的前提下可能需要使用它们,那么使用它们也无可厚非。
#5 接口污染
抽象概念应该被发现,而不是被创造。为了避免不必要的复杂性,应该在真正需要的时候才创建接口,而不是在预见到需要的时候才创建,或者至少要确保你能证明该抽象概念是有效的。
在设计和构建代码时,接口是 Go 语言的基石之一。然而,就像许多工具或概念一样,滥用接口通常不是明智之举。接口污染指的是在代码中堆砌不必要的抽象,使其难以理解。这是来自其他语言、习惯不同的开发者常犯的错误。在深入探讨这个话题之前,让我们先回顾一下 Go 的接口。然后,我们将探讨何时适合使用接口,以及何时使用接口会被视为接口污染。
概念
接口提供了一种指定对象行为的方法。我们使用接口来创建多个对象可以实现的通用抽象。Go 接口的独特之处在于,它们的实现是隐式的。没有像 implements 这样的显式关键字来标记对象 X 实现了接口 Y。
为了理解接口为何如此强大,我们将深入研究标准库中两个常用的接口:io.Reader 和 io.Writer。io 包为 I/O 原语提供了抽象。在这些抽象中,io.Reader 用于从数据源读取数据,而 io.Writer 用于向目标写入数据,如下图所示:

io.Reader 包含一个 Read 方法:
go
type Reader interface {
Read(p []byte) (n int, err error)
}
io.Reader 接口的自定义实现应接受一个字节切片,用数据填充该切片,并返回读取的字节数或错误信息。
另一方面,io.Writer 定义了一个单独的方法 Write:
go
type Writer interface {
Write(p []byte) (n int, err error)
}
io.Writer 的自定义实现应该将来自切片的数据写入目标,并返回已写入的字节数或错误信息。
因此,这两个接口都提供了基本的抽象:
- io.Reader 从数据源读取数据
- io.Writer 将数据写入目标
语言中引入这两个接口的理由是什么?创建这些抽象层的目的是什么?
假设我们需要实现一个函数,将一个文件的内容复制到另一个文件。我们可以创建一个特定的函数,该函数接受两个 *os.File 对象作为输入。或者,我们也可以选择使用 io.Reader 和 io.Writer 抽象来创建一个更通用的函数:
go
func copySourceToDest(source io.Reader, dest io.Writer) error {
// ...
}
此函数可处理 *os.File 类型的参数(因为 *os.File 同时实现了 io.Reader 和 io.Writer 接口),以及任何其他实现了这些接口的类型。例如,我们可以创建一个自定义的 io.Writer 来写入数据库,代码保持不变。这提高了函数的通用性,从而增强了其可重用性。
此外,编写此函数的单元测试也更加容易,因为我们无需直接处理文件,而是可以使用 strings 和 bytes 包提供的便捷实现:
t_test.go
只有 文件名以 _test.go 结尾 的文件,才会被 go test 当成测试文件
go
package main
import (
"bytes"
"io"
"strings"
"testing"
)
func copySourceToDest(source io.Reader, dest io.Writer) error {
// 一直循环调用 source.Read(...),把所有数据都读出来,直到遇到 EOF 或错误;
// 把读到的数据拼成一个 []byte 返回。
b, err := io.ReadAll(source)
if err != nil {
return err
}
// 把刚才读出来的 b 一口气写到 dest
_, err = dest.Write(b)
return err
}
func TestCopySourceToDest(t *testing.T) {
const input = "foo"
// strings.NewReader 返回一个实现了 io.Reader 的对象
// 把一个字符串包装成"可读的数据流"
source := strings.NewReader(input)
// dest 一开始是一个空的内存缓冲区,可以往里 Write 字节,然后再把写进去的内容取出来。
dest := bytes.NewBuffer(make([]byte, 0))
err := copySourceToDest(source, dest)
if err != nil {
// 立刻停止执行当前测试函数,后面的代码不再运行。
t.FailNow()
}
// 返回缓冲区中未读取部分的内容,以字符串形式表示。如果 Buffer 指向空指针,则返回 "<nil>"
got := dest.String()
if got != input {
t.Errorf("expected: %s, got: %s", input, got)
}
}
bash
root@GoLang:~/proj1/GoDistributeCache/example# go test -v
=== RUN TestCopySourceToDest
--- PASS: TestCopySourceToDest (0.00s)
PASS
ok github.com/LingoRihood/GoDistributeCache/example 0.054s
在这个例子中,源对象是 *strings.Reader,目标对象是 *bytes.Buffer。这里,我们测试 copySourceToDest 方法的行为,而不创建任何文件。
事实上,向接口添加方法可能会降低其可重用性。io.Reader 和 io.Writer 之所以是强大的抽象,是因为它们已经足够简洁。此外,我们还可以组合细粒度的接口来创建更高层次的抽象。io.ReadWriter 就是如此,它结合了读取器和写入器的行为:
go
type ReadWriter interface {
Reader
Writer
}
何时使用接口
在 Go 语言中,何时应该创建接口?让我们来看三个具体的用例,在这些用例中,接口通常被认为能够带来价值。需要注意的是,我们的目标并非穷尽所有用例,因为用例越多,它们就越依赖于上下文。然而,这三个用例应该能够给我们提供一个大致的概念:
常见行为
我们将讨论的第一个选项是当多个类型实现相同行为时使用接口。在这种情况下,我们可以将该行为提取到接口中。如果我们查看标准库,可以找到许多此类用例。例如,对集合进行排序可以通过三个方法来实现:
- 获取集合中的元素数量
- 判断某个元素是否必须先于另一个元素排序
- 交换两个元素
因此,以下接口被添加到 sort 包中:
go
type Interface interface {
Len() int // Number of elements
Less(i, j int) bool // Checks two elements
Swap(i, j int) // Swaps two elements
}
找到合适的抽象层来提取行为也能带来诸多好处。例如,sort 包提供了一些实用函数,这些函数也依赖于 sort.Interface,例如检查集合是否已排序。例如:
go
func IsSorted(data Interface) bool {
n := data.Len()
for i := n - 1; i > 0; i-- {
if data.Less(i, i-1) {
return false
}
}
return true
}
解耦
另一个重要的应用场景是将代码与具体实现解耦。如果我们依赖抽象概念而非具体实现,那么即使不修改代码,也可以用另一种实现来替换现有实现。这就是里氏替换原则(罗伯特·C·马丁SOLID设计原则中的L)。
go
package main
type CustomerService struct {
store Store
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.store.StoreCustomer(customer)
}
type Customer struct {
id string
}
type Store struct{}
func (s Store) StoreCustomer(customer Customer) error {
return nil
}
type customerStorer interface {
StoreCustomer(Customer) error
}
type CustomerService2 struct {
storer customerStorer
}
func (cs CustomerService2) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.storer.StoreCustomer(customer)
}
由于现在客户信息的存储是通过接口完成的,这让我们在测试方法时拥有更大的灵活性。
限制行为
我们要讨论的最后一个用例乍一看可能有点违反直觉。它涉及将某种类型限制为特定行为。假设我们实现了一个自定义配置包来处理动态配置。我们通过一个 IntConfig 结构体创建了一个用于存储整数配置的特定容器,该结构体还公开了两个方法:Get 和 Set。以下是相应的代码:
go
type IntConfig struct {
// ...
}
func (c *IntConfig) Get() int {
// Retrieve configuration
}
func (c *IntConfig) Set(value int) {
// Update configuration
}
现在,假设我们收到一个包含特定配置(例如阈值)的 IntConfig 对象。然而,在我们的代码中,我们只关心获取配置值,并且希望防止对其进行更新。如果我们不想更改配置包,如何才能在语义上强制执行此配置为只读呢?答案是通过创建一个抽象层,将行为限制为仅获取配置值:
go
type intConfigGetter interface {
Get() int
}
然后,在我们的代码中,我们可以依赖 intConfigGetter 而不是具体的实现:
go
type Foo struct {
threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo { // Injects the configuration getter
return Foo{threshold: threshold}
}
func (f Foo) Bar() {
threshold := f.threshold.Get() // Reads the configuration
// ...
}
完整代码
go
package main
type IntConfig struct {
value int
}
func (c *IntConfig) Get() int {
return c.value
}
func (c *IntConfig) Set(value int) {
c.value = value
}
type intConfigGetter interface {
Get() int
}
type Foo struct {
threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo {
return Foo{threshold: threshold}
}
func (f Foo) Bar() {
threshold := f.threshold.Get()
_ = threshold
}
func main() {
foo := NewFoo(&IntConfig{value: 42})
foo.Bar()
}
在这个例子中,配置获取器被注入到 NewFoo 工厂方法中。它不会影响该函数的客户端,因为客户端仍然可以传递一个 IntConfig 结构体,因为它实现了 intConfigGetter 接口。这样,我们就只能在 Bar 方法中读取配置,而不能修改它。因此,我们也可以出于各种原因(例如语义强制执行)使用接口来限制类型只能执行特定行为。

我们看到了接口通常被认为有价值的三个潜在用例:提取通用行为、实现解耦以及限制类型只能执行特定行为。再次强调,这个列表并不完整,但它应该能让我们对接口在 Go 中何时有用有一个大致的了解。
之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!