Python 3

home

User-Defined Functions

Review: user-defined functions

User-defined functions help us organize our code -- and our thinking.


Introduction The core tool for organizing code is the function, a named code block that allows us to group a series of statements together and execute them at any time in our programs. Up to now we've written relatively short programs, but longer programs must make use of functions to organize code into steps. Code built with functions is easier to read, understand, maintain and extend. Functions help us minimize repetition in our code -- instead of writing the same thing twice in our code, we can write it once and call it twice. Functions also do the powerful service of allowing us to "think in functions", making it easier to conceptualize what a program does and consider it in small blocks rather than as a whole. This inherently reduces bugs and also makes it easier to debug problems when they arise. Goals





Review: 'def' Statement, arguments and return value

A user-defined function is simply a named code block that can be called and executed any number of times.


def print_hello():
    print("Hello, World!")

print_hello()             # prints 'Hello, World!'
print_hello()             # prints 'Hello, World!'
print_hello()             # prints 'Hello, World!'

Function argument(s)


A function's arguments are renamed in the function definition, and the function refers to them by these names.

def print_hello(greeting, person):
    full_greeting = greeting + ", " + person + "!"
    print(full_greeting)

print_hello('Hello', 'World')         # prints 'Hello, World!'
print_hello('Bonjour', 'Python')      # prints 'Bonjour, Python!'
print_hello('squawk', 'parrot')       # prints 'squawk, parrot!'

(The argument objects are copied to the argument names -- they are the same objects.) Function return value


A function's return value is passed back from the function with the return statement.

def print_hello(greeting, person):
    full_greeting = greeting + ", " + person + "!"
    return full_greeting

msg = print_hello('Bonjour', 'parrot')
print(msg)                                       # 'Bonjour, parrot!'




Review: multi-target assignment

This convenience allows us to assign values in a list to individual variable names.


line = 'Acme:23.9:NY'

items = line.split(':')
print(items)                  # ['Acme', '23.9', 'NY']

name, revenue, state = items

print(revenue)                # 23.9

This kind of assignment is sometimes called "unpacking" because it assigns a container of items to individual variables.


We can assign multiple values to multiple variables in one statement too:

name, revenue, state = 'Acme', '23.9', 'NY'

(The truth is that the 3 values on the right comprise a tuple even without the paretheses, so we are technically still unpacking.)


If the number of items on the right does not match the number of variables on the left, an error occurs:

mylist = ['a', 'b', 'c', 'd']

x, y, z = mylist         # ValueError:  too many values to unpack

v, w, x, y, z = mylist   # ValueError:  not enough values to unpack

We also see multi-target assignment when returning multiple values from a function:

def return_some_values():

    return 10, 20, 30, 40


a, b, c, d = return_some_values()

print(a)          # 10
print(d)          # 40

This means that like with standard unpacking, the number of variables to the right of a function call must match the number of values returned from the function call.





Review: positional and keyword arguments

Your choice of type depends on whether they are required.


positional: args are required and in particular order

def sayname(firstname, lastname):
    print(f"Your name is {firstname} {lastname}")

sayname('Joe', 'Wilson')         # passed two arguments:  correct

sayname('Joe')    # TypeError: sayname() missing 1 required positional argument: 'lastname'

keyword: args are not required, can be in any order, and the function specifies a default value

def sayname(lastname, firstname="Citizen"):
    print(f"Your name is {firstname} {lastname}")


sayname('Wilson', firstname='Joe')       # Your name is Joe Wilson

sayname('Wilson')                        # Your name is Citizen Wilson




Review: variable name scoping inside functions

Variable names initialized inside a function are local to the function, and not available outside the function.


def myfunc():
    a = 10
    return a

var = myfunc()            # var is now 10
print(a)                  # NameError ('a' does not exist here)

Note that although the object associated with a is returned and assigned to var, the name a is not available outside the function. Scoping is based on names.


global variables (i.e., ones defined outside a function) are available both inside and outside functions:

var = 'hello global'

def myfunc():
    print(var)

myfunc()                  # hello global




"Pure" functions

Functions that do not touch outside variables, and do not create "side effects" (for example, calling exit(), print() or input()), are considered "pure" -- and are preferred.


We may not always follow these practices, but we should in most cases. In order to reinforce them, we will follow these "best practices" going forward. "Pure" functions have the following characteristics:


You may notice that these "impure" practices do not cause errors. So why should we avoid them?


The above rationales will become clearer as you write longer programs and are confronted with more complex errors that are sometimes difficult to trace. Over time you'll realize that the best practice of using pure functions enhances the "quality" of your code -- making it easier to write, maintain, extend and understand the programs you create. Please note that during development it is allowable to stray from these best practices. You may, for example, wish to use print() statements for debugging purposes. Or you may want to insert a premature exit() during development. In completed programs and assignments, however, these practices are required.





"Pure" functions: working only with "inside" variables

"outside" variables are ones defined outside the function -- they should be avoided


wrong way: referring to an outside variable inside a function

val = '5'                   # defined outside any function

def doubleit():
    dval = int(val) * 2     # BAD:  refers to "outside" variable 'val'
    return dval

new_val = doubleit()

right way: passing outside variables as arguments

val = '5'                   # defined outside any function

def doubleit(arg):
    dval = int(arg) * 2     # GOOD:  refers to same value as 'val',
    return dval             #        but accessed through argument 'arg'

new_val = doubleit(val)     # passing variable to function -
                            #   correct way to get a value in




"Pure" functions: print() only outside functions

print() should not be called inside a function


wrong way: printing from inside a function

val = 5

def doubleit(arg):
    dval = arg * 2
    print(dval)             # BAD:  print() called inside function

doubleit(val)

right way: returning a value from a function and printing it outside

val = 5

def doubleit(arg):
    dval = arg * 2
    return dval             # GOOD:  returning value to be printed
                            #        outside the function

new_val = doubleit(val)
print(new_val)




"Pure" functions: input() only outside functions

input() should not be called inside a function


wrong way: taking input from inside a function

def doubleit():
    val = input('what is your value? ')  # BAD:  input() called inside function
    dval = int(val) * 2
    return dval

new_val = doubleit()
print(new_val)

right way: taking input outside and passing it as argument

def doubleit(arg):
    dval = int(arg) * 2
    return dval

val = input('what is your value? ')      # GOOD:  input() called outside function
new_val = doubleit(val)




"Pure" functions: using 'raise' instead of exit() inside functions

exit() should not be called inside a function


wrong way: calling exit() from inside a function

def doubleit(arg):
    if not arg.isdigit():
        exit('input must be all digits')  # BAD:  exit() called inside function
    dval = int(arg) * 2
    return dval

val = input('what is your value? ')
new_val = doubleit(val)

right way: using 'raise' to signal an error from within the function

def doubleit(arg):
    if not arg.isdigit():
        raise ValueError('arg must be all digits')   # GOOD:  error signaled with raise
    dval = int(arg) * 2
    return dval

val = input('what is your value? ')
new_val = doubleit(val)




Signalling errors (exceptions) with 'raise'

'raise' creates an error condition (exception) that usually terminates program execution.


An error condition is raised anytime Python is unable to understand or execute a statement. We have seen exceptions raised when code is malformed (SyntaxError), when we refer to a variable name that doesn't exist (NameError), when we ask for a list index that doesn't exist (IndexError) or a nonexistent dictionary key (KeyError), and more. Error conditions are fatal unless they are trapped. We prefer to signal an error using the raise statement rather than by calling exit() or sys.exit(), because we wish to have the calling code control whether the program exits, or whether to trap the error and allow the program to continue.


To raise an exception, we simply follow raise with the type of error we would like to raise:

raise IndexError('I am now raising an IndexError exception')

This can be done anywhere in your program, but it's most appropriate from a function, module or class (these latter two we will discuss toward the end of the course). Any exception can be raised, but it will usually be one of the builtin exceptions. Here is a list of common exceptions that you might raise in your program:

TypeError the wrong type used in an expression
ValueError the wrong value used in an expression
FileNotFoundError a file or directory is requested that doesn't exist
IndexError use of an index for a nonexistent list/tuple item
KeyError a requested key does not exist in the dictionary





Function Type Hints

Python's type hints (or type annotations) let you indicate what types of arguments a function expects and what type it returns.


Many languages apply signature type checking to functions. In commonly used languages such as Java and C++, the compiler/interpreter ensures that values sent to a function are correct in number, as well as in type. For example if a function is coded to accept 2 integers followed by 1 string, the compiler/interpreter will raise an error if the function is called with arguments of any other type or number. The Python interpreter does perform signature checking, but only confirming that the correct number of arguments are passed. Types are not checked. Python's type hints (or type annotations) let you indicate what types of arguments a function expects and what type it returns. This doesn’t affect how the code runs, but it helps with readability, debugging, and static analysis tools like mypy.


Basic Syntax

def greet(name: str) -> str:
    return f"Hello, {name}"


Multiple Argument Syntax

def add(x: int, y: int) -> int:
    return x + y

Optional Arguments (allowing None)

from typing import Optional

def greet(name: Optional[str]) -> str:
    if name:
        return f"Hello, {name}"
    return "Hello, stranger"

The typing module provides an Optional class that allows us to specify that the argument may be the type specified, or it may also be None


Specifying "inner" types in containers

from typing import List, Dict, Tuple

def total(scores: List[int]) -> int:
    return sum(scores)

def get_user() -> Dict[str, str]:
    return {"name": "Alice", "role": "admin"}

def coordinates() -> Tuple[float, float]:
    return (40.7128, -74.0060)

If function should expect a list, dict or tuple container, we can simply specify those in our type hint. However we may want to go further, and specify the types of values insidethe container. In that case the typing class provides List, Dict and Tuple, and we can specify as shown.


Specifying callables as arguments:

from typing import Callable

def apply(op: Callable[[int, int], int], a: int, b: int) -> int:
    return op(a, b)

The above type hint allows a type checker to see first that a callable is the first argument, and second that the callable itself takes 2 ints as arguments and returns one int. If we want to specify a callable argument we can just use Callable by itself.





[pr]