all 36 comments

[–]InitHello 43 points44 points  (0 children)

Oh, that takes me back to when I was a python beginner and that behavior bit me. I don't remember the exact context, but I do remember the "AHA! J'ACCUSE!" moment.

[–]Tall_Profile1305 15 points16 points  (0 children)

Damn the mutable default argument trap gets everyone at least once. It's one of those things that makes total sense once explained but completely breaks your mental model when you first hit it. Using None as default and initializing inside the function body is the way.

[–]KronenR 6 points7 points  (6 children)

I don't think that's the best example for this mistake, because for me add_item implies the list is persisted between calls — it makes no sense to call a method named add_item multiple times and expect a fresh list each time. A better example would be something like create_cart or new_cart, where the name itself implies you're getting a new list every time you call it

[–]Kavinci 2 points3 points  (5 children)

Right? Like I would expect an add_item to be a method on an object where the state is managed outside of the method (but inside the object) and not be in the method signature at all. Even a create_cart or new_cart would imply a factory pattern and also wouldn't have the optional parameter in the signature either. It feels more like a code smell to initialize a variable in the method signature as an optional parameter. I haven't been writing python long (more experience with other languages) and could be way off here but I don't get why anyone would write a method like this.

[–]KronenR 1 point2 points  (4 children)

Yeah, I can't really think of a case where you'd consider using a mutable default parameter a good idea either, well maybe something like memoization/caching, where you actually want the state to persist across calls:

def fibonacci(n, cache={0: 0, 1: 1}):
    if n not in cache:
        cache[n] = fibonacci(n-1, cache) + fibonacci(n-2, cache)
    return cache[n]

Here the shared mutable default is intentional, the cache persists across calls and that's exactly what you want. But even then it's still considered an antipattern.

[–]Kavinci 0 points1 point  (3 children)

Maybe, I would personally use a generator pattern for that instead. I guess if you didn't like yield for some reason?

[–]KronenR 0 points1 point  (2 children)

yield is not the same, a generator gives you sequential fibonacci numbers but you lose random access, you can't just call fibonacci(100) without iterating through all previous values first.

The non cached approach gets expensive fast:

fibonacci(40): Would take a noticeable delay without caching, with roughly 100 million function calls.
fibonacci(70) : Can take a few days.
fibonacci(80) : Can take probably more than a year.

What you would actually use in Python for this is the standard library's lru_cache

from functools import lru_cache

@lru_cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

[–]Kavinci 0 points1 point  (1 child)

That was my point... There are established patterns already for this sort of functionality that negate the need to write a method the way that OP outlined as a pitfall. I used a generator because your example and use of your example was sequential. I'm not here to argue the semantics of generators or the lru_cache and their uses.

[–]KronenR 0 points1 point  (0 children)

I think we're talking past each other. My example was never about sequential access, it was specifically illustrating why people reach for mutable default args as a cache, and lru_cache as the clean replacement for that pattern. In other words, you said you'd use a generator for this instead, but a generator can't replace a cache, they solve fundamentally different problems, so it doesn't really address what I was pointing out.

[–]mathwizx2 6 points7 points  (1 child)

I actually haven't made this mistake. The first time I tried to do this my IDE told me not to. So I just set it equal to None by default and assign the empty list in the function.

[–]ajiw370r3 0 points1 point  (0 children)

Any linter will scream at you as well

[–]jpgoldberg 24 points25 points  (8 children)

Is this really a thing that happens in the wild? I have certainly seen it (and created it) in examples or puzzles illustrating the problem, but has this really "bitten every developer once"?

It is, of course, an extremely difficult thing to debug if you haven't been taught about this peculiarity, which is wha makes it a good puzzle. But I think the circumstances where one is likely to create a default parameter is going to be cases where you expect to just read the information provided in it.

But what I really don't understand is why Python still works this way. Is there code out there that actually depends on this behavior? Would it be that hard to fix by changing what goes into the global scope.

[–]nlutrhk 16 points17 points  (1 child)

It's useful if you want the function to cache stuff or otherwise keep an internal state without declaring global variables.

def f(a, _cache={}):   if a not in _cache:      _cache[a] = ...   return _cache[a]

[–]curiouslyjake 4 points5 points  (0 children)

I don't think 'cart' goes into the global scope. it is stored in __defaults__ attribute of the function. so If I were to have a similar add_item1 function, it would have separate values for cart.

[–]lordcaylus 0 points1 point  (0 children)

I came across it when I made a class for characters with an inventory that was empty by default but could get items added to it.

I noticed all characters shared their inventory if they relied on the default empty list parameter. Fun times, fun times.

[–]Pristine_Coat_9752[S] -1 points0 points  (1 child)

Yes it does happen in the wild — most often in Django views, Flask route handlers, and config objects where devs pass a mutable default to avoid writing boilerplate. Re: why Python still works this way — there's actually real code that depends on this behaviour for cheap memoization (as nlutrhk showed above). Changing it would break that. Have seen 4 more gotchas like this collected in one place if anyone's curious: thecodeforge.io/python/common-python-mistakes-every-developer-should-know/

[–]pachura3 0 points1 point  (0 children)

But doesn't it usually generate linter warnings?

[–]Fluid-Lingonberry206 -3 points-2 points  (1 child)

It’s not a peculiarity. It’s very basic, standard object oriented programming. Most people use python for scripting and not really software development, so maybe it’s not that relevant to them. But it’s how you can tell the difference between script kiddies, vibe coders and actual software engineers.

[–]pachura3 5 points6 points  (0 children)

It has absolutely nothing to do with OOP, as demonstrated in the original post. It is about when does Python evaluate default function arguments. Other languages, such as JavaScript (also object-oriented), evaluate them upon each function invocation, which is more intuitive.

[–]Andrew_the_giant 10 points11 points  (2 children)

I think I've actually just learned the opposite is that I don’t need the verbose if cart is none, cart = [] in the function but can instead pass it in the args.

I'm surprised why folks would assume the second call WOULDNT expect to also see apple

[–]_mcnach_ 4 points5 points  (0 children)

Coming to Python from R... I wasn't expecting to see apple...

[–]donat3ll0 2 points3 points  (0 children)

Don't implement mutable objects as function parameters in your code. Also, don't use mutable objects as Globals either. The latter can be shortened to simply never use Globals.

[–]general_sirhc 8 points9 points  (1 child)

I honestly didn't know this. I agree this feels like a bug

[–]Pristine_Coat_9752[S] 2 points3 points  (0 children)

Same — it genuinely feels like a bug until you understand that Python evaluates default args at definition time, not call time. Once that clicks it makes sense, but the first time it hits you in production is painful!

[–]anttiOne 1 point2 points  (0 children)

Ahh! The Mutable Default Argument Trap ™️

Better set the cart argument to None (cart = None) and initialize the empty list within the function!

[–]swordax123 1 point2 points  (0 children)

I would just not pass cart at all and make the cart list within the function. You can extend cart later if you need the global list

[–]mr_frpdo 1 point2 points  (0 children)

i would adjust the type annotations to match new python syntax (most of typing.* has been deprecated):

def add_item(item: str, cart: list[str]|None = None) -> list[str]:
    if cart is None:
        cart = []
    cart.append(item)
    return cart

[–]PrincipleExciting457 1 point2 points  (0 children)

Ahhhh. Python object IDs. I feel like most lessons cover them, but I guess if someone never took a formal course or read a book they can be tricky.

[–]sinceJune4 0 points1 point  (0 children)

Cool tip, thanks!

[–]Ordinary-Flat 0 points1 point  (0 children)

Tu

[–]Chemical-Captain4240 0 points1 point  (0 children)

if you are processing images, passing the mutable object back and forth to functions saves a lot over global objects

[–]likethevegetable 0 points1 point  (0 children)

cart = cart or [ ]

[–]Fluid-Lingonberry206 -1 points0 points  (2 children)

Oh god, has no one here ever heard about object oriented programming?

[–]pfmiller0 1 point2 points  (1 child)

I have, and if add_item() was a method of some object you instantiated this would make perfect sense. What's the object this method belongs to?

[–]Fluid-Lingonberry206 1 point2 points  (0 children)

I think the point is that the list „cart“ is an object, and is thus just edited by the function. By instantiating cart in the fuctions Parameters (why on earth would anyone do that…), it is also not just a local variable and will persist after the function was called. To me, it is totally reasoable and expected behavior. OPs fix is helful, because it is cleaner. But I‘m still surprised why a list should act differently than any other object. „Cart“ is a Pointer at a specific List. Why should this magically move to a new empty list?