Optional Values#
Sometimes we don’t have a value for a given variable. Perhaps the value is not known or
available yet. In Python we represent the absence of a value with the special value
None
. In other languages there is usually a null
value.
xs = None
print(xs)
None
Without type hints we don’t really know if the value is supposed to be `None´ or something else.
Null Reference Exceptions#
The billion-dollar mistake
Speaking at a software conference in 2009, Tony Hoare apologized for inventing the null reference:
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
We don’t have null-values in Python, but we have None
values. Dereferencing a None
value will lead to a NameError
:
xs.run()
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[2], line 1
----> 1 xs.run()
AttributeError: 'NoneType' object has no attribute 'run'
With type hints we can say that this is supposed to be an integer, but the value is missing, so we currently don’t know what integer just yet:
from typing import Optional
xs: Optional[int] = None
print(xs)
None
Testing for optional values#
We can test for optional values using is None
or is not None
:
xs = None
assert xs is None
y = 42
assert y is not None
In addition we have a number of falsy values:
assert not None
assert not 0
assert not []
assert not {}
assert not ()
assert not ""
Problems with Nullable Types#
Using Optional
and nullable types in general has a lot of advantages since a compiler
or static type checker can help us avoid using optional values before we have done
proper testing first. The type Optional[A]
is the same as Union[A, None]
which means
that there still a few more problems:
It’s easy to forget to check for
None
, but a static type checker will helpExtensive
None
checking can create a lot of noise in the code, increasing the cognitive loadOptional types cannot be nested. How do we differ between
None
being a proper values andNone
for telling that the value is missing i.eUnion[None, None]
? There is no equivalent of e.g a list containing an empty list e.g[[]]
.
Example: for dictionaries, how do we know if the key is missing or if the value is
None
?
mapping = dict(a=None)
mapping.get("a")
Options#
In functional programming we use the Option (or Maybe) type instead of None
and
null
. The Option type is used when a value could be missing, that is when an actual
value might not exist for a named value or variable.
An Option has an underlying type and can hold a value of that type Some(value)
, or it
might not have the value and be Nothing
.
The Expression library provides an option
module in the expression
package:
from expression import Option, option, Some, Nothing
Create option values#
Create some values using the Some
constructor:
from expression import Some
xs = Some(42)
print(xs)
Some 42
You should not normally want to retrieve the value of an option since you do not know if
it’s successful or not. But if you are sure it’s Some
value then you retrieve the
value back using the value
property:
from expression import Some
xs = Some(42)
assert isinstance(xs, Some) # important!
xs.value
To create the Nothing
case, you should use the Nothing
singleton value. In the same
way as with None
, this value will never change, so it’s safe to re-use it for all the
code you write.
from expression import Nothing
xs = Nothing
print(xs)
Nothing
To test if an option is nothing you use is
test:
xs = Nothing
assert xs is Nothing
Option returning functions#
Values are great, but the real power of options comes when you create option returning functions
def keep_positive(a: int) -> Option[int]:
if a > 0:
return Some(a)
return Nothing
keep_positive(42)
Option(some=42)
keep_positive(-1)
Option(none=None)
We can now make pure functions of potentially unsafe operations, i.e no more exceptions:
def divide(a: float, divisor: float) -> Option[int]:
try:
return Some(a/divisor)
except ZeroDivisionError:
return Nothing
divide(42, 2)
Option(some=21.0)
divide(10, 0)
Option(none=None)
Transforming option values#
The great thing with options is that we can transform them without looking into the box. This eliminates the need for error checking at every step.
from expression import Some, option, pipe, Nothing
xs = Some(42)
ys = pipe(
xs,
option.map(lambda x: x*10)
)
print(ys)
Some 420
If we map a value that is Nothing
then the result is also Nothing
. Nothing in,
nothing out:
xs = Nothing
ys = pipe(
xs,
option.map(lambda x: x*10)
)
print(ys)
Nothing
Option as an effect#
Effects in Expression is implemented as specially decorated coroutines (enhanced
generators) using yield
, yield from
and
return
to consume or generate optional values:
from expression import effect, Some
@effect.option[int]()
def fn():
x = yield 42
y = yield from Some(43)
return x + y
fn()
Option(some=85)
This enables “railway oriented programming”,
e.g., if one part of the function yields from Nothing
then the function is
side-tracked (short-circuit) and the following statements will never be executed. The
end result of the expression will be Nothing
. Thus results from such an option
decorated function can either be Ok(value)
or Error(error_value)
.
from expression import effect, Some, Nothing
@effect.option[int]()
def fn():
x = yield from Nothing # or a function returning Nothing
# -- The rest of the function will never be executed --
y = yield from Some(43)
return x + y
fn()
Option(none=None)
For more information about options: