Refined Datatypes

So far, we have seen how to refine the types of functions, to specify, for example, pre-conditions on the inputs, or post-conditions on the outputs. Very often, we wish to define datatypes that satisfy certain invariants. In these cases, it is handy to be able to directly refine the data definition, making it impossible to create illegal inhabitants.

Sparse Vectors Revisited

As our first example of a refined datatype, let’s revisit the sparse vector representation that we saw earlier. The SparseN type alias we used got the job done, but is not pleasant to work with because we have no way of determining the dimension of the sparse vector. Instead, let’s create a new datatype to represent such vectors:

data Sparse a = SP { spDim :: Int , spElems :: [(Int, a)] }

Thus, a sparse vector is a pair of a dimension and a list of index-value tuples. Implicitly, all indices other than those in the list have the value 0 or the equivalent value type a.

Sparse vectors satisfy two crucial properties. First, the dimension stored in spDim is non-negative. Second, every index in spElems must be valid, i.e. between 0 and the dimension. Unfortunately, Haskell’s type system does not make it easy to ensure that illegal vectors are not representable.1


Data Invariants LiquidHaskell lets us enforce these invariants with a refined data definition:

{-@ data Sparse a = SP { spDim :: Nat , spElems :: [(Btwn 0 spDim, a)]} @-}

Where, as before, we use the aliases:

{-@ type Nat = {v:Int | 0 <= v} @-} {-@ type Btwn Lo Hi = {v:Int | Lo <= v && v < Hi} @-}


Refined Data Constructors The refined data definition is internally converted into refined types for the data constructor SP:

-- Generated Internal representation
data Sparse a where
  SP :: spDim:Nat
     -> spElems:[(Btwn 0 spDim, a)]
     -> Sparse a

In other words, by using refined input types for SP we have automatically converted it into a smart constructor that ensures that every instance of a Sparse is legal. Consequently, LiquidHaskell verifies:

okSP :: Sparse String okSP = SP 5 [ (0, "cat") , (3, "dog") ]

but rejects, due to the invalid index:

badSP :: Sparse String badSP = SP 5 [ (0, "cat") , (6, "dog") ]


Field Measures It is convenient to write an alias for sparse vectors of a given size N. We can use the field name spDim as a measure, like vlen. That is, we can use spDim inside refinements2

{-@ type SparseN a N = {v:Sparse a | spDim v == N} @-}

Let’s write a function to compute a sparse product

{-@ dotProd :: x:Vector Int -> SparseN Int (vlen x) -> Int @-} dotProd x (SP _ y) = go 0 y where go sum ((i, v) : y') = go (sum + (x ! i) * v) y' go sum [] = sum

LiquidHaskell verifies the above by using the specification to conclude that for each tuple (i, v) in the list y, the value of i is within the bounds of the vector x, thereby proving x ! i safe.


Folded Product We can port the fold-based product to our new representation:

{-@ dotProd' :: x:Vector Int -> SparseN Int (vlen x) -> Int @-} dotProd' x (SP _ y) = foldl' body 0 y where body sum (i, v) = sum + (x ! i) * v

As before, LiquidHaskell checks the above by automatically instantiating refinements for the type parameters of foldl', saving us a fair bit of typing and enabling the use of the elegant polymorphic, higher-order combinators we know and love.


Exercise: (Sanitization): Invariants are all well and good for data computed inside our programs. The only way to ensure the legality of data coming from outside, i.e. from the “real world”, is to write a sanitizer that will check the appropriate invariants before constructing a Sparse vector. Write the specification and implementation of a sanitizer fromList, so that the following typechecks:



Hint: You need to check that all the indices in elts are less than dim; the easiest way is to compute a new Maybe [(Int, a)] which is Just the original pairs if they are valid, and Nothing otherwise.

fromList :: Int -> [(Int, a)] -> Maybe (Sparse a) fromList dim elts = undefined {-@ test1 :: SparseN String 3 @-} test1 = fromJust $ fromList 3 [(0, "cat"), (2, "mouse")]


Exercise: (Addition): Write the specification and implementation of a function plus that performs the addition of two Sparse vectors of the same dimension, yielding an output of that dimension. When you are done, the following code should typecheck:



plus :: (Num a) => Sparse a -> Sparse a -> Sparse a plus x y = undefined {-@ test2 :: SparseN Int 3 @-} test2 = plus vec1 vec2 where vec1 = SP 3 [(0, 12), (2, 9)] vec2 = SP 3 [(0, 8), (1, 100)]

Ordered Lists

As a second example of refined data types, let’s consider a different problem: representing ordered sequences. Here’s a type for sequences that mimics the classical list:

data IncList a = Emp | (:<) { hd :: a, tl :: IncList a } infixr 9 :<

The Haskell type above does not state that the elements are in order of course, but we can specify that requirement by refining every element in tl to be greater than hd:

{-@ data IncList a = Emp | (:<) { hd :: a, tl :: IncList {v:a | hd <= v}} @-}


Refined Data Constructors Once again, the refined data definition is internally converted into a “smart” refined data constructor

-- Generated Internal representation
data IncList a where
  Emp  :: IncList a
  (:<) :: hd:a -> tl:IncList {v:a | hd <= v} -> IncList a

which ensures that we can only create legal ordered lists.

okList = 1 :< 2 :< 3 :< Emp -- accepted by LH badList = 2 :< 1 :< 3 :< Emp -- rejected by LH

It’s all very well to specify ordered lists. Next, let’s see how it’s equally easy to establish these invariants by implementing several textbook sorting routines.

First, let’s implement insertion sort, which converts an ordinary list [a] into an ordered list IncList a.

insertSort :: (Ord a) => [a] -> IncList a insertSort [] = Emp insertSort (x:xs) = insert x (insertSort xs)

The hard work is done by insert which places an element into the correct position of a sorted list. LiquidHaskell infers that if you give insert an element and a sorted list, it returns a sorted list.

insert :: (Ord a) => a -> IncList a -> IncList a insert y Emp = y :< Emp insert y (x :< xs) | y <= x = y :< x :< xs | otherwise = x :< insert y xs


Exercise: (Insertion Sort): Complete the implementation of the function below to use foldr to eliminate the explicit recursion in insertSort.



insertSort' :: (Ord a) => [a] -> IncList a insertSort' xs = foldr f b xs where f = undefined -- Fill this in b = undefined -- Fill this in


Merge Sort Similarly, it is easy to write merge sort, by implementing the three steps. First, we write a function that splits the input into two equal sized halves:

split :: [a] -> ([a], [a]) split (x:y:zs) = (x:xs, y:ys) where (xs, ys) = split zs split xs = (xs, [])

Second, we need a function that combines two ordered lists

merge :: (Ord a) => IncList a -> IncList a -> IncList a merge xs Emp = xs merge Emp ys = ys merge (x :< xs) (y :< ys) | x <= y = x :< merge xs (y :< ys) | otherwise = y :< merge (x :< xs) ys merge _ _ = Emp

Finally, we compose the above steps to divide (i.e. split) and conquer (sort and merge) the input list:

mergeSort :: (Ord a) => [a] -> IncList a mergeSort [] = Emp mergeSort [x] = x :< Emp mergeSort xs = merge (mergeSort ys) (mergeSort zs) where (ys, zs) = split xs


Exercise: (QuickSort): Why is the following implementation of quickSort rejected by LiquidHaskell? Modify it so it is accepted.



Hint: Think about how append should behave so that the quickSort has the desired property. That is, suppose that ys and zs are already in increasing order. Does that mean that append x ys zs are also in increasing order? No! What other requirement do you need? bottle that intuition into a suitable specification for append and then ensure that the code satisfies that specification.

quickSort :: (Ord a) => [a] -> IncList a quickSort [] = Emp quickSort (x:xs) = append x lessers greaters where lessers = quickSort [y | y <- xs, y < x ] greaters = quickSort [z | z <- xs, z >= x] {-@ append :: x:a -> IncList a -> IncList a -> IncList a @-} append z Emp ys = z :< ys append z (x :< xs) ys = x :< append z xs ys

Ordered Trees

As a last example of refined data types, let us consider binary search ordered trees, defined thus:

data BST a = Leaf | Node { root :: a , left :: BST a , right :: BST a }

enjoy the property that each root lies (strictly) between the elements belonging in the left and right subtrees hanging off the root. The ordering invariant makes it easy to check whether a certain value occurs in the tree. If the tree is empty i.e. a Leaf, then the value does not occur in the tree. If the given value is at the root then the value does occur in the tree. If it is less than (respectively greater than) the root, we recursively check whether the value occurs in the left (respectively right) subtree.

Figure 1.1: A Binary Search Tree with values between 1 and 9. Each root's value lies between the values appearing in its left and right subtrees.

Figure 1.1 shows a binary search tree whose nodes are labeled with a subset of values from 1 to 9. We might represent such a tree with the Haskell value:

okBST :: BST Int okBST = Node 6 (Node 2 (Node 1 Leaf Leaf) (Node 4 Leaf Leaf)) (Node 9 (Node 7 Leaf Leaf) Leaf)


Refined Data Type The Haskell type says nothing about the ordering invariant, and hence, cannot prevent us from creating illegal BST values that violate the invariant. We can remedy this with a refined data definition that captures the invariant. The aliases BSTL and BSTR denote BSTs with values less than and greater than some X, respectively.3

{-@ data BST a = Leaf | Node { root :: a , left :: BSTL a root , right :: BSTR a root } @-} {-@ type BSTL a X = BST {v:a | v < X} @-} {-@ type BSTR a X = BST {v:a | X < v} @-}


Refined Data Constructors As before, the above data definition creates a refined smart constructor for BST

data BST a where
  Leaf :: BST a
  Node :: r:a -> BST {v:a| v < r}
       -> BST {v:a | r < v}
       -> BST a

which prevents us from creating illegal trees

badBST = Node 66 (Node 4 (Node 1 Leaf Leaf) (Node 69 Leaf Leaf)) -- Out of order, rejected (Node 99 (Node 77 Leaf Leaf) Leaf)


Exercise: (Duplicates): Can a BST Int contain duplicates?



Lets write some functions to create and manipulate these trees. First, a function to check whether a value is in a BST:

mem :: (Ord a) => a -> BST a -> Bool mem _ Leaf = False mem k (Node k' l r) | k == k' = True | k < k' = mem k l | otherwise = mem k r


Singleton Next, another easy warm-up: a function to create a BST with a single given element:

one :: a -> BST a one x = Node x Leaf Leaf


Insertion Lets write a function that adds an element to a BST.4

add :: (Ord a) => a -> BST a -> BST a add k' Leaf = one k' add k' t@(Node k l r) | k' < k = Node k (add k' l) r | k < k' = Node k l (add k' r) | otherwise = t


Minimum For our next trick, let’s write a function to delete the minimum element from a BST. This function will return a pair of outputs – the smallest element and the remainder of the tree. We can say that the output element is indeed the smallest, by saying that the remainder’s elements exceed the element. To this end, let’s define a helper type: 5

data MinPair a = MP { mElt :: a, rest :: BST a }

We can specify that mElt is indeed smaller than all the elements in rest via the data type refinement:

{-@ data MinPair a = MP { mElt :: a, rest :: BSTR a mElt} @-}

Finally, we can write the code to compute MinPair

delMin :: (Ord a) => BST a -> MinPair a delMin (Node k Leaf r) = MP k r delMin (Node k l r) = MP k' (Node k l' r) where MP k' l' = delMin l delMin Leaf = die "Don't say I didn't warn ya!"


Exercise: (Delete): Use delMin to complete the implementation of del which deletes a given element from a BST, if it is present.



del :: (Ord a) => a -> BST a -> BST a del k' t@(Node k l r) = undefined del _ Leaf = Leaf


Exercise: (Safely Deleting Minimum): The function delMin is only sensible for non-empty trees. Read ahead to learn how to specify and verify that it is only called with such trees, and then apply that technique here to verify the call to die in delMin.




Exercise: (BST Sort): Complete the implementation of toIncList to obtain a BST based sorting routine bstSort.



bstSort :: (Ord a) => [a] -> IncList a bstSort = toIncList . toBST toBST :: (Ord a) => [a] -> BST a toBST = foldr add Leaf toIncList :: BST a -> IncList a toIncList (Node x l r) = undefined toIncList Leaf = undefined

Hint: This exercise will be a lot easier after you finish the quickSort exercise. Note that the signature for toIncList does not use Ord and so you cannot (and need not) use a sorting procedure to implement it.

Recap

In this chapter we saw how LiquidHaskell lets you refine data type definitions to capture sophisticated invariants. These definitions are internally represented by refining the types of the data constructors, automatically making them “smart” in that they preclude the creation of illegal values that violate the invariants. We will see much more of this handy technique in future chapters.

One recurring theme in this chapter was that we had to create new versions of standard datatypes, just in order to specify certain invariants. For example, we had to write a special list type, with its own copies of nil and cons. Similarly, to implement delMin we had to create our own pair type.


This duplication of types is quite tedious. There should be a way to just slap the desired invariants on to existing types, thereby facilitating their reuse. In a few chapters, we will see how to achieve this reuse by abstracting refinements from the definitions of datatypes or functions in the same way we abstract the element type a from containers like [a] or BST a.


  1. The standard approach is to use abstract types and smart constructors but even then there is only the informal guarantee that the smart constructor establishes the right invariants.↩︎

  2. Note that inside a refined data definition, a field name like spDim refers to the value of the field, but outside it refers to the field selector measure or function.↩︎

  3. We could also just inline the definitions of BSTL and BSTR into that of BST but they will be handy later.↩︎

  4. While writing this exercise I inadvertently swapped the k and k' which caused LiquidHaskell to protest.↩︎

  5. This helper type approach is rather verbose. We should be able to just use plain old pairs and specify the above requirement as a dependency between the pairs’ elements. Later, we will see how to do so using abstract refinements.↩︎