Dynamic Dispatch
send is the method that isn't supposed to compile. Everything else Rails does dynamically — associations, callbacks, scopes, the whole metaprogramming apparatus — is elaborate, but it resolves: given the whole program, you can follow each one to the concrete method it names. send is different in kind. obj.send(name) calls whatever method name holds at runtime, and if name is a value that arrived from outside, there is no method to resolve to at compile time. That's fatal for an ahead-of-time compiler, which has to know every call's target to lay down code for it — and it's a problem for every strict target, because reflective dispatch is exactly the thing whole-program analysis can't see through.
So the honest expectation, walking into a real application, is that send is where the transpile stops.
Then you look at what the application actually does with it. lobste.rs — a real, deployed Rails app, ten times the size of the blog demo I'd been measuring against — reaches send with a dynamic name in exactly six places, and those six fall into three shapes. And in every one of them, the name isn't computed from the outside world. It's drawn from a collection the author wrote down a few lines up. The reflection is real, but the name set is finite, local, and — the word that matters — provable.
Which turns the problem inside out. If you can prove the complete set of names a send can dispatch to, you don't need reflection at all. You can rewrite it into the dispatch table the programmer wrote in longhand.
What the rewrite does
Here is the richest of the three shapes, verbatim from lobste.rs' Story model — the as_json serializer, which walks a spec array and calls each entry as a method:
def as_json(options = {})
h = [
:short_id, :short_id_url, :created_at, :title, :url, :score, :score, :flags,
{ :comment_count => :comments_count },
{ :description => :markeddown_description },
{ :description_plain => :description },
:comments_url,
{ :submitter_user => :user },
{ :tags => self.tags.map(&:tag).sort },
]
if options && options[:with_comments]
h.push(:comments => options[:with_comments])
end
js = {}
h.each do |k|
if k.is_a?(Symbol)
js[k] = self.send(k)
elsif k.is_a?(Hash)
if k.values.first.is_a?(Symbol)
js[k.keys.first] = self.send(k.values.first)
else
js[k.keys.first] = k.values.first
end
end
end
js
end
There are two dynamic sends here — self.send(k) and self.send(k.values.first) — and neither name is knowable in isolation. But h is a local array literal, grown only by push, iterated with the block variable k. Every symbol it can hold is written right there. A pass can read the literal, collect the reachable names, and rewrite each send into a case over exactly those names:
js[k] = case k
when :short_id then self.short_id
when :short_id_url then self.short_id_url
when :created_at then self.created_at
when :title then self.title
when :url then self.url
when :score then self.score
when :flags then self.flags
when :comments_url then self.comments_url
else raise "dynamic send: method not in the statically enumerated set"
end
Two things about that else arm. First, it's not decoration — it's the whole reason the rewrite is sound rather than merely plausible. send raises NoMethodError when handed a name the object doesn't respond to; the wildcard arm preserves that exact failure mode. If a symbol ever reaches this case that the pass didn't foresee, the program raises — loudly, at the same place Ruby would have — instead of silently returning nil. The rewrite is allowed to be surprised; it is never allowed to be quietly wrong.
Second, note what didn't happen: self.tags.map(&:tag).sort and options[:with_comments] are hash values on the non-symbol path, not method names, and they contribute no arms. They're data on every real execution — a runtime symbol arriving from one of them would hit the raising arm, which is the correct thing to do with a case that genuinely can't be proven. CRuby runs the rewritten method identically: on the benchmark, /hottest's JSON comes out byte-for-byte the same (all 16369 of them) through the rewritten serializer.
Three disguises for a finite set
The as_json walk is the elaborate case. The other two show how differently a provable name set can hide.
The plainest is a literal array mapped directly — Search#to_url_params:
[:q, :what, :order].map { |p| "#{p}=#{CGI.escape(self.send(p).to_s)}" }
The block variable ranges over three symbols spelled out on the same line. Three arms, done.
The subtle one is FlaggedCommenters, where the name set never appears as a symbol at all:
length = time_interval(interval)
@period = length[:dur].send(length[:intv].downcase).ago
The method name is length[:intv].downcase — a string, lowercased, pulled out of a hash. To prove that set you have to follow time_interval into a helper whose every return is a hash literal, and whose :intv value is drawn from a frozen constant table:
TIME_INTERVALS = { "h" => "Hour", "d" => "Day", "w" => "Week",
"m" => "Month", "y" => "Year" }.freeze
So the string set is Hour, Day, Week, Month, Year; downcased, hour, day, week, month, year. The proof travels across a method boundary, through a hash literal, and into a frozen const — the set is never written in one place, but it is always there. And there's a bonus: all five names are ActiveSupport::Duration units, so the emitted arms call .hours, .days, .weeks, .months, .years, which the existing duration lowering then grounds into real Duration objects without ever knowing a send had been there. One honest pass feeds the next.
The discipline: prove the whole set, or don't touch it
The thing that makes this safe is not that it's clever. It's that it's cowardly in exactly the right direction.
A rewrite that misses even one name turns a legal dispatch into a raise — it would break a working program. So the pass is a must-analysis: it fires only when it can prove the collection is complete. If a tracked array is reassigned from something non-literal, passed off to a method that might mutate it, or grown in a way the pass doesn't recognize, the variable is "poisoned" and the site is left exactly as written. An unprovable send stays a send — which means it still won't compile to the ahead-of-time target, and that's the correct outcome. The pass never trades a runtime error for a compile-time convenience.
This is the honest boundary, and it's worth stating plainly for anyone whose code this would touch: a send whose name is genuinely external — record.send(params[:column]) — has no provable value set, so this pass declines it. On purpose. We ground what we can prove and refuse to guess the rest.
How far could this go?
That boundary is drawn around the argument: we make the dispatch finite by proving what the name can be. But there's a second, completely independent way to make a dispatch finite, and it doesn't care about the name at all.
Suppose you know the receiver's type and the call's arity. Then even if the name is params[:column] — fully external, unprovable — you can still enumerate the dispatch, because a receiver of a known type responds to a knowable, finite set of methods. You don't prove which name arrives; you enumerate what the receiver could respond to, filtered to the methods that take the arity being passed:
# record.send(params[:column]), record known to be a Story, zero args
case params[:column]
when "title" then record.title
when "score" then record.score
when "short_id" then record.short_id
# ... one arm per arity-0 method Story responds to
else raise NoMethodError, params[:column]
end
These are duals. The pass that shipped proves finiteness from the argument; this one would prove it from the receiver. They succeed and fail under opposite conditions — the argument approach needs a visible name set and doesn't care about the type; the receiver approach needs a closed type and doesn't care about the name. Which means the second one reaches exactly the case the first one refuses.
It only works because a transpiler like this is closed-world. In open Ruby you could never enumerate a type's methods — monkey-patching, define_method, and subclasses can add more at any moment. Compiling a whole program at once, you can know the complete method table — with a few honest strings attached. The receiver's type has to be effectively closed (a base-typed receiver whose real object is a subclass responds to more). The result type collapses to the union of every arm — which is to say, untyped — but that's not a defect, it's the truth: obj.send(a_runtime_name) genuinely does have an unknown return type, and only targets that tolerate a top type at that expression can compile it at all. And you'd generate one dispatch method per type rather than inline a three-hundred-arm case at every call site.
The interesting part is what it buys. It reproduces precisely the reachability send already grants — it widens no attack surface that record.send(params[:column]) didn't already open, and narrowing the arms to public methods would make it strictly safer than the raw reflection. The case it's really for is the invisible whitelist: a serializer doing record.send(permitted_column), where permitted_column is finite but drawn from config, or a database row, or a filter applied three frames up — a set the argument pass can't see. The receiver approach doesn't need to see it. It grounds the dispatch from the type and lets the runtime string flow through.
I haven't built it, because lobste.rs needs zero of it — its dynamic dispatch is entirely the provable-name kind. But it's the right thing to reach for the first time an application has a legitimate table-driven send the argument pass can't follow. The point of writing it down is that "you can't compile send" turns out to have two escape hatches, not one, and they cover different ground.
The payoff
With the argument pass in place, the ahead-of-time compiler now clears every send site in lobste.rs and moves on to the next frontier, and the CRuby output is byte-identical to before — nobody running the app would know the rewrite happened.
But the number isn't the point of this one. The point is the shape of the surprise. send looks like the place where Ruby's dynamism defeats a compiler outright, and in the general case it does. In real code it mostly doesn't — because real programmers, reaching for reflection, almost always draw the name from a set they wrote down a moment earlier. The compiler's job is to be patient enough to recover that set, and honest enough to keep its hands off when it can't.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.