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 |