YOSHINO日記

プログラミングに関すること

Rails: 小さいクラスから大きいクラスを作る方法

委譲か継承。それが問題だ。そうなんだ。

Railsの委譲と継承について考えます。

具体的にいえば、委譲として”delegate_missing_to”、”SimpleDelegator”。

継承としてSTIを見てみます。

委譲か継承。それが問題だ。

継承の本質は、重複を避けて DRY にする事だ。 複数クラスで重複した部分が無ければ、継承をする意味が無い。

委譲の本質は、大きいパーツをより小さい疎結合なパーツに分けて実装する事だ。 小さいクラスを複数のクラスで流用出来なかったとしても、委譲する意味はある。 (もちろん、複数クラスで小さいクラスを流用できるならば、 するに越した事は無い。)

全てを委譲したいなら delegate_missing_to

Active Support コア拡張機能 | Rails ガイド

UserオブジェクトにないものをProfileにあるものにすべて委譲したいとしましょう。delegate_missing_toマクロを使えばこれを簡単に実装できます。

簡単な例をみてみます。

rails g model Blob filename
rails g mode Attachment name blob_id:integer

AttachmentからBlobへdelegate_missing_toを設定します。

attachment.rb

class Attachment < ApplicationRecord
  belongs_to :blob

  delegate_missing_to :blob
end

Blobモデルに以下のようにメソッドを追加します。

blob.rb

class Blob < ApplicationRecord
  def hello
    puts "hello from blob"
  end
end

AttachmentのからBlobのメソッドを実行することができます。

attachment = Attachment.create(name: "sample", blob_id: Blob.first.id
attachment.hello  #=> hello from blob

想像通り、attachment.rbに

class Attachment < ApplicationRecord
  belongs_to :blob

  delegate_missing_to :blob

  def hello  
    puts "hello from attachment"
  end
end

とすれば、BlobのhelloではなくてAttachmentのhelloが呼ばれます。

実際のコードはこんな感じになっています。

rails/delegation.rb at 3868648cae36fd64741135e3d33d7055e925879b · rails/rails · GitHub

同じようなことをしたいなら、delegate_missing_toを使わなくても可能です。

例えば、delegateメソッドを利用して移譲したい機能をすべて使用することも可能ですが、 全てを移譲したい場合、コード自体が冗長になりますし、意図が不明確になります。

委譲先を動的に決めることができるSimpleDelegator

Class: SimpleDelegator (Ruby 2.4.1)

SimpleDelegatorは形としては継承をしているけれど、継承先とは完全に独立しているし、 感覚的には委譲に近いかもしれない。

app/models/blob.rb

class Blob < ApplicationRecord
  def hello
    puts "hello from Blob!"
  end
end

app/models/attachment.rb

class Attachment <  SimpleDelegator
end
simple_blob = Blob.create(filename:"sample.png")
attachment = Attachment.new(simple_blob)
attachment.hello
#=> hello from Blob!

次のような特徴があります。

[Rails 5.1] 新機能: delegate_missing_toメソッド(翻訳)

このクラスの問題は、このdelegatorは実行時にどんな種類のオブジェクトでも使われる可能性があるため、呼び出しが委譲される先のオブジェクトを静的に確定できないという点です。

条件分岐によって委譲したい対象が変わる場合には、SimpleDelegatorの導入を検討すべきかもしれない。よくわからない。

STI(Single Table Inheritance: 単一継承テーブル)

継承のメリットは?

それは、実装時のステップ数が減る事だ。 小さいクラスではインターフェースを実装する必要は無い。 インターフェースを含めて小さいクラスで実装済みの物は、ほとんど全て 大きいクラスで流用できる。 一つの子クラスを複数のクラスで流用すれば、全体のステップ数が減る。

継承のデメリットは?

親クラスと子クラスが密結合になる事だ。 親クラスがどんなに優れたインターフェースを持っていたとしても、 子クラスはそのインターフェースを使わないので密結合になる。

密結合は本質的によろしくない。

なので、クラスを独立させられるのなら委譲を検討すべきだと思う。

RailsSTIの例を見てみることで、STIのメリット(これは委譲でも得られる)と、デメリットを見ることができる。

使い方のポイントは親モデルがtypeカラムを持つことです。

簡単な例を見てみます。

$ rails g model mother name type
$ rails db:migrate

app/models/mother.rb

class Mother < ApplicationRecord
  def good_morning
    puts "good morning! boys and girls!"
  end
end

このmotherモデルを継承したい場合は、以下のようにmodel下にファイルを追加するだけでOKです。

app/models/boy.rb

class Boy
  def baseball
    puts "I like baseball!"
  end
end

app/models/girl.rb

class Girl < Mother
  def shopping
    puts "I like shopping!"
  end
end

使う時はこんな感じ。

boy = Boy.create(name: "takeshi")
# => #<Boy id: 1, name: "takeshi", type: "Boy", created_at: "2018-09-01 02:00:23", updated_at: "2018-09-01 02:00:23">
boy.baseball
# => I like baseball!
boy.good_morning
# => good morning! boys and girls!
boy.shopping
# => NoMethodError (undefined method `shopping' for #<Boy:0x000055ca515b6ac8>)

STIのポイントをまとめると

  • DBが存在するのは親のものだけ(カラムは統一される)

子供クラスに独自のカラムをもたせたい場合、親のモデルにそのカラムを追加する必要があります。 他の子クラスでそのカラムを使用しない場合、そこは使用されずnilが埋まることになるとういうことです。 (Rails側で個別にvalidationはかけられますが。)

密結合。

  • 親のクラスから継承が可能

子クラスで親クラスのメソッドを継承できるし、子クラスでオーバーライドできるし、 子クラス独自のメソッドも定義できます。

DRYな記述ができる。

別の観点らのSTI

みんなRailsのSTIを誤解してないか!?

この記事の作者自体はSTIに好意的な意見ですが、この記事の@kenさんのコメントが参考になるかもしれません。

単純にtypeフィールドが文字列で入るので空間効率が良くない(しかもカーディナリティが低いのでインデックス向きでない)、という観点もありますね。とくにレコード件数が数億件を超えるような時系列テーブルになると、この反復は無視できません。こういった用途的・実装的な向き不向きもありそうです。

このため、多段継承がないならばenum kind: [:customer, :company]のようにして1バイトのIntegerフィールドで自前で分岐処理を書くという方法もあります。

経験上、STIとして実装したサブクラスには独自メソッドが1個か2個しかないようなケースも多く、ほとんどが共通の処理で、このような場合にはサブクラスの定義ファイルが分割されてしまうのはかえって見通しを悪くしてしまい、ストレスを感じることが多かったです。

また、「ほとんど共通だがちょっとだけクラスによって挙動が違う」ようなメソッド(例えばコールバック)では、どうしてもメソッド全体をまるっとコピペして一部だけ書き換えるというコードが増えがちです。これは非常に望ましくない状況でした。

このようなケースに限って言えば、enumで処理の異なる部分だけを分岐してやったほうが、全体としてコピペも減り、フローも追いやすくなったというのが実感としてあります。