From 9d0736a1e3367aa3280b52c039b8ebe4e4815976 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:05:00 +0900 Subject: [PATCH] Feat: function-local virtual objects for N-11 classes in pure functions (N-12a, #143) --- .github/workflows/typedpython.yml | 2 +- docs/SPEC.md | 16 +- .../src/main/python/typedpython/cgen.py | 51 +++ .../src/main/python/typedpython/ir.py | 5 + .../src/main/python/typedpython/verify.py | 96 ++++- .../src/test/python/test_virtual_objects.py | 328 ++++++++++++++++++ 6 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 python-multiplatform-ksp/src/test/python/test_virtual_objects.py diff --git a/.github/workflows/typedpython.yml b/.github/workflows/typedpython.yml index 4c47c2ac..a15e8e2b 100644 --- a/.github/workflows/typedpython.yml +++ b/.github/workflows/typedpython.yml @@ -79,7 +79,7 @@ jobs: run: | env $(.venv/bin/python tools/sanitizers.py env) \ .venv/bin/python -m pytest -q -m "not measurement and not race_mutant" \ - src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py src/test/python/test_interval_proofs.py + src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py src/test/python/test_interval_proofs.py src/test/python/test_virtual_objects.py app-fixture: # #45: a TypedPython-compiled module calling Kotlin through the upcall table, in the app diff --git a/docs/SPEC.md b/docs/SPEC.md index 3315c815..e0f0e6eb 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -771,7 +771,21 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `gc.callbacks` of *unrelated* cyclic garbage run. The language reference leaves this to the implementation ("An implementation is allowed to postpone garbage collection or omit it altogether", Data model §3.1), and CPython itself changed it in 3.14 (incremental GC). Everything - else above stays exact. `Status: planned (design)`. + else above stays exact. `Status: N-12a implemented (below); the rest planned (design)`. + **N-12a (first step, #143): function-local virtual objects by scalar replacement.** In a `pure` function + whose every node is one of Const, Local, scalar BinOp/UnaryOp/Compare/And/Or, ToFloat, ToInt, Box, + Unbox, MathCall, Len, Index, NewArray, CopyArray, Is, IsExact, CheckExact, FieldGet, New (so no call, + no global or attribute read, no user code at all), an OBJ local whose every assignment is `New(C, ...)` + of one N-11 class, whose New arguments are `Box` of a scalar, a `str`/`None` Const or an OBJ parameter, + and whose every read is the object of a `FieldGet` of C, is virtual: no object is allocated and its + fields live in C locals. The class guard of `New` is kept (a changed class is a deopt, as before). + Because the function has no unknown code, nothing can observe a virtual object (`id`, `gc`, + `tracemalloc`, `getrefcount`) before it dies; a field read returns the very object that was stored, + so `is` on field values is unchanged. The verifier recomputes the set (`Function.virtual`, never + trusted from the front end; a forged entry is `verify/virtual-escape`); anything else compiles as + N-11. **Known difference:** an object whose allocation was removed raises no `MemoryError` where + CPython's allocation would (same nature as escape analysis in PyPy). Impure functions, `FieldSet` on a + virtual object, escape with materialisation, borrowed reads and regions are not part of N-12a. - **N-13** TypedPython native ints in impure functions (#232; INTENT §1.4: compiled code computes what CPython computes). An impure function cannot deopt after an effect, so its `int` locals were CPython objects and every `+`, `//`, `%` and comparison was a generic `PyNumber_*` / `PyObject_RichCompare` diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index 3c10356b..164c8a4c 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -270,6 +270,19 @@ def _ident(prefix: str, name: str) -> str: return f"{prefix}x_{name.encode('utf-8').hex()}" +def _vfield(local: str, k: int) -> str: + """The name of field `k` of the virtual object held by `local` (not a Python identifier).""" + return f"{local}\x00v{k}" + + +def _assigns(body): + for s in body: + if isinstance(s, ir.Assign): + yield s + for nested in (getattr(s, "then", ()), getattr(s, "orelse", ()), getattr(s, "body", ())): + yield from _assigns(nested) + + def _i64_lit(v: int) -> str: if isinstance(v, bool) or not isinstance(v, int) or not (_INT64_MIN <= v <= _INT64_MAX): raise CGenError(f"I64 constant out of range or not an int: {v!r}") @@ -934,6 +947,16 @@ def __init__(self, mg: _ModGen, f: ir.Function, fast: bool = False): if t not in _CT: raise CGenError(f"{f.name}: local {name} has type {t}") self.types[name] = t + # SPEC N-12a: a virtual object lives as one OBJ C local per field (`_vfield`), no object + self.virtual: dict[str, str] = {} + for x in f.virtual: + classes = {e.value.cls for e in _assigns(f.body) if e.target == x + and isinstance(e.value, ir.New)} + if len(classes) != 1 or self.types.get(x) is not Type.OBJ: + raise CGenError(f"{f.name}: virtual local {x} is not assigned one New class") + self.virtual[x] = next(iter(classes)) + for k in range(len(mg.cls(f.name, self.virtual[x]).fields)): + self.types[_vfield(x, k)] = Type.OBJ # emit primitives @@ -1475,6 +1498,11 @@ def ev_CheckMethod(self, e: ir.CheckMethod) -> str: return o def ev_FieldGet(self, e: ir.FieldGet) -> str: + if isinstance(e.obj, ir.Local) and e.obj.name in self.virtual: + cd = self.mg.cls(self.f.name, e.cls) + if e.cls != self.virtual[e.obj.name] or e.field not in cd.fields: + raise CGenError(f"{self.f.name}: FieldGet {e.cls}.{e.field} on virtual {e.obj.name}") + return self._own_copy(_ident("l", _vfield(e.obj.name, cd.fields.index(e.field)))) slot = self.mg.slot(self.f.name, e.cls, e.field) o = self._obj_operand(e.obj, "FieldGet") t = self.tmp("PyObject *") @@ -1493,6 +1521,26 @@ def ev_FieldGet(self, e: ir.FieldGet) -> str: self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") return t + def _assign_virtual(self, s: ir.Assign) -> None: + """`x = New(C, args)` of a virtual local: the same guards and argument order as `ev_New` + (LOAD_GLOBAL C first, then the arguments, then the class check; a failure is a deopt, the + function is pure), but no object: each argument moves into the field local of `x`.""" + e = s.value + cd = self.mg.cls(self.f.name, e.cls) + if not isinstance(e, ir.New) or len(e.args) != len(cd.fields) or not self.f.pure: + raise CGenError(f"{self.f.name}: virtual {s.target} needs a New of {len(cd.fields)} " + f"fields in a pure function") + k = self._class(e.cls) + self.emit(f"tp_s = {self._global_ok(e.cls)};") + self.emit("if (tp_s < 0) { tp_rc = -1; goto tp_exit; }") + self.emit("if (!tp_s) { tp_class_note_slow(&tp_st->cls_rt[%d]); tp_rc = 1; goto tp_exit; }" % k) + items = [self._obj_operand(a, "New argument") for a in e.args] + self.emit(f"tp_s = {self._current('NULL', e.cls)};") + self.emit("if (tp_s < 0) { tp_rc = -1; goto tp_exit; }") + self.emit("if (!tp_s) { tp_class_note_slow(&tp_st->cls_rt[%d]); tp_rc = 1; goto tp_exit; }" % k) + for i, t in enumerate(items): + self._own_move(_ident("l", _vfield(s.target, i)), t) + def ev_New(self, e: ir.New) -> str: cd = self.mg.cls(self.f.name, e.cls) if not cd.trivial_init: @@ -1752,6 +1800,9 @@ def st_Assign(self, s: ir.Assign) -> None: if s.target in self.local_arrays: self._assign_array(s, self.local_arrays[s.target]) return + if s.target in self.virtual: + self._assign_virtual(s) + return t = self.types.get(s.target) if t is None: raise CGenError(f"{self.f.name}: assignment to unknown local {s.target}") diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py index e6470808..45fc41bc 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/ir.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -778,6 +778,11 @@ class Function: # back end does not replace a module global with it; it installs a descriptor in the class dict # (cgen.py "Methods"). None: a module-level function. owner: str | None = None + # SPEC N-12a: OBJ locals that are virtual objects (scalar-replaced `New(C, ...)`: no object is + # allocated, the fields live in C locals). Set only by `verify`, which recomputes the set and + # rejects a function whose declared entries differ (`verify/virtual-escape`). The front end + # leaves it empty. + virtual: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index c8126ed3..350314f9 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -260,6 +260,7 @@ STRUCTURE = "verify/structure" CONDITION_ONLY = "verify/condition-only" PROVEN = "verify/proven-overflow" +VIRTUAL = "verify/virtual-escape" REDO = "verify/redo" ENTRY_OPEN = "verify/entry-global-open" ARRAY_OPEN = "verify/array-open" @@ -1667,7 +1668,100 @@ def run(self) -> Function: self.error(ARRAY_STORED, f"array '{p.name}' has stored={p.stored} but the body " f"{'stores' if p.name in self.stored else 'never stores'}" f" into it") - return dataclasses.replace(fn, body=body) + virtual = _virtual_locals(fn, body) if not self.diags else {} + if fn.virtual and set(fn.virtual) != set(virtual): + self.error(VIRTUAL, f"virtual locals {sorted(fn.virtual)} declared, but only " + f"{sorted(virtual)} are provably virtual (SPEC N-12a)") + return dataclasses.replace(fn, body=body, virtual=tuple(sorted(virtual))) + + +# --- virtual objects (SPEC N-12a) ---------------------------------------------------------------------- + +# The expression nodes a function may contain for its objects to be virtual: none of them runs user +# code, reads a global or an attribute, or calls anything (a Call can redo in the interpreter), so +# nothing can observe a live virtual object. OBJ BinOp is excluded in `_virtual_locals`. +_VIRTUAL_OK = frozenset({Const, Local, BinOp, UnaryOp, Compare, And, Or, ToFloat, ToInt, Box, Unbox, + MathCall, Len, Index, NewArray, CopyArray, Is, IsExact, CheckExact, + FieldGet, New}) +_VIRTUAL_STMTS = frozenset({Assign, StoreIndex, If, While, ForRange, Return, Break, Continue}) + + +def _exprs_of(node: object): + """Every ir expression directly under `node` (an expression or statement), in field order.""" + for f in dataclasses.fields(node): + v = getattr(node, f.name) + for x in (v if isinstance(v, tuple) else (v,)): + if isinstance(x, ir.Expr): + yield x + + +def _virtual_locals(fn: Function, body: tuple) -> dict[str, str]: + """local -> class of the virtual objects of `fn` (SPEC N-12a), or {}. A local is virtual when + `fn` is pure and contains only `_VIRTUAL_OK` nodes (no unknown code at all), and the local is + never a parameter, is only assigned `New(C, args)` of one class with args that cannot run a + finalizer when dropped (Box of a scalar, a str/None Const, an OBJ parameter), and is read only as + the object of a `FieldGet` of C. Anything else stays an ordinary object (N-11).""" + if fn.pure is not True or not isinstance(body, tuple): + return {} + params = {p.name for p in fn.params} + safe_reads: dict[str, list[str]] = {} + other_reads: set[str] = set() + ok = True + + def expr(e: object) -> None: + nonlocal ok + if type(e) not in _VIRTUAL_OK or (type(e) is BinOp and e.type is Type.OBJ): + ok = False + return + if type(e) is FieldGet and type(e.obj) is Local: + safe_reads.setdefault(e.obj.name, []).append(e.cls) + return + if type(e) is Local: + other_reads.add(e.name) + for x in _exprs_of(e): + expr(x) + + def block(stmts: object) -> None: + nonlocal ok + if not isinstance(stmts, tuple): + ok = False + return + for st in stmts: + if type(st) not in _VIRTUAL_STMTS: + ok = False + return + for x in _exprs_of(st): + expr(x) + for nested in (getattr(st, "then", ()), getattr(st, "orelse", ()), getattr(st, "body", ())): + block(nested) + + block(body) + if not ok: + return {} + assigned: dict[str, list[object]] = {} + _assigned_values(body, assigned) + out: dict[str, str] = {} + for name, values in assigned.items(): + if (fn.locals.get(name) is not Type.OBJ or name in params or name in other_reads + or name not in safe_reads): + continue + if not all(type(v) is New and isinstance(v.cls, str) for v in values): + continue + classes = {v.cls for v in values} + if len(classes) != 1 or set(safe_reads[name]) != classes: + continue + if not all(_virtual_arg(a, params) for v in values for a in v.args): + continue + out[name] = next(iter(classes)) + return out + + +def _virtual_arg(a: object, params: set[str]) -> bool: + if type(a) is Box: + return a.operand.type in ir.SCALARS + if type(a) is Const: + return a.type is Type.OBJ + return type(a) is Local and a.type is Type.OBJ and a.name in params def _assigned(body: object, counts: dict[str, int] | None = None) -> dict[str, int]: diff --git a/python-multiplatform-ksp/src/test/python/test_virtual_objects.py b/python-multiplatform-ksp/src/test/python/test_virtual_objects.py new file mode 100644 index 00000000..d3baeba3 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_virtual_objects.py @@ -0,0 +1,328 @@ +"""TypedPython virtual objects, first step (SPEC N-12a, #143): function-local scalar replacement. + +An OBJ local that is only ever assigned `New(C, ...)` and only ever read as the object of a +`FieldGet`, in a pure function with no unknown code, is virtual: no object is allocated. The +verifier recomputes the set (`Function.virtual`); the back end trusts only the verified set. + +Red record: this file was written before the implementation. Before it, `Function` had no `virtual` +field and the generated C allocated every object through `tp_new_fixed`. +""" +from __future__ import annotations + +import dataclasses +import shutil +import sys +import textwrap +import threading + +import pytest + +from test_cgen import OUT_ROOT, I64, F64, BOOL, OBJ, c, L, fn, runtime_dir, need_runtime +from typedpython import cgen, ir, verify +from typedpython.ir import ( + Assign, BinOp, BinOpKind, Box, Call, CallObject, ClassDecl, Compare, CompareKind, Const, + ExprStmt, FieldGet, Global, If, Is, Module, New, Param, Return, Unbox, While, +) + +VIRTUAL_ESCAPE = "verify/virtual-escape" + +SOURCE = textwrap.dedent('''\ + """typedpython virtual object test module, the interpreted reference.""" + + class Pt: + __slots__ = ("x", "y") + + def __init__(self, x, y): + self.x = x + self.y = y + + class Qt: + __slots__ = ("x", "y") + + def __init__(self, x, y): + self.x = x + self.y = y + + def helper(a): + return a + 1.0 + + def norm2(a, b): + p = Pt(a, b) + return p.x * p.x + p.y * p.y + + def ident(o): + p = Pt(o, None) + return p.x + + def loop_sum(n): + t = 0 + i = 0 + while i < n: + p = Pt(i, i + 1) + t = t + p.x + p.y + i = i + 1 + return t + + def escapes(a, b): + p = Pt(a, b) + return p + + def with_call(a, b): + p = Pt(helper(a), b) + return p.x + p.y + ''') + +PT = ClassDecl("Pt", ("x", "y"), True) +QT = ClassDecl("Qt", ("x", "y"), True) + + +def fget(local, field, cls="Pt"): + return FieldGet(OBJ, L(local, OBJ), cls, field) + + +def f_norm2(): + a, b = L("a", I64), L("b", I64) + px = Unbox(I64, fget("p", "x")) + py = Unbox(I64, fget("p", "y")) + return fn("norm2", [Param("a", I64), Param("b", I64)], I64, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (Box(OBJ, a), Box(OBJ, b)))), + Return(BinOp(I64, BinOpKind.ADD, BinOp(I64, BinOpKind.MUL, px, px), + BinOp(I64, BinOpKind.MUL, py, py)))], + pure=True, may_deopt=True) + + +def f_ident(): + return fn("ident", [Param("o", OBJ)], OBJ, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (L("o", OBJ), Const(OBJ, None)))), + Return(fget("p", "x"))], + pure=True, may_deopt=False) + + +def f_loop_sum(): + n, i, t = L("n", I64), L("i", I64), L("t", I64) + px = Unbox(I64, fget("p", "x")) + py = Unbox(I64, fget("p", "y")) + return fn("loop_sum", [Param("n", I64)], I64, {"i": I64, "t": I64, "p": OBJ}, + [Assign("t", c(0)), Assign("i", c(0)), + While(Compare(BOOL, CompareKind.LT, i, n), + (Assign("p", New(OBJ, "Pt", (Box(OBJ, i), + Box(OBJ, BinOp(I64, BinOpKind.ADD, i, c(1)))))), + Assign("t", BinOp(I64, BinOpKind.ADD, BinOp(I64, BinOpKind.ADD, t, px), py)), + Assign("i", BinOp(I64, BinOpKind.ADD, i, c(1))))), + Return(t)], + pure=True, may_deopt=True) + + +def f_escapes(): + a, b = L("a", I64), L("b", I64) + return fn("escapes", [Param("a", I64), Param("b", I64)], OBJ, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (Box(OBJ, a), Box(OBJ, b)))), Return(L("p", OBJ))], + pure=True, may_deopt=False) + + +def f_helper(): + return fn("helper", [Param("a", I64)], I64, {}, + [Return(BinOp(I64, BinOpKind.ADD, L("a", I64), c(1)))], pure=True, may_deopt=True) + + +def f_with_call(): + a, b = L("a", I64), L("b", I64) + px = Unbox(I64, fget("p", "x")) + py = Unbox(I64, fget("p", "y")) + return fn("with_call", [Param("a", I64), Param("b", I64)], I64, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (Box(OBJ, Call(I64, "helper", (a,))), Box(OBJ, b)))), + Return(BinOp(I64, BinOpKind.ADD, px, py))], + pure=True, may_deopt=True) + + +def module(*funcs): + return Module(name="tp_virtual", functions=tuple(funcs), classes=(PT, QT)) + + +def proved(*funcs): + m, diags = verify.verify(module(*funcs)) + return {g.name: g for g in m.functions}, diags + + +# --- the verifier recomputes the set ------------------------------------------------------------------- + +def test_a_local_only_read_by_field_get_is_virtual(): + fs, diags = proved(f_norm2()) + assert not diags + assert fs["norm2"].virtual == ("p",) + + +def test_a_local_assigned_in_a_loop_is_virtual(): + fs, diags = proved(f_loop_sum()) + assert not diags + assert fs["loop_sum"].virtual == ("p",) + + +def test_an_object_parameter_may_be_a_field_value(): + fs, diags = proved(f_ident()) + assert not diags and fs["ident"].virtual == ("p",) + + +def test_returning_the_object_keeps_it_real(): + fs, diags = proved(f_escapes()) + assert not diags and fs["escapes"].virtual == () + + +def test_a_call_in_the_function_keeps_every_object_real(): + fs, diags = proved(f_helper(), f_with_call()) + assert not diags and fs["with_call"].virtual == () + + +def test_is_on_the_object_keeps_it_real(): + base = f_norm2() + g = dataclasses.replace(base, body=( + base.body[0], If(Is(BOOL, L("p", OBJ), Const(OBJ, None)), (Return(c(0)),)), base.body[1])) + fs, diags = proved(g) + assert not diags and fs["norm2"].virtual == () + + +def test_a_field_of_another_object_as_argument_keeps_it_real(): + q = FieldGet(OBJ, L("q", OBJ), "Qt", "x") + g = fn("fromq", [Param("q", OBJ, cls="Qt")], OBJ, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (q, Const(OBJ, None)))), Return(fget("p", "x"))], + pure=True, may_deopt=False) + fs, diags = proved(g) + assert not diags and fs["fromq"].virtual == () + + +def test_two_classes_assigned_to_one_local_keep_it_real(): + a = L("a", I64) + g = fn("two", [Param("a", I64)], OBJ, {"p": OBJ}, + [If(Compare(BOOL, CompareKind.GT, a, c(0)), + (Assign("p", New(OBJ, "Pt", (Box(OBJ, a), Const(OBJ, None)))),), + (Assign("p", New(OBJ, "Qt", (Box(OBJ, a), Const(OBJ, None)))),)), + Return(Const(OBJ, None))], + pure=True, may_deopt=False) + fs, diags = proved(g) + assert not diags and fs["two"].virtual == () + + +def test_an_impure_function_has_no_virtual_object(): + a, b = L("a", I64), L("b", I64) + g = fn("impure", [Param("a", I64), Param("b", I64)], I64, {"p": OBJ}, + [Assign("p", New(OBJ, "Pt", (Box(OBJ, a), Box(OBJ, b)))), + ExprStmt(CallObject(OBJ, Global(OBJ, "helper"), (Box(OBJ, a),), ())), + Return(a)], + pure=False, may_deopt=False) + fs, diags = proved(g) + assert not diags and fs["impure"].virtual == () + + +def test_a_forged_virtual_entry_is_rejected(): + forged = dataclasses.replace(f_escapes(), virtual=("p",)) + m, diags = verify.verify(module(forged)) + assert any(d.rule == VIRTUAL_ESCAPE for d in diags) + assert m.functions == () + + +def test_mutation_without_the_unknown_code_rule_the_call_case_flips(monkeypatch): + base, _ = proved(f_helper(), f_with_call()) + assert base["with_call"].virtual == () + monkeypatch.setattr(verify, "_VIRTUAL_OK", verify._VIRTUAL_OK | {ir.Call}) + mutated, _ = proved(f_helper(), f_with_call()) + assert mutated["with_call"].virtual == ("p",) # the rule is what keeps it real + + +# --- the back end: same results as the interpreter ----------------------------------------------------- + +def build(name, funcs): + from typedpython import cbuild + need_runtime() + d = OUT_ROOT / name + if d.exists(): + shutil.rmtree(d) + d.mkdir(parents=True) + src = d / f"{name}.py" + src.write_text(SOURCE) + mod, diags = verify.verify(Module(name=name, functions=tuple(funcs), classes=(PT, QT))) + assert not diags, diags + text = cgen.generate(mod, src) + so = cbuild.build(text, name, d, runtime_dir=runtime_dir()) + return cbuild.load(name, so), text + + +@pytest.fixture(scope="module") +def virt(): + return build("tp_virtual_v", (f_norm2(), f_ident(), f_loop_sum())) + + +@pytest.fixture(scope="module") +def real(): + return build("tp_virtual_r", (f_escapes(), f_helper(), f_with_call())) + + +def interp(m, name): + return m.__typedpython_interpreted__[name] + + +def test_virtual_functions_allocate_nothing_through_tp_new_fixed(virt, real): + assert "tp_new_fixed" not in virt[1] + assert "tp_new_fixed" in real[1] # the same call is there when the object is real + + +def test_virtual_function_matches_the_interpreter(virt): + m, _ = virt + for a, b in [(1, 2), (0, -3), (3000000000, 1), (2 ** 40, 1), (1.5, 2.5)]: # last two: deopt + assert m.norm2(a, b) == interp(m, "norm2")(a, b) + for n in (0, 1, 7, 1000): + assert m.loop_sum(n) == interp(m, "loop_sum")(n) + + +def test_a_field_read_returns_the_stored_object_itself(virt): + m, _ = virt + o = object() + assert m.ident(o) is o + before = sys.getrefcount(o) + for _ in range(1000): + m.ident(o) + assert sys.getrefcount(o) == before + + +def test_an_escaping_function_still_returns_a_real_pt(real): + m, _ = real + r = m.escapes(1, 2) + assert type(r) is m.Pt and (r.x, r.y) == (1, 2) + assert m.with_call(1, 2) == interp(m, "with_call")(1, 2) + + +def test_class_change_deopts_to_the_same_result(virt): + m, _ = virt + saved = m.Pt + + class Pt2: + __slots__ = ("x", "y") + + def __init__(self, x, y): + self.x, self.y = x + 1, y + + try: + m.Pt = Pt2 + assert m.norm2(1, 2) == interp(m, "norm2")(1, 2) == 8 + finally: + m.Pt = saved + assert m.norm2(1, 2) == 5 + + +def test_virtual_functions_are_safe_on_many_threads(virt): + m, _ = virt + errors = [] + + def work(): + try: + for i in range(2000): + assert m.loop_sum(i % 20) == interp(m, "loop_sum")(i % 20) + assert m.norm2(i, 1) == interp(m, "norm2")(i, 1) + except BaseException as e: # noqa: BLE001 + errors.append(e) + + ts = [threading.Thread(target=work) for _ in range(4)] + for t in ts: + t.start() + for t in ts: + t.join() + assert not errors