Go 没有沿用传统面向对象编程的诸多概念:比如继承(不支持继承,尽管匿名字段的内存布局和行为类似继承,但它不是继承)、封装、多态等,而是通过别的方式更优雅、简洁的实现了这些特性:

  • 封装:通过方法实现
  • 继承:通过匿名字段实现
  • 多态:通过接口实现

8.1 方法

在结构体和指针一章中,详细介绍了结构体的用法。使用.可以访问结构体中的数据,但操作略显复杂。Go 提供了方法操作结构体。

方法可以理解成“为特定类型(自定义类型)定义的函数”,这个函数只有特定类型才能调用。特定类型被称为接收者,位于 func 关键字和函数名之间。

方法通过在 func 关键字之后添加额外的参数来增强函数。下面是第七章中用到的示例,我在该示例中增加了一个方法:

type PersonalData struct {
    Name string
    Points int
    Assists int
    Rebounds int
}

func (p *PersonalData) summary() string {
    // summary函数只能由 PersonalData 类型的实例 p 调用
    ...
}

方法在 func 关键字后接收了一个附加参数,称为接收者。接收者也可看做是一种类型,在本例中它是指向结构体 PersonalData 的指针。接下来是方法名和方法的所有参数,然后是返回值类型。除了接收者外,方法和函数没有不同。可以将接收者视为与方法相关联的事物。通过声明 summary 方法,PersonalData 的任何实例都可以使用 summary 方法。

方法总是绑定对象实例,并隐式的将实例作为第一个实参(接收者(receiver)是方法的第一个实参):

  • 参数 receiver 可以任意命名,如果方法中未曾使用,可以省略参数名;
  • 参数 receiver 类型可以是 T 或 *T,但基类型T不能是接口或指针类型;
  • receiver 类型也是方法类型的一部分,即使方法名相同,但只要 receiver 类型不同,就不会出现重复定义函数的错误。

既然方法和函数相似,为什么不直接使用看上去更简单一些的函数呢?我们通过代码回答这个疑问。

type PersonalData struct {
    Name string
    Points int
    Assists int
    Rebounds int
}

func summary(p *PersonalData) string {
    ...
}

从代码可以看出,summary 函数依赖于 PersonalData 结构体。如果函数无法访问该结构体,就不可能被成功声明。多次使用函数还会导致代码重复。此外,如果对函数进行更改,那么需要编辑多个位置。使用方法的好处是只需编写一次即可,然后可以在任何结构体实例上使用它。

正如开篇所说,方法有类似封装的功能,把一些代码进行封装,并对其格式化。

func (p *PersonalData) summary() string {
    n := strconv.Itoa(p.Points)
    return n
}

调用 summary 方法:

package main

import (
    "fmt"
    "strconv"
)

type PersonalData struct {
    Name string
    Points int
    Assists int
    Rebounds int
}

func (p *PersonalData) summary() string {
    n := strconv.Itoa(p.Points)
    return n
}

func main() {
    p := PersonalData{
        Name: "Curry",
        Points: 51,
    }

    fmt.Println(p.summary())
}

8.1.1 方法的继承

方法的继承和结构体的继承一样,也是通过匿名字段,也就是把一个方法匿名嵌入到另一个方法中。

package main

import "fmt"

type Address struct {
    Number int
    Street string
    City   string
}

type SupperStart struct {
    Name         string
    PlayerNumber int
    Address
}

func (a Address) printAddress()  {
    fmt.Println(a.City,a.Street,a.Number)
}

func main()  {
    s := SupperStart{"Curry", 30, Address{33, "Changanjie", "BJ"}}
    s.printAddress() // s 继承了 printAddress方法.
}

8.1.2 方法集

方法集是数据类型可用的一组方法,Go 中的任何数据类型都可以有一个与之关联的方法集,正如 PersonalData 结构体所展示的。方法集中的方法数量没有限制,它是用来封装函数和创建代码库的有效手段。

计算球体的表面积和体积,这是使用结构和方法集的理想场景。要创建一个方法集,先声明一个 Sphere 结构体,然后声明两个以该结构体作为接收者的方法:

type Sphere struct {
    Radius float64
}

func (s *Sphere) SurfaceArea() float64 {
    return float64(4) * math.Pi * (s.Radius * s.Radius)
}

func (s *Sphere) Volume() float64 {
    radiusCubed := s.Radius * s.Radius * s.Radius
    return (float64(4) / float64(3) * math.Pi * radiusCubed)
}

声明了计算球体体积和表面积的方法,就像常规的函数定义,唯一不同的是增加了接收者参数,该参数类型是 Sphere 实例指针。从示例可见,Radius 的值,在方法内可用,通过.访问。

package main

import (
    "fmt"
    "math"
)

type Sphere struct {
    Radius float64
}

func (s *Sphere) SurfaceArea() float64 {
    return float64(4) * math.Pi * (s.Radius * s.Radius)
}

func (s *Sphere) Volume() float64 {
    radiusCubed := s.Radius * s.Radius * s.Radius
    return (float64(4) / float64(3) * math.Pi * radiusCubed)
}

func main()  {
    s := Sphere{
        Radius: 5,
    }

    fmt.Println(s.SurfaceArea())
    fmt.Println(s.Volume())
}

与仅使用函数相比,使用方法集的好处在于 SurfaceArea 和 Volume method 仅需编写一次。如果在某个方法中发现了错误,则只需对一处修改即可。

8.1.3 方法的值和指针

也和结构体一样,了解如何在方法中使用指针,也是很重要的。接收者可以是指针,也可以是值。但需要注意的是,接收者的基类型,不能是接口或指针类型。

// 值接收者
package main

import "fmt"

type PersonalData struct {
    Name string
    Points int
    Assists int
    Rebounds int
}
// 值接收者
func (p PersonalData) SetPoint(points int) {
    p.Points = points
}

func (p PersonalData) GetPoint() int  {
    return p.Points
}

func main() {
    p := PersonalData{
        Points: 51,
    }

    p.SetPoint(33)

    fmt.Println(p.GetPoint())

}

执行这段代码,points 的输出还是51,并没有改变。这和我们之前介绍值接收者情况是一样的。

// 指针接收者
package main

import "fmt"

type PersonalData struct {
    Name string
    Points int
    Assists int
    Rebounds int
}
// 指针接收者
func (p *PersonalData) SetPoint(points int) {
    p.Points = points
}

func (p PersonalData) GetPoint() int  {
    return p.Points
}

func main() {
    p := PersonalData{
        Points: 51,
    }

    p.SetPoint(33)
    // p.SetPoint(33) == (&p).setPoint(33)
    // 这是go语法糖,在方法调用时,会检测是否为指针类型,然后自动转换

    fmt.Println(p.GetPoint())

}

值引用是对方法的副本进行的操作,原始声明不被修改。指针接收者的方法能够修改原始声明中的值,这是因为它与原始定义具有相同的内存地址,而不是在结构体副本上操作。

指针和值之间的区别很细微,但如何选择还是很容易判断的。如果需要修改(或改变)结构体的初始化,请使用指针。如果需要对结构体进行操作,但不希望修改结构体的初始化,请使用值。

8.2 接口

接口也是一个自定义类型,是一系列方法的集合。你可以将接口视作方法的蓝图,因为接口里仅定义方法,但不负责方法本身的实现。

接口里定义了方法集的所有方法,并为每个方法提供函数签名。以下示例,假设编写一段代码控制机器人。机器人的类型不止一种,并且它们控制行为的方式可能略有不同。鉴于此编程任务,你可能认为每个机器人需要不同的代码。接口提供了一种方式,可以在共享相同行为的实体间做到代码重用。对此机器人示例,定义一个有打开和关闭机器人功能的方法:

type Roboter interface {
 PowerOn() error
}

Roboter 接口包含一个 PowerOn 方法。该接口还描述了 PowerOn 方法的函数签名,该方法不接受任何参数,并返回一个 error 类型。从更高层次讲,接口也有助于推导代码设计。如果不关心实现,很容易看到设计的表层是什么样的。

那么如何使用接口?接口作为方法集的蓝图,在使用接口前,需要实现方法集。满足接口的代码被称为实现接口的代码。可以通过声明满足该接口的方法集来实现 Roboter 接口。

type T850 struct {
 Name string
}

func (a *T850) PowerOn() error {
 return nil
}

虽然这是一个简单的实现,但是它满足了 Roboter 接口,因为它包含了一个 PowerOn 方法,并且函数签名也符合接口的期望。接口的强大之处在于它支持多种实现。如下代码也可以满足 Roboter 接口的要求:

type R2D2 struct {
 Broken bool
}

func (r *R2D2) PowerOn() error {
 if r.Broken {
  return errors.New("R2D2 is broken")
 } else {
  return nil
 }
}

这也满足了 Roboter 接口的要求,因为它符合 PowerOn 方法的方法集定义。它还符合函数签名。需要注意的是,方法集所附加的 R2D2 结构体相比于 T850 结构体,是不同的数据字段集。尽管它们都符合 PowerOn 方法的函数签名,但它们是完全不同的。对接口而言,重要的是它们符合使用正确的函数签名实现方法集的要求。

接口是一种类型,可以作为参数传递给函数,因此可以在一个接口的多个实现中实现函数重用。现在,有两种 Roboter 接口的实现,对于 Roboter 接口,可以通过一个函数来引导任何机器人:

func Boot(r Roboter) error {
    return r.PowerOn()
}

该函数接受 Roboter 接口的任何实现作为参数,并返回 PowerOn 方法的执行结果。Boot 函数可以用来启动任何机器人,不管 PowerOn 方法中的代码是如何实现的。

使用 Roboter 接口的完整示例:

package main

import (
 "errors"
 "fmt"
)

type Roboter interface {
 PowerOn() error
}

type T850 struct {
 Name string
}

func (a *T850) PowerOn() error {
 return nil
}

type R2D2 struct {
 Broken bool
}

func (r *R2D2) PowerOn() error {
 if r.Broken {
  return errors.New("R2D2 is broken")
 } else {
  return nil
 }
}

func Boot(r Roboter) error {
 return r.PowerOn()
}

func main() {
 t := T850{
  Name: "The Terminator",
 }

 r := R2D2{
  Broken: true,
 }

 err := Boot(&r)

 if err != nil {
  fmt.Println(err)
 } else {
  fmt.Println("Robot is powered on!")
 }

 err = Boot(&t)

 if err != nil {
  fmt.Println(err)
 } else {
  fmt.Println("Robot is powered on!")
 }
}

接口提供的抽象层最初可能看起来很复杂,但它促进了代码重用及功能切换。考虑一个场景,一个程序被编写为使用 MySQL 数据库。如果不使用接口,代码很可能是特定于 MySQL 的。在必须将 MySQL 切换为另一种数据库(如PostgreSQL)的场景中,很可能需要重写大量代码。

通过定义数据库接口,接口的实现变得比使用什么数据库更重要。如果实现满足接口,理论上任何数据库都可以使用,并且可以轻松切换。数据库接口可以包含一个或多个的实现引入了多态性的思想。

“多态”一词意味着多种形式,接口可以有多种实现。Go 中的接口以声明的方式提供多态性,因为接口描述了预期的方法集和这些方法的函数签名。如果一个方法集实现了一个接口,那么它可以被称为与实现了相同接口的另一个方法集的行为多态性。编译器还可以通过检查方法集并确保它确实是多态的来验证接口。通过形式化接口,接口的两个实现无疑是多态的。这种确定性促成了可验证、可测试和灵活的代码。

8.3 小结

本章介绍了方法和接口。你现在应该了解了如何声明方法,以及方法为什么比简单的使用函数更能促进代码重用和保持一致性。你了解了方法集如何将数据类型分组在一起。接着,介绍了值引用和指针引用,以及对结构体副本或原始声明进行操作。最后,介绍了接口,它实现了共享方法或快速切换的多态功能。

文档更新时间: 2021-11-08 10:11   作者:admin