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

  • 181
    いいね
  • 0
    コメント

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

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から必要な情報だけを抜き出したいが、一部のデータが存在しない可能性がある。

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

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

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

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に実装された。

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: Array, Hash, Struct, OpenStructに対応している。Pure Rubyで実装されている。
  • backport_dig: Array, Hash, OpenStructに対応している。Array#dig, Hash#digはC言語による拡張ライブラリとして実装されている。
  • ruby_dig: Array, Hashに対応している。Pure Rubyで実装されている。

rescueを使う

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

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

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

nil#[]を定義する

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

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

モンキーパッチを避ける

# 方法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

# 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)。
  • Hashから存在しないキーで値を取ろうとしたときにはKeyErrorが起きる。しかしArrayについては{a: []}.deep_fetch(:a, 100)のように存在しないArrayのインデックスにアクセスしたときには単にnilが返る。IndexErrorなどの例外は発生せず、ブロックも呼ばれない。

vine

{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が便利。READMEを読めばすぐ使える。

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も[]というメソッドを持つことに注意)。

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)

参考・その他の方法


This document is licensed under CC0.