An arithmetic dunder on self is pointer arithmetic
Filed 2026-08-26 while fixing [[bug-n-a-subscript-inside-a-base-class-skips-the-subclass-override]], from varying the shapes that ticket did not vary. Not the same defect — that one was static-vs-virtual dispatch and is fixed; this one never reaches the dunder at all.
Measured on pinned v376 and on HEAD after that fix (identical on both):
class A:
def __add__(self, o):
return 'A-add'
def probe(self):
return self + 1 # <-- never reaches __add__
a = A()
print(a + 1) # CPython: A-add pxx: A-add <- the arm that works
print(a.probe()) # CPython: A-add pxx: 127164370387056
| CPython | A-add / A-add |
| pxx (pinned v376 and HEAD) | A-add / 127164370387056 |
No inheritance is involved. One class, one method, and the same expression
answers correctly through a named receiver and wrongly through self. That
rules out the dispatch mechanism and points at operand typing: the additive
arm in pasparser_expr.inc (~8854) reaches the dunder via
PyCallMeth1(ResolveNodeRec(left) - REC_UCLASS_BASE, '__add__', left, right),
so if ResolveNodeRec does not give self a user-class rec id at that site,
the expression falls past every dunder arm to the raw integer/pointer add.
Verify that before believing it — PXXDBG=n.locals / a.ast:probe — the
last two N tickets both named the wrong mechanism.
The boundary, as far as it was measured
| shape | answer |
|---|---|
a + 1 (named receiver) |
correct |
self + 1 (inside the class) |
address printed |
self == 1, self < 1 |
wrong, but for a different reason — see the sibling below |
len(self), str(self), self[k], k in self, for x in self |
correct as of 2026-08-26 |
Only + was reduced to a minimal case. -, *, /, %, @, the bitwise
family and the augmented forms all go through the same arms and are not
measured — check them together, and note that a wrong answer here is an
ADDRESS, not an exception, so a passing-looking program proves nothing.
Why this is worth more than its shape suggests
The failure is silent and the value is plausible-looking only until you read
it. It is also the natural way to write a mixin that composes with itself
(def __iadd__(self, o): return self + o), which is the pattern the
collections-ABC work keeps landing on.
Sibling: [[bug-n-a-comparison-dunder-against-a-non-class-operand-answers-wrongly]].
Gate
make compiler/pascal26 + the repro above answering A-add twice, plus a
witness test in test-core pairing the named-receiver arm with the self arm
(the pattern test_nilpy_dunder_on_self_reaches_the_override.npy uses), with
the .expected generated by CPython.
Root cause (2026-08-27) — the ticket's own hypothesis was wrong, as it warned
The dunder IS dispatched. PXXDBG=a.ast:* on def probe(self): return self + 1 shows an AN_CALL (kind 8) to __add__ with tk=23 (tyAnsiString)
and the two arguments in place. ResolveNodeRec gives self its user-class rec
id at that site and the additive arm fires exactly as it does for a named
receiver. Nothing about dispatch is broken.
What is broken is the def's registered return type. PXXDBG=n.ret:
PXXDBG n.ret def@5 __add__ tk=23 rec=0 <- tyAnsiString, correct
PXXDBG n.ret def@19 probe tk=13 rec=0 <- tyInt64, wrong
probe returns Int64, so the correct call's AnsiString handle is handed back
through an integer slot and printed as an address. The two facts the ticket
found most damning — that a named receiver works and that no inheritance is
involved — are both explained by this and by nothing about dispatch: the
scanner's answer does not depend on the receiver spelling being wrong, only on
self being untypeable.
Why untypeable: PyInferDefRetTypeScan types the return expression with
PyInferExprType, a token scanner that has no way to know which class self
belongs to — the same limitation PyMethodReturnsSelf was written to work
around, in as many words: "self is a parameter whose class only this pre-pass
knows". So every arm in that scanner that types a CLASS operand was
unreachable through self, and the expression fell to the arithmetic walk.
Scope: all eleven, not just +
The ticket left -, *, /, %, the bitwise family unmeasured. Measured:
through a named receiver all eleven dispatch correctly and always did
(__add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __and__ __or__ __xor__ __lshift__ __rshift__ — every one matches CPython). Through self all
eleven were wrong, uniformly, and all eleven are fixed by the one change.
Fix
PyInferSelfCi— the enclosing class, lent to the scanner by the one pass that has it (PyRegisterClassMembers, around itsPyMethodRetTypecall) and cleared straight after.PyInferExprTypetypes a bareselffrom it;self.x/self.m()keep their own arms.PyBinOpDunderName— raw-token operator → dunder name, besidePyAugDunderName. Not a delegation to that one: it is keyed on the tokenPyAugBinToknormalises to, where&=becomestkAnd, while a bare&istkAmpandtkAndis theandKEYWORD. Delegating would have givenself and xthe type of__and__.- An operator-dunder arm in
PyInferExprType: first top-level operator, left operand only (matching Python's lookup and the parser arms it mirrors), answering the dunder's registered return type — including its class, so the result chains.
Placed above the true-division arm. Below it, self / 1 was still typed
tyDouble by that arm and was the one of the eleven that stayed wrong — caught
by running all eleven rather than the ticket's single +.
Verified against the CPython oracle
- All eleven operators, named receiver and
self: match. - A dunder returning the class, chained (
self + 1→V, then.n), and a parenthesised left operand(self + 1) + 10: match. - Non-regressions:
k / 2still a float,k ** 2stillPyMakePow(**is twotkStarand is excluded),return selfstill the fluent-builder rule,self and 1still short-circuit. - Witness:
test/test_nilpy_arith_dunder_on_self.npy(+.expectedgenerated by CPython, not written), registered intest-core. GREEN at HEAD, RED atstable_linux_amd64/default/pinned. make compiler/pascal26fixedpoint +tools/gate.sh quickGREEN.
Filed, not fixed here
[[bug-n-a-short-circuit-or-returning-self-is-typed-as-a-number]] — return self or 1 dies with TypeError: expected a number, got object, pre-existing at
pinned. A different mechanism (a short-circuit operator hands back an OPERAND;
there is no dunder), so it is a sibling rather than part of this. The witness
test names the two rows it deliberately does not assert.
Log
- 2026-08-27 — resolved, commit cf6494e08.