24

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

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

当涉及到哈希键时,为什么将字符串设计为与其他可变对象不同?是否有任何用例可以使该规范变得有用?该规范还有哪些其他后果?


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

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

我想将键规范化为小写以获得这个:

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

通过做这样的事情:

h.keys.each(&:downcase!)

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

4

5 回答 5

22

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

当在 Hash 中输入一个键时,会使用hash键的方法计算一个特殊的数字。Hash 对象使用这个数字来检索密钥。例如,如果您询问 value 的值h['a']是多少,Hash 会调用hashstring 'a' 的方法并检查它是否为该数字存储了一个值。当某人(你)改变字符串对象时,问题就出现了,所以字符串'a'现在是别的东西,比如说'aa'。哈希找不到“aa”的哈希数。

最常见的散列键类型是字符串、符号和整数。符号和整数是不可变的,但字符串不是。Ruby 试图通过复制和冻结字符串键来保护您免受上述令人困惑的行为。我猜它不适用于其他类型,因为可能会有令人讨厌的性能副作用(想想大型数组)。

于 2012-10-24T10:34:27.930 回答
4

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

这就是为什么在这部分 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传递一个指向将触发复制和冻结的函数的指针。

因此,如果我们理论上想要支持不可变列表和不可变哈希作为哈希键,那么我们可以将该代码修改为如下所示:

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需要以这种方式进行特殊处理,而不是Fixnumand Symbol

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

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

请注意,这并不是严格意义上的性能,因为冻结非立即对象的行为仅涉及翻转每个对象上存在的位域FL_FREEZE上的位。basic.flags这当然是一种廉价的操作。

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

更新@sawa 指出,将您的数组键简单地冻结意味着原始数组可能在键使用上下文之外出乎意料地不可变,这也可能是一个令人不快的惊喜(尽管它可以为您提供正确的使用数组作为哈希键,真的)。如果您因此推测 dup + freeze 是解决此问题的方法,那么您实际上可能会招致明显的性能成本。另一方面,让它完全解冻,你会得到 OP 的原始怪异。诡异无处不在。Matz 等人将这些边缘情况推迟到程序员的另一个原因。

于 2012-10-24T23:34:20.257 回答
4

请参阅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] } ]

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

于 2012-10-24T09:41:05.920 回答
2

你问两个不同的问题:理论和实践。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仅针对那些实际请求的缺失键调用。上述解决方案也接受符号。

于 2012-10-24T10:18:08.210 回答
0

一个非常古老的问题 - 但如果其他人试图回答“我怎样才能绕过哈希键正在冻结字符串”这个问题的一部分......

解决 String 特殊情况的一个简单技巧是:

class MutableString < String
end

s = MutableString.new("a")
h = {s => :s}
h.keys.first.upcase! # => RuntimeError: can't modify frozen String
puts h.inspect

除非您正在创建密钥,否则它不起作用,并且除非您小心谨慎,否则它不会对严格要求该类完全为“字符串”的任何内容造成任何问题

于 2021-12-16T03:25:21.603 回答