Lecture 25:
Language Semantics and Interpreter Structure. Type Checking. Special Forms

You can understand the discussion below much better if you try to solve the suggested problems below. We have collected these - and some non-interpreter related problems - into our list of suggested problems. We have provided solutions to selected problems. An updated version of the interpreter is available - it includes some of the code discussed in our solutions.

Following our discussions in the previous lectures and sections we now have an understanding of how our interpreter works. We now address the relation between interpreter structure and mini-ML, as well as special forms and type checking.

Language Semantics and Interpreter Structure

Let us examine a few simple changes that impact the semantics of mini-ML programs.

First, let us examine what it would entail to switch mini-ML from static scoping to dynamic scoping. We are familiar with static scoping, and most of the readers of these notes probably prefer it, given a choice. Still, it is interesting to see how easily we can switch between the two scoping rules.

Consider a fragment of function eval in file evaluator.sml, reproduced below:

  ...
  | APP_E(e1, e2) =>
    (* call the special form, if it is one *)
    (case expToSpecialFormName e1 of
         SOME s => evaluateSpecialForm(s, e2, env)
       | NONE => 
         (case eval(e1, env) of
              EXCEPTION_V(x, s) => EXCEPTION_V(x, s)
            | FN_V(x, body, closure_env) =>
              (case eval(e2, env) of
                   EXCEPTION_V(x, s) => EXCEPTION_V(x, s)
                 | v2 => eval(body, env_add(x, v2, !closure_env)))
            | _ => raise brokenTypes))
   ...
This is the code where the semantics of function calls is implemented. We discussed this piece of code in the previous set of lecture notes. Let us now focus on the highlighted line. You can see here that the environment retrieved from the function closure is used - after being extended by a new binding for the function's formal argument - to evaluate the function body. If we change this line to use the current environment, then our interpreter implements dynamic scoping:
eval(body, env_add(x, v2, env)))
The mini-ML examples below, edited slightly for better legibility, show the difference between static and dynamic scoping:
(* static scoping *)
MiniML> let
          val x = 0
        in
          let
            fun f(n: int): int = if n = 0 then x
                                          else n * (f (n - 1))
          in
            let
              val x = 1
            in
              f(10)
            end
          end
         end
0

(* dynamic scoping *)
MiniML> let
          val x = 0
        in
          let
            fun f(n: int): int = if n = 0 then x
                                          else n * (f (n - 1))
          in
            let
              val x = 1
            in
              f(10)
            end
          end
         end
3628800

Let us now turn our attention to a different issue. As you must have realized, there is no way for the user to transmit information between two command lines that have been entered into mini-ML. Consider the following example:

MiniML> let val x = 1 in x end
1
MiniML> x
Type error: Variable x does not have a defined type

As you can see, the definition of x does not survive beyond the first line. The immediate reason for this is the fact that the scoping rule of mini-ML (and SML, as a matter of fact), make x go out of scope once the evaluation of the wrapping let statement terminates. We could rework our mini-ML interpreter to accept val statements without an explicit enclosing let, like SML does. That would not solve our problem, however. The ultimate reason for no information being passed on from one piece of input code to the subsequent ones is that evaluation always starts with an empty environment. This is clearly seen in line

    Evaluator.eval(ast, AbstractSyntaxTree.empty_env)
in function evaluateString in file readevalprint.sml.

Rather than trying to pass information between various pieces of code we will solve a similar problem, which involves fewer changes to the interpreter. Specifically, we will examine how we can introduce predefined constants and functions into SML.

Introducing predefined constants and non-recursive functions is trivial; we achieve this by changing the definition of the initial environment from empty, to a non-empty list. We keep the name empty_env to minimize the number of changes we need to make, but we note that in the new situation empty_env is a misnomer; it should rather be called initial_env or - perhaps - toplevel_env.

val empty_env = [ ("year", INT_V 2004), 
                  ("course", STRING_V "CS312"),
                  ("identity", FN_V ("n", VAR_E "n", ref []))]

In the example above we have defined an integer constant named year, a string constant named course, and the identity function. Here is what happens when we try to use these predefined identifiers:

MiniML> year
Type error: Variable year does not have a defined type

As we will understand when we discuss type checking, we need to define a non-empty type environment that contains definitions for these constants as well. Thus we need to change empty_typenv defined in file typechecker.sml from [] to

val empty_typenv = [("year", INT_T),
                    ("course", STRING_T),
                    ("identity", FN_T(INT_T, INT_T))];

Once we perform this change we can use the predefined constants as shown below:

MiniML> year
2004
MiniML> course
"CS312"
MiniML> identity 312
312
MiniML> identity year
2004

Suggested problem: Define values for empty_env and empty_typenv so that a recursive function is predefined as well (say, factorial). Can you predefine two mutually recursive functions? If yes, can you extend your idea to predefine groups of more than two mutually recursive functions? Note: remember that the user can not define such groups of mutually recursive functions from the command prompt.

Type Checking

Type checking is similar to evaluation, except that we do not "compute" values, but types. Mini-ML makes type computations easy because all function arguments must be fully annotated, and the return types of functions declared with fun must also be provided (remember that these are the functions that could be recursive). Computing for types is easier in some sense than computing for values, because we do not need to actually follow recursive function calls, it is enough for us to know what is the type of their returned value.

Keep in mind that a program that passes type checking is not necessarily correct (since it might contain logical errors; e.g. it does not matter what integers you are adding up if you are adding up the wrong integers), nor does it mean that a computation will finish and actually produce a value of a certain type.

In mini-ML types are generally fully defined and unambiguous; there is no polymorphism as in SML. Consider the following trivial recursive function (slightly edited for legibility):

MiniML> let
          fun f(n: int): int * int = 
            if n < 0 then raise Fail "negative arg"
                     else if n = 0 then (3, 4)
                                   else f(n - 1)
        in 
          f 0
        end
(3, 4)
This example shows a computation that returns a value of type int * int for all non-negative values of the arguments n. If the function argument is negative, then an exception is raised, and no value of type int * int is returned. One could say that a value of type "exception" (whatever that means) is returned in this latter case. Should this expression pass type checking or not? One could argue that it should, since we can get values of type int * int out of it, unless we get an exception (or, in other cases, an infinite loop). So as long as the computationi terminates normally, we get an expression of the "right" type. SML takes the view that in a situation like this an expression should pass type checking (try it by entering the expression above at the SML prompt!).

The reasoning above raises an interesting question, which we examine by discussing the type checking rules for if statements. Under "normal" circumstantes an if statement should typecheck if and only if its condition evaluates to a boolean and its two branches evaluate to the same type. Thus statement if true then 1 else 3 should typecheck, while statement if false then 3 else "false" should not pass type checking. But what about a statement like if x = 7 then 1 else raise NotSeven "arg was not seven"? The reasoning in the previous paragraph suggests that the last statement should pass type checking, but is type "integer" the same as type, say, "exception"? Obviously, these two types are not equal.

We must extend our notion of equality of types by weakening it a bit. We do this by introducing, informally for now, the notion of "compatible" types: two types are compatibly if they can be made equal (they are not "irreconcilably different"). We will say that the exception type is compatible with the integer type (or any other type, for that matter) in mini-ML.

This issue is not unique to mini-ML; we have seen something similar when we talked informally about type inference. Consider the function below:

fun f x = x + 1
What is the type of f? Well, we can not say directly (we can, since we are now experienced SML users, but let us forget about our human insight and think like a dumb computer). We can, however, discover the type going step by step. First, we can assign a type to x; absent any pertinent information we will assign to it a type variable T1. We then encounter expression x + 1. We know that + is an operator that works on either two integers, or two reals. But constant 1 is an integer, so type T1 must be in fact int. Type T1 is now fully defined, and there is no uncertainty left. What about the type of f itself? Since we know that a priori that the type of f is a function type, let us associate with it type T2 -> T3. Type T2 must be the type of its argument, i.e. T1 = int, which restricts the type of f to be int -> T3. The expression on the right side of the equal sing has the type of the result of the addition, again, int. Thus T3 = int, and the type of f is int -> int.

The process described informally above shows how we can assign type variables to unknown types and use the rules of the language to partially or totally disambiguate these type variables. Disamibiguation occurs as the result of a number of steps in which we input two types (possibly containing type variables) and we make them "equal" (or "compatible") by generating the most general new type that embeds the constraints of both of the initial types. We say that the two types have been "unified."

As an example, consider the following two types: T1 -> int and (T2 -> bool) -> T3. What is the most general type that could make these equal? It is clear that T3 must be identified with int, while T1 must be identified with T2 -> bool. Thus the most general type that makes these two type equal is (T2 -> bool) -> int. We emphasize that this is the most general type; we could achieve equality using several more specific types, for example, replacing T2 by, say, int, to get (int -> bool) -> int.

Returning to mini-ML, type unification becomes a simple issue; we only need to worry about it when we have "true" types (e.g. int, bool, string, int->int) that must be unified with the exception type. We will always unify type T with an exception type E so that the result of the unification is type T. Thus unification is trivial in mini-ML, and no true type substitutions must be made as in SML.

We show how type substitution works in mini-ML in the example below (also, see the last mini-ML example from above):

MiniML> let
          fun f(x: int * int): int = (#1 x) + (#2 x)
        in
          (5, raise Fail "f will not be called")
        end
exception Fail with message  "f will not be called"
Here the computation does not finish "normally" because an exception is raised, but the code passes type checking.

We reproduce below the code that implements type unification in the mini-ML interpreter:

fun type_unification(t1:typ, t2:typ, err_str:string):typ =
  case (t1, t2) of
    (EXCEPTION_T, _) => t2
  | (_, EXCEPTION_T) => t1
  | (FN_T(t1, t2), FN_T(t1', t2')) => 
      FN_T(type_unification(t1, t1', err_str), type_unification(t2, t2', err_str))
  | (TUPLE_T lt1, TUPLE_T lt2) =>
      if not(length lt1 = length lt2)
      then raise StaticTypeError err_str
      else let
        val tr = map (fn (t1, t2) => type_unification(t1, t2, err_str))
                     (ListPair.zip(lt1, lt2))
        val exc = List.exists (fn t => t = EXCEPTION_T) tr
      in
        if exc
        then EXCEPTION_T
        else TUPLE_T tr
      end
  | _ => if t1 = t2
         then t1
         else raise StaticTypeError err_str
The first two cases address the issue of unification with exception types; as you can see, exceptions are ignored and the other - possibly - non-exception type is propagated through unification. The last case deals with the elementary types (int, bool, string).

The interesting cases are those of function types, and those of the tuple types. For function types, we create the unified type by unifying the type of the argument and the type of the returned function. For tuple types, the situation is more complicated. We check first the arity of the type; if the arity does not match, then the types can not match either. If the arity is the same, they we unify the corresponding types. Now, if any of the component types of the tuple type are an exception type, then the type of the tuple is set to the exception type.

Suggested problem: Is it correct - or necessary - to treat the tuple types so that they can degenerate to a (non-tuple) exception type? Does it make any difference? Explain your answer. While developing your answer, consider alternative implementations like the one below:

  | (TUPLE_T lt1, TUPLE_T lt2) =>
      if not(length lt1 = length lt2)
      then raise StaticTypeError err_str
      else let
        TUPLE_T (map (fn (t1, t2) => type_unification(t1, t2, err_str)
                     (ListPair.zip(lt1, lt2)))
      end
A related problem arises when we examine the evaluation of the type that results from a function evaluation (APP_E).
  ...
  | APP_E(e1, e2) =>
    (case (get_type(e1, tenv), get_type(e2, tenv)) of
      (EXCEPTION_T, _) => EXCEPTION_T
    | (* alternative: (FN_T(ta, tb), EXCEPTION_T) => tb *)
      (_, EXCEPTION_T) => EXCEPTION_T
    | (FN_T(ta, tb), t2) =>
        (type_unification(ta, t2, "Arg to application has wrong type");
         tb)
    | (_, _) => raise StaticTypeError "Function expected.")
  ...
The two lines in italic above both deal with the evaluation of expression e1 e2, and the type of e2 is EXCEPTION_T (this means that e2 evaluates unconditionally to an exception). As you see, we have provided two alternative implementations: one possibility is to recognize that if e2 evaluates unconditionally to an exception, then the entire expression e1 e2 evaluates to an exception; the "alternative" implementation assumes that the type of the function is that which was declared, despite the certain exception that will be raised. Does it matter which implementation we choose? Consider the following two examples (we edited the code for legibility):
(* uses implementation as shown *)

MiniML> let
          fun f(x: string): int = if x = "five" then 5 else 0
        in
          let
            fun g(x:int): int = 3
          in 
            f (g (raise Fail "a"))
          end
        end
exception Fail with message  "a"

MiniML> let
          fun f(x: string): int = if x = "five" then 5 else 0
        in
          let
            fun g(x:int): int = 3
          in 
            f (g (17))
          end
        end
Type error: Arg to application has wrong type

(* uses "alternative" implementation *)

MiniML> let
          fun f(x: string): int = if x = "five" then 5 else 0
        in
          let
            fun g(x:int): int = 3
          in 
            f (g (raise Fail "a"))
          end
        end
Type error: Arg to application has wrong type

MiniML> let
          fun f(x: string): int = if x = "five" then 5 else 0
        in
          let
            fun g(x:int): int = 3
          in 
            f (g (17))
          end
        end
Type error: Arg to application has wrong type
In the version that is currently active in the interpreter, the first version of the code that we try passes type-checking, and the code fails during execution due to the exception that is raised. Type checking is passed because the type checker realizes that g will unconditionally return an exception, and an exception can be unified ("is compatible with") the formal argument of f (which is a string). When the change the argument of function g to an integer (as opposed to an exception) we can argue that we improve the correctness of the code. Still, the currently implemented type checker signals a type error. This does not seem entirely consistent.

The alternative implementation assumes that the type of the function's return value is the one that has been declared even if the function unconditionally raises an exception. This, in turn, allows for the detection of the type error in both versions of the code. This treatment is more consistent with what the informed user's expectations are versus a type checker.

Suggested problem: By examining the implementation of type checking on anonymous functions (FN_E), explain whether the mini-ML code below passes type checking or not. If type checking is passed, infer the result that mini-ML prints after evaluation. If a type error is detected, explain why it is so.

((fn (n: int) => raise Fail "bad") 3) ^ "alpha"
Once we understand type unification issues, understanding the type checker is straightforward; it is, in fact, the evaluator in disguise. A type environment is maintained to remember the type of all identifiers in the current scope. Our type checker exploits the fact that functions declared with fun have full type-annotations to deal with recursive functions. An important issue is that we do not treat here is that of type checking for special forms (see below).

Special Forms

Special forms look - superficially - like functions. The essential element that distinguishes them from functions, however, is that special forms have full control over the evaluation of their parameters. In addition, special forms can not be redefined in mini-ML, nor can their definition be shadowed by function definitions.

Let us now focus on the fact that special forms have full control over the evaluation of their arguments. Consider the following attempt to implement the if statement using a function in SML (or in mini-ML, where the issues are the same, except for superficial differences related, for example, to the restriction that all input must be provided on one line):

fun if_function(condition: bool, yes: 'a, no: 'a): 'a =
  case condition of
    true => yes
  | false => no

Note: We could have implemented the function by resorting to an if statement. We chose a case statement to emphasize the reimplementation of if. This is only a cosmetic difference, however, and it is secondary to the main point we are addressing below.

Consider now the difference in semantics between the two versions of the code below:

(* version 1 *)
if true then 3 else raise Fail "this will NOT be evaluated"

(* version 2 *)
if_function(true, 3, raise Fail "This WILL be evaluated")

The result of the code in version 1 is 3, the result of the code in version 2 is an exception. This is because in an eager language, like SML and mini-ML, function arguments are evaluated before the function call. The if statement, however, is defined so that it evaluates its condition first, and then it evaluates exactly one of its two branches.

Clearly, we can not create an equivalent of the if statement using a function in an eager language. By recycling an idea that we have encountered in streams, we could "shield" the evaluation of an expression by encasing it into a 0-argument anonymous function. We could then implement the following version of if:

- fun if_function(condition: bool, yes: unit -> 'a, no: unit -> 'a): 'a =
=   case condition of
=     true => yes()
=   | false => no();

val if_function = fn : bool * (unit -> 'a) * (unit -> 'a) -> 'a

- if_function(true, fn () => 3, fn () => raise Fail "This will NOT be evaluated");
val it = 3 : int

In mini-ML we do not have 0-argument functinons. We could, of course, modify the parser so that 0-argument functions become possible, or we could replace 0-argument functions with, say, 1-argument functions that take an integer argument that is never used. Still, wrapping expressions in functions is cumbersome and unnecessarily complicates our programs. In addition, the fact that we rely on the semantics of function evaluation means that we must rely on (a version of) the environment model to understand what is happening. For example, all free variables present in the functions passed on to if_function must have been defined in the environment at the point of the function call. This might be exactly what we want, but one can think of situations in which one intentionally wants to allow for undefined variables.

Consider the situation where we want to define a "function" (in fact: a special form) which evaluates its argument by defining type-dependent default values for all undefined variables in the respective expression. We could assume, for example, that all undefined integers have value 0, all undefined booleans are false, and that all undefined strings are empty. Here is an example in this spirit:

Mini-ML> evaluate_default(let val y = 1 in x + y end)

Assuming the default values indicated above, the value of x in the expression x + y should be 0, and the value of the entire expression should be 1.

Could we write such a "function" in SML, possibly by wrapping the expression in a 0-argument function?

> evaluate_default(fn () => let val y = 1 in x + y end)
stdIn:32.44 Error: unbound variable or constructor: x

Assuming that the value of x has not been defined in a top-level val statement, it is not possible for us to shield our argument expression from the fact that x is unbound in the current environment. Hence we can not implement our evaluate_default function in SML. We can, however, do it in mini-ML by resorting to special forms.

The most important part of the interpreter with respect to the semantics of special forms in mini-ML is the piece of code shown below:

  ...
  | APP_E(e1, e2) =>
    (* call the special form, if it is one *)
    (case expToSpecialFormName e1 of
         SOME s => evaluateSpecialForm(s, e2, env)
       | NONE => 
         (case eval(e1, env) of
              EXCEPTION_V(x, s) => EXCEPTION_V(x, s)
            | FN_V(x, body, closure_env) =>
              (case eval(e2, env) of
                   EXCEPTION_V(x, s) => EXCEPTION_V(x, s)
                 | v2 => eval(body, env_add(x, v2, !closure_env)))
            | _ => raise brokenTypes))
  ...

Function expToSpecialFormName is provided in file evaluator.sml; its role is to distinguish between true function applications and special form "applications." If the interpreter finds an expression of the form e1 e2, then it will consider this to be a call to a special form (a) if e1 is a string, and (b) if e1 is the name of a predefined special form.

fun expToSpecialFormName(e:exp):specialFormName option = 
  case e of
    VAR_E x => (case nameToSpecialFormName x of
                  SOME t => SOME t
                | NONE => NONE)
  | _ => NONE

Function nameToSpecialFormName converts a string to a value of type specialFormName option. Type specialFormName is defined in file specialforms.sml; it serves to provide 0-argument datatype constructors that can be used to easily identify the special forms that we deal with. Strictly speaking, we do not need to define such a type, since we can always use the form's name as its unique indentifier.

Note that when treating the evaluation of APP_E we test first for a special form. This makes it impossible for the user to define a function that shadows a special form. Functions whose name coincides with a special form can be defined, but when the user will call the function, it is the special form that is invoked instead. This observation explains the behavior of the interpreter for the two examples below:

MiniML> let fun ifmaybe(n: int): int = 5 in ifmaybe(3) end

uncaught exception Fail: Not implemented
  raised at: evaluator.sml:156.23-156.45
             readevalprint.sml:32.37
             readevalprint.sml:32.37

MiniML> let fun othername(n: int): int = 5 in othername(3) end
5

In the first example, the identifier ifmaybe is recognized as the name of a special form irrespective of the fact that a user-defined function with the same name has been defined. Since we are using a version of the evaluator that does not (yet!) implement any special forms, the result is an SML (as opposed to mini-ML) exception. The second example is identical to the first, except for the identifier used to name the function; because there is no conflict with the name of predefined special forms, the function is evaluated without a hitch.

As the designer of a language, you might agree or not with the semantics implemented above. What reasonable alternatives could ve come up with? Here are two possibilities:

Suggested problem: Perform the changes changes needed to modify the interpreter to implement the two alternative semantics described above.

Returning to the treatment of APP_E, we also note the treatment of exceptions. If, while processing an expression of the form e1 e2 we encounter an exception while evaluating e1, then the evaluation of e2 will not even begin. We could have decided to temporarily ignore the exception that results from e1 and evaluate e2 anyway (of course, ultimately we would still have to return an exception).

Suggested problem: Modify the interpreter so that you implement the treatment of mini-ML exceptions in function applications as described above. Comment on the impacts of this change in semantics in terms of execution time, resource utilization, and other factors that you find relevant.

Suggested problem: Consider the following piece of mini-ML code:

let
  val x = 3
in
  let
    fun f1(n: int): int->int = if x = 0 then (raise Fail "failed")
                                        else (fn (y:int) => y + n)
  in
    let
      fun f2(n: int): int = if x = 0 then f2(n) else x
    in
      (f1(3)) (f2(5))
    end
  end
end 

Keeping in mind that all mini-ML programs must be written on one line, and that the code above has been edited for readability, state what the code will evaluate to for various values of x (a) assuming the default treatment of exceptions in mini-ML, and (b) assuming that the treatment of exceptions has been changed as described above.

Implementing Special Forms

We have put in stubs for three special forms into our interpreter: ifmaybe, nth_eval, and handle_eval.

The semantics of nth_eval is the following: The special form takes n+1 arguments. The first argument must evaluate to an integer; if not, the exception ArgNotInteger will be raised. If the first argument, whose value we denote by k is not in the range 1 to n, then an IndexOutOfBounds exception will be raised. Otherwise, the value of the (k+1)th argument will be returned. The special form must be called with at least two arguments; otherwise the exception NotEnoughArgs will be raised.

We implement this special form by modifying function evaluateSpecialForm in file evaluator.sml. We show the relevant changes below:

  ...
  | NTH_EVAL => (case expr of
                   TUPLE_E (h1::h2::tl) => 
                     (case eval(h1, env) of
                        INT_V k => eval(List.nth(h2::tl, k-1), env)
                                   handle Subscript => EXCEPTION_V("IndexOutOfBounds", 
                                                          Int.toString(k))
                      | _ => EXCEPTION_V("ArgNotInteger",
                                         "first arg to ntheval must be integer"))
                 | _ => EXCEPTION_V("NotEnoughArgs", 
                                    "0 or 1 args provided in ntheval"))
  ...

IMPORTANT NOTE: You have to disable type checking for special forms to work. The easiest way to achieve this is to comment out line val _ = TypeChecker.get_type(ast, TypeChecker.empty_typenv) in function evaluateString in file readevalprint.sml. If you now enter a type-incorrect expression at the mini-ML prompt, you will receive a message indicating that type checking is not implemented correctly; this is not true, as we skip type checking altogether:

MiniML> 1 + "alpha"

uncaught exception Fail: Type-checker implemented incorrectly if this happened.
  raised at: evaluator.sml:45.16-45.27
             readevalprint.sml:32.37

While we do not address type checking here, it appears as a suggested problem below. You can find a possible solution to type checking for special forms in our solution to the list of suggested problems.

Here are some examples of how nth_eval works:

MiniML> let val x = ~1 in nth_eval(x) end
exception NotEnoughArgs with message  "0 or 1 args provided in ntheval"
MiniML> let val x = ~1 in nth_eval(x, 1) end
exception IndexOutOfBounds with message  "~1"
MiniML> let val x = 10 in nth_eval(x, 1) end
exception IndexOutOfBounds with message  "10"
MiniML> let val x = 1 in nth_eval(x, 3) end
3
MiniML> let val x = 3 in nth_eval(x, 5, "alpha", true) end
true
MiniML> let val x = 2 in nth_eval(x, 5, "alpha", true) end
"alpha"
MiniML> let val x = 1 in nth_eval(x, 5, "alpha", true) end
5

The last three examples show that in the absence of type checking we can get away with certain type-incorrect expressions. If the value returned by nth_eval were used in some expression, say, in an addition, this would lead to failure for certain values of x.

Let us now implement if_specialform, which will behave exactly like an if statement. That is, if_specialform will take three arguments. The first argument must evaluate to a boolean; if it evaluates to true, then the second argument is evaluated and the resulting value returned, otherwise the third argument is evaluatted and its value returned. Appropriate mini-ML exceptions must be raised for all error situations.

What makes this special form interesting is that there are no stubs for its definition in the interpreter. The steps needed to defines this special form are as follows:

  1. Add the 0-argument datatype constructor IF_SPECIALFORM to type specialFormName in file specialforms.sml.
  2. Add line the line below to function nameToSpecialFormName in file specialforms.sml:
      | "if_specialform" => IF_SPECIALFORM
    
  3. Add the code below to function evaluateSpecialForm in file evaluator.sml:
      ...
      | IF_SPECIALFORM => (case expr of
                             TUPLE_E (cond::yes::[no]) =>
                               (case eval(cond, env) of
                                  BOOL_V b => if b then eval(yes, env)
                                                   else eval(no, env)
                                | _ => EXCEPTION_V("CondNotBoolean",
                                              "first arg to if_specialform must be bool"))
                           | _ => EXCEPTION_V("BadArgNumber",
                                              "if_specialform must have 3 args"))
      ...
    

And here is an illustration of how is_specialform works:

MiniML> let val cond = "bad" in if_specialform(cond, 1, 2) end
exception CondNotBoolean with message  "first arg to if_specialform must be bool"
MiniML> let val cond = true in if_specialform(cond, 1, 2) end
1
MiniML> let val cond = false in if_specialform(cond, 1, 2) end
2

Suggested problems:

  1. Consider special form if_maybe, for which we have provided a stub in the interpreter. Special form if_maybe takes exactly two arguments; when called, it randomly evaluates either the first or the second one, and returns the value of the evaluated expression. Error situations must be treated by raising appropriate mini-ML exceptions.
  2. Special form handle_eval takes exactly two arguments. The first argument is evaluated unconditionally; if any exception results from the evaluation of the first expression, then the second argument is evaluated and its value is returned as the value of the special form. This form behaves similarly to the handle statement, with the difference that the special form will catch all exceptions, irrespective of their type.
  3. Special form evaluate_default takes exactly two arguments. The first expression must evaluate to a non-exception value that will be used as the default value for all variables that are free in the second argument, and which are not defined in the current environment. Consider the following example:
    Mini-ML> let val x = 3 in evaluate_default(false, if y then x else x + 5) end
    
    In the case illustrated above the mini-ML should evaluate to 8.

Finally, let us recall that type checking for special forms has not been implemented.

Suggested problem: Implement type checking for if_specialform, if_maybe, handle_eval, and evaluate_default.