RU beehive logo promo banner for Computing & Info Sciences
ITEC 380
2023fall
ibarland

Expr Trees
eval/parse E basics; adding identifiers

Due 2023-Nov-10 (Fri.) 23:59. Note that E1 is “add code that is extremely similar to what already exists”, but E2 is not at all rote: it requires a solid understanding of the parse-tree representation and how the functions work.

Over the course of several homeworks, we'll implement a “tower” of languages (E0, E1, …, E6) each language incorporating more features than the previous. E0 is provided for you.

  1. Download & get the provided E0 running (both .rkt and .java.
  2. Each time we add new syntax to our language, we must update three methods: I recommend implementing and testing the methods in that order. See the provided test-cases, for examples. There might be other helper functions to implement as well.
  3. Your solution may be in Java or in Racket (or see me, if you want to use a different language). (The exception is E1, which you must implement in both Racket and Java.)
  4. You do not need to worry about error-checking the programs we process — we will only concern ourselves with syntactically correct Ei programs.
    (As a matter of defensive programming, you might still want to add some assertions/sanity-checks in your code.)
  5. Use the D2L discussion board for group questions and answers.
  6. This project is based, in part, on the first chapters of Programming Languages and Interpretation, by Shriram Krishnamurthi. As a result, this homework assignment is covered by the Creative Commons Attribution-NonCommercial-ShareAlike License. Although we're using a different dialect of scheme/racket than that book, you might find it helpful to skim it.
Deliverables:
  1. Implement E1 in racket (25pts) and in Java (25pts), both.
    E1 is just like E0, but with two additions:
    <Op>        | *                              Interpretation: “remainder1
    <Expr>      | <IfLE>                                Interpretation: “if less-or-equal to”
    <IfLE>     if <Expr> > <Expr> ? <Expr> : <Expr>  Interpretation: if 1st Expr is ≤ 2nd, result is the 3rd Expr else the 4th.
    Be sure to write test cases first; To ensure everybody makes test cases to cover the basics, I've spelled out these strongly-suggested E2-initial-tests.html.
    1. Add the * operator.
      1. update parse! (Java: Expr.parse) to handle this new operator (after writing test cases).
        (Or, if you don't need to update this function, understand why.)
      2. update expr->string (Java: Expr.toString) to handle this new operator (after writing test cases).
        (Or, if you don't need to update this function, understand why.)
      3. update eval to handle these new operators.

        • The semantics of ]x * y[ is:
          the remainder after dividing x by y, where the result is always between 0 (inclusive) and y (exclusive)2
          In particular, the result should never be positive if y<0. Notice that this is slightly different behavior than either Java's built-in % (which behaves differently on negative numbers), and from Racket's built-in modulo (which only accepts integers). In both racket and Java, you can calculate this as y * (x/y - ⌊x/y⌋), where ⌊r⌋ means the the floor of r.

        Note that you are already provided sufficient test cases for *s, in the comments of the E0 test-case files.

    2. Add the ifLE expression.
      1. update parse! (Java: Expr.parse) to handle this new type of expression (after writing test cases).
      2. update expr->string (Java: Expr.toString) to handle this new type of expression (after writing test cases).
      3. update eval to handle this new type of expression. The semantics of if Expr1 > Expr2 ? Expr3 : Expr4 is:

        first evaluate just Expr1 and Expr2 to get values v1; and v2; if v1 is less-or-equal to v2 then evaluate Expr3 and return its value; otherwise evaluate Expr4 and return that value.
        For example, if 7.5 > 44 ? 3.4 : 2.1 evaluates to 3.4.
        (Note how the non-returned Expr never gets evaluated: you are implementing short-circuit semantics for IfLEs!)

        You must make your own test cases for IfLEs; include at least two as-simple-as-possible tests, and two tests with more deeply nested Exprs. I suggest including one where the IfLE is not the top-level expression (e.g., a - expression which contains a IfLE as one of its operands).

  2. (50pts) Implement E2 in either racket or Java (your choice).
    E2 adds identifiers to E1:

    <Expr>      | <Id> | <LetExpr>             Interpretation: identifier; let
    <LetExpr>  forbid <Id> being <Expr> in <Expr>3            Interpretation: bind Id to result of 1st Expr (the “right-hand-side”); then eval 2nd “body” Expr w/ that binding
    where <Id> can be pretty much any string: it can be any token returned by our scanner. This simplistic4 definition lets us keep our code easy: if we get a token (via peek/UtilIan#hasNextSplittingBy), and it's not the start of any other expression, then it's an identifier! We will assume that (a) an Id is never one of our “reserved” words like if so you do not need to bother checking for that, and (b) for now any nested LetExpr expressions will use different Ids. We'll handle shadowing in E3, later.)

    Update your three methods parse, toString (a.k.a. expr->string), eval.

    1. add Ids to your data-definition (after deciding what data-type will represent them); then:
      1. update expr->string (or, Expr.toString) to handle this new type of expression (after test cases)
      2. update parse! (or, Expr.parse) to handle this new type of expression (after test cases)
      3. update eval (or, Expr.eval) to handle this new type of expression:
        eval'ing an identifier simply throws an error.

        You don't need test cases for evaling Ids. Though if you want to be spiffy, you can use check-error in racket, or assertThrows in JUnit5. In JUnit4, the hack-ish approach is to put ExpectedException.none().expect(RunTimeException.class) on the line before the one that should trigger an error.

    2. Next, add LetExprs to your data-definition (after deciding what data-type will represent them); then:
      1. update expr->string (or, Expr.toString) to handle this new type of expression (after test cases)
      2. update parse! (or, Expr.parse) to handle this new type of expression (after test cases). Since our parser splits on punctuation, < will be read as one token, and - as second (both racket and Java).
      3. Think about how to update eval to handle this new type of expression. Now we get to the heart of the issue! Write test cases, after reading the rest of this bullet.

        In order to write eval, we need to define the semantics of forbid Id being Expr0 in Expr1:

        • Evaluate Expr0; let's call the result v0.
        • Then, substitute v0 for all occurrences of Id inside the tree Expr1; name the result of the substitution E′.
        • Evaluate E′, and return that result.

        For example: forbid x being 3 in ]x - 2[]3 - 2[55. Be sure to write test cases for your substitution function before you write its code; include several trivial and easy tests, along with a couple of more complicated nestings and one deeply nested expression.

        Observe that when evaluating a (legal) E2 program, eval will never actually encounter an Id — that Id will have been substituted out before we ever recur down to it.

      4. Now that we realize that eval will need to do substitution in a tree, and that's a smaller, simpler, self-contained task — perfect for its own helper-function substitute. This function only does substition in a tree, and does not attempt to do any evaluating.
        Go and write substitute (after test cases for it), before implementing eval for LetExprs. (And when starting substitute, start from the template for Exprs.) For the test cases, think about exactly types you'll be wanting to sent to substitute. Your simplest test-cases won't even contain a LetExprsubstitute is a function whose purpose-statement stands on its own, entirely independent of LetExprs and eval!
        Hint: Substituting a variable with a value in an syntax-tree is essentially the same as replacing every occurrence of one name with another in an anc-tree. (The only difference is that an anc-tree had only two cond-branches, while Expr has around five, though the code for most of those are very similar.)

        (Note: you must do substitution in the parse tree; no credit given for string-substitution 7.)

      5. Finally, with the substitute helper written, we're ready: write eval for LetExprs.
        Hint: Your code will correspond almost word-for-word to the semantics given above.

1 Like remainder, except working for negative and fractional amounts. See homework for details.      
2 Because we don't need to check for bad inputs, it's fine to have your interpreter crash if y=0. If you prefer to "control" crash — creating a meaningful error message and calling error or throw yourself — you are also welcome to do that.      
3 For comparison, here is what comparable constructs look like in other languages:
ML-like: let x = 2+3 in x*9 end;
lisp-like: (let {[x (+ 2 3)]} (* x 9))
lisp-like, simplified: (let x (+ 2 3) (* x 9))
C#-like: using (var x = 2+3) { return x*9; }
javascript-like: var x = 2+3; return x*9;
Java-like: { int x = 2+3; return x*9; }
Haskell-like: * x 9 \n where x = + 2 3 \n
Another option for the assignment-character is “:=” (Ada,Pascal), or “” (indicating which way the data flows), or even something like “ExprId { }” (which might make CS1 students happier — the processing happens left-to-right, just like we read the statement).

Note that you can (and should) test and write a “substitute” function w/o worrying about the exact syntax of a LetExpr. Substituting one thing in a tree for another is its own independent task, de-coupled from eval’ing a local-binding statement.

The relevent xkcd.

     
4 Note that the different scanner(tokenizer) implementations now mean that our definition of the language is vague: in a Java implementation, NaN is a Num, and in a racket implementation it's an Id. We won't use any test cases involving such subtle differences. However, note how our choices in designing a new language are being influenced by the language we're trying to easily implement it in! This stems from the fact that a primary design constraint on E2 is that implementing an intepreter for E2 doesn't get bogged down in minutae when using either Java or Racket.      
5 The notation “forbid x being 3 in ]x - 2[]3 - 2[5” is shorthand for:
  eval(parse!("forbid x being 3 in ]x - 2["))
= eval(parse!("]3 - 2["))
= eval(parse!("5"))
Observe how we definitely don't write “forbid x being 3 in ]x - 2[ = ]3 - 2[ = 5” since those strings are not .equals(·) to each other, and strings are never equal to ints. More specifically: we distinguish between “” (“code evaluates to”) and “=” (“equals”, just as “=” has meant since kindergarten).      
7 For example: what if a E2 programmer uses a variable named “add” or “if” or “being” [which we might make into a keyword in the future]? While it's not advisable for somebody to do this, and perhaps our parse should disallow this, our eval shouldn't give wacky results in this situation.      
6 All our real code should work on the parse tree itself. String-substitution (like C pre-processor macros) can't be generalized to handle shadowed variables (scope) for E3, and is in general fraught with error7. A local-variable construct which requires globally-unique names isn't very impressive!      

logo for creative commons by-attribution license
This page licensed CC-BY 4.0 Ian Barland
Page last generated
Please mail any suggestions
(incl. typos, broken links)
to ibarlandradford.edu
Rendered by Racket.