为什么哈希表的字符串键是不可更改的?

25
根据规范,用作哈希键的字符串会被复制并冻结。其他可变对象似乎没有这样的特殊考虑。例如,对于数组键,可以执行以下操作。
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!)

但是由于上述原因,这会返回一个错误。

看起来,根据我的需求,最好的做法是 h.keys.each{|s| h.store(s.downcase, h.delete(s))} - sawa
我只能猜测“为什么”。除了字符串比数组更常见之外,我认为冻结字符串会更容易实现。如果我懂Perl,我会查看Ruby是否在其散列行为上试图与Perl保持一致。如果我精通日语,我会查看键的冻结是何时实现的,并查看那是否是一个错误报告或邮件列表中的讨论结果(可能是用日语,因为这是Ruby早期历史上的事情)。 - Andrew Grimm
1
@AndrewGrimm 在这里说,数组和哈希不适合作为哈希的键,因为它们可以被修改,而字符串是冻结的,所以您不必调用rehash。与steenslag的答案一致。 - sawa
不确定这个方法是何时添加的,使用transform_keys!方法,你可以执行h.transform_keys!(&:downcase) - Sundeep
5个回答

23

简而言之,这只是 Ruby 尝试表现得友好。

当在 Hash 中输入一个键时,使用键的 hash 方法计算出一个特殊的数字。Hash 对象使用该数字来检索该键。例如,如果您问 h['a'] 的值是多少,Hash 会调用字符串 'a' 的 hash 方法,并检查是否为该数字存储了一个值。问题出现在当有人(你)改变字符串对象时,使得字符串 'a' 变成了其他的东西,比如 'aa'。Hash 将找不到 'aa' 的哈希值。

Hash 最常见的键类型是字符串、符号和整数。符号和整数是不可变的,但字符串是可变的。Ruby 试图通过复制和冻结字符串键来保护您免受上述混乱行为的影响。我想这不是针对其他类型完成的,因为可能会有严重的性能副作用(考虑大型数组)。


感谢您回答了这个问题的理论部分。 - Boris Stitnicky

4
请查看这个ruby-core邮件列表中的帖子以获取解释(令人惊讶的是,当我在我的邮件应用程序中打开邮件列表时,它恰好是我遇到的第一封邮件!)。

我对你问题的第一部分毫无头绪,但对于第二部分,以下是一个实用的答案:

  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] } ]

作为另一个(你可能已经意识到了这一点,但有时最好采取实用的方法:)


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会传递一个指向触发dup和freeze的函数的指针。
因此,如果我们理论上想要支持不可变列表和不可变哈希作为哈希键,那么我们可以将该代码修改为以下内容:
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需要以这种方式进行特殊处理,而不是Fixnum和Symbol。
String是一个特殊例外,仅仅是作为Ruby程序员方便起见,因为字符串经常用作哈希键。
相反,其他对象类型之所以没有像这样被冻结,尽管会导致不一致的行为,主要是为了Matz和公司的方便而不支持边缘情况。实际上,相对较少的人会使用像数组或哈希这样的容器对象作为哈希键。因此,如果您这样做,您需要在插入之前进行冻结。
请注意,这不是严格关于性能的问题,因为冻结非立即对象的操作只涉及将basic.flags位字段上的FL_FREEZE位翻转。这当然是一个便宜的操作。
另外,在谈论性能时,请注意,如果您将使用字符串键,并且在代码的性能关键部分,则可能需要在插入之前冻结字符串。如果不这样做,则会触发dup,这是更昂贵的操作。
更新@sawa指出,将数组键保持简单地冻结意味着原始数组在键使用上下文之外可能会意外不可变,这也可能是一个不愉快的惊喜(尽管另一方面,如果您真的使用数组作为哈希键,那么这就是你应得的)。如果您因此推断dup + freeze是解决方法,那么您实际上会承担可能明显的性能成本。另一方面,将其完全保持未冻结状态,您将获得OP的原始怪异现象。到处都是怪异行为。这是Matz等人将这些边缘情况推迟到程序员的另一个原因。

1
冻结原始键而不进行复制会很容易引起混乱。如果一个键将自动被冻结,复制是必须的。即使冻结很便宜,复制数组等操作仍然很昂贵,因此看起来这确实是一个性能问题。你最后一段的信息很有价值。如果一个字符串从一开始就被冻结,那么在用作哈希键时是否也会被复制? - sawa
1
关于确保它是否是这样工作的,是的,您可以在此处查看:if (OBJ_FROZEN(orig)) return orig; 位于 rb_str_new_frozen() 顶部,当前位于此处:github.com/ruby/ruby/blob/trunk/string.c#L673 - manzoid
1
我并不完全同意“复制将是必须的”这一说法……如果设置哈希键的一致行为是它们都被简单地冻结,那么像尝试使用数组作为键然后稍后突变它的人很快就会发现这种用法不起作用,当更新尝试失败时会有明显的提示。一致性有时可能会很有帮助。现在,我也能理解你的观点……只是似乎有争议要优化什么——一致性、性能、保护程序员免受做奇怪事情的后果等等。 - manzoid

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构造函数时,块仅在请求实际缺少的键时调用。上述解决方案还接受符号。

0
一个非常古老的问题 - 但如果有人试图回答问题中“如何避免哈希键冻结字符串”的部分...
你可以采用一个简单的技巧来解决字符串特殊情况:
class MutableString < String
end

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

除非您正在创建密钥,否则它不起作用,除非您小心确保它不会对任何严格要求类为“String”的内容造成任何问题


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接