Go/Golang中的集合 – 使用映射和推荐的包

文摘   2024-09-26 23:41   上海  

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

使用空白标识符 _ 来丢弃第一个值,因为我们只关心元素是否存在。

如你在上面的例子中所看到的,即使 okfalse,第一个值也总是等于空结构体。

这是因为对映射的索引表达式对不存在的键返回零值。空结构体类型的零值就是一个空结构体。

在比较中使用集合

使用空结构体作为映射的值有一个缺点:在检查元素是否存在于集合中时,你总是需要一个变量。

这将导致在进行大量比较时代码更加冗长。

如果你关心可读性,并且内存使用不是问题,你可能仍然想使用 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* 构造函数。

这些返回的集合对于并发使用是不安全的,不使用互斥锁。

性能注意事项

集合的 IterIterator 方法返回一个从单独的 goroutine 中填充的通道。

在迭代进行时,不能进行写操作。

如果你运行大量迭代,这也会影响性能,因为每个新的迭代器都会产生一个新的 goroutine。请记住这一点。

不可比较类型

要构建一个集合,你需要一种比较值是否相等的方法。没有这样的比较,你将无法保证元素的唯一性,也无法创建集合。

有些类型不能使用 == 进行常规比较,不能作为集合元素。

Go 语言限制了某些类型的比较:例如,函数、映射和切片只能与 nil 进行比较。

在其他情况下,你的类型的相等性语义可能与 Go == 运算符使用的不同。

标准库中的一个例子是 time.Time 类型,它有一个自定义的 Equals 方法。== 运算符可以工作,但它不会 比较[2] 相同的时间瞬间。

如果我们将 time.Time 类型用作集合元素,结果可能与我们的预期大不相同。

存储这样的类型需要选择一个可比较类型的键。然后你需要能够将这个键转换或追踪回你的不可比较类型。

例如,对于我们的 time.Time 类型,我们可以考虑使用 UTC 格式的字符串或 UNIX 时间戳,并在需要时将其转换回 time.Time 类型。

总结

简短回顾:

  • Go 没有原生的集合数据类型,但你可以使用映射来实现它们。
  • 使用空结构体作为值来节省内存。
  • 使用映射时要考虑并发性。
  • deckarep/golang-set 提供了更全面的集合实现,具有差集、交集和 JSON 编组等功能。
  • 在使用类型作为集合元素时,要考虑如何比较这些类型。

这就是本文的全部内容,希望你学到了一些东西 :)

如果你喜欢这篇文章,可以考虑订阅我的通讯,还有更多内容等着你。

参考链接

  1. deckarep/golang-set: https://github.com/deckarep/golang-set
  2. 比较: https://www.willem.dev/articles/how-to-compare-time-date/

幻想发生器
图解技术本质
 最新文章