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¶
- Build a
Moneyvalue withData.define(:cents, :currency): aparseclass method that reads"12.50 eur", a custominitializethat turns the cents into an Integer and upcases the currency, a+that refuses mixed currencies and returns a new object throughwith, and ato_s. Show thata + bleavesaunchanged, that equal amounts are==and work as one Hash key, and thata.cents = 0is aNoMethodError. - Define
Version = Data.define(:major, :minor, :patch)withinclude Comparableand a<=>overto_h.values, then sort a list of versions, take the newest, and show thatVersion.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.
See also¶
- A missing key is nil, and
Hash.new([])shares its default — the Hash with Symbol keys that a Struct or Data replaces once the fields are fixed. - Assignment destructures, and blocks auto-splat —
*pointsplats a Struct throughto_a; a Data has noto_ato splat. - Define
<=>and Comparable gives you the rest — the one method that makes a Data or Struct sortable. deconstructanddeconstruct_keys— the two methods behind rows 7 and 17, and how to write them for your own class.case/inpattern matching — the patterns that take a Struct or Data apart.freezeandFrozenError— what the freeze in row 13 is, and why it is shallow.- Multiple return values — a Struct or Data as a named return value instead of an Array.
- Rust: what a struct is ↗ — a named group of values made into a type, with no behaviour of its own.
- Rust: struct update ↗ —
..basebuilds a changed copy the waywithdoes. dupis a fresh copy,cloneis a faithful one —dupandcloneboth make a new object with the same instance variables, and both are shallow- A Hash key needs
eql?andhash, not==— AHashfinds a key by callinghashand theneql? newisallocatetheninitialize—Point.new(1, 2)is a class method that does two thingsputscallsto_s,pcallsinspect— Every object answers two questionsClass.newmakes a class; a constant names it —Class.new(Base) { ... }returns a class object with no name,Foo = klassgives it the name of the…- What changed, 1.9 to 4.0 — Ruby's releases each added a few things you will meet in old code and new
- There is more than one way, and one obvious way — Ruby's idioms