Context/Notions of Computation
One of the core ideas in FP is composition, i.e. that to “do one computation after the other” is to compose these computations. In mathematics, function composition is straightforward, given by: \[(g\circ f)(x) = g(f(x)) \]
That is, \(g\circ f\) is the function ”\(g\) after \(f\)”, which applies \(f\) onto \(x\), and then apply \(g\) on the result.
In an ideal world, composing functions is as straightforward as we have described.
def add_one(x: int) -> int: return x + 1def double(x: int) -> int: return x * 2def div_three(x: int) -> float: return x / 3
print(div_three(double(add_one(4))))
However, things are rarely perfect. Let us take the following example of an application containing users, with several data structures to represent them.
First, we describe the User
and Email
classes:
from dataclasses import dataclass
@dataclassclass Email: name: str domain: str
@dataclassclass User: username: str email: Email salary: int | float
Now, we want to be able to parse user information that is provided as a string. However, note that this parsing may fail, therefore we raise exceptions if the input string cannot be parsed as the desired data structure.
def parse_email(s: str) -> Email: if '@' not in s: raise ValueError s = s.split('@') if len(s) != 2 or '.' not in s[1]: raise ValueError return Email(s[0], s[1])
def parse_salary(s: str) -> int | float: try: return int(s) except: return float(s) # if this fails and raises an exception, # then do not catch it
And to use these functions, we have to ensure that every program point that uses them must be wrapped in a try
and except
clause:
def main(): n = input('Enter name: ') e = input('Enter email: ') s = input('Enter salary: ') try: print(User(n, parse_email(e), parse_salary(s))) except: print('Some error occurred')
As you can see, exceptions are being thrown everywhere. Generally, it is hard to keep track of which functions raise/handle execptions, and also hard to compose exceptional functions! Worse still, if the program is poorly documented (as is the case for our example), no one actually knows that parse_salary
and parse_email
will raise exceptions!
There is a better way to do this—by using the railway pattern! Let us write the equivalent of the program above with idiomatic Haskell. First, the data structures:
data Email = Email { emailUsername :: String , emailDomain :: String } deriving (Eq, Show)
data Salary = SInt Int | SDouble Double deriving (Eq, Show)
data User = User { username :: String , userEmail :: Email , userSalary :: Salary } deriving (Eq, Show)
Now, some magic. No exceptions are raised in any of the following functions (which at this point, might look like moon runes):
parseEmail :: String -> Maybe EmailparseEmail email = do guard $ '@' `elem` email && length e == 2 && '.' `elem` last e return $ Email (head e) (last e) where e = split '@' email
parseSalary :: String -> Maybe SalaryparseSalary s = let si = SInt <$> readMaybe s sf = SDouble <$> readMaybe s in si <|> sf
And the equivalent of main
in Haskell is shown below.1 Although not apparent at this point, we are guaranteed that no exceptions will be raised from using parseEmail
and parseSalary
.
main :: IO ()main = do n <- input "Enter name: " e <- input "Enter email: " s <- input "Enter salary: " let u = User n <$> parseEmail e <*> parseSalary s putStrLn $ maybe "Some error occurred" show u
How does this work? The core idea behind the railway pattern is that functions are pure and statically-typed, therefore, all functions must make explicit the kind of effects it wants to produce. For this reason, any “exceptions” that it could raise must be explicitly stated in its type signature by returning the appropriate term whose type represents some notion of computation. Then, any other function that uses these functions with notions of computation must explicitly handle those notions of computations appropriately.
In this chapter, we describe some of the core facets of the railway pattern:
- What is it?
- What data structures and functions can we use to support this?
- How do we write programs with the railway pattern?
Many popular languages lie to you in many ways. An example is what we have seen earlier, where Python functions do not document exceptions in its type signature, and must be separately annotated as a docstrong to denote as such. This is not including the fact that Python type annotations are not enforced at all.
def happy(x: int) -> int: raise Exception("sad!")
This is not unique to dynamically-typed languages like Python. This is also the case in Java. In Java, checked exceptions must be explicitly reported in a method signature. However, unchecked exceptions, as named, do not need to be reported and are not checked by the Java compiler. That is not to mention other possible “lies”, for example, it is possible to return nothing (null
) even if the method’s type signature requires it to return “something”:
class A { String something() { return null; }}
We can’t lie in Haskell. In the first place, we shouldn’t lie in general. What now?
Instead, what we can do is to create the right data structures that represent what is actually returned by each function! In the Python example happy
, what we really wanted to return was either an int
, or an exception. Let us create a data structure that represents this:
data Either a b = Left a -- sad path | Right b -- happy path
Furthermore, instead of returning null
like in Java, we can create a data structure that represents either something, or nothing:
data Maybe a = Just a -- happy path | Nothing -- sad path
This allows the happy
and something
functions to be written safely in Haskell as:
happy :: Either String Inthappy = Left "sad!"
something :: Maybe Stringsomething = Nothing
The Maybe
and Either
types act as contexts or notions of computation:
Maybe a
—ana
or nothingEither a b
—eithera
orb
[a]
—a list of possiblea
s (nondeterminism)IO a
—an I/O action resulting ina
These types allow us to accurately describe what our functions are actually doing! Furthermore, these types “wrap” around a type, i.e. For instance, Maybe
, Either a
(for a fixed a
), []
and IO
all have kind * -> *
, and essentially provide some context around a type.
Using these types makes programs clearer! For example, we can use Maybe
to more accurately describe the head
function, which may return nothing if the input list is empty.
head' :: [a] -> Maybe ahead' [] = Nothinghead' (x : _) = x
Alternatively, we can express the fact that dividing by zero should yield an error:
safeDiv :: Int -> Int -> Either String IntsafeDiv x 0 = Left "Cannot divide by zero!"safediv x y = Right $ x `div` y
These data structures allow our functions to act as branching railways!
head' safeDiv
┏━━━━━ Just a ┏━━━━━ Right Int -- happy path[a] ━━━━┫ Int, Int ━━━━┫ ┗━━━━━ Nothing ┗━━━━━ Left String -- sad path
This is the inspiration behind the name “railway pattern”, which is the pattern of using algebraic data types to describe the different possible outputs from a function! This is, in fact, a natural consequence of purely functional programming. Since functions must be pure, it is not possible to define functions that opaquely cause side-effects. Instead, function signatures must be made transparent by using the right data structures.
What, then, is the right data structure to use? It all depends on the notion of computation that you want to express! If you want to produce nothing in some scenarios, use Maybe
. If you want to produce something or something else (like an error), use Either
, so on and so forth!
However, notice that having functions as railways is not very convenient… with the non-railway (and therefore potentially exceptional) head
function, we could compose head
with itself, i.e. head . head :: [[a]] -> a
is perfectly valid. However, we cannot compose head'
with itself, since head'
returns a Maybe a
, which cannot be an argument to head'
.
┏━━━━━ ? ┏━━━━━━━━━┫ <-----> ━━━━┫ ┗━━━━━ ┗━━━━━
How can we make the railway pattern ergonomic enough for us to want to use them?
Footnotes
-
Wait… is this an imperative program in Haskell? ↩