The scenario is we're working with a REST endpoint that gives us nice JSON objects. We're using requests, and everything works wonderfully. But one day you notice that data you though was always being pulled back isn't there for some models! You're getting index, key and attribute errors left, right and centre. What used to be obj.phones[0]
is now:
(DefaultAttr(obj, None).phones or [None])[0]
Or worse, if obj.phones[0]
could actually be None
!
And so I decided to build a simple Maybe
like group of classes. The interface in Haskell seemed really clean and simple. However there's no case
statement in Python, and not one that's based on type. so I decided to use Maybe.get(default: T) -> T
and Just._value
to get the value instead. Since Just._value
isn't implemented on either Nothing
or Maybe
, I decided to make the equality check if the LHS is an instance of the RHS, and so Just(1) == Just
would be True
.
This however isn't good enough for the JSON object, and so I subclassed Maybe
to create a MaybeNav
, that creates a MaybeNav
when you get items or attributes. And so if you don't care if obj.phones[0]
is there or None
, you can use MaybeNav(obj).phones[0].get()
. Which is much cleaner, and simpler to read.
I've added type hints to the code, so that it's a little simpler to reason with. And I've added some docstrings, I'd appreciate any way to improve these, as I don't normally use them. And so my skills with them are likely to be very poor.
Due to using f-strings, the code only works in Python 3.6. Finally any and all help is welcome.
mayby.py
from typing import TypeVar, Generic
T = TypeVar('T')
class Maybe(Generic[T]):
"""Simple Maybe abstract base class"""
def __init__(self, *args, **kwargs):
"""
Error as this should be overwridden
The code for this interface is implemented in Just and Nothing.
This is to prevent implementation errors. Such as overwritting
__getattr__ in a child class causing an infinate loop.
"""
raise TypeError("'Maybe' needs to be instansiated by child constructor")
def get(self, default: T = None) -> T:
"""Get the value in Just, or return default if Nothing"""
raise NotImplementedError("'get' should be changed in a sublcass")
class Constant(type):
def __call__(self, *args, **kwargs):
"""Simple metaclass to create a constant class"""
# Constants can't be called.
raise TypeError(f"'{self.__name__}' object is not callable")
def __repr__(self) -> str:
# Display the constant, rather than the class location in memory.
return f'{self.__name__}'
class JustBase:
def __init__(self, value: T):
"""Younger sibling class of Maybe for Just classes"""
self.__value = value
def __repr__(self) -> str:
return f'{type(self).__name__}({self._value!r})'
def __eq__(self, other: object) -> bool:
"""
Check if this is an instance of other
This makes checking if the class is a Just simpler.
As it's a common operation.
"""
return isinstance(other, type) and isinstance(self, other)
def get(self, default: T = None) -> T:
"""Get the value in Just, or return default if Nothing"""
return self._value
@property
def _value(self):
return self.__value
def build_maybes(Maybe, just_name, nothing_name):
"""Build a Just and Nothing inheriting from Maybe"""
Just = type(just_name, (JustBase, Maybe), {})
class MaybeConstant(Constant, Maybe):
def get(self, default: T = None) -> T:
"""Get the value in Just, or return default if Nothing"""
return default
Nothing = MaybeConstant(nothing_name, (object,), {})
return Just, Nothing
class MaybeNav(Maybe[T]):
"""Maybe for navigating objects"""
# __getitem__ and __getattr__ actually return MaybeNav[T].
def __getitem__(self, item: object) -> Maybe[T]:
if self == NothingNav:
return NothingNav
val = self._value
try:
val = val[item]
except Exception:
return NothingNav
else:
return JustNav(val)
def __getattr__(self, item: str) -> Maybe[T]:
obj = object()
val = getattr(self.get(obj), item, obj)
if val is obj:
return NothingNav
return JustNav(val)
Just, Nothing = build_maybes(Maybe, 'Just', 'Nothing')
JustNav, NothingNav = build_maybes(MaybeNav, 'JustNav', 'NothingNav')
# Don't delete T, so subclasses can use the same generic type
del TypeVar, Generic, Constant, JustBase
An example of using this can be:
import math
from collections import namedtuple
from typing import Iterable
from maybe import Maybe, Just, Nothing, MaybeNav, JustNav
def safe_log(number: float) -> Maybe[float]:
if number > 0:
return Just(math.log(number))
else:
return Nothing
def user_values(obj: object) -> Iterable[MaybeNav[object]]:
obj = JustNav(obj)
return [
obj.name,
obj.first_name,
obj.last_name,
obj.phones[0]
]
v = safe_log(1000)
print(v == Just, v)
v = safe_log(-1000)
print(v == Just, v)
User = namedtuple('User', 'name first_name last_name phones')
vals = user_values(User('Peilonrayz', 'Peilonrayz', None, []))
print(vals)
print([val.get(None) for val in vals])
I find maybe.py a little hard to read, mostly as I got the inheritance to happen in build_maybes
. And so the following is a class diagram of the user defined objects in maybe.py. One thing to note is the dotted arrows from Nothing
and NothingNav
to MaybeConstant
are to show they are using a metaclass, rather than box standard inheritance. The box with some classes in it is to emphasise they are created in build_maybes
.
1 Answer 1
This may very well be not what you want to hear, but this seems like the wrong kind of solution for a duck-typed language like Python, especially for something like a REST endpoint. You are going to be fighting the language and implementing non-idiomatic solutions to work around the fact that the language is based around a looser object model than you seem to want to impose.
My first reaction to (DefaultAttr(obj, None).phones or [None])[0]
would not be fit for this forum, but the next thought would be why obj.phones[0] if len(obj.phones) else None
wasn't good enough. It sounds like your data handling might be too generic - you are passing "arbitrary" objects to code which expects an object with a phones
attribute. Instead you could pull out the phones
-specific code and put that where you are sure of receiving compatible objects. Another option would be to build a generic object handler which finds all non-default attributes and either handles them all or passes them on to attribute-specific handlers.
Generics are much more of a known and expected factor in for example Haskell (as you know) and Java. So implementing this in a different language might better fit your programming style.
Passing type names as strings is a code smell in every language - you instantly lose the ability for automatic code inspection to understand what is happening.
Overriding __repr__
to print the class name is really weird. From the documentation:
If at all possible, this should look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment). If this is not possible, a string of the form <...some useful description...> should be returned. [...] This is typically used for debugging, so it is important that the representation is information-rich and unambiguous.
A couple of examples should illustrate this point:
>>> 'foo'.__repr__()
"'foo'"
>>> Exception().__repr__()
'Exception()'
With regard to docstrings, you should use them to explain the why, not the what. For example the one for Maybe.__init__
explains something which should be known to developers familiar with the Python object model. One exception in my opinion is when you cannot express a "what" in the language itself. For example, mentioning that something is an abstract base class is good because it's only implicit by the fact that the constructor raises TypeError
(that is, the constructor could conceivably throw TypeError
for some other reason).
-
\$\begingroup\$ Thank you for your answer, I've read over it a couple of times, but don't really understand why you think it's "the wrong kind of solution for a duck-typed language". But most of all, I don't really know what you would want me to replace it with, since
obj.phones.get(0)
raises an attribute error. As for__repr__
what do you expect a constant to look like? \$\endgroup\$2017年06月10日 13:20:27 +00:00Commented Jun 10, 2017 at 13:20 -
\$\begingroup\$ The answer is pretty generic, but I can't explain idiomatic Python in sufficient detail in this post. That would take ages and would be well beyond a code review. \$\endgroup\$l0b0– l0b02017年06月28日 16:41:40 +00:00Commented Jun 28, 2017 at 16:41
-
\$\begingroup\$ I have no problem with this being a generic review. My problem is for the most part you say X, Y and Z are bad, without saying why they're bad or how I could improve them. I know my code is bad, that's why I posted it on Code Review. But I want to improve my programming ability, which I can't do without a why or how to improve. If I didn't want to learn how to improve, then I'd more than happily just post my code on Code Crap. \$\endgroup\$2017年06月28日 17:58:10 +00:00Commented Jun 28, 2017 at 17:58
-
\$\begingroup\$ More specifically I don't get what you'd want me to change my
__repr__
to. I don't really understand how I'll be "fighting the language", rather than 100if-else
s, or the 'horrible'or
s, I'd just use normal getattrs and getitems. And to me "the language is based around a looser object model than you seem to want to impose" and "It sounds like your data handling might be too generic" seem to contradict eachother. I'm sure I'm missing something, but I've read this like 5 times, and I still don't get what I'm missing. \$\endgroup\$2017年06月28日 18:11:16 +00:00Commented Jun 28, 2017 at 18:11
Explore related questions
See similar questions with these tags.