FreshMarker Operator Overloading

“We cling nervously to the melody, but we don’t handle it freely, we don’t really make anything new out of it, we merely overload it.”

Johannes Brahms

Operator overloading is a programming technique in which an existing operator is extended or adapted to user-defined data types. This makes it possible to design user-defined classes in such a way that they behave syntactically like built-in data types and facilitates intuitive and readable use of these types using the familiar operators.

Overloaded operators have various advantages for the source code. They can make the code much more intuitive and easier to understand, especially when processing complex data structures. Less type checking and type conversion is required as this is already handled in the overloading code. Similar data types or structures can be handled uniformly, which improves the consistency of the code.

Every coin has two sides and so of course there are some disadvantages. Developers who are not familiar with the concept may find it difficult to immediately understand the overloaded code. If operators are overloaded inconsistently, this can lead to difficulties in predicting behavior, which can lead to errors and unexpected behavior.

In languages such as C++ and Python, the developer can overload operators at language level. With FreshMarker, however, the extension mechanism for new model types should provide for operator overloading. Overloading from the template language is not intended.

The idea for overloading the operators did not fall from the sky, but came about during a refactoring session in the TemplateOperator class. The class evaluates the numeric operations +, -, /, *, % and the string concatenation.

@Override
public TemplateObject evaluateToObject(ProcessContext processContext) {
  TemplateObject leftValue = left.evaluateToObject(processContext);
  TemplateObject rightValue = right.evaluateToObject(processContext);
  if (op == TokenType.PLUS) {
    Optional<TemplateString> leftString = leftValue.asString();
    Optional<TemplateString> rightString = rightValue.asString();
    if (leftString.isPresent() && rightString.isPresent()) {
      return leftString.get().concat(rightString.get());
    }
  }
  TemplateNumber leftNumber = leftValue.evaluate(processContext, TemplateNumber.class);
  TemplateNumber rightNumber = rightValue.evaluate(processContext, TemplateNumber.class);
  return switch (op) {
    case PLUS -> leftNumber.add(rightNumber);
    case MINUS -> leftNumber.subtract(rightNumber);
    case TIMES -> leftNumber.multiply(rightNumber);
    case DIVIDE -> leftNumber.divide(rightNumber);
    case PERCENT -> leftNumber.modulo(rightNumber);
    default -> throw new ProcessException("unsupported operation: " + op);
  };
}

At the beginning of the method, the two operands are evaluated and, depending on the type and the operand, the string concatenation or one of the numeric functions is called.

One idea to structure this code better is to move the actual calculation to the respective model classes TemplateString and TemplateNumber.

A default method operation is provided in the TemplateObject interface for this purpose. It receives the second operand and the operator as parameters and returns the result of the operation as the return value.

default TemplateObject operation(TokenType operator, TemplateObject operand, ProcessContext context) {
  throw new ProcessException("unsupported operation: " + operator);
}

This new method greatly simplifies the evaluateToObject method.

@Override
public TemplateObject evaluateToObject(ProcessContext processContext) {
  TemplateObject leftValue = left.evaluateToObject(processContext);
  TemplateObject rightValue = right.evaluateToObject(processContext);
  return leftValue.operation(op, rightValue, processContext);
}

The two operands are evaluated and then the operation method is called on the left operand. For the string concatenation to continue to work, the TemplateString class must override the operation method.

@Override
public TemplateObject operation(TokenType operator, TemplateObject operand, ProcessContext context) {
  if (operator != TokenType.PLUS) {
    super.operation(operator, operand, context);
  }
  return concat(operand.evaluate(context, TemplateString.class));
}

The implementation adds the right operand to the left operand as a template string. If an operator other than + is used, the implementation throws an exception.

The implementation for TemplateNumber is a few lines longer, because it has to support all operators.

@Override
public TemplateObject operation(TokenType operator, TemplateObject operand, ProcessContext context) {
  TemplateNumber rightNumber = operand.evaluate(context, TemplateNumber.class);
  return switch (operator) {
    case PLUS -> add(rightNumber);
    case MINUS -> subtract(rightNumber);
    case TIMES -> multiply(rightNumber);
    case DIVIDE -> divide(rightNumber);
    case PERCENT -> modulo(rightNumber);
    default -> super.operation(operator, operand, context);
  };
}

Both implementations throw an exception if the right operand is not a TemplateString or TemplateNumber instance.

This restores the original functionality after refactoring. The new implementation is not only simpler, but also more elegant from an OOP perspective.

But the new implementation has another charming side: the operation method can be implemented by all model types, internal as well as custom one.

public class TemplatePeriod extends TemplatePrimitive<Period> {

  public TemplatePeriod(Period value) {
    super(value);
  }

  @Override
  public TemplateObject operation(Token.TokenType operator, TemplateObject operand, ProcessContext context) {
    TemplatePeriod period = operand.evaluate(context, TemplatePeriod.class);
    return switch (operator) {
      case PLUS -> new TemplatePeriod(getValue().plus(period.getValue()));
      case MINUS -> new TemplatePeriod(getValue().minus(period.getValue()));
      default ->  super.operation(operator, operand, context);
    };
  }
}

Here, the TemplatePeriod class overwrites the operation method. This means that periods can be added up or differences calculated.

${period1} + ${period2} = ${period1 + period2}

The above template fragment generates the following output for the two periods P3D and P2D.

P3D + P2D = P5D

In the next article, the relational operators <, >, and are overwritten, to make it easier to formulate expressions over non numeric model types like TemplateVersion.

1 thought on “FreshMarker Operator Overloading”

Leave a Comment