Object-Oriented Programming

CSCI-UA.0470-001

NYU, Undergraduate Division, Computer Science Course - Fall 2013

OOP Class Notes for 10/17/13

The Expression Problem

  • We want to represent a graph-like structure (e.g., the syntax tree of expressions in our calculator).
  • Each node has a type (number, addition, etc.) and we have operations on the graph that depend on these types (e.g., expression evaluation and pretty printing).
  • How do you implement the operations? How do you implement the types?
  • You can only really have easy development of one (types or operations). This is called the Expression Problem.

Extensible Types

  • If you want extensibility of types, use the classic OOP implementation:
    • You have an abstract super class for nodes with abstract methods for every operation:
      abstract public class Expression { 
        abstract public int eval();
        abstract public void print();
      }
    • To add a new type of node, make a new class that extends the abstract super class.
      • Subsequently, this class has a method for every operation you can do on that node.
      public class Number extends Expression { 
        final int value;
        public Number(int value) { this.value = value; }
        public int value() { return value; }
        public int eval() { return value(); }
        public void print() { System.out.print(value()); }
      }
      
      abstract public class BinaryExpression extends Expression { ... }
      
      public class Addition extends BinaryExpression {
        public int eval() { return left().eval() + right().eval(); }
        public print() {
          System.out.print("(");
          left().print();
          System.out.print(" + ");
          right().print();
          System.out.print(")");
        }
      }
      ...
      
    • This is bad for maintainability of operations, since the implementation of a single operation is scattered over many node type classes.
      • To add a new operation on nodes, you have to edit every node class and add the new method.
    • Ideally, we also want extensibility of operations, i.e., the implementation of each operation should be encapsulated in its own class.
  • Extensibility of operations is often more important than extensibility of types.

Extensible Operations

  • A first idea to implement extensible operations would be to use method overloading:
  • public static class Evaluator {
      public int eval(Number num) {
        return num.value();
      }
      public int eval(Addition add) {
        return eval(add.left()) + eval(add.right());
      }
      ...
    }
    
  • Unfortunately, this code does not compile.
  • The Java compiler resolves calls to overloaded methods such as eval statically based on the static types of the arguments and result of the call:
    • The static type of add.left() passed to the first call to eval in eval(Addition add) is Expression.
    • However, there is no method with the signature public int eval(Expression).
  • To implement extensible operations on nodes, we need to be able to dispatch calls to methods based on the dynamic types of the node objects that are passed as arguments to the methods.

The Visitor Design Pattern

  • If you want extensibility of operations, use the Visitor Design Pattern
  • We have visitors and node classes.
    • Visitors implement operations.
      • Make a Visitor interface with a visit method for every type of nodes:
        public static interface Visitor<R, E extends Throwable> {
          R visit(Number num) throws E;
          R visit(Addition add) throws E;
          R visit(Subtraction sub) throws E;
          R visit(Multiplication mul) throws E;
          R visit(Division div) throws E;
        }
        
      • Each visit method in the interface returns a value of some generic result type R and may throw some generic exception E that extends Throwable:
        • In the generic visitor interface, E has to be bounded by Throwable since a visitor may throw both Exception and Error objects.
        • R has the implicit upper bound Object.
        • The visit method returns a generic type so that we can implement different kinds of visitors (we have visitors Printer, Evaluator, etc.). For the same reason, we have the generic exception type.
        • Remember: java erases type parameters of generics -- In our example, R gets replaced by Object, E gets replaced by Throwable in the generated byte code.
    • Node classes implement the different types of nodes
      • They all extend some abstract class with a method
        public abstract <R, E extends Throwable> R accept(Visitor<R,E> visitor) throws E;
      • Each subclass of the abstract class then implements accept(Visitor<R,E> visitor) by calling back the visit method of the visitor, providing a reference to the current node instance as argument, e.g.:
        public static class Addition extends BinaryExpression {
        
          public Addition(Expression left, Expression right) {
            super(left, right);
          }
        
          public  R accept(Visitor v) throws E {
            return v.visit(this);
          }
        
        }
    • To make a new Visitor, implement the interface by providing implementations for all of the overloaded visit methods:
      public static class Evaluator implements Visitor<Integer, ArithmeticException> {
      
        public Integer visit(Number num) {
          return num.value();
        }
      
        public Integer visit(Addition add) {
          return add.left().accept(this) + add.right().accept(this);
        }
        ...
      }

How does the Visitor Design Pattern work?

  • To implement extensible operations, we need double dispatching: we want to dispatch calls to the visit method based on
    • the dynamic type of the Visitor object; and
    • the dynamic type of the node object that is passed to the visit method.
  • Double dispatching is realized in the Visitor Pattern by introducing an additional level of indirection, namely, by introducing the additional call to the method accept. The call to accept first dispatches based on the dynamic type of the node, and then it calls back visit to dispatch based on the dynamic type of the visitor.
  • Let's look at how this works, step by step:
    • Suppose the visit method of a Visitor calls the accept method of some node object, say of dynamic type Addition.
    • The call to accept is dynamically dispatched to the specific implementation of the accept method in the specific node class; here the class Addition.
    • The dynamic type of the node object on which accept is called is also the static type of this inside of the implementation of the accept method to which the call is dispatched; here in class Addition.
    • Thus, when the accept method of class Addition calls back to the visit method using visitor.visit(this), the compiler uses the static type of this, to determine statically the right variant of the overloaded visit method in the Visitor interface to which the call should go, namely visit(Addition).
  • Now, to add a new operation, we only need to implement a new visitor class.
    • That is, you can now add a new operation without changing the node classes on which the operations will work on.
  • Drawback: to add a new type of nodes, you now have to edit every Visitor class to add a new visit(Type) method. Though, this is often the lesser of two evils.