Functions#

What you will learn in this lesson:

  • Defining and calling functions

  • Parameters and arguments

  • Return values

  • Scope and lifetime of variables

Introduction#

Functions take input, perform a specific task and, optionally, produce an output. They contain a block of code to do their work.

Functions can return a single value, multiple values, or even no value at.

Why do we use functions?

  • Code economy

With functions, you can keep your code short and concise. Once a function is defined, it can be used as many times as needed, which is great to not need to write the same code over and over. In addition, functions help your code be more readable. For example, if you give a function a well-chosen name, anyone could read your code, and already infer what it does.

Other forms of code economy is through modules and packages, which is you a way of grouping your code (e.g. functions).

  • Parametrization

Functions accept parameters. Therefore, one can study different function’s behaviors by changing the different parameters.

  • Production

We can use functions and the fact that they return one or multiple values for production.

Built-in functions#

Python provides many built-in functions. You can find a complete list here: Python built-in functions

We have already seen examples of some built-in functions, such asprint(), id(), isinstance(), enumerate() and zip().

Another example is bool(), which takes an argument (e.g. a variable) and returns True or False

# set a variable and pass into a conditional statement

x = 3
bool(x < 4)
True
bool(x >= 4)
False

Or the function help(), which provides a help information of any passed object:

help(bool)
Help on class bool in module builtins:

class bool(int)
 |  bool(x) -> bool
 |  
 |  Returns True when the argument x is true, False otherwise.
 |  The builtins True and False are the only two instances of the class bool.
 |  The class bool is a subclass of the class int, and cannot be subclassed.
 |  
 |  Method resolution order:
 |      bool
 |      int
 |      object
 |  
 |  Methods defined here:
 |  
 |  __and__(self, value, /)
 |      Return self&value.
 |  
 |  __or__(self, value, /)
 |      Return self|value.
 |  
 |  __rand__(self, value, /)
 |      Return value&self.
 |  
 |  __repr__(self, /)
 |      Return repr(self).
 |  
 |  __ror__(self, value, /)
 |      Return value|self.
 |  
 |  __rxor__(self, value, /)
 |      Return value^self.
 |  
 |  __xor__(self, value, /)
 |      Return self^value.
 |  
 |  ----------------------------------------------------------------------
 |  Static methods defined here:
 |  
 |  __new__(*args, **kwargs)
 |      Create and return a new object.  See help(type) for accurate signature.
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from int:
 |  
 |  __abs__(self, /)
 |      abs(self)
 |  
 |  __add__(self, value, /)
 |      Return self+value.
 |  
 |  __bool__(self, /)
 |      True if self else False
 |  
 |  __ceil__(...)
 |      Ceiling of an Integral returns itself.
 |  
 |  __divmod__(self, value, /)
 |      Return divmod(self, value).
 |  
 |  __eq__(self, value, /)
 |      Return self==value.
 |  
 |  __float__(self, /)
 |      float(self)
 |  
 |  __floor__(...)
 |      Flooring an Integral returns itself.
 |  
 |  __floordiv__(self, value, /)
 |      Return self//value.
 |  
 |  __format__(self, format_spec, /)
 |      Default object formatter.
 |  
 |  __ge__(self, value, /)
 |      Return self>=value.
 |  
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |  
 |  __getnewargs__(self, /)
 |  
 |  __gt__(self, value, /)
 |      Return self>value.
 |  
 |  __hash__(self, /)
 |      Return hash(self).
 |  
 |  __index__(self, /)
 |      Return self converted to an integer, if self is suitable for use as an index into a list.
 |  
 |  __int__(self, /)
 |      int(self)
 |  
 |  __invert__(self, /)
 |      ~self
 |  
 |  __le__(self, value, /)
 |      Return self<=value.
 |  
 |  __lshift__(self, value, /)
 |      Return self<<value.
 |  
 |  __lt__(self, value, /)
 |      Return self<value.
 |  
 |  __mod__(self, value, /)
 |      Return self%value.
 |  
 |  __mul__(self, value, /)
 |      Return self*value.
 |  
 |  __ne__(self, value, /)
 |      Return self!=value.
 |  
 |  __neg__(self, /)
 |      -self
 |  
 |  __pos__(self, /)
 |      +self
 |  
 |  __pow__(self, value, mod=None, /)
 |      Return pow(self, value, mod).
 |  
 |  __radd__(self, value, /)
 |      Return value+self.
 |  
 |  __rdivmod__(self, value, /)
 |      Return divmod(value, self).
 |  
 |  __rfloordiv__(self, value, /)
 |      Return value//self.
 |  
 |  __rlshift__(self, value, /)
 |      Return value<<self.
 |  
 |  __rmod__(self, value, /)
 |      Return value%self.
 |  
 |  __rmul__(self, value, /)
 |      Return value*self.
 |  
 |  __round__(...)
 |      Rounding an Integral returns itself.
 |      
 |      Rounding with an ndigits argument also returns an integer.
 |  
 |  __rpow__(self, value, mod=None, /)
 |      Return pow(value, self, mod).
 |  
 |  __rrshift__(self, value, /)
 |      Return value>>self.
 |  
 |  __rshift__(self, value, /)
 |      Return self>>value.
 |  
 |  __rsub__(self, value, /)
 |      Return value-self.
 |  
 |  __rtruediv__(self, value, /)
 |      Return value/self.
 |  
 |  __sizeof__(self, /)
 |      Returns size in memory, in bytes.
 |  
 |  __sub__(self, value, /)
 |      Return self-value.
 |  
 |  __truediv__(self, value, /)
 |      Return self/value.
 |  
 |  __trunc__(...)
 |      Truncating an Integral returns itself.
 |  
 |  as_integer_ratio(self, /)
 |      Return integer ratio.
 |      
 |      Return a pair of integers, whose ratio is exactly equal to the original int
 |      and with a positive denominator.
 |      
 |      >>> (10).as_integer_ratio()
 |      (10, 1)
 |      >>> (-10).as_integer_ratio()
 |      (-10, 1)
 |      >>> (0).as_integer_ratio()
 |      (0, 1)
 |  
 |  bit_count(self, /)
 |      Number of ones in the binary representation of the absolute value of self.
 |      
 |      Also known as the population count.
 |      
 |      >>> bin(13)
 |      '0b1101'
 |      >>> (13).bit_count()
 |      3
 |  
 |  bit_length(self, /)
 |      Number of bits necessary to represent self in binary.
 |      
 |      >>> bin(37)
 |      '0b100101'
 |      >>> (37).bit_length()
 |      6
 |  
 |  conjugate(...)
 |      Returns self, the complex conjugate of any int.
 |  
 |  to_bytes(self, /, length=1, byteorder='big', *, signed=False)
 |      Return an array of bytes representing an integer.
 |      
 |      length
 |        Length of bytes object to use.  An OverflowError is raised if the
 |        integer is not representable with the given number of bytes.  Default
 |        is length 1.
 |      byteorder
 |        The byte order used to represent the integer.  If byteorder is 'big',
 |        the most significant byte is at the beginning of the byte array.  If
 |        byteorder is 'little', the most significant byte is at the end of the
 |        byte array.  To request the native byte order of the host system, use
 |        `sys.byteorder' as the byte order value.  Default is to use 'big'.
 |      signed
 |        Determines whether two's complement is used to represent the integer.
 |        If signed is False and a negative integer is given, an OverflowError
 |        is raised.
 |  
 |  ----------------------------------------------------------------------
 |  Class methods inherited from int:
 |  
 |  from_bytes(bytes, byteorder='big', *, signed=False)
 |      Return the integer represented by the given array of bytes.
 |      
 |      bytes
 |        Holds the array of bytes to convert.  The argument must either
 |        support the buffer protocol or be an iterable object producing bytes.
 |        Bytes and bytearray are examples of built-in objects that support the
 |        buffer protocol.
 |      byteorder
 |        The byte order used to represent the integer.  If byteorder is 'big',
 |        the most significant byte is at the beginning of the byte array.  If
 |        byteorder is 'little', the most significant byte is at the end of the
 |        byte array.  To request the native byte order of the host system, use
 |        `sys.byteorder' as the byte order value.  Default is to use 'big'.
 |      signed
 |        Indicates whether two's complement is used to represent the integer.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from int:
 |  
 |  denominator
 |      the denominator of a rational number in lowest terms
 |  
 |  imag
 |      the imaginary part of a complex number
 |  
 |  numerator
 |      the numerator of a rational number in lowest terms
 |  
 |  real
 |      the real part of a complex number

Creating and calling a function#

Let’s show how to create and call functions with an example. In this case, the function compares a list of values against a threshold.

def vals_greater_than_or_equal_to_threshold(vals, thresh):
    '''
    PURPOSE: Given a list of values, compare each value against a threshold

    INPUTS
    vals    list of ints or floats
    thresh  int or float

    OUTPUT
    bools  list of booleans
    '''

    bools = [val >= thresh for val in vals] # List comprehension
    return bools

This function exhibits the common components of a function. Let’s break them down in detail:

  1. The function definition starts with def, followed by name, one or more parameters in parenthesis, and then a colon.
  2. Then it comes the body of the function. This should be indented, so Python knows that the following piece of code belongs to the function. The body of a function usually consists of:
    1. First, a docstring to provide annotation (optional).
    2. Second, the bulk of function.
    3. Lastly, a return statement (optional).

After its definition, we can call the function to use it. To do so, we simply write the function name followed by the required arguments in parentheses.

Let’s test our function:

# validate that it works for ints

x = [3, 4]
thr = 4

vals_greater_than_or_equal_to_threshold(x, thr)
[False, True]
# validate that it works for floats

x = [3.0, 4.2]
thr = 4.2

vals_greater_than_or_equal_to_threshold(x, thr)
[False, True]

This gives correct results and does exactly what we want.

Arguments and parameters#

Parameters and arguments are both inputs of the functions, but is there any difference between them?

In theory, parameters are the variables listed inside the parentheses in the function definition, whereas arguments are the actual values that you pass into the function when calling it.

For example, in vals_greater_than_or_equal_to_threshold, “vals” and “thresh” would be the parameters, while [3.0, 4.2] and 4.2, as shown in one of the examples above, are the arguments.

At the end of the day, people often use both terms interchangeably, so don’t worry too much about this technicality :)

Guidelines when passing arguments:#

  1. Functions need to be called with the correct number of parameters.

## function requiring 2 parameters
def fcn_bad_args(x, y):
    return x+y

This function requires two parameters, so calling the function in the following way will give an error:

# function call with only 1 of the 2 arguments
fcn_bad_args(10)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 2
      1 # function call with only 1 of the 2 arguments
----> 2 fcn_bad_args(10)

TypeError: fcn_bad_args() missing 1 required positional argument: 'y'
  1. When calling a function, the order of arguments matters. These types of arguments are called positional arguments.

x = 1
y = 2

# function with order of x then y
def fcn_swapped_args(x, y):
    out = 5 * x + y
    return out

# call function in correct order
print('fcn_swapped_args(x,y) =', fcn_swapped_args(x,y))

# call function in incorrect order
print('fcn_swapped_args(y,x) =', fcn_swapped_args(y,x))
fcn_swapped_args(x,y) = 7
fcn_swapped_args(y,x) = 11

Generally it’s best to keep parameters in order.

Important: The name of the parameters in a function do not mean anything. In other words, just because a function names an argument `x`, the variables passed to it don't have to name `x` or anything like it. They can even be named the same thing -- it does not matter.
foo = 1
bar = 2

fcn_swapped_args(foo, bar)

# works even though function was written as fcn_swapped_arg(x, y)
7
  1. You can pass arguments in any order if you use keyword arguments. In this case, you specify the parameter name followed by the value it should take.

x1 = 1
y1 = 2

# call parameter names in function call.
fcn_swapped_args(y=y1, x=x1)
7

4.You can combine positional and keyword arguments, but positional arguments must come before keyword arguments in the function call.

# All positional arguments
print(fcn_swapped_args(1, 2))
# All keyword  arguments
print(fcn_swapped_args(y=2, x=1))
# One positional and one keyword  argument
print(fcn_swapped_args(1, y=2))
7
7
7
# This will fail, because keywork argument precedes a positional argument
print(fcn_swapped_args(x=1, 2))
  Cell In[13], line 2
    print(fcn_swapped_args(x=1, 2))
                                 ^
SyntaxError: positional argument follows keyword argument
  1. (Advanced) You can use an asterisk (*) before certain parameters to force them to be passed as keyword-only arguments.

# The same function as above, but forcing y to be passed as keyword argument
def fcn_force_keywords(x, *, y):
    out = 5 * x + y
    return out
# This will fail
print(fcn_force_keywords(1, 2))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[15], line 2
      1 # This will fail
----> 2 print(fcn_force_keywords(1, 2))

TypeError: fcn_force_keywords() takes 1 positional argument but 2 were given
# We need to pass y as a keyword
print(fcn_force_keywords(1, y=2))
7
# x can be passed also as keyword, but it is optional
print(fcn_force_keywords(x=1, y=2))
7

Default Arguments#

Default arguments set the value when an argument is unspecified.

def show_results(precision, printing=True):
    precision = round(precision, 2)

    if printing:
      print('precision =', precision)
    return precision
pr = 0.912
# The function call didn't specify `printing`, so it defaulted to True.
res = show_results(pr)
precision = 0.91
# We can specify it to False here

res = show_results(pr, False)

Default arguments must follow non-default arguments. This causes trouble:

def show_results(precision, printing=True, uhoh):
    precision = round(precision, 2)

    if printing:
      print('precision =', precision)
    return precision
  Cell In[21], line 1
    def show_results(precision, printing=True, uhoh):
                                               ^
SyntaxError: non-default argument follows default argument

Packing and Unpacking arguments#

The * operator (for tuples) and ** operator (for dictionaries) can be used to pack and unpack arguments when calling a function.

Packing#

Packing allows us to pass an arbitrary number of values to a function.

For example, with tuples:

def show_arg_expansion(*models):

    print("models          :", models)
    print("input arg type  :",  type(models))
    print("input arg length:", len(models))
    print("-----------------------------")

    for mod in models:
        print(mod)

Now you can pass a tuple of an arbitrary number of values to the function…

show_arg_expansion("logreg", "naive_bayes")
models          : ('logreg', 'naive_bayes')
input arg type  : <class 'tuple'>
input arg length: 2
-----------------------------
logreg
naive_bayes
show_arg_expansion("logreg", "naive_bayes", "gbm")
models          : ('logreg', 'naive_bayes', 'gbm')
input arg type  : <class 'tuple'>
input arg length: 3
-----------------------------
logreg
naive_bayes
gbm

Example with print function, which accepts any arbitrary number of arguments to print.

help(print)
Help on built-in function print in module builtins:

print(*args, sep=' ', end='\n', file=None, flush=False)
    Prints the values to a stream, or to sys.stdout by default.
    
    sep
      string inserted between values, default a space.
    end
      string appended after the last value, default a newline.
    file
      a file-like object (stream); defaults to the current sys.stdout.
    flush
      whether to forcibly flush the stream.
print("Hello class.", "This is your instructor.", "We are in DS", 1002)
Hello class. This is your instructor. We are in DS 1002

With dictionary, you should use **

def show_entries(**entries):

    # kwargs is a dict
    print(type(entries))

    # Printing dictionary items
    for key in entries:
        print("%s = %s" % (key, entries[key]))

# Somebody's info:
show_entries(name="Javier", ID="1234", language="Python")
<class 'dict'>
name = Javier
ID = 1234
language = Python
# Again, we can pass an arbitrary number of entries
show_entries(name="Javier", ID="1234", language="Python", team = "Athletic Club")
<class 'dict'>
name = Javier
ID = 1234
language = Python
team = Athletic Club

Unpacking#

You can use the * operator to unpack list-like objects when passing them to a function that specifies its arguments. In this case, the arguments are passed as positional arguments.

def arg_expansion_example(x, y):
    return x**y

my_args = [2, 8]
arg_expansion_example(*my_args)
256

But, the passed object must be the right length.

my_args2 = [2, 8, 5]
arg_expansion_example(*my_args2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[30], line 2
      1 my_args2 = [2, 8, 5]
----> 2 arg_expansion_example(*my_args2)

TypeError: arg_expansion_example() takes 2 positional arguments but 3 were given

And since these are positional arguments, be aware of the order!

my_args = [2, 8]
print(arg_expansion_example(*my_args))

my_args = [8, 2]
print(arg_expansion_example(*my_args))
256
64

You can also use the ** operator to unpack a dictionary of arguments. In this case, the arguments are passed as keyword arguments.

my_args_dict = {"x": 2, "y": 8}
arg_expansion_example(**my_args_dict)
256

Returning Values#

  • Functions are not required to have return statement. If there is no return statement, function returns None object.

  • Functions can return no value (None object), one value, or many.

  • Any Python object can be returned.

Functions may have no return statement

# returns None, and prints.
def fcn_nothing_to_return(x, y):
    out = 'nothing to see here!'
    print(out)
r = fcn_nothing_to_return(1, 1)
print(r)
nothing to see here!
None

For clarity purposes, it’s generally a good idea to include return statements, even if not returning a value. You can use return or return None.

# returns None, and prints.
def fcn_nothing_to_return(x, y):
    out = 'nothing to see here!'
    print(out)
    return None
r = fcn_nothing_to_return(1, 1)
print(r)
nothing to see here!
None

Functions may return more than one output

# returns three values
def negate_coords(x, y, z):
    return -x, -y, -z
a,b,c = negate_coords(10,20,30)
print('a=', a)
print('b=', b)
print('c=', c)
a= -10
b= -20
c= -30
d,e,_ = negate_coords(10,20,30)
print('d=', d)
print('e=', e)
d= -10
e= -20

Functions can contain multiple return statements

# For non-negative values, the first `return` is reached.  
# For negative values, the second `return` is reached.
def absolute_value(num):
    if num >= 0:
        return num
    return -num
absolute_value(-4)
4
absolute_value(4)
4

Variable Scope#

A variable’s scope refers to the part of a program where it is visible.

In this context, visible means available or usable.

If a variable is in scope within a function, it is visible to the function.

If it is out of scope for a function, it is not visible to the function.

When a variable is defined inside a function, it is not visible outside of that function.

We say such variables are local to the function.

These variables are also removed from memory when the function completes.

def show_scope(x):
    x = 10*x
    z = 4
    print('z inside function =', z)
    print('memory address of z inside function =', hex(id(z)))
    return x
# This code recognizes z from inside the function.
show_scope(6)
z inside function = 4
memory address of z inside function = 0x87f448
60
# Calling it from outside, where it isn't defined, throws an error.
print('z =', z)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[45], line 2
      1 # Calling it from outside, where it isn't defined, throws an error.
----> 2 print('z =', z)

NameError: name 'z' is not defined

If we define z and call the function, the update to z won’t pass outside the function.

z = 2
print('z outside:', hex(id(z)))
out = show_scope(6)
print('z = ', z)
z outside: 0x87f408
z inside function = 4
memory address of z inside function = 0x87f448
z =  2

Local versus Global Variables#

It’s important to have a good understanding of local versus global variables; otherwise, you may encounter unexpected behavior and confusion. Let’s explore this with several examples.

Example 1: Variable defined outside function, used inside function

In the code below:

x is global and seen from inside the function.
r is local to the function.

x = 10

def fcn(r):
    out = x + r
    return(out)
print(fcn(6)) # works
16
print(r)
None

Example 2: Variable defined outside function, updated and used inside function

fcn uses the local version of x

x = 10

def fcn(a):
    x = 20
    suma = x + a
    print(f"x from fcn: {x}")
    return(suma)

print(f"fcn(6): {fcn(6)}")
print("x outside fcn: {}".format(x))
x from fcn: 20
fcn(6): 26
x outside fcn: 10

Example 3: Variable defined outside function. Inside function, print variable, update, and use

This one may be confusing. It fails!

Python treats x inside function as the local x.
The print occurs before x is assigned, so it can’t find x.

x = 10

def fcn(a):
    print('x from fcn, before update:', x)
    x = 20
    out = x + a
    print('x from fcn, after update:', x)
    return(out)

print('fcn(6):', fcn(6))
print('x outside fcn:', x)
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[53], line 10
      7     print('x from fcn, after update:', x)
      8     return(out)
---> 10 print('fcn(6):', fcn(6))
     11 print('x:', x)

Cell In[53], line 4, in fcn(a)
      3 def fcn(a):
----> 4     print('x from fcn, before update:', x)
      5     x = 20
      6     out = x + a

UnboundLocalError: cannot access local variable 'x' where it is not associated with a value
x = 10

def fcn(a):
    global x    # add this to reference global x outside function
    print('x from fcn, before update:', x)
    x = 20
    out = x + a
    print('x from fcn, after update:', x)
    return(out)

print('fcn(6):', fcn(6))
print('x outside fcn:', x)
x from fcn, before update: 10
x from fcn, after update: 20
fcn(6): 26
x outside fcn: 20

Docstring#

  • A docstring is a string that occurs as first statement in module, function, class, or method definition

  • Saved in __doc__ attribute

  • Needs to be indented

  • '''enclosed in triple quotes like this'''

  • We gave functions a descriptive docstring to: (1) explain its purpose, and (2) name each input and output, and give their data types

Let’s look at an example from the beginning of this lesson to see how this works:

def vals_greater_than_or_equal_to_threshold(vals, thresh):
    '''
    PURPOSE: Given a list of values, compare each value against a threshold

    INPUTS
    vals    list of ints or floats
    thresh  int or float

    OUTPUT
    bools  list of booleans
    '''

    bools = [val >= thresh for val in vals] # List comprehension
    return bools
# Here is the documentation
vals_greater_than_or_equal_to_threshold.__doc__
'\n    PURPOSE: Given a list of values, compare each value against a threshold\n\n    INPUTS\n    vals    list of ints or floats\n    thresh  int or float\n\n    OUTPUT\n    bools  list of booleans\n    '

To print this info in a readable way, use print

print(vals_greater_than_or_equal_to_threshold.__doc__)
    PURPOSE: Given a list of values, compare each value against a threshold

    INPUTS
    vals    list of ints or floats
    thresh  int or float

    OUTPUT
    bools  list of booleans
    

help function can also give you this info and more:

help(vals_greater_than_or_equal_to_threshold)
Help on function vals_greater_than_or_equal_to_threshold in module __main__:

vals_greater_than_or_equal_to_threshold(vals, thresh)
    PURPOSE: Given a list of values, compare each value against a threshold
    
    INPUTS
    vals    list of ints or floats
    thresh  int or float
    
    OUTPUT
    bools  list of booleans

Tips for creating good functions#

  • Design a function to do one thing.

  • Keep your function as simple as possible. This makes it more understandable, easier to maintain, and reusable.

  • Give your function a meaningful name. What makes a good function name?

  • It should clearly describe the action it performs.
  • Be consistent with naming conventions.
  • For example, a name like `compute_variances_sort_save_print` suggests the function is doing too much!
  • If a function like `compute_variances` also generates plots and updates variables, it can cause confusion.

- Always aim to provide your function with a docstring.

Practice exercises#

Exercise 21

1.1 Write a function with these requirements:

  • It should have a sensible name.

  • It should contain a docstring.

  • It should take two inputs: a string, and an integer.

  • It should return True if the string length is equal to the integer, otherwise False.

1.2 Call the function, passing inputs:

    - "is this text the right length?" for the string.
    - 30 for the integer.
    
Verify the output is True. 
Test other combinations.
# Start here your answers

Exercise 22

Write a function with these requirements:

  • It should take *args for the input argument, so we can later pass as many arguments as we want.

  • It should squares each argument, printing the value. You can use a for loop for this.

  • It should not return any value

Next, call the function, passing at least two integers. Try other combinations too.

# Start here your answers