The zero-argument form of a builtin type constructor is rejected
- Type: bug — Track N (Nil-Python frontend)
- Found: 2026-08-27, while probing
[[bug-nilpy-empty-str-and-none-are-the-same-value]]'s boundary —
str()was reached for as the obvious way to make an empty string and did not compile. - Measured on: HEAD and pinned v382 (
b12e5e27a) alike. Pre-existing.
Measured
print(repr(X)) for each, one file per row:
| form | pxx | CPython |
|---|---|---|
str() |
error: expected expression |
'' |
int() |
error: expected expression |
0 |
float() |
error: expected expression |
0.0 |
bool() |
error: expected expression |
False |
list() |
error: no overload of list matches these arguments |
[] |
tuple() |
error: no overload of tuple matches these arguments |
() |
bytes() |
error: no overload of bytes matches these arguments |
b'' |
dict() |
OK | {} |
set() |
OK | set() |
frozenset() |
OK | frozenset() |
bytearray() |
OK | bytearray(b'') |
Why it is one bug and not seven
One concept — "the zero-argument form of a type constructor is that type's zero value" — implemented for four of eleven names. The two error messages are the tell that it is falling through two mechanisms, and each already has a working precedent among the four that pass:
- parser intrinsics (
str/int/float/bool) are lowered byPyParseFactorCoreand die inParseArgExpron the missing argument; - pylib procs (
list/tuple/bytes) die in overload resolution — andbytearraypasses for exactly one reason:pylib.pas:1078declaresfunction bytearray: TPyBytes; overload;, the zero-argument overload its three siblings never got.
So the fix follows each mechanism's own precedent rather than inventing a third: a zero-value arm for the four intrinsics, and the missing zero-argument overload for the three pylib constructors.
Where it bites
x = x or str() and d.get(k, list()) are ordinary spellings of a default, and
int() / float() are how generated and generic code names a numeric zero
without committing to a literal. Nothing in the corpus is blocked on it that
we have measured — it is filed because it is a plain upward-compatibility break
on working CPython code and because the concept is already four-elevenths built.
Gate
Each row above matches CPython. Touches compiler/builtin/**, so it carries the
stabilize-fast + make pin obligation.
Resolution (2026-08-27)
All eleven now match CPython. Witness
test/test_nilpy_zero_argument_builtin_constructors.npy, registered in
test-core as test_nilpy_zeroctor26, .expected generated by CPython. Red at
pinned v382 (b12e5e27a), green at HEAD.
Each half fixed by its own precedent, not by a third mechanism
- Parser intrinsics —
str/int/float/bool. A newPyZeroArgScalarCtorinpyparser.incbuilds the zero value directly, called from one guard at the head ofPyParseFactorCore'stkIdentarm, before the name can resolve through the intrinsic chain and consume the(.int()is Int64, not a promotable int: 0 needs no width, and answering the wide type would register a promo signature over an unannotatedreturn int()— the silent ABI mismatch the neighbouringint(...)arm already documents. - pylib procs —
list/tuple/bytes. Three zero-argument overloads inpylib.pas, spelled exactly asbytearray's has been since it was written. That overload is the entire reasonbytearray()was the only one of the four container constructors that compiled.
Two mechanisms because there genuinely are two kinds of constructor, and each already had a working example among the four that passed. Growing a third dispatch to cover both would have been the second path that stays broken.
CORRECTION 2026-08-27, same day — the pylib half was REVERTED
The paragraph above reasons from a precedent that was itself a latent bug, and following it broke two gated tests inside an hour ([[regression-test-core-test-nilpy-unbound-builtin-method]], [[regression-test-core-test-nilpy-builtin-subclass-dunder-dispatch]]).
This dialect lets a parameterless function be called by its BARE NAME. So a zero-argument overload does not add the spelling
list(); it makes the bare wordlista complete call, andlist.append(self, x)— how a subclass reaches the base method it just overrode — becamelist().append(self, x).All seven now answer in the PARSER, keyed on the
name()shape, which by construction cannot capture a bare name or a name before a.. The containers build their zero value fromPyParseListLiteralTon the same(), so no new pylib name exists whose bare word could be called;list()andbytes()then undo that parser's paren-implies-TUPLE stamp withpylist_mark_list.So there is ONE mechanism after all, and the "two kinds of constructor" framing was wrong — not about the code, about which property mattered. What separates the two routes is not intrinsic-vs-proc, it is what else the name becomes callable from.
bytearraykeeps its overload and stays broken for the unbound spelling — measured identical at v383, original rather than a regression, and filed as [[bug-n-bytearrays-zero-argument-overload-makes-the-bare-name-a-call]]. It is not taken over here because its overload also stampsFIsByteArray, which the parser arm cannot reproduce without a new pylib entry point.The witness now carries a
Stack(list)callinglist.appendandlist.__getitem__unbound, so the shape is gated from this side too.
The bug inside the fix, caught by varying the shape
The first spelling typed the new str() node as tyAnsiString — the type the
call str(x) is inferred as. Measured:
| pxx | CPython | |
|---|---|---|
str() == "" in expression position |
False | True |
x = str(); x == "" |
True | True |
== dispatches on the static type, so a zero-length literal typed as a managed
handle took a different arm from the literal it was being compared with. The
value IS a literal and has to be typed like one (tyString), after which
str() is indistinguishable from "" in every position measured. The s = s + ("x" * 400) row is there because tyString is also the FROZEN 255-char type
and the obvious worry is a ceiling; there is none — the local widens exactly as
it does from a plain "".
Also measured and NOT a regression: def d(a=str()) produces an 8.4 MB BSS.
def d(a="") produces the identical 8.4 MB at v382. That is the pre-existing
cost of the hidden global a string default gets (pyparser.inc, the tkString
default arm), and str() simply inherits it — which is the correct outcome for
this ticket.
Deliberately NOT in the witness
Two rows in the probe still differ from CPython, both pre-existing and neither this ticket's:
str() is Noneanswers True — exactly as"" is Nonedoes.str()behaving identically to""is what this ticket wanted; the shared defect is [[bug-nilpy-empty-str-and-none-are-the-same-value]], re-measured and re-sized the same day.str.__name__on a bare type value answers?— the type-as-value family, [[bug-n-a-type-name-is-not-a-first-class-value]].
Gate
make compiler/pascal26 + tools/gate.sh quick GREEN. Touches
compiler/builtin/pylib.pas, so it needs stabilize-fast + make pin before
Track B can use the new spellings.
Log
- 2026-08-27 — resolved, commit 5a996b4b2.