Skip to content

Metaprogramming

Metaprogramming in Crystal is not the same as in Ruby. The links on this page will hopefully provide some insight into those differences and how to overcome them.

Differences between Ruby and Crystal

Ruby makes heavy use of send, method_missing, instance_eval, class_eval, eval, define_method, remove_method, and others for making code modifications at runtime. It also supports include and extend for adding modules to other modules to create new class or instance methods at runtime. Herein lies the biggest difference between the two languages: Crystal does not allow for runtime code generation. All Crystal code must be generated and compiled prior to executing the final binary.

Therefore, many of those mechanisms listed above do not even exist. Of the methods listed above, Crystal has some support only for method_missing via a macro facility. Read the official docs on macros to understand them, but note that the macro is used to define valid Crystal methods during the compile step, so all receivers and method names must be known ahead of time. You can't build a method name from a string or symbol and send it to a receiver; there is no support for send and the compile will fail.

Crystal does support include and extend. But all code included or extended must be valid Crystal to compile.

How to Translate Some Ruby Tricks to Crystal

But all is not lost for the intrepid metaprogrammer! Crystal still has powerful facilities for compile-time code generation. We just need to adjust our Ruby techniques a bit to work under the Crystal environment.

Overriding #new via extend

In Ruby we can do some powerful things by overriding the new method on a class.

module ClassMethods
  def new(*args)
    puts "Calling overridden new method with args #{args.inspect}"
    # Can do arbitrary setup or calculations here...
    instance = allocate
    instance.send(:initialize, *args) # need to use #send since #initialize is private
    instance
  end
end

class Foo
  def initialize(name)
    puts "Calling Foo.new with arg #{name}"
  end
end

foo = Foo.new('Quxo') # => Calling Foo.new with arg Quxo
p foo.class # => Foo

class Foo
  extend ClassMethods
end

foo = Foo.new('Quxo')
# => Calling overridden new method with args ["Quxo"]
# => Calling Foo.new with arg Quxo
p foo.class # => Foo

As seen in the example above, the Foo instance calls its normal constructor. When we extend it and override new we can inject all sorts of things into the process. The above example shows minimal interference and just allocates an instance of the object and initializes it. This instance is returned back from the constructor.

In the next example, we override new and return a completely different kind of class!

class Bar
  def initialize(foo)
    puts "This arg was an instance of class #{foo.class}"
  end
end

module ClassMethods
  def new(*args)
    puts "Calling overridden new method with args #{args.inspect}"
    Bar.new(allocate) # return a completely different class instance
  end
end

class Foo
  extend ClassMethods

  def initialize(name)
    puts "Calling Foo.new with arg #{name}"
  end
end

foo = Foo.new('Quxo')
# => Calling overridden new method with args ["Quxo"]
# => This arg was an instance of class Foo
p foo.class # => Bar

This allows for very powerful meta programming at runtime. We can wrap a class in another class as a proxy and return a reference to this new proxy object.

Is the same kind of magic possible with Crystal? I wouldn't have written this section if it were impossible. But it does have some caveats that we'll get to later.

Here's the original class in Crystal and the expected behavior.

module ClassMethods
  macro extended
    def self.new(number : Int32)
      puts "Calling overridden new added from extend hook, arg is #{number}"
      instance = allocate
      instance.initialize(number)
      instance
    end
  end
end

class Foo
  extend ClassMethods
  @number : Int32

  def initialize(number)
    puts "Foo.initialize called with number #{number}"
    @number = number
  end
end

foo = Foo.new(5)
# => Calling overridden new added from extend hook, arg is 5
# => Foo.initialize called with number 5
puts foo.class # Foo

This example makes use of the macro extended hook. This hook is called whenever a class body executes the extend method. We are able to use this macro to write a replacement new method.

(Need clarity on the method signature details. Removing the @number type declaration Foo causes the override to silently fail. Adding "number : Int32" to the Foo class initialize signature also causes the override to fail. There are some subtleties here with method overloads that I am missing. Need more experimentation. Examples above still work though...)

Generating Methods via method_missing Macro

Following is a very simple example that demonstrates how to use method_missing macro to create the missing method based on the existence of receiver JSON object's key

class Hashr
  getter obj

  def initialize(json : Hash(String, JSON::Any) | JSON::Any)
    @obj = json
  end

  macro method_missing(key)
    def {{ key.id }}
      value = obj[{{ key.id.stringify }}]

      Hashr.new(value)
    end
  end

  def ==(other)
    obj == other
  end
end

How to Mimic send Using records and Generated Lookup Tables

Sample code + explanation

Crystal Approach to alias_method

Sometimes we want to reopen a class and redefine a previously defined method to have some new behavior. Plus, we probably want the original method to still be accessible too. In Ruby, we use alias_method for this purpose. Example:

class Klass
  def salute
    puts "Aloha!"
  end
end

Klass.new.salute # => Aloha!

class Klass
  def salute_with_log
    puts "Calling method..."
    salute_without_log
    puts "... Method called"
  end

  alias_method :salute_without_log, :salute
  alias_method :salute, :salute_with_log
end

Klass.new.salute
# => Calling method...
# => Aloha!
# => ... Method called

Performing the same work in Crystal is fairly straight forward. Crystal provides a method called previous_def which can access the previously defined version of the method. To make the same example work in Crystal, it would look similar to this:

class Klass
  def salute
    puts "Aloha!"
  end
end

# Reopen the class...
class Klass
  def salute
    puts "Calling method..."
    previous_def
  end
end

# Reopen it again for kicks!
class Klass
  def salute
    previous_def
    puts "... Method called"
  end
end

Klass.new.salute
# => Calling method...
# => Aloha!
# => ... Method called

Each time we reopen the class previous_def is set to the prior method definition so we can use this to build an alias method chain at compile time much like in Ruby. However, we do lose access to the original method definition each time we extend the chain. Unlike in Ruby where we are giving the old method an explicit name that we could refer to somewhere else, Crystal does not provide that facility.

General Resources

Ary Borenszweig (@asterite on gitter) gave a talk at a conference in 2016 covering macros. It can be seen here.