Ghost Hunting the Perfect API

( with departed proofs )

 

Unsafe API
Patterns

>:t head
head :: [a] -> a
> head [1,2,3]
1
> head []
*** Exception: Prelude.head: empty list
> head []
-1

Safe API
Patterns

head :: [a] -> a
headMaybe :: [a] -> Maybe a
headMaybe (x:xs) = Just x
headMaybe []     = Nothing
data NonEmpty a = a :| [a]

safeHead :: NonEmpty a -> a
safeHead (x :| xs) = x

Responsibility

Our choice:

  • Handle it
  • Pass it on

 

People will pass responsibility in the same direction the code they call does.

Responsibility

When we restrict what we can do, it’s easier to understand what we can do.

 

 

identity :: a -> a

take :: Int -> [a] -> [a]
length :: [a] -> Int

Case study

-- merge 2 already sorted lists
unsafeMergeBy
  :: (a -> a -> Ordering) -- the comparator
  -> [a] -- first list
  -> [a] -- second list
  -> [a] -- merged list

> x = sortBy compare [4,3,1]
> y = sortBy compare [16,5,6]
> x
[1,3,4]
> y
[5,6,16]


> unsafeMergeBy compare x y
[1,3,4,5,6,16]

> unsafeMergeBy (comparing Down) x y
[5,6,16,1,3,4]

Case study

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nec metus justo. Aliquam erat volutpat.

-- merge 2 already sorted lists
mergeByMaybe
  :: (a -> a -> Ordering) -- the comparator
  -> [a]
  -> [a]
  -- a merged list
  -- or none if the list passed 
  -- was not sorted
  -> Maybe [a] 

> x = sortBy compare [4,3,1]
> y = sortBy compare [16,5,6]
> fromJust $ mergeByMaybe compare x y
 [1,3,4,5,6,16]

In the Wild

  • Hackage finds 2000 cases of lookup, followed by fromJust
  • Lookup tries to be good, but user has reason to believe key is in map.

How can we reflect constraints on function input values in the function type?

newtype Named name a = Named a
type a ~~ name = Named name a

name :: a -> (forall name. a ~~ name -> t) -> t
name x k = k (coerce x)

class The d a | d -> a where
  the :: d -> a
  default the :: Coercible d a => d -> a
  the = coerce
newtype Named name a = Named a
type a ~~ name = Named name a

phantom type-level names for values

class The d a | d -> a where
  the :: d -> a
  default the :: Coercible d a => d -> a
  the = coerce

A way to introduce names

 
name :: a -> (forall name. a ~~ name -> t) -> t
name x k = k (coerce x)

unwrap named values

 
newtype SortedBy name a = SortedBy a
instance The (SortedBy name a) a
import qualified Lists as L

sortBy :: ((a -> a -> Ordering) ~~ comp)
       -> [a]
       -> SortedBy comp [a]
sortBy comp xs = coerce (L.sortBy (the comp) xs)

mergeBy :: ((a -> a -> Ordering) ~~ comp)
        -> SortedBy comp [a]
        -> SortedBy comp [a]
        -> SortedBy comp [a]
mergeBy comp xs ys =
  coerce (L.mergeBy (the comp) (the xs) (the ys))

How can we reflect constraints on function input values in the function type?

> name compare $ \gt -> do
    let xs' = sortBy gt xs
        ys' = sortBy gt ys
    print (the (mergeBy gt xs' ys'))
> x = sortBy compare [4,3,1]
> y = sortBy compare [16,5,6]

> unsafeMergeBy (comparing Down) x y
[5,6,16,1,3,4]
> x = sortBy compare [4,3,1]
> y = sortBy compare [16,5,6]

> fromJust $ mergeByMaybe (comparing Down) x y
*** Exception: Maybe.fromJust: Nothing

Benefits

  • mergeBy cannot be called with a different comparator then the sorted lists were created with
  • allow the library user to decide when and how to validate api preconditions are met
  • achieved many of the benefits of dependent and refinement types, whilst only requiring some minor and well understood extensions to haskell2010
> name compare $ \gt -> do
    let xs' = sortBy gt xs
        ys' = sortBy gt ys
    print (the (mergeBy gt xs' ys'))

An additional example

minimum_O1 :: SortedBy comp [a] -> Maybe a
minimum_O1 xs = case the xs of
  []    -> Nothing
  (x:_) -> Just x

Ghostly Proofs

data Proof p = QED
axiom :: String -> Proof p
axiom reason = QED


newtype Rev xs = Rev ()
rev_rev 
  :: Proof (Rev (Rev xs) == xs)
rev_rev = 
  axiom "reverse reverse is identity"

reverse 
  :: ([a] ~~ xs) 
  -> ([a] ~~ Rev xs)
reverse xs = 
  coerce (P.reverse (the xs))
data p == q

Takeaways

  • Use existential names to discuss values at the type level

  • No runtime overhead

  • Give user combinators and proofs to construct their own safety arguments

  • Is it useful?

    • ​maybe, it's one approach

Links

Takeaways

  • Use existential names to discuss values at the type level

  • No runtime overhead

  • Give user combinators and proofs to construct their own safety arguments

  • Is it useful?

    • ​maybe, it's one approach

Simplified Example

 
newtype Rev xs = Rev ()

reverse 
  :: ([a] ~~ xs) 
  -> ([a] ~~ Rev xs)
reverse xs = 
  coerce (P.reverse (the xs))

rev_rev 
  :: Proof (Rev (Rev xs) == xs)
rev_rev = 
  axiom "reverse reverse is identity"

Ghostly Proofs

data Proof p = QED
axiom :: Proof p
axiom = QED

data p || q
data p && q
data p == q

andElimL 
  :: Proof (p && q) 
  -> Proof p
or_introL 
  :: p 
  -> Proof (p || q)

-- sitting in phantom type variables- no constructors needed

Usage

 
data IsCons xs
data IsNil  xs

pattern IsCons 
  :: Proof (IsCons xs) 
  -> ([a] ~~ xs)
pattern IsNil 
  :: Proof (IsNil xs) 
  -> ([a] ~~ xs)

head 
  :: ([a] ~~ xs ::: IsCons xs) 
  -> a
head xs = 
  Prelude.head (the xs)
name [1,3] $ \xs -> case xs of
  IsCons proof ->
    print (head (xs ...proof))
  IsNil proof  -> print “nada”

Other lemmas

 
rev_cons 
  :: Proof (IsCons xs)
  -> Proof (IsCons (Reverse xs))
rev_cons _ = axiom
name xs $ \xs -> case xs of
    IsCons proof ->
      print (head (xs ...proof))
      print (head (reverse xs ...rev_cons proof))
newtype Reverse xs 
  = Reverse Defn

reverse
  :: ([a] ~~ xs) 
  -> ([a] ~~ Reverse xs)
reverse xs = 
  defn 
    (Prelude.reverse (the xs))

Questions

  • How do we decide which props to get newtype wrappers?
  • How do we handle props involving multiple values, eg relationships between values?

Safer APIs with Ghosts of Departed Proofs

By ..

Safer APIs with Ghosts of Departed Proofs

  • 4,724