Go 面向对象,封装、继承、多态

发布于:2025-05-08 ⋅ 阅读:(17) ⋅ 点赞:(0)

Go 面向对象,封装、继承、多态

经典OO(Object-oriented 面向对象)的三大特性是封装、继承与多态,这里我们看看Go中是如何对应的。

1. 封装

封装就是把数据以及操作数据的方法“打包”到一个抽象数据类型中,这个类型封装隐藏了实现的细节,所有数据仅能通过导出的方法来访问和操作。这个抽象数据类型的实例被称为对象。经典OO语言,如Java、C++等都是通过类(class)来表达封装的概念,通过类的实例来映射对象的。熟悉Java的童鞋一定记得**《Java编程思想》**一书的第二章的标题:“一切都是对象”。在Java中所有属性、方法都定义在一个个的class中。

Go语言没有class,那么封装的概念又是如何体现的呢?来自OO语言的初学者进入Go世界后,都喜欢“对号入座”,即Go中什么语法元素与class最接近!于是他们找到了struct类型。

Go中的struct类型中提供了对真实世界聚合抽象的能力,struct的定义中可以包含一组字段(field),如果从OO角度来看,你也可以将这些字段视为属性,同时,我们也可以为struct类型定义方法(method),下面例子中我们定义了一个名为Point的struct类型,它拥有一个导出方法Length:

type Point struct {
    x, y float64
}

func (p Point) Length() float64 {
    return math.Sqrt(p.x * p.x + p.y * p.y)
}

我们看到,从语法形式上来看,与经典OO声明类的方法不同,Go方法声明并不需要放在声明struct类型的大括号中。Length方法与Point类型建立联系的纽带是一个被称为receiver参数的语法元素。

那么,struct是否就是对应经典OO中的类呢? 是,也不是!从数据聚合抽象来看,似乎是这样, struct类型可以拥有多个异构类型的、代表不同抽象能力的字段(比如整数类型int可以用来抽象一个真实世界物体的长度,string类型字段可以用来抽象真实世界物体的名字等)。

但从拥有方法的角度,不仅是struct类型,Go中除了内置类型的所有其他具名类型都可以拥有自己的方法,哪怕是一个底层类型为int的新类型MyInt:

type MyInt int

func(a MyInt)Add(b int) MyInt {
 return a + MyInt(b)
}

2. 继承

就像前面说的,Go设计者在Go诞生伊始就重新评估了对经典OO的语法概念的支持,最终放弃了对诸如类、对象以及类继承层次体系的支持。也就是说:在Go中体现封装概念的类型之间都是“路人”,没有亲爹和儿子的关系的“牵绊”

谈到OO中的继承,大家更多想到的是子类继承了父类的属性与方法实现。Go虽然没有像Java extends关键字那样的显式继承语法,但Go也另辟蹊径地对“继承”提供了支持。这种支持方式就是类型嵌入(type embedding),看一个例子:

package main

import "fmt"

type P struct {
	A int
	b string
}

func (P) M1() {
	fmt.Println("P M1")
}

func (P) M2() {
	fmt.Println("P M2")
}

type Q struct {
	c [5]int
	D float64
}

func (Q) M2() {
	fmt.Println("Q M2")
}
func (Q) M3() {
	fmt.Println("Q M3")
}

func (Q) M4() {
	fmt.Println("Q M3")
}

type T struct {
	P
	Q
	E int
}

// M2 重写方法:在 T 中重写 M2 方法,明确调用哪个嵌入结构体的 M2。
func (t T) M2() {
	t.P.M2() // 或者 t.Q.M2()
}

func main() {
	var t T
	t.M1()
	//需要显式调用
	t.P.M2()
	t.Q.M2()
	// 或重写方法
	t.M2()
	t.M3()
	t.M4()
	println(t.A, t.D, t.E)
}

我们看到类型T通过嵌入P、Q两个类型,“继承”了P、Q的导出方法(M1~M4)和导出字段(A、D)。

不过实际Go中的这种“继承”机制并非经典OO中的继承,其外围类型(T)与嵌入的类型(P、Q)之间没有任何“亲缘”关系。P、Q的导出字段和导出方法只是被提升为T的字段和方法罢了,其本质是一种组合,是组合中的代理(delegate)模式的一种实现。T只是一个代理(delegate),对外它提供了它可以代理的所有方法,如例子中的M1~M4方法。当外界发起对T的M1方法的调用后,T将该调用委派给它内部的P实例来实际执行M1方法。

以经典OO理论话术去理解就是T与P、Q的关系不是is-a,而是has-a的关系

组合大于继承

其实这种继承更应该被称为组合。Go 更愿意将模块分成互相独立的小单元,分别处理不同方面的需求,最后以匿名嵌入的方式组合到一起,共同实现对外接口。也就是组合大于继承的思想。

组合没有父子依赖,不会破坏封装。且整体和局部松耦合,可任意增加来实现扩展。各单元持有单一职责,互不关联,自由灵活组合,实现和维护更加简单。

匿名嵌套

匿名嵌套在编译时会根据嵌套类型生成包装方法包装方法实际是调用嵌套类型的原始方法

拓:匿名嵌套的多种玩法

  • struct 匿名嵌套 struct(上面已经展示过了)
  • interface 匿名嵌套 interface
  • struct 匿名嵌套 interface

interface 匿名嵌套 interface

接口可嵌入其他匿名接口,相当于将其声明的方法集导入

当然,注意只有实现了两个接口的全部的方法,才算实现大接口哈。

Go 标准库中经典用法如下:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
   Reader
   Writer
}

struct 匿名嵌套 interface

编译器自动为 struct 的方法集加上 interface 的所有方法
(后面是我猜的)如我们通过 struct.M () 调用 interface 的 M 方法,编译器实际为 struct 生成包装方法 struct.interface.M()

注意:struct 中的 interface 要记得赋值哈,不然调用时会显示 interface nil panic。

type I interface {
	M()
}

type A struct {
	I
}

type B struct {
}

func (B) M() {
	print("B")
}

func main() {
	var a A = A{I: B{}}
	a.M() // B

	// 当然 A 也是 I 接口类型
	var i I = A{I: B{}}
	i.M() // A
}

我们同时验证以下匿名嵌套的同名覆盖问题:

type I interface {
	M()
}

type A struct {
	I
}

type B struct {
}

// 多加这个:验证匿名方法同名覆盖
func (A) M() {
	print("A")
}

func (B) M() {
	print("B")
}

func main() {
	var a A = A{I: B{}}
	a.M() // A
}

Go 标准库中经典用法如下:

context 包中:

type valueCtx struct {
  Context  // 匿名接口
  key, val interface{}
}

// 创建 valueCtx
func WithValue(parent Context, key, val interface{}) Context {
  return &valueCtx{parent, key, val}
}

// 实际重写了 Value() 接口,其他父 context 的方法依旧可以调用
func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  return c.Context.Value(key)
}

3. 多态

经典OO中的多态是尤指运行时多态,指的是调用方法时,会根据调用方法的实际对象的类型来调用不同类型的方法实现。

下面是一个C++中典型多态的例子:

#include <iostream>

class P {
  public:
    virtual void M() = 0;
};

class C1: public P {
  public:
    void M();
};

void C1::M() {
  std::cout << "c1.M()\n";
}

class C2: public P {
  public:
    void M();
};

void C2::M() {
  std::cout << "c2.M()\n";
}

int main() {
  C1 c1;
  C2 c2;
  P *p = &c1;
  p->M(); // c1.M()
  p = &c2;
  p->M(); // c2.M()
}

这段代码比较清晰,一个父类P和两个子类C1和C2。父类P有一个虚拟成员函数M,两个子类C1和C2分别重写了M成员函数。在main中,我们声明父类P的指针,然后将C1和C2的对象实例分别赋值给p并调用M成员函数,从结果来看,在运行时p实际调用的函数会根据其指向的对象实例的实际类型而分别调用C1和C2的M。

显然,经典OO的多态实现依托的是类型的层次关系。那么对应没有了类型层次体系的Go来说,它又是如何实现多态的呢?Go使用接口来解锁多态

和经典OO语言相比,Go更强调行为聚合与一致性,而非数据。因此Go提供了对类似duck typing的支持,即基于行为集合的类型适配,但相较于ruby等动态语言,Go的静态类型机制还可以保证应用duck typing时的类型安全。

Go的接口类型本质就是一组方法集合(行为集合),一个类型如果实现了某个接口类型中的所有方法,那么就可以作为动态类型赋值给接口类型。通过该接口类型变量的调用某一方法,实际调用的就是其动态类型的方法实现。看下面例子:

type MyInterface interface {
 M1()
 M2()
 M3()
}

type P struct {
}

func (P) M1() {}
func (P) M2() {}
func (P) M3() {}

type Q int 
func (Q) M1() {}
func (Q) M2() {}
func (Q) M3() {}

func main() {
 var p P
 var q Q
 var i MyInterface = p
 i.M1() // P.M1
 i.M2() // P.M2
 i.M3() // P.M3

 i = q
 i.M1() // Q.M1
 i.M2() // Q.M2
 i.M3() // Q.M3
}

Go这种无需类型继承层次体系、低耦合方式的多态实现,是不是用起来更轻量、更容易些呢!

Go 通过接口来实现多态。

Go 的接口类型本质就是一组方法集合 (行为集合),一个类型如果实现了某个接口类型中的所有方法,那么就可以作为动态类型赋值给接口类型(注意:定义一个接口变量,该变量本质是个 Struct 类型的变量哦)。

Go 的接口是特别重要的东西,通过学习 Go 接口的底层实现可以学到很多东西,例如动态语言的实现,方法动态派发实现等。

4. Gopher的“OO思维”

到这里,来自经典OO语言阵营的小伙伴们是不是已经找到了当初在入门Go语言时“感觉到别扭”的原因了呢!这种“别扭”就在于Go对于OO支持的方式与经典OO语言的差别:秉持着经典OO思维的小伙伴一上来就要建立的继承层次体系,但Go没有,也不需要。

要转变为正宗的Gopher的OO思维其实也不难,那就是“prefer接口,prefer组合,将习惯了的is-a思维改为has-a思维”。

5. 小结

是时候给出一些结论性的观点了:

  • Go支持OO,只是用的不是经典OO的语法和带层次的类型体系;
  • Go支持OO,只是用起来需要换种思维;
  • 在Go中玩转OO的思维方式是:“优先接口、优先组合”。

网站公告

今日签到

点亮在社区的每一天
去签到