NilPy: dunders don't dispatch when the instance is reached through a container
- Type: bug (NilPy runtime dispatch) — Track N
- Opened: 2026-08-01, split out of [[bug-nilpy-comparison-dunders-not-dispatched]] while landing the operator-level ordering fix. That ticket's operator path is fixed; this is the OTHER route it exposed, and it is a different mechanism, not a leftover.
Measured boundary (2026-08-01, self-hosted binary at da085e9de + the
ordering-dunder fix)
class C:
def __init__(self, v): self.v = v
def __repr__(self): return "C" + str(self.v)
def __str__(self): return "S" + str(self.v)
def __lt__(self, o): return self.v < o.v
a, b = C(1), C(2)
print("direct str:", str(a)) # CPython S1 pxx S1 OK
print("direct lt:", a < b) # CPython True pxx True OK (just fixed)
print("in list:", [a, b]) # CPython [C1, C2] pxx "[, ]" WRONG, SILENT
print("str of elem:", str([a][0])) # CPython S1 pxx "" WRONG, SILENT
print(sorted([b, a])) # CPython [C1, C2] pxx TypeError
The split is exact and mechanical:
- Static class known at the call site → the compile-time dispatch in
parser.inc/pyparser.incfires and the dunder runs. Correct today. - Instance reached through a container (the element is a
Variantholding the handle, so the class is known only at RUN time) → no dispatch exists at all.pystr_ofyields an empty string andpyvar_gtfalls through topyvar_to_int("expected a number, got object").
Why this is the dangerous half
sorted()/min()/max() at least RAISE. print([obj]) does not — it prints
[, ], an empty string per element, which is a plausible-looking wrong value
rather than a failure. That is exactly the repo's expensive-bug shape: no crash,
no location, wrong output far from the cause.
Cause
There is no runtime dunder dispatch in pylib at all — verified by reading
pyvar_gt (compiler/builtin/pylib.pas, the sort/compare path) and by the
__repr__ measurement above. Both would need to look up a method BY NAME on an
arbitrary TObject at run time. The machinery to do that plausibly exists (RTTI
method reflection, VMT-8 — project_rtti_reflection_and_overload_landmines), but
nothing wires it to the dunder names.
Blocked on a design call
How container-element dispatch should work is a real fork (runtime RTTI lookup in pylib vs. compile-time monomorphisation vs. a per-class dunder vtable stamped into the handle), with different cost/generality trade-offs and a self-host blast radius that differs a lot between them. Filed as [[decide-nilpy-runtime-dunder-dispatch-mechanism]] — do NOT guess a direction here; pick it there first.
blocked-by: decide-nilpy-runtime-dunder-dispatch-mechanism
Gate (when it lands)
make test-nilpy + self-host byte-identical, and the boundary script above
diffed against CPython (tools/pydiff.py) — all five lines, not just the
sorted one.
2026-08-01 — consolidated: same root as two sibling tickets
Reproduced exactly as filed ([a, b] -> [, ], str([a][0]) -> empty,
sorted() -> TypeError, while direct str(a) and a < b are correct).
This is NOT a separate mechanism from the arithmetic case: dunder dispatch is
compile-time only, so ANY route that puts the instance in a Variant loses it —
containers here, a widened global elsewhere, a Variant parameter next. Folded
into [[feature-nilpy-runtime-dunder-dispatch-on-variants]] so the three do not
each grow a private runtime path, which is precisely how the not <x> family
came to need three separate fixes.
blocked-by: [[decide-nilpy-runtime-dunder-dispatch-strategy]]
2026-08-03 — dependency recorded in frontmatter
The body named the blocker (twice, under two different slugs) but no
blocked-by: edge existed. The edge is the broader of the two decide tickets,
[[decide-nilpy-runtime-dunder-dispatch-strategy]], per the supersession note on
[[decide-nilpy-runtime-dunder-dispatch-mechanism]]: one answer settles both, and
deciding them separately is the outcome that note warns against.