From 4d80ba2ba5dca1ccf5a0793598edf55c4aae6629 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 3 Sep 2026 11:05:19 +0100 Subject: [PATCH 1/5] [mypyc] Fixes to Final attribute defined in class body Don't make it into an instance attribute. Don't run the initializer more than once. --- mypyc/ir/class_ir.py | 35 ++++ mypyc/irbuild/builder.py | 70 ++++++- mypyc/irbuild/prepare.py | 6 + mypyc/irbuild/util.py | 40 +++- mypyc/test-data/alwaysdefined.test | 2 +- mypyc/test-data/irbuild-basic.test | 6 - mypyc/test-data/irbuild-constant-fold.test | 5 - mypyc/test-data/irbuild-final.test | 80 ++++++++ mypyc/test-data/irbuild-int.test | 6 - mypyc/test-data/run-classes.test | 218 +++++++++++++++++++++ mypyc/test-data/run-multimodule.test | 57 ++++++ 11 files changed, 502 insertions(+), 23 deletions(-) diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index 3b54331cb2f07..d81e373ce0d96 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -164,6 +164,16 @@ def __init__( self.attrs_to_keep_alive_on_completion: set[str] = set() # Final attributes defined in the class (not inherited) self.final_attributes: set[str] = set() + # Class-body Final attributes defined in the class (not inherited). + # + # These are "X: Final = " declarations in the class body, as opposed to + # "self.x: Final = ..." assignments in __init__ (which live in 'attributes' and + # 'final_attributes'). They are deliberately *not* in 'attributes': they get no + # slot in the instance struct. Instead the value is stored once in a module-level + # static named "." and reads are either constant folded or loaded + # from that static. The value is also set on the type object, so dynamic access + # from interpreted code resolves through the class dict as usual. + self.class_final_attributes: dict[str, RType] = {} # Deletable attributes self.deletable: list[str] = [] # We populate method_types with the signatures of every method before @@ -302,6 +312,25 @@ def is_final_attr(self, name: str) -> bool: return False return False + def class_final_attr_details(self, name: str) -> tuple[RType, ClassIR] | None: + """Look up a (possibly inherited) class-body Final attribute. + + Returns the attribute type and the class that defines it, or None if this + class has no such attribute. The defining class is what identifies the + static holding the value, so callers need it to build the static's name. + + Deliberately kept out of attr_details()/has_attr(): these attributes have no + instance slot, so callers that want to read or write one must not treat them + as ordinary attributes. + """ + for ir in self.mro: + if name in ir.class_final_attributes: + return ir.class_final_attributes[name], ir + if name in ir.attributes or name in ir.property_types: + # Shadowed by a real attribute or property closer in the MRO. + return None + return None + def method_decl(self, name: str) -> FuncDecl: for ir in self.mro: if name in ir.method_decls: @@ -435,6 +464,9 @@ def serialize(self) -> JsonDict: "attributes": [(k, t.serialize()) for k, t in self.attributes.items()], "attrs_to_keep_alive_on_completion": sorted(self.attrs_to_keep_alive_on_completion), "final_attributes": sorted(self.final_attributes), + "class_final_attributes": [ + (k, t.serialize()) for k, t in self.class_final_attributes.items() + ], # We try to serialize a name reference, but if the decl isn't in methods # then we can't be sure that will work so we serialize the whole decl. "method_decls": [ @@ -499,6 +531,9 @@ def deserialize(cls, data: JsonDict, ctx: DeserMaps) -> ClassIR: ir.attributes = {k: deserialize_type(t, ctx) for k, t in data["attributes"]} ir.attrs_to_keep_alive_on_completion = set(data["attrs_to_keep_alive_on_completion"]) ir.final_attributes = set(data["final_attributes"]) + ir.class_final_attributes = { + k: deserialize_type(t, ctx) for k, t in data["class_final_attributes"] + } ir.method_decls = { k: ctx.functions[v].decl if isinstance(v, str) else FuncDecl.deserialize(v, ctx) for k, v in data["method_decls"] diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index f4e3745836cf5..1f8bc773d03ec 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1226,15 +1226,18 @@ def is_synthetic_type(self, typ: TypeInfo) -> bool: return typ.is_named_tuple or typ.is_newtype or typ.typeddict_type is not None def get_final_ref(self, expr: MemberExpr) -> tuple[str, Var, bool] | None: - """Check if `expr` is a final class or module attribute. + """Check if `expr` is a final class, module or instance attribute. - Return False for instance attributes. - - This needs to be done differently for class and module attributes to + This needs to be done differently for class, module and instance attributes to correctly determine fully qualified name. Return a tuple that consists of the qualified name, the corresponding Var node, and a flag indicating whether the final name was defined in a compiled module. Return None if `expr` does not refer to a final attribute. + + Instance attributes only qualify if they are class-body Finals ("X: Final = ..." + in the class body), which have no instance slot -- see + ClassIR.class_final_attributes. Ordinary instance attributes, including + "self.x: Final = ..." set in __init__, return None. """ final_var = None if isinstance(expr.expr, RefExpr) and isinstance(expr.expr.node, TypeInfo): @@ -1254,10 +1257,69 @@ def get_final_ref(self, expr: MemberExpr) -> tuple[str, Var, bool] | None: final_var = expr.node fullname = expr.node.fullname native = self.is_native_ref_expr(expr) + else: + # Possibly a class-body Final read through an instance ("self.X"). These + # have no instance slot, so read them exactly like "Cls.X". + var = self.get_class_final_var(expr) + if var is not None: + final_var = var + fullname = f"{var.info.fullname}.{var.name}" + native = self.is_native_module(var.info.module_name) if final_var is not None: return fullname, final_var, native return None + def get_class_final_var(self, expr: MemberExpr) -> Var | None: + """Return the Var for a class-body Final read through an instance expression. + + Return None if `expr` isn't such a read; the caller then falls back to an + ordinary attribute read, which finds these names on the type object via + py_get_attr (correct, just slower). + + A union-typed object qualifies only when every item resolves to the same + declaration, since otherwise the value differs per item. + """ + instance_type = get_proper_type(self.types.get(expr.expr)) + if isinstance(instance_type, UnionType): + items = [get_proper_type(item) for item in instance_type.items] + else: + items = [instance_type] + found: Var | None = None + for item in items: + if not isinstance(item, Instance): + return None + var = self.class_final_var_of_instance(item, expr.name) + if var is None: + return None + if found is not None and var is not found: + return None + found = var + return found + + def class_final_var_of_instance(self, instance: Instance, name: str) -> Var | None: + """Look up a class-body Final attribute on a single instance type. + + ClassIR is the authority on which attributes were compiled as class-body + Finals (mypyc.irbuild.util's is_class_body_final decides, and the answer + survives serialization), so we gate on it and only use the mypy symbol table + to find the defining class. + """ + class_ir = self.mapper.type_to_ir.get(instance.type) + if class_ir is None: + return None + details = class_ir.class_final_attr_details(name) + if details is None: + return None + _, defining_ir = details + sym = instance.type.get(name) + if sym is None or not isinstance(sym.node, Var) or not sym.node.is_final: + return None + # mypy's MRO and mypyc's can differ (traits), so only proceed when both agree + # on which class defines the attribute; that class names the static. + if sym.node.info.fullname != defining_ir.fullname: + return None + return sym.node + def emit_load_final( self, final_var: Var, fullname: str, name: str, native: bool, typ: Type, line: int ) -> Value | None: diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py index 6e697823593a6..fdca2fd49e6f5 100644 --- a/mypyc/irbuild/prepare.py +++ b/mypyc/irbuild/prepare.py @@ -75,6 +75,7 @@ default_attr_name, get_func_def, get_mypyc_attrs, + is_class_body_final, is_dataclass, is_extension_class, is_trait, @@ -637,6 +638,11 @@ def prepare_methods_and_attributes( assert node.node.type, "Class member %s missing type" % name if not node.node.is_classvar and name not in ("__slots__", "__deletable__"): attr_rtype = mapper.type_to_rtype(node.node.type) + if is_class_body_final(node.node, ir, cdef, attr_rtype): + # No instance slot: the value lives in a module-level static + # and on the type object. See ClassIR.class_final_attributes. + ir.class_final_attributes[name] = attr_rtype + continue if ir.is_trait and attr_rtype.error_overlap: # Traits don't have attribute definedness bitmaps, so use # property accessor methods to access attributes that need them. diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index a6f793ccdc1a1..e8feccefa8bc0 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -35,7 +35,7 @@ from mypy.types import FINAL_DECORATOR_NAMES from mypyc.errors import Errors from mypyc.ir.class_ir import ClassIR -from mypyc.ir.rtypes import is_none_rprimitive, is_object_rprimitive, is_optional_type +from mypyc.ir.rtypes import RType, is_none_rprimitive, is_object_rprimitive, is_optional_type MYPYC_ATTRS: Final[frozenset[MypycAttr]] = frozenset( ["native_class", "allow_interpreted_subclasses", "serializable", "free_list_len", "acyclic"] @@ -107,6 +107,44 @@ def dataclass_type(cdef: ClassDef) -> str | None: return None +def is_class_body_final(var: Var, ir: ClassIR, cdef: ClassDef, attr_rtype: RType) -> bool: + """Whether a class member should be stored as a class-level Final instead of + an instance attribute. + + These are "X: Final = " declarations in the class body. The value is the + same for every instance, so giving each instance a struct slot wastes memory and + forces the initializer to be re-evaluated per construction. Instead we let the + existing final-name machinery own it: the value is stored once in a module-level + static and set on the type object (see ExtClassBuilder.add_attr), and reads are + constant folded or loaded from the static. + + See ClassIR.class_final_attributes. + """ + if not var.is_final or not var.has_explicit_value: + return False + if var.final_set_in_init: + # "self.x: Final = ..." in __init__ genuinely varies per instance. + return False + if not ir.is_ext_class or ir.builtin_base: + # No instance struct to save a slot in, and non-extension classes already + # keep class-level values in the class dict. + return False + if dataclass_type(cdef) is not None: + # The field is load-bearing: the interpreted __init__ that dataclasses (or + # attrs) generates assigns it, and __eq__/__repr__/fields() read it. + return False + if attr_rtype.error_overlap: + # A static of such a type can't distinguish "unset" from a value equal to + # the error sentinel, which would turn a valid read into a spurious + # NameError. See ai/bug-final-static-error-overlap.md. + return False + if ir.allow_interpreted_subclasses: + # An interpreted subclass can shadow the name in its own class dict, which + # native reads of a folded or static value would not see. + return False + return True + + def _defaults_skip(stmt: AssignmentStmt, cls_type: str | None) -> bool: """Whether a class-level default assignment is skipped when emitting __mypyc_defaults_setup, based on class type. diff --git a/mypyc/test-data/alwaysdefined.test b/mypyc/test-data/alwaysdefined.test index 9c9b15e4e122d..167437bf1202e 100644 --- a/mypyc/test-data/alwaysdefined.test +++ b/mypyc/test-data/alwaysdefined.test @@ -318,7 +318,7 @@ BinaryOps: [a, b, c, d, e, f] LocalsAndGlobals: [a, g] Booleans: [a, b, c, d, e] ModuleFinal: [a, b] -ClassFinal: [F, a] +ClassFinal: [a] Literals: [a, b, c] ListComprehension: [a] Helper: [x] diff --git a/mypyc/test-data/irbuild-basic.test b/mypyc/test-data/irbuild-basic.test index c6c231f0386be..55bf150149167 100644 --- a/mypyc/test-data/irbuild-basic.test +++ b/mypyc/test-data/irbuild-basic.test @@ -3027,12 +3027,6 @@ def f(a: bool) -> int: else: return C.y [out] -def C.__mypyc_defaults_setup(__mypyc_self__): - __mypyc_self__ :: __main__.C -L0: - __mypyc_self__.x = 2 - __mypyc_self__.y = 4 - return 1 def f(a): a :: bool L0: diff --git a/mypyc/test-data/irbuild-constant-fold.test b/mypyc/test-data/irbuild-constant-fold.test index 009a4e68f6788..643ee4d3845c1 100644 --- a/mypyc/test-data/irbuild-constant-fold.test +++ b/mypyc/test-data/irbuild-constant-fold.test @@ -229,11 +229,6 @@ class C: def f() -> None: a = C.X + 1 [out] -def C.__mypyc_defaults_setup(__mypyc_self__): - __mypyc_self__ :: __main__.C -L0: - __mypyc_self__.X = 10 - return 1 def f(): a :: int L0: diff --git a/mypyc/test-data/irbuild-final.test b/mypyc/test-data/irbuild-final.test index 0ede988a43206..f289bab822062 100644 --- a/mypyc/test-data/irbuild-final.test +++ b/mypyc/test-data/irbuild-final.test @@ -250,3 +250,83 @@ L0: r3 = 'a' r4 = PyUnicode_Concat(r2, r3) return r4 + +[case testClassBodyFinalConstantFoldedThroughInstance] +from typing import Final + +class C: + X: Final = 23 + + def get(self) -> int: + return self.X + +class D(C): + def get_inherited(self) -> int: + return self.X + +def f(c: C) -> int: + return c.X +[out] +def C.get(self): + self :: __main__.C +L0: + return 46 +def D.get_inherited(self): + self :: __main__.D +L0: + return 46 +def f(c): + c :: __main__.C +L0: + return 46 + +[case testClassBodyFinalReadFromStaticThroughInstance] +from typing import Final + +def make() -> int: + return 3 + +class C: + X: Final = make() + + def get(self) -> int: + return self.X +[out] +def make(): +L0: + return 6 +def C.get(self): + self :: __main__.C + r0 :: int + r1 :: bool +L0: + r0 = __main__.C.X :: static + if is_error(r0) goto L1 else goto L2 +L1: + r1 = raise NameError('value for final name "X" was not set') + unreachable +L2: + return r0 + +[case testInstanceFinalStillUsesAttribute] +from typing import Final + +class C: + def __init__(self, x: int) -> None: + self.x: Final = x + + def get(self) -> int: + return self.x +[out] +def C.__init__(self, x): + self :: __main__.C + x :: int +L0: + self.x = x + return 1 +def C.get(self): + self :: __main__.C + r0 :: int +L0: + r0 = self.x + return r0 diff --git a/mypyc/test-data/irbuild-int.test b/mypyc/test-data/irbuild-int.test index 10991f26c408f..3649e7ff7f087 100644 --- a/mypyc/test-data/irbuild-int.test +++ b/mypyc/test-data/irbuild-int.test @@ -141,12 +141,6 @@ def f4() -> int: def f5() -> int: return C.B [out] -def C.__mypyc_defaults_setup(__mypyc_self__): - __mypyc_self__ :: __main__.C -L0: - __mypyc_self__.A = 2 - __mypyc_self__.B = -2 - return 1 def f1(): L0: return -2 diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 8203de6feee2d..aff593ace56d0 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2800,6 +2800,16 @@ def test_final_attribute() -> None: assert C.A == -1 assert C.a == [-1] +def test_final_attribute_through_instance() -> None: + assert C().A == -1 + assert C().a == [-1] + +def test_final_attribute_not_per_instance() -> None: + # A class-body Final has a single value shared by every instance, so a + # mutable one must not be rebuilt per instance. + assert C().a is C.a + assert C().a is C().a + [case testClassWithFinalDecorator] from typing import final @@ -6460,3 +6470,211 @@ def test_property_getter_exception() -> None: with assertRaises(ValueError): getattr(t, "locked") + +[case testClassBodyFinalAttributeNoInstanceSlot] +from typing import Any, Final, final + +from testutil import assertRaises + +calls: list[int] = [] + +def make() -> int: + calls.append(1) + return 3 + +class C: + LITERAL: Final = 23 + COMPUTED: Final = make() + ITEMS: Final = [1, 2] + + def literal(self) -> int: + return self.LITERAL + + def computed(self) -> int: + return self.COMPUTED + + def items(self) -> list[int]: + return self.ITEMS + +class Sub(C): + def inherited(self) -> int: + return self.LITERAL + self.COMPUTED + +@final +class Sealed: + X: Final = 5 + + def get(self) -> int: + return self.X + +def test_native_read() -> None: + assert C().literal() == 23 + assert C().computed() == 3 + assert C().items() == [1, 2] + assert Sealed().get() == 5 + +def test_inherited_read() -> None: + assert Sub().inherited() == 26 + assert Sub().literal() == 23 + assert Sub.LITERAL == 23 + assert Sub().LITERAL == 23 + +def test_initializer_evaluated_once() -> None: + # The class body runs once, so a side-effecting initializer must not be + # re-evaluated for each instance. + C() + C() + assert len(calls) == 1 + +def test_no_instance_slot() -> None: + # Class-body Finals are not instance state, so they are absent from + # __mypyc_attrs__ (which drives pickling). + t: Any = C + assert 'LITERAL' not in t.__mypyc_attrs__ + assert 'COMPUTED' not in t.__mypyc_attrs__ + assert 'ITEMS' not in t.__mypyc_attrs__ + +def test_dynamic_read_via_any() -> None: + o: Any = C() + assert o.LITERAL == 23 + assert o.COMPUTED == 3 + assert o.ITEMS == [1, 2] + assert getattr(o, 'LITERAL') == 23 + s: Any = Sub() + assert s.LITERAL == 23 + assert s.COMPUTED == 3 + +def test_dynamic_read_through_class() -> None: + t: Any = C + assert t.LITERAL == 23 + assert t.COMPUTED == 3 + assert t.ITEMS == [1, 2] + assert getattr(t, 'COMPUTED') == 3 + +def test_dynamic_read_matches_native() -> None: + o: Any = C() + assert o.ITEMS is C().items() + assert o.COMPUTED == C().computed() + +def test_cannot_rebind_via_any() -> None: + o: Any = C() + with assertRaises(AttributeError): + o.LITERAL = 1 + assert o.LITERAL == 23 + with assertRaises(AttributeError): + setattr(o, 'COMPUTED', 1) + assert o.COMPUTED == 3 + +[case testClassBodyFinalAttributeInTrait] +from mypy_extensions import trait +from typing import Any, Final + +def make() -> int: + return 7 + +@trait +class T: + LITERAL: Final = 23 + COMPUTED: Final = make() + + def literal(self) -> int: + return self.LITERAL + +class C(T): + def computed(self) -> int: + return self.COMPUTED + +def test_trait_final() -> None: + assert C().literal() == 23 + assert C().computed() == 7 + assert T.LITERAL == 23 + assert C.COMPUTED == 7 + +def test_trait_final_via_any() -> None: + o: Any = C() + assert o.LITERAL == 23 + assert o.COMPUTED == 7 + +[case testDataclassFinalAttributeKeepsInstanceSlot] +from dataclasses import dataclass +from typing import Any, Final + +@dataclass +class C: + X: Final = 23 + y: int = 1 + +def test_dataclass_final_keeps_instance_slot() -> None: + # Final in a dataclass body declares a field, so it must keep its instance + # slot: the interpreted __init__/__eq__/__repr__ that dataclasses generates + # reach for it. (Instantiating such a class is separately broken -- the Final + # getset has no setter, so the generated __init__ can't assign it -- so this + # only checks that the slot is still there.) + t: Any = C + assert 'X' in t.__mypyc_attrs__ + assert 'y' in t.__mypyc_attrs__ + +[case testClassBodyFinalAttributeOfNativeIntType] +from mypy_extensions import i64 +from typing import Any, Final + +def make() -> i64: + return -113 + +class C: + # Native int types overlap with the error sentinel used by Final statics, so + # these keep their instance slot. See ai/bug-final-static-error-overlap.md. + N: Final = make() + + def get(self) -> i64: + return self.N + +def test_native_int_final() -> None: + assert C().get() == -113 + o: Any = C() + assert o.N == -113 + +[case testClassBodyFinalAttributeThroughUnionAndAny] +from typing import Any, Final, Union + +class C: + X: Final = 23 + +class D: + X: Final = 24 + +class E(C): + pass + +def through_union_same_decl(x: Union[C, E]) -> int: + # Both items inherit the same declaration, so this can use C's value. + return x.X + +def through_union_different_decls(x: Union[C, D]) -> int: + # The value differs per item, so this must read the attribute per object. + return x.X + +def through_any(x: Any) -> Any: + return x.X + +def through_narrowing(x: object) -> int: + assert isinstance(x, C) + return x.X + +def test_union_same_decl() -> None: + assert through_union_same_decl(C()) == 23 + assert through_union_same_decl(E()) == 23 + +def test_union_different_decls() -> None: + assert through_union_different_decls(C()) == 23 + assert through_union_different_decls(D()) == 24 + +def test_any() -> None: + assert through_any(C()) == 23 + assert through_any(D()) == 24 + assert through_any(E()) == 23 + assert through_any(C) == 23 + +def test_narrowing() -> None: + assert through_narrowing(C()) == 23 + assert through_narrowing(E()) == 23 diff --git a/mypyc/test-data/run-multimodule.test b/mypyc/test-data/run-multimodule.test index fed4d3606ed90..2a3d018a7af43 100644 --- a/mypyc/test-data/run-multimodule.test +++ b/mypyc/test-data/run-multimodule.test @@ -2048,3 +2048,60 @@ from mypy_extensions import mypyc_attr class CompiledBase: def value(self) -> int: raise NotImplementedError + +[case testMultiModuleClassBodyFinal] +from typing import Any +from other import Base + +class Sub(Base): + def sub_literal(self) -> int: + return self.LITERAL + + def sub_computed(self) -> int: + return self.COMPUTED + +def read_through_instance(b: Base) -> int: + return b.LITERAL + b.COMPUTED + +def read_through_class() -> int: + return Base.LITERAL + Base.COMPUTED + +[file other.py] +from typing import Final + +def make() -> int: + return 7 + +class Base: + LITERAL: Final = 23 + COMPUTED: Final = make() + ITEMS: Final = [1, 2] + + def base_literal(self) -> int: + return self.LITERAL + + def base_computed(self) -> int: + return self.COMPUTED + +[file driver.py] +from typing import Any +from native import Sub, read_through_instance, read_through_class +from other import Base + +# Reading a class-body Final defined in another module, both constant folded +# (LITERAL) and loaded from that module's static (COMPUTED). +assert read_through_instance(Base()) == 30 +assert read_through_class() == 30 +assert Sub().sub_literal() == 23 +assert Sub().sub_computed() == 7 +assert Sub().base_literal() == 23 +assert Sub().base_computed() == 7 + +# Dynamic access resolves through the class dict in either module. +o: Any = Sub() +assert o.LITERAL == 23 +assert o.COMPUTED == 7 +assert o.ITEMS == [1, 2] +b: Any = Base() +assert b.COMPUTED == 7 +assert b.ITEMS is Base.ITEMS From cdd9f135e3ae1862564069fed39e66debc4e59a2 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 3 Sep 2026 13:29:31 +0100 Subject: [PATCH 2/5] Simplify comments --- mypyc/ir/class_ir.py | 13 +++---------- mypyc/irbuild/util.py | 26 ++++++++++++-------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index d81e373ce0d96..dd2d0806295b9 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -162,17 +162,10 @@ def __init__( # Attributes that must survive generator/coroutine completion because # escaped nested functions may still read them as closure variables. self.attrs_to_keep_alive_on_completion: set[str] = set() - # Final attributes defined in the class (not inherited) + # Final attributes initialized in the __init__ method (not inherited) self.final_attributes: set[str] = set() - # Class-body Final attributes defined in the class (not inherited). - # - # These are "X: Final = " declarations in the class body, as opposed to - # "self.x: Final = ..." assignments in __init__ (which live in 'attributes' and - # 'final_attributes'). They are deliberately *not* in 'attributes': they get no - # slot in the instance struct. Instead the value is stored once in a module-level - # static named "." and reads are either constant folded or loaded - # from that static. The value is also set on the type object, so dynamic access - # from interpreted code resolves through the class dict as usual. + # Final attributes defined in class body as "X: Final = " (not inherited). + # They get no slot in the instance struct. The value lives in a module-level static. self.class_final_attributes: dict[str, RType] = {} # Deletable attributes self.deletable: list[str] = [] diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index e8feccefa8bc0..b14b6aa57d168 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -108,35 +108,33 @@ def dataclass_type(cdef: ClassDef) -> str | None: def is_class_body_final(var: Var, ir: ClassIR, cdef: ClassDef, attr_rtype: RType) -> bool: - """Whether a class member should be stored as a class-level Final instead of - an instance attribute. + """Is a member a final attribute initialized in class body ("X: Final = ")? - These are "X: Final = " declarations in the class body. The value is the - same for every instance, so giving each instance a struct slot wastes memory and - forces the initializer to be re-evaluated per construction. Instead we let the - existing final-name machinery own it: the value is stored once in a module-level - static and set on the type object (see ExtClassBuilder.add_attr), and reads are - constant folded or loaded from the static. + The value is the same for every instance, so we don't give these struct + slots. Instead, we let the general final-name machinery own it: the value + is stored once in a module-level static and set on the type object (see + ExtClassBuilder.add_attr), and reads are constant folded or loaded from + the static. See ClassIR.class_final_attributes. """ if not var.is_final or not var.has_explicit_value: return False if var.final_set_in_init: - # "self.x: Final = ..." in __init__ genuinely varies per instance. + # "self.x: Final = ..." in __init__ varies per instance. return False if not ir.is_ext_class or ir.builtin_base: # No instance struct to save a slot in, and non-extension classes already # keep class-level values in the class dict. return False if dataclass_type(cdef) is not None: - # The field is load-bearing: the interpreted __init__ that dataclasses (or - # attrs) generates assigns it, and __eq__/__repr__/fields() read it. + # Dataclass attribute definitions are special. return False if attr_rtype.error_overlap: - # A static of such a type can't distinguish "unset" from a value equal to - # the error sentinel, which would turn a valid read into a spurious - # NameError. See ai/bug-final-static-error-overlap.md. + # A static of type like this can't distinguish unset from a value equal to + # the error sentinel. + # + # TODO: Support thesse by having a separate initialized flag return False if ir.allow_interpreted_subclasses: # An interpreted subclass can shadow the name in its own class dict, which From 07965d03edb8ddc64396d438827151d684199f1b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 3 Sep 2026 14:03:03 +0100 Subject: [PATCH 3/5] Refactor and add test case --- mypyc/irbuild/util.py | 10 +++++++--- mypyc/test-data/irbuild-final.test | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index b14b6aa57d168..4cc0deb1345ab 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -123,9 +123,13 @@ def is_class_body_final(var: Var, ir: ClassIR, cdef: ClassDef, attr_rtype: RType if var.final_set_in_init: # "self.x: Final = ..." in __init__ varies per instance. return False - if not ir.is_ext_class or ir.builtin_base: - # No instance struct to save a slot in, and non-extension classes already - # keep class-level values in the class dict. + if not ir.is_ext_class: + # Non-extension classes keep class-level values in the class dict anyway. + return False + if ir.builtin_base: + # These can be subclassed from Python (we install no tp_new to reject it), + # and such instances reach native code, so this has the same shadowing + # hazard as allow_interpreted_subclasses below. return False if dataclass_type(cdef) is not None: # Dataclass attribute definitions are special. diff --git a/mypyc/test-data/irbuild-final.test b/mypyc/test-data/irbuild-final.test index f289bab822062..f7bbd0fd15923 100644 --- a/mypyc/test-data/irbuild-final.test +++ b/mypyc/test-data/irbuild-final.test @@ -330,3 +330,26 @@ def C.get(self): L0: r0 = self.x return r0 + +[case testClassBodyFinalNotOptimizedInBuiltinBaseSubclass] +from typing import Final + +class MyError(Exception): + # Deliberately not turned into a class-level Final, since an interpreted subclass + # can shadow it. See is_class_body_final() in mypyc/irbuild/util.py. + CODE: Final = 5 + + def get(self) -> int: + return self.CODE +[out] +def MyError.get(self): + self :: __main__.MyError + r0 :: str + r1 :: object + r2 :: int +L0: + r0 = 'CODE' + r1 = CPyObject_GetAttr(self, r0) + r2 = unbox(int, r1) + return r2 + From 16286e235740e55f8d81eebdbd367f3f89e736a0 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 3 Sep 2026 14:24:17 +0100 Subject: [PATCH 4/5] Fix interpreted subclasses and update comments --- mypyc/irbuild/util.py | 10 ++---- mypyc/test-data/run-classes.test | 54 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index 4cc0deb1345ab..a2378fa55bcc1 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -127,9 +127,9 @@ def is_class_body_final(var: Var, ir: ClassIR, cdef: ClassDef, attr_rtype: RType # Non-extension classes keep class-level values in the class dict anyway. return False if ir.builtin_base: - # These can be subclassed from Python (we install no tp_new to reject it), - # and such instances reach native code, so this has the same shadowing - # hazard as allow_interpreted_subclasses below. + # These have no attribute slots at all (see prepare_methods_and_attributes), + # so reads go through the class dict and honour shadowing by an interpreted + # subclass, which folding would break. return False if dataclass_type(cdef) is not None: # Dataclass attribute definitions are special. @@ -140,10 +140,6 @@ def is_class_body_final(var: Var, ir: ClassIR, cdef: ClassDef, attr_rtype: RType # # TODO: Support thesse by having a separate initialized flag return False - if ir.allow_interpreted_subclasses: - # An interpreted subclass can shadow the name in its own class dict, which - # native reads of a folded or static value would not see. - return False return True diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index aff593ace56d0..52be4a230ed78 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -6623,7 +6623,7 @@ def make() -> i64: class C: # Native int types overlap with the error sentinel used by Final statics, so - # these keep their instance slot. See ai/bug-final-static-error-overlap.md. + # these keep their instance slot. N: Final = make() def get(self) -> i64: @@ -6678,3 +6678,55 @@ def test_any() -> None: def test_narrowing() -> None: assert through_narrowing(C()) == 23 assert through_narrowing(E()) == 23 + +[case testClassBodyFinalAttributeWithInterpretedSubclasses] +from mypy_extensions import mypyc_attr +from typing import Any, Final + +@mypyc_attr(allow_interpreted_subclasses=True) +class C: + plain: int = 7 + LITERAL: Final = 5 + ITEMS: Final = [0] * 100 + + def __init__(self) -> None: + self.inst = 1 + + def read_plain(self) -> int: + return self.plain + + def read_inst(self) -> int: + return self.inst + + def read_literal(self) -> int: + return self.LITERAL + + def read_items(self) -> list[int]: + return self.ITEMS + +def make_shadowing_subclass() -> Any: + type2: Any = type + return type2('Sub', (C,), {'plain': 99, 'inst': 98, 'LITERAL': 97, 'ITEMS': [1]}) + +def test_no_instance_slot() -> None: + t: Any = C + assert 'plain' in t.__mypyc_attrs__ + assert 'inst' in t.__mypyc_attrs__ + assert 'LITERAL' not in t.__mypyc_attrs__ + assert 'ITEMS' not in t.__mypyc_attrs__ + +def test_initializer_evaluated_once() -> None: + # Not re-evaluated per instance, and native and interpreted reads agree. + assert C().read_items() is C().read_items() + assert C().read_items() is C.ITEMS + assert C().read_items() is C().ITEMS + +def test_interpreted_subclass_shadowing() -> None: + # A class-dict entry in an interpreted subclass shadows only interpreted reads. + # This matches how plain and __init__-assigned attributes already behave. + o = make_shadowing_subclass()() + assert (o.read_plain(), o.plain) == (7, 99) + assert (o.read_inst(), o.inst) == (1, 98) + assert (o.read_literal(), o.LITERAL) == (5, 97) + assert o.read_items() is C.ITEMS + assert o.ITEMS == [1] From 1c1e8de4807e7591df7d12414c9d903504d9b404 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 3 Sep 2026 14:35:44 +0100 Subject: [PATCH 5/5] Update tests --- mypyc/test-data/irbuild-final.test | 21 +++++++++++++++++ mypyc/test-data/run-classes.test | 37 +++++++++--------------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/mypyc/test-data/irbuild-final.test b/mypyc/test-data/irbuild-final.test index f7bbd0fd15923..a4f56e284a5b5 100644 --- a/mypyc/test-data/irbuild-final.test +++ b/mypyc/test-data/irbuild-final.test @@ -353,3 +353,24 @@ L0: r2 = unbox(int, r1) return r2 +[case testClassBodyFinalThroughUnion] +from typing import Final, Union + +class C: + X: Final = 23 + +class E1(C): + pass + +class E2(C): + pass + +def same_decl(x: Union[E1, E2]) -> int: + # Neither item is the declaring class itself, but both inherit the same + # declaration, so this can still be folded. + return x.X +[out] +def same_decl(x): + x :: union[__main__.E1, __main__.E2] +L0: + return 46 diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 52be4a230ed78..77f029b6b80b5 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -6595,25 +6595,6 @@ def test_trait_final_via_any() -> None: assert o.LITERAL == 23 assert o.COMPUTED == 7 -[case testDataclassFinalAttributeKeepsInstanceSlot] -from dataclasses import dataclass -from typing import Any, Final - -@dataclass -class C: - X: Final = 23 - y: int = 1 - -def test_dataclass_final_keeps_instance_slot() -> None: - # Final in a dataclass body declares a field, so it must keep its instance - # slot: the interpreted __init__/__eq__/__repr__ that dataclasses generates - # reach for it. (Instantiating such a class is separately broken -- the Final - # getset has no setter, so the generated __init__ can't assign it -- so this - # only checks that the slot is still there.) - t: Any = C - assert 'X' in t.__mypyc_attrs__ - assert 'y' in t.__mypyc_attrs__ - [case testClassBodyFinalAttributeOfNativeIntType] from mypy_extensions import i64 from typing import Any, Final @@ -6643,11 +6624,15 @@ class C: class D: X: Final = 24 -class E(C): +class E1(C): + pass + +class E2(C): pass -def through_union_same_decl(x: Union[C, E]) -> int: - # Both items inherit the same declaration, so this can use C's value. +def through_union_same_decl(x: Union[E1, E2]) -> int: + # Two sibling subclasses, so neither item is the declaring class itself, but both + # inherit the same declaration and can use C's value. return x.X def through_union_different_decls(x: Union[C, D]) -> int: @@ -6662,8 +6647,8 @@ def through_narrowing(x: object) -> int: return x.X def test_union_same_decl() -> None: - assert through_union_same_decl(C()) == 23 - assert through_union_same_decl(E()) == 23 + assert through_union_same_decl(E1()) == 23 + assert through_union_same_decl(E2()) == 23 def test_union_different_decls() -> None: assert through_union_different_decls(C()) == 23 @@ -6672,12 +6657,12 @@ def test_union_different_decls() -> None: def test_any() -> None: assert through_any(C()) == 23 assert through_any(D()) == 24 - assert through_any(E()) == 23 + assert through_any(E1()) == 23 assert through_any(C) == 23 def test_narrowing() -> None: assert through_narrowing(C()) == 23 - assert through_narrowing(E()) == 23 + assert through_narrowing(E1()) == 23 [case testClassBodyFinalAttributeWithInterpretedSubclasses] from mypy_extensions import mypyc_attr