方法

方法是面向对象编程(OOP)的重要一环。从我们的理解来讲,一个对象其实也就是一个简单的值或者一个变量,在这个对象中会包含一些方法,而一个方法则是一个和特殊类型关联的函数。一个面向对象的程序会用方法来表达其属性和对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。

本章会介绍OOP编程中的两个关键点:封装和组合。

方法声明

在函数声明时,在其名字之前放上一个变量,即是一个方法。这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。

// 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)
}

在Go语言中,我们并不会像其它语言那样用this或者self作为接收器;我们可以任意的选择接收器的名字。

比如在Python中定义方法的话,首先得声明一个类。

class P:
def __init__(self):
self.x = 1

def add(self):
self.x += 1

基于指针对象的方法

当调用一个函数时,会对其每一个参数值进行拷贝,如果一个函数需要更新一个变量,或者函数的其中一个参数实在太大我们希望能够避免进行这种默认的拷贝,这种情况下我们就需要用到指针了。对应到我们这里用来更新接收器的对象的方法,当这个接受者变量本身比较大时,我们就可以用其指针而不是对象来声明方法。

比如:

func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}

在调用这个方法时,可以通过以下的方式:

r := &Point{1,2}
r.ScaleBy(2)
fmt.Println(*r) // "{2, 4}"
//----
p := Point{1, 2}
pptr := &p
pptr.ScaleBy(2)
fmt.Println(p) // "{2, 4}"
//----
p := Point{1, 2}
(&p).ScaleBy(2)
fmt.Println(p) // "{2, 4}"
//----

后两种方法有些笨拙,但实际上我们可以简化这个步骤。Go语言的编译器会隐式的帮你去转换类型。比如一个类型为Point的p变量,如果方法的接收器要求是一个Point指针,可以用简短写法如下:

P.ScaleBy(2)
//等价于(&P).ScaleBy

同样的,对于指针类型的变量调用接收器为类型本身的方法时编译器也会做自动的类型转换。

只有类型(Point)和指向他们的指针 (*Point) ,才可能是出现在接收器声明里的两种接收器。此外,为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器中的。

可以总结为如下两点:

  1. 不管你的method的receiver是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。

  2. 在声明一个method的receiver该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为receiver,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。熟悉C或者C++的人这里应该很快能明白。

通过嵌入结构体来扩展类型

假如我们拥有如下代码:

import "image/color"
type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}

在这段代码中,我们定义了一个内嵌结构体ColoredPoint,它包含Point类型的一切字段,并且自己又定义了一些额外的成员。

ColoredPoint类型中,我们可以直接使用Point类型的方法,而无需指出Point。即使在ColoredPoint没有声明这样的方法。

用这种方式,内嵌可以使我们定义字段特别多的复杂类型,我们可以将字段先按小类型分组,然后定义小类型的方法,之后再把它们组合起来。

读者如果对基于类来实现面向对象的语言比较熟悉的话,可能会倾向于将Point看作一个基类,而ColoredPoint看作其子类或者继承类,或者将ColoredPoint看作"is a" Point类型。但这是错误的理解。请注意上面例子中对Distance方法的调用。Distance有一个参数是Point类型,但q并不是一个Point类,所以尽管q有着Point这个内嵌类型,我们也必须要显式地选择它。

也即你需要在调用Point类的方法前显示的指定Point

p.Distance(q) // error
p.Point.Distance(q)// right

当Point.Distance被第一个包装方法调用时,它的接收器值是p.Point,而不是p。

在类型中内嵌的匿名字段也可能是一个命名类型的指针,这种情况下字段和方法会被间接地引入到当前的类型中。这种间接关系允许我们共享通用的结构。

一个struct类型也可能会有多个匿名字段。这样他就获得了匿名字段中定义的所有方法。

方法值和方法表达式

我们经常选择一个方法,并且在同一个表达式里执行,比如常见的p.Distance()形式,实际上将其分成两步来执行也是可能的。

p.Distance叫作“选择器”,选择器会返回一个方法“值”->一个将方法(Point.Distance)绑定到特定接收器变量的函数。这个函数可以不通过指定其接收器即可被调用;即调用时不需要指定接收器(译注:因为已经在前文中指定过了),只要传入函数的参数即可

也就是可以通过下面这种方法来调用方法,来分离其与变量本身。

scaleP := p.ScaleBy // method value
scaleP(2) // p becomes (2, 4)
//等价于下面这种用法
res=p.ScaleBy(2)

在一个包的API需要一个函数值、且调用方希望操作的是某一个绑定了对象的方法的话,方法“值”会非常实用.

其实就是把一个方法作为“值”去传递,这与C语言中传递函数指针的概念类似,但Go语言的方法值提供了类型安全和更简洁的语法。

封装

一个对象的变量或者方法如果对调用方是不可见的话,一般就被定义为“封装”。封装有时候也被叫做信息隐藏,同时也是面向对象编程最关键的一个方面。

Go语言只有一种控制可见性的手段:大写首字母的标识符会从定义它们的包中被导出,小写字母的则不会。这种限制包内成员的方式同样适用于struct或者一个类型的方法。因而如果我们想要封装一个对象,我们必须将其定义为一个struct

这种基于名字的手段使得在语言中最小的封装单元是package,而不是像其它语言一样的类型。一个struct类型的字段对同一个包的所有代码都有可见性,无论你的代码是写在一个函数还是一个方法里

封装提供了三方面的优点。首先,因为调用方不能直接修改对象的变量值,其只需要关注少量的语句并且只要弄懂少量变量的可能的值即可。

第二,隐藏实现的细节,可以防止调用方依赖那些可能变化的具体实现,这样使设计包的程序员在不破坏对外的api情况下能得到更大的自由

封装的第三个优点也是最重要的优点,是阻止了外部调用方对对象内部的值任意地进行修改。因为对象内部变量只可以被同一个包内的函数修改,所以包的作者可以让这些函数确保对象内部的一些值的不变性。

只用来访问或修改内部变量的函数被称为setter或者getter,例子如下,比如log包里的Logger类型对应的一些函数。在命名一个getter方法时,我们通常会省略掉前面的Get前缀。这种简洁上的偏好也可以推广到各种类型的前缀比如Fetch,Find或者Lookup。

Go的编码风格不禁止直接导出字段。当然,一旦进行了导出,就没有办法在保证API兼容的情况下去除对其的导出,所以在一开始的选择一定要经过深思熟虑并且要考虑到包内部的一些不变量的保证,未来可能的变化,以及调用方的代码质量是否会因为包的一点修改而变差。

所以最好还是用setter或者getter等专门的方法去获取变量,方便维护。

封装并不总是理想的。 虽然封装在有些情况是必要的,但有时候我们也需要暴露一些内部内容,比如:time.Duration将其表现暴露为一个int64数字的纳秒,使得我们可以用一般的数值操作来对时间进行对比,甚至可以定义这种类型的常量

总结

在这章中,我们学到了如何将方法与命名类型进行组合,并且知道了如何调用这些方法。尽管方法对于OOP编程来说至关重要,但他们只是OOP编程里的半边天。为了完成OOP,我们还需要接口。Go里的接口会在下一章中介绍。