This blog post will cover how you can leverage functional programming in C# using a few simple classes and extension methods, with insights applicable to other modern languages that support lambda expressions or closures.
Having spent over 15 years writing C# code focused on Object Oriented Programming, I recently ventured into the Rust programming language, known for its functional programming capabilities. As I started to appreciate Rust’s features, like Options, Results, and Map functions, I wondered how to integrate functional programming principles into my C# development.
All code for this blogpost can be found in this Github repository
Characteristics of Functional programming
Before we dive into code, I should first clarify a bit on the characteristics of functional programming.
Functional programming is a programming paradigm that emphasizes immutability, the use of pure functions, and the avoidance of side effects. Its core characteristics include treating functions as first-class citizens, which allows them to be passed as arguments, returned from other functions, and stored in data structures. Functional programming also promotes the use of higher-order functions that take other functions as input or return them as output. Recursion is often favored over looping constructs, and the use of immutable data structures ensures that data remains unmodified throughout the program’s execution. This paradigm ultimately leads to more predictable, concise, and maintainable code, making it increasingly popular among developers for its ability to manage complexity and promote robust software design.
- Immutability: Data remains unmodified throughout the program’s execution, reducing the risk of unintended side effects.
- Pure functions: Functions depend solely on their input and produce consistent output without causing side effects.
- First-class functions: Functions can be passed as arguments, returned from other functions, and stored in data structures.
- Higher-order functions: Functions that accept other functions as input or return them as output, enabling powerful composition and abstraction techniques.
- Recursion: Repeated function calls to solve problems, often used as an alternative to looping constructs.
- Declarative programming: Code focuses on expressing what should be done, rather than detailing step-by-step procedures.
- Lazy evaluation: Computation is deferred until absolutely necessary, improving performance and avoiding unnecessary work.
- Pattern matching: A flexible way to destructure data and match specific patterns, allowing for concise and readable code.
- Type inference: Automatic determination of data types by the compiler, reducing the need for explicit type annotations.
- Referential transparency: Functions with the same input always produce the same output, making code easier to reason about and test.
Problems with imperative programming
Let’s start with a quick example: converting Celsius to Fahrenheit.
It’s worth noting that a, b, and c have a longer lifespan than necessary and could be unintentionally used elsewhere in the code. This could result in unexpected outcomes, so it’s important to be cautious when reusing variables outside of their intended scope.
While it may seem obvious, I’ve seen this mistake happen more than once in larger code implementations. To prevent unexpected outcomes, it’s best to eliminate intermediate values as soon as they are no longer needed. This helps to avoid unintentional reuse of variables outside of their intended scope.
Scoping and mutability
Scoping and mutability are important concepts to keep in mind when working with variables in a program. As for the Map function, it takes a value and a conversion function, which enables the transformation of values in a clear and concise way. To illustrate, consider the following example:
By utilizing this method, I can perform the same steps as before while ensuring that intermediate values are scoped appropriately. Since the converted value is returned, we can immediately chain a new Map function to it and continue processing without having to worry about reusing variables unintentionally.
Making it declaritive
Although the previous function ensures that intermediate values are short-lived and immutable, it can still be challenging to read and understand. However, by making it more declarative, we can shift our focus to what we want to accomplish rather than how to do it. One approach to achieving this is by utilizing a technique called currying.
Currying transforms a multi-argument function into a series of single-argument functions that return another function, until all necessary arguments are provided.
To illustrate, consider the following example of a curried function:
Which does the exact same thing as this fully written example.
The Multiply function takes a parameter (the multiplier) and returns a new function that multiplies a given value by the specified multiplier. When calling a curried function, you can do so by providing the arguments one at a time, like this:
Although it may seem unconventional at first, what we’re doing here is actually an example of partial application. With partial application, you can supply some of the arguments ahead of time and reuse that partial application as many times as needed. This can be a powerful technique depending on the use case you’re working on, so take a moment to let it sink in.
Now, let’s consider how we could use these curried functions with our Map function to apply the necessary transformations:
To simplify the process even further, we can utilize the shorthand notation that C# provides to apply the curried functions:
By implementing a declarative approach that utilizes immutable values, we now have a clear and concise conversion from Celsius to Fahrenheit that is easy to read and far less prone to errors.
But that seems like a lot of work, for such a simple task Thomas.
While it may seem like a lot of work for such a simple task, it’s important to keep in mind that the functions we created can be packaged and reused across multiple projects. This makes the investment of time and effort in creating them well worth it in the long run, as it can lead to greater efficiency and consistency in your codebase.
Now, let’s consider a larger example: validating a Dutch phone number. While we won’t be using regular expressions in this example, we’ll provide an imperative implementation to illustrate the concept.
To validate a phone number, there are several steps involved. If any of these steps fail, it’s important to return as early as possible to save CPU cycles. However, the imperative approach used to accomplish this focuses primarily on how to validate the phone number, rather than what we actually want to achieve. This can result in a lot of noise, with if checks and return statements that make the code difficult to read.
To address this issue, we can utilize a higher-order function and pass in a set of validation functions to aggregate over. This will allow us to focus more on the what of the problem, rather than the how.
First, we’ll need to split up the various validations into named functions.
This accomplishes the same result as the previous imperative approach, but with the validations split up into named functions.
Now, we can further improve this by using a higher-order function to aggregate over the validation functions. We can create a Validate extension method that takes in all of the validation functions as arguments.
The All method used on the predicates is a useful feature because it returns false as soon as one of the predicates fails to return true, which is equivalent to the early return false statements used in the imperative implementation. By using the Validate method on the phone number itself, we can easily validate the number using a declarative approach with more readable code that focuses on what we want rather than how we want to validate it.
Adding additional validations becomes much easier as we can simply add them to the list of validations without worrying about the control flow of an imperative implementation.
Anonymous functions can also be used in place of named functions for more concise code. Here’s an example using anonymous functions to validate a username.
In many cases, we need to convert data in a sequence of steps into a different form. Consider this Person class, for example
If we have a person record and we want to get the first name of the spouse, we can access it like this:
However, depending on the language version and the nullable settings for your project, either of these values can be null, despite what the compiler may suggest, and the operation would fail at runtime. To handle this, we would need to add null checks.
This approach adds a lot of clutter to our code and shifts the focus away from what we’re trying to accomplish. To avoid this, we can use a design pattern in which the pipeline implementation is abstracted by wrapping a value in a type. This pattern is called a Monad, which you can learn more about in the functional programming paradigm. Within functional programming, you’ll find types such as Result or Option (also called Maybe) that wrap the values for each result in a pipeline.
Here’s what our Option type looks like:
For our Option type, I will define 2 subtypes: Some and None:
A value can be implicitly converted to Some if the value is not null, and can be implicitly converted to None if the value is null.
Now, let’s extend our mapping function a bit:
In this case, we first check if the value we got is actually a value or if it’s None. If we got a None value, we’ll simply return another None.
If we got a Some value, we can now safely attempt to map the value by using the provided factory method. If the mapping fails, we’ll return None. Otherwise, we’ll return a new Some value wrapping the mapped result.
By abstracting this logic away into the Map method, we can now chain our calls without worrying about control flow or null checks. This allows us to focus on the data transformations we want to achieve, rather than the boilerplate code for handling null values.
If the person is None, or the spouse is None, or the firstname of the spouse is None, we will just return None. If all values are set, the firstname of the spouse will be returned.
We can continue chaining these operations to process the data as needed.
The code in the previous paragraph illustrates a situation where we get the spouse of the spouse of the spouse, and so on, until we eventually encounter a null value somewhere in the chain. This approach is also known as “Railway oriented programming”, which emphasizes handling success and failure paths in a linear manner.
Performing intermediate logic
At times, you may want to include additional logic in the pipelines mentioned earlier. For instance, you may want to log an intermediate value before proceeding with further operations on the same result.
Functional programming can be used to accomplish this goal by creating a function known as Tee.
The term ‘Tee’ is named after the Unix command ‘tee’, which is named after the T-shaped pipe fitting.
We can now achieve the same, but calling the Tee function where we want:
As you can see, the logic for printing whether the spouse has a value is now grouped within the same scope, and not divided over multiple lines. After we’re done logging the spouse value, we can continue mapping as before. This allows us to perform additional operations on the same result without breaking the pipeline, making our code more readable and maintainable.
I really like the fact that the Rust programming language is designed to be immutable by default, unless specified otherwise, which can be challenging for some developers at first. However, it is a good practice to consider whether a value should be mutable or not, as many issues with state and concurrency can lead to various bugs. By defaulting to immutability, we force ourselves to think about these issues more carefully.
In C#, the introduction of records and the with operator has made it much easier to have immutable types and values. In our Person record, the fields are immutable, so they can only be set once on an instance.
Our compiler will not allow the code snippet above to compile, as the properties on the Person record can only be set when the instance is instantiated. Therefore, what we actually need is an updated version of that instance. The with operator allows us to create a copy of the original record, initializing the members with the same values as the original, or a different value if specified within the with operator. Since the fields on our Person record are immutable, this will create a new instance of the Person record with the updated fields.
If another thread comes along and alters the _person private field somewhere within our sequence, our test would still succeed, as we’re not referring to a mutable instance. This is a prime example of pure functions, where we have no side effects.
If you want to leverage this in your own project there are a number of packages you could include in your project to get started quickly:
I hope you enjoyed this post on functional programming. By leveraging these concepts, we can create more maintainable, reusable and testable code. Although functional programming can be daunting at first, with a little bit of practice and experimentation, it can be a very powerful tool in your development arsenal. I hope you found this post informative and valuable.
Thank you for reading!