100 Go Mistakes and How to Avoid Them 之代码结构和项目组织(二)
四 过度使用getters and setters方法
未提供具体的代码示例的情况下,默认作为一个提示:可以直接使用的变量而无需调用setter或getter方法
五 接口污染
5.1 概念
接口污染的概念在于通过过度的抽象来处理代码而导致代码难以理解和使用。在Go语言中通常用于定义一组规范性约束而非具体的实现方案。
5.2 正确使用接口的举例
为了深入探求接口强大的原因,在接下来的学习中我们将从Go语言的标准库中挑选两个极具代表性的库进行详细研究。这两个库将分别是我们熟悉的io.Reader和io.Writer。
该包为实现I/O操作提供了原始接口,在这些接口中其中Reader用于从数据源读取信息Writer则负责将数据输出至指定的目标如图所示

io.Reader接口仅有一个Read方法
type Reader interface {
Read(p []byte) (n int, err error)
}
AI助手
该系统实现了与外部设备进行数据交互的核心接口设计。在具体实现过程中:
一般情况下会接收一段连续的字节序列。
随后将获取的数据内容注入到该接收缓冲区中。
最终会输出处理后的数据量信息或检测到读取异常情况。
另一方面,io.Writer接口仅有一个Write方法
type Writer interface {
Write(p []byte) (n int, err error)
}
AI助手
通过实现`io.Writer``接口,通常会将来自字节切片的数据写入到指定的文件中,并在操作完成后会返回所写的字节数或报告错误信息。
探讨这两个接口的工作原理。在构建这些抽象概念时的核心要素是什么?当我们需要设计一个方法时,这涉及将文件内容复制至另一个文件。我们应该设计一个特定的方法作为两个*os.File的输入端点。或者我们可以利用io.Reader和io.Writer接口进行抽象化处理,并设计出更具通用性的解决方案。
func copySourceToDest(source io.Reader, dest io.Writer) error {
// ...
}
AI助手
在本方法中,在传递os.File类型的参数时也会正常运行(因为os.File实现了io.Reader和io.Writer接口)。此外,在Go语言中还有许多其他的实例也实现了这两个接口(io.Reader和io.Writer),所以这些类型都可以被用作输入参数。关于测试部分,则不需要构造复杂的临时文件;相反地,我们可以直接将由字符串生成的strings.reader对象作为reader,并将bytes.Buffer对象作为writer传递给该函数。
func TestCopySourceToDest(t *testing.T) {
const input = "foo"
source := strings.NewReader(input)
dest := bytes.NewBuffer(make([]byte, 0))
err := copySourceToDest(source, dest)
if err != nil {
t.FailNow()
}
got := dest.String()
if got != input {
t.Errorf("expected: %s, got: %s", input, got)
}
}
AI助手
使用该方法的步骤:
① 创建一个io.Reader
② 创建一个io.Writer
③ 调用copySourceToDest函数,从strings.Reader拷贝到bytes.Buffer中。
这样一来,在设计接口时,我们需要注意的是其粒度(即包含的方法数量)。Go语言中有一句与接口大小相关的著名谚语是:
接口粒度越大,抽象越弱
事实上,在接口设计过程中每添加一个新的方法都会对系统的通用性产生影响进而使得整体架构的灵活性受到限制为了避免过于复杂的接口结构开发者必须优先选择那些经过充分抽象的对象模型.其中最著名的两种类型就是io.Reader和io.Writer这两个接口因其极简主义的设计风格成为了许多编程语言中的核心组件.作为开发者你必须始终遵循这一原则以确保系统架构的可维护性和可扩展性.
接口具备构建强大抽象能力的能力。这种能力在多个层面发挥着积极的作用。例如,在通过代码解耦技术实现对系统各部分的有效管理的同时,在提升函数的重用性方面具有显著优势,并有助于实现单元测试这一重要开发流程。
5.3 什么时候该使用接口
那么,在何种情况下应当采用接口呢?以下我们将对两个具有代表性的接口案例进行系统分析,在这些实例中我们可以观察到:通过采用接口能够实现哪些实质性的提升。
5.3.1 常用习惯
接口通常被广泛用于最常见的情况。我们可以深入探讨这一现象的一个典型实例。
该函数实现了某个核心功能,并接受一个customers列表作为参数。然后依次应用多个过滤器以进一步精炼数据集。最终返回满足所有过滤条件后的customers列表。为了提高筛选效率,我们首先定义了三个过滤器:
FilterByAge过滤器,通过age字段过滤
FilterByCity过滤器,通过city字段过滤
FilterByCount过滤器,最多返回多少个customers(例如,不超过100个)
type FilterByAge struct{ minAge int }
func (f FilterByAge) filter(customers []Customer) ([]Customer, error) ①
{
res := make([]Customer, 0)
for _, customer := range customers {
if customer.age < 0 {
return nil, errors.New("negative age")
}
if customer.age >= f.minAge {
res = append(res, customer)
}
}
return res, nil
}
type FilterByCity struct{ city string }
func (f FilterByCity) filter(customers []Customer) ([]Customer, error) ②
{
res := make([]Customer, 0)
for _, customer := range customers {
if customer.city == f.city {
res = append(res, customer)
}
}
return res, nil
}
type FilterByCount struct{ max int }
func (f FilterByCount) filter(customers []Customer) ([]Customer, error)③
{
if len(customers) < f.max {
return customers, nil
}
return customers[:f.max], nil
}
AI助手
① 根据age字段过滤的实现
② 根据city字段过滤的实现
③ 根据最大个数进行过滤的实现
随后我们开发了一个名为applyFilters的方法,并设计其功能如下:该方法将上述三个过滤器依次应用,并最终输出一个结果列表
type filterApplication struct {
filterByAge FilterByAge
filterByCity FilterByCity
filterByCount FilterByCount
}
// Init filterApplication
func (f filterApplication) applyFilters(customers []Customer) (
[]Customer, error) {
res, err := f.filterByAge.filter(customers) ①
if err != nil {
return nil, err
}
res, err = f.filterByCity.filter(customers) ②
if err != nil {
return nil, err
}
res, err = f.filterByCount.filter(customers) ③
if err != nil {
return nil, err
}
return res, nil
}
AI助手
① 应用第一个过滤器
② 应用第二个过滤器
③ 应用第三个过滤器
该方案能够正常运行。然而,在分析过程中我们发现了一些基于公式的代码值得特别关注这一现象的原因在于所有滤镜均展现出一致的功能:filter([]Customer)([]Customer, error)。
所以,我们应该使用接口重构我们的实现:
type Filter interface {
filter(customers []Customer) (result []Customer, err error)
}
type filterApplication struct {
filters []Filter
}
// Init filterApplication
func (f filterApplication) applyFilters(customers []Customer) (
[]Customer, error) {
for _, filter := range f.filters { ①
res, err := filter.filter(customers) ②
if err != nil {
return nil, err
}
customers = res
}
return customers, nil
}
AI助手
① 迭代所有的过滤器
② 应用每一个过滤器
在这个例子中,我们开发了一个API接口来定义一个Filter抽象类型,从而降低了样板代码的数量。我们可以通过在filterApplication结构体中添加更多的Filter实例来提升代码的可维护性,并且applyFilters方法仍然保持原样功能。
5.3.2 什么时候不该使用接口
如果你曾经学习过C++或Java,在定义具体类型之前为接口命名是一种很自然的习惯。然而,在这种情况下进行命名的做法与Go的设计理念相悖。
如同文中所述, 接口不仅允许我们生成抽象, 这些抽象通常是隐藏的, 没有被显式地生成出来. 换言之, 除非有明确的原因, 我们不应该在代码编写阶段就预先定义这些抽象. 而是尽量等到实际需要它们的时候再构建相应的接口, 这样既灵活又避免了不必要的设计开销.
在过度使用接口时会出现什么问题?需要注意的是,在传递接口作为参数时会导致性能上的消耗。当传递接口作为参数时,在程序中会调用一个哈希表查找操作来确定具体的类型信息。尽管如此,由于这些被禁止使用的接口通常缺乏相关的上下文信息(即它们所代表的具体类型),但这一点仍值得注意。核心问题是:使用接口可能导致程序流程变得更加复杂。例如,在某些情况下可能会引入不必要的层级结构(如无谓地增加间接调用),这样的做法并不会带来实际的好处;相反地,则会使得代码变得更为难以理解和维护的原因之一就是这种复杂的交互机制的存在
总体而言,在代码中构建抽象时不容忽视其潜在风险。值得注意的是,这种抽象并非显式关键字所创建的可见形式(implement关键字除外),而是隐藏于程序设计之中。作为软件开发者,在后续需求确定前往往会对哪些代码块可能需要进行抽象操作有所预判。为了避免因不必要的抽象而使代码变得过于复杂和难以理解,开发者应当刻意培养这种敏感性并加以严格控制。
