Press "Enter" to skip to content

Go 面向对象编程篇(三):通过组合实现类的继承和方法重写

概述

在前面两篇教程中,学院君已经介绍了 Go 语言不像 Java、PHP 等支持面向编程的语言那样,支持 class 之类的关键字来定义类,而是通过 type 关键字结合基本类型或者结构体来自定义类型系统,此外,它也不支持通过 extends 关键字来显式定义类型之间的继承关系。

所以,严格来说,Go 语言并不是一门面向对象编程语言,至少不是面向对象编程的最佳选择(Java 才是最根正苗红的),不过我们可以基于它提供的一些特性来模拟实现面向对象编程。

要实现面向对象编程,就必须实现面向对象编程的三大特性:封装、继承和多态。

封装

首先是封装,这一点我们在上篇教程中已经详细介绍过:将函数定义为归属某个自定义类型,这就等同于实现了类的成员方法,如果这个自定义类型是基于结构体的,那么结构体的字段可以看做是类的属性。

继承

然后是继承,Go 虽然没有直接提供继承相关的语法实现,但是我们通过组合的方式间接实现类似功能,所谓组合,就是将一个类型嵌入到另一个类型,从而构建新的类型结构。

传统面向对象编程中,显式定义继承关系的弊端有两个:一个是导致类的层级越来越复杂,另一个是影响了类的扩展性,很多软件设计模式的理念就是通过组合来替代继承提高类的扩展性。

我们来看一个例子,现在有一个 Animal 结构体类型,它有一个属性 Name 用于表示该动物的名称,以及三个成员方法,分别用来获取动物叫声、喜欢的食物和动物的名称:

type Animal struct {
    Name string
}

func (a Animal) Call() string {
    return "动物的叫声..."
}

func (a Animal) FavorFood() string {
    return "爱吃的食物..."
}

func (a Animal) GetName() string  {
    return a.Name
}

如果我们要定义一个继承自该类型的子类 Dog,可以这么做:

type Dog struct {
    Animal
}

这里,我们在 Dog 结构体类型中,嵌入了 Animal 这个类型,这样一来,我们就可以在 Dog 实例上访问所有 Animal 类型包含的属性和方法:

func main() {
    animal := Animal{"中华田园犬"}
    dog := Dog{animal}

    fmt.Println(dog.GetName())
    fmt.Println(dog.Call())
    fmt.Println(dog.FavorFood())
}

上述代码的打印结果如下:

中华田园犬
动物的叫声...
爱吃的食物...

这就相当于通过组合实现了类与类之间的继承功能。

多态

此外,我们还可以通过在子类中定义同名方法来覆盖父类方法的实现,在面向对象编程中这一术语叫做方法重写,比如在上述 Dog 类型中,我们可以重写 Call 方法和 FavorFood 方法的实现如下:

func (d Dog) FavorFood() string {
    return "骨头"
}

func (d Dog) Call() string {
    return "汪汪汪"
}

当我们再执行 main 函数时,直接在 Dog 实例上调用 Call 方法或 FavorFood 方法时,调用的就是 Dog 类中定义的方法而不是 Animal 中定义的方法:

-w681

当然,你可以可以像这样继续调用父类 Animal 中的方法:

fmt.Print(dog.Animal.Call())
fmt.Println(dog.Call())
fmt.Print(dog.Animal.FavorFood())
fmt.Println(dog.FavorFood())

只不过 Go 语言不同于 Java、PHP 等面向对象编程语言,没有专门提供引用父类实例的关键字罢了(superparent 等),在 Go 语言中,设计哲学一切从简,没有一个多余的关键字,所有的调用都是所见即所得。

这种同一个方法在不同情况下具有不同的表现方式,就是多态,在传统面向对象编程中,多态还有另一个非常常见的使用场景 —— 类对接口的实现,Go 语言也支持此功能,关于这一块我们放到后面接口部分单独介绍。

更多细节

可以看到,与传统面向对象编程语言的继承机制不同,这种组合的实现方式更加灵活,我们不用考虑单继承还是多继承,你想要继承哪个类型的方法,直接组合进来就好了。

多继承同名方法冲突处理

需要注意组合的不同类型之间包含同名方法,比如 AnimalPet 都包含了 GetName 方法,如果子类 Dog 没有重写该方法,直接在 Dog 实例上调用的话会报错:

...

type Pet struct {
    Name string
}

func (p Pet) GetName() string  {
    return p.Name
}

type Dog struct {
    Animal
    Pet
}

...

func main() {
    animal := Animal{"中华田园犬"}
    pet := Pet{"宠物狗"}
    dog := Dog{animal, pet}

    fmt.Println(dog.GetName())

    ...

}

执行上述代码会报错:

# command-line-arguments
chapter04/03-compose.go:49:17: ambiguous selector dog.GetName

除非你显式指定调用哪个父类的方法:

fmt.Println(dog.Pet.GetName())

调整组合位置改变内存布局

另外,我们还可以通过任意调整被组合类型的位置来改变类的内存布局:

type Dog struct {
    Animal
    Pet
}

type Dog struct {
   Pet
   Animal
}

虽然上面两个 Dog 子类的功能一致,但是它们的内存结构不同。

继承指针类型的属性和方法

当然,在 Go 语言中,你还可以以指针方式继承某个类型的属性和方法:

type Dog struct { 
    *Animal
    Pet
}

这种情况下,除了传入 Animal 实例的时候要传入指针引用之外,其它调用无需修改:

func main() {
    animal := Animal{"中华田园犬"}
    pet := Pet{"宠物狗"}
    dog := Dog{&animal, pet}

    fmt.Println(dog.Animal.GetName())
    fmt.Print(dog.Animal.Call())
    fmt.Println(dog.Call())
    fmt.Print(dog.Animal.FavorFood())
    fmt.Println(dog.FavorFood())
}

当我们通过组合实现类之间的继承时,由于结构体实例本身是值类型,如果传入值字面量的话,实际上传入的是结构体实例的副本,对内存耗费更大,所以组合指针类型性能更好

为组合类型设置别名

前面的示例调用父类方法时都直接引用的是组合类型(父类)的类型字面量,其实,我们还可以像基本类型一样,为其设置别名,方便引用:

type Dog struct {
    animal *Animal
    pet Pet
}

...

func main() {
    animal := Animal{"中华田园犬"}
    pet := Pet{"宠物狗"}
    dog := Dog{&animal, pet}

   // 通过 animal 引用 Animal 类型实例 
    fmt.Println(dog.animal.GetName())
    fmt.Print(dog.animal.Call())
    fmt.Println(dog.Call())
    fmt.Print(dog.animal.FavorFood())
    fmt.Println(dog.FavorFood())
}

关于 Go 语言如何通过组合实现类与类之间的继承和方法重写,学院君就简单介绍到这里,下篇教程,我们一起来看看 Go 语言是如何管理类属性和方法的可见性的。

本篇教程的源码可以在 Github 代码仓库获取:https://github.com/geekr-dev/go-tutorial/blob/main/chapter04/03-compose.go

5 Comments

  1. guandaxia
    guandaxia 2021年11月11日

    在“继承指针类型的属性和方法” 这一小节中第一段代码,是不是少写了Pet

    type Dog struct { 
        *Animal
        Pet
    }
    
发表回复