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メソッドは次のような動作をする。
- 最初の引数をキーにして値を取り出そうとする
- 引数の型がキーとして不正ならTypeError例外を発生させる(例:Arrayに対して文字列をキーにしたとき、OpenStructに対して数値をキーにしたとき)
- キーに対応する値が存在しなければ
nil
を返す - 要素が見つかったとき
- それが
nil
ならばnil
を返す - 引数が残っていなければ、見つかった要素を返す
- 引数が残っていれば、見つかった要素の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だけを捕捉したい場合はbegin
〜end
で囲んで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
メソッドがあれば要素にアクセス可能。
- Hash, Arrayやそれらのサブクラス以外でも、
- 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)
参考・その他の方法
- ruby on rails - Equivalent of .try() for a hash? - Stack Overflow
- Looking for a Good Way to Avoid Hash Conditionals in Ruby - Stack Overflow
- Rails - 扱いにくい階層の深いHashをフラット(1階層)にする - Qiita
- Hashie::Extensions::DeepFind: ネストしたHashやArrayなどから、指定したキーに対応する値を再帰的に探す。
This document is licensed under CC0.