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.

  1. module ClassMethods
  2. def new(*args)
  3. puts "Calling overridden new method with args #{args.inspect}"
  4. # Can do arbitrary setup or calculations here...
  5. instance = allocate
  6. instance.send(:initialize, *args) # need to use #send since #initialize is private
  7. instance
  8. end
  9. end
  10. class Foo
  11. def initialize(name)
  12. puts "Calling Foo.new with arg #{name}"
  13. end
  14. end
  15. foo = Foo.new('Quxo') # => Calling Foo.new with arg Quxo
  16. p foo.class # => Foo
  17. class Foo
  18. extend ClassMethods
  19. end
  20. foo = Foo.new('Quxo')
  21. # => Calling overridden new method with args ["Quxo"]
  22. # => Calling Foo.new with arg Quxo
  23. 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!

  1. class Bar
  2. def initialize(foo)
  3. puts "This arg was an instance of class #{foo.class}"
  4. end
  5. end
  6. module ClassMethods
  7. def new(*args)
  8. puts "Calling overridden new method with args #{args.inspect}"
  9. Bar.new(allocate) # return a completely different class instance
  10. end
  11. end
  12. class Foo
  13. extend ClassMethods
  14. def initialize(name)
  15. puts "Calling Foo.new with arg #{name}"
  16. end
  17. end
  18. foo = Foo.new('Quxo')
  19. # => Calling overridden new method with args ["Quxo"]
  20. # => This arg was an instance of class Foo
  21. 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.

  1. module ClassMethods
  2. macro extended
  3. def self.new(number : Int32)
  4. puts "Calling overridden new added from extend hook, arg is #{number}"
  5. instance = allocate
  6. instance.initialize(number)
  7. instance
  8. end
  9. end
  10. end
  11. class Foo
  12. extend ClassMethods
  13. @number : Int32
  14. def initialize(number)
  15. puts "Foo.initialize called with number #{number}"
  16. @number = number
  17. end
  18. end
  19. foo = Foo.new(5)
  20. # => Calling overridden new added from extend hook, arg is 5
  21. # => Foo.initialize called with number 5
  22. 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

  1. class Hashr
  2. getter obj
  3. def initialize(json : Hash(String, JSON::Any) | JSON::Any)
  4. @obj = json
  5. end
  6. macro method_missing(key)
  7. def {{ key.id }}
  8. value = obj[{{ key.id.stringify }}]
  9. Hashr.new(value)
  10. end
  11. end
  12. def ==(other)
  13. obj == other
  14. end
  15. 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:

  1. class Klass
  2. def salute
  3. puts "Aloha!"
  4. end
  5. end
  6. Klass.new.salute # => Aloha!
  7. class Klass
  8. def salute_with_log
  9. puts "Calling method..."
  10. salute_without_log
  11. puts "... Method called"
  12. end
  13. alias_method :salute_without_log, :salute
  14. alias_method :salute, :salute_with_log
  15. end
  16. Klass.new.salute
  17. # => Calling method...
  18. # => Aloha!
  19. # => ... 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:

  1. class Klass
  2. def salute
  3. puts "Aloha!"
  4. end
  5. end
  6. # Reopen the class...
  7. class Klass
  8. def salute
  9. puts "Calling method..."
  10. previous_def
  11. end
  12. end
  13. # Reopen it again for kicks!
  14. class Klass
  15. def salute
  16. previous_def
  17. puts "... Method called"
  18. end
  19. end
  20. Klass.new.salute
  21. # => Calling method...
  22. # => Aloha!
  23. # => ... 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.