第六章:方法
6.1 方法声明
在函数声明时,在其名字之前放上一个变量,这就是声明了变量对应类型的一个方法,相当于为这种类型定义了一个独占的方法。
下例为 Point 类型声明了计算两个点之间距离的方法:
package mainimport "math"type Point struct {X, Y float64
}func (p Point) Distance(q Point) float64 {return math.Hypot(q.X - p.X, q.Y - p.Y)
}
上述代码中的 p,叫做方法的接收器(receiver)。在 Golang 当中,不会像其他语言那样使用 this 或 self 作为接收器,可以任意选择作为接收器的名字。
下例尝试使用方法:
package mainimport ("fmt""math"
)type Point struct {X, Y float64
}func (p Point) Distance(q Point) float64 {return math.Hypot(q.X-p.X, q.Y-p.Y)
}func main() {p := Point{1, 2}q := Point{4, 5}distance := p.Distance(q)fmt.Println(distance)
}
需要注意的是,此处的p.Distance
是方法的调用,我们当然可以声明一个Distance
同名函数,接受p
和q
作为参数,计算二者之间的距离。函数和方法是不冲突的,可以同时调用。
但是需要注意的话,如果我们声明了一个名为X
的方法,编译器会报错,因为产生了歧义,因为X
已经是Point
的成员变量了。
下例定义了一个 Path 类型,Path 代表一个线段的集合,并且同样为 Path 定义一个名为 Distance 的方法:
type Path []Pointfunc (path Path) Distance() float64 {sum := 0.0for i := range path {if i > 0 {sum += path[i - 1].Distance((path[i]))}}return sum
}
Path 是一个命名的 slice 类型,但是我们仍然可以为其定义方法。在 Golang 当中,我们可以给同一个包内的任意命名类型定义方法,只要这个命名类型的底层类型不是指针或 interface。
下例调用新方法,计算三角形周长:
func main() {perim := Path{{1, 1},{5, 1},{5, 4},{1, 1},}fmt.Println(perim.Distance())
}
6.2 基于指针对象的方法
当调用一个函数时,会对其每一个参数值进行拷贝,如果一个函数需要更新一个变量,或者函数的其中一个参数是在太大我们希望能够避免进行这种默认的拷贝,该情况下我们就需要用到指针了:
func (p *Point) ScaleBy(factor float64) {p.X *= factorp.Y *= factor
}
上述方法的名字是(*Point).ScaleBy
,括号是必须的,没有括号的话这个表达式可能会被理解为*(Point.ScaleBy)
,即类型方法的指针。
在现实的程序当中,一般会约定,如果 Point 这个类有一个指针作为接收器的方法,那么所有 Point 的方法都必须有一个指针接收器,即使是那些并不需要这个指针接收器的函数。
只有类型(比如 Point)和指向它们的指针(*Point
),才可能出现在接收器声明里的两种接收器。此外,为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器当中的,比如:
type P *int
func (P) f() { /* ... ... ... */ } // Compile Error
想要调用指针类型方法(*Point).Salary
,只需要提供一个 Point 类型的指针即可:
func main() {r := &Point{1, 2}r.ScaleBy(2) fmt.Println(*r) // {2, 4}
}
或者:
p := Point{1, 2}
(&p).ScaleBy(2)
不过上述两种方法稍显笨拙,Golang 本身在这种地方会帮助我们。如果接收器 p 是一个 Point 类型的变量,并且其方法需要一个 Point 指针作为接收器,可以用下面这种简短的写法:
p.ScaleBy(2)
编译器会隐式地帮助我们用&p
去调用 ScaleBy 方法。这种简化写法只适用于“变量”,包括 struct 里的字段,比如p.X
,以及 array 和 slice 内的元素。不能通过一个无法取到地址的接收器来调用指针方法,比如临时变量的内存地址无法获取:
Point{1, 2}.ScaleBy(2) // Compile Error
我们可以用一个*Point
这样的接收器来调用 Point 的方法,因为我们可以通过地址找到这个变量,只要用解引用符号*
获取变量即可。同样地,如果是一个指针想要调用其所指对象类型的非指针接收器方法时,Golang 的编译器会为我们隐式地插入解引用符:
pptr.Distance(q)
// 等价于
(*pptr).Distance(q)
6.2.1 nil 也是一个合法的接收器类型
就像一些函数允许 nil 指针作为参数一样,方法理论上也可以使用 nil 指针作为接收器,尤其当 nil 对于对象来说是合法的零值时,比如 map 或 slice。在下面的简单 int 链表的例子里,nil 代表的是空链表:
type IntList struct {Value intTail *IntList
}
func (list *IntList) Sum() int {if list == nil {return 0}return list.Value + list.Tail.Sum()
} // 使用递归的方法计算链表值的综合
不必担心此时 list 是否为 nil,就算 list 为 nil,它也可以调用 IntList 的方法。
6.3 通过嵌入结构体来扩展类型
下例定义了一个 ColoredPoint 类型:
package mainimport "image/color"type Point struct {X, Y float64
}type ColoredPoint struct {PointColor color.RGBA
}
我们完全可以将 ColoredPoint 定义为一个有三个字段的 struct,但是我们却将 Point 这个类型嵌入到了 ColoredPoint 来提供 X 和 Y 这两个字段。
结构当中内嵌其它结构可以使我们在定义 ColoredPoint 时得到一种句法上的简写形式,并使其包含 Point 类型所具有的一切字段,然后再定义一些自己的。我们可以直接认为通过嵌入的字段就是 ColoredPoint 自身的字段。
对于 Point 中的方法我们也有类似的用法,可以将 ColoredPoint 类型当作接收器来调用 Point 里的方法,即使 ColoredPoint 里没有声明这些方法:
func main() {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))p.ScaleBy(2)q.ScaleBy(2)fmt.Println(p.Distance(q.Point))
}
Point 类的方法也被引入到了 ColoredPoint 当中。一个 ColoredPoint 并不是一个 Point,但它 “has a Point”,并且它有从 Point 类里引入的 Distance 和 ScaleBy 方法。
在类型中内嵌的匿名字段也可能是一个命名类型的指针,这种情况下字段和方法会被间接地引入到当前类型当中。添加这一层间接关系让我们可以共享通用的结构并动态地改变对象之间的关系:
type ColoredPoint struct {*PointColor color.RGBA
}func main() {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))q.Point = p.Point // p 和 q 共享同一个 Pointp.ScaleBy(2)fmt.Println(*p.Point, *q.Point)
}
一个 struct 类型也可以有多个匿名字段,比如:
type ColoredPoint struct {Pointcolor.RGBA
}
6.4 方法值和方法表达式
执行一个方法常见的形式是p.Distance()
,实际上这一步可以被拆分为两步来执行。p.Distance
叫做“选择器”,选择器返回一个值,这个值是将方法Point.Distance
绑定到特定接受其变量的函数。
这个函数可以不通过指定其接收器即可被调用,原因在于p.Distance
这条语句已经将Distance
方法与p
绑定了。只需要传入函数的参数即可:
P := Point{1, 2}
q := Point{4, 6}distanceFromP := p.Distance
fmt.Println(distanceFromP(q))
var origin Point
fmt.Println(distanceFromP(origin))
在一个包的API需要一个函数值,且调用方法希望操作的是某一个绑定了对象的方法的话,方法“值”会非常有用。例如,下例中的time.AfterFunc
这个函数的功能是在指定的延迟时间之后来执行一个函数。且这个函数操作的是一个Rocket 对象 r:
type Rocket struct { /* … … … */ }
func (r *Rocket) Launch() { /* … … … */ }
r := new(Rocket)
time.AfterFunc(10 * time.Second, func() { r.Launch() })
直接将方法的值传入 AfterFunc 的话可以更简短:
time.AfterFunc(10 * time.Second, r.Launch)
与调用一个普通的函数相比,调用一个方法时,必须要用选择器语法来指定方法的接收器。当 T 是一个类型时,方法表达式可能会写作T.f
或(*T).f
,会返回一个函数“值”,这种函数会将其第一个参数用作接收器,所以可以用类似于函数调用的方式来对方法表达式进行调用(意思是,直接将类型.方法
与某个变量绑定,此时这个变量类似于一个函数,加入原先的方法有一个参数,此时的函数有两个参数,第一个参数将会被视为选择器参数将第一个传入该函数的变量与方法绑定):
P := Point{1, 2}
q := Point{4, 6}
distance := Point.Distance // method expression
fmt.Println(distance(p, q)) // “5”
fmt.Printf(“%T\n”, distance)// “func(Point, Point) float64”scale := (*Point).ScaleBy
scale(&p, 2)
fmt.Println(p) // “{2, 4}”
fmt.Printf(“%T\n”, scale) // “func(*Point, float64)”
下例是一个更复杂的例子:
type Point struct{ X, Y float64 }func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }type Path []Pointfunc (path Path) TranslateBy(offset Point, add bool) {var op func(p, q Point) Pointif add {op = Point.Add} else {op = Point.Sub}for i := range path {path[i] = op(path[i], offset)}
}
6.6 封装
“封装”指的是一个对象的变量或方法对调用方是不可见的,是 OOP 最关键的一个概念。
Golang 只有一种控制可见性的手段:大写首字母的标识符会从定义它们的包中被导出,小写字母的则不会。这种限制包内成员的方式同样适用于 struct 或一个类型的方法。因而如果我们想要封装一个对象,必须将其定义为一个 struct。
一个例子如下:
type IntSet struct {words []uint64
}
当然我们也可以直接将 IntSet 定义为 slice 类型,这样我们就需要把代码中所有方法里的s.word
用*s
替换掉:
type IntSet []uint64
上述这种基于名字的手段使得在 Golang 中最小的封装单元是 package,而不是像其他语言一样的类型。一个 struct 类型的字段对同一个包的所有代码都有可见性,无论你的代码是写在一个函数还是一个方法里。
封装最重要的优点是阻止了外部调用方对对象内部的值进行修改:
type Counter struct { n int }
func (c *Counter) N() int { return c.n }
func (c *Counter) Increment() { c.n++ }
func (c *Counter) Reset() { c.n = 0 }
只用来访问或修改内部变量的函数被称为 setter 或 getter。
注意:Golang 的编码风格不禁止直接导出字段。当然,一旦进行了导出,就没有办法在保证 API 兼容的情况下去除对其的导出,所以在一开始的选择一定要经过深思熟虑并且要考虑到包内部的一些不变量的保证,未来可能的变化,以及调用方的代码质量是否会因为包的一点修改而变差。