Go语言-【结构体】-方法和接收器

在Go语言中,结构体就像是类的一种简化形式,那么类的方法在哪里呢?在Go语言中有一个概念,它和方法有着同样的名字,并且大体上意思相同,Go 方法是作用在接收器(receiver)上的一个函数,接收器是某种类型的变量,因此方法是一种特殊类型的函数。

接收器类型可以是(几乎)任何类型,不仅仅是结构体类型,任何类型都可以有方法,甚至可以是函数类型,可以是 int、bool、string 或数组的别名类型,但是接收器不能是一个接口类型,因为接口是一个抽象定义,而方法却是具体实现,如果这样做了就会引发一个编译错误invalid receiver type…

接收器也不能是一个指针类型,但是它可以是任何其他允许类型的指针,一个类型加上它的方法等价于面向对象中的一个类,一个重要的区别是,在Go语言中,类型的代码和绑定在它上面的方法的代码可以不放置在一起,它们可以存在不同的源文件中,唯一的要求是它们必须是同一个包的。

类型 T(或 T)上的所有方法的集合叫做类型 T(或 T)的方法集。

因为方法是函数,所以同样的,不允许方法重载,即对于一个类型只能有一个给定名称的方法,但是如果基于接收器类型,是有重载的:具有同样名字的方法可以在 2 个或多个不同的接收器类型上存在,比如在同一个包里这么做是允许的。

提示

在面向对象的语言中,类拥有的方法一般被理解为类可以做的事情。在Go语言中“方法”的概念与其他语言一致,只是Go语言建立的“接收器”强调方法的作用对象是接收器,也就是类实例,而函数没有作用对象。

为结构体添加方法

本节中,将会使用背包作为“对象”,将物品放入背包的过程作为“方法”,通过面向过程的方式和Go语言中结构体的方式来理解“方法”的概念。

1) 面向过程实现方法

面向过程中没有“方法”概念,只能通过结构体和函数,由使用者使用函数参数和调用关系来形成接近“方法”的概念,代码如下:

 
  1. type Bag struct {
  2. items []int
  3. }
  4. // 将一个物品放入背包的过程
  5. func Insert(b *Bag, itemid int) {
  6. b.items = append(b.items, itemid)
  7. }
  8. func main() {
  9. bag := new(Bag)
  10. Insert(bag, 1001)
  11. }

代码说明如下:

  • 第 1 行,声明 Bag 结构,这个结构体包含一个整型切片类型的 items 的成员。
  • 第 6 行,定义了 Insert() 函数,这个函数拥有两个参数,第一个是背包指针(*Bag),第二个是物品 ID(itemid)。
  • 第 7 行,用 append() 将 itemid 添加到 Bag 的 items 成员中,模拟往背包添加物品的过程。
  • 第 12 行,创建背包实例 bag。
  • 第 14 行,调用 Insert() 函数,第一个参数放入背包,第二个参数放入物品 ID。


Insert() 函数将 *Bag 参数放在第一位,强调 Insert 会操作 *Bag 结构体,但实际使用中,并不是每个人都会习惯将操作对象放在首位,一定程度上让代码失去一些范式和描述性。同时,Insert() 函数也与 Bag 没有任何归属概念,随着类似 Insert() 的函数越来越多,面向过程的代码描述对象方法概念会越来越麻烦和难以理解。

2) Go语言的结构体方法

将背包及放入背包的物品中使用Go语言的结构体和方法方式编写,为 *Bag 创建一个方法,代码如下:

 
  1. type Bag struct {
  2. items []int
  3. }
  4. func (b *Bag) Insert(itemid int) {
  5. b.items = append(b.items, itemid)
  6. }
  7. func main() {
  8. b := new(Bag)
  9. b.Insert(1001)
  10. }

第 5 行中,Insert(itemid int) 的写法与函数一致,(b*Bag) 表示接收器,即 Insert 作用的对象实例。

每个方法只能有一个接收器,如下图所示。
 


图:接收器


第 13 行中,在 Insert() 转换为方法后,我们就可以愉快地像其他语言一样,用面向对象的方法来调用 b 的 Insert。

接收器——方法作用的目标

接收器的格式如下:

func (接收器变量 接收器类型) 方法名(参数列表) (返回参数) {
    函数体
}

对各部分的说明:

  • 接收器变量:接收器中的参数变量名在命名时,官方建议使用接收器类型名的第一个小写字母,而不是 self、this 之类的命名。例如,Socket 类型的接收器变量应该命名为 s,Connector 类型的接收器变量应该命名为 c 等。
  • 接收器类型:接收器类型和参数类似,可以是指针类型和非指针类型。
  • 方法名、参数列表、返回参数:格式与函数定义一致。


接收器根据接收器的类型可以分为指针接收器、非指针接收器,两种接收器在使用时会产生不同的效果,根据效果的不同,两种接收器会被用于不同性能和功能要求的代码中。

1) 理解指针类型的接收器

指针类型的接收器由一个结构体的指针组成,更接近于面向对象中的 this 或者 self。

由于指针的特性,调用方法时,修改接收器指针的任意成员变量,在方法结束后,修改都是有效的。

在下面的例子,使用结构体定义一个属性(Property),为属性添加 SetValue() 方法以封装设置属性的过程,通过属性的 Value() 方法可以重新获得属性的数值,使用属性时,通过 SetValue() 方法的调用,可以达成修改属性值的效果。

 
  1. package main
  2. import "fmt"
  3. // 定义属性结构
  4. type Property struct {
  5. value int // 属性值
  6. }
  7. // 设置属性值
  8. func (p *Property) SetValue(v int) {
  9. // 修改p的成员变量
  10. p.value = v
  11. }
  12. // 取属性值
  13. func (p *Property) Value() int {
  14. return p.value
  15. }
  16. func main() {
  17. // 实例化属性
  18. p := new(Property)
  19. // 设置值
  20. p.SetValue(100)
  21. // 打印值
  22. fmt.Println(p.Value())
  23. }

运行程序,输出如下:

100

代码说明如下:

  • 第 6 行,定义一个属性结构,拥有一个整型的成员变量。
  • 第 11 行,定义属性值的方法。
  • 第 14 行,设置属性值方法的接收器类型为指针,因此可以修改成员值,即便退出方法,也有效。
  • 第 18 行,定义获取值的方法。
  • 第 25 行,实例化属性结构。
  • 第 28 行,设置值,此时成员变量变为 100。
  • 第 31 行,获取成员变量。

2) 理解非指针类型的接收器

当方法作用于非指针接收器时,Go语言会在代码运行时将接收器的值复制一份,在非指针接收器的方法中可以获取接收器的成员值,但修改后无效。

点(Point)使用结构体描述时,为点添加 Add() 方法,这个方法不能修改 Point 的成员 X、Y 变量,而是在计算后返回新的 Point 对象,Point 属于小内存对象,在函数返回值的复制过程中可以极大地提高代码运行效率,详细过程请参考下面的代码。

 
  1. package main
  2. import (
  3. "fmt"
  4. )
  5. // 定义点结构
  6. type Point struct {
  7. X int
  8. Y int
  9. }
  10. // 非指针接收器的加方法
  11. func (p Point) Add(other Point) Point {
  12. // 成员值与参数相加后返回新的结构
  13. return Point{p.X + other.X, p.Y + other.Y}
  14. }
  15. func main() {
  16. // 初始化点
  17. p1 := Point{1, 1}
  18. p2 := Point{2, 2}
  19. // 与另外一个点相加
  20. result := p1.Add(p2)
  21. // 输出结果
  22. fmt.Println(result)
  23. }

代码输出如下:

{3 3}

代码说明如下:

  • 第 8 行,定义一个点结构,拥有 X 和 Y 两个整型分量。
  • 第 14 行,为 Point 结构定义一个 Add() 方法,传入和返回都是点的结构,可以方便地实现多个点连续相加的效果,例如P4 := P1.Add( P2 ).Add( P3 )
  • 第 23 和 24 行,初始化两个点 p1 和 p2。
  • 第 27 行,将 p1 和 p2 相加后返回结果。
  • 第 30 行,打印结果。


由于例子中使用了非指针接收器,Add() 方法变得类似于只读的方法,Add() 方法内部不会对成员进行任何修改。

3) 指针和非指针接收器的使用

在计算机中,小对象由于值复制时的速度较快,所以适合使用非指针接收器,大对象因为复制性能较低,适合使用指针接收器,在接收器和参数间传递时不进行复制,只是传递指针。

示例:二维矢量模拟玩家移动

在游戏中,一般使用二维矢量保存玩家的位置,使用矢量运算可以计算出玩家移动的位置,本例子中,首先实现二维矢量对象,接着构造玩家对象,最后使用矢量对象和玩家对象共同模拟玩家移动的过程。

1) 实现二维矢量结构

矢量是数学中的概念,二维矢量拥有两个方向的信息,同时可以进行加、减、乘(缩放)、距离、单位化等计算,在计算机中,使用拥有 X 和 Y 两个分量的 Vec2 结构体实现数学中二维向量的概念,详细实现请参考下面的代码。

 
  1. package main
  2. import "math"
  3. type Vec2 struct {
  4. X, Y float32
  5. }
  6. // 加
  7. func (v Vec2) Add(other Vec2) Vec2 {
  8. return Vec2{
  9. v.X + other.X,
  10. v.Y + other.Y,
  11. }
  12. }
  13. // 减
  14. func (v Vec2) Sub(other Vec2) Vec2 {
  15. return Vec2{
  16. v.X - other.X,
  17. v.Y - other.Y,
  18. }
  19. }
  20. // 乘
  21. func (v Vec2) Scale(s float32) Vec2 {
  22. return Vec2{v.X * s, v.Y * s}
  23. }
  24. // 距离
  25. func (v Vec2) DistanceTo(other Vec2) float32 {
  26. dx := v.X - other.X
  27. dy := v.Y - other.Y
  28. return float32(math.Sqrt(float64(dx*dx + dy*dy)))
  29. }
  30. // 插值
  31. func (v Vec2) Normalize() Vec2 {
  32. mag := v.X*v.X + v.Y*v.Y
  33. if mag > 0 {
  34. oneOverMag := 1 / float32(math.Sqrt(float64(mag)))
  35. return Vec2{v.X * oneOverMag, v.Y * oneOverMag}
  36. }
  37. return Vec2{0, 0}
  38. }

代码说明如下:

  • 第 5 行声明了一个 Vec2 结构体,包含两个方向的单精度浮点数作为成员。
  • 第 10~16 行定义了 Vec2 的 Add() 方法,使用自身 Vec2 和通过 Add() 方法传入的 Vec2 进行相加,相加后,结果以返回值形式返回,不会修改 Vec2 的成员。
  • 第 20 行定义了 Vec2 的减法操作。
  • 第 29 行,缩放或者叫矢量乘法,是对矢量的每个分量乘上缩放比,Scale() 方法传入一个参数同时乘两个分量,表示这个缩放是一个等比缩放。
  • 第 35 行定义了计算两个矢量的距离,math.Sqrt() 是开方函数,参数是 float64,在使用时需要转换,返回值也是 float64,需要转换回 float32。
  • 第 43 行定义矢量单位化。

2) 实现玩家对象

玩家对象负责存储玩家的当前位置、目标位置和速度,使用 MoveTo() 方法为玩家设定移动的目标,使用 Update() 方法更新玩家位置,在 Update() 方法中,通过一系列的矢量计算获得玩家移动后的新位置,步骤如下。

① 使用矢量减法,将目标位置(targetPos)减去当前位置(currPos)即可计算出位于两个位置之间的新矢量,如下图所示。
 


图:计算玩家方向矢量


② 使用 Normalize() 方法将方向矢量变为模为 1 的单位化矢量,这里需要将矢量单位化后才能进行后续计算,如下图所示。
 


图:单位化方向矢量


③ 获得方向后,将单位化方向矢量根据速度进行等比缩放,速度越快,速度数值越大,乘上方向后生成的矢量就越长(模很大),如下图所示。
 


图:根据速度缩放方向


④ 将缩放后的方向添加到当前位置后形成新的位置,如下图所示。
 


图:缩放后的方向叠加位置形成新位置


下面是玩家对象的具体代码:

 
  1. package main
  2. type Player struct {
  3. currPos Vec2 // 当前位置
  4. targetPos Vec2 // 目标位置
  5. speed float32 // 移动速度
  6. }
  7. // 移动到某个点就是设置目标位置
  8. func (p *Player) MoveTo(v Vec2) {
  9. p.targetPos = v
  10. }
  11. // 获取当前的位置
  12. func (p *Player) Pos() Vec2 {
  13. return p.currPos
  14. }
  15. // 是否到达
  16. func (p *Player) IsArrived() bool {
  17. // 通过计算当前玩家位置与目标位置的距离不超过移动的步长,判断已经到达目标点
  18. return p.currPos.DistanceTo(p.targetPos) < p.speed
  19. }
  20. // 逻辑更新
  21. func (p *Player) Update() {
  22. if !p.IsArrived() {
  23. // 计算出当前位置指向目标的朝向
  24. dir := p.targetPos.Sub(p.currPos).Normalize()
  25. // 添加速度矢量生成新的位置
  26. newPos := p.currPos.Add(dir.Scale(p.speed))
  27. // 移动完成后,更新当前位置
  28. p.currPos = newPos
  29. }
  30. }
  31. // 创建新玩家
  32. func NewPlayer(speed float32) *Player {
  33. return &Player{
  34. speed: speed,
  35. }
  36. }

代码说明如下:

  • 第 3 行,结构体 Player 定义了一个玩家的基本属性和方法,结构体的 currPos 表示当前位置,speed 表示速度。
  • 第 10 行,定义玩家的移动方法,逻辑层通过这个函数告知玩家要去的目标位置,随后的移动过程由 Update() 方法负责。
  • 第 15 行,使用 Pos 方法实现玩家 currPos 的属性访问封装。
  • 第 20 行,判断玩家是否到达目标点,玩家每次移动的半径就是速度(speed),因此,如果与目标点的距离小于速度,表示已经非常靠近目标,可以视为到达目标。
  • 第 27 行,玩家移动时位置更新的主要实现。
  • 第 29 行,如果已经到达,则不必再更新。
  • 第 32 行,数学中,两矢量相减将获得指向被减矢量的新矢量,Sub() 方法返回的新矢量使用 Normalize() 方法单位化,最终返回的 dir 矢量就是移动方向。
  • 第 35 行,在当前的位置上叠加根据速度缩放的方向计算出新的位置 newPos。
  • 第 38 行,将新位置更新到 currPos,为下一次移动做准备。
  • 第 44 行,玩家的构造函数,创建一个玩家实例需要传入一个速度值。

3) 处理移动逻辑

将 Player 实例化后,设定玩家移动的最终目标点,之后开始进行移动的过程,这是一个不断更新位置的循环过程,每次检测玩家是否靠近目标点附近,如果还没有到达,则不断地更新位置,让玩家朝着目标点不停的修改当前位置,如下代码所示:

 
  1. package main
  2. import "fmt"
  3. func main() {
  4. // 实例化玩家对象,并设速度为0.5
  5. p := NewPlayer(0.5)
  6. // 让玩家移动到3,1点
  7. p.MoveTo(Vec2{3, 1})
  8. // 如果没有到达就一直循环
  9. for !p.IsArrived() {
  10. // 更新玩家位置
  11. p.Update()
  12. // 打印每次移动后的玩家位置
  13. fmt.Println(p.Pos())
  14. }
  15. }

代码说明如下:

  • 第 8 行,使用 NewPlayer() 函数构造一个 *Player 玩家对象,并设移动速度为 0.5,速度本身是一种相对的和抽象的概念,在这里没有单位,可以根据实际效果进行调整,达到合适的范围即可。
  • 第 11 行,设定玩家移动的最终目标为 X 为 3,Y 为 1。
  • 第 14 行,构造一个循环,条件是没有到达时一直循环。
  • 第 17 行,不停地更新玩家位置,如果玩家到达目标,p.IsArrived 将会变为 true。
  • 第 20 行,打印每次更新后玩家的位置。


本例中使用到了结构体的方法、构造函数、指针和非指针类型方法接收器等,读者通过这个例子可以了解在哪些地方能够使用结构体。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值