############################################################
# specifying behavior via parameters
############################################################


# Consider the following two functions and run the lines below that call
# them. It should be apparent that this code simply keeps the odd or the
# even numbers in a list.


def keep_odds(sequence):
    result = []
    for elt in sequence:
        if elt % 2 == 1:
            result.append(elt)
    return result


def keep_evens(sequence):
    result = []
    for elt in sequence:
        if elt % 2 == 0:
            result.append(elt)
    return result


count_nums = list(range(1, 10))
print(keep_odds(count_nums))
print(keep_evens(count_nums))


# This code works, but it should bother you that we had to write two
# nearly identical functions that differ only in the comparison value.
# A lot of value we get from functions in programming is the ability to
# reuse code and reduce duplication. Is there a way we could achieve
# that here?

# Read the following three functions, and run the following two
# `print()` lines. Along the way, draw a diagram of the objects and
# frames to explain what's happening.

# Now that pythontutor.com was introduced during recitation, you can
# also use it to check your work.


def filtered_list(sequence, test):
    result = []
    for elt in sequence:
        if test(elt):
            result.append(elt)
    return result


def is_odd(x):
    return x % 2 == 1


def is_even(x):
    return x % 2 == 0


# print(filtered_list(count_nums, is_odd))
# print(filtered_list(count_nums, is_even))


# The function `filtered_list()` has the same structure as `keep_odds()`
# and `keep_evens()`, except it takes in a second parameter `test` and
# uses it in the `if` condition. This parameter is treated as a function
# object because we call it with `elt`. Thus, when we call
# `filtered_list()`, we are expected to pass in an existing function
# object.

# Remember when we define a function, the name of the function becomes a
# variable in the global frame, pointing to a new function object. Thus,
# the names `is_odd` and `is_even` evaluate to such objects that we can
# pass into `filtered_list()`.

# [Note: To be technically precise, `filtered_list` refers to a function
# object, and `filtered_list(...)` is a function call that evaluates to
# what the function returns. However, it is often convenient in writing
# to refer to the function object as `filtered_list()`, simply because
# the parents `()` connotes it is a function. The official Python
# documentation uses this convention, and so will we.]


# EXERCISE: The two functions `is_odd()` and `is_even()` are quite
# short, but they're still extremely similar, and unintentional
# mismatches are a common source of bugs. Can you replace the `return`
# expression in `is_even()` with something less repetitive?


# With `filtered_list()`, we are not limited to only keeping odd or even
# numbers, but we can apply the same idea to filter other lists by any
# property we can think of. For example, below is a list of strings, and
# we keep only those that have odd or even length.


def odd_length(seq):
    return is_odd(len(seq))


def even_length(seq):
    return not odd_length(seq)


count_ordinals = [
    "first", "second", "third",
    "fourth", "fifth", "sixth",
    "seventh", "eighth", "ninth",
]
# print(filtered_list(count_ordinals, odd_length))
# print(filtered_list(count_ordinals, even_length))


# It's important to observe how `filtered_list()` calls and uses the
# function we pass into it. I.e., it takes in a single parameter and
# returns a True or False value. Thus, any function we pass into a call
# to `filtered_list()` must follow this input/output structure. When
# filtering the `count_ordinals` list, it is appropriate to use
# `odd_length()` or `even_length()` above, because they verify a
# property of any element of `count_ordinals`.

# Below is yet another example, where we change the property to be
# whether a string has more than one vowel.


def num_vowels(word):
    count = 0
    for vowel in "aeiou":
        count += word.count(vowel)
    return count


def multiple_vowels(word):
    return num_vowels(word) > 1


# print(filtered_list(count_ordinals, multiple_vowels))


# If we understand functions to be encapsulated programs that express
# behavior, then the concept of passing in functions as parameters to
# other functions lets us **customize** those other functions'
# behaviors. I.e., we're saying , "Use **this behavior** to perform your
# task."

# The ability to do this is often referred to as functions being
# **first-class** objects in a programming language. This is just a
# fancy way of saying we can create function objects and refer to them
# with variables. Not all languages support this, but modern languages
# increasingly do.


############################################################
# another example
############################################################


# Here is another scenario, where the idea is to evaluate several
# mathematical functions of a single variable `x`, and to identify which
# function sits above the other ones at any given `x`. On your own, try
# graphing on paper the three functions below, f(), g(), h(), over the
# domain [-10, 10]. Then run the code and see if you agree with the max
# value at each `x`.

# The key programming concept here is instead of just passing in a
# single function as a parameter, we pass in a list of functions. We
# iterate over this list in `multiple_eval()` to retrieve each function
# object of interest and call it on a given input `x`.


def multiple_eval(x, funcs):
    results = []
    for f in funcs:
        results.append(f(x))
    return results


def keep_max_evals(domain, funcs):
    results = []
    for x in domain:
        results.append(max(multiple_eval(x, funcs)))
    return results


def f(x):
    return (x - 3) ** 2 + 1

def g(x):
    return 2 * x + 3

def h(x):
    return 10


# print(keep_max_evals(range(-10, 11), [f, g, h]))
