So far, all our variables have been stored on an intrinsically-typed stack. Our imperative language also has addressable memory. In this section, we will look how we can realise addressable memory using what we know about intrinsically-typed stacks. We will begin with a lot of boiler plate that will quickly fall into place towards the end.
The boilerplate we are describing here is required but their definitions are, sorry to say, standard. For many auxiliary functions we require, we will leave their realisations to the reader.
sorry…
An Exploratory Language
We will explore evaluation of addressable heaps with the following intrinsically-typed expression language, extending our language from earlier with our knowledge of memory.
Our Expression Language
Types
data Ty = BOOL | INT | UNIT | REF Ty
Terms
data Expr : SnocList Ty -> Ty -> Type where
I : Int -> Expr c INT
B : Bool -> Expr c BOOL
Let : (expr : Expr ctxt typeV)
-> (kont : Expr (ctxt :< typeV) type)
-> Expr ctxt type
Var : (idx : Elem type ctxt)
-> Expr ctxt type
Fetch : (expr : Expr ctxt (REF type))
-> Expr ctxt type
Alloc : (expr : Expr ctxt type)
-> Expr ctxt (REF type)
Mutate : (ref : Expr ctxt (REF type))
-> (val : Expr ctxt type)
-> Expr ctxt UNIT
Values
data Value : Ty -> Type where
VB : Bool -> Value BOOL
VI : Int -> Value INT
VU : Value UNIT
Intrinsically-Typed Heaps
We saw that, when evaluating expressions (with let-bindings), our stack has an address space. This address space is the typing-context with the addresses themselves being our De Bruijn indices i.e. addresses numbers themselves go from to the height of the stack itself. The same principle can also help us with a nameless addressable heap. That is, we will use a type-level context to represent type values on the heap and De Bruijn indices as the addresses themselves.
To do this, we need to introduce a new value which is the address itself. Thus, our values will also be indexed by the heap address space.
data Value : (store : SnocList Ty)
-> (type : Ty)
-> Type
where
VA : Var type ctxt -> Value ctxt type
VB : Bool -> Value store BOOL
VI : Int -> Value store INT
VU : Value store UNIT
We can even use our efficient De Bruijn representation as well.
Our type-synonym for stacks also needs to change to support heaps, by introducing a second type-level index.
Stack : (store : SnocList Ty)
-> (stack : SnocList Ty)
-> Type
Stack store stack
= All (Value store) stack
We also need to change how we define our execution environments,
but first we need to define the heap itself.
We do so by creating a list of values,
much like Stack,
but using the same index twice:
Heap : (store : SnocList Ty) -> Type
Heap store = All (Value store) store
We can then redefine our Env datatype to contain a heap and a stack:
record Env (local : SnocList Ty)
(store : SnocList Ty)
where
constructor MkEnv
stack : Stack store local
heap : Heap store
We must also update our extend and lookup functions:
extendStack : Env ls store
-> Value store type
-> Env (ls :< type) store
lookupStack : (env : Env gs ls store)
-> (idx : Var type ls)
-> Value store type
Complete the definitions for extendStack and lookupStack.
We must also provide auxiliary functions to lookup and extend the heap.
These two functions are analogous to our operations for the stack.
lookupHeap : Env ls store
-> Var type store
-> Value store type
updateHeap : (env : Env ctxt store)
-> (var : Var type store)
-> (new : Value store type)
-> Heap store
Complete the definitions for extendHeap and lookupHeap.
Extending the heap requires a few more operations to be defined first, which we will do next.
Tracking Changes to Addresses
Before we can start to evaluate our expressions, we need to address how we track changes to the heap and the addresses we have defined. As we will be appending new addresses to the end of the heap, we know that the old heap address space will be a prefix of the new one. We can encode a subset relation that one ‘list’ is a subset of another using an order preserving encoding. Specifically a ‘thinning’ relation that encodes how we can thin one ordered list into another.
Our type constructor,
called Subset,
compares two instances of SnocList.
data Subset : (this : SnocList type)
-> (that : SnocList type)
-> Type
where
The subset proposition itself, relies on three cases:
- Two empty lists are subsets of each other.
Empty : Subset Lin Lin
- If the last two elements are equal and the front of the list is a subset, then the lists are subsets of each other.
Keep : (prf : x === y)
-> (ltr : Subset xs ys)
-> Subset (xs :< x) (ys :< y)
- Finally, if the front of the list are subsets of each other then we can skip an element in the larger list.
Skip : (rest : Subset xs ys)
-> Subset xs (ys :< y)
To complement our proposition, we can prove that this subset relation is decidable:
isSubset : DecEq type
=> (xs,ys : SnocList type)
-> Dec (Subset xs ys)
We do not give the full proof here.
Complete the proof that two Subset instances are transitive.
When updating the heap we will need to relate instances of Subset together.
As our Subset is a transitive relation we can easily state it.
trans : Subset xs ys
-> Subset ys zs
-> Subset xs zs
We do not give the full proof here.
Complete the proof that two Subset instances are transitive.
More so a
refl : All p xs -> Subset xs xs
We do not give the full proof here.
Complete the proof that we can construction reflexive proof that a type-level list is a Subset of itself.
Propagating Heap Changes i.e. Weakening
Subset tracks how our heap changes,
we can use Subset instances to update existing constructs that have old knowledge of the heap to have knowledge of the new heap.
We call this ‘weakening’.
We will give the function type signatures for core operations only.
We need to update variables:
weakenVar : (prf : Subset xs ys)
-> (var : Var x xs)
-> Var x ys
And values:
weakenVal : (prf : Subset xs ys)
-> (val : Value xs type)
-> Value ys type
And stacks:
weakenAll : (sx : Stack old types)
-> (prf : Subset old new)
-> Stack new types
And finally environments:
weakenEnv : (env : Env ctxt old)
-> (heap : Heap new
-> (prf : Subset old new)
-> Env ctxt new
Complete the functions:
weakenVarweakenValweakenAllweakenEnv
Extending the Heap
Fetching and updating the heap does not require changing the address space itself. Extending the heap does.
When we add a new value to the heap,
we add the value to the end of the heap.
As we are using a SnocList setup the new
(anonymous)
address is a De Bruijn index
(a Var instance)
that points to the last element in the list: Here.
newAddress : (heap : Heap store)
-> (value : Value store type)
-> Var type (store :< type)
newAddress h v = V 0 Here
With this new address,
we need to construct a new Subset proof that we have extended the heap.
To do so we compute the subset of the heap as it is,
using refl,
and thin using Skip.
newProof : (heap : Heap store)
-> (value : Value store type)
-> Subset store (store :< type)
newProof h v with (refl h)
newProof h v | prf = Skip prf
We can then use these convenience functions, together with our knowledge of weakening to safely extend the heap.
Extending the heap,
means that our address space will be extended and we need to provide proof that the change is safe.
We will use the datatype NewHeap to capture this change as a dependent pair.
The type constructor for NewHeap requires that we know the original address space and the type of the value being inserted.
This value will end up being REF.
public export
data NewHeap : (store : SnocList Ty)
-> (type : Ty)
-> Type
where
Our constructor for NewHeap returns a new version of the heap,
the address (value) being returned,
and proof that the new heap is a superset of the old heap.
NH : (new_heap : Heap new)
-> (value : Value new type)
-> (prf : Subset old new)
-> NewHeap old type
With NewHeap,
we can extend the heap with a value,
and return the value’s address in the heap.
extend : (heap : Heap store)
-> (value : Value store type)
-> NewHeap store (REF type)
First we construct the address for the value and proof that the change is safe. We then construct the new heap by weakening the existing heap and attaching the value at the end. We also need to weaken the value with the update to the address space. Finally we can return the new heap, the address, and proof of change.
extend h v
= let new_addr = newAddress h v
in let new_prf = newProof h v
in let new_heap = map (weaken new_prf) h :< weaken new_prf v
in NH new_heap (Address new_addr) new_prf
Evaluating (Memory Operations)
With all the boilerplate in place, we can start writing our evaluation function.
Means Heaps can Change
When we evaluate our expressions,
our heap will change.
We cannot return just a value,
and we must capture heap changes in our output.
We do so using a Result datatype.
The type constructor of result takes,
as input,
a heap address space and the return type of the value.
data Result : (store : SnocList type)
-> (type : Ty)
-> Type
where
Results return a new version of the heap, the value being returned, and proof that the new heap is a superset of the old heap.
R : (heap : All (Value new) new)
-> (value : Value new type)
-> (prf : Subset old new)
-> Result old type
Using Result we can rewrite our eval function to return Result instead of a Value instance.
eval : (env : Env ctxt store)
-> (expr : Expr ctxt type)
-> Result store type
With Result it will mean that for every call to eval we will have to update the heap for subsequent calls,
this is where refl, trans, and our weakening functions (especially weakenEnv) help.
Means sometimes Heaps do not change
When a heap’s structure does not change,
for example evaluating constants,
we can use refl to construct the Subset proof.
We will do this a few times here,
and many times within Olaf.
We can spin this operation out into a function we will call pure.
pure : (heap : Heap store)
-> (value : Value store type)
-> Result store type
pure heap value
= R heap value (refl heap)
Expressions
We can now finally look at evaluating expressions.
When evaluating expressions
Constants do not change the heap and are pure.
eval env (I i)
= pure env.heap (VI i)
eval env (B x)
= pure env.heap (VB x)
Let-bindings require us to evaluate the bound expression
(expr)
and extend the stack
(in env)
with the new value.
We must also update the environment
(env) with the new heap address space prior to extending the stack and then evaluating the scope.
Finally,
when returning the result of evaluating the scope,
we need to apply trans to track the changes that happens when calling env on the scope.
eval env (Let expr scope)
= let R h v p0 = eval env expr
in let R h v p1 = eval (extendStack (weakenEnv env h p0) v) scope
in R h v (trans p0 p1)
Finally variable lookups do not change the stack.
eval env (Var idx)
= let v = lookupStack env idx
in pure env.heap v
Fetching Values of the Heap
Fetching references from the heap is much the same as lookup values from the stack. First we need to evaluate the expression to obtain the address and then use the address to lookup the value from the heap. We must also ensure that we update the environment in case the heap changed when evaluating the expression. Finally, we return the ‘fetched’ value together with the new heap.
eval env (Fetch expr)
= let R h (VA idx) p0 = eval env expr
in let v = lookupHeap (weakenEnv env h p0) idx
in R h v p0
Mutation
Mutating references follows the same structure as fetching.
First we evaluate the expression to obtain the new value.
We then evaluate the reference to obtain the address of the old value,
taking care to update the environment with the new heap.
We then update the heap with the new value,
again taking care to ensure we chain the heap change proofs.
Finally return the new heap and the value require for the operation,
which is VU, the unit value.
eval env (Mutate ref expr)
= let R h v p0 = eval env expr
in let R h (VA idx) p1 = eval (weakenEnv env h p0) ref
in let h = updateHeap (weakenEnv env h (trans p0 p1)) idx (weakenVal p1 v)
in R h VU (trans p0 p1)
Allocation
Finally, we will look allocating values onto the heap.
First we will need a convenience function to update the heap, which returns the address of the allocated value.
insert : (value : Value store type)
-> (heap : All (Value store) store)
-> Result store (REF type)
insert will require you to call extendHeap.
Complete the definition of insert,
ensuring it is specified before the definition of eval and after the definition of Result.
With insert we can evaluate the expression to be inserted to a value.
Call insert to get the address,
and return the address with the updated heap.
eval env (Alloc expr)
= let R h0 v p0 = eval env expr
in let R h1 vad p1 = insert v h0
in R h1 vad (trans p0 p1)