Ruby Struct vs Data: Welk Value Object Past Bij Jouw Code?
Een praktische vergelijking van Ruby's Struct en Data klassen voor value objects. Wanneer gebruik je welke, prestatieverschillen, en echte patronen uit productie Ruby 3.2+ code.
Ruby 3.2 introduceerde Data, een nieuwe core class voor immutable value objects. Als je al jaren Struct gebruikt en je afvraagt of je moet overstappen: gebruik Data wanneer je immutability op class-niveau wilt afdwingen, blijf bij Struct wanneer je mutability of positionele constructie nodig hebt.
Maar de details zijn belangrijk, dus laten we uitzoeken wanneer welke optie zinvol is.
Wat Data Je Geeft Dat Struct Niet Doet
Data.define maakt klassen waarvan de instanties bevroren zijn bij creatie. Geen writer methods. Geen []=. Geen onbedoelde mutatie:
Point = Data.define(:x, :y)
p = Point.new(x: 3, y: 7)
p.x # => 3
p.x = 10 # => NoMethodError
p.frozen? # => true
Bij Struct zou je handmatig moeten freezeën, en zelfs dan kun je nog de setter aanroepen (het gooit een FrozenError, geen NoMethodError). Data verwijdert de setters volledig uit de method table.
De constructie-API verschilt ook. Data.define instanties vereisen keyword arguments:
Point.new(3, 7) # => ArgumentError (missing keywords: x, y)
Point.new(x: 3, y: 7) # werkt
Struct accepteert zowel positionele als keyword arguments (sinds Ruby 3.1 met optionele keyword_init: true):
LegacyPoint = Struct.new(:x, :y)
LegacyPoint.new(3, 7) # werkt
LegacyPoint.new(x: 3, y: 7) # werkt ook in Ruby 3.2+
Wanneer Struct Nog Steeds Wint
Struct gaat nergens heen en is in meerdere scenario’s nog steeds de betere keuze.
Muteerbare records. Als je objecten bouwt die veranderen na creatie — state opbouwen tijdens een batchproces, coördinaten bijwerken terwijl iets door een pipeline beweegt — geeft Struct je writer methods uit de doos.
Positionele constructie. Wanneer je CSV-rijen of database-tuples verwerkt waar de kolomvolgorde bekend en stabiel is, is Struct.new(:name, :email, :role) met positionele args schoner dan elke keer keywords uitspellen.
Legacy compatibiliteit. Code die afhankelijk is van to_a die waarden retourneert in definitievolgorde, of members gebruikt om velden te itereren, werkt exact zoals voorheen met Struct.
Row = Struct.new(:name, :email)
row = Row.new("Alice", "alice@example.com")
row.to_a # => ["Alice", "alice@example.com"]
row[0] # => "Alice"
Entry = Data.define(:name, :email)
entry = Entry.new(name: "Alice", email: "alice@example.com")
entry.to_a # => NoMethodError
Prestaties: Marginaal Maar Meetbaar
Benchmark op Ruby 3.3.0, 1 miljoen instanties:
require "benchmark/ips"
S = Struct.new(:a, :b, :c)
D = Data.define(:a, :b, :c)
Benchmark.ips do |x|
x.report("Struct.new") { S.new(1, 2, 3) }
x.report("Data.new") { D.new(a: 1, b: 2, c: 3) }
x.compare!
end
Resultaten op een M2 MacBook Pro (Ruby 3.3.0):
Struct.new: 8.412.309 i/s
Data.new: 5.874.221 i/s - 1,43x langzamer
Keyword argument parsing voegt overhead toe. Voor de meeste applicaties is dit onzichtbaar. Maar in strakke inner loops of data-transformatiepipelines is Struct’s positionele constructie meetbaar sneller.
Het geheugengebruik is nagenoeg identiek. Beide alloceren dezelfde onderliggende objectstructuur.
Pattern Matching: Beide Werken Prima
Zowel Struct als Data werken met Ruby’s pattern matching, waar value objects echt tot hun recht komen:
case Point.new(x: 0, y: 5)
in Point[x: 0, y:]
puts "Op de Y-as op #{y}"
in Point[x:, y: 0]
puts "Op de X-as op #{x}"
end
Als je pattern matching uitgebreid gebruikt, integreert beide klassen naadloos.
Echte Patronen Uit Productie
Configuratieobjecten → Data. Config mag nooit veranderen na initialisatie:
AppConfig = Data.define(:database_url, :redis_url, :worker_count)
config = AppConfig.new(
database_url: ENV.fetch("DATABASE_URL"),
redis_url: ENV.fetch("REDIS_URL"),
worker_count: ENV.fetch("WORKER_COUNT", "5").to_i
)
Event payloads → Data. Events zijn feiten over iets dat gebeurd is. Ze horen niet te muteren:
OrderPlaced = Data.define(:order_id, :customer_id, :total_cents, :placed_at)
Tussentijdse verwerkingsrecords → Struct. Wanneer je data verrijkt over pipeline-stappen:
ImportRow = Struct.new(:raw_name, :normalized_name, :email, :valid, keyword_init: true)
row = ImportRow.new(raw_name: " Bob Smith ")
row.normalized_name = row.raw_name.strip
row.valid = row.email&.include?("@")
De Keuzechecklist
Kies Data wanneer:
- Het object een waarde of feit representeert (coördinaten, geld, events, configuratie)
- Je immutability-garanties op class-niveau wilt
- Je op Ruby 3.2+ zit
- Keyword constructie de leesbaarheid verbetert
Kies Struct wanneer:
- Het object state opbouwt of verandert tijdens zijn levensduur
- Positionele constructie past bij de use case (CSV parsing, tuple unpacking)
- Je
to_aof integer-index toegang nodig hebt - Maximale instantiatie-doorvoer belangrijk is
- Je Ruby < 3.2 moet ondersteunen
FAQ
Kan ik custom methods toevoegen aan Data klassen?
Ja. Geef een block mee aan Data.define:
Money = Data.define(:cents, :currency) do
def to_s
"#{cents / 100.0} #{currency}"
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
Money.new(cents: cents + other.cents, currency: currency)
end
end
Omdat Data instanties bevroren zijn, moeten je custom methods nieuwe instanties retourneren in plaats van te muteren.
Werkt Data met ActiveRecord?
Data objecten zijn geen ActiveRecord modellen. Maar ze zijn handig als query result wrappers of value objects binnen AR modellen. De composed_of macro in Rails werkt met elke klasse die keyword arguments accepteert, dus Data klassen passen daar prima.
Hoe zit het met OpenStruct?
OpenStruct is een ander beest — het maakt dynamisch methods aan en is significant langzamer (circa 10x) dan zowel Struct als Data. Kies voor nieuwe code tussen Struct en Data. Beschouw OpenStruct als legacy.
Kan ik converteren tussen Struct en Data instanties?
Er is geen ingebouwde conversie, maar beide ondersteunen to_h:
struct_point = LegacyPoint.new(3, 7)
data_point = Point.new(**struct_point.to_h)
Dit werkt zolang de veldnamen overeenkomen.
Related Articles
Ruby Ractors: Echte Parallelle Verwerking Zonder de GVL
Ruby’s Global VM Lock (GVL, voorheen GIL) was jarenlang het standaardargument om naar Go of Elixir te grijpen voor ec...
Ruby method_missing: Wanneer Gebruiken en Wanneer Hard Wegrennen
method_missing is Ruby's krachtigste én meest misbruikte metaprogramming-tool. Zo gebruik je het correct in productie...
Ruby Lazy Enumerators: Verwerk Miljoenen Rijen Zonder Geheugenexplosie
Leer hoe Ruby's Lazy Enumerators enorme datasets regel voor regel verwerken met stabiel geheugengebruik. Inclusief be...