On this page:
7.1 A crashing language
7.2 When should errors happen?
7.3 Interpreter for Defend
7.4 Correctness
7.5 Runtime type checking
8.14

7 Defend: Handling Errors🔗

    7.1 A crashing language

    7.2 When should errors happen?

    7.3 Interpreter for Defend

    7.4 Correctness

    7.5 Runtime type checking

7.1 A crashing language🔗

We have seen that Con fails for programs that attempt to use boolean values where numbers should be used or numbers where boolean values should be used. Before addressing this problem in Con, let’s look at how errors should be handled generally.

We defined a new language called Con using Racket to build the parser and interpreter. We will refer to Con as the source language. We refer to Racket as the host language or implementation language. The distinction is we are building tools for the language Con, but using Racket to build them.

When the current Con interpreter fails, it fails in our host language - Racket. While our operational semantics gave precise meaning to the language, whenever any program deviated from that meaning we did not handle it in the interpreter, letting the interpreter crash with whatever error Racket produces.

As an example, here is a simple program in our Con interpreter:

Examples:
> (interp '(add1 #t))

+: contract violation

  expected: number?

  given: #t

> (interp '(/ 5 (sub1 1)))

quotient: division by zero

The error shows words like contract, number?, and quotient. Our language does not have contracts or the number? predicate or expose the quotient operator to the user! These are implementation details that the user of a language need not care about. However, things could have been worse. Here at least our interpreter crashes, but there could be scenarios where our interpreter computes a wrong value. This approach of failing in our host language (Racket) works, but we cannot control when our errors happen based on our language semantics. Moreover, if errors happen, language shows errors from Racket even though our user was programming in Con. Imagine if you were writing a Python program and saw an error from C or JavaScript program and saw a C++ exception? A good language hides implementation details and gives precise semantics for when errors happen.

Our only choice is to fail completely. What if our interpreters can avoid failures? What if we can predict failures during execution? This results in systems that are more robust and code that we can better control. For now, we will design a language called Defend that will handle errors dynamically or at run-time. Our interpreter will generate error messages without falling back on our implementation language.

7.2 When should errors happen?🔗

We have been defining the meaning of our languages in terms of a mathematical relation, image. Whenever, image relates an expression to a value our language is well defined, i.e., that program has a meaning in our language. Anytime a program cannot be related to a value in image is precisely when our language can crash!

For example, the program (/ 5 (sub1 1)) crashes with a division by 0 error. We update our language semantics to reflect that division is only defined when the divisor is not 0:

image

For arithmetic operations +, -, *, and /, we should note our evaluation relation image is only defined when the sub-expressions are integers and nothing else.

This is the guiding principle for adding errors to our language: whenever something is not allowed by our formal semantics our language should raise an error! This gives us a meaningful way to sever ties from errors raised by our implementation language (Racket in this case) and give errors that make sense for the user of our language Defend.

7.3 Interpreter for Defend🔗

First, let us see how to raise errors in Racket:

Example:
> (error "This is an error!")

This is an error!

The error function allows us to raise any custom errors. Using this we can now define a wrapper function only-int, that checks if the argument is an integer. If it is an integer, it returns the same integer, but raises an error otherwise:

Example:
> (define (only-int v)
    (if (integer? v)
        v
        (error "Integer expected!")))

Here are few examples of it in action:

Examples:
> (only-int 7)

7

> (only-int #t)

Integer expected!

After that we have to wrap all the cases where the operations are defined on integers with our only-int function. Our interpreter for Defend looks like below:

defend/interp.rkt

  #lang racket
   
  (require rackunit)
   
  (provide interp only-int)
   
  ;; Expr -> Value
  ;; Interpret given expression
  (define (interp e)
    (match e
      [(? integer?)      e]
      [(? boolean?)      e]
      [`(add1 ,e)        (+ (only-int (interp e))  1)]
      [`(sub1 ,e)        (- (only-int (interp e))  1)]
      [`(+ ,e1 ,e2)      (+ (only-int (interp e1)) (only-int (interp e2)))]
      [`(- ,e1 ,e2)      (- (only-int (interp e1)) (only-int (interp e2)))]
      [`(* ,e1 ,e2)      (* (only-int (interp e1)) (only-int (interp e2)))]
      [`(/ ,e1 ,e2)      (interp-div e1 e2)]
      [`(zero? ,e)       (interp-zero? e)]
      [`(and ,e1 ,e2)    (interp-and e1 e2)]
      [`(<= ,e1 ,e2)     (<= (only-int (interp e1)) (only-int (interp e2)))]
      [`(if ,e1 ,e2 ,e3) (interp-if e1 e2 e3)]
      [_                 (error "Parser error!")]))
   
  (define (interp-div e1 e2)
    (match* ((only-int (interp e1)) (only-int (interp e2)))
      [(v1 0)  (error "Division by 0 not allowed!")]
      [(v1 v2) (quotient v1 v2)]))
   
  (define (only-int v)
    (if (integer? v)
        v
        (error "Integer expected!")))
   
  (define (interp-zero? e)
    (match (interp e)
      [0 #t]
      [_ #f]))
   
  (define (interp-and e1 e2)
    (match (interp e1)
      [#f #f]
      [_  (interp e2)]))
   
  (define (interp-if e1 e2 e3)
    (match (interp e1)
      [#f (interp e3)]
      [_  (interp e2)]))
   

Notice, a new function interp-div? It throws a division by 0 error if the divisor is 0, or computes the division otherwise. The way we have defined error handling in our language not only protects us against type errors in the language, but also any other errors that may happen. A number divided by 0 is an undefined operation and now we can check and raise such an error gracefully without crashing via the Racket runtime.

Running our interpreter this time:

Examples:
> (interp '(add1 #t))

Integer expected!

> (interp '(add1 (+ 5 #f)))

Integer expected!

> (interp '(/ 5 (sub1 1)))

Division by 0 not allowed!

7.4 Correctness🔗

We can turn the above examples to automatic tests:

Examples:
> (check-eqv? (interp '(+ 42 (sub1 34))) 75)
> (check-eqv? (interp '(zero? (- 5 (sub1 6)))) #t)
> (check-eqv? (interp '(if (zero? 0) (add1 5) (sub1 5))) 6)
> (check-exn exn:fail? (λ () (interp '(add1 (+ 3 #f)))))
> (check-exn exn:fail? (λ () (interp '(add1 (and #t #t)))))
> (check-exn exn:fail? (λ () (interp '(/ 5 (sub1 1)))))

As we can see our interpreter behaves correctly on all previous examples, but this time also handles all error cases with errors that we defined. The check-exn function checks if an exception was raised by the lambda using the exn:fail? predicate. Here we are checking if the last 3 programs result in an error.

7.5 Runtime type checking🔗

Our language was an untyped one until now, but now because of the error handling we just added we have a runtime type-checked language, or colloquially known as a dynamically-typed language. Instead of giving us undefined behaviors on type errors (or crashes), our language now protects against type errors and fails reliably.

This is the same behavior as industrial grade languages such as Python:

Python REPL

> 3 + "foo"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Runtime type checking catches errors that happen during the evaluation of the program. Thus there is no way to know ahead of time which expression will cause a type error at runtime without running the program. This means, we can still write code ridden with type errors in a runtime type-checked language but still not be aware of it ahead of running our program. Even running our program does not flag all type errors as not all paths are taken during the execution of a program. Consider the example program in our Defend language:

(if (zero? (sub1 1)) (+ 2 3) (add1 #f))

This program has a type error in the else-branch of the if, but it will never be caught while running the program. The if will always evaluate to #t and go to the then-branch.

Static type-checking is an alternative way to address this problem. It can catch type errors in the program without running it, thus it can flag errors in parts of the program that you might not have evaluated when testing it. We will look at static type-checking later in the semester to compare the strengths and weaknesses of the approach in details.