Challenge

Retire the visitor

20 minintermediate310 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • The visitor pattern existed to get exhaustiveness out of a language that had none
  • A sealed hierarchy plus a switch expression gives the same guarantee in a fraction of the code
  • Record patterns deconstruct nested structures in the case label
  • The two are not equivalent: visitor puts the case list in the type, switch puts it at the call site

Starter

Starter.javaOpen in playground
import java.util.*;

/**
 * CHALLENGE — 20 minutes.
 *
 * A document model written the way it had to be written before Java 17: an
 * open interface plus a visitor, because the visitor was the only way to get
 * the compiler to tell you about a case you forgot.
 *
 * It works. It is also about four times as much code as the same thing needs
 * today, and the indirection makes the render logic hard to read in one pass.
 *
 * TASKS
 *   1. Run it and read the output, so you know what to preserve.
 *   2. Seal Node, make the three implementations records, and rewrite
 *      render() as a single exhaustive switch expression with no default.
 *   3. Delete Visitor, accept(), and the visit overloads.
 *   4. Add a fourth node type (Quote, say) and confirm the compiler points
 *      straight at render().
 *   5. In a comment: name one situation where you would still choose the
 *      visitor. There is a real one — the case list lives in a different
 *      place, and sometimes that is what you want.
 */
public class Starter {

    interface Node {
        <R> R accept(Visitor<R> visitor);
    }

    interface Visitor<R> {
        R visitText(Text text);

        R visitBold(Bold bold);

        R visitBranch(Branch branch);
    }

    static final class Text implements Node {
        final String value;

        Text(String value) { this.value = value; }

        @Override public <R> R accept(Visitor<R> visitor) { return visitor.visitText(this); }
    }

    static final class Bold implements Node {
        final Node inner;

        Bold(Node inner) { this.inner = inner; }

        @Override public <R> R accept(Visitor<R> visitor) { return visitor.visitBold(this); }
    }

    static final class Branch implements Node {
        final List<Node> children;

        Branch(List<Node> children) { this.children = children; }

        @Override public <R> R accept(Visitor<R> visitor) { return visitor.visitBranch(this); }
    }

    /** Everything below here should end up as one switch expression. */
    static final class HtmlRenderer implements Visitor<String> {
        @Override
        public String visitText(Text text) {
            return text.value;
        }

        @Override
        public String visitBold(Bold bold) {
            return "<b>" + bold.inner.accept(this) + "</b>";
        }

        @Override
        public String visitBranch(Branch branch) {
            StringBuilder out = new StringBuilder();
            for (Node child : branch.children) {
                out.append(child.accept(this));
            }
            return out.toString();
        }
    }

    static String render(Node node) {
        return node.accept(new HtmlRenderer());
    }

    public static void main(String[] args) {
        Node document = new Branch(List.of(
                new Text("Interviews reward "),
                new Bold(new Text("depth")),
                new Text(", not recall.")));

        System.out.println(render(document));
    }
}

Run it locally:

cd exercises/java/modern-java/sealed-classes/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Start by sealing Node and making the three implementations records.

  2. Hint 2

    Write render() as a single switch expression with no default. Then delete the Visitor interface and see what stops compiling.

  3. Hint 3

    A Branch holds a List<Node> — recursion in the switch arm is fine and reads better than a visitor's accept/visit round trip.

  4. Hint 4

    Nested record patterns are allowed: case Branch(List<Node> kids) and, where the shape is fixed, deeper still.

Done when

  • Visitor, accept() and the visit overloads are all deleted
  • render() is one exhaustive switch with no default branch
  • Adding a fourth node type produces a compile error naming render()
  • A comment says when you would still choose a visitor over a switch

Stretch

Add a fourth node type and count the files you had to touch, then do the same thought experiment for the visitor version. Then argue the other side: name a situation where the visitor's placement of the case list — inside the type hierarchy rather than at each call site — is the better trade.

← Back to What do sealed classes enable that abstract classes cannot?