DECIDED 2026-08-01 — option 2, scoped by whole-program usage analysis
User's call: 2, real per-instance "assigned" tracking — but not blanket
cost on every field of every object. pxx is a whole-program compiler, so it
can scan every hasattr call site first and only instrument what's
actually reachable:
- Enumerate every
hasattr(obj, name)call site in the program. - Resolve
obj's static class per site. The common case (hasattr(self, ...)inside a method) is unambiguous at compile time. A genuinely ambiguous target (a base-class-typed value with several possible subclasses, aVariant) falls back to tracking every class in that ambiguity set — still bounded by whathasattrcan reach, never the whole program. - Resolve
nameper site. A string literal (the common case) scopes tracking to that one field. A non-constant name expression falls back to tracking every field of the resolved class(es). - Only classes/fields reached by step 2+3 get a per-instance "assigned"
bit and the extra write at assignment time. Everything else keeps
today's exact codegen — zero-cost unless
hasattris actually used on it.
Since hasattr is rare (4 sites in the current corpus), most programs pay
nothing at all. Full CPython-correct semantics, cost paid only where
exercised. Gate unchanged from the ticket's own text: make test-nilpy +
self-host byte-identical + a .npy diffed against CPython (the uforth
first-time-init idiom as the anchor case), plus confirm a class NOT reached
by any hasattr call site compiles identically to today (the zero-cost
claim, not just assumed).
decide: should NilPy's hasattr answer per-INSTANCE or per-CLASS?
Raised 2026-07-20 implementing hasattr/getattr for the uforth drive.
The fork
Python's hasattr(o, "x") asks whether the attribute has been ASSIGNED on that
particular object. NilPy classes declare their fields statically and every
field is zero-initialised, so "declared" and "assigned" are not distinguishable
at runtime.
Shipped behaviour: resolved at COMPILE time against the class's declared fields. A declared field reports True even before the first assignment.
Where it differs in practice
uforth's idiom:
if not hasattr(self, "_tok_current"):
self._tok_in_string = False
self._tok_quote_char = ""
self._tok_paren_depth = 0
Under CPython the block runs once; under NilPy it never runs. It happens to be harmless HERE — the three fields zero-initialise to exactly those values — but that is luck, not equivalence. A guard whose body did something non-trivial (allocating a list, opening a file) would be silently skipped.
Options
- Keep compile-time class-based (shipped). Free, no per-object state, matches "fields are declared". Silently skips first-time-init idioms.
- Per-instance "assigned" bit. Faithful, but costs a bit per field per object and a write on every assignment — for a feature 4 sites use.
- Reject
hasattron a field the class declares and require the corpus to use an explicit sentinel (self._tok_current is None). Loud instead of silent; needs a corpus edit, which the uforth ticket forbids (it must run UNMODIFIED).
Recommendation: keep 1, but consider a --strict warning when hasattr is
used as a first-time-init guard, since that is the case where 1 is wrong.
Related
[[feature-nilpy-corpus-uforth]]. Option 3 conflicts with that ticket's "unmodified" requirement, which is itself worth confirming.