Lectures 8 and 9: Separation Logic
Lectures 8 and 9: Separation Logic
Learning outcomes
After this lecture you should be able to
- Predict behavior of the separating conjunction from intuition
- Write proof outlines in separation logic
- Appreciate the frame rule
Overview
After much time spent on functional programs, we will now finally start talking about imperative programs. Specifically we will add mutable variables allocated on the heap and pointers. Separation logic will be the new idea that will allow reasoning about pointers, especially the problem of aliasing: two pointers might have the same value, so storing a value in one affects the other.
Separation logic is an extension of Hoare logic. We'll still have a specification of the form . There are three main extensions:
- Our programs (the expressions ) will now have constructs for allocating, reading, and writing to heap memory.
- Propositions will no longer be pure and equivalent to a Coq
Prop
. Instead, they'll be predicates over the heap, of typehProp := heap β Prop
(heap
is our representation of the state of these imperative programs, which we'll get to later). The precondition and postcondition will be these heap predicates, so they'll say something about both program variables and the initial and final states of the program. - In addition to the usual connectives over propositions (, , ), we'll add a new points-to assertion that says what the value of one heap address is, and a new separating conjunction , pronounced " and separately ". says that and both hold separately: they must be true over disjoint sub-parts of the heap.
- We'll add some new rules for proving separation logic triples (and keep all the old rules). A crucial rule is the frame rule which permits us to write a specification for a function that talks only about the memory addresses that a program needs, while still capturing that other addresses are unchanged when we use that function's specification in a bigger context.
Programming language
We only need a few new constructs to have something interesting:
We add a value which is often called a "unit value" - you can think of it like an empty tuple, which carries no information, which will be used when a function has nothing interesting to return. Memory addresses are modeled using locations, which use the metavariable . Locations are a new variant of the value type, as denoted by . The type of locations will simply be loc := Z
(unbounded mathematical integers), and we won't have a way to do pointer-integer or integer-pointer casts. A location is like the value of a pointer; it doesn't carry data, but can be dereferenced to access data.
The expression allocates a new location and gives it the initial value , then reduces to . The syntax is a load from the address while stores in the address given by . In all cases when these operations are used on expressions, they must first be reduced to values; loading and storing to a non-location fails (is stuck). You can think of as being like the C or Go code *e1
and as being like *e1 = e2
.
The new syntax is fairly small, but it requires a big change in the semantics: programs now have state. In the computer, the heap memory is a large array of bytes. In our model of programs, it will be a little more abstract: memory addresses will be loc := Z
(that is, mathematical and thus unbounded integers), and each location in the heap will have a value of the programming language. A heap will then be heap := gmap loc val
or more mathematically ; the symbol represents a finite partial function, which maps some locations to values, and always has a finite domain. It will be useful to talk about the domain of a heap , which you can think of as the set of allocated addresses in .
The semantics of a program will now be given by a new small-step operational semantics of the form . This involves both reducing the expression and changes to a heap ; we will still have expressions that reduce to values. We use a new notation to be clear this is different from the pure reduction step from before. I'll use to refer to that relation, which is still useful for many expressions since if then .
Exercise: simulate heap operations
Suppose we have a heap . The locations , , and are allocated in . Starting in , the expression evaluates to , evaluates to , and evaluates to . We then fully evaluate the expression and finish in heap . What are and ?
You can use notation like to write out a heap where and (and nothing else is allocated). You can also ignore any locations not mentioned.
Solution
From the provided expressions, we can work out:
The three locations have different values so we can confirm they are distinct. (Did you think about that?)
The expression takes several steps in . It first reduces to , without changing the heap (though we did not say in what order those loads happen, it doesn't affect the answer). This store then results in a heap .
If we choose not to ignore other locations, we can say more generally that for some that does not contain , , or , and the final heap will be with the same . This statement captures all the possibilities for how this example expression executes, not just the smallest heap. However, writing doesn the general statement took much more work, and this is only a one-line program; separation logic will help with that.
Heap predicates
In separation logic, when we write , the propositions and will no longer be regular Coq Prop
s but instead heap predicates hProp := heap β Prop
. The meaning of the Hoare triple is that if we start in an initial heap where is true, run till it terminates in a value and final heap , then holds (notice how is a heap predicate which is a function of a value, thus it needs two arguments).
Aside on logic
The view that propositions of separation logic are heap predicates is not actually necessary, and goes against traditional presentations of logic. The alternative is that propositions are defined by the syntax and the rules below (plus several more rules, like the ones from the Hoare logic section on propositions). We could then use heap predicates to give a particular "model" or semantics for what separation logic propositions mean.
As with Hoare logic, I am instead giving what's called a model-theoretic view, where everything is done in the context of a specific model and all the rules are lemmas. I think this helps make things more concrete since you can think about one model rather than trying to juggle all the rules.
The logic view is still very useful. One thing it enables is that if we do proofs assuming just the rules, then we can switch to a different model where the rules are still true; while heap predicates are the standard model, there are extremely useful non-standard ones as well. Later on, when we get to concurrency, it will be practically necessary to work with the rules since the model is just too difficult to wrap your head around - someone still needs to prove the rules in that model once, but you'll be glad you're not doing it.
Separation logic propositions
The assertion (read " points to ") says that the heap maps location to value . The proposition (read " and separately ", or simply " star ") is called the separating conjunction. Like a regular conjunction, it asserts that both and are true. What makes it special (and this is the key feature of separation logic) is that it also asserts and hold in disjoint parts of the heap. For example, is false, because cannot be allocated in two disjoint parts of the heap.
We also add a new proposition which says the heap is empty.
Remembering that propositions are interpreted as heap predicates, we can formally define them as follows:
Points-to: The points-to connective is true for exactly one heap, which maps to and nothing else.
Separating conjunction: For separating conjunction we need to say two heaps are disjoint. We could say , but it's convenient to have a shorter notation and we'll use :
Understand separating conjunction
Separating conjunction is quite key to separation logic. You should make sure you understand what this definition says, with the goal of understanding it intuitively.
emp: This is pretty straightforward: (that's the empty map). Equivalently, (that's the empty set).
Here are a few rules for working with separating conjunction (the standard rules for propositions also largely apply here):
Entailment between heap predicates is straightforward to define:
Remember that and are predicates over heaps, so we cannot say one "implies" the other directly, but (for some heap ) on the other hand is a and we can use regular Coq implication over it.
Remember the syntax from earlier. This "lifts" a pure proposition to the propositions in our logic. Before those were also and lifting didn't do anything, but now we want heap predicates. The definition we'll choose is ; this requires to be true and also meanwhile asserts is true.
We'll also define to always be true, regardless of the heap. Observe that (where this True is now a pure proposition) is actually .
Exercise: prove sep-true
Prove , using the definitions above. (What definitions do you need?)
Linear vs Affine separation logic
There are actually two variations on separation logic: linear and affine. What is presented above is a linear separation logic. A linear logic requires every proposition or "resource" to be used exactly once in a proof . An affine separation logic is similar, but allows propositions to be used zero or one time. Concretely this is due to an additional rule, a weaken or discard rule:
Typically the divide between linear and affine separation logic is that a linear logic is used for a language with manual memory management while an affine logic is used for a garbage collected language. In the linear setting we would want an operation in the language that deallocates the location , which has the spec . Notice that we need actual code to drop a memory address, whereas sep-weaken is a logical operation that "forgets" about memory without disposing of it in the program; in a garbage collected language this is perfectly fine, since we cannot manually dispose of memory anyway.
We will eventually want to use an affine separation logic, for two reasons:
- We will be reasoning about Go, which is a garbage collected language.
- In sequential linear separation logic, we can prove that if a program satisfies really deallocates all memory it uses. In concurrent separation logic, linearity does not give us this property (there are ways to leak memory while still proving the rule above). Thus there is no benefit to having linearity, while being able to discard propositions is useful.
Affine separation logic has one wrinkle, though, which is why it isn't presented as the default above. The sep-weaken rule is not compatible with the model of heap predicates above: is true for a two-element heap, and is not true in that heap (since it has too large a domain).
There are a few ways to deal with this, but the simplest to explain is also the approach taken by Iris, the implementation of (concurrent) separation logic we'll be using shortly. Instead of allowing a heap predicate to be an arbitrary function , we require it to be a monotonic predicate where . Observe that if has this property, then ; we can extend the "footprint" of to include whatever memory held over.
If you want to see a different (and more sophisticated) approach, the Software Foundations volume on Separation Logic has a chapter on affine separation logic. It makes dropping a predicate a feature of the program logic rather than the assertions themselves, and also considers that only some propositions can be dropped; this allows us to, say, leak memory, but still be forced to explicitly close resources like file handles.
Separation logic program logic rules
Now we move to the program logic part of separation logic, for reasoning about programs. Separation logic adds very few rules on top of Hoare logic. First, some simple ones for the new operations:
These are not that surprising. These rules are sometimes called the "small footprint axioms" because they only talk about the smallest part of the heap that is relevant to the operation. We add a bit of notation here: is used for a postcondition that ignores the return value.
The heart of separation logic is the celebrated frame rule:
The frame rule supports separation logic's ownership reasoning. The idea is that having an assertion in the precondition expresses "ownership", for example means the function starts out owning the location . Owning a location means no other part of the program can read or write it. For example, in the triple , the function owns for the duration of its execution and during the proof of this triple we can be sure no other function will interfere with . Furthermore because the triple does not mention in its precondition, we know it does not need to access that location; this is what the frame rule captures.
As an example, consider proving a specification for the following program:
Assume the specification above for :
Definition of e1; e2 and assert
If it bothers you that we are using in a program and without defining them, here are appropriate definitions:
(equivalently, a let binding with any variable unused in )
(evaluates to if is true, otherwise is an error)
What you need to know about is really just that it has the following specification:
For now, we merely wish to prove that this program is safe, which means showing the assertion is always true, which is captured by the following specification:
We can give a proof outline in separation logic for this function, in the same style as we did for Hoare logic:
Some of the proof steps involve multiple operations on the same line, so I've annotated those with to indicate that we're showing how the program executes rather than a new line of code. (This is a somewhat ad-hoc notation; these proof outlines are only meant to communicate the idea of the proof to other people, so we don't need to be completely precise in what they mean.)
Exercise: prove swap correct
You should write out swap as three lines of code for this outline. Identify what the frame is each time your outline (implicitly) uses the frame rule.
Hint
Recall that from one assertion to the next is supposed to use a known specification for the function in between (for the outline to be valid). When you have more facts in the precondition than the known specification, you need to use the frame rule.
Solution
There's a frame in pretty much every step, since each line interacts with either or and the other is framed out. Technically the pure assertion is also framed once it appears.
Note that at the end we can drop \lift{t = a}
even in a linear logic; it "takes up no space" in the heap since the part where it holds also satisfies .
Soundness
Remember that with Hoare logic, we defined what a triple means, which we called the soundness theorem for Hoare logic. We also said we'll instead take it as the definition of a Hoare triple, and all the rules will be theorems proven from the definition. We can do something similar with separation logic.
At this point we should start thinking more abstractly about the logic, so we'll see four definitions of soundness, which differ in how much detail of the model (heap predicates) they rely on.
Definition 1 Pure Soundness: If and , then is not stuck, or for some value and holds.
This definition requires a pretty specific triple: it cannot involve anything about the heap, only a trivial precondition and a pure postcondition about the return value. However, notice that in this case it doesn't matter if we're using heap predicates or anything else.
Definition 2 Sequential Separation Logic Soundness: If
and and , then is not stuck or for some value and there is an such that .
This definition uses only the new separation logic propositions we've seen, points-to and separating conjunction, and also doesn't reference the fact that they are heap predicates. Notice that it does talk about framing out any extra parts of the heap not used by the precondition.
Definition 3 Heap predicate soundness: If holds, if we have and , then either is not stuck, or for some value and holds.
This definition is directly given in the model with and interpreted as heap predicates.
There's a fourth definition which turns out to be especially useful:
Heap predicate soundness with framing
It is convenient to define a separation logic triple to always include framing, especially if you wanted to prove the frame rule. This is one of the more useful definition of soundness. This is what that definition looks like:
Definition 4 Soundness with framing: If holds, if we have , some frame heap such that , and , then for some and either is not stuck, or for some value and holds.
In this definition plays the role of and plays the role of , compared to the definitions above.
Recursion & loops
Imperative programs typically have loops, and we haven't yet shown a way to reason about them. As you'll see later, the ultimate goal will be to use the simple programs we have above to model the behavior of an imperative program. In this process we can translate a complex feature like for
loops into something more primitive. For all types of loops, it's sufficient to add recursion to our programming language, and a way to reason about recursive functions.
When you write a recursive function, you typically refer to the definition of the function in its body. Our language doesn't have definitions, so we instead add recursive lambdas or anonymous recursive functions:
The expression is like , except that it can call itself via the name . In fact we don't need the non-recursive functions anymore; they can be replaced with new notation , where we just never refer to the function recursively.
The semantics of recursive functions are given by a new and improved beta rule:
We do two substitutions in a row: first, we substitute the entire original function (that's the recursion part) over the name chosen by the user, then substitute over the regular argument .
We can't really use the Hoare pure-step rule to reason about this function. After using that rule, we'd come back to some recursive subcall, and we'd be in an infinite loop in the proof. The situation is much like how destruct
isn't enough to reason about interesting theorems about nat
; we need induction
. The analogous reasoning here is the following rule (I've written just as the postcondition instead of for concision):
If you take a moment to parse this rule, it will seem magical. In reasoning about this function, we get to assume the same triple we originally set out to prove! There are two explanations for why this works:
First, separation logic, like Hoare logic, is only giving partial correctness. We don't prove the function terminates, only that if it does terminate, the postcondition will be true.
Second, we get to assume the original specification only for recursive occurrences; the premise has an entailment where the goal already has one step of beta reduction. In general assuming what you're about to prove is bad form (that is, allows you to prove false things), but that's not what's going on here.
We'll set aside recursion for a moment and start reasoning about loops. Let's say we want to write a function that sums the first natural numbers. We'd probably want to write it with a for
loop. Here's one way to write it:
The new construct is equivalent to . You can either take this as a new language feature, or expand the details block to see a definition using recursion.
Defining for
On a first read it's probably useful to take for granted that is a language construct with the expected meaning. Here's how we can put it into the language we already have (and therefore prove theorems about for loops using the recursion rule we already have):
We can prove the following rule for reasoning about for loops using a loop invariant that is true when the loop starts, and is preserved by each iteration of the loop. Since the for loop always has a loop index , we'll make a function of that index variable.
Using this rule, we can prove that returns . The key is to use a loop invariant .
Magic wand (separating implication)
There is one more operator for separation logic assertions: , typically pronounced " wand ". It is often affectionately called "magic wand". is a heap predicate that is true in a (sub)heap if when you add some disjoint heap satisfying , the whole heap would satisfy . The wand operator is the implication of separation logic.
Characterization of wand
If you remember only one thing about wand, remember .
Notice the similarity to , if we had mere s; this might help you remember this fact.
Formally we can define wand as
One characterization of wand is:
Again, read the same thing but for propositional logic:
If you read (either of these rules) left to right, you can think of them as moving into the hypotheses, if this were a Coq tactic. Reading right to left, we can move a hypotheses back into the goal (you haven't really needed to do that in Coq but you should certainly imagine it's sound).
One very useful rule for wands is that they curry in the same way as functions and propositional implication:
(the notation is a shorthand for and ; it's like ).
Again, make an analogy to how is equivalent to .
Because of currying, implication and wand are parsed as right associative: is written without parentheses as and is written . The other parenthesization, , has a less intuitive meaning ("if we had a proof that implies , then we could prove ") and comes up less often.
There's more to say about magic wand. Some practice is needed before you become comfortable with it, which I think will be easier with the Coq development than just seeing rules on paper.
Weakest preconditions
(The presentation here owes much to the Separation Logic volume of Software Foundations, written by Arthur CharguΓ©raud)
We will now introduce weakest preconditions. The high-level view is that weakest preconditions are an alternate view on triples which have practical benefits for formalizing and automating proofs in separation logic. There's also a broader motivation to learn weakest preconditions, which is that even more automated tools for program verification like Dafny use weakest preconditions to turn the user's program, specification, and proof into a query for an SMT solver like Z3, which knows nothing about pre or postconditions.
The weakest precondition , where is an expression and , is a heap predicate. It is defined so that is a precondition that, if true initially, would make a valid postcondition for , and also it is the weakest such precondition, more general or "better" than any other valid precondition.
We can define weakest preconditions in terms of the semantics, as a heap predicate. is true if for any , such that , either (a) is not stuck, or (b) is a value and .
Notice how very similar this definition is to the heap predicate soundness definition above. It is equivalent to the following (very useful) characterization:
Characterization of weakest preconditions
We can reformulate this as:
The left conjunct is useful to remember: the weakest precondition is a precondition that makes true. The right conjunct expresses the "weakest" part.
Weakest preconditions let us write more concise rules that are also better suited to automation:
One reason to introduce wand is that we can combine the frame rule and the rule of consequence into the ramified frame rule:
Weakest preconditions also make for more concise rules about sequencing and bind:
This is a special case of bind. Exercise: extend it to an arbitrary evaluation context, following the template of the Hoare bind rule.
Notice how unlike the hoare-seq rule, we don't have to invent an intermediate condition that is true after ; it's implicit that we can prove any postcondition that implies - this is the power of using a definition of weakest precondition.