Press "Enter" to skip to content

Go 面向对象编程篇(八):空接口、反射和泛型

空接口的引入

熟悉 Java 的同学应该都知道,在这个号称血统最纯正的面向对象编程语言中,「万事万物皆对象」,并且所有类都继承自祖宗类「Object」,所以 Object 类型变量可以指向任何类的实例。

Go 语言打破了传统面向对象编程中类与类之间继承的概念,而是通过组合实现方法和属性的复用,所以不存在类似的继承关系树,也就没有所谓的祖宗类,而且类与接口之间也不再通过 implements 关键字强制绑定实现关系,所以 Go 语言的面向对象编程非常灵活。

在 Go 语言中,类与接口的实现关系是通过类所实现的方法在编译期推断出来的,如果我们定义一个空接口的话,那么显然所有的类都实现了这个接口,反过来,我们也可以通过空接口来指向任意类型,从而实现类似 Java 中 Object 类所承担的功能,而且显然 Go 的空接口实现更加简洁,通过一个简单的字面量即可完成:

interface{}

需要注意的是空接口和接口零值不是一个概念,前者是 interface{},后者是 nil

空接口的基本使用

下面我们看一下空接口的使用示例。

指向任意类型变量

我们可以将其指向基本类型:

var v1 interface{} = 1 // 将 int 类型赋值给 interface{} 
var v2 interface{} = "学院君" // 将 string 类型赋值给 interface{} 
var v3 interface{} = true  // 将 bool 类型赋值给 interface{}

也可以将其指向复合类型:

var v4 interface{} = &v2 // 将指针类型赋值给 interface{} 
var v5 interface{} = []int{1, 2, 3}  // 将切片类型赋值给 interface{} 
var v6 interface{} = struct{   // 将结构体类型赋值给 interface{}
    id int
    name string
}{1, "学院君"} 

声明任意类型参数

空接口最典型的使用场景就是用于声明函数支持任意类型的参数,比如 Go 语言标准库 fmt 中的打印函数就是这样实现的:

func Printf(fmt string, args ...interface{}) 
func Println(args ...interface{}) ...
func (p *pp) printArg(arg interface{}, verb rune)

关于这一点,我们在前面类型断言中已经演示过。

实现更灵活的类型断言

此外,我们还可以基于空接口来实现更加灵活的类型断言。

在上篇教程中,我们提到类型断言运算符 . 左侧的变量必须是接口类型,而空接口可以表示任何类型,所以我们可以基于空接口将其他类型变量转化为空接口类型,这样,就不必单独引入 IAnimal 接口了:

var animal = NewAnimal("中华田园犬")
var pet = NewPet("泰迪")
var any interface{} = NewDog(&animal, pet)
if dog, ok := any.(Dog); ok {
    fmt.Println(dog.GetName())
    fmt.Println(dog.Call())
    fmt.Println(dog.FavorFood())
    fmt.Println(reflect.TypeOf(dog))
}

反射

很多现代高级编程语言都提供了对反射的支持,通过反射,你可以在运行时动态获取变量的类型和结构信息,然后基于这些信息做一些非常灵活的工作,一个非常典型的反射应用场景就是 IoC 容器。

Go 也支持反射功能,并且专门提供了一个 reflect 包用于提供反射相关的 API,这一点学院君在前面已经介绍过,Go 格式化输出标准库 fmt 底层就大量使用了反射。

reflect 包提供的两个最常用、最重要的类型就是 reflect.Typereflect.Value。前者用于表示变量的类型,后者用于存储任何类型的值,分别可以通过 reflect.TypeOfreflect.ValueOf 函数获取。

使用示例

下面我们来看一个简单的反射使用示例。

以前面编写的 Dog 类为例,我们可以这样在运行时通过反射获取其类型:

animal := NewAnimal("中华田园犬")
pet := NewPet("泰迪")
dog := NewDog(&animal, pet)

// 返回的是 reflect.Type 类型值
dogType := reflect.TypeOf(dog)    
fmt.Println("dog type:", dogType)

执行这段代码,打印结果是:

dog type: animal.Dog

如果你想要获取 dog 值的结构体信息,并且动态调用其成员方法,使用反射的话需要先获取对应的 reflect.Value 类型值:

// 返回的是 dog 指针对应的 reflect.Value 类型值
dogValue := reflect.ValueOf(&dog).Elem()

当然,Dog 类中不包含指针方法的话,也可以返回 dog 值对应的 reflect.Value 类型值:

dogValue := reflect.ValueOf(dog)

接下来,我们通过如下反射代码分别批量获取 dog 实例的所有属性和成员方法,并打印其名称、类型、值以及调用结果:

// 获取 dogValue 的所有属性
fmt.Println("================ Props ================")
for i := 0; i < dogValue.NumField(); i++ {
    // 获取属性名
    fmt.Println("name:", dogValue.Type().Field(i).Name)
    // 获取属性类型
    fmt.Println("type:", dogValue.Type().Field(i).Type)
    // 获取属性值
    fmt.Println("value:", dogValue.Field(i))
}
// 获取 dogValue 的所有方法
fmt.Println("================ Methods ================")
for j := 0; j < dogValue.NumMethod(); j++ {
    // 获取方法名
    fmt.Println("name:", dogValue.Type().Method(j).Name)
    // 获取方法类型
    fmt.Println("type:", dogValue.Type().Method(j).Type)
    // 调用该方法
    fmt.Println("exec result:", dogValue.Method(j).Call([]reflect.Value{}))
}

执行上述代码,对应的打印结果如下:

-w649

可以看到,即便我们不知道 Dog 类的属性类型、成员方法细节时,依然可以通过反射来动态获取和调用,非常灵活。

具体每个反射函数的语法细节,可以参考 Go 官方提供的 reflect 包文档,这里就不一一展开了。

我们可以通过反射获取变量的所有未知结构信息,以结构体为例(基本类型只有类型和值,更加简单),包括其属性、成员方法的名称和类型,值和可见性,还可以动态修改属性值以及调用成员方法。

不过这种灵活是有代价的,因为所有这些解析工作都是在运行时而非编译期间进行,所以势必对程序性能带来负面影响,而且可以看到,反射代码的可读性和可维护性比起正常调用差很多,最后,反射代码出错不能在构建时被捕获,而是在运行时以恐慌的形式报告,这意味着反射错误有可能使你的程序崩溃。

所以,如果有其他更好解决方案的话,尽量不要使用反射。

基于空接口和反射实现泛型

不过,在某些场景下,目前只能使用反射来实现,比如范型,因为现在 Go 官方尚未在语法层面提供对泛型的支持,我们只能通过空接口结合反射来实现。

在前面变长参数那里学院君已经简单演示过 Go 泛型的实现,这里再更严谨地实现下。

空接口 interface{} 本身可以表示任何类型,因此它其实就是一个泛型了,不过这个泛型太泛了,我们必须结合反射在运行时对实际传入的参数做类型检查,让泛型变得可控,从而确保程序的健壮性,否则很容易因为传递进来的参数类型不合法导致程序崩溃。

下面我们通过一个自定义容器类型的实现来演示如何基于空接口和反射来实现泛型:

package main

import (
    "fmt"
    "reflect"
)

type Container struct {
    s reflect.Value
}

// 通过传入存储元素类型和容量来初始化容器
func NewContainer(t reflect.Type, size int) *Container {
    if size <= 0  {
        size = 64
    }
    // 基于切片类型实现这个容器,这里通过反射动态初始化这个底层切片
    return &Container{
        s: reflect.MakeSlice(reflect.SliceOf(t), 0, size),
    }
}

// 添加元素到容器,通过空接口声明传递的元素类型,表明支持任何类型
func (c *Container) Put(val interface{})  error {
    // 通过反射对实际传递进来的元素类型进行运行时检查,
    // 如果与容器初始化时设置的元素类型不同,则返回错误信息
    // c.s.Type() 对应的是切片类型,c.s.Type().Elem() 应的才是切片元素类型
    if reflect.ValueOf(val).Type() != c.s.Type().Elem() {
        return fmt.Errorf("put error: cannot put a %T into a slice of %s",
            val, c.s.Type().Elem())
    }
    // 如果类型检查通过则将其添加到容器中
    c.s = reflect.Append(c.s, reflect.ValueOf(val))
    return nil
}

// 从容器中读取元素,将返回结果赋值给 val,同样通过空接口指定元素类型
func (c *Container) Get(val interface{}) error {
    // 还是通过反射对元素类型进行检查,如果不通过则返回错误信息
    // Kind 与 Type 相比范围更大,表示类别,如指针,而 Type 则对应具体类型,如 *int
    // 由于 val 是指针类型,所以需要通过 reflect.ValueOf(val).Elem() 获取指针指向的类型
    if reflect.ValueOf(val).Kind() != reflect.Ptr ||
        reflect.ValueOf(val).Elem().Type() != c.s.Type().Elem() {
        return fmt.Errorf("get error: needs *%s but got %T", c.s.Type().Elem(), val)
    }
    // 将容器第一个索引位置值赋值给 val 指针
    reflect.ValueOf(val).Elem().Set( c.s.Index(0) )
    // 然后删除容器第一个索引位置值
    c.s = c.s.Slice(1, c.s.Len())
    return nil
}

func main() {
    nums := []int{1, 2, 3, 4, 5}

    // 初始化容器,元素类型和 nums 中的元素类型相同
    c := NewContainer(reflect.TypeOf(nums[0]), 16)

    // 添加元素到容器
    for _, n := range nums {
        if err := c.Put(n); err != nil {
            panic(err)
        }
    }

    // 从容器读取元素,将返回结果初始化为 0
    num := 0
    if err := c.Get(&num); err != nil {
        panic(err)
    }

    // 打印返回结果值
    fmt.Printf("%v (%T)\n", num, num)
}

具体细节都已经在代码注释中详细标注了,执行上述代码,打印结果如下:

-w663

如果我们试图添加其他类型元素到容器:

if err := c.Put("s"); err != nil {
    panic(err)
}

或者存储返回结果的变量类型与容器内元素类型不符:

if err := c.Get(num); err != nil {
    panic(err)
}

都会报错:

-w940
-w958

注:本节完整示例代码可以在 Github 代码仓库获取:nonfu/golang-tutorial

在上面这段代码中,为了提高程序的健壮性,我们引入了错误处理机制,这块内容我们即将在下个章节中详细给大家介绍。

空结构体

另外,有的时候你可能会看到空的结构体类型定义:

struct{}

表示没有任何属性和成员方法的空结构体,该类型的实例值只有一个,那就是 struct{}{},这个值在 Go 程序中永远只会存一份,并且占据的内存空间是 0,当我们在并发编程中,将通道(channel)作为传递简单信号的介质时,使用 struct{} 类型来声明最好不过。

4 Comments

  1. Echo
    Echo 2023年11月15日

    前面都好好的,一到自定义容器示例这块,直接蒙逼了呢 – –

发表回复