<jupyter_start><jupyter_text>Worksheet 1.1.0: Functions    This worksheet implements to-do markers where work needs to be completed. In some cases, this means that you'll need to add a line or two to an example. In other cases (such as the final exercise), you may need to solve an entire problem. `def`initelyWhile we've used different _functions_ (`str`,`ord`,`int`,`print`, for example), many have been the so-called "built-ins" that come with Python. We don't see the code behind these functions because they're squirreled away in internal Python structure. However, we have seen one special user-defined function "in the wild" -- recall `Week 1`:```python IGNORE THIS def open_box(combo):    vals = [2, 6, 3]    pts = [a * b for a, b in zip(vals, combo)]    return sum(pts) IGNORE THIS ``` The above essentially packages and names instructions. At its most basic, that's what a function is -- a bundle of instructions with a convenient name attached to it. We could also draw a parallel between functions and variables; after all, they both essentially store some product of expressions. However, the key difference is that functions store _procedures_. In the above, the procedure is to print out the values of all of the variables at work in our program.The syntax, though a bit different from `while`, `for`, or `if` statements, behaves the same way with respect to _indentation_ -- anything indented one level (4 spaces) beneath the function's _declaration_ belongs to that function. The general pattern of _declaration_ follows:```python definition keyword | |     label for (name of) function |       | |       |   parameters -- must be present even if there are none |       |       |def FUNCTION_NAME():     expressions     statements    return variable or expression     |   return statement```The necessary parts here being:* the _declaration_ (`def FUNCTION_NAME():`)* the _parameters_ (the `()` in the above)* a `return` statement (though not always _required_, technically)Here's a simple function we can use to print a regular string:<jupyter_code>def hello_world():
    return "Hello, world!"

print(hello_world())<jupyter_output>Hello, world!<jupyter_text>In the above, we still respect _flow of control_, as the program runs top to bottom _until it encounters the function call_. At that point it jumps to the function, runs top to bottom _in it_ and then returns to where we left off.One key vocabulary change here is the word `parameter` -- which we can think of as the opposite of `arguments`. In the following scenario, we see both:```python          parameters          |        |def add(addend1, addend2):    return addend1 + addend2  <-- as long as an expression evaluates to a value, we can return it       arguments         | |print(add(2,2))``` 1. In the space below, finish the following function, given what we've learned so far.This function should: * be called `square`* accept a number referred to as `number`* multiply it against itself* store the outcome as `product`* `return` the value. Use `4` and `8` as test values and finish the `f-string` below to print it.<jupyter_code>def # TODO: Finish this declaration
    # TODO: Multiply parameter against itself
    # TODO: Write return statement

print(f"The square of 4 is {#TODO: Fill in with correct function call}")
print(f"The square of 8 is {#TODO: Fill in with correct function call}")<jupyter_output>The square of 4 is 16
The square of 8 is 64<jupyter_text>When we _call_ ("use") the function, we refer to the values supplied _arguments_. When we look at the function declaration, we refer to the values in the parenthesis as _parameters_. It's quite easy to get them confused, and for this class we might end up occasionally using the terms interchangeably.But, there is something interesting about the way Python functions work. Imagine a case where we wanted to add more than 2 numbers, but didn't know how many we actually wanted to add:<jupyter_code>#  Just like in a for loop, this variable
#  is created and assigned right here.
#           |
def add(*addends):
    # What did we get?
    print(f"I recieved: {addends}\n")
    # Let's add it
    total = 0
    for addend in addends:
        total += addend
    return total

print(f"The sum is: {add(2,2,2,2,2,2,2,2,2,2,2)}")

# Also check out what we can do with f-strings in the above -- neat.<jupyter_output>I recieved: (2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2)

The sum is: 22<jupyter_text>During our first data structures week, I said that we'd see `tuple`s again -- sure enough, we do! What we learn here is that functions pass things around like `tuple`s. In the case of the `*` operator in front of our variable, we automatically create a `tuple` out of all of the _arguments_ we pass to the function's _parameters_. 2. Finish the code below to write a function that can multipy the following numbers: `4`,`8`,`15`,`16`,`23`,`42`<jupyter_code>def multiply(*multiplicands):
    result = 1
    for multiplicand in multiplicands:
        result *= # TODO: Fill in with parameter
    # TODO: Complete return statement
    
print(f"The product of the numbers is {#TODO: Fill in with correct function call}")<jupyter_output>The product of the numbers is 6955200<jupyter_text>PolymorphismA big word, but an important idea: we can write functions in Python to _accept more than one data type or structure_. See the example below which accepts all of the data types we know thus far:<jupyter_code>def add_numbers(addends):
    sum = 0
    for addend in addends:
        sum += addend
    return sum

integers_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(f"The list sum is: {add_numbers(integers_list)}")

integers_dict = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
print(f"The dictionary sum is: {add_numbers(integers_dict)}")

integers_tuple = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
print(f"The tuple sum is: {add_numbers(integers_tuple)}")

integers_complicated = {
    0: "Zero",
    1: "One",
    2: "Two",
    3: "Three",
    4: "Four",
    5: "Five",
    6: "Six",
    7: "Seven",
    8: "Eight",
    9: "Nine"
}
print(f"The complicated sum is: {add_numbers(integers_complicated)}")<jupyter_output>The list sum is: 45
The dictionary sum is: 45
The tuple sum is: 45
The complicated sum is: 45<jupyter_text>We notice that the _syntax_ of the `add_numbers` function demonstrates something we already know: that `for` loops can iterate over all of these constructs in the same way. So, while "polymorphism" is a so-called "million dollar word," it's really just the practice of being able to handle different data types or structures in a standard all-encompassing/accommodating way. Default valuesOne of the last properties of functions is the abililty to accept _default values_:<jupyter_code>def best_cat_name(name="Ulysses"):
    print(f"The best cat name is: {name}")

best_cat_name("Bert")
best_cat_name()<jupyter_output>The best cat name is: Bert
The best cat name is: Ulysses
{'fraction_non_alphanumeric': 0.0858549297813812, 'fraction_numerical': 0.017228898219197916, 'mean_word_length': 4.143708116157856, 'pattern_counts': {'":': 0, '<': 20, '<?xml version=': 0, '>': 19, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '6', 'dir': 'jupyter-structured-clean-dedup', 'id': '423407', 'license': 'CC-BY-4.0', 'n_tokens_mistral': 2102, 'n_tokens_neox': 1957, 'n_words': 978, 'path': 'day-1/0_week-1-worksheet-functions.ipynb', 'repo_name': 'allegheny-college-cmpsc-100-jan-2021/week-1', 'size': '6907'}