Go泛型也出来很久了,最近也用泛型去实现一些功能,例如数据结构一些应用还是没有什么问题的。但是如果做一些类型上限制,还是有点问题,和其他语言有一些不一样的地方,想要基于泛型做一些类型上的限制,必须使用组合的模式,或者嵌套。这篇我将记录一下我在Go泛型上遇到一些问题。
阅读这篇文章读者需要了解Go泛型怎么使用和泛型能做什么,还有了解map
结构原理,并且本文会以LinkedHashMap
作为一个泛型结构讲解一些关于Go泛型不完美的地方。读者要知道LinkedHashMap
底层是怎么工作的才能读懂这篇文章。
原生Map局限性
大家都知道Go的内置map
结构在遍历的时候是无序的,如果要让其有序输出那么就要对key
进行排序,然后收集排序完的key
依次索引输出这样子,但是很麻烦的样子,并且还不能保证map
存储的的顺序不是以插入值的顺序而存储,并且还不能按照访问操作顺序重排底层存储结构元素的关系,了解的原生map
应该知道底层是通过hash function
打乱并且存储在不同桶里,遍历只能从不同的桶里随机输出这些元素,这就是无序的原因。
LinkedHashMap
使用过Java
朋友应该知道一个数据结构叫LinkedHashMap
,这个数据结构是可以保证数据存储顺序的,并且还能按照配置动态调整存储的元素的顺序关系,下面这是一段Java代码,介绍一下工作原理:
LinkedHashMap<String, Integer> lmap = new LinkedHashMap<String, Integer>();
lmap.put("语文", 1);
lmap.put("数学", 2);
lmap.put("英语", 3);
lmap.put("历史", 4);
lmap.put("政治", 5);
lmap.put("地理", 6);
lmap.put("生物", 7);
lmap.put("化学", 8);
for(Entry<String, Integer> entry : lmap.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
输出结果可以看到:
语文: 1
数学: 2
英语: 3
历史: 4
政治: 5
地理: 6
生物: 7
化学: 8
可以看到是以我们插入时候的顺序输出的结果,可以做到有序的。
这是怎么做到的呢?如果深入底层源代码分析发现,他内部结构入下图:
和HashMap
的运行结果不同,LinkedHashMap
的迭代输出的结果保持了插入顺序,它底层其实就是一个map
加Linked List
组成一个结构,map
负责索引,而Linked List
负责的是数据顺序组织存储方式,可以形成一个完全有序的链表,大部分可能认为就是一个链式存储方式,这是不对的,有兴趣可以源代码,是两种数据结构组合而成,类似于LRU
淘汰算法那样,每次操作元素都会发生底层数据交换位置。
元素是被内置的map
索引着的只是为了方便快速拿取,而为了数据能和插入时一致的顺序又用链表组织成了有序链表,原理可以想象上面这幅图,你洗衣服拿衣服怎么洗的?如果读者看到这里看不懂话,可以去网上看看关于LinkedHashMap
底层实现的代码分析文章吧,我这里不讲解了。
用Go泛型实现
对其工作原理介绍完了,下面我就针对这个这种方式,使用Go
去实现类似的,算是重复造轮子吧。但是在泛型key
上还是遇到一些问题,顺便写一个文章记录。
下面这是我使用Go复刻的LinkedHashMap
代码,Node[K Hashed, V any]
结构是为了底层数据存储所设计的,而LinkedHashMap
为主体结构,注意里面的table map[uint64]*Node[K, V]
字段。
type Node[K Hashed, V any] struct {
Prev, Next *Node[K, V]
Key K
Value V
}
// Stored structure
type LinkedHashMap[K Hashed, V any] struct {
accessOrder bool // the iteration ordering method
capacity int // total capacity
head, tail *Node[K, V] // linked list
table map[uint64]*Node[K, V] // data storeage
size int // current size
}
为了这个map
能达到和Java的LinkedHashMap
一样的效果,我在key
泛型上做了限制,因为要支持双泛型参数声明,有两个泛型参数,如果简单一个话,这个键完全可以设置为string
类型,那这样的后果就调用方只能使用string
类型做为键了,而value
可以泛型,那么就会失去双参数泛型的意义。
type Hashed interface {
comparable
string
HashCode() uint64
}
为了支持更多类型作为key
这里我定义了一个Hashed
接口作为泛型约束,可以看到comparable
和string
还有HashCode() uint64
,comparable
为了默认一些基础的数据类型例如int
和float
能作为键,而string
大家了解他底层实现的话其实是一个包装的类型,真实的数据是[]byte
类型,如果以现在的方式去使用string
作为键,那么我还要针对string
去做哈希才能得到最终哈希值。
在看一下具体的crud
代码:
func NewLinkedHashMap[K Hashed, V any](capacity int, accessOrder bool) LinkedHashMap[K, V] {
return LinkedHashMap[K, V]{
accessOrder: accessOrder,
capacity: capacity,
table: make(map[uint64]*Node[K, V], capacity),
}
}
func (hashmap *LinkedHashMap[K, V]) Put(key K, value V) bool {
// 问题就在这:
// 如果是按照我现在定义接口去实现,自定义结构体实现HashCode() uint64就可以满足这个key类型约束?
// 但是如果默认内置的数据类型,就没有实现HashCode() uint64,不能满足,如果需要满足就要实现HashCode() uint64
// 解决方法我在文末列举几种
// 如果按照当前的设计则我就要回归到类型断言,根据不同类型去调用不同计算方法
// 这种是我不推荐的,我还是建议使用类型组合制造一个新的类型,然后去实现HashCode() uint64
// 或者把map[uint64]*Node[K, V]的Key也改成泛型版本
// 如果是字符串 go内置的string没有实现HashCode()uint64
sum64 := Sum64([]byte(key))
// 自定义泛型 我们自己可以实现 HashCode()uint64
sum64 = key.HashCode()
node := &Node[K, V]{
Key: key,
Value: value,
}
if hashmap.size == 0 {
hashmap.head = node
hashmap.tail = node
return true
}
if node, ok := hashmap.table[sum64]; ok {
node.Value = value
moveNode(node)
node.Next = nil
addNodeAtTail(hashmap, node)
hashmap.size += 1
return true
}
addNodeAtTail(hashmap, node)
hashmap.table[sum64] = node
hashmap.size += 1
return true
}
func (hashmap *LinkedHashMap[K, V]) Remove(key K) {
}
func (hashmap *LinkedHashMap[K, V]) Get(key K) *V {
return nil
}
func (hashmap *LinkedHashMap[K, V]) Size() int {
return hashmap.size
}
// 从两个节点中间删除节点
func moveNode[K Hashed, V any](node *Node[K, V]) {
node.Next.Prev = node.Prev
node.Prev.Next = node.Next
}
// addTail 添加节点到链表尾巴
func addNodeAtTail[K Hashed, V any](hashmap *LinkedHashMap[K, V], node *Node[K, V]) {
node.Prev = hashmap.tail
hashmap.tail.Next = node
hashmap.tail = node
}
问题来了,看函数签名为(hashmap *LinkedHashMap[K, V]) Put(key K, value V)
的函数,问题在这,key
我在设计的时候就限制为了Hashed
类型,而如果这个key
在使用的时候传入的是结构体类型,结构体实现了HashCode() uint64
那么就做成key
,如果是默认字符串,go
内置的类型没有实现HashCode()uint64
,那么我这里就要断言了,看看是不是string
然后函数内置去计算哈希?有木有什么办法不这样搞?
解决方法好几种:
- 所以最后还是回归到类型断言,我其实想有木有其他好办法解决?
- 还有一种方法就是我在我自己包里面 自定义一个装个一个
string
类型代替 默认内置的string
类型 去实现HashCode()uint64
接口,这样就可以正确了,不过调用者在泛型字符串的时候必须使用我包里面这个string
类型。 - 如果按照上面这种其他类型也要去实现。
- 还有一种让内置的
table
泛型也是和外面的LinkedHashMap
一样,交给go编译器自己去处理,那这样就没有HashCode()uint64
方法了。
例如下面这样我就自定义造了一个String
包装类型满足key
要求:
type Hashed interface {
// comparable
// string
HashCode() uint64
}
type String struct {
string
}
func (s String) HashCode() uint64 {
return Sum64([]byte(s.string))
}
func MakeString(str string) String {
return String{
string: str,
}
}
内外一致泛型
下面是通过外内map
的key
泛型类型一直实现的代码,并且为其实现了迭代器代码,但是迭代器还是有点问题,不能返回节点的key
:
package sortmap
import "github.com/auula/coffee"
type Node[K comparable, V any] struct {
Prev, Next *Node[K, V]
Key K
Value V
}
// Stored structure
type LinkedHashMap[K comparable, V any] struct {
capacity int // total capacity
head, tail *Node[K, V] // linked list
table map[K]*Node[K, V] // data storeage
size int // current size
}
func New[K comparable, V any](capacity int) LinkedHashMap[K, V] {
return LinkedHashMap[K, V]{
capacity: capacity,
table: make(map[K]*Node[K, V], capacity),
}
}
func (hashmap *LinkedHashMap[K, V]) Put(key K, value V) bool {
node := &Node[K, V]{
Key: key,
Value: value,
}
if hashmap.size == 0 {
hashmap.head = node
hashmap.tail = node
hashmap.table[key] = node
hashmap.size += 1
return true
}
if node, ok := hashmap.table[key]; ok {
node.Value = value
return true
}
addNodeAtTail(hashmap, node)
hashmap.table[key] = node
hashmap.size += 1
return true
}
func (hashmap *LinkedHashMap[K, V]) Remove(key K) {
if node, ok := hashmap.table[key]; ok {
moveNode(hashmap, node)
hashmap.size -= 1
delete(hashmap.table, key)
}
}
func (hashmap *LinkedHashMap[K, V]) Get(key K) (*K, *V) {
var (
node *Node[K, V]
ok bool
)
if node, ok = hashmap.table[key]; !ok {
return nil, nil
}
moveNode(hashmap, node)
addNodeAtTail(hashmap, node)
return &node.Key, &node.Value
}
func (hashmap *LinkedHashMap[K, V]) Clear() {
hashmap.size = 0
hashmap.head = nil
hashmap.tail = nil
hashmap.table = make(map[K]*Node[K, V], hashmap.capacity)
}
func (hashmap *LinkedHashMap[K, V]) Size() int {
return hashmap.size
}
// 从两个节点中间删除节点
func moveNode[K comparable, V any](hashmap *LinkedHashMap[K, V], node *Node[K, V]) {
if node == hashmap.head {
node.Next.Prev = nil
hashmap.head = node.Next
return
}
if node == hashmap.tail {
node.Prev.Next = nil
hashmap.tail = node.Prev
return
}
node.Next.Prev = node.Prev
node.Prev.Next = node.Next
}
// addTail 添加节点到链表尾巴
func addNodeAtTail[K comparable, V any](hashmap *LinkedHashMap[K, V], node *Node[K, V]) {
node.Prev = hashmap.tail
hashmap.tail.Next = node
hashmap.tail = node
}
func (hashmap *LinkedHashMap[K, V]) Iter() coffee.Iterator[V] {
return hashmap
}
func (hashmap *LinkedHashMap[K, V]) HasNext() bool {
return hashmap.size != 0
}
func (hashmap *LinkedHashMap[K, V]) Next() V {
var node *Node[K, V]
if node = hashmap.head; node != nil {
hashmap.head = hashmap.head.Next
hashmap.size -= 1
return node.Value
}
return node.Value
}
func (hashmap *LinkedHashMap[K, V]) Range(fn func(Node[K, V])) {
var node *Node[K, V]
for hashmap.size != 0 {
if node = hashmap.head; node != nil {
hashmap.head = hashmap.head.Next
hashmap.size -= 1
fn(*node)
}
}
}
使用起来很简单:
package main
import (
"fmt"
"github.com/auula/coffee/sortmap"
)
func main() {
var sm = sortmap.New[string, float64](10)
sm.Put("Go", 91.2)
sm.Put("Java", 100)
sm.Put("Rust", 80.1)
// 因为返回的是地址,如果没有返回值则为nil
fmt.Println(sm.Get("Go101"))
// 如果有值则返回对应类型的指针
k, v := sm.Get("Rust")
fmt.Printf("\n key:%s \t value:%f \n", *k, *v)
// coffee.ForEach(sm.Iter(), func(i float64) {
// fmt.Println(i)
// })
sm.Range(func(node sortmap.Node[string, float64]) {
fmt.Printf("\n key:%s \t value:%f \n", node.Key, node.Value)
})
}
Go官方建议
Go官方给出使用泛型的意见是使用泛型去减少一些类型一样的重复代码,例如数据结构,这样子的。但是如果把泛型用一些类型约束上还是和一些其他原因有所差距,要使用嵌套或者组合的方式重新构建新的类型去实现,然后使用构建的类型,例如上面我给出的我的这个例子。