跳转至

18 函数设计:重复编写相似函数,怎样实现逻辑复用?

你好,我是徐逸。

上节课我们学习了如何使用设计模式,来提升我们代码的可维护性。不过除了设计模式之外,Go 语言本身所提供的反射和泛型特性,同样是我们手中的得力工具。借助这些特性,我们能够达成逻辑复用的目标,避免重复编写那些功能相近的函数,让代码更加简洁。

今天,我就以实现一个求最大值的函数为例,在优化这个函数实现的过程中,带你了解反射和泛型知识。

案例准备

假设我们已经有了如下的函数,用于求取两个整数间的最大值。

func MaxInt(a, b int) int {
    if a > b {
        return a
    }
    return b
}

现在,如果我们想要实现一个类似的函数,来求取两个浮点数的最大值,我们可能会像下面这样增加一个新的函数。

func MaxFloat32(a, b float32) float32 {
    if a > b {
        return a
    }
    return b
}

不过,一旦我们进一步拓展需求,需要针对int64、float64 等很多其它数值类型获取最大值,这种不断添加新函数的方式,会导致代码中出现大量逻辑极为相似的函数。这不仅会使代码库变得臃肿不堪,还会极大地增加代码维护的成本。

那么是否存在一种方法,能够让我们避免重复编写逻辑相似的函数,而是在一个统一的函数里,就可以实现对所有数值类型求最大值的功能呢?

反射:如何动态操作任意类型的对象?

在Go 1.18 版本之前,我们可以通过反射(reflection)机制,实现一个函数兼容多种不同的数据类型。反射使我们能够在程序运行时操作任意类型的对象,例如灵活地调用对象的方法和访问它的属性。

在用反射来实现支持多种类型的最大值函数之前,我们先来了解下Golang中的反射机制。

Golang通过reflect包提供了强大的反射功能,这个包的核心能力在于将 interface{} 类型的变量转换为反射类型对象 reflect.Type 和 reflect.Value。借助这两个反射类型对象,我们就可以访问和操作真实对象的方法和属性

下面是reflect包和反射对象提供的三大核心功能。

首先是对象类型转换功能。正如下面的图所示,我们可以通过 TypeOf 和 ValueOf 方法将 interface{} 类型的变量转换为反射类型对象 Type 和 Value。同样,通过 Interface 方法,我们可以将 Value 对象转换回 interface{} 类型的变量。

为了让你有更直观的理解,下面我给出了一段涉及对象类型转换方法的代码,供你参考。

package main

import (
    "fmt"
    "reflect"
)

func main() {
    age := 18
    fmt.Println("type: ", reflect.TypeOf(age)) // 输出type:  int
    value := reflect.ValueOf(age)
    fmt.Println("value: ", value) // 输出value:  18

    fmt.Println(value.Interface().(int)) // 输出18
}

接下来是变量值的设置功能。代码示例如下,借助 reflect.Value 对象提供的以 Set 为前缀的方法(如代码中的 SetInt 方法),我们能够对实际变量的值进行修改。同时,需要注意的是,只有当传入 ValueOf 方法的参数是变量的指针时,我们才能够通过 reflect.Value 来改变实际变量的值

package main

import (
    "fmt"
    "reflect"
)

func main() {
    age := 18
    // 通过reflect.ValueOf获取age中的reflect.Value
    // 参数必须是指针才能修改其值
    pointerValue := reflect.ValueOf(&age)
    // Elem和Set方法结合,相当于给指针指向的变量赋值*p=值
    newValue := pointerValue.Elem()
    newValue.SetInt(28) 
    fmt.Println(age) // 值被改变,输出28

    // reflect.ValueOf参数不是指针
    pointerValue = reflect.ValueOf(age)
    // 非指针,直接panic: reflect: call of reflect.Value.Elem on int Value
    newValue = pointerValue.Elem() 
}

最后是动态方法调用功能。如下面示例代码所示,利用 Value 对象提供的 MethodByName 或 Method 方法,我们能够获取实际对象的特定方法,随后,通过Call方法,我们可以动态地调用该方法,并传递所需的参数。

package main

import (
    "fmt"
    "reflect"
)

type User struct {
    Age int
}

func (u User) ReflectCallFunc(name string) {
    fmt.Printf("age %d ,name %+v\n", u.Age, name)
}

func main() {
    user := User{18}

    // 1. 通过reflect.ValueOf(interface)来获取到reflect.Value
    getValue := reflect.ValueOf(user)
    // 或者getValue.Method(0)
    methodValue := getValue.MethodByName("ReflectCallFunc")
    args := []reflect.Value{reflect.ValueOf("k哥")}
    // 2. 通过Call调用方法
    methodValue.Call(args) // 输出age 18 ,name k哥
}

在掌握了反射包 reflect 的基础知识后,我们就可以运用这个包来实现最大值函数了。下面代码是基于反射实现的这个函数。

import (
    "errors"
    "reflect"
)

// Max 使用反射比较两个值(目前支持基本数字类型),返回较大的值以及可能的错误
func Max(a, b interface{}) (interface{}, error) {
    va := reflect.ValueOf(a)
    vb := reflect.ValueOf(b)

    // 检查类型是否一致且是支持的数字类型
    if va.Type() != vb.Type() {
        return nil, errors.New("a and b are not of equal type")
    }
    switch va.Kind() {
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        if va.Int() > vb.Int() {
            return a, nil
        }
        return b, nil
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
        if va.Uint() > vb.Uint() {
            return a, nil
        }
        return b, nil
    case reflect.Float32, reflect.Float64:
        if va.Float() > vb.Float() {
            return a, nil
        }
        return b, nil
    default:
        return nil, errors.New("unsupported kind")
    }
}

它的核心逻辑是这样的。

首先,我们通过 reflect.ValueOf 方法,获取 Value 类型的反射对象。

紧接着,我们利用 Value 对象的 Type 方法,分别获取变量 a 和 b 的类型,并进行对比。如果两者类型不同,则抛出错误。

随后,我们使用Value对象的Kind方法来识别变量的基础数据类型。Kind方法返回一个枚举值,它涵盖了Golang中所有可能的类型,包括Bool、Int、Float64、String、Struct等。Kind方法能够揭示一个值的底层类型,即使这个值被自定义类型所包装。

例如,下面代码使用TypeOf函数获取变量的类型信息,结果显示myInt的类型是MyInt,而Kind方法获取的类型信息,结果显示的类型是int。

type MyInt int

func main() {
    var myInt MyInt = 42
    // 使用reflect.TypeOf()获取类型信息
    myIntType := reflect.TypeOf(myInt)
    // 使用reflect.ValueOf()获取值对象,并使用Kind()获取基础类型
    myIntValue := reflect.ValueOf(myInt)
    // 打印类型和基础类型
    fmt.Printf("Type of myInt: %s, Kind of myInt: %s\n", myIntType, myIntValue.Kind())
    // 输出 Type of myInt: main.MyInt, Kind of myInt: int
}

最后,基于Kind方法返回的不同类型,我们分别调用Value对象的Int、Uint、Float等方法来获取数值变量的实际数值,并进行比较。通过这种方式,我们就实现了一个能够处理多种数值类型的最大值函数。

尽管上面借助反射,我们成功实现了支持多种数值类型的最大值函数,但反射的实现方式存在下面几个问题,我们也要特别留意。

首先是类型安全问题。当我们向这个函数传入字符串时,编译阶段无法提前察觉错误,只有在程序运行调用这个函数时才会报错,使得我们无法预先发现这类问题。你可以参考一下后面的示例代码。

func TestMax(t *testing.T) {
    a := "aaa"
    b := "bbb"
    _, err := Max(a, b)
    if err != nil {
        panic(err)
    }
}
// 输出
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestMax$ server-go/18/reflection
=== RUN   TestMax
--- FAIL: TestMax (0.00s)
panic: unsupported kind [recovered]
        panic: unsupported kind

其次是性能问题。反射机制需要在运行时解析类型信息来执行操作,相较于直接的类型操作,这个过程会产生更高的性能开销。我们可以用下面的 Benchmark 脚本,来测试反射实现的最大值函数和普通最大值函数两者的性能差异。

// MaxInt函数benchmark
func BenchmarkRegular(b *testing.B) {
    for i := 0; i < b.N; i++ {
        regular.MaxInt(1, 2)
    }
}

// 反射实现的最大值函数benchmark
func BenchmarkReflection(b *testing.B) {
    for i := 0; i < b.N; i++ {
        reflection.Max(1, 2)
    }
}

测试结果出来了,两者在性能上的差异显著。借助反射机制求取两个整型的最大值,单次操作耗时约 9.5 ns,而采用不依赖泛型的常规方式,同样的操作仅需 0.3 ns,性能差距可达 30 倍之多。

killianxu@KILLIANXU-MB0 18 % go test -bench . -benchmem
goos: darwin
goarch: amd64
pkg: server-go/18
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkRegular-4              1000000000               0.3005 ns/op          0 B/op          0 allocs/op
BenchmarkReflection-4           121113453                9.541 ns/op           0 B/op          0 allocs/op

最后是代码可读性问题。在我们准备的案例中,常规的 MaxInt 函数仅需 6 行代码,简洁明了。然而,通过反射实现的 Max 函数,代码量激增到 20 多行,而且大量运用反射相关方法,这无疑极大地增加了理解难度,使得代码的可读性大打折扣。

为了避免反射的这些问题,Go在1.18版本引入了泛型特性。当我们需要根据不同类型执行差异化逻辑时,反射机制是一个不错的选择。然而,如果不同类型所对应的实现逻辑一致,那么泛型便是更优的选择。

泛型:如何实现多种类型对象的逻辑复用?

泛型允许我们在编写代码时使用类型参数,从而使代码能够适用于多种不同类型,而无需为每种类型单独编写特定的实现。在 Go 语言中,泛型主要是通过类型参数和类型约束来实现的。

以支持int和float32两种类型的最大值函数为例,我们可以通过下面的代码来实现。这里的T就是类型参数,而int | float32就是类型约束。

// Max使用泛型来比较两个同类型的值(要求类型是可比较的),并返回较大的值
func Max[T int | float32](a, b T) T {
    if a > b {
        return a
    }
    return b
}

需要留意的是,Golang 的泛型机制在编译阶段,会基于传入的实际参数类型,实例化出具体的函数。以下面的代码为例,若两次调用分别传入 int 和 float32 类型,编译时就会实例化出类似 MaxInt 和 MaxFloat32 这样的函数。然而,若传入 string 类型,编译时便会报错,这样一来,我们就能提前察觉类型安全问题

var a int = 1
var b int =1
Max[int](a,b) // 实例化出类似MaxInt的函数

var a float32 = 0.1
var b float32 = 0.2
Max[float32](a,b) // 实例化出类似MaxFloat32的函数

var a string = "aa"
var b string = "bb"

// 我们的实现里,不支持string类型,因此编译会报错
// string does not satisfy int | float32 (string missing in int | float32)
Max[string](a,b)

当然,上述关于泛型函数的实现与使用,仅仅是一个简易示例。实际上,为了给开发者提供更多便利,Golang 还具备更为强大的功能。

比如上面的最大值函数,如果我们希望它能支持更多类型,那么我们可以参考下面代码的做法,将类型约束放在一个单独的 interface 定义中。这样一来,就能有效避免在函数定义内出现冗长的类型约束列表,从而成功实现一个可支持多种数值类型的最大值函数。

type Ordered interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64 |
                ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
                ~float32 | ~float64 

}

// Max使用泛型来比较两个同类型的值(要求类型是可比较的),并返回较大的值
func Max[T Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

再比如,在实际使用过程中,我们无需显式传入类型参数。Golang 编译器具备类型推断能力,它能够依据传入的具体参数,自动推断出相应的类型参数。

var a int = 1
var b int =1
// 显示传入int类型
Max[int](a,b)

// 不传入int类型,由Go编译器推断
Max(a,b)

那么,通过泛型实现的最大值函数,是否解决了反射存在的三个缺点呢?接下来,我们就逐一分析看看。

首先是类型安全问题。泛型在编译阶段,依据传入的具体类型进行实例化。这意味着,一旦存在类型方面的问题,在编译时便会被察觉,于是就有效规避了运行时可能出现的类型安全隐患。

接着是性能问题。泛型减少了对大量反射方法的调用,所以在性能上更具优势。我们可以借助下面的 Benchmark 脚本进行测试。

// 泛型实现的最大值函数benchmark
func BenchmarkGenerics(b *testing.B) {
    for i := 0; i < b.N; i++ {
        generics.Max(1, 2)
    }
}

测试结果出来了,采用泛型实现的函数,性能与常规类型的函数大致相当,然而,相较于使用反射实现的函数,它的性能高出几十倍。

killianxu@KILLIANXU-MB0 18 % go test -bench . -benchmem 
warning: GOPATH set to GOROOT (/usr/local/go) has no effect
goos: darwin
goarch: amd64
pkg: server-go/18
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkRegular-4              1000000000               0.3693 ns/op          0 B/op          0 allocs/op
BenchmarkReflection-4           120235849                9.455 ns/op           0 B/op          0 allocs/op
BenchmarkGenerics-4             1000000000               0.2851 ns/op          0 B/op          0 allocs/op

最后是可读性问题。泛型的逻辑简明直观,它规避了很多反射方法的繁杂调用,显著提升了代码的可读性。

小结

今天这节课,我以实现一个支持多种数值类型的最大值函数为例,在逐步优化它的实现的过程中,向你介绍了Golang的反射和泛型知识。

现在让我们回顾一下这节课的重点。

首先是反射机制,反射使我们能够在程序运行时操作任意类型的对象。在 Golang 里,reflect 包提供了强大的反射功能,这个包的核心能力在于将 interface{} 类型的变量转换为反射类型对象 reflect.Type和reflect.Value。借助这两个反射类型对象,我们就可以访问和操作真实对象的方法和属性。

其次是泛型功能。在 Go 语言体系中,我们能够借助类型参数与类型约束,来构建泛型类型和函数。泛型允许我们在编写代码时运用类型参数,让代码得以适配多种不同类型,无需为每种类型另行编写特定实现。

希望你好好体会反射和泛型知识,在写代码的过程中,如果需要实现逻辑复用,不妨用泛型试一试。

思考题

在实践中,哪几类场景适合用泛型来实现呢?

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!