In Ruby 4.0, how does a Data.define class differ from a Struct.new class, and which would you pick for a money value object?
answer
- Data arrived in Ruby 3.2
- frozen instances, no writers
- every member required
- no each, to_a or []
- with returns a changed copy
basics
~20 sData.define (Ruby 3.2+) builds value classes whose instances are frozen, have no writers or Enumerable, and require every member. Struct.new builds mutable records with writers and optional members. Money is a value, so Data fits.
solid answer
~40 sBoth generators return a new class with readers, `members`, `to_h`, `inspect` and a value-based `==`. `Struct` adds writers, `[]`/`[]=`, `each` and `Enumerable`, and fills omitted members with `nil`. `Data`, added in Ruby 3.2, has **no writers**, no `each`, `to_a` or instance `[]`, **freezes every instance** in `initialize`, and raises `ArgumentError` (`missing keyword: :currency`) when a member is left out, whether you passed positional or keyword arguments. Changes go through `with`, which returns a new instance. For `Money = Data.define(:cents, :currency)` that means a price cannot drift after it is shared. The freeze is shallow, though: a mutable member such as an Array can still change. Struct stays the tool for records you fill in or update step by step.
code
ruby · 14 linesMoney = Data.define(:cents, :currency) do
def +(other)
raise ArgumentError, "currency mismatch" unless currency == other.currency
with(cents: cents + other.cents)
end
end
price = Money.new(1999, "EUR")
price.frozen? # => true
price.respond_to?(:cents=) # => false
(price + Money[1, "EUR"]).cents # => 2000
price.cents # => 1999, unchanged
Money.new(cents: 500) # ArgumentError: missing keyword: :currency
Money.new(1, "EUR", :extra) # ArgumentError (wrong number of arguments)go deeper
Recall that Data.define instances are frozen and have no writers, while Struct.new instances can be changed through member writers.
Explain how Data converts positional arguments to keywords, why a missing member raises ArgumentError, and how with builds a changed copy.
Justify Data for values shared across a system, such as money, and name the shallow-freeze gap and the lack of memoization that come with it.
Set a team rule for when new record types use Data, Struct or a full class, and weigh consistency against migrating existing Structs.
## Two generators from one family Ruby has two built-in **class generators** for simple data-holding classes: - `Struct.new(:cents, :currency)` returns a subclass of `Struct`, a mutable record. - `Data.define(:cents, :currency)`, added in **Ruby 3.2**, returns a subclass of `Data`, meant for **immutable value objects**. They share much of their implementation (both live in CRuby's `struct.c`), so both give you member readers, `members`, `to_h`, a readable `inspect` (`#<struct ...>` vs `#<data ...>`), a value-based `==`, `eql?` and `hash`, and a block form for adding methods. The differences are all about **mutability and completeness**. ## Side by side | Aspect | `Struct.new` class | `Data.define` class | |---|---|---| | Writers (`cents=`) | yes | none | | Instance frozen | no | yes, frozen in `initialize` | | Missing member | set to `nil` | `ArgumentError`, missing keyword | | Too many positional args | `ArgumentError` (struct size differs) | `ArgumentError` (wrong number of arguments) | | Keyword construction | yes since 3.2; `keyword_init:` option | always; no option | | `each`, `to_a`, `[]`, `Enumerable` | yes | no | | Modified copy | mutate, or `dup` then mutate | `with(cents: 500)` | Two construction details follow from the table: 1. `Data`'s `new` and `[]` **convert positional arguments to keywords** using the member order, then call `initialize` with keywords only. 2. `Data#initialize` checks that **every member** was supplied and rejects unknown ones, so `Money.new(500)` and `Money.new(cents: 500)` both raise `ArgumentError` naming `:currency`. ## Why money wants Data A money amount is a classic **value**: two amounts of 1999 EUR are interchangeable, and a price that has been handed to a cart, an invoice and a log line must not change underneath them. With `Data.define`: - there is no `cents=` to call, so no caller can edit a shared price; - arithmetic returns **new** objects, typically through `with(cents: cents + other.cents)`; - a half-built amount (cents without currency) cannot exist, because construction raises. With a Struct, every holder of the object can call `price.cents = 0`, and `Money.new(1999)` happily builds an amount with a `nil` currency that fails much later, far from the bug. ## The freeze is shallow `Data` freezes the **instance**, not the objects it points to. The Ruby documentation's own example stores an Array member and appends to it after construction. For money this rarely bites when cents are an Integer and the currency is a Symbol or a frozen String, since those cannot change, but a Data object holding an Array, Hash or unfrozen String can still change through that member. Copying and deep-freezing inputs is a separate topic; the point here is that `Data` only guarantees that **its own slots** never change. ## When Struct still wins - **Mutable working records**: a row being parsed column by column, an accumulator, a builder. - **Optional fields**: data where `nil` for a missing member is the honest answer. - **Collection-like use**: code that iterates members with `each`, reads `to_a`, or indexes by position. - **Older targets**: code that must run before Ruby 3.2. ## Converting between them Because both classes expose `to_h` and both accept keyword construction, moving a value across is one line: `Money.new(**row.to_h)` turns a Struct `row` with members `cents` and `currency` into a Data value, and an unexpected extra member raises `ArgumentError` instead of being dropped silently. The reverse, `Row.new(**price.to_h)`, gives you a mutable working copy. This is handy at boundaries: parse input into a Struct while fields are still being filled, then freeze the finished result into a Data value before it leaves the parsing code. ## Practical guidance 1. Default to `Data.define` for anything that represents a value: money, coordinates, date ranges, identifiers. 2. Reach for `Struct.new` when mutation or partial filling is the point. 3. In either case add methods in the generator's block rather than subclassing the generated class; RuboCop's `Style/DataInheritance` and `Style/StructInheritance` cops flag the subclass form. 4. Remember that a frozen Data instance cannot memoize into an instance variable; derived values are computed in methods or passed as members.
- How do you get a Data value's contents as a Hash, and can you iterate over its members?`to_h` returns a Hash of member name to value, and `members` lists the names, so `price.to_h.each` or `price.members.each` work. A Data instance itself has no `each`, `to_a` or instance `[]` and does not include `Enumerable`; that container-style API is deliberately left to Struct.
- Is a Data object holding an Array member fully immutable?No. Data freezes the instance, so the member cannot be reassigned, but the Array it references is a separate object and stays mutable: `event.weekdays << "Sat"` still works. To get a fully fixed value you must freeze or copy such inputs yourself, typically in an `initialize` override before calling `super`.
saying these in an interview costs you the question
- Data instances are deeply frozen, so their Array members cannot change either.
- A Data member left out of new defaults to nil, just as on a Struct.
- Data defines writers that raise FrozenError when called.
- Data classes include Enumerable, so each and to_a work as on a Struct.
- Data.define is a gem you must add to the Gemfile.