Notes on Functional C#
- 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
- Pure and Impure Functions
- Partial Application and Currying
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.
C# 7
C# 7 introduced the is
operator.
The is
operator can be used to cast the source variable to the correct type.
public decimal CalculateTax(Office ofc)
{
// If real type of object is BranchOffice
if (ofc is BranchOffice bofc)
{
if (bofc.Revenue > 10000)
{
return bofc.Profit * bofc.TaxRate;
}
}
}
C# 7 also introduced called type switching.
public decimal CalculateTax(Office ofc)
{
switch (ofc)
{
case BranchOffice bofc when bofc.Revenue > 10000:
return bofc.Profit * bofc.TaxRate;
case RegionalOffice rofc:
return rofc.Profit * 0.1;
case HeadOffice hofc:
return hofc.Profit * 0.1;
default:
return 0;
}
}
C# 8
C# 8 updated the syntax for type switching.
The default case is now the _
- discard operator.
Object properties and sub-properties can be added in curly braces.
The switch can now also be an expression.
public decimal CalculateTax(Office ofc) => ofc switch
{
case BranchOffice { Revenue: > 10000 } bofc => bofc.Profit * bofc.TaxRate,
case RegionalOffice rofc => rofc.Profit * 0.1,
case HeadOffice hofc => hofc.Profit * 0.1;
_ => 0
}
C# 9
The and
and not
keywords now work inside patterns.
public decimal CalculateTax(Office ofc) => ofc switch
{
case BranchOffice { Revenue: > 10000 and Revenue: < 20000 } bofc => bofc.Profit * bofc.TaxRate,
case BranchOffice { Revenue: > 20000 and Location: not "London"} bofc => bofc.Profit * 0.05,
case BranchOffice { Revenue: > 20000 } bofc => bofc.Profit * 0.1,
case RegionalOffice rofc => rofc.Profit * 0.1,
case HeadOffice hofc => hofc.Profit * 0.1;
_ => 0
}
C# 10
C# 10 added the feature for comparing the properties of subobjects belonging to the type being examined.
public decimal CalculateTax(Office ofc) => ofc switch
{
case BranchOffice { Revenue: > 10000 and Revenue: < 20000 } bofc => bofc.Profit * bofc.TaxRate,
case BranchOffice { Revenue: > 20000 and Location: not "London"} bofc => bofc.Profit * 0.05,
case BranchOffice { Revenue: > 20000 } bofc => bofc.Profit * 0.1,
case BranchOffice { Revenue: > 20000 and Manager.FirstName: "Simon" } bofc => bofc.Profit * 0.1,
case RegionalOffice rofc => rofc.Profit * 0.1,
case HeadOffice hofc => hofc.Profit * 0.1;
_ => 0
}
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.
Pure and Impure Functions
Side Effects
A function is said to have side effects if it does any of the following:
-
Mutates global state
-
Mutates its input arguments
-
Throws exceptions
-
Performs any I/O operation
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.
Partial Application and Currying
Partial Application
Consider the following example:
var greet = (string greeting, string name) => $"{greeting}, {name}"; (1)
/* (string, string) -> string */ (2)
Assert.Equal("hello, world", greet("hello", "world"));
var greetWith = (string greeting) => (string name) => $"{greeting}, {name}"; (3)
/* (string) => (string) => string */ (4)
Assert.Equal("hello, world", greetWith("hello")("world")); (5)
1 | A C# function using the lambda syntax. |
2 | The signature of such a function. |
3 | Defining a function one parameter at a time using the lambda syntax. |
4 | The function signature of a partially applied function. |
5 | Notice the arguments are passed separately because passing the first argument returns a new function that accepts a string and passing the second argument executes that function to give a return value. |
A general Apply
function:
public static Func<T2,R> Apply<T1,T2,R>(
this Func<T1,T2,R> f, (1)
T1 t1 (2)
)
=> t2 => f(t1,t2); (3)
1 | A binary function. A function that has 2 parameters. |
2 | Passing the first argument. |
3 | Returns a unary function that takes the second argument of the original function |
Example reusing the previous greet
function:
var greetInformally = greet.Apply("Hey"); (1)
Assert.Equal("Hey, world", greetInformally("world")); (2)
1 | Returns a function with "Hey" baked in |
2 | Calls the function with the remaining parameter passed. |
In a nutshell, this is the concept of a partial application.
Here is the Apply
defined for a ternary function:
public static Func<T2, T3, R> Apply<T1, T2, T3, R>
(
this Func<T1, T2, T3, R> f,
T1 t1
)
=> (t2, t3) => f(t1, t2, t3);
Partial application allows you to define functions with more general parameters on application startup and then pass more specific parameters some time later when required.
For example, you could create a general database query function with its DbConnection
passed as the first argument on startup and later in the webapi you can pass the actual query as the second argument which will cause the function to execute and return the values.
The greatest benefit, according to me, is that you do not need to depend on a dependency injection framework. You can pass in the dependencies to the function itself. Dependency injection moves to the function level and not the class or object level.
This also improves the testing story because now you can manually create fake dependencies and pass them to the function instead of using a mocking library to create mocks and and then setup a DI framework to use those mocks.
If you want to use higher order functions that take multi-argument functions as arguments, it is best to move away from using methods and write Funcs instead. (or methods that return Funcs .)
|
Currying
Currying is the process of transforming an n-ary function that takes the arguments t1, t2, …, tn into a unary function that takes t1 and yields a new function that takes t2, and so on, ultimately returning a result once the arguments have all been given.
It is possible to define generic functions that take an n-ary function and curry it.
// Binary function
public static Func<T1, Func<T2, R>> Curry<T1, T2, R>(this Func<T1, T2, R> f)
=> t1 => t2 => f(t1, t2);
// Ternary function
public static Func<T1, Func<T2, Func<T3, R>>> Curry<T1, T2, T3, R>(this Func<T1, T2, T3, R> f)
=> t1 => t2 => t3 => f(t1, t2, t3);
Difference between Partial application and Currying
Partial application - You give a function fewer arguments than the function expects, obtaining a function that’s particularized with the values of the arguments given so far.
Currying - You don’t give any arguments; you just transform an n-ary function into a unary function to which arguments can be successively given to eventually get the same result as the original function.
Currying doesn’t really do anything; rather, it optimizes a function for partial application.