在这面向对象横行的年代,作为一门新的程序设计语言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中方法没有类似于thisself这种表示自身的变量,而是直接使用方法名前的那个参数,比如上面定义的方法中是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

因为取地址操作只能对变量进行,字面值是无法取地址的。关于这个操作,我们总结以下三种情况:

  1. 调用方法的变量类型与接收器的类型一致,都是T或者*T,这种情况下无需做任何多余操作,直接调用即可:

    Point{1, 2}.Distance(q)    // Point
    pptr.ScaleBy(2)            // *Point
  2. 调用方法的变量类型是T,而接收器的类型是*T,此时编译器会隐式的帮我们取地址:

    p.ScaleBy(2)    // implicit (&p)
  3. 调用方法的变量类型是*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来说,如果我们想像很多其他语言一样定义私有成员的话,我们可以把它包在结构体里面,并且以小写字母开头。然后在包内提供一些导出的函数来对这个私有成员进行操作,就达到了封装的效果了。