Level 2 · The Toolbox

Arguments in Depth 🎛️

Python's argument handling is unusually flexible, which makes functions pleasant to call and hides exactly one legendary landmine.

Default values

def greet(name, greeting="Welcome"):
    return f"{greeting}, {name}."


print(greet("Guybrush"))
print(greet("Elaine", "Good evening"))
print(greet("Otis", greeting="Get out"))
Welcome, Guybrush.
Good evening, Elaine.
Get out, Otis.

Arguments with defaults are optional and must come after the ones without. Defaults are how a function grows new abilities without breaking every existing call, which matters enormously once other people use your code.

The famous landmine

def add_crew(name, crew=[]):        # do NOT do this
    crew.append(name)
    return crew


print(add_crew("Guybrush"))
print(add_crew("Elaine"))
print(add_crew("Otis"))
['Guybrush']
['Guybrush', 'Elaine']
['Guybrush', 'Elaine', 'Otis']
PARANOIA[Legendary: Success]

Look at it. Every call was supposed to start from an empty list, and they are all sharing one.

The default value is created once, when the function is defined, not each time it is called. There is exactly one list, it lives on the function object itself, and every call that does not pass its own gets that same one. It has been quietly accumulating since the program started.

The fix is always the same:

def add_crew(name, crew=None):
    if crew is None:
        crew = []
    crew.append(name)
    return crew


print(add_crew("Guybrush"))
print(add_crew("Elaine"))
print(add_crew("Otis", ["LeChuck"]))
['Guybrush']
['Elaine']
['LeChuck', 'Otis']
📏 The rule, memorised in one line

Never use a mutable value as a default. Not a list, not a dictionary, not a set. Use None and create it inside. Numbers, strings, tuples and booleans are immutable and perfectly safe.

*args: any number of positional arguments

def total(*numbers):
    print(f"got {numbers} which is a {type(numbers).__name__}")
    return sum(numbers)


print(total(1, 2, 3))
print(total(10, 20))
print(total())
got (1, 2, 3) which is a tuple
6
got (10, 20) which is a tuple
30
got () which is a tuple
0

The star collects every extra positional argument into a tuple. The name args is pure convention; the star is what does the work.

**kwargs: any number of named arguments

def describe(**details):
    for key, value in details.items():
        print(f"{key:10} {value}")


describe(name="Guybrush", role="pirate", insults=8)
name       Guybrush
role       pirate
insults    8

Two stars collect named arguments into a dictionary. Both together:

def log(message, *tags, level="INFO", **extra):
    tag_text = f" [{' '.join(tags)}]" if tags else ""
    extra_text = "".join(f" {k}={v}" for k, v in extra.items())
    print(f"{level}: {message}{tag_text}{extra_text}")


log("Ship departed")
log("Ship sank", "urgent", "nautical", level="ERROR", depth=40, souls=12)
INFO: Ship departed
ERROR: Ship sank [urgent nautical] depth=40 souls=12

The order is fixed and worth remembering: positional, *args, keyword-with-defaults, **kwargs. You will see this signature constantly in library code, and now it is not mysterious.

Unpacking at the call site

def make_pirate(name, role, insults):
    return f"{name} the {role} ({insults} insults)"


details = ["Guybrush", "pirate", 8]
print(make_pirate(*details))

as_dict = {"name": "Elaine", "role": "governor", "insults": 3}
print(make_pirate(**as_dict))
Guybrush the pirate (8 insults)
Elaine the governor (3 insults)

The same stars work in reverse when calling: * spreads a list into positional arguments, ** spreads a dictionary into named ones. This is how you pass configuration around without writing out every field.

Forcing arguments to be named

def transfer(amount, *, from_account, to_account):
    return f"Moving {amount} from {from_account} to {to_account}"


print(transfer(100, from_account="checking", to_account="savings"))
# transfer(100, "checking", "savings")   # TypeError: takes 1 positional argument
Moving 100 from checking to savings

A bare * in the signature means "everything after this must be passed by name". Use it whenever the arguments could be confused with each other. Nobody has ever swapped two accounts by accident when the names were required.

There is a mirror feature: a / in the signature marks arguments that must be positional. You will see it in the standard library's documentation and rarely need to write it.

Mutable arguments change the caller's data

def add_item(inventory, item):
    inventory.append(item)          # changes the caller's list


def add_item_safely(inventory, item):
    return inventory + [item]       # returns a new list


bag = ["map"]
add_item(bag, "grog")
print(bag)

bag2 = ["map"]
new_bag = add_item_safely(bag2, "grog")
print(bag2, new_bag)
['map', 'grog']
['map'] ['map', 'grog']

Both are legitimate designs. What matters is that you choose deliberately and say so in the name and the docstring. A function that quietly modifies what you handed it is a function that will surprise someone, and the someone is usually you.

Exercise 1

Flexible greeter

Write greet that takes any number of names, plus an optional greeting and an optional excited flag that adds an exclamation mark. The flag must be keyword-only.

Reveal solution
def greet(*names, greeting="Hello", excited=False):
    """Greet everyone by name."""
    if not names:
        return f"{greeting}, nobody."
    joined = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else names[0]
    end = "!" if excited else "."
    return f"{greeting}, {joined}{end}"


print(greet("Guybrush"))
print(greet("Guybrush", "Elaine"))
print(greet("Guybrush", "Elaine", "Otis", greeting="Ahoy", excited=True))
print(greet())
Hello, Guybrush.
Hello, Guybrush and Elaine.
Ahoy, Guybrush, Elaine and Otis!
Hello, nobody.
Exercise 2

Spot the landmine

This cache is meant to remember results. Why does it leak between calls, and how would you fix it while keeping the feature?

def remember(key, value, store={}):
    store[key] = value
    return store
Reveal solution

It is the mutable default again, except here the sharing is arguably the intent: it does successfully cache. The problem is that it is invisible, un-resettable, and shared with every other caller in the program including code you did not write.

If you want a cache, say so out loud:

_store = {}


def remember(key, value, store=None):
    """Remember a value. Uses the module cache unless given its own store."""
    target = _store if store is None else store
    target[key] = value
    return target


print(remember("a", 1))
print(remember("b", 2))
print(remember("c", 3, store={}))
{'a': 1}
{'a': 1, 'b': 2}
{'c': 3}

Now the shared state has a name, lives at module level where a reader will see it, and can be bypassed. Python also has functools.lru_cache for real caching, which Lesson 35 covers.

Exercise 3

Pass-through wrapper

Write shout that calls any function with any arguments and prints the result in capitals. It must work with functions you have never seen.

Reveal solution
def shout(func, *args, **kwargs):
    """Call func with whatever it was given, and print the result loudly."""
    result = func(*args, **kwargs)
    print(str(result).upper())
    return result


def introduce(name, role="pirate"):
    return f"{name} the {role}"


shout(introduce, "Guybrush")
shout(introduce, "Elaine", role="governor")
shout(max, 3, 9, 2)
GUYBRUSH THE PIRATE
ELAINE THE GOVERNOR
9

Collecting with *args, **kwargs and immediately spreading them again is the standard shape of any wrapper. It is exactly how decorators work, which is Lesson 35, and you have now written the hard part of one.

+100 XP