Effects
Effects handling in XFP are based on the functional pattern railway oriented programming
As you may see in Functional Programming in Python, a good practice is to encode semantic in your functions signatures. XFP provides a unique way to encode different semantics at once through its Xresult type.
What is an effect
In the functional world, an effect is the description of an operation interacting with the outside world, thus making the function unpure. It ranges from I/O operation to mutating something not in in the function scope. Its consequences (overly simplified) result often in something that is not a value (much like None
or raising Exception
).
import pandas as pd
import math
# read_csv raises FileNotFoundError when phantom_file does not exist
df = pd.read_csv("phantom_file.csv")
# sqrt return None when i < 0. The function is partial
def sqrt(i):
if i >= 0:
return math.sqrt(i)
One of the property of the function is that it should always return a value. Therefore effects break the functional paradigm and should be handled. Python handles them with keywords, however we will handle them with values, introducing the Xresult XFP type to catch the output of an effect.
import pandas as pd
# Python handles errors with try/except
try:
df = pd.read_csv("phantom_file.csv")
except Exception as e:
print(e)
Mecanisms
Type Structure
Xresult[Y, X]
can be seen as a collection of one unique element value
, being either of type Y or X. It always holds a branch
attribute as a semaphore of which type between X or Y is used. branch is a value of the XRBranch
enumeration, equals to XRBranch.LEFT
if the value
is a Y or a XRBranch.RIGHT
if the value is a LEFT.
from xfp import Xresult, XRBranch
from typing import Any
import math
result1: Xresult[Any, int] = Xresult(3, XRBranch.RIGHT)
result2: Xresult[str, Any] = Xresult("abc", XRBranch.LEFT)
# this is a preview of the added semantic of returning an Xresult
def partial_sqrt(i: int) -> Xresult[str, int]:
if i >= 0:
return Xresult(math.sqrt(i), XRBranch.RIGHT)
else:
return Xresult(f"{i} cannot be square rooted", XRBranch.LEFT)
You may find the partial_sqrt error handling tedious. This is completely normal since this is an overview of the plain effect handling in xfp. For more information about quality of lifes with effects, see Wrapping Python
API
As a collection
As a collection with a unique element, Xresults are homogene with the Collections API. It means you can process the value of an Xresult using the standard map
, flat_map
, filter
& co functions. However to take the branch into account, each function is divided into a “_left” and a “_right” version, each one either applying the transformation if the branch corresponds, or being a go-through otherwise.
from xfp import Xresult, XRBranch
def throws(x: Exception):
raise x
start = Xresult(1, XRBranch.RIGHT)
stop = (
start
.map_right(lambda x: x + 10) # XRBranch(1 + 10, XRBranch.RIGHT) because result is a RIGHT
.map_left(lambda y: y * 20) # go-through because result is a RIGHT
.flat_map_right(lambda x: Xresult(2, XRBranch.LEFT)) # Xresult(2, XRBranch.LEFT) because the initial result is a RIGHT
.map_left(lambda y: Exception(y))
)
stop.foreach_right(print) # doesn't print anything
stop.foreach_left(throws) # raise Exception(2)
stateDiagram
accTitle: Graph representation of the above transformations
accDescr: Represents visually the operation
start: start
yop: y + 10
xop: x + 10
ex: Exception(y)
throws: throws
res: Xresult(2, XRBranch.LEFT)
print: print
start-->xop: map_right
start-->yop: map_left
yop-->ex: map_left
ex-->throws: foreach_left
xop-->res: flat_map_right
res-->ex: map_left
res-->print: foreach_right
For convenience, Xresult is right-biased, meaning a non-suffixed function is a proxy for a “_right” one. For example my_xresult.map(f)
is the same as my_xresult.map_right(f)
Result branching
The collection API provides a bunch of functions that operate on the value to either transform it or to return a new Xresult (map
, flat_map
). However the Xresult itself add some functionalities to work on its branch (and optionally change its value). As before, every function exists in a left and right version, the unsuffixed one being a synonym for the right.
filter
returns a new Xresult being the opposite branch with the value holding a default error if the predicate is not met.
recover_with
is an alias over flat_map, on the opposite branch (recover_with_left
= flat_map_right
). It is meant to try to re-shift to the current bias. recover
holds a similar semantic as recover_with
but with an absolute recovery method.
from xfp import Xresult, XRBranch
(
Xresult("I am the wrong value", XRBranch.LEFT) # Let's encode an 'error' in the LEFT path
.recover_right(lambda err_msg: len(err_msg)) # Xresult(20, XRBranch.RIGHT)
.filter_right(lambda size: size < 5) # Xresult(XresultError(...), XRBranch.LEFT)
)
Integrate Semantic
In Result branching we started implying the existence of a “right” path, and an “error” one. While you should remember the mechanical behavior of Xresult, it is of equal importance to understand how it adds semantic to your code. It is a common and a good practice to encode a main value on one side, and a value representing the side effect on the other side.
from xfp import Xresult, XRBranch
import math
# Xresult[None, X] formally encodes an optional value while keeping the power of a collection
def partial_sqrt(i: int) -> Xresult[None, int]:
if i >= 0:
return Xresult(math.sqrt(i), XRBranch.RIGHT)
else:
return Xresult(None, XRBranch.LEFT)
# imperative vanilla python, the user needs to have a look at the documentation to understand the behavior of the function
def unsafe_function(i: int) -> str:
if i > 0:
return i
else:
raise Exception("break")
# with XFP, the unsafe behavior is encoded in the function signature, enforcing the code auto documentation
def unsafe_function(i: int) -> Xresult[Exception, str]:
# We even credit ourselves with not writing the implementation in this example
# since every case is visible in the return type
pass
Xeither, Xtry, Xopt
In the above example, you noticed some tedious notation, among them being always forced to explicitely set the branch. For the sake of simplicity, but also to improve furthermore the semantic behind the Xresult, subclasses Xeither
, Xopt
and Xtry
allow to directly create certain types of Xresult.
You will find more information about advanced usage of those types and integration with vanilla Python in the Pattern Matching and Wrapping Python sections.
from xfp import Xresult, Xeither, Xopt, Xtry
from typing import Any
# Xeither reproduces a plain mechanical "or" value
a_right: Xresult[Any, int] = Xeither.Right(3)
a_left: Xresult[int, Any] = Xeither.Left(3)
# Xopt reproduces an optional value, formally
some_value: Xresult[None, int] = Xopt.Some(3)
empty_value: Xresult[None, Any] = Xopt.Empty
# Xtry allows you to directly bind your Xresult to Exception semantic
success: Xresult[Exception, int] = Xtry.Success(3)
failure: Xresult[Exception, Any] = Xtry.Failure(Exception("Something went wrong"))
Using any of them will drastically improve the readability of your Xresult usages :
from xfp import Xresult, Xtry
# Now the behavior is encoded in the signature
# AND it becomes trivial to see the outcome of each branch of the code
def partial_sqrt(i: int) -> Xresult[Exception, int]:
if i >= 0:
return Xtry.Success(math.sqrt(i))
else:
return Xtry.Failure(Exception(f"{i} cannot be square rooted"))