I know in python the builtin object()
returns a sentinel object. I'm curious to what it is, but mainly its applications.
object
is the base class that all other classes inherit from in python 3. There's not a whole lot you can do with a plain old object. However an object's identity could be useful. For example the iter function takes a sentinel
argument that signals when to stop termination. We could supply an object() to that.
sentinel = object()
def step():
inp = input('enter something: ')
if inp == 'stop' or inp == 'exit' or inp == 'done':
return sentinel
return inp
for inp in iter(step, sentinel):
print('you entered', inp)
This will ask for input until the user types stop, exit, or done. I'm not exactly sure when iter
with a sentinel is more useful than a generator, but I guess it's interesting anyway.
I'm not sure if this answers your question. To be clear, this is just a possible application of object
. Fundamentally its existence in the python language has nothing to do with it being usable as a sentinel value (to my knowledge).
if inp == "stop": break; else: yield inp
and for inp in step()
. –
Kellda Object identity and Classes in Python
You statement "I know in python the builtin object() returns a sentinel object." is slightly off, but not totally wrong, so let me first address that just to make sure we're on the same page:
object()
in Python is merely the parent of all classes. In Python 2 this was for a while explicit. In Python 2 you had to write:
class Foo(object):
...
to get a so-called "new-style object". You could also define classes without that superclass, but that was only for backwards compatibility and not important for this question.
Today in Python 3, the object
superclass is implicit. So all classes inherit from that class. As such, the two classes below are identical in Python 3:
class Foo:
pass
class Foo(object):
pass
Knowing this, we can slightly rephrase your initial statement:
... the builtin object() returns a sentinel object.
becomes then:
... the builtin object() returns an object instance of class "object"
So, when writing:
my_sentinel = object()
simply creates an empty object instance "somewhere in memory". That last part is importante, because by default, the builtin id()
function and checks using ... is ...
, rely on the memory address. For example:
>>> a = object()
>>> b = object()
>>> a is b
False
This gives you a way to create object instances that you can use to check for a certain kind of logic in your code that is otherwise very difficult or even impossible. That is the main use of "sentinel" objects.
Example use case: Making the difference between "None" and "Nothing/Uninitialised/Empty/..."
Sometimes the value None
is a valid value for a variable and you may need to detect the difference between "empty" or something similar and None
.
Let's assume you have a class doing lazy-loading for an expensive operation where "None" is a valid value. You can then write it like this:
#: sentinel value for uninitialised values
UNLOADED = object()
class MyLoader:
def __init__(self, remote_addr):
self.value = UNLOADED
self.remote_addr = remote_addr
def get(self):
if self.value is UNLOADED:
self.value = expensive_operation(self.remote_addr)
return self.value
Now expensive_operation
can return any value. Even None or any other "falsy" value and the "caching" will work without unintended bugs. It also makes the code pretty readable as it communicates the intent pretty clearly to the reader of the code-block. You also save storage (albeit negilgable) for an additional "is_loaded" boolean value.
The same code using a boolean:
class MyLoader:
def __init__(self, remote_addr):
self.value = None
self.remote_addr = remote_addr
self.is_loaded = False # <- need for an additional variable
def get(self):
if not self.is_loaded:
self.value = expensive_operation(self.remote_addr)
self.is_loaded = True # <- source for a bug if this is forgotten
return self.value
or, using "None" as default:
class MyLoader:
def __init__(self, remote_addr):
self.value = None # <- We'll use this to detect load state
self.remote_addr = remote_addr
def get(self):
if self.value is None:
self.value = expensive_operation(self.remote_addr)
# If the above returned "None" we will never "cache" the result
return self.value
Final Thoughts
The above "MyLoader" example is just one example where sentinel values can be handy. They help making code more readable and more expressive. They also avoid certain types of bugs.
They are especially useful in areas where one is tempted to use None
to signify a special value. Whenever you think something like "When X is the case, I will set the variable to None
" it may be worth thinking about using a sentinel value. Because you now gave the value None
a special meaning for a specific context.
Another such example would be to have special values for infinite integers. The concept of infinity only exists in floats. But if you want to ensure type-safety you may want to create your own "special" values like that to signify infinity.
Using sentinel values like that help distinguish between multiple different concepts which would otherwise be impossible. If you need many different "special" values and use None
everywhere, you may end up using None
from one concept in the context of another concept and end up with unintended side-effects which may be hard to debug. Imagine a contrived function like this:
SENTINEL_A = object()
SENTINEL_B = object()
def foobar(a = SENTINEL_A, b = SENTINEL_B):
if a is SENTINEL_A:
a = -12
if b is SENTINEL_B:
b = a * 2
print(a+b)
By using sentinels like this it becomes impossible to accidentally triggering the if-branches by mixing up the variables. For example, assume you refactor code and trip up somewhere, mixin a and b like this:
SENTINEL_A = object()
SENTINEL_B = object()
def foobar(a = SENTINEL_A, b = SENTINEL_B):
if b is SENTINEL_A: # <- bug: using *b* instead of *a*
a = -12
if b is SENTINEL_B:
b = a * 2
print(a+b)
In that case, the first if
can never be true (unless the function is called improperly of course). If you would have used None
as default instead, this bug would become harder to detect because you would end up with a = -12
in cases where you would not expect it.
In that sense, sentinels make your code more robust. And if logical-errors occur in your code they will be easier to find.
Having said all that, sentinel values are pretty rare. I personally find them very useful to avoid excessive usages of None
for flagging special cases.
This is a source code example from the Python standard library for dataclasses on using sentinel values
# A sentinel object to detect if a parameter is supplied or not. Use
# a class to give it a better repr.
class _MISSING_TYPE:
pass
MISSING = _MISSING_TYPE()
def get(value: Union[int, _MISSING_TYPE] = MISSING): ...
–
Unicorn © 2022 - 2024 — McMap. All rights reserved.