1.4.2 方法
方法一般是面向对象编程(OOP)的一个特性,在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程(OOP)进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础之上支持了class等面向对象的特性。然后Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。
面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。Go语言的祖先C语言虽然不是一个支持面向对象的语言,但是C语言的标准库中的File相关的函数也用到了的面向对象编程的思想。下面我们实现一组C语言风格的File函数:
// 文件对象
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函数的第一个参数移动到函数名的开头:
// 关闭文件
func (f *File) CloseFile() error {
// ...
}
// 读文件数据
func (f *File) ReadFile(offset int64, data []byte) int {
// ...
}这样的话,CloseFile和ReadFile函数就成了File类型独有的方法了(而不是File对象方法)。它们也不再占用包级空间中的名字资源,同时File类型已经明确了它们操作对象,因此方法名字一般简化为Close和Read:
// 关闭文件
func (f *File) Close() error {
// ...
}
// 读文件数据
func (f *File) Read(offset int64, data []byte) int {
// ...
}将第一个函数参数移动到函数前面,从代码角度看虽然只是一个小的改动,但是从编程哲学角度来看,Go语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
方法是由函数演变而来,只是将函数的第一个对象参数移动到了函数名前面了而已。因此我们依然可以按照原始的过程式思维来使用方法。通过叫方法表达式的特性可以将方法还原为普通类型的函数:
// 不依赖具体的文件对象
// 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语言码农,我们可以通过结合闭包特性来消除方法表达式中第一个参数类型的差异:
// 先打开文件对象
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()这刚好是方法值也要解决的问题。我们用方法值特性可以简化实现:
// 先打开文件对象
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()Go语言不支持传统面向对象中的继承特性,而是以自己特有的组合方式支持了方法的继承。Go语言中,通过在结构体内置匿名的成员来实现继承:
import "image/color"
type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}虽然我们可以将ColoredPoint定义为一个有三个字段的扁平结构的结构体,但是我们这里将Point嵌入到ColoredPoint来提供X和Y这两个字段。
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y) // "2"通过嵌入匿名的成员,我们不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。我们一般会将Point看作基类,把ColoredPoint看作是它的继承类或子类。不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。
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语言接口来实现。
学员评价