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

Picking an order for parameters based on partial functions

When we look at complex functions, we'll sometimes see a pattern in the ways we use the function. We might, for example, evaluate a function many times with some argument values that are fixed by context, and other argument values that are changing with the details of the processing.

It can simplify our programming if our design reflects this concern. We'd like to provide a way to make the common parameters slightly easier to work with than the uncommon parameters. We'd also like to avoid having to repeat the parameters that are part of a larger context.

Getting ready

We'll look at a version of the Haversine formula. This computes distances between two points, and , on the surface of the Earth, and :

The essential calculation yields the central angle, c, between two points. The angle is measured in radians. We convert it into distance by multiplying by the Earth's mean radius in some units. If we multiply the angle c by a radius of 3,959 miles, the distance, we'll convert the angle into miles.

Here's an implementation of this function. We've included type hints:

from math import radians, sin, cos, sqrt, asin
MI= 3959
NM= 3440
KM= 6372
def haversine(lat_1: float, lon_1: float,
 lat_2: float, lon_2: float, R: float) -> float:
    """Distance between points.
 R is Earth's radius.
 R=MI computes in miles. Default is nautical miles.
 
 >>> round(haversine(36.12, -86.67, 33.94, -118.40, R=6372.8), 5)
 2887.25995
 """
    Δ_lat = radians(lat_2) - radians(lat_1)
    Δ_lon = radians(lon_2) - radians(lon_1)
    lat_1 = radians(lat_1)
    lat_2 = radians(lat_2)
    a = sqrt(
        sin(Δ_lat / 2) ** 2 + cos(lat_1) * cos(lat_2) * sin(Δ_lon / 2) ** 2
    )
    return R * 2 * asin(a)

Note on the doctest example: The example uses an Earth radius with an extra decimal point that's not used elsewhere. This example will match other examples online. The Earth isn't spherical. Around the equator, a more accurate radius is 6378.1370 km. Across the poles, the radius is 6356.7523 km. We're using common approximations in the constants, separate from the more precise value used in the unit test case.

The problem we often have is that the value for R rarely changes. In a practical application, we may be consistently working in kilometers or nautical miles. We'd like to have a consistent, default value like R = NM to get nautical miles throughout our application.

There are several common approaches to providing a consistent value for an argument. We'll look at al.l of them.

How to do it...

In some cases, an overall context will establish a single value for a parameter. The value will rarely change. There are several common approaches to providing a consistent value for an argument. These involve wrapping the function in another function. There are several approaches:

  • Wrap the function in a new function.
  • Create a partial function. This has two further refinements:
  • We can provide keyword parameters
  • We can provide positional parameters

We'll look at each of these in separate variations in this recipe.

Wrapping a function

We can provide contextual values by wrapping a general function in a context-specific wrapper function:

  1. Make some parameters positional and some parameters keywords. We want the contextual features—the ones that rarely change—to be keywords. The parameters that change more frequently should be left as positional. We can follow the Forcing keyword-only arguments with the * separator recipe to do this. We might change the basic haversine function so that it looks like this:
    def haversine(lat_1: float, lon_1: float, 
        lat_2: float, lon_2: float, *, R: float) -> float: 
    
  2. We can then write a wrapper function that will apply all of the positional arguments, unmodified. It will supply the additional keyword argument as part of the long-running context:
    def nm_haversine_1(*args): 
        return haversine(*args, R=NM) 
    

    We have the *args construct in the function declaration to accept all positional argument values in a single tuple, args. We also have *args, when evaluating the haversine() function, to expand the tuple into all of the positional argument values to this function.

The lack of type hints in the nm_haversine_1() function is not an oversight. Using the *args construct, to pass a sequence of argument values to a number of parameters, makes it difficult to be sure each of the parameter type hints are properly reflected in the *args tuple. This isn't ideal, even though it's simple and passes the unit tests.

Creating a partial function with keyword parameters

A partial function is a function that has some of the argument values supplied. When we evaluate a partial function, we're mixing the previously supplied parameters with additional parameters. One approach is to use keyword parameters, similar to wrapping a function:

  1. We can follow the Forcing keyword-only arguments with the * separator recipe to do this. We might change the basic haversine function so that it looks like this:
    def haversine(lat_1: float, lon_1: float, 
        lat_2: float, lon_2: float, *, R: float) -> float: 
    
  2. Create a partial function using the keyword parameter:
    from functools import partial 
    nm_haversine_3 = partial(haversine, R=NM) 
    

The partial() function builds a new function from an existing function and a concrete set of argument values. The nm_haversine_3() function has a specific value for R provided when the partial was built.

We can use this like we'd use any other function:

>>> round(nm_haversine_3(36.12, -86.67, 33.94, -118.40), 2) 
1558.53 

We get an answer in nautical miles, allowing us to do boating-related calculations without having to patiently check that each time we used the haversine() function, it had R=NM as an argument.

Creating a partial function with positional parameters

A partial function is a function that has some of the argument values supplied. When we evaluate a partial function, we're supplying additional parameters. An alternative approach is to use positional parameters.

If we try to use partial() with positional arguments, we're constrained to providing the leftmost parameter values in the partial definition. This leads us to think of the first few arguments to a function as candidates for being hidden by a partial function or a wrapper:

  1. We might change the basic haversine function to put the radius parameter first. This makes it slightly easier to define a partial function. Here's how we'd change things:
    def p_haversine(
     R: float,
     lat_1: float, lon_1: float, lat_2: float, lon_2: float
    ) -> float: 
    
  2. Create a partial function using the positional parameter:
    from functools import partial
    nm_haversine_4 = partial(p_haversine, NM)
    

    The partial() function builds a new function from an existing function and a concrete set of argument values. The nm_haversine_4() function has a specific value for the first parameter, R, that's provided when the partial was built.

We can use this like we'd use any other function:

>>> round(nm_haversine_4(36.12, -86.67, 33.94, -118.40), 2)
1558.53

We get an answer in nautical miles, allowing us to do boating-related calculations without having to patiently check that each time we used the haversine() function, it had R=NM as an argument.

How it works...

The partial function is—essentially—identical to the wrapper function. While it saves us a line of code, it has a more important purpose. We can build partials freely in the middle of other, more complex, pieces of a program. We don't need to use a def statement for this.

Note that creating partial functions leads to a few additional considerations when looking at the order for positional parameters:

  • If we try to use *args, it must be defined last. This is a language requirement. It means that the parameters in front of this can be identified specifically; all the others become anonymous and can be passed – en masse – to the wrapped function. This anonymity means mypy can't confirm the parameters are being used correctly.
  • The leftmost positional parameters are easiest to provide a value for when creating a partial function.
  • The keyword-only parameters, after the * separator, are also a good choice.

These considerations can lead us to look at the leftmost argument as being more of a context: these are expected to change rarely and can be provided by partial function definitions.

There's more...

There's a third way to wrap a function—we can also build a lambda object. This will also work:

nm_haversine_L = lambda *args: haversine(*args, R=NM)

Notice that a lambda object is a function that's been stripped of its name and body. It's reduced to just two essentials:

  • The parameter list, *args, in this example
  • A single expression, which is the result, haversine(*args, R=NM)

A lambda cannot have any statements. If statements are needed in the body, we have to create a definition that includes a name and a body with multiple statements.

The lambda approach makes it difficult to create type hints. While it passes unit tests, it's difficult to work with. Creating type hints for lambdas is rather complex and looks like this:

NM_Hav = Callable[[float, float, float, float], float]
nm_haversine_5: NM_Hav = lambda lat_1, lon_1, lat_2, lon_2: haversine(
    lat_1, lon_1, lat_2, lon_2, R=NM
)

First, we define a callable type named NM_Hav. This callable accepts four float values and returns a float value. We can then create a lambda object, nm_haversine_5, of the NM_Hav type. This lambda uses the underlying haversine() function, and provides argument values by position so that the types can be checked by mypy. This is rather complex, and a function definition might be simpler than assigning a lambda object to a variable.

See also

  • We'll also look at extending this design further in the Writing testable scripts with the script library switch recipe.