Skip to content

Struct is mutable, Data is not

Level: 201 · anyone choosing between a Hash, a Struct and a Data for a record

One line: Struct.new(:x, :y) makes a record you can build positionally or by keyword and then change; Data.define(:lat, :lng) makes one you cannot — no setters, frozen instances, and with for a changed copy — and both compare by value and pattern-match; Python's three counterparts are namedtuple, @dataclass and @dataclass(frozen=True).

Struct.new returns a class. Its instances take their members positionally or, since 3.2, by keyword; a member left out is nil, one too many is an ArgumentError; each member has a reader, a writer and an index (pt[:y], pt[0]); two instances of the same Struct class with the same values are == and eql? and hash alike, so a Struct works as a Hash key; and to_a, members, to_h, Enumerable, deconstruct and deconstruct_keys all come for free. A block given to Struct.new adds methods, and keyword_init: true forbids the positional form. Data.define, added in 3.2, returns a class whose instances are frozen at birth: there are no writers (c.lat = 5 is a NoMethodError, not a FrozenError — the method does not exist), instance_variable_set hits the freeze, and with(lat: 9) is the way to get a changed copy. Construction is stricter — a missing member is missing keyword, an extra one unknown keyword, and positional arguments are turned into keywords before initialize runs, which is why a custom initialize(deg:) that calls super(deg: Float(deg)) normalises both spellings. A Data has to_h and deconstruct_keys and also deconstruct, so it matches array patterns, but it has no to_a, no each and is not Enumerable. Immutability is shallow: a mutable member is still mutable.

Verified output of struct_and_data_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. Struct.new(:x, :y): Point.new(1, 2), Point.new(x: 1, y: 2)           [#<struct Point x=1, y=2>, #<struct Point x=1, y=2>]
 2. Point.new(1)  (a missing member is nil); Point.new(1, 2, 3)          #<struct Point x=1, y=nil>; ArgumentError: struct size differs
 3. pt.x = 5; pt[:y] = 6  (mutable); then pt, pt[0]                      #<struct Point x=5, y=6>, 5
 4. Point.new(1, 2) == / eql? / hash ==; as a Hash key                   [true, true, true, :key]
 5. pt.to_a, Point.members, pt.to_h                                      [[5, 6], [:x, :y], {x: 5, y: 6}]
 6. Struct.new(:x).new(1) == Struct.new(:x).new(1)  (two classes)        false
 7. case Point.new(1, 2) in [x, y]; in {x:, y:}  (both match)            ["in [x, y] -> 1,2", "in {x:, y:} -> 1,2"]
 8. Struct.new(:w, :h) { def area = w * h }.new(2, 3).area               6
 9. Struct.new(:x, :y, keyword_init: true).new(1, 2)                     ArgumentError: wrong number of arguments (given 2, expected 0)
10. Data.define(:lat, :lng): Coord.new(1, 2), Coord.new(lat: 1, lng: 2)  [#<data Coord lat=1, lng=2>, #<data Coord lat=1, lng=2>]
11. Coord.new(1); Coord.new(1, 2, 3); Coord.new(lat: 1, lng: 2, alt: 3)  ArgumentError: missing keyword: :lng; ArgumentError: wrong number of arguments (given 3, expected 0..2); ArgumentError: unknown keyword: :alt
12. c.lat = 5  (no setter)                                               NoMethodError: undefined method 'lat=' for an instance of Coord
13. c.instance_variable_set(:@lat, 5); c.frozen?                         FrozenError: can't modify frozen Coord: #<data Coord lat=1, lng=2>; true
14. c.with(lat: 9), then c                                               [#<data Coord lat=9, lng=2>, #<data Coord lat=1, lng=2>]
15. c == Coord.new(1, 2), eql?, hash ==; as a Hash key                   [true, true, true, :key]
16. c.respond_to?(:to_a), (:each), (:deconstruct); Enumerable?; c.to_h   [false, false, true, false, {lat: 1, lng: 2}]
17. case c in [lat, lng]; in {lat:, lng:}; in Coord(lat:, lng:)          ["in [lat, lng] matched", "in {lat:, lng:} -> 1,2", "in Coord(lat:, lng:) -> 1,2"]
18. Temp.new("35").deg, Temp.new(deg: 12).deg  (initialize normalises)   [35.0, 12.0]
19. Coord.new(lat: [1], lng: 2).lat << 2  (immutability is shallow)      [1, 2]

Reading the output

Rows 1–2: the two spellings of construction, the nil for a missing member, and struct size differs for one too many.

Rows 3–6: a Struct is mutable through the writer and through []; equal values make equal, alike-hashing instances that work as Hash keys; to_a, members and to_h are the three views; and two different Struct classes with the same members are not equal, whatever their values.

Row 7: in [x, y] uses deconstruct and in {x:, y:} uses deconstruct_keys, and both match a Struct.

Rows 8–9: a block adds methods; keyword_init: true turns the positional call into wrong number of arguments (given 2, expected 0).

Rows 10–11: Data.define takes the same two spellings, but a missing member is missing keyword: :lng, three positional arguments are one too many, and an unknown keyword is refused.

Rows 12–14 are immutability. There is no lat= method; the frozen instance refuses instance_variable_set; with builds a copy with one member changed and leaves the original alone.

Rows 15–16: equality, eql?, hash and Hash-key use match the Struct's; to_a and each do not exist and the class is not Enumerable, but deconstruct and to_h are there.

Row 17: the array pattern does match a Data — it defines deconstruct — and so do the hash pattern and the Coord(lat:, lng:) form.

Row 18: Temp.new("35") and Temp.new(deg: 12) both reach initialize(deg:), and both come out as Floats.

Row 19: the Array inside a frozen Data is not frozen; << on it works.

Compared with Python

The twin puts the same nineteen questions to namedtuple and @dataclass for the Struct rows and to @dataclass(frozen=True) for the Data rows. A namedtuple is a tuple: immutable (row 3, AttributeError), hashable, iterable, equal to any tuple with the same values — including a different namedtuple class (row 6) — and matched by a sequence pattern as well as by Point(x, y) (row 7). A plain @dataclass is the mutable record: it compares equal only within its own class (row 6), it is unhashable by default (row 4: hash(P(1, 2)) is a TypeError, because eq=True without frozen=True sets __hash__ to None), and astuple/asdict are its to_a/to_h (row 5). Missing or extra arguments are TypeErrors in all three (rows 2, 9, 11), and kw_only=True is keyword_init: true (row 9). The frozen dataclass is the Data: assignment raises FrozenInstanceError (row 12) — but object.__setattr__ walks straight past the freeze (row 13), which has no Ruby counterpart, since a Ruby freeze is enforced by the object — replace is with (row 14), it is hashable (row 15), and it is not iterable (row 16). Its pattern matching is class-only: case [lat, lng] does not match a dataclass (row 17) where Ruby's in [lat, lng] does. Normalisation goes in __post_init__, and inside a frozen class it needs object.__setattr__ too (row 18). Immutability is just as shallow (row 19), and a frozen dataclass holding a list is unhashable for the same reason the list is.

Verified output of struct_and_data_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. namedtuple Point(1, 2), Point(x=1, y=2); @dataclass P(1, 2)          [Point(x=1, y=2), Point(x=1, y=2)]; P(x=1, y=2)
 2. Point(1); Point(1, 2, 3)  (and the same for P)                       TypeError, TypeError; TypeError, TypeError
 3. p.x = 5 on the namedtuple; q.x = 5 on the dataclass; then q, p[0]    AttributeError; P(x=5, y=2), 1
 4. Point == Point, hash ==, dict key; P == P, hash(P), P.__hash__ is None [True, True, 'key']; True, TypeError, True
 5. list(p), Point._fields, p._asdict(); astuple(q), asdict(q)           [[1, 2], ('x', 'y'), {'x': 1, 'y': 2}]; [(5, 2), {'x': 5, 'y': 2}]
 6. Point(1, 2) == Other(1, 2)  (both tuples); P(1, 2) == Q(1, 2)        True; False
 7. match: case Point(x, y); case (x, y); case P(x, y)  (__match_args__) ['case Point(x, y) -> 1,2', 'case (x, y) -> 1,2', 'case P(x, y) -> 1,2']
 8. @dataclass Rect with def area(self): Rect(2, 3).area()               6
 9. @dataclass(kw_only=True) KW: KW(1, 2)                                TypeError
10. @dataclass(frozen=True) Coord: Coord(1, 2), Coord(lat=1, lng=2)      [Coord(lat=1, lng=2), Coord(lat=1, lng=2)]
11. Coord(1); Coord(1, 2, 3); Coord(lat=1, lng=2, alt=3)                 TypeError; TypeError; TypeError
12. c.lat = 5                                                            FrozenInstanceError
13. object.__setattr__(c2, "lat", 5)  (bypasses frozen); then c2         Coord(lat=5, lng=2)
14. replace(c, lat=9), then c                                            [Coord(lat=9, lng=2), Coord(lat=1, lng=2)]
15. c == Coord(1, 2), hash ==; as a dict key                             [True, True, 'key']
16. astuple(c), asdict(c); list(c)  (not iterable)                       [(1, 2), {'lat': 1, 'lng': 2}]; TypeError
17. match c: [lat, lng]; Coord(lat=lat, lng=lng); Coord(lat, lng)        ['case [lat, lng] -> no match', 'case Coord(lat=lat, lng=lng) -> 1,2', 'case Coord(lat, lng) -> 1,2']
18. Temp("35").deg, Temp(deg=12).deg  (__post_init__ normalises)         [35.0, 12.0]
19. Bag().items.append(2)  (immutability is shallow); hash(Bag())        Bag(items=[2]); TypeError

Try it

  1. Build a Money value with Data.define(:cents, :currency): a parse class method that reads "12.50 eur", a custom initialize that turns the cents into an Integer and upcases the currency, a + that refuses mixed currencies and returns a new object through with, and a to_s. Show that a + b leaves a unchanged, that equal amounts are == and work as one Hash key, and that a.cents = 0 is a NoMethodError.
  2. Define Version = Data.define(:major, :minor, :patch) with include Comparable and a <=> over to_h.values, then sort a list of versions, take the newest, and show that Version.new(1, 10, 0) == Version.new(1, 10, 0) while a Struct-based version with the same members is a different class and never equal to it.
Solution to 1

struct_and_data_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: an immutable Money value with Data.define -- a normalising initialize,
# a parser, an operator that returns a new object, `with` for a change, and
# use as a Hash key.

Money = Data.define(:cents, :currency) do
  def self.parse(text)                       # "12.50 eur" -> Money
    amount, currency = text.split
    new(cents: (Float(amount) * 100).round, currency: currency)
  end

  def initialize(cents:, currency: "EUR") = super(cents: Integer(cents), currency: currency.upcase)

  def +(other)
    raise ArgumentError, "#{currency} + #{other.currency}" unless currency == other.currency
    with(cents: cents + other.cents)
  end

  def to_s = format("%.2f %s", cents / 100.0, currency)
end

a = Money.parse("12.50 eur")
b = Money.new(cents: "250")
puts "a = #{a}, b = #{b}"
puts "a + b = #{a + b}; a is still #{a}"
puts "a == Money.new(1250)? #{a == Money.new(1250)}  (positional, currency defaulted)"
puts "as a Hash key: #{{a => :paid}[Money.new(cents: 1250, currency: 'eur')].inspect}"
begin
  a + Money.new(cents: 100, currency: "usd")
rescue ArgumentError => e
  puts "mixed currencies: #{e.class}: #{e.message}"
end
begin
  a.cents = 0
rescue NoMethodError => e
  puts "a.cents = 0: #{e.class}"
end
puts "inspect: #{a.inspect}"

Verified output of struct_and_data_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

a = 12.50 EUR, b = 2.50 EUR
a + b = 15.00 EUR; a is still 12.50 EUR
a == Money.new(1250)? true  (positional, currency defaulted)
as a Hash key: :paid
mixed currencies: ArgumentError: EUR + USD
a.cents = 0: NoMethodError
inspect: #<data Money cents=1250, currency="EUR">

See also