1.4. Introduction to Functions
Up to this point, we have seen enough programming concepts to express non-trivial algorithms, such as determining whether a number is prime or not:
encountered_divisor = False
n = 83
for i in range(2, n):
if n % i == 0:
encountered_divisor = True
break
if encountered_divisor:
print(n, "is NOT prime")
else:
print(n, "is prime")
83 is prime
The input to that algorithm was a number (stored in variable n
in
the code above) and the result was a simple “yes” or “no” answer, printed
in the final if
statement.
However, we can run into a couple of issues with this code:
What if we wanted to re-run that program to test the primality of a different number? If we were using the interpreter, we would have to type in all the code again (changing the value of
n
as we go). If we had saved the code to a file, we would have to edit the file manually to change the value ofn
.What if we wanted to use the result of the algorithm in a different context? For example, we could be writing a more complex program that, at some point, performs a specific action depending on whether a number is prime (certain cryptography algorithms, for example, need to make this choice). We would have to re-write the primality testing code and change the final
if
.What if we needed to use the primality testing code in multiple parts of our program? We would have to repeat the above code in multiple places, which can be very error-prone. For example, if we decided to update the algorithm, we would have to make sure to update every copy of it.
Functions address these issues. Functions allow us to name a piece of code and re-use it multiple times with different inputs (in fact, some programming languages refer to them as subroutines or subprograms).
This mechanism is called a function because, like a mathematical function, functions in Python take some input values, called the parameters, and produce a new value, called the return value:
We have already seen uses of functions in some of our examples, like
the function random.randint
, which returns a randomly generated integer:
>>> import random
>>> n = random.randint(0, 1000)
>>> print("The value of n is", n)
The value of n is 315
When calling an existing function, you specify parameters in
parentheses, with each parameter separated by a
comma. random.randint
takes two parameters: one to specify a lower
bound and one to specify an upper bound. The code in
random.randint
takes the value of those parameters, computes a
random number between them, and returns the random
number. In the above example, we assign the returned value to
variable n
.
We can also call a function to perform an action rather than to
compute a value. The print
function, which we have used
extensively, is an example of such a function.
>>> print("Hello, world!")
Hello, world!
>>> n = 5
>>> print("The value of n is", n)
The value of n is 5
The print
function runs code necessary to print input parameters
on the screen.
All Python functions return some value. Functions
like print
that perform an action rather than compute
a value return the special value None
.
As we will explore, functions are an important
abstraction mechanism. They allow us to encapsulate a piece of code and
then reuse that code, without being concerned with how that piece of code
works (other than knowing its purpose, parameters, and return value). For example,
when we used random.randint
, we did not have to worry about the
exact algorithm used to produce a random number. We just incorporated
this functionality into our code, abstracting away the internal details
of random.randint
.
In this chapter, we will cover the basics of writing our own functions, and also dig into some of the lower-level aspects of how functions work. As we will point out then, you can safely skip those low-level details for now, but may want to revisit them once you’re more comfortable with functions, and definitely before we get to the Functional Programming and Recursion chapters, where we explore more advanced concepts involving functions.
1.4.1. Anatomy of a function
To describe the basic elements of a Python function, we will start with a very simple function that takes two integers and returns the product of those two integers:
def multiply(a, b):
"""
Compute the product of two values.
Args:
a (numeric): first operand
b (numeric): second operand
Returns (numeric): the product of the inputs
"""
n = a * b
return n
Let’s break down the above code:
The
def
keyword indicates that we are def-ining a function. It is followed by the name of the function (multiply
).The name of the function is followed by the names of the parameters. These names appear in parentheses, with parameters separated by commas, and are followed by a colon. (The parenthesis are required, even if the function has no parameters.) The parameters are the input to the function. In this case, we are defining a function to multiply two numbers, so we must define two parameters (the numbers that will be multiplied). Sometimes we refer to these names as the formal parameters of the function to distinguish them from the actual values provided when the function is used. The line(s) containing keyword
def
, the function name, the parameters, and the colon are known as the function header.A docstring and the body of the function follow the colon, both indented one level from the function header.
A docstring is a multi-line string (delimited by triple quotes, either
'''
or"""
) that contains at least a brief description of the purpose of the function, the expected inputs to the function, and the function’s return value. While docstrings are not required by the syntax of the language, it is considered good style to include one for every non-trivial function that you write.The body of the function is a block of code that defines what the function will do. Notice that the code operates on the parameters. As we’ll see later on, the parameters will take on specific values when we actually run the function. At this point, we are just defining the function, so none of the code in the body of the function is run just yet; it is simply being associated with a function called
multiply
.Notice that the body of the function contains a
return
statement. This statement is used to specify the return value of the function (in this case,n
, a variable that contains the product of parametersa
andb
). The last line of the body of the function is typically areturn
statement but, as we’ll see later on, this statement is not strictly required.
Try typing in the function into the interpreter:
>>> def multiply(a, b):
... """
... Compute the product of two values.
...
... Args:
... a (numeric): first operand
... b (numeric): second operand
...
... Returns (numeric): the product of the inputs
... """
...
... n = a * b
... return n
...
Once the multiply
function is defined, we can call it
directly from the interpreter. To do so, we just write the name
of the function followed by the values of the parameters in parentheses,
with the parameters separated by commas:
>>> multiply(3, 4)
12
This use of multiply
is referred to as a function call, and the values passed to the
function are referred to as either the actual parameters or the arguments.
This call will run the code in the
multiply
function, initializing a
with the value 3 and b
with the value 4. More
specifically, remember that the body of the function was this code:
n = a * b
return n
When the function is called with parameters 3 and 4, the function effectively executes this code:
a = 3
b = 4
n = a * b
return n
And then the Python interpreter prints 12
, the value returned by the
function called with parameters 3 and 4.
Later on, we’ll see that passing arguments to functions is more complicated, but for now, you can think of a function simply initializing formal parameters in the function body from the actual parameter values specified in the function call.
You can also include function calls in expressions:
>>> 2 + multiply(3, 4)
14
When Python evaluates the expression, it calls multiply
with actual
parameters 3 and 4 and uses the return value to compute the value of the
expression. In general,
you can use a function call in any place where its return value would
be appropriate. For example, the following code is valid because the print
function can take an arbitrary number of
arguments and both string and integer are acceptable argument types:
>>> print("2 x 3 =", multiply(2, 3))
2 x 3 = 6
Of course, context matters. The following code is not valid because the function call returns an integer and Python can’t add strings and integers:
>>> "2 x 3 = " + multiply(2, 3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
Finally, parameter values can also be expressions, as long as the expression yields a value of the expected type. For example:
>>> x = 3
>>> y = 5
>>> multiply(x, y)
15
>>> multiply(x - 1, y + 1)
12
The actual parameter expressions are evaluated before the multiply
function is
called. As a result, the first call to multiply
uses 3 and 5
(the values of x
and y
respectively) as parameters. The
second call to multiply
uses x - 1
, or 2, and y + 1
, or 6,
as the initial values for a
and b
.
In fact, the parameters to a function can themselves be function calls:
>>> multiply(4, multiply(3, 2))
24
In this case, Python first evaluates the inner call to multiply
(that is, multiply(3, 2)
) and,
then uses the call’s value (6
) as the second parameter
to multiply
. The outer call essentially becomes multiply(4, 6)
.
A Common Pitfall
The distinction between a function that prints something and a
function that returns something is important, but often misunderstood.
Our multiply
function, for example,
returns the product of its two arguments.
Let’s look a similar function that prints the result instead:
def print_multiply(a, b):
"""
Print the product of two values.
Args:
a (numeric): first operand
b (numeric): second operand
Returns: None
"""
n = a * b
print(n)
When we call this function:
>>> print_multiply(5, 2)
10
It appears to return 10
. The Python interpreter displays
integers returned from a function and integers printed using print
in the same way (other Python interpreters, like IPython,
explicitly distinguish between the two). We can see this difference
if we explicitly assign the return value to a variable and then
print it:
>>> rv = multiply(5, 2)
>>> print("The return value is:", rv)
The return value is: 10
>>> rv = print_multiply(5, 2)
10
>>> print("The return value is:", rv)
The return value is: None
Notice that print_multiply
still printed the value 10
, but
the return value of the function is the special value None
.
Additionally, although it is valid to use the
result of one call to multiply
as an argument to another, it
is not valid do use a call to print_multiply
in the same way.
>>> multiply(5, multiply(2, 3))
30
>>> print_multiply(5, print_multiply(2, 3))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 12, in print_multiply
TypeError: unsupported operand type(s) for *: 'int' and 'NoneType'
In general, if you are writing a function that produces a value that you want to use elsewhere, make sure that you return that value. Printing it is not sufficient.
1.4.2. Encapsulating primality testing
We can use a function to encapsulate our primality testing code,
allowing us to use it easily and in more places. Our
function will take an integer as a parameter and return True
if
the integer is prime and False
if it is not.
def is_prime(n):
"""
Determines whether the input is prime.
Args:
n (int): value to be checked
Returns (bool): True if the input is prime and False otherwise
"""
encountered_divisor = False
for i in range(2, n):
if n % i == 0:
encountered_divisor = True
break
return not encountered_divisor
Once we type this function into the interpreter, we can run it as many times as we want.
>>> is_prime(4)
False
>>> is_prime(17)
True
>>> is_prime(81)
False
This is a big improvement from the previous chapter, where we either had to type the code in all over again or edit a file to modify the value of the number we were testing and rerun the code.
Instead of typing the function into the interpreter,
you can create a file named primes.py
that contains the above function, and then run the following in the
interpreter:
>>> import primes
You will be able to call the function from the interpreter like this:
>>> primes.is_prime(4)
False
>>> primes.is_prime(7)
True
Similarly, you can use import primes
in another Python file to
get access to the function. In this context, we would refer to
primes
as a module. Python already includes many built-in
modules that provide access to a large collection of useful
functions. For example, earlier in the chapter we used
Python’s random
module and, more specifically, the randint
function contained in that module.
Before we move on to the next topic, let’s fix a bug you might have
noticed in our implementation of is_prime
: it does not handle one
as an argument properly:
>>> primes.is_prime(1)
True
We could fix this problem by using a more complex expression in the return statement:
def is_prime(n):
"""
Determines whether the input is prime.
Args:
n (int): value to be checked
Returns (bool): True if the input is prime and False otherwise
"""
encountered_divisor = False
for i in range(2, n):
if n % i == 0:
encountered_divisor = True
break
return (n != 1) and (not encountered_divisor)
>>> is_prime(1)
False
>>> is_prime(4)
False
>>> is_prime(7)
True
But as you will see in the next section, there is a better way to resolve this problem.
1.4.2.1. Practice Problem
Problem 1 Write a function print_categories
that takes two
integers, a lower bound and an upper bound on a range (inclusive), and
prints each number in the range with an indication of whether the
number is prime or composite (that is, not prime). Here is a sample
use of this function:
>>> print_categories(2, 10)
2 prime
3 prime
4 composite
5 prime
6 composite
7 prime
8 composite
9 composite
10 composite
Your solution should call is_prime
, rather than repeat the
primality testing code.
1.4.3. Return statements
The functions we’ve seen so far have a single return statement at the end of the function. A return statement can appear anywhere in the function and can appear multiple times. For example:
def absolute(x):
"""
Compute the absolute value of a number.
Args:
n (numeric): operand
Returns (numeric): the magnitude of the input
"""
if x < 0:
return -x
else:
return x
>>> absolute(3)
3
>>> absolute(-3)
3
We can use multiple return statements to simplify the return statement
in our second version of the is_prime
function. Specifically, we
can modify the function to treat one as a special case, immediately
returning False
when the function is called with 1
as the
parameter.
def is_prime(n):
"""
Determines whether the input is prime.
Args:
n (int): value to be checked
Returns (bool): True if the input is prime and False otherwise
"""
if n == 1:
return False
encountered_divisor = False
for i in range(2, n):
if n % i == 0:
encountered_divisor = True
break
return not encountered_divisor
In fact, we can tweak the function further to avoid using the
encountered_divisor
variable altogether. If we find that n
is divisible
by i
, then we can return False
right away. If we make it
through the whole for
loop, then n
must be
prime:
def is_prime(n):
"""
Determines whether the input is prime.
Args:
n (int): value to be checked
Returns (bool): True, if the input is prime and False otherwise
"""
if n == 1:
return False
for i in range(2, n):
if n % i == 0:
return False
return True
>>> is_prime(1)
False
>>> is_prime(4)
False
>>> is_prime(7)
True
Python computes the return value and leaves
the function immediately upon encountering a return
statement.
For example, when it executes the first call above (is_prime(1)
),
Python encounters the return
statement
within the first conditional and exits the function before it reaches the loop.
In contrast, in the second call (is_prime(4)
),
Python enters the loop and encounters the return statement during its first
iteration. Python reaches the final return statement only
during the third call (is_prime(7)
).
Tip
As with break
and continue
, every return
statement in a
loop should be guarded by a conditional statement. Because Python
returns from the current function as soon as it encounters a
return
statement, a loop with an unguarded return
will
never execute more than one iteration (and possibly, a partial
iteration at that).
1.4.3.1. Practice Problem
Problem 2
Let’s return to the problem of writing code to help an instructor determine a student’s grade. The instructor has decided that a student will earn a(n):
A if the student’s midterm, final, and hw are all at least 90
B if the student’s midterm, final, and hw are all at least 80
C if the student’s midterm, final, and hw are all at least 70
B if the student’s hw is at least 90, their midterm and final are both at least 50 and their final is at least 20 points higher than their midterm. Otherwise the student should receive an F.
In the previous chapter, you wrote code to set a variable to the grade a student earned given variables with the student’s homework average and their grades on the midterm and final exams.
For this problem, your task is to write a function, compute_grade
,
that takes those three values as floats and returns the student’s
grade as a string.
1.4.4. Advantages of using functions
Functions help us organize and abstract code. We’ll look at simulating Going to Boston, a simple game with dice, to help illustrate this point.
Going to Boston is played with three dice by two or more players who alternate turns. When it is a player’s turn, they first roll all three dice and set aside the die with the largest face value, then roll the remaining two dice and set aside the largest one, and finally, roll the remaining die. The sum of the resulting face values is their score for the round. The players keep a running total of their scores until one reaches 500 and wins.
1.4.4.1. Reusability
Let’s think about how to implement the code for a single round. We could write code that implements the rules directly:
def play_round():
"""
Play a round of the game Going to Boston using three dice.
Args: none
Returns (int): score earned
"""
num_sides = 6
score = 0
# roll 3 dice, choose largest
die1 = random.randint(1, num_sides)
die2 = random.randint(1, num_sides)
die3 = random.randint(1, num_sides)
largest = max(die1, die2, die3)
score += largest
# roll 2 dice, choose largest
die1 = random.randint(1, num_sides)
die2 = random.randint(1, num_sides)
largest = max(die1, die2)
score += largest
# roll 1 die, choose largest
largest = random.randint(1, num_sides)
score += largest
return score
This code is not very elegant, but it does the job and we can call it over and over again to play as many rounds as we want:
>>> play_round()
17
>>> play_round()
14
>>> play_round()
9
Although this implementation works, it has repeated code, which is a sign of a poor design. We should abstract and separate the repeated work into a function. At times, this task will be easy because your implementation will have a block of code repeated verbatim. More commonly, you will have several blocks of similar but not identical code. When faced with the latter case, think carefully about how to abstract the task into a function that can be used, with suitable parameters, in place of original code.
In the function play_round
, for example, we have three variants of
code to roll dice and find the largest face value (keep in mind the
largest face value of a single die roll is the value of the single
die). To abstract this task into a function, we will take the number
of dice as a parameter and replace the calls(s) to max
with a
single call.
def get_largest_roll(num_dice):
"""
Roll a specified number of dice and return the largest face
value.
Args:
num_dice (int): the number of dice to roll
Returns (int): the largest face value rolled
"""
num_sides = 6
# initialize largest with a value smaller than the smallest
# possible roll.
largest = 0
for i in range(num_dice):
roll = random.randint(1, num_sides)
largest = max(roll, largest)
return largest
Given this function, we can replace the similar blocks of code with functions calls that have appropriate inputs:
def play_round():
'''
Play a round of the game Going to Boston using three dice.
Args: None
Returns: the score earned by the player as an integer.
'''
score = get_largest_roll(3)
score += get_largest_roll(2)
score += get_largest_roll(1)
return score
This version is easier to understand and yields a new function,
get_largest_roll
, that may be useful in other contexts.
As an aside, we chose to implement the rules for playing a round of Going to Boston with exactly three dice. We could have chosen to generalize it by taking the number of dice as a parameter. For example:
def play_round_generalized(num_dice):
"""
Play a round of the game Going to Boston.
Args:
num_dice (int): the number of dice to use
Returns (int): score earned
"""
score = 0
# run the loop from num_dice down to one to mimic the
# orginal code that rolls # three dice, then two, and then
# finally, just one.
for nd in range(num_dice, 0, -1):
score += get_largest_roll(nd)
return score
A good rule of thumb is to start with a simple version of a function and generalize as you find new uses.
1.4.4.2. Composability
In addition to reducing repeated code, we also use functions as building
blocks for more complex pieces of code. For example, we can use our
play_round
function to simulate a two-person version of Going to
Boston.
def play_going_to_boston(goal):
'''
Simulate one game of Going to Boston.
Args:
goal (int): threshold for a win.
Returns: None
'''
player1 = 0
player2 = 0
while (player1 < goal) and (player2 < goal):
player1 += play_round()
if player1 >= goal:
break
player2 += play_round()
if player1 > player2:
print("player1 wins")
else:
print("player2 wins")
This function is more complex than a simple call to
play_round
, so let’s look at it carefully. Notice that we
take the winning score as a parameter instead of hard-coding it
as 500. Next, notice that in the body of the loop, we play a round for
player2
only if player1
does not reach the winning score.
The loop ends when one player reaches the target score; we then print
the winner.
Since both of our implementations of play_round
complete the same
task and have the same interface, we could use either with our
implementation play_go_to_boston
. In fact, it is common to start
with a straightforward algorithm, like our very basic implementation
of play_round
, for a task and only come back to replace if it
becomes clear that an algorithm that is more efficient or easier to
test is needed.
In general, we want to design functions that allow the function’s user
to focus on the function’s purpose and interface (the number
and types of arguments the function expects and the
type of the value the function returns), and to ignore the
details of the implementation. In this case,
we didn’t need to understand how rounds are played to design the loop;
we only needed to know that play_round
does not take any arguments
and returns the score for the round as an integer.
A word about our design choices: the decision to make the winning score a parameter seems like a good choice, because it makes our function more general without adding substantial burden for the user. The decision to combine the code for running one game and printing the winner, on the other hand, has fewer benefits. Our implementation fulfills the stated task but yields a function that is not useful in other contexts. For example, this function would not be useful for answering the question of “is there a significant benefit to going first?” A better design would separate these tasks into two functions:
def play_one_game(goal):
"""
Simulate one game of Going to Boston.
Args:
goal (int): threshold for a win
Returns (bool): True if player1 wins and False, if player2 wins.
"""
player1 = 0
player2 = 0
while (player1 < goal) and (player2 < goal):
player1 += play_round()
if player1 < goal:
player2 += play_round()
return player1 > player2
def play_going_to_boston(goal):
"""
Simulate one game of Going to Boston and print the winner.
Args:
goal (int): threshold for a win
Returns: None
"""
if play_one_game(goal):
print("player1 wins")
else:
print("player2 wins")
This design allows us to simulate many games to see if the first player has an advantage:
def simulate_many_games(num_trials, goal):
"""
Simulate num_trials games of Going to Boston and print the
average number of wins for player 1.
Args:
num_trials (int): number of trial games to play
goal (int): threshold for a win
Returns: None
"""
wins = 0
for i in range(num_trials):
if play_one_game(goal):
wins = wins + 1
print(wins/num_trials)
Simulating 10,000 trials with a goal of 500 shows that there is a big
advantage to going first: player1
wins roughly 60% of the time.
>>> simulate_many_games(10000, 500)
0.618
>>> simulate_many_games(10000, 500)
0.6122
>>> simulate_many_games(10000, 500)
0.6062
1.4.4.3. Testability
Finally, functions make code easier to test. A piece of code that fulfills a specific purpose (e.g., to determine whether a number is prime or roll N dice and return the largest face value) is far easier to test when it is encapsulated inside a function.
As we saw with primality testing earlier, once our code is encapsulated as a function, we can test it informally by running the function by hand with parameters for which we know the correct return value. Of course, manually testing functions in the interpreter can still be cumbersome. The process of testing code can be largely automated using unit test frameworks that allow us to specify a series of tests, and easily run all of them.
1.4.5. Variable scope
In Python and other programming languages, variables have a specific scope, meaning that they are only valid and can only be used in a specific part of your code. This concept is especially relevant for functions because any variables that are defined within a function have function scope, meaning that they are only valid within that function (i.e., within the scope of that function).
Variables that are only valid in a specific scope, such as the scope of a function, are commonly referred to as local variables (as opposed to global variables, which we will discuss later). A function’s formal parameters are local variables because they are valid only within the scope of the function.
Calling a function alters the control flow of a program: when
Python reaches a function call, it “jumps” to the function, runs
through the statements in the function, and finally, returns to the point
in the code where
the function was called. Let’s look more carefully at what happens
during a call to play_round
:
def play_round():
'''
Play a round of the game Going to Boston using three dice.
Args: None
Returns: the score earned by the player as an integer.
'''
score = get_largest_roll(3)
score += get_largest_roll(2)
score += get_largest_roll(1)
return score
The first statement in play_round
introduces a new variable, named
score
and sets its initial value to the result of evaluating a
function call (get_largest_roll(3)
). To evaluate this call,
Python will:
evaluate the argument (
3
),create the parameter
num_dice
and initialize it to the result of evaluating the argument (3),transfer control to the body of
get_largest_roll
,execute the first two statements, which create new variables named
num_sides
andlargest
, and initialize them to 6 and 0, respectively,execute the loop, which itself introduces new variables, named
i
androll
, that are updated for each iteration,set the return value of the function call to be the value of
largest
,discard the variables it created during the function evaluation (e.g.,
num_dice
andnum_sides
), andtransfer control back to the first statement of
play_round
.
The variables num_sides
, largest
, i
, and roll
are
valid only within get_largest_roll
. We would get an error if we
tried to use one them or the parameter (num_dice
) outside of
the context of this function. For example, this code will fail,
>>> score = get_largest_roll(3)
>>> print(largest)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'largest' is not defined
Similarly, the variable score
is visible inside play_round
,
but not within get_largest_roll.
Any attempt to use score
inside get_largest_roll
would also fail.
The second and third statements in play_round
also call
get_largest_roll
and update the variable score
. Python will go
through exactly the same steps to evaluate the second call, including
creating fresh versions of the parameter (num_dice
) and the rest
of the local variables (num_sides
, largest
, i
, and
roll
), initializing them appropriately, and eventually discarding
them when the function returns. Finally, it will complete the same process all
over again to evaluate the third call.
In all, three distinct sets of the local variables for
get_largest_roll
will be created and discarded over the course of
a single execution of play_round
.
1.4.6. Parameters
Understanding how parameters work is an important aspect of learning how to design and write functions. So far, we have seen some fairly simple parameters, but, as we’ll see in this section, parameters have several features and nuances that we need to keep in mind when writing functions.
1.4.6.1. Call-by-value
As discussed above, expressions used as arguments for a function call are evaluated before the function call, and their values are used to initialize fresh copies of the formal parameters. This type of parameter passing is known as call-by-value.
Let’s consider a trivial example to illustrate one impact of this design. Here’s a function that updates the value of its formal parameter and three functions that use it:
def add_one(x):
print(" The value of x at the start of add_one is", x)
x = x + 1
print(" The value of x at the end of add_one is", x)
return x
def f():
y = 5
print("The value of y before the the call to add_one is", y)
z = add_one(y)
print("The value returned by the call to add_one is", z)
print("The value of y after the the call to add_one is", y)
def g():
y = 5
print("The value of y before the the call to add_one is", y)
z = add_one(5)
print("The value returned by the call to add_one is", z)
print("The value of y after the the call to add_one is", y)
def h():
x = 5
print("The value of x before the the call to add_one is", x)
z = add_one(x)
print("The value returned by the call to add_one is", z)
print("The value of x after the the call to add_one is", x)
(We omitted docstrings from the functions to save space.)
Here’s what happens when we call function f
:
>>> f()
The value of y before the the call to add_one is 5
The value of x at the start of add_one is 5
The value of x at the end of add_one is 6
The value returned by the call to add_one is 6
The value of y after the the call to add_one is 5
As expected, we do not see any changes to y
. When Python reached the
call to add_one
in f
, it evaluated the expression (y
)
and initialized a fresh copy of x
, the formal
parameter of add_one
, to the resulting value (5
). As the function
executed, the value of that copy of x
was read, updated, and then
read again. Once add_one
returned to f
, the copy of x
was
discarded.
From the perspective of
the add_one
function, there is no difference between the call in
function f
and the call in function g
.
>>> g()
The value of y before the the call to add_one is 5
The value of x at the start of add_one is 5
The value of x at the end of add_one is 6
The value returned by the call to add_one is 6
The value of y after the the call to add_one is 5
We can even reuse the name x
in place of the name y
, as seen in
function h
, and the values printed by our code will not change:
h()
The value of x before the the call to add_one is 5
The value of x at the start of add_one is 5
The value of x at the end of add_one is 6
The value returned by the call to add_one is 6
The value of x after the the call to add_one is 5
Why? Because the variable x
defined in function h
and the
formal parameter x
in add_one
are different variables that
just happen to have the same name.
1.4.6.2. Practice Problem
Problem 3 Given the following functions:
def fun1(i):
i = i - 2
return i
def fun2(i):
return fun1(i) + fun1(i)
def fun3(i):
return fun1(i * 2)
def fun4(i):
i = fun3(i)
return fun2(i)
What is the result of evaluating this call: fun4(6)
?
Problem 4 Given the following code:
def fun1(x, y, z):
if x % y == z:
return x + y + z
else:
return 1
def fun2(i,j):
i = i + 2
j = j + 3
def fun3(x, y, z=2):
for i in range(4, x):
for j in range(2, y):
a = fun1(i, j, z)
if a >= 10:
fun2(i,j)
return i + j
return -1
What is the result of evaluating fun3(6, 4, 2)
?
1.4.6.3. Default parameter values
Suppose we wanted to write a function that simulates flipping a coin
n
times. The function itself would have a single parameter, n
,
and would return the number of coin flips that landed on heads.
The implementation of the function needs a for
loop to perform
all of the coin flips and uses random.randint
to randomly produce
either a 0 or a 1 (arbitrarily setting 0 to be heads and 1 to be tails):
import random
def flip_coins(n):
"""
Flip a coin n times and report the number that comes up heads.
Args:
n (int): number of times to flip the coin
Returns (int): number of flips that come up heads.
"""
num_heads = 0
for i in range(n):
flip = random.randint(0, 1)
if flip == 0:
num_heads = num_heads + 1
return num_heads
>>> flip_coins(100)
50
As expected, if we flip the coin a large number of times, the number of flips that come out heads approaches 50%:
>>> n = 100000
>>> heads = flip_coins(n)
>>> print(heads/n)
0.50008
Now, let’s make this function more general. Right now,
it assumes we’re flipping a fair coin (i.e., there is an equal
probability of getting heads or tails). If we wanted to simulate a weighted coin
with a different probability of getting heads, we could add an extra
parameter prob_heads
to provide the probability of getting
heads (for a fair coin, this value would be 0.5
):
def flip_coins(n, prob_heads):
Of course, we also have to change the implementation of the
function. We will now use a different function from the random
module: the uniform(a,b)
function. This function returns a
randomly chosen float between a
and b
. If we call the function with
parameters 0.0
and 1.0
, we can simply use values less than
prob_heads
as indicating that the coin flip resulted in heads, and
values greater than or equal to prob_heads
to indicate tails:
def flip_coins(n, prob_heads):
"""
Flip a weighted coin n times and report the number that come up
heads.
Args:
n (int): number of times to flip the coin
prob_heads (float): probability that the coin comes up heads
Returns (int): number of flips that came up heads.
"""
num_heads = 0
for i in range(n):
flip = random.uniform(0.0, 1.0)
if flip < prob_heads:
num_heads = num_heads + 1
return num_heads
Like before, we can informally validate that the function seems to be working
correctly when we use large values of n
:
>>> n = 100000
>>> heads = flip_coins(n, 0.7)
>>> print(heads/n)
0.70163
>>> heads = flip_coins(n, 0.5)
>>> print(heads/n)
0.50078
However, it’s likely that we will want to call flip_coins
to
simulate a fair coin most of the time. Fortunately, Python
allows us to specify a default value for parameters. To do this, we
write the parameter in the form of an assignment, with the default value
“assigned” to the parameter:
def flip_coins(n, prob_heads=0.5):
"""
Flip a weighted coin n times and report the number that come up
heads.
Args:
n (int): number of times to flip the coin
prob_heads (float): probability that the coin comes up heads
(default: 0.5)
Returns (int): number of flips that came up heads.
"""
num_heads = 0
for i in range(n):
flip = random.uniform(0.0, 1.0)
if flip < prob_heads:
num_heads = num_heads + 1
return num_heads
We can still call the function with the prob_heads
parameter:
>>> flip_coins(100000, 0.7)
70083
>>> flip_coins(100000, 0.35)
34854
But, if we omit the parameter, Python will use 0.5
by default:
>>> flip_coins(100000)
49846
When you specify a function’s parameters, those without default values must come first, followed by those with default values (if there are any). Notice, for example, that the following code fails:
def flip_coins(prob_heads=0.5, n):
"""
Flip a weighted coin n times and report the number that come up
heads.
Args:
prob_heads (float): probability that the coin comes up heads
(default: 0.5)
n (int): number of times to flip the coin
Returns (int): number of flips that came up heads.
"""
num_heads = 0
for i in range(n):
flip = random.uniform(0.0, 1.0)
if flip < prob_heads:
num_heads = num_heads + 1
return num_heads
File "<stdin>", line 1
def flip_coins(prob_heads=0.5, n):
^
SyntaxError: parameter without a default follows parameter with a default
1.4.6.4. Positional and keyword arguments
So far, whenever we have called a function, we have specified the arguments in that function call in the same order as they appear in the function’s list of parameters (with the ability to omit some of those parameters that have a default value). These types of arguments are referred to as positional arguments, because how they map to specific parameters depends on their position in the list of arguments. For example:
>>> flip_coins(100000, 0.7)
69817
In this case, 100000
will be the value for the n
parameter and
0.7
will be the value for the prob_heads
parameter.
It is also possible to specify the exact parameter that we are passing a value by using keyword arguments. This type of argument follows the same syntax as an assignment; for example:
>>> flip_coins(n=100000, prob_heads=0.7)
70024
Notice that, because we explicitly provide a mapping from values to parameters, the position of the arguments no longer matters:
>>> flip_coins(prob_heads=0.7, n=100000)
69956
Keyword arguments can make a function call easier to read, especially for functions that have many parameters. With keyword arguments, we do not need to remember the exact position of each parameter.
It is possible to use both positional arguments and keyword arguments, although in that case the positional arguments must come first.
For example, this call works:
>>> flip_coins(100000, prob_heads=0.7)
69964
But this call doesn’t:
>>> flip_coins(prob_heads=0.7, 100000)
File "<stdin>", line 1
flip_coins(prob_heads=0.7, 100000)
^
SyntaxError: positional argument follows keyword argument
1.4.6.5. Practice Problem
Problem 5
Test code is designed to help validate the behavior of a piece for
code for a given set of values. How do we test computations that use
randomness given that the generated random values change each time we
run the computation? One way to address this problem is to set the
seed used to by the random number generator to compute the sequence of
values it returns. The seed is set by calling random.seed
with an
integer value. If random.seed
is called with the value None
,
the value of the system clock time at the time of the call is used as
the seed. This function is typically called once at the start of the
computation.
Here is a simple illustration of how the seed works:
>>> random.seed(5000)
>>> random.uniform(0.0, 1.0)
0.2330654737797524
>>> random.uniform(0.0, 1.0)
0.7830144643540888
>>> random.uniform(0.0, 1.0)
0.4553051301153339
>>> random.seed(5000)
>>> random.uniform(0.0, 1.0)
0.2330654737797524
>>> random.uniform(0.0, 1.0)
0.7830144643540888
>>> random.uniform(0.0, 1.0)
0.4553051301153339
Notice that the second three calls to random.uniform
generate the
exact same values as the first three calls.
Rewrite flip_coins
to take a seed as a second optional parameter
and to include a call to random.seed
. Make sure to update the
docstring to include the extra parameter.
Once you have writen your function, write function calls that provide:
only a value for
n
,a value for
n
and a value forprob_heads
,a value for
n
and a value for the seed parameter, but notprob_heads
, andvalues for all three parameters.
Problem 6
A common trick when debugging a complex function is to include a
parameter called debug_level
that is used to specify how much
information the function should print out during the computation.
During regular use, the debug level might be set to zero, while during
active debugging, it might be set higher.
For example, if we were to add a debug level parameter to
flip_coin
from Problem 5
, we might have:
0
mean do not print any information about the computation,1
mean print the values of the parameters passed to the function, and2
mean print the parameters and the values offlip
andnum_heads
for coin flip.
For example, the call flip_coins(5, 0.5, 5000, debug_level=0)
would not print any extra information; the call flip_coins(5, 0.5, 5000, debug_level=1)
would print something like:
Debug 1: flip_coins(n=5, prob_heads0.5, seed=5000, debug_level=1)
the call flip_coins(5, 0.5, 5000, 1)
would print something like:
Debug 2: flip_coins(n=5, prob_heads0.5, seed=5000, debug_level=2)
Debug 2: flip: 0.2330654737797524 num_heads: 1
Debug 2: flip: 0.7830144643540888 num_heads: 1
Debug 2: flip: 0.4553051301153339 num_heads: 2
Debug 2: flip: 0.501694756752729 num_heads: 2
Debug 2: flip: 0.8394352573499935 num_heads: 2
The return value for all three calls, which is not shown, would be
2
as expected from the debugging output.
Update your solution for Problem 5
to take a debug_level
parameter and to use it to decide what information to print
using above description.
The exact output you will see when you run your implementation will
depend on the exact format you use in the necessary print statements
and the version of the random
library that you are using.
1.4.6.6. Dynamic typing revisited
Let’s return to our multiply
function and revisit the impact of
dynamic typing. In this case, we’ve defined a function that is
intended to work with numbers. One of the nice things about Python is
that, as defined, the function will also seamlessly work with floats as well as with the integers that we used in our initial examples:
>>> multiply(2.5, 3.0)
7.5
It even works when one parameter is an integer and one is a string:
>>> multiply("hello ", 3)
'hello hello hello '
but it does not work for all combinations of types. Because Python does not verify the types of parameters when the code is loaded, passing incompatible values can lead to errors at runtime:
>>> multiply(3.0, "hello")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 12, in multiply
TypeError: can't multiply sequence by non-int of type 'float'
This behavior is both an advantage and a disadvantage of dynamically-typed languages. It is an advantage because we can often use the same code for different types of inputs. It is a disadvantage because we do not learn about these types of errors until runtime and it is very discouraging to have a long-running computation fail with a type error. In a statically-typed language, we would specify the types of the parameters. Doing so can make the definition more rigid, but enables us to catch errors more quickly.
1.4.7. Global variables
As mentioned earlier, variables defined in a function are known as local variables. Variables defined outside the context of a function are known as global variables.
Let’s say we have an application that needs to compute the maximum of \(N\) rolls of a die in some places and the sum of \(N\) rolls of a die in other places and that the number of sides in a die is constant (that is, the value does not change once it is set). To avoid defining variables for this constant in multiple places in our code, we could move the definition out of the functions altogether.
NUM_SIDES = 6
def get_largest_roll(num_dice):
"""
Roll a specified number of dice and return the largest face
value.
Args:
num_dice (int): the number of dice to roll
Returns (int): the largest face value rolled
"""
# initialize largest with a value smaller than the smallest
# possible roll.
largest = 0
for i in range(num_dice):
roll = random.randint(1, NUM_SIDES)
largest = max(roll, largest)
return largest
def sum_throw(num_dice):
"""
Throw a specified number dice and sum up the resulting rolls.
Args:
num_dice (int): the number of dice to roll
Returns (int): the sum of the face values rolled
"""
total = 0
for i in range(num_dice):
total = total + random.randint(1, NUM_SIDES)
return largest
In this example, the variable NUM_SIDES
is defined and then used
in both functions. As an aside, it is common to use names with all
capital letters to signal that a variable is a constant (that is, its
value will not change once it is set).
This is an excellent use of a global variable. We avoid repeated code (i.e., multiple definitions of the same value) and, since the value does not change, there is never any confusion about the variable’s current value.
Common Pitfalls
It can be tempting to use global variables as a way to reduce the
number of parameters that are passed to a function. If, for
example, let’s say you found a new use for get_largest_roll
,
but with an eight-sided die instead of a six-sided die. You might
be tempted to simply update the value of NUM_SIDES
to 8
before calling get_largest_roll
:
>>> NUM_SIDES = 8
>>> get_largest_roll(3)
5
Don’t. This design is bad. If you forget to set NUM_SIDES
back
to 6
, subsequent calls to get_largest_roll
(especially
those related to its orginal purpose) might be incorrect in
a particularly pernicious way: the answer looks fine (e.g., a
3
is returned) even though the function is no longer modelling
the desired behavior.
The appropriate way to extend the use of get_largest_roll
is to
use an optional parameter to allow the user to provide information
about the die in some cases and not others:
NUM_SIDES = 6
def get_largest_roll(num_dice, die_num_sides=NUM_SIDES):
"""
Roll a specified number of dice and return the largest face
value.
Args:
num_dice (int): the number of dice to roll
die_num_sides (int): the number of sides on a
die (default: 6)
Returns (int): the largest face value rolled
"""
# initialize largest with a value smaller than the smallest
# possible roll.
largest = 0
for i in range(num_dice):
roll = random.randint(1, die_num_sides)
largest = max(roll, largest)
return largest
Using this implementation, the original uses of the function will work without change, while new uses can supply the number of sides for the die as needed.
To avoid writing code that is hard to understand and debug, beginning programmers should limit themselves to using global variables for constants. Even more experienced programmers should use them with great caution.
When a global variable and a local variable have the same name, the local variable shadows the global variable. That is, the local variable takes precedence. Here is a simple example of this behavior:
>>> c = 5
>>> def add_c(x, c):
... """ Add x and c """
... return x + c
...
...
>>> add_c(10, 20)
30
When the call to add_c
is made, a fresh local variable c
is
created with the initial value of 20
. This local shadows the
global c
and is the variable used in the computation.
Here is a related example that illustrates another aspect of shadowing:
>>> c = 5
>>> def add_10(x):
... """ Add 10 to x """
... c = 10
... return x + c
...
...
>>> add_10(10)
20
>>> c
5
When the call to add_10
reaches the assignment to c
it defines
a local variable named c
. It does not change the value of the
global variable of the same name; the global c
still has the value
5
after the call.
Technical Details
It is possible to override Python’s default behavior to update a
global variable within a function by declaring that it is
global
before is it used in the function. Here is a trivial
use of this feature:
>>> c = 5
>>> def update_c(new_c):
... """ Update the value of the global variable c """
...
... global c
... c = new_c
...
...
>>> c
5
>>> update_c(10)
>>> c
10
Notice that in this function, the value of the global variable
named c
has changed after the call to update_c
because
Python was informed that it should use the global c
before it
reached the assignment statement in the function update_c
. As
a result, it it does not create and set a fresh local variable.
Instead, it updates the global variable c
.
This mechanism should be used with great care as indiscriminate use of updates to global variables often yields code that is buggy and hard to understand.
1.4.8. The function call stack
Understanding what happens under the hood during a function call helps us understand scoping and many other nuances of functions. This section delves into some of the lower-level details of how functions work. You can safely skip for now it if you like, but you should revisit it once you become more comfortable with functions.
Programs, including Python programs, usually have a location in memory called the call stack (usually referred to as the stack) that keeps track of function calls that are in progress. When a program begins to run, the call stack is empty because no functions have been called. Now, let’s suppose we have these three simple functions:
def tax(p, rate):
"""
Compute the tax for a given price.
Args:
p (float): price
rate (float): tax rate
Returns (float): the computed tax
"""
t = p * rate
return t
def taxed_price(price, rate):
"""
Compute the price with tax.
Args:
price (float): price
rate (float): tax rate
Returns (float): price with tax
"""
price = price + tax(price, rate)
return price
def main():
"""
Compute and print the price 100 with tax.
Args: none
Returns: None
"""
p = 100
tp = taxed_price(p, 0.10)
print("The taxed price of", p, "is", tp)
taxed_price
takes a price (price
) and a tax rate (rate
) and
computes the price with tax. tax
takes the same parameters and
computes just the tax. The
main
function calls taxed_price
and prints information about
the price with and without taxes.
Now, let’s say we call the main
function:
main()
This call will add an entry to the call stack:
Function: Parameters: None Local Variables: None Return Value: None |
This entry, known as a stack frame, contains all of the information
about the function call. This diagram shows the state of the function
call at the moment it is called, so we do not yet have any local
variables, nor do we know what the return value will be. However, the
moment we run the statement p = 100
, the stack frame will be
modified:
Function: Parameters: None Local Variables:
Return Value: None |
Next, when we reach this line:
tp = taxed_price(p, 0.10)
A local variable, tp
, will be created, but its value will remain
undefined until taxed_price
returns a value. So, the frame for
main
on the call stack will now look like this:
Function: Parameters: None Local Variables:
Return Value: None |
Remember that, when we call a function, the code that is currently
running (in this case, the statement tp = taxed_price(p, 0.10)
)
is, in a sense, put on hold while the called function runs and returns
a value. Internally, an additional frame is added
to the call stack to reflect that a call to taxed_price
has been made.
By convention, we draw the new frame stacked below the existing frame:
Function: Parameters: None Local Variables:
Return Value: None |
Function: Parameters:
Variables: None Return Value: None |
Notice that the call stack retains information about main
. The
program needs to remember the state main
was in before the call to
taxed_price
(such as the value of its local variables) so that it
can return to that exact same state when taxed_price
returns.
Next, notice that the value of parameter price
is set to 100 and the value of the parameter rate
is set to 0.10. Why?
Because we called taxed_price
like this:
taxed_price(p, 0.10)
We can now see why passing a variable as a parameter to a function
doesn’t modify that variable. The function receives
the value of the variable, not the variable itself. This means that changes made inside the function
won’t change the variable itself. In this case, taxed_price
receives
the value of p
(100), but does not modify p
itself.
Now, in taxed_price
we will run the following statement:
price = price + tax(price, t)
Once again, we are calling a function. As a result, the execution of taxed_price
is paused while we run the tax
function, which adds another frame to the
call stack:
Function: Parameters: None Local Variables:
Return Value: None |
Function: Parameters:
Variables: None Return Value: None |
Function: Parameters:
Variables: None Return Value: None |
The order of the functions in the stack diagram is important:
notice that tax
appears under taxed_price
(or is stacked below taxed_price
)
and taxed_price
is below main
. This means that tax
was called from taxed_price
which,
in turn, was called from main
. In other words, the stack contains information
about not only each function call, but also the order
in which those calls were made.
Now, let’s get back to tax
. It has the following two statements:
t = p * rate
return t
The first statement creates a new local variable t
, and the second specifies
the function’s return value and terminates the function. So, after
return t
is run, the most recent frame of the call stack will look like this:
Function: Parameters:
Variables:
Return Value: 10 |
Once the calling function, taxed_price
, has retrieved the return value,
this frame will be removed from the
stack. After tax
returns,
and the price = price + tax(price, t)
statement in taxed_price
is run, the stack will look like this:
Function: Parameters: None Local Variables:
Return Value: None |
Function: Parameters:
Variables:
Return Value: None |
All of the parameters and local variables in a function’s
scope disappear as soon as the function returns. As we see above,
the frame for tax
is gone, along with all of the information associated with it,
including its local variables. In addition, calling tax
again
will create a fresh frame for the call stack: the values of the
parameters and local variables from previous calls will not carry over into new calls.
Similarly, once we execute the statement return price
in the function taxed_price
, its return value will be set to 110
,
and Python will plug that return value into the statement tp = taxed_price(p, 0.10)
in
main
(which effectively becomes tp = 110
). At this point, the call stack will look like this:
Function: Parameters: None Local Variables:
Return value: None |
The main
function will then call print
, which create a frame for
print
stacked below main
’s frame. After
print
returns, main
itself doesn’t return anything explicitly, which means the
return value of main
will default to None
:
>>> rv = main()
The taxed price of 100 is 110.0
>>> print(rv)
None
Although all of the above may seem like a lot of under-the-hood details, there are a few important takeaways:
Every time we call a function, the values of all of its parameters and local variables are stored in a freshly-created stack frame and only exist while the function is running. We cannot access variables or parameters from any other stack entry unless they were passed as parameters to the current function and, even then, we will only get their values.
When a function returns, the values of its parameters and variables are discarded and they do not persist into future function calls. For example, if we called
tax
again, it would not “remember” that a previous call already set a value for its local variablet
.
1.4.9. Practice Problem Solutions
Problem 1:
Here is a solution that assumes that the functions is_prime
and print_categories
are in the same file.
def print_categories(lb, ub):
"""
Print the primes between lb and ub inclusive.
Args:
lb (int): the lower bound of the range
ub (int): the lower bound of the range
Returns: None
"""
for n in range(lb, ub + 1):
if is_prime(n):
print(n, "prime")
else:
print(n, "composite")
And here is one that assumes that is_prime
and
print_categories
are in different files and that is_prime
is
in a file named primes.py
.
import primes
def print_categories(lb, ub):
"""
Print the primes between lb and ub inclusive.
Args:
lb (int): the lower bound of the range
ub (int): the lower bound of the range
Returns: None
"""
for n in range(lb, ub + 1):
if primes.is_prime(n):
print(n, "prime")
else:
print(n, "composite")
Problem 2
Here is a solution that uses a single return statement:
def compute_grade(midterm, final, hw_avg):
"""
Given a student's midterm and final exam grades and their average
homework score, compute the student's grade.
Inputs:
midterm (float): a score between 0-100
final (float): a score between 0-100
hw_avg (float): a score between 0-100
Returns (str): the student's grade
"""
# Verify that the parameters have sensible values
assert 0.0 <= midterm <= 100.0
assert 0.0 <= final <= 100.0
assert 0.0 <= hw_avg <= 100.0
if final >= 90 and midterm >= 90 and hw_avg >= 90:
grade = 'A'
elif final >= 80 and midterm >= 80 and hw_avg >= 80:
grade = 'B'
elif final >= 70 and midterm >= 70 and hw_avg >= 70:
grade = 'C'
elif (final >= 50 and midterm >= 50 and
final >= (midterm+20) and hw_avg >= 90):
grade = 'B'
else:
grade = 'F'
return grade
And here is a solution that uses multiple return statements:
def compute_grade(midterm, final, hw_avg):
"""
Given a student's midterm and final exam grades and their average
homework score, compute the student's grade.
Inputs:
midterm (float): a score between 0-100
final (float): a score between 0-100
hw_avg (float): a score between 0-100
Returns (str): the student's grade
"""
# Verify that the parameters have sensible values
assert 0.0 <= midterm <= 100.0
assert 0.0 <= final <= 100.0
assert 0.0 <= hw_avg <= 100.0
if final >= 90 and midterm >= 90 and hw_avg >= 90:
return 'A'
if final >= 80 and midterm >= 80 and hw_avg >= 80:
return 'B'
if final >= 70 and midterm >= 70 and hw_avg >= 70:
return 'C'
if (final >= 50 and midterm >= 50 and
final >= (midterm+20) and hw_avg >= 90):
return 'B'
return 'F'
Which do you find easier to understand?
Here is some basic test code for this function:
def test_compute_grade():
""" Simple test code for compute grade """
assert compute_grade(90.0, 90.0, 92.0) == "A"
assert compute_grade(80.0, 85.5, 80.0) == "B"
assert compute_grade(70.0, 70.0, 73.5) == "C"
assert compute_grade(50.0, 70.0, 90.0) == "B"
assert compute_grade(70.0, 50.0, 90.0) == "F"
assert compute_grade(50.0, 70.0, 85.0) == "F"
This test function must either be in the same file as
compute_grade
or the that contains the test code must include an
import statement that imports the function by name. For example, if
compute_grade
is in a file call instructor.py
, the inport
statement would have the form:
from instructor import compute_grade
Problem 3:
>>> def fun1(i):
... i = i - 2
... return i
...
...
>>> def fun2(i):
... return fun1(i) + fun1(i)
...
...
>>> def fun3(i):
... return fun1(i * 2)
...
...
>>> def fun4(i):
... i = fun3(i)
... return fun2(i)
...
...
>>> print(f"fun4(6): {fun4(6)}")
fun4(6): 16
Problem 4
>>> def fun1(x, y, z):
... if x % y == z:
... return x + y + z
... else:
... return 1
...
...
>>> def fun2(i,j):
... i = i + 2
... j = j + 3
...
...
>>> def fun3(x, y, z=2):
... for i in range(4, x):
... for j in range(2, y):
... a = fun1(i, j, z)
... if a >= 10:
... fun2(i,j)
... return i + j
... return -1
...
...
>>> print(f"fun3(6, 4, 2): {fun3(6, 4, 2)}")
fun3(6, 4, 2): 8
Problem 5
Here is flip_coin
augmented to take the seed for the random number
generator as an optional parameter:
>>> def flip_coins(n, prob_heads=0.5, seed=None):
... """
... Flip a weighted coin n times and report the number that come up
... heads.
...
... Args:
... n (int): number of times to flip the coin
... prob_heads (float): probability that the coin comes up heads
... (default: 0.5)
... seed (int | None): the seed for the random number generator
... (default: None)
...
...
... Returns (int): number of flips that came up heads.
... """
...
... random.seed(seed)
...
... num_heads = 0
...
... for i in range(n):
... flip = random.uniform(0.0, 1.0)
... if flip < prob_heads:
... num_heads = num_heads + 1
...
... return num_heads
...
Here are some sample uses of this function:
>>> flip_coins(5)
3
>>> flip_coins(5, prob_heads=0.8)
2
>>> flip_coins(5, seed=5000)
2
>>> flip_coins(5, prob_heads=0.8, seed=5000)
4
>>> flip_coins(5, 0.8, 5000)
4
Depending on which version of the Python random
library you are
running, you may get different results.
Problem 6
Here is the implement of flip_coin
from Problem 5 augmented to take the debug level as parameter:
def flip_coins(n, prob_heads=0.5, seed=None, debug_level=0):
"""
Flip a weighted coin n times and report the number that come up
heads.
Args:
n (int): number of times to flip the coin
prob_heads (float): probability that the coin comes up heads
(default: 0.5)
seed (int | None): the seed for the random number generator
(default: None)
debug_level (int): controls the amount of information printed
about the computation
Returns (int): number of flips that came up heads.
"""
assert 0 <= debug_level <= 2
if debug_level >= 1:
msg = (f"Debug {debug_level}: " +
f"flip_coins(n={n}, prob_heads{prob_heads}, " +
f"seed={seed}, debug_level={debug_level})")
print(msg)
random.seed(seed)
num_heads = 0
for i in range(n):
flip = random.uniform(0.0, 1.0)
if flip < prob_heads:
num_heads = num_heads + 1
if debug_level == 2:
msg = (f"Debug {debug_level}: flip: {flip}\t" +
f"num_heads: {num_heads}")
print(msg)
return num_heads
The escape sequence \t
, used in the coin flip print statement, corresponds to a tab character.