◀ Course contents Part 3 · Module 3-02

Arguments & Scope

What goes in, and where names actually live

Functions worked in the last module. This one is about the two places they surprise people: the rules for what you can pass and in what order, and the fact that a name inside a function is not the same name as the one outside it — except when it is, and except when the thing it points at can be changed from either side.

Ready?

1

Positional, Keyword, *args, **kwargs

Two ways to pass a value in, and two ways to accept however many turn up.

def send(message, retry=False):
    ...

send("hello")                # positional
send("hello", True)          # positional
send("hello", retry=True)    # keyword — says what the True means

Keyword arguments are worth reaching for whenever the value alone would not explain itself. Six months later send("hello", True) needs the definition open to read; retry=True does not.

When the number of arguments is not fixed, a star collects the extras:

def totals(*numbers):        # numbers is a tuple
    return len(numbers), sum(numbers)

def render(**options):       # options is a dict
    ...

totals(1, 2, 3)              # (3, 6)
totals()                     # (0, 0) — no arguments is fine
render(port=8080, debug=True)

The order in a definition is fixed and Python enforces it: ordinary parameters, then ones with defaults, then *args, then **kwargs. Put a plain parameter after a defaulted one and the def line itself is a SyntaxError, because there would be no way to tell which value you meant to leave out.

Quick check

Why is def f(a=1, b): a SyntaxError?

2

The Default That Is Created Once

This is the most famous trap in the language, and it catches everyone exactly once:

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

add_tag("a")    # ['a']
add_tag("b")    # ['a', 'b']   — not ['b']
add_tag("c")    # ['a', 'b', 'c']

The default is evaluated once, when the function is defined — not once per call. So there is exactly one list, it belongs to the function, and every call that does not supply its own keeps adding to it.

With an immutable default — 0, "", None, a tuple — this is invisible, because nothing can change the value in place. With a list, a dictionary or a set, it is a bug that grows with use.

The fix is always the same shape:

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

None is the "nothing was supplied" marker, and the fresh list is made inside the body, which does run once per call.

Why is None rather than if not tags

if not tags: is also true for an empty list somebody deliberately passed in, so it quietly replaces their list with a different one. is None asks the question that was actually being asked: was anything supplied at all?

Quick check

When is a mutable default harmless?

3

Which Name Did You Mean?

Names created inside a function are local to it. They appear when it is called and disappear when it returns, and nothing outside can see them.

Reading a name the function did not create looks outward, to the module:

RATE = 0.2

def with_tax(amount):
    return amount * (1 + RATE)   # reading RATE is fine

Assigning is different. If a function assigns to a name anywhere in its body, that name is local for the whole function — decided when the function is compiled, before a single line runs. Which produces this:

count = 0

def bump():
    count = count + 1    # UnboundLocalError

bump()

The assignment on that line made count local, so the right-hand side is reading a local that has not been given a value yet. The message — cannot access local variable 'count' where it is not associated with a value — is exact, and only makes sense once you know the rule.

global count at the top of the function makes it work, and you should almost always do something else instead: take the value in as an argument and hand the new one back. A function that reaches out and changes module state is one you cannot test without setting the world up first.

Needs the world set up

def bump():
    global count
    count += 1

Takes nothing, returns nothing, and only works in one program.

Testable in one line

def bump(count):
    return count + 1

Value in, value out. The caller decides what to do with it.

Quick check

A function reads RATE and never assigns to it. Does it need global?

4

A Function Can Change What You Handed It

Rebinding a parameter inside a function affects nothing outside — the parameter is a local name, and pointing it somewhere else is a local event.

But if the thing passed in is mutable, the function and the caller are looking at one object, and a change through either is visible to both. This is the aliasing from module 2-04, arriving through the front door:

def add_default_tag(tags):
    tags.append("new")       # changes the caller's list
    return tags

def add_default_tag_safely(tags):
    return tags + ["new"]    # builds a new one

Neither is wrong. What is wrong is doing the first while the name suggests the second. If a function changes what it was given, the name should say so — sort_in_place, append_default — and it should not also hand back a value, because a function that both mutates and returns invites a caller to use it both ways.

Rebinding is local

tags = [] inside a function points the local name elsewhere. The caller's list is untouched.

Mutating is not

tags.append(x) changes the one object both names point at.

Copy at the boundary

tags = tags.copy() as the first line, when the caller should be protected.

Or say so in the name

Mutation is fine when it is the advertised job.

The test that settles it

Could you call this function twice with the same input and get the same answer both times? If not, something is being carried between calls — a mutable default, a global, or the caller's own data being edited underneath them.

Arguments in, value out, nothing else touched. Everything in this module is a way that quietly stops being true.

Quick check

Inside a function, tags = [] where tags is a parameter. What does the caller see?

0 of 9 completed

Real Python runs right here in your browser — nothing to install, nothing sent to a server. The interpreter downloads once the first time you press Run, then stays cached.

01

To do

An argument can go in by position or by name. Naming it costs a few characters and buys a line that explains itself:

send("hello", True)         # True what?
send("hello", retry=True) # oh, retry

Keyword arguments can also come in any order, since the name says where each one belongs.

Your task: write describe(name, level, active) returning a single line, then print three calls — all positional, all by keyword in a different order, and a mix:

Kenji is level 3 (active)
Ada is level 7 (active)
Grace is level 1 (inactive)

The word in brackets is active when the flag is true and inactive when it is not.

your_code.py
Python
Hint

A conditional expression handles the bracket: "active" if active else "inactive". Then return an f-string built from the three values.

Output

      
    02

    To do

    A parameter with a default may not be followed by one without. Python rejects the def line itself, before anything runs.

    The reason is that a call like f(5) would be ambiguous — there would be no way to tell which parameter the 5 was for.

    Your task: run it, read the SyntaxError, then reorder the parameters so the three calls below work unchanged:

    3750
    3375.0
    1875.0
    your_code.py
    Python
    Hint

    quantity and price are required, so they go first. discount has a default, so it goes last. The calls all name their arguments, so nothing else needs changing.

    Output
    
          
      03

      To do

      The most famous trap in the language. A default is evaluated once, when the function is defined — not once per call. So there is exactly one list, it belongs to the function, and every call that does not supply its own keeps adding to it.

      add_tag("a")   # ['a']
      add_tag("b") # ['a', 'b'] ← not ['b']
      add_tag("c") # ['a', 'b', 'c']

      The fix is always the same shape: default to None, and build the fresh list inside the body, which does run once per call.

      Your task: run it and watch the list grow, then fix it so the output is:

      ['a']
      ['b']
      ['x', 'c']

      The third call supplies its own list, and must still work.

      your_code.py
      Python
      Hint

      Default tags to None, then `if tags is None: tags = []` as the first line of the body. Use `is None` rather than `if not tags`, or an empty list somebody passed in gets replaced too.

      Output
      
            
        04

        To do

        A starred parameter collects every extra positional argument into a tuple. Zero of them is fine — the tuple is simply empty.

        def totals(*numbers):
            return len(numbers), sum(numbers)

        totals(1, 2, 3) # (3, 6)
        totals() # (0, 0)

        Your task: write totals(*numbers) handing back the count and the sum, and print three calls:

        (3, 6)
        (0, 0)
        (5, 305)

        The last one passes a list with a star at the call, which spreads it into separate arguments.

        your_code.py
        Python
        Hint

        One character: put a star on the parameter. Inside the function numbers is then a tuple, and len() and sum() work on it unchanged.

        Output
        
              
          05

          To do

          A double star collects every extra keyword argument into a dictionary. This is how configuration and option-passing is usually written.

          def render(**options):
              return sorted(options.items())

          Your task: write describe(name, **options) returning the name followed by each option in alphabetical order, and print two calls:

          server: debug=True, port=8080
          server: (no options)

          With no options at all, the second half reads (no options).

          your_code.py
          Python
          Hint

          `if not options: return f"{name}: (no options)"` handles the empty case first. Otherwise build a list of f"{k}={v}" from sorted(options.items()) and join it with ", ".

          Output
          
                
            06

            To do

            A name created inside a function is local. It appears when the function is called and disappears when it returns, and nothing outside can see it — even when the name is the same.

            Reading a module-level name from inside a function is fine and needs nothing special. It is only assignment that makes a name local.

            Your task: run it and predict the output before you read it. Then leave the function alone and print the three values, in this order — what the function returned, the outer total, and RATE:

            120.0
            100
            0.2
            your_code.py
            Python
            Hint

            The total inside the function is a different name from the one outside — assigning to it created a local. The outer one is untouched.

            Output
            
                  
              07

              To do

              If a function assigns to a name anywhere in its body, that name is local for the whole function — decided when the function is compiled, before a single line runs.

              So count = count + 1 makes count local, and then the right-hand side reads a local that has not been given a value yet. Hence UnboundLocalError: cannot access local variable 'count' where it is not associated with a value.

              global would silence it. The better answer is almost always to take the value in and hand the new one back — a function that reaches out and edits module state cannot be tested without setting the world up first.

              Your task: run it, read the error, then rewrite bump to take the current count and return the new one. Keep the module-level count and update it at the call:

              1
              2
              your_code.py
              Python
              Hint

              def bump(count): return count + 1. Then at each call, count = bump(count) before printing it — the caller owns the value, the function just does the arithmetic.

              Output
              
                    
                08

                To do

                Handing a list to a function does not copy it. The function's parameter and the caller's name point at the same object, so tags.append(...) inside is visible outside — the aliasing from module 2-04, arriving through the front door.

                This is not always wrong. It is wrong when the name does not say so, and it is wrong here: with_default_tag sounds like it hands something back, not like it edits what it was given.

                Your task: make the function leave its argument alone, building and returning a new list instead:

                ['a', 'b', 'new']
                ['a', 'b']
                your_code.py
                Python
                Hint

                `return tags + ["new"]` builds a new list and leaves the original untouched. Adding two lists never modifies either one.

                Output
                
                      
                  09

                  To do

                  Sometimes changing the thing you were given is exactly right — sorting a list in place, filling a cache, adding to a queue. Two rules make it safe:

                  1

                  Say so in the name

                  append_default, sort_in_place. Not with_... or get_....

                  2

                  Do not also return it

                  Returning the same object invites a caller to treat it as a copy. Python's own list.sort() returns None for exactly this reason.

                  And when a function must not touch what it was given, copy at the boundary — first line of the body, before anything else can go wrong.

                  Your task: write both. append_default(tags) adds "new" to the caller's list and returns nothing. safe_copy(tags) takes a copy first and returns a changed one, leaving the original alone:

                  None
                  ['a', 'b', 'new']
                  ['x', 'new']
                  ['x']
                  your_code.py
                  Python
                  Hint

                  append_default is one line: tags.append("new"), with no return at all — the None it hands back is the honest answer. safe_copy starts with tags = tags.copy(), then appends and returns.

                  Output
                  
                        

                    The settings resolver

                    To do

                    Every service in the dojo starts by working out its settings: a set of defaults, whatever the deployment overrides, and whatever was passed on the command line. Write the function that resolves them — and does it without damaging anything it was handed.

                    Write these two, with exactly these names:

                    • resolve(overrides=None, **extras) — returns the settings. Start from DEFAULTS, apply overrides if one was given, then apply extras on top, so a command-line value beats a file value and a file value beats a default.
                    • describe(settings) — returns one line per setting, in alphabetical order, as key=value joined by ", ". Returns "(none)" for empty settings.

                    Neither may damage anything. After any number of calls, DEFAULTS and the caller's own dictionary must be exactly as they started. Both functions need a docstring of at least twenty characters, and neither may print.

                    Then print exactly four lines:

                    debug=False, port=8080, timeout=30
                    debug=False, port=9000, region=eu, timeout=30
                    debug=True, port=9000, region=eu, timeout=30
                    {'port': 8080, 'debug': False, 'timeout': 30}

                    The first is the defaults alone, the second adds the file overrides, the third adds a command-line debug, and the fourth is DEFAULTS, proving it survived.

                    your_code.py
                    Python
                    Hint

                    Start with settings = DEFAULTS.copy(). If overrides is not None, settings.update(overrides). Then settings.update(extras) — extras is always a dict, empty when nothing was passed, so it needs no guard. Default overrides to None rather than {}, or every call shares one dictionary. describe guards the empty case, then builds f"{k}={v}" from sorted(settings.items()) and joins with ", ".

                    Output
                    
                          

                      Notification