在这面向对象横行的年代,作为一门新的程序设计语言Go自然也是支持面向对象编程(OOP,Object-Oriented Programming)的。在Go中,面向对象主要包含两部分——方法(Methods)和接口(Interface)。本篇文章先介绍方法。
Go的方法和许多OOP语言的方法模子上比较像,但却有更加丰富的小特性,使用起来非常的灵活方便。
1. 方法声明
方法和声明与函数非常类似,不过是函数名前多了一个参数,这个参数把这个方法和参数对应的类型联系在一起,即这个方法属于这个类型。看下面的例子
package geometry
import "math"
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)
}
上面的例子中我们定义了一个Distance函数和一个Distance方法,二者功能相同,名字也相同,但并不冲突。Distance函数一geometry包内的一个函数,命名空间是geometry.Distance;而Distance方法是Point类型的方法,命名空间是Point.Distance。访问时自然也不同:
p := Point{1, 2}
q := Point{4, 6}
fmt.Println(Distance(p, q)) // "5", 函数调用
fmt.Println(p.Distance(q)) // "5", 方法调用
我们一般把方法前面的那个参数(本例中是p)称之为方法的“接收器(英文是receiver)”(源于早起OOP语言调用方法称之为给对象发送一个消息)。Go中方法没有类似于this
或self
这种表示自身的变量,而是直接使用方法名前的那个参数,比如上面定义的方法中是q。
在很多其他OOP语言中,方法一般属于一个类,而在Go中方法属于某种类型。除了指针和接口两种类型外,其他任何类型都可以定义自己的方法。不同类型中的方法可以同名,因为他们在不同的命名空间里面。
2. 带有指针接收器的方法
我们知道在函数传参时,如果传的是指针类型,那么将是传址而非传值,即不用做数据拷贝,而且函数内对指针的改变会直接影响到外部的变量。这个特性也适用于方法。
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提供了更简单的写法:
p.ScaleBy(2)
当然,取地址的操作编译器隐式的帮我们做了。但是这种操作只能用在可以取地址的场合。比如下面的调用就是错误的:
Point{1,2}.ScaleBy(2) // compile error: can't take address of Point literal
因为取地址操作只能对变量进行,字面值是无法取地址的。关于这个操作,我们总结以下三种情况:
调用方法的变量类型与接收器的类型一致,都是
T
或者*T
,这种情况下无需做任何多余操作,直接调用即可:Point{1, 2}.Distance(q) // Point pptr.ScaleBy(2) // *Point
调用方法的变量类型是
T
,而接收器的类型是*T
,此时编译器会隐式的帮我们取地址:p.ScaleBy(2) // implicit (&p)
调用方法的变量类型是
*T
,而接收器的类型是T
,此时编译器会隐式的帮我们做解引用操作:pptr.Distance(q) // implicit (*pptr)
这种隐式的操作给我们提供了便利,但也带来了一些风险。比如我们本意是想将某个变量传值给某个方法,但因为该方法没有T类型的接收器,只有*T类型的接收器方法。那这个方法内对该指针的任何操作都会影响到外部的变量的值。这可能不是我们想要的。所以,调用方法时一定要清楚我们的本意与实际发生的是不是一致。
3. 嵌套结构体中方法的“继承”
之前我们介绍嵌套结构体的时候已经提到过,新结构体除了能“继承”它嵌套的结构体的数据成员外,还可以“继承”这些类型的方法。
type Point struct{X, Y float64}
type ColoredPoint struct {
Point
Color color.RGBA
}
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类型的方法已经被提升(promoted)到ColoredPoint类型去了。我们上面的继承都加了双引号是因为怕和其他OOP类语言里面的继承混淆。Go的这种机制表面上看的确非常像继承,Point就是ColoredPoint的基类,但Go设计者告诉我们这样认为是错误的。因为上面的例子中我们给Distance方法传的参数是Point类型,而非ColoredPoint类型。即使ColoredPoint通过嵌套Point类型获取到了Point的方法,但是使用的时候依旧必须传递Point类型的参数才可以(上面的例子中如果我们直接给Distance方法传q的话会报错),这和OOP语言里面的继承还是不同的。
从语言设计层面看的话,其实当我们在结构体里面嵌套的时候,编译器会帮我们封装一些方法。比如对于上面的例子,编译器会封装如下方法:
func (p ColoredPoint) Distance(p Point) float64 {
return p.Point.Distance(q)
}
func (p *ColoredPoint) ScaleBy(factor float64) {
return p.Point.ScaleBy(factor)
}
编译器在解析的时候会优先去本类型查找有没有对应名字的方法,如果没有,就去嵌套类型里面去找,如果最后都没有找到,就会报错。
4. 方法值和表达式
我们可以看到,函数和方法还是非常相像的,我们利用方法值和表达式(Method Values and Expressions)可以将两者联系起来。
先看方法值(Method Value):
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // method value
fmt.Println(distanceFromP(q)) // "5"
fmt.Printf("%T\n", distanceFromP) // "func(Point) float64"
var origin Point
fmt.Println(distanceFromP(origin)) // "2.23606797749979"
scaleP := p.ScaleBy
scaleP(2) // p becomes (2, 4)
scaleP(3) // then (6, 12)
scaleP(10) // then(60, 120)
可以看到,方法值其实就是把接收器绑定在某个特定的变量上面,后面这个变量就类似于一个函数一样了。
再看方法表达式(Method Expression):
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)"
我们看到T.f
或(*T).f
表达式可以返回一个Function value
,这个函数的第一个参数就是方法f的接收器参数。
5. 封装
封装也是OOP语言的一大特性。而Go中对于变量函数等的可见性都只有一个策略:首字母大写表示导出,包外可访问;首字母非大写表示未导出,包外不可访问。结构体成员的对外可见性也遵循这个原则。
所以对于Go来说,如果我们想像很多其他语言一样定义私有成员的话,我们可以把它包在结构体里面,并且以小写字母开头。然后在包内提供一些导出的函数来对这个私有成员进行操作,就达到了封装的效果了。
评论已关闭