あるキーに対して値(バリュー)を持つ、いわゆるキーバリューのようなデータ構造はプログラミングでよく使います。Rubyでそれを扱うには複数の選択肢(Hash、Struct、OpenStruct、Data、Class)があり、Ruby on Railsを使う場合さらにActiveModel、ActiveRecordもあります。この記事では私の知っている特徴と、使いわけを紹介します。
こちらはRuby Advent Calendar 2024の12/6日分の記事です。 Rubyアドベントカレンダー6日目に書けたわけではありませんが、Rubyに関係ある記事をタイミングよく公開することになったのと、せっかくなら自分が好きな言語のカレンダーを埋めたい気持になったので参加しました!
みなさんはどうやって使いわけていますか?以下のコード例のように、どれもそれなりに似た初期化とアクセスができますね。
require 'bundler/inline' gemfile do source 'https://rubygems.org' gem 'activemodel', '~> 8.0' end require 'ostruct' require 'active_model' # Hash hash = {a: 1, b: 2, c: 3} hash_a = hash[:a] puts "hash_a is #{hash_a}" # => hash_a is 1 # Struct MyStruct = Struct.new(:a, :b, :c, keyword_init: true) struct = MyStruct.new(a: 1, b: 2, c: 3) struct_a = struct.a puts "struct_a is #{struct_a}" # => struct_a is 1 # OpenStruct open_struct = OpenStruct.new(a: 1, b: 2, c: 3) open_struct_a = open_struct.a puts "open_struct_a is #{open_struct_a}" # => open_struct_a is 1 # Data MyData = Data.define(:a, :b, :c) data = MyData.new(a: 1, b: 2, c: 3) data_a = data.a puts "data_a is #{data_a}" # => data_a is 1 # Class class MyClass attr_reader :a, :b, :c def initialize(a:, b:, c:) @a = a @b = b @c = c end end my_class = MyClass.new(a: 1, b: 2, c: 3) my_class_a = my_class.a puts "my_class_a is #{my_class_a}" # => my_class_a is 1 # Active Model class MyActiveModel include ActiveModel::API attr_accessor :a, :b, :c end my_active_model = MyActiveModel.new(a: 1, b: 2, c: 3) my_active_model_a = my_active_model.a puts "my_active_model_a is #{my_active_model_a}" # => my_active_model_a is 1 # Active Record # (セットアップが必要なのでこの単純なスクリプトでは表現できなかった><)
私はこう判断してます
Ruby on Railsを使っている場合を前提としていますが、Rubyのみで考えてもそれなりに機能します。 Ruby on Railsを用いたWebプログラミングでこの振り分けに従うと、もちろん場合によって異なりますが、7-8割がActiveRecord、1-2割がHash、残りがその他くらいの登場人物になる感覚です。
graph TD A([クラス選択]) --> B{RDBの読み書きに使いたい?} B --> |はい| ActiveRecord([ActiveRecord]) B --> |いいえ| C{同じ値を保持しているなら同じ物と考えたい?} C --> |はい| D[非Class系] D --> E{Hashの柔軟さを制限したい?} E --> |はい| F{Ruby3.2以上?} E --> |いいえ| Hash([Hash]) F --> |はい| Data([Data]) F --> |いいえ| Struct([Struct]) C --> |いいえ(それだけでは足りない)| G[Class系] G --> H{ActiveRecordに似た使い方をしたい?} H --> |はい| ActiveModel([ActiveModel]) H --> |いいえ| Class([Class])
RDBの読み書きに使いたい?
これが「はい」の場合はActiveRecordを使いましょう。 判断に迷うのは、「いいえ」の場合のRDBに読み書きしない場合の選択肢になると思います。
同じ値を保持しているなら同じ物と考えたい?
同じ値を持っているならtrueになるHash、Struct、Dataのグループと、同じ値を持っているだけではtrueにならないClassのグループがあります。 Classのほうは自分で「同じ」というのが何なのか定義しなければいけません。
単に値だけで比較してよい場合はデフォルトでうまく扱える前者のグループを使いましょう。
require 'bundler/inline' gemfile do source 'https://rubygems.org' gem 'activemodel', '~> 8.0' end require 'active_model' ## 非Class 系は等しくなる # Hash p({a:1, b:2, c:3} == {a:1, b:2, c:3}) # => true # Struct MyStruct = Struct.new(:a, :b, :c, keyword_init: true) p(MyStruct.new(a:1, b:2, c:3) == MyStruct.new(a:1, b:2, c:3)) # => true # Data MyData = Data.define(:a, :b, :c) p(MyData.new(a:1, b:2, c:3) == MyData.new(a:1, b:2, c:3)) # => true ## Class 系は等しくならない # Class class MyClass def initialize(a:, b:, c:) @a = a @b = b @c = c end end p(MyClass.new(a:1, b:2, c:3) == MyClass.new(a:1, b:2, c:3)) # => false # ActiveModel class MyActiveModel include ActiveModel::API attr_accessor :a, :b, :c end p (MyActiveModel.new(a:1, b:2, c:3) == MyActiveModel.new(a:1, b:2, c:3)) # => false
Hashの柔軟さを制限したい?
読み手が理解しやすいプログラムは、書き手の意図がコードから読みとれるものですよね。 書き手がわざわざ「できない」ことが多いものを使って何かを表現しているとき、そこからは意図を強く感じとることができます。
Hashは今回の選択肢の中で一番素朴で柔軟なクラスです。 Hashのなんでも受けいれられる柔らかさはすごく便利な一方で、受けいれを制限して「できない」を明示することで読みやすいプログラムになることも多いです。
書き手の意図を読み手に十分に伝える自信が持てない場所ではHash以外を検討してよいと思います。
Hashでも十分なのは、例えば書き捨てするスクリプトであるとか、特定の短いメソッドの中でのみ利用するデータです。 これらはHashがどんなものを受けいれるかはすぐそばのコードを見るとわかるので読み手も誤解しにくいです。
インスタンス変数やメソッドの引数などはHashで十分な場合もありますがHash以外を検討してもいいでしょう。広い範囲での利用が見込まれるため、さまざまな読み手が書き手の意図をうまく感じとれるかが不明瞭になってくるためです。
どこまでがHashでよくて、どこからはHashではないほうがよいかは、プログラムの種類、プログラムの目的、書き手と読み手の関係性、修正の頻度、language serverなどプログラミングを補助してくれるツールの完成度など様々な要因によって変わるので一概には言えず、この判断というのは私にとっては面倒でもありますがプログラミングのおもしろポイントだと感じています。
Ruby3.2以上?
Ruby3.2からDataクラスを使えるようになりました。
DataクラスはStructクラスに比べて、初期値から値を変えることができないという制限がついてきますが、逆に初期値を見れば入っている値が想像しやすいので読み手にとっては優しいです。使える場合はこちらを使いませんか。
Structクラスは初期化以降も値を入れ替えることができるので、そういった用途の場合はStructクラスがよいです。 ただプログラムの理解しやすさの観点でいうと、初期化以降も値を入れ替えながら使うプログラムはもっと見通しをよくできないか、書きなおせないか検討してよさそうです。
オブジェクトを再利用することで、オブジェクトの数を増やしすぎないシステムにやさしいプログラムを書くことができるという観点はあるのですが、これは最適化のときに考えることで、この記事を読んでくれている人に一番助けになるのは言いきっちゃうことかなと思って思想強めな判断軸にしています。テヘッ☆ミ。
ActiveRecordに似た使い方をしたい?
Ruby on Railsを使っている場合はActiveRecordに対しての処理が大半を占めると思います。 RDBからの読み書きをしない場合でもActiveRecordに似た操作ができるとプログラムの書き手も処理を簡略化できますし、読み手もActiveRecordにある既存の機能を使っているのだなと振舞いを理解しやすいです。
クラスの紹介
ここまでで紹介したクラスのそれぞれの特徴を、個別に実行可能なコードをつけて紹介します。
Hash
リファレンスは https://docs.ruby-lang.org/ja/latest/class/Hash.html にあります。
今回列挙するなかで一番素朴で柔軟なクラスです。 また { a: 1, b: 2 } のようなリテラルを備えています。 格納したバリューへのアクセスも hash[:a] のような形になり、この後紹介する object.a といった形式と異なります
他のクラスでの振舞のほとんどをカバーできてしまう柔軟性を備えていますが、なんでもできてしまうと困ることもあります。 例えば、プログラマによるキーの書き間違いなどもそのまま受けいれてしまうため、間違いに気づかないことがあります。
また、何のためのものかの名付けを強く意識しなくても運用できてしまうため、書き手が注意深くコーディングしなければ読み手に情報が伝わりにくいです。
diggle_taro = {first_name: 'Taro', last_name: 'Diggle'} p diggle_taro[:first_name] # => "Taro" # 本当は diggle_taro[:first_name] と書きたかったけど、間違えてしまったケース。特にエラーにはならず間違いに気づきにくい diggle_taro[:firstname] = 'Jiro' p diggle_taro # => {first_name: "Taro", last_name: "Diggle", firstname: "Jiro"} # 本当は diggle_taro[:last_name] と書きたかったけど、間違えてしまったケース。特にエラーにはならず間違いに気づきにくい p diggle_taro[:lastname] # => nil # これが何を表しているものなのか、クラス名から判別することはできない p diggle_taro.class # => Hash
書き手と読み手の意図が強く揃う小さなスコープの範囲で使うか、どんなキーを持つかをコードを完成させた時点では規定しにくいところで使うのに適しています。
前者は書き捨てするスクリプトであるとか、特定の短いメソッドの中でのみ利用するデータが該当します。後者はリクエストのパラメーターなどが該当します。
Class
リファレンスは https://docs.ruby-lang.org/ja/latest/class/Class.html にあります。
Rubyには、Hashのようにデータのみを扱う狙いのものと、データとそれに伴う操作をまとめた、いわゆる値オブジェクトというものの両方を作れます。
Classはデータとそれに伴う操作をまとめるための代表的な存在です。
Hashとは異なりプログラマによるキーの書き間違いなどはエラーになるので気づくことができます。
オブジェクトのクラス名もわかるので、何を表しているものか理解しやすいです。
class Name attr_reader :first_name, :last_name def initialize(first_name, last_name) @first_name = first_name @last_name = last_name end def full_name "#{@first_name} #{@last_name}" end end diggle_taro = Name.new('Taro', 'Diggle') p diggle_taro.full_name # => "Taro Diggle" p diggle_taro.class # => Name # family_name というメソッドは存在しないため、エラーが発生する begin diggle_taro.family_name rescue => e p e # => #<NoMethodError: undefined method 'family_name' for an instance of Name> end # クラスの場合、等しさというのはオブジェクトのidが一致しているかどうかで判断される # 同じデータを持つものを「等しい」と見なすためには一手間必要 diggle_taro2 = Name.new('Taro', 'Diggle') p diggle_taro.object_id # => 16 p diggle_taro2.object_id # => 24 p diggle_taro == diggle_taro2 # => false
Struct
リファレンスは https://docs.ruby-lang.org/ja/latest/class/Struct.html にあります。
データの格納が主目的なので、役割はHashに近いです。一方であらかじめ定義したキーしか受けつけないところ、{ a: 1, b: 2 } のようなリテラルがないところ、struct.a のようなアクセサを持つところ、クラス名をつけられる点などはClassの特性に近いです。
Structには振るまいも追加しやすようにコード設計されていて、newにブロック引数をとってその中で定義が可能です。
Classとは異なり、オブジェクトが等しいかを保持しているデータが等しいかで判定するので、データの入れ物としては扱いやすいです。
コードを書いた時点でどんなキーが必要かわかっているときで、ある程度色々なところにデータを取り回す場合にはStructがいいかもしれません。
Name = Struct.new(:first_name, :last_name) do def full_name "#{self.first_name} #{self.last_name}" end end diggle_taro = Name.new('Taro', 'Diggle') # オブジェクトが保持するデータの操作(メソッド)も一緒に扱える # Name.full_name(diggle_taro) などとしなくてよい p diggle_taro.full_name # => "Taro Diggle" # このオブジェクトが何を表しているものなのか、クラス名から判別することができる p diggle_taro.class # => Name # family_name というメソッドは存在しないため、エラーが発生する # プログラマが間違えてもエラーが発生するので、黙って受け入れられるよりは間違いに気づきやすい begin diggle_taro.family_name rescue => e p e # => #<NoMethodError: undefined method 'family_name' for an instance of Name> end # Structの場合、Class と異なりデフォルトで同じデータを持つものを「等しい」とみなすようになっている diggle_taro2 = Name.new('Taro', 'Diggle') p diggle_taro.object_id # => 16 p diggle_taro2.object_id # => 24 p diggle_taro == diggle_taro2 # => true
OpenStruct
リファレンスは https://docs.ruby-lang.org/ja/latest/class/OpenStruct.html にあります。
Structはあらかじめ定義したキーのみ受けつけるのに対し、OpenStructは未定義のキーも受けつけます。 名前に反して性質はStructよりHashに近く、アクセス方法が object.a の形式で行えるようになった拡張Hashだと捉えています。
もし性質が似ているなら素朴かつrequireの必要ないHashのほうを使いたいなと思っています。そういった意味で私がどれを使おうか考慮するときにOpenStructは候補に入ってきません。 もしOpenStructをよく便利に使っているよというご意見や、OpenStructこんなところで活躍しているよという情報があればぜひ教えてください。
require 'ostruct' diggle_taro = OpenStruct.new(first_name: 'Taro', last_name: 'Diggle') # 本当は diggle_taro.first_name と書きたかったけど、間違えてしまったケース。特にエラーにはならず間違いに気づきにくい diggle_taro.firstname = 'Jiro' p diggle_taro # => #<OpenStruct first_name="Taro", last_name="Diggle", firstname="Jiro"> # 本当は diggle_taro.last_name と書きたかったけど、間違えてしまったケース。特にエラーにはならず間違いに気づきにくい p diggle_taro.lastname # => nil # これが何を表しているものなのか、クラス名から判別することはできない p diggle_taro.class # => OpenStruct
Data
リファレンスは https://docs.ruby-lang.org/ja/latest/class/Data.html にあります。
Ruby3.2から導入された新しい方法です。 Structに似ていますが、生成時に入力したデータを書き換えることができない点が異なります。
できることが少なくなっているのは悪いことではなく、書き手が意図している使い方がよりはっきりと読み手に伝わります。
Name = Data.define(:first_name, :last_name) do def full_name "#{self.first_name} #{self.last_name}" end end diggle_taro = Name.new('Taro', 'Diggle') # オブジェクトが保持するデータの操作(メソッド)も一緒に扱える # Name.full_name(diggle_taro) などとしなくてよい p diggle_taro.full_name # => "Taro Diggle" # このオブジェクトが何を表しているものなのか、クラス名から判別することができる p diggle_taro.class # => Name # family_name というメソッドは存在しないため、エラーが発生する # プログラマが間違えてもエラーが発生するので、黙って受け入れられるよりは間違いに気づきやすい begin diggle_taro.family_name rescue => e p e # => #<NoMethodError: undefined method 'family_name' for an instance of Name> end # Dataの場合、Class と異なりデフォルトで同じデータを持つものを「等しい」とみなすようになっている diggle_taro2 = Name.new('Taro', 'Diggle') p diggle_taro.object_id # => 16 p diggle_taro2.object_id # => 24 p diggle_taro == diggle_taro2 # => true # Dataの場合、Struct と異なり値を書き換えることができない begin diggle_taro.first_name = 'Jiro' rescue => e p e # => #<NoMethodError: undefined method 'first_name=' for an instance of Name> end # 新しいものを作るwithメソッドが準備されている diggle_jiro = diggle_taro.with(first_name: 'Jiro') p diggle_taro # => #<data Name first_name="Taro", last_name="Diggle"> p diggle_jiro # => #<data Name first_name="Jiro", last_name="Diggle">
Active Record
概観をつかむにはリファレンスよりRailsガイドがよいと思いますのでActive Record の基礎をどうぞ。 言わずと知れたRailsの強力なライブラリです。
Ruby on Railsを使っていて、値をリクエストやセッションの間だけ保持するのではなく永続的にRDBへ保存する場合のほとんどのケースをカバーしています。こちらを使いましょう。
Active Model
Active Model の基礎をどうぞ。Active Modelは、RDBに保存しないけれどActive Record風のオブジェクトを作りたいときClassを拡張するのに便利です。
require 'bundler/inline' gemfile do source 'https://rubygems.org' gem 'activemodel', '~> 8.0' end require 'active_model' class Name include ActiveModel::API attr_accessor :first_name, :last_name validates :first_name, :last_name, presence: true def full_name "#{@first_name} #{@last_name}" end end diggle_taro = Name.new(first_name: 'Taro', last_name: 'Diggle') p diggle_taro.full_name # => "Taro Diggle" p diggle_taro.class # => Name # ActiveModelを使ってバリデーションや、バリデーションのエラーを苦労せず得られている diggle_taro2 = Name.new(first_name: 'Taro') p diggle_taro2.valid? # => false p diggle_taro2.errors.full_messages # => ["Last name can't be blank"]
まとめ
似たようなことのできるクラスが複数あるなか、違いに着目してどう使いわけるかを紹介しました。
これは私がWebプログラミングしている中での経験を元にしているので、Rubyを使っていても異なるWebプログラミングや、異なるプログラミング領域の場合には別の選択肢や切り口があると思います。もし別の意見があればみなさんもブログなどに書いて教えてもらえると私が嬉しいです!
この記事に興味をもって楽しく読んでくださる方は、書き手の立場であるときに読み手のことを強く考慮に入れられる方なのかなと思っています。 この概念を拡張すると、システムを作る立場であるときに使う側のユーザーのことを強く考慮に入れられる方とも言えそうです。
DIGGLEではそういった受けとり手の立場の人に思いを寄せながらプログラミングできるWebエンジニアを歓迎しています。興味をもってくれた方はぜひ https://herp.careers/v1/diggle/_dgvcOQcFfeq から応募してください。絶対的な正解というものはない中で、自分達にとってよりうまくいく方を模索しながらシステムを一緒に作りましょう〜
niku がお送りしました。