<jupyter_start><jupyter_text>Functional Programming  Functions Are Just Objects What's the difference between an integer and a function?<jupyter_code>a = 42
def add_one(x):
    return x + 1<jupyter_output><empty_output><jupyter_text>A function has a type, just like an integer:<jupyter_code>type(a), type(add_one)<jupyter_output><empty_output><jupyter_text>A function has an identity, just like an integer:<jupyter_code>id(a), id(add_one)<jupyter_output><empty_output><jupyter_text>`add_one` is just a _name_ of a function object, just as `a` is the _name_ of the integer object<jupyter_code>add_one, add_one(a)<jupyter_output><empty_output><jupyter_text>A function has attributes, like other objects:<jupyter_code>dir(add_one)<jupyter_output><empty_output><jupyter_text>Note the `__call__()` magic method:<jupyter_code>add_one.__call__(42)
from dataclasses import dataclass<jupyter_output><empty_output><jupyter_text>We can define classes having a `__call__()` method<jupyter_code>@dataclass
class AddN:
    n: int
    
    def __call__(self, x):
        return x + self.n<jupyter_output><empty_output><jupyter_text>... and call objects of these classes as if they were functions:<jupyter_code>add_two = AddN(2)
add_two(42)<jupyter_output><empty_output><jupyter_text>So a function is nothing else than an object with a `__call__()` method implemented. Every mention of _callable_ in documentation or type hints means that the "callable" object has a `__call__()` implementation.<jupyter_code>callable(add_one), callable(add_two)<jupyter_output><empty_output><jupyter_text>__Discussion__: What would be a good use case for having an (obviously more complex) class with a `__call__()` method instead of a simple function definition?  Functions as Arguments Because functions are objects, we can pass them as arguments to other functions:<jupyter_code>from typing import Callable, Iterable, List, Any

def mapper(fn: Callable, l: Iterable[Any]) -> List[Any]:
    return [fn(x) for x in l]
mapper(add_one, [1, 10, 100])
def is_even(n: int) -> bool:
    return n % 2 == 0

def filters(filter_fn: Callable, l: Iterable[Any]) -> List[Any]:
    return [x for x in l if filter_fn(x)]
filters(is_even, [1, 2, 3, 4])<jupyter_output><empty_output><jupyter_text>Our `mapper()` and `filters()` functions were just there for illustration purposes: Python has the built-in functions [`map()`](https://docs.python.org/3/library/functions.htmlmap) and [`filter()`](https://docs.python.org/3/library/functions.htmlfilter).<jupyter_code>import pandas as pd
transaction_df = pd.DataFrame({
    'amount': [42., 100., 999.],
    'from': ['bob', 'alice', 'bob'],
    'to': ['alice', 'bob', 'alice']
})
transaction_df<jupyter_output><empty_output><jupyter_text>Let's define a function that indicates if the amount of a transaction is larger than 100:<jupyter_code>def select_large_transactions(transaction_df):
    return transaction_df['amount'] > 100  # Where does transaction_df refer to?<jupyter_output><empty_output><jupyter_text>Since `.loc[]` accepts a _callable_ as input, we can pass our function to it:<jupyter_code>transaction_df.loc[select_large_transactions]<jupyter_output><empty_output><jupyter_text>Note that we didn't _call_ the function ourselves. It was `.loc[]` that called our function, and it was `loc[]` that passed whatever DataFrame it was bound to at that moment as the first argument to our function. We can similarly pass functions to `assign()`:<jupyter_code>def get_commission(transaction_df):
    return transaction_df['amount'] * 0.05
transaction_df.assign(commission=get_commission)<jupyter_output><empty_output><jupyter_text>We can also _freeze_ some arguments of a function before passing it using [`functools.partial()`](https://docs.python.org/3/library/functools.htmlfunctools.partial) from the standard library:<jupyter_code>from functools import partial
def add_n(x, n=1):
    return x + n

add_three = partial(add_n, n=3)
add_three(10)
def get_commission(transaction_df, commission_percent=5):
    return transaction_df['amount'] * (commission_percent / 100)
transaction_df.assign(commission=partial(get_commission, commission_percent=10))<jupyter_output><empty_output><jupyter_text>Functions Returning Functions If functions can accept other functions as arguments, they surely can also _return_ functions:<jupyter_code>def add_n(n):
    def adder(x):
        return x + n
    return adder
add_two = add_n(2)
add_two, type(add_two)
add_two(42)
mapper(add_two, [1, 42, 100])
mapper(add_two, [1, None, 100])<jupyter_output><empty_output><jupyter_text>There's a problem when we pass `None` (or other unexpected types) to the function that we apply on our iterable. We can fix it by modifying the adder function:<jupyter_code>def add_one(x):
    if x is not None:
        return x + 1
    else:
        return None

mapper(add_one, [1, None, 100])<jupyter_output><empty_output><jupyter_text>Why is this sub-optimal? What if there's an `add_two()` etc? Do we need to repeat all the checking for None everywhere?<jupyter_code>def skip_None(fn):
    def fn_wrapper(n):
        if n is not None:
            return fn(n)
        else:
            return None
    
    return fn_wrapper<jupyter_output><empty_output><jupyter_text>A solution is to define a _wrapper_ function `fn_wrapper()` that intercepts calls to the wrapped function `fn`, and only calls the wrapped function (and returns its result) if the arguments passed to the wrapped function are valid. Note that this wrapper function is defined inside the body of another function `skip_None()`, which accepts the actual function to be wrapped.<jupyter_code>def add_one(x):
    return x + 1

def add_two(x):
    return x + 2

add_one_wrapped = skip_None(add_one)
add_two_wrapped = skip_None(add_two)
mapper(add_one_wrapped, [1, None, 100]), mapper(add_two_wrapped, [1, None, 100])<jupyter_output><empty_output><jupyter_text>Instead of manually calling `skip_None()` to wrap our adder functions as above, Python has the `@` _decorator_ construct, which is just a syntactic shortcut to help us write more concise code. The following snippets are all equivalent:<jupyter_code>def add_one(x):
    return x + 1

skip_None(add_one)(1), skip_None(add_one)(None)
def add_one(x):
    return x + 1

add_one = skip_None(add_one)
add_one(1), add_one(None)
@skip_None
def add_one(x):
    return x + 1

add_one(1), add_one(None)<jupyter_output><empty_output><jupyter_text>We can also write decorators that accept arguments. These add a second layer of inner function nesting:<jupyter_code>def check_value(max_expected):
    def value_check_decorator(fn):
        def fn_wrapper(n):
            if n > max_expected:
                print(f'Unusual value {n}, expected a maximum of {max_expected}')
            return fn(n)
        return fn_wrapper
    return value_check_decorator

@check_value(max_expected=42)
def add_one(x):
    return x + 1

mapper(add_one, [1, 42, 100])<jupyter_output><empty_output><jupyter_text>Seeing decorators like these for the first time may be confusing. The following snippets clarify the nesting of functions:<jupyter_code>def add_one(x):
    return x + 1

max_41_checker = check_value(max_expected=41)
add_one_with_max_41_checking = max_41_checker(add_one)
add_one_with_max_41_checking(40), add_one_with_max_41_checking(42)
(
    check_value(max_expected=41)(add_one)(40),
    check_value(max_expected=41)(add_one)(42)
)<jupyter_output><empty_output><jupyter_text>Anonymous Functions When we intend to use a single-purpose function that consists of returning the evaluation of one expression, we can use _lambda expressions_ instead of defining a (named) function:<jupyter_code>mapper(lambda x: x + 10, [1, 42, 100])<jupyter_output><empty_output><jupyter_text>Even a lambda expression ... is just an object (of type `function`)!<jupyter_code>type(lambda x: x + 10)
id(lambda x: x + 10)
dir(lambda x: x + 10)
(lambda x: x + 10)(42)<jupyter_output><empty_output><jupyter_text>We can even assign a lambda expression to a variable, but this is discouraged in the "official" [Python Style Guide](https://www.python.org/dev/peps/pep-0008/).<jupyter_code># Don't do this IRL
add_ten = lambda x: x + 10
add_ten(42)<jupyter_output><empty_output><jupyter_text>Lambda expressions can have more than one argument:<jupyter_code>from functools import reduce
reduce(lambda x, y: x + y, [1, 2, 3])<jupyter_output><empty_output><jupyter_text>Exercises Let's look back at our Vector class from the previous module.<jupyter_code>from dataclasses import dataclass
@dataclass
class Vector:
    values: List[float]
    
    def __getitem__(self, index: int):
        return self.values[index]
    
    def __len__(self):
        return len(self.values)
    
    def __mul__(self, scalar: float):
        return Vector([v * scalar for v in self.values])
    
    def __add__(self, other: 'Vector'):
        return Vector([self[i] + other[i] for i in range(len(self))])<jupyter_output><empty_output><jupyter_text>__Exercise__: Add a method `pipe()` to our `Vector` class, which accepts as argument a function that transforms its input vector argument to another vector. As an example of such transformation functions, consider `rotate_right()` and `rotate_left()` given below. Test your implementation with the assertions below.<jupyter_code>def rotate_right(v: Vector) -> Vector:
    # rotates input by 90 degrees clockwise
    return Vector([v[1], -v[0]])

def rotate_left(v: Vector) -> Vector:
    # rotates input by 90 degrees counterclockwise
    return Vector([-v[1], v[0]])
# Your solution:
# %load solutions/vector_pipe.py
assert Vector([1, 1]).pipe(rotate_right) == Vector([1, -1])
assert Vector([1, 1]).pipe(rotate_left).pipe(rotate_left) == Vector([-1, -1])<jupyter_output><empty_output><jupyter_text>__Bonus Exercise__: Make sure that our `pipe()` method can accept additional arguments: create a generic `rotate()` function that accepts an optional `direction` keyword argument with possible values `'right'`, `'clockwise'`, `'left'`, `'counterclockwise'`. If an invalid or no `direction` keyword argument is provided, `rotate()` should return the original vector. See the assertions below for the expected behavior of the solution.<jupyter_code># Your solution:
# %load solutions/vector_pipe_vararg.py
# bonus, have one generic rotation function
assert Vector([1, 1]).pipe(rotate) == Vector([1, 1])
assert Vector([1, 1]).pipe(rotate, direction='right').pipe(rotate, direction='counterclockwise') == Vector([1, 1])
assert Vector([1, 1]).pipe(rotate, direction='clockwise').pipe(rotate, direction='left') == Vector([1, 1])
# bonus, deal with any Vector manipulation function
assert Vector([1, 1]).pipe(lambda v: Vector([v[0] * 42, v[1] * 99])) == Vector([42, 99])<jupyter_output><empty_output><jupyter_text>By now, the pandas `pipe()` method should have no secrets anymore:<jupyter_code>transaction_df
def select_amount_greater_than(tx_df, amount=100):
    return tx_df.loc[lambda df: df['amount'] > amount]
transaction_df.pipe(select_amount_greater_than)
transaction_df.pipe(select_amount_greater_than, amount=99)
transaction_df.pipe(lambda df: df.loc[df['to'].isin(['bob', 'carol'])])<jupyter_output><empty_output>