Returning Values From Functions

Beginner

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn how to return multiple values from functions in Python. You'll also understand optional return values and how to handle errors effectively.

Moreover, you will explore the concept of Futures for concurrent programming. Although returning a value may seem simple, different programming scenarios present various patterns and considerations.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 91% completion rate. It has received a 100% positive review rate from learners.

Returning Multiple Values from Functions

In Python, when you need a function to return more than one value, there's a handy solution: returning a tuple. A tuple is a type of data structure in Python. It's an immutable sequence, which means once you create a tuple, you can't change its elements. Tuples are useful because they can hold multiple values of different types all in one place.

Let's create a function to parse configuration lines in the format name=value. The goal of this function is to take a line in this format and return both the name and the value as separate items.

  1. First, you need to create a new Python file. This file will hold the code for our function and the test code. In the project directory, create a file named return_values.py. You can use the following command in the terminal to create this file:
touch ~/project/return_values.py
  1. Now, open the return_values.py file in your code editor. Inside this file, we'll write the parse_line function. This function takes a line as input, splits it at the first '=' sign, and returns the name and value as a tuple.
def parse_line(line):
    """
    Parse a line in the format 'name=value' and return both the name and value.

    Args:
        line (str): Input line to parse in the format 'name=value'

    Returns:
        tuple: A tuple containing (name, value)
    """
    parts = line.split('=', 1)  ## Split at the first equals sign
    if len(parts) == 2:
        name = parts[0]
        value = parts[1]
        return (name, value)  ## Return as a tuple

In this function, the split method is used to divide the input line into two parts at the first '=' sign. If the line is in the correct name=value format, we extract the name and value and return them as a tuple.

  1. After defining the function, we need to add some test code to see if the function works as expected. The test code will call the parse_line function with a sample input and print the results.
## Test the parse_line function
if __name__ == "__main__":
    result = parse_line('email=guido@python.org')
    print(f"Result as tuple: {result}")

    ## Unpacking the tuple into separate variables
    name, value = parse_line('email=guido@python.org')
    print(f"Unpacked name: {name}")
    print(f"Unpacked value: {value}")

In the test code, we first call the parse_line function and store the returned tuple in the result variable. Then we print this tuple. Next, we use tuple unpacking to directly assign the elements of the tuple to the name and value variables and print them separately.

  1. Once you've written the function and the test code, save the return_values.py file. Then, open the terminal and run the following command to execute the Python script:
python ~/project/return_values.py

You should see output similar to:

Result as tuple: ('email', 'guido@python.org')
Unpacked name: email
Unpacked value: guido@python.org

Explanation:

  • The parse_line function splits the input string at the '=' character using the split method. This method divides the string into parts based on the specified separator.
  • It returns both parts as a tuple using the syntax return (name, value). A tuple is a way to group multiple values together.
  • When calling the function, you have two options. You can either store the entire tuple in one variable, like we did with the result variable. Or you can "unpack" the tuple directly into separate variables using the syntax name, value = parse_line(...). This makes it easier to work with the individual values.

This pattern of returning multiple values as a tuple is very common in Python. It makes functions more versatile because they can provide more than one piece of information to the code that calls them.

Returning Optional Values

In programming, there are times when a function might not be able to generate a valid result. For example, when a function is supposed to extract specific information from an input, but the input doesn't have the expected format. In Python, a common way to handle such situations is to return None. None is a special value in Python that indicates the absence of a valid return value.

Let's take a look at how we can modify a function to handle cases where the input doesn't meet the expected criteria. We'll work on the parse_line function, which is designed to parse a line in the format 'name=value' and return both the name and value.

  1. Update the parse_line function in your return_values.py file:
def parse_line(line):
    """
    Parse a line in the format 'name=value' and return both the name and value.
    If the line is not in the correct format, return None.

    Args:
        line (str): Input line to parse in the format 'name=value'

    Returns:
        tuple or None: A tuple containing (name, value) or None if parsing failed
    """
    parts = line.split('=', 1)  ## Split at the first equals sign
    if len(parts) == 2:
        name = parts[0]
        value = parts[1]
        return (name, value)  ## Return as a tuple
    else:
        return None  ## Return None for invalid input

In this updated parse_line function, we first split the input line at the first equals sign using the split method. If the resulting list has exactly two elements, it means the line is in the correct 'name=value' format. We then extract the name and value and return them as a tuple. If the list doesn't have two elements, it means the input is invalid, and we return None.

  1. Add test code to demonstrate the updated function:
## Test the updated parse_line function
if __name__ == "__main__":
    ## Valid input
    result1 = parse_line('email=guido@python.org')
    print(f"Valid input result: {result1}")

    ## Invalid input
    result2 = parse_line('invalid_line_without_equals_sign')
    print(f"Invalid input result: {result2}")

    ## Checking for None before using the result
    test_line = 'user_info'
    result = parse_line(test_line)
    if result is None:
        print(f"Could not parse the line: '{test_line}'")
    else:
        name, value = result
        print(f"Name: {name}, Value: {value}")

This test code calls the parse_line function with both valid and invalid inputs. It then prints the results. Notice that when using the result of the parse_line function, we first check if it's None. This is important because if we try to unpack a None value as if it were a tuple, we'll get an error.

  1. Save the file and run it:
python ~/project/return_values.py

When you run the script, you should see output similar to:

Valid input result: ('email', 'guido@python.org')
Invalid input result: None
Could not parse the line: 'user_info'

Explanation:

  • The function now checks if the line contains an equals sign. This is done by splitting the line at the equals sign and checking the length of the resulting list.
  • If the line doesn't contain an equals sign, it returns None to indicate that parsing failed.
  • When using such a function, it's important to check if the result is None before trying to use it. Otherwise, you might encounter errors when trying to access elements of a None value.

Design Discussion:
An alternative approach to handling invalid input is to raise an exception. This approach is suitable in certain situations:

  1. Invalid input is truly exceptional and not an expected case. For example, if the input is supposed to come from a trusted source and should always be in the correct format.
  2. You want to force the caller to handle the error. By raising an exception, the normal flow of the program is interrupted, and the caller has to handle the error explicitly.
  3. You need to provide detailed error information. Exceptions can carry additional information about the error, which can be useful for debugging.

Example of an exception-based approach:

def parse_line_with_exception(line):
    """Parse a line and raise an exception for invalid input."""
    parts = line.split('=', 1)
    if len(parts) != 2:
        raise ValueError(f"Invalid format: '{line}' does not contain '='")
    return (parts[0], parts[1])

The choice between returning None and raising exceptions depends on your application's needs:

  • Return None when the absence of a result is common and expected. For example, when searching for an item in a list and it might not be there.
  • Raise exceptions when the failure is unexpected and should interrupt normal flow. For example, when trying to access a file that should always exist.

Working with Futures for Concurrent Programming

In Python, when you have a need to run functions at the same time, or concurrently, the language offers useful tools like threads and processes. But here's a common problem you'll face: how can you get the value that a function returns when it's running in a different thread? This is where the concept of a Future becomes very important.

A Future is like a placeholder for a result that will be available later. It's a way to represent a value that a function will produce in the future, even before the function has finished running. Let's understand this concept better with a simple example.

Step 1: Create a New File

First, you need to create a new Python file. We'll call it futures_demo.py. You can use the following command in your terminal to create this file:

touch ~/project/futures_demo.py

Step 2: Add Basic Function Code

Now, open the futures_demo.py file and add the following Python code. This code defines a simple function and shows how a normal function call works.

import time
import threading
from concurrent.futures import Future, ThreadPoolExecutor

def worker(x, y):
    """A function that takes time to complete"""
    print('Starting work...')
    time.sleep(5)  ## Simulate a time-consuming task
    print('Work completed')
    return x + y

## Part 1: Normal function call
print("--- Part 1: Normal function call ---")
result = worker(2, 3)
print(f"Result: {result}")

In this code, the worker function takes two numbers, adds them together, but first it simulates a time - consuming task by pausing for 5 seconds. When you call this function in a normal way, the program waits for the function to finish and then gets the return value.

Step 3: Run the Basic Code

Save the file and run it using the following command in your terminal:

python ~/project/futures_demo.py

You should see the output like this:

--- Part 1: Normal function call ---
Starting work...
Work completed
Result: 5

This shows that a normal function call waits for the function to finish and then returns the result.

Step 4: Run the Function in a Separate Thread

Next, let's see what happens when we run the worker function in a separate thread. Add the following code to the futures_demo.py file:

## Part 2: Running in a separate thread (problem: no way to get result)
print("\n--- Part 2: Running in a separate thread ---")
t = threading.Thread(target=worker, args=(2, 3))
t.start()
print("Main thread continues while worker runs...")
t.join()  ## Wait for the thread to complete
print("Worker thread finished, but we don't have its return value!")

Here, we're using the threading.Thread class to start the worker function in a new thread. The main thread doesn't wait for the worker function to finish and continues its execution. However, when the worker thread finishes, we have no easy way to get the return value.

Step 5: Run the Threaded Code

Save the file again and run it using the same command:

python ~/project/futures_demo.py

You'll notice that the main thread continues, the worker thread runs, but we can't access the return value of the worker function.

Step 6: Use a Future Manually

To solve the problem of getting the return value from a thread, we can use a Future object. Add the following code to the futures_demo.py file:

## Part 3: Using a Future to get the result
print("\n--- Part 3: Using a Future manually ---")

def do_work_with_future(x, y, future):
    """Wrapper that sets the result in the Future"""
    result = worker(x, y)
    future.set_result(result)

## Create a Future object
fut = Future()

## Start a thread that will set the result in the Future
t = threading.Thread(target=do_work_with_future, args=(2, 3, fut))
t.start()

print("Main thread continues...")
print("Waiting for the result...")
## Block until the result is available
result = fut.result()  ## This will wait until set_result is called
print(f"Got the result: {result}")

In this code, we create a Future object and pass it to a new function do_work_with_future. This function calls the worker function and then sets the result in the Future object. The main thread can then use the result() method of the Future object to get the result when it's available.

Step 7: Run the Code with Future

Save the file and run it again:

python ~/project/futures_demo.py

Now you'll see that we can successfully get the return value from the function running in the thread.

Step 8: Use ThreadPoolExecutor

The ThreadPoolExecutor class in Python makes working with concurrent tasks even easier. Add the following code to the futures_demo.py file:

## Part 4: Using ThreadPoolExecutor (easier way)
print("\n--- Part 4: Using ThreadPoolExecutor ---")
with ThreadPoolExecutor() as executor:
    ## Submit the work to the executor
    future = executor.submit(worker, 2, 3)

    print("Main thread continues after submitting work...")
    print("Checking if the future is done:", future.done())

    ## Get the result (will wait if not ready)
    result = future.result()
    print("Now the future is done:", future.done())
    print(f"Final result: {result}")

The ThreadPoolExecutor takes care of creating and managing the Future objects for you. You just need to submit the function and its arguments, and it will return a Future object that you can use to get the result.

Step 9: Run the Complete Code

Save the file one last time and run it:

python ~/project/futures_demo.py

Explanation

  1. Normal Function Call: When you call a function in the normal way, the program waits for the function to finish and directly gets the return value.
  2. Thread Problem: Running a function in a separate thread has a drawback. There's no built - in way to get the return value of the function running in that thread.
  3. Manual Future: By creating a Future object and passing it to the thread, we can set the result in the Future and then get the result from the main thread.
  4. ThreadPoolExecutor: This class simplifies concurrent programming. It handles the creation and management of Future objects for you, making it easier to run functions concurrently and get their return values.

Future objects have several useful methods:

  • result(): This method is used to get the result of the function. If the result is not ready yet, it will wait until it is.
  • done(): You can use this method to check if the computation of the function is complete.
  • add_done_callback(): This method allows you to register a function that will be called when the result is ready.

This pattern is very important in concurrent programming, especially when you need to get results from functions running in parallel.

Summary

In this lab, you have learned several key patterns for returning values from functions in Python. Firstly, Python functions can return multiple values by packing them into a tuple, enabling clean and readable value return and unpacking. Secondly, for functions that may not always yield valid results, returning None is a common way to indicate the absence of a value, and raising exceptions was also presented as an alternative.

Lastly, in concurrent programming, a Future acts as a placeholder for a future result, allowing you to obtain return values from functions running in separate threads or processes. Understanding these patterns will enhance the robustness and flexibility of your Python code. For further practice, experiment with different error - handling strategies, use Futures with other concurrent execution types, and explore their application in asynchronous programming with async/await.