在上一讲中,我们已经学习了Go语言中结构体、匿名结构体的定义和使用。并以创建游戏角色为例,演示了在真实开发环境中的应用。不过,关于结构体的故事还没有完结。

请大家思考这样一个场景:在真实世界中,存在不同品种、不同毛色、不同体重、不同年龄以及其它不同特征的狗。每一只狗又能独自完成动作,比如吃饭、大叫、睡觉、奔跑等等。在大多数面向对象编程语言中,通常会使用“构造函数”来描述每一只狗的特征,使用“方法”来让某一只狗完成特定的动作。 在Go语言中,则使用结构体来实现构造函数和方法

从本质上说,Go语言的类型或结构体本身是没有构造函数功能的,但是我们可以使用结构体初始化的过程来模拟实现构造函数。从这一点上可以看出Go语言中的结构体使用起来是非常灵活、好用的。

接下来,本讲将以“狗的特征和动作”为例,向大家讲述如何使用结构体来实现:

  • 面向对象语言中的构造函数;
  • 面向对象语言中的方法。

# 构建独一无二的对象——构造函数

显然,标题中所说的“对象”,就是本例中的小狗了。依照惯例,我依然尽量简化代码,让各位更好地理解语言本身。

本例使用品种(string)、年龄(int)、体重(float)、性别(int)这四个特征来创建“狗”对象,根据我们现有的知识,若要使用结构体来描述,其代码实现类似如下:

go
复制代码type Dog struct {
   Breed  string
   Age    int
   Weight float64
   Gender string
}

既然说,构造函数是通过使用结构体初始化的过程来模拟实现的,我们不妨编写一个函数,将狗的四个特征作为参数传入其中,并最终返回Dog类型的变量。如此一来,一旦这个函数被调用后,便会生成一个独一无二的Dog类型变量。代码示例如下:

go
复制代码func NewDog(breed string, age int, weight float64, gender string) *Dog {
   return &Dog{
      Breed:  breed,
      Age:    age,
      Weight: weight,
      Gender: gender,
   }
}

类似NewDog()这样的函数,其作用与面向对象语言中的构造函数类似。如此,我们便使用结构体实现了Go语言中并未原生支持的构造函数。

构造函数在使用时非常简单,我们只需声明一个变量,并通过调用NewDog()函数为其赋值即可。比如下面这段完整的代码,就创建了两只胖瘦不同柴犬:

go
复制代码type Dog struct {
   Breed  string
   Age    int
   Weight float64
   Gender string
}

func NewDog(breed string, age int, weight float64, gender string) *Dog {
   return &Dog{
      Breed:  breed,
      Age:    age,
      Weight: weight,
      Gender: gender,
   }
}

func main() {
   fatShibaInu := NewDog("Shiba Inu", 2, 12.0, "公")
   weakShibaInu := NewDog("Shiba Inu", 2, 7.0, "公")
   fmt.Println(fatShibaInu)
   fmt.Println(weakShibaInu)
}

运行本例,控制台将输出:

&{Shiba Inu 2 12 "公"}

&{Shiba Inu 2 7 "公"}

请大家关注代码的结构以及main()函数中的调用方式。为了讲解方便,我把结构体、相应的构造函数和main()函数放到了一起。一种更好的做法是将它们分开放到两个Go源码文件中,那样做会使代码结构更清晰、更有条理,日后维护起来就会更轻松了。

相信不少朋友可能会有疑问:使用构造函数,和直接创建结构体,似乎没有什么区别。就拿fatShibaInu变量来说吧,下面两种写法都能达到目的。

  • 使用构造函数:
go
复制代码fatShibaInu := NewDog("Shiba Inu", 2, 12.0, "公")
  • 不使用用构造函数:
go
复制代码fatShibaInu2 := &Dog{
   Breed:  "Shiba Inu",
   Age:    2,
   Weight: 12.0,
   Gender: "公",
}

乍看上去还真是一样,但我们考虑一种情况——使用多国语言。

像年龄和体重倒没关系,关键是品种和性别。就拿性别来说吧,当使用“公”来赋值时,只有懂汉语的人能看懂。所以更多时候我们使用0和1分别代表公和母,这样就屏蔽了语言不同造成的理解上的困难。

所以,我们就非常迫切地需要在赋值和取值过程中进行一个“转换”处理。在赋值时将不同语言中表达性别的文字转换成0或1,并在取值时反向转换。

因此,我们修改Dog的结构体和构造函数如下:

go
复制代码type Dog struct {
   Breed  string
   Age    int
   Weight float64
   Gender int
}

func NewDog(breed string, age int, weight float64, gender string) *Dog {
   genderValue := 0
   if gender == "公" {
      genderValue = 0
   } else if gender == "母" {
      genderValue = 1
   }
   return &Dog{
      Breed:  breed,
      Age:    age,
      Weight: weight,
      Gender: genderValue,
   }
}

如此修改后,NewDog()函数内部完成了对gender参数的转换,尽管传入的是汉字,但最终创建Dog变量时用的却是数字。main()函数中创建fatShibaInu变量无需做任何修改,依然使用汉字来表示性别即可。

反观直接创建结构体的方式,就只能传入0和1了:

go
复制代码fatShibaInu := &Dog{
   Breed:  "Shiba Inu",
   Age:    2,
   Weight: 12.0,
   Gender: 0,
}

# 让对象“动起来”

# 获取“静态指标”

经过上述一番修改,再次运行本例,控制台的输出就变为:

&{Shiba Inu 2 12 0}

&{Shiba Inu 2 7 0}

最后的“0”,表示的就是性别了。显然,这是不好理解的,我们需要再做一个“转换”,将0和1转换为汉字的“公”和“母”。为了完成这个转换,我们需要定义“方法”。

在Go语言中,方法和函数的定义格式非常像,大家可不要搞混了。由于方法和对象存在紧密的关系,因此在定义的格式上需要接收器,具体格式如下:

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

其中,接收器变量和接收器类型共同构成了接收器;参数列表是可选的;返回参数也是可选的;方法名无需多做解释。

对应到本例,我们就可以创建一个名为getGender()的方法,接收器就是Dog类型的指针,无需任何参数,返回值则是string类型的表示性别的汉字。代码示例如下:

go
复制代码func (d *Dog) GetGender() string {
   if d.Gender == 0 {
      return "公"
   } else if d.Gender == 1 {
      return "母"
   }
   return ""
}

GetGender()通过fatShibaInu变量来调用,具体如下:

go
复制代码fmt.Println(fatShibaInu.GetGender())

运行后,可以看到控制台中会输出:

# 再论接收器

为对象定义方法时,需要注意接收器的类型。使用指针与否,将决定了是否对原始变量产生影响。本例使用了*Dog,即指针类型,在方法中对该类型变量(d变量)的任何影响都将影响原始变量(fatShibaInu);反之,若使用Dog类型,则不会影响。

其原因是当不使用指针类型变量时,方法中的接收器变量实际上是对原始数据的“拷贝”,所做出的改变也仅仅会作用于这份“拷贝”的数据上,并不会影响到原始数据。

对比来说,我们分别定义两个不同的方法——GrowUp()和GrowUp2(),前者使用指针类型接收器,后者不使用。方法体均是对相应变量中的年龄属性自增1,然后在控制台输出运行结果。测试代码关键部分如下:

go
复制代码func (d *Dog) GrowUp() {
   d.Age++
}

func (d Dog) GrowUp2() {
   d.Age++
}

func main() {
   fatShibaInu := NewDog("Shiba Inu", 2, 12.0, "公")

   fatShibaInu.GrowUp()
   fmt.Println(fatShibaInu)

   fatShibaInu.GrowUp2()
   fmt.Println(fatShibaInu)
}

运行结果为:

&{Shiba Inu 3 12 0}

&{Shiba Inu 3 12 0}

显然,虽然GrowUp2()方法也对d变量中的Age属性做了自增1计算,但并未影响原始数据。

# 让对象“动起来”

到此,我们已经掌握了如何使用结构体实现构造函数和方法。接下来到了该让对象“动起来”的时候了。

之前,我们已经声明了两个Dog类型的变量——fatShibaInu和weakShibaInu。要知道一只体重正常的成年雄性柴犬大概在9公斤左右。所以fatShibaInu过重了,要多运动来减肥;weakShibaInu太轻了,要多补充营养。那么,多运动和补营养便是它们接下来的动作了。

所以,我们不妨继续实现两个方法:一个叫做Sport(),每次执行后,体重都减0.1KG;另一个叫做Eat(),每次执行后,体重增加0.1KG。同时,向控制台输出具体的动作内容。代码如下:

go
复制代码func (d *Dog) Sport() {
   fmt.Println("做运动!")
   d.Weight -= 0.1
   fmt.Println("我减重到了", d.Weight)
}

func (d *Dog) Eat() {
   fmt.Println("多吃饭!")
   d.Weight += 0.1
   fmt.Println("我增重到了", d.Weight)
}

接着,让fatShibaInu执行Sport()方法;让weakShibaInu执行Eat()方法:

go
复制代码func main() {
   fatShibaInu := NewDog("Shiba Inu", 2, 12.0, "公")
   weakShibaInu := NewDog("Shiba Inu", 2, 7.0, "公")
   fatShibaInu.Sport()
   weakShibaInu.Eat()
}

再次运行程序,可以看到控制台如下输出:

做运动!

我减重到了 11.9

多吃饭!

我增重到了 7.1

长此以往,这两只柴犬都能回到正常的体重了。要注意的是,在写代码的时候请务必让每只狗做正确的事情,不要让该运动的柴犬继续吃吃吃,也不要让该补充食物的柴犬做锻炼。

# 总结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

使用结构体实现:

  • 面向对象语言中的构造函数;
  • 面向对象语言中的方法。

本讲是结构体系列专题的第二篇,得益于Go语言设计的灵活,我们只需要结构体就能实现面向对象语言中的构造函数和方法了。

说到构造函数,其关键点要掌握如何使用结构体初始化的过程来模拟实现。同时,还要明白这样做和直接创建结构体的区别和优势。

说到方法,首先要掌握的就是定义方法的格式。其次还要明白接收器的概念以及不同类型对原始数据的影响。

➡️ 在下次课程中,我们会介绍Go语言结构体的最后一部分知识,具体内容是:

  • 使用结构体实现继承