Spaces:
Running
Running
# /// script | |
# requires-python = ">=3.12" | |
# dependencies = [ | |
# "marimo", | |
# ] | |
# /// | |
import marimo | |
__generated_with = "0.12.9" | |
app = marimo.App(app_title="Applicative programming with effects") | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# Applicative programming with effects | |
`Applicative Functor` encapsulates certain sorts of *effectful* computations in a functionally pure way, and encourages an *applicative* programming style. | |
Applicative is a functor with application, providing operations to | |
+ embed pure expressions (`pure`), and | |
+ sequence computations and combine their results (`apply`). | |
In this notebook, you will learn: | |
1. How to view `Applicative` as multi-functor intuitively. | |
2. How to use `lift` to simplify chaining application. | |
3. How to bring *effects* to the functional pure world. | |
4. How to view `Applicative` as a lax monoidal functor. | |
5. How to use `Alternative` to amalgamate multiple computations into a single computation. | |
/// details | Notebook metadata | |
type: info | |
version: 0.1.3 | last modified: 2025-04-16 | author: [métaboulie](https://github.com/metaboulie)<br/> | |
reviewer: [Haleshot](https://github.com/Haleshot) | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# The intuition: [Multifunctor](https://arxiv.org/pdf/2401.14286) | |
## Limitations of functor | |
Recall that functors abstract the idea of mapping a function over each element of a structure. | |
Suppose now that we wish to generalise this idea to allow functions with any number of arguments to be mapped, rather than being restricted to functions with a single argument. More precisely, suppose that we wish to define a hierarchy of `fmap` functions with the following types: | |
```haskell | |
fmap0 :: a -> f a | |
fmap1 :: (a -> b) -> f a -> f b | |
fmap2 :: (a -> b -> c) -> f a -> f b -> f c | |
fmap3 :: (a -> b -> c -> d) -> f a -> f b -> f c -> f d | |
``` | |
And we have to declare a special version of the functor class for each case. | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Defining Multifunctor | |
/// admonition | |
we use prefix `f` rather than `ap` to indicate *Applicative Functor* | |
/// | |
As a result, we may want to define a single `Multifunctor` such that: | |
1. Lift a regular n-argument function into the context of functors | |
```python | |
# lift a regular 3-argument function `g` | |
g: Callable[[A, B, C], D] | |
# into the context of functors | |
fg: Callable[[Functor[A], Functor[B], Functor[C]], Functor[D]] | |
``` | |
3. Apply it to n functor-wrapped values | |
```python | |
# fa: Functor[A], fb: Functor[B], fc: Functor[C] | |
fg(fa, fb, fc) | |
``` | |
5. Get a single functor-wrapped result | |
```python | |
fd: Functor[D] | |
``` | |
We will define a function `lift` such that | |
```python | |
fd = lift(g, fa, fb, fc) | |
``` | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Pure, apply and lift | |
Traditionally, applicative functors are presented through two core operations: | |
1. `pure`: embeds an object (value or function) into the applicative functor | |
```python | |
# a -> F a | |
pure: Callable[[A], Applicative[A]] | |
# for example, if `a` is | |
a: A | |
# then we can have `fa` as | |
fa: Applicative[A] = pure(a) | |
# or if we have a regular function `g` | |
g: Callable[[A], B] | |
# then we can have `fg` as | |
fg: Applicative[Callable[[A], B]] = pure(g) | |
``` | |
2. `apply`: applies a function inside an applicative functor to a value inside an applicative functor | |
```python | |
# F (a -> b) -> F a -> F b | |
apply: Callable[[Applicative[Callable[[A], B]], Applicative[A]], Applicative[B]] | |
# and we can have | |
fd = apply(apply(apply(fg, fa), fb), fc) | |
``` | |
As a result, | |
```python | |
lift(g, fa, fb, fc) = apply(apply(apply(pure(g), fa), fb), fc) | |
``` | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
/// admonition | How to use *Applicative* in the manner of *Multifunctor* | |
1. Define `pure` and `apply` for an `Applicative` subclass | |
- We can define them much easier compared with `lift`. | |
2. Use the `lift` method | |
- We can use it much more convenient compared with the combination of `pure` and `apply`. | |
/// | |
/// attention | You can suppress the chaining application of `apply` and `pure` as: | |
```python | |
apply(pure(g), fa) -> lift(g, fa) | |
apply(apply(pure(g), fa), fb) -> lift(g, fa, fb) | |
apply(apply(apply(pure(g), fa), fb), fc) -> lift(g, fa, fb, fc) | |
``` | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Abstracting applicatives | |
We can now provide an initial abstraction definition of applicatives: | |
```python | |
@dataclass | |
class Applicative[A](Functor, ABC): | |
@classmethod | |
@abstractmethod | |
def pure(cls, a: A) -> "Applicative[A]": | |
raise NotImplementedError("Subclasses must implement pure") | |
@classmethod | |
@abstractmethod | |
def apply( | |
cls, fg: "Applicative[Callable[[A], B]]", fa: "Applicative[A]" | |
) -> "Applicative[B]": | |
raise NotImplementedError("Subclasses must implement apply") | |
@classmethod | |
def lift(cls, f: Callable, *args: "Applicative") -> "Applicative": | |
curr = cls.pure(f) | |
if not args: | |
return curr | |
for arg in args: | |
curr = cls.apply(curr, arg) | |
return curr | |
``` | |
/// attention | minimal implementation requirement | |
- `pure` | |
- `apply` | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md(r"""# Instances, laws and utility functions""") | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Applicative instances | |
When we are actually implementing an *Applicative* instance, we can keep in mind that `pure` and `apply` fundamentally: | |
- embed an object (value or function) to the computational context | |
- apply a function inside the computation context to a value inside the computational context | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
### The Wrapper Applicative | |
- `pure` should simply *wrap* an object, in the sense that: | |
```haskell | |
Wrapper.pure(1) => Wrapper(value=1) | |
``` | |
- `apply` should apply a *wrapped* function to a *wrapped* value | |
The implementation is: | |
""" | |
) | |
def _(Applicative, dataclass): | |
class Wrapper[A](Applicative): | |
value: A | |
def pure(cls, a: A) -> "Wrapper[A]": | |
return cls(a) | |
def apply( | |
cls, fg: "Wrapper[Callable[[A], B]]", fa: "Wrapper[A]" | |
) -> "Wrapper[B]": | |
return cls(fg.value(fa.value)) | |
return (Wrapper,) | |
def _(mo) -> None: | |
mo.md(r"""> try with Wrapper below""") | |
def _(Wrapper) -> None: | |
Wrapper.lift( | |
lambda a: lambda b: lambda c: a + b * c, | |
Wrapper(1), | |
Wrapper(2), | |
Wrapper(3), | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
### The List Applicative | |
- `pure` should wrap the object in a list, in the sense that: | |
```haskell | |
List.pure(1) => List(value=[1]) | |
``` | |
- `apply` should apply a list of functions to a list of values | |
- you can think of this as cartesian product, concatenating the result of applying every function to every value | |
The implementation is: | |
""" | |
) | |
def _(Applicative, dataclass, product): | |
class List[A](Applicative): | |
value: list[A] | |
def pure(cls, a: A) -> "List[A]": | |
return cls([a]) | |
def apply(cls, fg: "List[Callable[[A], B]]", fa: "List[A]") -> "List[B]": | |
return cls([g(a) for g, a in product(fg.value, fa.value)]) | |
return (List,) | |
def _(mo) -> None: | |
mo.md(r"""> try with List below""") | |
def _(List) -> None: | |
List.apply( | |
List([lambda a: a + 1, lambda a: a * 2]), | |
List([1, 2]), | |
) | |
def _(List) -> None: | |
List.lift(lambda a: lambda b: a + b, List([1, 2]), List([3, 4, 5])) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
### The Maybe Applicative | |
- `pure` should wrap the object in a Maybe, in the sense that: | |
```haskell | |
Maybe.pure(1) => "Just 1" | |
Maybe.pure(None) => "Nothing" | |
``` | |
- `apply` should apply a function maybe exist to a value maybe exist | |
- if the function is `None` or the value is `None`, simply returns `None` | |
- else apply the function to the value and wrap the result in `Just` | |
The implementation is: | |
""" | |
) | |
def _(Applicative, dataclass): | |
class Maybe[A](Applicative): | |
value: None | A | |
def pure(cls, a: A) -> "Maybe[A]": | |
return cls(a) | |
def apply( | |
cls, fg: "Maybe[Callable[[A], B]]", fa: "Maybe[A]" | |
) -> "Maybe[B]": | |
if fg.value is None or fa.value is None: | |
return cls(None) | |
return cls(fg.value(fa.value)) | |
def __repr__(self): | |
return "Nothing" if self.value is None else f"Just({self.value!r})" | |
return (Maybe,) | |
def _(mo) -> None: | |
mo.md(r"""> try with Maybe below""") | |
def _(Maybe) -> None: | |
Maybe.lift( | |
lambda a: lambda b: a + b, | |
Maybe(1), | |
Maybe(2), | |
) | |
def _(Maybe) -> None: | |
Maybe.lift( | |
lambda a: lambda b: None, | |
Maybe(1), | |
Maybe(2), | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
### The Either Applicative | |
- `pure` should wrap the object in `Right`, in the sense that: | |
```haskell | |
Either.pure(1) => Right(1) | |
``` | |
- `apply` should apply a function that is either on Left or Right to a value that is either on Left or Right | |
- if the function is `Left`, simply returns the `Left` of the function | |
- else `fmap` the `Right` of the function to the value | |
The implementation is: | |
""" | |
) | |
def _(Applicative, B, Callable, Union, dataclass): | |
class Either[A](Applicative): | |
left: A = None | |
right: A = None | |
def __post_init__(self): | |
if (self.left is not None and self.right is not None) or ( | |
self.left is None and self.right is None | |
): | |
msg = "Provide either the value of the left or the value of the right." | |
raise TypeError( | |
msg | |
) | |
def pure(cls, a: A) -> "Either[A]": | |
return cls(right=a) | |
def apply( | |
cls, fg: "Either[Callable[[A], B]]", fa: "Either[A]" | |
) -> "Either[B]": | |
if fg.left is not None: | |
return cls(left=fg.left) | |
return cls.fmap(fg.right, fa) | |
def fmap( | |
cls, g: Callable[[A], B], fa: "Either[A]" | |
) -> Union["Either[A]", "Either[B]"]: | |
if fa.left is not None: | |
return cls(left=fa.left) | |
return cls(right=g(fa.right)) | |
def __repr__(self): | |
if self.left is not None: | |
return f"Left({self.left!r})" | |
return f"Right({self.right!r})" | |
return (Either,) | |
def _(mo) -> None: | |
mo.md(r"""> try with `Either` below""") | |
def _(Either) -> None: | |
Either.apply(Either(left=TypeError("Parse Error")), Either(right=2)) | |
def _(Either) -> None: | |
Either.apply( | |
Either(right=lambda x: x + 1), Either(left=TypeError("Parse Error")) | |
) | |
def _(Either) -> None: | |
Either.apply(Either(right=lambda x: x + 1), Either(right=1)) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Collect the list of response with sequenceL | |
One often wants to execute a list of commands and collect the list of their response, and we can define a function `sequenceL` for this | |
/// admonition | |
In a further notebook about `Traversable`, we will have a more generic `sequence` that execute a **sequence** of commands and collect the **sequence** of their response, which is not limited to `list`. | |
/// | |
```python | |
@classmethod | |
def sequenceL(cls, fas: list["Applicative[A]"]) -> "Applicative[list[A]]": | |
if not fas: | |
return cls.pure([]) | |
return cls.apply( | |
cls.fmap(lambda v: lambda vs: [v] + vs, fas[0]), | |
cls.sequenceL(fas[1:]), | |
) | |
``` | |
Let's try `sequenceL` with the instances. | |
""" | |
) | |
def _(Wrapper) -> None: | |
Wrapper.sequenceL([Wrapper(1), Wrapper(2), Wrapper(3)]) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
/// attention | |
For the `Maybe` Applicative, the presence of any `Nothing` causes the entire computation to return Nothing. | |
/// | |
""" | |
) | |
def _(Maybe) -> None: | |
Maybe.sequenceL([Maybe(1), Maybe(2), Maybe(None), Maybe(3)]) | |
def _(mo) -> None: | |
mo.md(r"""The result of `sequenceL` for `List Applicative` is the Cartesian product of the input lists, yielding all possible ordered combinations of elements from each list.""") | |
def _(List) -> None: | |
List.sequenceL([List([1, 2]), List([3]), List([5, 6, 7])]) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Applicative laws | |
/// admonition | id and compose | |
Remember that | |
- `id = lambda x: x` | |
- `compose = lambda f: lambda g: lambda x: f(g(x))` | |
/// | |
Traditionally, there are four laws that `Applicative` instances should satisfy. In some sense, they are all concerned with making sure that `pure` deserves its name: | |
- The identity law: | |
```python | |
# fa: Applicative[A] | |
apply(pure(id), fa) = fa | |
``` | |
- Homomorphism: | |
```python | |
# a: A | |
# g: Callable[[A], B] | |
apply(pure(g), pure(a)) = pure(g(a)) | |
``` | |
Intuitively, applying a non-effectful function to a non-effectful argument in an effectful context is the same as just applying the function to the argument and then injecting the result into the context with pure. | |
- Interchange: | |
```python | |
# a: A | |
# fg: Applicative[Callable[[A], B]] | |
apply(fg, pure(a)) = apply(pure(lambda g: g(a)), fg) | |
``` | |
Intuitively, this says that when evaluating the application of an effectful function to a pure argument, the order in which we evaluate the function and its argument doesn't matter. | |
- Composition: | |
```python | |
# fg: Applicative[Callable[[B], C]] | |
# fh: Applicative[Callable[[A], B]] | |
# fa: Applicative[A] | |
apply(fg, apply(fh, fa)) = lift(compose, fg, fh, fa) | |
``` | |
This one is the trickiest law to gain intuition for. In some sense it is expressing a sort of associativity property of `apply`. | |
We can add 4 helper functions to `Applicative` to check whether an instance respects the laws or not: | |
```python | |
@dataclass | |
class Applicative[A](Functor, ABC): | |
@classmethod | |
def check_identity(cls, fa: "Applicative[A]"): | |
if cls.lift(id, fa) != fa: | |
raise ValueError("Instance violates identity law") | |
return True | |
@classmethod | |
def check_homomorphism(cls, a: A, f: Callable[[A], B]): | |
if cls.lift(f, cls.pure(a)) != cls.pure(f(a)): | |
raise ValueError("Instance violates homomorphism law") | |
return True | |
@classmethod | |
def check_interchange(cls, a: A, fg: "Applicative[Callable[[A], B]]"): | |
if cls.apply(fg, cls.pure(a)) != cls.lift(lambda g: g(a), fg): | |
raise ValueError("Instance violates interchange law") | |
return True | |
@classmethod | |
def check_composition( | |
cls, | |
fg: "Applicative[Callable[[B], C]]", | |
fh: "Applicative[Callable[[A], B]]", | |
fa: "Applicative[A]", | |
): | |
if cls.apply(fg, cls.apply(fh, fa)) != cls.lift(compose, fg, fh, fa): | |
raise ValueError("Instance violates composition law") | |
return True | |
``` | |
> Try to validate applicative laws below | |
""" | |
) | |
def _(): | |
id = lambda x: x | |
compose = lambda f: lambda g: lambda x: f(g(x)) | |
const = lambda a: lambda _: a | |
return compose, const, id | |
def _(List, Wrapper) -> None: | |
print("Checking Wrapper") | |
print(Wrapper.check_identity(Wrapper.pure(1))) | |
print(Wrapper.check_homomorphism(1, lambda x: x + 1)) | |
print(Wrapper.check_interchange(1, Wrapper.pure(lambda x: x + 1))) | |
print( | |
Wrapper.check_composition( | |
Wrapper.pure(lambda x: x * 2), | |
Wrapper.pure(lambda x: x + 0.1), | |
Wrapper.pure(1), | |
) | |
) | |
print("\nChecking List") | |
print(List.check_identity(List.pure(1))) | |
print(List.check_homomorphism(1, lambda x: x + 1)) | |
print(List.check_interchange(1, List.pure(lambda x: x + 1))) | |
print( | |
List.check_composition( | |
List.pure(lambda x: x * 2), List.pure(lambda x: x + 0.1), List.pure(1) | |
) | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Utility functions | |
/// attention | using `fmap` | |
`fmap` is defined automatically using `pure` and `apply`, so you can use `fmap` with any `Applicative` | |
/// | |
```python | |
@dataclass | |
class Applicative[A](Functor, ABC): | |
@classmethod | |
def skip( | |
cls, fa: "Applicative[A]", fb: "Applicative[B]" | |
) -> "Applicative[B]": | |
''' | |
Sequences the effects of two Applicative computations, | |
but discards the result of the first. | |
''' | |
return cls.apply(cls.const(fa, id), fb) | |
@classmethod | |
def keep( | |
cls, fa: "Applicative[A]", fb: "Applicative[B]" | |
) -> "Applicative[B]": | |
''' | |
Sequences the effects of two Applicative computations, | |
but discard the result of the second. | |
''' | |
return cls.lift(const, fa, fb) | |
@classmethod | |
def revapp( | |
cls, fa: "Applicative[A]", fg: "Applicative[Callable[[A], [B]]]" | |
) -> "Applicative[B]": | |
''' | |
The first computation produces values which are provided | |
as input to the function(s) produced by the second computation. | |
''' | |
return cls.lift(lambda a: lambda f: f(a), fa, fg) | |
``` | |
- `skip` sequences the effects of two Applicative computations, but **discards the result of the first**. For example, if `m1` and `m2` are instances of type `Maybe[Int]`, then `Maybe.skip(m1, m2)` is `Nothing` whenever either `m1` or `m2` is `Nothing`; but if not, it will have the same value as `m2`. | |
- Likewise, `keep` sequences the effects of two computations, but **keeps only the result of the first**. | |
- `revapp` is similar to `apply`, but where the first computation produces value(s) which are provided as input to the function(s) produced by the second computation. | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
/// admonition | Exercise | |
Try to use utility functions with different instances | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# Formal implementation of Applicative | |
Now, we can give the formal implementation of `Applicative` | |
""" | |
) | |
def _( | |
ABC, | |
B, | |
Callable, | |
Functor, | |
abstractmethod, | |
compose, | |
const, | |
dataclass, | |
id, | |
): | |
class Applicative[A](Functor, ABC): | |
def pure(cls, a: A) -> "Applicative[A]": | |
"""Lift a value into the Structure.""" | |
msg = "Subclasses must implement pure" | |
raise NotImplementedError(msg) | |
def apply( | |
cls, fg: "Applicative[Callable[[A], B]]", fa: "Applicative[A]" | |
) -> "Applicative[B]": | |
"""Sequential application.""" | |
msg = "Subclasses must implement apply" | |
raise NotImplementedError(msg) | |
def lift(cls, f: Callable, *args: "Applicative") -> "Applicative": | |
"""Lift a function of arbitrary arity to work with values in applicative context.""" | |
curr = cls.pure(f) | |
if not args: | |
return curr | |
for arg in args: | |
curr = cls.apply(curr, arg) | |
return curr | |
def fmap( | |
cls, f: Callable[[A], B], fa: "Applicative[A]" | |
) -> "Applicative[B]": | |
return cls.lift(f, fa) | |
def sequenceL(cls, fas: list["Applicative[A]"]) -> "Applicative[list[A]]": | |
""" | |
Execute a list of commands and collect the list of their response. | |
""" | |
if not fas: | |
return cls.pure([]) | |
return cls.apply( | |
cls.fmap(lambda v: lambda vs: [v, *vs], fas[0]), | |
cls.sequenceL(fas[1:]), | |
) | |
def skip( | |
cls, fa: "Applicative[A]", fb: "Applicative[B]" | |
) -> "Applicative[B]": | |
""" | |
Sequences the effects of two Applicative computations, | |
but discards the result of the first. | |
""" | |
return cls.apply(cls.const(fa, id), fb) | |
def keep( | |
cls, fa: "Applicative[A]", fb: "Applicative[B]" | |
) -> "Applicative[B]": | |
""" | |
Sequences the effects of two Applicative computations, | |
but discard the result of the second. | |
""" | |
return cls.lift(const, fa, fb) | |
def revapp( | |
cls, fa: "Applicative[A]", fg: "Applicative[Callable[[A], [B]]]" | |
) -> "Applicative[B]": | |
""" | |
The first computation produces values which are provided | |
as input to the function(s) produced by the second computation. | |
""" | |
return cls.lift(lambda a: lambda f: f(a), fa, fg) | |
def check_identity(cls, fa: "Applicative[A]") -> bool: | |
if cls.lift(id, fa) != fa: | |
msg = "Instance violates identity law" | |
raise ValueError(msg) | |
return True | |
def check_homomorphism(cls, a: A, f: Callable[[A], B]) -> bool: | |
if cls.lift(f, cls.pure(a)) != cls.pure(f(a)): | |
msg = "Instance violates homomorphism law" | |
raise ValueError(msg) | |
return True | |
def check_interchange(cls, a: A, fg: "Applicative[Callable[[A], B]]") -> bool: | |
if cls.apply(fg, cls.pure(a)) != cls.lift(lambda g: g(a), fg): | |
msg = "Instance violates interchange law" | |
raise ValueError(msg) | |
return True | |
def check_composition( | |
cls, | |
fg: "Applicative[Callable[[B], C]]", | |
fh: "Applicative[Callable[[A], B]]", | |
fa: "Applicative[A]", | |
) -> bool: | |
if cls.apply(fg, cls.apply(fh, fa)) != cls.lift(compose, fg, fh, fa): | |
msg = "Instance violates composition law" | |
raise ValueError(msg) | |
return True | |
return (Applicative,) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# Effectful programming | |
Our original motivation for applicatives was the desire to generalise the idea of mapping to functions with multiple arguments. This is a valid interpretation of the concept of applicatives, but from the three instances we have seen it becomes clear that there is also another, more abstract view. | |
The arguments are no longer just plain values but may also have effects, such as the possibility of failure, having many ways to succeed, or performing input/output actions. In this manner, applicative functors can also be viewed as abstracting the idea of **applying pure functions to effectful arguments**, with the precise form of effects that are permitted depending on the nature of the underlying functor. | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## The IO Applicative | |
We will try to define an `IO` applicative here. | |
As before, we first abstract how `pure` and `apply` should function. | |
- `pure` should wrap the object in an IO action, and make the object *callable* if it's not because we want to perform the action later: | |
```haskell | |
IO.pure(1) => IO(effect=lambda: 1) | |
IO.pure(f) => IO(effect=f) | |
``` | |
- `apply` should perform an action that produces a value, then apply the function with the value | |
The implementation is: | |
""" | |
) | |
def _(Applicative, Callable, dataclass): | |
class IO(Applicative): | |
effect: Callable | |
def __call__(self): | |
return self.effect() | |
def pure(cls, a): | |
return cls(a) if isinstance(a, Callable) else IO(lambda: a) | |
def apply(cls, fg, fa): | |
return cls.pure(fg.effect(fa.effect())) | |
return (IO,) | |
def _(mo) -> None: | |
mo.md(r"""For example, a function that reads a given number of lines from the keyboard can be defined in applicative style as follows:""") | |
def _(IO): | |
def get_chars(n: int = 3): | |
return IO.sequenceL([ | |
IO.pure(input(f"input the {i}th str")) for i in range(1, n + 1) | |
]) | |
return (get_chars,) | |
def _() -> None: | |
# get_chars()() | |
return | |
def _(mo) -> None: | |
mo.md(r"""# From the perspective of category theory""") | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Lax Monoidal Functor | |
An alternative, equivalent formulation of `Applicative` is given by | |
""" | |
) | |
def _(ABC, Functor, abstractmethod, dataclass): | |
class Monoidal[A](Functor, ABC): | |
def unit(cls) -> "Monoidal[Tuple[()]]": | |
pass | |
def tensor( | |
cls, this: "Monoidal[A]", other: "Monoidal[B]" | |
) -> "Monoidal[Tuple[A, B]]": | |
pass | |
return (Monoidal,) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
Intuitively, this states that a *monoidal functor* is one which has some sort of "default shape" and which supports some sort of "combining" operation. | |
- `unit` provides the identity element | |
- `tensor` combines two contexts into a product context | |
More technically, the idea is that `monoidal functor` preserves the "monoidal structure" given by the pairing constructor `(,)` and unit type `()`. | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
Furthermore, to deserve the name "monoidal", instances of Monoidal ought to satisfy the following laws, which seem much more straightforward than the traditional Applicative laws: | |
- Left identity | |
`tensor(unit, v) ≅ v` | |
- Right identity | |
`tensor(u, unit) ≅ u` | |
- Associativity | |
`tensor(u, tensor(v, w)) ≅ tensor(tensor(u, v), w)` | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
/// admonition | ≅ indicates isomorphism | |
`≅` refers to *isomorphism* rather than equality. | |
In particular we consider `(x, ()) ≅ x ≅ ((), x)` and `((x, y), z) ≅ (x, (y, z))` | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Mutual definability of Monoidal and Applicative | |
We can implement `pure` and `apply` in terms of `unit` and `tensor`, and vice versa. | |
```python | |
pure(a) = fmap((lambda _: a), unit) | |
apply(fg, fa) = fmap((lambda pair: pair[0](pair[1])), tensor(fg, fa)) | |
``` | |
```python | |
unit() = pure(()) | |
tensor(fa, fb) = lift(lambda fa: lambda fb: (fa, fb), fa, fb) | |
``` | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Instance: ListMonoidal | |
- `unit` should simply return a empty tuple wrapper in a list | |
```haskell | |
ListMonoidal.unit() => [()] | |
``` | |
- `tensor` should return the *cartesian product* of the items of 2 ListMonoidal instances | |
The implementation is: | |
""" | |
) | |
def _(B, Callable, Monoidal, dataclass, product): | |
class ListMonoidal[A](Monoidal): | |
items: list[A] | |
def unit(cls) -> "ListMonoidal[Tuple[()]]": | |
return cls([()]) | |
def tensor( | |
cls, this: "ListMonoidal[A]", other: "ListMonoidal[B]" | |
) -> "ListMonoidal[Tuple[A, B]]": | |
return cls(list(product(this.items, other.items))) | |
def fmap( | |
cls, f: Callable[[A], B], ma: "ListMonoidal[A]" | |
) -> "ListMonoidal[B]": | |
return cls([f(a) for a in ma.items]) | |
return (ListMonoidal,) | |
def _(mo) -> None: | |
mo.md(r"""> try with `ListMonoidal` below""") | |
def _(ListMonoidal): | |
xs = ListMonoidal([1, 2]) | |
ys = ListMonoidal(["a", "b"]) | |
ListMonoidal.tensor(xs, ys) | |
return xs, ys | |
def _(mo) -> None: | |
mo.md(r"""and we can prove that `tensor(fa, fb) = lift(lambda fa: lambda fb: (fa, fb), fa, fb)`:""") | |
def _(List, xs, ys) -> None: | |
List.lift(lambda fa: lambda fb: (fa, fb), List(xs.items), List(ys.items)) | |
def _(ABC, B, Callable, abstractmethod, dataclass): | |
class Functor[A](ABC): | |
def fmap(cls, f: Callable[[A], B], a: "Functor[A]") -> "Functor[B]": | |
msg = "Subclasses must implement fmap" | |
raise NotImplementedError(msg) | |
def const(cls, a: "Functor[A]", b: B) -> "Functor[B]": | |
return cls.fmap(lambda _: b, a) | |
def void(cls, a: "Functor[A]") -> "Functor[None]": | |
return cls.const_fmap(a, None) | |
return (Functor,) | |
def _(): | |
import marimo as mo | |
return (mo,) | |
def _(): | |
from abc import ABC, abstractmethod | |
from collections.abc import Callable | |
from dataclasses import dataclass | |
from typing import TypeVar, Union | |
return ABC, Callable, TypeVar, Union, abstractmethod, dataclass | |
def _(): | |
from itertools import product | |
return (product,) | |
def _(TypeVar): | |
A = TypeVar("A") | |
B = TypeVar("B") | |
C = TypeVar("C") | |
return A, B, C | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# From Applicative to Alternative | |
## Abstracting Alternative | |
In our studies so far, we saw that both `Maybe` and `List` can represent computations with a varying number of results. | |
We use `Maybe` to indicate a computation can fail somehow and `List` for computations that can have many possible results. In both of these cases, one useful operation is amalgamating all possible results from multiple computations into a single computation. | |
`Alternative` formalizes computations that support: | |
- **Failure** (empty result) | |
- **Choice** (combination of results) | |
- **Repetition** (multiple results) | |
It extends `Applicative` with monoidal structure, where: | |
```python | |
@dataclass | |
class Alternative[A](Applicative, ABC): | |
@classmethod | |
@abstractmethod | |
def empty(cls) -> "Alternative[A]": | |
'''Identity element for alternative computations''' | |
@classmethod | |
@abstractmethod | |
def alt( | |
cls, fa: "Alternative[A]", fb: "Alternative[A]" | |
) -> "Alternative[A]": | |
'''Binary operation combining computations''' | |
``` | |
- `empty` is the identity element (e.g., `Maybe(None)`, `List([])`) | |
- `alt` is a combination operator (e.g., `Maybe` fallback, list concatenation) | |
`empty` and `alt` should satisfy the following **laws**: | |
```python | |
# Left identity | |
alt(empty, fa) == fa | |
# Right identity | |
alt(fa, empty) == fa | |
# Associativity | |
alt(fa, alt(fb, fc)) == alt(alt(fa, fb), fc) | |
``` | |
/// admonition | |
Actually, `Alternative` is a *monoid* on `Applicative Functors`. We will talk about *monoid* and review these laws in the next notebook about `Monads`. | |
/// | |
/// attention | minimal implementation requirement | |
- `empty` | |
- `alt` | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## Instances of Alternative | |
### The Maybe Alternative | |
- `empty`: the identity element of `Maybe` is `Maybe(None)` | |
- `alt`: return the first element if it's not `None`, else return the second element | |
""" | |
) | |
def _(Alternative, Maybe, dataclass): | |
class AltMaybe[A](Maybe, Alternative): | |
def empty(cls) -> "AltMaybe[A]": | |
return cls(None) | |
def alt(cls, fa: "AltMaybe[A]", fb: "AltMaybe[A]") -> "AltMaybe[A]": | |
if fa.value is not None: | |
return cls(fa.value) | |
return cls(fb.value) | |
def __repr__(self): | |
return "Nothing" if self.value is None else f"Just({self.value!r})" | |
return (AltMaybe,) | |
def _(AltMaybe) -> None: | |
print(AltMaybe.empty()) | |
print(AltMaybe.alt(AltMaybe(None), AltMaybe(1))) | |
print(AltMaybe.alt(AltMaybe(None), AltMaybe(None))) | |
print(AltMaybe.alt(AltMaybe(1), AltMaybe(None))) | |
print(AltMaybe.alt(AltMaybe(1), AltMaybe(2))) | |
def _(AltMaybe) -> None: | |
print(AltMaybe.check_left_identity(AltMaybe(1))) | |
print(AltMaybe.check_right_identity(AltMaybe(1))) | |
print(AltMaybe.check_associativity(AltMaybe(1), AltMaybe(2), AltMaybe(None))) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
### The List Alternative | |
- `empty`: the identity element of `List` is `List([])` | |
- `alt`: return the concatenation of 2 input lists | |
""" | |
) | |
def _(Alternative, List, dataclass): | |
class AltList[A](List, Alternative): | |
def empty(cls) -> "AltList[A]": | |
return cls([]) | |
def alt(cls, fa: "AltList[A]", fb: "AltList[A]") -> "AltList[A]": | |
return cls(fa.value + fb.value) | |
return (AltList,) | |
def _(AltList) -> None: | |
print(AltList.empty()) | |
print(AltList.alt(AltList([1, 2, 3]), AltList([4, 5]))) | |
def _(AltList) -> None: | |
AltList([1]) | |
def _(AltList) -> None: | |
AltList([1]) | |
def _(AltList) -> None: | |
print(AltList.check_left_identity(AltList([1, 2, 3]))) | |
print(AltList.check_right_identity(AltList([1, 2, 3]))) | |
print( | |
AltList.check_associativity( | |
AltList([1, 2]), AltList([3, 4, 5]), AltList([6]) | |
) | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
## some and many | |
/// admonition | This section mainly refers to | |
- https://stackoverflow.com/questions/7671009/some-and-many-functions-from-the-alternative-type-class/7681283#7681283 | |
/// | |
First let's have a look at the implementation of `some` and `many`: | |
```python | |
@classmethod | |
def some(cls, fa: "Alternative[A]") -> "Alternative[list[A]]": | |
# Short-circuit if input is empty | |
if fa == cls.empty(): | |
return cls.empty() | |
return cls.apply( | |
cls.fmap(lambda a: lambda b: [a] + b, fa), cls.many(fa) | |
) | |
@classmethod | |
def many(cls, fa: "Alternative[A]") -> "Alternative[list[A]]": | |
# Directly return empty list if input is empty | |
if fa == cls.empty(): | |
return cls.pure([]) | |
return cls.alt(cls.some(fa), cls.pure([])) | |
``` | |
So `some f` runs `f` once, then *many* times, and conses the results. `many f` runs f *some* times, or *alternatively* just returns the empty list. | |
The idea is that they both run `f` as often as possible until it **fails**, collecting the results in a list. The difference is that `some f` immediately fails if `f` fails, while `many f` will still succeed and *return* the empty list in such a case. But what all this exactly means depends on how `alt` is defined. | |
Let's see what it does for the instances `AltMaybe` and `AltList`. | |
""" | |
) | |
def _(mo) -> None: | |
mo.md(r"""For `AltMaybe`. `None` means failure, so some `None` fails as well and evaluates to `None` while many `None` succeeds and evaluates to `Just []`. Both `some (Just ())` and `many (Just ())` never return, because `Just ()` never fails.""") | |
def _(AltMaybe) -> None: | |
print(AltMaybe.some(AltMaybe.empty())) | |
print(AltMaybe.many(AltMaybe.empty())) | |
def _(mo) -> None: | |
mo.md(r"""For `AltList`, `[]` means failure, so `some []` evaluates to `[]` (no answers) while `many []` evaluates to `[[]]` (there's one answer and it is the empty list). Again `some [()]` and `many [()]` don't return.""") | |
def _(AltList) -> None: | |
print(AltList.some(AltList.empty())) | |
print(AltList.many(AltList.empty())) | |
def _(mo) -> None: | |
mo.md(r"""## Formal implementation of Alternative""") | |
def _(ABC, Applicative, abstractmethod, dataclass): | |
class Alternative[A](Applicative, ABC): | |
"""A monoid on applicative functors.""" | |
def empty(cls) -> "Alternative[A]": | |
msg = "Subclasses must implement empty" | |
raise NotImplementedError(msg) | |
def alt( | |
cls, fa: "Alternative[A]", fb: "Alternative[A]" | |
) -> "Alternative[A]": | |
msg = "Subclasses must implement alt" | |
raise NotImplementedError(msg) | |
def some(cls, fa: "Alternative[A]") -> "Alternative[list[A]]": | |
# Short-circuit if input is empty | |
if fa == cls.empty(): | |
return cls.empty() | |
return cls.apply( | |
cls.fmap(lambda a: lambda b: [a, *b], fa), cls.many(fa) | |
) | |
def many(cls, fa: "Alternative[A]") -> "Alternative[list[A]]": | |
# Directly return empty list if input is empty | |
if fa == cls.empty(): | |
return cls.pure([]) | |
return cls.alt(cls.some(fa), cls.pure([])) | |
def check_left_identity(cls, fa: "Alternative[A]") -> bool: | |
return cls.alt(cls.empty(), fa) == fa | |
def check_right_identity(cls, fa: "Alternative[A]") -> bool: | |
return cls.alt(fa, cls.empty()) == fa | |
def check_associativity( | |
cls, fa: "Alternative[A]", fb: "Alternative[A]", fc: "Alternative[A]" | |
) -> bool: | |
return cls.alt(fa, cls.alt(fb, fc)) == cls.alt(cls.alt(fa, fb), fc) | |
return (Alternative,) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
/// admonition | |
We will explore more about `Alternative` in a future notebooks about [Monadic Parsing](https://www.cambridge.org/core/journals/journal-of-functional-programming/article/monadic-parsing-in-haskell/E557DFCCE00E0D4B6ED02F3FB0466093) | |
/// | |
""" | |
) | |
def _(mo) -> None: | |
mo.md( | |
r""" | |
# Further reading | |
Notice that these reading sources are optional and non-trivial | |
- [Applicaive Programming with Effects](https://www.staff.city.ac.uk/~ross/papers/Applicative.html) | |
- [Equivalence of Applicative Functors and | |
Multifunctors](https://arxiv.org/pdf/2401.14286) | |
- [Applicative functor](https://wiki.haskell.org/index.php?title=Applicative_functor) | |
- [Control.Applicative](https://hackage.haskell.org/package/base-4.21.0.0/docs/Control-Applicative.html#t:Applicative) | |
- [Typeclassopedia#Applicative](https://wiki.haskell.org/index.php?title=Typeclassopedia#Applicative) | |
- [Notions of computation as monoids](https://www.cambridge.org/core/journals/journal-of-functional-programming/article/notions-of-computation-as-monoids/70019FC0F2384270E9F41B9719042528) | |
- [Free Applicative Functors](https://arxiv.org/abs/1403.0749) | |
- [The basics of applicative functors, put to practical work](http://www.serpentine.com/blog/2008/02/06/the-basics-of-applicative-functors-put-to-practical-work/) | |
- [Abstracting with Applicatives](http://comonad.com/reader/2012/abstracting-with-applicatives/) | |
- [Static analysis with Applicatives](https://gergo.erdi.hu/blog/2012-12-01-static_analysis_with_applicatives/) | |
- [Explaining Applicative functor in categorical terms - monoidal functors](https://cstheory.stackexchange.com/questions/12412/explaining-applicative-functor-in-categorical-terms-monoidal-functors) | |
- [Applicative, A Strong Lax Monoidal Functor](https://beuke.org/applicative/) | |
- [Applicative Functors](https://bartoszmilewski.com/2017/02/06/applicative-functors/) | |
""" | |
) | |
if __name__ == "__main__": | |
app.run() | |