Functions - Additional Topics#
This notebook presents additional, miscellaneous topics related to functions.
Packing and Unpacking Arguments#
In Python, the asterisk * is used for packing and unpacking arguments, which allows for more flexible argument passing to functions.
Positional#
When defining a function, use *args to pack all positional arguments into a tuple. This is useful when you don’t know how many arguments will be passed to your function.
1def pack_args(*args):
2 print("Number of arguments:", len(args))
3 for index, item in enumerate(args):
4 print("{}: {}".format(index,item))
5
6pack_args(1, 2, 3, 4) # Output: (1, 2, 3, 4)
Number of arguments: 4
0: 1
1: 2
2: 3
3: 4
You can mix regular arguments with *args in function definitions. Regular arguments are assigned first, and the rest are captured by *args.
1def mixed_arguments(first, *args):
2 print("First argument:", first)
3 print("Other arguments:", args)
4
5mixed_arguments(1, 2, 3, 4)
First argument: 1
Other arguments: (2, 3, 4)
Keyword#
While *args
packs positional arguments, **kwargs
packs keyword arguments, which are placed into a dictionary.
1def greet_all(**kwargs):
2 for key, value in kwargs.items():
3 print("{}: {}".format(key,value))
4
5greet_all(John="Hello", Alice="Hi")
John: Hello
Alice: Hi
Unpacking (exploding) Arguments#
Outside of a function definition, we can use *
and **
to unpack (explode) arguments to be sent to a function.
1def greet(first, last):
2 print("Hello {} {}!".format(first,last))
3
4alist = ['Condoleezza','Rice']
5adict = {'first': 'Edward', 'last': 'Sorin' }
6greet(*alist)
7greet(**adict)
Hello Condoleezza Rice!
Hello Edward Sorin!
Pass by Reference#
As a reminder, everything in Python is an object. When we pass arguments to a function, the reference to the object is passed. So if a mutable object is passed to a function, any changes to that object within the function will be reflected outside of the function.
In Python, a side effect refers to any change that a function makes to its state or the global program state, in addition to its return value. Typically, side effects emerge from modifying a global variables or changing mutatable arguments (e.g., a list or dictionary passed to a function).
An interesting case arises when a mutable object is used a default parameter. In the following function f
, a naive reading would assume that a new list is created everytime f
is called.
1def f(a = [], b = 2.34 * 500):
2 a.append("test")
3 print("b's identifier: ", id(b))
4 return a
In actuality, the Python interpreter only evaluates the default arguments once - most likely when the function itself is evaluated and then stored for future use.
1print(f())
2print(f())
b's identifier: 4581675664
['test']
b's identifier: 4581675664
['test', 'test']
Since the identifier for b remains constant, we can safely conclude that the interpreter only evaluates the expression 2.34 * 500
once. That expression was explicitly chosen over something like 1
, which the Python intrepeter optimizes for performance.
Also, you’ll see that the side effect of appending “test” to a
occurs and that the default value for a
remains unchanged through subsequent calls.
Functions are First-class Citizens#
Since everything in Python is an object, functions are also objects. Primarily, this means we can assign functions to varables. In turn, this allows us to store them in functions, pass them as arguments, and return them from functions.
1 def greet(name):
2 return "Hello, {}!".format(name)
3
4greet_function = greet
5print(greet_function("Alice"))
Hello, Alice!
1def shout(text):
2 return text.upper()
3
4def whisper(text):
5 return text.lower()
6
7def greet(style, name):
8 return style(name)
9
10print(greet(shout, "Hello"))
11print(greet(whisper, "Hello"))
HELLO
hello
Inner functions#
As we saw in the Recursion notebook with wrapper functions, we can define a function within another function. These functions are often used for organization, encapsulation, or to create closures. In a future notebook, We’ll also see how they are used to create decorators.
1def outer_function(text):
2 # This is the outer enclosing function
3
4 def inner_function():
5 # This is the nested inner function
6 return text.upper()
7
8 # The outer function returns the result of the inner function
9 return inner_function()
10
11result = outer_function("hello")
12print(result)
HELLO
Closures#
Inner functions can also create closures in Python. A closure in Python is a function object that remembers values in enclosing scopes. It’s a record that stores a function together with an environment: a mapping associating each free variable of the function with the value or reference to which the name was bound when the closure was created. A closure occurs when:
A nested function references a value in its enclosing scope.
The enclosing function returns the nested function.
1def outer_function(text):
2
3 def inner_function():
4 return text
5
6 return inner_function
7
8# Create a closure
9my_closure = outer_function("Hello, world!")
10print(my_closure())
Hello, world!
Key Points of a Closure
Enclosure: The inner function inner_function is enclosed within the scope of outer_function.
Environment: inner_function remembers the environment in which it was created. In this case, it remembers the value of the variable text.
Persistence: Even after outer_function has finished executing, my_closure retains the information about the variable text.
Advantages of Closures
Data Hiding: Closures can prevent data from being modified unintentionally.
Function Factories: Closures can generate functions tailored to specific needs.
Maintaining State: Closures can persist state across function calls without using global variables.
Function Factory#
Suppose we want to create a function that multiplies a number by a specific factor. We can use a closure to create a factory for such functions:
1def multiplier_factory(n):
2 def multiplier(x):
3 return x * n
4 return multiplier
5
6double = multiplier_factory(2)
7triple = multiplier_factory(3)
8
9print(double(5))
10print(triple(5))
10
15