Click here to Skip to main content
15,867,568 members
Articles / Programming Languages / C#

Stop Writing Switch and If-Else Statements!

Rate me:
Please Sign up or sign in to vote.
4.89/5 (47 votes)
4 May 2018CPOL4 min read 77.2K   297   76   26
Fun with Tuples and Extension Methods to Implement a Match Function

Introduction

While this article is somewhat tongue-in-cheek, it's also an interesting exploration into what you can do with tuples, extension methods, and variable parameters. I thought I'd see just how far I could take this, and this article is the result.

This code requires C# 7.1 at a minimum!

F# has a nifty match expression, that looks like this:

match [something] with 
| pattern1 -> expression1
| pattern2 -> expression2
| pattern3 -> expression3

Could we do something similar to this in C#?

Certainly! In fact, the improved switch keyword can evaluate expressions and perform pattern matching.  For example:

  • expression evaluation: case var testAge when ((testAge >= 90) & (testAge <= 99)):
  • pattern matching: case Planet p:

OK, that's cool, but let's explore something a wee bit more like the F# syntax.

Four Variations

I'll walk you through the four variations that I implemented, starting with the first proof of concept and ending with the fourth "ah, this is the solution." Then, we'll look at some other cool things you can do with this.

Pass 1 -- Adding Pattern and Expressions to a Match Collection

I wanted to start with something basic, so I implemented a Rule class:

C#
public class Rule<T>
{
  protected List<(Func<T, bool> qualifier, 
       Action<T> action)> matches = new List<(Func<T, bool> qualifier, Action<T> action)>();

  public Rule<T> MatchOn(Func<T, bool> qualifier, Action<T> action)
  {
    matches.Add((qualifier, action));

    return this;
  }

  public Rule<T> Match(T val)
  {
    foreach (var match in matches)
    {
      if (match.qualifier(val))
      {
        match.action(val);
        break;
      }
    }

    return this;
  }
}

And it's used like this:

C#
var rule = new Rule<int>()
  .MatchOn(n => n == 0, _ => Console.WriteLine("Zero"))
  .MatchOn(n => n == 1, _ => Console.WriteLine("One"))
  .MatchOn(n => n == 2, _ => Console.WriteLine("Two"));

  rule.Match(0);

The output is of course "Zero". This really isn't what I want because we're creating a collection every time we run the rule (unless we save the rules somewhere, yuck) and it's verbose.  But it's a proof of concept of the overall pattern -> expression syntax that we see in F#.

Pass 2 - Using an Extension Method

My second attempt gets rid of the Rule class but has a major flaw -- every expression is evaluated even if a match is found. This is a rather brain dead solution, but here it is:

C#
public static class ExtensionMethods
{
  public static (bool, T) Match<T>(this T item, Func<T, bool> qualifier, Action<T> action)
  {
    return (false, item).Match(qualifier, action);
  }

  public static (bool, T) Match<T>(this (bool hasMatch, T item) src, 
                                   Func<T, bool> qualifier, Action<T> action)
  {
    bool hasMatch = src.hasMatch;

    if (!hasMatch)
    {
      hasMatch = qualifier(src.item);

      if (hasMatch)
      {
        action(src.item);
      }
    }

    return (hasMatch, src.item);
  }
}

Note how each expression returns a tuple indicating whether a match is found, and the source item. Using tuples is the one consistent thing in each of these variations. A couple usage examples:

C#
(false, 1)
  .Match(n => n == 0, _ => Console.WriteLine("Zero"))
  .Match(n => n == 1, _ => Console.WriteLine("One"))
  .Match(n => n == 2, _ => Console.WriteLine("Two"));

2
  .Match(n => n == 0, _ => Console.WriteLine("Zero"))
  .Match(n => n == 1, _ => Console.WriteLine("One"))
  .Match(n => n == 2, _ => Console.WriteLine("Two"));

The one advantage that this approach has is that I'm no longer creating a collection of pattern -> expression cases -- these are evaluated "live."

Pass 3 - Static Method with Variable Parameters

So then, I went back to the simpler case of having a class with a static method, which is basically like an extension method, it's just that this form is sometimes easier on the brain. It looks like this:

C#
public static class Matcher<T>
{
  public static void Match(T val, params (Func<T, bool> qualifier, Action<T> action)[] matches)
  {
    foreach (var match in matches)
    {
      if (match.qualifier(val))
      {
        match.action(val);
        break;
      }
    }
  }
}  

and is used like this:

C#
Matcher<int>.Match(3,
  (n => n == 0, _ => Console.WriteLine("Zero")),
  (n => n == 1, _ => Console.WriteLine("One")),
  (n => n == 2, _ => Console.WriteLine("Two")),
  (n => n == 3, _ => Console.WriteLine("Three")));

OK, now we're getting somewhere. We've eliminated the Match method name for each pattern -> expression, but we still have the class name, the static method name, and even worse, we have to specify the generic parameter type.  So, one step forward and two steps backward.

Pass 4 - Extension Methods to the Rescue Again

In this version, I get rid of the static class method by using an extension method, but in this case the extension method has a variable number of parameters:

C#
public static void Match<T>(this T val, params (Func<T, bool> qualifier, Action<T> action)[] matches)
{
  foreach (var match in matches)
  {
    if (match.qualifier(val))
    {
      match.action(val);
      break;
    }
  }
}

And the usage example:

C#
2.Match(
  (n => n == 0, _ => Console.WriteLine("Zero")),
  (n => n == 1, _ => Console.WriteLine("One")),
  (n => n == 2, _ => Console.WriteLine("Two")),
  (n => n == 3, _ => Console.WriteLine("Three")),
  (n => n == 4, _ => Console.WriteLine("Four")));

Snazzy!  That is the closest to the F# syntax.

What Else Can we Do?

MatchAll

Here's something you can't do with an F# match or C# switch statement: MatchAll!

C#
public static void MatchAll<T>(this T val, params (Func<T, bool> qualifier, Action<T> action)[] matches)
{
  foreach (var match in matches)
  {
    if (match.qualifier(val))
    {
      match.action(val);
    }
  }
}

All we've done here is eliminate the break statement. Usage example:

C#
10.ForEach(n => n.MatchAll(
  (v => v % 2 == 0, v => Console.WriteLine($"{v} is even.")),
  (v => v % 2 == 1, v => Console.WriteLine($"{v} is odd.")),
  (_ => true,       v => Console.WriteLine($"{v} * 10 = {v * 10}."))
  ));

Screams will erupt regarding the 10.ForEach, I'm sure. Also, if you're not familiar with the "$" notation (I rarely use it myself), look up string interpolation. Anyways, here's the output:

0 is even.
0 * 10 = 0.
1 is odd.
1 * 10 = 10.
2 is even.
2 * 10 = 20.
3 is odd.
3 * 10 = 30.
4 is even.
4 * 10 = 40.
5 is odd.
5 * 10 = 50.
6 is even.
6 * 10 = 60.
7 is odd.
7 * 10 = 70.
8 is even.
8 * 10 = 80.
9 is odd.
9 * 10 = 90.

I personally find that pretty snazzy.

MatchAsync

This might be useful for a pattern or expression that takes a while to execute, perhaps some sort of an I/O operation or DB query:

C#
public async static void MatchAsync<T>(this T val, 
               params (Func<T, bool> qualifier, Action<T> action)[] matches)
{
  foreach (var match in matches)
  {
    if (await Task.Run(() => match.qualifier(val)))
    {
      await Task.Run(() => match.action(val));
      break;
    }
  }
}

And a simplistic (and weird) example:

C#
Console.WriteLine(DateTime.Now.ToString("hh:MM:ss"));

1000.MatchAsync(
(v => { Thread.Sleep(v); return true; }, _ => Console.WriteLine(DateTime.Now.ToString("hh:MM:ss")))
);

Console.WriteLine("Ready!");
Console.ReadLine();

The output is what you'd expect -- the time is displayed, the match falls through as the task is run, then the continuation of the async operation is performed:

C#
08:05:09
Ready!
08:05:10

Now that is quite interesting!

MatchReturn

As wkempf commented in the message section: "In F#, match is an expression (it returns a value) and not a statement" we can implement a match extension method that returns a value:

C#
public static U MatchReturn<T, U>(this T val, params 
            (Func<T, bool> qualifier, Func<T, U> func)[] matches)
{
  U ret = default(U);

  foreach (var match in matches)
  {
    if (match.qualifier(val))
    {
      ret = match.func(val);
      break;
    }
  }

  return ret;
}

Usage when all the return types are the same:

C#
string ret = 2.MatchReturn(
  (n => n == 0, _ => "Zero"),
  (n => n == 1, _ => "One"),
  (n => n == 2, _ => "Two"),
  (n => n == 3, _ => "Three"),
  (n => n == 4, _ => "Four"));

Console.WriteLine(ret);

Usage when the expression returns different types:

C#
5.ForEach(q =>
{
  dynamic retd = q.MatchReturn<int, dynamic>(
    (n => n == 0, _ => "Zero"),
    (n => n == 1, n => n),
    (n => n == 2, n => new BigInteger(n)),
    (n => n == 3, n => new Point(n, n)),
    (n => n == 4, n => new Size(n, n)));
  Console.WriteLine(retd.ToString());
});

Output:

Zero
1
2
{X=3,Y=3}
{Width=4, Height=4}

Because the return type cannot be inferred, note that we have to explicitly provide the generic parameters specifying the input type and the dynamic return type.

Performance

Regarding the question of performance, a simplistic performance tester:

C#
DateTime start = DateTime.Now;
1000000.ForEach(q => (q % 5).MatchReturn(
  (n => n == 0, _ => "Zero"),
  (n => n == 1, _ => "One"),
  (n => n == 2, _ => "Two"),
  (n => n == 3, _ => "Three"),
  (n => n == 4, _ => "Four")));
DateTime stop = DateTime.Now;
Console.WriteLine("MatchReturn for 1,000,000 runs took " + (stop - start).TotalMilliseconds + "ms");

string sret;
start = DateTime.Now;
1000000.ForEach(q =>
{
  switch (q % 5)
  {
    case 0:
    sret = "Zero";
    break;
  case 1:
    sret = "One";
    break;
  case 2:
    sret = "Two";
    break;
  case 3:
    sret = "Three";
    break;
  case 4:
    sret = "Four";
    break;
  }
});
stop = DateTime.Now;
Console.WriteLine("Switch for 1,000,000 runs took " + (stop - start).TotalMilliseconds + "ms");

Shows how poor performing (by a factor of 22) this implementation is compared to a simple numeric switch:

C#
MatchReturn for 1,000,000 runs took 224ms
Switch for 1,000,000 runs took 7.9983ms

Conclusion

Personally, I find that what started off as a silly idea became more and more interesting, particularly with the MatchAll and MatchAsync implementation, which I've found to already be useful in an application I'm developing.  But yes, I do walk on the wild side.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
PraiseGreat article, I think Microsoft read this too Pin
Sander Rossel18-Jul-20 22:53
professionalSander Rossel18-Jul-20 22:53 
QuestionIt's Great But... Pin
Hyland Computer Systems25-Jun-18 8:30
Hyland Computer Systems25-Jun-18 8:30 
AnswerRe: It's Great But... Pin
Marc Clifton25-Jan-19 7:49
mvaMarc Clifton25-Jan-19 7:49 
QuestionMy vote of 5* Pin
Bojan Sala25-Jun-18 2:32
professionalBojan Sala25-Jun-18 2:32 
GeneralMy vote of 4 Pin
tbayart25-Jun-18 1:50
professionaltbayart25-Jun-18 1:50 
Praisevery nice Pin
BillW3318-Jun-18 5:17
professionalBillW3318-Jun-18 5:17 
QuestionAs always excellent Pin
Mike Hankey8-Jun-18 4:01
mveMike Hankey8-Jun-18 4:01 
Interesting excursion on the wild side.
Everyone has a photographic memory; some just don't have film. Steven Wright

SuggestionWhy not a dictionary? It already matches. Pin
rhyous11-May-18 5:08
rhyous11-May-18 5:08 
GeneralRe: Why not a dictionary? It already matches. Pin
Marc Clifton14-May-18 10:26
mvaMarc Clifton14-May-18 10:26 
GeneralRe: Why not a dictionary? It already matches. Pin
rhyous21-May-18 6:17
rhyous21-May-18 6:17 
QuestionElse or Default statement? Pin
dbrenth9-May-18 3:20
dbrenth9-May-18 3:20 
AnswerRe: Else or Default statement? Pin
Marc Clifton9-May-18 7:15
mvaMarc Clifton9-May-18 7:15 
Questionwhat is the conclusion? Pin
Debashis 104336567-May-18 0:16
Debashis 104336567-May-18 0:16 
AnswerRe: what is the conclusion? Pin
wkempf7-May-18 1:04
wkempf7-May-18 1:04 
AnswerRe: what is the conclusion? Pin
Marc Clifton7-May-18 2:02
mvaMarc Clifton7-May-18 2:02 
QuestionMy vote of #5, and one comment/idea Pin
BillWoodruff7-May-18 0:14
professionalBillWoodruff7-May-18 0:14 
GeneralMy vote of 5 Pin
Bryian Tan4-May-18 10:13
professionalBryian Tan4-May-18 10:13 
GeneralRe: My vote of 5 Pin
Marc Clifton5-May-18 10:05
mvaMarc Clifton5-May-18 10:05 
PraiseRule engine Pin
RickZeeland4-May-18 0:20
mveRickZeeland4-May-18 0:20 
GeneralRe: Rule engine Pin
Marc Clifton4-May-18 2:20
mvaMarc Clifton4-May-18 2:20 
QuestionNice one Pin
Sacha Barber3-May-18 22:29
Sacha Barber3-May-18 22:29 
SuggestionPerformance Pin
Ciprian Beldi3-May-18 20:15
Ciprian Beldi3-May-18 20:15 
GeneralRe: Performance Pin
wkempf4-May-18 1:51
wkempf4-May-18 1:51 
GeneralRe: Performance Pin
Marc Clifton4-May-18 3:44
mvaMarc Clifton4-May-18 3:44 
GeneralRe: Performance Pin
wkempf4-May-18 4:39
wkempf4-May-18 4:39 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.