The Relicans

The Relicans is a community of amazing relicans

Recursively creating communities of developers; learn, create, teach, repeat.

Create new account Log in
loading...

Dynamic Dispatch, Ruby vs. Crystal

Kirk Haines
I've had a 30+ year career across spectrum from System and DevOps to SRE type work to (a lot of) Software Engineering, and I love helping others to grow in their careers.
・6 min read

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)