| 著作一覧 | 
以下のようなオブジェクトモデル、クラスAの集合とクラスAのインスタンスをJSON化することを考える(Rails環境)。重要なのはAクラスが自分を保持するオブジェクトへの参照を持つことだ。
class A
  def initialize(caption, data, parent)
    @caption = caption
    @data = data
    @parent = parent
  end
  attr_reader :caption, :data
end
 
class ACloud
  def initialize(items)
    @items = items.map {|e| A.new(e[0], e[1], self)}
  end
  attr_reader :items
end
 
list = ACloud.new([[:a, 0], [:b, 1]])
puts list.to_json       # stack level too deep (SystemStackError)
bundle exec rails r test.rbを行うとスタック深過ぎエラーとなる。まあ、そうなるよね、という結果だ。
ここで自分を保有するオブジェクトへの参照を持つのは実装の都合なので(Aのインスタンスを単体で扱う場合があり、そこから親を取得する必要があるとする)、JSONにした場合は親から復元できるわけだからparentというプロパティは不要だとする。
そこでクラスAを次のように修正する。
class A
  def initialize(caption, data, parent)
    @caption = caption
    @data = data
    @parent = parent
  end
  attr_reader :caption, :data
  def as_json(opts = {})
    super(opts.merge(except: :parent))
  end
end
が、不思議なことにこの場合もstack level too deepとなる。
そこで、as_jsonではなく、to_jsonで直接欲しいJSONを作ることにしてみる。
class A
  def initialize(caption, data, parent)
    @caption = caption
    @data = data
    @parent = parent
  end
  attr_reader :caption, :data
  def to_json(opts = {})
     {caption: caption, data: data}.to_json
  end
end
が、これもstack level too deepとなる。
まさか、to_jsonの呼び出しがまずいのかな? と以下のように変えてみる。
class A
  def initialize(caption, data, parent)
    @caption = caption
    @data = data
    @parent = parent
  end
  attr_reader :caption, :data
  def to_json(opts = {})
    "{\"caption\": \"#{caption}\", \"data\": \"#{data}\"}"
  end
end
やはりstack level too deepとなる。
実はA#to_jsonは呼ばれていないので、to_jsonを変えることには意味はない。
つまり、as_jsonをオーバーライドしなければならないのだが、ここでsuperを呼び出すのではなく、to_jsonを呼び出し可能なオブジェクトに変えると期待している動作となる。
class A
  def initialize(caption, data, parent)
    @caption = caption
    @data = data
    @parent = parent
  end
  attr_reader :caption, :data
  def as_json(opts = {})
    {caption: caption, data: data}
  end
end
(...)
puts list.to_json #=> {"items":[{"caption":"a","data":0},{"caption":"b","data":1}]}
したがって、as_jsonをオーバーライドすれば良いということは間違いない。で、いろいろ試した結果、シンボルではなく文字列を使ってプロパティを指定するということにやっとたどりついた。
def as_json(opts = {})
  super(opts.merge(except: 'parent'))  # => 期待通りに振る舞う
end
が、プロパティ数が上の例のように十分に少なければas_jsonに直接Hashを書いたほうが余計な動作が不要なので良いと思う。
というのは、この例の呼び出しは、ActiveSupport(7.0.2.3の場合)のcore_ext/object/json.rbの59行目からのas_jsonが最初に呼ばれて、次にcore_ext/object/instance_variables.rbの14行目からのinstance_valuesが呼び出され、この時点でプロパティ名の文字列をキーとしたHashが生成され、最終的にcore_ext/hash/except.rbの12行目のexceptでas_jsonのオプションに与えたexceptが処理されるからだ(このため、プロパティ名にシンボルを与えるとHashからの削除処理が無視される。けど、except.rbのドキュメントではキーとしてシンボルを前提としているから、シンボルにしたほうが良さそうな気もするがおそらく互換性問題が出てくるのかなぁ)。
要は捨てるプロパティについても値の取得を行っているから無駄だ。
いかがでしたか、as_jsonのオプションに与えるexceptで指定する除外プロパティには文字列を使うか、またはas_jsonで必要なプロパティを抽出したHashを返す、これで循環参照を回避できる。
ジェズイットを見習え  |