python function structure
So, you've seen all the function examples in chapter 4 and how defining a function creates a function object and a variable of that name. A function is just one way that we can modularise our code, and that code will only run when the function is called - an important fact to remember. Indentation is key here- note that once the code is de-dented, the function will terminate.
We want a function to have the following attributes:
A function keyword is followed by a function name- semantic name (descriptive, has meaning- clear what it does)
The function arguments (if any) also have useful, descriptive names.
A docstring: help information on that function (later we can do this for files, classes and those special functions called methods)
A type contract indicating the data types of the arguments the function takes, and the data type of what the function returns. While it is uncommon for a function to return different data types, there is a recent way to indicate that more than one type can be returned. For example, if the types are int and float, we can use the term num- an abstract parent of both these number types.
If the types are more dissimilar however, such as bool and list we can use the | to indicate "or", for example:
(int) -> bool | list of int
indicates a function that accepts a single int as arg, and returns either an int or a list of int.
A description summarising what the function does (not how)
Optionally, where appropriate, state any preconditions- assumptions about the data being passed as a param/arg to the function.
Optionally, where appropriate, state any postconditions expected after execution of the function.
Example calls of the function (test cases):
Base or normal case(s)
Case(s) at the edges of valid input
Case(s) of invalid input
A clearly defined body- indicated by being indented beneath the declaration- where the code "does its thing"
A return statement- even though the default is "None"- so if your function has no return statement it will return this, but we should specify a return value and type and document this.
A python function does not have to indicate it's return type, but we will put this information into our type contract. This format is based on the University of Toronto design recipe, but instead of using type-hinting we are using an explicit type-contract. The google python docstring format is also popular, and there are extensions that can generate a docstring, taking advantage of type-hinting.
In the case below, this works out to be a bit easier than writing:
def area_rect(width: int|float, height: int|float) -> int|float:
In particular because of multiple types being valid in this case.
Here is shown an example function to calculate the area of a rectangle, with suitable names given for both arguments.
Note that, since the function could accept either floats or integers, we have simplified this to show that our function could accept or return either type. There is no num datatype in python, so we can call this an abstract way to represent both ints and floats.
One of the MOST IMPORTANT things about functions, as with the examples above, is that:
When doing calculations- keep it clean. Send arg(s), return the answer
No input functions or print statement in calculating functions
This means that we can create tests for functions that can specify:
The input
The corresponding output
You can see examples of that in the page on testing. Doctest expects to find examples of inputs and the expected outputs for those inputs to be specified in the docstring. Using any automated testing, we would need to be able to specify the inputs and what the expected outputs would be for those inputs.... usually creating a test table to document this.
If we want to get user input, but not clutter up the hard part of a function by putting it into the same function as the calculations, we use what are called helper functions. By breaking up code into blocks similar to the flowchart, it simplifies our code.
In the following example, we've created separate functions to get the width and height from the user.
Here, get_num is a useful helper function that we can use to get user input.
It also makes the main function easier to read, and all the complexity is offloaded to the get_num and the area_rect functions.
If we now wanted to add a loop to the main program, this makes it look much easier and only four lines in main would have to be placed inside a loop and have some lines to allow the option to quit or loop again.
We could give our get_num function a default prompt, so if we call it without passing an argument, it will still run. Consider this case:
we change one line in the main() function:
width = get_num() # no arg passed
and we call the main function:
if __name__ == "__main__":
main()
now when we run the code, python throws an error.
TypeError: get_num() missing 1 required positional argument: 'prompt'
We could fix this by adding a default value for the prompt... in the function definition
def get_num(prompt="Enter number: "):
Now, if we call the function without passing an arg for prompt, the function is happy and uses the default prompt.
We can allow functions to accept no, one or many args using the *arg as an argument to the function. Look at the following.
When we use the *args it allows us to process a tuple of all of the args passed to the function. These can then be processed by a for loop as shown in the example here. There is also an option to use keyword args- both are illustrated in the following video. In OOP, Java has different constructors for a class, depending on how many args are being passed. We will see that Python handles it differently because you cannot have multiple constructors in Python- just one! Come to that- strictly speaking the __init__ is not really a constructor, but we will call it that for simplicity. It is analogus to thinking of the range generating a list... which is good enough for us to think about using it, but under the hood it is not quite doing that exactly.
It is also a pretty cool way to allow flexibility in coding functions or methods. You've seen something like this with the range() and randrange() functions.
Before looking any more at *args and **kwargs, lets get a few things clear about functions:
when calling functions, we pass args/params in the order in which the function is designed.
We never call a named arg and then a positional argument, e.g. from the easygui module we have function boolbox
boolbox(msg='Shall I continue?', title=' ', choices=('[T]rue', '[F]alse'), image=None,
default_choice='[T]rue', cancel_choice='[F]alse')
If we call the boolbox, we can call it validly with any of the following:
choice = easygui.boolbox() # no args/params passed
choice = easygui.boolbox("Continue?", "Make decision") # given msg and title by position
choice = easygui.boolbox("Continue?", choices=("Yes", "No") # one positional and one keyword
but the following will create an error:
choice = easygui.boolbox("Continue?", "Decide", choices=("Yes", "No"), "images/dec.png")
SyntaxError: positional argument follows keyword argument
so the order is
positional and then
named
Next in the hierarchy is *args. If we were to implement a version of the range function to return a real list, we could do the following:
def myrangemaker(*args):
'''
args will be wrapped in a tuple
'''
if len(args) == 1: # implies only the stop value
return list(range(0, args[0]))
elif len(args) == 2: # implies the start and stop
return list(range(args[0], args[1]))
elif len(args) == 3: # implies start, stop, inc
return list(range(args[0], args[1], args[2]))
So we can simplify our function and design it to behave differently depending on the number of args passed. This would be very useful is we want to apply a function to an unknown number of objects, such as multiply or add from 1 to n numbers.
Now the order is:
positional and then
named and then
*args - an arbitrary name- it is the '*' that is important
Before moving on to **kwargs, lets look at a little information about namespace! and mention the locals() builtin function.
Calling/running the locals() function will update and return a dictionary representing the current local symbol table. Free variables are returned by locals when it is called in function blocks, but not in class blocks. Note that at the module (file) level, locals and globals are the same dictionary.
If you make a new file and put in the following code and run it...
"""
my test file
"""
a = 1
b = 2.0
c = '3'
d = [True, False]
def foo():
"""test func"""
e = "11"
f = 3
g = e * f
print(f"Foo locals is: {locals()}")
print(f"Foo dunder doc is: {foo.__doc__}")
print(locals())
print(f"Locals functions returns a: {type(locals())}")
foo()
the result will be:
>>> %Run testfile.py
{'__name__': '__main__', '__doc__': '\nmy test file\n', '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x0000019675878730>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'C:\\Users\\ThorGodOfThunder\\Desktop\\testfile.py', 'a': 1, 'b': 2.0, 'c': '3', 'd': [True, False], 'foo': <function foo at 0x00000196761A9AB0>}
Locals functions returns a: <class 'dict'>
Foo locals is: {'e': '11', 'f': 3, 'g': '111111'}
Foo dunder doc is: test func
So there are a lot of "things" created under-the-hood when we create a file. Note that the dicts that are created have a lot of pre-defined keys, some of which have been populated. docstrings become the __doc__ for a file or module. All variables in that namespace are loaded into the dict with variable names stored as strings, while the values become the values for each key. I've color-matched some of the above to make them easier to read. Review the code and result for a few moments before continuing.
All of the objects (variables, functions) are mapped in the local namespace for our file- which we can see by calling the locals() function, but not the objects inside the function foo. In the function called foo, the locals() shows us the objects in the namespace for that function. If this makes sense, continue reading...
To continue to use the example code from earlier in myrangemaker, what if we want to do a version with keywords, like start, stop, step- just like in the "real" range function. well, we can use **kwargs. When we use kwargs, the keyword variables (names) and values are not wrapped now in a tuple, but in a dict.
def bam(**kwargs):
print(kwargs)
print(type(kwargs))
bam(some="Tom", thing=42)
will result in the following output:
>>> %Run -c bam_test.py
{'some': 'Tom', 'thing': 42}
<class 'dict'>
Looks familiar now- maybe a little bit?
How can we refactor our myrangemaker to take advantage?
def myrangemaker(**kwargs):
'''
kwargs will be wrapped in a dict
'''
print(kwargs)
start = 0
stop = 0
step = 1 # need this as a default
for k in kwargs:
if k == "start":
start = kwargs[k]
if k == "stop":
stop = kwargs[k]
if k == "step":
step = kwargs[k]
r = list(range(start, stop, step))
return r
print(myrangemaker(stop=5))
print(myrangemaker(start=3, stop=9))
print(myrangemaker(start=6, stop=22, step=3))
# or even out of order
print(myrangemaker(stop=61, step=7, start=3))
the resulting output will be the following:
{'stop': 5}
[0, 1, 2, 3, 4]
{'start': 3, 'stop': 9}
[3, 4, 5, 6, 7, 8]
{'start': 6, 'stop': 22, 'step': 3}
[6, 9, 12, 15, 18, 21]
{'stop': 61, 'step': 7, 'start': 3}
[3, 10, 17, 24, 31, 38, 45, 52, 59]
This gives us great flexibility in coding functions which may have the capacity and flexibility to accept many, many arguments/params, but where doing so explicitly may make it quite verbose.
Here, we can set up many defaults, for even dozens of values, and whatever or however many variables the user chooses to pass as named variables, we can handle them. it does require more coding in our function, but there will be cases where the advantages outweigh the disadvantages. You will know a good use-case when you see it.
Now the order is:
positional and then
named and then
*args - an arbitrary name- it is the '*' that is important and then
**kwargs - another arbitrary but descriptive name, where the ** is the important part!
It is possible to return more than one object from a python function, however what happens s that these get wrapped in a tuple.
You can see this happen visually in the example below.
If your head is not spinning after *args, **kwargs and multiple returns wrapped in tuple Christmas wrapping, you might want to take your programming to the next level with python decorators!
Python decorators allow you to modify or extend the behavior of functions and methods without changing their actual code. When you use a Python decorator, you wrap a function with another function, which takes the original function as an argument and returns its modified version. This technique provides a simple way to implement higher-order functions in Python, enhancing code reusability and readability.
You can use decorators in various practical scenarios, such as logging, enforcing access control, caching results, or measuring execution time.
To create a custom decorator, define a function that takes another function as an argument, creates a nested wrapper function, and returns the wrapper. When applying a decorator, you place @decorator on the line before the function definition.
It's important to keep in mind that all python "things" are objects- functions can be passed around and used as arguments, just like any other object like str, int, float, list, and so on. They can also be returned as objects, just as other objects can be returned- list, dict, int etc.
Now that you’ve seen that functions are just like any other object in Python, you’re ready to move on and see the magical beast that is the Python decorator. We’ll start with an example:
def decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper
def say_whee():
print("Whee!")
say_hey = decorator(say_whee) # set but NOT run!
say_whee()
print("-" * 50)
say_hey() # called here!
will result in the following:
>>> %Run -c $EDITOR_CONTENT
Whee!
--------------------------------------------------
Something is happening before the function is called.
Whee!
Something is happening after the function is called.
>>>
from https://realpython.com/primer-on-python-decorators/