Functions as Data: Functional Programming in C#

In Object Oriented Programming (OOP), we’re used to using collections of objects or simple data types. We often sort and filter these collections using LINQ as part of business logic behaviors or for data transformation. While these are useful tasks we frequently perform, it can be easy to forget that functions in C# can be treated as data. If we realign our thinking around functions as data, it enables us to discover alternative solutions to standard problems in OOP.

In this article, we’ll look at an example from my C# Functional Programming workshop. The scenario outlines a solution used to score a poker hand. We’ll examine an alternative pattern to a solution that utilizes functions as data. Through this new pattern, we’ll provide flexibility to the scoring mechanic of the game.

Scoring Criteria

First, let’s take a look at the individual scoring functions that are used to produce the final score. Each function is a rule that determines if the hand of cards meets a criteria.

private bool HasFlush(IEnumerable<Card> cards) => ...;
private bool HasRoyalFlush(IEnumerable<Card> cards) => ...;
private bool HasPair(IEnumerable<Card> cards) => ...;
private bool HasThreeOfAKind(IEnumerable<Card> cards) => ...;
private bool HasFourOfAKind(IEnumerable<Card> cards) => ...;
private bool HasFullHouse(IEnumerable<Card> cards) => ...;
private bool HasStraightFlush(IEnumerable<Card> cards) => ...;
private bool HasStraight(IEnumerable<Card> cards) => ...;

The diagram below illustrates the rules of which the game is scored by. While the functions tell if the hand meets the criteria, they don’t directly impact the final score of the hand. We need to arrange the rules and evaluate them in order of importance to produce a score and assign it to an enumerator of HandRank.

 

Determining the Score

Using the rules, we can determine the final score value in a few different ways. Each of the following examples is technically correct and offers its own level of readability and simplicity. The negative aspect to each approach is that the order in which the rules execute is “hard coded”.

  1. Maintain a state
    This way of evaluating the score uses a temporary placeholder value to keep track of the score. As each evaluation takes place, the score is updated with the best HandRank available. This method is very explicit, but involves extra code and variables that aren’t necessary to complete the task.
  2. public HandRank GetScore(Hand hand)
    {
        var score = HandRank.HighCard;
        if (HasPair(hand.Cards)) { score = HandRank.Pair; }
         ... 
        if (HasRoyalFlush(hand.Cards)) { score = HandRank.RoyalFlush; }
        return score;
    }
    
  3. Return early
    Using a return early pattern allows us to write intuitive code that returns the best HandRank by returning immediately from the function when an evaluation is found to be true. This method is easy to read and fairly easy to modify as new rules are required by the application.
  4. public HandRank GetScore(Hand hand)
    {
        if (HasRoyalFlush()) return HandRank.RoyalFlush;
         ...
        if (HasPair()) return HandRank.Pair;
        return HandRank.HighCard;
    }
    
  5. Ternary Expression
    The function can be written as a single expression using a ternary operator. This has a similar effect as the return early method, but with even less code. Readability for this method may be easier for some than others.
public HandRank GetScore(Hand hand) => 
    HasRoyalFlush(hand.Cards) ? HandRank.RoyalFlush :
     ...
    HasPair(hand.Cards) ? HandRank.Pair :
    HandRank.HighCard;

In all of the previous examples order of operation is crucial. If we decide to add new rules to this scoring function, then we’ll need to insure they are inserted in the correct order to determine the proper score.

Thinking Functional

The GetScore operation is stepping through criteria evaluations and matching the first rule that results to true and returning the matching HandRank. Instead of evaluating the functions as individual statements we, can approach the problem from a functional programming mindset. Let’s change the way we look at the problem by thinking of the functions as data.
If we look at the individual scoring functions as data, we can identify a pattern. Consider the signature for the following scoring functions.

private bool HasFlush(IEnumerable<Card> cards) => ...;
private bool HasRoyalFlush(IEnumerable<Card> cards) => ...;
private bool HasPair(IEnumerable<Card> cards) => ...;
private bool HasThreeOfAKind(IEnumerable<Card> cards) => ...;
private bool HasFourOfAKind(IEnumerable<Card> cards) => ...;
private bool HasFullHouse(IEnumerable<Card> cards) => ...;
private bool HasStraightFlush(IEnumerable<Card> cards) => ...;
private bool HasStraight(IEnumerable<Card> cards) => ...;

Each function is of the same type, Func<IEnumerable<Card>, bool>. Since we have many pieces of data of the same type, we can arrange them in a collection or array. Next, we’ll need to match each function with the HandRank it represents. For example: HasPair will result in a score of HandRank.Pair. Using Tuples we can easily create this mapping without the need for a specialized class. In C# 7.1, we can create a tuple by simply enclosing multiple values in parenthesis. Using the function and its mapped enumerator, we can build the collection.

private List<(Func<IEnumerable<Card>, bool> eval, HandRank rank)> GameRules() =>
   new List<(Func<IEnumerable<Card>, bool> eval, HandRank rank)>
   {
               (cards => HasRoyalFlush(cards), HandRank.RoyalFlush),
               (cards => HasStraightFlush(cards), HandRank.StraightFlush),
               (cards => HasFourOfAKind(cards), HandRank.FourOfAKind),
               (cards => HasFullHouse(cards), HandRank.FullHouse),
               (cards => HasFlush(cards), HandRank.Flush),
               (cards => HasStraight(cards), HandRank.Straight),
               (cards => HasThreeOfAKind(cards), HandRank.ThreeOfAKind),
               (cards => HasPair(cards), HandRank.Pair),
               (cards => true, HandRank.HighCard),
   };

To keep things tidy, we’ll wrap the construction of the collection in a single function called GameRules. We can later use this as an extensible point for additional game rules. By moving the ranking system outside of the GetScore method it can be modified or replaced with new evaluations and ranks. For the lowest rank possible, we’ll simply use true to represent the default evaluation.

Refactoring with LINQ

Now we’ll rewrite the GetScore method using LINQ to evaluate the list. By treating the items in the list as data, we can utilize sorting to ensure they are executed in the proper order. We no longer have to worry about the “hard coded” execution order. We can use .OrderByDescending(card => card.rank) to sort the evaluations from strongest rank to weakest since HandRank.RoyalFlush is of the highest value.

public HandRank GetScore(Hand hand) => GameRules()
                    .OrderByDescending(rule => rule.rank)
                    .First(rule => rule.eval(hand.Cards)).rank;

Finally, to get the result we’ll perform our evaluation. The most efficient way to do this is by using the First LINQ method. Since First is a short-circuit operator, it will stop evaluating the items as soon as it finds the first item which returns true. When the first item evaluates to true we’ll take the rank value of the tuple from the data set and return it. The rank value is our final hand score.

Conclusion

Functions in C# are often thought of as static statements that our application can use to change the state of data within the system. By turning our perspective from imperative to functional, we can find alternative solutions. One way of bringing a functional mindset to the problem is by remembering that functions are also data and conform to many of the same rules as other data types in C# do. In this example, we saw how a functional approach changed a hard-coded statement-based evaluation to a flexible sort & map-based evaluation. This simple change expands the functionality of the application and reduces fritcion when adding new criteria as no order of operation is predefined.

To add more functional thinking to your mental toolbox, download the free Functional Programming cheat sheet and watch the video Functional Programming in C# on Channel 9.

Comments