Go没有原生的集合类型,标准库也没有提供。那么,如何在Go中创建集合呢?
使用映射。
首先,我们将探讨如何使用映射来实现集合。
然而,如果你的程序大量依赖集合,使用映射可能很快就会变得繁琐。因此,在第二部分,我们将介绍一个开源包,它提供了两种方便的集合实现。
最后,由于这些解决方案只适用于可比较类型,我们将探讨如何在集合中处理不可比较类型。
让我们开始吧!
使用映射实现集合
Go中的映射由键值对组成。键和值都有特定的类型。
例如,在映射 x
中:
x := map[string]bool{}
所有的键都是 string
类型,所有的值都是 bool
类型。
键类型需要是可比较的,函数、映射或切片等类型不能用作键类型。
每个键都是唯一的,映射不能包含重复的键。这意味着映射中的键实际上形成了一个集合。
因此,x
已经为我们提供了一个字符串集合,因为它的键是 string
类型。
使用空结构体作为值
在映射 x
中,我们还为映射中的每个元素存储了一个 bool
值。
由于在集合中我们只关心键,我们可以使用空结构体 struct{}
代替。
空结构体不占用内存,并且清楚地向其他开发人员传达了这些值只是没有意义的占位符。
我们的字符串集合看起来像这样:
x := map[string]struct{}{}
虽然看起来有点冗长,但它既高效又实用。
让我们看看它的实际应用,并回顾一些常见用例。
初始化集合
创建一个带有一些初始元素的集合可能很有用。
为此,使用键初始化映射,并提供空结构体作为值。
例如,下面的代码用 "foo"
和 "bar"
元素初始化了一个集合。
注意 {}
,这些是空结构体值。
set := map[string]struct{}{ "foo": {}, "bar": {}, }
向集合添加元素
可以通过将适当的键设置为空结构体来向集合添加元素。
例如,下面我们向集合添加 "foo"
元素。
注意 struct{}{}
,这是空结构体值。
set["foo"] = struct{}{}
从集合中移除元素
使用内置的 delete
函数从集合中移除元素。
例如,下面我们从集合中移除 "bar"
元素。
delete(set, "bar")
检查元素是否存在
要检查元素是否存在于集合中,我们可以使用返回两个值的索引表达式:
_, ok := set["foo"]
如果键存在于集合中,ok
变量将为 true
,否则为 false
。
使用空白标识符 _
来丢弃第一个值,因为我们只关心元素是否存在。
如你在上面的例子中所看到的,即使 ok
为 false
,第一个值也总是等于空结构体。
这是因为对映射的索引表达式对不存在的键返回零值。空结构体类型的零值就是一个空结构体。
在比较中使用集合
使用空结构体作为映射的值有一个缺点:在检查元素是否存在于集合中时,你总是需要一个变量。
这将导致在进行大量比较时代码更加冗长。
如果你关心可读性,并且内存使用不是问题,你可能仍然想使用 bool
作为值。
看看下面的例子,比较了这两种方法。
set1 := map[string]struct{}{"foo": {}, "bar": {}} set2 := map[string]bool{"foo": true, "bar": true} // 使用空结构体 _, exists := set1["foo"] if exists { fmt.Println("foo exists in set1") } // 使用布尔值 if set2["foo"] { fmt.Println("foo exists in set2") }
第二种比较之所以有效,是因为 bool
值的零值是 false
。所以即使 "baz"
不是 set2
的一部分,访问它也会返回 false
值。
这有点间接,但可能会导致更简洁的代码。
(感谢 Reddit 上的 /u/Responsible-Hold8587 提出这个建议)
集合的大小(基数)
使用内置的 len
函数来获取映射的长度和集合的大小。
size := len(set)
列出集合中的元素
使用带有 range
子句的 for
语句来列出集合中的元素。
for element := range set { fmt.Println(element) }
并发
重要的是要知道,Go中的映射(以及使用它们实现的集合)对于并发使用是不安全的。
如果你想在多个 goroutine 中使用你的映射,你需要使用互斥锁或通道来协调访问。
不想自己实现这个?考虑使用我们接下来要探讨的包。
更多功能
到目前为止,我们已经看到了集合类型的基础知识。你通常可以通过在这些基础上构建来实现更高级的功能。
比如:
- 两个集合的差集。
- 两个集合的交集。
- 检查子集/超集。
这些操作可以用几个循环来实现。
然而,这可能会变得重复,你可能会考虑构建一个通用的集合类型。
在你这样做之前,我建议你看看 deckarep/golang-set
[1] 。
这是一个开源包,提供了通用集合,实现了许多典型的集合功能。
这些实现在底层使用了映射。
例如,下面我们找到两个集合的交集:
s1 := mapset.NewSet("a", "b", "c") s2 := mapset.NewSet("c", "d", "e") intersection := s1.Intersect(s2) fmt.Println(intersection) // Set{c}
JSON 转换
deckarep/golang-set
提供的集合类型实现了 encoding/json
包中的编组接口。
这意味着它们可以使用标准库函数编码和解码为 JSON 数组。
如果你正在构建一个典型的 Web 应用程序或 API,这非常有用。
并发
NewSet*
返回的集合都是并发安全的,这些集合使用读写互斥锁来协调访问。
如果由于某种原因这个互斥锁的开销太大,并且集合只是顺序使用,你可以看看 NewThreadUnsafe*
构造函数。
这些返回的集合对于并发使用是不安全的,不使用互斥锁。
性能注意事项
集合的 Iter
和 Iterator
方法返回一个从单独的 goroutine 中填充的通道。
在迭代进行时,不能进行写操作。
如果你运行大量迭代,这也会影响性能,因为每个新的迭代器都会产生一个新的 goroutine。请记住这一点。
不可比较类型
要构建一个集合,你需要一种比较值是否相等的方法。没有这样的比较,你将无法保证元素的唯一性,也无法创建集合。
有些类型不能使用 ==
进行常规比较,不能作为集合元素。
Go 语言限制了某些类型的比较:例如,函数、映射和切片只能与 nil
进行比较。
在其他情况下,你的类型的相等性语义可能与 Go ==
运算符使用的不同。
标准库中的一个例子是 time.Time
类型,它有一个自定义的 Equals
方法。==
运算符可以工作,但它不会 比较[2] 相同的时间瞬间。
如果我们将 time.Time
类型用作集合元素,结果可能与我们的预期大不相同。
存储这样的类型需要选择一个可比较类型的键。然后你需要能够将这个键转换或追踪回你的不可比较类型。
例如,对于我们的 time.Time
类型,我们可以考虑使用 UTC 格式的字符串或 UNIX 时间戳,并在需要时将其转换回 time.Time
类型。
总结
简短回顾:
- Go 没有原生的集合数据类型,但你可以使用映射来实现它们。
- 使用空结构体作为值来节省内存。
- 使用映射时要考虑并发性。
deckarep/golang-set
提供了更全面的集合实现,具有差集、交集和 JSON 编组等功能。- 在使用类型作为集合元素时,要考虑如何比较这些类型。
这就是本文的全部内容,希望你学到了一些东西 :)
如果你喜欢这篇文章,可以考虑订阅我的通讯,还有更多内容等着你。
参考链接
deckarep/golang-set
: https://github.com/deckarep/golang-set- 比较: https://www.willem.dev/articles/how-to-compare-time-date/