March 4, 2013

Replacing Python's positional arguments with keyword arguments

I was renaming a positional function argument named id (which was masking Python's built in function id()), but that broke the rest of the third party code. I've just discovered a very nasty language feature.

A function in Python that takes only positional arguments:

def foo(x, y):
    print x ** y

should be called as:

foo(23)

But when calling a function it is possible to use keyword arguments in place of the given positional arguments:

foo(2, y=3)
foo(x=2, y=3)

Whoever wrote foo() counts it will always be called with positional arguments, but there is no way to prevent someone to use keyword calling style (foo(2, y=3)). If foo() arguments are renamed, e.g. to make it more readable:

def foo(base, exponent):
    print base ** exponent

the code using it will stop working:

>>> foo(x=2, y=3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() got an unexpected keyword argument 'x'

Positional function arguments should be and stay local, but that is not the case in Python. One could blame the programmer, but language should be able to prevent this problem. Never use keyword argument for a function that doesn't explicitly define one. This is a very nasty way to introduce bugs in otherwise very clean and safe code.

It goes the other way around: a function with keyword arguments can be called as if it was defined with positional arguments:

def hi(name="Nobody"):
    print "Hi {}!".format(name)

Usage:

>>> hi(name="Aleksa")
Hi Aleksa!
>>> hi("Aleksa")
Hi Aleksa!

I'd prefer to see an error here, but it "does the right thing" in a Perl like fashion. Keyword to positional argument replacement is going to make your code less readable, but will not break it as when replacing positional for named arguments.

Python 3 changed things a bit, but didn't fix the problem. Having * between positional and keyword arguments ensures that keyword arguments are properly used:

def foo(x, *, y=None):
    print("X: {}".format(x), end="\n")
    print("Y: {}".format(y), end="\n")
>>> foo(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() takes 1 positional argument but 2 were given
>>> foo(1, y=2)
X: 1
Y: 2

However, positional arguments can still be replaced with keyword arguments:

>>> foo(x=1, y=2)
X: 1
Y: 2

Every function argument in Python is a hybrid global variable!