How to Make Python Functions Extensible

Let’s say you have a python function that is used widely throughout your code base. You need to make changes to it. You change the arguments and everything breaks. You painstakingly update all call sites and hope that, when the tests pass, it’s all working as expected.

Is there a better way?

Consider the function foo.

def foo(a, b, c):
    pass

foo(1, 2, 3)

If you want to add another argument to foo you might update it like so.

def foo(a, b, c, d):
    pass

foo(1, 2, 3, 4)

You need to update all of the call sites because d is a required positional argument. That’s not too bad because there are a small number of arguments and it’s easy to update by just adding an argument to the end.

Now let’s say you need to update foo with argument e which is sometimes None. Oh, and you realized that c can also be None.

def foo(a, b, c, d, e):
    pass

foo(1, 2, None, 4, None)

Now we have several problems:

  1. Every call site needs to be updated again
  2. The argument list is getting longer, what is argument 3 again?
  3. It’s easy to make a mistake updating calls to foo because ordering matters—python will happily take foo(1, 2, 4, 3, 5)

Finally, you want to refactor because the argument list is getting long and they aren’t in a logical order causing frequent instances of problem 3.

def foo(a, b, e, g, c, f, d, h):
    pass

foo(1, 2, None, 7, None, 6, 4, None)

A more extensible way to do this is to use keyword arguments kwargs.

The ordering of arguments doesn’t matter. If you change the order of arguments in the function, you won’t need to update every call site.

def foo(a, b, e, g, c, f, d, h):
    pass

foo(e=None, a=1, h=None, g=7, c=None, f=6, b=2, d=4)

Using kwargs with default values can be omitted, perfect for indicating what is actually optional when calling foo. Python will insist that these arguments go last (making it possible to still reach for calling the function with positional args.

def foo(a, b, d, f, g, c=None, e=None, h=None):
    pass

foo(a=1, b=2, d=4, f=6, g=7)

Extending foo with another optional argument is now super easy—no need to update every call to foo (unless it needs to use the new optional argument). Notice how the example function call hasn’t changed even though we added argument i which also altered the argument ordering.

def foo(a, b, d, f, g, c=None, e=None, i=None, h=None):
    pass

foo(a=1, b=2, d=4, f=6, g=7)

To take it one step further, you can require keyword arguments so the function can’t be called with positional arguments.

def foo(*, a, b, d, f, g, c=None, e=None, i=None, h=None):
    pass

# This will throw an error
foo(1, 2, 4, 6, 7)

# This will not
foo(a=1, b=2, d=4, f=6, g=7)

Neat!