C# 8 adds a wide array of new pattern types to the pattern matching features introduced in C# 7. One of these is the 'positional pattern'. This is a recursive pattern, meaning that it contains nested patterns. It can be used to determine whether tuples match certain criteria. For example:
All the case labels here are using positional patterns with two positions. The first uses constant patterns in both positions. The next two use a constant in one position, and a discard pattern (which will match any value) in the other.
What you might not know is that this kind of pattern is not limited to tuples. If a type supplies a deconstructor, it gets to enjoy some of the same language features as tuples. Here's a type with a custom deconstructor:
Deconstruct method enables us to use the code in a deconstructing assignment:
But of more interest to the topic at hand, it also lets us apply positional patterns to our type:
Here I'm using C# 8's new switch expression, because it was a slightly more succinct option than the ordinary
switch statement I wrote in the first example, in which each
case is a
return statement. As far as its use of patterns is concerned, this looks very similar to my first example, but the interesting point with this example is that these patterns are all using my type's custom deconstructor.
Deconstruction and positional patterns work with tuples thanks to baked-in support in C#. The types that underpin tuples (a family of generic types including
ValueTuple<T1, T2, T3> etc.) don't provide a
Deconstruct method. The compiler just recognizes these types, and if you deconstruct them, it generates code that extracts the tuple members directly. And as we'll see, it generates special tuple-specific code for pattern matching too. But there's another respect in which tuples are special: in cases where C# cannot infer a pattern's input type statically, the compiler will presume that a positional pattern is looking for a tuple unless you state some other type explicitly. Look at this example:
This is almost identical to the preceding example. The only differences are the method name, and a change to the parameter type. But that second difference turns out to be critical, as becomes apparent when we try to use these two methods:
DescribeSize2 have identical bodies, you might expect each iteration of the loop to print out two copies of the same message. But in fact, we see this:
EmptyNormalExtremely narrowNormalExtremely wideNormal
DescribeSize matches in the way we want. But
DescribeSize2 has hit the fallback discard pattern every time! What's happening? Here's how the compiler has interpretted these patterns:
In the expanded
DescribeSize you can see that it's looking specifically for values of type
Size, and that it's then checking either or both of the properties. But
DescribeSize2 is altogether more weird.
Why is that what we get if we omit the type? Well when you use a positional pattern, C# will attempt to infer the type it should match. In the original
DescribeSize, it could see that the input to the
switch expression (and thus the input to each of the patterns) was the argument
Size s. But in the original
DescribeSize2, that input is of type
object, so it has nothing to go on. (The built-in
object type does not define a
Deconstruct method.) In this case, the compiler just presumes that we're looking for a tuple, so for each pattern it generates code that first checks to see whether the input implements the
ITuple interface. (This is implemented by all the various
ValueTuple types that underpin tuples.) And if that interface is present, it next checks that the tuple has the expected number of elements, before going on to evaluate each child pattern (except for the discards, which don't need any code) against its corresponding tuple element.
We could make our
Size type implement
ITuple, but that wouldn't actually help: the nested patterns are all looking for
int values but since
double values, these would fail to match. This problem doesn't arise in
DescribeSize because the compiler knows exactly which type we're using, enabling it to know that it has to implicitly promote those integer
0 constants to floating point values.
Note: If you want a positional pattern to match through a custom deconstructor even in cases where the pattern input's static type is
object, you can just include the type name specification, i.e., put
Size before the opening parenthesis of the pattern.
Until I experimented with this, I hadn't understood that C# has two completely different code generation strategies that it uses with positional patterns, depending on whether it can see a custom deconstructor on the static type of the input. It makes sense that the pattern somehow needs to know the type—otherwise it would need to support matching against any type with a deconstructor, and since anyone can write new deconstructable types, that's an open ended list. That could only really work through reflection, which would be inefficient. Even so, I hadn't previously realised that the mechanisms used with tuples are so different from the ones used with custom deconstructors.
I describe this as a pitfall in the title because with a very subtle change (the static type of the input to the pattern) you can significantly alter a pattern's meaning, possibly to something quite different from what you had in mind. (This can be a problem even in an all-tuple world: when the input is a known tuple type, the compiler may know that it has to perform implicit type conversions, which would not happen for a tuple hiding behind a variable with a static type of
object) In fact, my very first example is ambiguous: you can't know exactly what it does without knowing the static type of its input,