By realigning your thinking about functions as data, you can uncover new solutions to problems in OOP. Let's look at an example of 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.
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.
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."
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.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;
}
public HandRank GetScore(Hand hand)
{
if (HasRoyalFlush()) return HandRank.RoyalFlush;
...
if (HasPair()) return HandRank.Pair;
return HandRank.HighCard;
}
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.
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.
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.
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 friction 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.
Ed