吃透 Golang 基础:方法
文章目录
- 方法
- 方法声明
- 基于指针对象的方法
- nil 也是合法的接收器类型
- 通过嵌入结构体来扩展类型
- 方法值和方法表达式
- 封装
方法
今天我们来复习 Golang 语法中的“方法”,方法属于某个「类型」,它属于 Go 的语法糖,本质上是一个函数,使得这个类型可以像在调用它的“类方法”一样来调用这个函数。
方法声明
在声明函数时,在函数名前面放上一个变量,这个函数就成为了变量所对应类型的方法,这个方法将成为该类型的独占方法。一个方法声明的例子如下:
type Point struct{ X, Y float64 }func Distance(p, q Point) float64 {return math.Hypot(q.X-p.X, q.Y-p.Y)
}func (p Point) Distance(q Point) float64 {return math.Hypot(q.X-p.X, q.Y-p.Y)
}
上述方法定义时,Point
类型的p
是方法的接收器(receiver),在 C++ 当中我们使用this
作为接收器,而在 Python 中我们使用self
作为接收器。在 Golang 当中,我们可以自定义接收器的名称,《Go 语言圣经》当中给出的建议是使用类型的第一个字母。
调用方法的例子如下:
p := Point{1, 2}
q := Point{4, 6}
fmt.Println(Distance(p, q)) // "5", function call
fmt.Println(p.Distance(q)) // "5", method call
可以看到,函数Distance
与Point
类型的Distance
方法不会产生冲突。
在 Go 当中,美中类型都有其方法的命名空间,我们在使用 Distance 这个名字的时候,不同的 Distance 调用指向了不同类型里的 Distance 方法。下例是一个更复杂的例子,它定义了一个Path
类型,它本质是[]Point
,我们进一步为它也定义一个Distance
方法:
// A Path is a journey connecting the points with straight lines.
type Path []Point
// Distance returns the distance traveled along the path.
func (path Path) Distance() float64 {sum := 0.0for i := range path {if i > 0 {sum += path[i-1].Distance(path[i])}}return sum
}
两个Distance
有不同的类型,但两个方法之间没有任何关系。调用新方法来计算三角形周长:
perim := Path{{1, 1},{5, 1},{5, 4},{1, 1},
}
fmt.Println(perim.Distance()) // "12"
基于指针对象的方法
在最开始我们提到,「方法」是 Golang 的语法糖,其底层本质上仍然是一个函数,方法的接收器将作为这个函数的第一个参数。因此,如果我们想要在方法当中修改接收器的值,或是这个方法的接收器占用的内存非常大,我们不希望在调用方法时进行值拷贝,此时就可以使用指针对象作为接收器:
func (p *Point) ScaleBy(factor float64) {p.X *= factorp.Y *= factor
}
方法的名字是(*Point).ScaleBy
,此处的括号是必须的,否则会被理解为*(Point.ScaleBy)
(函数值的指针)。
在真实开发环境中,一般约定如果Point
这个类型有「以指针作为接收器」的方法,那么Point
的所有方法必须有一个指针接收器,即使某些方法不需要指针接收器。此外,为了避免歧义,如果一个类型本身是一个指针的话(比如type P *int
),那么它不允许出现在接收器中。
想要调用指针类型方法(*Point).ScaleBy
,只需要提供一个Point
类型的指针并调用该方法即可。比如:
r := &Point{1, 2}
r.ScaleBy(2)
fmt.Println(*r) // "{2, 4}"
以上的写法较为笨拙,因为需要我们显式地将值转为指针再调用指针接收器方法。 Go 存在一种语法糖,也就是如果一个值类型直接调用它的以指针作为接收器的方法,那么 Go 的编译器会隐式地帮我们用这个值的指针来调用指针方法。比如:
p := Point{1, 2}
p.ScaleBy(3)
上述代码中,编译器隐式地帮我们用&p
调用ScaleBy
。这种简写方法仅适用于变量,因为变量的地址是确定的,如果Point
是某个 struct 的成员,或者是 slice 当中的元素,由于我们无法取到 struct 成员的地址,且 slice 底层的数组可能会修改从而导致地址改变,因此对于这类值,我们不能调用它们的以指针作为接收器的方法。临时变量的内存地址也无法取到,因此也不能直接对临时变量调用指针接收器方法:
Point{1, 2}.ScaleBy(2) // ❌ 不能对临时变量调用指针接收器方法
此外,对于一个指针类型,如果它具有以值作为接收器的方法,那么这个指针也可以直接调用值接收器方法,Go 编译器会隐式地帮我们解指针引用。
总结一下,在每一个合法的方法调用表达式中,存在以下三种情况,都是可以正常运行的:
第一种情况是方法调用者的类型与其方法接收器的类型匹配,即二者都是值T
或指针*T
:
Point{1, 2}.Distance(q) // Distance 是以值为接收器的方法
pptr.ScaleBy(2) // pptr 是 Point 的指针, ScaleBy 是以指针为接收器的方法
第二种是:如果接收器的实参,即方法的调用者类型是值T
,但接收器的形参类型是*T
,这种情况下编译器会隐式地帮助我们取实参的地址:
p.ScaleBy(2) // implicit (&p)
第三种是:如果接收器的实参是指针*T
,形参是T
,编译器会隐式地帮助我们解引用,取到指针实际指向的变量值:
pptr.Distance(q) // implicit (*pptr)
nil 也是合法的接收器类型
就像函数允许 nil 值的指针作为参数一样,方法本质上也是函数,因此该类型的指针接收器方法可以通过 nil 指针来调用。
下例是一个链表求和的例子,该例通过调用链表类型的 Sum 方法来对链表进行求和,由于 nil 指针也可以调用对应类型的方法,因此当链表到尾时,nil 仍然可以继续调用 Sum 方法,只不过这次调用会在方法的逻辑中判断出当前指针为 nil,返回 0:
// An IntList is a linked list of integers.
// A nil *IntList represents the empty list.
type IntList struct {Value intTail *IntList
}
// Sum returns the sum of the list elements.
func (list *IntList) Sum() int {if list == nil {return 0}return list.Value + list.Tail.Sum()
}
通过嵌入结构体来扩展类型
下例定义了一个 ColoredPoint 类型,它将 Point 类型作为嵌入加入到了结构体的定义当中:
type Point struct { X, Y int64 }
type ColoredPoint struct {PointColor color.RGBA
}
基于结构体内嵌的方式,我们可以直接认为嵌入的字段就是 ColoredPoint 自己的字段,在使用时完全不需要指出 Point,ColoredPoint 本身就可以直接访问 X 和 Y 成员:
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X)
cp.Point.Y = 2
fmt.Println(cp.Y)
对于 Point 中的方法,我们也有类似的用法,可以把 ColoredPoint 类型当作接收器来调用 Point 里的方法,即使 ColoredPoint 没有声明这些方法:
red := color.RGBA{255, 0, 0, 255}
blue := color.RGBA{0, 0, 255, 255}
var p = ColoredPoint{Point{1, 1}, red}
var q = ColoredPoint{Point{5, 4}, blue}
fmt.Println(p.Distance(q.Point)) // "5"
p.ScaleBy(2)
q.ScaleBy(2)
fmt.Println(p.Distance(q.Point)) // "10"
Point 类的方法也被引入了 ColoredPoint,故内嵌可以使我们定义字段特别多的复杂类型,可以先将字段按小类型分组,然后定义小类型的方法,之后再把它们组合起来。
需要注意的是,Point 嵌入在了 ColoredPoint 当中,这种关系不是继承,也不是子类与父类之间的关系。ColoredPoint “has” a Point,所以在调用 Distance 方法时,方法传入的实参必须显式地选择 ColoredPoint 当中的 Point 对象,否则编译器会报错:compile error: cannot use q (ColoredPoint) as Point
。
ColoredPoint 不是 Point,但基于内嵌,它拥有一个 Point,并且从拥有的 Point 中引入了 Distance 和 ScaleBy 方法。从具体的实现角度来说,内嵌字段会指导编译器隐式地额外生成方法来对已有的方法进行封装,等价于:
func (p ColoredPoint) Distance(q Point) float64 {return p.Point.Distance(q)
}func (p *ColoredPoint) ScaleBy(factor float64) {p.Point.ScaleBy(factor)
}
因此,即使我们通过 ColoredPoint 对象调用内嵌的 Point 的方法,在 Point 的方法中我们也无法访问 ColoredPoint 的成员。
在类型中内嵌的匿名字段也可能是一个命名类型的指针,这种情况下字段和方法会间接地引入到当前的类型中。添加这一层间接关系让我们可以共享通用的结构并动态地改变对象之间的关系。下例的 ColoredPoint 声明内嵌了一个*Point
指针:
type ColoredPoint struct {*PointColor color.RGBA
}p := ColoredPoint{&Point{1, 1}, red}
q := ColoredPoint{&Point{5, 4}, blue}
fmt.Println(p.Distance(*q.Point)) // "5"
q.Point = p.Point // Now, p and q share the same Point
p.ScaleBy(2)
fmt.Println(*p.Point, *q.Point) // {2, 2}, {2, 2}
一个 struct 可以定义多个匿名字段,例如:
type ColoredPoint struct {Pointcolor.RGBA
}
这种类型的值会拥有 Point 和 RGBA 类型的所有方法,以及直接定义在 ColoredPoint 中的方法。当编译器解析一个选择器到方法时,比如p.ScaleBy
,它会首先去找直接定义在这个类型当中的ScaleBy
方法,然后找 ColoredPoint 内嵌字段引入的方法,然后去 Point 和 RGBA 的内嵌字段继续找引入的方法,一直递归向下寻找直到找到为止。如果选择器有二义性的话,编译器会报错,比如你在同一级里有两个同名的方法。
下例展示了一个基于 Go 实现的非常简单的 Cache 的 Demo:
var cache = struct {sync.Mutexmapping map[string]string
}{mapping: make(map[string]string),
}func Lookup(key string) string {cache.Lock()v := cache.mapping[key]cache.Unlock()return v
}
该例中,sync.Mutex
字段被嵌入到了 struct 当中,故其 Lock 和 Unlock 方法也被引入到了 cache 对应的匿名结构类型,使得我们可以非常方便地进行加锁和解锁操作。
方法值和方法表达式
我们之前使用过的p.Distance
(注意,不带括号,此时是方法的值)叫做“选择器”,选择器会返回一个方法“值”,即一个将方法(Point.Distance
)绑定到特定接收器变量的函数。这个函数调用时不需要指定接收器,因为已经在p.Distance
中指定p
为接收器了,此时只需要传入参数即可:
p := Point{1, 2}
q := Point{4, 6}distanceFromP := p.Distance // p.Distance 获取方法值, 绑定到 distanceFromP 上
// ⬆️ 此时已经选择 p 为接收器了
fmt.Println(distanceFromP(q)) // "5"
当T
是一个类型时,方法表达式可能会写作T.f
或(*T).f
,此时返回的是一个函数的“值”,这种函数会将第一个传入的参数作为接收器,例如:
p := Point{1, 2}
q := Point{4, 6}
distance := Point.Distance
fmt.Println(distance(p, q))
这一点不难理解,因为我在本篇开头已经提到,Golang 的 Method 实际上是一种语法糖,它本质上是一个以方法调用者为第一个实参的函数。因此,类型的方法值就是函数本身,即:
distance := Point.Distance
// ⬆️ distance 本身是一个有两个形参的函数, 这两个形参的类型都是 Point
封装
“一个对象的变量或方法对调用方不可见”被定义为“封装”,详细来说也可以称为“信息隐藏”。封装是面向对象的特性之一。
Go 只有一种可见性手段,那就是大写首字母的标识符会从定义它们的包中被导出,小写字母则不会导出。这种限制包内成员可见性的方式同样适用于 struct 或一个类型的方法。基于上述原因,如果我们想对一个对象进行封装,那么它必须是一个 struct。
下例定义了一个 IntSet 类型,尽管它只有一个字段,但是由于我们想要对它进行封装,所以必须把这个单独的字段定义在 struct 当中:
type IntSet struct {words []uint64
} // words 是非导出的, 用户无法直接访问// ⬇️ 如果我们直接定义为
type IntSet []uint64 // 该方法会使得其他包的用户直接改变 IntSet 底层的 []uint64
这种基于名字的手段使得在 Golang 语言层面最小的封装单元是 package,而不是其他语言一样的类型。一个 struct 类型的字段对同一个包内的所有代码都有可见性,无论你的代码是写在一个函数还是一个方法里。
封装提供了三个优点:
- 调用方不能直接修改对象的变量值,而修改只能通过包的发布人员对外提供的接口来完成;
- 隐藏了实现的细节,防止调用方以来那些可能变化的具体实现,这使得设计包的程序员可以在不破坏对外 API 的情况下获得更多开发上的自由;
- 阻止外部调用方对对象内部的值任意地进行修改。
Go 的编程风格不禁止导出字段,一旦导出,就无法保证在 API 兼容的前提下去除对已经导出字段的导出。