Skip to content

Commit

Permalink
Add Maybe monad (#32)
Browse files Browse the repository at this point in the history
* Add Maybe base impl (#4)

* `Maybe` container
* `Some` container
* `_Nothing` singletone for empty value
* `Nothing` instance
* `bind` function

* Add tests for `Maybe` monad (#4)

* `test_nothing_is_singleton`
* `test_bind` (including >> syntax)

* Upd commit template (#4)

* Add dedicated __rshift__ (#4)

* Add `maybe.recover` with tests (#4)

* Add `maybe.choose` (#4)

* Replace use of Callable with MaybeFunc (#4)
  • Loading branch information
katunilya committed Apr 23, 2022
1 parent e6d4396 commit 8b00e99
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"tests"
],
"vscodeGitCommit.template": [
"{message}"
"{message} (#{issue})"
]
}
137 changes: 137 additions & 0 deletions mona/maybe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import abc
import dataclasses
import typing

import toolz

from mona import state

T = typing.TypeVar("T")
V = typing.TypeVar("V")


@dataclasses.dataclass(frozen=True)
class Maybe(state.State[T], abc.ABC):
"""General purpose container for optional value.
`Maybe` container has 2 variations: `Some` and `Nothing`. `Some` stands for some
actual value and means that it is present in execution context. `Nothing` on the
other hand describes emtpiness and lack of value. This is additional wrapping around
`None` values.
Strict types help ignore
"""

def __rshift__(self, func: typing.Callable[[T], "Maybe[V]"]) -> "Maybe[V]":
"""Dunder method for `>>` bind syntax.
>>> maybe.bind(function, cnt)
>>> # exactly the same as
>>> cnt >> function
Args:
func (typing.Callable[[T], "Maybe[V]"]): _description_
Returns:
Maybe[V]: _description_
"""
return bind(func, self)


@dataclasses.dataclass(frozen=True)
class Some(Maybe[T]):
"""Container for actually present value."""


@dataclasses.dataclass(frozen=True)
class Nothing(Maybe[typing.Any]):
"""Private container for non-existent value."""

__slots__ = ()
__instance: "Nothing | None" = None

def __new__(cls, *args, **kwargs) -> "Nothing": # noqa
match cls.__instance:
case None:
cls.__instance = object.__new__(cls)
return cls.__instance
case _:
return cls.__instance

def __init__(self) -> None: # noqa
super().__init__(None)


MaybeFunc = typing.Callable[[T], Maybe[V]]


@toolz.curry
def bind(func: MaybeFunc[T, V], cnt: Maybe[T]) -> Maybe[V]:
"""Bind function for `Maybe` monad.
`function` is executed only in case `cnt` is `Some` and not `Nothing` as it does not
make any sense to execute some `function` with `Nothing`.
Args:
function (typing.Callable[[Maybe[T]], Maybe[V]]): to bind
cnt (Maybe[T]): `Maybe` container
Returns:
Maybe[V]: result of running `function` on `cnt` value.
"""
match cnt:
case Some(value):
return func(value)
case _:
return cnt


def recover(value: T) -> MaybeFunc[V, T | V]:
"""Recovers from `Nothing` or just passes `Some`.
When `Some` value is passed nothing is done and it is just returned. When `Nothing`
is passed than it is repaced with `Some` `value`.
Args:
value (T): to recover to
Returns:
typing.Callable[[Maybe[V]], Maybe[V] | Maybe[T]]: maybe handler function
"""

def _recover(cnt: Maybe[V]) -> Maybe[V] | Maybe[T]:
match cnt:
case Some():
return cnt
case _:
return Some(value)

return _recover


def _continue_on_some(cur: MaybeFunc[T, V], nxt: MaybeFunc[T, V]) -> MaybeFunc[T, V]:
def __continue_on_some(val: T):
match cur(val):
case Some(result):
return result
case Nothing():
return nxt(val)

return __continue_on_some


def choose(*functions: MaybeFunc[T, V]) -> MaybeFunc[T, V]:
"""Return first `Some` result from passed functions.
If `functions` is empty, than return `Nothing`.
If no `function` can return `Some` than return `Nothing`.
"""

def _choose(value: T):
match functions:
case ():
return Nothing()
case _:
return toolz.reduce(_continue_on_some, functions)(value)

return _choose
72 changes: 72 additions & 0 deletions tests/test_maybe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import pytest

from mona import maybe


def test_nothing_is_singleton():
assert maybe.Nothing() == maybe.Nothing()
assert id(maybe.Nothing()) == id(maybe.Nothing())


@pytest.mark.parametrize(
"arrange_function,arrange_cnt,assert_cnt",
[
(lambda x: maybe.Some(x), maybe.Some(1), maybe.Some(1)),
(lambda x: maybe.Some(x), maybe.Some(2), maybe.Some(2)),
(lambda x: maybe.Some(x), maybe.Nothing(), maybe.Nothing()),
(lambda x: maybe.Nothing(), maybe.Nothing(), maybe.Nothing()),
(lambda x: maybe.Nothing(), maybe.Some(1), maybe.Nothing()),
],
)
def test_bind(arrange_function, arrange_cnt, assert_cnt):
# act
act_cnt = maybe.bind(arrange_function, arrange_cnt)
act_cnt_rshift = arrange_cnt >> arrange_function

# assert
assert act_cnt == assert_cnt
assert act_cnt_rshift == assert_cnt


@pytest.mark.parametrize(
"arrange_recovery_value,arrange_cnt,assert_cnt",
[
(1, maybe.Some(1), maybe.Some(1)),
(2, maybe.Some(1), maybe.Some(1)),
(2, maybe.Nothing(), maybe.Some(2)),
],
)
def test_recover(arrange_recovery_value, arrange_cnt, assert_cnt):
# arrange
arrange_recover = maybe.recover(arrange_recovery_value)

# act
act_cnt = arrange_recover(arrange_cnt)

# assert
assert act_cnt == assert_cnt


@pytest.mark.parametrize(
"arrange_functions, arrange_cnt, assert_cnt",
[
(
[lambda _: maybe.Nothing(), lambda x: maybe.Some(x)],
maybe.Some(1),
maybe.Some(1),
),
(
[lambda _: maybe.Nothing, lambda x: maybe.Some(x)],
maybe.Nothing(),
maybe.Nothing(),
),
([lambda _: maybe.Nothing()], maybe.Nothing(), maybe.Nothing()),
([lambda _: maybe.Nothing()], maybe.Some(1), maybe.Nothing()),
],
)
def test_choose(arrange_functions, arrange_cnt, assert_cnt):
arrange_choose = maybe.choose(*arrange_functions)

act_cnt = arrange_cnt >> arrange_choose

assert act_cnt == assert_cnt

0 comments on commit 8b00e99

Please sign in to comment.