这是我参与8月更文挑战的第 18 天,活动详情查看: 8月更文挑战
方法
方法声明
方法的声明和普通函数的声明类似,只是在函数的名字前边多了一个参数。这个参数把这个方法绑定到这个参数对应的类型上
package main
import (
"fmt"
"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)
}
func main() {
p := Point{1, 2}
q := Point{4, 6}
fmt.Println(Distance(p, q)) //"5" 函数调用
fmt.Println(p.Distance(q)) //"5" 方法调用
}
附加的参数p称为方法的接收者。在Go语言中,接收者不使用特殊的名字(比如self或this),而是我们自己选择接收者名字,就像定义变量一样。调用方法的时候,接收者在方法名的前面,这样就和声明保持一致
上边的两个Distance函数声明并没有冲突,第一个声明一个包级别的函数。第二个声明一个类型Point的方法,所以它的名字应该是Point.Distance
表达式p.Distance称作选择子,因为它为接收者p选择合适的Distance方法。选择子也用于选择结构类型中的某些字段值。由于方法和字段来自同一个命名空间,因此在Point结构类型中声明一个叫做X的方法,会与结构体中的成员X冲突,会编译不通过
因为每一个类型,都可以有自己的很多自定义的方法,所以我们可以在不同的类型中使用名字Distance作为方法名。下边的Path类型表示一条线段,同样也使用Distance作为方法名
type Path []Point
func (path Path) Distance() float64 {
sum := 0.0
for i := range path {
if i>0 {
sum += path[i-1].Distance(path[i])
}
}
return sum
}
Path是一个命名的slice类型,而非Point这样的结构体类型,但是我们依旧可以给它定义方法。Go和其它类型的语言不一样,他可以将方法绑定到任何类型上,可以很方便的为简单的类型(数字、字符串、slice、map、甚至函数等)定义附加的行为。同一个包下的任何类型都可以声明方法,只要它的类型既不是指针类型也不是接口类型
指针接收者的方法
由于主调函数会复制每一个实参变量,如果函数需要更新一个变量,或者如果一个实参太大而我们希望避免复制整个实参,因此我们必须使用指针来传递变量的地址。这也同样适用于更新接收者,我们可以将它绑定到指针类型,比如*Point
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
这个方法的名字是(*Point).ScaleBy。圆括号是必须的,没有圆括号,表达式会被解析为 *(Point.ScaleBy)
在正式的开发中,习惯上遵循,如果Point的任何一个方法使用指针接收者,那么该类型的所有方法都应该使用指针接收者,即使某些方法并不一定需要
为防止混淆,不允许本身是指针的类型,进行方法的声明
type P *int
func (P) f() {/*......*/}//编译错误,非法的接收者类型
//通过提供*Point能够调用(*Point).ScaleBy方法,比如:
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}"
如果接收者p是Point类型的变量,但是方法要求一个*Point接收者,我们可以简写成
p.ScaleBy(2)
实际山编译器会对变量进行&p的因式转换。只有变量才允许这么做,包括结构体字段,像p.X或者slice或数组的元素,比如a[0]。不能够对一个不能取地址的Point接收者参数调用*Point的方法,因为无法获取临时变量的地址
Point{1, 2}.ScaleBy(2)//编译错误,不能获取Point类型字面量的地址
总结
在合法的方法调用表达式中,只有符合下面三种形式的语句才能够成立
- 实参接收者和形参接收者是同一个类型,比如都是T类型或都是*T类型
- 实参接收者是T类型的变量,而形参接收者是*T类型。编译器会隐式的获取变量的地址
func (p *Point) Distance(q Point){/*...*/}
p.Distance(q)//隐式转换成&p
- 实参接收者是*T类型,而形参接收者是T类型。编译器会隐式地解引用接收者,获取实际的取值
func (p Point) Distance(q Point){/*...*/}
(&p).Distance(q)
nil是一个合法的接收者
就像一些函数允许nil指针作为实参,方法的接收者也一样,尤其是当nil是类型中有意义的零值(如map和slice类型)时,更是如此。一个结构体方法示例:
//IntList是整形链表
//*IntList的类型nil代表空链表
type IntList struct{
Value int
Tail *IntList
}
//Sum返回列表元素的总和
func (list *IntList) Sum() int {
if list == nil {
return 0
}
return list.Value + list.Tail.Sum()
}
l := nil
l.Sum()
再看一个map类型的示例
// Values映射字符串到字符串列表
type Values map[string][]string
//Get返回第一个具有给定key的值
func (v Values) Get(key string) string {
if vs := v[key];len(vs) > 0 {
return vs[0]
}
return ""
}
// Add添加一个键值到对应的key列表中
func (v Values) Add(key, value string) {
v[key] = append(v[key], value)
}
m := url.Values{"lang":{"en"}}
m.Add("item", "1")
m.Add("item", "2")
fmt.Println(m.Get("lang"))// "en"
fmt.Println(m.Get("q")) // ""
fmt.Println(m.Get("item"))// "1"
fmt.Println(m["item"]) // "[1 2]"
m = nil
fmt.Println(m.Get("item")) // ""
m.Add("item", "3") // 宕机:赋值给空的map类型
在最后一个Get调用中,nil接收者充当一个空map。它可以等同地写成Values(nil).Get("item"),但是nil.Get("item")不能通过编译,因为nil的类型还没有确定
通过结构体内嵌组成类型
还是以一个示例来协助理解
type Point struct{
X, Y float64
}
type ColoredPoint struct{
Point
Color color.RGBA
}
结构体ColoredPoint中内嵌了一个Point类型,以获取字符X和Y,内嵌可以使我们更简便的定义ColoredPoint类型(结构体内嵌可以点击这里了解),如果需要,可以直接使用ColoredPoint内所有的字段而不需要提到Point类型
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y) // "2"
同理,这也适用于Point类型的方法,我们能够通过类型为ColoredPoint的接收者调用内嵌类型Point的方法,即使在ColoredPoint类型没有声明过这个方法的情况下
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)) // "5"
p.ScaleBy(2)
q.ScaleBy(2)
fmt.Println(p.Distance(q.Point)) // "10"
可以发现Point类型的方法都被纳入到ColoredPoint类型中,以这种方式,内嵌允许构成复杂的类型,该类型由许多字段构成,每个字段提供一些方法
如果你了解面向对象,可能会认为Point类型就是ColoredPoint类型的基类,而ColoredPoint则作为子类或派生类,或将这两个之间的关系翻译为ColoredPoint就是Point的其中一种表现。但这其实是一种误解,你可以注意一下上边调用Distance的地方,Distance有一个形参Point,q不是Point,所以,虽然q有一个内嵌的Point字段,但是必须显示的使用它,按下边这种写法就会编译不通过
p.Distance(q) // 编译错误:不能将q转换为Point类型
ColoredPoint并不是Point,但是它包含一个Point,并且它有两个另外的方法Distance和ScaleBy来自Point。如果考虑具体实现,实际上,内嵌的字段会告诉编译器生成额外的包装方法来调用Point声明的方法,这相当于如下代码:
func (p ColoredPoint) Distance(q Point) {
return p.Point.Distance(q)
}
func (p *ColoredPoint) ScaleBy(factor float64) {
p.Point.ScaleBy(factor)
}
方法变量与表达式
我们还可以将选择子p.Distance赋予一个方法变量,它是一个函数,把方法(Point.Distance)绑定到一个接收者p上。函数只需要提供实参,而不需要提供接收者就能够调用
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // 方法变量
fmt.Println(distanceFromP(q)) // "5"
var origin Point // {0, 0}
fmt.Println(distanceFromP(origin)) // "2.236067..." 根号5
scaleP := p.ScaleBy // 方法变量
scaleP(2) // p变成(2,4)
scaleP(3) // 然后是(6, 12)
scaleP(10) // 然后是(60, 120)
如果包内的API调用一个函数值,并且使用者期望这个函数的行为是调用一个特定接收者的方法,这个方法变量就非常有用。比如,函数time.AfterFunc会在指定的延迟后调用一个函数
type Rocket struct{/*...*/}
func (r *Rocket) Launch() {/*...*/}
r := new(Rocket)
time.AfterFunc(10 * time.Second, func() { r.Launch() })
如果使用方法变量,则更简洁
time.AfterFunc(10 * time.Second, r.Launch)
与方法变量相关的是方法表达式。和调用一个普通的函数不同,在调用方法的时候必须提供接收者,并且按照选择子的语法进行调用。而方法表达式写成T.f或者(*T).f,其中T是类型,是一种函数变量,把原来方法的接收者替换成函数的第一个形参,因此它可以像平常的函数一样调用
p := Point{1, 2}
q := Point{4, 6}
distance := Point.Distance // 方法表达式
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 []Point
func (path Path) TranslateBy(offset Point, add bool) {
var op func(p, q Point) Point
if add {
op = Point.Add
} else {
op = Point.Sub
}
for i := range path {
//调用path[i].Add(offset)或者是path[i].Sub(offset)
path[i] = op(path[i], offset)
}
}
封装
Go语言中只包含面向对象三大特性中的封装。并且,在Go语言中,只有一种方式来控制命名的可见性:定义的时候,首字母大写的标识符是可以跨包访问的,而首字母小写,则只能在包内使用。同样的机制也适用于结构体内的字段和类型中的方法(注意:封装,是以包的维度来看的)
在Go语言中,封装的单元是包而不是类型。无论是在函数内的代码,还是方法内的代码,结构体类型内的字段对于同一个包中的所有代码都是可见的
封装的三个优点
- 因为使用方法不能直接修改对象的变量,所以不需要更多的语句来检查变量的值
- 隐藏实现细节,可以防止使用方依赖的属性发生改变,使得设计者可以更加灵活的改变API的实现而不破坏兼容性
看一个例子帮助理解。bytes包中有一个Buffer类型,它用来堆积非常小的字符串,因此为了优化性能,实现上会预留一部分额外的空间,避免频繁申请内存。由于Buffer是结构体类型,因此这块空间使用额外的一个字段[64]byte,并且命名不是首字母大写。因为这个字段是私有的,bytes包之外的Buffer使用者除了能够感觉到性能提升之外,不会关心其中的实现,下边是里边的实现
type Buffer struct {
buf []byte
initial [64]byte
/*...*/
}
//Grow 方法按需扩展缓冲区大小
//保证n个字节的空间
func (b *Buffer) Grow(n int) {
if b.buf == nil {
b.buf = b.initial[:0]//最初使用预分配的空间
}
if len(b.buf)+n > cap(b.buf) {
buf := make([]byte, b.Len(), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
}
- 防止使用者肆意的改变对象的变量。因为对象的变量只能被同一个包内的函数修改,所以包的作者能够保证所有的函数都可以维护对象内的资源。
下边的Counter类型允许使用者递增计数或者重置计数器,但是不能够随意地设置当前计数器的值
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
}
参考
《Go程序设计语言》—-艾伦 A. A. 多诺万
《Go语言学习笔记》—-雨痕