【问题标题】:Is it right to conform Hashable by only taking id into consideration?仅考虑 id 来符合 Hashable 是否正确?
【发布时间】:2021-01-24 11:39:44
【问题描述】:

我在网上遇到过很多例子,当他们试图符合Hashable时,他们只考虑id。例如https://www.raywenderlich.com/8241072-ios-tutorial-collection-view-and-diffable-data-sourcehttps://medium.com/@JoyceMatos/hashable-protocols-in-swift-baf0cabeaebd,...

/// Copyright (c) 2020 Razeware LLC
/// 
/// Permission is hereby granted, free of charge, to any person obtaining a copy
/// of this software and associated documentation files (the "Software"), to deal
/// in the Software without restriction, including without limitation the rights
/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
/// copies of the Software, and to permit persons to whom the Software is
/// furnished to do so, subject to the following conditions:
/// 
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
/// 
/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish,
/// distribute, sublicense, create a derivative work, and/or sell copies of the
/// Software in any work that is designed, intended, or marketed for pedagogical or
/// instructional purposes related to programming, coding, application development,
/// or information technology.  Permission for such use, copying, modification,
/// merger, publication, distribution, sublicensing, creation of derivative works,
/// or sale is expressly withheld.
/// 
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
/// THE SOFTWARE.

import UIKit

class Video: Hashable {
  var id = UUID()
  var title: String
  var thumbnail: UIImage?
  var lessonCount: Int
  var link: URL?
  
  init(title: String, thumbnail: UIImage? = nil, lessonCount: Int, link: URL?) {
    self.title = title
    self.thumbnail = thumbnail
    self.lessonCount = lessonCount
    self.link = link
  }
  // 1
  func hash(into hasher: inout Hasher) {
    // 2
    hasher.combine(id)
  }
  // 3
  static func == (lhs: Video, rhs: Video) -> Bool {
    lhs.id == rhs.id
  }
}

我想知道,这是符合Hashable 的正确方法吗?我认为我们应该考虑所有类成员变量?

例如,在func hash/func == 中仅使用id,将产生以下不当行为。

我们将遇到 2 个内容不同的对象,但 func == 在比较 2 个内容不同的对象时会返回 true。

struct Dog: Hashable {
    let id = UUID()
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: Dog, rhs: Dog) -> Bool {
        lhs.id == rhs.id
    }
}


var dog0 = Dog(name: "dog", age: 1)
var dog1 = dog0

/*
 dog0 is -5743610764084706839, dog, 1
 dog1 is -5743610764084706839, dog, 1
 compare dog0 with dog1 is true
 */
print("dog0 is \(dog0.hashValue), \(dog0.name), \(dog0.age)")
print("dog1 is \(dog1.hashValue), \(dog1.name), \(dog1.age)")
print("compare dog0 with dog1 is \(dog0 == dog1)")


dog1.name = "another name"
dog1.age = 9

// Same id, but different content!

/*
 dog0 is -5743610764084706839, dog, 1
 dog1 is -5743610764084706839, another name, 9
 compare dog0 with dog1 is true
 */
print("dog0 is \(dog0.hashValue), \(dog0.name), \(dog0.age)")
print("dog1 is \(dog1.hashValue), \(dog1.name), \(dog1.age)")
print("compare dog0 with dog1 is \(dog0 == dog1)")

我想知道,只考虑id 来符合Hashable 是否正确?


p/s

我尝试从 Java 等其他语言中寻找关于哈希码生成的一般建议。这就是他们流行的 Effective Java 书中所写的内容。

不要试图从哈希码中排除重要的字段 计算以提高性能。而得到的哈希函数 可能运行得更快,其质量差可能会降低哈希表的性能 到了它们变得无法使用的地步。特别是哈希 函数可能会遇到大量实例 主要区别在于您选择忽略的区域。如果发生这种情况, 哈希函数会将所有这些实例映射到一些哈希码,并且 应该以线性时间运行的程序将改为以二次方式运行 时间。这不仅仅是一个理论问题。在 Java 2 之前, 字符串散列函数最多使用 16 个均匀分布的字符 整个字符串,从第一个字符开始。对于大 分层名称的集合,例如 URL,此函数 完全显示了前面描述的病理行为。

【问题讨论】:

  • 视情况而定。从 diffable 数据源的角度来看,如果它是可变的,那么内容很重要,所以combinehash(into:) 方法中的所有相关结构成员。

标签: swift hashable


【解决方案1】:

TL;DR:这个散列函数是不必要的,但是合法的,并且可以说是理想的。这个 == 是不正确的,尽管在教程中很常见,因为它破坏了 Equatable 所要求的可替代性,正如您所建议的那样。

但是,正如 matt 所指出的,可区分的数据源可能无论如何都需要这样做。这并不能使它变得好,但它可能使它成为必要。 (请阅读下面的所有 matt 的 cmets。它们提供了很多重要的上下文。具体参考 diffable 数据源,请参阅他的回答;我对 diffable 数据源不是特别熟悉。)


我建议转向文档,其中列出了这一点。

首先,Hashable

散列一个值意味着将它的基本组成部分输入一个散列函数,由 Hasher 类型表示。基本组件是那些有助于 Equatable 类型实现的组件。两个相等的实例必须以相同的顺序将相同的值提供给 hash(into:) 中的 Hasher。

最重要的是Hashable与Equatable保持一致。两件事必须永远相等,但具有不同的哈希值。

反之则不成立。两个不相等的事物具有相同的哈希是完全有效的。事实上,这是散列的基本事实,称为pigeonhole principle。一个好的哈希通过避免不必要的相等检查来提高性能。但以下hash(into:) 函数始终有效:

func hash(into hasher: inout Hasher) {
    hasher.combine(0)
}

这只是意味着每个值都具有相同的哈希值,因此系统将始终调用 ==。这对性能不利(在服务器应用程序中可能会转化为称为哈希泛洪的拒绝服务攻击)。但这是合法的。

如果这是合法的,当然只是散列 id 是合法的。

但是……

这将我们带到Equatable and its docs,以及最重要的一段(添加了重点):

平等意味着可替代性——任何两个比较相等的实例都可以在取决于它们的值的任何代码中互换使用。 为了保持可替换性,== 运算符应考虑 Equatable 类型的所有可见方面。 不鼓励公开 Equatable 类型的非值方面而不是类标识,并且应明确指出任何公开的方面在文档中。

只有在任何情况下都可以相互替换时,才能认为值相等,并且不会影响程序的正确性。显然,在您的示例中,这是不正确的。事实上,对于具有可变公共属性的类型来说,它永远不会是真的(尽管许多教程都犯了这个错误)。所以你的 == 是不正确的。但是您的哈希函数很好,可以说是理想的。它的目标是快速检查不等式,从而最大限度地减少冲突。如果 id 相同,您仍然需要检查其余的值,但如果它们不同,您知道它不会相等。

如果您的 Dog 类型是不可变的(nameagelet 而不是 var),那么以这种方式实现 == 可能是可以接受的。手动设置id 是不可能的,因此不可能获得两个具有相同id 但值不同的值。但我不会这样做,除非你能表现出显着的性能提升。它把正确性挂在一个太微妙的要求上。例如,如果一个扩展添加了一个允许直接设置idinit,它会使你的== 无效。 IMO 太脆弱了。

私有可变状态怎么样?只要这只是出于性能目的(记忆/缓存),那么省略 == (和散列)就可以了。但是,如果这种内部状态可以影响外部可见的行为,那么它就需要成为 == 的一部分。

好消息是,大多数时候您无需担心。 Swift 的自动实现开箱即用地为您正确处理,并比较所有属性。因此,在您的 Dog 示例中,最好的解决方案是删除方法(我相信您已经意识到这一点;只是为阅读的人们说明它)。只要有可能,我强烈建议使用 Hashable 的默认一致性并避免自己编写。

但在你必须自己实现的情况下,规则很简单:

  • 两个相等的值必须在所有情况下都可以完全替代,而不会影响正确性(尽管替代可能会影响性能)
  • 两个相等的值必须始终具有相同的哈希

准则也相当简单:散列应该很快,同时尽量减少冲突。


对于 == 的这些错误实现,我看到的一个论点是试图让 Set 工作得很好。 IMO,这是对 Set 和 Equatable 的滥用,并且不承诺以预期的方式工作(如果您插入具有相同标识符但属性不同的重复值,则不确定哪些值将在集合中)。您不应该为了使用特定的数据结构而扭曲 Equatable。你应该使用符合你的意思的数据结构。

在常见情况下,正确的工具是 Dictionary as [ID: Value]。它表达了您真正的意思:ID 和该 ID 的单个值之间的映射,而不是唯一值的无序包。

使用 Dictionary 而不是 Set 可能会消耗内存(因为您必须复制 ID)。但是你应该只在证明有问题需要解决之后尝试解决这个问题。


另外,请参阅下面的马特评论。我没有花很多时间在新的 diffable 数据源上。我记得当我第一次看到他们时,我担心他们可能会滥用 Equatable。如果这是真的,那么您可能不得不滥用 Equatable 来使用它们,这将解释一些这样做的教程。这并不能使它成为好的 Swift,但 Apple 框架可能需要它。


随着我对 Apple 代码的研究更多(参见 matt 对许多人的回答),我注意到它们都遵循我上面讨论的规则:它们是不可变的,并且您不能在初始化期间设置 UUID。这种结构使得两个值不可能具有相同的 id 但其他值不同,因此检查 id 总是足够的。但是,如果您使值可变,或者您允许 id 为 let id = UUID() 以外的任何值,那么这种构造就会变得危险。

【讨论】:

  • 问题是一个可区分的数据源使用== 来决定一个项目标识符是否已经存在。因此,如果 Hashable 和 Equatable 不使用完全相同的属性,它就会中断。这是大多数基于 UUID 的 Hashable/Equatable 声明都试图解决的问题(至少包括 OP 引用的问题之一)。
  • @matt 添加了一些评论。我认为您的观点非常关键,可能会影响人们所看到的;我只是没有花太多时间在可区分的数据源上。
  • 好吧,公平地说,OP 指出的第一个教程中的所有内容都非常错误,你可能会晕倒。
  • 不过,正是苹果颁布了这种处事方式。请查看developer.apple.com/videos/play/wwdc2019/220/?time=832(时间很重要,所以你来对地方了)并(尽你所能)查看代码。您将看到 Apple 正在完全执行您说不该做的事情。他们定义了 hash(into:)==依赖于 UUID 标识符。请记住,这是 ur-example,任何公众看到的关于如何创建可区分数据源的第一个示例。因此,人们都在按照 Apple 所说的去做。
  • 抱歉,我一直在喋喋不休,但我突然想到,您关于 diffable 数据源中的可变性的问题可能可以通过我的示例来回答:stackoverflow.com/a/64164508/341994
【解决方案2】:

这完全没问题。 Hashable 只有一个要求:如果a == ba.hashValue == b.hashValue 也必须为真。这在此处实现,因此您的结构将用作字典键或集合成员。

请注意,如果您的 hash(into:) 没有将任何数据(或仅常量数据)组合到哈希器中,这也可以实现。这会使哈希表查找变慢,但它们仍然可以工作。

另一种选择是比较您的== 实现中的所有字段,但仅使用它们的子集在hash(into:) 中进行散列。这仍然遵循规则(当然不允许反过来)。这可能对性能优化很有用,但也可能会损害性能。取决于您要散列的数据的分布。

【讨论】:

    【解决方案3】:

    仅使用属性子集来实现Hashable 一致性是否正确完全取决于您的要求。

    如果对于某个对象,相等性实际上仅由单个变量(或变量子集)定义,则将变量子集用于Hashable(和Equatable 一致性)是正确的。

    但是,如果需要一个类型的所有属性来决定两个实例是否相等,那么您应该使用所有属性。

    【讨论】:

      【解决方案4】:

      拥有具有多个属性(包括 UUID)的类型很好,其中对 Hashable 和 Equatable 的一致性仅取决于 UUID,而不取决于任何其他属性。 Apple 在他们自己的代码中使用了这种模式。从这里下载 Apple 的示例代码:

      https://docs-assets.developer.apple.com/published/6840986f9a/ImplementingModernCollectionViews.zip

      查看 WiFiController.Network 结构、MountainsController.Mountain 结构、OutlineViewController.OutlineItem 类和 InsertionSortArray.SortNode 结构。他们都做同样的事情。所以,所有这些代码都是 Apple 的:


      struct Network: Hashable {
          let name: String
          let identifier = UUID()
      
          func hash(into hasher: inout Hasher) {
              hasher.combine(identifier)
          }
          static func == (lhs: Network, rhs: Network) -> Bool {
              return lhs.identifier == rhs.identifier
          }
      }
      

      struct Mountain: Hashable {
          let name: String
          let height: Int
          let identifier = UUID()
          func hash(into hasher: inout Hasher) {
              hasher.combine(identifier)
          }
          static func == (lhs: Mountain, rhs: Mountain) -> Bool {
              return lhs.identifier == rhs.identifier
          }
          func contains(_ filter: String?) -> Bool {
              guard let filterText = filter else { return true }
              if filterText.isEmpty { return true }
              let lowercasedFilter = filterText.lowercased()
              return name.lowercased().contains(lowercasedFilter)
          }
      }
      

      class OutlineItem: Hashable {
          let title: String
          let subitems: [OutlineItem]
          let outlineViewController: UIViewController.Type?
      
          init(title: String,
               viewController: UIViewController.Type? = nil,
               subitems: [OutlineItem] = []) {
              self.title = title
              self.subitems = subitems
              self.outlineViewController = viewController
          }
          func hash(into hasher: inout Hasher) {
              hasher.combine(identifier)
          }
          static func == (lhs: OutlineItem, rhs: OutlineItem) -> Bool {
              return lhs.identifier == rhs.identifier
          }
          private let identifier = UUID()
      }
      

      struct SortNode: Hashable {
          let value: Int
          let color: UIColor
      
          init(value: Int, maxValue: Int) {
              self.value = value
              let hue = CGFloat(value) / CGFloat(maxValue)
              self.color = UIColor(hue: hue, saturation: 1.0, brightness: 1.0, alpha: 1.0)
          }
          private let identifier = UUID()
          func hash(into hasher: inout Hasher) {
              hasher.combine(identifier)
          }
          static func == (lhs: SortNode, rhs: SortNode) -> Bool {
              return lhs.identifier == rhs.identifier
          }
      }
      

      【讨论】:

        【解决方案5】:

        这是真的。您的代码对 hashable 有一个要求,当您使用 dog == dog1 时,它只比较 dog.id == dog1.id

        如果您想检查结构的所有字段,请在== 方法中比较该字段。

        static func == (lhs: Dog, rhs: Dog) -> Bool {
            lhs.id == rhs.id && lhs.name == rhs.name && lhs.age == rhs.age
        }
        

        【讨论】:

          【解决方案6】:

          在我看来,您询问的更多是 Equatable,而不是 Hashable。 Rob Napier 为您提供了一个很棒的答案。

          教程中的== 函数不正确(尽管它可能满足用例的要求)。它应该被排除在外。

          但是一旦您将Equatable 关闭,这是一个很好的默认设置……

          public extension Hashable where Self: Identifiable {
            func hash(into hasher: inout Hasher) {
              hasher.combine(id)
            }
          }
          

          ...尤其是对于引用类型,您可以使用另一个可爱的默认值。

          public extension Equatable where Self: AnyObject {
            static func == (class0: Self, class1: Self) -> Bool {
              class0 === class1
            }
          }
          

          【讨论】:

          • 好吧,我对 Rob Napier 的回答提出了同样的反对意见。仅基于 UUID 标识符同时定义 hash(into:)==完全 Apple 告诉你的。
          • 这并不总是正确的。 (例如,这里的 VideoDog 类型不正确。)我在 Rob 的答案中回复了 cmets。
          猜你喜欢
          • 2020-05-03
          • 1970-01-01
          • 1970-01-01
          • 2020-11-26
          • 2017-07-11
          • 1970-01-01
          • 1970-01-01
          • 2019-09-09
          • 2010-09-07
          相关资源
          最近更新 更多