feat!: Mutable structs#1793
Conversation
|
This PR contains breaking changes to the public Python API. Breaking changes summary |
| stack += [ | ||
| FieldAccess(place, field, place.defined_at) for field in place.ty.fields | ||
| ] | ||
| if place.ty.fields: | ||
| stack += [ | ||
| FieldAccess(place, field, place.defined_at) | ||
| for field in place.ty.fields | ||
| ] | ||
| else: | ||
| yield place |
There was a problem hiding this comment.
Empty struct places should be treated as leafs, before we just omitted them
f02de59 to
c0ee238
Compare
|
| Branch | mk/mutable-structs |
| Testbed | Linux |
Click to view all benchmark results
| Benchmark | hugr_bytes | Benchmark Result bytes x 1e3 (Result Δ%) | Upper Boundary bytes x 1e3 (Limit %) | hugr_nodes | Benchmark Result nodes (Result Δ%) | Upper Boundary nodes (Limit %) |
|---|---|---|---|---|---|---|
| tests/benchmarks/test_big_array.py::test_big_array_compile | 📈 view plot 🚷 view threshold | 158.77 x 1e3(0.00%)Baseline: 158.77 x 1e3 | 160.36 x 1e3 (99.01%) | 📈 view plot 🚷 view threshold | 6,641.00(0.00%)Baseline: 6,641.00 | 6,707.41 (99.01%) |
| tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_compile | 📈 view plot 🚷 view threshold | 27.54 x 1e3(0.00%)Baseline: 27.54 x 1e3 | 27.81 x 1e3 (99.01%) | 📈 view plot 🚷 view threshold | 1,074.00(0.00%)Baseline: 1,074.00 | 1,084.74 (99.01%) |
| tests/benchmarks/test_queue_push_pop.py::test_queue_push_benchmark_compile | 📈 view plot 🚷 view threshold | 10.91 x 1e3(0.00%)Baseline: 10.91 x 1e3 | 11.02 x 1e3 (99.01%) | 📈 view plot 🚷 view threshold | 308.00(0.00%)Baseline: 308.00 | 311.08 (99.01%) |
| tests/benchmarks/test_queue_push_pop.py::test_queue_push_pop_benchmark_compile | 📈 view plot 🚷 view threshold | 14.84 x 1e3(0.00%)Baseline: 14.84 x 1e3 | 14.99 x 1e3 (99.01%) | 📈 view plot 🚷 view threshold | 435.00(0.00%)Baseline: 435.00 | 439.35 (99.01%) |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1793 +/- ##
==========================================
+ Coverage 92.99% 93.02% +0.03%
==========================================
Files 135 135
Lines 13068 13138 +70
==========================================
+ Hits 12152 12222 +70
Misses 916 916 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
c0ee238 to
7de7032
Compare
| # We have to check the item type to determine if we can move out of the | ||
| # subscript. | ||
| if not is_inout_arg and not subscript.ty.copyable: | ||
| # We are only allowed to move copyable data out of a subscript | ||
| if not is_inout_arg and not node.place.ty.copyable: |
There was a problem hiding this comment.
Driveby: Whether we can move out of a subscript depends on the type of the final projection, not the whole array element. For example:
@guppy.struct(frozen=False)
class S:
q: qubit
x: int
@guppy
def foo(xs: array[S, 42]) -> None:
a = xs[0].x # Ok since the x projection is int, so we can copy it
b = xs[0].q # Error: Cannot move affine value out of array This came up while writing the array test, so I fixed it here
nicolaassolini-qntm
left a comment
There was a problem hiding this comment.
I think is good just little comments!
| 12 | if b: | ||
| 13 | t = s | ||
| 14 | return s | ||
| | ^ Field `s.x` cannot be returned ... |
There was a problem hiding this comment.
should here say "Variable s cannot be returned ..." as in the mutable_affine_struct case?
| 11 | def foo(s: MyStruct @owned, b: bool) -> None: | ||
| 12 | while b: | ||
| 13 | t = s | ||
| | ^ Field `s.x` cannot be moved ... |
There was a problem hiding this comment.
same, why s.x and not s?
| 14 | t.x += 1 | ||
| | ^ Mutation of classical fields is not supported | ||
| | ^^^ Struct `MyStruct` is immutable | ||
|
|
There was a problem hiding this comment.
adding a Note that says "Set frozen=False to make the struct mutable"?
| @@ -145,42 +146,72 @@ class Scope(Locals[PlaceId, Place]): | |||
| """ | |||
|
|
|||
| parent_scope: "Scope | None" | |||
There was a problem hiding this comment.
you commented all the fields a part of this, a small comment here?
| def used(self, x: PlaceId) -> Use | None: | ||
| """Checks whether a place has already been used.""" | ||
| if x in self.vars: | ||
| return self.used_local.get(x, None) | ||
| assert self.parent_scope is not None | ||
| return self.parent_scope.used(x) | ||
| if x in self.used_projections: | ||
| return self.used_projections[x] | ||
| if self.parent_scope: | ||
| return self.parent_scope.used(x) | ||
| return None |
There was a problem hiding this comment.
the new implementation of used only checks for place in used_projections or in the parent_scope, is it fine? before we was checking in used_local
| return (subscript, literal_idx) | ||
|
|
||
|
|
||
| def check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None: |
There was a problem hiding this comment.
| def check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None: | |
| def _check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None: |
if it is meant to be just a local helper, (same for the other new helper functions)
| for parent in parent_places(place, include_self=True): | ||
| match parent.ty: | ||
| case StructType(frozen=False): | ||
| mutable = True | ||
| case _: | ||
| mutable = False | ||
| if mutable and (prev_use := scope.used(parent.id)): | ||
| err = ParentAlreadyUsedError(use.node, place, use.kind) | ||
| sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind) | ||
| err.add_sub_diagnostic(sub) | ||
| raise GuppyError(err) |
There was a problem hiding this comment.
| for parent in parent_places(place, include_self=True): | |
| match parent.ty: | |
| case StructType(frozen=False): | |
| mutable = True | |
| case _: | |
| mutable = False | |
| if mutable and (prev_use := scope.used(parent.id)): | |
| err = ParentAlreadyUsedError(use.node, place, use.kind) | |
| sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind) | |
| err.add_sub_diagnostic(sub) | |
| raise GuppyError(err) | |
| for parent in parent_places(place, include_self=True): | |
| match parent.ty: | |
| case StructType(frozen=False): | |
| if prev_use := scope.used(parent.id): | |
| err = ParentAlreadyUsedError(use.node, place, use.kind) | |
| sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind) | |
| err.add_sub_diagnostic(sub) | |
| raise GuppyError(err) | |
|
I also requested Copilot, sometimes it is useful, at least it is better than me in spotting typos or "ugly" comments |
There was a problem hiding this comment.
Pull request overview
This PR introduces mutable (non-frozen) structs by adding an optional frozen kwarg to @guppy.struct (defaulting to True for backwards compatibility). To preserve soundness with mutable/affine structs, it updates linearity checking to track non-leaf projections so that moving a parent prevents later use of its projections (even if the leaf fields are individually copyable), and adds/updates integration + error tests accordingly.
Changes:
- Add
frozen: boolto@guppy.struct(defaultTrue) and plumb it through internal struct definitions/types. - Make mutable structs intrinsically non-copyable; allow mutation only when permitted (with a temporary compatibility exception for affine fields on frozen structs).
- Update the linearity checker to track uses via projections and introduce
ParentAlreadyUsedError; add extensive regression tests.
Reviewed changes
Copilot reviewed 49 out of 49 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/integration/test_linear.py | Adds integration coverage for mutating struct fields across control flow. |
| tests/integration/test_inout.py | Adds a mutating struct method test (self.x += ...). |
| tests/integration/test_array.py | Adds coverage for mutating struct fields inside arrays/subscripts. |
| tests/error/struct_errors/mutate_classical.err | Updates expected error from “Unsupported” to “Immutable” for frozen structs. |
| tests/error/linear_errors/parent_moved1.py | New regression: moved parent prevents later projection use. |
| tests/error/linear_errors/parent_moved1.err | Expected diagnostic for parent/projection move violation. |
| tests/error/linear_errors/parent_moved2.py | New regression for conditional parent move then projection use. |
| tests/error/linear_errors/parent_moved2.err | Expected diagnostic for conditional parent move case. |
| tests/error/linear_errors/parent_moved3.py | New regression: branch assigns vs moves, then projection use. |
| tests/error/linear_errors/parent_moved3.err | Expected diagnostic for branch assign/move mix. |
| tests/error/linear_errors/parent_moved4.py | New regression: move field then later return nested projection. |
| tests/error/linear_errors/parent_moved4.err | Expected diagnostic for moved field then projection return. |
| tests/error/linear_errors/parent_moved5.py | New regression involving walrus/boolean context moving parent. |
| tests/error/linear_errors/parent_moved5.err | Expected diagnostic for walrus-based parent move. |
| tests/error/linear_errors/parent_moved6.py | New regression: move in loop then return projection. |
| tests/error/linear_errors/parent_moved6.err | Expected diagnostic for loop move then projection return. |
| tests/error/linear_errors/parent_moved7.py | New regression: repeated move inside loop for nested mutable field. |
| tests/error/linear_errors/parent_moved7.err | Expected diagnostic for repeated-move-in-loop scenario. |
| tests/error/linear_errors/mutable_empty_struct1.py | New regression: empty mutable structs are affine/non-copyable. |
| tests/error/linear_errors/mutable_empty_struct1.err | Expected diagnostic for empty mutable struct copy violation. |
| tests/error/linear_errors/mutable_empty_struct2.py | Variant regression with conditional move of empty mutable struct. |
| tests/error/linear_errors/mutable_empty_struct2.err | Expected diagnostic for conditional empty-struct move. |
| tests/error/linear_errors/mutable_affine_struct1.py | New regression: mutable structs treated affine across moves. |
| tests/error/linear_errors/mutable_affine_struct1.err | Expected diagnostic for basic mutable-struct move then reuse. |
| tests/error/linear_errors/mutable_affine_struct2.py | Regression: conditional move then return mutable struct. |
| tests/error/linear_errors/mutable_affine_struct2.err | Expected diagnostic for conditional move/return. |
| tests/error/linear_errors/mutable_affine_struct3.py | Regression: else-branch move then return. |
| tests/error/linear_errors/mutable_affine_struct3.err | Expected diagnostic for else-branch move then return. |
| tests/error/linear_errors/mutable_affine_struct4.py | Regression: move then conditional return. |
| tests/error/linear_errors/mutable_affine_struct4.err | Expected diagnostic for move then conditional return. |
| tests/error/linear_errors/mutable_affine_struct5.py | Regression: walrus move then return. |
| tests/error/linear_errors/mutable_affine_struct5.err | Expected diagnostic for walrus move then return. |
| tests/error/linear_errors/mutable_affine_struct6.py | Regression: loop move then return. |
| tests/error/linear_errors/mutable_affine_struct6.err | Expected diagnostic for loop move then return. |
| tests/error/linear_errors/mutable_affine_struct7.py | Regression: repeated move inside loop. |
| tests/error/linear_errors/mutable_affine_struct7.err | Expected diagnostic for repeated-move-in-loop. |
| tests/error/linear_errors/affine_struct_array.py | New regression: array subscript returning affine struct is illegal. |
| tests/error/linear_errors/affine_struct_array.err | Expected diagnostic for subscript-returned non-copyable array element. |
| tests/error/inout_errors/field_borrow_twice.py | New regression: borrowing same mutable field twice is illegal. |
| tests/error/inout_errors/field_borrow_twice.err | Expected diagnostic for duplicate borrow of non-copyable field. |
| tests/error/inout_errors/field_already_used.py | New regression: consume then borrow same mutable field is illegal. |
| tests/error/inout_errors/field_already_used.err | Expected diagnostic for borrow-after-consume of field. |
| guppylang/src/guppylang/decorator.py | Adds frozen kwarg plumbing into RawStructDef creation (default True). |
| guppylang-internals/src/guppylang_internals/tys/ty.py | Makes StructType copyability depend on frozen. |
| guppylang-internals/src/guppylang_internals/definition/struct.py | Threads frozen through Raw/Parsed/Checked struct defs. |
| guppylang-internals/src/guppylang_internals/checker/stmt_checker.py | Enforces immutability on field assignment (new StructImmutableError). |
| guppylang-internals/src/guppylang_internals/checker/linearity_checker.py | Tracks projection uses + parent-moved checks; new helper functions. |
| guppylang-internals/src/guppylang_internals/checker/errors/type_errors.py | Adds StructImmutableError. |
| guppylang-internals/src/guppylang_internals/checker/errors/linearity.py | Adds ParentAlreadyUsedError diagnostic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| elif not place.ty.copyable: | ||
| raise GuppyTypeError(ComprAlreadyUsedError(use.node, place, use.kind)) | ||
|
|
||
| # Also check if parents have been moved | ||
| check_mutable_parent_already_used(place, use, self.scope) |
| use_scope = scopes[use_bb] | ||
| place = use_scope[x] | ||
| use = use_scope.used_parent[x] | ||
| if not place.ty.copyable and (prev_use := scope.used(x)): | ||
| use = use_scope.used_parent[x] |
Closes #1775
This PR adds the optional
frozenkwarg to the@guppy.structdecorator. When set toTrue, the struct will support mutation of both classical and linear fields.To make this change non-breaking, structs defaults to
frozen=Trueand mutation of affine fields is allowed even iffrozen=True. See #1776 for the issue for changing the default behaviour.The main difficulty with this feature is linearity checking: If we use a projection of a mutable struct, we need to make sure that the struct value itself has not been moved. In particular, values can now be affine, even if all their projections are copyable individually. This clashes with the previous approach of linearity checking where struct values were tracked by just tracking all leaf projections.
Initially, I was planning to rewrite the entire linearity checker to stop decomposing places into their leaf projections. I still think this would be a good idea, however it felt too risky to get everything right in time for the 1.0 release. Instead, I found an alternative solution: Now, we track uses of values via all their projections, not only the leaf ones. This retains enough information to check if parents have been moved.
I recommend reviewing commit by commit.
Note that this PR is only breaking for
guppylang-internals:BREAKING CHANGE:
RawStructDef,ParsedStructDef, andCheckedStructDefhave a new required fieldfrozenBREAKING CHANGE:
linearity_checker.Scope.usenow requires aPlaceinstead of aPlaceId