xs += ys on a VARIANT-typed list rebinds instead of extending, so the caller never sees it
def grow(l):
l += [9]
c = [1]
grow(c)
print(c) # CPython: [1, 9] pxx: [1]
vs = [[1]]
e = vs[0]
e += [5]
print(vs[0]) # CPython: [1, 5] pxx: [1]
Python's += on a list is __iadd__ — IN-PLACE, so every alias sees the new
elements. pxx does the in-place lowering only when the target's type is
STATICALLY TPyList:
pyparser.inc, both augmented-assignment sites (≈10855 and ≈10963):
if (augTk = tkPlus) and (PyNodeListCi(lhsNode) >= 0) then
Result := PyCallMeth1(PyNodeListCi(lhsNode), 'extend', lhsNode, CurASTNode);
and PyNodeListCi gives up immediately unless the node is tyClass:
if IntToTypeKind(ASTTk[n]) <> tyClass then Exit;
An unannotated parameter, a list out of a list, a list out of a dict — all
variants — therefore fall through to x := x + y, which builds a NEW list and
rebinds the local. The original object is untouched, so the mutation is simply
lost. No error, no warning.
Measured, so the boundary is exact
Same file under CPython and pxx (alias2.npy):
| form | CPython | pxx |
|---|---|---|
def grow(l: List[int]) — annotated, so tyClass |
[1, 9] |
[1, 9] |
def grow(l) — unannotated, so variant |
[1, 9] |
[1] |
l.extend([9]) through a variant |
[1, 9] |
[1, 9] |
l.append(9) through a variant |
[1, 9] |
[1, 9] |
e = vs[0]; e += [5] |
[1, 5] |
[1] |
g = d["k"]; g += [8] |
[1, 8] |
[1] |
So the METHODS already dispatch correctly through a variant; only the operator
form does not. Pre-existing — reproduced identically on pinned, not a
regression from the mixed-type operand work.
Shape of a fix
The type is unknown at compile time, so it has to be decided at run time, the same way the rest of the operator family now is ([[bug-nilpy-mixed-type-arithmetic-silently-does-pointer-math]]).
A helper that keeps the object identity when the target holds a list:
function pyaugadd_v(const target: Variant; const v: Variant): Variant;
{ target holds a TPyList -> extend it IN PLACE and hand the SAME object back,
so the surrounding `x := pyaugadd_v(x, y)` rebinds x to what it already
pointed at and every alias sees the new elements. Anything else -> pyadd_v. }
Then mark the augmented + node in ASTSLen (a new PY_BINOP_AUGADD
alongside PY_BINOP_IDENTITY in defs.inc) at the two pyparser sites when the
lhs is tyVariant, and have the ir.inc AN_BINOP variant-dispatch block
pick pyaugadd_v instead of pyadd_v for a marked node. No by-reference
variant parameter is needed — extend mutates through the handle, so returning
the same object is enough.
Watch the ARC side: the returned variant must retain the way pyor_v's
PyVarSlotInit does, or the rebind releases the object it just kept.
Gate
make test-nilpy + self-host byte-identical, plus the table above re-diffed
against CPython, and xs += ys where the target is statically a list must
still lower to extend (unchanged path).
CLOSED
Implemented exactly as suggested: pyaugadd_v (extends a TPyList target in
place, returns the same retained object via PyVarSlotInit, falls through to
pyadd_v otherwise), a new PY_BINOP_AUGADD marker set at both pyparser.inc
+= sites (the lhs-expression one and the bare-name one — the ticket's own
def grow(l): l += [9] repro goes through the SECOND, which needed the same
marker) when the target's static type is tyVariant, and ir.inc's
variant-+ dispatch picks the new helper when it sees the marker.
Every row in the ticket's table now matches CPython, and the statically-typed
xs += ys path (an annotated parameter) is unchanged — confirmed still lowers
straight to TPyList.extend, no new dispatch involved.
Test: test/test_nilpy_augmented_add_variant_list.npy. Gate: make test-nilpy green, self-host fixedpoint, testmgr --tier quick.
Ticket closed.
Log
- 2026-07-31 — resolved, commit b7124a98b.