C# 8 Positional Patterns Custom Deconstructor Pitfall
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:
switch (v)
{
case (0, 0): return "Origin";
case (0, _): return "On Y axis";
case (_, 0): return "On X axis";
default: return "Somewhere";
}
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:
public readonly struct Size
{
public Size(double w, double h)
{
W = w;
H = h;
}
public void Deconstruct(out double w, out double h)
{
w = W;
h = H;
}
public double W { get; }
public double H { get; }
}
That Deconstruct
method enables us to use the code in a deconstructing assignment:
public static double GetDiagonalLength(Size s)
{
(double w, double h) = s;
return Math.Sqrt(w * w + h * h);
}
But of more interest to the topic at hand, it also lets us apply positional patterns to our type:
static string DescribeSize(Size s) => s switch
{
(0, 0) => "Empty",
(0, _) => "Extremely narrow",
(_, 0) => "Extremely wide",
_ => "Normal"
};
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<T>
, ValueTuple<T1, T2>
, 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:
static string DescribeSize2(object s) => s switch
{
(0, 0) => "Empty",
(0, _) => "Extremely narrow",
(_, 0) => "Extremely wide",
_ => "Normal"
};
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:
Size[] sizes = { new Size(0, 0), new Size(0, 10), new Size(10, 0) };
foreach (Size size in sizes)
{
Console.WriteLine(DescribeSize(size));
Console.WriteLine(DescribeSize2(size));
Console.WriteLine();
}
Since DescribeSize
and 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:
static string DescribeSize(Size s) => s switch
{
Size s when s.W == 0.0 && s.H == 0.0 => "Empty",
Size s when s.W == 0.0 => "Extremely narrow",
Size s when s.H == 0.0 => "Extremely wide",
_ => "Normal"
};
static string DescribeSize2(object s) => s switch
{
ITuple t when t.Length == 2 && t[0] is int t0 && t0 == 0 && t[1] is int t1 && t1 == 0 => "Empty",
ITuple t when t.Length == 2 && t[0] is int t0 && t0 == 0 => "Extremely narrow",
ITuple t when t.Length == 2 && t[1] is int t1 && t1 == 0 => "Extremely wide",
_ => "Normal"
};
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 Size
contains 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, v
.