Skip to content

方法

定义

方法一般是面向对象编程(OOP)的一个特性,在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。
但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。
一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。
面向对象编程(OOP)进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础之上支持了class等面向对象的特性。
然后Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。

面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。
Go语言的祖先C语言虽然不是一个支持面向对象的语言,但是C语言的标准库中的File相关的函数也用到了的面向对象编程的思想。下面我们实现一组C语言风格的File函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 文件对象
type File struct {
    fd int
}

// 打开文件
func OpenFile(name string) (f *File, err error) {
    // ...
}

// 关闭文件
func CloseFile(f *File) error {
    // ...
}

// 读文件数据
func ReadFile(f *File, offset int64, data []byte) int {
    // ...
}

其中 OpenFile 类似构造函数用于打开文件对象,CloseFile类似析构函数用于关闭文件对象,ReadFile则类似普通的成员函数,这三个函数都是普通的函数。
CloseFile和ReadFile作为普通函数,需要占用包级空间中的名字资源。
不过CloseFile和ReadFile函数只是针对File类型对象的操作,这时候我们更希望这类函数和操作对象的类型紧密绑定在一起。

Go语言中的做法是,将CloseFile和ReadFile函数的第一个参数移动到函数名的开头:

1
2
3
4
5
6
7
8
9
// 关闭文件
func (f *File) CloseFile() error {
    // ...
}

// 读文件数据
func (f *File) ReadFile(offset int64, data []byte) int {
    // ...
}

这样的话,CloseFile和ReadFile函数就成了File类型独有的方法了(而不是File对象方法)。
它们也不再占用包级空间中的名字资源,同时File类型已经明确了它们操作对象,因此方法名字一般简化为Close和Read:

1
2
3
4
5
6
7
8
9
// 关闭文件
func (f *File) Close() error {
    // ...
}

// 读文件数据
func (f *File) Read(offset int64, data []byte) int {
    // ...
}

将第一个函数参数移动到函数前面,从代码角度看虽然只是一个小的改动,但是从编程哲学角度来看,Go语言已经是进入面向对象语言的行列了。
我们可以给任何自定义类型添加一个或多个方法。
每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。
对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。

方法是由函数演变而来,只是将函数的第一个对象参数移动到了函数名前面了而已。
因此我们依然可以按照原始的过程式思维来使用方法。通过叫方法表达式的特性可以将方法还原为普通类型的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 不依赖具体的文件对象
// func CloseFile(f *File) error
var CloseFile = (*File).Close

// 不依赖具体的文件对象
// func ReadFile(f *File, offset int64, data []byte) int
var ReadFile = (*File).Read

// 文件处理
f, _ := OpenFile("foo.dat")
ReadFile(f, 0, data)
CloseFile(f)

在有些场景更关心一组相似的操作:比如Read读取一些数组,然后调用Close关闭。
此时的环境中,用户并不关心操作对象的类型,只要能满足通用的Read和Close行为就可以了。
不过在方法表达式中,因为得到的ReadFile和CloseFile函数参数中含有File这个特有的类型参数,这使得File相关的方法无法和其它不是File类型但是有着相同Read和Close方法的对象无缝适配。
这种小困难难不倒我们Go语言码农,我们可以通过结合闭包特性来消除方法表达式中第一个参数类型的差异:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 先打开文件对象
f, _ := OpenFile("foo.dat")

// 绑定到了 f 对象
// func Close() error
var Close = func() error {
    return (*File).Close(f)
}

// 绑定到了 f 对象
// func Read(offset int64, data []byte) int
var Read = func(offset int64, data []byte) int {
    return (*File).Read(f, offset, data)
}

// 文件处理
Read(0, data)
Close()

这刚好是方法值也要解决的问题。我们用方法值特性可以简化实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 先打开文件对象
f, _ := OpenFile("foo.dat")

// 方法值: 绑定到了 f 对象
// func Close() error
var Close = f.Close

// 方法值: 绑定到了 f 对象
// func Read(offset int64, data []byte) int
var Read = f.Read

// 文件处理
Read(0, data)
Close()

方法是与对象实例绑定的特殊函数

方法是面向对象编程的基本概念,用于维护和展示对象的自身状态。
对象是内敛的,每个实例都有各自不同的独立特征,以属性和方法来暴露对外通信接口。
普通函数则专注于算法流程,通过接收参数来完成特定逻辑运算,并返回最终结果。
换句话说,方法是有关联状态的,而函数通常没有。

方法和函数定义语法的区别在于前者有前置实例接收参数,编译器以此确定方法所属类型。
在某些语言里,尽管没有显式定义,但会在调用时隐式传递 this 实例参数.

可以为当前包,以及除接口和指针以外的任何类型定义方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import "fmt"

type N int

func (n N) toString() string {
    return fmt.Sprintf("%#x", n)
}

func main() {
    var a N = 25
    println(a.toString())
}

输出:

1
0x19

方法同样不支持重载。
参数名没有限制,按惯例会选用简短有意义的名称(不推荐使用 this、self)。
如方法内部并不引用实例,可省略参数名,仅保留类型。

1
2
3
4
5
type N int

func (N) test() {
    println("hi!")
}

方法可看作特殊的函数,那么 receiver 的类型自然可以是基础类型或指针类型。
这会关系到调用时对象实例是否被复制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

type N int

func (n N) value() { // func value(n N)
    n++
    fmt.Printf("v: %p, %v\n", &n, n)
}

func (n *N) pointer() { // func pointer(n *N)
    (*n)++
    fmt.Printf("p: %p, %v\n", n, *n)
}

func main() {
    var a N = 25
    a.value()
    a.pointer()

    fmt.Printf("a: %p, %v\n", &a, a)
}

输出:

1
2
3
v: 0xc000014080, 26  // receiver 被复制
p: 0xc000014078, 26
a: 0xc000014078, 26

使用实例值或指针调用方法,编译器会根据方法 receiver 类型自动在基础类型和指针类型间转换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "fmt"

type N int

func (n N) value() { // func value(n N)
    n++
    fmt.Printf("v: %p, %v\n", &n, n)
}

func (n *N) pointer() { // func pointer(n *N)
    (*n)++
    fmt.Printf("p: %p, %v\n", n, *n)
}

func main() {
    var a N = 25
    p := &a
    a.value()
    a.pointer()

    p.value()
    p.pointer()
}

输出:

1
2
3
4
v: 0xc000014080, 26
p: 0xc000014078, 26
v: 0xc0000140a0, 27
p: 0xc000014078, 27

如何选择方法的 receiver 类型?

  • 要修改实例状态,用 *T
  • 无须修改状态的小对象或固定值,建议用 T
  • 大对象建议用 *T,以减少复制成本
  • 引用类型、字符串、函数等指针包装对象,直接用 T
  • 若包含 Mutex 等同步字段,用 *T,避免因复制造成锁操作无效
  • 其他无法确定的情况,都用 *T
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import "fmt"

type rect struct {
    width, height int
}

func (r *rect) area() int {
    return r.width * r.height
}

func (r rect) perim() int {
    return 2*r.width + 2*r.height
}

func main() {
    r := rect{width: 10, height: 5}

    fmt.Println("area: ", r.area())
    fmt.Println("perim:", r.perim())

    rp := &r
    fmt.Println("area: ", rp.area())
    fmt.Println("perim:", rp.perim())
}

输出:

1
2
3
4
area:  50
perim: 30
area:  50
perim: 30

匿名字段

可以像访问匿名字段成员那样调用其方法,由编译器负责查找

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type data struct {
    sync.Mutex
    buf [1024]byte
}

func main() {
    d := data{}
    d.Lock()  // 编译会处理为 sync.(*Mutex).Lock() 调用 
    defer d.Unlock()
}

方法也会有同名遮蔽问题。但利用这种特性,可实现类似覆盖(override)操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

type user struct{}

type manager struct {
    user
}

func (user) toString() string {
    return "user"
}

func (m manager) toString() string {
    return m.user.toString() + ";manager"
}

func main() {
    var m manager

    println(m.toString())
    println(m.user.toString())
}

输出:

1
2
user;manager
user

尽管能直接访问匿名字段的成员和方法,但它们依然不属于继承关系

继承

Go语言不支持传统面向对象中的继承特性,而是以自己特有的组合方式支持了方法的继承。
Go语言中,通过在结构体内置匿名的成员来实现继承:

1
2
3
4
5
6
7
8
import "image/color"

type Point struct{ X, Y float64 }

type ColoredPoint struct {
    Point
    Color color.RGBA
}

虽然我们可以将ColoredPoint定义为一个有三个字段的扁平结构的结构体,但是我们这里将Point嵌入到ColoredPoint来提供X和Y这两个字段。

1
2
3
4
5
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y)       // "2"

通过嵌入匿名的成员,我们不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。
我们一般会将Point看作基类,把ColoredPoint看作是它的继承类或子类。
不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Cache struct {
    m map[string]string
    sync.Mutex
}

func (p *Cache) Lookup(key string) string {
    p.Lock()
    defer p.Unlock()

    return p.m[key]
}

Cache结构体类型通过嵌入一个匿名的 sync.Mutex 来继承它的Lock和Unlock方法.
但是在调用 p.Lock()p.Unlock() 时, p并不是Lock和Unlock方法的真正接收者, 而是会将它们展开为 p.Mutex.Lock()p.Mutex.Unlock() 调用.
这种展开是编译期完成的, 并没有运行时代价.

在传统的面向对象语言(eg.C++或Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的this可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。
而在Go语言通过嵌入匿名的成员来“继承”的基类方法,this就是实现该方法的类型的对象,Go语言中方法是编译时静态绑定的。
如果需要虚函数的多态特性,我们需要借助Go语言接口来实现。