Functional Programming in C# (WIP)
- Introduction
- Concepts
- The Option<T> type
- The Smart Constructor Pattern
- Guidelines to preventing NullReferenceException
- The Map Function
- Functors
- The Bind Function
- Monad
- The Return Function
- Common Synonyms for Functional Methods
- Combining IEnumerable with Bind
- Regular vs Elevated Values
- Crossing Levels of Abstraction
- Function Composition
- References
Introduction
Functional Programming is a programming style that emphasizes functions while avoiding state mutations.
It includes two fundamental concepts.
-
Functions as first-class values.
-
Avoiding state mutations.
Concepts
Pattern Matching
Lets you use the switch keyword to match not only on specific values but also on the shape of the data, most importantly its type.
Records
Boilerplate-free immutable types with built-in support for creating modified versions.
static decimal Vat(Address address, Order order)
=> address switch
{
Address("de") => DeVat(order),
Address(var country) => Vat(RateByCountry(country), order),
};
static decimal Vat(Address address, Order order)
=> address switch
{
("de") _ => DeVat(order),
(var country) _ => Vat(RateByCountry(country), order),
};
static decimal Vat(Address address, Order order)
=> address switch
{
{ Country: "de" } => DeVat(order),
{ Country: var c } => Vat(RateByCountry(c), order),
};
Delegates
Delegates are type-safe function pointers.
Type-safe here means that a delegate is strongly typed.
The types of the input and output values of the function are known at compile time.
Creating a delegate is a two-step process:
-
First, declare the delegate type.
-
Second, provide the implementation.
public delegate int Comparison<in T>(T x, T y);
Instead of creating a custom delegate like this:
delegate Greeting Greeter(Person p);
you can simply use
Func<Person, Greeting>
Lambda expressions
They are used to declare a function inline.
Higher Order Functions (HOFs)
HOFs are functions that take other functions as inputs or return a function as output or both.
Parts of your program that consist entirely of pure functions can be optimized in a number of ways
-
Parallelization: Different threads carry out tasks in parallel.
-
Lazy evaluation: Only evaluates values as needed.
-
Memoization: Caches the result of a function so that it’s only computed once.
Concurrency
Concurrency is the general concept of having several things going on at the same time.
More formally, concurrency is when a program initiates a task before another one has completed so that different tasks are executed in overlapping time windows.
Pure Functions
A pure function has the following properties:
-
Output depends entirely on the input arguments.
-
Causes no Side Effects.
Impure Functions
An impure function has the following properties:
-
Factors other than input arguments can affect the output.
-
Can cause Side Effects.
Static Methods
When all variables required within a method are provided as input, you can define the methods as static.
Static methods can cause problems if they do either of the following:
-
Mutate static fields - These are effectively the most global variables. They can be updated from any code or any thread. Not safe to use in a large application.
-
Perform I/O - In this case, it is the testability that is the problem. If Method A depends on the I/O behaviour of static method B, it is not possible to unit test A.
When a function is pure, it can safely be marked as static.
As a general guideline:
-
Make pure functions static.
-
Avoid mutable static fields.
-
Avoid directly calling static methods that perform I/O.
The more you code functionally, most of your functions will be pure. This means more of the code will be in static classes.
Domain Thinking
Think in terms of custom types.
Age is not an int, Age is a custom type with its own validations.
AmountPaid is not a decimal, AmountPaid is a custom type with its own validations.
Generally, create a custom type if validation of the values of any int or string or decimal or type is involved.
It is easier to think in domain terms like Age and AmountPaid instead of int and decimal.
record
and struct record
make it very easy to create a lot of custom domain types.
An Honest Function
-
It returns a value of the declared type.
-
It does not throw exceptions.
-
It never returns null.
The Option<T> type
When coding functionally, you never use null…ever.
Instead, use the Option<T>
type.
Option<T>
is a container that wraps a value…or no value.
The symbolic definition for Option: Option<T> = None | Some<T>
-
None - indicating the absence of a value.
-
Some(T)
- indicating the presence of a value T.
A sample implementation of Option<T>
(not for real world use):
public struct NoneType { } (1)
public struct Option<T>
{
readonly T? value; (2)
readonly bool isSome; (3)
public Option(T value) (4)
{
this.value = value ?? throw new ArgumentNullException();
this.isSome = true;
}
public static implicit operator Option<T>(NoneType _) (5)
=> default;
public static implicit operator Option<T>(T value)
=> value is null ? None : Some(value);
public R Match<R>(Func<R> None, Func<T, R> Some) (6)
=> isSome ? Some(value!) : None();
}
public static partial class F
{
public static Option<T> Some<T>(T value) => new Option<T>(value); (7)
public static NoneType None => default; (8)
}
using static F;
string Greet(Option<string> greetee) (9)
=> greetee.Match(
None: () => "Sorry, who?",
Some: (name) => $"Hello, {name}"
);
System.Console.WriteLine(Greet(Some("Sir"))); (10)
System.Console.WriteLine(Greet(None)); (11)
1 | A dedicated, non-generic type, NoneType |
2 | The value wrapped by Some |
3 | Indicates whether the Option is Some or None |
4 | Constructs an option in the Some state. |
5 | Constructs an option in the None state. |
6 | Once an option is constructed, the only way to interact with it is with Match. |
7 | The static Some function which easily allows to create an Option. |
8 | The static None value which can be used to represent the NoneType option. |
9 | Sample usage. |
10 | Outputs - "Hello, Sir" |
11 | Outputs - "Sorry, who?" |
This implementation has the following advantages:
-
Better performance as structs are allocated on the stack.
-
Being a struct, an option cannot be null.
-
The default value of an Option is None.
The Smart Constructor Pattern
Instead of calling a constructor, you can define a function that returns Some or None.
This is known as a smart constructor.
It is smart in the sense that it is aware of some rules and can prevent the construction of an invalid object.
Sample implementation of Age
:
using static F;
public struct Age
{
public int Value { get; }
public static Option<Age> Create(int age) (1)
=> IsValid(age) ? Some(new Age(age)) : None;
private Age(int value) (2)
=> Value = value;
private static bool IsValid(int age)
=> 0 <= age && age < 120;
}
1 | A smart constructor returning an Option. |
2 | The constructor should now be marked private. |
Now instead of int
, you will work with Option<Age>
which forces you to account for the failure case.
Guidelines to preventing NullReferenceException
-
Enable Null Reference Types feature.
-
For optional values, use
Option<T>
rather thanT?
. -
Identify the boundaries of your code
-
Public methods exposed by libraries that you intend to publish or share across projects.
-
Web APIs
-
Listeners to messages from message brokers or queues.
-
-
In those boundaries, prevent null values from seeping in
-
For required values:
-
Throw an ArgumentNullException - Use Guard Clauses.
-
Return a status code of 400 (Bad request)
-
Reject the message
-
-
For optional values, convert null values into Options:
-
This can be trivially done using implicit conversions.
-
Add conversion logic to the formatter that deserializes your data.
-
-
-
Where you consume .NET or third-party libraries, you need to prevent
null
from seeping in.
The Map Function
The most famous map function in the world is the IEnumerable.Select<T>()
function.
In FP, it is called Map
. In LINQ, it is called Select
.
Signature of Map
for Option
: (Option<T>, (T → R)) → Option<R>
public static Option<R> Map<T, R>(
this Option<T> optT,
Func<T, R> f
) =>
optT.Match(
() => None,
(c) => Some(f(c))
);
Functors
The Map
function can be generalized as follows:-
Map: (C<T>, (T→R)) → C<R>
-
C<T>
is a generic container that wraps some inner value of typeT
. -
It returns a
C<R>
after applying the functionT→R
to the inputT
.
In FP, a type which implements a Map
function is called a functor.
IEnumerable
and Option
are functors.
However, a Map
should apply a function to the container’s inner value and do nothing else.
A Map
should have no side effects.
The Bind Function
The Bind
function is useful for option-returning functions.
Signature:- Option.Bind : (Option<T>, (T→ Option<R>)) → Option<R>
Bind
takes an Option
and an Option
-returning function and applies the function to the inner value of the Option
.
public static Option<R> Bind<T, R>(
this Option<T> optT,
Func<T, Option<R>> f
)
=> optT.Match(
() => None,
(c) => f(c)
);
Monad
The Bind
function can be generalized as follows:-
Bind: (C<T>, (T→C<R>)) → C<R>
-
C<T>
is a generic container that wraps some inner value of typeT
. -
It returns a
C<R>
after applying the functionT→C<R>
to the inputT
.
In FP, a type which implements a Bind
function and a Return
function is called a monad.
A Bind
method allows you to combine two (or more) monad-returning functions without ending up with a nested structure.
To summarize, a monad is a type M<T>
for which the following functions are defined:
Return : T -> M<T>
Bind: (M<T>, (T -> M<R>)) -> M<R>
The Return Function
A monad must also have a Return
function that lifts or wraps a normal value T
into a monadic value C<T>
.
Signature:- Option.Return : (T→ Option<T>)
Common Synonyms for Functional Methods
-
Bind - SelectMany, FlatMap, Chain, Collect, Then
-
Map - Select, fMap, Project, Lift
-
Where - Filter
-
ForEach - Iter
-
Return - Pure
Combining IEnumerable with Bind
Converting an Option to IEnumerable is easy.
public struct Option<T>
{
public IEnumerable<T> AsEnumerable()
{
if(isSome) yield return value!;
}
}
If the Option
is Some
, the resulting IEnumerable<T>
yields one item; if None, it yields no item.
Functions that map between functors, such as AsEnumerable
, are called natural transformations and are very useful in practice.
In some scenarios, you may end up with an IEnumerable<Option<T>>
(or an Option<IEnumerable<T>>
) and want to flatten it into an IEnumerable<T>
.
record Person(Option<Age> Age);
IEnumerable<Person> Population => new[]
{
new Person(Age.Create(33)),
new Person(None), (1)
new Person(Age.Create(37)),
}
IEnumerable<Option<Age>> optionalAges = Population.Map(p => p.Age); (2)
// => [Some(Age(33)), None, Some(Age(37))]
// This returns an IEnumerable<R> of values that are not None.
public static IEnumerable<R> Bind<T,R>
(this IEnumerable<T> list, Func<T, Option<R>> func) (3)
=> list.Bind(t => func(t).AsEnumerable());
// This returns an IEnumerable<R> of values for an Option.
public static IEnumerable<R> Bind<T,R>
(this Option<T> opt, Func<T, IEnumerable<R>> func) (4)
=> opt.AsEnumerable().Bind(func);
var optionalAges = Population.Map(p => p.Age); (5)
// => [Some(Age(33)), None, Some(Age(37))]
var statedAges = Population.Bind(p => p.Age); (6)
// => [Age(33), Age(37)]
var averageAge = statedAges.Map(age => age.Value).Average(); (7)
1 | This person did not disclose Age . |
2 | Selects all the values for Age. |
3 | We can use the first overload to get an IEnumerable<T> , where Map would give us an IEnumerable<Option<T>> . |
4 | We can use the second overload to get an IEnumerable<T> , where Map would give us an Option<IEnumerable<T>> . |
5 | Map returns an IEnumerable<Option<T>> . |
6 | Bind returns an IEnumerable<T> which contains only the success values. |
7 | Use Map or Select to calculate the Average . |
Regular vs Elevated Values
When working with IEnumerable<int>
or Option<Employee>
, you are coding at a higher level of abstraction than when working with non-generic type like int
or Employee
.
-
Regular values:- which we will call
T
.String
,int
,Neighbour
, orDayofWeek
, are all examples of regular values. -
Elevated values:- which we will call
A<T>
.Option<T>
,IEnumerable<T>
,Func<Neighbour>
, orTask<bool>
, are all examples of elevated values.
Elevated types imply an abstraction of the corresponding regular types.
Abstraction is a way to add an effect to the undelying type.
-
Option
adds the effect of optionality, which is not aT
but the possibility ofT
. -
IEnumerable
adds the effect of aggregation, which is notT
or two but a sequence of `T`s. -
Func
adds the effect of laziness, which is not aT
but a computation that can be evaluated to obtain aT
. -
Task
adds the effect of asynchrony, which is not aT
but a promise that at some point you’ll get aT
.
Crossing Levels of Abstraction
-
Functions mapping regular value
-
Signature:
T → R
-
Example:
(int i) ⇒ i.ToString()
-
-
Functions mapping elevated values
-
Signature:
A<T> → A<R>
-
Examples: Map, Bind, Where, OrderBy
-
-
Upward Crossing Functions
-
Signature:
T → A<R>
-
Examples: Return
-
-
Downward Crossing Functions
-
Signature:
A<T> → R
-
Examples: Average, Sum, Count for
IEnumerable
and Match forOption
.
-
Function Composition
In a nutshell, function composition means composing function calls in such a way that the output of one function becomes the input to the following function.
When you think in this way, you start to look at your program in terms of data flow.
The program is just a set of functions, and data flows through the program through one function and into the next.