从上一讲开始,开始了接口专题的学习,具体来说是Go语言中接口的定义和使用。本讲继续针对接口进行深入讲解,包含以下内容:

  • 空接口与泛型
  • 类型断言

什么是空接口,什么又是泛型呢?

所谓泛型,可以简单地理解为数据类型中的“万能牌”,它能存放任何类型的数据。Go语言中的空接口正是为了实现泛型所采用的手段。

# 泛型初体验

什么时候该使用泛型呢?举个例子,如果我们想要封装一个函数,该函数的作用便是实现传入参数数据的原样输出,该如何做呢?

利用我们已经掌握的知识,写出的代码可能会是这样:

go
复制代码func main() {
   dataOutput("Hello")
}

func dataOutput(data string) {
   fmt.Println(data)
}

直接运行这段程序,控制台会输出:

Hello

看似没有问题,但如果传入的参数不是string类型,而是数字型、布尔型呢?显然,程序是无法编译通过的,因为类型不匹配。

当然,我们也可以编写多个函数,来匹配不同的参数类型,比如:

go
复制代码func main() {
   stringDataOutput("Hello")
   intDataOutput(123)
}

func stringDataOutput(data string) {
   fmt.Println(data)
}

func intDataOutput(data int) {
   fmt.Println(data)
}

如此确实可以实现,但代码整体不够优雅。况且这还只是两种类型,要是更多,日后的代码维护成本就会直线飙升了。

细心的朋友会发现,尽管类型不同,但函数体内实际执行的逻辑都是相通的。那么,有没有一种办法使函数的参数不再受限呢?当然有,那就是使用泛型。

泛型是类型中的“万能牌”,使用泛型作为函数参数,实际上就相当于告诉调用者:“我能兼容任何类型的参数,尽管将数据传给我就是了。”泛型以超级宽广的胸怀接纳所有类型的数据。在Go语言中的泛型,则使用空接口来实现。 而所谓的“空接口”,使用代码表示非常简单,就是:

go
复制代码interface{}

和普通接口的定义格式不同,空接口内部无需填写任何方法。

空接口能接纳所有类型的数据,因此可以将任何类型的数据赋值给它的变量,请大家阅读下面这段代码:

go
复制代码var anyTypeValue interface{}

func main() {
   anyTypeValue = 123
   anyTypeValue = true
   anyTypeValue = "Hello"
}

这段代码完全合法,可以编译、运行。

另一方面,在函数参数中使用空接口,可以使其能接受所有类型的数据传入。 以本讲一开始的示例举例,若要编写一个函数,实现传入参数数据的原样输出,只需按如下编写代码即可:

go
复制代码func main() {
   dataOutput("Hello")
   dataOutput(123)
   dataOutput(true)
}

func dataOutput(data interface{}) {
   fmt.Println(data)
}

程序运行结果为:

Hello

123

true

如此编码,是不是比写一堆类似的函数要方便、简洁很多呢?还能节省开发和维护的时间。

💡 提示: 猜一猜,如果在dataOutput()函数中输出data变量的类型,将会如何输出呢?各位朋友不妨亲自动手一试,获取数据类型的函数是reflect.TypeOf(i interface{})。注意到了吗?这个函数所需的参数类型也是空接口类型,即泛型。

# 货车容量计算器

品尝到空接口的“甜头”之后,我们来实战空接口的使用,进一步体会使用它带来的好处。

现在,我们计划进行一次搬家,正在预估需要多大容量的货车来存放全部家当。

为了讲解方便,本例将简化各种家具家电的体积计算方式。把它们简单粗暴地分为正方体、长方体和圆柱体三种体积形式,这三种形状的物品分别对应代码中的三种结构体类型。

此外,还需实现为这三种形状的物品编写体积计算的方法。如此一来,我们便可通过调用这个体积计算的方法,将其计算结果累加在一起,便可得知需要至少多大容量的货车了。

还记得如何定义一个结构体吧?以正方体为例,计算体积仅需要知道边长就可以了。所以我们定义一个名为cube的结构体,其中包含float64类型的length变量,表示边长,具体代码如下:

go
复制代码// 正方体
type cube struct {
   // 边长
   length float64
}

接着,定义一个方法,名为cubeVolume,表示计算正方体的体积。作用于*cube类型,返回float64类型值,具体代码为:

go
复制代码// 正方体的体积计算
func (c *cube) cubeVolume() float64 {
   return c.length * c.length * c.length
}

如上,关于正方体的结构体和体积计算方法已经全部实现完成。依葫芦画瓢,继续实现长方体和圆柱体对应的结构体和体积计算方法。以下是具体的代码片段:

go
复制代码// 长方体
type cuboid struct {
   // 长
   length float64
   // 宽
   width float64
   // 高
   height float64
}

// 长方体的体积计算
func (c *cuboid) cuboidVolume() float64 {
   return c.length * c.width * c.height
}

// 圆柱体
type cylinder struct {
   // 直径
   diameter float64
   // 高度
   height float64
}

// 圆柱体的体积计算
func (c *cylinder) cylinderVolume() float64 {
   return math.Pi * (c.diameter / 2) * (c.diameter / 2) * c.height
}

接下来是本讲的另一个重点知识。如果我们想用同样一个函数来计算所有类型物体的体积,那么“认清”家具的种类就是非常必要的一环。换言之,当传入这个函数的参数是正方体,则需要调用cubeVolume()函数进行计算;当传入这个函数的参数是长方体,则需要调用cuboidVolume()函数进行计算;当传入这个函数的参数是正方体,则需要调用cylinderVolume()函数进行计算。

在Go语言中,用来判断某个数据是否属于某种类型的方法被称为“类型断言”。

类型断言的使用格式为:

go
复制代码value, ok := x.(T)

其中,x是指某个变量,T表示类型,value是将x变量转换为T类型之后的值,ok是布尔类型,表示x是否属于T类型。

看上去有些绕口,我们用实际的例子来做演示。

前面说过,我们要实现一个函数,传入空接口类型的参数以便接收不同类型形状的家具,然后在这个函数中计算体积并返回最终的计算值。我们先以正方体为例,具体代码如下:

go
复制代码// 计算某个物体的体积
func calcSize(material interface{}) float64 {
   cubeMaterial, cubeOk := material.(cube)
   if cubeOk {
      return cubeMaterial.cubeVolume()
   } else {
      return 0
   }
}

请大家仔细阅读这段代码,函数体内首行便进行了类型断言。material是传入该函数的参数,material.(cube)表示要判断material变量是否属于cube(正方体)类型。这种判断最终将返回两个结果,一个是cubeOk,它时布尔类型的值,当该值为true时,表示material是cube类型,反之则不是。另一个是cubeMaterial,它是将material变量转换为cube类型之后的变量,以便后续用它参与运算。

理解了函数中的首行代码,后面的代码便很好理解了。当cubeOK为true,即material属于cube时,使用转换后的cubeMaterial变量执行cubeVolume()方法,最终返回正方体的体积。

接下来,请大家自行编码实现长方体和圆柱体的类型断言和体积计算。

最后,整个程序完整的代码如下:

go
复制代码package main

import (
   "fmt"
   "math"
)

func main() {
   truckSize := 0.0
   // 声明空接口类型变量materials,存放各种不同体积的家具
   var materials []interface{}
   materials = append(materials, cube{12.5})
   materials = append(materials, cuboid{25, 13, 60})
   materials = append(materials, cylinder{5, 25.3})
   // 遍历materials切片,依次计算每个家具的体积,并相加求和
   for _, singleMaterial := range materials {
      truckSize += calcSize(singleMaterial)
   }
   fmt.Println(truckSize)
}

// 计算某个物体的体积
func calcSize(material interface{}) float64 {
   cubeMaterial, cubeOk := material.(cube)
   cuboidMaterial, cuboidOk := material.(cuboid)
   cylinderMaterial, cylinderOk := material.(cylinder)
   if cubeOk {
      return cubeMaterial.cubeVolume()
   } else if cuboidOk {
      return cuboidMaterial.cuboidVolume()
   } else if cylinderOk {
      return cylinderMaterial.cylinderVolume()
   } else {
      return 0
   }
}

// 正方体
type cube struct {
   // 边长
   length float64
}

// 正方体的体积计算
func (c *cube) cubeVolume() float64 {
   return c.length * c.length * c.length
}

// 长方体
type cuboid struct {
   // 长
   length float64
   // 宽
   width float64
   // 高
   height float64
}

// 长方体的体积计算
func (c *cuboid) cuboidVolume() float64 {
   return c.length * c.width * c.height
}

// 圆柱体
type cylinder struct {
   // 直径
   diameter float64
   // 高度
   height float64
}

// 圆柱体的体积计算
func (c *cylinder) cylinderVolume() float64 {
   return math.Pi * (c.diameter / 2) * (c.diameter / 2) * c.height
}

程序运行的结果为:

21949.889338348887

如此,我们便可有依据地选择货车了。

你注意到了吗?在main()函数中,我将空接口类型作为切片中的元素放在了名为materials的切片中。这在实际开发中是非常巧妙的使用空接口的方式,它可以规避数据类型的不同,将不同类型的数据存放于同一个切片/数组中,对于组织大量具有不同类型的数据是非常有效的做法。

# 总结

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

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

  • 空接口与泛型
  • 类型断言

本讲是接口系列专题的第二篇。在本讲中,我们首先了解到泛型到底是什么,以及Go语言中将使用空接口实现泛型。接着,我们使用了一个数据原样输出的例子演示了空接口的简单使用。

接着,以搬家选货车为示例,进一步强化了空接口的使用方法,并介绍了Go语言中的类型断言,使用分支的方式实现判断空接口中变量的类型。

在实际开发中,空接口和类型断言往往同时出现,配套使用。

好了,本讲就到这里。

➡️ 在下次课程中,我们会介绍Go语言中接口的更多使用技巧,具体内容是:

  • 接口使用的注意事项