- 5.1 Function Definitions
- 5.2 Default Arguments
- 5.3 Variadic Arguments
- 5.4 Keyword Arguments
- 5.5 Variadic Keyword Arguments
- 5.6 Functions Accepting All Inputs
- 5.7 Positional-Only Arguments
- 5.8 Names, Documentation Strings, and Type Hints
- 5.9 Function Application and Parameter Passing
- 5.10 Return Values
- 5.11 Error Handling
- 5.12 Scoping Rules
- 5.13 Recursion
- 5.14 The lambda Expression
- 5.15 Higher-Order Functions
- 5.16 Argument Passing in Callback Functions
- 5.17 Returning Results from Callbacks
- 5.18 Decorators
- 5.19 Map, Filter, and Reduce
- 5.20 Function Introspection, Attributes, and Signatures
- 5.21 Environment Inspection
- 5.22 Dynamic Code Execution and Creation
- 5.23 Asynchronous Functions and await
- 5.24 Final Words: Thoughts on Functions and Composition
5.17 Returning Results from Callbacks
Another problem not addressed in the previous section is that of returning the results of the calculation. Consider this modified after() function:
def after(seconds, func, *args): time.sleep(seconds) return func(*args)
This works, but there are some subtle corner cases that arise from the fact that two separate functions are involved—the after() function itself and the supplied callback func.
One issue concerns exception handling. For example, try these two examples:
after("1", add, 2, 3) # Fails: TypeError (integer is expected) after(1, add, "2", 3) # Fails: TypeError (can't concatenate int to str)
A TypeError is raised in both cases, but it’s for very different reasons and in different functions. The first error is due to a problem in the after() function itself: A bad argument is being given to time.sleep(). The second error is due to a problem with the execution of the callback function func(*args).
If it’s important to distinguish between these two cases, there are a few options for that. One option is to rely on chained exceptions. The idea is to package errors from the callback in a different way that allows them to be handled separately from other kinds of errors. For example:
class CallbackError(Exception): pass def after(seconds, func, *args): time.sleep(seconds) try: return func(*args) except Exception as err: raise CallbackError('Callback function failed') from err
This modified code isolates errors from the supplied callback into its own exception category. Use it like this:
try: r = after(delay, add, x, y) except CallbackError as err: print("It failed. Reason", err.__cause__)
If there was a problem with the execution of after() itself, that exception would propagate out, uncaught. On the other hand, problems related to the execution of the supplied callback function would be caught and reported as a CallbackError. All of this is quite subtle, but in practice, managing errors is hard. This approach makes the attribution of blame more precise and the behavior of after() easier to document. Specifically, if there is a problem in the callback, it’s always reported as a CallbackError.
Another option is to package the result of the callback function into some kind of result instance that holds both a value and an error. For example, define a class like this:
class Result: def __init__(self, value=None, exc=None): self._value = value self._exc = exc def result(self): if self._exc: raise self._exc else: return self._value
Then, use this class to return results from the after() function:
def after(seconds, func, *args): time.sleep(seconds) try: return Result(value=func(*args)) except Exception as err: return Result(exc=err) # Example use: r = after(1, add, 2, 3) print(r.result()) # Prints 5 s = after("1", add, 2, 3) # Immediately raises TypeError. Bad sleep() arg. t = after(1, add, "2", 3) # Returns a "Result" print(t.result()) # Raises TypeError
This second approach works by deferring the result reporting of the callback function to a separate step. If there is a problem with after(), it gets reported immediately. If there is a problem with the callback func(), that gets reported when a user tries to obtain the result by calling the result() method.
This style of boxing a result into a special instance to be unwrapped later is an increasingly common pattern found in modern programming languages. One reason for its use is that it facilitates type checking. For example, if you were to put a type hint on after(), its behavior is fully defined—it always returns a Result and nothing else:
def after(seconds, func, *args) -> Result: ...
Although it’s not so common to see this kind of pattern in Python code, it does arise with some regularity when working with concurrency primitives such as threads and processes. For example, instances of a so-called Future behave like this when working with thread pools. For example:
from concurrent.futures import ThreadPoolExecutor pool = ThreadPoolExecutor(16) r = pool.submit(add, 2, 3) # Returns a Future print(r.result()) # Unwrap the Future result