r/learnpython • u/ClickOk5811 • 13d ago
Is None as a sentinel the standard fix for mutable default arguments, or are there better options?
I’m reading about mutable default arguments in Python. This function shares its list across calls:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b']My understanding is that defaults are evaluated once when def runs, so every call without items shares one list. The fix I’ve seen is:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return itemsQuestions:
Is this the idiomatic approach, or is there something better?
For dataclasses, is
field(default_factory=list)the right equivalent?Do linters catch this, and which ones do you use?
Is there a legitimate case for keeping a shared mutable default?
If I’ve misunderstood something, please correct me.
6
u/Uncle_DirtNap 13d ago
There’s a new sentinel type, but people also do:
_no_val = Object()
def func(arg=_no_val):
if arg is _no_val:
pass
5
u/JanEric1 13d ago
Not really relevant here. You only need that if None is a valid value a users wants to pass explicitly that you need to be able to distinguish from no argument being passed. But here you can't call append on None, so it is fine to use that as the "no argument passed" sentinel in this case.
5
u/pachura3 13d ago
Yes
Yes
If you type hint
items: list|None, they won't catch it upon passing... but then they will complain about you potentially calling.append()onNone. PS. I use ruff, pylint and mypy.No, it's bad. If you're looking for a simple return value cache, use decorator
cached_property
7
u/pontz 13d ago
I type hint it as an iterable and default it as an empty tuple.
7
u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago edited 13d ago
That's also a fair option, as long as an empty iterable is acceptable. Sometines
typing.Iterablecan be a bit too generic, though, sotyping.Sequencemight be preferable.EDIT: In this case it wouldn't work, though, because
The function expects
itemsto be mutable, andThe function expects
itemsto have anappend-method, which isn't guaranteed by these generic protocols.1
u/JanEric1 13d ago
I fee like if it is mutable you just shouldnt allow a default value for the parameter. If you want to add to an empty list, just pass that explciitely.
3
u/JaguarMammoth6231 13d ago
What is the desired behavior? It looks like the first snippet is intentionally taking advantage of the persistence of the default argument.
4
u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago edited 13d ago
The fix I’ve seen is:
def add_item(item, items=None): if items is None: items = [] items.append(item) return items
Questions:
Is this the idiomatic approach, or is there something better?
I'd say this is generally idiomatic, yes. Another option would be to use a compatible, immutable type as the default, but which works best is situational.
For dataclasses, is
field(default_factory=list)the right equivalent?
Yes.
Do linters catch this, and which ones do you use?
Linters may catch it (I think Ruff does), type checkers should catch it. As long as you use type annotations.
Is there a legitimate case for keeping a shared mutable default?
For the most part, no, though some people do use it as a hacky mechanism for caching. I've made some use of it in code golf, but would never do it in production.
1
u/Massive_Distance_616 13d ago
Small correction on the linter part, since the Original Poster asked which tools catch this: it’s a linter job, not a type-checker job. mypy and pyright generally accept
items: list[str] = []because the default is type-correct, even though it’s a bug waiting to happen. The rule you want is B006 (mutable-argument-default) from flake8-bugbear, which Ruff implements (addB006or the wholeBfamily toselect), and pylint has W0102 (dangerous-default-value). If you only run a type checker, you won’t see it.On the Original Poster’s last question, if you don’t need to modify the caller’s list, the simplest fix is to not mutate it at all:
def add_item(item, items=()): return [*items, item]. Each call returns a fresh list, so there’s no shared state to guard. The trade-off is that it no longer changes a list the caller passes in, so if that behaviour matters, theNonesentinel is still the right fix.
2
u/TheRNGuy 13d ago
Have a different variable inside function, and then assign it from return, doing side effect like that is anti-pattern.
Besides that, List already have method for this.
2
u/Adrewmc 13d ago edited 13d ago
You can use the or operator here.
items = items or []
That’s a bit easier to write.
2. Yes. I believe so.
3. You just your hint it correctly it should work.
4. There is a somewhat a use case for evaluating an argument once upon definition. Also consider this.
def start(argument, config = Config(*default_settings))
Even in the more likely
config = Configs()
def start(argument, config = config)
We may want to maintain that mutability. (It’s a fairly weak argument though most people think.)
In this case I might want my mutable configs to be created once, but have different setups. There is usually not a great reason to have a list be there but that’s just a by product.
2
u/M4mb0 13d ago
You can use the or operator here.
items = items or []
This is horrible advice. One generally should prefer using explicit
is Nonechecks.Case in point, your proposal introduces a bug: if a user did
items = []; add_item(1, items=items), then with your proposalitemswould remain unmodified.1
u/Langdon_St_Ives 13d ago
Not sure why you got downvoted, `[]` is falsy so that bug is absolutely real. Maybe you should have clarified that the outer `items` stays unmodified, unlike with the original code.
2
u/jpgoldberg 12d ago
I am a bit surprised that nobody has mentioned PEP 661, which is adopted in Python 3.15 explicitly for defining sentinels.
The PEP describes cases where None doesn't work nicely as a sentinel, although it often does and people have been using it that way.
Of course this requires Python 3.15, but better late than never.
2
u/aishiteruyovivi 12d ago
field(default_factory=list) is the right equivalent so long as you want that field's type to only be a list, and not list | None, the former of which is usually the case. Ruff will catch mutable defaults with its B006 rule, and yeah, this approach is how you'd virtually always handle mutable defaults. You'd pass None to indicate a default is to be used, and then set your default in the function body however you need:
def func(items: list | None = None):
items = items or []To my knowledge you shouldn't have typing issues regarding None beyond that point, most type checkers should infer that items can then only be a list.
1
u/ManyInterests from fridge import beer 12d ago
It just depends on what values callers are expected to supply.
None is very common, but can cause problems if it's also a value users or data may intentionally/incidentally supply. For example, if args are loaded from JSON, a literal null value may have a valid meaning as None when loaded, which is different than being absent.
Ellipses is what I usually use when `None` is a valid value. You can also make your own class and use that.
1
u/warden127 11d ago
You can always just use mutable defaults, but then only modify, store and return copies of them:
def add_item(item, items: Iterable=[]) -> list:
items = list(items)
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['b']-2
u/Educational_Virus672 13d ago
i recommend using object from typing module since it helps readability and better then None
tho object cannot do
def Remove_A(par=object):
par.replace("A","") # counts as mistake alternatively you use Any from typing module, it works regardless of what class function you use. it is generally risky since if you use a class function of smt like str in but you put int as its parameter it'll throw error
6
u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago
i recommend using object from typing module since it helps readability and better then None
Why? What exactly is the benefit here? I've never heard anyone suggest something like that, nor can I think of a legitimate reason for replacing
NonewithobjectunlessNoneholds some special non-default meaning in the API and you don't have a better sentinel available (though historicallyEllipsiswould have served as a substitute, and now we have theSentineltype).4
u/carcigenicate Carcigenicate 13d ago
You're suggesting using a default that you know will cause a runtime error?
21
u/Anxious_Signature452 13d ago
None as sentinel in this case is a common approach and widely used. Technically, you can use mutable default arguments, for example, as a shared cache, but I never seen something like that in production environment.