← Contents

Chapter 15

Dispatch

Raku has more dispatch than most languages: named subs, multiple dispatch on argument types, method dispatch on an invocant, private methods, meta-object calls, and a re-dispatch protocol (callsame, nextsame, callwith, nextwith, samewith) that lets a routine hand control to the next candidate.

Raku++ implements them as three distinct mechanisms that meet at the edges, and knowing which one is in play explains most of the surprises.

Named calls

evalCall resolves every named call the same way:

// src/Interpreter.cpp — evalCall, abridged
if (Value* f = tctx_.cur->find("&" + c->name))
    return callCallable(*f, std::move(args), &c->args, false, …);
…
auto it = builtins_.find(c->name);        // built-in fallback

The environment first, walking the parent chain; the builtin table second. That order is the whole rule, and it has three consequences worth spelling out.

A lexical my sub shadows a builtin. That is Raku's semantics and it comes for free.

A module's exported sub also shadows a builtin, because the loader copies it into the global environment, which is the last link of the chain (Chapter 29).

A compiled program must reproduce this order, which it cannot do by resolving names against the builtin table at compile time. That is a real bug that happened — --exe printed the built-in val's answer where the interpreter called a module's exported val — and Chapter 27 describes the fix.

The argument expressions are passed alongside the values (&c->args) so that is rw write-back can re-resolve them.

Multiple dispatch

A multi sub or multi method is one Callable with isMultiDispatcher set and a candidates vector; each multi declaration pushes its Code onto it. A proto declares the group without being a candidate.

At call time, scoreCandidate scores every candidate against the actual arguments, returning -1 for "does not apply" or a non-negative specificity:

// src/Interpreter.cpp — scoreCandidate, per positional parameter
if (subsets_.count(p->type)) {
    if (!subsetMatches(p->type, pos[i])) return -1;
    score += 2;
} else if (!typeMatchesArg(pos[i], p->type)) return -1;
if (p->defConstraint == 1 && !isDefined(pos[i])) return -1;   // :D
if (p->defConstraint == 2 &&  isDefined(pos[i])) return -1;   // :U
if (p->defConstraint) score++;
if (!p->type.empty() && p->type != "Any" && p->type != "Mu") {
    score++;                                    // constrained beats not
    if (p->type == pos[i].typeName()) score++;  // exact beats supertype
}

Arity gates run first — too few required arguments, or too many for a non-slurpy signature, is an immediate -1. Literal parameters (multi fact(0)) and sub-signature destructuring are treated as very specific. A required named parameter that was not supplied is -1.

The winner is the highest score:

const Value* best = nullptr; int bestScore = -1;
for (auto& cand : c.candidates) {
    int s = scoreCandidate(cand, args);
    if (s > bestScore) { bestScore = s; best = &cand; }   // strict >
}
if (!best || bestScore < 0) throw /* X::Multi::NoMatch */;

Two honest notes. The comparison is a strict >, so on equal scores the first-declared candidate wins and there is no X::Multi::Ambiguous diagnostic. And a genuine no-match does throw X::Multi::NoMatch, with the candidate list in the message.

Subsets

A subset is a refinement type that participates in dispatch:

// src/Interpreter.h
struct SubsetInfo { std::string base; const Expr* where = nullptr; };
std::unordered_map<std::string, SubsetInfo> subsets_;
bool subsetMatches(const std::string& name, const Value& v, int depth = 0);

subsetMatches checks the base type and then evaluates the where expression with the value as the topic. The depth parameter is a recursion guard, because a subset can be defined in terms of another subset and nothing prevents a cycle.

A subset match scores +2, above a plain nominal match, which is what makes multi f(Even $n) beat multi f(Int $n).

Method dispatch: two worlds

An Int, a Str or an Array is a native Value — it has no ClassInfo and no method table. A user class instance is a VT::Object with both. So there are two dispatch worlds.

User objects go through ClassInfo::findMethod, which is the method resolution order:

// src/Value.h
Value* findMethod(const std::string& m, ClassInfo** owner) {
    auto it = methods.find(m);
    if (it != methods.end()) { if (owner) *owner = this; return &it->second; }
    if (parent) { if (Value* r = parent->findMethod(m, owner)) return r; }
    for (auto& p : extraParents) if (p)
        { if (Value* r = p->findMethod(m, owner)) return r; }
    if (owner) *owner = nullptr;
    return nullptr;
}

Own methods, then the first parent recursively, then each extra parent. It is a depth-first walk, not C3 linearisation, and for the diamond hierarchies where the two differ it can pick a different method than Rakudo. The owner out-parameter reports which class the method was found in, so re-dispatch can resume from that class's parent.

Built-in values go through methodCall, a very long cascade of branches keyed on the method name and, inside each branch, on the invocant's tag:

if (m == "uc")       return Value::str(mapCase(inv.toStr(), true, 0));
if (m == "chars")    return Value::integer(graphemeCount(inv.toStr()));
if (m == "is-prime") { /* Miller–Rabin on inv.toInt() */ }
// … some 1,640 more …

The C++ if-ladder is the built-in method set. That is the pragmatic counterpart to the fat Value: since every native value is the same struct, its methods are one dispatch function rather than per-type classes.

The ladder is split across four files as ordered segments (Chapter 2), each returning std::optional<Value>. The name is wrapped in an MName at the top so the comparisons are integer compares (Chapter 9).

What runs before the ladder

Two things are consulted first, and both are bridges between the two worlds.

Junction autothreading. If the invocant is a junction, a small allow-list of methods (Bool, gist, new, …) act on the whole junction; everything else autothreads, returning a junction of the per-eigenstate results.

augment on a built-in type. Methods a program adds to a built-in with augment class Int { … } are parked in a map of maps and consulted before the native branches, so they can override built-ins:

// src/Builtins.cpp — before the native branches
if (!builtinExt_.empty() && inv.t != VT::Object) {
    std::string tn = inv.t == VT::Type ? inv.s : inv.typeName();
    if (Value* f = lookup(tn))
        return invokeMethod(*f, inv, std::move(args), rwArgs);
    for (const std::string& anc : typeAncestry(tn))
        if (anc != tn) if (Value* f = lookup(anc))
            return invokeMethod(*f, inv, …);
}

The ancestry walk is what makes augment class Cool { … } reach an Int and a Str. The guard on builtinExt_.empty() is why a program that never augments anything pays one branch.

This is also why two of the inline built-in fast paths in Chapter 27 are builtinExt_-guarded: an inlined abs must stop inlining the moment a program augments Int.

Re-dispatch

callsame, nextsame, callwith, nextwith and samewith need to know what the next candidate is, and that differs by context: the next-less-specific multi candidate, the same method on the owning class's parent, or a built-in that a user method shadowed.

// src/Interpreter.h
struct RedispatchCtx {
    std::function<Value(ValueList)> next;      // callsame / nextsame / …with
    std::function<Value(ValueList)> restart;   // samewith
    ValueList sameArgs;
    bool lastcall = false; bool fromChain = false;
};
static thread_local std::vector<RedispatchCtx> redispatchStack_;

Each dispatch site that can be re-entered pushes a context knowing how to invoke the next thing, and the original arguments for the *same variants. invokeMethodChain is the method version: it invokes a method found from a given class and pushes a context that resumes from that class's parent, recursively.

One field guards a subtle case:

// src/Interpreter.h — ExecContext
size_t redispatchFloor = 0;   // frames below this are another routine's

Without it, a callsame inside a routine could reach a re-dispatch context belonging to a caller, and dispatch into something unrelated.

The stack is static thread_local for the reason given in Chapter 13: as a plain member it was written by every dispatching call on every thread.

Private methods, qualified calls, indirect calls

The MethodCall node carries the variants as flags:

WrittenFlagMeaning
$o.mordinary dispatch
$o.?mmaybereturn Nil rather than dying when absent
$o!mbangprivate method, reachable only through self!m
$o.Class::mmethodQualdispatch to a specific class's method, past overrides
$o."$name"()methodExprthe name is computed at run time
$o.=mmutatecall and assign back to the invocant
$o>>.mhyperapply to each element
$o.^mmetaa meta-object call

.^ calls go to the meta-object. Most are answered directly — .^name, .^methods, .^attributes, .^parents, .^roles — but .HOW must return a persistent object, because T.HOW does SomeRole mixins have to stick. So ClassInfo carries its meta-object rather than building one per call:

// src/Value.h — ClassInfo
Value howObj;   // the persistent .HOW metaobject

&builtin as a value

&say must be a Callable value, and &dir.wrap({…}) must mutate the Callable the call path consults — that is exactly how File::Find's test suite mocks dir. So a reference to a builtin is memoised:

// src/Interpreter.h
std::map<std::string, Value> builtinRefs_;

populated lazily, and empty for programs that never take a builtin reference — which keeps the check on the call path free.

Where the time goes

After the two fixes in Chapters 9 and 10, a method call on a built-in costs roughly three times what Rakudo charges, down from about twenty. The profile of for ^6000000 { "ab".uc }:

share
heap allocate and free31%
Value copy and destroy11%
method-name comparison8.5%
the dispatch function's own body6.1%

The 42% in allocation and value churn is the remaining target, and it has a known shape: both methodCall and methodCallInner take their invocant and argument list by value, so every call copies a Value — with its eleven shared_ptr members — and heap-allocates a ValueList.

Switching both to const& is mechanically small: eighteen compile errors, each either "make a local copy here" or "let this helper take const&". It has not been done because it trades away the accidental safety that copying provides against a callee mutating the container its own invocant lives in. That wants an aliasing audit of 178 call sites, not a quick pass.

Honest limitations