It’s totally an exercise in alt-history fiction, but I could imagine a very-slightly-more-strictly-typed None that never allowed this, and the idiom just grew up differently. But of course it’s moot now.
My point was more that it makes sense to encode this linting rule in the definition of sentinels, given the opportunity.
There’s enough posts in this thread demonstrating important use cases for customising bool behavior that I think that while I personally would still be fine with whatever bool behavior, I’m going to +1 the idea of having customisable bool behavior from the outset since it will clearly make sentinels much more useful for people in general than without it.
One slight improvement could be that instead of allowing bool=True|False|NotImplemented where passing NotImplemented results in a TypeError, you just allow bool=True|False|exception_factory where exception_factory satisfies typing.Callable[[], Exception]. It then just raises exception_factory(), so you can pass bool=TypeError directly if you want it to raise TypeError, or some other exception type if you prefer.
I think that ought to solve everyone’s requirements and it doesn’t strike me as particularly complex or onerous. The exact choice of default is probably going to be the subject of bikeshedding, as there are already arguments supporting True, False and TypeError; the SC advice is that “it’s fine for sentinels to evaluate as truthy in boolean contexts” so maybe this supports the default being True, but I also understand the argument for making TypeError the default and I kind of lean more towards that at the moment…
I suppose overall I’m currently
+1 for Sentinels in general
+1 for including customisable bool behavior as described here
+0 for TypeError being the default
-0 for True being the default
-1 for False being the default as that seems too “surprising” to be a default
-0 for implementing Sentinels without customisable bool behavior (whatever bool behavior is picked).
I’m still -1 on __bool__. Customisable __bool__ will only make code using sentinels harder to evaluate in your head. Falsy/Truthy __bool__ will make it too easy to introduce subtle and hard to catch bugs simply because someone wrote if x: instead of if x is SENTINEL:.
I think the exception should mention the name of the sentinel in its error message, otherwise the traceback is going to be difficult to read. It was recently mentioned that we should not be trying too hard to accommodate older custom sentinels, so having a fully customize exception might be a bad idea. I suggested TypeError as the exception because a type checker should detect when a truth test isn’t allowed on a sentinel before the code is even run. So in my view a custom exception would be a false choice.
Everyone can point out use-cases for each, and I think everyone is satisfied as long as the behavior can be customized. Then the question of default is not about which option is most used, but about which default is going to cause the least amount of trouble once it’s implemented forever. TypeError being the default feels like the obvious choice due to it being the most explicit.
Without customization however, this choice ends up being far more authoritative. Are we enforcing identity comparisons for sentinels or are we trying to make sentinels behave like None or object()? Should if x: be a valid method of sorting types?
If truthiness on sentinels was forbidden then people would be less happy, but also there would be only one unambiguous way to use a sentinel.
The Sentinel class may not be sub-classed, to avoid the greater complexity of supporting subclassing.
is IMO the problem here. If the Sentinel class could be subclassed, people could simply create their own subclass with the boolean behaviour they wanted. But because it can’t be, people who don’t like the default have to write their own sentinel class.
Because of that, I’m in favour of customisable bool behaviour. I don’t have a use case myself for it, but enough people here have expressed a need for it that I think it’s important.
If Sentinel is only ever going to raise TypeError, it can easily pass a meaningful message to it; if it’s going to call an exception_factory function, then it can’t provide a message, at least not without a complicated interface for what you can pass as exception_factory, and by then you’re definitely going too far.
So yes, with this in mind, better to forget the idea of allowing users to request a different exception type, and just always raise TypeError.
I still think it’s probably better to spell it as bool=TypeError than bool=NotImplemented (it’s a bit more obvious what it does), but that’s a minor detail.
Assuming that we do get customisable bool behavior, I think it’s more likely that in a given situation, some sentinel will be invented that has “natural” bool behavior for the circumstances, and then when someone sees list[SomeClass | SomeSentinel] or the like, the thought will be “aha, there can be this particular sentinel in there” (and recalling the bool behavior of that particular sentinel will come naturally), not “hmm, there could be a sentinel in here… what’s its bool behavior?”.
Without customisable bool behavior, the situations where it’s natural for the bool behavior to be whatever is picked will work fine, but the situations where that behavior feels unnatural (even if that behavior is “raise TypeError”) will cause a headscratching moment.
Or put another way, I reckon real-world situations where some particular sentinel could be relevant to the matter at hand are likely, while real-world situations where any sentinel could be relevant are unlikely.
Taking SomeClass always being True for granted can lead to logical errors later on. Adding a SomeClass.__len__ method would be enough to break any assumptions being made here.
The purpose of a new sentinel is to be a unique type/value which will not collide with existing types or values. Sorting types by truthiness defeats this purpose by allowing sentinels to collide with arbitrary types of similar truthiness.
In fact it feels like some are accidentally advocating for None to exist when it already does. If one wants a falsy sentinel which they do not need to use identity comparisons with then None already does everything they want. The semantics of if x: are important to understand here and it feels like people are not taking it as seriously as they should. Sentinels removing support for __bool__ would force people to respect that if x: truly does not separate sentinels from arbitrary types instead of giving a false hope that it does.
For TypeError its error message could explain using identity comparisons:
TypeError: SomeSentinel is not convertible to bool
Did you mean 'x is SomeSentinel' or 'x is not SomeSentinel'?
By SomeClass, I meant some particular class such that the person working with that code already understands its bool behavior, just like for SomeSentinel. If I had meant any class I would have written list[object | SomeSentinel], not list[SomeClass | SomeSentinel].
It can be useful to have something like list[SomeTruthyClass | SomeTruthySentinel | SomeFalsySentinel | None] and then, when considering each item, your first statement might be if item: such that the true body deals with SomeTruthyClass | SomeTruthySentinel and the false body deals with SomeFalsySentinel | None. You can obviously use is or isinstance checks inside those to further distinguish the individual types from each other.
None does not fulfill every use case for a falsy sentinel. You may want an object that is guaranteed to not already exist in some passed-in collection but still convert to false. If None may be passed in then None doesn’t fulfill this role but a falsy sentinel does.
Granted, the kind of situations I’m talking about aren’t exactly everyday, but they do crop up now and then, especially in more mature codebases.
I do think the documentation of Sentinel should advise people to first check if None fulfills their requirements before defining their own custom falsy sentinel, though. I like your suggestion for the error message too.
It’s a big bikeshed. It can be more than one color.
Since the consensus to be “yes it’s a good idea” along with “not that way,” just make a sentinel module that incorporates the preferred variants. (e.g. bool convertible, not bool convertiable).
The SC have given positive noises towards it being a built-in and not a standard library module to make it just as easy to use as object(). I agree with them and think that’s a very important benefit that would be lost with a separate module.
After reviewing I think you and @Jelle are right: In such cases surviving copies (such as pickling+unpicklng) is likely unnecessary. Which means the registry is unnecessary. I’ll revise the PEP accordingly.
Regarding truthiness, I’ve read the entire discussion and will consider it further.
Before I reach any conclusions, I’d like to thank everyone who participated in the discussion, and assure you that I’m taking it all into consideration.
To help guide decision-making, I consider be able to use this for sentinels in the stdlib a major goal and a guiding principle. To that end, I’ve conducted an updated search of the stdlib today, reviewing @vstinner’s original list of sentinels in the stdlib and adding others. (Several have been removed since, see at the end of this post.) The following list also includes some information about each sentinel: how it is implemented, what it is used for, and why (to the extent that I could figure that out).
Of the 27 sentinels below:
7 use a dedicated class, 5 of which have a custom repr; the other 20 use object()
All are “truthy” (evaluate to True in a boolean context).
About half are exposed externally and half are not.
Most are defined at the module level. A few are defined on every call to a function/method; of those about half do so intentionally for a clear reason.
List of sentinels:
_collections_abc.MutableMapping.__marker
= object()
Default value for the default argument of the .pop() method
Needs to be distinct from other possible values such as None
annotationlib._sentinel
= object()
Default value for the type_params argument of ForwardRef._evaluate()
Used to clearly deprecate not passing type_params
configparser: _UNSET
= object()
configparser.UNNAMED_SECTION
Uses a dedicated class with a custom repr: “<UNNAMED_SECTION>”
Not a default function argument value: Intended for users to pass in to signify an unnamed section
dataclasses._ANY_MARKER
= object()
Not a default argument value: Used internally
dataclasses: _HAS_DEFAULT_FACTORY, MISSING, KW_ONLY
A dedicated class for each, one with a custom repr
datetime.timezone._Omitted
= object()
Used internally to disallow None for the name argument to the timezone class
dis.UNKNOWN
Uses a dedicated class with a custom repr: “”
Not a default argument value: Used in output to signal something specific
functools.lru_cache: sentinel
= object()
Each lru_cache() call creates its own sentinel object
Used for dict-like lookups
functools._initial_missing
= object()
Default function argument value for reduce()
functools._NOT_FOUND
= object()
Not a default argument value, used as default for cache.get()
glob._no_recurse_symlinks
= object()
Not a default argument value: Used internally
Apparently used instead of something like None for better clarity
glob._walk_symlinks_as_files
= object()
Not a default argument value: Used internally
Apparently used instead of something like None for better clarity
heapq: temporary sentinel in nsmallest() and nlargest()
= object()
Not a default argument value: used as default for min() or max()
inspect._is_coroutine_mark
= object()
Not a default argument value: used to be different from None when calling getattr()
pickle._NoValue
= object()
Not a default argument value: Used internally for various .get() and getattr() calls
plistlib._undefined
= object()
Not a default argument value: used for an internal list of objects while parsing
pydoc._GoInteractive
= object()
The default value for the request argument of Helper()
Apparently used instead of something like None for better clarity
runpy._ModifiedArgv0._sentinel
= object()
Not a default argument value: used to mark whether a value has been initialized (“saved”)
sched: _sentinel
= object()
Default value for the kwargs argument of scheduler.enter() and scheduler.enterabs()
Could be None, apparently used for better clarity
sentinel.comare(): sentinel
= object(), redefined on each call
Used as a default for getattr()
socket._GLOBAL_DEFAULT_TIMEOUT
= object()
The default value for the timeout argument of create_connection()
Apparently used instead of something like None for better clarity
tarfile._KEEP
= object()
Apparently used instead of something like None for better clarity
traceback: _sentinel
Uses a dedicated class with a custom repr: “”
Default value for the value and tb arguments of print_exception()
Could be None, apparently used for better clarity
traceback.BUILTIN_EXCEPTION_LIMIT
= object()
Internal, used to signal to StackSummary._extract_from_extended_frame_gen() that it is being used for Python’s internal exception handling.
Apparently used instead of something like None for better clarity
programming.rst
The example for optional argument defaults that could be None recommends using object(), which would result in bad function signatures.
Removed from the stdlib (of those in @vstinner’s original list):
There’s also typing.NoDefault (defined in C). It’s public, indicating that certain attributes are missing. None cannot be used because it is itself a valid value. This is similar to dataclasses.MISSING. It doesn’t override truthiness but has a custom repr of typing.NoDefault.
In 3.15 we plan to add another, typing.NoExtraItems, for the implementation of PEP 728.
Maybe this is already what you’re describing, but: is the truthiness of these sentinels relied on? Or is it a byproduct of them being objects that aren’t 0 / None / length 0?
For sentinels that are exposed API I assume the answer is “probably someone, somewhere” but for internal sentinels it would be interesting to know.
Also os.path.ALLOW_MISSING & os.path.ALL_BUT_LAST (both custom types with reprs, using @object.__new__), and functools.Placeholder (also with a custom type).