Modern Python Cookbook
上QQ阅读APP看书,第一时间看更新

Using super flexible keyword parameters

Some design problems involve solving a simple equation for one unknown when given enough known values. For example, rate, time, and distance have a simple linear relationship. We can solve any one when given the other two.

Here are the three rules that we can use as an example:

  • d = r × t
  • r = d / t
  • t = d / r

When designing electrical circuits, for example, a similar set of equations is used based on Ohm's Law. In that case, the equations tie together resistance, current, and voltage.

In some cases, we want to provide a simple, high-performance software implementation that can perform any of the three different calculations based on what's known and what's unknown. We don't want to use a general algebraic framework; we want to bundle the three solutions into a simple, efficient function.

Getting ready

We'll build a single function that can solve a Rate-Time-Distance (RTD) calculation by embodying all three solutions, given any two known values. With minor variable name changes, this applies to a surprising number of real-world problems.

There's a trick here. We don't necessarily want a single value answer. We can slightly generalize this by creating a small Python dictionary with the three values in it. We'll look at dictionaries in more detail in Chapter 4, Built-In Data Structures Part 1: Lists and Sets.

We'll use the warnings module instead of raising an exception when there's a problem:

>>> import warnings

Sometimes, it is more helpful to produce a result that is doubtful than to stop processing.

How to do it...

  1. Solve the equation for each of the unknowns. We can base all of this on the d = r × t RTD calculation. This leads to three separate expressions:
    • Distance = rate * time
    • Rate = distance / time
    • Time = distance / rate
  2. Wrap each expression in an if statement based on one of the values being None when it's unknown:
    if distance is None:
        distance = rate * time
    elif rate is None:
        rate = distance / time
    elif time is None:
        time = distance / rate
    
  3. Refer to the Designing complex if...elif chains recipe from Chapter 2, Statements and Syntax, for guidance on designing these complex if...elif chains. Include a variation of the else crash option:
    else:
        warnings.warning( "Nothing to solve for" )
    
  4. Build the resulting dictionary object. In some very simple cases, we can use the vars() function to simply emit all of the local variables as a resulting dictionary. In other cases, we'll need to build the dictionary object explicitly:
    return dict(distance=distance, rate=rate, time=time)
    
  5. Wrap all of this as a function using keyword parameters with default values of None. This leads to parameter types of Optional[float]. The return type is a dictionary with string keys and Optiona[float] values, summarized as Dict[str, Optional[float]]:
    def rtd(
     distance: Optional[float] = None,
     rate: Optional[float] = None,
     time: Optional[float] = None,
    ) -> Dict[str, Optional[float]]:
        if distance is None and rate is not None and time is not None:
            distance = rate * time
        elif rate is None and distance is not None and time is not None:
            rate = distance / time
        elif time is None and distance is not None and rate is not None:
            time = distance / rate
        else:
            warnings.warn("Nothing to solve for")
        return dict(distance=distance, rate=rate, time=time)
    

The type hints tend to make the function definition so long it has to be spread across five physical lines of code. The presence of so many optional values is difficult to summarize!

We can use the resulting function like this:

>>> rtd(distance=31.2, rate=6)
{'distance': 31.2, 'rate': 6, 'time': 5.2}

This shows us that going 31.2 nautical miles at a rate of 6 knots will take 5.2 hours.

For a nicely formatted output, we might do this:

>>> result = rtd(distance=31.2, rate=6)
>>> ('At {rate}kt, it takes '
... '{time}hrs to cover {distance}nm').format_map(result)
'At 6kt, it takes 5.2hrs to cover 31.2nm'

To break up the long string, we used our knowledge from the Designing complex if...elif chains recipe from Chapter 2, Statements and Syntax.

How it works...

Because we've provided default values for all of the parameters, we can provide argument values for any two of the three parameters, and the function can then solve for the third parameter. This saves us from having to write three separate functions.

Returning a dictionary as the final result isn't essential to this. It's a handy way to show inputs and outputs. It allows the function to return a uniform result, no matter which parameter values were provided.

There's more...

We have an alternative formulation for this, one that involves more flexibility. Python functions have an all other keywords parameter, prefixed with **. It is often shown like this:

def rtd2(distance, rate, time, **keywords):
    print(keywords)

We can leverage the flexible keywords parameter and insist that all arguments be provided as keywords:

def rtd2(**keywords: float) -> Dict[str, Optional[float]]:
    rate = keywords.get('rate')
    time = keywords.get('time')
    distance = keywords.get('distance')
etc.

The keywords type hint states that all of the values for these parameters will be float objects. In some rare case, not all of the keyword parameters are the same type; in this case, some redesign may be helpful to make the types clearer.

This version uses the dictionary get() method to find a given key in the dictionary. If the key is not present, a default value of None is provided.

The dictionary's get() method permits a second parameter, the default, which is provided if the key is not present. If you don't enter a default, the default value is set to None, which works out well for this function.

This kind of open-ended design has the potential advantage of being much more flexible. It has some disadvantages. One potential disadvantage is that the actual parameter names are hard to discern, since they're not part of the function definition, but instead part of the function's body.

We can follow the Writing clear documentation strings with RST markup recipe and provide a good docstring. It seems somehow better, though, to provide the parameter names explicitly as part of the Python code rather than implicitly through documentation.

This has another, and more profound, disadvantage. The problem is revealed in the following bad example:

>>> rtd2(distnace=31.2, rate=6)
{'distance': None, 'rate': 6, 'time': None}

This isn't the behavior we want. The misspelling of "distance" is not reported as a TypeError exception. The misspelled parameter name is not reported anywhere. To uncover these errors, we'd need to add some programming to pop items from the keywords dictionary and report errors on names that remain after the expected names were removed:

def rtd3(**keywords: float) -> Dict[str, Optional[float]]:
    rate = keywords.pop("rate", None)
    time = keywords.pop("time", None)
    distance = keywords.pop("distance", None)
    if keywords:
        raise TypeError(
            f"Invalid keyword parameter: {', '.join(keywords.keys())}")

This design will spot spelling errors, but has a more complex procedure for getting the values of the parameters. While this can work, it is often an indication that explicit parameter names might be better than the flexibility of an unbounded collection of names.

See also

  • We'll look at the documentation of functions in the Writing clear documentation strings with RST markup recipe.