Optional module

By Martin McBride, 2022-07-24
Tags: optional
Categories: utility library


There are often situations in code where a required value doesn't exist. A typical way of dealing with this is to return None but optionals provide a cleaner way, and the optional.py module provides an easy-to-use implementation of optionals.

An example

For example, suppose we had a customer database, and we needed a function to find the value of a customer's most recent transaction:

def getLastTransactionAmount(customerId):
    ...
    return value

This is all fine except for the edge case of a customer who is registered but never bought anything. What should we return in that case?

  • We could return zero. But maybe our shop sometimes gives away free offers that get recorded as a transaction of value zero. We would then have no way to distinguish between a customer who has never bought anything, as opposed to a customer who has purchased many times before but their most recent transaction happens to be a freebie.
  • We could return None. That is fairly common practice but isn't necessarily good practice. It means the calling code has to check the type of the return value, which is all a little bit hacky. In addition, if we use truthy values to check for zero transactions, we might accidentally conflate None with zero, and they are both falsy.
  • Or we could raise an exception. Again this complicates the calling code. Not to mention that a registered customer who hasn't bought anything yet is hardly an exceptional situation.

What we really need is a clean way to return either a value or an unambiguous indication that the value doesn't exist.

Using optionals

To use the optional.py module, you must first install it. The easiest way is to use pip install to install it from the PyPi repository, or if you prefer it is available via Conda. Be sure to choose "optional.py" (there is a different module named "optional" that appears to be unmaintained).

The Optional class is a container class, It can either contain the value you wish to return, or it can be empty. Here is how to use the class Optional in your code:

from optional import Optional

def getLastTransactionAmount(customerId):
    ...
    if value_exists:
        return Optional.of(value)
    else:
        return Optional.empty()

We first import Optional. Then we use it to return a suitable value:

  • If the value exists, we return Optional.of(value), which is an Optional object with the value contained inside it.
  • If there is no value, we return Optional.empty(), which is an Optional object that contains nothing.

In both cases, the function returns an Optional object, so there is no ambiguity:

result = getLastTransactionAmount()

There are several ways for the caller to handle an optional. First, we could check if the result is present, and only handle it if it is there:

f = Optional.of(100)
e = Optional.empty()

if f.is_present():
    print(f.get())  # 100

if e.is_present():
    print(e.get())  # Never called, the if statement is false.

For illustration, we have created f (an Optional with a value 100) and e (an empty Optional).

f.is_present() returns true because f has a value, so the if body executes. We use f.get() to get the value 100 and print it.

e.is_present() returns false because e has an empty, so the if body is skipped.

An alternative is to supply a default to handle the empty case, If the value is present, its value is used, but if there is no value, the default is used:

print(f.get_or_default(10))  # 100
print(e.get_or_default(10))  # 10 (the default)

Another option is to raise an exception if the value isn't present. There are two ways to do this, but the second is best practice as the intent is more clear:

# Get without checking (bad practice)

print(f.get()) # 100
print(e.get()) # Raises an exception

# get_or_raise (best practice)

print(f.get_or_raise(SomeException)) # 100
print(e.get_or_raise(SomeException)) # Raises SomeException

Chaining and mapping

Optionals have their origins in functional programming, specifically the Maybe monad. So they fit very well with other functional techniques such as chaining and mapping.

Put simply, chaining and mapping allow us to pass values into functions without having to explicitly deal with getting the value or handling empties.

The if_present method checks if the Optional is not empty, and then calls the function, passing the value from the Optional. If the Optional is empty, the function is not called at all. Here it is in action:

def do_something(val):
    print("If present example", val)
    return -1

x = f.if_present(do_something)
x = e.if_present(do_something)

do_something is a function that does something with the value. Whatever function you use must take exactly one argument. In this case, it prints the value. So this bit of code runs do_something with the value of f (which is 100, see above):

x = f.if_present(do_something)

This prints:

If present example 100

There are a couple of important things to note here:

  • When we pass in do_something we are passing in the function object itself (strictly speaking it has to be a callable object, which usually means a function). We could also use a lambda here, see later.
  • The return value of do_something is completely ignored. if_present allows do_something to perform an action based on the value, but not to return a value. To do that you must use the map function as we will see. After executing the line above, x will be set equal to f.

If we repeat this with an empty optional, the do_something won't be called at all, and x will be set equal to e:

x = e.if_present(do_something)

The or_else method calls a function only if the optional is empty:

def supplier():
    print("In supplier")
    return -2

x = f.or_else(supplier)
x = e.or_else(supplier)

In this case, when we call f.or_else, the supplier function will not be called at all, and x will be set equal to f.

When we call e.or_else, the supplier function will be called. The supplier function returns -2, so or_else will return an Optional object with a value of -2. x will be set to that optional object. Notice also that the supplier function has no parameters.

This is a little bit like get_or_default except that it uses a supplier function. This is a subtle but important difference. Consider the following:

a = f.get_or_default(time_consuming_function())
b = f.or_else(time_consuming_function) 

In the first case, time_consuming_function is always called, because its result is needed to pass into get_or_default. So even though f has content so the default isn't needed, it is still calculated. Which is a waste of time.

In the second case, time_consuming_function isn't called until or_else is called. But because f has content, or_else won't be called so neither will time_consuming_function.

The other difference, of course, is that a will be set to 100 (the value of f) whereas b will be set equal to f itself (but you can easily get its value by calling b.get()).

Finally, mapping provides a way to easily call a function on a value wrapped in an optional. Here is how it works:

def add_1(val):
    return val + 1

def mul_2(val):
    return val*2

x = f.map(add_1).map(mul_2)
x = e.map(add_1).map(mul_2)

The functions add_1 and mul_2 are both simple functions that operate on numbers. The map function does three things:

  • Checks if the optional is empty. If it is, the empty value is returned immediately.
  • Gets the value from the optional (which at this stage we know is not empty).
  • Calls the function and returns the value as an optional.

So for the first call (involving f), the code first calls add_1 with the value of f (which is 100), so the result is an optional with a value of 101. It then calls mul_2 with this new optional. This results in an optional with value 202, which is returned and assigned to x.

In the second case, where we run the same code using the empty value e, the map method doesn't call add_1 or mul_2, and the value of x is an empty optional.

This provides a transparent way to run one or more functions on an optional value, that also handles empty optionals by simply setting the result to an empty optional.

There is also a variant of this, called flat_map. If you are writing code that uses optionals, you might sometimes have a useful function that does exactly what you need, but it just happens to have been written to return an optional rather than a raw value:

def add_2_opt(val):
    return Optional.of(val + 2)

x = f.map(add_2_opt)
x = f.flat_map(add_2_opt)

In this case add_2_opt returns an optional of val + 2 rather than just val + 2. This means that if we use map with this function we will get an optional of an optional of 102. Now that is perfectly valid but it almost certainly isn't what you want.

You could duplicate the function to create a similar function that returns a raw value, but code duplication is always best avoided. A better solution is to use flat_map. This is similar to map, except that it removes the extra layer of optionals.

Summary

The optional module provides a clean and simple alternative to the ambiguous use of None to represent optional values.

If you found this article useful, you might be interested in the book NumPy Recipes or other books by the same author.

Join the PythonInformer Newsletter

Sign up using this form to receive an email when new content is added:

Popular tags

2d arrays abstract data type alignment and angle animation arc array arrays bar chart bar style behavioural pattern bezier curve built-in function callable object chain circle classes clipping close closure cmyk colour combinations comparison operator comprehension context context manager conversion count creational pattern data science data types decorator design pattern device space dictionary drawing duck typing efficiency ellipse else encryption enumerate fill filter font font style for loop formula function function composition function plot functools game development generativepy tutorial generator geometry gif global variable gradient greyscale higher order function hsl html image image processing imagesurface immutable object in operator index inner function input installing iter iterable iterator itertools join l system lambda function latex len lerp line line plot line style linear gradient linspace list list comprehension logical operator lru_cache magic method mandelbrot mandelbrot set map marker style matplotlib monad mutability named parameter numeric python numpy object open operator optimisation optional parameter or pandas partial application path pattern permutations pie chart pil pillow polygon pong positional parameter print product programming paradigms programming techniques pure function python standard library radial gradient range recipes rectangle recursion reduce regular polygon repeat rgb rotation roundrect scaling scatter plot scipy sector segment sequence setup shape singleton slice slicing sound spirograph sprite square str stream string stroke structural pattern subpath symmetric encryption template tex text text metrics tinkerbell fractal transform translation transparency triangle truthy value tuple turtle unpacking user space vectorisation webserver website while loop zip zip_longest