Overview
The purpose of the PCG Analysis is to provide clients with the following:
- The PCG data structure representing the state of ownership and borrowing of Rust places at arbitrary program points within a Rust function
- For any pair of consecutive program points, an ordered list of actions that describe the transformation of the PCG of to the PCG of .
PCG Analysis Algorithm
Key Concepts:
- The PCG Analysis Algorithm operates on the MIR Body of a Rust function and returns a PCG Analysis of the function.
- A PCG Analysis contains PCG Data for every reachable1 MIR location in the Body2.
- The PCG Data is a tuple of PCG States and PCG Action Data.
The PCG States of a MIR location is a list of the PCGs computed at that location, concretely:
- An Initial PCG, followed by
- One PCG for every PCG evaluation phase
The PCG Action Data of a MIR location contains a list of PCG Actions for each evaluation phase in the location (i.e. the actions performed at that phase).
The PCG Dataflow Analysis
The PCG Analysis Algorithm is implemented as a MIR dataflow
analysis using PcgDomainData as
the domain. PcgDomainData contains a PCGData value and other relevant
metadata (e.g the associated basic block). Notably, the analysis only analyzes
the statements in each basic block one time. Conceptually, this property is
ensured because the final state at a loop head is computed upon entering it in
the analysis (without having first seen the body).
We note that the behaviour of the join operation on PCGDomainData requires
tracking of what blocks have been previously joined (this is basically a
consequence of the interface of the MIR dataflow analysis). The PCGDomainData
join operation joins the PCG of block into the PCG at
block as follows:
- If no block has ever been joined into , then set =
- If the edge from to is not a back edge3 of a loop, then is joined into using the algorithm defined here
Because the join does not modify the PCG for back edges, the analysis can be completed without ever having to re-analyse the statements within a block.
Our implementation should also be checking that the PCG generated at the loop head is valid w.r.t the state at the back edge here, but this is not happening yet.
PCG Data Structure
The PCG data structure represents the state of ownership and borrowing of Rust places at an arbitrary program point.
It consists of three components:
- The Owned PCG, which describes the state of owned places
- The Borrow State, which describes borrowed memory (borrowed places and lifetime projections) and borrow relations, and also some auxillary data structures
- The Place Capabilities which is a map from places to capabilities
Owned PCG
The Owned PCG is a forest, where the root of each tree in the forest is a MIR
Local.
Each tree in the forest is defined by a set of place expansions, which describe how unpacked all of the owned places are. For each expansion in the set, the base is a node in the forest and are its children. We note that each expansion can be similarly interpreted as a hyperedge
Each local in the body of a MIR function is either unallocated or allocated in the Owned PCG. A local is allocated iff there exists a corresponding root node in the Owned PCG, otherwise it is unallocated.
The operation allocate in the Owned PCG requires that is not allocated, and adds a new root for . The deallocate operation removes the forest rooted at .
Borrow State
The Borrow State is a tuple containing a set of Validity Conditions that describes the set of paths leading to the current block and a Borrows Graph, a directed acyclic hypergraph which describes borrowed places, sets of borrows, and their relation to one another.
In our implementation the Borrows Graph is represented as a map from PCG hyperedges to validity conditions.
Because a borrow created within a block exists only for executions that visit that block, we label new borrows using the validity conditions of the block in which they were created.
Place Capabilities
The design for how place capabilities are represented and computed is being updated. In the new design, capabilities are computed from the initialisation state and borrow state rather than stored in an explicit map. See Computing Place Capabilities for details.
Place capabilities is a partial map from places to capabilities.
-
A MIR location reachable iff its basic block is reachable from the start block in the CFG without traversing unwind or fake edges (the latter kind do not correspond to the actual control flow of the function). The original reason for only considering reachable edges was to improve performance; removing this constraint (and instead considering all locations) would be simple to change in the implementation. ↩
-
The PCG analysis algorithm is implemented as a MIR dataflow analysis defined by the rust compiler crate. In the implementation, the PCG Data is computed for every reachable MIR location during the algorithm execution itself, but only the PCG Data for the entry state of each basic block is stored. The PCG Data for an arbitrary location within a block is re-computed by applying the dataflow analysis transfer function from the entry state. ↩
-
In the join implementation, we an edge from to is a back edge if dominates in the CFG ↩
Definitions
Types
Type Contains
A type contains a type , iff:
- , or
- is an ADT and contains a field and contains
- and contains
Types Containing Lifetimes
A type contains a lifetime iff contains the type for some type . A lifetime is nested in a type iff contains a type and contains . We extend these concept to places: a place contains a lifetime iff contains ; is nested in iff is nested in . A lifetime projection is nested if is nested in .
PCG Evaluation Phase
The PCG Evaluation Phases are:
PreOperandsPostOperandsPreMainPostMain
For every MIR location, a seperate PCG is generated for each phase. They represent the following:
PreOperands- A reorganization of the initial state1 to prepare to apply the effects of evaluating the operands in the statementPostOperands- The result of applying the operand effects on thePreOperandsstatePreMain- A reorganization of thePostOperandsstate to prepare to apply any other effects of the statementPostMain- The result of applying any other effects of the statement to thePreMainstate.
Program Point
A program point represents a point within the execution of a Rust function in a way that is more fine-grained than a MIR location (each MIR location has multiple program points which to different stages of evaluation of a statement). Concretely, a program point is either:
- The start of a basic block
- A pair of a MIR location and a PCG evaluation phase
Borrows and Blocking
Blocking
A place blocks a place at a location if a usage of at would invalidate a live borrow contained in the origins of at .
Borrow Liveness
A borrow is live at location if a usage of at would invalidate the borrow.
Directly Borrowed
A place is directly borrowed by a borrow if is exactly the borrowed place (not e.g. a pre- or postfix of the place).
-
The initial state is either the
PostMainof the previous location within the basic block. Or if this is the first statement within the block, it is the entry state of the block (i.e. the result from joining the final states of incoming basic blocks). ↩
Capabilities
A capability describes the actions that the program is permitted to perform on a place at a particular program point. There are three main capabilities:
Exclusive (E)
Places with this capability can be read from, written to, or mutably borrowed.
We do not differentiate between locals bound with let bindings and let mut
bindings: a variable bound with let would still have this capability if it
could be written to if it was mutably borrowed.
Read (R)
Places with this capability can be read from. Shared borrows can also be created to this place. Shared references with this capability can be dereferenced.
A place with capability E is downgraded to R if a shared borrow is
created to a place that is a pre- or postfix of .
When a shared reference is dereferenced, the capability to is downgraded
to R. Any place projecting from a shared references will have capability R.
Write (W)
The place can be written to.
When an exclusive reference is dereferenced, the capability to is downgraded
to W.
In the implementation we define a fourth capability ShallowExclusive (e),
which is used for a rather specific and uncommon situation. When converting a raw
pointer *mut T into a Box<T>, there is an intermediate state where the
memory for the box is allocated on the heap but the box does not yet hold a
value. We use ShallowExclusive to represent this state.
In the implementation, writing to a Box-typed place p via e.g. *p = 5
requires that p have capability e.
PCG Nodes
Associated Place
The associated place of a PCG node is defined by the partial function :
Where is the base of the lifetime projection as defined here.
Local PCG Nodes
A PCG node is a local node if it has an associated place .
PCG Hyperedges
A PCG Hyperedge is a directed edge of the form , where elements of and are PCG nodes. The set are referred to as the source nodes of the edge, and are the target nodes. Hyperedges in the PCG are labelled with validity conditions1.
We represent a PCG hyperedge as a tuple of an edge kind and validity conditions.
Edge Kinds
An edge kind is a description an edge, including its source and target nodes, as well as other associated metadata. The metadata is described by the type of the edge, the various types are presented below:
Unpack Edges
Unpack edges are used to represent the unpack of an owned place in order to
access one of its fields. For example, writing to a field x.f requires
expanding x.
An unpack edge connects an owned place to one of it's expansions . Each place in is guaranteed to be owned.
For example, if x is an owned place with fields x.f and x.g, the edge
{x} -> {x.f, x.g} is a valid unpack edge.
Unpack edges are not generated for dereferences of reference-typed places.
Borrow PCG Expansion Edges are used in such cases.
Unpack edges are used in derefences of Box-typed places.
In the implementation, we don't have an explicit data type representing unpack edges. Rather, the unpack edges are conceptually represented as the interior edges in the Owned PCG.
Validity conditions are not currently associated with unpack edges in the implementation.
Borrow PCG Expansion Edge
Borrow PCG Expansion Edges conceptually take one of three forms:
- Dereference Expansion: The dereference of a reference-typed place
- Place Expansion: The expansion of a borrowed place to access one of its fields
- Lifetime Projection Expansion: The expansion of the lifetime projections of an owned or borrowed place
Dereference Expansion
The source nodes of a dereference expansion consist of:
- A maybe-labelled place , and:
- A lifetime projection
Where and have the same associated place .
The target node of a dereference expansion is a maybe-labelled place with associated place .
Place Expansion
The source node of a place expansion is a maybe-labelled place with associated place , where is a borrowed place and is not a reference.
The target nodes of a place expansion is a set of maybe-labelled places where the associated places of are an expansion of .
Lifetime Projection Expansion
The source node of a lifetime projection expansion is a lifetime projection where is a maybe-labelled place with associated place of .
The target nodes of a lifetime projection expansion is a set of lifetime projections where the base of each place is a maybe-labelled place. The associated places of the bases of are an expansion of .
It might make sense to differentiate lifetime projection expansions of owned and borrowed places, since they differ in terms of how placeholder labels should be included. Namely, for owned places there is no need to connect the expansion nodes to the placeholder of the base node (the owned place may never be repacked, or could be repacked with entirely unrelated lifetime projections)
Borrow Edges
Borrow edges are used to represent direct borrows in the program. We define two types:
- Local Borrow Edges: A borrow created in the MIR Body,
e.g. from a statement
let y = &mut x; - Remote Borrow Edges: A borrow created by the caller of this function where the assigned place of the borrow is an input to this function.
Remote Borrows are named as such because (unlike local borrows), the borrowed place does not have a name in the MIR body (since it was created by the caller).
Local Borrows
The source node of a local borrow is a maybe-labelled place . The target node of a local borrow is a lifetime projection where is a maybe-labelled place.
Remote Borrows
The source node of a remote borrow is a remote place . The target node of a remote borrow is a lifetime projection where is a maybe-labelled place.
Abstraction Edge
An abstraction edge represents the flow of borrows introduced due to a function call or loop.
Function Call Abstraction Edge
The source node of a function call abstraction edge is a lifetime projection where is a maybe-labelled place.
The target node of a function call abstraction edge is a lifetime projection where is a maybe-labelled place.
Loop Abstraction Edge
The source node of a loop abstraction edge is a PCG node.
The target node of a loop abstraction edge is a local PCG node.
Borrow-Flow Edge
A borrow-flow edge represents the flow of borrows between a lifetime projection and a local lifetime projection . This edge is used when the relationship between the blocked and target node is known concretely, but does not correspond to an expansion or a borrow.
Borrow-Flow Edges are labelled with a Borrow-Flow Edge Kind with associated metadata, enumerated as follows:
Aggregate
Metadata:
- : Field Index
- : Lifetime Projection Index
An aggregate kind is used for borrows flowing into an aggregate type (i.e. struct, tuple). The metadata indicates that the blocked lifetime projection flows into the lifetime projection of the field of the blocking lifetime projection.
Introduced in the following two cases:
- Collapsing an owned place:
- edges flow from the lifetime projections of the labelled places of the base to the lifetime projections of the current base
- Assigning an aggregate (e.g.
x = Aggregate(a, b)):- edges flow from the lifetime projections of the labelled places in the
rvalue to the lifetime projections of
x
- edges flow from the lifetime projections of the labelled places in the
rvalue to the lifetime projections of
is probably not necessary. We probably don't even need for case 1 (field index can be inferred from the expansion place), so perhaps a different edge kind could be used in that case.
Reference to Constant
Connects a region projection from a constant to some PCG node. For example let x: &'x C = c; where c is a constant of type &'c C, then an edge {c↓'c} -> {x↓'x} of this kind is created. This is called ConstRef in the implementation.
This seems quite similar to "Borrow Outlives", perhaps we should merge them?
Borrow Outlives
For a borrow e.g. let x: &mut y;, the PCG analysis inserts edges of this kind
to connect the (potentially snapshotted) lifetime projections of y to the
lifetime projections of x.
Initial Borrows
To construct the initial PCG state, the PCG analysis adds an edge of this kind between every lifetime projection in each remote place to the corresponding lifetime projection of its corresponding argument.
For example, if is the local corresponding to a function argument and contains a lifetime projection , an edge will appear in the graph.
Connects the lifetime projections of remote places to the lifetime projections of
Copy
For a copy let x = y;, the PCG analysis inserts edges of this kind
to connect the lifetime projections of y to the lifetime projections of x.
In the implementation this is currently called CopyRef.
Move
For a move let x = move y;, the PCG analysis inserts edges of this kind to
connect the (potentially snapshotted) lifetime projections of y to the
lifetime projections of x.
Future
These edges are introduced to describe the flow of borrows to the lifetime projections of a place that is currently blocked. When they are created, the target node is a placeholder lifetime projection of a blocked place.
-
Currently not in the owned portion of the PCG, but this should happen eventually. ↩
Types
A type is either:
- A type parameter of the form
- An alias type of the form
- A type constructor application of the form
- A box type of the form
Corresponding Regions
If is a region in , the corresponding region in a type is:
If and then
If and , iterate over , and if there exists an where in is the corresponding region of in , then .
Places
A place is a tuple of a local and a projection.
Place Expansion
A set of places is a place expansion iff there exists a base such that:
- is an
enumtype and and is a variant of - is a
structor tuple type and is the set of places obtained by projecting with each of the fields in the type of - is a reference-typed field and
- is an array or slice and (TODO: more cases)
If there is such a , then that is unique, and is an expansion of .
Owned Places
A place is owned iff it does not project from the dereference of a reference-typed place.
Place Liveness
A place is live at a location iff there exists a location and a control flow-path from to where a place conflicting with is used at and there are no assignments of any places conflicting with along c.
Place Prefix
A place is a prefix of a place iff:
- and have the same local, and
- The projection of is a prefix of the projection of
Note that is a prefix of itself.
A place is a strict prefix of iff is a prefix of and .
Regions
A region is one of:
- A
RegionVid(a region variable identifier) - The static region
'static - An early-bound region
TODO: Other cases
Lifetime Projections
Generalized Lifetimes
A generalized lifetime is either a region or , where is either:
- a type parameter, or
- a type alias that cannot be normalized
Generalized Lifetime Projections
A generalized lifetime projection takes the form where is a base having an associated type . The index of a lifetime projection is the index of the occurence of in the generalized lifetime list (the list of generalized lifetimes in , occurring in the order they appear in , and with duplicates removed).
A lifetime projection is a generalized lifetime projection of the form (that is, a generalized lifetime projection where the associated generalized lifetime is a region).
PCG Lifetime Projections
PCG lifetime projections take the following form
Lifetime Projection Lifetime
The lifetime of a lifetime projection is conceptually the lifetime on the right of the , i.e:
Lifetime Projection Base
The base of a lifetime projection is conceptually the part on the left of the , i.e. defined as follows:
Validity Conditions
We associate borrow PCG edges with validity conditions to identify the control-flow conditions under which they are valid. Consider the following program:
fn main(){ // BB0 let mut x = 1; let mut y = 2; let mut z = 3; let mut r = &mut x; if rand() { // BB1 r = &mut y; } // BB2 if rand2() { // BB3 r = &mut z; } // BB4 *r = 5; }
We represent control flow using a primitive called a branch choice
of the form . A branch choice represents the flow of
control from one basic block to another. In the above code there are two
possible branch choices from bb0: and
and one branch choice from bb1 (to
bb2).
For any given basic block in the program, an execution path leading to
is an ordered list of blocks ending in . For
example, one path of execution to bb4 can be described by: .
Validity conditions conceptually define a predicate on execution paths.
For example, the validity conditions describing the
control flow where r borrows from z at bb3 require that the path contain
.
We represent validity conditions as a partial function mapping relevant blocks to sets of allowed target blocks.
An execution path is valid for validity conditions iff, for each , either:
- is not a relevant block in , or
- is in the set of allowed target blocks of in
Formal Definition
The representation of validity conditions in our implementations corresponds closely to the following description:
Validity conditions is a map describing control-flow conditions. For each block , is a subset of the real successors of .
The join of validity conditions and is defined as:
Validity conditions are valid for a path iff:
Correctness Theorem (Sketch)
For every borrow edge in the PCG at location and execution path to , the corresponding borrow is live at iff satisfies the validity conditions of .
Proof
We prove this for each location in the MIR by induction on the basic blocks of the graph via a reverse-postorder traversal. The inductive hypothesis is that the property holds for each ancestor block.
Having proved for the 1st location in a basic block, it is trivial to show for the remaining locations in the block, therefore we only concern ourselves with the basic blocks.
The case of bb0 is trivial.
We now concern ourselves with the validity conditions of an arbitrary edge at block , assuming via IH that it holds for all where . Let be the set of incoming nodes. We define our as the repeated join: .
We first show that the if direction holds: For every borrow edge in the PCG at location and execution path to , if the corresponding borrow is live at , then satisfies the validity conditions of .
Every has a prefix and ends with , by our IH we have satisfies . We need to then show that satisfies . Our proof is by contradiction.
Suppose did not satisfy ; then there must be a pair where . We know that the pair cannot be the final pair in (i.e. ) by the definition of join we have . Therefore the edge must exist in some strict prefix of . Then, there must be a nonempty set where (otherwise, the joined would be either or contain ). But then, because must contain , it would not satisfy the validity conditions of for any .
We now show the reverse: For every borrow live at for an execution path , its corresponding edge satisfies the validity conditions of .
Our proof relies on the monotonicity of joins: if then for any .
The borrow is created at a unique block . Then is definitely in at some index and the borrow is not killed in any block for all .
Our proof works by building longer prefixes of . The base case is when the borrow is created (and therefore becomes live) at , where the property holds trivially. Then, when going from to , we add to the validity conditions and join other incoming blocks. Because the prefix satisfies the prior conditions by induction, satisfies the additional condition, and monotonicity of join, the longer prefix also satisfies conditions. QED.
Functions
Generalized Types and Parameter Environments
A generalized type is either a type or a region
A param env is a list of constraints; where a constraint takes the form:
- ( outlives )
- (All regions in outlive )
- ( implements )
Outlives Judgments
The base outlives relation holds iff it can be derived from the following rules:
- Direct:
- Reflexivity:
- Transitivity: and implies
That is, the base outlives relation is the transitive closure of the region-outlives and type-outlives facts in .
There also exists an outlives relation such that implies . This relation is computed by Rust's type system and additionally accounts for outlives constraints implied by trait membership constraints in .
Function Signatures
A function signature is a pair .
A defined function signature is a tuple .
An instantiation of is the tuple ; where is a list of early-bound parameters.
The identity instantiation of is obtained by applying the identity substitution . Defined function calls are applied to instantiations of a function.
The generalized lifetime projections of a function instantiation is defined as the set:
Function Calls
A function call target is either an instantiation or a closure / function pointer .
A function call takes the form , where is a MIR place, and is a sequence of MIR operands.
is the parameter environment of the function with respect to the call site.
The lifetime projections of a function call is the union of the lifetime projections in and the lifetime projections in .
A function call is valid iff it satisfies the unique region property: each region in the lifetime projections of is unique.
MIR Definitions
Here we describe definitions of MIR concepts that are relevant to the PCG.
It's possible that these definitions will become outdated as the MIR is not stable. If there is any discrepency between the descriptions here and those from official Rust sources (e.g. the dev guide), this page should be updated accordingly.
MIR Dataflow Analysis
At a high level, a MIR dataflow analysis is defined by the following elements:
- A domain
- A join operation
- An empty state
- A transfer function
Performing the dataflow analysis on a MIR body computes a value of type for every location in . The analysis is performed (conceptually) as follows1:
- The analysis defines a map that maps locations in to elements of .
- Each location in is initialized to
- The operation analyze(b) updates as follows:
- For
- The analysis defines a worklist
- While is not empty:
- Pop from
- Perform
- Let be the entry of the last location in in
- For each successor of :
- Let
- Let
- If :
- Add to
- is the result
I'm not sure of the order things are popped from . Any ordering should yield the same but some blocks may be analyzed more frequently than necessary. We should check the rustc implementation.
-
The current analysis implementation (defined in the rust compiler) is more efficient than what we describe because it tracks state per basic block rather than per-location, as the states for any location in a block can be computed by repeated application of the transfer function to the entry state. ↩
Borrow-Checker Interface
The PCG Analysis requires an implementation of the borrow-checker interface providing the following:
- A predicate , which holds iff the borrow extents in PCG node are all in scope at MIR location
- A predicate , which holds iff place is directly blocked at MIR location
- A predicate which holds iff mutating at would cause to be dead
- A predicate which holds iff must outlive at
- A function which returns the set of borrows that would be invalidated if was modified at
- A function which returns the set of two-phase borrows that are activated at .
Analysing Statements
The PCG Analysis computes four states for each MIR statement, corresponding to the PCG evaluation phases:
PreOperandsPostOperandsPreMainPostMain
The analysis for each statement proceeds in two steps:
- Step 1: Place Conditions are computed for each statement phase
- Step 2: PCG Actions are performed for each statement phase
Determining Place Conditions
A place condition is a predicate on the PCG related to a particular MIR place.
We define the following place conditions:
Capability: Place must have capabilityRemoveCapability: Capability for place should be removed1AllocateOrDeallocate: Storage for local is allocated (e.g. via theStorageLiveinstruction)Unalloc: Storage for local is not allocated (e.g. via theStorageDeadinstruction)ExpandTwoPhase: Place is the borrowed place of a two-phase borrowReturn: TheRETURNplace has Exclusive capability
ExpandTwoPhase may not be necessary. AllocateOrDeallocate is a confusing
name, also it's not clear if it's any different than just having Write
permission.
During this step of the analysis, place conditions are computed for each phase. The determination of place conditions is based on the MIR statement; the state of the PCG is not relevant.
The conditions computed for each phase are as follows:
PreOperands: Pre-conditions on the PCG for the operands in the statement to be evaluatedPostOperands: Post-conditions on the PCG after the operands in the statement has been evaluatedPreMain: Pre-conditions on the PCG for the main effect of the statement to be appliedPostMain: Post-conditions on the PCG after the main effect of the statement has been applied
As an example, the MIR statement: let y = move x would have the following
place conditions:
PreOperands:{x: E}PostOperands:{x: W}PreMain:{y: W}PostMain:{y: E}
The rules describing how these place conditions are generated for a statement are described here.
Performing PCG Actions
After the place conditions for each phase are computed, the analyses proceeds by performing the actions for each phase. At a high-level, this proceeds as follows:
PreOperands
- The Borrow PCG graph is minimized by repeatedly removing every effective
leaf edge2 for which their target PCG nodes of are not live
at the current MIR location. A Borrow PCG
RemoveEdgeaction is generated for each removed edge. See more details here.
TODO: Precisely define liveness.
- The place capabilities required by the
PreOperandplace conditions are obtained.
PostOperands
No actions occur at this stage. Capabilities for every moved-out operand are set to Write.
PreMain
The place capabilities required by the PreMain place conditions are
obtained.
Then, the behaviour depends on the type of statement:
StorageDead(v)- The analysis performs the
MakePlaceOld(v, StorageDead)action.
- The analysis performs the
Assign(p r)- If
pis a reference-typed value and contained in the borrows graph and the capability forpisR:- The analysis performs the
RestoreCapability(p, E)action
- The analysis performs the
- If :
- The analysis performs the action
- All edges in the borrow PCG blocked by any of the lifetime projections in
pare removed
- If
PostMain
- For every operand
move pin the statement:- The analysis performs the
MakePlaceOld(p, MoveOut)action.
- The analysis performs the
- If the statement is a function call
p = call f(..):- Function call abstraction edges are inserted using the rules defined here
- If the statement takes the form
Assign(p, r):pis given exclusive permission- If takes the form
Aggregate(o_1, ..., o_n):- For every
- Let be the associated place of
- For all
- If outlives :
- Add an
AggregateBorrowFlow edge , with associated field index and lifetime projection index .
- Add an
- If outlives :
- For every
- If takes the form
use c,cis a reference-typed constant with lifetime , and is a reference-typed place with lifetime , then:- Create a new
ConstRefborrowedge of the form
- Create a new
- If takes the form
move p_forcast(_, move p_f):- For all :
- Let be the i'th lifetime projection in
p - Let be the i'th lifetime projection in
p_f - Let be the snapshot location
- Add a
Moveedge
- Let be the i'th lifetime projection in
- For all :
- If takes the form
copy p_forcast(_, copy p_f):- For all :
- Let be the i'th lifetime projection in
p - Let be the i'th lifetime projection in
p_f - Add a
CopyRefedge
- Let be the i'th lifetime projection in
- For all :
- If takes the form
&por&mut p:- Handle the borrow as described here
-
This is only used for mutably borrowed places ↩
-
The set of effective leaf edges are the leaf edges in the graph obtained by removing all edges to placeholder lifetime projections. ↩
Removing Edges for out-of-scope Borrow Extents
At the beginning of each statement
Rules for Determining Place Conditions
Place conditions are computed in terms of triples, where a triple is a pair (pre, post) where pre is a place condition and post is either a place condition or None.
The place conditions for each phase are determined by two sets of triples: the
operand triples and the main triples. The place conditions for the
PreOperands phase is the set of conditions in the pres of the operand
triples. The PostOperands phase is the set of conditions in the posts of the
operand triples. PreMain and PostMain are defined accordingly.
Determining Operand Triples for a Statement
- For each operand in the statement:
- If takes the form
copy p:- Add
(p: R, None)to the operand triples
- Add
- If takes the form
move p:- Add
(p: E, p: W)to the operand triples
- Add
- If takes the form
- For each rvalue in the statement:
- If takes the form
&p:- Add
(p: R, p: R)to the operand triples
- Add
- If takes the form
&mut p:- If the borrow is a two-phase borrow:
- Add
(ExpandTwoPhase p, p: R)to the operand triples
- Add
- Otherwise, add
(p: E, RemoveCapability p)to the operand triples
- If the borrow is a two-phase borrow:
- If takes the form
*mut p:- Add
(p: E, None)to the operand triples
- Add
- If takes the form
*const p:- Add
(p: R, None)to the operand triples
- Add
- If takes the form
len(p),discriminant(p)orCopyForDeref(p):- Add
(p: R, None)to the operand triples
- Add
- If takes the form
Determining Main Triples for a Statement
The rule depends on the statement type:
Assign(p, r)- If
rtakes the form&fake q:- Add
(p: W, None)to the main triples
- Add
- If
rtakes the formShallowInitBox o t- Add
(p: W, p: e)to the main triples
- Add
- Otherwise, add
(p: W, p: E)to the main triples
- If
FakeRead(_, p)- Add
(p: R, None)to the main triples
- Add
SetDiscriminant(p, ..)- Add
(p: E, None)to the main triples
- Add
Deinit(p)- Add
(p: E, p: w)to the main triples
- Add
StorageLive(v)- Add
(Unalloc v, AllocateOrDeallocate v)to the main triples
- Add
StorageDead(v)- Add
(AllocateOrDeallocate v, Unalloc v)to the main triples
- Add
Retag(_, p)- Add
(p: E, None)to the main triples
- Add
Determining Main Triples for a Terminator
The rule depends on the terminator type:
Return- Add
(Return, _0: w)to the main triples
- Add
Drop(p)- Add
(p: W, None)to the main triples
- Add
Call(p, _)- Add
(p: W, p: E)to the main triples
- Add
Yield(p, _)- Add
(p: W, p: E)to the main triples
- Add
Rules for the Creation of Borrows
Mutable Borrows
Consider the stmt p = &mut q, at a program point , where has type , and has type , and is a type containing lifetimes .
At the end of the PreOperands phase, the PCG is guaranteed to be in a state where, for each the lifetime projection is in the graph. During the Operands phase, each lifetime projection is labelled with the current program point to become . At the end of the PreMain phase, for each , the lifetime projection is guaranteed not to be in the graph. During the Main phase, these projections are added.
Subsequently, the labelled lifetime projections under are connected with BorrowFlow edges to the new lifetime projections under . Namely, for each if outlives , then a BorrowFlow edge is added.
Subsequently, we introduce Future nodes and edges to account for nested references as follows. For each :
- Insert the node into the graph
- If any
Futureedges originate from the labelled projection , redirect them such that they originate from . - Insert a
Futureedge - Insert a
Futureedge
Owned PCG Operations
Collapse
The operation modifies place expansions and set of place capabilities such that becomes a leaf in the forest corresponding to . Stated more formally it modifies to ensure that contains an expansion containing , and is not the base of any expansion in . Capabilities in are updated to account for the removal of expansions from .
collapse returns the set of Owned PCG Actions corresponding to the removed expansions.
This logic is very similar to the collapse defined on the (combined) PCG defined here. This is used in contexts where the Borrow PCG is not available (such as the join on owned PCGs).
We should investigate making a common operation.
The algorithm is implemented as follows:
- Let be the subset of place expansions in such that for each in , the base place is a prefix of .
- Let be an ordered list of the expansions in sorted in order of descending length of the projections of their base place
- Let be the list of operations to return
- For each in
- Let be the capabilities of in
- Let be the minimum common capabiility of .
- Let be the base of
- Remove capabilities to the places in from
- Assign capability to in
- Remove from
- Add to
- return
Join Operation
The join algorithm on PCGs takes as input PCGs and and mutates to join in .
We define the join in this manner because this is similar to how the implementation works.
The algorithm proceeds in the follow steps:
- The Owned PCG of is joined into the Owned PCG of (this may also change the capabilities of )
- The capabilities of are joined into the capabilities of .
- The Borrow State of is joined into the Borrow State of
We now describe each step in detail:
Owned PCG Join
Let be the owned PCG of and the PCG of .
- For each local in the MIR body:
- If is allocated in both and :
- Join the place expansions rooted at into
- Otherwise, if is allocated in , it should be deallocated in
- If is allocated in both and :
Joining Local Expansions (Algorithm Overview)
The algorithm for joining local expansions also involves modifying capabilities (including to borrowed places), and also may cause some places to be labelled. We "emulate" a modification of the block to be joined in, so that we can use the resulting modified capabilities and borrows graph in the remainder of the join. We implement the join by defining a "one-way" join operation , and then performing a join and subsequently . The owned PCGs of and (including the associated capabilities) should be the same after the join. Formally, we could imagine a partial order where iff the owned PCG of can be transformed to the owned PCG of by a sequence of repack operations on its owned PCG. The join identifies the maximum graph (w.r.t ) where and .
Before we present the full algorithm (that takes into account borrows), we first
describe a version that would work in the absence of borrows and therefore only
considers capabilities W and E. The key point of the join is that all
places/capabilities in the resulting joined PCGs should be obtainable from the
originals (i.e. via a sequence of PCG repacks).
The join starts from the root of both trees, and navigate downwards. For
expansions that are the same in both, nothing needs to happen. If the other
tree is expanded more than us, we continue expanding ours: the only reason it
would be expanded is that part of it is moved out, so we can expand and weaken
the LHS to match the RHS. For example, if the LHS is {x: E}, and the RHS is
{x.f: E, x.g: W}, we'd want to expand x in the LHS (note that the subsequent
capability join will properly account for capabilities).
If we're at a place where the expansions differ in both trees (e.g. x -> {x@Some} vs x -> {x@None}), we collapse both to the place (e.g x) and set
capability to the place to W. Neither of the expansion places are accessible by
both, so the "most" capability we could obtain is the W permission to the base.
More formally, we define the one-way join as the traversal where:
- If we're at a leaf in that is not a leaf in
(having children ), then:
- Expand to in
- If were at a node in where the expansion is not the same as the expansion in :
- Collapse both and to for capability
Now, we extend the join to work with borrows and the read capability . Borrows complicate the story because it enables mutually exclusive (at runtime) places to appear in the PCG. For example:
#![allow(unused)] fn main() { fn f(x: Either<String, String>) -> &String; { let result = match x { Left(l) => &l, Right(r) => &r }; result } }
After the assignment, we should have capability to both x@Left and
x@Right because they are borrowed into result. In the case of exclusive
borrows, such places would not have capability but still present in the owned
PCG (again, to reflect the target of result). Therefore, the collapse and
expand rule previously shown are no longer sufficient.
- If we're at a place that's a leaf in both and :
- If has capability of at least in and has capability exactly in :
- Then, after the join, the place can have at most capability . Its also possible that we can now have incompatible different owned expansions of in the joined PCG, that have been borrowed under mutually exclusive paths.
- If has capability of at least in and has capability exactly in :
Local Expansions Join:
The algorithm joins a set of place expansions into a set of place expansions , and makes corresponding changes to borrows and capabilities .
- If either or have any expansions:
- Perform
- Perform
- Otherwise:
- Let be the local
- Perform
- Perform
We define as:
- Let
- For each expansion (shortest first):
- Let be the base of
- If there exists a where is a prefix of , then ignore this expansion
- Otherwise, let by the set of (direct) expansions from in
- If :
- Perform
- Otherwise, if is empty and :
- If :
- Perform
- Otherwise, perform a normal expansion operation of in
- If :
- Otherwise, perform
- If the result of the join is to collapse to , then add to
We define as:
- If
- Add expansion to
- Perform
- Otherwise, if :
- Let be the base of
- Perform
- Otherwise do nothing
Expansion Edge One-Way Join
We define as:
- For each place in the expansion of :
- Perform
Expansion Edge Two-Way Join
We define as:
- Let be the base of
- If and :
- Perform a regular expand of
- Otherwise, if there exists a descendant of in that does not have a capability:
- Insert into (do not change capabilities)
- Otherwise:
- Collapse and to
Place Join
We define as:
- If is not a leaf in or is not a leaf in or or :
- Abort
- Otherwise, let ,
- If and :
- Perform
- If and :
- Perform
- If and :
- Perform
Leaf RW Join
We define as:
- Label all postfixes places of in
Place Capabilitiies Join
The algorithm join() is defined as:
- For each
p: cin :- If :
- Remove capability to in
- Otherwise:
- If is defined:
- Assign capability to in
- Otherwise, remove capability to in
- If is defined:
- If :
Borrow State Join
- The borrow graphs are joined
- The validity conditions are joined
Borrow PCG Join
Joining into , where is the block for and is the block for .
Update the validity conditions for each edge in to require the branch condition .
Update the validity
- If is a loop head perform the loop join algorithm as described here.
- Otherwise:
- For each edge in :
- If there exists an edge of the same kind in
- Join the validity conditions associated with in to the validity conditions associated with in
- Otherwise, add to
- If there exists an edge of the same kind in
- For all placeholder lifetime projections in :
- Label all lifetime projection nodes of the form in with
- For each edge in :
Loops
The loop head is the basic block with an incoming back edge. We define as the location of the first statement of the loop head.
The pre-loop block is the block prior to the loop head. We assume that there is always a unique pre-loop block. The pre-loop state is the state after evaluating the terminator of the pre-loop block.
The following operations are performed when we join the pre-loop block with the loop head. Note that at this point we've already computed .
We construct the state for the loop head as follows:
Step 1 - Identify Relevant Loop Places
We identify the following sets of places:
- : the places used inside the loop that are live and initialized at .
TODO: Doesn't liveness imply initialization?
-
: the subset of that are directly borrowed by a borrow live at
-
: the subset of that contain lifetime projections
-
: Places used in the loop that may be relevant for the invariant:
is the union of and the associated lifetime projections of .
are the associated lifetime projections of .
Step 2 - Obtain Relevant Loop Nodes in Pre-state Graph
The nodes in will need to appear in but may not be present in (for example, it's possible that the loop could borrow from a subplace that requires unpacking). We construct a graph by performing the obtain operation on each place in .
Step 3 - Identify Borrow Roots
The borrow roots of a place are the most immediate places that could be borrowing from and later become accessible, and excluding places already in
We defined the borrow roots using the function :
- Initialize a list to contain all lifetime projections in
- while is not empty:
- Pop from
- For each edge blocked by in :
- If the edge is an unpack edge, add all of its blocked nodes to
- Otherwise, for each blocked node in :
- If or , do nothing
- If is live at , add to
- If is a root in , add to
- Otherwise, add to
- The resulting roots are the associated places of
We then identify , the most immediate nodes that could be borrowing from and later become accessible (excluding nodes already in ). is the union of the roots for each place in .
Step 4 - Construct Abstraction Graph And Compute Blocked Lifetime Projections
We construct an abstraction graph that describes the blocking relations potentially introduced in the loop from places in to nodes in and from nodes in to nodes in .
connect() function:
We begin by define a helper function which adds edges to based on being blocked by in the loop:
- Identify : the top-level lifetime projection in
- Insert a loop abstraction edge into
- For each :
- Identify the lifetime projections in that may mutate borrows in
- If is nonempty:
- Introduce a placeholder node
- Add a borrowflow hyperedge
- Add a future hyperedges:
- Identify the lifetime projections in that borrows in may flow into
- If is nonempty:
- Add a borrowflow hyperedge
- Identify the lifetime projections in that may mutate borrows in
Algorithm:
-
For each blocker place :
- For each :
- If is a remote node, or if blocks at :
- Perform
- If is a remote node, or if blocks at :
- For each :
-
Subsequently, ensure that is well-formed by adding unpack edges where appropriate. For example, if
(*x).fis in the graph, there should also be an expansion edge from*xto(*x).f. -
We identify the set of lifetime projections that will need to be renamed (indicating they will be expanded in the loop and remain non-accessible at the loop head). is the set of non-leaf lifetime projection nodes in (leaf nodes are accessible at the head).
-
Label all lifetime projections in with location , add connections to their
Futurenodes as necessary.
Step 5 - Label Blocked Lifetime Projections in Pre-State
The resulting graph for the loop head will require new labels on lifetime projections modified in the loop. We begin by constructing an intermediate graph by labelling each lifetime projection in with and remove capabilities to all places in in .
Step 6 - Identify Pre-State Subgraph to Replace With Abstraction Graph
We then identify the subgraph that will be removed from and replaced with the loop abstraction .
- Let = .
- We construct by including all nodes in and all edges where there exists where is on a path between and in .
Step 7 - Replace Pre-State Subgraph with Abstraction Graph
The graph for the loop head is defined as
Step 8 (Optional) - Confirm Invariant is Correct
To confirm that the resulting graph is correct, for any back edge into the state at with state , performing the loop join operation on and should yield .
Function Shapes
Background
The PCG generates a "shape" for a function call - a bipartite graph indicating where borrows could flow as a result of the call. In particular this shape represents (1) reborrows that are returned from the function (w.r.t borrows passed in the arguments), and (2) the effects of the nested borrows passed in the operands.
We model the first case by introducing abstraction edges between lifetime projections in the operands and those in the result place: each lifetime projection in the operands is connected to the corresponding lifetime projections that it outlives in the result place. The (compiler-checked) outlives constraint captures whether borrows could be assigned in this way, according to the type system.
For (2) To directly model the potentially changed sets of borrows relevant to these concerns, our analysis of function calls introduces lifetime projections to represent the post-state of each lifetime projection in the operands. Each lifetime projection in the operands is connected with abstraction edges to its corresponding post-state projection as well as the post-state nested lifetime projections that it outlives (analogously to sets of borrows explicitly returned).
Creating Function Shapes
Function Shapes
A function shape source base takes the form . A function shape target base is either or .
A function shape source node is a pair where is the region index of the node. Function shape target nodes are defined analogously.
A function shape edge is a pair , and a function shape is a set of edges.
A shape permits more borrowing than a shape iff ; likewise permits less borrowing than iff .
Signature Shape
The corresponding node of a generalized lifetime projection is .
The corresponding generalized lifetime projection of a node is the generalized lifetime projection such that .
A generalized lifetime outlives a generalized lifetime in the signature of (denoted iff:
- , or
- is a generalized type and is a region and
Note that represents all regions that could occur in . More generally, outlives when all regions in outlive all corresponding regions in . The current implementation handles the case where (reflexivity); other cases may be added in the future.
The signature shape for a function instantiation is defined as follows:
For each then add to if both:
- , and
- is , or is a region that is invariant in .
Call Shape
The corresponding node of a lifetime projection is .
The corresponding node of a lifetime projection is .
The call shape for a function call is defined as follows:
For each then add to if both:
- at according to the borrow checker, and
- is , or is invariant in .
Type Aliases and Normalization
An alias type is a type of the form where is a type constructor. The function returns a type where alias types in may possibly be replaced with other types. This normalisation is idempotent, e.g. .
Signature-Derived Call Shape
For a call at ), the signature-derived call shape is obtained as follows:
Let be the normalized signature shape, e.g the one obtained by replacing each in with .
If is the operand in , the corresponding normalized type is the type of the argument in . Likewise, if , then is the output type of. Then, the corresponding normalized region of a lifetime projection is the region in that corresponds to in .
For each :
- Let , be the corresponding lifetime projections
- Then, let and be the corresponding normalized regions of and respectively.
- If outlives in , then add to
Using shapes for function calls in the PCG
If the call is to a defined function, then the signature-derived call shape is used. Otherwise, the call shape is used.
More Background
For a function , there are three types of shapes to consider:
- Signature Shapes: The shape of an instantiation of generated from its signature
- Call Shapes: A shape used to represent call to an instantiation of
- Implementation Shapes: A shape representing 's body, which connects remote lifetime projection nodes to the the result.
These different shape types are relevant for Prusti, as:
- Signature shapes define the shape of a magic wand
- Call shapes are used for magic wands that will be applied
- Implementation shapes define magic wands that will be packaged
The call shape and implementation shapes are necessarily related to the signature shape; the former can contain more edges while the latter can contain less.
The shape for a function call must necessarily have corresponding edges that appear in the shape for the function signature. The reverse is not necessarily the case. For example, consider the following:
#![allow(unused)] fn main() { fn caller<'e, 'f: 'e>(x: T<'e>, y: U<'e>, z: T<'f>) { let r: W<'e> = f(x, y, z); } }
Using the borrow-checker to generate the shape of the call will result in an edge from z|'f to r|'e: the borrow-checker reflects that 'f: 'e. However, the definition of f do not allow borrows to flow from z to r (which is reflected in the function call shape).
Therefore, to build more precise graphs at function call sites. We want to use the shape of the function to determine the shape of the call. The procedure is as follows:
- Generate the shape of the function.
- Build a map from
Def Projections toCall Projectionsby comparing the types of the arg places and formal args (analogously to the results). By construction, will contain all projections in the function def (even if they don't appear in the shape).
Here's an example of how its computed:
If the type of the 'th place is &'?1 mut U<'?2> and the type of the 'th formal arg is &'a mut U<'b>, then the visitor will first compare at the top level, add arg_i |'a -> p_i '?1 to the map and continue by comparing U<'?2> to U<'b>
- Then, for each edge in the fn shape, the edge is replaced with the corresponding projections of the call. If the fn shape has an edge
arg 1|'a -> result|'bfor example, then for the call shape it will lookup corresponding call projections and add an edge between them.
Reasoning about Associated Types
Consider the following code:
#![allow(unused)] fn main() { trait MyTrait<'a> { type Assoc<'b> where 'a: 'b; fn get_assoc<'slf, 'b>(&'slf mut self) -> Self::Assoc<'b> { todo!() } } }
The full signature for get_assoc is:
#![allow(unused)] fn main() { fn(&'slf mut Self) -> <Self as MyTrait<'a>>::Assoc<'b> }
we observe that the fn sig has the following lifetime projections:
argidx 0|'slfresult 0|'aresult 0|'b
And the shape for get_assoc contains the single edge:
argidx 0|'slf -> result|'b
For the body, self has type &'8 mut Self/#0
and result has Alias(Projection, AliasTy { args: [Self/#0, '?6, '?7], def_id: DefId(0:5 ~ input[9b88]::MyTrait::Assoc), .. }),
So we unify &'slf mut Self with &'?8 mut Self
adding argidx 0|'slf -> _1|'?8 to
We unify <Self as MyTrait<'a>::Assoc<'b> with
<Self as MyTrait<'?6>::Assoc<'?7>
adding
result|'a -> result|'?6toresult|'b -> result|'?7to
Then applying the substitutions our shape is _1|'?8 -> result|'?7
Function Calls
Consider a function call at location . Each is an operand of the form move p | copy p | const c.
Let be the set of moved operand places.
The function call abstraction is created in the PostMain evalution stage,
after all of the operands have been evaluated. Therefore, all places in
are labelled, as they have been moved-out by this point.
Algorithm
Let be the set of lifetime projections in (that is, obtained from places in , regardless of whether they are already in the graph). For any place , we define as the place .
We define the set of lifetime projections of the moved-out operands as .
We define the "pre-call states" of as .
We define the "post-call states" of as
The definitions of sets , , , and do not depend on the nodes present in the PCG. In general, we postulate that following invariants should always hold:
- Only the nodes in are in the graph at the end of stage
- Only the nodes in are in the graph at the end of the stage
- Only the nodes in are in the graph at the end of the stage
1. Redirect Future Nodes
This follows the standard process, e.g. similar to borrows and borrow PCG expansions
- For each
Futureedge where the source node is in :- Change the source node from to
2. Label Lifetime Projections for Pre-State
Label projections in to become in the graph:
- For each :
- Label the with
3. Add Abstraction Edges
At a high level we construct by connecting lifetime projections to the lifetime projections in and the lifetime projections in , where connections are made based on outlives constraints. Concretely:
- Let be union of and the lifetime projections in
- For each in :
- Let contain each lifetime projection where outlives the lifetime of
- If is not empty, add the abstraction edge to the PCG
Generating Annotations
Generating Annotations Between Statements
The annotations for transitioning from statement to statement are the recorded PCG actions performed during its analysis.
Generating Annotations Between Basic Blocks
We generate the annotations to get from the state of to its successor . The state of may be to the join of multiple basic blocks (including ).
We generate annotations by performing a join that joins into a copy of and recording the actions that occur. Note that this is the reverse of the join that occurs during the analysis where is joined into . Conceptually, the actions performed by the join are the procedure that weakens state to obtain state .
Generating Annotations for Magic Wands
Clients like Prusti want to generate magic wands from function bodies.
The PCG facilitates this via the UnblockGraph interface. The interface takes a
PCG and a PCG node , and returns an ordered list of annotations
that describe how to unblock in . Prusti can consume these annotations
to generate magic wands.
The interface could trivially be extended to unblock multiple nodes simultaneously
The annotations generated from the UnblockGraph are Borrow PCG edges.
At a high level, the resulting annotations are a topological sort of the edges in the subgraph of that contains and all of its ancestors. Concretely, the procedure to generate the list of annotations is as follows:
- Let be the subgraph containing and its ancestors in
- While has at least one edge:
- Let be the set of leaf edges in .
- If is empty fail
- Append to
- Remove from
We note that the above procedure could fail, for example, could contain a cycle. We expect that in practice this is quite rare. Furthermore, we believe the following property should hold:
For any list of PCG edges forming a cycle, there does not exist any execution path that call satisfy the validity conditions of every edge in the cycle.
Therefore, it should be possible to modify the implementation to e.g. produce multiple lists for distinct paths.
Misc (To Update)
Unpacking Places
An unpack operation introduces an Unpack edge into the graph, and is associated with a source place and an expansion . An unpack operation can either be mutable or read-only.
An unpack operation is a dereference unpack for lifetime iff the type of the source place p is &'r mut p or &'r p.
Applying an unpack operation introduces an Unpack edge as follows:
- If the unpack operation is a dereference operation, the edge is added to the graph.
- Otherwise, the edge is added to the graph.
A mutable unpack operation for capabiliy requires that the source place have capability . When the operation is a applied, the capability for is removed and the capability of all places in is set to .
A read-only unpack operation requires that have capability and assigns capability to all places in .
Updating Lifetime Projections
Assume the source place has type with lifetimes . If the unpack operation is mutable, then we label each lifetime projection with location .
The source lifetime projection for a lifetime is if the unpack is mutable, and otherwise. The target lifetime projections is the set .
For each lifetime , if the set of target lifetime projections associated with is nonempty:
- For each in the set of target projections, add a
BorrowFlowedge where is the source projection1.
- If the unpack is mutable and the source place of the expansion is either a reference or a borrowed place:
a. Add a
Futureedge b. For each in the set of target projections, add aFutureedge c. If anyFutureedges originate from the source projection , redirect them such that they originate from .
Origin Containg Loan
An origin contains a loan created at location iff:
Polonius: Read directly from output facts
NLL: is live at and outlives
-
TODO: Currently we actually introduce an unpack edge in the implementation, but we should change this. ↩
PCG Operations
Obtaining Capability to a Place
The operation to obtain capability to a place in a PCG proceeds as follows.
When performing this operation to satisfy the place capability required for a statement, the analysis guarantees that no live mutable borrows conflicting with 1. At a high level, capability to is obtained by first collapsing the owned places in to
Step 1 - Label dereferences of shared borrows stored in
Reborrows of shared references derived from (i.e. from any postfix of ) will survive even if is moved or mutated. Therefore, if permits the function to mutate (i.e. ), then the analysis labels all places that project from shared references derived from .
Formally:
If , then for each place in where:
- is a strict postfix of , and
- is a shared reference, and
- is in
The analysis labels every postfix of in .
Step 2 - Collapse
Implementation
This operation is implemented as PlaceObtainer::obtain
The obtain(p, o) operation reorganizes a PCG state to a new state in where the PCG node for a place is present in the graph. The parameter o is an Obtain Type which describes the reason for the expansion. The reason is either:
- To obtain a specified capability to the place
- To access the place because it is the borrowed place of a two-phase borrow
An Obtain Type is associated with a result capability which is either
the specified capability (in the first case), or Read (in the case of access
for two-phase borrows).
The "two-phase" borrow case is likely unnecessary: we can use the borrow-checker to detect if the place is also the borrowed place of a two-phase borrow reserved at the current location. In fact the current implementation make similar queries as part of the expand step.
Note that a place node for is not necessarily present in the graph before this occurs.
This proceeds as follows:
Step 1 - Label dereferences of shared borrows stored in
(Same as high-level description)
Step 2 - Upgrading Closest Ancestor From R to E
This step is included to handle a relatively uncommon case (see the Rationale section below).
If the obtain operation is called with permission or and the closest ancestor to , that is, the longest prefix of for which there exists a node in the graph, has capability, we upgrade 's capability to in exchange for removing capability to all pre- and postfix places of in the graph (excluding itself).
This is sound because if we need to obtain non-read capability to place, and
there are any ancestors of place in the graph with R capability, one such
ancestor originally had E capability was subsequently downgraded. This function
finds such an ancestor (if one exists), and performs the capability exchange.
Perhaps it would be better to explicitly track downgrades in the analysis so that they can be upgraded later? This will make the soundness argument more convincing.
Rationale
It's possible that we want to obtain exclusive or write permission to a field that we currently only have read access for. For example, consider the following case:
- There is an existing shared borrow of
(*c).f1 - Therefore we have read permission to *c, (*c).f1, and (*c).f2
- Then, we want to create a mutable borrow of (*c).f2
- This requires obtaining exclusive permission to (*c).f2
We can upgrade capability of (*c).f2 from R to E by downgrading all other pre-and postfix places of (*c).f2 to None (in this case c and *c). In the example, (*c).f2 is actually the closest read ancestor, but this is not always the case (e.g. if we wanted to obtain (*c).f2.f3 instead)
Step 3 - Collapse
Then, if a node for exists in the graph and 's capability is not at least as strong as , collapse the subgraph rooted at (and obtain capability for ) by performing the collapse(p, c) operation.
collapse
The collapse(p) operation is implemented as follows:
- For each such that is a prefix of (from longest to shortest) and there is a node for in the graph:
- perform the Collapse Repack Operation on .
- For each lifetime in the type of :
- Create a new lifetime projection node
- For each lifetime projection node where is an expansion of :
- Label
- Create a new
BorrowFlowedge
Step 4 - Labelling
At this point, if is , we know that a subsequent operation will mutate .
As a result, if there exists a lifetime projection node for (for example, if stores a borrow that has since been reborrowed), it will no longer be tied to the current value of .
So, we label with reason ReAssign.
Step 5 - Expand
At this point there should be a node for some prefix of in the graph such that .
We expand the graph to (and obtain the capability for ) by performing the expandTo(p, o) operation.
expandTo
The expandTo operation is implemented as follows:
- For each strict prefix of (from shortest to longest):
- If expanding one level adds new edges to the graph, then
- We expand the lifetime projections of one level
- If expanding one level adds new edges to the graph, then
The operation to expand a place one level is the expandPlaceOneLevel operation, and the operation to expand the lifetime projections one level is expandLifetimeProjectionsOneLevel.
expandLifetimeProjectionsOneLevel
expandLifetimeProjectionsOneLevel is defined with three parameters:
- : The place to expand
- : The target expansion of
- : The Obtain Type
The operation is implemented as follows:
- Let be the expansion of using
- For each lifetime projection of :
- Let be the set of lifetime projections in with lifetime
- If is nonempty2:
- We identify the base lifetime projection as follows:
- Let be the current snapshot location
- If is not an obtain for capability R:
- Otherwise, if is blocked by a two-phase borrow live at :
- Let be the reservation location of the conflicting borrow
- Otherwise,
- Create a new Borrow PCG Expansion hyperedge
- We identify the base lifetime projection as follows:
-
If the program requires capability to the place to do some action, then the place cannot be borrowed mutably. This invariant does not hold when we are obtaining capability to the place in order to construct a loop abstraction. ↩
-
This could happen e.g. expanding an
x : Option<&'a T>to ax@None↩
Collapsing Owned Places
At the outset of each program point, the collapse_owned_places operation eagerly collapses the Owned PCG.
This operation is implemented as PcgVisitor::collapse_owned_places (see https://github.com/viperproject/pcg/blob/main/src/pcg/visitor/pack.rs
It is implemented as follows:
- For each place for which there exists a place node (from longest to shortest):
- If no expansion of is blocked by a borrow and every expansion of has the same capability:
- perform the collapse(p) operation
- if has no projection and has capability, upgrade 's capability to
- If no expansion of is blocked by a borrow and every expansion of has the same capability:
Activating Two-Phase Borrows
activateTwophaseBorrowCreatedAt
reserveLocation is a function from borrow edges to the MIR location at which the borrow edge was created.
The activateTwophaseBorrowCreatedAt operation takes a single parameter:
- , a MIR location
The operation is implemented as follows:
- If there exists a borrow edge in the graph such that l = reserveLocation(e):
- If there exists a place node for in the graph:
- Restore capability to
- If is not owned:
- Downgrade the capability of every ancestor of to None
- If there exists a place node for in the graph:
- TODO Logic is bad
Packing Old and Dead Borrow Leaves
The packOldAndDeadBorrowLeaves operation removes leaf nodes in the Borrow PCG that are old or dead (according to the borrow checker).
- : the current MIR location
When analysing a particular location, this operation is performed before the effect of the statement.
Note that the liveness calculation is performed based on what happened at the end of the previous statement.
For example when evaluating:
bb0[1]: let x = &mut y;
bb0[2]: *x = 2;
bb0[3]: ... // x is dead
we do not remove the *x -> y edge until bb0[3].
This ensures that the edge appears in the graph at the end of bb0[2] (rather than never at all).
This operation is implemented as PcgVisitor::pack_old_and_dead_borrow_leaves (see https://github.com/viperproject/pcg/blob/main/src/pcg/visitor/pack.rs
We must first introduce some auxiliary operations:
isDead
isDead(n, l) is true if and only if the borrow checker considers the node to be dead at MIR location
removeEdgeAndPerformAssociatedStateUpdates
removeEdgeAndPerformAssociatedStateUpdates is defined with one parameter:
- : the edge to remove
It proceeds as follows:
- For each current place node that would be unblocked by removing :
- If does not have capability, and is mutable:
- Update to map to where is the current MIR location
- If does not have capability, and is mutable:
- Remove from the graph
- For each current place node that is unblocked by removing :
- Let be if projects a shared reference and otherwise
- If has no capability or capability, upgrade its capability to
- Unlabel each region projection of
- If is a Borrow PCG Expansion edge:
- If is a Dereference Expansion where is a current place with no lifetime projections:
- unlabel
- If has a source node where is a current place:
- For each place node in the expansion of , label each region projection of with , where is the current MIR location
- If is a Dereference Expansion where is a current place with no lifetime projections:
- If is a Borrow edge; where is the current MIR location; the target of the borrow is a current place ; and has non-zero capability:
- weaken 's capability to
Main Loop
packOldAndDeadBorrowLeaves proceeds as follows:
- Until the PCG remains unchanged across the following steps:
- Let be be the set of edges such that either:
- is an Borrow PCG Expansion edge and either:
- for each , either:
- , where is the current MIR location
- or is old
- or , any pair of place nodes in have the same capability, and for all such that , has the same label as and is an exact prefix of
- or and for all such that , is an exact prefix of ; and have the same label; and and have the same label.
- for each , either:
- or for each , where or , either:
- is old
- or 's associated place is not a function argument and either:
- has a non-empty projection and is not blocked by an edge
- or , where is the current MIR location
- is an Borrow PCG Expansion edge and either:
- For each in :
- perform removeEdgeAndPerformAssociatedStateUpdates(e)
- Let be be the set of edges such that either:
Repack Operations
Repack operations describe actions on owned places.
RegainLoanedCapability
Fields:
- - Place
- - Capability
This operation is used to indicate that is no longer borrowed, and can therefore be restored to capability .
In principle I think should always be exclusive capability
Applying RegainLoanedCapability
The PCG applies this operation by setting the capability of to .
DerefShallowInit
Fields:
- - From Place
- - To Place
This operation is used to indicate that a (which is a shallow-initialized box) was dereferenced.
Applying DerefShallowInit
Let be the expansion of obtained by expanding towards .
The PCG applies this operation by adding read capability to the places in
Why do we use this logic?
Collapse
Fields:
- - Place
- - Collapse Guide
- - Capability
This operation indicates that the expansion of should be packed (using guide ) with resulting capability .
Applying Collapse
Let be the expansion of towards guide place .
Preconditions:
- Each place in has a capability
Let be the minimum capability of the places in .
- Capability for each place in is removed.
- Capability for is set to .
- The Unpack edge from is removed
The current implementation guarantees there is only one unpack edge from . In the future this may change.
Expand
Fields:
- - Place
- - Expand Guide
- - Capability
This operation indicates that should be expanded (using guide ) such that each place in the expansion has capability .
Applying Expand
Let be the expansion of towards guide place .
Preconditions:
- has capability
- The unpack edge is added
- Capability for every place in is set to
- If is Read capability, the capability of is set to Read
- Otherwise, if is not Read, capability of is removed
Note that reference-typed places will never be expanded.
Borrow PCG Actions
LabelLifetimeProjection
Fields
- predicate - A predicate describing lifetime projections that should be labelled
- label - The label to apply (
current,FUTUREor a label )
Applying LabelLifetimeProjection
Replaces the label associated with lifetime projections in the borrow PCG
matching predicate. If label is current, then the label of each matching
lifetime projection is removed.
Weaken
Fields:
- - Place
- - From capability
- - (Optional) To capability
Used to reduce the capability of a place. In general the is Exclusive, for example in the following cases:
- Before writing to , capability should be reduced to Write
- When a two-phase borrow is activated, capabilities to places conflicting with the borrowed place should be removed
Applying Weaken
If is defined, the capability of is set to . Otherwise, capability to is removed.
RestoreCapability
Fields:
- - Place
- - Capability
Instructs that the capability to the place should be restored to the given capability, e.g. after a borrow expires, the borrowed place should be restored to exclusive capability.
Applying RestoreCapability
The capability of is set to .
LabelPlace
Fields
- - Place
- - The snapshot location to use
- reason - Why the place is to be made labelled
The purpose of this action is to label current versions of (and potentially prefixes and postfixes of ) with the label corresponding to the last time they were updated.
There are six reasons defined:
StorageDeadMoveOutReAssignLabelSharedDerefProjectionsCollapse
Applying LabelPlace
The behaviour of this action depends on reason:
ReAssign, StorageDead, MoveOut
The places to be labelled are:
- All postfixes of
- All prefixes of prior to the first dereference of a reference.
For example, if is (*x).f, then *((*x).f), (*x).f, and *x will be
labelled.
Collapse
The place is labelled (but none of its prefixes or postfixes).
LabelSharedDerefProjections
All strict postfixes of are labelled.
RemoveEdge
Removes an edge from the graph. If the removal of the edge causes any place nodes to be removed from the graph, the capability of those places are removed.
AddEdge
Inserts an edge into the graph. This does not change the capabilities.
Coupling (WIP)
The PCG tracks ownership and borrowing at a fine-grained level, and in some
cases this granularity cannot be "observed" by the type system. For example,
lifetime projection nodes can represent a notion of reborrowing that is more
precise than Rust's borrow-checker itself. For example, consider the choose
function:
#![allow(unused)] fn main() { fn choose<'a, T>(choice: bool, lhs: &'a mut T, rhs: &'a mut T) -> &'a mut T { if choice { lhs } else { rhs } } }
The the PCG shape of a call choose(x, y) function: consists of two edges
and
. However, because
the compiler only tracks lifetimes, the borrows of x and y will always
expire at the same time. Accordingly, the two edges corresponding to the call
will always be removed from the graph at the same time. These edges are
therefore coupled, because the Rust type system forces the PCG to remove them
at the same time.
Motivation
Because the type systems forces a set of coupled edges to be removed "all-at-once", edges that are known to be coupled could be treated as a single hyperedge.
The primary reason for doing so is to provide more information analysis tools.
For example, Prusti uses coupling information to generate the shape of magic
wands: in the choose, the coupled hyperedge provides precisely the shape of
magic wand that Prusti encodes (although this is not always the case).
Another benefit is that coupling can reduce the size of the graphs.
Formal Definitions
Hyperedge
A hyperedge is an object with an associated set of source nodes and target nodes. The functions and denote the source and target nodes respectively.
Coupled Edges
A coupled edge is a hyperedge defined by a set of underlying hyperedges, where the sources and targets are defined as follows:
Let be the union of the sources of and be the union of the targets of . Then and .
Hypergraph
A hypergraph is a tuple where is a set of nodes and is a set of hyperedges. Functions and return the sets of nodes and hyperedges respectively.
Blocked Nodes
A node is blocked in iff and is not a leaf in .
Descendant Relation
We define the descendant relation as
Frontier
A set of nodes is a frontier of a hypergraph (denoted ) iff and is closed under .
If is a frontier of , it defines a valid expiry. The valid expiry is the subgraph of obtained by removing all nodes in and all edges containing sources or targets in . The expired edges of a valid
Reachable Subgraph
A graph is a reachable subgraph of a graph iff there exists a frontier such that
Desired Properties of Coupled Edges
Edges that will always be removed from the graph at the same time should definitely be coupled. Formally:
A set of edges expire together on a graph iff for all reachable subgraphs , either contains all edges in or none of them, i.e.:
- , or
If a set of edges expire together on a graph , then there must exist a set of edges that are coupled for .
Note that we could in principle define coupling as such, but we could also consider a stronger definition we describe below:
Definition Based on Unblocking Frontier Expiries
Our stronger definition is based on the following two observations:
First, if removing a frontier does not unblock any node in a graph, there is no way for the removal to be observed in the program. Therefore, such frontiers do not need to be considered for the purpose of asserting properties about a place once it becomes accessible. We can instead define coupling via notion of unblockings of a graph, where an unblocking is an ordered list of the sets of nodes that become available by repeated removal of frontiers.
Unblockings
An unblocking of a graph is an ordered partitioning of the non-root nodes of into non-empty subsets , satisfying the property that there exists a frontier of with an expiry that unblocks all nodes in , and is an unblocking of . The function denotes the set of all unblockings of .
Correspondingly, we can define edges as coupled if they always observably expire together, i.e. at all points when a node becomes accessible, they are either all in the graph or none of them are.
Reachable Subgraphs
Formally, for a graph and an unblocking of , the reachable subgraphs of an unblocking is the list of graphs where . The function is the lifting of to sets of unblockings:
Therefore, edges should be coupled if they are either all present or all absent for each graph in .
The second observation is that this set can be computed by considering only a subset of the unblockings in . This is because an unblocking can subsume an unblocking in the sense that the reachable subgraphs of are a superset of the reachable subgraphs of .
Subsumption
An unblocking is immediately subsumed by an unblocking (denoted ) iff there exists an such that and and .
subsumes (denoted ) iff is in the transitive closure of .
Theorem (Subsumption): If , then
Distinct Unblockings
The distinct unblockings of a graph (denoted ) is the subset of unblockings obtained by removing all non-minimal elements w.r.t .
Theorem (Distinct Unblockings): For all graphs ,
Effective and Maximal Coupling
A set of edges are effectively coupled for a graph iff for all reachable subgraphs in the distinct unblockings of , contains either all edges in or none of them. A set of edges is maximally coupled if it is effectively coupled and not a subset of an effectively coupled set.
Theorem (Correctness)
If a set of edges expire together on a graph , then there exists a set of edges that are maximally coupled on .
Proof
Recall that a set of edges expire together on if every reachable subgraph either contains all edges in or none of them, and that a set of edges are definitely coupled if for all reachable subgraph in the distinct unblockings of contains either all edges in or none of them.
Therefore it is sufficient to show that the reachable subgraphs in the distinct unblockings of is a subset of the reachable subgraphs of . The proof makes use of the following lemma:
Lemma: Valid Expiry on Unions of Frontier Nodes
If is a frontier of and is a frontier of , then is a frontier of and:
Proof is TODO
Then, let be an arbitrary unblocking of , it follows by induction on the list of frontiers corresponding to the nodes in , that any for every reachable subgraph of , there exists a frontier of such that . Therefore, for all unblockings of , the reachable subgraphs of are a subset of the reachable subgraphs of .
Test Graphs
m function
#![allow(unused)] fn main() { fn m<'a: 'c, 'b: 'e, 'c, 'd, 'e, T>( x: &'a mut T, y: &'b mut T, ) -> (&'c mut T, &'d mut T, &'e mut T) where 'a: 'd, 'b: 'd { unimplemented!() } }
w function
#![allow(unused)] fn main() { fn w<'a: 'd, 'b: 'd, 'c: 'e, 'd, 'e T>( x: &'a mut T, y: &'b mut T, z: &'c mut T, ) -> (&'d mut T, &'e mut T) where 'b: 'e { unimplemented!() } }
Previous Example
Additional Examples from HackMD
One Lifetime Reborrower Function
#![allow(unused)] fn main() { fn f<'a>(x: &'a mut T) -> &'a mut T { x } }
Possible Outlives Reborrower Function
#![allow(unused)] fn main() { fn f<'a, 'b: 'a>(x: &'a mut T, y: &'b mut T) -> &'a mut T { todo!() } }
Non-Bipartite Graph Example
Owned State
This section describes a design that is currently being implemented and is subject to change.
The Owned State describes the state of owned places at a program point. It consists of two layers:
- The Initialisation State, which tracks which owned places are initialised, uninitialised, or shallowly initialised.
- Materialised extensions, which extend the leaves of the initialisation state to reach the roots of borrows in the borrow PCG.
Place capabilities are computed from the owned state and the borrow state (see Computing Place Capabilities), rather than being stored and updated directly.
The key motivation for this design is that the initialisation state does not depend on what places are borrowed. This independence simplifies the join algorithm significantly, because borrows no longer force the owned state to be unpacked in borrow-dependent ways.
Initialisation State
Initialisation Capabilities
Each leaf node in the initialisation state carries one of three initialisation capabilities, ordered as :
Deep (D)
The place is fully initialised. All memory reachable from this place (including through dereferences) is valid and accessible. This is the state of a place after it has been assigned a value.
Shallow (S)
The place is shallowly initialised: the place itself holds a valid
value, but memory behind a dereference may not be initialised. This
state arises only for Box-typed places, where the heap allocation
exists but no value has been written through the pointer yet.
Uninit (U)
The place is uninitialised or has been moved out of. No reads are permitted; only writes (to re-initialise the place) are allowed.
Tree Structure
The initialisation state is a forest of trees, one per allocated MIR local. The root of each tree is a local variable, and internal nodes correspond to place expansions (unpacking a struct or tuple into its fields).
The key structural properties are:
- Leaf nodes carry an initialisation capability (
D,S, orU). - Internal nodes have no explicit capability; their capability is derived from their children. An internal node exists only because one or more of its descendants has a different initialisation status than its siblings.
- Invariant: if a tree is expanded (i.e. is not a single leaf node),
then at least one of its leaves must be
UorS. Otherwise, the tree would be collapsed to a singleDleaf.
For example, after executing consume(pair.0) on a pair: (String, String):
pair
├── .0: U
└── .1: D
The tree is expanded because pair.0 has been moved out while pair.1
remains initialised.
In contrast, a fully initialised pair is represented as a single leaf:
pair: D
Join Algorithm
The join algorithm on the initialisation state operates pointwise on the tree structure. Because the initialisation state is independent of borrows, the join does not need to consult the borrow state.
The algorithm is defined recursively:
The intuition behind these cases:
- Two leaves: take the minimum capability. If either side is uninitialised, the join must conservatively assume the place may be uninitialised.
- Leaf
UorSvs internal: the leaf dominates because if the place is (at best) uninitialised or shallowly initialised, the detailed expansion on the other side is irrelevant. - Leaf
Dvs internal: the internal node's structure is preserved, because a deeply initialised place is compatible with any expansion of that place. - Two internals: join children pointwise.
Example
Consider the following program:
#![allow(unused)] fn main() { type Pair = (String, String); fn f(choice: bool) { let mut pair0 = (String::new(), String::new()); let mut pair1 = (String::new(), String::new()); let mut pair2: Pair; let mut rx: String; if choice { rx = pair0.0; pair2 = pair1; // {pair0: {.0: U, .1: D}, pair1: U} } else { rx = pair1.0; pair2 = pair0; // {pair0: U, pair1: {.0: U, .1: D}} } // join: {pair0: U, pair1: U} } }
At the join point, pair0 has state {.0: U, .1: D} on one branch and
U on the other. Applying the rule
,
the result is pair0: U. Symmetrically, pair1: U.
Materialised Extensions
The leaves of the initialisation state serve as the roots of materialised extensions. A materialised extension is an additional subtree that grows off a leaf of the initialisation state to reach places that are targets of borrows in the borrow PCG.
Materialised extensions exist because borrowing a sub-place does not
change the initialisation state (e.g. &mut x.f does not expand x in
the init state), but the owned state still needs to represent the
expanded structure so that the borrow target is a node in the graph.
Construction
For each leaf of the initialisation state with place , if there exist places in the borrow PCG that are strict descendants of (i.e. is a strict prefix of each ), then a materialised extension tree is constructed by expanding toward each . The materialised extension tree uses the same expansion structure as owned place expansions. Leaves of the materialised extension carry no additional data.
Example
Suppose x.0 is moved out and x.1 is D, and there is a borrow
targeting x.1.h:
Initialisation state:
x
├── .0: U
└── .1: D
Materialised extension off x.1:
x.1
└── .h (materialised)
The full owned state combines both: the init state provides the tree
x -> {x.0, x.1}, and the materialised extension provides the edge
x.1 -> {x.1.h}.
A simpler example where the init state is not expanded:
#![allow(unused)] fn main() { let mut pair = (String::new(), String::new()); let rx = &mut pair.0; // init state: {pair: D} // materialised: pair -> {pair.0, pair.1} }
Here the initialisation state is a single leaf {pair: D} (borrowing
does not change initialisation), but the materialised extension expands
pair so that pair.0 (the borrow target) is a node in the owned
state.
Edge Capabilities
This section describes a design that is currently being implemented and is subject to change.
In the updated design, edges in the Owned PCG (specifically, unpack hyperedges) carry an edge capability that describes whether the expansion is mutable or immutable. This is used to generate upgrade/downgrade annotations when the mutability of an edge changes between PCG states.
Edge Capability Values
An edge capability is one of:
- Immutable (
I): the expansion is under a shared borrow. The parent place has capabilityR, and the children inheritR. - Mutable (
M): the expansion is not constrained by a shared borrow. Children may have capabilitiesE,W, oredepending on their initialisation state.
Computing Edge Capabilities
Edge capabilities are computed, not tracked explicitly. An edge's
capability is determined by the borrow state: if any place in the
subtree rooted at the parent of the edge is blocked by a shared
(immutable) borrow, the edge is I; otherwise it is M.
Annotations from Edge Capability Changes
When the edge capability of an unpack hyperedge changes between two
consecutive PCG states (e.g. from I to M because a shared borrow
expired), an upgrade annotation is emitted. Conversely, when an edge
transitions from M to I (e.g. because a shared borrow is created),
a downgrade annotation is emitted.
These edge-oriented annotations replace the previous per-place capability
update annotations (such as "Restore pair.1 to E"). The advantage is
that edge annotations make explicit where a capability change
originates, rather than describing isolated per-place updates whose
source is implicit.
Example
Consider the following program:
#![allow(unused)] fn main() { fn shared_borrow() { let mut pair = (String::new(), String::new()); // {pair: D} -> {pair: E} let r0 = &pair.0; // edge {pair} -> {.0, .1}: I // {r0: E, pair: R, pair.0: R, pair.1: R} let p1 = pair.1; // edge {pair} -> {.0, .1}: M (upgrade emitted) // {r0: E, pair: ∅, pair.0: R, pair.1: W} } }
When pair.1 is moved, the shared borrow on pair.0 no longer covers
pair as a whole, so the edge {pair} -> {.0, .1} is upgraded from I
to M, and an upgrade {pair} -> {.0, .1} annotation is emitted.
Computing Place Capabilities
This section describes a design that is currently being implemented and is subject to change.
In the updated design, place capabilities are computed from the owned state and the borrow state, rather than being tracked and updated via explicit rules. This eliminates a class of soundness issues (see below) and simplifies the capability accounting logic.
Motivation
The previous design maintained a map from places to capabilities that was updated by three mechanisms: statement evaluation, borrow expiry/activation, and control-flow joins. This led to three problems:
-
Unsoundness: the rule "when a mutable borrow expires, restore the place's capability to
E" is unsound when the place has been conditionally moved out. After a join, the move-out information is lost, and the borrow expiry incorrectly restoresEto a potentially uninitialised place. -
Insufficient annotations: per-place capability update annotations (e.g. "Restore
pair.1toE") do not explain the source of the capability. Edge-oriented annotations (see Edge Capabilities) address this. -
Complex rules: the rules for updating capabilities were numerous and difficult to justify, complicating an eventual soundness proof.
Computing Capabilities for Owned Places
The capability of an owned place is determined as follows:
-
Look up in the initialisation state. If is within a materialised extension, use the initialisation capability of the ancestor leaf from which the extension grows.
-
Based on the initialisation capability:
U(uninitialised): the place capability isW(write-only).S(shallow): the place capability ise(shallow exclusive).- Internal node: the place capability is
∅(none), because the place is only partially initialised. D(deep): proceed to check the borrow state below.
-
If the place is fully initialised (
D), consult the borrow state:- If the type of contains a region but the lifetime
projection does not exist in the borrow state:
the capability is
e(shallow exclusive). - If or any of its sub-places is blocked by a mutable borrow:
the capability is
∅(none). - If or any of its sub-places is blocked by a shared borrow:
the capability is
R(read). - Otherwise: the capability is
E(exclusive).
- If the type of contains a region but the lifetime
projection does not exist in the borrow state:
the capability is
Computing Capabilities for Borrowed Places
For borrowed places, the capability is determined by the borrow PCG:
- If the place projects a shared borrow (e.g.
*rwherer: &T):R(read). - Otherwise, if the place is a leaf in the borrow PCG:
E(exclusive). - Otherwise:
∅(none).
Example: Soundness Fix
The following example demonstrates how the computed capability approach avoids the unsoundness of the previous design:
#![allow(unused)] fn main() { fn conditional_move(choice: bool) { let mut p = String::new(); let mut p2 = String::new(); let mut rp: &mut String; // BB0: init {p: D, p2: D, rp: U} -> cap {p: E, p2: E, rp: W} if true { consume(p); rp = &mut p2; // BB1: init {p: U, p2: D, rp: D} -> cap {p: W, p2: ∅, rp: E} } else { rp = &mut p; // BB2: init {p: D, p2: D, rp: D} -> cap {p: ∅, p2: E, rp: E} } // BB3: join -> init {p: U, p2: D, rp: D} // -> cap {p: ∅, p2: ∅, rp: E} *rp = String::from("updated"); // After borrow expiry: // init {p: U, p2: D, rp: D} -> cap {p: W, p2: E, rp: e} }
In the previous design, borrow expiry would have incorrectly restored
p to E. In the computed design, p remains W after expiry because
the initialisation state still records p: U.