Prelude: The Problem
On stream, I was working on a library for interacting with the Twitch EventSub API, using the Crystal language. Crystal has considerable syntactical similarity to Ruby, but there are some fundamental differences between the two languages.
I was writing code that could receive any of a wide variety of inputs from a Twitch API call. For each of these different inputs, I wanted to call a specific method if that method is defined in the class.
The most direct approach to doing this is to explicitly write an extensive case
statement. Something like this:
case type
when "channel.follow" then handler_channel_follow(params)
when "channel.subscribe" then handler_channel_subscribe(params)
when "channel.cheer" then handler_channel_cheer(params)
else
raise "Unsupported message type #{type}"
end
Imagine that, only with another 15 lines of when
clauses. One of the viewers asked about that huge case statement. Since that viewer is learning Ruby, I talked some about the difference in dynamic programming between Crystal and Ruby, including how Ruby's dynamic programming capabilities could make the Ruby equivalent code very short. This exchange highlighted some interesting differences in how to approach his problem between the two languages.
Dynamic Dispatch in Ruby
One of the chief differences is that Crystal is a compiled language, while Ruby is an interpreted language. Ruby is known for its ability to embrace a high level of metaprogramming, and its feature set supports a broad range of dynamic options.
One of these is dynamic dispatch. One can write Ruby code where methods are called based on information that can not be known ahead of time. Consider the example below:
class Greeter
def greet(name)
__send__("greet_#{name.downcase}")
end
def method_missing(*args)
if args[0] =~ /^greet_(.+)/
"I like you, #{$1}."
else
super
end
end
def greet_jaime
"Jaime, you are the best!"
end
end
greeter = Greeter.new
print "Hello! Who am I greeting? > "
name = gets.chomp
puts greeter.greet(name)
If you look in the first method, #greet
, you will observe that it takes a single argument, name
, and that it uses the #__send__
method with a single argument that is a string constructed by combining the string greet_
and the provided name.
If you are not familiar with the #__send__
method, it is used to call methods dynamically. When given either a string or a symbol as its first argument, it will call the method with the name given in that argument. Subsequent arguments are passed into the called method.
The next method, #method_missing
, is invoked if a method called on an object does not exist. It provides an opportunity to do something other than raising a NoMethodError in the case of an attempt to call a method that does not exist.
So, in this example, consider the following two examples of running it:
> ruby greeter.rb
Hello! Who am I greeting? > Tattersail
I like you, Tattersail.
> ruby greeter.rb
Hello! Who am I greeting? > Jaime
Jaime, you are the best!
In the first example, the program will pass the name Tattersail to the #greet
method, which will attempt to, in turn, call #greet_tattersail
. That method is not defined in the class, so the execution will fall to #method_missing
, which will return the default greeting.
In the second example, the method #greet_jaime
exists, so it will be called by __send__
.
This pattern is prevalent in Ruby codebases, even if you, as a programmer, never explicitly write code yourself to do this. It is a frequently used metaprogramming feature.
In Crystal, however, you can't do this. Crystal does not support #send
. Because Crystal is compiled, most of its dynamic programming capabilities have to be done differently from how Ruby does them. Crystal supports macros, which is a secondary language, effectively an interpreted subset of the main Crystal language, that is used to write code that runs before the compilation phase. The macro code generates new code that is pasted into the program before compilation.
Macros are very different from Ruby's style of dynamic programming, but they are powerful in their own way.
Using Ruby Dynamic Dispatch To Replace A Giant, Explicit case
Statement
Before discussing how to handle this kind of dynamic metaprogramming in Crystal, I should demonstrate what I did in the stream, which was how to code it concisely in Ruby.
def dispatch(type, params)
meth = "handle_#{type.tr(".","_")}"
if respond_to?(meth)
__send__(meth, params)
else
raise NoMethodError(meth)
end
end
Taken in the context of the earlier examples, one can see how this code works. A Twitch notification carries a type value. That type value is used here to call the correct handler for that type. If there is no handler for that type, an exception is raised. And it only takes a few lines of code. That's a lot nicer than a case statement with two or three dozen lines.
As mentioned, though, Crystal doesn't support #send
. So is there any way, with Crystal, to do something like this versus just writing a monster case
statement?
Dynamic Dispatch with Crystal
The answer is yes, mostly, though the approach is very different.
Crystal, as mentioned before, uses macros. And macros generate new code which is pasted into the program prior to compilation.
Consider this example:
require "colorize"
macro red(string)
%({{ string.id }}).colorize(:red)
end
puts red("\nEmergency!!!")
puts "The thingy is broken!"
It creates a macro, red()
, that will color red the argument that is passed to it. The macro actually generates code at compile-time. It is not generating a runtime method.
This means that the code that is actually compiled ends up looking like this:
require "colorize"
puts %(\nEmergency!!!).colorize(:red)
puts "The thingy is broken!"
Macros provide powerful capabilities to generate code at compile-time, and they can be used to do something similar to Ruby's dynamic dispatch.
macro dispatch(type, params)
case {{ type.id }}
{% for method in @type.methods %}
{% if method.name =~ /^handle_/ %}
when "{{ method.name.gsub(/^handle_/, "") }}" then {{ method.name.id }}({{ params.id }})
{% end %}
{% end %}
else
raise NotImplementedError.new("handle_#{ {{ type.id }} }")
end
end
Can you discern what this does?
It creates a macro, called dispatch()
, that generates the required case statement without requiring the developer to grind away writing that statement.
Without turning this into an entire article on Crystal macros, the critical point to note is that the example iterates through the list of methods on the class in which the macro is running (via @type.methods
). For each method, if its name begins with handle_, then it writes a line in the form of:
when "crystal_follow" then handle_crystal_follow(params)
The nice thing about this approach is that the user of the library can make a subclass of the top-level TwitchHander and the macro, defining only the methods for notifications that they want to handle, and the macro will work correctly to write a dispatcher case statement for just those methods:
class MyTwitchHandler << TwitchEventSub::HttpServer::TwitchHandler
def handle_client_follow(params)
# do stuff...
end
def handle_client_subscribe(params)
# do stuff...
end
end
The macro will determine which methods are defined on the class and will ensure that a case statement is written which handles only the ones that the programmer actually wrote handlers for. The library itself need not attempt to provide handlers for every possible message type. Using a macro, the crystal code will generate the code necessary to handle precisely what is needed.
For this trivial example, the macro might be used like this:
def do_request(context)
request, body, params = parse_context(context)
return if body.nil?
handled = true
handled = dispatch(params["type"].as_s.tr(".", "_"), params)
rescue NotImplementedError
# Do nothing if the notification type does not have a handler.
ensure
if handled
context.response.respond_with_status(200)
else
# If handled is false, something bad happened.
context.response.respond_with_status(500)
end
end
The dispatch()
macro would be expanded to something similar to:
def do_request(context)
request, body, params = parse_context(context)
return if body.nil?
handled = true
handled = case params["type"].as_s.tr(".", "_")
when "client_follow" then handle_client_follow(params)
when "client_subscribe" then handle_client_subscribe(params)
else
raise NotImplementedError.new("handle_#{params["type"].as_s.tr(".", "_")}")
end
rescue NotImplementedError
# Do nothing if the notification type does not have a handler.
ensure
if handled
context.response.respond_with_status(200)
else
# If handled is false, something bad happened.
context.response.respond_with_status(500)
end
end
Wrapping Up
Ruby provides potent tools for writing DSLs and for defining very dynamic code behavior with just a few lines of code.
Crystal does not provide most of these same tools -- in addition to having no #send
in Crystal, there is no #eval
, for example -- but Crystal's macro language is powerful. Its ability to utilize introspection -- such as accessing the methods defined on a class, among other things -- while generating code means that macros have tremendous power to do a lot of work for the programmer while hiding a lot of complexity in the process. The end results are often the same or similar to what one can do with Ruby.
Discussion (0)