The "visitor pattern" is one of those design patterns when reading in the abstract is hard to follow. Before we look at how it might be useful in Scala, let's first look at what the original pattern was and what problem it was trying to solve.
What if you have a handful of classes that share some common ancestry. There must be behavior on all of them to do something but what if that behavior is outside the scope of the class? This would be true when the behavior is extremely specific to one use case and thus would just pollute the class's implementation.
Scenario
Say you're writing a media player. Most people want the basics: play, pause, skip song, playlist. However you also want to have a plugin that allows DJ's to calculate the BPM of a song. We don't want to clutter up the main player with behavior (BPM determination) that isn't core to the product.
What is a way this problem could be solved? As you probably guessed from the title and introduction the visitor pattern is one such approach.
Vanilla Visitor
We start by defining a visitor. A visitor exposes one visit
method for every type the visitor will be used on. This means that fundamentally the visitor pattern is based on method overloading. In our simple case let's assume we have an Audio
and a Video
class which both extend trait Media
.
1
2
3
4
trait MediaVisitor {
def visit(audio: Audio): Any
def visit(video: Video): Any
}
Ignore the Any
return type. We'll come back to that in a second. Next, every class in implements an accept
method that takes a visitor as it's argument.
1
2
3
4
5
6
7
class Audio extends Media {
def accept(visitor: MediaVisitor) = visitor.visit(this)
}
class Video extends Media {
def accept(visitor: MediaVisitor) = visitor.visit(this)
}
At this point any behavior we wanted to implement needs to implement the MediaVisitor
method. Keeping with our scenario, we could have a BPM visitor.
1
2
3
4
5
6
7
8
9
10
object BPMCalculator extends MediaVisitor {
def visit(audio: Audio) = {
// ... do some magic to determine BPM from audio stream ...
}
def visit(video: Video) = {
// ... extract audio stream
// ... do some magic to determine BPM from audio stream ...
}
}
To find the BPM of a song we could then pass BPMCalculator
to an instance of either Audio
or Video
.
1
2
val song = new Song
val bpm = song.accept(BPMCalculator).asInstanceOf[Int]
Improvements with Scala
The asInstanceOf[Int]
dangling off the end there is ugly. And it's a bad coding practice. It means we haven't "proven" our types to the compiler and so we must cheat and tell the compiler that it's really that thing.
One thing we could start down the road of is changing the MediaVisitor
trait to allow the implementation to define the return type.
1
2
3
4
trait MediaVisitor[A] {
def visit(audio: Audio): A
def visit(video: Video): A
}
Our class implementations of visit
can now be updated too.
1
2
3
4
5
6
7
class Audio extends Media {
def accept[A](visitor: MediaVisitor[A]): A = visitor.visit(this)
}
class Video extends Media {
def accept[A](visitor: MediaVisitor[A]): A = visitor.visit(this)
}
When we invoke accept
now we can explicitly specify the return type.
1
val bpm = song.accept[Int](BPMCalculator)
However Scala's type inference can actually figure it out, which means we can totally remove it leaving us with:
1
2
val song = new Song
val bpm = song.accept(BPMCalculator)
This is a decent improvement. The visitor pattern in Scala now works as intended.
Using Native Language Features
Scala has other, probably better ways to solve this. Implicit classes effectively allow extension methods on a class by combining a new class with an implicit def
. Say we kept our BPMCalculator
object but turned it into a class and renamed the method from visit
to calculateBpm
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
object BPMCalculator {
implicit class BPMCalculatorAudio(audio: Audio) {
def calculateBpm = {
// ... do some magic to determine BPM from audio stream ...
}
}
implicit class BPMCalculatorVideo(video: Video) {}
def calculateBpm = {
// ... extract audio stream
// ... do some magic to determine BPM from audio stream ...
}
}
}
This implicit class
can live anywhere in the code, but when imported (import BPMCalculator._
) makes the method calculateBpm
available on Audio
and Video
.
Ultimately both the visitor pattern and the implicit class striving to implement the Open/Close Principle. Scala makes this possible wholey within the language.