🐷

【Ruby】シン・オブジェクト 第4形態!【オブジェクトマッパー編】

2022/12/27に公開

はじめに

Rubyでコードを書いてる時、配列の中の配列とか、ハッシュの中のハッシュとか、そういったデータ構造を取り扱うことってありませんか?
手軽にサクっと作れて、データ構造を表現するのに便利ですよね?でも、私はそういったデータ構造を取り扱う時ちょっとモヤモヤします。

ハッシュや配列などのデータ構造を直接取り扱っていると、カッコやクォートなどの記号があふれていて、そういったちょっとした記号の理解がチリ積もとなって、脳内のワーキングメモリを持っていかれてしまいます。

だから、そういったむき出しのデータ構造を直接取り扱うのではなく、コードの関心事をわかりやすくするために、モデル化を行いたくなるのです。

この記事では、Gemなどを使うのではなく、実際にコードを書いてモデル化していきたいと思います。いきなり最終的なコードを提示するのではなく、モデル化にいたる過程を段階的に説明していくことで、オブジェクト指向っぽくするために、どのような考えで進めているのか、サンプルを提示したいと思います。

これらに興味がある人向けの記事です

  • オブジェクト指向
  • モデル化
  • メタプログラミング
  • オブジェクトマッパー
  • クラスインスタンス変数
  • Rubyっぽい?コード

こんな人には不向きです

Rubyでデータ構造を宣言的にクラスで表現する方法をご紹介します。

以下の場合はこの記事は不向きです。

  • データ構造をハッシュや配列そのままの形で使用したい場合
  • データ構造をクラス化したいが、宣言的である必要がない場合
    (オンデマンドでアクセッサーが生えれば良い場合は、OpenStructなどを使用)

「シン・オブジェクト」って何?

タイトルの「シン・オブジェクト」見て「何これ?」ってなった方へ。

すみません。

なんとなくノリで書いちゃいました。

オブジェクト指向に関連する記事を書きたいなと思ってたら、こんなタイトルになってしまいました。内容は真面目にオブジェクト指向に取り組んでいるつもりです。

目次

サンプルデータ構造

第1形態:むき出しのハッシュデータ
第2形態:シンプルなモデル
第2.5形態:コメントを書く

第3形態:モデルの中に保持したデータ構造を使ってサンプルデータを読み込む
第4形態:モデルとパース処理を分離する

サンプルデータ構造

例えば、次のような映画のデータベースみたいなものがあったとします。

映画作品集
	映画
		タイトル
		評価
		監督
			名前
			生年月日
		キャスト
			俳優
				名前
				生年月日
			俳優
				:
				:
	映画
		:
		:

このデータ構造は、YamlとかJsonとかXmlとか何でも良いのですが、階層構造になっていて、ライブラリーか何かで読み込んだ結果、次のようなハッシュと配列のデータになったとします。

sample_data.rb
$sample_data = {
  :movies => [
    {
      :title => "インターステラー",
      :star => "8.6",
      :director => {:name => "クリストファーノーラン", :born => "1970-07-30"},
      :cast => [
        {:name => "マシューマコノヒー", :born => "1969-11-04"},
        {:name => "アンハサウェイ", :born => "1982-11-12"}
      ]
    },
    {
      :title => "パルプフィクション",
      :star => "8.9",
      :director => {:name => "クエンティンタランティーノ", :born => "1963-03-27"},
      :cast => [
          {:name => "ジョントラボルタ", :born => "1954-02-18"},
          {:name => "サミュエル L ジャクソン", :born => "1948-12-21"},
          {:name => "ブルースウィリス", :born => "1955-03-19"}
      ]
    },
    {
      :title => "君の名は",
      :star => "8.3",
      :director => {:name => "新海誠", :born => "1973-02-09"},
      :cast => [
        {:name => "神木隆之介", :born => "1993-05-19"},
        {:name => "上白石萌音", :born => "1998-01-27"},
      ]
    }
  ]
}

このサンプルデータを取り扱っていくことを考えてみます。
話しをシンプルにするために、データの更新はちょっと置いておいて、データの参照に絞って話を進めます。

第1形態

むき出しのハッシュデータ

単純に中身を表示する処理を書いてみます。
まだモデルになっていないので、ハッシュデータをそのまま使います。

form1.rb
require_relative 'sample_data'

#
# サンプルデータ:ハッシュ構造を直接操作する
#

puts "===== 第1形態 =====\n\n"

$sample_data[:movies].each do | movie |
  puts "movie: title: #{movie[:title]}, star: #{movie[:star]}"
  puts "director: name: #{movie[:director][:name]}, born: #{movie[:director][:born]}"
  movie[:cast].each do | actor |
    puts "actor: name: #{actor[:name]}, born: #{actor[:born]}"
  end
  puts
end
実行結果
===== 第1形態 =====

movie: title: インターステラー, star: 8.6
director: name: クリストファーノーラン, born: 1970-07-30
actor: name: マシューマコノヒー, born: 1969-11-04
actor: name: アンハサウェイ, born: 1982-11-12

movie: title: パルプフィクション, star: 8.9
director: name: クエンティンタランティーノ, born: 1963-03-27
actor: name: ジョントラボルタ, born: 1954-02-18
actor: name: サミュエル L ジャクソン, born: 1948-12-21
actor: name: ブルースウィリス, born: 1955-03-19

movie: title: 君の名は, star: 8.3
director: name: 新海誠, born: 1973-02-09
actor: name: 神木隆之介, born: 1993-05-19
actor: name: 上白石萌音, born: 1998-01-27

良いところ

  1. シンプルでコードが短い

モヤモヤするところ

  1. どういったデータ構造なのか、パッと見てわからない
  2. データに対するロジックをどこに書くか悩む
  3. ハッシュの表現がコードの可読性に直接影響している
  4. これってRubyらしいの?

どういったデータ構造なのか、パッと見てわからない

このコードは、コードの読み手に処理をトレースしてもらうことでデータ構造を理解することを期待しています。シンプルな構造のうちは良いのですが、複雑になってくると辛くなってきます。
コメントにデータ構造を書いて解消すべきことなのでしょうか?

データに対するロジックをどこに書くか悩む

オブジェクトは、データとその手続き(メソッド)をひとまとめにすることで、理解や取り扱いを良くするためのものです。
例えば、「star / 評価」が8.5以上の映画を取り出したいとなった場合、その判定ロジックはどこに書くべきなのでしょうか?

元データのハッシュの表現がコードの可読性に直接影響している

ハッシュのキーがむき出しのまま、もしも「movie」ってキーが「m」なんてキーだったら、このコードは可読性は著しく低下するでしょう。

これってRubyらしいの?

何がRubyらしいのか?こんな短いサンプルにRubyらしさも何もない。と言えばそうなのですが
こんな感じのコードがそっくりそのまま巨大化したものがよくあるので、モヤモヤしてしまうのです。

第2形態

シンプルなモデル

シンプルにデータ構造をクラスとアトリビュートを使って表現してみます。

require_relative 'sample_data'

#
# モデル定義
#

class Root
  attr_accessor :movies
end

class Movie
  attr_accessor :title
  attr_accessor :star
  attr_accessor :director
  attr_accessor :cast
end

class Person
  attr_accessor :name
  attr_accessor :born
end

class Director < Person
end

class Actor < Person
end

#
# サンプルデータをモデルにマッピングする
#

root = Root.new
root.movies = []
$sample_data[:movies].each do | movie_hash |
  movie = Movie.new
  movie.title = movie_hash[:title]
  movie.star = movie_hash[:star]

  director_hash = movie_hash[:director]
  director = Director.new
  director.name = director_hash[:name]
  director.born = director_hash[:born]
  movie.director = director

  cast = movie_hash[:cast]
  movie.cast = []
  cast.each do | actor_hash |
    actor = Actor.new
    actor.name = actor_hash[:name]
    actor.born = actor_hash[:born]
    movie.cast.push( actor )
  end
  root.movies.push( movie )
end

#
# マッピングされたモデルを操作する
#

puts "===== 第2形態 =====\n\n"

root.movies.each do | movie |
  puts "movie: title: #{movie.title}, star: #{movie.star}"
  puts "director: name: #{movie.director.name}, born: #{movie.director.born}"
  movie.cast.each do |actor|
    puts "actor: name: #{actor.name}, born: #{actor.born}"
  end
  puts
end
実行結果
===== 第2形態 =====

movie: title: インターステラー, star: 8.6
director: name: クリストファーノーラン, born: 1970-07-30
actor: name: マシューマコノヒー, born: 1969-11-04
actor: name: アンハサウェイ, born: 1982-11-12

movie: title: パルプフィクション, star: 8.9
director: name: クエンティンタランティーノ, born: 1963-03-27
actor: name: ジョントラボルタ, born: 1954-02-18
actor: name: サミュエル L ジャクソン, born: 1948-12-21
actor: name: ブルースウィリス, born: 1955-03-19

movie: title: 君の名は, star: 8.3
director: name: 新海誠, born: 1973-02-09
actor: name: 神木隆之介, born: 1993-05-19
actor: name: 上白石萌音, born: 1998-01-27

コード長っ!

良くなったところ

  1. データ構造がパッと見て何となくわかる
  2. データに対するロジックを書きたくなった時にモデルに記述することができる
  3. ハッシュのキー表現を隠すことができる

モヤモヤするところ

  1. コードが長くなった
  2. モデル同士の関係はパッと見てもわからない
  3. データ構造とマッピング処理に関連性がない
  4. マッピングしてからトラバース(走査)しているので処理効率が悪い

いきなりコードが長くなりました。データ構造を1回走査するだけならオーバースペックですね。
このデータ構造を何度も使うのであれば仕方ないかも?と思うような長さです。

しかし、データ構造がわかりやすくなっていることや、データに対するロジックを書く場所が用意できたことは精神衛生上よろしいかと思います。

「cast」の中に「Actor」が複数入ることは、映画について知っていれば容易に想像できますが、もしも、読み手が前提知識を持っていないモデルなのであれば、マッピング処理を読み解く必要があります。

accessorを普段から使っている方だと、モデルのinitializeメソッドで、attrに初期値を設定したくなりますよね。
でも、そこはぐっと堪えましょう。理由は後半で!?

第2_5形態

コメントを書く

第2形態には、モデル同士の関係はパッと見てもわからない。といった、モヤモヤがありました。

データ構造にコメントを追記してみます。
このコードは実行しても何も起きません。

form2_5_1.rb

# このコードは実行しても何も起きない

#
# モデル定義
#

class Root
  attr_accessor :movies    # moviesという名前で、movieを複数保持する
end

class Movie
  attr_accessor :title     # 文字情報:タイトル
  attr_accessor :star      # 文字情報:評価
  attr_accessor :director  # directorというクラスを1つ保持する
  attr_accessor :cast      # castという名前で、actorを複数保持する
end

class Person
  attr_accessor :name      # 文字情報:名前
  attr_accessor :born      # 文字情報:生年月日
end

class Director < Person
end

class Actor < Person
end

データ構造をコードで表現してみる

上のコードはただのコメントですが、
データ構造がどうなっているかを、モデルの内部にデータとして保持することができれば、それを活用することができるかもしれません。

では、さっそくデータ構造をコードで表現してみましょう。

このコードはモデルの構造を表示するだけです。
サンプルデータの内容を表示するものではありません。

form2_5_2.rb
#
# モデルの構造をモデル内部に持つようにする
#
# このMapperモジュールは、このファイルの下にあるモデル定義時に呼び出される
#

module Mapper
  module Model
    # モデルにincludeされた時に実行される
    def self.included(base)
      base.instance_eval do
        @text = []
        @child = []
        @has_many = []
      end
      base.extend Mapper::ClassMethods
    end
  end

  module ClassMethods
    def text(name)
      @text.push(name)
      attr_accessor(name)
    end

    def child(klass)
      @child.push(klass)
      attr_accessor(klass)
    end

    def has_many(name, klass)
      @has_many.push( [ name, klass ] )
      attr_accessor(name)
    end
  end

end

#
# モデル定義
# さっきのコメントをコードとして表現するようにした
#

class Root
  include Mapper::Model
  has_many :movies, :movie # moviesという名前で、movieを複数保持する
end

class Movie
  include Mapper::Model
  text :title
  text :star
  child :director
  has_many :cast, :actor  # castという名前で、actorを複数保持する
end

class Person
  include Mapper::Model
  text :name
  text :born
end

class Director < Person
  include Mapper::Model
end

class Actor < Person
  include Mapper::Model
end

#
# 第2.5形態は、モデルの内部構造を表示するところまでしか行わない
#

puts "===== 第2.5形態 =====\n\n"

def print_structure(klass)
  puts "#{klass}の構造"
  klass.instance_variable_get("@text").each { |v| puts "#{v}" }
  klass.instance_variable_get("@child").each { |v| puts "#{v}" }
  klass.instance_variable_get("@has_many").each { |v| puts "#{v}" }
  puts
end

print_structure(Root)
print_structure(Movie)
print_structure(Director)
print_structure(Actor)
実行結果
===== 第2.5形態 =====

Rootの構造
[:movies, :movie]

Movieの構造
title
star
director
[:cast, :actor]

Directorの構造

Actorの構造

モジュールが出てきてややこしくなりましたが、実行結果を見ながらコードを読んでみるとモジュールがやっていることが見えてくると思います。

単純に内部の配列に、どんなtextがあるか、どんなchildがあるか、どんなhas_manyがあるか、を保持しているだけです。

あれっ? DirectorとActorの構造が表示されていませんね。

モデルの継承関係に対応する

モデルの継承関係に対応させます。

このコードは、まだモデルの構造を表示するだけです。
サンプルデータの内容を表示するものではありません。

def self.included(base)に追記します。

form2_5_3.rb
require_relative 'sample_data'

#
# モデルの構造をモデル内部に持つようにする
#
# このMapperモジュールは、このファイルの下にあるモデル定義時に呼び出される
#

module Mapper
  module Model
    # モデルにincludeされた時に実行される
    def self.included(base)
      # 継承されている場合に親クラスから内部構造を持ってくる
      if base.superclass < Model
        base.instance_eval do
          @text = base.superclass.instance_variable_get("@text")
          @child = base.superclass.instance_variable_get("@child")
          @has_many = base.superclass.instance_variable_get("@has_many")
        end
      else
        base.instance_eval do
          @text = []
          @child = []
          @has_many = []
        end
      end
      base.extend Mapper::ClassMethods
    end
  end

  module ClassMethods
    def text(name)
      @text.push(name)
      attr_accessor(name)
    end

    def child(klass)
      @child.push(klass)
      attr_accessor(klass)
    end

    def has_many(name, klass)
      @has_many.push( [ name, klass ] )
      attr_accessor(name)
    end
  end

end

#
# モデル定義
# さっきのコメントをコードとして表現するようにした
#

class Root
  include Mapper::Model
  has_many :movies, :movie # moviesという名前で、movieを複数保持する
end

class Movie
  include Mapper::Model
  text :title
  text :star
  child :director
  has_many :cast, :actor  # castという名前で、actorを複数保持する
end

class Person
  include Mapper::Model
  text :name
  text :born
end

class Director < Person
  include Mapper::Model
end

class Actor < Person
  include Mapper::Model
end

#
# 第2.5形態は、モデルの内部構造を表示するところまでしか行わない
#

puts "===== 第2.5形態 =====\n\n"

def print_structure(klass)
  puts "#{klass}の構造"
  klass.instance_variable_get("@text").each { |v| puts "#{v}" }
  klass.instance_variable_get("@child").each { |v| puts "#{v}" }
  klass.instance_variable_get("@has_many").each { |v| puts "#{v}" }
  puts
end

print_structure(Root)
print_structure(Movie)
print_structure(Director)
print_structure(Actor)
実行結果
===== 第2.5形態 =====

Rootの構造
[:movies, :movie]

Movieの構造
title
star
director
[:cast, :actor]

Directorの構造
name
born

Actorの構造
name
born

継承されたデータ構造が表現できました。

コードでモデル同士の関係を表現できるようになりましたが
まだ、この時点ではただのコメントと変わりありません。

良いところ

  1. データ構造がパッと見てわかる
  2. データ構造を内部に保持している

モヤモヤするところ

  1. コードが長く、そして複雑になってきた
  2. データ構造を内部に保持しているが、まだパース処理との関連はないまま

クラスインスタンス変数

Rubyをそれなりに使っていると、クラス変数やインスタンス変数について学ぶと思います。
でも、クラスインスタンス変数については、なかなか学ぶ機会がないのではないでしょうか?

上記のコードでは、クラスインスタンス変数を使っています。

@text、@child、@has_many がそうです。

簡単に説明するとクラスインスタンス変数は

  • クラス変数のように、クラスをnewしていなくても使える
  • クラス変数と違って、サブクラスからはアクセスできない
  • クラスの数だけ用意される

といった特長があります。

上記のコードだと、@text、@child、@has_manyは、
Root、Movie、Director、Actor、Personの数だけ存在します。
@textなら5個あることになります。

どうしてスーパークラスじゃなくてインクルードなのか

どうして、こんな風にスーパークラスで実装を行わずに

class Root < Mapper::Model

includeで実装しているのか?

class Root
  include Mapper::Model

Rubyは、単一継承がサポートされていて、多重継承はサポートされていません。その代わりにmixinがサポートされています。

継承は単一継承しかできないので、何を継承するかをきちんと考えたいところです。

今回は継承機能を使う候補は2つあります。

ひとつは、映画のデータを表したモデルそのもの。
もうひとつは、データ構造を表してマッピングするための仕組み。

コードの読み手に関心を持ってもらいたいのは、映画のデータ構造の方です。

データ構造をマッピングする仕組み(Mapper::Model)はどちらかというと裏方の仕組みです。今回は映画のデータを表したモデルの方を継承するようにしました。

第3形態

モデルの中に保持したデータ構造を使ってサンプルデータを読み込む

先ほど用意した内部のデータ構造を使って、サンプルデータからモデルにデータを読み込んでみます。

includeしていた Mapper::Model を別ファイルに切り出しました。

form3.rb
require_relative 'sample_data'
require_relative 'mapper'

#
# モデル定義
#

class Root
  include Mapper::Model
  has_many :movies, :movie
end

class Movie
  include Mapper::Model
  text :title
  text :star
  child :director
  has_many :cast, :actor
end

class Person
  include Mapper::Model
  text :name
  text :born
end

class Director < Person
  include Mapper::Model
end

class Actor < Person
  include Mapper::Model
end

#
# サンプルデータをオブジェクトにマッピングする
#

root = Root.parse($sample_data)

#
# モデルを操作する
#

puts "===== 第3形態 =====\n\n"

root.movies.each do | movie |
  puts "movie: title: #{movie.title}, star: #{movie.star}"
  puts "director: name: #{movie.director.name}, born: #{movie.director.born}"
  movie.cast.each do |actor|
    puts "actor: name: #{actor.name}, born: #{actor.born}"
  end
  puts
end

マッピング処理と共にパース処理を組み込みました。

パース処理は、モデルの内部に保持したデータ構造を元に、ハッシュデータ構造からデータを取ってきてモデルのアトリビュートとして保存する処理です。

mapper.rb
module Mapper

  module Model

    # モデルにincludeされた時に実行される
    def self.included(base)
      # モデルが継承されている場合、データ構造をコピーする
      if base.superclass < Model
        base.instance_eval do
          @text = base.superclass.instance_variable_get("@text")
          @child = base.superclass.instance_variable_get("@child")
          @has_many = base.superclass.instance_variable_get("@has_many")
        end
      else
        base.instance_eval do
          @text = []
          @child = []
          @has_many = []
        end
      end
      base.extend Mapper::ClassMethods
    end

  end

  module ClassMethods

    def text(name)
      @text.push(name)
      attr_accessor(name)
    end

    def child(klass)
      @child.push(klass)
      attr_accessor(klass)
    end

    def has_many(name, klass)
      @has_many.push( [ name, klass ] )
      attr_accessor(name)
    end

    #
    # ここから下はパース処理
    #

    def parse(hash)
      instance = new
      parse_text(hash, instance)
      parse_child(hash, instance)
      parse_has_many(hash, instance)
      instance
    end

    def parse_text(hash, instance)
      text_list = instance.class.instance_variable_get("@text")
      text_list.each do |text_name|
        text_value = hash[text_name]
        unless text_value.nil?
          instance.instance_variable_set("@#{text_name}", text_value)
        end
      end
    end

    def parse_child(hash, instance)
      child_list = instance.class.instance_variable_get("@child")
      child_list.each do |child_klass|
        child_hash = hash[child_klass]
        unless child_hash.nil?
          child_instance = create_instance_and_parse(child_klass, child_hash)
          instance.instance_variable_set("@#{child_klass}", child_instance)
        end
      end
    end

    def parse_has_many(hash, instance)
      has_many_list = instance.class.instance_variable_get("@has_many")
      has_many_list.each do |has_many|
        list_name = has_many[0]
        klass_name = has_many[1]
        sibling_list = hash[list_name]
        next if sibling_list.nil?
        sibling_list.each do |sibling_hash|
          sibling_instance = create_instance_and_parse(klass_name, sibling_hash)
          sibling = instance.instance_variable_get("@#{list_name}")
          sibling ||= []
          sibling.push(sibling_instance)
          instance.instance_variable_set("@#{list_name}", sibling)
        end
      end
    end

    # 子オブジェクトを作ってパースを再帰呼び出しする
    def create_instance_and_parse(klass_name, hash)
      # クラス名に変換するところが手抜き処理
      klass_name = klass_name.to_s.capitalize
      klass = Object.const_get(klass_name)
      instance = klass.parse(hash)
      instance
    end

  end

end
実行結果
===== 第3形態 =====

movie: title: インターステラー, star: 8.6
director: name: クリストファーノーラン, born: 1970-07-30
actor: name: マシューマコノヒー, born: 1969-11-04
actor: name: アンハサウェイ, born: 1982-11-12

movie: title: パルプフィクション, star: 8.9
director: name: クエンティンタランティーノ, born: 1963-03-27
actor: name: ジョントラボルタ, born: 1954-02-18
actor: name: サミュエル L ジャクソン, born: 1948-12-21
actor: name: ブルースウィリス, born: 1955-03-19

movie: title: 君の名は, star: 8.3
director: name: 新海誠, born: 1973-02-09
actor: name: 神木隆之介, born: 1993-05-19
actor: name: 上白石萌音, born: 1998-01-27

良いところ

  1. データ構造にパース処理が関連付いた

モヤモヤするところ

  1. コードがさらに長く、さらに複雑になった。もはや諦めの境地

ほぼ完成です。

モデル自身がデータ構造を表現し、そのデータ構造を使ってデータをモデルに読み込むことができました。
モデルを用意すれば、映画データ構造以外のデータ構造にも対応できるようになっています。

第4形態

モデルとパース処理を分離する

モデルは、データ構造を保持する。
パーサーは、データ構造を元にデータを読み込む。

といった具合に、責務によってコードを分けます。

form4.rb
require_relative 'sample_data'
require_relative 'mapper2'
require_relative 'parser'

#
# モデル定義
#

class Root
  include Mapper::Model
  has_many :movies, :movie
end

class Movie
  include Mapper::Model
  text :title
  text :star
  child :director
  has_many :cast, :actor
end

class Person
  include Mapper::Model
  text :name
  text :born
end

class Director < Person
  include Mapper::Model
end

class Actor < Person
  include Mapper::Model
end

#
# サンプルデータをオブジェクトにマッピングする
#

parser = Parser.new
root = Root.parse(parser, $sample_data)

#
# モデルを操作する
#

puts "===== 第4形態 =====\n\n"

root.movies.each do | movie |
  puts "movie: title: #{movie.title}, star: #{movie.star}"
  puts "director: name: #{movie.director.name}, born: #{movie.director.born}"
  movie.cast.each do |actor|
    puts "actor: name: #{actor.name}, born: #{actor.born}"
  end
  puts
end
mapper2.rb
module Mapper

  module Model

    # モデルにincludeされた時に実行される
    def self.included(base)
      # モデルが継承されている場合、データ構造をコピーする
      if base.superclass < Model
        base.instance_eval do
          @text = base.superclass.instance_variable_get("@text")
          @child = base.superclass.instance_variable_get("@child")
          @has_many = base.superclass.instance_variable_get("@has_many")
        end
      else
        base.instance_eval do
          @text = []
          @child = []
          @has_many = []
        end
      end
      base.extend Mapper::ClassMethods
    end

  end

  module ClassMethods

    def text(name)
      @text.push(name)
      attr_accessor(name)
    end

    def child(klass)
      @child.push(klass)
      attr_accessor(klass)
    end

    def has_many(name, klass)
      @has_many.push( [ name, klass ] )
      attr_accessor(name)
    end

    def parse(parser, hash)
      instance = new
      # 外部で定義されたパーサーに委譲する
      parser.parse(hash, instance)
      instance
    end

  end

end
parser.rb
class Parser

  def parse(hash, instance)
    parse_text(hash, instance)
    parse_child(hash, instance)
    parse_has_many(hash, instance)
  end

  def parse_text(hash, instance)
    text_list = instance.class.instance_variable_get("@text")
    text_list.each do |text_name|
      text_value = hash[text_name]
      unless text_value.nil?
        instance.instance_variable_set("@#{text_name}", text_value)
      end
    end
  end

  def parse_child(hash, instance)
    child_list = instance.class.instance_variable_get("@child")
    child_list.each do |child_klass|
      child_hash = hash[child_klass]
      unless child_hash.nil?
        child_instance = create_instance_and_parse(child_klass, child_hash)
        instance.instance_variable_set("@#{child_klass}", child_instance)
      end
    end
  end

  def parse_has_many(hash, instance)
    has_many_list = instance.class.instance_variable_get("@has_many")
    has_many_list.each do |has_many|
      list_name = has_many[0]
      klass_name = has_many[1]
      sibling_list = hash[list_name]
      next if sibling_list.nil?
      sibling_list.each do |sibling_hash|
        sibling_instance = create_instance_and_parse(klass_name, sibling_hash)
        sibling = instance.instance_variable_get("@#{list_name}")
        sibling ||= []
        sibling.push(sibling_instance)
        instance.instance_variable_set("@#{list_name}", sibling)
      end
    end
  end

  # 子オブジェクトを作ってパースを再帰呼び出しする
  def create_instance_and_parse(klass_name, hash)
    # クラス名に変換するところが手抜き処理
    klass_name = klass_name.to_s.capitalize
    klass = Object.const_get(klass_name)
    instance = klass.parse(self, hash)
    instance
  end

end
実行結果
===== 第4形態 =====

movie: title: インターステラー, star: 8.6
director: name: クリストファーノーラン, born: 1970-07-30
actor: name: マシューマコノヒー, born: 1969-11-04
actor: name: アンハサウェイ, born: 1982-11-12

movie: title: パルプフィクション, star: 8.9
director: name: クエンティンタランティーノ, born: 1963-03-27
actor: name: ジョントラボルタ, born: 1954-02-18
actor: name: サミュエル L ジャクソン, born: 1948-12-21
actor: name: ブルースウィリス, born: 1955-03-19

movie: title: 君の名は, star: 8.3
director: name: 新海誠, born: 1973-02-09
actor: name: 神木隆之介, born: 1993-05-19
actor: name: 上白石萌音, born: 1998-01-27

コードの長さには辟易します。

良いところ

  1. 責務が明確になった
  2. モデルはデータ構造を表現
  3. パーサーはデータ読み込み処理を表現

パーサーを切り替えることで、ハッシュデータ構造以外のデータ構造、例えば、XMLやYamlやJsonなどにも対応できるようになります。

モデルの初期化メソッドで初期値を設定しない理由

第2形態でモデル化した時

accessorを普段から使っている方だと、モデルのinitializeメソッドで、attrに初期値を設定したくなりますよね。
でも、そこはぐっと堪えましょう。理由は後ほど!?

この理由なのでが
モデルのinitializeメソッドでhashを受け取ってattrにセットしたとすると

下の図のように、モデルがハッシュデータに依存する関係になります。
スクリーンショット 2022-03-16 11.05.58.png

パーサーを間にはさむことで、モデルはハッシュデータに依存しなくなります。
この関係が可能なのは、モデルが自身のデータ構造をパーサーに開示しているからです。
スクリーンショット 2022-03-16 11.06.12.png

モデルとデータに直接の依存関係がないので、パーサーを切り替えることで、他のデータ形式に対応させることもできます。
スクリーンショット 2022-03-16 11.06.21.png

まとめ

第1形態

ハッシュデータそのまま

第2形態

シンプルなモデル

第3形態

モデルの内部に自身のデータ構造を保持する。
そのデータ構造を元にパースし、取得した値をモデルにマッピングする。

第4形態

モデルからパース処理を分離

残る課題

第1形態のところで

元データのハッシュの表現がコードの可読性に直接影響している
ハッシュのキーがむき出しのまま、もしも「movie」ってキーが「m」なんてキーだったら、このコードは可読性は著しく低下するでしょう。

実は、第3、第4形態でもここはスルーしています。
サンプルコードをシンプルにして理解し易くするためです。

しかし、ちょっと変更するだけで、
「movie」ってキーが「m」
みたいなのに対応できるので、少し考えてみてください。

class Root
  include Mapper::Model
  has_many :movies, :movie :m  # <-- こんな感じに書けると嬉しい
end

最後に

いかがでしたでしょうか。

今回ご紹介したこのような仕組みのことを「オブジェクトマッパー」と呼んだりします。

モデル化とかメタプログラミングとかオブジェクト指向とか
いっぱい詰め込み過ぎて説明しきれていない感は否めませんが、
少しでも、みなさんの好奇心の刺激になったらと思います。

ご質問、ご指摘事項、ご要望(もう少しここを説明してほしいとか)ありましたらコメントいただけると幸いです。

オブジェクト指向は用量用法を守ってお使いください。

Discussion