問題
以下のような初期化済みのハッシュがあります。
hash
# => {:madoka=>"鹿目 まどか"}
hash.class
# => Hash
この時、以下のコードを実行するとどうなるでしょうか?
(hash に :sayaka というキーは存在していません。)
if hash[:sayaka]
raise('あたしって、ほんとバカ')
end
ほとんどの場合、
if hash[:sayaka]
raise('あたしって、ほんとバカ')
end
# => nil
と何も起きないでしょう。
なぜなら、ハッシュ (Hash#[] メソッド) は通常、キーに対応する値が存在しない時には nil を返すからです。
(そして Ruby は nil を偽として扱います。)
ところがどっこい、実は例外が起きる場合もあるのです!
if hash[:sayaka]
raise('あたしって、ほんとバカ')
end
# => RuntimeError: あたしって、ほんとバカ
_人人人人人人人人人人人人人_
> あたしって、ほんとバカ <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
あれ? この場合の hash[:sayaka] の返り値は一体どうなってるでしょうか?
このハッシュの中身を調べてみましょう。
hash
# => {:madoka=>"鹿目 まどか"}
hash[:madoka]
# => "鹿目 まどか"
hash[:sayaka]
# => "nil かと思った? 残念! さやかちゃんでした!"
_人人人人人人人人人人人人人人人人人人人人人人人人_
> nil かと思った? 残念! さやかちゃんでした! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
種明かし
実はこのハッシュには デフォルト値 が設定されていたのでした。
先に述べたように、ハッシュは通常、キーに対応する値が存在しない場合に nil を返すのですが、
これを任意の値を返すように変更することができます。
Hash.new の引数に任意の値を指定すると、それがそのハッシュのデフォルト値となります。
hash = {}
hash[:sayaka]
# => nil
hash = Hash.new([])
hash[:sayaka]
# => []
hash = Hash.new('nil かと思った? 残念! さやかちゃんでした!')
hash[:sayaka]
# => "nil かと思った? 残念! さやかちゃんでした!"
このテクニックは例えば以下のように応用できます。
# 改良前
def count_by_char(string)
string.each_char.inject({}) do |hash, char|
hash[char] ||= 0 # hash[char] を初期化しているこの行を消したい。
hash[char] += 1
hash
end
end
count_by_char('banana') #=> {"b"=>1, "a"=>3, "n"=>2}
# 改良後
def count_by_char(string)
string.each_char.inject(Hash.new(0)) do |hash, char|
# hash[char] にはデフォルト値 0 が設定されているので、初期化は不要になった。
hash[char] += 1
hash
end
end
count_by_char('banana') #=> {"b"=>1, "a"=>3, "n"=>2}
デフォルト値便利! ✌('ω'✌ )三✌('ω')✌三( ✌'ω')✌
しかし、ここで懸念すべきなのは、
キーに対応する値が存在しない場合に返されるのは、必ずしも nil (偽) というわけではない
ということです。
つまり、よく見かける
if hash[:sayaka]
# do_something
end
という書き方は安全ではない (必ずしも期待通りに動くとは限らない) のです。
安全な扱い方
ではデフォルト値が変更されていることを想定してハッシュをより安全に利用するにはどうすればいいのでしょうか?
これには2つのアプローチがあります。
(1) キーや値の存在チェックを工夫する
文脈に応じて適切な存在チェックを行いましょう。
if hash[:sayaka]
# do_something
end
この if 文が「ハッシュに :sayaka というキーが存在している場合」という意図であれば、
if hash.has_key?(:sayaka)
# do_something
end
と書くのが適切でしょう。
もしくは「hash[:sayaka] の値が nil ではない場合」という意図であれば、
unless hash[:sayaka].nil?
# do_something
end
と書くべきでしょう。
この場合は、ハッシュのデフォルト値が nil (もしくは false) ではない時はもちろん unless 文の中身が実行されてしまいますが
if hash[:sayaka] と書くよりもコードの意図が明らかになると思います。
(2) Hash#fetch を利用してデフォルト値を設定する
先に述べたように Hash.new を利用してデフォルト値を設定する方法では、
思いがけない箇所に副作用を及ぼしてしまう危険性があります。
そこで、より安全にハッシュのデフォルト値を設定するには Hash#fetch を使うという手があります。
Hash#fetch は Hash#[] と同じような機能を持つメソッドですが、ハッシュに対応するキーが存在しない場合の挙動が異なります。
hash = { madoka: '鹿目 まどか' }
# => {:madoka=>"鹿目 まどか"}
hash[:madoka]
# => "鹿目 まどか"
hash[:sayaka]
# => nil
hash.fetch(:madoka)
# => "鹿目 まどか"
hash.fetch(:sayaka)
# => KeyError: key not found: :sayaka
このように Hash#fetch の第一引数として存在しないキーを与えると nil を返す代わりに KeyError が発生します。
そして Hash#fetch に第二引数を渡すとさらに挙動が変わります。
hash.fetch(:sayaka)
# => KeyError: key not found: :sayaka
hash.fetch(:sayaka, '奇跡も、魔法も、あるんだよ')
# => "奇跡も、魔法も、あるんだよ"
そう、第二引数にデフォルト値を指定できるのです。
このように Hash#fetch を利用すれば、Hash.new を使うことなく一時的なデフォルト値を設定することが可能です。
まとめ
- ハッシュのキーや値の存在チェックには
Hash#has_key?やObject#nil?などを利用する。 - ハッシュにデフォルト値を設定したいときは
Hash#fetchの利用を検討する。
ハッシュ (Hash) は Ruby を扱う上で欠かすことのできない存在です。
とても便利でよくお世話になるクラスですが、その反面、思わぬ落とし穴もあります。
より安全な扱いを心がけたいですね。
参考
書籍
-
Amazon.co.jp: Effective Ruby
- 項目20 で言及されています。