Ruby 2.7, 3.0, 3.1でもデータクラス (Data.define) が使えるようなライブラリdata_class_factoryを作った

まえおき

Ruby 3.2のデータクラスっていうやつがとてもハイセンス。実装した人自身が、dev.toでとても自慢げに記事を書いている。

dev.to

大昔に自分で初めて作ったライブラリが奇しくもデータクラスだったので、これは見逃せない。

yusukeiwaki.hatenablog.com

Ruby 3.2のデータクラスは、Structから機能を落とした版で、おそらく多くのRubyistたちが望んだものだろう。しかしながら当然のこと、これはRuby 3.1以前のバージョンでは使えない。

いっぽうで、そこそこ大きめのRailsアプリケーションを開発してると、Rubyのバージョンはそんなにホイホイ上げられるものではないので、Ruby 3.0や3.1止まりの会社も今はまだ多かろう。そんなわけで、kt_data_classのエッセンスは取り入れつつ、バックポートを目的としたライブラリを作ることにした。

1日で作りたい

kt_data_classを初めて作ったときは、たぶん1週間くらいかけて作ってたのだけど、今回のデータクラスのバックポートは、なる早で使いたい。

目標として「1日で仕上げる」ことを気に留めながら開発を進めた。

テスト駆動だが、今回はMinitest

スピード開発なので、迷わずテスト駆動で開発をすすめることにした。

kt_data_classのときは bundle gemのデフォルト値に従うままでRSpecでテストを書いていたが、Minitestの並列実行の速さに慣れてしまうとRSpecはとっても遅い。今回は速いMinitestを採用。

あと、bundle gemのデフォルトの構成だとtest_helperとか色々作られるが、よりシンプルな構成に変更。

Rakefileはこんなかんじ。 rake 単体でテストが流れるのは若干キモイので、 rake test でテストが流れるだけの設定にダイエット。

require 'bundler/gem_tasks'
require 'rake/testtask'

Rake::TestTask.new(:test) do |t|
  t.libs << 'test'
  t.libs << 'lib'
  t.test_files = FileList['test/**/*_test.rb']
end

個々のテストでは、test_helperを使わず、↓こんな感じのをテンプレートとしてこぴぺして使った。

require 'minitest/autorun'
require 'data_class_factory'

class XXXTest < Minitest::Test
  def test_zzzzzz

ここまでやることで、「テストファイル1つを見れば、単体テスト内容がすべてわかる」という状態にできる。あっちこっち見て回らなくて住むのでスピーディーにテストが書ける。

リファレンスとRuby本体の単体テストを大いに参考にした

Dataクラスについては、Rubyのリファレンスがとても充実している。

docs.ruby-lang.org

あとは、RubyにDataクラスが追加される際に書かれた単体試験(Add Data class implementation: Simple immutable value object by zverok · Pull Request #6353 · ruby/ruby · GitHub)も、ほぼそのまま流用することで、Dataクラスがどう振る舞うべきか、Ruby 3.2のDataクラスとの乖離が少ない形で実装が進められる。

dup, cloneと、Marshal.dump/loadの対応

Ruby本体の単体試験には以下のようなコードがある。

  def test_dup
    klass = Data.define(:foo, :bar)
    test = klass.new(foo: 1, bar: 2)
    assert_equal(klass.new(foo: 1, bar: 2), test.dup)
    assert_predicate(test.dup, :frozen?)
  end

  Klass = Data.define(:foo, :bar)

  def test_marshal
    test = Klass.new(foo: 1, bar: 2)
    loaded = Marshal.load(Marshal.dump(test))
    assert_equal(test, loaded)
    assert_not_same(test, loaded)
    assert_predicate(loaded, :frozen?)
  end

https://github.com/ruby/ruby/blob/v3_2_0/test/ruby/test_data.rb

シャローコピー(dupやclone)してもディープコピー(Marshal.dump/load)しても、freezeされた状態であれ、というもの。

class Data
  def initialize(**kwargs)
     ...いろいろ

     freeze
  end

こんな感じでinitializeの末尾でfreezeしておけばいいのだと思っていたが、これではdupやclone, Marshal.dump/loadいずれの操作時にも、freezeされていないインスタンスが生成されてしまった。

結論から言うと

  • dupやcloneでは #initialize_copy のオーバーライドが必要
  • Marshal.loadでは #marshal_dump #marshal_load のオーバーライドが必要
    • marshal_loadだけをオーバーライドしても動かない。両方オーバーライドが必要。

ということだった。kt_data_classのときはここまで考慮してなかったので、今回とても勉強になった。

github.com

.newの引数はキーワード引数だけにする

Structを踏襲してなのか、Dataクラスも「キーワード引数でもキーワード引数じゃなくてもいい」という仕様が健在だ。

Point = Data.define(:x, :y)

Point.new(3, 4) # OK
Point.new(x: 3, y: 4) # OK

ただ、これは完全に個人的意見だが、キーワード引数だけで十分だと思うし、そのほうが呼び出し側だけ読んで引数の意味がわかりやすくて良いと思う。

Feature #16122: Data: simple immutable value object - Ruby master - Ruby Issue Tracking Systemのディスカッションを見るに、どうもパターンマッチングとの整合性や、単純に書く量多くね?みたいな流れでキーワードじゃない引数もサポートするようにしたようだ。 また、New in Ruby 3.2 - Data.define - DEV Communityにも書かれているように、キーワードじゃない引数をサポートするための実装として、initializeではなく .newのほうで少々工夫が凝らされている。

もしキーワード引数だけの対応であれば、そんな工夫は一切いらない。

別にそれでよくね?

というわけで、バックポートDataクラスでは、一旦キーワード引数だけをサポートする形としてみた。

やっぱりpositional argumentもサポートしたほうが便利だなってなったら、今後追加するかもしれない。

で、一日でできたの?

思い立ったのが2023/3/4の10:53で

仕事終わりの夜に実装を進めて、データクラスが出来上がったのは 3/6の2:31 github.com

Gemを世に放ったのは3/8の10:28なので、まぁ全然1日では終わっていないw

そうはいっても、kt_data_classのときよりはだいぶスピーディーに開発したとは思う。(自画自賛w

まとめ

思い立って数日で、なんとか形にはした。

rubygems.org

そして、こういう既存機能ポーティングのライブラリ開発は作るたびに勉強になる。