Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

An Exploration of Alternatives to if-then-else workflows

4.91/5 (33 votes)
20 Jul 2020CPOL3 min read 66K  
Alternatives to If-Then-Else with Extension Methods and Functional Programming Techniques
In this tip, you will see some alternative to if-then-else with extension methods

In this tip, we'll look at a couple of ways to work with if-then statements, using extension methods and FP techniques that improve code readability but require what might be considered obtuse or overly complex implementations. While I'm using C# as the language for the code examples, you can use these techniques in just about any language that supports passing functions.

Recently, I wrote a function to download a new version of the app. The flowchart looked like this:

Image 1

And the code basically looked like this (the use of static is simply because I'm illustrating this code with a simple console app):

C#
static void SinglePointOfReturn()
{
  bool ok = GetConfig();

  if (ok)
  {
    if (VersionChanged())
    {
      ok = DownloadVersion();

      if (ok)
      {
        ok = Unzip();

        if (ok)
        {
          ok = CopyFiles();

          if (ok)
          {
            RestartApp();
          }
          else
          {
            Error(ErrorType.Copy);
          }
        }
        else
        {
          Error(ErrorType.Unzip);
        }
      }
      else
      {
        Error(ErrorType.Download);
      }
    }
  }
  else
  {
    Error(ErrorType.Config);
  }
}

The above code has the advantage of a single point of return, but the nesting and if-then-else statements hide the overall flow. We can change the code, breaking the single point of return, to look something like this:

C#
static void MultipleReturnPoints()
{
  if (!GetConfig())
  {
    Error(ErrorType.Config);
    return;
  }

  if (!VersionChanged()) return;

  if (!DownloadVersion())
  {
    Error(ErrorType.Download);
    return;
  }

  if (!Unzip())
  {
    Error(ErrorType.Unzip);
    return;
  }

  if (!CopyFiles())
  {
    Error(ErrorType.Copy);
    return;
  }

  RestartApp();
}

This code is more linear and therefore a bit easier to read, but the multiple return points are not ideal in my opinion.

Using an extension method, we convert the if statement into a method:

C#
public static class Extensions
{
  public static bool If(this bool ok, Func<bool> fnc, Action onFail = null)
  {
    bool nextok = ok; // Preserve current state.

    if (ok) // if current state allows us to continue...
    {
      nextok = fnc();

      if (!nextok) // Continuation errored.
      {
        if (onFail != null) // If we have an error handler, call it.
        {
          onFail();
        }
      }
    }

    return nextok; // Return new state.
  }
}

Now we can write:

C#
static void IfAsAMethod()
{
  bool ok = true.If(GetConfig, () => Error(ErrorType.Config));
  ok = ok.If(VersionChanged);
  ok = ok.If(DownloadVersion, () => Error(ErrorType.Download));
  ok = ok.If(Unzip, () => Error(ErrorType.Unzip));
  ok = ok.If(CopyFiles, () => Error(ErrorType.Copy));
  ok.If(RestartApp);
}

As the above code illustrates, using an extension method to write the workflow in a more functional programming manner, we change the code that implements the workflow into something that is a clearer expression of the flowchart, although it looks strange to those uninitiated in FP techniques, and certainly converting a language feature, like an if statement, into a function, is not what you typically encounter either.

You might ask why we don't just pass in the error type.  The answer is that usually extension methods are put into libraries; passing the error type in to the extension method creates a dependency on the application's intention, both with the enumeration and having to be able to reference the Error handler function.  Using Func and Action decouples this dependency.

You might also ask, why each of the functions being called does not simply return the error type? This also creates a coupling of the extension method to the enumeration, which might be an acceptable compromise if the extension method is localized to just the namespace that handles the workflow. The enum could look like this:

C#
enum ErrorType
{
  None,
  Copy,
  Unzip,
  Download,
  Config,
}

Now note how the parameter signatures of the extension method are changed:

C#
public static ErrorType If(this ErrorType errorType, Func<ErrorType> fnc, 
                           Action<ErrorType> onFail = null)
{
  ErrorType nextError = errorType;   // Preserve current state.

  if (errorType == ErrorType.None)   // if current state allows us to continue...
  {
    nextError = fnc();

    if (nextError != ErrorType.None) // Continuation errored.
    {
      if (onFail != null)            // If we have an error handler, call it.
      {
        onFail(nextError);
      }
    }
  }

  return nextError;                  // Return new state.
}

The workflow is tightened up further:

C#
static void IfAsMethod2()
{
  ErrorType ok = ErrorType.None.If(GetConfig, Error);
  ok = ok.If(VersionChanged);
  ok = ok.If(DownloadVersion, Error);
  ok = ok.If(Unzip, Error);
  ok = ok.If(CopyFiles, Error);
  ok.If(RestartApp);
}

This is a good illustration of the tension between abstract implementation and implementations that are tailored to meet a specific need and are therefore less abstract, but less abstract means less re-usable.  So it's always a decision one should be aware of.

However, it still looks very atypical of procedural code, and the biggest problem with the code above is that, if any function fails (returns false), the If extension method is still evaluated for the remainder of the workflow.  We can look at a different way of implementing a workflow:

C#
static void AsWorkflow()
{
  ProcessWorkflow(Error, new Func<ErrorType>[] {
  GetConfig, 
  VersionChanged, 
  DownloadVersion, 
  Unzip, 
  CopyFiles, 
  RestartApp});
}

Which we could implement the workflow processor like this:

C#
static void ProcessWorkflow(Action<ErrorType> errorHandler, Func<ErrorType>[] workflow)
{
  ErrorType errorType = ErrorType.None;
  int idx = 0;

  while ( (errorType == ErrorType.None) && (idx <workflow.Length) )
  {
    errorType = errorType.If(workflow[idx++], errorHandler);
  }
}

This might be considered better because it returns immediately on failure, and the calling function makes it clearer that the array of functions are being called as a workflow, but is it better or are we just getting more and more obtuse? Again, if you even go down this path, that's an implementation decision you'll have to make.

So, there you have it -- some food for thought on working with workflows where each step potentially can exit with an error, and some different implementations, sticking with what can be done in procedural languages like C# that provide some functional programming capability.

History

  • 31st December, 2015: Initial version

License

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