-
-
-
-
URL copied!
When approaching design patterns in C#/.NET, it's crucial to understand not just the theoretical aspects but also their practical applications. This article aims to bridge that gap by offering a deep dive into pattern matching within .NET. We will explore its fundamental constructs, provide clear examples, and discuss how this feature can enhance your code's readability and maintainability. The structure of this article includes an overview of pattern matching constructs, detailed code examples, and a discussion on best practices for implementation.
Pattern matching isn't new in programming. It's common in functional programming languages like Haskell or even C#'s cousin, F#. Essentially, it allows you to test if an expression meets certain conditions and act based on that test's result. This article will dive into C#'s pattern matching, providing implementation details where necessary to understand its evolution with the language.
Constructs of Pattern Matching in C#
From a high-level perspective, the syntax and constructs that support pattern matching in C# include:
- expressions using the is operator
- switch statements / expressions
- Logical operators not, and, or (analogous to !, &&, ||)
Historically, switch statements were the first C# feature resembling pattern matching. While not called pattern matching at the time, their format shares similarities:
Here, the switch statement matches the expression's value against patterns x, y, or default, but it was limited to exact value matching.
Pattern matching officially entered C# in version 7.0 with the is keyword and basic pattern support. C# 8.0 brought switch expressions and additional improvements. Each new release has continued to enhance pattern matching.
Below is a list of the various patterns we'll discuss:
- Constant Patterns
- Type Patterns
- Relational Patterns
- Property Patterns
- Deconstruct Patterns
- List Patterns
Constant Patterns
Constant patterns, introduced in C# 7.0, as the name suggests, allow you to match values against constants such as numeric values, enums, strings, and null.
Using SharpLab.io, we see that constant pattern matching for simple and nullable types transforms into similar code. For reference types, the is operator ensures null checks, even if the == operator is overloaded.
Type Patterns
Type patterns can be seen as a reflection alternative to runtime type checking of an object. However, one striking difference between them is that when using type pattern matching only the compatibility of the types is being tested. This can lead to a looser type checking when dealing with hierarchies, demonstrated using the example below.
As we can see, when type matching against a base type, any derived type will cause a match, which can be useful depending on the use case (for example LINQ uses this internally to try and optimize some calls such as Count() on collections), but might catch you off-guard should you be unaware of this behavior.
Type patterns often combine with variable capturing on successful match, saving manual safe-casting:
Relational & Logical Patterns
Relational patterns use relational operators (>, <, >=, <=) and logical operators (and, or, not) to create complex patterns.
This example shows combining multiple patterns to achieve a desired result. Logical pattern matching operators maintain precedence like regular logical operators, with not having the highest priority, followed by and, then or.
Property Patterns
Introduced in C# 8.0, property patterns are what we would consider, the subset that has the most potential from all the pattern matching techniques. They allow you to evaluate an object based on the value of its properties. This is also applicable for nested properties that can be tested to achieve patterns like exemplified below:
The example illustrates the power of the property pattern by displaying the amount of space saving achievable through using pattern matching when compared to the regular if checks. First, we start by having built-in null-safety-checking built into property patterns. Then, we gain the ability to “declaratively” specify how an object matching our criteria should look like and all the hassle of manually checking those properties is abstracted away by the compiler.
Another more niche / preference use case for them, is another way of checking if an object is null or not by comparing it with the empty property pattern. This can be seen demonstrated below (left equivalent to right).
Deconstruct Patterns
Deconstruct patterns (also known as positional patterns) take advantage of the deconstruction mechanism for objects present in C#. As a quick refresher, if an object has a void method called Deconstruct without parameters, it can automatically be deconstructed in a tuple in a single operation.
Pattern matching is available on tuples as with any other type, and it is aware of the deconstruction mechanism, so it allows us to match the object based on the deconstructed tuple directly.
As we can see, we can match a complexObject directly as if it were a tuple type. Moreover, previously mentioned pattern matching techniques and variable binding are fully available inside tuples. In the example above, we are matching on an object where oneProp is a string with at least 4 characters, anotherProp can be any integer and we are binding both to the variables x and y to be ready to use inside the if block.
Also based on the tuples ability to be matched element-by-element, we can also derive a nifty way to sometimes make regular conditions shorter and more concise.
Since we can construct value tuples at will by combining different elements, we can also combine the conditions inside a single pattern check where possible. This has the same end result and since value tuples are cheap to create, this can be an alternative based on your preference.
List Patterns
List patterns are the newest addition to the pattern matching suite, being introduced with the release of C# 11 and .NET 7. As you can probably deduce from the name, they allow you to match lists against patterns.
Let's start with a simple example and understand the basics:
Following the example above we can see that it is possible to match individual elements of a list capturing them in variables if needed, and even test other patterns on each element of the list (like in the 4th branch of the switch).
This can be useful if you know the exact dimensions of a list, however you are probably wondering "what if I'm not interested in every element and just want the first or the last" or "what if I don't know the exact size of the list and I don't want to write a branch for each possible size".
Those are all perfect questions and here is where a bit of confusion comes into play. Even if the official name of the technique is called list patterns, to take advantage of the entire feature set that would answer the previously mentioned questions, we cannot use lists here. We need collections that support slicing, with the main candidate being Array instead of lists, as internally the slice operation is used for those cases. Let's have another example to demonstrate what can be achieved on arrays:
If we follow the example above, we immediately see a new syntax being introduced, that being the .. syntax. Using it we can collect multiple elements in the initial list in one pattern and even apply further tests to the pattern. The only restriction we have is that slicing inside a pattern can only be used once, however as we can see from the example its use is very flexible as it accommodates any scenario that is logically correct. Taking advantage of slicing turns "array patterns" (to call them exactly what they are) into a potentially extremely powerful feature.
One thing we must be careful with when using slicing, is that every new slice that we make represents an entirely new array. This new array is mutable which depending on the use case might be what is needed; however, we would expect that most use cases for this will be operations without side effects, even on the sliced arrays.
This is important as every new allocation costs which might impact performance if used repeatedly (maybe even recursively) on a large enough array. If you need to use slicing but all your operations have no side effects on the sliced arrays, then it is possible to eliminate the overhead of new allocations by using Span instead of T[] (or call AsSpan() before matching).
Collection Expressions
Collection expressions and pattern matching are complementary features that enhance code readability and expressiveness when working with collections.
While collection expressions provide a concise way to create collections, pattern matching offers a powerful mechanism to deconstruct and analyze them. By combining these features, developers can write more elegant and efficient code for handling collections.
In this example, a collection expression is used to initialize the numbers list, and pattern matching is applied to extract the first two elements:
Conclusion
We have described many of the pattern matching techniques in C# introduced over the years as this feature set evolved. While we have certainly proven that it is powerful when used in the right circumstances, the fact that most of the techniques interact with each other and allow "recursion-like" behavior inside patterns is both a blessing and a curse.
One on hand, the power to combine multiple patterns can ease the number of checks we have to do, falling into the other side where we try and match too much in a single go, can lead to severe reduction in readability of the exact criteria we are searching for. As a fun exercise we've left a trivia like question below with one of these cases where we went maybe a bit too far with the checks:
We hope you find your match in these techniques.
Let’s Work Together
Related Content
Accelerating Digital Transformation with Structured AI Outputs
Enterprises increasingly rely on large language models (LLMs) to derive insights, automate processes, and improve decision-making. However, there are two significant challenges to the use of LLMs: transforming structured and semi-structured data into suitable formats for LLM prompts and converting LLM outputs back into forms that integrate with enterprise systems. OpenAI's recent introduction of structured … Continue reading C#/.NET- Now you’re thinking with patterns →
Learn More
Connected Vehicle Cybersecurity Considerations That Vehicle Manufacturers Need to Know
According to Deloitte, there will be 470 million connected vehicles on highways worldwide by 2025. These connected vehicles provide opportunities and have a higher cybersecurity risk than any other connected devices; even the FBI had to make a statement about it. A typical new model car runs over 100 million lines of code and has … Continue reading C#/.NET- Now you’re thinking with patterns →
Learn More
Accelerating Enterprise Value with AI
As many organizations are continuing to navigate the chasm between AI/GenAI pilots and enterprise deployment, Hitachi is already making significant strides. In this article, GlobaLogic discusses the importance of grounding any AI/GenAI initiative in three core principles: 1) thoughtful consideration of enterprise objectives and desired outcomes; 2) the selection and/or development of AI systems that are purpose-built for an organization’s industry, its existing technology, and its data; and 3) an intrinsic commitment to responsible AI. The article will explain how Hitachi is addressing those principles with the Hitachi GenAI Platform-of-Platforms. GlobalLogic has architected this enterprise-grade solution to enable responsible, reliable, and reusable AI that unlocks a high level of operational and technical agility. It's a modular solution that GlobalLogic can use to rapidly build solutions for enterprises across industries as they use AI/GenAI to pursue new revenue streams, greater operational efficiency, and higher workforce productivity.
Learn More
Share this page:
-
-
-
-
URL copied!