Equivalent to Ruby's throw/catch (lightweight goto's)

I have been considering how a routing library like Roda might work in Crystal. One oddity of the library is that it uses throw to return from a request handler block (here’s an example). If you aren’t familiar with Ruby’s throw/catch opposed to the normal raise/rescue, here’s a stackoverflow question that might be helpful: What is the difference between Raising Exceptions vs Throwing Exceptions in Ruby? - Stack Overflow

Does anyone know if there’s something roughly equivalent in Crystal?

The api is something like:

class MyApp < Roda
  route do |r|
    r.get("/users") do
      json User.all
    end

    # throw is called after these blocks are run
    # so the request doesn't run more code
    # than it needs to

    r.get("/users/:id") do |id|
      json User.find(id)
    end
  end
end
1 Like

The closest would be to use unions and return a given type. However, you have to handle it for each method call if they are nested.
For certain cases, passing a Proc could do it too.

Can you use exceptions instead? It seems throw/catch is almost the same as exceptions except it doesn’t involve exceptions?

Yea, they are roughly equivalent in their usage but, in Ruby, they don’t deal with stacktraces and other error-related things which makes them much lighter. I will most likely go forward with raising errors for now.

I’m a bit confused by your response. Maybe we are both misunderstanding? In a typical routing library (kemal for example) when you declare a route it builds a list of routes and their connected block of code to run at compile time or at least before it handles a request. This is meant to all run when a request is given, which makes it different, hence the need for a throw.

I’m not sure I follow. If Roda knows of each route and the related block to handle a given request, what extra code would run? Or are you saying that without the throw Roda would check other defined routes, i.e. /users/:id even if the path was /users and matched the first route block?

1 Like

I guess throw/catch could be implemented similarly to exceptions in Crystal, maybe, but without unwinding the stack. I’m not sure… But I do see its use case

As @Blacksmoke16 said, not sure to follow. Usually routers store the routes as Procs (in lists, and/or hashes, or other data structures). When a request is made, the corresponding Proc matching the path is executed - that’s it.

Maybe Exceptions can be used similarly, but it is not a good idea to use them as a regular basis. Exceptions, as their name says, should occur exceptionally (both for performance reasons and code clarity).

Crystal could take some inspiration from Swift, where errors don’t involve stack unwinding.

1 Like

Both cuba and roda execute blocks. If they don’t halt the execution, blocks defined later on are executed.

I wouldn’t suggest using return values, that’s basically suggesting using an enitrely different paradigm.

1 Like

Ok, maybe that’s common in the Ruby world? In the Crystal ecosystem, all the routers I’ve seen work as I described above: find an object corresponding to a route path. A Proc (or more) can be stored inside, and possibly other things.

Here the answer can be there are other ways to design differently the router to achieve the same goal.

@matthewmcgarvey I suggest you to see how other routing shards are implemented Routing on Shardbox

@j8r I don’t think it matters what other routers in Crystal do. He wants to do it the way Cuba and Roda do it and I think that’s perfectly valid.

2 Likes

It is possible to have the same high-level API, but with an other internal implementation.

throw is not present in Crystal, so it must be done differently. I believe seeing how other routers have achieved it can help.

@j8r yes, I get that it’s different than the other routers. That and a couple other reasons are why I’m interested in it. I apologize for giving a bad example. I dont even think what I wrote is valid roda so that definitely caused some confusion.

I think it’s pretty firmly hammered into most of us that using gotos is bad, so I’d love to know about more scenarios where this throw/catch dynamic could be useful.

I guess we could almost implement this entirely in Crystal. For example:

require "benchmark"

class ThrowError(T) < Exception
  getter value : T

  def initialize(@value : T)
  end
end

def catch(value : T) forall T
  begin
    yield
  rescue error : ThrowError(T)
    if error.value == value
      return
    else
      raise error
    end
  end
end

def throw(value)
  raise ThrowError.new(value)
end

# This is similar to `raise(Exception)` except that it doesn't compute a callstack.
def raise(exception : ThrowError(T)) : NoReturn forall T
  unwind_ex = Pointer(LibUnwind::Exception).malloc
  unwind_ex.value.exception_class = LibC::SizeT.zero
  unwind_ex.value.exception_cleanup = LibC::SizeT.zero
  unwind_ex.value.exception_object = exception.as(Void*)
  unwind_ex.value.exception_type_id = exception.crystal_type_id
  __crystal_raise(unwind_ex)
end

Benchmark.ips do |x|
  x.report("raise/rescue") do
    begin
      raise "OH NO!"
    rescue
    end
  end

  x.report("throw/catch") do
    catch(:foo) do
      throw :foo
    end
  end
end

Results are:

raise/rescue 680.32k (  1.47µs) (± 2.33%)  256B/op   1.66× slower
 throw/catch   1.13M (887.09ns) (± 3.23%)  128B/op        fastest

The only thing else we’d need to do is to avoid rescuing ThrowError when you do rescue e or rescue e : Exception. Maybe try to allocate even less memory, but I’m not sure it’s possible.

But then, I’m not sure this is all worth it. If it’s only going to be twice as fast, maybe it doesn’t make much difference.

2 Likes

An alternative solution would be to avoid setting callstack on a throw exception. If CallStack hand an initializer that doesn’t unwind and just creates an empty stack, we could just assign that value to the special exception type’s callstack property and the existing ::raise would work.

Having a root exception type that is not rescued by default would also help Gracefully stopping fibers · Issue #3561 · crystal-lang/crystal · GitHub, so I’d really like to see that.

Then it’s really trivial to implement throw/catch in user code without having to touch any runtime details. We could consider adding it to stdlib or it can easily be implemented externally.

5 Likes

I’ve also thought about throw/catch in Crystal for this exact same reason — I’ve preferred Roda for small Ruby APIs for a long time. The result of that was this mixin (gist includes an example of usage). I’m currently using it in 2 production apps and it works pretty well.

It doesn’t unwind the stack at all, instead it just doesn’t enter any other blocks if the request has been handled by a “terminal” block (basically, anything other than r.on flips that bit on the request).

One thing that led to this implementation, though, was realizing that throw is not actually that impactful for this use case in Crystal. Roda uses throw to throw a value back up the stack (not simply to avoid calling other route matchers) because of Rack’s interface being based on the return value of call(env), but Crystal’s HTTP::Server::Context gives you the request and response to work with as IO objects. So you can, for example, serialize DB records directly to JSON without having multiple representations in memory all at the same time (all the DB records, the intermediate hashes/arrays, and the JSON string output) as you would need to do with a Rack-based framework.

For rendering HTML content within a layout, I typically end up doing stuff like this (render calls are this macro to render an ECR template to the response):

  def call(context)
    route context do |r, response|
      render "app_header"

      # do routing in between to render content

      render "app_footer"
    end
  end
1 Like

I’m working on it here GitHub - matthewmcgarvey/croda: (Experimental) Crystal web routing based on Ruby's Roda library

In my latest commit Giving up on Roda compatibility and adding support for path variables · matthewmcgarvey/croda@e4751e3 · GitHub I’m giving up on trying to exactly copy Roda’s API. If I constrain all those basic methods (r.on, r.is, r.get, etc.) to only take one argument I can actually get it working similarly

class App < Croda
  route do |r|
    r.on "posts" do
      r.get Int32 do |post_id|
        # GET /posts/:post_id
      end
    end
  end
end
2 Likes