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