This tutorial will guide you through the process of creating your own containers.
Step 0: Motivation¶First things first, why would anyone want to create a custom containers?
The great idea about “containers” in functional programming is that it can be literally anything. There are endless use-cases.
You can create your own primitives for working with some language-or-framework specific problem, or just model your business domain.
You can copy ideas from other languages or just compose existing containers for better usability (like IOResult
is the composition of IO
and Result
).
Example
We are going to implement a Pair
container for this example. What is a Pair
? Well, it is literally a pair of two values. No more, no less. Similar to a Tuple[FirstType, SecondType]
. But with extra goodies.
After you came up with the idea, you will need to make a decision: what capabilities my container must have?
Basically, you should decide what Interfaces you will subtype and what methods and laws will be present in your type. You can create just a returns.interfaces.mappable.MappableN
or choose a full featured returns.interfaces.container.ContainerN
.
You can also choose some specific interfaces to use, like returns.interfaces.specific.result.ResultLikeN
or any other.
Summing up, decide what laws and methods you need to solve your problem. And then subtype the interfaces that provide these methods and laws.
Example
What interfaces a Pair
type needs?
returns.interfaces.equable.Equable
, because two Pair
instances can be compared
returns.interfaces.mappable.MappableN
, because the first type can be composed with pure functions
returns.interfaces.bindable.BindableN
, because a Pair
can be bound to a function returning a new Pair
based on the first type
returns.interfaces.altable.AltableN
, because the second type can be composed with pure functions
returns.interfaces.lashable.LashableN
, because a Pair
can be bound to a function returning a new Pair
based on the second type
Now, after we know about all interfaces we would need, let’s find pre-defined aliases we can reuse.
Turns out, there are some of them!
returns.interfaces.bimappable.BiMappableN
which combines MappableN
and AltableN
returns.interfaces.swappable.SwappableN
is an alias for BiMappableN
with a new method called .swap
to change values order
Let’s look at the result:
Note
A special note on returns.primitives.container.BaseContainer
. It is a very useful class with lots of pre-defined features, like: immutability, better cloning, serialization, and comparison.
You can skip it if you wish, but it is highlighly recommended.
Later we will talk about an actual implementation of all required methods.
Step 2: Initial implementation¶So, let’s start writing some code!
We would need to implement all interface methods, otherwise mypy
won’t be happy. That’s what it currently says on our type definition:
error: Final class test_pair1.Pair has abstract attributes "alt", "bind", "equals", "lash", "map", "swap"
Looks like it already knows what methods should be there!
Ok, let’s drop some initial and straight forward implementation. We will later make it more complex step by step.
1from collections.abc import Callable 2from typing import TypeVar, final 3 4from returns.interfaces import bindable, equable, lashable, swappable 5from returns.primitives.container import BaseContainer, container_equality 6from returns.primitives.hkt import Kind2, SupportsKind2, dekind 7 8_FirstType = TypeVar('_FirstType') 9_SecondType = TypeVar('_SecondType') 10 11_NewFirstType = TypeVar('_NewFirstType') 12_NewSecondType = TypeVar('_NewSecondType') 13 14 15@final 16class Pair( 17 BaseContainer, 18 SupportsKind2['Pair', _FirstType, _SecondType], 19 bindable.Bindable2[_FirstType, _SecondType], 20 swappable.Swappable2[_FirstType, _SecondType], 21 lashable.Lashable2[_FirstType, _SecondType], 22 equable.Equable, 23): 24 """ 25 A type that represents a pair of something. 26 27 Like to coordinates ``(x, y)`` or two best friends. 28 Or a question and an answer. 29 30 """ 31 32 def __init__( 33 self, 34 inner_value: tuple[_FirstType, _SecondType], 35 ) -> None: 36 """Saves passed tuple as ``._inner_value`` inside this instance.""" 37 super().__init__(inner_value) 38 39 # `Equable` part: 40 41 equals = container_equality # we already have this defined for all types 42 43 # `Mappable` part via `BiMappable`: 44 45 def map( 46 self, 47 function: Callable[[_FirstType], _NewFirstType], 48 ) -> 'Pair[_NewFirstType, _SecondType]': 49 return Pair((function(self._inner_value[0]), self._inner_value[1])) 50 51 # `BindableN` part: 52 53 def bind( 54 self, 55 function: Callable[ 56 [_FirstType], 57 Kind2['Pair', _NewFirstType, _SecondType], 58 ], 59 ) -> 'Pair[_NewFirstType, _SecondType]': 60 return dekind(function(self._inner_value[0])) 61 62 # `AltableN` part via `BiMappableN`: 63 64 def alt( 65 self, 66 function: Callable[[_SecondType], _NewSecondType], 67 ) -> 'Pair[_FirstType, _NewSecondType]': 68 return Pair((self._inner_value[0], function(self._inner_value[1]))) 69 70 # `LashableN` part: 71 72 def lash( 73 self, 74 function: Callable[ 75 [_SecondType], 76 Kind2['Pair', _FirstType, _NewSecondType], 77 ], 78 ) -> 'Pair[_FirstType, _NewSecondType]': 79 return dekind(function(self._inner_value[1])) 80 81 # `SwappableN` part: 82 83 def swap(self) -> 'Pair[_SecondType, _FirstType]': 84 return Pair((self._inner_value[1], self._inner_value[0]))
You can check our resulting source with mypy
. It would be happy this time.
As you can see our existing interfaces do not cover everything. We can potentially want several extra things:
A method that takes two arguments and returns a new Pair
instance
A named constructor to create a Pair
from a single value
A named constructor to create a Pair
from two values
We can define an interface just for this! It would be also nice to add all other interfaces there as supertypes.
That’s how it is going to look:
1class PairLikeN( 2 bindable.BindableN[_FirstType, _SecondType, _ThirdType], 3 swappable.SwappableN[_FirstType, _SecondType, _ThirdType], 4 lashable.LashableN[_FirstType, _SecondType, _ThirdType], 5 equable.Equable, 6): 7 """Special interface for types that look like a ``Pair``.""" 8 9 @abstractmethod 10 def pair( 11 self: _PairLikeKind, 12 function: Callable[ 13 [_FirstType, _SecondType], 14 KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType], 15 ], 16 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 17 """Allows to work with both arguments at the same time.""" 18 19 @classmethod 20 @abstractmethod 21 def from_paired( 22 cls: type[_PairLikeKind], 23 first: _NewFirstType, 24 second: _NewSecondType, 25 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 26 """Allows to create a PairLikeN from just two values.""" 27 28 @classmethod 29 @abstractmethod 30 def from_unpaired( 31 cls: type[_PairLikeKind], 32 inner_value: _NewFirstType, 33 ) -> KindN[_PairLikeKind, _NewFirstType, _NewFirstType, _ThirdType]: 34 """Allows to create a PairLikeN from just a single object."""
Awesome! Now we have a new interface to implement. Let’s do that!
1 def pair( 2 self, 3 function: Callable[ 4 [_FirstType, _SecondType], 5 Kind2['Pair', _NewFirstType, _NewSecondType], 6 ], 7 ) -> 'Pair[_NewFirstType, _NewSecondType]': 8 return dekind(function(self._inner_value[0], self._inner_value[1]))
1 @classmethod 2 def from_unpaired( 3 cls, 4 inner_value: _NewFirstType, 5 ) -> 'Pair[_NewFirstType, _NewFirstType]': 6 return Pair((inner_value, inner_value))
Looks like we are done!
Step 4: Writing tests and docs¶The best part about this type is that it is pure. So, we can write our tests inside docs!
We are going to use doctests builtin module for that.
This gives us several key benefits:
All our docs has usage examples
All our examples are correct, because they are executed and tested
We don’t need to write regular boring tests
Let’s add docs and doctests! Let’s use map
method as a short example:
1 def map( 2 self, 3 function: Callable[[_FirstType], _NewFirstType], 4 ) -> 'Pair[_NewFirstType, _SecondType]': 5 """ 6 Changes the first type with a pure function. 7 8 >>> assert Pair((1, 2)).map(str) == Pair(('1', 2)) 9 10 """ 11 return Pair((function(self._inner_value[0]), self._inner_value[1]))
By adding these simple tests we would already have 100% coverage. But, what if we can completely skip writing tests, but still have 100%?
Let’s discuss how we can achieve that with “Laws as values”.
Step 5: Checking laws¶We already ship lots of laws with our interfaces. See our docs on laws and checking them.
Moreover, you can also define your own laws! Let’s add them to our PairLikeN
interface.
Let’s start with laws definition:
1class _LawSpec(LawSpecDef): 2 @law_definition 3 def pair_equality_law( 4 raw_value: _FirstType, 5 container: 'PairLikeN[_FirstType, _SecondType, _ThirdType]', 6 ) -> None: 7 """Ensures that unpaired and paired constructors work fine.""" 8 assert_equal( 9 container.from_unpaired(raw_value), 10 container.from_paired(raw_value, raw_value), 11 ) 12 13 @law_definition 14 def pair_left_identity_law( 15 pair: tuple[_FirstType, _SecondType], 16 container: 'PairLikeN[_FirstType, _SecondType, _ThirdType]', 17 function: Callable[ 18 [_FirstType, _SecondType], 19 KindN['PairLikeN', _NewFirstType, _NewSecondType, _ThirdType], 20 ], 21 ) -> None: 22 """Ensures that unpaired and paired constructors work fine.""" 23 assert_equal( 24 container.from_paired(*pair).pair(function), 25 function(*pair), 26 )
And them let’s add them to our PairLikeN
interface:
1class PairLikeN( 2 bindable.BindableN[_FirstType, _SecondType, _ThirdType], 3 swappable.SwappableN[_FirstType, _SecondType, _ThirdType], 4 lashable.LashableN[_FirstType, _SecondType, _ThirdType], 5 equable.Equable, 6): 7 """Special interface for types that look like a ``Pair``.""" 8 9 _laws: ClassVar[Sequence[Law]] = ( 10 Law2(_LawSpec.pair_equality_law), 11 Law3(_LawSpec.pair_left_identity_law), 12 ) 13 14 @abstractmethod 15 def pair( 16 self: _PairLikeKind, 17 function: Callable[ 18 [_FirstType, _SecondType], 19 KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType], 20 ], 21 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 22 """Allows to work with both arguments at the same time.""" 23 24 @classmethod 25 @abstractmethod 26 def from_paired( 27 cls: type[_PairLikeKind], 28 first: _NewFirstType, 29 second: _NewSecondType, 30 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 31 """Allows to create a PairLikeN from just two values.""" 32 33 @classmethod 34 @abstractmethod 35 def from_unpaired( 36 cls: type[_PairLikeKind], 37 inner_value: _NewFirstType, 38 ) -> KindN[_PairLikeKind, _NewFirstType, _NewFirstType, _ThirdType]: 39 """Allows to create a PairLikeN from just a single object."""
The last to do is to call check_all_laws(Pair, use_init=True)
to generate 10 hypothesis
test cases with hundreds real test cases inside.
Here’s the final result of our brand new Pair
type:
1from abc import abstractmethod 2from collections.abc import Callable, Sequence 3from typing import ClassVar, TypeVar, final 4 5from typing_extensions import Never 6 7from returns.contrib.hypothesis.laws import check_all_laws 8from returns.interfaces import bindable, equable, lashable, swappable 9from returns.primitives.asserts import assert_equal 10from returns.primitives.container import BaseContainer, container_equality 11from returns.primitives.hkt import Kind2, KindN, SupportsKind2, dekind 12from returns.primitives.laws import Law, Law2, Law3, LawSpecDef, law_definition 13 14_FirstType = TypeVar('_FirstType') 15_SecondType = TypeVar('_SecondType') 16_ThirdType = TypeVar('_ThirdType') 17 18_NewFirstType = TypeVar('_NewFirstType') 19_NewSecondType = TypeVar('_NewSecondType') 20 21_PairLikeKind = TypeVar('_PairLikeKind', bound='PairLikeN') 22 23 24class _LawSpec(LawSpecDef): 25 @law_definition 26 def pair_equality_law( 27 raw_value: _FirstType, 28 container: 'PairLikeN[_FirstType, _SecondType, _ThirdType]', 29 ) -> None: 30 """Ensures that unpaired and paired constructors work fine.""" 31 assert_equal( 32 container.from_unpaired(raw_value), 33 container.from_paired(raw_value, raw_value), 34 ) 35 36 @law_definition 37 def pair_left_identity_law( 38 pair: tuple[_FirstType, _SecondType], 39 container: 'PairLikeN[_FirstType, _SecondType, _ThirdType]', 40 function: Callable[ 41 [_FirstType, _SecondType], 42 KindN['PairLikeN', _NewFirstType, _NewSecondType, _ThirdType], 43 ], 44 ) -> None: 45 """Ensures that unpaired and paired constructors work fine.""" 46 assert_equal( 47 container.from_paired(*pair).pair(function), 48 function(*pair), 49 ) 50 51 52class PairLikeN( 53 bindable.BindableN[_FirstType, _SecondType, _ThirdType], 54 swappable.SwappableN[_FirstType, _SecondType, _ThirdType], 55 lashable.LashableN[_FirstType, _SecondType, _ThirdType], 56 equable.Equable, 57): 58 """Special interface for types that look like a ``Pair``.""" 59 60 _laws: ClassVar[Sequence[Law]] = ( 61 Law2(_LawSpec.pair_equality_law), 62 Law3(_LawSpec.pair_left_identity_law), 63 ) 64 65 @abstractmethod 66 def pair( 67 self: _PairLikeKind, 68 function: Callable[ 69 [_FirstType, _SecondType], 70 KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType], 71 ], 72 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 73 """Allows to work with both arguments at the same time.""" 74 75 @classmethod 76 @abstractmethod 77 def from_paired( 78 cls: type[_PairLikeKind], 79 first: _NewFirstType, 80 second: _NewSecondType, 81 ) -> KindN[_PairLikeKind, _NewFirstType, _NewSecondType, _ThirdType]: 82 """Allows to create a PairLikeN from just two values.""" 83 84 @classmethod 85 @abstractmethod 86 def from_unpaired( 87 cls: type[_PairLikeKind], 88 inner_value: _NewFirstType, 89 ) -> KindN[_PairLikeKind, _NewFirstType, _NewFirstType, _ThirdType]: 90 """Allows to create a PairLikeN from just a single object.""" 91 92 93PairLike2 = PairLikeN[_FirstType, _SecondType, Never] 94PairLike3 = PairLikeN[_FirstType, _SecondType, _ThirdType] 95 96 97@final 98class Pair( 99 BaseContainer, 100 SupportsKind2['Pair', _FirstType, _SecondType], 101 PairLike2[_FirstType, _SecondType], 102): 103 """ 104 A type that represents a pair of something. 105 106 Like to coordinates ``(x, y)`` or two best friends. 107 Or a question and an answer. 108 109 """ 110 111 def __init__( 112 self, 113 inner_value: tuple[_FirstType, _SecondType], 114 ) -> None: 115 """Saves passed tuple as ``._inner_value`` inside this instance.""" 116 super().__init__(inner_value) 117 118 # `Equable` part: 119 120 equals = container_equality # we already have this defined for all types 121 122 # `Mappable` part via `BiMappable`: 123 124 def map( 125 self, 126 function: Callable[[_FirstType], _NewFirstType], 127 ) -> 'Pair[_NewFirstType, _SecondType]': 128 """ 129 Changes the first type with a pure function. 130 131 >>> assert Pair((1, 2)).map(str) == Pair(('1', 2)) 132 133 """ 134 return Pair((function(self._inner_value[0]), self._inner_value[1])) 135 136 # `BindableN` part: 137 138 def bind( 139 self, 140 function: Callable[ 141 [_FirstType], 142 Kind2['Pair', _NewFirstType, _SecondType], 143 ], 144 ) -> 'Pair[_NewFirstType, _SecondType]': 145 """ 146 Changes the first type with a function returning another ``Pair``. 147 148 >>> def bindable(first: int) -> Pair[str, str]: 149 ... return Pair((str(first), '')) 150 151 >>> assert Pair((1, 'b')).bind(bindable) == Pair(('1', '')) 152 153 """ 154 return dekind(function(self._inner_value[0])) 155 156 # `AltableN` part via `BiMappableN`: 157 158 def alt( 159 self, 160 function: Callable[[_SecondType], _NewSecondType], 161 ) -> 'Pair[_FirstType, _NewSecondType]': 162 """ 163 Changes the second type with a pure function. 164 165 >>> assert Pair((1, 2)).alt(str) == Pair((1, '2')) 166 167 """ 168 return Pair((self._inner_value[0], function(self._inner_value[1]))) 169 170 # `LashableN` part: 171 172 def lash( 173 self, 174 function: Callable[ 175 [_SecondType], 176 Kind2['Pair', _FirstType, _NewSecondType], 177 ], 178 ) -> 'Pair[_FirstType, _NewSecondType]': 179 """ 180 Changes the second type with a function returning ``Pair``. 181 182 >>> def lashable(second: int) -> Pair[str, str]: 183 ... return Pair(('', str(second))) 184 185 >>> assert Pair(('a', 2)).lash(lashable) == Pair(('', '2')) 186 187 """ 188 return dekind(function(self._inner_value[1])) 189 190 # `SwappableN` part: 191 192 def swap(self) -> 'Pair[_SecondType, _FirstType]': 193 """ 194 Swaps ``Pair`` elements. 195 196 >>> assert Pair((1, 2)).swap() == Pair((2, 1)) 197 198 """ 199 return Pair((self._inner_value[1], self._inner_value[0])) 200 201 # `PairLikeN` part: 202 203 def pair( 204 self, 205 function: Callable[ 206 [_FirstType, _SecondType], 207 Kind2['Pair', _NewFirstType, _NewSecondType], 208 ], 209 ) -> 'Pair[_NewFirstType, _NewSecondType]': 210 """ 211 Creates a new ``Pair`` from an existing one via a passed function. 212 213 >>> def min_max(first: int, second: int) -> Pair[int, int]: 214 ... return Pair((min(first, second), max(first, second))) 215 216 >>> assert Pair((2, 1)).pair(min_max) == Pair((1, 2)) 217 >>> assert Pair((1, 2)).pair(min_max) == Pair((1, 2)) 218 219 """ 220 return dekind(function(self._inner_value[0], self._inner_value[1])) 221 222 @classmethod 223 def from_paired( 224 cls, 225 first: _NewFirstType, 226 second: _NewSecondType, 227 ) -> 'Pair[_NewFirstType, _NewSecondType]': 228 """ 229 Creates a new pair from two values. 230 231 >>> assert Pair.from_paired(1, 2) == Pair((1, 2)) 232 233 """ 234 return Pair((first, second)) 235 236 @classmethod 237 def from_unpaired( 238 cls, 239 inner_value: _NewFirstType, 240 ) -> 'Pair[_NewFirstType, _NewFirstType]': 241 """ 242 Creates a new pair from a single value. 243 244 >>> assert Pair.from_unpaired(1) == Pair((1, 1)) 245 246 """ 247 return Pair((inner_value, inner_value)) 248 249 250# Running hypothesis auto-generated tests: 251check_all_laws(Pair, use_init=True)Step 6: Writing type-tests¶
The next thing we want is to write a type-test!
What is a type-test? This is a special type of tests for your typing. We run mypy
on top of tests and use snapshots to assert the result.
We recommend to use pytest-mypy-plugins. Read more about how to use it.
Let’s start with a simple test to make sure our .pair
function works correctly:
Warning
Please, don’t use env:
property the way we do here. We need it since we store our example in tests/
folder. And we have to tell mypy
how to find it.
1- case: test_pair_type 2 disable_cache: false 3 env: 4 - MYPYPATH=./tests/test_examples/test_your_container 5 mypy_config: disallow_subclassing_any = False 6 main: | 7 # Let's import our `Pair` type we defined earlier: 8 from test_pair4 import Pair 9 10 reveal_type(Pair) 11 12 def function(first: int, second: str) -> Pair[float, bool]: 13 ... 14 15 my_pair: Pair[int, str] = Pair.from_paired(1, 'a') 16 reveal_type(my_pair.pair(function)) 17 out: | 18 main:4: note: Revealed type is "def [_FirstType, _SecondType] (inner_value: tuple[_FirstType`1, _SecondType`2]) -> test_pair4.Pair[_FirstType`1, _SecondType`2]" 19 main:10: note: Revealed type is "test_pair4.Pair[builtins.float, builtins.bool]"
Ok, now, let’s try to raise an error by using it incorrectly:
1- case: test_pair_error 2 disable_cache: false 3 env: 4 # We only need this because we store this example in `tests/` 5 # and not in our source code. Please, do not copy this line! 6 - MYPYPATH=./tests/test_examples/test_your_container 7 8 # TODO: remove this config after 9 # mypy/typeshed/stdlib/unittest/mock.pyi:120: 10 # error: Class cannot subclass "Any" (has type "Any") 11 # is fixed. 12 mypy_config: 13 disallow_subclassing_any = False 14 main: | 15 # Let's import our `Pair` type we defined earlier: 16 from test_pair4 import Pair 17 18 # Oups! This function has first and second types swapped! 19 def function(first: str, second: int) -> Pair[float, bool]: 20 ... 21 22 my_pair = Pair.from_paired(1, 'a') 23 my_pair.pair(function) # this should and will error 24 out: | 25 main:9: error: Argument 1 to "pair" of "Pair" has incompatible type "Callable[[str, int], Pair[float, bool]]"; expected "Callable[[int, str], KindN[Pair[Any, Any], float, bool, Any]]" [arg-type]Step 7: Reusing code¶
The last (but not the least!) thing you need to know is that you can reuse all code we already have for this new Pair
type.
This is because of our Higher Kinded Types feature.
So, let’s say we want to use native map_()
pointfree function with our new Pair
type. Let’s test that it will work correctly:
1- case: test_pair_map 2 disable_cache: false 3 env: 4 - MYPYPATH=./tests/test_examples/test_your_container 5 mypy_config: disallow_subclassing_any = False 6 main: | 7 from test_pair4 import Pair 8 from returns.pointfree import map_ 9 10 my_pair: Pair[int, int] = Pair.from_unpaired(1) 11 reveal_type(my_pair.map(str)) 12 reveal_type(map_(str)(my_pair)) 13 out: | 14 main:5: note: Revealed type is "test_pair4.Pair[builtins.str, builtins.int]" 15 main:6: note: Revealed type is "test_pair4.Pair[builtins.str, builtins.int]"
Yes, it works!
Now you have fully working, typed, documented, lawful, and tested primitive. You can build any other primitive you need for your business logic or infrastructure.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4