java里的泛型对应程序的开发和接口的封装非常的有用, 我们在java的开发里经常通过泛型的实现来封装多种类型输入或者输出的接口,而不是用object的形式来进行处理, 对应java来说,可以通过cast的方式来对参数或者返回值进行强制造型,但是这样的接口没有体现出参数和返回值的类型说明,在接口的可阅读性上就没有使用泛型的实现这么友好了,
golang从17开始也支持泛型, 这个对于golang的开发者而言,正是个好消息, 在golang里没有java的object这个超级父类的实现,而是interface{}结构或者any类型, 这两者也类似于object这样的超级父类的功能,但是不是超级父类,而是一种特别的struct,在转换为使用的对象时,必须通过反射进行造型,golang的反射是个非常痛苦的事情, 而且使用反射cast的反射和直接引用的方式,在性能上,可不是一点的差距(对于golang的开发,一个毫秒级别的差距可以比较到的差距了,这点上和java不同), 通过新特性造型的支持,我们就可以避开使用interface{}和any的方式,这样不仅在接口的阅读性上非常友好,还在性能上有非常大的优化, 今天这里,我们就通过boot4go里的一些源代码,来探究一下golang里泛型的使用
golang里最常用泛型的几种场景,汇总如下
1. Slice, Map, Chan
2. 自定义函数
3. 自定义结构体
Boot4go里是怎样使用它们的Slice, Map, Chan
boot4go里没有直接就是用Sclice,Map,Chan的样例的,对于golang里最重要的三种数据结构切片,映射和通道,都不是简单的进行使用的, 都是结合着自定义函数和自定义结构体来进行封装的,故这里演示Slice,Map,Chan的简单泛型场景,我们通过测试代码来进行演示
测试代码
type SampleSlice[T any] []T
func TestSample1(t *testing.T) {
var list SampleSlice[int]
list = make(SampleSlice[int], 2)
list[0] = 1
list[1] = 2
fmt.Printf("%d\n", list[0])
fmt.Printf("%d\n", list[1])
}
运行结果
=== RUN TestSample1
1
2
--- PASS: TestSample1 (0.00s)
PASS
上面的代码就演示了一个切片泛型的例子,通过泛型,SampleSlice这个自定义的切片类就可以非常方便的扩张到其他的类型,比如SampleSlice[int32], SampleSlice[int64], SampleSlice[string]
Map和Channel
type SampleChan[T any] chan T
type SampleMap[K comparable, V any] map[K]V
这里的SampleMap使用了comparable这个约束类, 这个约束类是golang的builtin接口,可以查看golang的源码,即可
自定义函数泛型这个是在泛型里使用最为广泛的, 看多源码,就可以发现很多基于自定义函数泛型的应用, 在boot4go里也是有非常多这样的实例, 比如在arrays.go这个类包里,就有很多, arrays里主要是封装的对切片进行操作的函数, 通过泛型提供的方式, arrays里的函数就提供了多态的各种数据类型切片的实现
arrays.go(boot4go)样例代码
func InsertAt[T any](list []T, idx int, t T) []T {
l := len(list)
if idx < 0 {
idx = l + idx
}
if idx >= l || idx < 0 {
panic("Out of index " + strconv.Itoa(idx))
}
var rtn []T
rtn = append(rtn, list[0:idx]...)
rtn = append(rtn, t)
if idx < l+1 {
rtn = append(rtn, list[idx:]...)
}
return rtn
}
func RemoveAt[T any](list []T, idx int) []T {
l := len(list)
if idx < 0 {
idx = l + idx
}
if idx >= l || idx < 0 {
panic("Out of index " + strconv.Itoa(idx))
}
var rtn []T
rtn = append(rtn, list[0:idx]...)
if idx < l {
rtn = append(rtn, list[idx+1:]...)
}
return rtn
}
上面两个封装的接口就可以适用于所有类型的切片,而不需要针对特有的类型
自定义结构体,在应用上是对整个结构体定义的泛型, 他相对于其他两种类型的封装上是最复杂的,当然他也是功能最强大,封装艺术感最强的泛型应用,
boot4go里的trie前缀数数据结构的封装也是用的这样的泛型封装,应用对于前缀数数据结果的封装,我们在封装的时候,专注于前缀树的树形结构的遍历算法,但是对树形结构里,节点上保存的数据类型我们是不可知的,只有在使用时候才知道, 解决这个问题,我们可以使用any来定义存储数据的数据类型, 这样解决了问题,但是在get的时候,必须使用一直造型,影响性能,同时在put和get的时候,都会构建一个interface{}或者any的临时对象,增加了对象的gc回收的操作,所以最优雅的方式还是使用那个泛型的方式来实现。
对与自定义结构体的泛型,实际上就是同时包含了类型对象的泛型和自定义函数泛型两种单一场景的复合使用场景,
用样例来说明
// TrieNode trie Node
type TrieNode[T any] struct {
children map[string]any
Data *T
parent any // pointer of parent node
Key string
}
// NewTrie create a new trie tree
func NewTrie[T any]() *TrieNode[T] {
o := newNode[T]()
return o
}
解读
type TrieNode[T any] struct 定义结构体TrieNode,并且使用泛型来指定接受体的数据类型
Data *T 结构体里的Data属性,用来保存TrieNode关联的对象,这里使用*T, 也是一个泛型定义, 指针的数据类型,就是接受体的数据类型。
func NewTrie[T any]() *TrieNode[T] 这是一个自定义函数的泛型使用, 也使用了泛型, 通过NewTrie这个函数,可以产生一个TrieNode对象实例。
下面我们来看看如何对上面的进行调用
type Sample struct {
ID string
}
func TestTrie(t *testing.T) {
trie := NewTrie[Sample]()
trie.AddData("a/b/c1", &Sample{ID: "a/b/c1"})
trie.AddData("a/c/c2", &Sample{ID: "a/c/c2"})
trie.AddData("a/d/c3", &Sample{ID: "a/d/c3"})
r := trie.GetMatchedData("a/#")
printSample(r)
}
func printSample(l []*Sample) {
for _, sample := range l {
fmt.Printf("%+v ", *sample)
}
fmt.Println()
}
先定义一个结构体,这里是我们用来做样例的时候,保存草TrieNode里的对象
trie := NewTrie[Sample](), 对泛型进行调用, 泛型传入的是Sample对象,表示我们得到的trie对象里,Data类型保持的就是Sample的指针, 对应TrieNode来说,封装的主要是有关前缀数的遍历算法,并不关心书中的数据节点保持是哪一种具体数据, 我们在确定要使用一个前缀数的结构时,才能确定保存什么类型的数据, 所以在NewTrieNode的时候就进行指定,这样代码非常优雅的隔离了算法逻辑和存储对象的关系,而实现了优雅的代码实现,并且在代码的实现功能里,由于减少了不必要的中间临时对象和反射罩型的过程,在执行效率上也做到了尽可能的完美。
结束语虽然相对于java而言,golang在反射和泛型,包括面向对象的设计上没有做到非常的优雅,但是golang的开发的目的和java是不一样的,而且就笔者我自己认为,至少在泛型的这个设计上,golang在编译的时候检查并且可以直接编译成指定的类型地址,对于性能处理上来说,是非常的思考到位的,和java完全不同,泛型是golang17开始体验版,18正式推出,是golang编程里的一个高级特性和高级话题,在golang提供高性能的编程过程中,还提供了比较好的泛型编程方式,大家可以多进行使用。