Solving the halting problem

Soon after teaching Turing machines, educators often explain why the halting problem is undecidable. But then they seem to leave the story unfinished. Have we just learned we can never trust software? How can we rely on a program to control spacecraft or medical equipment if it can unpredictably loop forever?

One might claim extensive testing is the answer. We check a variety of cases work as intended, then hope for the best. But though helpful, testing alone rarely suffices. Dijkstra said: “Program testing can be used to show the presence of bugs, but never to show their absence!” In the worst case, a malicious user can examine our tests, learn what is untested, then quickly find ways to deliberately sabotage our program.

The only true fix is to rein in those unruly Turing machines. Constraining our code, might make it behave. We might try banning GOTO statements for example.

But how do we know if our restrictions are effective? Also, halting is but one concern: even if we’re sure our program halts, it should still do the right thing.

We’ll see that reasoning about our programs is easiest if we replace Turing machines with an equally powerful model of computation: lambda calculus. Only then can we introduce types, and draw on mathematics to prove correctness.

Unfortunately, some restrictions appear to have been invented without paying any heed to theory. Could this be caused by the relative obscurity of lambda calculus?

Simply typed lambda calculus

We can easily modify lambda calculus so that all programs halt while retaining some power. We’ll walk through the solution that was first discovered, the aptly named simply typed lambda calculus.

Simply typed lambda calculus is also known as \(\lambda^{\rightarrow}\).

We start with base types, say Int and Bool, from which we build other types with the (→) type constructor, such as: Int → (Int → Bool). Conventionally, (→) is right associative, so we write this as:

Int -> Int -> Bool

This type describes a function that takes an integer, and returns a function mapping an integer to a boolean.

We can view this as a function that takes two Int parameters and returns a single Bool. For example, the less-than function might have this type. Representing functions that take multiple arguments as a series of functions that each take one argument is known as currying.

We populate the base types with constants, such as 0, 1, …​ for Int, and True and False for Bool.

This seems quotidian so far. Typical high-level languages do this sort of thing. The fun part is seeing how easily it can be tacked on to lambda calculus. There are only two changes:

  1. We add a new kind of leaf node, which holds a constant.

  2. The left child of a lambda abstraction (a variable) must be accompanied by a type.

We only need to add a few lines to our lambda calculus example to add simple types. Let’s get started:

{-# LANGUAGE CPP #-}
#ifdef __HASTE__
import Haste.DOM
import Haste.Events
#else
import System.Console.Haskeline
#endif
import Control.Monad
import Data.Char
import Data.List
import Text.Parsec

First we need a new data structure to represent types. To avoid clashing with predefined Haskell types, we use B for Bool and I for Int.

data Type = I | B | Fun Type Type deriving Eq

By abuse of notation, Var will hold variables and constants: when the string it holds is True, False, or a string representation of an integer, then it counts as a constant. Otherwise it is a variable.

We add a type to the left child of a lambda abstraction.

We also add an If node, for if-then-else expressions. This has nothing to do with simply typed lambda calculus, but we introduce it because, firstly, we want to demonstrate how to extend lambda calculus, and secondly, simply typed lambda calculus turns out to be a bit weak, and can use all the help it can get!

data Term = Var String | App Term Term | Lam (String, Type) Term
  | If Term Term Term

instance Show Type where
  show I = "I"
  show B = "B"
  show (Fun t u) = showL t ++ " -> " ++ show u where
    showL (Fun _ _) = "(" ++ show t ++ ")"
    showL _         = show t

instance Show Term where
  show (Lam (x, t) y)    = "\955" ++ x ++ ":" ++ show t ++ showB y where
    showB (Lam (x, t) y) = " " ++ x ++ ":" ++ show t ++ showB y
    showB expr           = "." ++ show expr
  show (Var s)    = s
  show (App x y)  = showL x ++ showR y where
    showL (Lam _ _) = "(" ++ show x ++ ")"
    showL _         = show x
    showR (Var s)   = ' ':s
    showR _         = "(" ++ show y ++ ")"
  show (If x y z) = "if " ++ show x ++ " then " ++ show y ++ " else " ++ show z

Parsing

Because we must attach type signatures to variable bindings, apart from adding a parser for types, we also change our lambda calculus parser so that (→) is strictly a type constructor, and (.) is strictly for lambda abstractions. Haskell gets away with using (→) for both cases because its grammar is different. (For example, we can declare the type of a Haskell function in one line, and define it in another.)

The strings "if", "then", and "else" are reserved keywords and hence invalid variable names.

data SimplyLambda = None | Let String Term | Run Term

line :: Parsec String () SimplyLambda
line = (<* eof) . (ws >>) $ option None $
    (try $ Let <$> v <*> (str "=" >> term)) <|> (Run <$> term) where
  term = ifthenelse <|> lam <|> app
  ifthenelse = If <$> (str "if" >> term)
    <*> (str "then" >> term) <*> (str "else" >> term)
  lam = flip (foldr Lam) <$> between lam0 lam1 (many1 vt) <*> term where
    lam0 = str "\\" <|> str "\955"
    lam1 = str "."
    vt   = (,) <$> v <*> (str ":" >> typ)
  typ = ((str "B" >> pure B) <|> (str "I" >> pure I)
    <|> between (str "(") (str ")") typ)
      `chainr1` (str "->" >> pure Fun)
  app = foldl1' App <$> many1 ((Var <$> v) <|> between (str "(") (str ")") term)
  v   = try $ do
    s <- many1 alphaNum
    when (s `elem` ["if", "then", "else"]) $ fail $ "unexpected " ++ s
    ws
    pure s
  str = try . (>> ws) . string
  ws = spaces >> optional (try $ string "--" >> many anyChar)

Typing

In a closed lambda term, every leaf node is typed because it’s either a constant, or its type is given at its binding. Type checking works in the obvious manner: for example, we can only apply a function of type Int → Int → Bool to an Int, and we can only apply the resulting function to an Int, and the result will be a Bool.

We predefine a few functions: negate, add, and not, whose types are hard-coded here.

Traditionally, an uppercase gamma denotes a set of variables and their types, which is called a typing context or typing environment, hence our use of gamma for the first argument in our typeOf function:

typeOf gamma t = case t of
  Var "negate" -> Just (Fun I I)
  Var "add" -> Just (Fun I (Fun I I))
  Var "not" -> Just (Fun B B)
  Var "False" -> Just B
  Var "True"  -> Just B
  Var s | [(_, [])] <- (reads s :: [(Integer, String)]) -> Just I
        | otherwise                                     -> lookup s gamma
  App x y -> do
    tx <- rec x
    ty <- rec y
    case tx of
      Fun ty' tz | ty == ty' -> pure tz
      _                      -> Nothing
  Lam (x, t) y -> do
    u <- typeOf ((x, t):gamma) y
    pure $ Fun t u
  If x y z -> if rec x /= Just B then Nothing else do
    ty <- rec y
    tz <- rec z
    if ty == tz then pure ty else Nothing
  where rec = typeOf gamma

Evaluation

Evaluation works the same as it does for untyped lambda calculus. In fact, we could perform type erasure and drop the type of every bound variable before evaluation: types are only needed during type checking. However, keeping types around can be useful for sanity checks. GHC has a typed intermediate language for this reason.

Otherwise, apart from handling predefined functions, our eval function is the same as our corresponding function of untyped lambda calculus.

We assume the input is well-typed and hence closed. This simplifies the function fv because let definitions are no longer permitted to contain free variables.

eval env (If x y z) = eval env $ case eval env x of
  Var "True"  -> y
  Var "False" -> z

eval env (App m a) = let m' = eval env m in case m' of
  Lam (v, _) f -> let
    beta (Var s) | s == v         = a
                 | otherwise      = Var s
    beta (Lam (s, t) m)
                 | s == v         = Lam (s, t) m
                 | s `elem` fvs   = let s1 = newName s fvs in
                   Lam (s1, t) $ beta $ rename s s1 m
                 | otherwise      = Lam (s, t) (beta m)
    beta (App m n)                = App (beta m) (beta n)
    beta (If x y z)               = If (beta x) (beta y) (beta z)
    fvs = fv [] a
    in eval env $ beta f
  Var "not" -> case eval env a of
    Var "True"  -> Var "False"
    Var "False" -> Var "True"
  Var "negate" -> case eval env a of
    Var x  -> Var (show $ negate (read x :: Integer))
  App (Var "add") b -> Var $ show (m + n) where
    Var mstr = eval env a
    Var nstr = eval env b
    m = read mstr :: Integer
    n = read nstr :: Integer
  _ -> App m' a

eval env term@(Var v) | Just x  <- lookup v env = eval env x
eval _   term                                   = term

fv vs (Var s) | s `elem` vs = []
              | otherwise   = [s]
fv vs (Lam (s, _) f)        = fv (s:vs) f
fv vs (App x y)             = fv vs x `union` fv vs y
fv vs (If x y z)            = fv vs x `union` fv vs y `union` fv vs z

newName x ys = head $ filter (`notElem` ys) $ (s ++) . show <$> [1..] where
  s = dropWhileEnd isDigit x

rename x x1 term = case term of
  Var s | s == x    -> Var x1
        | otherwise -> term
  Lam (s, t) b
        | s == x    -> term
        | otherwise -> Lam (s, t) (rec b)
  App a b           -> App (rec a) (rec b)
  where rec = rename x x1

The function eval leaves the term in weak head normal form. To fully normalize, we recurse:

norm env term = case eval env term of
  Var v    -> Var v
  Lam v m  -> Lam v (rec m)
  App m n  -> App (rec m) (rec n)
  If x y z -> If (rec x) (rec y) (rec z)
  where rec = norm env

User interface

We check a term is well-typed before adding it to our list of let definitions or evaluating it. We print the type of terms to show off our new code.

For each let definition, we record the definition as well as its type. Unlike our untyped lambda calculus interpreter, recursion is forbidden, because we require the body of a let definition to be well-typed, and we only prepopulate gamma with the types of previous let definitions.

#ifdef __HASTE__
main = withElems ["input", "output", "evalB", "resetB", "resetP",
    "churchB", "churchP"] $
  \[iEl, oEl, evalB, resetB, resetP, churchB, churchP] -> do
  let
    reset = getProp resetP "value"
      >>= setProp iEl "value" >> setProp oEl "value" ""
    run (out, env) (Left err) =
      (out ++ "parse error: " ++ show err ++ "\n", env)
    run (out, env@(gamma, lets)) (Right m) = case m of
      None       -> (out, env)
      Run term   -> case typeOf gamma term of
        Nothing -> (out ++ "type error: " ++ show term ++ "\n", env)
        Just t  -> (out ++ show (norm lets term) ++ "\n", env)
      Let s term -> case typeOf gamma term of
        Nothing -> (out ++ "type error: " ++ show term ++ "\n", env)
        Just t  -> (out ++ "[" ++ s ++ ":" ++ show t ++ "]\n",
          ((s, t):gamma, (s, term):lets))
  reset
  resetB `onEvent` Click $ const reset
  churchB `onEvent` Click $ const $ getProp churchP "value"
    >>= setProp iEl "value" >> setProp oEl "value" ""
  evalB `onEvent` Click $ const $ do
    es <- map (parse line "") . lines <$> getProp iEl "value"
    setProp oEl "value" $ fst $ foldl' run ("", ([], [])) es
#else
repl env@(gamma, lets) = do
  let redo = repl env
  ms <- getInputLine "> "
  case ms of
    Nothing -> outputStrLn ""
    Just s  -> case parse line "" s of
      Left err  -> do
        outputStrLn $ "parse error: " ++ show err
        redo
      Right None -> redo
      Right (Run term) -> do
        case typeOf gamma term of
          Nothing -> outputStrLn "type error"
          Just t -> do
            outputStrLn $ "[type = " ++ show t ++ "]"
            outputStrLn $ show $ norm lets term
        redo
      Right (Let s term) -> case typeOf gamma term of
        Nothing -> outputStrLn "type error" >> redo
        Just t -> do
          outputStrLn $ "[type = " ++ show t ++ "]"
          repl ((s, t):gamma, (s, term):lets)

main = runInputT defaultSettings $ repl ([], [])
#endif

With induction, we can show type checking is efficient, and if a closed lambda term is well-typed, then it normalizes. (This implies the Y combinator and omega combinator cannot be expressed in this system.) Moreover, any evaluation strategy will lead to the normal form, that is, simply typed lambda calculus is strongly normalizing.

In other words, programs always halt. Try doing this with Turing machines!

Simply typed, or typically simple?

Simply typed lambda calculus has limited power, but this is fine for certain applications. In fact, it turns out some languages wind up embedding simply typed lambda calculus in the type system. But for general-purpose programming, we need more.

There are two ways to fix this:

  1. We boldly add features like unrestricted recursion. We lose our guarantee that all programs halt, but at least most of our language is trustworthy.

  2. We cautiously enrich our type system, at each step checking that well-typed programs normalize. With a sufficiently advanced type system, we gain some forms of recursion.


Ben Lynn blynn@cs.stanford.edu 💡