new&
  • 字段拥有自己的类型和值。
  • 字段名必须唯一。
  • 字段的类型也可以是结构体,甚至是字段所在结构体的类型。

关于 Go 语言的类(class)

Go 语言中没有“类”的概念,也不支持“类”的继承等面向对象的概念。

Go 语言的结构体与“类”都是复合结构体,但 Go 语言中结构体的内嵌配合接口比面向对象具有更高的扩展性和灵活性。

Go 语言不仅认为结构体能拥有方法,且每种自定义类型也可以拥有自己的方法。

Go语言结构体定义

Go语言的关键字 type 可以将各种基本类型定义为自定义类型,基本类型包括整型、字符串、布尔等。结构体是一种复合的基本类型,通过 type 定义为自定义类型后,使结构体更便于使用。

结构体的定义格式如下:

type 类型名 struct {
    字段1 字段1类型
    字段2 字段2类型
    …
}

对各个部分的说明:

  • 类型名:标识自定义结构体的名称,在同一个包内不能重复。
  • struct{}:表示结构体类型,type 类型名 struct{} 可以理解为将 struct{} 结构体定义为类型名的类型。
  • 字段1、字段2……:表示结构体字段名。结构体中的字段名必须唯一。
  • 字段1类型、字段2类型……:表示结构体字段的类型。


使用结构体可以表示一个包含 X 和 Y 整型分量的点结构,代码如下:

同类型的变量也可以写在一行。颜色的红、绿、蓝 3 个分量可以使用 byte 类型表示,定义的颜色结构体如下:


结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存,我们将在下节详细讲解。

Go语言实例化结构体

结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存。因此必须在定义结构体并实例化后才能使用结构体的字段。

实例化就是根据结构体定义的格式创建一份与格式一致的内存区域,结构体实例与实例间的内存是完全独立的。

Go 语言可以通过多种方式实例化结构体,根据实际需要可以选用不同的写法。

基本的实例化形式

结构体本身是一种类型,可以像整型、字符串等类型一样,以 var 的方式声明结构体即可完成实例化。

基本实例化格式如下:

var ins T

其中,T 为结构体类型,ins 为结构体的实例。

用结构体表示的点结构(Point)的实例化过程请参见下面的代码:

.

创建指针类型的结构体

Go 语言中,还可以使用 new 关键字对类型(包括结构体、整型、浮点数、字符串等)进行实例化,结构体在实例化后会形成指针类型的结构体。

使用 new 的格式如下:

ins := new(T)

其中:

  • T 为类型,可以是结构体、整型、字符串等。
  • ins:T 类型被实例化后保存到 ins 变量中,ins 的类型为 *T,属于指针。
.

经过 new 实例化的结构体实例在成员赋值上与基本实例化的写法一致。

取结构体的地址实例化

&

ins := &T{}

其中:

  • T 表示结构体类型。
  • ins 为结构体的实例,类型为 *T,是指针类型。


下面使用结构体定义一个命令行指令(Command),指令中包含名称、变量关联和注释等。对 Command 进行指针地址的实例化,并完成赋值过程,代码如下:

代码说明如下:

  • 第 1 行,定义 Command 结构体,表示命令行指令
  • 第 3 行,命令绑定的变量,使用整型指针绑定一个指针。指令的值可以与绑定的值随时保持同步。
  • 第 7 行,命令绑定的目标整型变量:版本号。
  • 第 9 行,对结构体取地址实例化。
  • 第 10~12 行,初始化成员字段。


取地址实例化是最广泛的一种结构体实例化方式。可以使用函数封装上面的初始化过程,代码如下:

初始化结构体的成员变量

结构体在实例化时可以直接对成员变量进行初始化。初始化有两种形式:一种是字段“键值对”形式及多个值的列表形式。键值对形式的初始化适合选择性填充字段较多的结构体;多个值的列表形式适合填充字段较少的结构体。

使用“键值对”初始化结构体

结构体可以使用“键值对”(Key value pair)初始化字段,每个“键”(Key)对应结构体中的一个字段。键的“值”(Value)对应字段需要初始化的值。

键值对的填充是可选的,不需要初始化的字段可以不填入初始化列表中。

结构体实例化后字段的默认值是字段类型的默认值,例如:数值为 0,字符串为空字符串,布尔为 false,指针为 nil 等。

1) 键值对初始化结构体的书写格式

键值对初始化的格式如下:

ins := 结构体类型名{
    字段1: 字段1的值,
    字段2: 字段2的值,
    …
}

下面是对各个部分的说明:

  • 结构体类型:定义结构体时的类型名称。
  • 字段1、字段2:结构体的成员字段名。结构体类型名的字段初始化列表中,字段名只能出现一次。
  • 字段1的值、字段2的值:结构体成员字段的初始值。
:,

2) 使用键值对填充结构体的例子

下面例子中描述了家里的人物关联。正如儿歌里唱的:“爸爸的爸爸是爷爷”,人物之间可以使用多级的 child 来描述和建立关联。使用键值对形式填充结构体的代码如下:

代码说明如下:

  • 第 1 行,定义 People 结构体。
  • 第 2 行,结构体的字符串字段。
  • 第 3 行,结构体的结构体指针字段,类型是 *People。
  • 第 6 行,relation 由 People 类型取地址后,形成类型为 *People 的实例。
  • 第 8 行,child 在初始化时,需要 *People 类型的值。使用取地址初始化一个 People。


结构体成员中只能包含结构体的指针类型,包含非指针类型会引起编译错误。

使用多个值的列表初始化结构体

Go语言可以在“键值对”初始化的基础上忽略“键”。也就是说,可以使用多个值的列表初始化结构体的字段。

1) 多个值列表初始化结构体的书写格式

多个值使用逗号分隔初始化结构体,例如:

ins := 结构体类型名{
    字段1的值,
    字段2的值,
    …
}

使用这种格式初始化时,需要注意:

  • 必须初始化结构体的所有字段。
  • 每一个初始值的填充顺序必须与字段在结构体中的声明顺序一致。
  • 键值对与值列表的初始化形式不能混用。

2) 多个值列表初始化结构体的例子

下面的例子描述了一段地址结构。地址要求具有一定的顺序。例如:

运行代码,输出如下:
{四川 成都 610000 0}

初始化匿名结构体

匿名结构体没有类型名称,无须通过type关键字定义就可以直接使用。

1) 匿名结构体定义格式和初始化写法

匿名结构体的初始化写法由结构体定义和键值对初始化两部分组成。结构体定义时没有结构体类型名,只有字段和类型定义。键值对初始化部分由可选的多个键值对组成,如下格式所示:

ins := struct {
    // 匿名结构体字段定义
    字段1 字段类型1
    字段2 字段类型2
    …
}{
    // 字段值初始化
    初始化字段1: 字段1的值,
    初始化字段2: 字段2的值,
    …
}

下面是对各个部分的说明:

  • 字段1、字段2……:结构体定义的字段名。
  • 初始化字段1、初始化字段2……:结构体初始化时的字段名,可选择性地对字段初始化。
  • 字段类型1、字段类型2……:结构体定义字段的类型。
  • 字段1的值、字段2的值……:结构体初始化字段的初始值。


键值对初始化部分是可选的,不初始化成员时,匿名结构体的格式变为:

ins := struct {
    字段1字段类型1
    字段2字段类型2
    …
}

2) 使用匿名结构体的例子

在本例中,使用匿名结构体的方式定义和初始化一个消息结构,这个消息结构具有消息标示部分(ID)和数据部分(data)。打印消息内容的 printMsg() 函数在接收匿名结构体时需要在参数上重新定义匿名结构体,代码如下:

代码输出如下:
*struct { id int; data string }

代码说明如下:

%T


匿名结构体的类型名是结构体包含字段成员的详细描述。匿名结构体在使用时需要重新定义,造成大量重复的代码,因此开发中较少使用。

Go语言构造函数

Go 语言的类型或结构体没有构造函数的功能。结构体的初始化过程可以使用函数封装实现。

其他编程语言构造函数的一些常见功能及特性如下:

  • 每个类可以添加构造函数,多个构造函数使用函数重载实现。
  • 构造函数一般与类名同名,且没有返回值。
  • 构造函数有一个静态构造函数,一般用这个特性来调用父类的构造函数。
  • 对于 C++ 来说,还有默认构造函数、拷贝构造函数等。

多种方式创建和初始化结构体——模拟构造函数重载

如果使用结构体描述猫的特性,那么根据猫的颜色和名字可以有不同种类的猫。那么不同的颜色和名字就是结构体的字段,同时可以使用颜色和名字构造不同种类的猫的实例,这个过程可以参考下面的代码:

代码说明如下:

  • 第 1 行定义 Cat 结构,包含颜色和名字字段。
  • 第 6 行定义用名字构造猫结构的函数,返回 Cat 指针。
  • 第 7 行取地址实例化猫的结构体。
  • 第 8 行初始化猫的名字字段,忽略颜色字段。
  • 第 12 行定义用颜色构造猫结构的函数,返回 Cat 指针。


在这个例子中,颜色和名字两个属性的类型都是字符串。由于 Go 语言中没有函数重载,为了避免函数名字冲突,使用 NewCatByName() 和 NewCatByColor() 两个不同的函数名表示不同的 Cat 构造过程。

带有父子关系的结构体的构造和初始化——模拟父级构造调用

黑猫是一种猫,猫是黑猫的一种泛称。同时描述这两种概念时,就是派生,黑猫派生自猫的种类。使用结构体描述猫和黑猫的关系时,将猫(Cat)的结构体嵌入到黑猫(BlackCat)中,表示黑猫拥有猫的特性,然后再使用两个不同的构造函数分别构造出黑猫和猫两个结构体实例,参考下面的代码:

代码说明如下:

  • 第 6 行,定义 BlackCat 结构,并嵌入了 Cat 结构体。BlackCat 拥有 Cat 的所有成员,实例化后可以自由访问 Cat 的所有成员。
  • 第 11 行,NewCat() 函数定义了 Cat 的构造过程,使用名字作为参数,填充 Cat 结构体。
  • 第 18 行,NewBlackCat() 使用 color 作为参数,构造返回 BlackCat 指针。
  • 第 19 行,实例化 BlackCat 结构,此时 Cat 也同时被实例化。
  • 第 20 行,填充 BlackCat 中嵌入的 Cat 颜色属性。BlackCat 没有任何成员,所有的成员都来自于 Cat。


这个例子中,Cat 结构体类似于面向对象中的“基类”。BlackCat 嵌入 Cat 结构体,类似于面向对象中的“派生”。实例化时,BlackCat 中的 Cat 也会一并被实例化。

总之,Go 语言中没有提供构造函数相关的特殊机制,用户根据自己的需求,将参数使用函数传递到结构体构造参数中即可完成构造函数的任务。

Go语言方法和接收器

Go 语言中的方法(Method)是一种作用于特定类型变量的函数。这种特定类型变量叫做接收器(Receiver)。

如果将特定类型理解为结构体或“类”时,接收器的概念就类似于其他语言中的 this 或者 self。

在 Go 语言中,接收器的类型可以是任何类型,不仅仅是结构体,任何类型都可以拥有方法。

提示

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

为结构体添加方法

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

1) 面向过程实现方法

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

代码说明如下:

  • 第 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 创建一个方法,代码如下:

第 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() 方法的调用,可以达成修改属性值的效果。

100

代码说明如下:

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

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

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

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

代码输出如下:
{3 3}

代码说明如下:

P4 := P1.Add( P2 ).Add( P3 )


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

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

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

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

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

1) 实现二维矢量结构

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

代码说明如下:

  • 第 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 的单位化矢量。这里需要将矢量单位化后才能进行后续计算,如下图所示。


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


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


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

代码说明如下:

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

3) 处理移动逻辑

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

代码说明如下:

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


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

为任意类型添加方法

Go 语言可以对任何类型添加方法。给一种类型添加方法就像给结构体添加方法一样,因为结构体也是一种类型。

为基本类型添加方法

在 Go 语言中,使用 type 关键字可以定义出新的自定义类型。之后就可以为自定义类型添加各种方法。我们习惯于使用面向过程的方式判断一个值是否为 0,例如:

如果将 v 当做整型对象,那么判断 v 值就可以增加一个 IsZero() 方法,通过这个方法就可以判断 v 值是否为 0,例如:

为基本类型添加方法的详细实现流程如下:

代码输出如下:
true
3

代码说明如下:

  • 第 8 行,使用 type MyInt int 将 int 定义为自定义的 MyInt 类型。
  • 第 11 行,为 MyInt 类型添加 IsZero() 方法。该方法使用了 (m MyInt) 的非指针接收器。数值类型没有必要使用指针接收器。
  • 第 16 行,为 MyInt 类型添加 Add() 方法。
  • 第 17 行,由于 m 的类型是 MyInt 类型,但其本身是 int 类型,因此可以将 m 从 MyInt 类型转换为 int 类型再进行计算。
  • 第 24 行,调用 b 的 IsZero() 方法。由于使用非指针接收器,b的值会被复制进入 IsZero() 方法进行判断。
  • 第 28 行,调用 b 的 Add() 方法。同样也是非指针接收器,结果直接通过 Add() 方法返回。

http包中的类型方法

Go 语言提供的 http 包里也大量使用了类型方法。Go 语言使用 http 包进行 HTTP 的请求,使用 http 包的 NewRequest() 方法可以创建一个 HTTP 请求,填充请求中的 http 头(req.Header),再调用 http.Client 的 Do 包方法,将传入的 HTTP 请求发送出去。

下面代码演示创建一个 HTTP 请求,并且设定 HTTP 头。

代码执行结果如下:

<html>
<head><title>405 Not Allowed</title></head>
<body bgcolor="white">
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx</center>
</body>
</html>

代码说明如下:

  • 第 11 行,实例化 HTTP 的客户端,请求需要通过这个客户端实例发送。
  • 第 14 行,使用 POST 方式向网易的服务器创建一个 HTTP 请求,第三个参数为 HTTP 的 Body 部分。Body 部分的内容来自字符串,但参数只能接受 io.Reader 类型,因此使用 strings.NewReader() 创建一个字符串的读取器,返回的 io.Reader 接口作为 http 的 Body 部分供 NewRequest() 函数读取。创建请求只是构造一个请求对象,不会连接网络。
  • 第 24 行,为创建好的 HTTP 请求的头部添加 User-Agent,作用是表明用户的代理特性。
  • 第 27 行,使用客户端处理请求,此时 client 将 HTTP 请求发送到网易服务器。服务器响应请求后,将信息返回并保存到 resp 变量中。
  • 第 37 行,读取响应的 Body 部分并打印。


由于我们构造的请求不是网易服务器所支持的类型,所以服务器返回操作不被运行的 405 号错误。

在本例子第 24 行中使用的 req.Header 的类型为 http.Header,就是典型的自定义类型,并且拥有自己的方法。http.Header 的部分定义如下:

代码说明如下:

  • 第 1 行,Header 实际是一个以字符串为键、字符串切片为值的映射。
  • 第 3 行,Add() 为 Header 的方法,map 是一个引用类型,因此即便使用 (h Header) 的非指针接收器,也可以修改 map 的值。


为类型添加方法的过程是一个语言层特性,使用类型方法的代码经过编译器编译后的代码运行效率与传统的面向过程或面向对象的代码没有任何区别。因此,为了代码便于理解,可以在编码时使用 Go 语言的类型方法特性。

time包中的类型方法

Go 语言提供的 time 包主要用于时间的获取和计算等。在这个包中,也使用了类型方法,例如:

第9行的time.Second是一个常量,下面代码的加粗部分就是time.Second的定义:

Second 的类型为 Duration,而 Duration 实际是一个 int64 的类型,定义如下:

type Duration int64

它拥有一个 String 的方法,部分定义如下:

Duration.String 可以将 Duration 的值转为字符串。

示例:使用事件系统实现事件的响应和处理

Go 语言可以将类型的方法与普通函数视为一个概念,从而简化方法和函数混合作为回调类型时的复杂性。这个特性和 C# 中的代理(delegate)类似,调用者无须关心谁来支持调用,系统会自动处理是否调用普通函数或类型的方法。

本节中,首先将用简单的例子了解 Go 语言是如何将方法与函数视为一个概念,接着会实现一个事件系统,事件系统能有效地将事件触发与响应两端代码解耦。

方法和函数的统一调用

本节的例子将让一个结构体的方法(class.Do)的参数和一个普通函数(funcDo)的参数完全一致,也就是方法与函数的签名一致。然后使用与它们签名一致的函数变量(delegate)分别赋值方法与函数,接着调用它们,观察实际效果。

详细实现请参考下面的代码。

代码说明如下:

  • 第 10 行,为结构体添加一个 Do() 方法,参数为整型。这个方法的功能是打印提示和输入的参数值。
  • 第 16 行,声明一个普通函数,参数也是整型,功能是打印提示和输入的参数值。
  • 第 24 行,声明一个 delegate 的变量,类型为 func(int),与 funcDo 和 class 的 Do() 方法的参数一致。
  • 第 30 行,将 c.Do 作为值赋给 delegate 变量。
  • 第 33 行,调用 delegate() 函数,传入 100 的参数。此时会调用 c 实例的 Do() 方法。
  • 第 36 行,将 funcDo 赋值给 delegate。
  • 第 39 行,调用 delegate(),传入 100 的参数。此时会调用 funcDo() 方法。


运行代码,输出如下:
call method do: 100
call function do: 100

这段代码能运行的基础在于:无论是普通函数还是结构体的方法,只要它们的签名一致,与它们签名一致的函数变量就可以保存普通函数或是结构体方法。

了解了 Go 语言的这一特性后,我们就可以将这个特性用在事件中。

事件系统基本原理

事件系统可以将事件派发者与事件处理者解耦。例如,网络底层可以生成各种事件,在网络连接上后,网络底层只需将事件派发出去,而不需要关心到底哪些代码来响应连接上的逻辑。或者再比如,你注册、关注或者订阅某“大V”的社交消息后,“大V”发生的任何事件都会通知你,但他并不用了解粉丝们是如何为她喝彩或者疯狂的。如下图所示为事件系统基本原理图。


一个事件系统拥有如下特性:

  • 能够实现事件的一方,可以根据事件ID或名字注册对应的事件。
  • 事件发起者,会根据注册信息通知这些注册者。
  • 一个事件可以有多个实现方响应。


通过下面的步骤详细了解事件系统的构成及使用。

事件注册

事件系统需要为外部提供一个注册入口。这个注册入口传入注册的事件名称和对应事件名称的响应函数,事件注册的过程就是将事件名称和响应函数关联并保存起来,详细实现请参考下面代码的 RegisterEvent() 函数。

代码说明如下:

  • 第 4 行,创建一个 map 实例,这个 map 通过事件名(string)关联回调列表([]func(interface{}),同一个事件名称可能存在多个事件回调,因此使用回调列表保存。回调的函数声明为 func(interface{})。
  • 第 7 行,提供给外部的通过事件名注册响应函数的入口。
  • 第 10 行,eventByName 通过事件名(name)进行查询,返回回调列表([]func(interface{})。
  • 第 13 行,为同一个事件名称在已经注册的事件回调的列表中再添加一个回调函数。
  • 第 16 行,将修改后的函数列表设置到 map 的对应事件名中。


拥有事件名和事件回调函数列表的关联关系后,就需要开始准备事件调用的入口了。

事件调用

事件调用方和注册方是事件处理中完全不同的两个角色。事件调用方是事发现场,负责将事件和事件发生的参数通过事件系统派发出去,而不关心事件到底由谁处理;事件注册方通过事件系统注册应该响应哪些事件及如何使用回调函数处理这些事件。事件调用的详细实现请参考上面代码的 CallEvent() 函数。

代码说明如下:

  • 第 20 行,调用事件的入口,提供事件名称 name 和参数 param。事件的参数表示描述事件具体的细节,例如门打开的事件触发时,参数可以传入谁进来了。
  • 第 23 行,通过注册事件回调的 eventByName 和事件名字查询处理函数列表 list。
  • 第 26 行,遍历这个事件列表,如果没有找到对应的事件,list 将是一个空切片。
  • 第 29 行,将每个函数回调传入事件参数并调用,就会触发事件实现方的逻辑处理。

使用事件系统

例子中,在 main() 函数中调用事件系统的 CallEvent 生成 OnSkill 事件,这个事件有两个处理函数,一个是角色的 OnEvent() 方法,还有一个是函数 GlobalEvent(),详细代码实现过程请参考下面的代码。

代码说明如下:

  • 第 6 行,声明一个角色的结构体。在游戏中,角色是常见的对象,本例中,角色也是 OnSkill 事件的响应处理方。
  • 第 10 行,为角色结构添加一个 OnEvent() 方法,这个方法拥有 param 参数,类型为 interface{},与事件系统的函数(func(interface{}))签名一致。
  • 第 16 行为全局事件响应函数。有时需要全局进行侦听或者处理一些事件,这里使用普通函数实现全局事件的处理。
  • 第 27 行,注册一个 OnSkill 事件,实现代码由 a 的 OnEvent 进行处理。也就是 Actor的OnEvent() 方法。
  • 第 30 行,注册一个 OnSkill 事件,实现代码由 GlobalEvent 进行处理,虽然注册的是同一个名字的事件,但前面注册的事件不会被覆盖,而是被添加到事件系统中,关联 OnSkill 事件的函数列表中。
  • 第 33 行,模拟处理事件,通过 CallEvent() 函数传入两个参数,第一个为事件名,第二个为处理函数的参数。


整个例子运行结果如下:
actor event: 100
global event: 100

结果演示,角色和全局的事件会按注册顺序顺序地触发。

一般来说,事件系统不保证同一个事件实现方多个函数列表中的调用顺序,事件系统认为所有实现函数都是平等的。也就是说,无论例子中的 a.OnEvent 先注册,还是 GlobalEvent() 函数先注册,最终谁先被调用,都是无所谓的,开发者不应该去关注和要求保证调用的顺序。

一个完善的事件系统还会提供移除单个和所有事件的方法。

类型内嵌和结构体内嵌

结构体允许其成员字段在声明时没有字段名而只有类型,这种形式的字段被称为类型内嵌或匿名字段类型内嵌的写法如下:

代码说明如下:

  • 第 2~4 行定义结构体中的匿名字段,类型分别是整型、浮点、布尔。
  • 第 8~10 行将实例化的 Data 中的字段赋初值。


类型内嵌其实仍然拥有自己的字段名,只是字段名就是其类型本身而已,结构体要求字段名称必须唯一,因此一个结构体中同种类型的匿名字段只能有一个。

结构体实例化后,如果匿名的字段类型为结构体,那么可以直接访问匿名结构体里的所有成员,这种方式被称为结构体内嵌。

声明结构体内嵌

结构体类型内嵌比普通类型内嵌的概念复杂一些,下面通过一个实例来理解。
计算机图形学中的颜色有两种类型,一种是包含红、绿、蓝三原色的基础颜色;另一种是在基础颜色之外增加透明度的颜色。透明度在颜色中叫 Alpha,范围为 0~1 之间。0 表示完全透明,1 表示不透明。使用传统的结构体字段的方法定义基础颜色和带有透明度颜色的过程代码如下:

代码输出如下:
{Basic:{R:1 G:1 B:0} Alpha:1}

对代码的说明:

  • 第 8 行定义基础颜色结构,包含 3 个颜色分量,分别是红、绿、蓝,范围为 0~1。
  • 第 14 行定义了完整颜色结构,包含有基础颜色和透明度。
  • 第 25 行,实例化一个完整颜色结构。
  • 第 28~30 行访问基础颜色并赋值。


第 28~30 行的代码需要通过 Basic 结构才能设置 R、G、B 分量,虽然合理但是写法很复杂。使用 Go 语言的结构体内嵌写法重新调整代码如下:

代码加粗部分是经过调整及修改的代码。代码第 12 行中,将 BasicColor 结构体嵌入到 Color 结构体中,BasicColor 没有字段名而只有类型,这种写法就叫做结构体内嵌。

第 19~21 行中,可以直接对 Color 的 R、G、B 成员进行设置,编译器通过 Color 的定义知道 R、G、B 成员来自 BasicColor 内嵌的结构体。

结构内嵌特性

Go语言的结构体内嵌有如下特性。

1) 内嵌的结构体可以直接访问其成员变量

嵌入结构体的成员,可以通过外部结构体的实例直接访问。如果结构体有多层嵌入结构体,结构体实例访问任意一级的嵌入结构体成员时都只用给出字段名,而无须像传统结构体字段一样,通过一层层的结构体字段访问到最终的字段。例如,ins.a.b.c的访问可以简化为ins.c。

2) 内嵌结构体的字段名是它的类型名

内嵌结构体字段仍然可以使用详细的字段进行一层层访问,内嵌结构体的字段名就是它的类型名,代码如下:

一个结构体只能嵌入一个同类型的成员,无须担心结构体重名和错误赋值的情况,编译器在发现可能的赋值歧义时会报错。

 

结构体内嵌模拟类的继承

在面向对象思想中,实现对象关系需要使用“继承”特性。例如,人类不能飞行,鸟类可以飞行。人类和鸟类都可以继承自可行走类,但只有鸟类继承自飞行类。

面向对象的设计原则中也建议对象最好不要使用多重继承,有些面向对象语言从语言层面就禁止了多重继承,如 C# 和 Java 语言。鸟类同时继承自可行走类和飞行类,这显然是存在问题的。在面向对象思想中要正确地实现对象的多重特性,只能使用一些精巧的设计来补救。

Go 语言的结构体内嵌特性就是一种组合特性,使用组合特性可以快速构建对象的不同特性。

下面的代码使用 Go 语言的结构体内嵌实现对象特性组合,请参考下面的代码。

人和鸟的特性:

代码说明如下:

  • 第 6 行,声明可飞行结构(Flying)。
  • 第 8 行,为可飞行结构添加飞行方法 Fly()。
  • 第 13 行,声明可行走结构(Walkable)。
  • 第 15 行,为可行走结构添加行走方法 Walk()。
  • 第 20 行,声明人类结构。这个结构嵌入可行走结构(Walkable),让人类具备“可行走”特性
  • 第 25 行,声明鸟类结构。这个结构嵌入可行走结构(Walkable)和可飞行结构(Flying),让鸟类具备既可行走又可飞行的特性。
  • 第 33 行,实例化鸟类结构。
  • 第 35 和 36 行,调用鸟类可以使用的功能,如飞行和行走。
  • 第 39 行,实例化人类结构。
  • 第 41 行,调用人类能使用的功能,如行走。


运行代码,输出如下:
Bird:
can fly
can calk
Human:
can calk

使用 Go 语言的内嵌结构体实现对象特性,可以自由地在对象中增、删、改各种特性。Go 语言会在编译时检查能否使用这些特性。

初始化内嵌结构体

结构体内嵌初始化时,将结构体内嵌的类型作为字段名像普通结构体一样进行初始化,详细实现过程请参考下面的代码。

车辆结构的组装和初始化:

代码说明如下:

  • 第 6 行定义车轮结构。
  • 第 11 行定义引擎结构。
  • 第 17 行定义车结构,由车轮和引擎结构体嵌入。
  • 第 27 行,将 Car 的 Wheel 字段使用 Wheel 结构体进行初始化。
  • 第 32 行,将 Car 的 Engine 字段使用 Engine 结构体进行初始化。

初始化内嵌匿名结构体

在前面描述车辆和引擎的例子中,有时考虑编写代码的便利性,会将结构体直接定义在嵌入的结构体中。也就是说,结构体的定义不会被外部引用到。在初始化这个被嵌入的结构体时,就需要再次声明结构才能赋予数据。具体请参考下面的代码。

代码说明如下:

    • 第 14 行中原来的 Engine 结构体被直接定义在 Car 的结构体中。这种嵌入的写法就是将原来的结构体类型转换为 struct{…}。
    • 第 30 行,需要对 Car 的 Engine 字段进行初始化,由于 Engine 字段的类型并没有被单独定义,因此在初始化其字段时需要先填写 struct{…} 声明其类型。
    • 第 3行开始填充这个匿名结构体的数据,按“键:值”格式填充。

内嵌结构体成员名字冲突

嵌入结构体内部可能拥有相同的成员名,成员重名时会发生什么?下面通过例子来讲解。

代码说明如下:

  • 第 7 行和第 11 行分别定义了两个拥有 a int 字段的结构体。
  • 第 15 行的结构体嵌入了 A 和 B 的结构体。
  • 第 21 行实例化 C 结构体。
  • 第 22 行按常规的方法,访问嵌入结构体 A 中的 a 字段,并赋值 1。
  • 第 23 行可以正常输出实例化 C 结构体。


接着,将第22行修改为如下代码:

此时再编译运行,编译器报错:

.main.go:22:3: ambiguous selector c.a

编译器告知 C 的选择器 a 引起歧义,也就是说,编译器无法决定将 1 赋给 C 中的 A 还是 B 里的字段 a。

在使用内嵌结构体时,Go 语言的编译器会非常智能地提醒我们可能发生的歧义和错误。

使用匿名结构体解析JSON数据

手机拥有屏幕、电池、指纹识别等信息,将这些信息填充为 JSON 格式的数据。如果需要选择性地分离 JSON 中的数据则较为麻烦。Go 语言中的匿名结构体可以方便地完成这个操作。

首先给出完整的代码,然后再讲解每个部分。

定义数据结构

首先,定义手机的各种数据结构体,如屏幕和电池,参考如下代码:

上面代码定义了屏幕结构体和电池结构体,它们分别描述屏幕和电池的各种细节参数。

准备JSON数据

准备手机数据结构,填充数据,将数据序列化为 JSON 格式的字节数组,代码如下:

代码说明如下:

  • 第 4 行定义了一个匿名结构体。这个结构体内嵌了 Screen 和 Battery 结构体,同时临时加入了 HasTouchID 字段。
  • 第 10 行,为刚声明的匿名结构体填充屏幕数据。
  • 第 17 行,填充电池数据。
  • 第 22 行,填充指纹识别字段。
  • 第 26 行,使用 json.Marshal 进行 JSON 序列化,将 raw 变量序列化为 []byte 格式的 JSON 数据。

分离JSON数据

调用 genJsonData 获得 JSON 数据,将需要的字段填充到匿名结构体实例中,通过 json.Unmarshal 反序列化 JSON 数据达成分离 JSON 数据效果。代码如下:

代码说明如下:

    • 第 4 行,调用 genJsonData() 函数,获得 []byte 类型的 JSON 数据。
    • 第 6 行,将 jsonData 的 []byte 类型的 JSON 数据转换为字符串格式并打印输出。
    • 第 9 行,构造匿名结构体,填充 Screen 结构和 HasTouchID 字段,第 12 行中的 {} 表示将结构体实例化。
    • 第 15 行,调用 json.Unmarshal,输入完整的 JSON 数据(jsonData),将数据按第 9 行定义的结构体格式序列化到 screenAndTouch 中。
    • 第 18 行,打印输出 screenAndTouch 中的详细数据信息。
    • 第 21 行,构造匿名结构体,填充 Battery 结构和 HasTouchID 字段。
    • 第 27 行,调用 json.Unmarshal,输入完整的 JSON 数据(jsonData),将数据按第 21 行定义的结构体格式序列化到 batteryAndTouch 中。
    • 第 30 行,打印输出 batteryAndTouch 的详细数据信息。