Last updated on

Lab 3: calculator

Introduction

Before starting to do this lab, check the callback for find lab first!

In this lab, you will craft a calculator that understands and evaluates arithmetic expressions. You can feed expressions to it, like 1/2+5*5. In response, it will reply with 25.5:

> 1/2+5*5
25.5
>

You will start from a basic calculator that evaluates simple arithmetic expressions. As you progress, you add features to the calculator, including divisions and variables. In the end, the calculator can not only evaluate an expression but also simplify it. For instance, a * (3 - 2) can be simplified to a.

Throughout this lab, you will:

Logistics

git clone https://gitlab.epfl.ch/lamp/cs-214/calculator.git
src/main/scala/calculator/tiny/TinyPrinter.scala
src/main/scala/calculator/tiny/TinyEvaluator.scala
src/main/scala/calculator/basic/BasicEvaluator.scala
src/main/scala/calculator/full/FullEvaluator.scala
src/main/scala/calculator/full/AlgebraicSimplifier.scala
src/main/scala/calculator/full/ConstFoldSimplifier.scala
src/main/scala/calculator/full/FullSimplifier.scala
src/main/scala/HowManyHoursISpentOnThisLab.scala

Expressions as Trees

Arithmetic expression is a minimal form of a programming language. Surprisingly, the methodologies and principles employed to construct the calculator in this lab bear significant resemblance to those utilized in programming language interpreters and compilers.

How a calculator evaluates an expression? Let’s start with a concrete example: 1 + 2 * 3. The calculator’s first job is to parse it. To parse is similar to understand the structure of the expression: the calculator scans the text, understands how it forms a expression, and then turns it into a tree—what we call an Abstract Syntax Tree (AST).

For instance, the AST of 1 + 2 * 3 looks like the following:

Want to see more examples? Type your own arithmetic expression below and its AST will be displayed.

Before moving on, can you draw the tree for -(1 * 2 + 3 * (4 + 5))? Try sketching it on the paper before checking the answer using the above tool.

Representing Trees as Enums

We have seen with examples how expressions can be parsed and represented as trees. How to get these trees into our Scala code?

Scala’s enums are perfect for this. With them, you can encode recursive structures, just like the expression trees. The following enum represents ASTs.

enum Expr:
  case Number(v: Double)
  case Add(e1: Expr, e2: Expr)
  case Minus(e1: Expr, e2: Expr)
  case Mul(e1: Expr, e2: Expr)
  case Div(e1: Expr, e2: Expr)
  case Neg(e: Expr)

The enum mirrors the structure of ASTs with remarkable fidelity:

As a concrete example, to represent the AST of 1 + 2 * 3, you would write:

Add(Number(1), Mul(Number(2), Number(3)))

You can paste the definition of Expr into the worksheet and try your hand at constructing expressions. To bring the constructors (like Add and Number) into the scope of the worksheet, do not forget to have import Expr.* in the code.

How to represent the ASTs of 1 + 2 * -(3 + 4) and -(1 * 2 + 3 * (4 + 5)) in Scala?

See the Answers

For the expression 1 + 2 * -(3 + 4), the Scala representation is:

Add(Number(1), Mul(Number(2), Neg(Add(Number(3), Number(4)))))

For the expression -(1 * 2 + 3 * (4 + 5)), the Scala representation is:

Neg(Add(Mul(Number(1), Number(2)), Mul(Number(3), Add(Number(4), Number(5)))))

Warmup: Printing Expressions

Before jumping into crafting your calculator, let’s exercise a bit to get a feeling of expressions. You will write two functions that print expressions—but in very different styles.

Printer 1: show (not graded)

The first task: print the expression just like how you see it in Scala. This is called an in-order traversal. For instance, 1 + 2 should be printed as Add(Number(1.0),Number(2.0)). In fact, this is how the built-in toString method prints the expression enum. To see it in action, check the worksheet at src/main/scala/printers.worksheet.sc.

Here is the skeleton to be completed:

  /** Re-implement the `toString` function of TinyExpr. */
  def show(e: TinyExpr): String =
    "42" // TODO: modify this

src/main/scala/calculator/tiny/TinyPrinter.scala

You could use the worksheet to experiment with your implementation. Test cases prefixed with show: can be used to test your code. To run these tests, open the sbt console and enter testOnly -- "*show:*".

To run all tests prefixed by <name>:, use the sbt command testOnly -- "*<name>:*".

Solution
  /** Re-implement the `toString` function of TinyExpr. */
  def show(e: TinyExpr): String =
    e match
      case Number(value) => f"Number($value)"
      case Add(e1, e2)   => f"Add(${show(e1)},${show(e2)})"
      case Minus(e1, e2) => f"Minus(${show(e1)},${show(e2)})"
      case Mul(e1, e2)   => f"Mul(${show(e1)},${show(e2)})"
      case Neg(e)        => f"Neg(${show(e)})"

src/main/scala/calculator/tiny/TinyPrinter.scala

Printer 2: toPolish

One of the optional exercises in week 1 looked at polish notation.

Let’s generate Polish notation expressions, rather than evaluating them.

For example, 1 + 1 should be printed as + 1 1 and 1 + 2 * 3 be printed as + 1 * 2 3.

Specially, use -- for negation to disambiguate negation and subtraction. For instance, (1 + -2) - 3 should be printed as - + 1 -- 2 3.

If - stands for both negation and subtraction, can a polish string be ambiguous?

Reveal the Answer

- + 1 - 2 3 is an ambiguous polish string. Depending on how you interpret -, it can be parsed to either - (1 + (2 - 3)) or (1 + -2) - 3.

Now, complete the following function:

  /** Print the expression in Polish notation. */
  def toPolish(e: TinyExpr): String =
    "42" // TODO: modify this

src/main/scala/calculator/tiny/TinyPrinter.scala

Feel free to experiment with it in the worksheet. Your objective: passing all the tests prefixed by toPolish:.

Evaluating Simple Arithmetic Expressions

Now you are ready to start working with the calculator: your task is to complete the missing parts in it. The infrastructure of the calculator is in place, including components such as the parser and a simple command-line interface. What is missing is the core components that understands and processes the ASTs. For instance, the evaluator. Your job is to flesh them out.

📝 Important: don’t forget to fill in how many hours you spent on this lab in HowManyHoursISpentOnThisLab.scala to pass the last test.

Interacting with Your Calculator

Before you diving into coding, let’s first seeing your calculator lively! You have two ways to interact with it.

Terminal

This is a calculator console running inside the terminal. To run it, first start sbt within the top directory of the lab. When sbt is active, run runMain cs214.cli.main to bring the calculator console up and running. The following exemplifies what you could expect:

> 1
1.0
> 1+1
2.0
> 1+1-2
0.0
> 1+2*3
7.0
> 1+
Error:
 1 | 1+
      ^
      unexpected EOF, expecting start of expression

>

Right now, the output of your calculator mismatches with what is shown above: your calculator always returns 42.0. It is the expected behavior since the calculator logic is not completed yet.

Webpage

If you prefer a visual interface, go for the web-based UI! To start it, run runMain cs214.ui.main in the sbt console. Then, open http://127.0.0.1:8080 in your browser.

You will find three selection boxes for expression types: Tiny, Basic and Full. They differ in terms of expressiveness and are used by different sections in the lab. We will make it clear which one you should be working with as you go along.

Fleshing out the Evaluator

Give your calculator a try by typing something in it! For instance, 1 + 2 * 3 and 100 * 100. You may already notice that the calculator invariably responds with 42.0. This is because the evaluator is not yet implemented, and rectifying it is your first task!

We will start simple with the evaluator for TinyExpr, which includes numbers, addition, subtraction, multiplication and negation. You can find its definition in tiny/TinyExpr.scala.

Now, complete the evaluate function:

  /** Evaluate an expression to its value. */
  def evaluate(e: TinyExpr): Double =
    42 // TODO: modify this

src/main/scala/calculator/tiny/TinyEvaluator.scala

Experiment with the calculator and tweak your implementation as needed. When you are confident it is working, test it by commanding testOnly -- "*TinyEvaluator:*" in the sbt console.

If your calculator is still running and you want to go back to the sbt console, type :quit in the calculator to exit it.

Restarting the interface. Similar to the boids lab, you have to restart the calculator interface (either the terminal- or the web-based one) after modifying the code to make the modification effective.

  • For the terminal interface, you can exit the calculator console with :quit, and re-open it with run and choose the CLI main class.
  • For the webpage interface, use Ctrl-C to stop it, and runMain ui.main to restart it.

In case any test fails, do not worry too much! The error message shows the expression that makes your calculator behave incorrectly. Use that counter-example to pinpoint the problem in your evaluator.

Add println statements in the function to get a view of the intermediate state of evaluation.

Remember to use the provided worksheet to debug your code efficiently. Go back to our debugging guide How to diagnose (almost) anything and apply the corresponding techniques. First, reproduce the issue in the worksheet, then simplify and minimize the bug.

Division by Zero?

So, you have built a calculator. It adds, it subtracts, it multiplies. But what about division? The challenge with division is that dividing by zero is mathematically undefined. It this section, you will extend your calculator so that it handles that gracefully.

The following enum, BasicEvaluator.Result, will serve as the result type of our next evaluate function:

enum Result:
  case Ok(v: Double)
  case DivByZero

src/main/scala/calculator/basic/BasicEvaluator.scala

It has two cases, one returning the evaluated result when the evaluation succeeds, the other indicating that a division-by-zero error occured.

You have already completed the evaluate function for TinyExpr. Now you will finish the one for BasicExpr, which includes the division operation.

  /** Evaluate an expression to its value. */
  def evaluate(e: BasicExpr): Result =
    Ok(42) // TODO: modify this

src/main/scala/calculator/basic/BasicEvaluator.scala

The result type is BasicEvaluator.Result. Use the DivByZero case to signal an error when needed!

By default, both the terminal and the web interface use the evaluator for TinyExpr. To switch over to the new and improved version, do the following:

  • In the calculator console: type :basic. The console will change to the basic mode, in which it uses the new evaluator to process the expressions. Or restart the calculator with runMain cs214.cli.main basic to jump directly into the basic mode.

  • In the webpage, select Basic as the expression type.

Your code should pass all tests prefixed by BasicEvaluator:.

Here Come Variables

Now, your calculator supports basic arithmetics and gracefully handles division-by-zero. Now it’s time to add another exciting new feature: variables!

Think of a variable like a placeholder in an expression, a slot reserved for a value we not yet know. For instance, in a * a + 2 * a * b + b * b, a and b are variables. When we want to evaluate an expression containing variables, we “plug in” numbers for these variables to get a numerical answer. Let’s say we assign a with 1 and b with -1, a * a + 2 * a * b + b * b yields the result 0.0.

How do we associate variables with values? Contexts! A context is like a dictionary that tells us what each variable stands for. In this week’s exercise, we have seen two ways to represent contexts: an enum, or a function.

Pick your favorite one! Replace Unit with your chosen type for context representation in the following line:

  type Context =
    Unit // TODO: modify this

src/main/scala/calculator/full/FullEvaluator.scala

Here, type Context = Unit defines a type alias. In the scaffold, Context is an alias for Unit. Change it to your context type! As a hint:

  • If you define you context as an enum, you could do:
enum MyContext:
  /* cases */

type Context =
  MyContext
  • Alternatively, if prefer using functions as contexts, you could do:
enum LookupResult:
  /* cases */

type Context =
  String => LookupResult

Next, implement two functions for your context: empty and cons.

Context.empty returns an empty context:

def empty: Context =
  () // TODO: modify this

src/main/scala/calculator/full/FullEvaluator.scala

Context.cons adds a new variable and its value into the context:

def cons(name: String, value: Double, tail: Context) =
  () // TODO: modify this

src/main/scala/calculator/full/FullEvaluator.scala

Now you are ready to accommodate variables in the evaluator. Complete the evaluate function in FullEvaluator to evaluate FullExpr, which is the expression type that includes variables:

  /** Evaluate an expression to its value. */
  def evaluate(e: FullExpr): Result =
    Ok(42) // TODO: modify this

src/main/scala/calculator/full/FullEvaluator.scala

Note that the Context used for evaluating the expression is passed as ctx, a class parameter of the FullEvaluator class.

The objective: your code should pass all the tests prefixed by FullEvaluator:.

Similar to the previous section, you have to tell the interfaces to use the new evaluator.

  • Terminal: :full in the calculator console, or runMain cs214.cli.main full in sbt.
  • Webpage: select Full as the expression type.

Expression Simplification

You have conquered basic arithmetics and variable handling. The next step is even more interesting: expression simplification. We have designed minimalist test cases to help you debug your implementation. We recommend working through these tests carefully: if a test fails, run the algorithm by hand, for this test, on paper, and see when and why things go wrong.

Why Simplify?

Expressions with variables can represent formulas that are invoked repeatedly with variables being set to different values. For instance, 2 * 3.1415 * r is a formula for the perimeter of circles.

Simplifying them results in smaller expressions that saves redundant calculation. Imagine evaluating an expression like a * (1 + 0 * (b + c + d)). Even without knowing the values of a, b, c, and d, we can tell that this is the same as just a. Also, 2 * 3.1415 * r can be simplified to 6.2830 * r, saving the redundant calculation of the constant part each time the formula is used.

Senior track

This week, if you want to follow the senior track of this lab, you will have to:

  • Skip the hints of the next section

  • Disable the easier debug test cases in the test suites by setting calculator.SimpSuite.enableDebugTests to false and write your own tests. Note however that all the tests will be run on our side, you may want to re-enable the variable before submitting

    val enableDebugTests = true
    

    src/test/scala/calculator/SimpSuite.scala

  • Implement the fire Refactor simplify optional exercise

Three Types of Simplifications

We will explore three expression simplifiers, each simplifying expressions in different ways.

Constant Folding

Let’s start with constant folding! It simplifies constant sub-expressions. You have already seen the example for the perimeter of circles. More examples: a + 2 * 3 can be constant folded into a + 6; a + b * (3 + 3) can be simplified to a + b * 6.

Complete the function below:

def simplify(e: FullExpr): FullExpr =
  e // TODO: modify this

src/main/scala/calculator/full/ConstFoldSimplifier.scala

When implementing constant folding, you need not worry about the division-by-zero error. This will not happen in any of the tests.

Your objective is to pass the all tests prefixed by ConstFoldSimplifier:.

To interact with your simplifier:

Algebraic Simplification

The second simplifier manipulates expressions in a more interesting way. It simplifies them as much as possible based on the following algebraic rules:

For instance, the expression a * 1 - a / 1 can be simplified to 0.0 with these rules.

However, is this set of rules complete? What else could we simplify?

Let’s test your knowledge with a quiz: by applying these rules, what is the simplified form of a * (b + 0) * c / 1 - a * b * c + a - 0?

Show Me the Answer

a

Your task:

def simplify(e: FullExpr): FullExpr =
  e // TODO: modify this

src/main/scala/calculator/full/AlgebraicSimplifier.scala

To interact with your simplifier, switch to the algebraic mode of the calculator console. On the web UI, select the Full expression type and there are two tabs, Algebraic Simplified and Algebraic Simplified (AST), show the simplified expression and its AST.

Run testOnly -- "*AlgebraicSimplifier:*" to test your simplifier. If any test fails, try working with pen and paper to break down the process and pinpoint where your simplifier goes astray.

Combined Simplification

In this section, you will create a new simplifier that combine the bests of both worlds: constant-folding and algebraic simplification.

Sometimes, one method of simplification opens the door for another. For instance, the expression (3 - 2) * (a * 1) - a gets constant-folded to 1 * (a * 1) - a, and algebraically simplified to (3 - 2) * a - a. But when the two simplifiers work together, they simplify the expression to 0.0.

At first, the natural idea that comes to mind is to simply combine the above simplification methods, either as constfold `andThen` algebraic or as algebraic `andThen` constfold.

Does it always work?
On paper, come up with an expression that doesn’t get fully simplified with either one of the compositions above.

Solution

Try with the expressions e0 = ‘(y - y) + 1 + 1’ and e1 = ‘(x + x) * (3 - 2)’.

Step by step

val driver = FullDriver(FullEvaluator.Context.empty) val algebraic = AlgebraicSimplifier.simplify val constfold = ConstFoldSimplifier.simplify

val e0 = driver.parse(“(y - y) + 1 + 1”).get algebraic(constfold(e0)) // Add(Number(1.0),Number(1.0)) constfold(algebraic(e0)) // Number(2.0)

val e1 = driver.parse(“(x + x) * (3 - 2)”).get

algebraic(constfold(e1)) // Add(Var(x),Var(x)) constfold(algebraic(e1)) // Mul(Add(Var(x),Var(x)),Number(1.0))

Based on what we just described, naive composition does not always produce the correct result. Using pen and paper, try coming up with another solution.

Hint

The key point here is that one method of simplification opens the door for another.
On paper, try simplifying expressions using one of the two simplifiers at a time.

Implement a new simplifier, covering all simplifications performed by both previous simplifiers.

def simplify(e: FullExpr): FullExpr =
  e // TODO: modify this

src/main/scala/calculator/full/FullSimplifier.scala

An “idempotent” function is a function such that f(f(x)) == f(x). Is simplify idempotent? How about constfold and algebraic?

To interact with the simplifier:

Your implementation should pass all tests prefixed by FullSimplifier:.

Bonus: Refactor simplify 🔥

This part is optional and not graded.

The version of simplify you came up with is probably verbose and repetitive. Can you extract common patterns between constfold and algebraic to eliminate the duplication?

Hint

Both constfold and algebraic do two things:

  1. Simplify children (recursion), then
  2. Apply rules on the current node (post-processing)

Can you use this insight to come up with a better solution? (Hint: it involves higher-order functions!)