photo of computer monitor displaying program

Functions are one of the primitive building blocks of a programming language. They are ‘specialized workers’ that take in some data, process it and return the output. They wrap up common routines that are needed multiple times in your program to improve code readability and maintenance. In this guide, we will look at basically everything you need to write functions in Python. Let us get started!


Declaration

We’ll start with a simple example; a function that takes two integers as input, adds them, and returns the result.

The first thing we need to do is to declare a function. A declaration acts a starting point of our function, telling the interpreter that the following code block defines a function. We use the def keyword to start a function definition, followed by a function name and arguments.

def sum(i1:int, i2:int):
  • sum is the function name. Naming a function is important as it is used to call our function in a program. Choosing a good name allows a programmer to tell what kind of operation a function is performing without explicitly reading the definition.
  • Enclosed in brackets are arguments. Arguments are data inputs to a function separated by a comma. In the above declaration, i1 and i2 are arguments. You may optionally specify a type for these arguments too. In our case, we set both of our arguments to int. This is done by adding a ‘:’ followed by the type after an argument name. Python does not enforce any kind of checks on what type of arguments are passed to a function, even if it is specified in the declaration. This is because Python is dynamically typed i.e., the declaration of variable types can change throughout a program.
  • Variable types in arguments can be omitted i.e., you can also specify arguments as (i1, i2). However, specifying types makes debugging and documenting functions easier and it is a good practice to always add them, unless your argument really doesn’t need a specific type.
  • The colon : at the end marks start of the function definition. It tells interpreter that the code block below is part of the function definition. All statements below the declaration have to be indented.

Asserting Arguments

We talked earlier about how Python doesn’t enforce any kind of type check on the arguments passed to a function. In our example, the sum function requires the two arguments to be integers to perform a basic addition. To do this, we can manually assert data types as part of the function definition.

def sum(i1:int, i2:int):
	assert type(i1) == int and type(i2) == int

Assert statements are conditional statements but unlike traditional conditions, they halt program execution if the condition evaluates as ‘False’. Adding asserts are optional but they are great for debugging. As noted, Python is dynamically typed and when your program gets bigger, it becomes difficult to track variable types. Assert statements are helpful to ensure certain conditions are met at a specific execution point in your program, in our case, the arguments passed to the sum function must be integers.

The actual definition

It is now time to code a list of statements or tasks our sum function will perform. Well, it is just summing in our case.

def sum(i1:int, i2:int):
	assert type(i1) == int and type(i2) == int
    output = i1 + i2

There is no limit to statements a function can have. We will look at some examples later in this article.

Returning results

After your function has performed its task, you can return that output so that it can be used elsewhere in the code. To do this you can use the return keyword, followed by the object you want to return.

	return output

The return keyword can also return expressions. For example, in the function definition, instead of defining a new variable output and setting it to sum of our arguments, we can directly evaluate the summing expression and return it.

	return i1 + i2

Return statements are optional. It is perfectly normal for a function to perform a task and not return anything useful back. In such cases, you can omit the return statement completely. Functions with no return statements, return None.

Complete Function

This basically wraps up a function in Python. To recap, a function needs a declaration, definition and an optional return.

def sum(i1:int, i2:int):
	assert type(i1) == int and type(i2) == int
    output = i1 + i2
    return output

Calling out a Function

We can now use the sum function anywhere in our program. It is as simple as using the function name and passing in the required arguments.

>>> sum(1,2)
3
>>> sum(2,2)
4
>>> sum(5,50)
55

Do give importance to the order of arguments. For example, in sum(1,2), 1 gets mapped to i1 and 2 gets mapped to i2. If you want to specify the order yourself, use the argument name in the function definition. Note that you have to pass all arguments. Missing an argument will throw an error.

>>> res:int = sum(i2=5, i1=50)
>>> print(res)
55

Default Arguments

As noted, you must provide all arguments when calling out a function. However, in certain cases, you might want some arguments to be automatically set a defined default value if not specified. For example, you want to write a function that greets the user.

def greeting(name:str):
    assert type(name) == str
    print("Hello {}!".format(name))

You also want to set argument name to User by default if the argument isn’t passed. This can be done by modifying the argument list in our function declaration as follow:

def greeting(name:str="User"):
    assert type(name) == str
    print("Hello {}!".format(name))

Lets confirm if our desired workflow works by calling out the function with and without the argument:

>>> greeting("Fahad")
Hello Fahad!
>>> greeting()
Hello User!
>>>

There’s one small caveat. Default arguments in functions are initialized when the function is defined and not when the function is executed. If mutable default arguments, such as dictionaries, lists, etc., are modified during the function call, they’ll retain that modification in the next call too. To understand this, consider the function below. The function appends hello to a list named input and returns it. Input is set to a default empty list.

def append_hello(input:list=[]):
    assert type(input) == list
    output = list(input.append("hello"))
    return output

Try calling this function now without providing an argument.

>>> append_hello()
['hello']
>>> append_hello()
['hello', 'hello']
>>> append_hello()
['hello', 'hello', 'hello']

Note that with every function call, the default argument input is getting modified since it was initialized once during function definition and its mutable property modifies it on every call.

Variable number of Arguments

The sum function we defined earlier can only add two integers at a given time. What if we want to add an arbitrary number of integers? Python has two special notations for arguments that allows functions to receive a variable number of arguments.

*args

The *args keyword (single asterisk followed by the variable name) allows the function to receive an arbitrary number of variable type arguments. As an example, see the function below that performs addition or multiplication decided by the argument type on integers provided by the variable argument *ints.

def addmul(fun:str, *ints):
	if fun == 'add':
		return reduce(lambda x,y: x+y, ints)
	if fun == 'mul':
		return reduce(lambda x,y: x*y, ints)

We can now call this function to validate if the output is correct.

>>> addmul('add', 1,2,3,4)
10
>>> addmul('mul', 1,2,3,4)
24

The arguments add and mul are mapped to fun while the integers 1,2,3,4 are packed into a tuple and mapped to ints as specified by the * symbol. Since ints is an iterator, it can be used in loops, iterators, etc. to perform any desired operation.

**kwargs

The **kwargs (single asterisk followed by variable name) allows function to receive an arbitrary number of keyword-value pair arguments. See the function below that simply prints the arguments it receives.

def printkv(**args):
    for key,value in args.items():
        print("{}.{}".format(key,value))

Keyword-value arguments require argument name and value to be specified in function calls. These arguments are stored in a dictionary structure and can be the used inside the function to perform the desired task.

>>> printkv(one="1", two="2")
one.1
two.2

Functions as First Class Objects

Functions in Python are objects. They can be passed and stored in variables, just like you do with objects such as integers, floats, lists, etc.

For example, we can map our function addmul to muladd by simply doing the following:

>>> muladd = addmul
>>> muladd('add', 1,2,3,4)
10
>>> type(muladd)
<class 'function'>

Since functions are objects, they can be even passed as arguments to other functions. See the following function below that greets the user.

def hello_eng(name:str):
    return "Hello {}!".format(name)

def hello_jap(name:str):
    return "こんにちは {}!".format(name)

def greet(lang, name:str):
    print(lang(name))

We can now pass the first two functions’ name as argument to our greet function to print the greeting in our desired language.

>>> greet(hello_jap, "Fahad")
こんにちは Fahad!
>>> greet(hello_eng, "Fahad")
Hello Fahad!

Functions inside Functions

Functions are rather peculiar in Python language. They can be defined just about anywhere in your code, even inside an existing one. Functions inherit the scope of the parent, so a function defined directly in a .py script file can be used anywhere inside that file. Similarly, functions defined inside classes or an existing code block maintain their existence inside that definition scope.

However, defining a function inside a function can give a function its own little environment. See the example below.

def counter(start:int = 0):
    def _fun():
        nonlocal curr
        curr += 1
        return curr
    curr = start
    return _fun

Call this function and store the returned function reference in a variable.

ctr_1 = counter()
ctr_2 = counter()

Since both variables hold function references, the variables now behave as functions themselves and can be called!

>>> ctr_1()
1
>>> ctr_1()
2
>>> ctr_1()
3
>>> ctr_2()
1
>>> ctr_2()
2

When a function is first called, Python creates a small environment for that function that holds all the instructions and variables required for the function to execute. That environment inherits from the main Python environment so that default Python functions, operations or any custom library imports can be used inside that environment. Normally after a function is executed, that environment is destroyed to free up memory. In our case, we are actually returning a reference to a function inside that small environment. Since that reference is now bound to a variable (such as ctr_1), Python doesn’t automatically deletes that environment in its garbage collection routine. This allows the variables inside that function, such as curr, to remain in memory, allowing us to perform a simple increment operation by simply calling the function again. You might say that our counter function behaves way too much like a class object. Well, it does because functions are objects themselves!

The nonlocal declaration in our inner function ensures that the variable defined in our parent is shared inside the child function.

Function Decorators

Functions can be made fancier, i.e. their capabilities can be indefinitely expanded by using decorators. In simple words, decorators allow function extensions without modifying the original function. You can think of decorators as a nice gift packing that wraps around your existing functions!

We’ll consider multiple scenarios to understand decorators better. Lets start with a pretty basic decoration. This one prints all arguments a function receives, performs the operation and then prints the result, similar to what a logging routine might do.

def printInsOuts(func):
    def fun(*args):
        # Print the arguments received
        print('Inputs:', end=' ')
        for arg in args:
            print(arg, end=' ')
        # Perform operation
        out = func(*args)
        # Print output
        print('')
        print('Output: {}'.format(out))
    return fun

@printInsOuts
def add(*args):
    return reduce(lambda x,y: x+y, args)

To wrap a function, use the @ keyword followed by the function name to be wrapped around. In our example, add function is wrapped by printInsOuts function. When the add function is called, its implementation is merged inside the decorator function thus allowing the pretty print statements to be executed first, followed by the actual function itself. Note that the decorator function takes in the decorated function reference as input. This actually allows the decorator to call the decorated function in its function definition.

>>> add(2,2)
Inputs: 2 2
Output: 4

We can use this decorator anywhere. For example, we can define a similar multiply function and can decorate it too!

@printInsOuts
def multiply(*args):
    return reduce(lambda x,y: x*y, args)
    
>>> multiply(3,4)
Inputs: 3 4
Output: 12

Decorators can accept arguments too. For example, lets say you need a decorator that can time a function’s execution and has the option to either display it in seconds or milliseconds. To do this, simply define the decorator and add necessary code to handle the provided argument. See the snippet below.

from timeit import default_timer as timer
from functools import reduce

def Timer(arg):
    def decorator(func):
        def fun(*args):
            start = timer()
            out = func(*args)
            stop = timer()
            elapsed = stop-start
            if units == 'ms':
                elapsed *= 1000
            print('Elapsed Time: {}'.format(elapsed))
            return out
        return fun
    if callable(arg):
        # Expecting default argument since arg is a function
        # Set default to seconds
        units = 's'
        return decorator(arg)
    else:
        # Expecting argument here
        if arg in ['s', 'ms']:
            units = arg
            return decorator
        else:
            # Throw an exception here
            return None


@Timer('ms')
def add(*args):
    return reduce(lambda x,y: x+y, args)

First, we have the required decorator that logs time, executes the decorated function and prints elapsed time. We have wrapped this decorator inside another function that checks if an argument is provided. If an argument is provided, it is stored and decorator function is called. Otherwise, a default value is used and a reference to the actual decorator is returned.

>>> print(add(1,2,3,4))
Elapsed Time: 0.0043299996832502075
10

Note the order of execution. The decorator is called first as evidenced by the Elapsed Time statement executed before the print statement.

A function can be wrapped by multiple decorators. In our case, we can wrap add function by both Timer and printInsOuts decorator.

@Timer('ms')
@printInsOuts
def add(*args):
    return reduce(lambda x,y: x+y, args)
>>> add(1,2,3,4)
Inputs: 1 2 3 4 
Output: 10
Elapsed Time: 0.03734999972948572

Conclusion

We have just barely scratched the surface with functions. There is tons of amazing and exciting functionality and interfaces provided by functions in Python. I hope this article can serve as a starting point to build more solid foundational concepts in Python programming language. Have a great day! Cheers :3

Leave a Reply

Your email address will not be published. Required fields are marked *