【问题标题】:How does Dictionary use the Equatable protocol in Swift?Dictionary 如何在 Swift 中使用 Equatable 协议?
【发布时间】:2015-10-18 09:19:58
【问题描述】:

为了解决this question,我一直在玩一个实现哈希协议的自定义结构。我正在尝试查看等效运算符重载 (==) 的调用次数,具体取决于填充 Dictionary 时是否存在哈希冲突。

更新

@matt 编写了一个更简洁的自定义结构示例,该结构实现了 Hashable 协议,并显示了hashValue 和== 被调用的频率。我在下面复制his code。要查看我的原始示例,请查看edit history。

struct S : Hashable {
    static func ==(lhs:S,rhs:S) -> Bool {
        print("called == for", lhs.id, rhs.id)
        return lhs.id == rhs.id
    }
    let id : Int
    var hashValue : Int {
        print("called hashValue for", self.id)
        return self.id
    }
    init(_ id:Int) {self.id = id}
}
var s = Set<S>()
for i in 1...5 {
    print("inserting", i)
    s.insert(S(i))
}

这会产生结果:

/*
inserting 1
called hashValue for 1
inserting 2
called hashValue for 2
called == for 1 2
called hashValue for 1
called hashValue for 2
inserting 3
called hashValue for 3
inserting 4
called hashValue for 4
called == for 3 4
called == for 1 4
called hashValue for 2
called hashValue for 3
called hashValue for 1
called hashValue for 4
called == for 3 4
called == for 1 4
inserting 5
called hashValue for 5
*/

由于 Hashable 使用 Equatable 来区分散列冲突(我假设无论如何),我希望 func ==() 仅在存在散列冲突时被调用。但是,在上面的@matt 示例中根本没有散列冲突,但仍在调用==。在我的其他强制哈希冲突的实验中(请参阅此问题的编辑历史记录),== 似乎被称为随机次数。

这是怎么回事?

【问题讨论】:

  • 我讨厌给出这个答案,甚至是评论,但这是一个 Swift 内部实现细节。他们可以根据需要优化类型,只要它符合文档中的 Dictionary API。并且这些文档不保证多久检查一次密钥是否相等——它们只要求您提供== 接口。我想我们会在今年晚些时候 Swift 开源时了解更多。另外,请参阅我对您的 other question 的类似评论。
  • 这是一个更简单的测试(我认为):gist.github.com/mattneub/430fef70e3496f5ce6917aa35c98f419 输出非常明确地说明了每次插入调用 hashValue 和 == 的次数。
  • @matt,是的,这是一个更简单、更清晰的测试。但现在我比我想象的更困惑。您的示例没有哈希冲突,对吗?然而== 仍然被调用。我之前的假设是 == 只被调用来处理哈希冲突情况。
  • 是的,我也很惊讶。我正在考虑向 bugs.swift.org 提交错误报告,除非你愿意这样做——毕竟,这是你的主意,所以我不想窃取它。 (另外,我尝试阅读源代码,但无法弄清楚底层 VariantBuffer 的实现位置。所以我无法回答您的实际问题。)
  • 看看你的想法:bugs.swift.org/browse/SR-3330

标签: swift dictionary hash-collision hashable equatable


【解决方案1】:

我在这里从bugs.swift.org 复制我的答案。它谈到了集合,但细节以同样的方式适用于字典。

在散列集合中,只要桶的数量小于键空间,就会发生冲突。当您在未指定最小容量的情况下创建新 Set 时,该集合可能只有一个存储桶,因此当您插入第二个元素时,会发生冲突。然后插入方法将决定是否应该增加存储,使用称为负载因子的东西。如果存储空间增加,则必须将现有元素迁移到新的存储缓冲区。那时您会看到插入 4 时对 hashValue 的所有额外调用。

如果桶数等于或高于元素数,您仍然看到对 == 的调用多于预期的原因与桶索引计算的实现细节有关。 hashValue 的位在模运算之前混合或“混洗”。这是为了减少具有不良哈希算法的类型的过度冲突。

【讨论】:

  • 谢谢。所以在这种情况下,我有责任在添加之前增加集合的容量吗?
  • 如果您提前知道元素的数量,我总是会将该值传递给初始化程序。如果可以节省大量周期。
  • 虽然这个答案仍然非常有用,但由于@matt 提到的新信息,我至少暂时取消选中它作为接受的答案。
【解决方案2】:

嗯,这就是你的答案:

https://bugs.swift.org/browse/SR-3330?focusedCommentId=19980&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-19980

实际情况:

  • 我们只在插入时散列一次值。
  • 我们不使用散列来比较元素,只使用 ==。仅当您存储散列时,使用散列进行比较才是合理的,但是 这意味着每个字典的内存使用量更多。一种妥协 需要评估。
  • 我们尝试在评估 Dictionary 是否适合该元素之前插入该元素。这是因为该元素可能已经在 字典,在这种情况下,我们不需要更多容量。
  • 当我们调整字典大小时,我们必须重新散列所有内容,因为我们没有存储散列值。

所以你看到的是:

  • 搜索键的一个哈希
  • 一些 ==(搜索空格)
  • 集合中每个元素的哈希值(调整大小)
  • 搜索键的一个哈希(实际上完全浪费,但考虑到它只发生在 O? 重新分配之后,这没什么大不了的)
  • 一些 =='s(在新缓冲区中搜索空间)

我们都完全错了。他们根本不使用散列 - only == - 来决定这是否是一个不同的键。然后在集合增长的情况下进行第二轮调用。

【讨论】:

  • 那么当我们想让一个自定义结构在字典中可用时,让我们实现 Hashable 协议的意义何在?似乎 Equatable 协议就足够了。
  • @Suragch 基本上哈希表用于获取。但在这里,我们存储,这是一个完全不同的蜡球。
  • 当我们调整字典大小时,我们必须重新散列所有内容,因为我们没有存储散列值。你能解释一下吗? @Suragch 这是否适用于问题中的示例
  • @Honey,这确实适用于问题中的示例,即何时在字典中使用该结构。但是,老实说,我仍然不明白哈希的意义。假设它们用于获取,但如果它们从未存储,它们如何用于获取任何东西?如果它们被存储(至少足够长以用于获取),那么字典为什么需要重新散列所有内容以调整大小?
  • @Suragch 您是否找到了问题的答案(在上面的评论中提到)?
【解决方案3】:

虽然该问题已经得到解答,但这些答案在 cmets 中引发了一些其他问题。 @Suragch 询问我是否会将我的 cmets 包含在新答案中,以帮助其他可能对 Equatable 和 Hashable 之间的关系感到困惑的人。首先我必须说我对底层机制只有初步的了解,但我会尽力解释我所知道的。

Equatable 是一个相当简单的概念,我们只需查看 Swift 文档即可了解此协议的简洁定义:

Equatable:可以比较值相等的类型。

如果一个类型是相等的,我们可以用==比较两个实例。很简单。

Hashable 是另一回事。当我第一次在 Swift 文档中阅读该协议的定义时,我真的笑出了声:

Hashable:一种可以散列成Hasher以产生整数散列值的类型。

如果这还没有为您解决问题,那么您并不孤单!无论如何,如果== 用于确定一个实例是否真正唯一(它需要在集合中或用作字典中的键时),我们为什么需要Hashable? (这是@Suragch 在 cmets 中提出的问题。)

这个问题涉及散列集合(或散列表)的基本性质,如集合和字典。考虑一下为什么您可能首先选择字典而不是数组。当您需要通过已知索引以外的方式查找或引用实例时,您会选择字典,对吗?与数组不同,字典的元素不是按顺序编号的,这使得查找内容变得更加困难。如果我们只有==,我们将不得不逐个迭代字典的元素,并且随着字典大小的增加,这将变得越来越慢。

这就是哈希函数的魅力所在。哈希函数(或哈希器)将唯一键作为参数并返回元素的地址。你怎么能确定它会返回正确的地址?因为它与最初用于设置该地址的函数相同!创建字典时,它获取每个键(或者更确切地说,每个键的唯一标识属性),根据一些秘密公式将它们混合起来,并为每个键吐出一个数字(哈希值),然后从这些数字中得出每个元素的新索引。稍后,当您想要查找其中一个元素时,散列器获取相同的参数,因此它返回相同的值。而且因为您所做的只是调用一个函数,所以无论集合有多大,都不会涉及迭代,而且结果很快。

不过有一个问题。没有哈希是完美的。即使你给它提供唯一的参数,偶尔它也可能为两个完全不同的元素返回相同的哈希值——哈希冲突。发生这种情况时,它需要检查这两个元素是否真的相同,当然,它通过调用== 来做到这一点。

但是在您的示例中,您直接操纵了hashValue(这是人们在hash(into:) 出现之前所做的那种事情!)它仍然称为==!我的意思是,理论上,它不应该这样做,因为没有任何碰撞。但答案就在comment quoted by robinkunde:

在散列集合中,只要桶的数量小于键空间,就会发生冲突

虽然通常我们不需要担心 Swift 内置哈希函数的实现细节,但这个特定的细节很重要……在幕后,哈希器需要另一个参数,那就是集合的大小。如果大小发生变化(当您在一个范围内迭代并将新元素插入到集合中时它会反复发生),则哈希器可以尝试推入比索引插槽(或存储桶)更多的元素,并且您会遇到冲突,或者它需要使用足够的内存从头开始重新散列所有内容,以便为每个元素提供唯一索引。正如comment quoted by matt 所说:

在评估 Dictionary 是否适合该元素之前,我们会尝试插入该元素。这是因为该元素可能已经在 Dictionary 中,在这种情况下我们不需要更多容量。


这就是我尝试对散列集合、散列函数和== 方法之间的关系以及意外行为的原因进行更简单的解释。但这一切又给我提出了一个问题……为什么我们需要手动声明Hashable? Apple 不能采用某种算法来为所有 Equatable 类型合成 Hashable 一致性吗?我的意思是,hash(into:) documentation 说:

用于哈希的组件必须与组件相同 在您的类型的 == 运算符实现中进行比较。

如果组件需要相同,那么 Swift 不能仅从 Equatable 的实现中推断出我们的意图吗?我不确定为什么它不能为那些不想更多控制细节的人提供这样的便利(类似于它提供默认初始化程序的方式)。也许有一天 Swift 会提供这个?不过现在,他们将它们作为单独的关注点,Hashable 继承自 Equatable。

【讨论】:

  • 不错的一个!感谢您留下答案。留下答案的好处是,随着时间的推移,如果有错误,其他人会纠正你,你会学到一些东西。我仍然不太了解哈希,无法知道您是对还是错,但这听起来不错,我喜欢您使用类比来解释一个令人困惑的话题。
  • 不用担心。我也喜欢类比,但在重读这个类比时,感觉有点做作,所以我改写了我的答案,以一种我认为更好地解释它的方式——并且没有参考魔法精灵。 ;-) 写它也有助于我巩固这些概念。是的,如果我对任何细节有误,我总是愿意得到纠正。
猜你喜欢
  • 2017-06-27
  • 1970-01-01
  • 1970-01-01
  • 2015-11-02
  • 1970-01-01
  • 2016-09-29
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多