Effective Python 21 - 25

Click here for the first post, which contains the context of this series.

Item #21: Understand how closures interact with variable scope.

Suppose that you have a list L of numbers, have a list G of important numbers, want to sort L while giving priority to the numbers of L that are in G, and want to know if a number in L is in G:

def my_sort(L, G):
    flag = False
    def helper(x):
        if x in G:
            flag = True
            return (0, x)
        return (1, x)
    L.sort(key=helper)
    return flag

This sorts L as expected but returns False: the problem is that flag = True in helper is a new variable due to how scoping works in Python. The fix is to use the keyword nonlocal:

def my_sort(L, G):
    flag = False
    def helper(x):
        nonlocal flag
        if x in G:
            flag = True
            return (0, x)
        return (1, x)
    L.sort(key=helper)
    return flag

However, it is better practice to wrap states in classes:

class MySort:
    def __init__(self, G):
        self.G = G
        self.flag = False

    def __call__(self, x):
        if x in self.G:
            self.flag = True
            return (0, x)
        return (1, x)

def my_sort(L, G):
    my_sort = MySort(G)
    L.sort(key=my_sort)
    return my_sort.flag

Item #22: Reduce visual noise with variable positional arguments.

Suppose that you have the following function:

def my_logger(message, items):
    return f'{message}{", ".join([str(x) for x in items])}'

numbers = [1, 2, 3]
print(my_logger('I like these numbers: ', numbers))
print(my_logger('I like no numbers.', []))

Passing an empty list is noisy. Instead, use positional arguments:

def my_logger(message, *items):
    return f'{message}{", ".join([str(x) for x in items])}'

numbers = [1, 2, 3]
print(my_logger('I like these numbers: ', *numbers))
print(my_logger('I like no numbers.'))

Note that *numbers converts numbers into a tuple, which means that if numbers were a massive generator, this would be resource-intensive.

Moreover, if you were to update the signature of my_logger to something like def my_logger(date, message, *items):, then not updating all of the calls to my_logger would introduce bugs that are hard to detect.

Item #23: Provide optional behavior with keyword arguments.

Note the use of **:

def flow_rate(weight_diff, time_diff, period=1, units_per_kg=1):
    return weight_diff * units_per_kg * period / time_diff

kwargs = {
    'weight_diff': 0.5,
    'time_diff': 3,
    'period': 3600,
    'units_per_kg': 2.2
}

print(flow_rate(**kwargs))

With optional arguments, do not do this: flow_rate(0.5, 3, 3600, 2.2). Instead, do this: flow_rate(0.5, 3, period=3600, units_per_kg=2.2).

Item #24: Use None and Docstring to specify dynamic default arguments.

Suppose that you run the following script:

def append_zero(x=[]):
    x.append(0)
    return x

a = append_zero()
b = append_zero()

It turns out that a and b are the same list, so both look like [0, 0]. This is because lists are dynamic and not static, like strings.

Initialize keyword arguments that have dynamic values with None, and document this in the docstring:

def append_zero(x=None):
    '''Append a zero to a list.

    Args:
        x: list. Defaults to an empty list.
    '''
    if not x:
        x = []
    x.append(0)
    return x

Item #25: Enforce clarity with keyword-only and positional-only arguments.

Suppose that you have the following division function:

def safe_division(number, divisor, ignore_overflow, ignore_zero_division):
    try:
        return number / divisor
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise

safe_division(12, 3, True, False)

This is noisy. An improvement would be to change the signature to

def safe_division(number, divisor, ignore_overflow=False, ignore_zero_division=False):
    # ...

safe_division(12, 3, ignore_overflow=True)

The problem is that this is still possible:

safe_division(12, 3, True, False)

Keyword-only arguments cannot be passed by position:

def safe_division(number, divisor, *, ignore_overflow=False, ignore_zero_division=False):
    # ...

safe_division(12, 3, True, False) # This gives an error.

Now, suppose that we change the signature to

def safe_division(numerator, denominator, *, ignore_overflow=False, ignore_zero_division=False):
    # ...

Then this name change could break multiple existing calls to the function.

Positional-only arguments cannot be passed by keyword:

def safe_division(numerator, denominator, /, *, ignore_overflow=False, ignore_zero_division=False):
    # ...

safe_division(numerator=10, denominator=2) # This gives an error.