CrackedRuby logo

CrackedRuby

Class Macros

Ruby class macros that execute at class definition time to generate methods, define behavior, and configure class-level functionality.

Metaprogramming DSL Creation
5.7.4

Overview

Class macros in Ruby are methods called during class definition that modify the class being defined. These methods execute immediately when Ruby parses the class definition, generating instance methods, class methods, or configuring class behavior before any instances exist.

Ruby provides several built-in class macros like attr_reader, attr_writer, and attr_accessor. Custom class macros follow the same pattern - they receive arguments during class definition and use metaprogramming techniques to modify the class.

class User
  attr_reader :name, :email  # Built-in class macro
  validates :email, presence: true  # Custom class macro (Rails)
  
  def initialize(name, email)
    @name, @email = name, email
  end
end

# The attr_reader macro generated these methods:
user = User.new("Alice", "alice@example.com")
user.name   # => "Alice"
user.email  # => "alice@example.com"

Class macros operate on the class object itself using self within the class definition context. When Ruby encounters a method call at the class level, it searches for that method in the class's singleton class, parent classes, or included modules.

class Product
  # This executes during class definition
  def self.create_attribute(name)
    define_method(name) { instance_variable_get("@#{name}") }
    define_method("#{name}=") { |value| instance_variable_set("@#{name}", value) }
  end
  
  create_attribute :price
  create_attribute :category
end

product = Product.new
product.price = 29.99
product.price  # => 29.99

The macro execution happens in the class context where self refers to the class object. This context provides access to metaprogramming methods like define_method, alias_method, private, protected, and module inclusion methods.

Basic Usage

Creating class macros involves defining class methods that modify the class during definition. The most common pattern uses define_method to create instance methods dynamically.

class ApiClient
  def self.endpoint(name, path)
    define_method(name) do |params = {}|
      url = path.gsub(/:(\w+)/) { params[$1.to_sym] }
      make_request(url)
    end
  end
  
  endpoint :get_user, "/users/:id"
  endpoint :get_posts, "/users/:id/posts"
  
  private
  
  def make_request(url)
    "Making request to #{url}"
  end
end

client = ApiClient.new
client.get_user(id: 123)   # => "Making request to /users/123"
client.get_posts(id: 123)  # => "Making request to /users/123/posts"

Class macros can accept blocks to define method behavior. The block becomes the method body when using define_method.

class EventHandler
  def self.on_event(event_name, &block)
    define_method("handle_#{event_name}", &block)
    
    # Store event handlers for later lookup
    @event_handlers ||= {}
    @event_handlers[event_name] = "handle_#{event_name}".to_sym
  end
  
  def self.event_handlers
    @event_handlers || {}
  end
  
  on_event :user_login do |user|
    puts "User #{user.name} logged in at #{Time.now}"
    send_welcome_email(user)
  end
  
  on_event :user_logout do |user|
    puts "User #{user.name} logged out"
    clear_session_data(user)
  end
  
  private
  
  def send_welcome_email(user)
    "Sending welcome email to #{user.name}"
  end
  
  def clear_session_data(user)
    "Clearing session for #{user.name}"
  end
end

Multiple class macros can work together to build complex class configurations. Each macro call modifies the class incrementally.

class Model
  def self.field(name, type, **options)
    # Define getter
    define_method(name) { instance_variable_get("@#{name}") }
    
    # Define setter with type validation
    define_method("#{name}=") do |value|
      validated_value = validate_type(value, type)
      instance_variable_set("@#{name}", validated_value)
    end
    
    # Store field metadata
    @fields ||= {}
    @fields[name] = { type: type, options: options }
  end
  
  def self.fields
    @fields || {}
  end
  
  field :id, Integer, required: true
  field :name, String, required: true, max_length: 100
  field :email, String, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\z/i
  field :age, Integer, min: 0, max: 150
  
  def validate_type(value, type)
    case type
    when String
      value.to_s
    when Integer  
      Integer(value)
    else
      value
    end
  end
end

user = Model.new
user.name = "John Doe"
user.age = "30"  # Automatically converted to integer
user.age        # => 30

Class macros can modify class behavior beyond method generation. They can include modules, define constants, or configure class-level settings.

class Configurable
  def self.config_option(name, default: nil)
    # Create class-level storage
    @config ||= {}
    @config[name] = default
    
    # Class-level getter and setter
    define_singleton_method(name) { @config[name] }
    define_singleton_method("#{name}=") { |value| @config[name] = value }
    
    # Instance-level access
    define_method("#{name}") { self.class.send(name) }
  end
  
  config_option :timeout, default: 30
  config_option :retries, default: 3
  config_option :debug, default: false
end

class HttpClient < Configurable
  config_option :base_url, default: "https://api.example.com"
end

HttpClient.timeout = 60
HttpClient.debug = true

client = HttpClient.new
client.timeout   # => 60
client.debug     # => true
client.base_url  # => "https://api.example.com"

Advanced Usage

Class macros can implement sophisticated patterns using Ruby's full metaprogramming capabilities. Advanced macros often combine multiple techniques like method aliasing, module inclusion, and inheritance hooks.

class Trackable
  def self.track_changes(*attributes)
    # Include tracking module if not already included
    unless included_modules.include?(ChangeTracking)
      include ChangeTracking
    end
    
    attributes.each do |attr|
      # Store original setter
      original_setter = "#{attr}_without_tracking="
      alias_method original_setter, "#{attr}="
      
      # Redefine setter with tracking
      define_method("#{attr}=") do |value|
        old_value = instance_variable_get("@#{attr}")
        send(original_setter, value)
        track_change(attr, old_value, value) if old_value != value
      end
    end
    
    # Store tracked attributes for introspection
    @tracked_attributes ||= []
    @tracked_attributes.concat(attributes)
  end
  
  def self.tracked_attributes
    @tracked_attributes || []
  end
  
  module ChangeTracking
    def initialize_tracking
      @changes = {}
    end
    
    def track_change(attribute, old_value, new_value)
      initialize_tracking unless defined?(@changes)
      @changes[attribute] = { from: old_value, to: new_value }
    end
    
    def changes
      @changes ||= {}
    end
    
    def changed_attributes
      changes.keys
    end
    
    def changed?
      !changes.empty?
    end
  end
end

class User
  extend Trackable
  
  attr_accessor :name, :email, :status
  track_changes :name, :email, :status
  
  def initialize(name, email)
    @name, @email = name, email
    @status = :active
    initialize_tracking
  end
end

user = User.new("Alice", "alice@example.com")
user.name = "Alice Smith"
user.status = :inactive

user.changes
# => {:name=>{:from=>"Alice", :to=>"Alice Smith"}, :status=>{:from=>:active, :to=>:inactive}}

Class macros can create domain-specific languages (DSLs) for configuration or behavior definition. These macros often store configuration data and generate methods based on that data.

class StateMachine
  def self.state(name, &block)
    @states ||= {}
    @states[name] = StateDefinition.new(name)
    @states[name].instance_eval(&block) if block
    
    # Generate state query method
    define_method("#{name}?") { @current_state == name }
  end
  
  def self.transition(from:, to:, on:, **conditions)
    @transitions ||= []
    @transitions << {
      from: from, to: to, event: on, conditions: conditions
    }
    
    # Generate event method if not exists
    unless method_defined?(on)
      define_method(on) do
        trigger_transition(on)
      end
    end
  end
  
  def self.states
    @states ||= {}
  end
  
  def self.transitions  
    @transitions ||= []
  end
  
  class StateDefinition
    attr_reader :name
    
    def initialize(name)
      @name = name
    end
  end
  
  def initialize(initial_state)
    @current_state = initial_state
  end
  
  def current_state
    @current_state
  end
  
  def trigger_transition(event)
    transition = self.class.transitions.find do |t|
      t[:from] == @current_state && t[:event] == event
    end
    
    if transition
      @current_state = transition[:to]
      true
    else
      false
    end
  end
end

class Document
  extend StateMachine
  
  state :draft
  state :review  
  state :published
  state :archived
  
  transition from: :draft, to: :review, on: :submit_for_review
  transition from: :review, to: :published, on: :approve
  transition from: :review, to: :draft, on: :reject
  transition from: :published, to: :archived, on: :archive
  
  def initialize
    super(:draft)
  end
end

doc = Document.new
doc.draft?      # => true
doc.published?  # => false

doc.submit_for_review
doc.current_state  # => :review
doc.review?        # => true

Advanced macros can implement inheritance patterns where child classes automatically receive enhanced behavior. Class instance variables and inheritance hooks make this possible.

class Serializable
  def self.serialize_with(*serializers)
    @serializers = serializers
    
    # Ensure child classes inherit serializers
    def self.inherited(child_class)
      super
      child_class.instance_variable_set(:@serializers, @serializers.dup) if @serializers
    end
    
    # Generate serialization methods
    serializers.each do |serializer|
      define_method("to_#{serializer}") do
        send("serialize_as_#{serializer}")
      end
    end
  end
  
  def self.add_serializer(name, &block)
    define_method("serialize_as_#{name}", &block)
  end
  
  def self.serializers
    @serializers || []
  end
end

class BaseModel
  extend Serializable
  
  serialize_with :json, :xml, :csv
  
  add_serializer :json do
    instance_variables.each_with_object({}) do |var, hash|
      key = var.to_s.delete('@')
      hash[key] = instance_variable_get(var)
    end.to_json
  end
  
  add_serializer :xml do
    "<#{self.class.name.downcase}>" + 
    instance_variables.map do |var|
      key = var.to_s.delete('@')
      value = instance_variable_get(var)
      "  <#{key}>#{value}</#{key}>"
    end.join("\n") +
    "</#{self.class.name.downcase}>"
  end
end

class User < BaseModel
  def initialize(name, email)
    @name, @email = name, email
  end
end

user = User.new("Alice", "alice@example.com")
user.to_json  # => "{\"name\":\"Alice\",\"email\":\"alice@example.com\"}"

Common Pitfalls

Class macros execute during class parsing, which creates timing issues when the macro depends on methods or constants not yet defined. The order of macro calls and method definitions matters significantly.

# WRONG: Macro called before helper method is defined
class BadExample
  create_fields_from_config  # Error: undefined method
  
  private
  
  def self.create_fields_from_config
    # This won't work because it's defined after the call
  end
end

# CORRECT: Define helper method before macro call  
class GoodExample
  def self.create_fields_from_config
    # Helper method defined first
  end
  
  private
  
  create_fields_from_config  # Now this works
end

Class instance variables in macros don't behave like regular instance variables. Each class in an inheritance hierarchy has its own set of class instance variables, which can lead to unexpected behavior when sharing data between parent and child classes.

class Parent
  def self.add_field(name)
    @fields ||= []
    @fields << name
    attr_accessor name
  end
  
  def self.fields
    @fields || []
  end
  
  add_field :name
end

class Child < Parent
  add_field :age
end

Parent.fields  # => [:name]
Child.fields   # => [:age]  - NOT [:name, :age] as you might expect

# The child class has its own @fields variable
# To share data, use class variables or more complex inheritance handling
class SharedDataParent
  def self.add_field(name)
    @@fields ||= []
    @@fields << name
    attr_accessor name
  end
  
  def self.fields
    @@fields || []
  end
end

Method visibility modifiers (private, protected, public) affect subsequently defined methods, including those generated by macros. This can create unexpected method visibility.

class VisibilityTrap
  def self.create_method(name)
    define_method(name) { "This is #{name}" }
  end
  
  create_method :public_method
  
  private
  
  create_method :should_be_private  # This becomes private
  create_method :also_private       # This also becomes private
  
  public
  
  create_method :back_to_public     # This is public again
end

obj = VisibilityTrap.new
obj.public_method      # Works fine
obj.should_be_private  # NoMethodError: private method called

Macros that modify global state or use global variables can create hidden dependencies between classes. This makes testing difficult and can cause unexpected behavior when classes are loaded in different orders.

# PROBLEMATIC: Global state modification
$global_registry = {}

class RegistryDependent
  def self.register_type(name, klass)
    $global_registry[name] = klass  # Modifies global state
  end
end

# BETTER: Use class-level state or dependency injection
class BetterRegistry  
  @registry = {}
  
  def self.register_type(name, klass)
    @registry[name] = klass  # Class-level state
  end
  
  def self.registry
    @registry.dup  # Return copy to prevent external modification
  end
end

Macros that generate methods with dynamic names can create naming conflicts, especially when combined with inheritance or module inclusion. Always check for existing methods before defining new ones.

class ConflictProne
  def self.add_accessor(name)
    # WRONG: Blindly overwrite existing methods
    define_method(name) { instance_variable_get("@#{name}") }
    define_method("#{name}=") { |val| instance_variable_set("@#{name}", val) }
  end
  
  def important_method
    "This is important"
  end
  
  add_accessor :important_method  # Overwrites the existing method!
end

class BetterConflictHandling
  def self.add_accessor(name)
    if method_defined?(name) || private_method_defined?(name)
      raise ArgumentError, "Method #{name} already exists"
    end
    
    define_method(name) { instance_variable_get("@#{name}") }
    define_method("#{name}=") { |val| instance_variable_set("@#{name}", val) }
  end
end

Block-based macros can create scope issues when the block references local variables from the class definition context. These variables might not be available when the generated method executes.

class ScopeIssues
  config = { timeout: 30, retries: 3 }  # Local variable in class scope
  
  def self.create_method_with_config(&block)
    define_method(:configured_method) do
      # This block runs in instance context later
      # The 'config' local variable is not accessible here
      instance_eval(&block)
    end
  end
  
  create_method_with_config do
    puts config[:timeout]  # NameError: undefined local variable 'config'
  end
end

# SOLUTION: Pass data as arguments or store in instance variables
class BetterScope
  @config = { timeout: 30, retries: 3 }  # Class instance variable
  
  def self.create_method_with_config(&block)
    config_copy = @config.dup
    
    define_method(:configured_method) do
      instance_exec(config_copy, &block)
    end
  end
  
  create_method_with_config do |config|
    puts config[:timeout]  # This works
  end
end

Reference

Built-in Class Macros

Macro Parameters Returns Description
attr_reader(*names) Symbol/String names nil Creates getter methods for instance variables
attr_writer(*names) Symbol/String names nil Creates setter methods for instance variables
attr_accessor(*names) Symbol/String names nil Creates getter and setter methods
private(*names) Method names (optional) nil Makes methods private
protected(*names) Method names (optional) nil Makes methods protected
public(*names) Method names (optional) nil Makes methods public
alias_method(new, old) Symbol/String names self Creates method alias
include(*modules) Module objects self Includes modules as ancestors
extend(*modules) Module objects self Extends class with module methods
prepend(*modules) Module objects self Prepends modules to ancestor chain

Metaprogramming Methods for Macros

Method Parameters Returns Description
define_method(name, &block) Symbol/String name, block Symbol Defines instance method dynamically
define_singleton_method(name, &block) Symbol/String name, block Symbol Defines class method dynamically
remove_method(name) Symbol/String name self Removes method from class
undef_method(name) Symbol/String name self Undefines method (prevents inheritance)
method_defined?(name) Symbol/String name Boolean Checks if instance method exists
private_method_defined?(name) Symbol/String name Boolean Checks if private method exists
class_eval(&block) Block or string Varies Evaluates code in class context
instance_eval(&block) Block or string Varies Evaluates code in singleton context

Class Context Methods

Method Parameters Returns Description
self None Class Current class object in macro context
ancestors None Array Class and module hierarchy
included_modules None Array Directly included modules
instance_methods(include_super=true) Boolean Array Public instance method names
private_instance_methods(include_super=true) Boolean Array Private instance method names
class_variables None Array Class variable names
instance_variables None Array Class instance variable names

Hook Methods for Advanced Macros

Hook Triggered When Parameters Use Case
self.inherited(subclass) Class is subclassed Subclass object Copy class configuration to child
self.included(base) Module included in class Including class Add class methods to includer
self.extended(object) Module extends object Extended object Configure extended object
self.prepended(base) Module prepended to class Prepending class Modify prepending class behavior

Common Patterns Reference

Basic Method Generation:

def self.simple_accessor(name)
  define_method(name) { instance_variable_get("@#{name}") }
  define_method("#{name}=") { |val| instance_variable_set("@#{name}", val) }
end

Block-Based Configuration:

def self.configure(&block)
  @configuration = Configuration.new
  @configuration.instance_eval(&block)
end

Inheritance-Safe Class Variables:

def self.inherited(child)
  super
  child.instance_variable_set(:@data, @data.dup) if @data
end

Method Visibility Control:

def self.private_accessor(name)
  define_method(name) { instance_variable_get("@#{name}") }
  private name
end

Conditional Method Definition:

def self.optional_method(name, condition: true)
  return unless condition
  define_method(name) { "Method #{name} called" }
end

Error Classes

Exception Raised When Example Scenario
NoMethodError Calling undefined macro Typo in macro name
ArgumentError Wrong number of arguments Missing required macro parameters
NameError Reference to undefined constant Macro references missing constant
TypeError Wrong argument type Passing string where symbol expected
LocalJumpError Block context issues Block references unavailable variables