Design Patterns Are Temporary, Language Features Are Forever
Design Patterns Are Temporary, Language Features Are Forever #
Excerpt #
Design patterns are these cool things that make you go ‘hell yeah’ because they make a certain problem easier to deal with. Whether it’s applicable to all languages, or due to a language constraint, they’re pretty nice. Of course, some people go crazy with them rather than doing the simple thing that works. Other times, you’ll naturally do a design pattern without even knowing about it…
Design patterns are these cool things that make you go ‘hell yeah’ because they make a certain problem easier to deal with. Whether it’s applicable to all languages, or due to a language constraint, they’re pretty nice. Of course, some people go crazy with them rather than doing the simple thing that works. Other times, you’ll naturally do a design pattern without even knowing about it.
Every once in a while, I like to go through design patterns that are ‘commonly applicable’ to Java as a thought experiment to see:
- Have I seen this in a codebase before?
- Does this look like something that would’ve helped me with before?
- Is this a solution to a problem which doesn’t exist?
and so on.
A few times I have come across patterns such as ‘visitor’ and never truly grasped what it was solving, or why I would use it. Young Joel was unsure, probably due to poor explanations, which I think can be a big problem when it comes to concepts today. In jiujitsu, it’s extremely evident when a concept or a technique is practical and not bullshido (if you go to a gym which competes at least in my anecdotal experience). But for the visitor pattern, explanations to me have always been:
>“here’s how the pattern works”
>just randomly logs stuff
Not exactly helpful presenting how to use ‘x’ with an unrealistic use case.
When my son was born, I had decided to learn Rust since I was sleepless most of the time and I wanted to keep myself sharp during parental leave.
Can you imagine how mind blown I was when I saw this??:
let number = 13; match number { 1 => println!("One!"), 2 | 3 | 5 | 7 | 11 => println!("This is a prime"), 13..=19 => println!("A teen"), _ => println!("catch leftovers") }
And that’s how Young Joel discovered pattern matching (and further exploration into FP (OCaml btw)).
After some time, I went back to the visitor pattern out of curiosity again to see if I could try to understand it once more. Now knowing about pattern matching, this is how I came to realise that the visitor pattern was pattern matching in an OO way. Of course, it’s not 1:1 (I think), but it gives a familiar feel. I wouldn’t try to pretend I understand completely the key details/downfalls of visitors, but now I had a better idea on how to use them.
At work I write a mix of Java, JavaScript, and Rust. Majority of my time is Java, but I don’t like staying on a particular version for long because that’s a major L and one of the worst characteristics of Java culture today in enterprise land.
The release of Java 21 saw a lot of fantastic features, one of them being: Pattern Matching for switch.
With pattern matching in switches and sealed types, the visitor pattern to me looks like a thing of the past (which is good!). I think if a language can improve in such a way, then the language itself becomes more enjoyable to use. For example, you can do this!:
Here is the Rust version:
enum WebEvent { PageLoad, PageUnload, KeyPress(char), Paste(String), Click { x: i64, y: i64 }, } fn inspect(event: WebEvent) { match event { WebEvent::PageLoad => println!("page loaded"), WebEvent::PageUnload => println!("page unloaded"), WebEvent::KeyPress(c) => println!("pressed '{}'.", c), WebEvent::Paste(s) => println!("pasted \"{}\".", s), WebEvent::Click { x, y } => { println!("clicked at x={}, y={}.", x, y); }, } }
Sealed types, records, switch expressions, switch pattern matching.. the list goes on. I really love modern Java, and the feature delivery is quality. Compared to Java <17 it feels like an entirely different language.
Coming back to the visitor pattern, here is some sample code I wrote during my son’s nap times to get a feel on the pattern.
- We model a filesystem where there is a Node which is either a BlobNode or a TreeNode.
- There are two use cases: adding or deleting a BlobNode (the visitor pattern really shines because of how you can implement this)
package visitor; import java.util.ArrayList; import java.util.List; abstract class Node { public final String name; public Node(String name) { this.name = name; } abstract public void accept(NodeVisitor visitor); } class BlobNode extends Node { public BlobNode(String name) { super(name); } @Override public void accept(NodeVisitor visitor) { visitor.visit(this); } } class TreeNode extends Node { public List<Node> children; public TreeNode(String name, List<Node> children) { super(name); this.children = children; } @Override public void accept(NodeVisitor visitor) { visitor.visit(this); children.forEach((c) -> c.accept(visitor)); } } interface NodeVisitor { void visit(BlobNode node); void visit(TreeNode node); } class BlobNodeVisitor implements NodeVisitor { @Override public void visit(BlobNode node) { System.out.println("BlobNode: " + node.name); } @Override public void visit(TreeNode node) { } } class TreeNodeVisitor implements NodeVisitor { @Override public void visit(BlobNode node) { } @Override public void visit(TreeNode node) { System.out.println("TreeNode: " + node.name); } } class NodeAddVisitor implements NodeVisitor { private final String name; private final String directory; public NodeAddVisitor(String directory, String filename) { this.name = filename; this.directory = directory; } @Override public void visit(BlobNode node) { } @Override public void visit(TreeNode node) { if (node.name.equals(directory)) { System.out.println("\nAdding new file to '" + directory + "': " + name); node.children.add(new BlobNode(name)); } } } class NodeDeleteVisitor implements NodeVisitor { private final String name; public NodeDeleteVisitor(String filename) { this.name = filename; } @Override public void visit(BlobNode node) { } @Override public void visit(TreeNode node) { // NOOOOOOOOOOOOOOOOO TAKE YOUR FP OUT OF MY OOP // REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE node.children = node.children.stream() .filter(n -> !n.name.equals(name)) .toList(); } }
Here’s it in action:
Honestly it’s pretty cool you can achieve this, but still, for me:
- Lots of code indirection (more use cases or concrete implementations might be harder to comprehend)
- Double dispatch
- New use case requires more ‘visit’ implementations (visit kinda sucks as a term as well)
The only valid point I think is double dispatch, the rest are just opinions.
Here’s the modern Java version:
package visitor; import java.util.ArrayList; import java.util.List; import visitor.FileNode.BlobNode; import visitor.FileNode.TreeNode; import visitor.FileNodeAction.Add; import visitor.FileNodeAction.Delete; sealed interface FileNode { String name(); record BlobNode(String name) implements FileNode {}; record TreeNode(String name, List<FileNode> children) implements FileNode {}; } sealed interface FileNodeAction { String directory(); record Add(FileNode node, String directory) implements FileNodeAction {}; record Delete(FileNode node, String directory) implements FileNodeAction {}; } public class NewStuff { public NewStuff() {}; private void modifyFileNodes(List<FileNode> nodes, FileNodeAction action) { nodes.forEach(n -> { if (n instanceof TreeNode tn) { if (action.directory().equals(tn.name())) { switch (action) { case FileNodeAction.Add a -> tn.children().add(a.node()); case FileNodeAction.Delete d -> tn.children().remove(d.node()); } } else { modifyFileNodes(tn.children(), action); } } }); } //... }
This even fits entirely on my screen:
In this example, it’s a lot nicer to follow and shifts the idea on being explicitly ‘OO’ to being more on the data itself.
Here’s a side by side:
Visitors feel like an esoteric pattern I think, but now I have more appreciation for them.
Hopefully I’ll never have to work on old Java projects but I got something up my sleeve now if I must.