Grok worksheet 12 PDF

Title Grok worksheet 12
Course Foundations Of Computing
Institution University of Melbourne
Pages 13
File Size 795 KB
File Type PDF
Total Downloads 12
Total Views 154

Summary

Grok worksheet 12 with summary and sample solutions...


Description

Grok 12 Overview Let's return to look at functions, and discuss them in greater detail, focusing on: what to do when there is no well-defined return value, how to return multiple values, return and "lazy evaluation", local variables and "namespace", and passing by value/reference. Along the way, we'll talk about an additional use for tuples, and make an important observation about dictionaries.

None Below is the code for finding the maximum value within a (single-argument) list, i.e.:

This is fine if numlist is non-empty, but what happens when it is an empty list? There are two possible answers to this: (1) fail (i.e. raise an exception of an appropriate type); or (2) run normally, but return a special value to indicate that there isn't a maximum value. The built-in max function opts for the first option, i.e., it throws an exception:

For the second option, we want to have some value where there isn't the possibility of a clash with a legal value for any legal input type (e.g. the value -1 wouldn't be appropriate, as that may be the actual maximum for a given input). In fact, more generally, we want some value which isn't a legal value for any type, suggesting that we need a new type altogether reserved specifically for this purpose. Fortunately, Python has such a type, namely NoneType, which takes the unique value of None. Note that this is different to the string "None", and is a reserved word (i.e. just like if or return, you can't use it as the name of a user-defined object).

Naturally, there is no value of any other type that it is equal to:

Non-returning Functions You may have noticed that functions don't need to have a return statement, and yet always have a return value, begging the question of where the return value comes from if there is no return statement. The answer is simple: there is an implicit (bare) return statement at the end of every function, and the default value for return is ... you guessed it, None. So, in fact, for our example function in the previous slide, the return value when called with an empty list is None, because it fails the condition in the if statement, and hits the implicit return statement at the end of the function:

The function of None return Write a function mymax() that takes a single argument numlist in the form of a list of numbers, and returns the highest number in numlist in the case that it is nonempty, and None otherwise. Note that you may use the built-in function max in your code. For example:

A First Attempt at Returning Multiple Values When we originally introduced return, we said that it took a unique argument, suggesting that it's not possible to return multiple values from a function. This seems like a curious blind spot in Python, but it turns out it's not a limitation at all, because the unique argument to return can be of a type that allows us to embed arbitrarily many "nested" values. Based on what we know already about lists, we can achieve this already — just as a list can serve as a single argument to a function but contain a arbitrarily many objects, a function can return a single list that contains a sequence of arbitrary objects. Consider, for example, that we want to write a function to find both the minimum and maximum value in a list of integers. A simple way of returning these two values would be to construct and return a 2-element list, as follows:

Multi-return with Tuples In practice, we tend not to use a list to return multiple values, but rather a tuple, because they are slightly more efficient and can't be inadvertently (or otherwise!) mutated, as follows for our example from the previous slide:

Multi-returning Functions Let's write our first function that returns multiple values, using a tuple. Write a function maxby(intlist)that takes a single argument intlist in the form of a list of integers, and returns a tuple (maxnum,bymargin), where maxnum is the maximum integer in the list and bymargin is the difference between maxnum and the next-highest value. In the case of a tie for the maximum value, the difference over the next highest value should be 0. In the case that intlist is an empty list, both values should be set to None; in the case of a singleton list, bymargin should be set to None. For example:

Returning from Danger Let's come back to look at return in a bit more detail briefly. A classic error in computing is to divide a number by zero, because the equation has no conventional solution. In Python it throws a run-time error (or technically speaking, it "raises an exception"):

Consider the following code: Note that, if run by itself, the final line of the function ( 1/0) should raise a ZeroDivisionError: and yet the function works. Why? Quite simply because the 1/0 is after the return statement, which aborts the running of the function entirely, meaning that line of code is never executed.

Returning on Time Building off our observation about return and aborting the execution of functions in the previous slide, we can now think about the interaction between control structures, iteration and return. First, consider the following function, which is designed to test whether a list of numbers contains all-positive numbers or not:

Contrast it with the following alternative (with identical functionality), which uses return to prematurely abort the iteration the moment a counter-example to the test is found (based on the observation that once a single counter-example is found, the return value is necessarily False):

This style of checking is called "lazy evaluation" and both more elegant and succinct, and potentially much more efficient (try to think of examples where this would be the case). The use of multiple exit points from a function is also very important in recursion, which we will return to later in the subject.

Another Viewpoint Observe the resulting execution of our previous example with print statements inserted (or perhaps — need I say it? — using pythontutor):

Notice how the loop doesn't go through the whole list. This makes the function more efficient. For this particular example list with four elements, we may expand the loop to the sequence of if...elif statements as below. The statements below are equivalent to the loop. With this in mind, it is easy to see that if we reach the end of the block of elif s, it must be the case that the condition (that the numbers were negative) evaluated to False on all the elements. This means that all the elements are positive, which is why we can return True.

Timely return Rewrite the provided code for the function issorted(numlist) that takes a single argument numlist in the form of a list of numbers, and returns True if numlist is in increasing sort-order (noting that ties between adjacent elements are allowed), and False otherwise. In rewriting the code, you should introduce (at least) one more return statement, which aborts the function prematurely when a local violation of the sort-order requirement is detected (expect to fail one of the hidden test cases if you don't!). For example:

Base-n Number Validation In a base $$n$$ number system, all numbers are written using only the digits $$\ {0,1,..,n-1\}$$. For example, in the decimal (= base 10) number system that you are used to using, all numbers are written using the digits 0,1,..,9, whereas in the binary (= base 2) number system, all numbers are written using the digits 0 and 1 only. Write a function basenum(num, base) that takes as arguments num (a non-negative integer) and base (a non-negative integer not greater than 10) and returns True if all digits of num are strictly less than base, and False otherwise (using lazy evaluation). Once again, expect to be tripped up by one of the hidden test cases if you do not use lazy evaluation. For example:

Namespace: Introduction It turns out that something very subtle but extremely important (the importance of which will become self-evident when we talk about recursion) happens when a function is called: any variables defined in the function are created in a local namespace (accessible only within the local function call), and destroyed on return from the function. To observe this, try running the following code, and looking at what is printed out:

To make sense of the output, when we trace the execution of the code relative to i, what happens is the following: 1. first, we create the variable i outside the function, and call the function using it 2. inside the function, we create a new variable i to assign each of the letters of word in the forloop 3. on return from the function, our original i (the string "banana") remains intact, whereas the variable vowels (and the variable i from inside the function) are no longer accessible

Local and Global Namespaces What is happening here is that, whenever a function is called, it creates a local namespace in which to define its own variables, completely separate to the global namespace outside the function. That is, in our preceding example, there is a global i, and separately a local i (in addition to a local word and vowels). By default, variables in functions are dereferenced relative to the local namespace, which is why i within the function always referred to the local variable. On return from a function, we permanently lose access to all locally-defined variables.

How Local? In practice, things are even more subtle than that. Try running the following code, and looking at what is printed out:

Have a look at this on pythontutor. This time, we have a nested function call — vowel_count calls the function is_vowel — and print(i)in the body of is_vowel. What is printed is, perhaps surprisingly, the global variable rather than the local variable in vowel_count (despite the function call to vowel_count still being "live"). It turns out that the explanation for this is simple: when a variable is encountered in a function, Python first attempts to dereference it relative to the local namespace (of the local function call), and failing this, it attempts to dereference it relative to the global namespace, bypassing the local namespace of any other functions. In fact, the "local" namespace of a given function call is exactly that — local to that function call, and inaccessible from any other function call.

Assigning to Global Variables in Functions — DON'T You could be forgiven for finding the code in the previous two examples confusing. It is generally considered bad practice to reference global variables in functions, and notoriously hard to debug. In fact, while Python allows you to implicitly dereference global variables in functions (i.e. use their values), if you attempt to assign a new value to a global variable within a function, all that will happen is that a local variable is created, e.g. try running:

Global Constants

As a final word on global variables, there are certainly instances where the use of global variables is appropriate, notably for (fixed-valued) parameters that will are used in various functions throughout a piece of code. In our previous example, e.g., the string containing our vowels may be used in other functions, in which case we might define it as a global variable:

A good typographical convention in these cases — to signify that a global variable is to be used with a fixed value variously in your code, including in functions — is to use an ALLCAPS variable name. Somewhat confusingly, these are generally termed global constants (despite being variables in Python).

Function parameters As something of an aside, note that just as you can define local variables, you can also define default arguments for function parameters. This means that you do not have to provide them when calling the function:

What the F(unction)?! Yes, we said that was the final word on global variables, but it turns out that there's an extra layer of subtlety when "mutable" types (lists, dictionaries and sets, for our purposes) are passed as arguments to functions which confuses the distinction somewhat. Recall what it means for a type to be mutable, relative to the following example, where we use the append method to internally-modify a list:

Based on what we have said about functions and local variables, read the following code and try to work out what the output will be, then run it to check whether your intuition was correct (remember what we saw when discussing mutability in Worksheet 8):

Despite the append method being called over a local variable, the mutation also applied to the global variable that was passed to the function. Surprising given our earlier comments about local variables and assignment? At first glance, possibly, but then again, consider the following code (with no functions, and nothing untoward up Python's sleeves):

Once again, surprising? Possibly, yes, but what we observed with the function is at least consistent with this behaviour.

Making Sense of Calling by Object What we observed in the examples on the previous slide (and an equivalent behaviour can be observed with dictionaries and other mutable types) is a result of what is sometimes known as call by object: when we assign a variable to an object directly (e.g. lst2 = lst), all we are really doing is creating a new "pseudonym" for the object, rather than creating an entirely new object. If the object grows a moustache (bad example, but you get the idea),

irrespective of which name it goes under (lst or lst2), the moustache is visible to all. Thus, when we mutate an object (in a function, or via a second variable name for the same object), the mutation is reflected in all variables that are bound to the object. If, on the other hand, as part of the assignment to a variable/function call, we create a new object (i.e. we do anything other than assign directly to an existing variable), we lose all connection between the two objects. For example, consider a close variant of the code from the preceding slide:

Here, a new object is created (lst2 + []) when we assign to lst2; it is based on the contents of lst, but is still a new object. Thus, when we mutate lst2, there is no impact on lst. Subtle? Absolutely! Details aside, the important thing to bear in mind is that direct assignment of variable to variable (including via a function call) results in a pseudonym being created, meaning any change to the object via one variable will be reflected in all of the other variables that point to the same object. And yes, this is one of the harder concepts to get your head around in this subject. Note that the reason Python opts to call by object is simple: it's more efficient, as the object could be arbitrarily large/complex (it could be a long list of lists, e.g.), making copying very expensive....


Similar Free PDFs