为什么哈希冻结的字符串键?

根据规范 ,用作哈希键的字符串被复制和冻结。 其他可变对象似乎没有这么特别的考虑。 例如,使用数组键,可以使用以下内容。

a = [0] h = {a => :a} h.keys.first[0] = 1 h # => {[1] => :a} h[[1]] # => nil h.rehash h[[1]] # => :a 

另一方面,使用字符串键不能完成类似的操作。

 s = "a" h = {s => :s} h.keys.first.upcase! # => RuntimeError: can't modify frozen String 

当涉及到散列键时,为什么字符串被设计为与其他可变对象不同? 是否存在此规范变得有用的用例? 该规范还有哪些其他后果?


我实际上有一个用例,其中没有关于字符串的这种特殊规范可能是有用的。 也就是说,我用yaml gem读取了一个描述哈希的手动编写的YAML文件。 键可能是字符串,我想在原始YAML文件中允许不区分大小写。 当我读取文件时,我可能会得到这样的哈希:

 h = {"foo" => :foo, "Bar" => :bar, "BAZ" => :baz} 

并且我想将键规范化为小写以获得:

 h = {"foo" => :foo, "bar" => :bar, "baz" => :baz} 

做这样的事情:

 h.keys.each(&:downcase!) 

但由于上述原因,返回错误。

简而言之,只是Ruby试图变得更好。

当在哈希中输入密钥时,使用密钥的hash方法计算特殊数字。 Hash对象使用此数字来检索密钥。 例如,如果你问h['a']的值是什么,Hash会调用字符串’a’的hash方法,并检查它是否有为该数字存储的值。 当有人(你)改变字符串对象时会出现问题,因此字符串’a’现在是别的东西,让我们说’aa’。 哈希不会找到’aa’的哈希值。

散列的最常见键类型是字符串,符号和整数。 符号和整数是不可变的,但字符串不是。 Ruby试图通过加密和冻结字符串键来保护您免受上述混淆行为的影响。 我想这对其他类型没有做,因为可能会有令人讨厌的性能副作用(想想大型数组)。

请参阅ruby-core邮件列表上的这个主题以获得解释(奇怪的是,这恰好是我在邮件应用程序中打开邮件列表时遇到的第一封邮件!)。

我不知道你问题的第一部分,但是h这是第二部分的实际答案:

  new_hash = {} h.each_pair do |k,v| new_hash.merge!({k.downcase => v}) end h.replace new_hash 

这种代码有很多排列,

  Hash[ h.map{|k,v| [k.downcase, v] } ] 

是另一个(你可能知道这些,但有时最好采取实际的路线:)

不可变密钥通常是有意义的,因为它们的哈希码将是稳定的。

这就是为什么字符串是专门转换的,在这部分MRI代码中:

 if (RHASH(hash)->ntbl->type == &identhash || rb_obj_class(key) != rb_cString) { st_insert(RHASH(hash)->ntbl, key, val); } else { st_insert2(RHASH(hash)->ntbl, key, val, copy_str_key); } 

简而言之,在字符串键的情况下, st_insert2传递一个指向函数的指针,该函数将触发dup并冻结。

因此,如果我们理论上希望支持不可变列表和不可变哈希作为哈希键,那么我们可以将代码修改为这样的代码:

 VALUE key_klass; key_klass = rb_obj_class(key); if (key_klass == rb_cArray || key_klass == rb_cHash) { st_insert2(RHASH(hash)->ntbl, key, val, freeze_obj); } else if (key_klass == rb_cString) { st_insert2(RHASH(hash)->ntbl, key, val, copy_str_key); } else { st_insert(RHASH(hash)->ntbl, key, val); } 

其中freeze_obj定义为:

 static st_data_t freeze_obj(st_data_t obj) { return (st_data_t)rb_obj_freeze((VALUE) obj); } 

这样可以解决您观察到的特定不一致性,其中数组键是可变的。 然而,为了真正一致,更多类型的对象也需要变得不可变。

然而,并非所有类型。 例如,没有必要冻结像Fixnum这样的直接对象,因为实际上只有一个Fixnum实例对应于每个整数值。 这就是为什么只有String需要以这种方式特殊设置,而不是FixnumSymbol

字符串是一个特殊的例外,只是为了方便Ruby程序员,因为字符串经常被用作哈希键。

相反,其他对象类型没有像这样被冻结的原因,这无疑会导致行为不一致,这主要是为了方便Matz&Company不支持边缘情况。 实际上,相对较少的人会使用像数组或散列之类的容器对象作为散列键。 因此,如果您这样做,则由您在插入前冻结。

请注意,这并非严格意义上的性能,因为冻结非直接对象的行为只涉及在每个对象上出现的basic.flags位域上翻转FL_FREEZE位。 那当然是廉价的操作。

还要谈到性能,请注意,如果您要使用字符串键,并且您处于性能关键的代码段,则可能需要在插入之前冻结字符串。 如果不这样做,则会触发dup,这是一个更昂贵的操作。

更新 @sawa指出,将数组密钥简单地冻结意味着原始数组可能在密钥使用上下文之外意外地不可变,这也可能是一个令人不快的惊喜(尽管它可以使您正确使用数组作为哈希键,真的)。 因此,如果你猜测dup + freeze就是这样的话,那么你实际上会产生可观的性能成本。 第三方面,让它完全解冻,你得到OP的原始怪异。 周围的怪异。 Matz等人将这些边缘情况推迟给程序员的另一个原因。

你要问两个不同的问题:理论和实践。 Lain是第一个回答,但我想提供一些我认为适合你的实际问题的更合适,更懒惰的解决方案:

 Hash.new { |hsh, key| # this block get's called only if a key is absent downcased = key.to_s.downcase unless downcased == key # if downcasing makes a difference hsh[key] = hsh[downcased] if hsh.has_key? downcased # define a new hash pair end # (otherways just return nil) } 

Hash.new构造函数一起使用的块仅针对实际请求的缺失键进行调用。 上述解决方案也接受符号。