Go语言包含了对OOP语言的支持,接下来我们来看看Go语言中的方法。
尽管没有被大众所接受的明确的OOP的定义,从我们的理解来讲,一个对象其实也就是一个
简单的值或者一个变量,在这个对象中会包含一些方法,而一个方法则是一个一个和特殊类
型关联的函数。一个面向对象的程序会用方法来表达其属性和对应的操作,这样使用这个对
象的用户就不需要直接去操作对象,而是借助方法来做这些事情。
在函数声明时,在其名字之前放上一个变量,即是一个方法。
这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。
package geometry
import "math"
type Point struct{ X, Y float64 }
// traditional function
func Distance(p, q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
// same thing, but as a method of the Point type
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
上面的代码里那个附加的参数p,叫做方法的接收器(receiver),早期的面向对象语言留下的遗
产将调用一个方法称为“向一个对象发送消息”。
在Go语言中,我们并不会像其它语言那样用this或者self作为接收器;我们可以任意的选择接
收器的名字。由于接收器的名字经常会被使用到,所以保持其在方法间传递时的一致性和简
短性是不错的主意。这里的建议是可以使用其类型的第一个字母,比如这里使用了Point的首
字母p。
在方法调用过程中,接收器参数一般会在方法名之前出现。这和方法声明是一样的,都是接
收器参数在方法名字之前。
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,但是却没有发生冲突。第一个Distance的调用
实际上用的是包级别的函数geometry.Distance,而第二个则是使用刚刚声明的Point,调用的
是Point类下声明的Point.Distance方法。
这种p.Distance的表达式叫做选择器,因为他会选择合适的对应p这个对象的Distance方法来
执行。选择器也会被用来选择一个struct类型的字段,比如p.X。由于方法和字段都是在同一
命名空间,所以如果我们在这里声明一个X方法的话,编译器会报错,因为在调用p.X时会有
歧义。
因为每种类型都有其方法的命名空间,我们在用Distance这个名字的时候,不同的Distance调
用指向了不同类型里的Distance方法。
在能够给任意类型定义方法这一点上,Go和很多其它的面向对象的语言不太一样。因此
在Go语言里,我们为一些简单的数值、字符串、slice、map来定义一些附加行为很方便。方
法可以被声明到任意类型,只要不是一个指针或者一个interface。
基于指针对象的方法
当调用一个函数时,会对其每一个参数值进行拷贝,如果一个函数需要更新一个变量,或者
函数的其中一个参数实在太大我们希望能够避免进行这种默认的拷贝,这种情况下我们就需
要用到指针了。
对应到我们这里用来更新接收器的对象的方法,当这个接受者变量本身比较
大时,我们就可以用其指针而不是对象来声明方法,如下:
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
这个方法的名字是 (*Point).ScaleBy 。这里的括号是必须的;没有括号的话这个表达式可能
会被理解为 *(Point.ScaleBy)。
实际上注意两点:
- 不管你的method的receiver是指针类型还是非指针类型,都是可以通过指针/非指针类型
进行调用的,编译器会帮你做类型转换。 - 在声明一个method的receiver该是指针还是非指针类型时,你需要考虑两方面的内部,第
一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷
贝;第二方面是如果你用指针类型作为receiver,那么你一定要注意,这种指针类型指向
的始终是一块内存地址,就算你对其进行了拷贝。熟悉C或者C艹的人这里应该很快能明白。
Nil也是一个合法的接收器类型
就像一些函数允许nil指针作为参数一样,方法理论上也可以用nil指针作为其接收器,尤其当
nil对于对象来说是合法的零值时,比如map或者slice。
方法值和方法表达式
我们经常选择一个方法,并且在同一个表达式里执行,比如常见的p.Distance()形式,实际上
将其分成两步来执行也是可能的。p.Distance叫作“选择器”,选择器会返回一个方法"值"->一
个将方法(Point.Distance)绑定到特定接收器变量的函数。这个函数可以不通过指定其接收器
即可被调用;即调用时不需要指定接收器(译注:因为已经在前文中指定过了),只要传入函数
的参数即可:
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // method value
fmt.Println(distanceFromP(q)) // "5"
var origin Point // {0, 0}
fmt.Println(distanceFromP(origin)) // "2.23606797749979", sqrt(5)
scaleP := p.ScaleBy // method value
scaleP(2) // p becomes (2, 4)
scaleP(3) // then (6, 12)
scaleP(10) // then (60, 120)
封装
一个对象的变量或者方法如果对调用方是不可见的话,一般就被定义为“封装”。封装有时候也
被叫做信息隐藏,同时也是面向对象编程最关键的一个方面。
Go语言只有一种控制可见性的手段:大写首字母的标识符会从定义它们的包中被导出,小写
字母的则不会。这种限制包内成员的方式同样适用于struct或者一个类型的方法。因而如果我
们想要封装一个对象,我们必须将其定义为一个struct。
例如:
type IntSet struct {
words []uint64
}
这种基于名字的手段使得在语言中最小的封装单元是package,而不是像其它语言一样的类
型。一个struct类型的字段对同一个包的所有代码都有可见性,无论你的代码是写在一个函数
还是一个方法里。
封装提供了三方面的优点。首先,因为调用方不能直接修改对象的变量值,其只需要关注少
量的语句并且只要弄懂少量变量的可能的值即可。
第二,隐藏实现的细节,可以防止调用方依赖那些可能变化的具体实现,这样使设计包的程
序员在不破坏对外的api情况下能得到更大的自由。
把bytes.Buffer这个类型作为例子来考虑。这个类型在做短字符串叠加的时候很常用,所以在
设计的时候可以做一些预先的优化,比如提前预留一部分空间,来避免反复的内存分配。又
因为Buffer是一个struct类型,这些额外的空间可以用附加的字节数组来保存,且放在一个小
写字母开头的字段中。这样在外部的调用方只能看到性能的提升,但并不会得到这个附加变量。
接口
接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一
起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力。
很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐
式实现的。也就是说,我们没有必要对于给定的具体类型定义所有满足的接口类型;简单地
拥有一些必需的方法就足够了。
这种设计可以让你创建一个新的接口类型满足已经存在的具
体类型却不会去改变这些类型的定义;当我们使用的类型来自于不受我们控制的包时这种设
计尤其有用。