def pet_names(name1, name2):
    print("Pet 1: {}".format(name1))
    print("Pet 2: {}".format(name2))

pet1 = "King"
pet2 = "Reginald"
pet_names(pet1, pet2)
pet_names(pet2, pet1)

"""
Explanation: Lecture 10: More on Functions
CSCI 1360E: Foundations for Informatics and Analytics
Overview and Objectives
In the previous lecture, we went over the basics of functions. Here, we'll expand a little bit on some of the finer points of function arguments that can both be useful but also be huge sources of confusion. By the end of the lecture, you should be able to

Differentiate positional arguments from keyword arguments
Construct functions that take any number of arguments, in positional or key-value format
Explain "pass by value" and contrast it with "pass by reference", and why certain Python types can be modified in functions while others can't

Part 1: Keyword Arguments
In the previous lecture we learned about positional arguments. As the name implies, position is key:
End of explanation
"""


def pet_names(name1, name2):
    print("Pet 1: {}".format(name1))
    print("Pet 2: {}".format(name2))

"""
Explanation: In this example, we switched the ordering of the arguments between the two function calls; consequently, the ordering of the arguments inside the function were also flipped. Hence, positional: position matters.
In contrast, Python also has keyword arguments, where order no longer matters as long as you specify the keyword. We can use the same function as before:
End of explanation
"""


pet1 = "Rocco"
pet2 = "Lucy"
pet_names(name1 = pet1, name2 = pet2)
pet_names(name2 = pet2, name1 = pet1)

"""
Explanation: Only this time, we'll use the names of the arguments themselves (aka, keywords):
End of explanation
"""


def make_pizza(*toppings):
    print("Making a pizza with the following toppings:")
    for topping in toppings:
        print(" - {}".format(topping))

make_pizza("pepperoni")
make_pizza("pepperoni", "banana peppers", "green peppers", "mushrooms")

"""
Explanation: As you can see, we used the names of the arguments from the function header itself, setting them equal to the variable we wanted to use for that argument. Consequently, order doesn't matter--Python can see that, in both function calls, we're setting name1 = pet1 and name2 = pet2.
Keyword arguments are extremely useful when it comes to default arguments.
If you take a look at any NumPy API--even the documentation for numpy.array--there are LOTS of default arguments. Trying to remember their ordering is a pointless task. What's much easier is to simply remember the name of the argument--the keyword--and use that to override any default argument you want to change.
Ordering of the keyword arguments doesn't matter; that's why we can specify some of the default parameters by keyword, leaving others at their defaults, and Python doesn't complain.
Part 2: Passing an Arbitrary Number of Arguments
There are instances where you'll want to pass in an arbitrary number of arguments to a function, a number which isn't known until the function is called and could change from call to call!
On one hand, you could consider just passing in a single list, thereby obviating the need. That's more or less what actually happens here, but the syntax is a tiny bit different.
Here's an example: a function which lists out pizza toppings. Note the format of the input argument(s):
End of explanation
"""


def build_profile(**user_info):
    profile = {}
    for key, value in user_info.items():
        profile[key] = value
    return profile

profile = build_profile(firstname = "Shannon", lastname = "Quinn", university = "UGA")
print(profile)
profile = build_profile(name = "Shannon Quinn", department = "Computer Science")
print(profile)

"""
Explanation: Inside the function, it's basically treated as a list: in fact, it is a list.
So why not just make the input argument a single variable which is a list? Convenience. In some sense, it's more intuitive to the programmer calling the function to just list out a bunch of things, rather than putting them all in a list first. But that argument could go either way depending on the person and the circumstance, most likely.
With variable-length arguments, you may very well ask: this is cool, but it doesn't seem like I can make keyword arguments work in this setting? And to that I would say, absolutely correct!
So we have a slight variation to accommodate keyword arguments in the realm of including arbitrary numbers of arguments:
End of explanation
"""


def build_better_profile(firstname, lastname, *nicknames, **user_info):
    profile = {'First Name': firstname, 'Last Name': lastname}
    for key, value in user_info.items():
        profile[key] = value
    profile['Nicknames'] = nicknames
    return profile

profile = build_better_profile("Shannon", "Quinn", "Professor", "Doctor", "Master of Science",
                               department = "Computer Science", university = "UGA")
for key, value in profile.items():
    print("{}: {}".format(key, value))

"""
Explanation: Instead of one * in the function header, there are two. And yes, instead of a list when we get to the inside of the function, now we basically have a dictionary! 
Arbitrary arguments (either "lists" or "dictionaries") can be mixed with positional arguments, as well as with each other.
End of explanation
"""


def magic_function(x):
    x = 20
    print("Inside function: {}".format(x))

x = 10
print("Before function: {}".format(x))
magic_function(x)
print("After function: {}".format(x))

"""
Explanation: We have our positional or keyword arguments (they're used as positional arguments here) in the form of firstname and lastname
*nicknames is an arbitrary list of arguments, so anything beyond the positional / keyword (or default!) arguments will be considered part of this aggregate
**user_info is comprised of any key-value pairs that are not among the default arguments; in this case, those are department and university

Part 2: Pass-by-value vs Pass-by-reference
This is arguably one of the trickiest parts of programming, so please ask questions if you're having trouble.
Let's start with an example to illustrate what's this is. Take the following code:
End of explanation
"""


def magic_function2(x):
    x[0] = 20
    print("Inside function: {}".format(x))

x = [10, 10]
print("Before function: {}".format(x))
magic_function2(x)
print("After function: {}".format(x))

"""
Explanation: What will the print() statement at the end print? 10? 20? Something else?
It prints 10. Before explaining, let's take another example.
End of explanation
"""


some_list = [1, 2, 3]

# some_list -> reference to my list
# [1, 2, 3] -> the actual, physical list

"""
Explanation: What will the print() statement at the end print? [10, 10]? [20, 10]? Something else?
It prints [20, 10].
To recap, what we've seen is that

We tried to modify an integer function argument. It worked inside the function, but once the function completed, the old value returned.
We modified a list element of a function argument. It worked inside the function, and the changes were still there after the function ended.

Explaining these seemingly-divergent behaviors is the tricky part, but to give you the punchline:


1 above is an example of pass by value, in which the value of the argument is copied when the function is called, and then discarded when the function ends, hence the variable retaining its original value.


2 above is an example of pass by reference, in which a reference to the list--not the list itself!--is passed to the function. This reference still points to the original list, so any changes made inside the function are also made to the original list, and therefore persist when the function is finished.


StackOverflow has a great gif to represent this process in pictures:

In pass by value (on the right), the cup (argument) is outright copied, so any changes made to it inside the function vanish when the function is done.
In pass by reference (on the left), only a reference to the cup is given to the function. This reference, however, "refers" to the original cup, so changes made to the reference are propagated back to the original.
What are "references"?
So what are these mysterious references? Glad you asked!
Imagine you're throwing a party for some friends who have never visited your house before. They ask you for directions (or, given we live in the age of Google Maps, they ask for your home address).
Rather than try to hand them your entire house, or put your physical house on Google Maps (I mean this quite literally), what do you do? You write down your home address on a piece of paper (or, realistically, send a text message).
This is not your house, but it is a reference to your house. It's small, compact, and easy to give out--as opposed to your physical, literal home--while intrinsically providing a path to the real thing.
So it is with references. They hearken back to ye olde computre ayge when fast memory was a precious commodity measured in kilobytes, which is not enough memory to store even the Facebook home page.
It was, however, enough to store the address. These addresses, or references, would point to specific locations in the larger, much slower main memory hard disks where all the larger data objects would be saved.
Scanning through larger, extremely slow hard disks looking for the object itself would be akin to driving through every neighborhood in the city of Atlanta looking for a specific house. Possible, sure, but not very efficient. Much faster to have the address in-hand and drive directly there whenever you need to.
That doesn't explain why the list changed in the function
Very astute. This has to do with a subtle but important difference in how Python passes variables of different types to functions.


For the "primitive" variable types--int, float, string--they're passed by value. These are [typically] small enough to be passed directly to functions. However, in doing so, they are copied upon entering the function, and these copies vanish when the function ends.


For the "object" variable types--lists, sets, dictionaries, NumPy arrays, generators, and pretty much anything else that builds on "primitive" types--they're passed by reference. This means you can modify the values inside these objects while you're still in the function, and those modifications will persist even after the function ends.


Think of references as "arrows"--they refer to your actual objects, like lists or NumPy arrays. The name with which you refer to your object is the reference.
End of explanation
"""


