Sentinel object and its applications?
Asked Answered
X

3

10

I know in python the builtin object() returns a sentinel object. I'm curious to what it is, but mainly its applications.

X answered 4/9, 2016 at 5:52 Comment(3)
docs.python.org/3/library/builtins.html it makes stuff a lot easier for instance if you are taking user input you can use .lower() and even if they type caps its saved as all lower case letters. So it makes conditionals easier to deal with user input.Sidereal
Could I have an example of this?X
Also interesting: effbot.org/zone/default-values.htm#what-to-do-insteadSheathe
I
10

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).

Injector answered 4/9, 2016 at 6:36 Comment(3)
I’ve seen it used as a default value for parameters, as a replacement for None for parameters that may receive None as their value without the usual meaning of ‘this parameter has not been set by the caller’. e.g. docs.aiohttp.org/en/stable/…Endocrinotherapy
Thanks for your example. It is recommended to write module-level names in uppercase, so sentinel would become a SENTINEL (and probably with an underscore to make it non exportable).Karelia
Anyone has other use examples? Here I find the use of a sentinel very hard to read compared to a simple if inp == "stop": break; else: yield inp and for inp in step().Kellda
U
7

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.

Unicorn answered 18/1, 2021 at 14:27 Comment(0)
S
6

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()
Stockjobber answered 18/10, 2018 at 23:57 Comment(1)
Not only is subclassing useful for better reprs, but it also allows you to have much better type-hinting writing something like def get(value: Union[int, _MISSING_TYPE] = MISSING): ...Unicorn

© 2022 - 2024 — McMap. All rights reserved.