Friday, 1 November 2019

Is there a pythonic way to decouple optional functionality from a function's main purpose?

Context

Suppose I have the following Python code:

def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        for _ in range(n_iters):
            number = halve(number)
        sum_all += number
    return sum_all


ns = [1, 3, 12]
print(example_function(ns, 3))

example_function here is simply going through each of the elements in the ns list and halving them 3 times, while accumulating the results. The output of running this script is simply:

2.0

Since 1/(2^3)*(1+3+12) = 2.

Now, let's say that (for any reason, perhaps debugging, or logging), I would like to display some type of information about the intermediate steps that the example_function is taking. Maybe I would then rewrite this function into something like this:

def example_function(numbers, n_iters):
sum_all = 0
for number in numbers:
    print('Processing number', number)
    for i_iter in range(n_iters):
        number = number/2
        print(number)
    sum_all += number
    print('sum_all:', sum_all)
return sum_all

which now, when called with the same arguments as before, outputs the following:

Processing number 1
0.5
0.25
0.125
sum_all: 0.125
Processing number 3
1.5
0.75
0.375
sum_all: 0.5
Processing number 12
6.0
3.0
1.5
sum_all: 2.0

This achieves exactly what I intended. However, this goes a bit against the principle that a function should only do one thing, and now the code for example_function is sligthly longer and more complex. For such a simple function this is not a problem, but in my context I have quite complicated functions calling each other, and the printing statements often involve more complicated steps than shown here, resulting in a substantial increase in complexity of my code (for one of my functions there were more lines of code related to logging than there were lines related to its actual purpose!).

Furthermore, if I later decide that I don't want any printing statements in my function anymore, I would have to go through example_function and delete all of the print statements manually, along with any variables related this functionality, a process which is both tedious and error-prone.

The situation gets even worse if I would like to always have the possibility of printing or not printing during the function execution, leading me to either declaring two extremely similar functions (one with the print statements, one without), which is terrible for maintaining, or to define something like:

def example_function(numbers, n_iters, debug_mode=False):
    sum_all = 0
    for number in numbers:
        if debug_mode:
            print('Processing number', number)
        for i_iter in range(n_iters):
            number = number/2
            if debug_mode:
                print(number)
        sum_all += number
        if debug_mode:
            print('sum_all:', sum_all)
    return sum_all

which results in a bloated and (hopefully) unnecessarily complicated function, even in the simple case of our example_function.


Question

Is there a pythonic way to "decouple" the printing functionality from the original functionality of the example_function?

More generally, is there a pythonic way to decouple optional functionality from a function's main purpose?


What I have tried so far:

The solution I have found at the moment is using callbacks for the decoupling. For instance, one can rewrite the example_function like this:

def example_function(numbers, n_iters, callback=None):
    sum_all = 0
    for number in numbers:
        for i_iter in range(n_iters):
            number = number/2

            if callback is not None:
                callback(locals())
        sum_all += number
    return sum_all

and then defining a callback function that performs whichever printing functionality I want:

def print_callback(locals):
    print(locals['number'])

and calling example_function like this:

ns = [1, 3, 12]
example_function(ns, 3, callback=print_callback)

which then outputs:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

This successfully decouples the printing functionality from the base functionality of example_function. However, the main problem with this approach is that the callback function can only be run at a specific part of the example_function (in this case right after halving the current number), and all of the printing has to happen exactly there. This sometimes forces the design of the callback function to be quite complicated (and makes some behaviors impossible to achieve).

For instance, if one would like to achieve exactly the same type of printing as I did in a previous part of the question (showing which number is being processed, along with its corresponding halvings) the resulting callback would be:

def complicated_callback(locals):
    i_iter = locals['i_iter']
    number = locals['number']
    if i_iter == 0:
        print('Processing number', number*2)
    print(number)
    if i_iter == locals['n_iters']-1:
        print('sum_all:', locals['sum_all']+number)

which results in exactly the same output as before:

Processing number 1.0
0.5
0.25
0.125
sum_all: 0.125
Processing number 3.0
1.5
0.75
0.375
sum_all: 0.5
Processing number 12.0
6.0
3.0
1.5
sum_all: 2.0

but is a pain to write, read, and debug.



from Is there a pythonic way to decouple optional functionality from a function's main purpose?

No comments:

Post a Comment