Let us consider the /Well-Typed Interpreter/ (WTI), a well known application of dependent types to ensure that our program’s are not only well-typed but also well-scoped w.r.t. variables. To be specific the WTI details not how program’s are correct but /expressions/. When dealing with program’s we will have a top-level expression that may depend on globally declared expressions.
In this post I will describe a Razor, detailing how with a little thought we can use the concept of the WTI to described /Well-Typed Programs/.
Further, I will also show how we can then convert a well-typed expression language to a well-typed program where certain Let-bound expressions are promoted to declarations. Thus, showing in some ways how to use dependent types for well-typed program transformations.
This post was derived from a literate Idris file so there will be some cruft related to Idris.
module Main
We will need some data structures, and helper functions, not defined in Idris’ prelude.
import Data.List
The following import is from my container’s library, we will need these in the final sections to keep our musing’s DRY.
import Data.DList
Totality is a good thing here.
%default total
A Well-Typed Expression Language.
namespace Source
Here we shall describe a well-typed expression language based on Hutton’s Original Razor.
Types
Within our expression language our types are either Boolean’s or Integers:
data Ty = BOOL | INT
Expressions
Using dependent types we can then construct our expression language as an algebraic data type whose type is indexed by the type of expressions:
data Expr : Ty -> Type where
We now describe our language’s expressions:
Ref : String -> (ty : Ty) -> Expr ty
Our references are not well-scoped using DeBruijn indicies. I have done this purposefully to mirror a problem in a code generation tool I am currently developing.
I : Int -> Expr INT
B : Bool -> Expr BOOL
Our values contain values.
I have simplified values specification and an alternative formulation would be to specify a singular constructor with a type-level function to calculate the type of the value from an instance of Ty
.
This is not required for this post.
Add : Expr INT -> Expr INT -> Expr INT
And : Expr BOOL -> Expr BOOL -> Expr BOOL
For each of our base types we present a singular binary operation (conjunction for booleans, and addition for integers).
Let : (binder : String) -> Expr typeA -> Expr typeB -> Expr typeB
Standard Let-bindings introduce variables.
Examples
Here we describe some instances of our expression language.
example0 : Expr BOOL
example0 = And (B False) (B True)
example1 : Expr INT
example1 = Add (I 5) (I 9)
example2 : Expr BOOL
example2 = Let "x" example0
(And (Ref "x" BOOL) example0)
example3 : Expr INT
example3 = Let "x" example1
(Add (Ref "x" INT) example1)
example4 : Expr INT
example4 = Let "y" example0
(Let "z" (Ref "y" BOOL)
(Add (I 4) example3)
)
example : Expr INT
example = Let "x" example0
(Let "y" example1
(Let "z" (I 6)
(Let "a" (And (B False) (Ref "x" BOOL))
(Add (Ref "z" INT) (Ref "y" INT))
)
)
)
A Well-Typed Well-Scoped Program
namespace Destination
Here we describe our well-typed and well scoped program. Many of the construct’s used follow that from the WTI and may be familiar, if not my previous post on Hutton’s Razor(s) should give a quick introduction.
Types
Like our expression language we have two types: Booleans and Integers.
data Ty = INT | BOOL
Context
We can use our types to define a typing context as a list of name-value pairs.
Context : Type
Context = List (String, Destination.Ty)
To help us construct existenial proofs (DeBruijn indicies) that a name-type pairing exisits in the typing context we need to provide an instance of Decidable Equality.
intNotBool : Destination.INT = Destination.BOOL - Void
intNotBool Refl impossible
DecEq Destination.Ty where
decEq INT INT = Yes Refl
decEq INT BOOL = No intNotBool
decEq BOOL INT = No (negEqSym intNotBool)
decEq BOOL BOOL = Yes Refl
Fortunatly, our language has two types or we would be here for a while providing a coherent proof of decidable equality…
Expressions
We now go on to describe our expression language.
data Expr : (local : Context)
-> (global : Context)
-> (type : Destination.Ty)
-> Type
where
Our trick to describing well-typed programs is to parameterise the type of our expressions with two contexts: a local context; and global context.
Local : Elem (binder, ty) local -> Expr local global ty
Global : Elem (binder, ty) global -> Expr local global ty
We can now define two kinds of references a Local
reference that refers to a locally bound expression, and a Global
reference that refers to a globally bound expression.
I : Int -> Expr local global INT
B : Bool -> Expr local global BOOL
As before, values are values.
And : Expr local global BOOL
-> Expr local global BOOL
-> Expr local global BOOL
Add : Expr local global INT
-> Expr local global INT
-> Expr local global INT
And our two operations are defined as before.
Let : (this : String)
-> (beThis : Expr local global x)
-> (inThis : Expr ((binder,x)::local) global y)
-> Expr local global y
Let
is a binding site to bind an expression to a variable name.
As our well-scoped terms rely on true names, our let binding also requires that we provide the binder and at the type-level we extend the context for inThis
with the introduced variable name.
Examples
We now give some simple examples using locally and globaly bound expressions.
expr : Expr Nil Nil BOOL
expr = (Let "x" (B True)
(And (Local Here) (B False)))
expr1 : Expr Nil [("foo", INT)] INT
expr1 = (Let "x" (I 1)
(Add (Local Here) (Global Here)))
Declarations
Our attention now turns to global declarations. We define this using a cons-style list that contains expressions bound to names.
data Decls : (global : Context) -> Type where
We index the type of delcarations with the global context ensuring that our declarations can refer to earlier defined declarations.
Empty : Decls Nil
It is okay to have no global declarations in our programs.
DeclAdd : (binder : String)
-> (expr : Expr Nil global type)
-> (rest : Decls global)
-> Decls ((MkPair binder type) :: global)
DeclAdd
allows us to extend our list of declarations.
We require that our expressions are closed terms w.r.t to the local context.
Addition of a declaration extends the global context by one.
Our declarations mirror Let
bindings from our expressions.
Note that our use of real names makes our well-scoped representations named rather than nameless. We still need to think how to ensure that our list contains unique names in a nice fashion.
Programs
We have expressions and declarations, so let us now define a program as a set of declarations paired with a single closed term.
data Prog : Type where
MkProg : Decls global
-> Expr Nil global type
-> Prog
Done!
And here is an example program:
prog : Prog
prog = MkProg (DeclAdd "bar" (Let "x" (B True) (And (Local Here) (B False)))
(DeclAdd "foo" (I 2) Empty))
(Let "x" (I 1)
(Add (Local Here) (Global (There Here))))
Going from Source to Destination.
We have thus far shown an expression language, and a /program language/. Let us now consider how to build a program from an expression.
Type interpretation
We first describe a function interpTy
to map types from our expression language to our program language.
interpTy : Source.Ty -> Destination.Ty
interpTy BOOL = BOOL
interpTy INT = INT
Build Environment
As with interpretation in the WTI we will have a /build environment/ to keep track of our local typing context and store our global declarations.
data BuildEnv : (genv, lenv : Context)
-> Type
where
MkBEnv : (decls : Decls global)
-> (local : Context)
-> BuildEnv global local
Build Result
We also need a build result to return the resulting top-level expression and list of declarations that the expression depends upon.
data BuildRes : (genv, lenv : Context)
-> Destination.Ty
-> Type
where
MkBRes : Decls genv
-> Expr lenv genv type
-> BuildRes genv lenv type
We also provide a default environment.
defaultEnv : BuildEnv Nil Nil
defaultEnv = MkBEnv Empty Nil
Build Errors
Unfortunately, our conversion between the two programs will not be perfect and errors can happen. We present a set of helpful errors to help describe problems we have.
data Error = NotAVar String
| AddLOperandWrong Error
| AddROperandWrong Error
| AndLOperandWrong Error
| AndROperandWrong Error
| BadContextL String (Context) Context
| BadContextV (Context) Context
| ToGlobalErrorExpr String Error
| ToGlobalErrorBody Error
| ToLocalErrorExpr String Error
| ToLocalErrorBody Error
Nasty Hack
When we convert the inThis
body of a let binding for local variables we may extend the global context with new declarations.
We need updateThis
to facilitate updating the global references in the this
expression.
updateThis : (globalY : Context)
-> (this : Expr local globalX type)
-> Either Error (Expr local globalY type)
updateThis globalY (And x y) =
do x' <- (updateThis globalY x)
y' <- (updateThis globalY y)
pure $ And x' y'
updateThis globalY (Add x y) =
do x' <- (updateThis globalY x)
y' <- (updateThis globalY y)
pure $ Add x' y'
updateThis globalY (Let binder y z) =
do x' <- (updateThis globalY y)
y' <- (updateThis globalY z)
pure $ Let binder x' y'
updateThis globalY (Global {binder} {ty} x) with (isElem (binder,ty) globalY)
updateThis globalY (Global {binder = binder} {ty = ty} x) | (Yes prf) = pure $ Global prf
updateThis globalY (Global {binder = binder} {ty = ty} x) | (No contra) = Left $ NotAVar binder
updateThis globalY (Local x) = pure $ Local x
updateThis globalY (I x) = pure $ I x
updateThis globalY (B x) = pure $ B x
There will probably be a better way to do this, as described in PLFA, but this is a quick and cheap hack…
The Conversion function!
With all our machinery we can now construct our conversion function:
convert : (env : BuildEnv global lenv)
-> (expr : Source.Expr type)
-> Either Error (genv' ** BuildRes genv' lenv (interpTy type))
The result of converting an expression expr
, with assumed environment env
, will either be an error or the converted expression together with the global declarations.
For this conversion we will promote all Boolean typed variables to be global declarations.
For conversion to work properly we assume that our expression language is in a normal form: all let-bindings have been floated to the top of the expression tree.
convert (MkBEnv {global} decls lenv) (Ref x type) with (interpTy type)
convert (MkBEnv {global} decls lenv) (Ref x type) | ty with (isElem (x,ty) lenv)
convert (MkBEnv {global} decls lenv) (Ref x type) | ty | (Yes prf) =
pure (_ ** MkBRes decls (Local prf))
convert (MkBEnv {global} decls lenv) (Ref x type) | ty | (No contra) with (isElem (x,ty) global)
convert (MkBEnv {global = global} decls lenv) (Ref x type) | ty | (No contra) | (Yes prf) =
pure (_ ** MkBRes decls (Global prf))
convert (MkBEnv {global = global} decls lenv) (Ref x type) | ty | (No contra) | (No f) = Left (NotAVar x)
When encountering a reference from the expression language, we check if the reference refers to a global or local context. If it doesn’t then we have an error.
convert env@(MkBEnv ds lenv) (I x) = pure $ (_ ** MkBRes ds (I x))
convert env@(MkBEnv ds lenv) (B x) = pure $ (_ ** MkBRes ds (B x))
Constant conversion is straightforward.
Conversion of operations is that liitle bit more involved both require that we convert both operands, however we need to ensure that the resulting global contexts are the same. If not then an error has occurred.
Note Some of this code will be hard to read as we have to use dependent pattern matching when using dependent pairs.
I think this can be simpilified to using Do-notation and considered use of case
but this works…
convert env (Add x y) with (convert env x)
convert env (Add x y) | Left err = Left (AddLOperandWrong err)
convert env (Add x y) | (Right z) with (z)
convert env (Add x y) | (Right z) | (g' ** pfX) with (pfX)
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') with (convert env y)
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | Left err = Left (AddROperandWrong err)
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) with (w)
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) with (pfY)
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') with (decEq g' g'')
convert env (Add x y) | (Right z) | (g'' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') | (Yes Refl) = pure (_ ** MkBRes declsY (Add x' y'))
convert env (Add x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') | (No contra) = Left (BadContextV g' g'')
More of the same!
convert env (And x y) with (convert env x)
convert env (And x y) | Left err = Left (AndLOperandWrong err)
convert env (And x y) | (Right z) with (z)
convert env (And x y) | (Right z) | (g' ** pfX) with (pfX)
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') with (convert env y)
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | Left err = Left (AndROperandWrong err)
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) with (w)
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) with (pfY)
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') with (decEq g' g'')
convert env (And x y) | (Right z) | (g'' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') | (Yes Refl) = pure (_ ** MkBRes declsY (And x' y'))
convert env (And x y) | (Right z) | (g' ** pfX) | (MkBRes declsX x') | (Right w) | (g'' ** pfY) | (MkBRes declsY y') | (No contra) = Left (BadContextV g' g'')
We now move onto dealing with let bindings. If the variable has type boolean then we promote it to a global definition.
convert env (Let binder x {typeA} y) with (typeA)
First we convert the expression to be bound, ensuring that it is closed.
convert (MkBEnv ds lenv) (Let binder x {typeA = typeA} y) | BOOL with (convert (MkBEnv ds Nil) x)
convert (MkBEnv ds lenv) (Let binder x {typeA = typeA} y) | BOOL | Left err = Left (ToGlobalErrorExpr binder err)
convert (MkBEnv ds lenv) (Let binder x {typeA = typeA} y) | BOOL | (Right resX) with (resX)
convert (MkBEnv ds lenv) (Let binder x {typeA = typeA} y) | BOOL | (Right resX) | (globalX ** exprX) with (exprX)
Then we extend our list of global declarations and return the result of converting the body.
convert (MkBEnv ds lenv) (Let binder x {typeA = typeA} y) | BOOL | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') = convert (MkBEnv (DeclAdd binder x' declsX) lenv) y
Now to look at converting locally bound variables, which we do not assume are closed.
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT with (convert (MkBEnv decls lenv) x)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Left l) = Left (ToLocalErrorExpr binder l)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) with (resX)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) with (exprX)
Once we have converted the expression to be bound we can look at converting the body.
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') with (convert (MkBEnv decls ((binder,INT)::lenv)) y)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') | (Left l) = Left (ToLocalErrorBody l)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') | (Right resY) with (resY)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') | (Right resY) | (globalY ** exprY) with (exprY)
convert (MkBEnv decls lenv) (Let binder x {typeA = typeA} y) | INT | (Right resX) | (globalX ** exprX) | (MkBRes declsX x') | (Right resY) | (globalY ** exprY) | (MkBRes declsY y') =
Remember we need to update the variable’s value with the new set of global declarations.
do x'' <- updateThis globalY x'
Now we can return the new Let
binding.
pure (_ ** MkBRes declsY (Let binder x'' y'))
Helper functions
To make like easier we define convert'
, a helper function to set up the default environment for conversion.
convert' : (expr : Source.Expr type)
-> Either Error (genv ** BuildRes genv Nil (interpTy type))
convert' expr = convert defaultEnv expr
and runConvert
to construct a program instance:
runConvert : (expr : Source.Expr type)
-> Either Error Prog
runConvert expr =
case convert' expr of
Left err = Left err
Right (_ ** MkBRes decls e') = Right (MkProg decls e')
Example
We end with an example:
Here is an example expression we defined earlier:
λΠ> Source.example
Let "x"
(And (B False) (B True))
(Let "y"
(Add (I 5) (I 9))
(Let "z"
(I 6)
(Let "a"
(And (B False) (Ref "x" BOOL))
(Add (Ref "z" INT) (Ref "y" INT))))) : Expr INT
Here is the resulting program:
λΠ> runConvert Source.example
Right ([("a", BOOL), ("x", BOOL)] **
MkBRes (DeclAdd "a"
(And (B False) (Global Here))
(DeclAdd "x" (And (B False) (B True)) Empty))
(Let "y"
(Add (I 5) (I 9))
(Let "z" (I 6) (Add (Local Here) (Local (There Here)))))) : Either Error
(genv : List (String,
Ty) **
BuildRes genv
[]
INT)
λΠ>
Coda
In this post we have looked at converting a well-typed expression language to a well-typed and well-scoped program. This was an interesting problem to solved, and aside from incorporating it into my work, I am not sure what other dependent type program enthusiats will make of this. Not sure if this would make a novel paper or not, but it is something work sharing.
Improvements…
I am hoping that the with-blocks can be turned into do-notation and dependent pairs second argument accessed using DPair’s projection functions…
One interesting improvement here would to not only have Source
be well-scoped, as well as well-typed, but to use Thinnings to better describe the type of convert
to ensure that our resulting global and local contexts were originally sourced from the context of source.
I will look at that later.