How can I call a sequence of functions until the return value meets some condition?

StackOverflow https://stackoverflow.com/questions/23434852

  •  14-07-2023
  •  | 
  •  

Pergunta

Sometimes I find myself writing code like this:

def analyse(somedata):
    result = bestapproach(somedata)
    if result:
        return result
    else:
        result = notasgood(somedata)
        if result:
            return result
        else:
            result = betterthannothing(somedata)
            if result:
                return result
            else:
                return None

That's pretty ugly. Of course, some people like to leave off some of that syntax:

def analyse(somedata):
    result = bestapproach(somedata)
    if result:
        return result
    result = notasgood(somedata)
    if result:
        return result
    result = betterthannothing(somedata)
    if result:
        return result

But that's not much of an improvement; there's still a ton of duplicated code here, and it's still ugly.

I looked into using the built-in iter() with a sentinel value, but in this case the None value is being used to signal that the loop should keep going, as opposed to a sentinel which is used to signal that the loop should terminate.

Are there any other (sane) techniques in Python for implementing this sort of "keep trying until you find something that works" pattern?

I should clarify that "return value meets some condition" is not limited to cases where the condition is if bool(result) is True as in the example. It could be that the list of possible analysis functions each produce some coefficient measuring the degree of success (e.g. an R-squared value), and you want to set a minimum threshold for acceptance. Therefore, a general solution should not inherently rely on the truth value of the result.

Foi útil?

Solução

Option #1: Using or

When the number of total functions is a) known, and b) small, and the test condition is based entirely on the truth value of the return, it's possible to simply use or as Grapsus suggested:

d = 'somedata'
result = f1(d) or f2(d) or f3(d) or f4(d)

Because Python's boolean operators short-circuit, the functions are executed from right to left until one of them produces a return value evaluated as True, at which point the assignment is made to result and the remaining functions are not evaluated; or until you run out of functions, and result is assigned False.


Option #2: Using generators

When the number of total functions is a) unknown, or b) very large, a one-liner generator comprehension method works, as Bitwise suggested:

result = (r for r in (f(somedata) for f in functions) if <test-condition>).next()

This has the additional advantage over option #1 that you can use any <test-condition> you wish, instead of relying only on truth value. Each time .next() is called:

  1. We ask the outer generator for its next element
  2. The outer generator asks the inner generator for its next element
  3. The inner generator asks for an f from functions and tries to evaluate f(somedata)
  4. If the expression can be evaluated (i.e., f is a function and somedata is a valid arugment), the inner generator yields the return value of f(somedata) to the outer generator
  5. If <test-condition> is satisfied, the outer generator yields the return value of f(somedata) and we assign it as result
  6. If <test-condition> was not satisfied in step 5, repeat steps 2-4

A weakness of this method is that nested comprehensions can be less intuitive than their multi-line equivalents. Also, if the inner generator is exhausted without ever satisfying the test condition, .next() raises a StopIteration which must be handled (in a try-except block) or prevented (by ensuring the last function will always "succeed").


Option #3: Using a custom function

Since we can place callable functions in a list, one option is to explicitly list the functions you want to "try" in the order they should be used, and then iterate through that list:

def analyse(somedata):
    analysis_functions = [best, okay, poor]
    for f in analysis_functions:
        result = f(somedata)
        if result:
            return result

Advantages: Fixes the problem of repeated code, it's more clear that you're engaged in an iterative process, and it short-circuits (doesn't continue executing functions after it finds a "good" result).

This could also be written with Python's for ... else syntax:*

def analyse(somedata):
    analysis_functions = [best, okay, poor]
    for f in analysis_functions:
        result = f(somedata)
        if result:
            break
    else:
        return None
    return result

The advantage here is that the different ways to exit the function are identified, which could be useful if you want complete failure of the analyse() function to return something other than None, or to raise an exception. Otherwise, it's just longer and more esoteric.

*As described in "Transforming Code into Beautiful, Idiomatic Python", starting @15:50.

Outras dicas

If the number of functions is not too high, why not use the or operator ?

d = 'somedata'
result = f1(d) or f2(d) or f3(d) or f4(d)

It will only apply the functions until one of them returns something not False.

This is pretty pythonic:

result = (i for i in (f(somedata) for f in funcs) if i is not None).next()

The idea is to use generators so you do lazy evaluation instead of evaluating all functions. Note that you can change the condition/funcs to be whatever you like, so this is more robust than the or solution proposed by Grapsus.

This is a good example why generators are powerful in Python.

A more detailed description of how this works:

We ask this generator for a single element. The outer generator then asks the inner generator (f(d) for f in funcs) for a single element, and evaluates it. If it passes the condition then we are done and it exits, otherwise it continues asking the inner generator for elements.

Licenciado em: CC-BY-SA com atribuição
Não afiliado a StackOverflow
scroll top