接口的设计哲学

Go 接口定义了一组方法,但并不实现这些方法。任何类型只要实现了接口中定义的所有方法,就可以被视为该接口的实现。Go 中没有显式的 “implements” 关键字,只要类型满足接口的要求,编译器自动认定它实现了该接口。

type Animal interface {
Speak() string
}

type Dog struct{}

func (d Dog) Speak() string {
return "Woof!"
}

type Cat struct{}

func (c Cat) Speak() string {
return "Meow!"
}

在这个例子中,DogCat 类型都实现了 Animal 接口,因为它们都实现了 Speak() 方法。

为什么Go会存在接口?

Go 语言中的接口是为了增强代码的灵活性解耦性

  • 减少耦合:接口提供了抽象层,使得代码更加灵活,允许不同的实现方式。比如上例中的 Animal 接口可以被不同的类型实现,客户端代码不需要知道具体的类型是什么。

    耦合(Coupling)在计算机科学中,特别是在软件工程领域,指的是不同模块、组件或系统之间相互依赖的程度。这种依赖可以表现为一个模块需要知道另一个模块的内部细节以便能够正常工作,或者是一个模块直接操作或调用另一个模块的功能。

    耦合度是评价软件设计质量的一个重要指标:

    • 高耦合:意味着模块间相互关联紧密,一个模块的变化可能会引起其他模块的连锁反应,导致代码难以理解和修改,同时也会降低代码的可重用性和可维护性。
    • 低耦合:则是理想的软件设计目标,它要求模块间的关系尽量简单和间接,每个模块尽可能独立地完成自己的功能,通过明确的接口进行交互,而不是依赖于其他模块的内部实现。低耦合的系统更易于测试、扩展和维护。
  • 简化多态性:Go 舍弃了传统的继承机制,通过接口实现了多态性。不同的类型可以通过实现同一个接口,达到同一个目的。

  • 注重组合而非继承:Go 强调通过组合(composition)而不是继承(inheritance)来构建程序。接口鼓励将功能分解为小的、独立的部分,而不是创建复杂的继承体系。

    组合优于继承。

    我们有一只鸭子和一只鸡,他们工作得很好。

    我们发现鸭子和鸡有很多重复的地方,他们都会飞,都有两只脚两个翅膀,都会唧唧或者嘎嘎叫。

    于是我们抽象出鸟这个父类,鸭子和鸡都继承了鸟这个父类, 当我们想要在飞的时候额外做点什么,只需要修改鸟就好了,代码得到了缩减,维护起来看似方便了。

    鸟工作得也很好。

    我们业务不断扩展,企鹅出现了。 它不会飞,但是会游泳。

    鸟的工作出了问题,于是我们把飞行这个功能被下沉到了会飞的鸟类,企鹅继承自一个不会飞的鸟类。

    接下来橡皮鸭子出现了,人们对于它究竟是不是鸟有了争议。开始浪费时间大量的讨论什么是鸟,鸟该做些什么。

    ……

    但我们的生活中没有鸟(请注意这句话),鸟是一个抽象, 我们生活中有鸡,有鸭。我们觉得他们有一些相同的地方,于是把拥有这些相同点的东西叫做鸟,但永远不知道下一个遇见的,能不能算鸟, 鸟的定义要不要修改。

    这就是继承不适用的原因,让我们看看组合会怎么样。

    我们找到了鸡和鸭的共同点, 会飞,两只脚,两个翅膀,会叫。 这些东西加上其他的特质『组合』成了鸡或鸭。 会飞这个能力就能提出来,使用在每一个需要飞行能力的地方。 当我遇到企鹅,就不用拿飞行来『组合』它。

    飞行,不应该是鸡或鸭从父类继承的能力,而应该是『飞行能力』组合成了鸡鸭的一部分。

设计概念

Go 的设计哲学是 “简单就是美” 和 “清晰胜于复杂”。Go 的接口(interface)是 Go 语言中一个核心的概念,源自其设计哲学,特别是简洁性、组合性和类型系统的灵活性。

通过对比“面向接口编程”和“面向对象编程”可以更加清晰的总结出接口的特点。Go 的接口虽然有一些与面向对象编程(OOP)类似的特性,但它本质上与传统 OOP 有很大的不同。

两者虽然都能实现多态抽象。但在OOP中,多态性是通过继承和虚方法实现的,而 Go 是通过接口实现类似的效果。不同的类型可以实现相同的接口,提供不同的行为;Go 的接口也能像 OOP 中的抽象类一样,提供了抽象层,隐藏具体实现。

与OOP相比,Go没有继承,且接口隐式实现;没有类的层级结构,且比OOP更加灵活。

  • 没有继承:Go 中没有类和继承的概念。传统 OOP 中通过继承共享行为,而 Go 更倾向于通过组合和接口来共享行为。Go 强调 “组合优于继承”。
  • 接口是隐式的:在 OOP 中,类必须显式地声明实现某个接口(如 implements 关键字),而 Go 则通过隐式实现接口,大大减少了依赖和耦合。
  • 接口的灵活性:由于 Go 的接口是隐式实现的,它更加灵活。你可以在任何时候给一个类型添加方法使其符合某个接口,而不需要修改原始类型或重新定义继承关系。
  • 没有类的层级结构:OOP 中的类通常有层级结构,子类继承父类的行为,而在 Go 中,接口没有层级结构,类型通过实现接口的具体方法与接口关联。Go 倾向于"平面化"的设计,没有复杂的层级关系。
特性 面向接口编程 面向对象编程
关注点 行为抽象,关注方法签名(接口定义的方法) 数据和行为结合,关注类的继承和封装
核心机制 通过接口实现解耦合和多态性,通常依赖组合而不是继承 通过类继承来实现代码复用和多态性
耦合性 低耦合,模块之间通过接口通信,易于替换实现 较高耦合,类的层次结构带来依赖,修改某些类可能影响其子类
实现灵活性 高灵活性,通过组合和替换实现可扩展系统 中等灵活性,依赖类的继承体系和多态,层级关系带来复杂性
方法重用 通过实现同一接口的多个实现来重用逻辑 通过继承和方法覆盖来重用父类的逻辑
典型应用场景 通常用于依赖注入、模块化系统设计、可替换的行为逻辑 通常用于具有层次化结构的业务场景,需要复用和扩展对象的功能
设计哲学 强调功能分离,使用接口解耦系统 强调类的继承关系,使用对象封装和多态

运用举例

Q:假如一个数据结构实现了某接口,然后呢?我的意思是接口定义了一系列方法,数据类型实现了接口定义的方法,直接调用它的方法便是,接口的存在有什么用呢?是否有些多余了。

接口的存在看起来像是额外的一层抽象,而实际用处似乎并不明显,特别是在你只想直接调用方法的时候。实际上,Go 的接口在某些场景中确实是为了应对特定的设计需求,让代码在灵活性和解耦性方面表现更好。可以从以下几个场景加深理解。

面向抽象编程,降低耦合

假设你编写了一个函数,它只依赖某个具体的数据结构,那么这个函数的用途非常局限,必须依赖该具体实现。如果将依赖改为接口,那么这个函数可以接受任何实现了该接口的类型,极大提升了复用性和灵活性。

func ProcessAnimal(a Animal) {
fmt.Println(a.Speak())
}

dog := Dog{}
cat := Cat{}
ProcessAnimal(dog) // "Woof!"
ProcessAnimal(cat) // "Meow!"

这里的 ProcessAnimal 函数并不需要知道具体传入的类型是 Dog 还是 Cat,只要它们实现了 Animal 接口即可。这降低了耦合性,如果后续有新的类型如 Bird 实现了 Animal 接口,ProcessAnimal 函数可以不做任何修改直接处理 Bird

代码解耦和模块化

接口可以将代码的依赖关系从具体实现中解耦。例如,在开发中,经常需要通过接口定义来编写可替换的模块。当系统需要更换某个模块的具体实现时,不需要改动依赖它的代码,只需要提供符合接口定义的新实现。

比如,你可能有一个数据存储模块,最开始是用内存存储数据,但后来你希望用数据库存储。通过接口,你可以轻松实现不同的存储方式。

type Storage interface {
Save(data string) error
}

type MemoryStorage struct {}

func (m MemoryStorage) Save(data string) error {
fmt.Println("Saving to memory:", data)
return nil
}

type DatabaseStorage struct {}

func (d DatabaseStorage) Save(data string) error {
fmt.Println("Saving to database:", data)
return nil
}

// 业务代码
func StoreData(s Storage, data string) {
s.Save(data)
}

memStore := MemoryStorage{}
dbStore := DatabaseStorage{}

StoreData(memStore, "sample data") // "Saving to memory: sample data"
StoreData(dbStore, "sample data") // "Saving to database: sample data"

在不修改 StoreData 业务代码的前提下,存储方式可以灵活切换。这种解耦方式使得代码更加模块化,也更加易于维护和扩展。

方便测试

接口在测试中的作用尤为明显。通过接口,你可以轻松替换实现,编写 mock(模拟)对象来替代实际的实现,从而进行单元测试。这样你可以独立测试依赖了接口的业务逻辑,而不依赖实际的复杂实现。

比如在上一个例子中,你可以为 Storage 接口创建一个 mock 对象,用来测试依赖 StoreData 函数的代码,而不需要实际保存数据。

type MockStorage struct {}

func (m MockStorage) Save(data string) error {
fmt.Println("Mock saving data:", data)
return nil
}

mockStore := MockStorage{}
StoreData(mockStore, "test data") // "Mock saving data: test data"

通过使用 MockStorage,你不需要真的去处理存储问题,而是能专注于测试业务逻辑。

动态多态性

Go 的接口提供了动态多态性,可以通过接口来处理不同类型的对象,而不需要知道它们的具体类型。这在某些场景下非常有用,例如处理不同类型的请求、消息或处理策略。

例如,定义一个空接口 interface{},你可以使用它来处理任意类型的对象:

func PrintValue(i interface{}) {
fmt.Println(i)
}

PrintValue(42) // 打印 42
PrintValue("hello") // 打印 hello
PrintValue(true) // 打印 true

这种基于接口的动态类型特性,使得代码可以处理更多种类的输入。一般会结合switch根据输入数据的数据类型来执行不同的操作。

延迟实现的灵活性

接口不仅仅是为了当前的实现考虑。它还允许程序设计者在将来的实现中扩展程序功能。例如,你可以设计好接口但暂时不去实现,或者根据用户需求在未来实现更多接口。

为什么提倡面向接口编程?

从初学C语言的面向过程编程,到C++、Python的面向对象编程,最后到Go的面向接口编程,可以看出这里面编程的思想是逐步递进的关系。为什么要提倡面向接口编程,原因在于面向对象编程出现了一些无法避免的缺陷。

先来谈谈为什么要面向接口编程吧。

面向接口编程的好处

正如前文所提到的,使用接口编程最明显的好处在于解耦。说穿了,也就是“方便维护和扩展”,因为“规范和实现分离”。对于平面化的组合接口编程来说,这是对层级化的继承对象编程得天独厚的优势。

但是实体类也未见得做不到这一点。我们完全可以让顶级实体类只提供空方法,而留待具体实现类提供实现的细节。但是,这么做会导致顶级实体类对子类的控制力减弱,因为顶级实体类无法规定方法的实现。用抽象类可以吗?一个只定义了抽象方法的抽象类在本质上和接口是没有区别的。

那么为什么要用接口而不使用抽象类编程呢?这就要涉及到面对对象编程无法避免的缺陷问题了。

类继承的缺陷

先行者们发现,类继承有一些无法避免的缺陷。

父类的实现细节会影响子类的行为

这句话看上去是理所当然的,但如果父类的实现导致子类出现了bug呢?

要知道,封装可是面向对象最引以为傲的卖点之一,其一大目标就是:你不用管我的具体实现逻辑,只需要知道传入参数和返回值就行了。但这个目标却被面向对象的另一引以为傲的卖点——继承,打破了。

导致子类出现bug的原因来自于类继承编程中重要却潜在冲突的两个要点:封装和继承。一般来说,封装隐藏了程序的具体实现细节,提供了一层新的抽象;继承则允许程序员在封装好的代码的基础上进行拓展,由于封装隐藏了具体的代码实现,所以程序员能够较为容易的上手共同开发。

但是,继承其实破坏了封装。封装的核心理念是隐藏类的实现细节,让使用者只需关心输入和输出,而不必了解内部的实现逻辑。然而,继承引入了问题,因为子类不仅依赖父类的接口(即方法的签名),还依赖父类的具体实现。这样一来,父类的实现细节变得对子类是透明的,这破坏了封装的初衷。

在设计良好的面向对象系统中,子类通常应该只依赖父类的接口(即公开方法),而不应该依赖父类的实现细节。可实际上,由于子类会继承并使用父类的方法,子类不可避免地依赖了父类的实现逻辑。这意味着,如果父类的实现发生改变(即使父类内部逻辑改变没有违反其方法的契约),子类的行为也可能随之发生变化,从而引发潜在的Bug

这种问题在继承体系中非常常见,因为子类可能会无意中依赖父类的特定实现细节,而当这些细节发生变化时,子类的行为可能会变得不可预测。

子类的实现可能会影响父类

如果说子类被父类影响还算情有可原的话,那么父类被子类影响实在是有点说不过去了。

在 Java 等面向对象语言中,父类的构造器在执行时,可能会调用父类中定义的方法。然而,如果这些方法在子类中被覆盖,那么当父类构造器调用该方法时,实际上调用的是子类的实现。此时,子类的构造器尚未完全执行,子类的成员变量尚未被初始化,这会导致严重的错误。

这暴露了面向对象编程中的一个常见问题,即在父类的构造器中调用可被子类覆盖的方法可能导致意外的行为,特别是在父类构造期间子类的状态尚未完全初始化的情况下。这会引发一系列问题。

怎么办?

答案是使用接口编程。

因为接口可以避免类继承的所有问题。

纵观类继承所引起的问题,都是由于其可被实例化造成的,而接口是不可被实例化的,所以其可以避免所有这些问题。由于其不能被实例化,所以不需要在其内部定义非static或非public的属性,进而导致定义非final的属性也是不恰当的(因为一个随时可被任何人随意修改的属性不符合面向对象的价值观);由于其不能被实例化,所以也不需要定义方法的实现,进而导致类可以实现多个接口而不至于担心不同接口出现相同方法签名却有不同实现的冲突

总结一下,就是类可以被实例化,而接口不会。从而避免了一系列因类继承引起的一切问题。

接口的优劣

在设计哲学这一章,我们小结一下接口的优缺点。

优点:

  • 简洁和灵活:接口提供了简洁的多态性,避免了复杂的类层次结构。
  • 松耦合设计:通过接口,代码可以变得更加模块化,便于测试和扩展。
  • 隐式实现减少代码依赖:不需要显式声明类型实现了接口,代码更加灵活且不需要维护冗余的关系。

缺点:

  • 没有继承的代码复用:没有继承意味着 Go 缺少一种直接的代码复用机制。在某些情况下,继承可以减少重复代码的编写,而 Go 依赖组合来实现类似功能。
  • 灵活但不够强制:隐式实现虽然增加了灵活性,但缺乏 OOP 中的显式关系,有时可能使得代码的结构性和可读性稍弱。

参考资料

什么是耦合?_计算机中的耦合-CSDN博客

(3 封私信) 为什么go和rust语言都舍弃了继承? - 知乎 (zhihu.com)

为什么提倡面向接口编程_at father.(father. java:2)-CSDN博客


d367a38091cb770dfa360d3dbf289740