attr_readerとattr_accessorを拡張してto_hメソッドを簡潔に実装する

はじめに

先日Ruby on Railsであるサービスの開発を行っていた際に、自作のクラスにto_hというインスタンスメソッドを実装する必要が出てきました。
このメソッドの要件は以下の通りです。

  • attr_readerまたはattr_accessorで設定したgetterメソッドの名前をkey、戻り値をvalueとするハッシュを返す
  • #to_hメソッドの中には、属性名を直接書かないようにする(=勝手に上手いことやってくれるような書き方にする)

最初はObject#instance_variablesObject#respond_to?の組み合わせで以下のように実装しました。

class Hoge
  attr_accessor :attr1, :attr2

  def to_h
    keys = instance_variables.flat_map do |val_name| 
      getter_name = val_name[1..-1]
      respond_to?(getter_name) ? getter_name : []
    end
    keys.map { |key| [key.to_sym, public_send(key)] }.to_h   
  end
end

これだと、getterメソッドは存在するが、同名のインスタンス変数が初期化されていない場合に、戻り値のハッシュから除外されてしまうという問題があります。
例えば以下のような感じです。

hoge = Hoge.new
hoge.attr1 = 1
hoge.to_h # => {:attr1=>1}

上記の例の場合、@attr2は初期化されていないため、#to_hの戻り値には含まれないことになります。

そこで、これ以外の方法はないかと色々と調べたところ、それなりに納得のいく実装ができたので、備忘録も兼ねて記事にしました。

方針

基本的には、Ruby: attr_accessor を拡張する – CLARA ONLINE techblogと同じです。
上記リンク先での方針をまとめると、以下のようになります。

  • attr_accessorをモジュールを使ってオーバライドし、渡された引数をクラスインスタンス変数として保持しておく
  • #to_hメソッド内で上記のクラスインスタンス変数を同名のクラスメソッド経由で取得し、戻り値のハッシュを組み立てる

方針はこれで問題ないのですが、リンク先の実装には以下のような不具合があります。

  1. attr_accessorがエラーで落ちた際の(不正な)引数を保持してしまう
  2. attr_accessorに渡された同名の文字列とシンボル(例えば"attr1":attr1)を区別して保持してしまう
  3. モジュールをincludeしたクラスAを継承した、attr_accessorの定義されていないクラスBをさらに継承したクラスCを作った場合、クラスAの属性がクラスCの#to_hの戻り値に含まれない

3つ目はちょっと複雑ですが、これはsuperclassに対してinstance_variable_getを使った呼び出しを行っていることに起因しています。
詳しく知りたい方は、リンク先の実装を見ていただければと思います。

実装

こんな感じでいくつかの不具合があることがわかったので、これらを解消する実装を考えてみました。
具体的には以下の通りです。

このAttrAccessorExtensionモジュールを任意のクラスでincludeすることで、当初の要件が実現できます。

おわりに

良い頭の体操になりました。

参考