💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# **第 15 章 散列类** 在本章将详细介绍散列(Hash)类。 - **复习散列** 简略地介绍散列的相关用法。 - **散列的创建方法** 介绍如何创建散列。 - **获取、设定键值** 介绍批量获取键值的方法。 - **条件判断** 介绍判断键值是否在散列中存在的方法。 - **查看大小** 介绍查看散列大小的方法。 - **初始化** 比较散列的初始化与新创建有何异同。 - **使用例子** 以计算单词数量的程序为例,介绍散列的用法。 ### **15.1 复习散列** 在复习散列前,我们再次回顾一下数组的用法。 通过索引可以获取数组元素或对其赋值。 ~~~ person = Array.new person[0] = "田中一郎" person[1] = "佐藤次郎" person[2] = "木村三郎" p person[1] #=> "佐藤次郎" ~~~ 散列与数组一样,都是表示对象集合的对象。数组通过索引访问对象内的元素,而散列则是利用键。索引只能是数值,而键则可以是任意对象。通过使用键,散列就可以实现对元素的访问与赋值。 ~~~ person = Hash.new person["tanaka"] = "田中一郎" person["satou"] = "佐藤次郎" person["kimura"] = "木村三郎" p person["satou"] #=> "佐藤次郎" ~~~ 在本例中,`tanaka`、`satou` 等字符串就是键,对应的值为 `"田中一郎 "`、`"佐藤次郎 "`。散列中 `[]` 的用法也与数组非常相似。 ### **15.2 散列的创建** 与数组一样,创建散列的方法也有很多。其中下面两种是最常用到的。 ### **15.2.1 使用 {}** 使用字面量直接创建散列。 **{ 键 => 值}** 像下面那样指定键值对,键值对之间用逗号(`,`)隔开。 ~~~ h1 = {"a"=>"b", "c"=>"d"} p h1["a"] #=> "b" ~~~ 另外,用符号作为键时, **{ 键: 值}** 也可以采用上述定义方法。 ~~~ h2 = {a: "b", c: "d"} p h2 #=> {:a=>"b", :c=>"d"} ~~~ ### **15.2.2 使用 Hash.new** Hash.new 是用来创建新的散列的方法。若指定参数,则该参数值为散列的默认值,也就是指定不存在的键时所返回的值。没指定参数时,散列的默认值为 nil。 ~~~ h1 = Hash.new h2 = Hash.new("") p h1["not_key"] #=> nil p h2["not_key"] #=> "" ~~~ 散列的键可以使用各种对象,不过一般建议使用下面的对象作为散列的键。 - **字符串(`String`)** - **数值(`Numeric`)** - **符号(`Symbol`)** - **日期(`Date`)** 更多详细内容请参考专栏《关于散列的键》。 ### **15.3 值的获取与设定** 与数组一样,散列也是用 `[]` 来实现与键相对应的元素值的获取与设定的。 ~~~ h = Hash.new h["R"] = "Ruby" p h["R"] #=> "Ruby" ~~~ 另外,我们还可以用 `store` 方法设定值,用 `fetch` 方法获取值。下面的例子的执行结果与上面的例子是一样的。 ~~~ h = Hash.new h.store("R", "Ruby") p h.fetch("R") #=> "Ruby" ~~~ 使用 `fetch` 方法时,有一点与 `[]` 不一样,就是如果散列中不存在指定的键,程序就会发生异常。 ~~~ h = Hash.new p h.fetch("N") #=> 错误(IndexError) ~~~ 如果对 `fetch` 方法指定第 2 个参数,那么该参数值就会作为键不存在时散列的默认值。 ~~~ h = Hash.new h.store("R", "Ruby") p h.fetch("R", "(undef)") #=> "Ruby" p h.fetch("N", "(undef)") #=> "(undef)" ~~~ 此外,`fetch` 方法还可以使用块,此时块的执行结果为散列的默认值。 ~~~ h = Hash.new p h.fetch("N"){ String.new } #=> "" ~~~ ### **15.3.1 一次性获取所有的键、值** 我们可以一次性获取散列的键、值。由于散列是键值对形式的数据类型,因此获取键、值的方法是分开的。此外,我们还可以选择是逐个获取,还是以数组的形式一次性获取散列的所有键、值,不过这两种情况下使用的方法是不同的(表 15.1)。 **表 15.1 获取散列的键与值的方法** <table border="1" data-line-num="127 128 129 130 131 132" width="90%"><thead><tr><th> <p class="表头单元格"> </p> </th> <th> <p class="表头单元格">数组形式</p> </th> <th> <p class="表头单元格">迭代器形式</p> </th> </tr></thead><tbody><tr><td> <p class="表格单元格">获取键</p> </td> <td> <p class="表格单元格"><code>keys</code></p> </td> <td> <p class="表格单元格"><code>each_key{| 键 | ......}</code></p> </td> </tr><tr><td> <p class="表格单元格">获取值</p> </td> <td> <p class="表格单元格"><code>values</code></p> </td> <td> <p class="表格单元格"><code>each_value{| 值 | ......}</code></p> </td> </tr><tr><td> <p class="表格单元格">获取键值对[ 键, 值]</p> </td> <td> <p class="表格单元格"><code>to_a</code></p> </td> <td> <p class="表格单元格"><code>each{| 键 , 值 | ......}</code><br/><code>each{| 数组 | ......}</code></p> </td> </tr></tbody></table> `keys` 与 `values` 方法各返回封装为数组后的散列的键与值。`to_a` 方法则会先按下面的形式把键值对封装为数组, **[ 键, 值]** 然后再将所有这些键值对数组封装为一个大数组返回。 ~~~ h = {"a"=>"b", "c"=>"d"} p h.keys #=> ["a", "c"] p h.values #=> ["b", "d"] p h.to_a #=> [["a", "b"], ["c", "d"]] ~~~ 除了返回数组外,我们还可以使用迭代器获取散列值。 使用 `each_key` 方法与 `each_value` 方法可以逐个获取并处理键、值。使用 `each` 方法还可以得到 [ 键 , 值 ] 这样的键值对数组。 关于迭代器的例子请参考 15.8 节。 无论是使用 `each` 方法按顺序访问散列元素,还是使用 `to_a` 方法来获取全部的散列元素,这两种情况下都是可以按照散列键的设定顺序来获取元素的。 ### **15.3.2 散列的默认值** 下面我们来讨论一下散列的默认值(即指定散列中不存在的键时的返回值)。在获取散列值时,即使指定了不存在的键,程序也会返回某个值,而且不会因此而出错。我们有 3 种方法来指 定这种情况下的返回值。 - **1. 创建散列时指定默认值** `Hash.new` 的参数值即为散列的默认值(什么都不指定时默认值为 `nil`)。 ~~~ h = Hash.new(1) h["a"] = 10 p h["a"] #=> 10 p h["x"] #=> 1 p h["y"] #=> 1 ~~~ 这个方法与初始化数组一样,所有的键都共享这个默认值。 - **2. 通过块指定默认值** 当希望不同的键采用不同的默认值时,或者不希望所有的键共享一个默认值时,我们可以使用 `Hash.new` 方法的块指定散列的默认值。 ~~~ h = Hash.new do |hash, key| hash[key] = key.upcase end h["a"] = "b" p h["a"] #=> "b" p h["x"] #=> "X" p h["y"] #=> "Y" ~~~ 块变量 `hash` 与 `key`,分别表示将要创建的散列以及散列当前的键。用这样的方法创建散列后,就只能在需要散列默认值的时候才会执行块。此外,如果不对散列进行赋值,通过指定相同的键也可以执行块。 - **3. 用 fetch 方法指定默认值** 最后就是刚才已经介绍过的 `fetch` 方法。当 `Hash.new` 方法指定了默认值或块时,`fetch` 方法的第 2 个参数指定的默认值的优先级是最高的。 ~~~ h = Hash.new do |hash, key| hash[key] = key.upcase end p h.fetch("x", "(undef)") #=> "(undef)" ~~~ ### **15.4 查看指定对象是否为散列的键或值** - ***h*.`key?`(*key*) *h*.`has_key?`(*key*) *h*.`include?`(*key*) *h*.`member?`(*key*)** 上面 4 个方法都是查看指定对象是否为散列的键的方法,它们的用法和效果都是一样的。大家可以统一只用某一个,也可以根据不同的情况选择使用。 散列的键中包含指定对象时返回 `true`,否则则返回 `false`。 ~~~ h = {"a" => "b", "c" => "d"} p h.key?("a") #=> true p h.has_key?("a") #=> true p h.include?("z") #=> false p h.member?("z") #=> false ~~~ - ***h*.`value?`(*value*) *h*.`has_value?`(*value*)** 查看散列的值中是否存在指定对象的方法。这两个方法只是把 `key?`、`has_key?` 方法中代表键的 *key* 部分换成了值 *value*,用法是完全一样的。 散列的值中有指定对象时返回 `true`,否则则返回 `false`。 ~~~ h = {"a"=>"b", "c"=>"d"} p h.value?("b") #=> true p h.has_value?("z") #=> false ~~~ ### **15.5 查看散列的大小** - ***h*.`size` *h*.`length`** 我们可以用 `length` 方法或者 `size` 方法来查看散列的大小,也就是散列键的数量。 ~~~ h = {"a"=>"b", "c"=>"d"} p h.length #=> 2 p h.size #=> 2 ~~~ - ***h*.`empty?`** 我们可以用 `empty?` 方法来查看散列的大小是否为 0,也就是散列中是否不存在任何键。 ~~~ h = {"a"=>"b", "c"=>"d"} p h.empty? #=> false h2 = Hash.new p h2.empty? #=> true ~~~ ### **15.6 删除键值** 像数组一样,我们也可以成对地删除散列中的键值。 - ***h*.`delete`(*key*)** 通过键删除用 `delete` 方法。 ~~~ h = {"R"=>"Ruby"} p h["R"] #=> "Ruby" h.delete("R") p h["R"] #=> nil ~~~ `delete` 方法也能使用块。指定块后,如果不存在键,则返回块的执行结果。 ~~~ h = {"R"=>"Ruby"} p h.delete("P"){|key| "no #{key}."} #=> "no P." ~~~ - ***h*.`delete_if`{|*key, val*| … } h.`reject!`{|*key, val*| … }** 希望只删除符合某种条件的键值的时候,我们可以使用 `delete_if` 方法。 ~~~ h = {"R"=>"Ruby", "P"=>"Perl"} p h.delete_if{|key, value| key == "P"} #=> {"R"=>"Ruby"} ~~~ 另外,虽然 `reject!` 方法的用法与 `delete_if` 方法相同,但当不符合删除条件时,两者的返回值却各异。 `delete_if` 方法会返回的是原来的散列,而 `reject!` 方法则返回的是 `nil`。 ~~~ h = {"R"=>"Ruby", "P"=>"Perl"} p h.delete_if{|key, value| key == "L"} #=> {"R"=>"Ruby", "P"=>"Perl"} p h.reject!{|key, value| key == "L"} #=> nil ~~~ ### **15.7 初始化散列** - ***h*.`clear`** 用 `clear` 方法清空使用过的散列。 ~~~ h = {"a"=>"b", "c"=>"d"} h.clear p h.size #=> 0 ~~~ 这有点类似于使用下面的方法创建新的散列: ~~~ h = Hash.new ~~~ 实际上,如果程序中只有一个地方引用 `h` 的话,两者的效果是一样的。不过如果还有其他地方引用 `h` 的话,那效果就不一样了。我们来对比一下下面两个例子(图 15.1)。 ~~~ 【例 1】 h = {"k1"=>"v1"} g = h h.clear p g #=> {} ~~~   ~~~ 【例 2】 h = {"k1"=>"v1"} g = h h = Hash.new p g #=> {"k1"=>"v1"} ~~~ ![{%}](https://box.kancloud.cn/2015-10-26_562e01f942aaf.png) **图 15.1 例 1 与例 2 的不同点** 在例 1 中,`h.clear` 清空了 `h` 引用的散列,因此 `g` 引用的散列也被清空,`g` 与 `h` 还是引用同一个散列对象。 而在例 2 中,程序给 `h` 赋值了新的对象,但 `g` 还是引用原来的散列,也就是说,`g` 与 `h` 分别引用不同的散列对象。 需要注意的是,这里方法操作的不是变量,而是变量引用的对象。 ### **处理有两个键的散列** 散列的值也可以是散列,也就是所谓的“散列的散列”,这与数组中的“数组的数组”的用法是一样的。 ~~~ table = {"A"=>{"a"=>"x", "b"=>"y"}, "B"=>{"a"=>"v", "b"=>"w"} } p table["A"]["a"] #=> "x" p table["B"]["a"] #=> "v" ~~~ 在本例中,名为 `table` 的散列的值也是散列。因此,这里使用了 `["A"]["a"]` 这种两个键并列的形式来获取值。 ### **15.8 应用示例:计算单词数量** 下面我们用散列写个简单的小程序。在代码清单 15.1 中,程序会统计指定文件中的单词数量,并按出现次数由多到少的顺序将其显示出来。 **代码清单 15.1 word_count.rb** ~~~ 1: # 计算单词数量 2: count = Hash.new(0) 3: 4: ## 统计单词 5: File.open(ARGV[0]) do |f| 6: f.each_line do |line| 7: words = line.split 8: words.each do |word| 9: count[word] += 1 10: end 11: end 12: end 13: 14: ## 输出结果 15: count.sort{|a, b| 16: a[1] <=> b[1] 17: }.each do |key, value| 18: print "#{key}: #{value}\n" 19: end ~~~ 首先,在程序第 2 行创建记录单词出现次数的散列 `count`。`count` 的键表示单词,值表示该单词出现的次数。如果键不存在,那么值应该为 0,因此将 `count` 的默认值设为 0。 在程序第 6 行到第 11 行的循环处理中,读取指定的文件,并以单词为单位分割文件,然后再统计各单词的数量。 在程序第 6 行,使用 `each_line` 方法读取每行数据,并赋值给变量 `line`。接下来,在程序第 7 行,使用 `split` 方法分割变量 `line`,将其转换为以单词为单位的数组,然后赋值给变量 `words`。 在程序第 8 行的循环处理中,对 `words` 使用 `each` 方法,逐个取出数组中的单词,然后将各单词作为键,从 `count` 中获取对应的出现次数,并做 +1 处理。 在程序第 15 行的循环处理中,输出统计完毕的出现次数。然后,在程序第 15 行到第 17 行,使用 `sort` 方法的块将单词按出现次数进行排序。 这里有两个关键点。一是使用了 `<=>` 运算符进行排序,另外一点是比较对象使用了数组的第 2 个元素,如 `a[1]`、`b[1]`。 `<=>` 运算符会比较左右两边的对象,检查判断它们的关系是 `<`、`=`、还是 `>`。`<` 时结果为负数,`=` 时结果为 0,`>` 时结果为正数。另外,之所以使用 `a[1]` 这样的数组,是因为用 `sort` 方法获取 `count` 的对象时,各个值会被作为数组提取出来,如下所示: **[ 单词, 出现次数]** 这样一来,`a[0]` 就表示单词本身,`a[1]` 才表示出现次数。因此,通过比较 `a[1]` 与 `b[1]`,就能实现按出现次数排序。 在程序第 17 行,`each` 方法会将排序后的散列元素逐个取出,然后再在程序第 18 行输出该单词与出现次数。 以上就是整个程序的执行流程,下面就让我们来实际执行一下这个程序,统计 Ruby 的 `README` 文件中各单词出现的次数。 > **执行示例** ~~~ > ruby word_count.rb README =: 1 What's: 1 end:: 1 rdoc: 1 ┊ you: 10 of: 11 Ruby: 1 and: 13 to: 22 the: 23 *: 25 ~~~ 根据这个结果我们可以看出,除符号之外,出现最多的单词是“`the`”,总共出现了 23 次。 > **专栏** > **关于散列的键** > 下面我们来讨论一下用数值或者自己定义的类等对象作为散列的键时需要注意的地方。在下面的例子中,我们首先尝试创建一个以数值为键的散列。 ~~~ h = Hash.new n1 = 1 n2 = 1.0 p n1==n2 #=> true h[n1] = "exists." p h[n1] #=> "exists." p h[n2] #=> nil ~~~ > 用 `n1` 可以获取以 `n1` 为键保存的值,但是用与 `n1` 有相同的值的 `n2` 却无法获取。这是由于使用 `n2` 时,无法在散列中找到与之对应的值,因此就返回了默认值 `nil`。 > 在散列内部,程序会将散列获取值时指定的键,与将值保存到散列时指定的键做比较,判断两者是否一致。这时,判断键是否一致与键本身有着莫大的关系。具体来说,对于两个键 `key1`、`key2`,当 `key1.hash` 与 `key2.hash` 得到的整数值相同,且 `key1.eql?(key2)` 为 `true` 的时候,就会认为这两个键是一致的。 > 像本例那样,虽然使用 `==` 比较时得到的结果是一致的,但是,当两个键分别属于 `Fixnum` 类和 `Float` 类的对象时,由于不同类的对象不能判断为相同的键,因此就会产生与期待不同的结果。 ### **练习题** 1. 定义散列 `wday`,内容为星期的英文表达与中文表达的对应关系。 ~~~ p wday[:sunday] #=> "星期天" p wday[:monday] #=> "星期一" p wday[:saturday] #=> "星期六" ~~~ 2. 使用散列的方法,统计 1 的散列 `wday` 中键值对的数量。 3. 使用 `each` 方法和 1 的散列 `wday`,输出下面的字符串: ~~~ “sunday”是星期天。 “monday”是星期一。 ┊ ~~~ 4. 散列没有像数组的 `%w` 这样的语法。因此,请定义方法 `str2hash`,将被空格、制表符、换行符隔开的字符串转换为散列。 ~~~ p str2hash("blue 蓝色 white 白色\nred 红色") #=> {"blue"=>"蓝色", "white"=>"白色", "red"=>"红色"} ~~~ > 参考答案:请到图灵社区本书的“随书下载”处下载([http://www.ituring.com.cn/book/1237](http://www.ituring.com.cn/book/1237))。