Friday, July 24, 2009

Does pattern matching break encapsulation?

Scala attempts to unify functional and object-oriented programming. One of the concepts that Scala borrows from functional programming is pattern matching.

Pattern matching can be used anywhere you might use switch/case:

def fibonnaci(n: Int): Int = n match {
case 0 => 0
case 1 => 1
case n => f(n-1) + f(n-2)
}

You can also use pattern matching to "break open" case classes and access the parameters that were used to construct it:

trait Point
case class CartesianPoint(x: Double, y: Double) extends Point

def isSpecial(point: Point): Boolean = point match {
case CartesianPoint(x, y) if x*x + y*y > 25.0 => true
case _ => false
}

One criticism of pattern matching is that it violates encapsulation, the object-oriented principle that says the interface of a class should be independent of its implementation details. If we change the implementation details of CartesianPoint, we'll break the pattern matching statements that depended on those details.

Extractors

Scala relies on extractors to make pattern matching more object-oriented. Extractors define a "view" on a particular kind of object. This allows Scala code that uses pattern matching to maintain encapsulation: just provide a "view" into your new implementation that matches the old one.

Suppose we wanted to implement Points in terms of polar coordinates instead of Cartesian coordinates:

trait Point
case class PolarPoint(radius: Double, theta: Double) extends Point

object CartesianPoint {
def apply(x: Double, y: Double): Point = {
val radius = Math.sqrt(x*x + y*y)
val theta =
if (x == 0.0 && y == 0.0) 0.0
else if (x == 0.0 && y > 0.0) Math.Pi/2
else if (x == 0.0 && y < 0.0) 3*Math.Pi/2
else if (x > 0 && y >= 0.0) Math.atan(y/x)
else if (x > 0 && y < 0.0) Math.atan(y/x) + 2*Math.Pi
else Math.atan(y/x) + Math.Pi
PolarPoint(radius, theta)
}
def unapply(point: Point) = point match {
case PolarPoint(r, th) =>
Some((r*Math.cos(th), r*Math.sin(th)))
case _ =>
None
}
}

The apply method on CartesianPoint specifies how to construct a PolarPoint from a pair of (x, y) coordinates. Likewise, the unapply method specifies how to construct a pair of (x, y) coordinates from a PolarPoint. It is this second method which is the extractor. It lets us pattern match on PolarPoints as if they were CartesianPoints. In particular, the isSpecial method, defined above, can be used unchanged with our new, polar, implementation of points. Extractors let us keep encapsulation even when using pattern matching.

A catch (or two)

Is that the end of the story of pattern matching and encapsulation? Unfortunately, no. There are two ways in which pattern matching can break encapsulation. The first is through sealed classes, the second is through singletons.

A sealed class can only be subclassed within the same file, so the compiler knows statically all the possible subclasses of that class. This lets the Scala compiler check whether a pattern match on a sealed type is exhaustive. If you forget to check one of the possible subclasses, the Scala compiler will warn you that your match is not exhaustive. Consider the List class. List has two subclasses, :: (cons) and Nil. Cons represents the non-empty List, with a head and a tail, while Nil represents the empty List, with no head and no tail. If we try to define a method that matches on a List but forgets to match the Nil case:

def head[T](xs: List[T]): T = xs match {
case hd :: tl => hd
}

Then we get a reprimand from the compiler: warning: match is not exhaustive! missing combination Nil.

If we define our own "cons" extractor, we don't get the same warning:

object :/: {
def unapply[T](xs: List[T]) =
if (xs.isEmpty) None
else Some(xs.head, xs.tail)
}

def customHead[T](xs: List[T]): T = xs match {
case hd :/: tl => hd
}

Now, this is a relatively minor problem. The warning is nice to have, but it's not essential. The bigger problem is matching on singletons. The empty List, Nil, is an implementation detail we can never change. We can simulate the :: case class with an extractor (as above with :/:, but there is simply no way we can simulate Nil with an extractor. The best we can do requires us to match on MyNil(), because matching on MyNil would match on the object, not it's unapply method.

object MyNil {
def unapply[T](xs: List[T]): Boolean =
if (xs.isEmpty) true
else false
}

def empty[T](xs: List[T]): Boolean = xs match {
case MyNil() => true
case _ => false
}

Conclusion

I don't want people to get me wrong: I love pattern matching. Practically every day I'm grateful that Scala has it. It makes certain kinds of programming problems much easier to solve. (Click here for a StackOverflow question where I discuss the kinds of problems that benefit from pattern matching vs OO-style inheritance and virtual method dispatch.) However, library designers should be conscious of the implementation details they might be exposing if they're letting their users pattern match on sealed classes or singletons. Pattern matching can be very poweful, but as with many things in Scala: with great power comes great responsibility.
blog comments powered by Disqus