まえおき
Ruby 3.2のデータクラスっていうやつがとてもハイセンス。実装した人自身が、dev.toでとても自慢げに記事を書いている。
大昔に自分で初めて作ったライブラリが奇しくもデータクラスだったので、これは見逃せない。
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のリファレンスがとても充実している。
あとは、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
のオーバーライドが必要- よく見たら Object#initialize_copy (Ruby 3.2 リファレンスマニュアル) に詳しく書いてある。
- .newの場合は allocateしたあと、initialize が呼ばれるのに対し、dupやcloneはallocateしたあとinitializeは呼ばずinitialize_copyを呼ぶらしい
- Marshal.loadでは
#marshal_dump
#marshal_load
のオーバーライドが必要- marshal_loadだけをオーバーライドしても動かない。両方オーバーライドが必要。
ということだった。kt_data_classのときはここまで考慮してなかったので、今回とても勉強になった。
.newの引数はキーワード引数だけにする
Structを踏襲してなのか、Dataクラスも「キーワード引数でもキーワード引数じゃなくてもいい」という仕様が健在だ。
Point = Data.define(:x, :y) Point.new(3, 4) # OK Point.new(x: 3, y: 4) # OK
ただ、これは完全に個人的意見だが、キーワード引数だけで十分だと思うし、そのほうが呼び出し側だけ読んで引数の意味がわかりやすくて良いと思う。
Ruby 3.2のData.defineの「キーワード引数もキーワードじゃない引数も受け付けるよ」って仕様、ほとんどのケースであまりうれしくないんじゃないかって思うんだけど、どうしてこうしたん??せめてキーワード引数だけを受け付けるオプションほしい...。
— Yusuke Iwaki (@yi01imagination) 2023年2月26日
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で
これを改造してRuby 3.1以下でもData.defineを使えるようにしたら需要あるんでは?と思ってる。すでに誰かやってそうではあるけどhttps://t.co/rgcBY2yshS
— Yusuke Iwaki (@yi01imagination) 2023年3月4日
仕事終わりの夜に実装を進めて、データクラスが出来上がったのは 3/6の2:31 github.com
Gemを世に放ったのは3/8の10:28なので、まぁ全然1日では終わっていないw
そうはいっても、kt_data_classのときよりはだいぶスピーディーに開発したとは思う。(自画自賛w
まとめ
思い立って数日で、なんとか形にはした。
そして、こういう既存機能ポーティングのライブラリ開発は作るたびに勉強になる。