Building from Pieces

Names, Objects, and Mutable Lists


Learning Objectives

  • You can explain how a function call gives its arguments local parameter names.
  • You can distinguish reassigning a name from modifying a mutable object.
  • You can predict changes made through a list parameter or an alias.
  • You can write functions that change a supplied list or return a separate result, according to their purpose.

The previous chapter used parameters with numbers and strings. A function can also receive a list. To understand when a call changes that list, we need to look more closely at how names refer to values.

Variables Are Names for Objects

In Part 1, a variable gave a value a name. We can describe that relationship more precisely:

A variable is a name bound to an object.

An object is a Python value. Integers, strings, booleans, and lists are all objects. Numbers, strings, and booleans are immutable: they cannot be changed in place. Lists are mutable: their contents can change.

Assignment makes a name refer to an object:

score = 10

This can be pictured as:

score ──► 10

Assignment and function calls do not automatically duplicate the object.

Local Names and Scope

Run this as a separate program:

def double(number):
result = number * 2
return result
answer = double(5)
print(answer)
print(result)

Inside the function, number and result are local names. The call returns 10, which the main program stores as answer. The first print displays 10, so the returned value is available to the caller.

The next statement, print(result), stops the program with a NameError. Python reports this error when it cannot find a name used by the program. Here, result is available inside double, but the main program has no name result.

The part of a program where a name can be used is its scope. For now, the most important distinction is:

  • names created inside a function are normally available only in that function’s body;
  • names created in the main program are outside that local scope.

Parameters are local names too. Each call has its own local names, even when several calls use the same function definition.

The Same Spelling Can Be Used in Different Scopes

message = "outside"
def show_message():
message = "inside"
print(message)
show_message()
print(message)

This prints:

inside
outside

The two names are spelled alike but belong to different scopes. Assigning the local message does not reassign the outside message.

Prefer Visible Inputs

A function can read a name from outside when it has no local name with the same spelling:

tax_rate = 0.24
def tax(amount):
return amount * tax_rate

This works, but it gives tax a dependency that is not visible in its parameters. A clearer small calculation function is often:

def tax(amount, tax_rate):
return amount * tax_rate

This is a rule of thumb rather than an absolute prohibition: prefer passing required information as a parameter when doing so makes the function’s inputs clearer.

Reassigning a Parameter

When a function is called, the argument expression is evaluated and the parameter becomes a local name for that object:

No files opened Select a file to edit

At the beginning of the call, the names can be pictured as:

score ──► 10
number ──► 10

The integer object 10 is immutable: it cannot be changed in place. The expression number + 1 produces the integer 11, and assignment rebinds only local number:

score ──► 10
number ──► 11

This does not assign to the caller’s name score. The same reassignment rule applies to every parameter, including list parameters.

Trace what happens to names inside and outside a function during a call:

Lists as Function Arguments

Lists can be supplied as arguments just like numbers and strings:

def total_values(values):
total = 0
for value in values:
total = total + value
return total

total_values([5, 8, 2]) returns 15. The accumulator and loop are familiar from Part 2; only the function boundary is new.

A list is mutable, so a function can also modify the list object supplied by its caller:

No files opened Select a file to edit

Step through the call. The caller’s results and local scores refer to one list. append changes that list while both names keep referring to it.

Code

Step 1 of 6

1 / 6
Formatting...

State

main

No variables

Objects

No objects yet

Output

No output yet

When the call finishes, its local frame closes. The caller observes the changed list through results.

Rebinding Is Not Mutation

Compare the preceding example with:

No files opened Select a file to edit

Watch the two names again. The assignment creates a separate list and makes only local scores refer to it. The caller’s results stays bound to the original list.

Code

Step 1 of 6

1 / 6
Formatting...

State

main

No variables

Objects

No objects yet

Output

No output yet

The program prints [10, 12]. Rebinding scores did not modify the original list or rebind results.

scores.append(15) # modifies the existing list
scores = [0, 0, 0] # rebinds the local name

The rule did not change between the number and list examples:

  • a parameter is a local name bound to the argument object;
  • reassigning the parameter changes only that local binding;
  • mutating a shared mutable object is visible through every name bound to it.

Aliasing Outside Functions

This behavior is not specific to parameters:

first = [1, 2]
second = first
second.append(3)
print(first)
print(second)

Both lines print [1, 2, 3]. Assignment did not create an independent list. It bound second to the same list as first.

More than one name for the same object is called aliasing. When aliases refer to a mutable list, a change made through one name is visible through the others.

Trace reassignment, mutation, and aliasing side by side:

Mutation Should Be Deliberate

A function whose stated purpose is to change a supplied list may reasonably use indexed mutation:

def increase_each(values):
for index in range(len(values)):
values[index] = values[index] + 1

Apply that pattern to a conditional cleanup:

Normalize a sensor log

0 / 15 points

Define a function named replace_negative_readings with one parameter, values.

Use an indexed loop to replace every negative item in the existing list with 0. Leave zero and positive items unchanged. The function must modify the list supplied by its caller, print nothing, and return None; do not return either the original list or a new list.

For example:

readings = [4, -2, 0, -7, 5]
replace_negative_readings(readings)
print(readings)

prints:

[4, 0, 0, 0, 5]

An empty list remains empty. Submit only the function definition.

A calculation function should usually return its result without unexpectedly changing its input. The earlier total_values function does this: it reads the items and returns their total without changing the list.

Sometimes a caller needs an updated list while keeping the original values. The function can return a separate list instead of changing the supplied one. Building a result list uses the same loop and list operations as in Part 2.

Practise that choice by recording another sensor reading:

Record another reading

0 / 15 points

Define with_reading_added(readings, reading) for a sensor log. The parameter readings is a list of numbers, and reading is one more number to record. The numbers may be negative, zero, positive, or decimal values.

Return a new list containing all the original readings in their original order, followed by the additional reading. Keep repeated values. An empty log produces a list containing just the additional reading.

Leave readings unchanged. The returned list must be separate from the original: changing either list afterward must not change the other. Each call must create its own result, so recording another reading later must not change a list returned by an earlier call.

For example:

original = [4, -2, 0, 4]
extended = with_reading_added(original, 1.5)
print(original)
print(extended)

prints:

[4, -2, 0, 4]
[4, -2, 0, 4, 1.5]

The function must not read input or print anything. The loops and list operations from Part 2 are enough for this task. Try calls with an empty log and repeated readings as well as the example above. Remove temporary calls before submitting, leaving your function definitions.

Returning a list does not automatically copy it. In the exercise, the function must create a separate list because the caller needs both the original log and the extended log. The earlier cleanup exercise instead changes the existing list and returns None. Follow the function’s stated purpose when deciding which behavior to use.

Finish by explaining why mutation and reassignment have different effects:

Explain a function call with a list

0 / 10 points

Explain in plain language why this program prints [1, 5]:

def revise(values):
values.append(5)
values = [0]
numbers = [1]
revise(numbers)
print(numbers)

In three or four sentences, describe what numbers and the parameter values refer to at the start of the call. Distinguish the effect of .append(5) from the later assignment values = [0].

Check Your Understanding

  1. When a list is passed to a function, what do the caller’s name and the parameter refer to?
  2. Why can a caller use a returned value even though the function’s local names are unavailable there?
  3. How does assigning a new list to a parameter differ from changing an item in the supplied list?
  4. What does it mean for two names to be aliases?
  5. How would you decide whether a function should change its input list or return a separate list?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0