RubyでネストしたHashやArrayから値を取り出す方法いろいろ

Web APIのレスポンスを扱ったりしていると、ネストしたハッシュや配列に出くわすことがある。

```rb
require 'json'
require 'open-uri'

json = open("http://iss.ndl.go.jp/books/R100000002-I000011125696-00.json").read

api_response = JSON.parse(json)
#{"link"=>"http://iss.ndl.go.jp/books/R100000002-I000011125696-00",
# "identifier"=>
#  {"JPNO"=>["21976263"],
#   "NSMARCNO"=>["121144000"],
#   "ISBN"=>["978-4-87783-259-9"]},
# "title"=>[{"value"=>"セマンティック・ウェブ入門", "transcription"=>"セマンティック ウェブ ニュウモン"}],
# "creator"=>[{"name"=>"赤間, 世紀, 1960-", "transcription"=>"アカマ, セイキ"}], ... }
```

`api_response`から必要な情報だけを抜き出したいが、一部のデータが存在しない可能性がある。

```rb
# このデータは必ず存在することが保証されているが、
title = api_response["title"][0]["value"]

# ISBNは存在しないかもしれない。その場合、[0]の呼び出しでNoMethodErrorが発生してしまう。
isbn = api_response["identifier"]["ISBN"][0]
```

条件分岐などを使ってみても冗長になる。

```rb
isbn = api_response["identifier"]["ISBN"] if api_response["identifier"] && api_response["identifier"]["ISBN"]
isbn = api_response["identifier"] && api_response["identifier"]["ISBN"] && api_response["identifier"]["ISBN"][0]
```

どのように書けばいいだろうか。

## digメソッドを使う

Ruby 2.3.0からdigメソッドがArray, Hash, OpenStruct, Structに実装された。

```rb
isbn = api_response.dig("identifier", "ISBN", 0) #=> "978-4-87783-259-9"
isbn = api_response.dig("identifier", "TRCMARCNO", 0) #=> nil
```

digメソッドは次のような動作をする。

1. 最初の引数をキーにして値を取り出そうとする
    - 引数の型がキーとして不正ならTypeError例外を発生させる（例：Arrayに対して文字列をキーにしたとき、OpenStructに対して数値をキーにしたとき）
2. キーに対応する値が存在しなければ`nil`を返す
3. 要素が見つかったとき
    1. それが`nil`ならば`nil`を返す
    2. 引数が残っていなければ、見つかった要素を返す
    3. 引数が残っていれば、見つかった要素のdigメソッドに残った引数を渡して呼び出す
        - digメソッドが呼び出せなければTypeError例外を発生させる

### Polyfill

- [dig_rb](https://rubygems.org/gems/dig_rb): Array, Hash, Struct, OpenStructに対応している。Pure Rubyで実装されている。
- [backport_dig](https://rubygems.org/gems/backport_dig): Array, Hash, OpenStructに対応している。Array#dig, Hash#digはC言語による拡張ライブラリとして実装されている。
- [ruby_dig](https://rubygems.org/gems/ruby_dig): Array, Hashに対応している。Pure Rubyで実装されている。

## rescueを使う

```rb
isbn = api_response["identifier"]["ISBN"][0] rescue nil  # 見つからなかったら`nil`
```

NoMethodErrorはStandardErrorのサブクラスなのでrescue修飾子で捕捉できる。

rescue修飾子は本来捕捉すべきでない例外まで握りつぶしてしまうので使うべきでないという意見もあるが、HashやArrayへのアクセス程度ならほとんど問題にならないと思う。どうしてもNoMethodErrorだけを捕捉したい場合は`begin`〜`end`で囲んでrescue節を使う必要がある。（rescue修飾子の中で`$!`を参照するという手はあるが、あまりスマートではないように思う。）

## nil#[]を定義する

```rb
class NilClass
  def [](*)
    nil
  end
end
```

何も考えずに`result["identifier"]["ISBN"][0]`などと書くことができる。変更が広い範囲に影響するので、予期しない結果を招く恐れがある。

## deep_fetch系ライブラリを使う

`{a: {b: [1]}}.deep_fetch(:a, :b, 0) == 1`のような機能を提供するライブラリがいくつか存在する。

要素へのアクセスに使うメソッド名（`[]`, `fetch`）、要素が見つからなかったときの挙動、HashとArray以外の要素の扱いなどに微妙な違いがあるので、使う前にソースを確認するのがよい。

### Hashie::Extensions::DeepFetch

* [rubygems.org](http://rubygems.org/gems/hashie)
* [hashie/deep_fetch.rb · intridea/hashie](https://github.com/intridea/hashie/blob/master/lib/hashie/extensions/deep_fetch.rb)

```rb
require 'hashie/extensions/deep_fetch'

example = {
  identifier:
    { JPNO: ["21976263"],
      NSMARCNO: ["121144000"],
    }
}

# Hash#deep_fetchを定義する
Hash.include(Hashie::Extensions::DeepFetch)

example.deep_fetch(:identifier, :JPNO)    # => ["21976263"]
example.deep_fetch(:identifier, :JPNO, 0) # => "21976263"
example.deep_fetch(:identifier, :ISBN) # => Hashie::Extensions::DeepFetch::UndefinedPathError
example.deep_fetch(:identifier, :ISBN) { "not found" } # => "not found"
```

- 要素へのアクセスには`fetch`メソッドが使われる。
  - Hash, Arrayやそれらのサブクラス以外でも、`fetch`メソッドがあれば要素にアクセス可能。
- Integerに変換できない値をキーとしてArrayにアクセスしようとするとTypeError例外が発生する。（`{a: [0, 1]}.deep_fetch(:a, :b) #=> TypeError`）

#### モンキーパッチを避ける

```rb
# 方法1: 特異メソッドとして追加
example.extend(Hashie::Extensions::DeepFetch)
example.deep_fetch("identifier", "JPNO", 0)

# 方法2: UnboundMethodをbindする
deep_fetch = Hashie::Extensions::DeepFetch.instance_method(:deep_fetch)
deep_fetch.bind(example).call("identifier", "JPNO", 0)
```

### deep_fetch

* [rubygems.org](http://rubygems.org/gems/deep_fetch)
* [pewniak747/deep_fetch · GitHub](https://github.com/pewniak747/deep_fetch)

```rb
# README.mdより抜粋

require 'deep_fetch'

example = {
  :foo => {
    :bar => [ 'a', 'b', 'c' ],
    :baz => :boo
  }
}

example.deep_fetch(:foo, :baz) # => :boo
example.deep_fetch(:foo, :boo) # => KeyError: key not found: :boo
example.deep_fetch(:foo, :boo) { "not found" } # => "not found"
example.deep_fetch(:foo, :bar, 1) # => 'b'
```

* Hash、Arrayとそれらのサブクラス以外の要素にはアクセスできない。
* Integerに変換できない値をキーとしてArrayにアクセスしようとするとTypeError例外が発生する。（`{a: [0, 1]}.deep_fetch(:a, :b) #=> TypeError`）
* 現在の実装では、Arrayにアクセスして得られたオブジェクトが`deep_fetch`メソッドを持っていないとNoMethodErrorが発生する（[Pull Request #3](https://github.com/pewniak747/deep_fetch/pull/3)）。
* Hashから存在しないキーで値を取ろうとしたときにはKeyErrorが起きる。しかしArrayについては`{a: []}.deep_fetch(:a, 100)`のように存在しないArrayのインデックスにアクセスしたときには単に`nil`が返る。IndexErrorなどの例外は発生せず、ブロックも呼ばれない。

### vine

* [rubygems.org](http://rubygems.org/gems/vine)
* [guangnan/vine · GitHub](https://github.com/guangnan/vine)

```rb
{a: {b: [:foo, :bar]}}.access("a.b.0") #=> :foo
{a: {b: [:foo, :bar]}}.access("a.b.c") #=> TypeError: no implicit conversion of String into Integer
{a: {b: [:foo, :bar]}}.access("a.b.1.2") #=> "r"
```

## hash_mapper

Hashから別のHashを作りたいときには[hash_mapper](http://rubygems.org/gems/hash_mapper)が便利。[README](https://github.com/ismasan/hash_mapper#readme)を読めばすぐ使える。

```rb
require 'hash_mapper'
require 'date'
require 'pp'

api_response = {"identifier"=>
                  {"JPNO"=>["21976263"],
                   "NSMARCNO"=>["121144000"],
                   "ISBN"=>["978-4-87783-259-9"]},
                "title"=>[{"value"=>"セマンティック・ウェブ入門", "transcription"=>"セマンティック ウェブ ニュウモン"}],
                "date"=>["2011.3"],
                "issued"=>["2011"]}

# DSL風の記法で、どの要素をどの要素に移し、その際にどのような処理をするかを簡潔に指定できる
class NDLBook
  extend HashMapper
  map from('/title[0]/value'),     to('/title')
  map from('/identifier/ISBN[0]'), to('/isbn') {|isbn| isbn.gsub("-", "")}
  map from('/issued[0]'),          to('/dates/issued', &:to_i)
  map from('/date[0]'),            to('/dates/date') do |date|
    Date.parse(date.gsub(".", "/"))
  end
  map from('/non-existent/items'), to('/are/just/ignored')
end

pp NDLBook.normalize(api_response)
#{:title=>"セマンティック・ウェブ入門",
# :isbn=>"9784877832599",
# :dates=>
#  {:issued=>2011, :date=>#<Date: 2011-03-01 ((2455622j,0s,0n),+0s,2299161j)>}}
```

hash_mapperはHashから値を取り出すとき、クラスを見て細かく場合分けをしたり、`respond_to?`などでチェックしたりせず、どんどん`[]`を適用して`nil`が出るまで掘り進んでいくので、思ったより階層が浅かった場合には例外が出たり、予期しない値が返されることがある（FixnumやStringも`[]`というメソッドを持つことに注意）。

```rb
require 'hash_mapper'

class A
  extend HashMapper
  map from("/a/b/c"), to("/abc")
end

p A.normalize({a: 0})
# => .../hash_mapper-0.1.2/lib/hash_mapper.rb:134:in `[]': no implicit conversion of Symbol into Integer (TypeError)
```

## 参考・その他の方法

* [ruby on rails - Equivalent of .try() for a hash? - Stack Overflow](http://stackoverflow.com/questions/6224875/equivalent-of-try-for-a-hash)
* [Looking for a Good Way to Avoid Hash Conditionals in Ruby - Stack Overflow](http://stackoverflow.com/questions/4371716/looking-for-a-good-way-to-avoid-hash-conditionals-in-ruby/4380514)
* [Rails - 扱いにくい階層の深いHashをフラット(1階層)にする - Qiita](http://qiita.com/okappy/items/72625a3e9437d70bc7c4)
* [Hashie::Extensions::DeepFind](https://github.com/intridea/hashie#deepfind): ネストしたHashやArrayなどから、指定したキーに対応する値を再帰的に探す。

---

<p>
This document is licensed under
    <a rel="license" href="http://creativecommons.org/publicdomain/zero/1.0/">CC0</a>.
</p>