class Data

Practical examples and pitfalls for Data

Data examples: describe a value without adding setters

Practical notes by Ruby-Doc.org

Give a small result a name

A coordinate, a page of results or the outcome of a calculation often needs a few named fields and no editing interface. Data.define provides that shape. The first example describes a position in a grid and compares it with another position containing the same member values.

Example 1
Position = Data.define(:row, :column)
start = Position.new(row: 2, column: 5)
next_row = start.with(row: 3)
puts start == Position.new(2, 5)
p [start.row, next_row.row, next_row.column]
Expected output
true
[2, 3, 5]

Equality is useful here because the caller cares about the position rather than which instance carried it. Defining a value type also puts the field names in one place. If the same pair of numbers means something different elsewhere, give that other concept its own type instead of passing interchangeable anonymous arrays around.

Check the objects stored in the members

A data object does not acquire member writers, but that does not recursively freeze everything assigned to it. The second example shows an array changing through a member. For the fixed list that follows, the array and its string are frozen deliberately before they become part of the value.

Example 2
LabelList = Data.define(:labels)
shared = LabelList.new(labels: ["draft"])
shared.labels << "reviewed"
p shared.labels
fixed = LabelList.new(labels: ["ready".freeze].freeze)
puts fixed.frozen?
puts fixed.labels.frozen?
puts fixed.labels.first.frozen?
Expected output
["draft", "reviewed"]
true
true
true

This is a small, known structure. A general object graph needs a more careful ownership policy than adding one call to freeze. In particular, a frozen array can still contain mutable objects. Decide which values the caller may share and which values your constructor must copy or normalize.

Make replacement explicit

Use with when producing a changed value makes sense to the reader. The original remains available, which is handy when comparing a proposed update with a stored result. It is not a deep-copy operation: unchanged members can still refer to the same underlying objects.

Keep calculations that explain the value close to its type, but put I/O and unrelated workflow decisions elsewhere. Tests for a small value object should exercise member names, equality and the intended treatment of nested objects. Those choices tell callers more than a long list of trivial accessor tests.

API reference: Data API reference

Related: Struct ยท Hash

Class Data provides a convenient way to define simple classes for value-alike objects.

The simplest example of usage:

Measure = Data.define(:amount, :unit)

# Positional arguments constructor is provided
distance = Measure.new(100, 'km')
#=> #<data Measure amount=100, unit="km">

# Keyword arguments constructor is provided
weight = Measure.new(amount: 50, unit: 'kg')
#=> #<data Measure amount=50, unit="kg">

# Alternative form to construct an object:
speed = Measure[10, 'mPh']
#=> #<data Measure amount=10, unit="mPh">

# Works with keyword arguments, too:
area = Measure[amount: 1.5, unit: 'm^2']
#=> #<data Measure amount=1.5, unit="m^2">

# Argument accessors are provided:
distance.amount #=> 100
distance.unit #=> "km"

Constructed object also has a reasonable definitions of == operator, to_h hash conversion, and deconstruct / deconstruct_keys to be used in pattern matching.

::define method accepts an optional block and evaluates it in the context of the newly defined class. That allows to define additional methods:

Measure = Data.define(:amount, :unit) do
  def <=>(other)
    return unless other.is_a?(self.class) && other.unit == unit
    amount <=> other.amount
  end

  include Comparable
end

Measure[3, 'm'] < Measure[5, 'm'] #=> true
Measure[3, 'm'] < Measure[5, 'kg']
# comparison of Measure with Measure failed (ArgumentError)

Data provides no member writers, or enumerators: it is meant to be a storage for immutable atomic values. But note that if some of data members is of a mutable class, Data does no additional immutability enforcement:

Event = Data.define(:time, :weekdays)
event = Event.new('18:00', %w[Tue Wed Fri])
#=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri"]>

# There is no #time= or #weekdays= accessors, but changes are
# still possible:
event.weekdays << 'Sat'
event
#=> #<data Event time="18:00", weekdays=["Tue", "Wed", "Fri", "Sat"]>

See also Struct, which is a similar concept, but has more container-alike API, allowing to change contents of the object and enumerate it.