Pattern matching -- background and design goals
Brian Goetz
brian.goetz at oracle.com
Thu Apr 20 18:46:31 UTC 2017
I would like to start the discussion surrounding pattern matching with
some motivation and goals. The design space here is enormous, and
connected to so many other features, so bear with me as I try to
linearize the story. I've posted a general introductory document on
possibilities for pattern matching in Java here:
http://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html
The first question is why, as in: Why is this feature needed in Java at
all?
I'll start with the obvious reasons (explicated in greater detail in the
document): the common pattern of testing an input against multiple
candidates not only suffers from boilerplate, but, more importantly, the
tools we have for this give bugs too many places to hide, obfuscate the
essential business logic, and introduce accidental ordering constraints
that make programs slower than necessary.
There are also some less obvious reasons why this makes sense. What's
being proposed here are really two interrelated features here: better
support for compositional test-and-extract (destructuring), and better
support for handling do-exactly-one-of-these constructs. While either
could stand on its own, the two together are much stronger. Of the two,
destructuring is the richer concept.
To illustrate why destructuring is so important, allow me to make a
comparison to Lambda. Adding the ability to pass behavior as data meant
that we could build richer, more powerful libraries. We could move
responsibility for control flow from client code (external iteration) to
libraries (internal iteration), enabling libraries to expose richer,
higher-level operations. Additionally, by exposing higher-level
operations, libraries might not have to expose fiddly, state-dependent
low-level operations like Iterator.hasNext() / next(), and therefore
don't need to code iteration logic against being called in the wrong
order or in the wrong state.
Destructuring has a similar abstraction-raising characteristic. Consider
the methods Class.isArray() and Class.getComponentType(); the latter
only returns a value if the former returns true. Method pairs like this
offer the worst of both worlds; the client has to make two calls, and
has a chance to do it wrong -- and the library has to defend against the
client getting it wrong. These two methods really should be one
operation, which simplifies both the client invocation and the library
implementation:
if (c matches Class.arrayClass(Class<?> componentType)) { ... }
Further, decomposition (when done right), is compositional, enabling us
to express a compound test+extract operation as a single operation,
rather than as a sequence of operations, each of which can individually
fail. This brings client code more in line with how the problem
statement is typically stated.
Finally, this is a feature with depth; we can start with simple patterns
and simple pattern-aware language constructs, and add more sophisticated
kinds of patterns and more constructs that can use patterns as we go.
This in turn means a greater return on conceptual complexity.
If one looked only at the first few examples in the document, one might
be tempted to ask "Why not 'just' do flow typing"? Flow typing would
eliminate the (irritating) need to cast something to X right after
having done an instanceof test against X. It seems like a cheap win
(though would invariably put pressure on our handling of intersection
types), but it just doesn't go very far -- essentially it eliminates a
single increment of boilerplate and a single place where error can creep
in. This is not nothing, and it's a defensible choice, but it seems
like it would be a missed opportunity.
Similarly, one might ask "Why not 'just' do type switch?". Again, this
is a feature that seems merely "additive" rather than multiplicative; it
again eliminates some boilerplate in repeated type tests, but it doesn't
go much farther than that.
Which brings me around to a deeper observation, which we think is at the
design center of this feature: destructuring is the natural dual of
aggregation, and an obvious (and yet missing) component of
object-oriented modeling.
People often associate pattern-matching as being a "functional
programming" feature. But any language that supports aggregation (that
is, all of them) also has to address how aggregates are going to be
decomposed. Scala moved the ball forward here by showing that having a
destructuring operator ("unapply") on objects allows us to match and
destructure objects in terms of what their constructor invocation looks
like (regardless of their representation.) This is not "OO borrows FP
idea", this is "OO applies sensible programming concept in a natural OO
way."
That said, I think Scala didn't go far enough. (No criticism intended;
they moved the ball forward dramatically, there's just farther to go.)
For what are likely mostly accidental reasons, Scala only allows a
single constructor pattern per class, even though constructors
themselves can be overloaded.
Again, without criticism: I'm guessing most of the motivation for
Scala's pattern matching was drawn from the distinguished and
well-behaved case of algebraic data types, not general objects. So the
limitation of a single unapply was not really a problem; algebraic data
types don't go out of their way to hide or evolve their representation.
But we can apply destructuring to a wider range of targets, in a more OO
way.
So, to put a stake in the ground, we believe that:
- destructuring is the dual of construction, and should be treated as
a first-class construct;
- destructuring an object should be syntactically similar to how that
object is constructed.
From these principles, it follows that:
- if you can declare a ctor, you should be able to declare an instance
dtor;
- instance dtors should support the same overloading and inheritance
behaviors as ctors;
- if you can declare a static factory, you should be able to declare a
static dtor.
(The syntax of how dtors are declared is a topic for another day.)
If an object is created with a constructor:
x = new Foo(a, b)
ideally, it should be destructurable with an instance dtor:
if (x matches Foo(var a, var b))
Note the syntactic similarity; this is not accidental. Acquiring dtors
isn't automatic (though data classes can automatically acquire both
ctors and dtors), but it should be easy and straightforward to declare
such a dtor.
If dtors are overloaded, type information at the use site may be needed
to disambiguate. So if we provide dtors Foo(int y) and Foo(String y), then
if (x matches Foo(var y))
is ambiguous, but by replacing the var-pattern with a an explicit
type-test pattern, the compiler can properly select:
if (x matches Foo(int x))
Similarly, if an object is created with a static factory:
x = Foo.of(a, b)
it should be destructurable with static dtors:
if (x matches Foo.of(var a, var b))
So, for example, since Optional has two factories:
x = Optional.of(y)
x = Optional.empty()
one can discriminate via:
case Optional.of(var y):
case Optional.empty():
Why does this matter? By way of example, we often prefer factory
methods to constructors. A factory has more flexibility than a ctor; it
can return different subtypes in different conditions, and it can change
its implementation over time to return different subtypes. That a
factory commits only to an abstract type:
static Foo redFoo() {
return new MyPrivateFooImplementation();
}
is great for the implementor, but less so for the client -- once the
caller gets a Foo from the factory, it has no way of asking "is this a
red Foo?", unless we clutter the API with ad-hoc methods like
isRedFoo(). This means that APIs with multiple factories have some bad
choices: either live with the opacity, which can limit what the client
can do (sometimes this is good, but sometimes it's not), add lots of new
API points for destructuring, or expose the intermediate types.
Having a complementary dtor allows the implementation to hide its
details while still providing a degree of reversibility. Without
venturing near the toxic syntax bikeshed:
static ... matcher ... Foo ... redFoo(...) { ... }
the library author can render the factory reversible without exposing
the implementation details.
x = redFoo();
...
if (x matches redFoo()) { ... }
The implementation of redFoo() remains hidden, but because the dtor is
part of the same library as the factory, it can still help a client
reconstruct the object's state (or the parts that it wants to), without
compromising the encapsulation. (In just the last few weeks of thinking
about this, once you start to see this pattern, you can't look at an API
and not see it.)
Again, this is mostly something to file in the background and think
about the consequences of -- until we start to circulate a JEP for
pattern matching, this is still technically outside the Amber charter.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://mail.openjdk.java.net/pipermail/amber-spec-experts/attachments/20170420/53c060a4/attachment-0001.html>
More information about the amber-spec-experts
mailing list