1.3. Functions#

1.3.1. An Introduction to Functions#

In Python, the import statement is used to bring functionality from other Python modules or packages into your current script or program. This allows you to use functions, classes, variables, and other resources defined in external modules to enhance the functionality of your code without having to rewrite everything from scratch. The import statement has different forms to import various components from modules [Downey, 2015, Python Software Foundation, 2023]:

1.3.1.1. Importing the whole module#

You can use the import statement followed by the module name to import the entire module.

import math

result = math.sqrt(16)
print(result)  # Output: 4.0
4.0

1.3.1.2. Importing specific items from a module#

You can import specific functions, classes, or variables from a module using the from keyword.

from math import sqrt

result = sqrt(16)
print(result)  # Output: 4.0
4.0

1.3.1.3. Importing with an alias:#

You can use the as keyword to give a module or item an alias, making it easier to reference.

import math as m

result = m.sqrt(16)
print(result)  # Output: 4.0
4.0

1.3.2. Adding new functions#

Adding new functions in Python is a fundamental way to extend the functionality of your code. To create a new function, you use the def keyword, followed by the function name, a list of parameters (if any), and a colon. The function body is indented below the function definition, and it contains the code that defines what the function does [Downey, 2015, Python Software Foundation, 2023].

Here’s the basic syntax for defining a new function:

def function_name(parameter1, parameter2, ...):
    # Function body
    # Code to perform the task of the function
    # Optional: return statement to return a value

Let’s see an example of defining and using a new function:

def greet(name):
    """
    This function takes a 'name' as input and prints a greeting message.
    """
    print("Hello", name,"!")

# Call the greet function with the argument "Alice"
greet("Alice")
# Output: "Hello, Alice!"
Hello Alice !

In the example above, we defined a new function named greet that takes a single parameter name. When called with an argument, the function prints a greeting message containing the provided name.

Functions can have multiple parameters, and they can also return values using the return statement. Here’s an example of a function that calculates the sum of two numbers and returns the result:

def add_numbers(a, b):
    """
    This function takes two numbers 'a' and 'b' as input
    and returns their sum.
    """
    return a + b

# Call the add_numbers function with arguments 5 and 10
result = add_numbers(5, 10)
print(result)  # Output: 15
15

When you call a function with arguments, the values you pass are assigned to the function’s parameters. Inside the function, you can then perform any operations using those parameter values.

Remember to define functions before calling them in your code. Functions allow you to encapsulate logic, promote code reuse, and make your programs more organized and easier to understand.

1.3.3. Parameters and arguments#

In Python functions, the terms “parameters” and “arguments” are used to describe the input values that a function can accept. These terms are often used interchangeably, but they have distinct meanings in the context of functions:

1.3.3.1. Parameters:#

Parameters are the variables listed in the function definition, representing the input values that the function expects. They act as placeholders for the actual values that will be provided when the function is called. Parameters are defined inside the parentheses () following the function name [Downey, 2015, Python Software Foundation, 2023]. Example:

Example:

def greet(name):
    # 'name' is the parameter of the function
    print("Hello", name,"!")

In this example, name is the parameter of the greet function, representing the name of the person to greet.

1.3.3.2. Arguments:#

Arguments are the actual values passed to a function when it is called. They provide the values that correspond to the function’s parameters. When you call a function, the arguments are provided inside the parentheses () [Downey, 2015, Python Software Foundation, 2023].

Example:

# 'Alice' is the argument passed to the 'greet' function
greet("Alice")
Hello Alice !

In this example, "Alice" is the argument passed to the greet function, and it will be assigned to the name parameter inside the function.

It’s important to note that the number of arguments provided during the function call must match the number of parameters defined in the function’s definition. If the function expects a certain number of parameters, you must provide the same number of arguments during the function call.

There are also different ways to pass arguments to functions:

  • Positional Arguments: These are arguments passed to the function based on their position. The order of arguments matters.

Example:

def add_numbers(a, b):
    return a + b

result = add_numbers(5, 10)
print(result)
15
  • Keyword Arguments: These are arguments passed with their corresponding parameter names, regardless of their positions.

Example:

def greet(name, age):
    print("My Name is", name, ", and I am", age ,"years old.")

greet(name="John Doe", age=35)
My Name is John Doe , and I am 35 years old.
  • Default Arguments: These are parameters that have a default value assigned in the function definition. If the corresponding argument is not provided during the function call, the default value is used.

Example:

def greet(name, greeting="Hello"):
    print(greeting + ", " + name + "!")

greet("Alice")
greet("Bob", greeting="Hi")
Hello, Alice!
Hi, Bob!

Understanding parameters and arguments is crucial for writing functions that can accept input and perform operations based on that input. It also allows you to create flexible and reusable functions in Python.

1.3.4. Variables and parameters are local#

In Python, variables and parameters defined within a function are considered local to that function. This means that they have a local scope and are only accessible within the body of the function where they are defined. Variables and parameters with the local scope are created when the function is called and destroyed when the function execution is completed.

Let’s see an example to illustrate local variables and parameters:

def my_function(x, y):
    # x and y are parameters (local variables) within the function
    result = x + y
    print("Inside the function:", result)

my_function(5, 10)
# Output: "Inside the function: 15"

# Trying to access the local variable 'result' outside the function will raise an error
# print("Outside the function:", result)
# Uncommenting the above line will raise a NameError: name 'result' is not defined
Inside the function: 15

In this example, x and y are parameters (local variables) of the my_function. They are accessible only within the function, and trying to access them outside the function scope would raise an error. It’s important to note that variables defined outside the function, in the global scope, are not directly accessible within the function unless explicitly passed as arguments. If you want to modify a global variable inside a function, you need to use the global keyword to indicate that the variable is global and not local.

Example:

global_variable = 10

def modify_global():
    # To modify a global variable inside a function, use the 'global' keyword
    global global_variable
    global_variable += 5

modify_global()
print(global_variable)  # Output: 15
15

As seen in the example, the global_variable is modified inside the function modify_global using the global keyword.

Summary

In summary, variables and parameters defined inside a function have local scope, meaning they are accessible only within that function. If you need to use a variable from the global scope inside a function or modify a global variable, you need to explicitly use the global keyword.

1.3.4.1. The role of “__main__”#

In Python, __main__ is a special built-in variable or name that holds the name of the script that is currently being executed as the main program. It is used to differentiate between the script that is being run directly and the script that is being imported as a module into another program [Downey, 2015, Python Software Foundation, 2023].

When a Python script is executed directly, the Python interpreter assigns the value "__main__" to the __name__ variable, indicating that this script is the main program. On the other hand, if the script is imported as a module into another script, the __name__ variable will be set to the name of the module (i.e., the name of the imported script). Consider the following example:

Suppose you have two Python scripts: main_script.py and module_script.py.

main_script.py:

def hello():
    print("Hello from the main script!")

if __name__ == "__main__":
    hello()
Hello from the main script!

module_script.py:

def hello():
    print("Hello from the module script!")

print("This is the module script.")
This is the module script.

Now, if you run main_script.py directly from the command line (or a terminal):

python main_script.py

Output:

Hello from the main script!

Note

The “main” construct in Python is indeed specific to Python script files (“.py”) and not applicable to Jupyter Notebooks. In Python, when a script is executed, the special variable “name” is set to “main” if the script is the entry point of the program. This allows you to write code that can be both used as a standalone script and imported as a module into other scripts.

However, in Jupyter Notebooks, the execution model is different. Cells in a Jupyter Notebook are executed independently, and there is no concept of a main script. Instead, each cell is executed as it is run, and variables and functions defined in one cell can be used in subsequent cells within the same notebook.

Therefore, you won’t typically find the “main” construct in Jupyter Notebooks, as it’s not needed in this context. Instead, you can execute code directly within the cells of the notebook to achieve the desired functionality.

1.3.5. Stack diagrams#

A stacking diagram, also known as a call stack or function call stack, is a graphical representation of the execution flow of a program involving function calls. It helps us to visualize how functions are called, executed, and returned in a program [Downey, 2015, Python Software Foundation, 2023].

Here’s how a stack diagram works in Python:

  1. Whenever a function is called, a new “frame” is created on top of the stack. The frame contains information about the function call, such as the local variables, parameters, and the return address.

  2. The current function’s frame is always on top of the stack, representing the currently executing function.

  3. When a function is done executing, its frame is removed from the stack, and the control returns to the previous function.

  4. The process continues until the main program is completed, and there are no more function calls to be made.

Example:

def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

def main():
    x = 2
    y = 3
    sum_result = add(x, y)
    product_result = multiply(x, sum_result)
    print("Final Result:", product_result)

main()
Final Result: 10
../_images/Main_Function.png

Fig. 1.3 Stack Diagram.#

In the diagram:

  • The main program starts executing, and variables x and y are assigned values.

  • The add function is called with arguments x and y, resulting in sum_result being assigned the value 5.

  • The multiply function is called with arguments x and sum_result, resulting in product_result being assigned the value 10.

  • The final result, "Final Result: 10", is printed.

  • The call stack then starts unwinding as the functions complete their execution, eventually reaching back to the main() function.

1.3.6. Fruitful functions and void functions#

1.3.6.1. Fruitful Functions:#

Fruitful functions are functions that return a value after performing some computation or processing. They use the return statement to send a value back to the caller. The returned value can be assigned to a variable or used directly in the calling code [Downey, 2015, Python Software Foundation, 2023].

Example of a fruitful function:

def add_numbers(a, b):
    return a + b

result = add_numbers(3, 5)
print(result)  # Output: 8
8

In the example above, add_numbers is a fruitful function that takes two arguments a and b, performs addition, and returns the result.

1.3.6.2. Void Functions:#

Void functions, also known as non-fruitful functions or procedures, are functions that do not return a value. They perform some actions or side effects but do not send anything back to the caller. Instead of using the return statement, they may have print statements, modify global variables, or perform other actions [Downey, 2015, Python Software Foundation, 2023].

Example of a void function:

def greet(name):
    print("Hello,", name, "! Welcome to ENGG 680!")

greet("John")
Hello, John ! Welcome to ENGG 680!

In this example, greet is a void function that takes a name as an argument and prints a greeting message. It’s important to note that all Python functions return a value, even if you don’t explicitly use the return statement. If the return statement is omitted, the function returns None, which represents the absence of a value. So, even if a function does not have a return statement, it is technically a fruitful function that implicitly returns None.

Observe that:

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

result = say_hello()
print(result)  # Output: None
Hello, World!
None

In this case, the say_hello function implicitly returns None, which is then assigned to the variable result.

Summary

To summarize, fruitful functions return a value using the return statement, void functions do not return anything explicitly, and they may have side effects in the form of print statements or other actions. Both types of functions are useful in different scenarios depending on the task they need to perform.

1.3.7. Functions within other functions#

In Python, the concept of nested functions, also known as inner functions, involves the ability to define one function within another function. This nested structure is grounded in the principles of scope and encapsulation, making it a valuable feature for creating organized, modular, and efficient code. By defining functions within specific contexts, you can maintain a cleaner codebase, prevent global namespace pollution, and even leverage advanced techniques such as closures and decorators [Python Software Foundation, 2023].

Consider the following example to illustrate the idea of nested functions:

def main_function(x, y):
    def square(a):
        return a ** 2
    
    def add(a, b):
        return a + b
    
    def multiply(a, b):
        return a * b
    
    result1 = square(x)
    result2 = add(result1, y)
    result3 = multiply(result1, result2)
    return result3

a = 3
b = 4
final_result = main_function(a, b)
print("The final result is:", final_result)
The final result is: 117

This example demonstrates how you can define and utilize multiple functions within the body of a single function to create more complex behavior and calculations. Details:

  1. The square, add, and multiply functions are all defined within the body of the main_function.

  2. The square function calculates the square of its input.

  3. The add function performs addition between two values.

  4. The multiply function calculates the product of two values.

  5. The main_function takes two arguments, x and y. Inside this function, the square, add, and multiply functions are called to perform various calculations.

  6. The a and b variables are set to 3 and 4, respectively.

  7. The main_function is called with a and b as arguments, and the final result is stored in the final_result variable.

1.3.7.1. Nested and closures functions#

Nested functions are particularly valuable in scenarios where you need to create utility functions or helpers that are closely related to the main function, but you want to encapsulate them to maintain code organization and prevent clashes in the global namespace.

Another powerful use case for nested functions is when you need to create closures. Closures are functions that retain access to the variables in their enclosing scope even after the outer function has completed execution. This property allows you to create specialized functions, such as decorators in Python.

Imagine you have a main function that defines another function inside it. This inner function can still remember and use variables from the main function, even after the main function has finished running. It’s like the inner function carries around a piece of the main function’s memory. This unique behavior allows you to create special functions that “remember” certain things from the past, and one common use of this is in creating decorators in Python. Decorators are functions that can modify the behavior of other functions in a flexible and convenient way. So, closures enable you to do some clever tricks with functions that remember their past context [Python Software Foundation, 2023].

def multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

double = multiplier(2)
triple = multiplier(3)

print(double(5))  # Output: 10
print(triple(5))  # Output: 15
10
15

This Python code snippet defines a function called multiplier which takes a single argument, factor. Within this multiplier function, another nested function named multiply is defined. The multiply function, in turn, takes an argument x and returns the result of multiplying x by the factor provided to the outer multiplier function.

Here is a step-by-step breakdown of the code:

  1. def multiplier(factor):: This line defines the multiplier function, which takes a single argument factor.

  2. def multiply(x):: Inside the multiplier function, a nested function multiply is defined. This inner function takes a single argument x.

  3. return x * factor: The multiply function returns the result of multiplying x by the factor. This multiplication operation is performed using the value of factor that was provided when calling the outer multiplier function.

  4. return multiply: The outer multiplier function returns the multiply function itself as its result. This means that when you call multiplier(2), it returns the multiply function with a factor of 2, and when you call multiplier(3), it returns the multiply function with a factor of 3.

  5. double = multiplier(2): This line assigns the multiply function with a factor of 2 to the variable double. Essentially, double now represents a function that will double any number passed to it.

  6. triple = multiplier(3): Similarly, this line assigns the multiply function with a factor of 3 to the variable triple. triple now represents a function that will triple any number passed to it.

  7. print(double(5)): Here, we call the double function with an argument of 5. Since double was created with a factor of 2, it multiplies 5 by 2 and prints the result, which is 10.

  8. print(triple(5)): This line calls the triple function with an argument of 5. Since triple was created with a factor of 3, it multiplies 5 by 3 and prints the result, which is 15.

In summary, the code demonstrates the concept of closures in Python, where the multiply function “remembers” the value of factor from the outer multiplier function, allowing you to create specialized multiplication functions (double and triple) based on different factors. When these specialized functions are called, they apply the corresponding multiplication factor to the input value.

1.3.8. Lambda functions#

Lambda functions, also known as anonymous functions or lambda expressions [McKinney, 2022], are a feature in Python that allows you to create small, unnamed functions without explicitly defining them using the def keyword. Lambda functions are often used when you need a simple function for a short period and don’t want to create a named function using def.

Here is the basic syntax of a lambda function:

lambda arguments: expression
  • lambda: The keyword used to define a lambda function.

  • arguments: The input parameters or arguments for the function.

  • expression: The single expression or calculation that the function will return.

Lambda functions can take multiple arguments separated by commas, but they should consist of a single expression. The result of the expression is automatically returned by the lambda function.

Example: A lambda function that adds two numbers:

# Define a lambda function 'add' that takes two arguments x and y and returns their sum.
add = lambda x, y: x + y

# Call the lambda function with arguments 5 and 3 and store the result in 'result'.
result = add(5, 3)

# Print the result of the addition.
print(result)  # Output: 8
8

Example: A lambda function to square a number:

# Define a lambda function 'square' that takes a single argument x and returns its square.
square = lambda x: x ** 2

# Call the lambda function with argument 4 and store the result in 'result'.
result = square(4)

# Print the result, which is the square of 4.
print(result)  # Output: 16
16

Example: Sorting a list of tuples based on the second element using lambda:

# Create a list 'pairs' containing tuples with two elements each.
pairs = [(1, 5), (2, 2), (3, 8), (4, 1)]

# Sort the 'pairs' list based on the second element (index 1) of each tuple.
# The key argument specifies the sorting criterion using a lambda function.
sorted_pairs = sorted(pairs, key=lambda x: x[1])

# Print the sorted list of pairs.
print(sorted_pairs)  # Output: [(4, 1), (2, 2), (1, 5), (3, 8)]
[(4, 1), (2, 2), (1, 5), (3, 8)]

Note

Both [(1, 5), (2, 2), (3, 8), (4, 1)] and [(4, 1), (2, 2), (1, 5), (3, 8)] are instances of lists containing tuples. We will delve deeper into the concepts of lists and tuples in Chapter 3.

1.3.9. The Benefits of Using Functions in Programming#

The utilization of functions within a program might not immediately reveal its advantages, but upon closer examination, there are several compelling reasons why incorporating functions into your codebase is highly beneficial [Downey, 2015, Python Software Foundation, 2023]:

  1. Enhanced Readability and Simplified Debugging: The practice of creating distinct functions facilitates the organization of related statements under descriptive names. This structural approach significantly contributes to the clarity and comprehensibility of your program. Moreover, during the debugging phase, well-defined functions simplify the identification of errors, allowing for targeted fixes within specific segments of code.

  2. Promotes Code Reusability: Functions serve as powerful tools for minimizing code repetition. By encapsulating repetitive code within function definitions, you effectively centralize and maintain such code in a singular location. Consequently, any modifications or updates applied to the function instantly propagate across all instances where the function is invoked. This strategy not only reduces redundancy but also ensures consistency throughout the program.

  3. Modularity and Facilitated Testing: Dividing a complex program into smaller, self-contained functions introduces a modular framework that greatly facilitates the debugging process. By addressing the testing and debugging of individual functions independently, you adopt an incremental approach that makes the overall debugging endeavor more manageable and efficient. This systematic approach allows for comprehensive testing of each component before its integration into the complete program.

  4. Facilitates Repurposing: Proficiently designed functions, once crafted and debugged, hold the potential to be seamlessly integrated into multiple programs. This practice of function reuse streamlines the development trajectory of subsequent projects. By harnessing proven and tested code, you circumvent the need for redundant implementations, thus accelerating the development process and promoting consistency.

In essence, the incorporation of functions into programming not only enhances the legibility and maintainability of code but also fosters efficient development practices through the facilitation of debugging, code reuse, and modular testing.