ExerciseChallenge
Challenge
Retire the visitor
20 minintermediate3–10 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 StarterHints
Hint 1
Start by sealing Node and making the three implementations records.
Hint 2
Write render() as a single switch expression with no default. Then delete the Visitor interface and see what stops compiling.
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.
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?