CrackedRuby logo

CrackedRuby

Struct Class

Overview

Struct provides a mechanism for creating classes that encapsulate related data with named attributes. Ruby implements Struct as a class that generates new classes when called, creating accessor methods automatically based on provided attribute names. The generated classes inherit from Struct itself, gaining enumerable behavior and comparison methods.

Ruby creates Struct classes through Struct.new, which returns a new class rather than an instance. This class can then create instances that store values for the defined attributes. Struct classes include getter and setter methods for each attribute, along with methods for iteration, conversion, and comparison.

# Creating a Struct class
Point = Struct.new(:x, :y)

# Creating instances
p1 = Point.new(1, 2)
p1.x  # => 1
p1.y  # => 2

# Setter methods are created automatically
p1.x = 10
p1.x  # => 10

Struct classes behave like regular Ruby classes but with predefined structure. They support equality comparison based on attribute values, enumerable methods for iteration over values, and conversion to arrays and hashes. Struct instances are mutable by default, allowing modification of attribute values after creation.

The primary use cases include creating simple data containers, value objects for data transfer, and lightweight alternatives to full class definitions when only data storage and basic operations are needed.

Basic Usage

Creating Struct classes requires passing attribute names as symbols to Struct.new. The returned class can instantiate objects that store values for those attributes.

# Basic Struct creation
Person = Struct.new(:name, :age, :email)
person = Person.new("Alice", 30, "alice@example.com")

# Accessing attributes
person.name   # => "Alice"
person.age    # => 30
person.email  # => "alice@example.com"

# Modifying attributes
person.age = 31
person.name = "Alice Smith"

Struct instances can be created with positional arguments matching the attribute order, or with a hash of attribute names to values. Missing arguments default to nil.

# Different initialization approaches
Product = Struct.new(:name, :price, :category)

# Positional arguments
p1 = Product.new("Laptop", 999.99, "Electronics")

# Partial initialization
p2 = Product.new("Book", 29.99)
p2.category  # => nil

# No arguments
p3 = Product.new
p3.name   # => nil
p3.price  # => nil

Struct classes inherit enumerable behavior, making instances iterable over their attribute values. This enables functional programming patterns and easy conversion to other data structures.

Contact = Struct.new(:name, :phone, :email)
contact = Contact.new("Bob", "555-1234", "bob@test.com")

# Enumeration over values
contact.each { |value| puts value }
# Outputs: Bob, 555-1234, bob@test.com

# Array-like access
contact[0]     # => "Bob"
contact[1]     # => "555-1234"
contact[-1]    # => "bob@test.com"

# Conversion to array and hash
contact.to_a   # => ["Bob", "555-1234", "bob@test.com"]
contact.to_h   # => {:name=>"Bob", :phone=>"555-1234", :email=>"bob@test.com"}

Struct instances support equality comparison based on attribute values rather than object identity. Two Struct instances are equal if they belong to the same Struct class and have identical attribute values.

Address = Struct.new(:street, :city, :zip)

addr1 = Address.new("123 Main St", "Boston", "02101")
addr2 = Address.new("123 Main St", "Boston", "02101")
addr3 = Address.new("456 Oak Ave", "Boston", "02101")

addr1 == addr2  # => true (same values)
addr1 == addr3  # => false (different street)
addr1.eql?(addr2)  # => true
addr1.hash == addr2.hash  # => true

Advanced Usage

Struct supports several advanced creation patterns beyond basic attribute specification. Anonymous Struct classes can be created without assignment to constants, and Struct classes can be subclassed to add custom methods.

# Anonymous Struct class
coordinate_class = Struct.new(:lat, :lng) do
  def distance_to(other)
    # Simplified distance calculation
    Math.sqrt((lat - other.lat)**2 + (lng - other.lng)**2)
  end
  
  def to_s
    "(#{lat}, #{lng})"
  end
end

loc1 = coordinate_class.new(42.3601, -71.0589)
loc2 = coordinate_class.new(40.7128, -74.0060)
distance = loc1.distance_to(loc2)  # Custom method usage

Struct classes can accept blocks during creation to define additional methods directly within the class definition. This approach creates more cohesive data objects with behavior attached to the data structure.

# Struct with block for custom methods
Rectangle = Struct.new(:width, :height) do
  def area
    width * height
  end
  
  def perimeter
    2 * (width + height)
  end
  
  def square?
    width == height
  end
  
  def resize(factor)
    self.width *= factor
    self.height *= factor
    self
  end
end

rect = Rectangle.new(5, 3)
rect.area       # => 15
rect.perimeter  # => 16
rect.square?    # => false
rect.resize(2)  # Returns self after modification
rect.width      # => 10

Subclassing existing Struct classes enables inheritance hierarchies with shared attributes and specialized behavior. Subclasses inherit all parent attributes and methods while adding their own.

# Base Struct class
Vehicle = Struct.new(:make, :model, :year)

# Subclass with additional attributes and methods
class Car < Vehicle
  def initialize(make, model, year, doors = 4)
    super(make, model, year)
    @doors = doors
  end
  
  attr_reader :doors
  
  def description
    "#{year} #{make} #{model} (#{doors} doors)"
  end
  
  def vintage?
    year < 1990
  end
end

car = Car.new("Honda", "Civic", 2020)
car.make         # => "Honda" (inherited)
car.doors        # => 4 (added in subclass)
car.description  # => "2020 Honda Civic (4 doors)"

Struct classes support keyword arguments in their initializers when using Ruby 2.5+, providing more explicit instantiation patterns. This feature requires enabling keyword initialization explicitly.

# Keyword argument initialization
User = Struct.new(:username, :email, :role, keyword_init: true)

user1 = User.new(username: "john_doe", email: "john@example.com", role: "admin")
user2 = User.new(email: "jane@example.com", username: "jane_smith")  # Order doesn't matter

# Mix with default values through custom initializer
class ApiUser < Struct.new(:username, :email, :role, :active, keyword_init: true)
  def initialize(username:, email:, role: "user", active: true, **kwargs)
    super
  end
end

api_user = ApiUser.new(username: "test", email: "test@api.com")
api_user.role    # => "user" (default)
api_user.active  # => true (default)

Common Pitfalls

Struct instances are mutable by default, which can lead to unexpected modifications when Struct objects are shared between different parts of an application. This mutability can cause debugging challenges when attribute values change unexpectedly.

# Mutability pitfall
Config = Struct.new(:host, :port, :ssl)
default_config = Config.new("localhost", 8080, false)

# Sharing the same instance
service_a_config = default_config
service_b_config = default_config

# Modification affects all references
service_a_config.port = 3000
service_b_config.port  # => 3000 (unexpected change)

# Solution: Create separate instances
service_a_config = Config.new(*default_config.to_a)
service_b_config = Config.new(*default_config.to_a)
service_a_config.port = 3000
service_b_config.port  # => 8080 (unchanged)

Struct equality comparison only works between instances of the same Struct class, even when attribute names and values are identical. This behavior can cause unexpected inequality results when comparing logically equivalent data from different Struct classes.

# Equality pitfall with different Struct classes
Point2D = Struct.new(:x, :y)
Coordinate = Struct.new(:x, :y)

point = Point2D.new(1, 2)
coord = Coordinate.new(1, 2)

point == coord  # => false (different classes)
point.to_a == coord.to_a  # => true (same values)

# Classes themselves are also different
Point2D == Coordinate  # => false
Point2D.new.class == Coordinate.new.class  # => false

Struct attribute access using bracket notation with invalid indices or keys raises exceptions rather than returning nil. This behavior differs from Hash access patterns that developers might expect.

# Index access pitfalls
Person = Struct.new(:name, :age)
person = Person.new("Alice", 25)

person[0]   # => "Alice"
person[1]   # => 25
person[2]   # raises IndexError: offset 2 too large for struct(size:2)
person[-3]  # raises IndexError: offset -3 too small for struct(size:2)

# String keys don't work with bracket notation
person["name"]  # raises TypeError: no implicit conversion of String into Integer

# Use members for safe attribute name access
person.class.members  # => [:name, :age]
person.class.members.include?(:name)  # => true

Struct classes created with the same attribute names are not identical classes, leading to type checking issues when expecting specific Struct classes rather than duck typing.

# Class identity pitfall
def create_point_class
  Struct.new(:x, :y)
end

PointA = create_point_class
PointB = create_point_class

# Classes are different despite same structure
PointA == PointB  # => false
PointA.new(1, 2).class == PointB.new(1, 2).class  # => false

# Type checking fails
def process_point(point)
  raise TypeError unless point.is_a?(PointA)
  # Process point
end

point_b = PointB.new(3, 4)
process_point(point_b)  # raises TypeError

# Solution: Use duck typing or shared base class
def process_point_duck(point)
  raise TypeError unless point.respond_to?(:x) && point.respond_to?(:y)
  # Process point - works with any object having x and y methods
end

Production Patterns

Struct excels as a foundation for value objects in domain-driven design, providing immutable-like data containers with semantic meaning. Value objects encapsulate related data and can include validation and business logic.

# Value object pattern with validation
class Money < Struct.new(:amount, :currency)
  def initialize(amount, currency = "USD")
    raise ArgumentError, "Amount must be positive" if amount < 0
    raise ArgumentError, "Invalid currency" unless valid_currency?(currency)
    super(amount.round(2), currency.upcase)
  end
  
  def +(other)
    raise ArgumentError, "Currency mismatch" unless currency == other.currency
    Money.new(amount + other.amount, currency)
  end
  
  def to_s
    format("%.2f %s", amount, currency)
  end
  
  private
  
  def valid_currency?(code)
    %w[USD EUR GBP JPY].include?(code.upcase)
  end
end

# Usage in business logic
price = Money.new(29.99, "USD")
tax = Money.new(2.40, "USD")
total = price + tax  # => Money instance with amount: 32.39, currency: "USD"

Data Transfer Objects (DTOs) benefit from Struct's automatic accessor generation and serialization capabilities, particularly in API responses and service layer communication.

# DTO pattern for API responses
class UserResponse < Struct.new(:id, :username, :email, :created_at, :profile, keyword_init: true)
  def self.from_user(user)
    new(
      id: user.id,
      username: user.username,
      email: user.email,
      created_at: user.created_at.iso8601,
      profile: ProfileResponse.from_profile(user.profile)
    )
  end
  
  def to_json(*args)
    to_h.to_json(*args)
  end
  
  def sanitized
    dup.tap { |dto| dto.email = "[REDACTED]" if email }
  end
end

class ProfileResponse < Struct.new(:first_name, :last_name, :bio, keyword_init: true)
  def self.from_profile(profile)
    return new unless profile
    
    new(
      first_name: profile.first_name,
      last_name: profile.last_name,
      bio: profile.bio&.truncate(100)
    )
  end
end

# Controller usage
def show
  user = User.find(params[:id])
  response = UserResponse.from_user(user)
  
  render json: current_user.admin? ? response : response.sanitized
end

Configuration objects using Struct provide type safety and validation while maintaining simplicity for application settings and feature flags.

# Configuration pattern with defaults and validation
class DatabaseConfig < Struct.new(:host, :port, :database, :username, :password, :pool_size, :timeout, keyword_init: true)
  DEFAULT_VALUES = {
    host: "localhost",
    port: 5432,
    pool_size: 5,
    timeout: 30
  }.freeze
  
  def initialize(**options)
    merged_options = DEFAULT_VALUES.merge(options)
    validate_required_fields(merged_options)
    validate_types(merged_options)
    super(**merged_options)
  end
  
  def connection_string
    "postgresql://#{username}:#{password}@#{host}:#{port}/#{database}"
  end
  
  def pool_config
    {
      pool: pool_size,
      checkout_timeout: timeout,
      reaping_frequency: timeout * 2
    }
  end
  
  private
  
  def validate_required_fields(options)
    required = [:database, :username, :password]
    missing = required - options.keys
    raise ArgumentError, "Missing required fields: #{missing.join(', ')}" if missing.any?
  end
  
  def validate_types(options)
    raise ArgumentError, "Port must be integer" unless options[:port].is_a?(Integer)
    raise ArgumentError, "Pool size must be positive" unless options[:pool_size] > 0
  end
end

# Application usage
db_config = DatabaseConfig.new(
  database: "myapp_production",
  username: ENV["DB_USERNAME"],
  password: ENV["DB_PASSWORD"],
  host: ENV["DB_HOST"],
  pool_size: ENV["DB_POOL_SIZE"]&.to_i
)

# Use in database connection setup
connection = DatabaseConnection.new(
  db_config.connection_string,
  **db_config.pool_config
)

Event sourcing patterns leverage Struct for creating immutable event objects that capture state changes with timestamps and metadata.

# Event sourcing with Struct-based events
class BaseEvent < Struct.new(:aggregate_id, :occurred_at, :version, :metadata, keyword_init: true)
  def initialize(**args)
    args[:occurred_at] ||= Time.current
    args[:version] ||= 1
    args[:metadata] ||= {}
    super
    freeze  # Make events immutable
  end
  
  def event_type
    self.class.name.demodulize
  end
end

class UserRegisteredEvent < BaseEvent.new(:user_id, :email, :registration_source, keyword_init: true)
  def initialize(user_id:, email:, registration_source: "web", **base_args)
    super(aggregate_id: user_id, user_id: user_id, email: email, registration_source: registration_source, **base_args)
  end
end

class OrderPlacedEvent < BaseEvent.new(:order_id, :customer_id, :total_amount, :items, keyword_init: true)
  def initialize(order_id:, customer_id:, total_amount:, items:, **base_args)
    super(aggregate_id: order_id, order_id: order_id, customer_id: customer_id, 
          total_amount: total_amount, items: items.freeze, **base_args)
  end
  
  def item_count
    items.sum { |item| item[:quantity] }
  end
end

# Event store usage
event_store = EventStore.new
event = UserRegisteredEvent.new(user_id: 123, email: "user@example.com")
event_store.append(event)

Reference

Class Methods

Method Parameters Returns Description
Struct.new(*attrs, **opts, &block) *attrs (Symbol/String), keyword_init: (Boolean), block (optional) Class Creates new Struct class with specified attributes
Struct.keyword_init? none Boolean Returns true if Struct uses keyword initialization

Instance Methods

Method Parameters Returns Description
#initialize(*values) *values (any) self Creates new instance with attribute values
#[] index (Integer/Symbol) Object Accesses attribute by index or name
#[]= index (Integer/Symbol), value (Object) Object Sets attribute by index or name
#each &block Enumerator/self Iterates over attribute values
#each_pair &block Enumerator/self Iterates over attribute name-value pairs
#length none Integer Returns number of attributes
#size none Integer Alias for length
#members none Array<Symbol> Returns attribute names as symbols
#to_a none Array Converts to array of values
#to_h &block Hash Converts to hash of names to values
#values none Array Returns array of attribute values
#values_at(*indices) *indices (Integer) Array Returns values at specified indices
#dig(*keys) *keys (Integer/Symbol) Object Navigates nested structures safely

Comparison and Equality

Method Parameters Returns Description
#== other (Object) Boolean Value equality comparison
#eql? other (Object) Boolean Hash key equality comparison
#hash none Integer Hash code based on values

Enumerable Methods

Struct instances include Enumerable, providing access to all enumerable methods:

Method Returns Description
#map(&block) Array Maps over attribute values
#select(&block) Array Filters attribute values
#find(&block) Object Finds first matching value
#any?(&block) Boolean Tests if any value matches
#all?(&block) Boolean Tests if all values match

Creation Options

Option Type Description
keyword_init: Boolean Enables keyword argument initialization

Common Patterns

Anonymous Struct Creation:

point_class = Struct.new(:x, :y)
point = point_class.new(1, 2)

Named Struct Creation:

Point = Struct.new(:x, :y)
point = Point.new(1, 2)

Keyword Initialization:

User = Struct.new(:name, :email, keyword_init: true)
user = User.new(name: "Alice", email: "alice@example.com")

Block Definition:

Rectangle = Struct.new(:width, :height) do
  def area
    width * height
  end
end

Error Types

Error Condition
ArgumentError Wrong number of arguments to constructor
IndexError Invalid index access with []
TypeError Invalid type for index access
FrozenError Modification of frozen Struct instance

Memory and Performance

  • Struct instances have lower memory overhead than equivalent custom classes
  • Attribute access is faster than hash lookup but slower than instance variable access
  • Struct creation has minimal overhead compared to class definition
  • Enumeration methods create intermediate arrays, consider memory usage with large datasets