Maybe -> Possible absence of data
We all know about the one-billion-dollar mistake that the invention of the null pointer
caused. However, it’s not that big of a problem if you can find a solution to abstract away the dirty business of dealing with it. So, the problem is not that the object is null
but the fact that you are trying to do something with it when you don’t know whether it is null
or not.
In C#, the default value for reference type variables is null
. So the object is laying somewhere on the heap (or maybe there is no object) but the variable holds no reference to it. So, when we try to do something with that object, not knowing that there is no reference to it, we get the notorious NullReferenceException
saying "Object reference not set to an instance of an object"
.
In C#, we also have nullable value types (Nullable<T> where T : struct
or simply T?
) which is a type constructor for value types that tells us that the underlying value may not be present. From C# 8 onwards, we also have nullable reference types that can be used to warn us that the corresponding reference type object may be null
, however, we are not forced by the compiler to handle it in any way. With Maybe
, you will be able to address these issues with ease and have clean code without repetitive null checks, guards, etc. Additionally, you will be forced by the compiler to resolve the value before using it. It is a value type (struct
) and therefore can’t be null and its default value is simply an empty Maybe
object.
Lifting functions
There are a few explicit ways of creating a Maybe
object.
var name = Maybe.Create("John"); // using a factory method -> Maybe<string>
var customer = customers.GetOrNull(id).AsMaybe(); // using an extension method -> Maybe<Customer>
int? nullable = null;
var number = may(nullable); // using a Prelude function -> Maybe<int>
The important thing to note here is that the Maybe
resolves the nullable type automatically. So, the number is of type Maybe<int>
and not Maybe<int?>
. To use the may
function, you need to import the Prelude
as a static reference.
There is an implicit conversion between an object and a Maybe
of that object so the following is legal.
public Maybe<Customer> GetCustomer(Guid id) => customers.Get(id);
Here, the Get
function returns a Customer
object which is implicitly converted to the Maybe<Customer>
object. With the Maybe
type, besides safety, we also ensure that our function is honest. We improve the level of abstraction as the caller is not forced to look into the implementation to see what happens and what does the function do if the item is not found. We do not lie to the caller saying that we will return the object no matter what. We are being honest and by that, we make it easier for the callers as they won’t have to write null checks
, try-catch blocks
, etc. They will just need to resolve the Maybe
type object and the following examples will show how we can do that with ease.
Maybe
is a concept present in FP languages. In F#, it is called an Option
same as in Scala. In Haskell, it is called Maybe
the same as in Funk. In OOP, we have a pattern that tries to accomplish a similar thing called Optional pattern
.
In Funk, the Maybe
type is a construct that is a functor
, an applicative
, and a monad
(actually, once you satisfy the rules of being a monad
you can easily satisfy the rules for being the first two as the monad
is the most complex and powerful of the three). Object-oriented programmers may be unfamiliar with these terms as they come from the Category Theory
. To get familiar with these concepts, the best resource is Bartosz Milewski’s Category Theory for Programmers.
To use Funk, you don’t have to know these concepts as you will get to know them through various examples. However, learning these concepts will open a whole new world for you and will change the way you think about the software in general.
For something to be a monad
or a functor
, it has to obey certain rules in a specific way. You can think of them as specific type constructors (generic types) that amplify the underlying type they wrap. So, if you have a string object, it has a certain set of functions available that you can use to perform certain operations. For example, a function Split
splits a string into substrings based on the provided character separator. Now, what a functor
or a monad
can do is to lift that string into what’s called an elevated world
where that string suddenly has more functions available that can be quite useful. Besides power that the elevated world
brings, it also brings clarity and safety to your codebase.
We are not going to explain these rules one by one here. Instead, we will see them and many other functions that Funk provides in action where it will be obvious what benefit they bring.
Pattern-matching
Let’s start with a Match
function.
Match
is a pattern-matching function that provides a nice way of handling the possible absence of data. So let’s say we have a function that might return a Customer
and we want to get its middle name and in case there is no object, return a default placeholder.
var customer = customers.Get(id); // returns Maybe<Customer>
var middleName = customer.Match(
_ => "", // _ is a Unit
c => c.MiddleName // c is a Customer
); // string
Match
has 2 cases it covers. The first one represents an empty case when the Maybe
object is empty. Empty value is represented by Unit
. The second case is executed if the Maybe
object is not empty. This way, we expressed what we want in a pretty straightforward way without using statements.
In case we didn’t care about the empty case we could just write a single case when it has value.
var middleName = customer.Match(
c => c.MiddleName // c is a Customer
); // string
However, if the object is empty, EmptyValueException
will be thrown. In case, you want to throw another type of Exception you can specify that in the second case.
var middleName = customer.Match(
c => c.MiddleName // c is a Customer
_ => new CustomException("Customer not found.")
); // string
You can also use Action
type delegates instead of Func
in case you don’t want to return a value.
customer.Match(
_ => Console.WriteLine("Customer not found"),
c => Console.WriteLine(c.MiddleName)
);
Now, imagine a case where some customers have empty (null
) middle names. Operating even after the Match
function on the result would cause an exception. This is because we evaluated the Customer
object and not its inner properties.
We can fix this by using the Map
function.
Functor
var middleName = customer.Map(c => c.MiddleName); // results in Maybe<string>
Map
is a function that makes the Maybe
type a functor
. It takes an elevated T
object (Maybe<T>
) and applies a function to its inner value (if present) and returns an elevated R
object (Maybe<R>
). With the Map
function, we completely solve the issue with the possible absence of data. If the Customer
object is empty, the Map
will not execute the provided function and will return an empty Maybe
. In case it is not empty, it will unwrap the underlying value, execute the provided function, and wrap the result back into Maybe
. It wraps the result back as the result itself can be empty. This way, we stay in the elevated world
. And you will see that it is better to stay in the world of elevated values as much as possible.
Even though the Map
function is powerful, we still need another function that can help us when working with nested Maybe
objects. Imagine that, instead of returning the middle name, we performed some operation on a Customer
object that returns another Maybe
object.
var account = customer.Map(c => accounts.Get(c.AccountId)); // results in Maybe<Maybe<Account>>
Here, the GetAccount
function returns the Maybe<Account>
object. We end up with the nested Maybe
object and it becomes tricky to unwrap it. What we need here is to somehow flatten (in FP, we call this bind) the result.
We can fix this by using the FlatMap
function.
Monad
var account = customer.FlatMap(c => accounts.Get(c.AccountId)); // results in Maybe<Account>
FlatMap
is a function that makes the Maybe
type a monad
(along with the lifting function that was described earlier). It takes an elevated T
object (Maybe<T>
) and applies a function to its inner value (if present) and returns the result of that function (Maybe<R>
). The Map
function uses the FlatMap
function internally with the additional wrapping of the result. This is why a monad
is more powerful than a functor
as you can implement the Map
function using the FlatMap
function but not vice versa.
Async
versions of Map
and FlatMap
are available as well (MapAsync
and FlatMapAsync
). Match
, on the other hand, does not require an async
version as you can simply return a Task<R>
as a result of an operation.
var account = customer.FlatMapAsync(c => accounts.GetAsync(c.AccountId)); // results in Task<Maybe<Account>>
Here, GetAccountAsync
function returns a Task<Account>
and we need to use the async
version of the FlatMap
. Otherwise, the result would be Maybe<Task<Account>>
which doesn’t make much sense as you wouldn’t be able to continue working with it properly.
Async
versions also support transformations directly on Task<T>
as long as the T
is a Maybe
object.
var account = customers.GetAsync(id).FlatMapAsync(c =>
accounts.GetAsync(c.AccountId)
); // results in Task<Maybe<Account>>
Other useful functions
Another interesting function is Or
and its async
versions. It basically says: “Give me the first non-empty Maybe
object from the two provided”. We can do many useful operations using this function. For example, we can aggregate on the list of Maybe
objects and find the first non-empty object.
var accounts = customers.Select(c => c.GetAccount(c.AccountId)); // IEnumerable<Maybe<Account>>
var account = accounts.Aggregate((first, second) => first.Or(_ => second)); // Maybe<Account>
Or
function accepts a function and in case a first object is not empty, the function is not evaluated. This is because, we do not want to call a function if it is not necessary. We try to be as lazy as possible. Consider the following example.
var avatar = await customer.FlatMapAsync(c => images.GetAvatarAsync(c.AvatarId)).OrAsync(async _ =>
(await images.GetDefaultAvatarAsync()).AsMaybe()
); // Maybe<Avatar>
The GetDefaultAvatar
function is only called if either there is no customer or if the customer did not set up the avatar image.
Sometimes, we just want to unwrap the value without using any of the above-mentioned functions. We can use the GetOr
function and its async
versions for that.
var middleName = customer.Map(c => c.MiddleName).GetOr(_ => ""); // string
GetOr
tries to retrieve the underlying value. In case it is empty, it executes the function provided, and returns its result.
Properties IsEmpty
and NotEmpty
tell you whether the specified Maybe
object is empty or not.
There are also GetOrDefault
and UnsafeGet
functions but they should be used with caution!
Funk provides many other helpful functions for working with the Maybe
type and some of them will be mentioned later on.
Applicative (applicative functor)
As mentioned, the Maybe
type is also an applicative
. Applicatives
are less powerful than a monad
, but more powerful than a functor
. As we saw in the previous examples with Map
and FlatMap
functions, the functions provided as arguments are coming from the so-called normal
(regular
) world. With applicatives
, even the function provided as an argument belongs in the world of elevated values.
To understand applicatives
, it is best to understand the concept of the partial application first.
Funk provides the Apply
function for the Maybe
type that behaves similarly to the Apply
function from the partial application
but it operates in the elevated world
. If you understand the benefit that the partial application
brings, you can easily see the benefit of the Apply
function for the Maybe
type.
We can have a function that concatenates two strings if both of them are not null
or empty.
public static Func<string, string, string> FullName => (name, surname) => $"{name} {surname}";
Now, we can apply arguments one by one, and to check whether the string is null
/empty or not, we can use the AsNotEmptyString
extension method.
var name = customer.FlatMap(c => c.Name.AsNotEmptyString());
var applied = FullName.AsMaybe().Apply(name); // Maybe<Func<string, string>>
As opposed to the partial application
, here we have to lift the function to the Maybe
value and then use the Apply
function.
var surname = customer.FlatMap(c => c.Surname.AsNotEmptyString());
var fullName = applied.Apply(surname); // Maybe<string>
In this scenario, when we apply the second argument, we will execute the pipeline created and maybe get the result back. It means that, if all the previously applied arguments together with the function are not empty, we will get back the full name of the customer.
The beauty of this approach is that we didn’t have to implement any logic regarding null
/empty checks inside the Function
as it is done for us through the Apply
function. We also didn’t have to change the signature of the function to accept Maybe
type objects.
LINQ compatibility
LINQ
stands for Language Integrated Query and if you haven’t noticed, it is the functional programming library as well. However, it is primarily intended for working with sequences that implement IEnumerable
. Actually, IEnumerable
is a monad
. As you saw from the previous examples, lifting and flattening (FlatMap
) functions make a certain type a monad
. IEnumerable
provides lifting functions through its specific implementations (e.g. new List<T>()
) and its flattening functions are maybe better known to you as SelectMany
functions. From this, you can probably deduce that IEnumerable
is also a functor
as it provides mapping functions as well. They are maybe better known to you as Select
functions.
Developers usually tend to work with sequences functionally (relying on expressions rather than statements), but with other types, it is not the case. Suddenly, the code is full of loops
and if-else
statements. It breaks the fluency and makes the code unreadable.
Maybe
type has some of the LINQ
functions implemented. Not all of them are implemented as it simply doesn’t make much sense. So instead of using the Map
and FlatMap
functions, you can use the corresponding Select
and SelectMany
functions. The Where
function is also implemented which returns a non-empty Maybe
object only if the item is not empty and if the predicate
criteria are satisfied.
The purpose of these functions is to be able to write using the query syntax
instead of the fluent API
as sometimes it makes the code more readable.
The following example is retrieving a list of parent accounts for the list of related customers (IEnumerable<Maybe<Customer>>
). So, we need to first get the account and then get the parent account. With the query syntax
, this expression is readable and pretty clear.
var parentAccount = relatedCustomers.Select(customer =>
from c in customer
from a in accounts.Get(c.AccountId)
select accounts.GetParentAccount(a.ParentAccountId)
);
The same expression can be expressed using the fluent API
but it becomes quite hard to understand what is actually going on.
var parentAccount = relatedCustomers.Select(customer => customer.SelectMany(c =>
accounts.Get(c.AccountId), (c, a) => accounts.GetParentAccount(a.ParentAccountId))
);
The Where
function behaves similarly as the one provided for IEnumerable
types. The following example returns the account if the balance is more than 100. It also checks whether the customer and account are not empty and only if all these conditions are met, it returns the account.
var account = from c in customers.Get(id)
from a in accounts.Get(c.AccountId)
where a.Balance > 100
select a;
As we see, the query syntax
makes this expression quite readable. With the fluent API
, we can accomplish the same thing, but it makes our code quite messy.
var account = customers.Get(id).SelectMany(
c => accounts.Get(c.AccountId),
(_, a) => a
).Where(a => a.Balance > 100);
After all, the query syntax
is a syntactic sugar and it uses the fluent API
methods underneath, so why not use it in situations when it makes your code more readable :)