Skip to content

Commit 958b9d1

Browse files
committed
Makes monads immutable, adds more docs
1 parent aaad83f commit 958b9d1

File tree

7 files changed

+216
-20
lines changed

7 files changed

+216
-20
lines changed

docs/pages/do-notation.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _do-notation:
2+
13
Do notation
24
===========
35

docs/pages/monad.rst

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,144 @@
11
Monad: the concept
22
==================
33

4+
.. currentmodule:: dry_monads.primitives.monads
5+
6+
We won't say that monad is `a monoid in the category of endofunctors <https://stackoverflow.com/questions/3870088/a-monad-is-just-a-monoid-in-the-category-of-endofunctors-whats-the-problem>`_.
7+
8+
Monad is a concept that allows you
9+
to write code without traditional error handling
10+
while maintaining the execution context.
11+
12+
We will show you its simple API of one attribute and several simple methods.
13+
14+
Internals
15+
---------
16+
17+
The main idea behind a monad is that it wraps some internal state.
18+
That's what
19+
:py:attr:`Monad._inner_value <dry_monads.primitives.monad.Monad._inner_value>`
20+
is used for.
21+
22+
And we have several functions to create new monads based on the previous state.
23+
And we can see how this state is evolving during the execution.
24+
25+
.. mermaid::
26+
:caption: State evolution.
27+
28+
graph LR
29+
F1["State()"] --> F2["State(UserId(1))"]
30+
F2 --> F3["State(UserAccount(156))"]
31+
F3 --> F4["State(FailedLoginAttempt(1))"]
32+
F4 --> F5["State(SentNotificationId(992))"]
33+
34+
Creating new monads
35+
~~~~~~~~~~~~~~~~~~~
36+
37+
We use two methods to create new monads from the previous one.
38+
``bind`` and ``fmap``.
39+
40+
The difference is simple:
41+
42+
- ``fmap`` works with functions that return regular values
43+
- ``bind`` works with functions that return monads
44+
45+
:func:`Monad.bind <dry_monads.primitives.monad.Monad.bind>`
46+
is used to literally bind two different monads together.
47+
48+
.. code:: python
49+
50+
from dry_monads.either import Either, Success
51+
52+
def make_http_call(user_id: int) -> Either[int, str]:
53+
...
54+
55+
result = Success(1).bind(make_http_call)
56+
# => will be equal to either Success[int] or Failure[str]
57+
58+
So, the rule is: whenever you have some impure functions,
59+
it should return a monad instead.
60+
61+
And we use :func:`Monad.fmap <dry_monads.primitives.monad.Monad.fmap>`
62+
to use monads with pure functions.
63+
64+
.. code:: python
65+
66+
from dry_monads.either import Success
67+
68+
def double(state: int) -> int:
69+
return state * 2
70+
71+
result = Success(1).fmap(double)
72+
# => will be equal to Success(2)
73+
74+
Unwrapping values
75+
~~~~~~~~~~~~~~~~~
76+
77+
And we have two more functions to unwrap
78+
inner state of monads into a regular types:
79+
80+
- :func:`Monad.value_or <dry_monads.primitives.monad.Monad.value_or>` - returns
81+
a value if it is possible, returns ``default_value`` otherwise
82+
- :func:`Monad.unwrap <dry_monads.primitives.monad.Monad.unwrap>` - returns
83+
a value if it possible, raises ``UnwrapFailedError`` otherwise
84+
85+
.. code:: python
86+
87+
from dry_monads.either import Failure, Success
88+
89+
Success(1).value_or(None)
90+
# => 1
91+
92+
Success(0).unwrap()
93+
# => 0
94+
95+
Failure(1).value_or(default_value=100)
96+
# => 100
97+
98+
Failure(1).unwrap()
99+
# => Traceback (most recent call last): UnwrapFailedError
100+
101+
The most user-friendly way to use ``unwrap`` method is with :ref:`do-notation`.
102+
103+
Immutability
104+
------------
105+
106+
We like to think of ``dry-monads`` as immutable structures.
107+
You cannot mutate the inner state of the created monad,
108+
because we redefine ``__setattr__`` and ``__delattr__`` magic methods.
109+
110+
You cannot also set new attributes to monad instances,
111+
since we are using ``__slots__`` for better performance and strictness.
112+
113+
Well, nothing is **really** immutable in python, but you were warned.
114+
115+
Using lambda functions
116+
----------------------
117+
118+
Please, do not use ``lambda`` functions in ``python``. Why?
119+
Because all ``lambda`` functions arguments are typed as ``Any``.
120+
This way you won't have any practical typing features
121+
from ``fmap`` and ``bind`` methods.
122+
123+
So, instead of:
124+
125+
.. code:: python
126+
127+
some_monad.fmap(lambda x: x + 2) #: Callable[[Any], Any]
128+
129+
Write:
130+
131+
.. code:: python
132+
133+
from functools import partial
134+
135+
def increment(addition: int, number: int) -> int:
136+
return number + addition
137+
138+
some_monad.fmap(partial(increment, 2)) #: functools.partial[builtins.int*]
139+
140+
This way your code will be type-safe from errors.
141+
4142
API Reference
5143
-------------
6144

dry_monads/either.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(self, inner_value: ErrorType) -> None:
5353
5454
'value' is any arbitrary value of any type including functions.
5555
"""
56-
self._inner_value = inner_value
56+
object.__setattr__(self, '_inner_value', inner_value)
5757

5858
def fmap(self, function) -> 'Left[ErrorType]':
5959
"""Returns the 'Left' instance that was used to call the method."""
@@ -86,7 +86,7 @@ def __init__(self, inner_value: ValueType) -> None:
8686
8787
'value' is any arbitrary value of any type including functions.
8888
"""
89-
self._inner_value = inner_value
89+
object.__setattr__(self, '_inner_value', inner_value)
9090

9191
def fmap(
9292
self,

dry_monads/primitives/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ def __init__(self, monad: 'MonadType') -> None:
1818
"""
1919
super().__init__()
2020
self.halted_monad = monad
21+
22+
23+
class ImmutableStateError(Exception):
24+
"""Raised when a monad is forced to be mutated."""

dry_monads/primitives/monad.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
11
# -*- coding: utf-8 -*-
22

33
from abc import ABCMeta, abstractmethod
4-
from typing import Any, Generic, TypeVar
4+
from typing import Any, Generic, NoReturn, TypeVar
5+
6+
from dry_monads.primitives.exceptions import ImmutableStateError
57

68
ValueType = TypeVar('ValueType')
79
NewValueType = TypeVar('NewValueType')
810

911

10-
class Monad(Generic[ValueType], metaclass=ABCMeta):
12+
class _BaseMonad(Generic[ValueType], metaclass=ABCMeta):
13+
"""Utility class to provide all needed magic methods to the contest."""
14+
15+
__slots__ = ('_inner_value',)
16+
_inner_value: Any
17+
18+
def __setattr__(self, attr_name, attr_value) -> NoReturn:
19+
"""Makes inner state of the monads immutable."""
20+
raise ImmutableStateError()
21+
22+
def __delattr__(self, attr_name) -> NoReturn: # noqa: Z434
23+
"""Makes inner state of the monads immutable."""
24+
raise ImmutableStateError()
25+
26+
def __str__(self) -> str:
27+
"""Converts to string."""
28+
return '{0}: {1}'.format(
29+
self.__class__.__qualname__,
30+
str(self._inner_value),
31+
)
32+
33+
def __eq__(self, other) -> bool:
34+
"""Used to compare two 'Monad' objects."""
35+
if not isinstance(other, _BaseMonad):
36+
return False
37+
return self._inner_value == other._inner_value # noqa: Z441
38+
39+
40+
class Monad(_BaseMonad[ValueType]):
1141
"""
1242
Represents a "context" in which calculations can be executed.
1343
@@ -17,9 +47,12 @@ class Monad(Generic[ValueType], metaclass=ABCMeta):
1747
a series of calculations while maintaining
1848
the context of that specific monad.
1949
20-
"""
50+
This is an abstract class with the API declaration.
2151
22-
_inner_value: Any
52+
Attributes:
53+
_inner_value: Wrapped internal immutable state.
54+
55+
"""
2356

2457
@abstractmethod
2558
def fmap(self, function): # pragma: no cover
@@ -53,16 +86,3 @@ def unwrap(self) -> ValueType: # pragma: no cover
5386
And for ones that raise an exception for no values.
5487
"""
5588
raise NotImplementedError()
56-
57-
def __str__(self) -> str:
58-
"""Converts to string."""
59-
return '{0}: {1}'.format(
60-
self.__class__.__qualname__,
61-
str(self._inner_value),
62-
)
63-
64-
def __eq__(self, other) -> bool:
65-
"""Used to compare two 'Monad' objects."""
66-
if not isinstance(other, Monad):
67-
return False
68-
return self._inner_value == other._inner_value # noqa: Z441

setup.cfg

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ warn_unused_ignores = True
7777
warn_redundant_casts = True
7878
warn_unused_configs = True
7979

80-
8180
[doc8]
8281
ignore-path = docs/_build
8382
max-line-length = 80

tests/test_either/test_equality.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# -*- coding: utf-8 -*-
22

3+
import pytest
4+
35
from dry_monads.either import Left, Right
6+
from dry_monads.primitives.exceptions import ImmutableStateError
47

58

69
def test_nonequality():
@@ -9,3 +12,33 @@ def test_nonequality():
912

1013
assert Left(input_value) != input_value
1114
assert Right(input_value) != input_value
15+
16+
17+
def test_immutability_failure():
18+
"""Ensures that Failure monad is immutable."""
19+
with pytest.raises(ImmutableStateError):
20+
Left(0)._inner_state = 1 # noqa: Z441
21+
22+
with pytest.raises(ImmutableStateError):
23+
Left(1).missing = 2
24+
25+
with pytest.raises(ImmutableStateError):
26+
del Left(0)._inner_state # type: ignore # noqa: Z420, Z441
27+
28+
with pytest.raises(AttributeError):
29+
Left(1).missing # type: ignore # noqa: Z444
30+
31+
32+
def test_immutability_success():
33+
"""Ensures that Success monad is immutable."""
34+
with pytest.raises(ImmutableStateError):
35+
Right(0)._inner_state = 1 # noqa: Z441
36+
37+
with pytest.raises(ImmutableStateError):
38+
Right(1).missing = 2
39+
40+
with pytest.raises(ImmutableStateError):
41+
del Right(0)._inner_state # type: ignore # noqa: Z420, Z441
42+
43+
with pytest.raises(AttributeError):
44+
Right(1).missing # type: ignore # noqa: Z444

0 commit comments

Comments
 (0)