Functional Programming in C# (WIP)

one-pagers csharp

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.

Deconstructing a record
static decimal Vat(Address address, Order order)
=> address switch
{
	Address("de") => DeVat(order),
	Address(var country) => Vat(RateByCountry(country), order),
};
Simplified version
static decimal Vat(Address address, Order order)
=> address switch
{
	("de") _ => DeVat(order),
	(var country) _ => Vat(RateByCountry(country), order),
};
Property Pattern
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.

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

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.

Types of Concurrency
  • Asynchrony: Your program performs non-blocking operations.

  • Parallelism Your program leverages the hardware of multicore machines to execute tasks at the same time by breaking up work into tasks, each of which is executed on a separate core.

  • Multithreading: A software implementation that allows different threads to execute concurrently. A multithreaded program appears to be doing several things at the same time even when it’s running on a single-core machine.

Examples of Concurrency
  • Asynchrony: It’s a bit like when you send an email and then go on with your life without waiting for a response.

  • Parallelism: It’s a bit like singing in the shower: you’re actually doing two things at exactly the same time.

  • Multithreading: This is a bit like chatting with different people through various IM windows, although you’re actually switching back and forth. The net result is that you’re having multiple conversations at the same time

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 than T?.

  • 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 type T.

  • It returns a C<R> after applying the function T→R to the input T.

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 type T.

  • It returns a C<R> after applying the function T→C<R> to the input T.

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, or DayofWeek, are all examples of regular values.

  • Elevated values:- which we will call A<T>. Option<T>, IEnumerable<T>, Func<Neighbour>, or Task<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 a T but the possibility of T.

  • IEnumerable adds the effect of aggregation, which is not T or two but a sequence of `T`s.

  • Func adds the effect of laziness, which is not a T but a computation that can be evaluated to obtain a T.

  • Task adds the effect of asynchrony, which is not a T but a promise that at some point you’ll get a T.

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 for Option.

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.

References