DEV Community

Cover image for Typeclass in Python with Sparrow
Fabián Vega Alcota
Fabián Vega Alcota

Posted on

Typeclass in Python with Sparrow

Lately I've been working on a library called Sparrow. This library was born from the need to have a collection of tools to make it easier to develop in Python using a declarative approach, mainly in the composition of functions. Here I've created a collection of functions and decorators, and I've also implemented a Haskell inspired typeclass.

What is a Typeclass?

The typeclass concept is a very powerful tool used by languages like Haskell that allows us to create types of types. It is a way of using polymorphism through types. In Haskell we can define a typeclass like this:

class Eq a where
    (==) :: a -> a -> Bool
    a == b = not (a /= b)

    (/=) :: a -> a -> Bool
    a /= b = not (a == b)
Enter fullscreen mode Exit fullscreen mode

In the example above, Eq is defined as a "type of types" for comparing the equality of two values. The great thing about this is that we can define, for example, that the arguments of the function are of type Eq, and the compiler will check if the types of the arguments are of type Eq. So we can do generic functions like this:

same :: (Eq a) => a -> a -> Bool
same a b = a == b
Enter fullscreen mode Exit fullscreen mode

This function will work for any type that has the Eq typeclass.

Using Sparrow we can the same in Python:

from sparrow.kind import Kind, kind_function

class Eq(Generic[T], Kind):
    @kind_function(True)
    def eq(self: "Eq[T]", other: "Eq[T]"):
        return not self.neq(other)

    @kind_function(True)
    def neq(self: "Eq[T]", other: "Eq[T]"):
        return not self.eq(other)


def same(a: Eq[T], b: Eq[T]) -> bool:
    return a.eq(b)
Enter fullscreen mode Exit fullscreen mode

You can see that the definition of this type (I will refer to it as "kind") is very similar in both languages using Sparrow. The main difference is that in Python we need to use a decorator to define the polymorphic functions and specify if they have a default implementation.

How to use it?

This library facilitates data modelling. This means that we can model our system using types and functions. We can use the power of the type system to make our code safer and easier to understand.

Let's see an example.

from sparrow.kind import Kind, kind_function
from sparrow.datatype import DataType

class Speaker(Kind):
    @kind_function
    def speak(speaker: "Speaker"):
        pass

class Runner(Kind):
    @kind_function(has_default=True)
    def start_running(runner: "Runner") -> None:
        print("I'm running")

    @kind_function(has_default=True)
    def stop_running(runner: "Runner") -> None:
        print("I'm not running")

class Animal(Speaker, Runner, DataType):
    pass

@dataclass
class Dog(Animal):
    def speak(self):
        print("Woof woof")

@dataclass
class Cat(Animal):
    def speak(self):
        print("Meow...")

    def start_running(self):
        print("I'm a cat, I won't run")

    def stop_running(self):
        print("You can't stop me")

dog = Dog()
cat = Cat()

dog.speak() # Woof woof
cat.speak() # Meow...

dog.start_running() # I'm running
dog.stop_running() # I'm not running

cat.start_running() # I'm a cat, I won't run
cat.stop_running() # You can't stop me
Enter fullscreen mode Exit fullscreen mode

This is nothing new, we can do the same thing in Python using simple inheritance. But the difference is that a typeclass is a more declarative way of doing it. It makes the code more readable by composing types and performing operations on them (product and sum of types).

Error handling

In languages like Python, Java, C# and [insert your favorite OOP language]. It is common to use try-catch blocks to handle the errors, but this can add unnecessary complexity to using the result value.

def divide(a: int, b: int) -> int:
    return a / b

try:
    result = divide(10, 2)
except ZeroDivisionError:
    do_something()
Enter fullscreen mode Exit fullscreen mode

Here we can refactor the code to use a functor called Result or Either in other languages to handle exceptions. This is a very common pattern in functional programming. We can do it like this:

from sparrow.datatype.result import Result, Success, Failure

def divide(a: int, b: int) -> Result[int, str]:
    if b == 0:
        return Failure("Division by zero")
    return Success(a // b)

divide(10, 0) # Failure("Division by zero")
divide(10, 2) # Success(5)
Enter fullscreen mode Exit fullscreen mode

Or better:

from sparrow.decorator.wrap import result

@result
def divide_v2(a: int, b: int) -> Result[int, Exception]:
    return a // b

divide(10, 0) # Failure(ZeroDivisionError)
divide(10, 2) # Success(5)
Enter fullscreen mode Exit fullscreen mode

Now if we want to use the result we can simply map the value.

result = divide(10, 0) # Failure(ZeroDivisionError)
result.fmap(lambda x: x * 2) # Failure(ZeroDivisionError)
Enter fullscreen mode Exit fullscreen mode

And with other values:

result = divide(10, 2) # Success(5)
result.fmap(lambda x: x * 2) # Success(10)
Enter fullscreen mode Exit fullscreen mode

Inclusive we can compose more operations:

result = (
    divide(10, 2) # Success(5)
    .fmap(lambda x: x * 2) # Success(10)
    .fmap(lambda x: x + 1) # Success(11)
    .fmap(lambda x: x % 2) # Success(1)
    .fmap(lambda x: "Even" if x == 0 else "Odd") # Success("Odd")
).value # "Odd"
Enter fullscreen mode Exit fullscreen mode

Result also is a Bifunctor and we can use first, second, and bimap to map the first, second, or both possible values.

result = divide(10, 0) # Failure(ZeroDivisionError)
result.first(lambda x: x * 2) # Failure(ZeroDivisionError)
result.second(lambda x: repr(x)) # Failure("ZeroDivisionError()")
result.bimap(lambda x: x * 2, lambda x: repr(x)) # Failure("ZeroDivisionError()")
Enter fullscreen mode Exit fullscreen mode

Here the definition of Functor and Bifunctor is not relevant, these are just functional patterns and in this case, we are using them to handle errors.

Maybe

Maybe is another very common pattern in functional programming. It is used to handle optional values, and it is very similar to the Result type, but it returns a value or nothing.

from sparrow.datatype.maybe import Maybe, Just, Nothing

def divide(a: int, b: int) -> Maybe[int]:
    if b == 0:
        return Nothing()
    return Just(a // b)

divide(10, 0) # Nothing()
divide(10, 2) # Just(5)
Enter fullscreen mode Exit fullscreen mode

Or if we prefer to use the decorator:

from sparrow.decorator.wrap import maybe

@maybe
def divide(a: int, b: int) -> Optional[int]:
    return a // b if b != 0 else None

divide(10, 0) # Nothing()
divide(10, 2) # Just(5)
Enter fullscreen mode Exit fullscreen mode

The decorator will map an optional return value to a Maybe type.

Maybe is a Functor, Applicative, and Monad. This means that we can use fmap, apply, and bind.

result = divide(10, 2) # Just(5)
result.fmap(lambda x: x * 2) # Just(10)
result.apply(Just(lambda x: x * 2)) # Just(10)
result.bind(lambda x: Just(x * 2)) # Just(10)
Enter fullscreen mode Exit fullscreen mode

Also, the Maybe has a method called pure that is used to create a Just value.

from sparrow.datatype.maybe import Maybe

Maybe.pure(1) # Just(1)
Enter fullscreen mode Exit fullscreen mode

The pure function can be used by any type that implements the Applicative typeclass.

Others tools

Sparrow also offers other functions tools. Here some examples:

Currying

Currying is a technique to transform a function with multiple arguments into a function with a single argument. This is very useful when we want to use partial application. For example:

from sparrow.decorator.currify import currify

@currify
def add(a: int, b: int) -> int:
    return a + b

add_one = add(1) # A function with one argument
add_one(2) # 3
Enter fullscreen mode Exit fullscreen mode

Composition

Composition is a technique to combine two or more functions.

from sparrow.decorator.compose import compose

@compose(lambda x: x * 2, lambda x: x + 1)
def add_one_and_double(x: int) -> int:
    return x

add_one_and_double(1) # 4
add_one_and_double(2) # 6
Enter fullscreen mode Exit fullscreen mode

The idea is to make pipelines of functions with more easily to modularize the code.

Reflex

Reflex is intended to debug the code or produce a side effect to finish the execution.

from sparrow.decorator.reflex import reflex

@reflex(lambda x: print(f"The value is {x}"))
def add_one(x: int) -> int:
    return x + 1

add_one(1)
# stdout: The value is 2
Enter fullscreen mode Exit fullscreen mode

If we want to debug we can use debug, info, warning, error, and critical decorators.

from sparrow.decorator.reflex import debug, info, warning, error, critical

@critical
@error
@warning
@info
@debug
def add_one(x: int) -> int:
    return x + 1

add_one(1)
# stdout:
# DEBUG:root:2
# INFO:root:2
# WARNING:root:2
# ERROR:root:2
# CRITICAL:root:2
Enter fullscreen mode Exit fullscreen mode

Also we can format the message.

from sparrow.decorator.reflex import info

@info("The value is {0}")
def add_one(x: int) -> int:
    return x + 1

add_one(1)
# stdout: INFO:root:The value is 2
Enter fullscreen mode Exit fullscreen mode

Before and After

Before and after are decorators to execute a function before or after the execution of the decorated function.


from sparrow.decorator.before_after import before, after

@after(str)
@before(int)
def add_one(x: int) -> int:
    return x + 1

add_one(1.1) # "2"
Enter fullscreen mode Exit fullscreen mode

When

The decorator

When is a decorator to execute a function when a condition is true.


from sparrow.decorator.when import when

@when(lambda x: x > 0)
def add_one(x: int) -> int:
    return x + 1

add_one(1) # 2
add_one(-1) # -1
Enter fullscreen mode Exit fullscreen mode

The function

When is also a function to execute a function when a condition is true.

from sparrow.function.when import when

def divide(a: int, b: int) -> int:
    return when(
        condition=b != 0,
        then=lambda x: x / b,
        otherwise=lambda x: 0,
        value=a
    )

divide(10, 2) # 5
divide(10, 0) # 0
Enter fullscreen mode Exit fullscreen mode

Map when

Map when is a function to execute a function when a condition is true and map the result.

from sparrow.function.when import map_when

my_list = [1, 2, 3, 4, 5]

map_when(
    condition=lambda x: x % 2 == 0,
    then=lambda x: x * 2,
    value=my_list
) # [1, 4, 3, 8, 5]
Enter fullscreen mode Exit fullscreen mode

Conclusion

All tools must be used with care and preferably with strict type checking. The purpose of this library is to help you write more declarative code, and make data modelling and function composition more flexible.

This library is still under development and I am open to suggestions and contributions. The repository is available on GitHub. The documentation is not yet available, but I am working on it.

Top comments (0)