De Bruijn indices are a powerful tool when reasoning about programming languages. Our encoding using Elem, however, has its drawbacks. For each index, we are constructing a value that contains a copy of the context. As the number of variables grow there can be an effect on our variables’ memory footprint.

In this section we will show how Idris’ support for Quantitative Type Theory (QTT) enables to retain the proof-relevant inductive structure of nameless indices and reduce indices to natural numbers at runtime.

I first encountered this technique when Edwin Brady first started investigating how to re-imagine Idris, then written in Haskell, in Idris itself.

Usage Annotations

Idris’ implementation of QTT allows binders to be annotated with one of two values that restrict how/when a variable can be used:

0
This variable is only accessible at compile-time;
1
This variable is can only be used once at runtime;

All nonannotated variables have unrestricted usage, can be used as many times as you like both at runtime and compile-time.

A Location Aware Elem

We know from our use of Elem for De Bruijn indices that the position within the SnocList is indicated by the Here and There data constructors. The constructor Here says we are the ‘head’ of the SnocList, the last element in the list. The There constructor says we are one more position aware from the head i.e. one more element away from the last element.

If we examine the structure of how natural numbers are encoded using datatypes:

data Nat = Z | S Nat

there is a correspondence with There and Here. We can use this correspondence to provide an alternative to Elem, AtIndex, to state that an element exists in a list at a specific position.

data AtIndex : (x   :          type)
            -> (sx  : SnocList type)
            -> (loc : Nat)
                   -> Type
  where
    Here : (prf : x = y)
               -> AtIndex (sx :< y) Z

    There : (ltr : AtIndex x  sx      (S loc))
                -> AtIndex x (sx : y)    loc

AtIndex differs from Elem in that we extend the type constructor to receive a natural number. As we build up instances of AtIndex, we also build up the natural number representing the location.

Within this section, we will not dive too deeply into how we can construct instances of AtIndex. We do not need to know how at this moment in time, and we will revisit their construction when talking about elaboration. Suffice it to say, there are two ways we can build AtIndex instances. Both of which are decidable, assuming that the equality of elements within the list is also decidable.

The first, isAt, checks to see if a given element is at the specificed position within the list.

isAt : DecEq type
    => (x   : type)
    -> (loc : Nat)
    -> (sx  : SnocList type)
           -> Dec (AtIndex x sx loc)

The ‘proof’ isAt assumes that we know a priori the index, this is not useful when we want to know what the index is! Instead a more useful version is hasIndex, which finds the index iff the element is within the list.

hasIndex : DecEq type
        => (x  : type)
        -> (sx : SnocList type)
              -> Dec (loc ** AtIndex x sx loc)

The return type of hasIndex is a dependent-pair that enables us to provide/promote a value to the type level. Meaning that if we do find a location we can then provide it.

We will see how we can use AtIndex to safely index elements in the next lecture.

If you are familiar with decidability, try completing the function bodies for isAt and hasIndex.

If you are not familiar with decidability, try completing the function bodies for isAt and hasIndex with Maybe instead of Dec.

Intermezzo Dependent Pairs

Dependent pairs are an important datatype within dependently-typed programming, For a while, their definition within Idris was:

data DPair : (type : Type)
          -> (pred : type -> Type)
                  -> Type
  where
    MkDPair : (witness : type)
           -> (value   : pred witness)
                      -> DPair type pred

The type constructor DPair states that we need to know the type of a value and that there is an indexed type that requires an instance of that value. The data constructor MkDPair establishes the relationship that a value exists, the witness, that can inhabit the type of the dependent datatype.

Dependent pairs are now defined as a record so that projection functions fst and snd, to extract the witness and value, need not be defined in addition.

record DPair type (pred : type -> Type) where
  constructor MkDPair
  fst : type
  snd : pred fst

Within Idris dependent pairs, both types and values, are often written using the sugared syntax:

Values
(<value> ** <data constructor>)
Types
( <variable> : <optional type> ** <type constructors>)

For instance, here are three versions of saying the 1 is at the end of the following SnocList [< 1].

one : (loc ** AtIndex 1 [< 1] loc)
one = (Z ** Here Refl)

two : DPair Nat (\loc => AtIndex 1 [< 1] loc)
two = MkDPair Z (Here Refl)

thr : DPair Nat (AtIndex 1 [< 1])
thr = MkDPair Z (Here Refl)

Efficient Indices

We will use AtIndex as our datatype that provides a nameless representation. We do not, however, want AtIndex to be around at runtime. Thus, we will use the 0 usage annotation to ensure that the index itself is only available at compile-time, but retain the location (the natural number) at runtime. We will apply the usage information on a binder inside a custom datatype Var.

data Var : (x  :          type)
        -> (sx : SnocList type)
              -> Type
  where
    V : (  loc : Nat)
     -> (0 prf : AtIndex x sx loc)
              -> AtIndex x sx

The type constructor for Var is the same as Elem. However, there is a single data constructor that replicates the operation of a dependent pair but with the second element (prf) marked with 0 usage. We have use an explicit datatype to keep our usage annotations specific for our needs.

Try repeating the earlier exercises, completing the function bodies for isAt and hasIndex, by returning Var instead of the dependent pair.

Idris supports Exists and Subset to make the value or dependent datatype compile-time only.

An alternate encoding would be to use a type-synonym that maps to Subset.

export
Var : (x : type) -> (sx : SnocList type) -> Type
Var x sx = Subset Nat (AtIndex s sx)

By making this synonym export only we obfuscate the definition to ensure that only instances of the synonym are used elsewhere in the code base.

Rewrite your intrinsically-typed representation of Expr using Var instead of Elem.

We will see how to use Var to safely index lists in the next lecture.