Hacker News new | ask | show | jobs
by m4r71n 1062 days ago
I would not recommend the default arguments hack. Any decent linter or IDE will flag that as an error and complain about the default argument being mutable (in fact, mutable default arguments are the target of many beginner-level interview questions). It's much easier to decorate a function with `functools.cache` to achieve the same result.
6 comments

Or, if you need a "static" variable for other purposes, the usual alternative is to just use a global variable, but if for some reason you can't (or you don't want to) you can use the function itself!

    def f():
        if not hasattr(f, "counter"): 
            f.counter = 0
    
        f.counter += 1
        return f.counter

    print(f(),f(),f())

    > 1 2 3
I didn’t realize that the function was available in its own scope. This information is going to help me do horrible things with pandas.
This is very important for self-recursion.
Is there something that isn't "self-recursion"?
Mutual recursion. Horrible example, don’t use this:

  even 0 = true
  even n = not (odd n-1)
  odd 0 = false
  odd n = not (even n-1)
That should be

  even 0 = true
  even n = odd n-1

  odd 0 = false
  odd n = even n-1
I fed a C version of this (with unsigned n to keep the nasal daemons at bay) to clang and observed that it somehow manages to see through the mutual recursion, generating code that doesn't recurse or loop.
This is very important for self-recursion.
This is very important for self-recursion.
RecursionError: maximum recursion depth exceeded
This is very important for self-recursion.
In Python you'd maybe think, smart, then my counter is a fast local variable. But you look up (slow) the builtin hasattr and the module global f anyway to get at it. :)

I looked at python dis output before writing this, you can look at how it specializes in 3.11. But there's also 4 occurences of LOAD_GLOBAL f in the disassembly of this function, all four self-references to f go through module globals, which shows the kind of "slow" indirections Python code struggles with (and can still be optimized, maybe?)

You could scratch your head and wonder why even inside itself, why is the reference to the function itself going through globals? In the case of a decorated or otherwise monkeypatched function, it has to still refer to the same name.

More concretely, one of the classic Python bugs is to use `[]` as a default argument and then mutate what "is obviously" a local variable.
I think it's even more safe/preferable to use non-mutable `None`s as a default and do:

``` def myfunc(x=None): x = x if x is not None else [] ... ```

In some cases you can also do:

  x = x or []
Your method is best when you might get falsy values but if that’s not an issue the `or` method is handy.
I tend to dislike this method as it's unclear what or returns unless you already know that or behaves this way. x if x is not None else default is cleaner in my opinion
I'm learning python, and I hit this milestone about a week ago!
What's it do?
When you set an object as a default that object is the default for all calls to that function/method. This also holds true if you create the object, like that empty list. So in this case, every call that uses the default argument is using the same list.

    def listify(item, li=[]):
        li.append(item)
        return li

    listify(1) # [1]
    listify(2) # [1, 2]
I would hate to get an interview question where the very premise of it is wrong. Python does have mutable arguments, but so does Ruby.

    def func(arr=[])
      # Look ma we mutated it.
      arr.append 1
      puts arr
    end
Why calling this function a few times outputs [1], [1],... instead of [1], [1, 1],... isn't because Ruby somehow made the array immutable and hid it with copy-on-write or anything like that. It's because Ruby, unlike Python, has default expressions instead of default values. Whenever the default it needed Ruby reevaluates the expression in the scope of the function definition and assigns the result to the argument. If your default expression always returned the same object you would fall into the same trap as Python.

The sibling comment is wrong too -- it is a local variable, or as much one as Python can have since all variables, local or not, are names.

Just as a demo of what you're saying:

If you were to do (the following is from memory, probably has typos):

  def func(arr=[]):
    print(locals)
You'd see `arr` there. The `[]` value lives in `func.__defaults__`:

  def func(arr=[]):
    print(locals)
    print(func.__defaults__) # will print: ([],)
If you assign to `arr` nothing changes with defaults:

  def func(arr=[]):
    print(locals)
    arr = 10
    print(func.__defaults__) # will still print: ([],)
But since lists are mutable, calling a mutating function on the list referenced by `arr` will cause a mutation of the list stored in defaults:

  def func(arr=[]):
    print(locals)
    arr.append(10)
    print(func.__defaults__) # will print: ([10],)
But only when `func` is called without something to assign to `arr`:

  # if pristine and it has not been run before
  def func(arr=[]):
    print(locals)
    arr.append(10)
    print(func.__defaults__) # will print: ([],)
  func([])
Agreed, I found that example very confusing.
Why does that issue only come up with default arguments?

Why not other places?

Default arguments are evaluated and created when the function definition is evaluated, not when the function itself is evaluated. This means that the scope of the default argument is actually the entire module, not just a single invocation of the method. This is what throws people off.
functools.cache is pretty new; py3.8 is still supported for another year and a bit.
functools.cache is basically `functools.lru_cache(maxsize=None)`. `lru_cache` was added in py3.3, which is widely available.