Go编程基础-6. 方法

3

方法

方法是针对某种特定类型的函数。面向对象编程就是使用方法来描述不同数据结构的属性和操作。

1. 方法声明

方法的声明与函数的声明类似,只是在函数名字前面多了一个类型参数,来将方法绑定到这个参数对应的类型上。下面是一个简单的方法声明:

package main

import (
	"fmt"
	"math"
)

type Point struct {
	X, Y float64
}

// 1. 方法
func (p Point) Distance(q Point) float64 {
	return math.Hypot(q.X-p.X, q.Y-p.Y)
}

// 2. 等价的函数
func Distance(p, q Point) float64 {
	return math.Hypot(p.X-q.X, p.Y-q.Y)
}

func main() {
	p := Point{1, 1}
	q := Point{2, 2}

	dis1 := Distance(p, q)
	dis2 := p.Distance(q)

	fmt.Printf("函数结果:%f, 方法结果:%f\n", dis1, dis2)
}

可以看到,go语言的方法和其它面向对象语言大体相同,以一个对象为载体调用方法来执行相关的操作。区别在于:1. 没有硬性规定方法的声明区域,只要在对应结构体声明的同一个包下即可;2. 没有对调用对象本身进行特殊命名(this、self等),可以自定义命名,但同一个类型的所有方法中尽量保持一致,一般为其类型名称的第一个小写字母。

这里需要明确几个概念:

  1. 接收者(receiver):方法附加的对象参数p被称为方法的接收者,这一称呼源自早期的面向对象语言,将调用方法类比为向对象发送消息;
  2. 选择子(selector):表达式p.Distance称作选择子, 用来为接收者p选择合适的Distance方法。需要注意的是,选择子也同时用于选择结构体中的字段值,例如:p.X。
func (receiver  receiver_type) some_func_name(arguments) return_values

由于方法和字段来自于同一个命名空间(结构体级别),如果结构体/对象的"字段和方法"名称重复,那么就会发生冲突,导致编译器报错。反之,包级别和对象级别是相互分离的,所以上述示例代码中的Distance函数,一个属于main包(main.Distance),一个属于Point结构体(Point.Distance),所以可以共存。需要注意的是,go语言中类型拥有的方法名必须都是唯一的,因此go语言中没有方法重载的概念。

2. 指针接收者

函数或方法在调用时会复制每一个参数变量,在此基础上对生成的副本进行操作和修改。这在一些情况下能实现变量的隔离,避免一些闭源函数对变量进行未知的操作和修改,导致无法预期的结果。但同样也会导致:

  1. 在函数内对变量的修改无法同步到主函数;
  2. 如果参数太大,复制这个参数会占用大量内存空间,同时大幅降低优化效率。
    针对这些情况,我们可以使用指针来传递变量的地址来对参数本体进行修改。接收者的传递机制和参数变量一致。如果直接传递类型变量,那么会生成一个临时副本进行修改,所以修改不会同步到调用函数中。如果想要在方法中对接收者进行调整,那么就要使用类型对应的指针进行传递:
func (p *point) ScaleBy(factor float64) {
	p.X *= factor
	p.Y *= factor
}

// 调用方法: 注意类型对应的指针必须被圆括号包围,否则会被解析为&(p.ScaleBy)
(&p).ScaleBy(factor) 

习惯上如果 Point 的任何一个方法使用指针接收者,那么所有的Point 方法都应该使用指针接收者,即使有些方法并不一定需要。

可以看到,命名类型和指向它们的指针是相同对象的不同表征形式,共用同一片命名空间。为了防止混淆,go不允许本身是指针的类型进行方法声明

标准写法

p := &Point{1, 2}
p.ScaleBy(2)
fmt.Println(*p)

这也是为什么建议所有方法,要么统一使用类型接收者,要么使用指针接收者的原因。因为在声明变量的时候就可以直接确定,声明的是类型,还是指针,避免在代码中频繁的进行转换。

隐式转换

p := Point{1, 2}
p.ScaleBy(2)
fmt.Println(p)

如果方法要求的是一个指针接收者*Point,但是调用者提供了一个Point类型的变量,这种情况下也是可以正常运行的,因为编译器会对变量进行隐式的&p转换。但是不能够对一个字面量进行这样的方法调用,例如:Point{1,2}.ScaleBy(2),因为无法直接获取字面量的地址。

nil是一个合法的接收者

某些函数和方法允许使用nil作为参数或接收者,特别是当nil对于特定类型(如map、slice、链表)具有实际意义时。以链表为例:

// IntList :整型链表, 当*IntList为nil时表示空链表
type IntList struct {
	Value int
	Tail  *IntList
}

// Sum :计算整形链表元素的和
func (list *IntList) Sum() int {
	if list.Tail == nil {
		return 0
	}
	return list.Value + list.Tail.Sum()
}

因为nil是一个合法的接受者,所以在编程过程中,我们需要更多地考虑到这种边界情况,对这种条件进行判断和处理。以map为例,对一个map取值,如果key不存在,那么返回的就是value类型的零值。利用这个技巧,我们可以在value为slice的情况下实现简单的元素插入,而无需判断key是否存在,是否需要针对不存在的情况进行初始化:

// Add : add a value to the key list
func (v Values) Add(key, value string) {
	v[key] = append(v[key], value)
}

在key值不存在时,v[key]会返回一个空slice[]方便我们进行append操作,操作完成后赋值给map就实现了更新,这在多数情况下都是可行的。但是,当存储slice的map为nil时,情况就复杂了起来。主要存在两个问题:

  1. 对nil map进行索引得到的结果是什么
  2. 对nil map进行更新得到的结果是什么
    经过测试,对nil map进行索引得到的结果依然是value类型的零值,因此可以使用v[key]进行读取。但是对v[key]进行赋值、更新时就会报错panic: assignment to entry in nil map。因此上述add方法中,等式右侧append(v[key], value)能够正常执行,返回一个包含插入value的单元素slice。但是当执行赋值时,就会报错。

3. 通过结构体内嵌实现继承

正如之前提到的那样,go语言中可以通过结构体内嵌实现属性和方法的继承,从而构成更加复杂的类型。这种属性和方法的继承在多数情况下都是隐式的,但是这种继承并非传统意义上的父子类关系。**两种类型依然是相互独立的,只是通过内嵌的方式获得了内部结构体的方法和属性。**例如:

type Point struct {
	X, Y float64
}

func (p Point) Distance(q Point) float64 {
	return math.Hypot(q.X-p.X, q.Y-p.Y)
}

type ColoredPoint struct {
	Point
	Color color.RGBA
}

在这种嵌入关系下,我们可以使用ColoredPoint的对象调用Point的属性和方法:

cp1 := ColoredPoint{Point{1, 1}, color.RGBA{255, 0, 0, 255}}
fmt.Println(cp1.X)
fmt.Println(cp1.Point.Y)

cp2 := ColoredPoint{Point{2, 2}, color.RGBA{0, 255, 0, 255}}
dis := cp1.Distance(cp2.Point)
fmt.Println(dis)

需要注意的是,我们在调用方法时使用的是cp1.Distance(cp2.Point),即接收者会隐式转换,但是参数任然需要显示地调用内部结构体。这也是go语言结构体内嵌区别于继承的体现,ColoredPoint并不是Point,只是内部包含了Point及其对应的方法。实际上,内嵌的字段会告诉编译器生成额外的包装方法来调用Point声明的方法,相当于以下代码:

// 包装方法内部调用了Point声明的方法,从而实现接收者的隐式转换,但是参数类型是不变的
func (p ColoredPoint) Distance(q Point) float64 {
	return p.Point.Distance(q)
}

匿名字段类型可以是个指向命名类型的指针

匿名字段类型可以为指向命名类型的指针,这种情况下字段和方法间接地源于指向的对象,这有助于共享通用结构并增强对象间关系的动态性和多样性。

type ColoredPoint2 struct {
	*Point
	Color color.RGBA
}

cp1 := ColoredPoint2{&Point{1, 1}, color.RGBA{255, 0, 0, 255}}
fmt.Println(cp1.X)
fmt.Println(cp1.Point.Y)

cp2 := ColoredPoint2{&Point{2, 2}, color.RGBA{0, 255, 0, 255}}
dis := cp1.Distance(*cp2.Point)
fmt.Println(dis)

多个匿名字段

结构体类型可以有多个匿名字段,在这种情况下可能会出现不同内嵌结构体有着同名方法的情况。当编译器处理选择子(p.Distance)的时候,会优先寻找:

  1. 结构体自身声明的方法Distance
  2. 再从内嵌结构体的中寻找方法Distance(所有内嵌结构体同级)
    如果2中存在多个结构体具有同名方法就会报错ambiguous reference/selector。因此,在进行结构体构建时需要注意使用的匿名字段是否存在同名方法,是否可能导致潜在的冲突。

go结构体内嵌相较于java继承的优点

Go语言的结构体内嵌功能支持开发者以简洁而灵活的方式整合多个功能模块,构建复杂的对象系统。这种方法较之Java的单继承模式,不仅更为灵活,还使得变量的命名和调用过程更加直观和贴切。例如,考虑以下Go语言代码片段:

var cache = struct {
	sync.Mutex
	mapping map[string]string
}{
	mapping: make(map[string]string),
}

func Lookup(key string) string {
	cache.Lock()
	defer cache.Unlock()
	v := cache.mapping[key]
	return v
}

在此示例中,我们定义了一个包级别的变量cache,它通过结构体内嵌的方式集成了互斥锁(sync.Mutex)和映射表(map[string]string),用于实现缓存操作。这种结构体内嵌的用法简化了同步机制的实现:可以直接在cache对象上调用Lock和Unlock方法,这使得代码更为简洁且易于理解。此外,使用defer语句确保在函数返回前释放锁,这是Go语言中处理资源释放的推荐方式。

4. 方法变量

与函数变量类似,我们也可以将方法作为变量来进行传递和调用。区别在于,方法变量需要提前指定接收者,以火箭延迟发射为例:

type Rocket struct {
	name string
}

func (r *Rocket) Launch() {
	fmt.Println("Launch Rocket " + r.name)
}

r := new(Rocket)
r.name = "\"Space X\""
// 将方法作为变量进行传递,从而实现延迟调用
time.AfterFunc(2*time.Second, r.Launch)
time.Sleep(3 * time.Second)

如果包内的API需要调用一个特定接收者的方法,而不在乎接收者本身的参数,此时方法变量就非常有用。方法变量的表达式可以写作T.f 或者 (*T).f, 即指定的接收者+方法,是一种函数变量。通过方法表达式可以进行方法的传递和赋值:

func DelayExecute(delayTime float64, delayFunc func()) {
	time.Sleep(time.Duration(delayTime) * time.Second)
	delayFunc()
}

r := new(Rocket)
r.name = "\"Space X\""
delayFunc := r.Launch          // 1. 使用方法变量的表达式进行赋值
DelayExecute(1.5, delayFunc)   // 2. 将方法变量作为参数传递,实现延迟调用

注意,此处方法变量的类型和函数变量是一致的,通过参数+返回值确定,与接收者类型无关。

5. 封装

封装是指变量或方法不通过对象直接访问,是面向对象编程的关键概念。在Go语言中,通过标识符的首字母大小写控制可见性,大写则可外部访问,小写则限制访问,实现封装。因此,封装对象需使用结构体。

封装提供了三大优点:

  1. 第一,隐藏的内部变量不可直接修改,因此无需额外语句检查变量是否合法,可用性由封装者进行保证。
  2. 第二,隐藏实现细节可以确保用户不依赖于易变属性,从而使设计者能灵活修改API内部实现而不影响兼容性。
  3. 第三,就是防止使用者肆意地改变对象内的变量。例如提供了一个Counter,只能++,那么这个变量就需要尽可能隐藏起来避免调用者肆意修改,导致程序出错。

Getter和setter函数作为对外暴露的接口,用于访问或修改内部变量。但是需要注意的是,go语言中部分内置库命名getter方法时常省略"Get"前缀,例如:log.Flag(), 来使命名和调用更加简洁。这一命名习惯也适用于其他类似前缀,如Fetch、Find和Lookup。