Introduction to Python Programming - Function Basics
Tags: beginners, programming, python, tutorial
Published: 2023-03-04 11:44:10

## Table of Content

- [Introduction](#Introduction)
- [Defining Functions](#defining-functions)
- [Calling a Function](#calling-a-function)
- [Function Parameters and Arguments](#function-arguments)
- [Return Statement](#return-statement)
- [Built-In Functions](#built-in-functions)
- [Lambda Functions](#lambda-functions)
- [An Example: Validate a Full Name](#validate-name)

## Introduction

<a id="Introduction"></a>

At the end of this article, you will have learned the following:

- how to define and use your functions
- how to use function parameters and return statements
- built-in functions
- lambda functions for one-time-only tasks
- how to create a function to meet the requirements of a "real-life" scenario.

I assume that you've read the previous articles in this series. Also, you are either a beginner programmer or an experienced programmer learning. Let's get started :)

Let's say you want to calculate the body mass index (BMI) of 20 people. Maybe under different conditions or in other parts of your program. Typically you might be tempted to go straight to the calculation. 

Let's assume the height and weight of the first person were 1.71m and 68kg, respectively.

```py

# calculate BMI

# height in meters
height = 1.71

# weight in kilograms
weight = 68

# bmi = weight/height square
bmi = weight / height**2

print(bmi)
```

How about the second person? You copy the code to where you need it.

```py

# calculate BMI

# height in meters
height = 1.89

# weight in kilograms
weight = 92

# bmi = weight/height square
bmi = weight / height**2

print(bmi)

```

The third person? Or in a different file? You copy again.

That is way too much repetition that you want to avoid. Luckily, Python functions allow you to group or organize your codes.

An inherent benefit of using functions, apart from logic grouping, is the expression of your intention. Function names are a great way to express your intent to yourself and others who will read your code. For example, let's rewrite the above code that calculates BMI.

```py

# define the function
def calculate_bmi(height, weight):
    bmi = weight / height**2
    return bmi

# call the function and save its output in 
# the result variable
result = calculate_bmi(1.71, 68)

print(result)

```

You don't have to look at the codes line by line to know what it is doing. It calculates BMI.

In Python, two categories of functions exist - **Built-In** functions provided by the standard library and **user-defined** functions (the ones you define yourself). Let's start with user-defined functions since they will enable us to understand Built-In functions.

## Defining Functions

<a id="defining-functions"></a>

To define a function, start by using the `def` keyword. Followed by the *name* of the function, a *parenthesis*, a colon (`:`), and then the function body. The format is:

    def function_name():
        # Do something here in the function body
    
    
    def second_function(parameter_1, parameter_2, ..., parameter_N):
        # do something here
        
        return something # to get the result

The function name must follow the rules on variable naming. The parenthesis can contain 0 or more implicitly-defined variables called parameters or arguments. The function body can contain a `return` statement depending on whether the function generates an output.

Let's examine the `calculate_bmi` function we created above. It has a name (stating the obvious) and has two parameters (`height` and `weight`). It computes the BMI within its body (`bmi = weight / height**2`) and uses the return keyword to give back the result of the calculation (`return bmi`). Here's the function again for reference.

```py
def calculate_bmi(height, weight):
    bmi = weight / height**2
    return bmi

```

## Calling a Function

<a id="calling-a-function"></a>

To use (call) a function, write its name and include a parenthesis with or without arguments (or parameters). For example, call the `calculate_bmi` function like so:

```py
calculate_bmi(1.91, 80)
```

If the function returns a value, we can (optionally) save the value in a variable.

```py
my_bmi = calculate_bmi(1.91, 80)
print(my_pmi)
```

> **Note:** Any function that does not explicitly return a value will return `None` by default.

## Function Parameters and Arguments

<a id="function-arguments"></a>

As earlier stated, parameters/arguments are variables implicitly created based on function definition. They act as the function's inputs.

A function can take multiple parameters as in the `calculate_bmi` function. Also, a function can have no parameter. For example:

```py
# define a function that takes no argument/parameter
def print_hello_world():
    print("Hello World!")

```

```py
# call the function
print_hello_world()
```

One or more arguments can have default values. A function uses the default values if you call it without specifying the argument. For example, `sum_num` is a function that adds two numbers. By default, those numbers are 5 and 10.

```python
def sum_num(num1 = 5, num2 = 10):
  result = num1 + num2
  return result
```

We can use `sum_num` in several ways.

```py
# although no arguments are passed in
# num1 is 5 and num2 is 10. The defaults.
fifteen = sum_num()

# returns 25 since num1 is 13 and num2 is 12
twenty_five = sum_num(13, 12)

# specify num1 (20) and use num2's default value (10)
thirty = sum_num(20)
```

But wait! How do I specify `num2` alone? This question brings us to the concept of positional and named arguments.

### Positional and Named (Keyword) Arguments

Let's start by defining a different version of `sum_num` say `sum_num_v2`.

```python

# function with 1 positional required argument. 
# num1 and num2 are optional named/keyword arguments
def sum_num_v2(num1, num2 = 5, num3 = 10):
  return num1 + num2 + num3
```

> You can use the return keyword in the same line as the calculation.

We can pass arguments to a function based on the position of the arguments or based on the names (keywords) of the arguments. The function `sum_num_v2` has one positional argument `num1` and two named arguments `num2` and `num2`. The required position argument `num1` must always be in the first position during the function call while the named arguments can either take position 2 or 3. For example

```python
# num1, num2, num3 take their positions - 1,2 and 3 respectively
sum_num_v2(10, 20, 30)

# This is the same as using the positions above
sum_num_v2(10, num2 = 20, num3 = 30)

# The order of num2 and num3 have been switched
# We get the same result as above since
sum_num_v2(10, num3 = 30, num2 = 20)

# we could omit num2 and num3 since they
# have default values.
sum_num_v2(10)
```

Calling a function without its required arguments throws an error.

```python
# sum_num_v2() # fails :(
```

In recent Python versions, you can be strict about the number and placement of positional and keyword arguments. For example, consider the `describe_me` function below:

```py
def describe_me(name, age, city=None, zipcode=None):
  # do nothing
  pass

```

You can call the function in two ways:

- with four positional arguments `describe_me("Johnny Depp", 1020, "Caribbean", "123456")`
- with two positional and two keyword arguments `describe_me("Johnny Depp", 1020, city="Caribbean", zipcode="123456")`

What if you wanted to be very specific? You can use `\` (positional) and/or `*` (keyword) to restrict the function's use to a single way like so:

```py

def describe_me(name, age, /, *, city=None, zipcode=None):
  pass

# Fails
# describe_me("Peter Griffin", 48, "Quahog", "00093")
# TypeError: describe_me() takes 2 positional arguments but 4 were given

# works!
describe_me("Peter Griffin", 48, city="Quahog", zipcode="00093")
```

The first two arguments must be positional arguments while the last two must be keyword arguments. **You can't call the function with four positional arguments.**

Likewise, you can also restrict a function to use only keyword arguments like so:

```py
def I_dont_take_positions(*, city=None, zipcode=None):
  pass


# Fails
# I_dont_take_positions() takes 0 positional arguments but 2 were given
# I_dont_take_positions("Quahog", "00093")

# works!
I_dont_take_positions(city="Quahog", zipcode="00093")

# works! the order does not matter remember?
I_dont_take_positions(zipcode="00093", city="Quahog")
```

## Return Statement

<a id="return-statement"></a>

Earlier, you saw the `return` keyword in the `calculate_bmi`, `sum_num`, and `sum_num_v2`. These functions did some calculations and used the `return` statement to give back the results.

```py
# Store the calculation in the result variable
result = sum_num_v2(10, 20, 30)

print(result)
```

In other cases, you can return nothing (`None`) from a function if you use the `return` keyword alone. For example, the `calculate_bmi` function requires the weight and height parameters. What if they're absent? What if you give strings instead?

One way to defend your function is to **return** early if the parameters are not valid:

```py

# helper function: check if parameter n is a number 
def is_number(n):
  if isinstance(n, int) or isinstance(n, float):
    return True
  
  return False

# A safer, better version of calculate_bmi

def calculate_bmi_v2(weight, height):
  # Do not calculate BMI if weight or height is not a number
  # return early
  if not is_number(weight) or not is_number(height):
    return 
  
  bmi = weight / height**2
  return bmi
```

```py
# works well
print(calculate_bmi_v2(68, 1.61))

# returns None
print(calculate_bmi_v2("Hello", 1))

print(calculate_bmi_v2(1, "Hello"))

# returns None
print(calculate_bmi_v2("Hello", "World"))

```

> Note that in the absence of a return statement, a function returns `None` implicitly.

You can return a single value or multiple values from a function. To do so, separate the return values with a comma. For example, the `generate_123` function below returns three different values-1,2,3.

```py

def generate_123():
  return 1, 2, 3

```

The result of the function call is a tuple containing the values.

```py
one_two_three = generate_123()

print(one_two_three)
```

You could also unpack the return values individually.

```py
# Use tuple unpacking
one, two, three = generate_123()

print(one, two, three)

```

You can use the return statement within an if-statement, a for loop, or a while loop. The function will exit. For example:

```py

def contains_only_even_numbers(numbers):
  for num in numbers:
    # If it's an odd number return False
    if num % 2 != 0:
      return False
  
  # If we get here, the numbers parameter contains only even numbers
  return True

```

```py
outcome = contains_only_even_numbers([2, 4, 6])
print(outcome)  # True

outcome = contains_only_even_numbers([1, 2, 4]) # False
print(outcome)  # False

```

## Built-In Functions

<a id="built-in-functions"></a>

Python provides many functions in its standard library to make development easier. 

These functions allow you to perform common operations without writing them yourself. For example, you can replace the functions `sum_num` and `sum_num_v2` with the `sum` function provided by Python.

```py
numbers = [1, 2, 3, 4, 5]

total = sum(numbers)

print(total) # 15
```

The table below shows some common **Built-In** functions:

| Function Name | Description | Example |
|---------------|-------------|---------|
| `int` | Convert a string/float to an integer | `int(4.8)`|
| `float` | Convert a string/integer to a float | `float("11.82")`|
| `str` | Convert any type to a string | `str(1)`|
| `type` | Check the type of a variable/literal | `type(5)` |
| `map` | Apply a function to every element of a sequence/collection | `map(your_function, collection)`|
| `filter` | Apply a predicate to a collection | `filter(your_function, collection)`|
| `any` | Check if **any** item in a collection is `True`. Use with `map`  | `any(map(predicate, collection))` |
| `all` | Check if **all** item in a collection are `True`. Use with `map`  | `all(map(predicate, collection))` |
| `enumerate` | Add indices to a sequence/collection | `enumerate(numbers)` |
| `zip` | Combine collections | `zip(numbers, letters)` |
| `min` | Get the **minimum** value in a sequence/collection | `min([1,2,3])` |
| `max` | Get the **maximum** value in a sequence/collection | `max("xab")` |
| `sorted` | Sort a sequence/collection in a customized way | `sorted([2, 1, 5], reverse=True)` |
| `reversed` | Reverse the content of a sequence | `list(reversed([2, 4, 5, 1]))` |
| `open` | Open a file for reading/writing/appending. | `open("my_file.txt", mode="r", encoding="utf8")` |
| `help` | Provide helpful message on variables, functions, etc. | `help(print)`|

> A `predicate` function returns a boolean-`True` or `False`

Let's use some of the functions. Start by creating some collections `names` and `numbers`

```py
# some names
names = ["John Doe", "Peter Griffin", "Ghost Busters", "Super Mario"]

# numbers 1 - 100
numbers = list(range(1, 101))

```

### map: *apply a function to every element of a sequence/collection/iterable (list, tuple, etc.)*

Create two functions `square` and `length`. `square` raises a number to power 2. `length` gives the length of a string.

```py
def square(num):
  return num**2

def length(name):
  return len(name)

```

Square all the numbers within the `numbers` list.

```py
squared_numbers = map(square, numbers)

# squared_number is wrapped (a map object)
print(squared_numbers)

# Convert to a list/tuple to view the actual content
squared_numbers = list(squared_numbers)
print(squared_numbers)

```

Get the length of each name in the `names` list.

```py
# length names are wrapped (a map object)
length_names = map(length, names)

# Convert to a tuple and view the content
print(tuple(length_names))

# CAREFUL HERE: # tuple is empty because we already used the content
# To avoid this scenario, save the content of the map object.
print(tuple(length_names)) 
```

> The `map` function returns a **map object**, a collection that's not evaluated instantly. The operation runs when you use `list` or `tuple` on the map object. **Note You shouldn't use `list` or `tuple` on the same map object twice. Else, the content of the second call will be empty.**

Instead of storing and converting a map object, you can loop through its content. For example:

```py
for length_of_name in map(length, names):
  print(length_of_name)
```

### filter: apply a predicate function to each element of an iterable/sequence

Let's say you only want some names. For example, names that have 13 letters or less. Or names that start with the letter P. Use the `filter` function here.

```py
def less_or_eq_13(name):
  return len(name) <= 13

def starts_with_p(name):
  return name.lower().startswith("p")
```

```py
names_lte_13 = filter(less_or_eq_13, names)

pnames = filter(starts_with_p, names)

for name in names_lte_13:
  print(name)

# Just like `map`, `filter` returns a filter object that is used only once
# For multiple uses, save the content in a variable
print(list(names_lte_13))
```

### any and all

```py
def is_multiple_of_5(n): return n % 5 == 0

def is_odd(n): return n % 2 != 0
```

```py

# check if any of the elements is a multiple of 5
mult_5 = any(map(is_multiple_of_5, numbers))
print(mult_5)

# Check if numbers contain only odd numbers
all_odds = all(map(is_odd, numbers))
print(all_odds)
```

### enumerate

`enumerate` associates an index with each element of collection/sequence. `enumerate` returns an **enumerate object** that can be converted to a list of tuples.

```py
indices_and_names = enumerate(names)

# enumerate object
print(indices_and_names)

# Convert to a list and print
print(list(indices_and_names))
```

Like map objects and filter objects, you can loop through an enumerate object. For example:

```py

for index, name in enumerate(names):
  print(f"{name} is at position {index}")

```

### zip

Using `zip`, you can combine sequences to create a zip object. Consequently, the zip object can be converted into a list of tuples or used in a for loop.

For example, you can combine the `names` list with the `length_names` to have both a name and its length side by side.

```py
# Create a zipped object
names_length_zipped = zip(names, length_names)

# loop through
for name, length in names_length_zipped:
  print(f"{name} has {length} letters")
```

You can combine the zip step and the for loop.

```py
# Do the above in a single step
for name, length in zip(names, length_names):
  print(f"{name} has {length} letters")

```

### Detour: Implementing the Map and Filter Functions Yourself

The `map` and `filter` functions are similar to the `map_` and `filter_` defined below.

```py
# many people prefer def map_(f, items)
# I chose to write mine in full for demo purposes
def map_(function, items):
  result = []
  for item in items:
    # Call the function and append its output to result
    result.append(function(item))

  return result


# many people prefer def filter_(f, items)
# I chose to write mine in full for demo purposes
def filter_(function, items):
  result = []
  for item in items:
    if function(item):
      result.append(item)

  return result

```

> **Note that these functions `map_` and `filter_` are evaluated immediately. Don't use in real code.** 


## Lambda Functions

<a id="lambda-functions"></a>

If the question "Do I have to define a function each time I use `map` and `filter`?" comes to your mind, the answer is NO! 

With `lambdas` you can define a small function where you need it and forget it never existed. Lambdas also provide the following advantages:

- Lambda functions are nameless. No need to put thought into naming such functions.
- You won't have to litter your Python script with functions you'll only use once. For example, `square`, `length`, `is_multiple_of_5`, `is_odd`, `less_or_eq_13`, and `starts_with_p` ought to be lambdas.

Excited? How do I create a lambda function?

A lambda function has the following structure: `lambda *0_or_more_parameters* : *function body*`. No parenthesis or explicit return statement is required. Implicitly, Python returns the result of the expression in the function body.

Let's rewrite some of the lines above using lambdas. First, ditch the `square` and `length` functions.

```py
# Initially, you used a square function.
# Replace with a lambda that does exactly the same thing
squared_numbers = map(lambda x: x**2, numbers)

# Convert to a list/tuple to view the actual content
squared_numbers = list(squared_numbers)
print(squared_numbers)

```

```py
# No need for the length function you created above
length_names = map(lambda name: len(name), names)

# Convert to a tuple and view the content
print(tuple(length_names))
```

The filter function is next. Let's select only names where the length of the name is less than or equal to 13 using a lambda function.

```py
names_lte_13 = filter(lambda name: len(name) <= 13, names)

pnames = filter(lambda name: name.lower().startswith('p'), names)

print(list(names_lte_13))
print(list(pnames))
```

How about the `any` and `all`?

```py
# check if any of the elements is a multiple of 5
mult_5 = any(map(lambda n: n % 5 == 0, numbers))
print(mult_5)

# Check if numbers contain only odd numbers
all_odds = all(map(lambda n: n % 2 != 0, numbers))
print(all_odds)
```

## An Example: Validate a Full Name

<a id="validate-name"></a>

Imagine that your boss says that the name of a user of your application must meet the following requirements:

- A space should separate first name and last name
- First name and last name must be an alphabet
- First name and last name must have at least three letters
- The function should return `True` or `False`
- The function must have two versions-one that uses a for loop and another that uses `map`

To get you started, your colleague creates two functions that must print **OK!** if your code is correct.

```py

def assert_true(result):
  if result is True:
    print("OK!")
    return

  raise ValueError("incorrect!")


def assert_false(result):
  if result is False:
    print("OK!")
    return

  raise ValueError("incorrect!")

```

Next, your colleague adds the following calls/tests to prove your function works.

```py

# Test for valid names using both versions 1 and 2

assert_true(valid_name("Mark Edosa"))
assert_true(valid_name("Jet Lis"))

assert_true(valid_name_v2("Mark Edosa"))
assert_true(valid_name_v2("Jet Lis"))


# Test for invalid names using both versions

assert_false(valid_name("a b"))
assert_false(valid_name("Mark"))
assert_false(valid_name("abc"))

assert_false(valid_name_v2("a b"))
assert_false(valid_name_v2("Mark"))
assert_false(valid_name_v2("abc"))

```
{% collapsible solution %}
```py
def valid_name(name):

  name_list = name.split()

  if len(name_list) != 2:
    return False
  
  for name in name_list:
    test = name.isalpha() and len(name) >= 3
    if not test:
      return False
    
  return True 


def valid_name_v2(name):
  name_list = name.split()

  if len(list_names) != 2:
    return False
  
  tests = map(lambda name: name.isalpha() and len(name) >= 3, list_names)
  return all(tests)
  
```
{% endcollapsible %}
## Conclusion

Functions help you organize your code. They also help you express your intentions through appropriate names. 

In this article, you learned how to define and use your functions. You learned about function parameters and return statements. You also saw some Built-In functions in action. And you saw how to use lambda functions for one-time-only tasks. Finally, you (hopefully) created a function to meet the requirements of a "real-life" scenario.

Thank you for reading.