Kotlinのデータクラスが便利なのでRubyでそれっぽいものを作ってみたときに勉強になったこと

いまの職場に入社した頃はPython大好きRuby大嫌いだったのだけど、3年半もRubyばっかり触っていたら流石に少し克服しつつあるので、初めてGemを作ってみることにした。

ネタはKotlinで便利なdata class

data class Point(val x: Double = 0.0, val y: Double = 0.0) {
  val norm get() = Math.sqrt(x * x + y * y)
}

つくってみると、「Rubyのこの仕組みって、こうやって実装されてるのか!」ってところがいくつか有ったので、書いておこうかなと思う。

そもそもなんでデータクラスを作ろうと思ったのか?

まずは前提の共有から。

いま検索画面を仕事でつくっていて、「検索条件」のように6つくらいの値(キーワード、ソート順、カテゴリ、etc)を保持する箱がほしいと常日頃おもっていた。

qiita.com

↑ではかなり控えめに書いたんだけど、現状だと6つくらいの値がすべてコントローラのインスタンス変数で保持されていて、無秩序にそれらをパーシャルビューやヘルパーメソッドから読んでいるような実装になっている。イメージこんなかんじ↓

<% if @keyword.present? %>
<h1><%= @keyword %>の検索結果</h1>
<% else %>
  <% if professional_search? %>
  <h1>プロに仕事を依頼する</h1>
  <% elsif ... %>
     ...
  <% end %>
<% end %>
def professional_search?
  @keyword.blank? && @only_professional_flag.present?
end

これを、

SearchQueryViewModel = ViewModel.create(:keyword, :sort_order, category_ids, ...)
@search_query_view_model = SearchQueryViewModel.new(keyword: params[:q], sort_order=params[:order], ...)

のように、コントローラの変数は1つにして整理したり、

search_result = User.search(...)

UserPartialViewModel = ViewModel.create(:display_name, :avarage_feedback_score, :num_contracts)
@users = User.where(id: search_result.user_ids).includes(:feedback_summary, :profile).map{|user|
                  UserPartialViewModel.new(
                    display_name: user.profile.display_name,
                    avarage_feedback_score: user.feedback_summary.avarage_score,
                    num_contracts: user.feedback_summary.num_contracts)
               }
<% @users.each do |user| %>
<%= render 'shared_partials/user', locals: { user: user } %>
<% end %>

のように、パーシャルビューに必要な変数たちをひとまとめにしたり、あとは、ヘルパーに生やしてる変数をビューモデル側で定義したり、

まぁそれなりにひとかたまりに値をまとめる箱がほしいと思うケースがあった。  

とくにライブラリとか使わなくてもシンプルに

class HogeViewModel
  def initialize(keyword:, hoge_flag:)
    @keyword = keyword
    @hoge_flag = hoge_flag
  end

  attr_reader :keyword, :hoge_flag
end

のようなボイラープレートコードを書きまくればいい話なんだけど、できればこれを先に書いたような HogeViewModel = ViewModel.create(:keyword, :hoge_flag) みたいにサクッと済ませられる仕組みが欲しかった。

Ruby標準のStructでいいんじゃないか?」とも思ったのだけど、こいつは多機能すぎた。そもそもImmutableではないし、#members のように属性一覧をイテレートできるような仕組みまであるのはビューモデルでは全く必要ない。

ということで、自分が欲しいものをそのまま作ってみることにした。

 

bundle gem

Nodeでいうところの npm init みたいな感じの物があるだろうと思ったら、やっぱりあった。

bundle gem kt_data_class --test=rspec

github.com

RSpecの下準備をやってくれるのは結構ありがたみある。

CIの設定

bundle gem を叩いただけで、 .travis.yml が作られるし、rakeのデフォルト動作で単体試験実行をしてくれるようになっている。なので、そのままTravis CIをONにするだけで、masterプッシュ時に自動でテストがまわるCIっぽいことは十分にできる。

ただ「CIで実行したときだけspecが落ちる」という事象が起きたときに、Travis CIだとどうにもこうにもデバッグができず、かなり格闘をすることになった。 やっぱり使い慣れてるCircle CIでSSHデバッグしたいなーーと思い、速攻で乗り換えた。

github.com

結局は require 'spec _helper' を書き忘れていただけ、というショボショボの原因だったんだけど、それでもなぜかローカルではspecが通ってしまうので結構ハマった。

大抵の場合にはTravis CIで十分なんだろうなーとは思うけど、とはいえRubyのバージョンによってはTravis CIはRubyのダウンロードとか毎度やって無駄に時間がかかるし、Circle CIのコンフィグはコピペすれば動く系なので、Circle CIを最初から使ってしまうのが楽かなぁと個人的には思った。

eql?, equal?, ==, === は何を返すべきか

これはPython信者からするとかなり意味不明とキレたくなるところだ。Rubyにはequalっぽいメソッドが多く、名前もややこしい。 same_object? same_value? とかそういうメソッド名にしなかったのはなんで??ってのがものすごく謎い。

という愚痴はここまでにして、

mickey24.hatenablog.com

を参考に実装した。あとは、HashとかStructがそれぞれどういう eql? equal? == になっているかも参考にした。

このあたりは、毎度コンソールで動作確認するのがしんどいので、

  describe 'equality' do
    let(:klass1) { KtDataClass.create(:x) }
    let(:klass2) { KtDataClass.create(:x) }

    describe '同一のクラスの2つの同値インスタンスの比較' do
      let(:instance1) { klass1.new(x: 1) }
      let(:instance2) { klass1.new(x: 1) }

      it { expect(instance1.equal?(instance2)).to eq(false) }
      it { expect(instance1 == instance2).to eq(true) }
      it { expect(instance1 <=> instance2).to eq(0) }
       it { expect(instance1.eql?(instance2)).to eq(true) }
      it { expect(instance1 === instance2).to eq(true) }
    end

    describe '同一のクラスの2つの異なる値のインスタンスの比較' do
      let(:instance1) { klass1.new(x: 1) }
      let(:instance2) { klass1.new(x: 2) }

      it { expect(instance1.equal?(instance2)).to eq(false) }
      it { expect(instance1 == instance2).to eq(false) }
      it { expect(instance1 <=> instance2).not_to eq(0) }
      it { expect(instance1.eql?(instance2)).to eq(false) }
      it { expect(instance1 === instance2).to eq(false) }
    end

    describe '定義が同じで、異なるクラスの2つのインスタンスの比較' do
      let(:instance1) { klass1.new(x: 1) }
      let(:instance2) { klass2.new(x: 1) }

      it { expect(instance1.equal?(instance2)).to eq(false) }
      it { expect(instance1 == instance2).to eq(true) }
      it { expect(instance1 <=> instance2).to eq(0) }
      it { expect(instance1.eql?(instance2)).to eq(false) }
      it { expect(instance1 === instance2).to eq(true) }
    end

のようにspecをササッと書いて、こいつが通るまで実装を繰り返すっていうTDDっぽい感じで進めると捗った。

分割代入はどうやったら実現できるのか

これは必要のない機能なんだけど、どうやって実現されてるのか知りたかったので実装した。

github.com

Structだと

Point = Struct.new(:x, :y)
p1 = Point.new(3, 4)

x1, y1 = p1

x1
# => 3

y1
# => 4

のような挙動になるやつは、何を実装すればできるようになるのか?

to_a あたりを生やせばいいのかな?と思って調べ始めたところ

Rubyの多重代入におけるto_aとto_aryの挙動 - maeharinの日記

この記事に行き着いた。 to_ary っていうメソッドを生やせばいいんだとか。(これまたわかりにくい名前だw)

初期化ブロックはどうやって実装するのか?

Structだと

Point = Struct.new(:x, :y) do
  def norm
    Math.sqrt(x * x + y * y)
  end
end

のように拡張することができる。これはどうしても欲しい。

で、何を調べていたか忘れたけど、このあたりで「あれ、既に似たようなライブラリあるじゃん??」ってのを発見して、

github.com

この実装を結構参考にさせてもらった。クラスの拡張は class_eval を使ってるんだなー、と。

github.com

少し悩んだ末に型安全を捨てた

実はこのGemを作り始めたときは

Point = KtDataClass.create(x: Fixnum, y: Fixnum)

のように、型を明示的に指定して、

p1 = Point.new(x: 3, y: 4)
# => #<Point:0x000000000233c5f0 @x=3, @y=4>

p2 = Point.new(x: 3, y: "4")
# ArgumentError: type mismatch: y must be a Fixnum, String given

のように、型に合わないものは入れられないようにするつもりだった。

特に理由はないが、なんとなく「入れられるものを制限しておいたほうが、無用な条件分岐とか is_a? とかしなくて済むかなー」くらいの軽いノリで。

ところが、実際に使うとなると、これだと結構不便だということがわかった。

一番「あれれ」と思ったのが、true/falseを入れるときの定義。 TrueClass or FalseClass みたいな謎の定義をしないといけない。それはあまり狙ったところではない。むしろ邪魔くさい。

さらに、

qiita.com

このあたりの議論を見るに、Rubyはダックタイピングなので、型で制限するのは筋がよくないということだ。

それはそのとおりだと思った。

github.com

Gemの公開

何を間違ったか、最初の方でRakefileを消してしまっていたので rake build ができなかった。

とはいえ、specをrake経由で実行したいとは思わないので、

require "bundler/gem_tasks"

の1行だけを書いたRakefileを定義して、無事にrake buildでパッケージが作れた。

github.com

あとは、 rake release でリリースしようとしたら、Gitに何かがpushされるっぽく邪魔くさかったので、

rake build
gem push pkg/kt_data_class-0.1.0.gem 

のように、明示的にプッシュ対象を指定して、シンプルにリリースした。

このあたりは手作業でやるのがだるいので、そのうちCIのgit tagトリガーのbuildに任せたいと思う。

まとめ

Gemを作ると、結構勉強になることが多かった。