Lecture 2.1
Guoliang Ma
The Chow Institute, 2025
How you can define and call simple functions.
How functions, as objects (chunks of memory), are stored, and when you call a function, what will happen inside Python.
Functions naturally divide memory into smaller pieces, and names live in such spaces. You’ll see how Python manages the spaces and controls the accessibility of variables.
“Functions are first-class objects.”
Review (read text file, work with dict):
Context: You are managing a database for a training camp. Under the data folder, there is students’ information from 5 classes. Each class has an age.txt and a gender.txt.
Please read the age.txt and gender.txt from class 1.
Make a new .txt file to store the information of class 1.
Repeat the steps for the other classes.
From the above example, you probably have noticed that for each class, the job is the same, requiring the same code to be used more than once. What are the disadvantages?
Prone to errors
Hard to modify
…
We now introduce how to abstract the procedure and encapsulate it into a function.
How is a function composed?
A function is designed to free us from repetitive jobs so that we can work more efficiently. So we need the function to know what we want to do. This is known as the function body.
The function will work on different classes (or more generally, objects). We need to let it know which one we need to proceed. We provide case-dependent information to the function via parameters.
As always, the name is so important. A function also needs a name.
There is another component of a function: the return value. We’ll talk about it later.
Putting the elements together. In code, we create a function with:
The above is the Pythonic style to define a function.
Assemble the code from exercise 2.1.1 to define a function.
def <name>(<parameters>):
<body>
return None
Once defined, the functions will be stored in the memory as other objects. Try to print the function and see what is printed.
Programming functions are very similar to math functions.
In math, we need to repeatedly find the distance between any two points and . So we define the distance function
.
We define functions so we can use them. In Python, when we use a function, we call it.
In math, given two specific points and . We evaluate the function by passing the values to the function: .
Python does exactly the same --- pairs of parentheses.
Call the function you just defined and apply it to class 2.
Summarize the steps to define a function.
Define a function that computes the square of all numbers in a list. For example, if the list is l = [1, 2, 3], the function finds [1, 4, 9].
Store the new list for future use.
Code Example (the call stack)
import inspect
def print_stack():
stack = inspect.stack()
for frame in stack:
print(f"Function: {frame.function}")
print(f"Code context: {frame.code_context}")
print("-" * 80)
print_stack()
A summary of a Python function call
Add a local frame, forming a new environment
Bind the function's formal parameters to its arguments in that frame
Execute the body of the function in that new environment
source: Prof. John DeNero, CS61A, UC Berkeley
How do you distinguish a function from a function call
This is more mathematical …
Given any function, how do you find its root?
The Newton’s method is one of the most commonly used.
Write a function to find the root of around . When the error is smaller than a value, report the root.
How do you stop the function iteration?
In Newton’s method, we can choose an error bound each time we call the function.
Usually we want to use the same bound, changing it only when needed.
A default parameter value supplies that value when the caller omits the argument.
Try the code below. What do you find? Can you explain?
def append_to(element, to=[]):
to.append(element)
return to
my_list = append_to(12)
print(my_list)
my_other_list = append_to(42)
print(my_other_list)
Try the code below. What do you find? Can you explain?
ff = {}
for i in range(5):
def f():
return i
ff[i] = f
ff[3]()
We have learned that Python interpreter binds names to objects. A namespace is a dictionary (more precisely, a hash map) telling us which name is bound to which value.
For example, we have the builtins, globals, closure, and the local namespaces.
A related concept is scope, which describes the range where a name can be resolved.
Write a function to swap the values of two objects. For example, a, b = 1, 2. After swapping, a is 2 and b is 1.
Analyze the function for variable names.
Check the names in the namespaces
import builtins
print(type(builtins), dir(builtins))
globals()
Can you please check the objects living in the local namespace?
When Python resolves a name, it searches in LEGB order:
| Search order | Namespace | Where it comes from |
| 1 · L | Local | The current function call |
| 2 · E | Enclosing | Enclosing function calls |
| 3 · G | Global | The current module |
| 4 · B | Built-in | Python’s built-in names |
nonlocal targets an existing binding in an enclosing function; global targets the module namespace.
import builtins
print(type(builtins), dir(builtins))
globals()
What will the following give us? How can we make it normal?
print(max(1, 2))
max = min
print(max(1, 2))
A long example
How does Python count number of references?
avoid unnecessary global variables
How do you modify the function factory in Special case II?
explicitly refer to a nonlocal variable
How do you hide information from the global functions?
follow the LEGB rule
First-class objects are flexible. Being first class means there is no restrictions on the use of the object. We can pass this object as an argument to a function and can return it as a return value. We can also create dictionaries to store it, etc.
When we use a function as an argument and return values of another "higher-level" functions, we are using higher-order functions.
Functions as return values
def intercept_1():
a = 1
def slope_2(x):
return 2 * x + a
return slope_2
linear_trans = intercept_1()
linear_trans(3)
Functions as arguments
def call_count(func, x=[0]):
print(f"calling {x[0] + 1} times")
x[0] += 1
func()
call_count(print)
call_count(print)
call_count(print)
Modify code example 1, so that we can select the intercept.
Modify code example 1, so that we can also select the slope.
Modify code example 2, so that we do not need default parameters.
In the call_count example, we can pass a function as an argument to the function. But this function cannot have its own parameters. How can we pass arguments to the function being counted?
def call_count(func, arg_to_called, x=[0]):
print(f"calling {x[0]} times")
x[0] += 1
func(arg_to_called)
call_count(print, "hello")
call_count(print, "python")
call_count(print, "world")
When we are not sure about how many parameters to pass to the function, the conventional parameter names are args and kwargs. The special syntax is *args and **kwargs; the names themselves are not keywords.
The * operator. A star is known as the (un)packing operator.
How do they differ?
a = 1, 2, 3
a, b, c = 1, 2, 3
a, b = 1, 2, 3
a, *b, c = 1, 2, 3, 4, 5
Some cases deliberately contain errors. Consider and run each assignment separately.
Summarize the pattern by considering
*a, b = 1, 2, 3, 4, 5
a, *b = 1, 2, 3, 4, 5
*a, *b = 1, 2, 3, 4, 5
*a, b, c = 1, 2, 3, 4, 5
*a, b = 1
Note that a is a list but *a unpacks the list into several elements. Passing indefinite number of arguments to a function involves two steps:
collecting positional arguments in a tuple (when defining *args)
unpacking an iterable with * when making a call
To check the unpacking behavior, we can use the sep parameter.
def call_count(func, *args, x=[0]):
print(f"calling {x[0]} times")
x[0] += 1
func(*args, sep=", ")
call_count(print, "hello", "python", "world")
The ** operator.
Another type of arguments is called keyword arguments, which must be passed to a function with the form param=arg. These are named arguments. Unlike * that unpacks a list, we use ** to unpack a dictionary. There are fewer use cases than the unpacking of a list.
dict1 = {"a": 1,
"b": 2,
"c": 3}
dict2 = {"d": 4,
"e": 5,
"f": 6}
combined_dict = {**dict1, **dict2}
Ordinary positional arguments precede keyword arguments in a call:
print("hello", "Python", sep=", ")
In the positional parameter list of a function definition, required parameters come before parameters with defaults.
Keyword-only parameters follow * or *args; **kwargs, when present, comes last.
def function(required, optional=0, *args, keyword_only, **kwargs):
...
Note that **kwargs is actually unpacking a dict. This is to say, kwargs is a dict. We learned that a dict has keys and values. Read the document about named arguments: https://docs.python.org/3/library/stdtypes.html#dict
Write a function to take the sum of several (the numbers are unknown) named arguments. For example,
def sum_of_kwargs(???):
pass
sum_of_kwargs(Alice=5, Bob=3, Charlie=4)
The creation of tools
source: https://cn.nytimes.com/culture/20180515/2001-a-space-odyssey-kubrick/
