ADR 0006: Unified Method Dispatch with Hierarchy Walking
Status
Implemented (2026-02-08) — Epic BT-278
Context
Beamtalk currently has three different dispatch mechanisms for method calls:
Current State
-
Compiled Classes (Counter, user-defined actors)
- Each class generates its own
dispatch/4function with hardcoded case clauses - Only dispatches methods defined in that class
- Falls through to
doesNotUnderstand:args:if method not found - No hierarchy walking - doesn't check superclass methods
- Each class generates its own
-
Dynamic Classes (created at runtime via
create_subclass)- Methods stored as closures in
__methods__field - Dispatch via
beamtalk_dynamic_object:dispatch/4usingapply/2 - Hierarchy support depends on if
__methods__includes inherited closures
- Methods stored as closures in
-
Primitives (Integer, String, Boolean)
- Compiled to direct Erlang function calls
- No gen_server, no dispatch function
- Sealed (no subclassing), so no hierarchy walking needed
The Problems
Problem 1: Incomplete hierarchy walking
Counter methods %=> [increment, decrement, getValue]
% Missing: class, respondsTo:, perform:, etc. from Object
The Counter class only lists methods defined in Counter, not inherited methods from Actor or Object.
Problem 2: Inconsistent dispatch behavior
Compiled classes:
%% Counter dispatch - NO hierarchy walking
'dispatch'/4 = fun (Selector, Args, Self, State) ->
case Selector of
<'increment'> -> ...
<'getValue'> -> ...
<'class'> -> ... %% Hardcoded in every class!
<'respondsTo:'> -> ... %% Hardcoded in every class!
<OtherSelector> -> call doesNotUnderstand handler
end
Dynamic classes:
%% Dynamic dispatch - maybe walks hierarchy?
dispatch(Selector, Args, Self, State) ->
Methods = maps:get('__methods__', State),
case maps:find(Selector, Methods) of
{ok, Fun} -> apply(Fun, [Self, Args, State]);
error -> ... %% What happens here? Walk to super?
end
Problem 3: Code duplication
Common reflection methods (class, respondsTo:, instVarNames) are code-generated into every class's dispatch function. (Note: instVarNames/instVarAt:/instVarAt:put: were later renamed to fieldNames/fieldAt:/fieldAt:put: — see ADR 0035.) This bloats generated code and makes changes difficult.
Problem 4: Cannot add methods to Object at runtime
If we want to add a method to Object (via hot code reload or extension), every compiled class's dispatch function would need to be regenerated. This breaks the live coding experience.
Design Constraints
- Performance: Method dispatch is on the hot path - must be fast
- Distribution: Objects may be on remote nodes - can't rely on local ETS
- Separate compilation: Each module compiles independently
- Hot code reload: Must support adding/changing methods at runtime
- Erlang interop: Must work seamlessly with existing Erlang gen_servers
Decision
Implement a two-tier unified dispatch mechanism with runtime hierarchy walking.
Fast Path Policy (Evolvable Boundary)
- Fast path: selectors known at compile time to be defined on the current class (local method table).
- Optional inline core: a small, stable set of Object/Actor methods may be inlined (e.g.,
class,respondsTo:) if we accept recompilation when they change. - Everything else: use runtime hierarchy lookup before DNU.
- Super sends: runtime lookup must start at the immediate superclass (avoid re-walking the current class).
- Perform:
perform:always routes through the same runtime lookup path as normal sends.
This boundary is intentionally shiftable over time to balance performance and flexibility.
Method Combination Ordering
- Before: run from superclass -> subclass (setup flows down).
- Primary: run only the most specific method (no chain).
- After: run from subclass -> superclass (cleanup flows up).
This ordering keeps method combinations predictable and mirrors common Smalltalk/Flavors expectations.
Before/after methods are collected from the entire superclass chain, not just the defining class. The dispatch service walks the full chain, collects all before/after funs for the selector, and runs them in the specified order around the primary method.
Method Invocation Strategy
When the hierarchy walk finds a method on a superclass, invocation depends on class type:
- Compiled class: call
Module:dispatch(Selector, Args, Self, State)— the compiled module already has the method body in itsdispatch/4function. - Dynamic class: call
apply(Fun, [Self, Args, State])— the closure is stored in the class process'sdynamic_methodsmap.
The class process knows the module name (beamtalk_object_class:module_name/1), so the dispatch service can resolve which strategy to use.
Value Types
User-defined value types (Object subclasses like Point, Color) use the same dispatch pattern as actors — codegen generates a dispatch/4 function with local fast path + runtime fallback. The only difference is entry point: value types are called directly (no gen_server), actors go through gen_server:call. The hierarchy walk is identical for both.
Bootstrap Ordering
Object and Actor classes must be registered in beamtalk_bootstrap before any user modules load. The bootstrap sequence is:
- Register ProtoObject (no superclass)
- Register Object (superclass: ProtoObject)
- Register Actor (superclass: Object)
- Load user modules (can now hierarchy-walk to Object/Actor)
Reflection Method Implementations
Core reflection methods (class, respondsTo:, instVarNames, perform:, instVarAt:, instVarAt:put:) are implemented once in the Object class's compiled module (e.g., beamtalk_object.erl). They are found via normal hierarchy walking — no need to duplicate them in every class's codegen.
Note (ADR 0035):
instVarNames,instVarAt:, andinstVarAt:put:were subsequently renamed tofieldNames,fieldAt:, andfieldAt:put:. See ADR 0035.
Domain Service: beamtalk_dispatch
A dedicated beamtalk_dispatch module serves as the dispatch domain service (DDD). This replaces the existing beamtalk_object_class:super_dispatch/3 with a cleaner interface:
%% Core dispatch entry point (hierarchy walk)
beamtalk_dispatch:lookup(Selector, Args, Self, State, CurrentClass)
-> {reply, Result, NewState} | {error, not_found}
%% Super send (starts at immediate superclass)
beamtalk_dispatch:super(Selector, Args, Self, State, CurrentClass)
-> {reply, Result, NewState} | {error, not_found}
%% Method combinations (collects before/after from chain)
beamtalk_dispatch:invoke_with_combinations(Selector, Args, Self, State, CurrentClass)
-> {reply, Result, NewState}
Responsibilities are separated (DDD):
beamtalk_dispatch: method lookup, hierarchy walking, method combinations, invocationbeamtalk_object_class: class registry, method storage, metadatabeamtalk_actor: actor lifecycle (spawn, make_self, supervision)
Static Class Hierarchy (Compile-Time Model)
Design principle: The hierarchy walking algorithm is specified abstractly and implemented twice — once over the runtime class registry (Erlang), once over static AST/source analysis (Rust) — with shared test cases that verify both produce identical results for the same hierarchy.
A ClassHierarchy structure is built during semantic analysis from parsed class definitions. It serves three consumers:
| Consumer | Uses it for |
|---|---|
| Codegen | Fast path decisions, method combination pre-collection, selector validation, arity checking |
| Language Service | Completions (including inherited methods), go-to-definition across hierarchy, hover ("inherited from Object"), edit-time diagnostics |
| Runtime | Authoritative fallback for dynamic cases (hot reload, dynamic classes, perform:) |
What static analysis enables at compile time:
- Smarter fast path selection — compiler knows the full inherited method set, can inline sealed/stable superclass methods
- Selector validation — warn if a method won't resolve in any superclass (likely DNU)
- Method combination pre-collection — pre-compute before/after methods for a selector from the full chain
- Arity checking — verify argument counts against inherited method signatures
- Sealed class enforcement — reject
Integer subclass: MyIntat compile time
Implementation approach: Build ClassHierarchy in crates/beamtalk-core/src/semantic_analysis/ as a map from class name → {superclass, methods, state, sealed}. ~200-300 lines of Rust for the minimal version. The existing NameResolver already manages scopes; this extends it with class-level knowledge.
Critical invariant: The static model and the runtime dispatch must agree on method resolution order and lookup semantics. Shared E2E tests verify this (e.g., compile a hierarchy, run it, confirm static predictions match runtime behavior).
Architecture
User code: counter increment
↓
gen_server:call(CounterPid, {increment, []})
↓
counter:handle_call({increment, []}, From, State)
↓
counter:safe_dispatch(increment, [], State)
↓
counter:dispatch(increment, [], Self, State) ← LOCAL LOOKUP (fast path)
↓ (not found locally)
↓
beamtalk_dispatch:super(increment, [], Self, State, 'Counter') ← HIERARCHY WALK
↓
Look up Actor class in registry
↓
Check Actor's method table (ETS or class process)
↓ (not found in Actor)
↓
Recurse to Actor's superclass (Object)
↓
Check Object's method table
↓ (found!)
↓
Invoke Object's method implementation
DDD Context and Responsibilities
Bounded contexts:
- Compilation Context: codegen emits fast-path cases + a single runtime fallback call (no runtime knowledge).
- Runtime Context: method lookup and hierarchy walking live in a dispatch domain service (
beamtalk_dispatch) and the class registry (beamtalk_object_class). - Language Service Context: completions/reflection reuse the same hierarchy lookup to avoid drift.
Ubiquitous language: selector, method table, superclass chain, class registry, dispatch, DNU. Avoid generic “utils”; prefer domain service names in runtime.
Implementation Strategy
Phase 1: Runtime Hierarchy Walking (Immediate)
Codegen changes:
- Keep fast path for local methods only (no change to performance)
- When method not found, call runtime helper instead of immediate DNU:
'dispatch'/4 = fun (Selector, Args, Self, State) -> case Selector of <'increment'> -> ... %% Fast path - local method <'getValue'> -> ... %% Fast path - local method <OtherSelector> when 'true' -> %% NEW: Try hierarchy walk before DNU case beamtalk_dispatch:super(OtherSelector, Args, Self, State, 'Counter') of {'reply', Result, NewState} -> {'reply', Result, NewState}; {error, {not_found, _}} -> %% NOW try doesNotUnderstand ... end end
Runtime helper beamtalk_dispatch:super/5:
super(Selector, Args, Self, State, CurrentClass) ->
%% Look up superclass
case whereis_class(CurrentClass) of
undefined -> {error, {class_not_found, CurrentClass}};
ClassPid ->
case beamtalk_object_class:superclass(ClassPid) of
none -> {error, {not_found, Selector}};
SuperName ->
%% Check if super has this method
case check_class_has_method(SuperName, Selector) of
true ->
%% Invoke super's implementation
invoke_super_method(SuperName, Selector, Args, State);
false ->
%% Recurse up the chain
super(Selector, Args, Self, State, SuperName)
end
end
end.
Benefits:
- ✅ Fast path unchanged (local methods still inline)
- ✅ Supports hot code reload (hierarchy walked at runtime)
- ✅ Works with dynamic classes (same runtime helper)
- ✅ No code duplication (reflection methods in Object once)
Trade-offs:
- ❌ Inherited method calls are slower (ETS lookup + recursion)
- ✅ But local methods (99% of calls) are unaffected
Phase 2: Class Method Table Optimization (Future)
Store flattened method table in class process:
%% In beamtalk_object_class state:
-record(class_state, {
...
instance_methods = #{}, %% Methods defined in this class
flattened_methods = #{}, %% All methods including inherited (cached)
...
}).
When class registers:
- Walk to Object, collect all methods
- Build flattened table (child overrides parent)
- Cache for fast lookup
Benefits:
- ✅ Faster inherited method lookup (no recursion)
- ✅
Counter methodsreturns complete list
Trade-offs:
- ❌ More memory (duplicated method info)
- ❌ Invalidation on hot reload (must rebuild flattened tables)
Phase 3: Compile-Time Inlining (Future)
For sealed hierarchies (Object → Actor is unlikely to change), inline inherited methods at compile time:
'dispatch'/4 = fun (Selector, Args, Self, State) ->
case Selector of
%% Local methods
<'increment'> -> ...
%% Inherited from Object (inlined at compile time)
<'class'> -> ...
<'respondsTo:'> -> ...
%% Runtime hierarchy walk for unknown
<OtherSelector> -> beamtalk_dispatch:super(...)
end
Benefits:
- ✅ No runtime lookup for common inherited methods
- ✅ Fast path for
class,respondsTo:, etc.
Trade-offs:
- ❌ Recompilation needed if Object changes (acceptable for core classes)
Method Resolution Order (MRO)
Beamtalk uses simple depth-first left-to-right traversal:
Counter → Actor → Object → ProtoObject
No multiple inheritance, so no C3 linearization needed.
Where Methods Are Stored
| Class Type | Method Definitions | Flattened Table | Dispatch |
|---|---|---|---|
| Compiled | Class process (instance_methods) | Class process (flattened_methods) | Generated dispatch/4 + runtime fallback |
| Dynamic | Instance state (__methods__) | N/A (computed on demand) | beamtalk_dynamic_object:dispatch/4 |
| Primitive | Compiled Erlang modules | N/A (sealed, no hierarchy) | Direct function calls (no dispatch) |
Reflection API
Class methods (complete hierarchy):
Counter methods
%=> [increment, decrement, getValue, %% Defined in Counter
spawn, spawnWith:, %% Defined in Actor
class, respondsTo:, perform:, ...] %% Defined in Object
Implementation:
%% In beamtalk_object_class:
handle_call(methods, _From, #class_state{flattened_methods = Flattened} = State) ->
{reply, maps:keys(Flattened), State}.
Insights and Gotchas
Insights
- Runtime lookup is the source of truth: codegen fast paths must be behaviorally identical to the runtime hierarchy walk.
- Fast paths are shiftable: adding/removing selectors from the fast path should never change observable lookup order.
- Uniform reflection: completions,
methods, andrespondsTo:must reuse the same hierarchy walk to avoid drift. - Hot reload compatibility: runtime lookup preserves live coding; flattening or inlining must include invalidation/regen hooks.
Gotchas
- DNU ordering: always attempt runtime hierarchy lookup before
doesNotUnderstand:args:. - Dynamic classes: ensure dynamic dispatch uses the same hierarchy rules (even if methods are stored as closures).
- Remote nodes: superclass lookup must work across distributed nodes (no local-only assumptions).
- Cache invalidation: flattened tables and lookup caches must invalidate on method additions, removals, or class reloads.
- Error shape: missing methods should return structured
#beamtalk_error{}consistently across dispatch paths.
Consequences
Positive
- Correct Smalltalk semantics - Hierarchy walking works as expected
- Unified dispatch model - All object types use same resolution algorithm
- Efficient fast path - Local methods unaffected (still inline case clauses)
- Hot reload friendly - Runtime lookup enables method addition to superclasses
- Less code generation - No need to duplicate reflection methods in every class
- Correct reflection -
Counter methodsreturns complete list
Negative
- Inherited method overhead - First call to inherited method requires ETS lookup and recursion
- Implementation complexity - Need runtime helper plus codegen changes
- Memory overhead (Phase 2) - Flattened method tables duplicate method info
- Invalidation complexity (Phase 2) - Hot reload must rebuild flattened tables
Neutral
- Performance characteristics change - Fast path same, inherited path slower but correct
- Debugging - Easier to trace (hierarchy walk explicit in logs)
Implementation Plan
Phase 1a: Static ClassHierarchy
- Build
ClassHierarchystruct incrates/beamtalk-core/src/semantic_analysis/ - Populate from parsed class definitions (name → superclass, methods, state, sealed)
- Expose hierarchy queries:
all_methods(class),resolves_selector(class, selector),superclass_chain(class) - Wire into codegen so it can query inherited methods
- Wire into LSP completion/hover providers
Acceptance criteria:
- [ ]
ClassHierarchybuilt from parsed AST - [ ] Codegen can query full method set (local + inherited)
- [ ] LSP completions include inherited methods
- [ ] Shared test cases verify static model matches expected MRO
Phase 1b: Runtime Dispatch Service
- Create
beamtalk_dispatchmodule (hierarchy walking, method invocation, method combinations) <<<<<<< HEAD - Bootstrap Object with hand-written
beamtalk_object.erlruntime module (reflection methods:class,respondsTo:,instVarNames,perform:,instVarAt:,instVarAt:put:— later renamed per ADR 0035) ======= - Bootstrap Object with hand-written
beamtalk_object.erlruntime module (reflection methods:class,respondsTo:,instVarNames,perform:,instVarAt:,instVarAt:put:) — note:instVarNames/instVarAt:/instVarAt:put:renamed tofieldNames/fieldAt:/fieldAt:put:in ADR 0035
origin/main
- Modify codegen: local fast path +
beamtalk_dispatch:lookup/5fallback before DNU - Update
beamtalk_object_class:methods/1to walk hierarchy - Remove duplicated reflection methods from per-class codegen
- Add tests for inherited method dispatch + method combinations
Bootstrap strategy for Object/Actor: Hand-written Erlang modules (beamtalk_object.erl, beamtalk_actor.erl) registered during beamtalk_bootstrap — consistent with how primitives (beamtalk_integer.erl, beamtalk_string.erl, etc.) are already implemented. ADR 0007 will explore a Rust core-style approach for compiling the stdlib from Beamtalk source.
Acceptance criteria:
- [ ]
Counter incrementworks (local method - fast path) - [ ]
Counter classworks (inherited from Object - runtime fallback) - [ ]
Counter methodsincludesincrement+class+respondsTo:+ ... - [ ]
Counter undefinedMethodcalls DNU handler - [ ]
perform:routes through same dispatch path - [ ]
supersends start at immediate superclass - [ ] Before/after method combinations fire in correct order across chain
- [ ] Reflection/completions reuse the hierarchy walk (no divergence between dispatch and tooling)
- [ ] Structured
#beamtalk_error{}for all dispatch failures - [ ] Performance: local methods ±0%, inherited methods acceptable
Phase 2: Flattened Method Tables (Future)
- Add
flattened_methodsfield to class state - Build flattened table during class registration
- Update
beamtalk_dispatch:lookupto use flattened table - Add invalidation on hot reload
- Benchmark memory and performance
Phase 3: Compile-Time Inlining (Future)
- Use static
ClassHierarchyto identify stable inherited methods - Inline common inherited methods in dispatch (e.g.,
class,respondsTo:) - Keep runtime fallback for unknown/dynamic methods
References
-
Related files:
crates/beamtalk-core/src/codegen/core_erlang/gen_server.rs- Dispatch codegenruntime/src/beamtalk_object_class.erl- Class registry and method storageruntime/src/beamtalk_dynamic_object.erl- Dynamic class dispatchruntime/src/beamtalk_actor.erl- Actor support functions
-
Linear issues:
- BT-278: Epic: Unified Method Dispatch (ADR 0006)
- BT-279: Build static ClassHierarchy in semantic analysis (Phase 1a)
- BT-281: Create beamtalk_dispatch runtime module (Phase 1b)
- BT-282: Bootstrap beamtalk_object.erl with shared reflection methods (Phase 1b)
- BT-283: Flattened method tables for O(1) inherited dispatch (Phase 2)
- BT-162: Epic: BEAM Object Model Implementation (ADR 0005) — parent epic
- BT-216: Optimize message dispatch: sync for value types, async for actors
-
Related ADRs:
- ADR 0005: BEAM Object Model - Pragmatic Hybrid Approach (establishes class hierarchy)
-
Prior art:
- LFE Flavors - Method combinations and hierarchy
- Smalltalk-80 - Method lookup algorithm (Chapter 13)
- Python MRO (C3) - For comparison (we don't need this complexity)
Open Questions
- Caching strategy: Should we cache method lookups in process dictionary? (Deferred to Phase 2)
- Remote dispatch: How does hierarchy walking work when the class registry is distributed? (Deferred —
pggroups work across nodes, but latency implications need benchmarking) - Performance target: What's acceptable overhead for inherited methods? (Deferred to Phase 1b benchmarking)
- Hot reload invalidation: Eager vs lazy rebuild of flattened tables? (Deferred to Phase 2)
Future ADRs
- ADR 0007: Stdlib Compilation and Primitive Specification — How to compile the stdlib from Beamtalk source, including specifying primitives and core classes (analogous to Rust's
corecrate). Replaces hand-written bootstrap modules with compiled Beamtalk using named intrinsic pragmas. Introduces three-kind class routing (Actor/Value Type/Primitive) driven by stdlib metadata instead ofis_actor_class()heuristic.