Crystal’s original inspiration, Ruby, is a master of runtime introspection and manipulation of code—also called metaprogramming. Metaprogramming is the secret to Ruby on Rails’ sophistication, but Ruby has no macros. Crystal is a compiled language, so it doesn’t have an eval method to create new code in runtime. It has to take a different path, a competent system of macros to build code at compile time, and that will go a long way.
When you’re writing code, sometimes you’ll find yourself writing methods that are near duplicates of each other, only differing in name or parameters. In these cases, it might help if you could generate all this code automatically using one macro version of the method.
A macro is a function that gets called while code is compiled. The output of the macro is more code, which gets compiled. Macros let you do more with less code.
Less code (usually) means fewer bugs. Don’t repeat yourself—apply the DRY principle!
For example, let’s see how we could implement a macro that returns the value of instance variables. We’ll start from a simple Mineral class with attributes name and hardness, pretending we don’t know about getter:
| class Mineral |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| def name |
| @name |
| end |
| |
| def hardness |
| @hardness |
| end |
| end |
| |
| min1 = Mineral.new("gold", 2.5) |
| "#{min1.name} - #{min1.hardness}" # => "gold - 2.5" |
Code is duplicated: for each attribute, we have a method of that name to return its value.
But we want something like this to use a new macro called get, right?
| class Mineral |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| get name |
| get hardness |
| end |
or even:
| get name, hardness |
Let’s do this in steps:
1) First, copy the name method into a macro, get, like this:
| macro get |
| def name |
| @name |
| end |
| end |
| |
| class Mineral |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| get |
| |
| def hardness |
| @hardness |
| end |
| end |
| |
| min1 = Mineral.new("gold", 2.5) |
| "#{min1.name} - #{min1.hardness}" # => "gold - 2.5" |
The code still works: the macro, get, creates code for the method, name. A macro is defined like an ordinary method. But instead of def, the keyword macro is used.
2) Because we want this for every attribute, we must generalize the code:
| macro get(prop) |
| def {{prop}} |
| @{{prop}} |
| end |
| end |
| |
| class Mineral |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| get name |
| get hardness |
| end |
| |
| min1 = Mineral.new("gold", 2.5) |
| "#{min1.name} - #{min1.hardness}" # => "gold - 2.5" |
The macro get now takes a parameter, prop, so we can use it for every attribute. The body of a macro often contains {{ }} expressions. These are expanded when code is generated from the macro at compile-time: every expression inside {{ }} is substituted in the generated code. So now we can use get for every attribute.
3) How can we reduce the code even more to get name, hardness? Because we don’t know how many attributes there are, we use a splat * (see Using the Splat Argument *). To loop over the attributes, we can use the following for in syntax:
| {% for prop in props %} |
| # code |
| {% end %} |
The complete version looks like this:
| macro get(*props) |
| {% for prop in props %} |
| def {{prop}} |
| @{{prop}} |
| end |
| {% end %} |
| end |
| |
| class Mineral |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| get name, hardness |
| end |
| |
| min1 = Mineral.new("gold", 2.5) |
| "#{min1.name} - #{min1.hardness}" # => "gold - 2.5" |
The Crystal language includes many powerful built-in macros, such as getter, setter, and property, in a class definition. These aren’t keywords. They are macros defined in class Object. There’s even a record macro that can generate an entire struct definition for you:
| record Mineral, name : String, hardness : Float64 |
| |
| min1 = Mineral.new("gold", 2.5) |
| "#{min1.name} - #{min1.hardness}" # => "gold - 2.5" |
Macros are a great way for you to extend the language, even for writing DSLs (Domain Specific Languages).
Like the {% for in %} construct, there’s a {% if %} {% else %}. You can use both outside of a macro definition as well. Inside a macro, you can access the current instance type with the special instance variable @type. Macros can live inside modules or classes. They can call each other, and a macro can even call itself recursively. Be careful, though—you need to define macros before you use them.
How do macros work? The compiler takes a few extra steps to generate the actual executable code. In the step that processes the Abstract Syntax Tree (AST), code using the macro syntax works on these AST nodes. These expand into valid Crystal code, which then compiles as usual. By hooking into the compilation process, you can do some sophisticated things, and it doesn’t slow down runtime performance like it does in Ruby!
➤ def_method: Make a macro, define_method, that takes a method name, mname, and a body to construct that method. Test it by producing the code for a method, greets, that prints “Hi,” and a method, add, that returns 1 + 2.
You might know from Ruby that you can generate code in runtime when a called method can’t be found. You’d do this by defining a method_missing in the class. In Crystal, you can do something very similar with a macro, but here you generate the method at compile-time:
| class Mineral |
| getter name, hardness |
| |
| def initialize(@name : String, @hardness : Float64) |
| end |
| |
| macro method_missing(call) |
| print "Unknown method: ", {{call.name.stringify}}, |
| " with ", {{call.args.size}}, " argument(s): ", |
| {{call.args}}, ' ' |
| end |
| end |
| |
| min1 = Mineral.new("gold", 2.5) |
| min1.alien_planet?(42) |
| # => Unknown method: alien_planet? with 1 argument(s): [42] |
In the preceding example, the method alien_planet? doesn’t exist in class Mineral. Normally, this would cause a compile error:
| undefined method 'alien_planet?' for Mineral |
With this macro, method_missing, in place, we have access to the method’s name and arguments, coding something more useful than just printing them out.
In the same way, you can define the following macros, which are invoked at compile-time:
Using these, you can program at a meta-level.
While macros are powerful, coding with macros is a lot more complicated. So, as a rule, don’t use macros! If you really think you need a macro, first write the code without it, and check that you have duplicated code. If you do, then eliminate that by writing a macro. As you get deeper into macros, you will find more nuance and power in the docs.[47]