Error handling and Functional Programming

paris.py 2/12/2019

About me

  • @ThierryCols
  • 🐍 2 to 3 years
  • 🖥 4 years

Started functional stuff @inato 💊

Why we have exceptions

A simple problem

timestamp,content,viewed,href
2019-12-01T05:33:34Z,@guidovr is not onto functional shenanigans,unread,https://example.com/guido/the/boss
2019-11-30T13:47:12Z,@javascript mentioned you in 'Are you leaving me?' discussion,viewed,https://example.com/discussions/heartbroken
2019-11-29T03:50:08Z,@parispy sent you a functional poke,unread,https://example.com/interactions/poke/

CSV Input

[
  {
    "message": "@guidovr is not onto functional shenanigans",
    "date": "Dimanche 1er Décembre",
    "timestamp": "2019-12-01T05:33:34+00:00",
    "link": "https://example.com/guido/the/boss",
    "read": false
  },
  {
    "message": "@javascript mentioned you in 'Are you leaving me?' discussion",
    "date": "Samedi 30 Octobre",
    "timestamp": "2019-11-30T13:47:12+00:00",
    "link": "https://example.com/discussions/heartbroken",
    "read": false
  },
  {
    "message": "@parispy sent you a functional poke",
    "date": "Vendredi 29 Octobre",
    "timestamp": "2019-11-29T03:50:08+00:00",
    "link": "https://example.com/interactions/poke/",
    "read": true
  }
]

JSON Output

What we'll usually do

def split_fields(row):
    return row.split(",")

Get data (don't do it at home)

Build message from line

def zip_row(headers, row):
    if len(headers) != len(row):
        raise Exception("Row has unexpected number of fields")
    return dict(zip(headers, row))

What we'll usually do

Return correct date

def add_human_readable_date(message):
    error_message = "Unable to parse date stamp in message object"
    months = [
        "January", "February", "March", "April", "May", "June", "July",
        "August", "September", "October", "November", "December"
    ]
    try:
        date = datetime.strptime(message["timestamp"], "%Y-%m-%dT%H:%M:%SZ")
        human_readable_date = "{day} {month} {year}".format(
            day=date.day, month=months[date.month - 1], year=date.year
        )
        complete_message = dict(message)
        complete_message["human_date"] = human_readable_date
        return complete_message
    except Exception:
        raise Exception(error_message)

What we'll usually do

Format to output

def format_to_output(message):
    return {
        "message": message["content"],
        "date": message["human_date"],
        "timestamp": message["timestamp"],
        "link": message["href"],
        "read": True if message["viewed"] == "read" else False,
    }

Format to error

def format_to_error(error_message):
    return {"error": error_message}

What we'll usually do

Process a line

def process_line(headers, line):
    try:
        split_headers = split_fields(headers)
        split_line = split_fields(line)
        dict_line = zip_row(split_headers, split_line)
        dict_line_with_date = add_human_readable_date(dict_line)
        return format_to_output(dict_line_with_date)
    except Exception as e:
        return format_to_error(e.error_message)

Exceptions are good !

Nice separation of "happy path" and "error path"

 

Each function is responsible for its own failures

 

All exceptions happen in one place

Exceptions are good !

def process_line_with_no_exception(headers, line):
    split_headers = split_fields(headers)
    split_line = split_fields(line)
    
    dict_line = zip_row(split_headers, split_line)
    if dict_line is None:
        return format_to_error("wrong number of items in row")

    dict_line_with_date = add_human_readable_date(dict_line)
    if dict_line_with_date is None:
        return format_to_error("incorrect date in row")
    
    return format_to_error(e.error_message)

Exceptions are too good !

Too good at "masking" errors

 

Hard to know where to try/except

 

Make functions not pure

Science to the rescue!

Having clearer functions

They shall always return something.

 

We could return Errors instead of None...

 

 

Result: same if/else pasta code

Polymorphism

One interface, several behaviors

class MyClass:
    @staticmethod
    def __str__():
        return "cool polymorphism"


if __name__ == "__main__":
  
    my_class = MyClass()
    print(my_class) # ➡️ 'cool polymorphism'
    
    print([1, 2]) # ➡️ '[1, 2]'

Let's build paths

class Left:
    def __init__(self, value):
        self.value = value

    def run_function_on_happy_path(self, fn):
        return

    def __str__(self):
        return "Left({})".format(str(self.value))
class Right:
    def __init__(self, value):
        self.value = value

    def run_function_on_happy_path(self, fn):
        return fn(self.value)

    def __str__(self):
        return "Right({})".format(str(self.value))

Let's build paths

if __name__ == "__main__":

    def print_stuff(value):
        print(value)
        return value
    
    left = Left("test")
    left.run_function_on_happy_path(print_stuff) # prints nothing
    print(left) # prints "Left(test)"

    right = Right("test")
    right.run_function_on_happy_path(print_stuff) # prints "test"
    print(right) # prints "Right(test)"

Introducing map 🗺

class Left:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return self

    def __str__(self):
        return "Left({})".format(str(self.value))
class Right:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return Right(fn(self.value))

    def __str__(self):
        return "Right({})".format(str(self.value))

Introducing map 🗺

if __name__ == "__main__":
    
    def plus1(x):
        return x + 1

    left = Left(1)
    left.fmap(plus1).fmap(print_stuff) # prints nothing

    right = Right(1)
    right.fmap(plus1).fmap(print_stuff) # prints Right(2)

Path Driven Development

Left + Right = Either

Refactoring the code

Let's rewrite our functions

def zip_row(headers):
    def zip_row(row):
        length_match = len(headers) != len(row)
        return (
            Right(dict(zip(headers, row)))
            if length_match
            else Left(Exception("Row has unexpected number of fields"))
        )
    return zip_row

And rewrite our program

from either import Left, Right

def process_line(headers, line):
    split_headers = split_fields(headers)
    either_split_line = Right(line).fmap(split_fields)

    either_dict_line = either_split_line.fmap(zip_row(split_headers))
    # ... wait...

split_fields ➡️ value

zip_row ➡️ Either

 

What is fmap(Either)? 🤯

Now we chain ⛓

class Left:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return self
    
    def chain(self, fn):
        return self

    def __str__(self):
        return "Left({})".format(
            str(self.value)
        )
class Right:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return Right(fn(self.value))
    
    def chain(self, fn):
        return fn(self.value)

    def __str__(self):
        return "Right({})".format(
            str(self.value)
        )

Now we chain ⛓

And we get a cleaner program

from either import Left, Right

def process_line(headers, line):
    split_headers = split_fields(headers)
    either_split_line = Right(line).fmap(split_fields)
    either_dict_line = either_split_line.chain(zip_row(split_headers))
    either_dict_line_with_date = either_dict_line.chain(add_human_readable_date)

And how to return data?

eventually returning value

class Left:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return self
    
    def chain(self, fn):
        return self
    
    def fold(self, on_left, on_right):
      return on_left(self.value)

    def __str__(self):
        return "Left({})".format(
            str(self.value)
        )
class Right:
    def __init__(self, value):
        self.value = value

    def fmap(self, fn):
        return Right(fn(self.value))
    
    def chain(self, fn):
        return fn(self.value)

    def fold(self, on_left, on_right):
        return on_right(self.value)

    def __str__(self):
        return "Right({})".format(
            str(self.value)
        )

And we're done 🏁

from either import Left, Right


def process_line(headers, line):
    split_headers = split_fields(headers)
    return Right(line)
        .fmap(split_fields)
        .chain(zip_row(split_headers))
        .chain(add_human_readable_date)
        .fold(on_left=format_to_error, on_right=format_to_output)

A Labyrinthine System

Yes but,

Throwing an exception makes another function responsible for error handling

either is honest about letting you stay on the happy path

In the end we did not write much code to handle errors with our either

works super great with types

Cheers 🍻

Questions?
I work for inato

either

By Thierry Colsenet

either

  • 161
Loading comments...

More from Thierry Colsenet