SoFunction
Updated on 2025-03-06

C# Pattern Matching Complete Guide

Preface

Since the introduction of declaration patterns and constant pattern matching in C# 7.0 version in 2017, until C# 11 in 2022, the last section list pattern and slice pattern matching have been completed, and the originally planned pattern matching content has basically been completed.

The next step in C#’s pattern matching is to support active patterns, which will be introduced at the end of this article. Before introducing future pattern matching plans, this article’s topic is for pattern matching ending C# 11.(No)A complete guide, hope it will be helpful to all developers to improve the efficiency, readability and quality of code writing.

Pattern matching

To use pattern matching, you must first understand what a pattern is. When using regular expressions to match strings, the regular expression itself is a pattern, and the process of matching strings using this regular expression is pattern matching. The same is true in code. The process of matching objects with a certain pattern is pattern matching.

There are many supported modes in C# 11, including:

  • Declaration pattern
  • Type pattern
  • Constant pattern
  • Relational pattern
  • Logical pattern
  • Property pattern
  • Positional pattern
  • var pattern
  • discard pattern
  • List pattern
  • slice pattern

Among them, many patterns support recursion, which means that patterns can be nested to achieve more powerful matching functions.

Pattern matching can be passedswitchExpressions can also be used in ordinaryswitchIn the statementcaseYou can also use it inifPassed in the conditionisCome to use. This article mainlyswitchUse pattern matching in expressions.

Then, let’s introduce these patterns.

Example: Expression Calculator

To introduce pattern matching more intuitively, we will use pattern matching to write an expression calculator.

In order to write an expression calculator, first we need to abstract the expression:

public abstract partial class Expr<T> where T : IBinaryNumber<T>
{
    public abstract T Eval(params (string Name, T Value)[] args);
}

We use the aboveExpr<T>to represent an expression whereTis the type of operand, and then further divide the expression into constant expressionsConstantExpr, parameter expressionParameterExpr, unary expressionUnaryExpr, binary expressionBinaryExprand ternary expressionsTernaryExpr. Finally provide oneEvalMethod, used to calculate the value of an expression, the method can pass in aargsto provide the parameters required for expression calculation.

With one and binary expressions, operators are naturally needed, such as addition, subtraction, multiplication, division, etc., we also define them at the same time.OperatorTo represent operators:

public abstract record Operator
{
    public record UnaryOperator(Operators Operator) : Operator;
    public record BinaryOperator(BinaryOperators Operator) : Operator;
}

Then set the allowed operators, where the first three are unary operators and the following are binary operators:

public enum Operators
{
    [Description("~")] Inv, [Description("-")] Min, [Description("!")] LogicalNot,
    [Description("+")] Add, [Description("-")] Sub, [Description("*")] Mul, [Description("/")] Div,
    [Description("&")] And, [Description("|")] Or, [Description("^")] Xor,
    [Description("==")] Eq, [Description("!=")] Ne,
    [Description(">")] Gt, [Description("<")] Lt, [Description(">=")] Ge, [Description("<=")] Le,
    [Description("&&")] LogicalAnd, [Description("||")] LogicalOr,
}

You may be curiousTHow can the operation of logic and or non-logic? Regarding this, we directly use0Come to representfalse,No0representtrue

Next is the time to implement various expressions separately!

Constant expression

A constant expression is simple, it holds a constant value, so it only needs to store the user-provided value in the constructor. ItsEvalThe implementation also requires simply returning the stored value:

public abstract partial class Expr<T> where T : IBinaryNumber<T>
{
    public class ConstantExpr : Expr<T>
    {
        public ConstantExpr(T value) => Value = value;

        public T Value { get; }
        public void Deconstruct(out T value) => value = Value;
        public override T Eval(params (string Name, T Value)[] args) => Value;
    }
}

Parameter expressions

Parameter expressions are used to define parameters during expression calculation, allowing users to execute the expressionEvalParameters are passed when calculating the result, so only the parameter name is needed. ItsEvalThe implementation needs to be based on the parameter nameargsFind the corresponding parameter values ​​in:

public abstract partial class Expr&lt;T&gt; where T : IBinaryNumber&lt;T&gt;
{
    public class ParameterExpr : Expr&lt;T&gt;
    {
        public ParameterExpr(string name) =&gt; Name = name;

        public string Name { get; }
        public void Deconstruct(out string name) =&gt; name = Name;
        // Pattern matching of args        public override T Eval(params (string Name, T Value)[] args) =&gt; args switch
        {
            // If args has at least one element, then we take out the first element and save it as (name, value).            // Then determine whether name is the same as the parameter name stored in this parameter expression.            // Return value if the same, otherwise use args to remove the remaining parameters of the first element and continue to match.            [var (name, value), .. var tail] =&gt; name == Name ? value : Eval(tail),
            // If args is an empty list, it means that no parameters with the same name and Name were found in args, and an exception was thrown            [] =&gt; throw new InvalidOperationException($"Expected an argument named {Name}.")
        };
    }
}

Pattern matching will be matched from top to bottom until the match is successful.

You may be curious in the above code[var (name, value), .. var tail]What is the mode? This mode is overall a list mode, and the declaration mode, position mode and slice mode are used in the list mode. For example:

  • []: Match an empty list.[1, _, 3]: Match a list with length 3 and the beginning and end elements are 1 and 3 respectively. in_is a discard mode, indicating any element.
  • [_, .., 3]: Matching a last element is 3, and 3 is not a list of the first element. in..It is a slice pattern, which means any slice.
  • [1, ..var tail]: Match a list with the first element 1 and assign a slice of the element other than the first element totail. invar tailyesvarPattern, used to assign matching results to variables.
  • [var head, ..var tail]: Match a list and assign its first element tohead, the remaining elements are assigned totail, there are no elements in this slice.
  • [var (name, value), ..var tail]: Match a list and assign its first element to(name, value), the remaining elements are assigned totail, there are no elements in this slice. in(name, value)It is a position mode, which is used to assign the deconstruction result of the first element to the position according to the position.nameandvalue, can also be written as(var name, var value)

Unary expressions

Unary expressions are used to process calculations with only one operand, such as non, inverse, etc.

public abstract partial class Expr&lt;T&gt; where T : IBinaryNumber&lt;T&gt;
{
    public class UnaryExpr : Expr&lt;T&gt;
    {
        public UnaryExpr(UnaryOperator op, Expr&lt;T&gt; expr) =&gt; (Op, Expr) = (op, expr);

        public UnaryOperator Op { get; }
        public Expr&lt;T&gt; Expr { get; }
        public void Deconstruct(out UnaryOperator op, out Expr&lt;T&gt; expr) =&gt; (op, expr) = (Op, Expr);
        // Pattern matching for Op        public override T Eval(params (string Name, T Value)[] args) =&gt; Op switch
        {
            // If Op is UnaryOperator, the deconstruction result is assigned to op, and then the op is matched. op is an enum, and the enum values ​​in .NET are all integers            UnaryOperator(var op) =&gt; op switch
            {
                // If op is                 =&gt; ~(args),
                // If op is                 =&gt; -(args),
                // If op is                 =&gt; (args) ==  ?  : ,
                // If the value of op is greater than LogicalNot or less than 0, it means it is not a unary operator                &gt;  or &lt; 0 =&gt; throw new InvalidOperationException($"Expected an unary operator, but got {op}.")
            },
            // If Op is not UnaryOperator            _ =&gt; throw new InvalidOperationException("Expected an unary operator.")
        };
    }
}

In the above code, the first thing that C# tuples can be used as lvalues ​​is used, and the assignment of the construction method and the deconstruction method is completed using one line of code:(Op, Expr) = (op, expr)and(op, expr) = (Op, Expr). If you are curious whether you can use this feature to exchange multiple variables, the answer is yes!

existEvalIn the first way, the type mode, position mode and declaration mode are combined intoUnaryOperator(var op), indicates a matchUnaryOperatorType and can deconstruct an element. If it matches, the deconstructed element will be assigned toop

Then we continue to deconstructopFor matching, constant mode is used here, for exampleUsed to matchopIs it true. Constant mode can match objects using various constants.

Here> and< 0It is a relational pattern, which is used to match greater thanThe value and less than0finger. Then use the logical modeorCombining two patterns to represent or relationship. In addition to the logical modeorIn addition, there isandandnot

Since we exhaustively enumerated all the unary operators in the enum, we can also> or < 0Switch to discard mode_Or var modevar foo, both are used to match any thing, but the former is directly discarded after matching, while the latter declares a variablefooPut the matching value inside:

op switch
{
    // ...
    _ => throw new InvalidOperationException($"Expected an unary operator, but got {op}.")
}

or

op switch
{
    // ...
    var foo => throw new InvalidOperationException($"Expected an unary operator, but got {foo}.")
}

Binary expressions

Binary expressions are used to represent expressions with two operands. With experience in writing one-part expressions, binary expressions can be prepared in the same way.

public abstract partial class Expr<T> where T : IBinaryNumber<T>
{
    public class BinaryExpr : Expr<T>
    {
        public BinaryExpr(BinaryOperator op, Expr<T> left, Expr<T> right) => (Op, Left, Right) = (op, left, right);

        public BinaryOperator Op { get; }
        public Expr<T> Left { get; }
        public Expr<T> Right { get; }
        public void Deconstruct(out BinaryOperator op, out Expr<T> left, out Expr<T> right) => (op, left, right) = (Op, Left, Right);

        public override T Eval(params (string Name, T Value)[] args) => Op switch
        {
            BinaryOperator(var op) => op switch
            {
                 => (args) + (args),
                 => (args) - (args),
                 => (args) * (args),
                 => (args) / (args),
                 => (args) & (args),
                 => (args) | (args),
                 => (args) ^ (args),
                 => (args) == (args) ?  : ,
                 => (args) != (args) ?  : ,
                 => (args) > (args) ?  : ,
                 => (args) < (args) ?  : ,
                 => (args) >= (args) ?  : ,
                 => (args) <= (args) ?  : ,
                 => (args) ==  || (args) ==  ?  : ,
                 => (args) ==  && (args) ==  ?  : ,
                <  or >  => throw new InvalidOperationException($"Unexpected a binary operator, but got {op}.")
            },
            _ => throw new InvalidOperationException("Unexpected a binary operator.")
        };
    }
}

Similarly, you can also< or > Switch to discard mode or var mode.

Tripartite Expressions

A ternary expression contains three operands: a conditional expressionCond, true expressionLeft, a false expressionRight. This expression will be based onCondWhether it is true, choose to chooseLeftstillRight, relatively simple to implement:

public abstract partial class Expr<T> where T : IBinaryNumber<T>
{
    public class TernaryExpr : Expr<T>
    {
        public TernaryExpr(Expr<T> cond, Expr<T> left, Expr<T> right) => (Cond, Left, Right) = (cond, left, right);

        public Expr<T> Cond { get; }
        public Expr<T> Left { get; }
        public Expr<T> Right { get; }
        public void Deconstruct(out Expr<T> cond, out Expr<T> left, out Expr<T> right) => (cond, left, right) = (Cond, Left, Right);

        public override T Eval(params (string Name, T Value)[] args) => (args) ==  ? (args) : (args);
    }
}

Finish. We completed all the core logic with just a few dozen lines of code! This is the power of pattern matching: simple, intuitive and efficient.

Expression judgment, etc.

So far, we have completed all the implementations of expression construction, deconstruction and calculation. Next, we implement judgment logic for each expression, that is, we determine whether the two expressions (literally) are the same.

For examplea == b ? 2 : 4anda == b ? 2 : 5Different,a == b ? 2 : 4andc == d ? 2 : 4Different, anda == b ? 2 : 4anda == b ? 2 : 4same.

To implement this function, we rewrite theEqualsandGetHashCodemethod.

Constant expression

To determine constant expressions, etc., you only need to determine whether the constant values ​​are equal:

public override bool Equals(object? obj) => obj is ConstantExpr(var value) && value == Value;
public override int GetHashCode() => ();

Parameter expressions

To determine parameter expressions, etc., you only need to determine whether the parameter names are equal:

public override bool Equals(object? obj) => obj is ParameterExpr(var name) && name == Name;
public override int GetHashCode() => ();

Unary expressions

For unary expression judgment, it is necessary to determine whether the expression being compared is a unary expression. If so, it is determined whether the operator and operand are equal:

public override bool Equals(object? obj) => obj is UnaryExpr({ Operator: var op }, var expr) && (op, expr).Equals((, Expr));
public override int GetHashCode() => (Op, Expr).GetHashCode();

The above code uses attribute mode{ Operator: var op }, used to match the value of the attribute, here directly combines the declaration pattern to convert the attributeOperatorThe value ofexpr. In addition, tuples in C# can be combined for judgment and other operations, so there is no need to write() && (Expr), but can be written directly(op, expr).Equals((, Expr))

Binary expressions

It's similar to a one-party expression, the difference is that there is an additional operand this time:

public override bool Equals(object? obj) => obj is BinaryExpr({ Operator: var op }, var left, var right) && (op, left, right).Equals((, Left, Right));
public override int GetHashCode() => (Op, Left, Right).GetHashCode();

Tripartite Expressions

It's similar to binary expressions, except operatorsOpBecome an operandCond

public override bool Equals(object? obj) => obj is TernaryExpr(var cond, var left, var right) && (Cond) && (Left) && (Right);
public override int GetHashCode() => (Cond, Left, Right).GetHashCode();

So far, we have implemented judgments for all expressions.

Some tools and methods

We reload someExpr<T>The operator is convenient for us to use:

public static Expr<T> operator ~(Expr<T> operand) => new UnaryExpr(new(), operand);
public static Expr<T> operator !(Expr<T> operand) => new UnaryExpr(new(), operand);
public static Expr<T> operator -(Expr<T> operand) => new UnaryExpr(new(), operand);
public static Expr<T> operator +(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator -(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator *(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator /(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator &(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator |(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator ^(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator >(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator <(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator >=(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator <=(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator ==(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static Expr<T> operator !=(Expr<T> left, Expr<T> right) => new BinaryExpr(new(), left, right);
public static implicit operator Expr<T>(T value) => new ConstantExpr(value);
public static implicit operator Expr<T>(string name) => new ParameterExpr(name);
public static implicit operator Expr<T>(bool value) => new ConstantExpr(value ?  : );

public override bool Equals(object? obj) => (obj);
public override int GetHashCode() => ();

Due to overload==and!=, the compiler prompts us to rewrite it for the sake of safety.EqualsandGetHashCode, there is actually no need to rewrite it, so it is called directlybaseThe method on it can maintain the default behavior.

Then write two extension methods to facilitate the construction of ternary expressions, andDescriptionGet the name of the operator in:

public static class Extensions
{
    public static Expr<T> Switch<T>(this Expr<T> cond, Expr<T> left, Expr<T> right) where T : IBinaryNumber<T> => new Expr<T>.TernaryExpr(cond, left, right);
    public static string? GetName<T>(this T op) where T : Enum => typeof(T).GetMember(()).FirstOrDefault()?.GetCustomAttribute<DescriptionAttribute>()?.Description;
}

Since we need to provide parameter values ​​in advance when participating in the parameter expression to callEvalDo calculations, so we write an interactive oneEvalWhen encountering parameter expressions during calculation, prompt the user to enter the value, and the name isInteractiveEval

public T InteractiveEval()
{
    var names = <string>();
    return Eval(GetArgs(this, ref names, ref names));
}
private static T GetArg(string name, ref string[] names)
{
    ($"Parameter {name}: ");
    string? str;
    do { str = (); }
    while (str is null);
    names = (name).ToArray();
    return (str, , null);
}
private static (string Name, T Value)[] GetArgs(Expr<T> expr, ref string[] assigned, ref string[] filter) => expr switch
{
    TernaryExpr(var cond, var left, var right) => GetArgs(cond, ref assigned, ref assigned).Concat(GetArgs(left, ref assigned,ref assigned)).Concat(GetArgs(right, ref assigned, ref assigned)).ToArray(),
    BinaryExpr(_, var left, var right) => GetArgs(left, ref assigned, ref assigned).Concat(GetArgs(right, ref assigned, refassigned)).ToArray(),
    UnaryExpr(_, var uexpr) => GetArgs(uexpr, ref assigned, ref assigned),
    ParameterExpr(var name) => filter switch
    {
        [var head, ..] when head == name => <(string Name, T Value)>(),
        [_, .. var tail] => GetArgs(expr, ref assigned, ref tail),
        [] => new[] { (name, GetArg(name, ref assigned)) }
    },
    _ => <(string Name, T Value)>()
};

Here isGetArgsIn the method, mode[var head, ..]Followed by onewhen head == name, herewhenUsed to specify additional conditions for pattern matching, which will be successful only if the condition is satisfied, so[var head, ..] when head == nameThe meaning is to match a list with at least one element and assign the header element tohead, and onlyhead == nameOnly when the match is successful.

Finally, we'll rewrite itToStringThe method facilitates the output of expressions, and all the work is done.

test

Next let me test the expression calculator we wrote:

Expr<int> a = 4;
Expr<int> b = -3;
Expr<int> x = "x";
Expr<int> c = !((a + b) * (a - b) > x);
Expr<int> y = "y";
Expr<int> z = "z";
Expr<int> expr = ((y, z) - a > x).Switch(z + a, y / b);
(expr);
(());

After running, get the output:

((((! ((((4) + (-3)) * ((4) - (-3))) > (x))) ? (y) : (z)) - (4)) > (x)) ? ((z) + (4)) : ((y) / (-3))

Then we givexyandzSet to 42, 27 and 35 respectively to get the calculation result:

Parameter x: 42
Parameter y: 27
Parameter z: 35
-9

Then test the expression judgment logic:

Expr<int> expr1, expr2, expr3;
{
    Expr<int> a = 4;
    Expr<int> b = -3;
    Expr<int> x = "x";
    Expr<int> c = !((a + b) * (a - b) > x);
    Expr<int> y = "y";
    Expr<int> z = "z";
    expr1 = ((y, z) - a > x).Switch(z + a, y / b);
}

{
    Expr<int> a = 4;
    Expr<int> b = -3;
    Expr<int> x = "x";
    Expr<int> c = !((a + b) * (a - b) > x);
    Expr<int> y = "y";
    Expr<int> z = "z";
    expr2 = ((y, z) - a > x).Switch(z + a, y / b);
}

{
    Expr<int> a = 4;
    Expr<int> b = -3;
    Expr<int> x = "x";
    Expr<int> c = !((a + b) * (a - b) > x);
    Expr<int> y = "y";
    Expr<int> w = "w";
    expr3 = ((y, w) - a > x).Switch(w + a, y / b);
}

((expr2));
((expr3));

Get the output:

True
False

Activity Mode

In the future, C# will introduce active mode, which allows users to customize methods for pattern matching, such as:

static bool Even<T>(this T value) where T : IBinaryInteger<T> => value % 2 == 0;

The above code defines aTExtended methodEven, used to matchvalueWhether it is an even number, so we can use it like this:

var x = 3;
var y = x switch
{
    Even() => "even",
    _ => "odd"
};

In addition, this pattern can be combined with a deconstruction pattern, allowing users to customize deconstruction behavior, such as:

static bool Int(this string value, out int result) => (value, out result);

Then when using:

var x = "3";
var y = x switch
{
    Int(var result) => result,
    _ => 0
};

Just rightxThis string matches ifxCan be parsed asint, get the analysis resultresult, otherwise take 0.

postscript

Pattern matching is extremely convenient for us to write concise and readable high-quality code, and will automatically help us do exhaustive checks to prevent us from missing the situation. In addition, when using pattern matching, the compiler will also help us optimize the code, reduce the number of comparisons required to complete the match, and ultimately reduce branches and improve operational efficiency.

In order to cover all the patterns, the examples in this article do not necessarily use the optimal writing method, readers should also pay attention to this point.

All the codes of the expression calculator in this article can be obtained in my GitHub repository: /hez2010/PatternMatchingExpr

This is the article about the complete guide to C# pattern matching. For more related C# pattern matching content, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!