Python 3home |
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
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!'
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.
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
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
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.
"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
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)
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)
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)
'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 |
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.