【Ruby】Hashの構造チェックはパターンマッチを使うと簡単

記事タイトルとURLをコピーする

大きなパズルのピースを持って形を合わせる男女

こんにちは、サービス開発課の丸山です。
今回はRubyの小ネタの紹介です。

Hashの構造を検査するための方法いろいろ

アプリケーションを書いていると、Hashの構造を確認してバリデーションしたい場合があると思います。
例えば、引数で渡されるあるHashが次のような構造であることを担保したい場合などです。

{
  name: String, # ex: Tarou
  age: Integer, # ex: 20
}

モデルのjson型やjsonb型カラムのバリデーションを行う場合であれば activerecord_json_validator gem のようなライブラリもありますが、今回はバリューオブジェクトの初期化時に渡されたHashの検査という使い道だったので使えませんでした。

引数のHashのチェックを簡単に行う分には ActiveSupport の Hash#assert_valid_keysも便利です。

{ name: 'Rob', years: '28' }.assert_valid_keys(:name, :age)
# => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age"

ただし、これだとキーしか確認できず、値も確認したい場合には使えません。

他にはclassy_hash gemを使うと、簡単に構造のバリデーションができます。

  
schema = {
  name: String,
  age: Integer,
}
  
hash = {
  name: "Tarou",
  age: 20,
}
  
ClassyHash.validate(hash, schema) # => true

とはいえ、gemを導入するとプロジェクトの依存が増えることにもなりますし、ちょっとしたバリデーションにgemを導入するのは少し及び腰になってしまいます。

そこで Ruby 2.7 から実験的に導入されRuby3から正式な機能になった「case/in」のパターンマッチ構文を使うと簡単にHashの構造をチェックできました。

パターンマッチを使ったHashの構造検査

例えば次のように記述します。

def check_hash_schema!(user_hash)
  case user_hash
  in { name: String, age: Integer }
  else
    raise ArgumentError
  end
end
  
check_hash_schema!({name: "Tarou", age: 20}) # 妥当
# => nil
check_hash_schema!({name: "Tarou", year: 20}) # キーが異なる
# => ArgumentError
check_hash_schema!({name: "Tarou", age: "22"}) # 値の型が異なる
# => ArgumentError

パターンマッチ構文はマッチした値をinで捉えて何らかの操作をすることが多いと思いますが、
今回の場合はマッチした場合には何もせず、マッチしなかった場合をelseで捉えて例外を発生させることで引数チェックをしています。 *1

ただし、このようなパターンマッチ構文でマッチできるのはキーがシンボルのHashだけであることには注意が必要です。
(文字列がキーのHashに対して今回紹介したようなパターンマッチを行いたい場合は、ActiveSupport::HashWithIndifferentAccess に変換しシンボルでの呼び出しに対応させることで動作しましたが、全てのパターンマッチの構文に対応しているかは確認していません。)

in キーワードに渡すパターンにはクラスでなくリテラルも指定できます。
例えば次のコードでは、typeキーのバリューが:admin以外のHashには全て例外を発生します。

  case user_hash
  in { name: String, age: Integer, type: :admin }
  else
    raise ArgumentError
  end

さらに、オプショナルなキーも同様に表現できます。
例えばnameageの他にaddressも受け取れる場合は、次のようにします。

  case user_hash
  in { name: String, age: Integer }
  in { name: String, age: Integer, address: String }
  else
    raise ArgumentError
  end

最初のinaddressキーがないHashをマッチさせ、2つ目のinaddressキーがある場合をマッチさせています。

そして、「case/in」構文ではガード節と合わせて使うことができるので、型(クラス)以外の情報もパターンマッチの中で使うことができます。 例えば、「ageが偶数の場合」にのみマッチさせたい場合は、次のようにかけます。

  case user_hash
  in { name: String, age: age } if age.is_a?(Integer) && age.even?
  else
    raise ArgumentError
  end

:ageキーの値をage変数に代入させて、if文の条件に当てはまるかチェックしています。 もちろんinの内側でも同じように書けますが、この程度ならガード節にした方が見た目がスッキリして良いですね。

以上のようにRuby2.7から登場した「case/in」構文を使うことで、簡単にHashの構造を検査できました。*2 まだ目がなれず、いろいろ調べたりして調査も必要でしたが、gemを導入せずに済んで個人的には満足です。

*1:パターンにマッチしない場合、何も記述しなくとも NoMatchingPatternError が発生しますが、今回は引数のチェックを行うロジックであるのでわかりやすさを重視して else で ArgumentError を発生させています。

*2:Ruby2.7の時点では「case/in」は実験的な機能で実行時に「warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!」という警告が出力されます。Ruby3以降では正式な機能となったため「case/in」を使っても警告は出力されなくなりました。

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。