← board

How should a class used as a VALUE be called?

Blocks the fix for three tickets that all currently record the same plan, and that plan does not work:

The measurement that forces this decision (2026-08-09, HEAD 3e7a6b792)

All three tickets conclude that what is needed is a distinct variant tag for a class reference (tag 11 is free; VT_BOUNDFN_TAG = 10 is the last one used). A tag is necessary. It is not sufficient, and the reason had not been measured:

class A:
    def __init__(self, v): self.v = v
class B:
    def __init__(self, s): self.s = s
A(3); B("x")
PXXDBG=n.ctorargs
  A trial=0 [0]kind=1,tk=1     <- __init__ param 0 is an INTEGER
  B trial=0 [0]kind=2,tk=4     <- __init__ param 0 is an ANSISTRING

NilPy constructor parameters are statically inferred per class from the call sites. So two classes with the same arity have different ABIs. A registry — handlers = {"a": A, "b": B}, then handlers[k](x) — cannot be called through one code pointer, no matter how well the value is tagged. Tagging fixes recovering the class; it does nothing about calling it.

This is why the existing AN_METACLASS_NEW lowering does not generalise either: it reads the instance size and VMT from the RTTI blob at run time (so the CLASS is dynamic), but its argument lowering runs through IRLowerCallArg(cpi, ...) against a statically known ctor proc index. Dynamic class, static signature.

The fork

At a call site cls(args) where cls is a variant classref, emit a runtime switch comparing the payload against the RTTI blob address of each candidate class, and under each arm emit the ORDINARY statically-typed construction that A(args) would have emitted.

Option B — RTTI-driven runtime marshaller

TMethInfo already carries Arity, RetKind and ParamKinds (a ^array of Int64 type-kind words), and PyFindDunder already walks the class RTTI at run time — so the runtime can discover the ctor's exact signature.

Option C — a uniform variant ctor ABI for classes used as values

Compile a SECOND entry point per class whose parameters are all Variant, and give the classref tag that entry's address. Calling is then uniform.

Recommendation

Option A. It is the only one of the three whose correctness is inherited rather than argued, and the code-size worry is measurable before committing: count classes and dynamic call sites in the real targets (html5lib, tinycss2, songformatter) rather than reasoning about it. If a real program shows the blowup is bad, C becomes interesting for exactly the classes A struggles with, and B stays the last resort.

Note it also stages nicely: A with a single-candidate set is already the useful case (cls = A; cls(3)), and the registry case is the same machinery with more arms.

Not part of this decision

str, int, bytes as values ([[bug-n-a-type-name-is-not-a-first-class-value]]) are a separate representation question — they are not user classes and have no RTTI blob, so they need a payload space of their own (a small type code) whatever is decided here. Worth deciding together, but it does not block: the six shim's blocked names (text_type = str, binary_type = bytes, string_types = (str,)) are all BUILTIN types, so that half turns on the builtin-type-code question, not on this one.


CORRECTION 2026-08-10 — this is not a new problem; NilPy already solved it for DEFS

Everything above frames the argument ABI as an open three-way design fork. It is not. The identical problem — inferred, per-callee, mutually incompatible argument ABIs — already exists for functions, and the frontend already solved it and shipped it. The options section argues against the uniform-variant route on "two mechanisms for one concept" grounds; that is the route NilPy actually took for defs, and it took it in the way that avoids that smell.

Measured at HEAD (8adc552b5), both programs run correctly today:

def fa(v): return v + 1        # inferred: v Integer
def fb(s): return s + "!"      # inferred: s AnsiString
table = {"a": fa, "b": fb}     # one dict, DIFFERENT parameter ABIs
table["a"](10)  # 11    OK
table["b"]("y") # y!    OK
def fa(v): return v + 1
fa(3)      # direct   OK
g = fa
g(10)      # indirect OK   <- SAME entry point, not a second one

The mechanism is PyDefUsedAsValue (pyparser.inc ~19469). It scans the module for the def's name used as a bare value; if found, that def is compiled with variant parameters and a variant result (pyparser.inc:21991 states it in as many words: "PyDefUsedAsValue normalises a def: variant result, variant parameters"). Every Callable[...] site then marshals the same way whichever def it receives.

Note what it does NOT do: it does not add a second entry point. It normalises the def's ONE entry, selected by the use-as-value scan — which is exactly why the direct call fa(3) and the indirect g(10) both work with one conversion path. Option C above proposed a second all-variant entry and was then (correctly) criticised for creating two conversion semantics. That criticism does not apply to what the def path actually does.

There is no PyClassUsedAsValue. The class path simply never got the treatment its sibling has had for a long time.

What this leaves genuinely open

The decision is much smaller than the options above suggest. Of the three pieces:

So the real question is no longer "A, B or C" but: is there any reason the ctor cannot follow the same normalisation a def gets? Candidate reasons worth checking before committing — none yet investigated:

If none of those bites, this stops being a Track U decision at all and becomes ordinary Track N work: "give classes the PyDefUsedAsValue treatment". It should be re-filed into N in that case — U holds open questions, not work.

Do not implement Option A or B off the analysis above without first establishing why the def route does not apply. That analysis was written without noticing the def path existed.

SUPERSEDED 2026-08-10

Re-filed as ordinary Track N work: [[feature-nilpy-class-as-a-value]]. Nothing here needs a human decision — the trigger is missing, not the design. Kept only as the record of how the wrong framing arose. Prio dropped to 5.

MOVED TO decided/ 2026-08-11 — record only

Superseded and re-filed as work (see the SUPERSEDED note above). Kept as the record of how the framing arose; nothing here awaits a human decision.