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 00 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:

  1. Two empty lists are subsets of each other.
    Empty : Subset Lin Lin
  1. 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)
  1. 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:

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)