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)
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
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)
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
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()
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)
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)
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)
And with other values:
result = divide(10, 2) # Success(5)
result.fmap(lambda x: x * 2) # Success(10)
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"
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()")
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)
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)
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)
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)
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
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
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
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
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
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"
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
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
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]
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)