CrackedRuby logo

CrackedRuby

Data Class Introduction

Ruby's Data class provides immutable value objects with built-in pattern matching support and automatic equality comparisons.

Core Built-in Classes Data Class
2.11.1

Overview

Data creates classes for immutable value objects with automatic implementations of equality, hash calculation, and string representation. Ruby includes Data in the core library to address common patterns where developers need simple data containers with value semantics rather than object identity.

The Data class generates methods based on the attributes provided during class definition. Each Data subclass receives automatic implementations of ==, eql?, hash, inspect, and to_s. Pattern matching integration allows Data objects to work seamlessly with Ruby's pattern matching syntax.

# Define a Data class with two attributes  
Point = Data.define(:x, :y)

# Create instances
origin = Point.new(0, 0)
point = Point.new(3, 4)

# Automatic equality comparison
origin == Point.new(0, 0)  # => true
point == Point.new(3, 4)   # => true
point == origin            # => false

Data classes differ from Struct in their immutability and pattern matching support. While Struct creates mutable objects by default, Data objects cannot be modified after creation. This immutability ensures thread safety and prevents accidental state changes.

Person = Data.define(:name, :age)
john = Person.new("John", 30)

# Data objects are frozen
john.frozen?  # => true

# No setter methods exist
john.respond_to?(:name=)  # => false

Data supports inheritance, allowing subclasses to extend parent functionality while maintaining data semantics. Subclasses can define additional methods and override inherited behavior.

Basic Usage

Creating Data classes requires calling Data.define with attribute names as symbols. The method returns a new class with the specified attributes and automatic method generation.

# Simple data class with three attributes
User = Data.define(:id, :username, :email)

# Create instances using positional arguments
user1 = User.new(1, "alice", "alice@example.com")
user2 = User.new(2, "bob", "bob@example.com")

# Access attributes through reader methods
user1.id        # => 1
user1.username  # => "alice"  
user1.email     # => "alice@example.com"

Data classes automatically implement to_h for hash conversion, maintaining attribute order as specified during definition.

Coordinates = Data.define(:latitude, :longitude, :elevation)
location = Coordinates.new(40.7128, -74.0060, 10)

location.to_h
# => {:latitude=>40.7128, :longitude=>-74.0060, :elevation=>10}

# Convert to array maintains order
location.to_a
# => [40.7128, -74.0060, 10]

The with method creates new instances with modified attributes while preserving immutability. This method accepts keyword arguments for the attributes to change.

Product = Data.define(:name, :price, :category)
laptop = Product.new("MacBook", 1999, "Electronics")

# Create modified copy
discounted_laptop = laptop.with(price: 1799)

laptop.price         # => 1999 (unchanged)
discounted_laptop.price  # => 1799 (new instance)

Data classes implement deconstruct and deconstruct_keys for pattern matching integration. The deconstruct method returns attribute values as an array, while deconstruct_keys returns a hash with specified keys.

Rectangle = Data.define(:width, :height)
rect = Rectangle.new(10, 20)

# Array destructuring  
width, height = rect.deconstruct
# width => 10, height => 20

# Hash destructuring for pattern matching
rect.deconstruct_keys([:width])  # => {:width=>10}
rect.deconstruct_keys(nil)       # => {:width=>10, :height=>20}

Block syntax allows defining custom methods during class creation. The block executes in the context of the new Data class.

Temperature = Data.define(:celsius) do
  def fahrenheit
    celsius * 9.0 / 5.0 + 32
  end
  
  def kelvin
    celsius + 273.15
  end
end

temp = Temperature.new(25)
temp.celsius    # => 25
temp.fahrenheit # => 77.0
temp.kelvin     # => 298.15

Advanced Usage

Data classes support inheritance hierarchies where subclasses extend parent attributes and functionality. Subclass definitions must include all parent attributes plus any additional ones.

# Base data class
Animal = Data.define(:name, :species)

# Subclass with additional attributes  
Dog = Data.define(:name, :species, :breed, :trained) do
  def bark
    "#{name} says woof!"
  end
end

# Inheritance chain works correctly
buddy = Dog.new("Buddy", "Canine", "Golden Retriever", true)
buddy.is_a?(Animal)  # => true
buddy.name           # => "Buddy"
buddy.bark           # => "Buddy says woof!"

Pattern matching with Data objects supports both array and hash patterns. Array patterns match against attribute order, while hash patterns match against attribute names.

Response = Data.define(:status, :body, :headers)
success = Response.new(200, "OK", {"content-type" => "text/plain"})
error = Response.new(404, "Not Found", {})

# Array pattern matching
case success
in [200, body, *]
  puts "Success: #{body}"
in [status, *, *] if status >= 400
  puts "Error: #{status}"
end

# Hash pattern matching  
case error
in Response(status: 404, body:)
  puts "Not found: #{body}"
in Response(status: code) if code >= 500
  puts "Server error: #{code}"
end

Custom equality and hash implementations can override default behavior when needed. This requires careful consideration to maintain consistency between ==, eql?, and hash.

# Case-insensitive string comparison
CaseInsensitiveString = Data.define(:value) do
  def ==(other)
    other.is_a?(self.class) && value.downcase == other.value.downcase  
  end
  
  def eql?(other)
    self == other
  end
  
  def hash
    value.downcase.hash ^ self.class.hash
  end
end

str1 = CaseInsensitiveString.new("Hello")
str2 = CaseInsensitiveString.new("HELLO")
str1 == str2  # => true

Data classes integrate with serialization libraries through their hash conversion methods. Custom serialization logic can extend the basic to_h implementation.

Event = Data.define(:timestamp, :type, :payload) do
  def to_json(*args)
    {
      timestamp: timestamp.iso8601,
      type: type.to_s,
      payload: payload
    }.to_json(*args)
  end
  
  def self.from_hash(hash)
    new(
      Time.parse(hash[:timestamp]),
      hash[:type].to_sym,
      hash[:payload]
    )
  end
end

event = Event.new(Time.now, :user_login, {user_id: 123})
json_string = event.to_json
restored = Event.from_hash(JSON.parse(json_string, symbolize_names: true))

Composition patterns allow Data classes to contain other Data objects while maintaining immutability throughout the object graph.

Address = Data.define(:street, :city, :postal_code)
Person = Data.define(:name, :address, :phone) do
  def move_to(new_address)
    with(address: new_address)  
  end
  
  def update_phone(new_phone)
    with(phone: new_phone)
  end
end

address = Address.new("123 Main St", "Springfield", "12345")  
person = Person.new("Alice", address, "555-0123")

# Create new person with different address
new_address = Address.new("456 Oak Ave", "Springfield", "12346")  
moved_person = person.move_to(new_address)

person.address.street       # => "123 Main St" (unchanged)
moved_person.address.street # => "456 Oak Ave" (new instance)

Common Pitfalls

Data classes freeze instances automatically, preventing modification of both the object and its attribute values. This can cause unexpected behavior when attributes contain mutable objects.

# Mutable objects within Data instances remain mutable
UserPreferences = Data.define(:settings, :tags)
prefs = UserPreferences.new({theme: "dark"}, ["ruby", "programming"])

# The Data instance is frozen
prefs.frozen?           # => true
prefs.settings.frozen?  # => false (Hash not frozen)
prefs.tags.frozen?      # => false (Array not frozen)

# Attributes can still be mutated
prefs.settings[:theme] = "light"  # Works, modifies existing hash
prefs.tags << "learning"          # Works, modifies existing array

# This breaks immutability expectations
other_prefs = UserPreferences.new(prefs.settings, prefs.tags)
prefs == other_prefs  # => true, but both share mutable state

The solution requires freezing mutable attribute values during instantiation or using immutable alternatives.

SafePreferences = Data.define(:settings, :tags) do
  def initialize(settings, tags)
    super(settings.freeze, tags.freeze)
  end
end

safe_prefs = SafePreferences.new({theme: "dark"}, ["ruby"])
safe_prefs.settings.frozen?  # => true  
safe_prefs.tags.frozen?      # => true

Pattern matching with Data classes requires understanding the relationship between attribute order and array destructuring. Changing attribute order breaks existing pattern matches.

# Original definition  
Point = Data.define(:x, :y)
point = Point.new(10, 20)

case point
in [x, y]
  puts "x=#{x}, y=#{y}"  # Works: x=10, y=20
end

# Later changed to different order - breaks pattern matches
# Point = Data.define(:y, :x)  # Don't do this
# Same pattern match now assigns y to x variable and x to y variable

Hash pattern matching avoids this issue by using attribute names instead of positions.

case point  
in Point(x:, y:)
  puts "x=#{x}, y=#{y}"  # Robust against attribute reordering
in {x:, y:}
  puts "x=#{x}, y=#{y}"  # Also works, less specific
end

Inheritance hierarchies require careful attribute management. Subclasses must declare all parent attributes explicitly, and attribute order matters for pattern matching.

# Parent class
Vehicle = Data.define(:make, :model, :year)

# Subclass must include ALL parent attributes
Car = Data.define(:make, :model, :year, :doors, :fuel_type)

# This creates problems - different attribute order
# WrongCar = Data.define(:doors, :make, :model, :year, :fuel_type)
# Pattern matches expecting [make, model, year, ...] will break

Equality comparison between Data classes and other object types returns false even when attributes match. This differs from Struct behavior in some cases.

PersonData = Data.define(:name, :age)
PersonStruct = Struct.new(:name, :age)

data_person = PersonData.new("Alice", 30)
struct_person = PersonStruct.new("Alice", 30)

data_person == struct_person    # => false (different classes)
data_person.to_h == struct_person.to_h  # => true (same attributes)

# Only Data instances of same class compare as equal
data_person == PersonData.new("Alice", 30)  # => true

Reference

Class Methods

Method Parameters Returns Description
Data.define(*attributes, &block) attributes (Symbol...), optional block Class Creates new Data class with specified attributes

Instance Methods

Method Parameters Returns Description
#initialize(*values) values (Object...) Data Creates new instance with attribute values
#==(other) other (Object) Boolean Compares equality based on class and attributes
#eql?(other) other (Object) Boolean Strict equality including class and attribute types
#hash None Integer Hash code based on class and attribute values
#inspect None String Human-readable representation showing attributes
#to_s None String String representation (alias for inspect)
#to_h None Hash Hash with attribute names as keys
#to_a None Array Array of attribute values in definition order
#with(**changes) changes (Hash) Data New instance with modified attributes
#deconstruct None Array Array of values for array pattern matching
#deconstruct_keys(keys) keys (Array or nil) Hash Hash for hash pattern matching
#members None Array Array of attribute names as symbols
#frozen? None Boolean Always returns true (Data objects are frozen)

Pattern Matching Patterns

Pattern Type Syntax Example Description
Array pattern in [a, b, c] in [x, y] Matches against deconstruct return value
Hash pattern in {key: var} in {name:, age:} Matches against deconstruct_keys
Class pattern in ClassName(attr:) in Point(x:, y:) Type-safe hash pattern matching
Variable capture in [a, *rest] in Point(x:, y: _) Captures or ignores values

Inheritance Behavior

Aspect Behavior Example
Subclass creation Must declare all parent attributes Child = Data.define(:parent_attr, :child_attr)
Method inheritance Inherits all parent instance methods child.parent_method works
Pattern matching Subclass instances match parent patterns child_instance matches Parent(attr:) pattern
Equality Subclass != parent class even with same attributes Child.new(x) != Parent.new(x)

Common Error Types

Error Cause Solution
ArgumentError Wrong number of arguments to new Match argument count to defined attributes
NoMethodError Calling non-existent setter Use with method for modifications
FrozenError Attempting to modify frozen object Create new instance with with
TypeError Pattern match type mismatch Use appropriate pattern syntax

Performance Characteristics

Operation Time Complexity Memory Usage Notes
Instance creation O(n) Linear in attributes Slightly slower than Struct
Equality comparison O(n) Constant Compares all attributes
Hash calculation O(n) Constant Based on all attributes
Pattern matching O(1) Constant Hash key lookup
with method O(n) Linear in attributes Creates new instance