Dixin's Blog

Coding.

Functional C# (15) Pattern matching

[LINQ via C#] - [Functional C#]

Pattern matching is a common feature in functional languages. C# 7.0 introduces basic pattern matching, including constant value as pattern and type as pattern.

Is expression

Before C# 7.0, is operator is used in the (instance is Type) expression to test whether the instance is compatible with the specified type. Since C# 7.0, is can be used test for constant pattern, like constant, null, enumeration etc:

internal static partial class PatternMatching
{
    internal static void IsConstantValue(object @object)
    {
        if (@object is int.MinValue)
        {
            @object.WriteLine();
        }
        if (@object is null)
        {
            @object.WriteLine();
        }
        if (@object is DayOfWeek.Monday)
        {
            @object.WriteLine();
        }
    }
}

The above is expressions are simply compiled to object.Equal calls, where the constant is the first argument and the tested instance is the second:

internal static void CompiledIsConstantValue(object @object)
{
    if (object.Equals(int.MinValue, @object))
    {
        @object.WriteLine();
    }
    if (object.Equals(null, @object))
    {
        @object.WriteLine();
    }
    if (object.Equals(DayOfWeek.Monday, @object))
    {
        @object.WriteLine();
    }
}

Internally, object.Equals first does a few checks, then it could call the first argument’s Equals instance method:

namespace System
{
    [Serializable]
    public class Object
    {
        public static bool Equals(object objA, object objB) =>
            objA == objB || (objA != null && objB != null && objA.Equals(objB));

        // Other members.
    }
}

The early versions of C# 7.0 compiler takes the tested instance as the first argument of object.Equals call, and the constant as the second argument. This can cause some issues. In this way, object.Equals could call the tested instance’s Equals instance method, where the tested instance can be any type, and its Equals instance method can be arbitrary custom implementation. In C# 7.0 GA release, this was fixed, so that the constant becomes the first argument of object.Equals, so that the constant’s Equals instance method, which has more predictable behavior, could be called.

The pattern can also be a type, followed by a pattern variable of that type:

internal static void IsReferenceType(object @object)
{
    if (@object is Uri uri)
    {
        uri.AbsoluteUri.WriteLine();
    }
}

The type in above pattern is a reference type (class), so the is expression is compiled to as type conversion and null check:

internal static void CompiledIsReferenceType(object @object)
{
    Uri uri = @object as Uri;
    if (uri != null)
    {
        uri.AbsoluteUri.WriteLine();
    }
}

This syntactic sugar also works for value type:

internal static void IsValueType(object @object)
{
    if (@object is DateTime dateTime)
    {
        dateTime.ToString("o").WriteLine();
    }
}

The as operator cannot be used for value type. Type cast (ValueType)instance can work, but when the cast fails it throws exception. So pattern matching for value type is compiled to nullable value type conversion with as operator, and HasValue check:

internal static void CompiledIsValueType(object @object)
{
    DateTime? nullableDateTime = @object as DateTime?;
    DateTime dateTime = nullableDateTime.GetValueOrDefault();
    if (nullableDateTime.HasValue)
    {
        dateTime.ToString("o").WriteLine();
    }
}

It is also common to use pattern matching with additional conditions:

internal static void IsWithCondition(object @object)
{
    if (@object is string @string && TimeSpan.TryParse(@string, out TimeSpan timeSpan))
    {
        timeSpan.TotalMilliseconds.WriteLine();
    }
}

After compilation, the condition id additional to the null check:

internal static void CompiledIsWithCondition(object @object)
{
    string @string = @object as string;
    if (@string != null && TimeSpan.TryParse(@string, out TimeSpan timeSpan))
    {
        timeSpan.TotalMilliseconds.WriteLine();
    }
}

The previously discussed Data type override the Equals method of object:

internal partial class Data : IEquatable<Data>
{
    public override bool Equals(object obj)
    {
        return obj is Data && this.Equals((Data)obj);
    }

    public bool Equals(Data other) // Member of IEquatable<T>.
    {
        return this.value == other.value;
    }
}

With the traditional syntax, the object parameter’s type was detected twice. In .NET Framework, the Code Analysis tool issues warning CA1800 for this: 'obj', a parameter, is cast to type 'Data' multiple times in method 'Data.Equals(object)'. Cache the result of the 'as' operator or direct cast in order to eliminate the redundant castclass instruction. Now with the new syntax, this can be simplified as:

internal partial class Data : IEquatable<Data>
{
    public override bool Equals(object obj) => 
        obj is Data data && this.Equals(data);
}

And the var keyword can be the pattern of any type:

internal static void IsType(object @object)
{
    if (@object is var match)
    {
        object.ReferenceEquals(@object, match).WriteLine();
    }
}

Since the var pattern matching always works, it is compiled to true in debug build:

internal static void CompiledIsAnyType(object @object)
{
    object match = @object;
    if (true)
    {
        object.ReferenceEquals(@object, match).WriteLine();
    }
}

In release build, the above if (true) test is simply removed.

Switch statement

Before C# 7.0, the switch statement only supports string, integral types (like bool, byte, char, int, long, etc.), and enumeration; and the case label only supports constant value. Since C# 7.0, switch supports any type and case label supports pattern matching for either constant value or type. The additional condition for the pattern matching can be specified with a when clause. The following example tries to convert object to DateTime:

internal static DateTime ToDateTime(object @object)
{
    switch (@object)
    {
        // Match constant @object.
        case null:
            throw new ArgumentNullException(nameof(@object));
        // Match value type.
        case DateTime dateTIme:
            return dateTIme;
        // Match value type with condition.
        case long ticks when ticks >= 0:
            return new DateTime(ticks);
        // Match reference type with condition.
        case string @string when DateTime.TryParse(@string, out DateTime dateTime):
            return dateTime;
        // Match reference type with condition.
        case int[] date when date.Length == 3 && date[0] >= 0 && date[1] >= 0 && date[2] >= 0:
            return new DateTime(year: date[0], month: date[1], day: date[2]);
        // Match reference type.
        case IConvertible convertible:
            return convertible.ToDateTime(null);
        case var _: // default:
            throw new ArgumentOutOfRangeException(nameof(@object));
    }
}

The last section with with any type pattern is equivalent to the default section, because it always matches. Each pattern matching is compiled in the similar ways as is expression:

internal static DateTime CompiledToDateTime(object @object)
{
    // case null:
    if (@object == null)
    {
        throw new ArgumentNullException("@object");
    }

    // case DateTime dateTIme:
    DateTime? nullableDateTime = @object as DateTime?;
    DateTime dateTime = nullableDateTime.GetValueOrDefault();
    if (nullableDateTime.HasValue)
    {
        return dateTime;
    }

    // case long ticks
    long? nullableInt64 = @object as long?;
    long ticks = nullableInt64.GetValueOrDefault();
    // when ticks >= 0:
    if (nullableInt64.HasValue && ticks >= 0L)
    {
        return new DateTime(ticks);
    }

    // case string text 
    string @string = @object as string;
    // when DateTime.TryParse(text, out DateTime dateTime):
    if (@string != null && DateTime.TryParse(@string, out DateTime parsedDateTime))
    {
        return parsedDateTime;
    }

    // case int[] date
    int[] date = @object as int[];
    // when date.Length == 3 && date[0] >= 0 && date[1] >= 0 && date[2] >= 0:
    if (date != null && date.Length == 3 && date[0] >= 0 && date[1] >= 0 && date[2] >= 0)
    {
        return new DateTime(date[0], date[1], date[2]);
    }

    // case IConvertible convertible:
    IConvertible convertible = @object as IConvertible;
    if (convertible != null)
    {
        return convertible.ToDateTime(null);
    }

    // case var _:
    // or
    // default:
    throw new ArgumentOutOfRangeException("@object");
}

1 Comment

Add a Comment

As it will appear on the website

Not displayed

Your website