Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
14
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

ハッシュの安全な扱い方 (デフォルト値とその落とし穴について)

問題

以下のような初期化済みのハッシュがあります。

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#fetchHash#[] と同じような機能を持つメソッドですが、ハッシュに対応するキーが存在しない場合の挙動が異なります。

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 を使うことなく一時的なデフォルト値を設定することが可能です。

まとめ

  1. ハッシュのキーや値の存在チェックには Hash#has_key?Object#nil? などを利用する。
  2. ハッシュにデフォルト値を設定したいときは Hash#fetch の利用を検討する。

ハッシュ (Hash) は Ruby を扱う上で欠かすことのできない存在です。
とても便利でよくお世話になるクラスですが、その反面、思わぬ落とし穴もあります。
より安全な扱いを心がけたいですね。

参考

書籍

リファレンス

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
14
Help us understand the problem. What are the problem?