Midterm 2 Walkthrough

1. A Bug’s Life

Wasp

Richard is trying to write a generator function that maps a given iterable using a one-argument function. Here’s what he comes up with:

def map_gen(fn, iterable):
    for it in iterable:
        yield from fn(it)

i. (0.5 pt) Richard tests out his function by running the code below. He expected to see [2, 4, 6, 8] but instead gets an error when he runs these lines in an interpreter:

>>> data = [1, 2, 3, 4]
>>> fn = lambda x: x * 2
>>> list(map_gen(fn, data))

Which line of code in the function caused the code to break?

ii. (0.5 pt) Rewrite the buggy line of code you identified above to fix the error.

Walkthrough

Remember that yield from keyword only expects an iterable expression next to it. The other way to think about it:

yield from itr 

# code above is equivalent to the code below

for e in itr:
    yield e

This means that we can rewrite Richard’s function as:

def map_gen(fn, iterable):
    for it in iterable:
        for e in fn(it): 
            yield e

From this, we can conclude that fn(it) has to be an iterable. However, the test code that Richard is using defines fn as lambda function that multiplies the argument by 2, which is unlikely to produce an iterable. In map_gen, the function fn is used with it, which represents the contents of iterable argument. In the test code, iterable is a list data, so it is just a number that gets fed into fn function. Therefore, fn(it) would never result in an iterable, causing an error for yield from that actually expects an iterable.

A quick fix would be to not use yield from at all and yield the elements of iterable after applying fn on them:

def map_gen(fn, iterable):
    for it in iterable:
        yield fn(it)

Moth

Melanie is trying to write some code to represent a cylinder in Python. This is her code:

class Cylinder:
    def __init__(self, radius, height):
        self.radius = radius
        self.height = height

    def volume(self):
        volume = 3.14 * self.radius ** 2 * self.height
        return volume

    def percent_filled(self, volume_of_water):
        return (volume_of_water / self.volume) * 100

i. (0.5 pt) Melanie runs the following code expecting to see it return 50.0, since 392.5 is a half-empty cyl (or is it half-full?).

>>> cyl = Cylinder(5, 10)
>>> cyl.percent_filled(392.5)

Unfortunately, the code results in this error instead:

Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "/Users/melanie/cylinder.py", line 11, in percent_filled
		return (volume_of_water / self.volume) * 100
TypeError: unsupported operand type(s) for /: 'int' and 'method'

According to this traceback, which method contains an error?

ii. (1.0 pt) Rewrite the buggy line of code from the method that you identified above to fix the error.

Walkthrough

Reading through the traceback, we could see that we got the TypeError on the line 11, which corresponds to the method percent_filled.

Continuing to read the error message closely, we see that the code attempted to divide the argument volume_of_water (which is a number) by self.volume (which has a function type, since volume is defined as a method of Cylinder class). We can fix this code by actually using the result of volume method, which means we would have to call it.

def percent_filled(self, volume_of_water):
    return (volume_of_water / self.volume()) * 100

# Now volume_of_water is divided by the result of self.volume(), which is also a number

Bumblebee

Amritansh steps up with a challenge of his own. He’s writing some code to flatten lists, but he seems to be running into some errors. Here’s his code:

def flatten(lst):
    res = []
    for el in lst:
        if isinstance(el, list):
            res += [res.extend(flatten(el))]   
        else:
            res += [el]
    return res

i. (1.0 pt) He runs this on a nested list, as below:

>>> my_data = ["Mialy", ["Daphne", "Jordan"]]
>>> flatten(my_data)

The result he expects is ["Mialy", "Daphne", "Jordan"]. What is the actual result?

ii. (0.5 pt) What line number causes the unexpected output to appear?
iii. (1.0 pt) Rewrite the buggy line you identified above so that Amritansh gets the expected output.

Walkthrough

my_data is a list of two elements: "Mialy" and ["Daphne", "Jordan"]. It means that the for-loop in the body of flatten (line 3) will perform 2 iterations. On the first iteration, el refers to a string "Mialy" . It is not an instance of a list, so it just gets added to res. For now, res looks like ["Mialy"].

However, on the next iteration, el refers to ["Daphne", "Jordan"], forcing the code to enter the if-condition body. There, we are executing the line res += [res.extend(flatten(el))]. Let’s break this line down:

Before adding a list on the right hand side to res with +=, we have to know what that list is. It contains a single element, which is a result of evaluating res.extend(flatten(["Daphne", "Jordan"])).

The argument of extend is a function call flatten(["Daphne", "Jordan"]) , which would return a list ["Daphne", "Jordan"] back because there is nothing to flatten there (try executing Amritansh’s code to convince yourself).

So now we are evaluating res.extend(["Daphne", "Jordan"]). extend mutates the list by adding the contents of its argument. res that was ["Mialy"] turns into ["Mialy", "Daphne", "Jordan"]. However, after completing its job, a call to extend evaluates to None, turning the list on the right hand side into [None]. Here is a link to PythonTutor which can help you to visualize the changes.

So even though we’ve added two elements to res, we still have to perform += with the right hand side, adding an additional 4-th element to res.

At the end, res looks like ["Mialy", "Daphne", "Jordan", None] because the evaluation result of extend was added to res. Fixing the behavior should focus on line 5 because that is where we are calling extend but also using its evaluation result. Getting rid of += like this: res.extend(flatten(el)) or completely avoiding the use of extend like: res += flatten(el) will do the job.

2. What Would Python Display?

Question

Assume the following code has been run (you may find a box-and-pointer diagram useful):

>>> lst1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> number_one = lst1.remove(1)
>>> some_number = lst1.pop()
>>> lst2 = lst1
>>> lst3 = lst2[:]
>>> lst1.insert(4, lst3)
>>> lst3.extend([lst1.pop() for _ in range(3)])
>>> lst2.append(lst1.remove(lst3))

For each part below, write the output of running the given code. Assume that none of the lines below will error.

(a) >>> lst1[4:] + [number_one, some_number]

(b) >>> lst3[5:9]

(c) >>> next(reversed(lst3[:5]))

(d) >>> lst1 is lst2

Walkthrough

This is a question which is much easier to answer if you draw out the state of the list at each stage, similar to the visualization offered by PythonTutor. Thus, for the walkthrough, we'll go through the steps shown in PythonTutor.

Here is the link to Python tutor with the code from this question. The bullet points below describe what happens after each step of the environment diagram. After reaching the final step, you will have the correct state of all variables and ready to answer the parts (a)-(d).

Environment diagram steps:

  • Step 1: Create a list lst1 with numbers from 1-10.
  • Step 2: remove method modifies the list by removing the first occurrence of the argument (1 in this case), going left to right. So 1 gets removed from lst1. However, the call to remove will evaluate to None and that is the final value of number_one variable.
  • Step 3: pop deletes the last element of the list and also returns that last element, setting some_number variable to 10. After remove(1), followed by pop(), lst1 has numbers from 2 to 9.
  • Step 4: we create variable lst2 which just points to the same list object as lst1.
  • Step 5: we are creating a copy of lst2 with slicing and since lst2 refers to lst1, we copy its contents to form a new list object. lst3 will point to that object.
  • Step 6: insert method sticks in the second argument as a new element into the position specified by the first argument. In this case, for the 4-th index of the list, we add a box and add a pointer to the list lst3. When we add a list as a single object, we add a pointer to it.
  • Steps 7-13: we are going to call lst3.extend() with list comprehension expression as an argument. Let’s evaluate it first. The list comprehension essentially “pop”-s 3 elements from lst1 and populates the list with them. The last three elements (counting from backwards since pop deletes the last element every time it gets called) from lst1 form the list that looks like [9, 8, 7]. Now our original call looks like lst3.extend([9, 8, 7]). We extend the lst3 (which still has its “own” 7, 8, 9 numbers, since it was copied from lst1 in the past). Therefore, lst1 loses three elements and looks like [2, 3, 4, 5, lst3, 6], while lst3 gains three elements and now is [2, 3, 4, 5, 6, 7, 8, 9, 9, 8, 7].
  • Step 14: append adds its argument as a single element into lst2. What is the argument? The result of evaluating lst1.remove(lst3)). From step 2, we know that remove will just delete the first occurrence of lst3, which is at index 4 of lst1. After deleting that element, the remove method returns None. So lst1 loses one more element, but gains None since we are executing lst2.append(None) (lst1 and lst2 are referring to the same object).

Congrats, you have the final state of the environment! Let’s answer the questions:

Blank (a)

The expression lst1[4:] + [number_one, some_number] first takes a slice of all elements of lst1 starting from index 4. This would be equal to [6, None] . The second operand in our expression is [number_one, some_number], which would evaluate to [None, 10]. Adding these two lists forms a new one which looks like: [6, None, None, 10].

Blank (b)

Slicing lst3 from index 5 to 8 (recall that slicing excludes the upper-bound index), we will get [7, 8, 9, 9].

Blank (c)

This might be a tough one. When we are working with heavily nested expression, we start by evaluating the deepest one — lst3[:5] in this case. A new copy of all elements from 0 to 4 would be equal to [2, 3, 4, 5, 6]. So now we are looking at next(reversed([2, 3, 4, 5, 6])). Once more we pay close attention to the deepest expression — a call to reversed. It returns an iterator to the given sequence, which yields the elements in the reverse order. The first element starting from the back is 6, which is what gets yielded when we call next.

Blank (d)

At step 4, we’ve created a label lst2 that points to the same object as lst1. Since they are still pointing to the same object, lst1 is lst2 evaluates to True.

3. A Different Kind of Dictionary

Question

The DictionaryEntry class stores entries for an English dictionary, using instance variables for the word and its definition.

class DictionaryEntry:
    """
    >>> euph = DictionaryEntry("euphoric", "intensely happy")
    >>> avid = DictionaryEntry("avid", "enthusiastic")
    >>> [euph, avid]
    [DictionaryEntry('euphoric', 'intensely happy'), DictionaryEntry('avid', 'enthusiastic')]
    >>> f'Today we are learning {euph}'
    'Today we are learning euphoric: "intensely happy"'
    """
    def __init__(self, w, d):
        self.word = w
        self.definition = d

    def __repr__(self):
        __________
           (a)

    def __str__(self):
        __________
           (b)

Fill in blank (a).
Fill in blank (b).

Walkthrough

Recall that repr is called when we ask Python interpreter to evaluate the object instance, while str is called when we would like to print the object or use it as part of the format string. Let’s run through a quick example with Link objects which will also help us while solving this problem:

>>> lnk = Link(1, Link(2, Link(3)))
>>> print(str(lnk))
<1 2 3>
>>> print(repr(lnk))
Link(1, Link(2, Link(3)))
>>> a = eval(repr(lnk))
# a is now also a linked list that looks like 1 -> 2 -> 3

Recall that repr delivers the representation which could be used to re-create the same object using eval() (it is the same "text" we use to create lnk variable in the code).
Now back to the problem. The doctest >>> [euph, avid] creates a list with two DictionaryEntry type objects. To create the list, Python interpreter firstly has to evaluate each element (figure out how to represent it with __repr__ function) and display the resulting list. We have to match the result that looks like: [DictionaryEntry('euphoric', 'intensely happy'), DictionaryEntry('avid', 'enthusiastic')].

Seems that every element is represented as if we are using the initializer DictionaryEntry to create it. Our solution could look something like:

def __repr__(self):
    return f"DictionaryEntry({self.word}, {self.definition})"    

However, we will lose the quotes around the word and definition strings. From the Link example above, we've learned that repr outputs a string that can be used to re-create the same object if passed to eval() function. If attributes lose quotes, eval method would treat these attributes as variables and try to look them up (which would result in error since such variables are not defined). Consider this example:

DictionaryEntry(avid, enthusiastic)

Here avid and enthusiastic are part of the syntax and interpreter will try to make sense of them. In contrast, if we preserve the quotes, two arguments of DictionaryEntry initializer will be treated as string objects (as originally defined).

DictionaryEntry('avid', 'enthusiastic')

To achieve that, we can wrap string objects into their own repr calls, which would output them with quotes. Our solution for part (a) would look like:

def __repr__(self):
    return f"DictionaryEntry({repr(self.word)}, {repr(self.definition)})" 

The second doctest >>> f'Today we are learning {euph}' uses the format string, which relies on the implementation of __str__ method of the object. The result we should strive for is 'Today we are learning euphoric: "intensely happy"' (notice that str will be called only on for {euph} part of the format string and "Today we are learning " is actually part of the doctest, which should not be included in the implementation). So looks like we are just inserting the word, followed by colon and then the word’s definition (with quotes around it). Our code that reflects this logic:

def __str__(self):
    return f"{self.word}: "{self.definition}""

However, we would not be able to execute this code since it has an error. Python interpreter treats anything between quotes " as a string, so __str__ would try to return f"{self.word}: ", since it is the first combination of symbols trapped between quotes. However, it would not be happy about remaining symbols {self.definition}"" since it does not know what to do with them.

To work around this issue, we have to convey that quotes we use around {self.definition} are part of the result string and the interpreter should treat only the first and the last quotes for string-creation purposes. We achieve this by “escaping” the inner quotes and complete the solution for part (b):

def __str__(self):
    return f"{self.word}: \"{self.definition}\"" 

# \" is read by the interpreter as a single " symbol that is part of the string, 
# not as a symbol to start/end the string object

The alternative solution would be to just use single quotes without any escaping:

def __str__(self):
    return f"{self.word}: '{self.definition}'" 

4. Beadazzled

Question

Implement make_necklace, a function that creates a linked list where each value comes from a given list of beads, and the beads are repeated in order to make a necklace of a given length.

For example, if make_necklace is called with ["~", "@"] and a length of 3, then the linked list will contain '~', then '@', then '~'. Here's a diagram of that list:

https://i.imgur.com/61nLPuf.png

See the docstring and doctests for further details on how the function should behave.

def make_necklace(beads, length):
    """
    Returns a linked list where each value is taken from the BEADS list,
    repeating the values from the BEADS list until the linked list has reached
    LENGTH. You can assume that LENGTH is greater than or equal to 1, there is
    at least one bead in BEADS, and all beads are string values.

    >>> wavy_ats = make_necklace(["~", "@"], 3)
    >>> wavy_ats
    Link('~', Link('@', Link('~')))
    >>> print(wavy_ats)
    <~ @ ~>
    >>> wavy_ats2 = make_necklace(["~", "@"], 4)
    >>> print(wavy_ats2)
    <~ @ ~ @>
    >>> curly_os = make_necklace(["}", "O", "{"], 9)
    >>> print(curly_os)
    <} O { } O { } O {>
    """
    if __________:
           (a)
        return __________
                   (b)
    return Link(__________, make_necklace(__________, __________))
                   (c)                       (d)         (e)

Fill in blank (a).

Fill in blank (b).

Fill in blank (c).

Fill in blank (d).

Fill in blank (e).

Walkthrough

For this question, we basically have to create a new linked list of given length using elements from the beads list. If we run out of elements from there, we circle back to its beginning and start over.

Blanks (a) and (b) look like a spot to handle the base case, but we are not sure what it should be: len(beads) == 0? length == 0? or length == 1? Since we do not know yet how our recursion constructs the list and changes the input arguments, it might be tough to choose the correct base case from the start.
Let’s tackle the recursive call. We see that the last line is creating a new instance of Link and calling make_necklace to use its result as the rest component of a newborn linked list. This call to make_necklace is inviting us to make a leap-of-faith!

Let’s consider this doctest:

>>> curly_os = make_necklace(["}", "O", "{"], 9)
>>> print(curly_os)
    <} O { } O { } O {>

We know that the first element of the linked list should be "}", which is also the first element in the beads list. Well, we can move closer to the solution by putting beads[0] into blank (c):

...
return Link(beads[0], make_necklace(__________, __________))
     #        (c)                       (d)         (e)

We’ve got the first element right! Now, we need to come up with the correct rest of the linked list: <O { } O { } O {>.

Assuming that the function works correctly (leap-of-faith!), how could we use the result of make_necklace on smaller inputs to get the correct rest of the linked list? We’ve already used the first element of beads and the rest component is supposed to have a smaller length, so we could try changing the arguments the following way:

make_necklace(beads[1:], length - 1)

# since initially beads = ["}", "O", "{"] and length = 9, this translates to

make_necklace(["0", "{"], 8)

If we assumed that the function was implemented correctly (taking a leap of faith), the function call above would end up creating a necklace that looks like <0 { 0 { 0 { 0 {>, which is not the <O { } O { } O {> that we wish to get. Moreover, this current implementation Link(beads[0], make_necklace(beads[1:], length - 1)) would likely result in an IndexError for beads[0], since it will run out of beads elements before achieving the required length.

In fact, this function call above would not even be “aware” of a symbol "}" (because its beads argument does not contain it)! What if we do not slice the beads list and only decrement the length argument?

make_necklace(beads, length - 1)

# initially beads = ["}", "O", "{"] and length = 9, so this translates to

make_necklace(["}", "0", "{"], 8)

This call is supposed to bring us <} 0 { } 0 { } 0>, which is again not the <O { } O { } O {> we are hoping for. 😕 If we do not slice the list, it would again start from "}" symbol.

Okay, if we move away from slicing our given list for a moment, what is the correct beads content that would evaluate to <O { } O { } O {>?

make_necklace(["0", "{", "}"], 8)

Alright, now lets try to use our beads (which is equal to ["}", "0", "{"]) to match this list.

Everything except the last element is actually just the result of slice beads[1:]. Where do we take the last "}"? It is actually the first element of our beads!

# if beads = ["}", "0", "{"], we can use create the circular version of it by
beads[1:] + [beads[0]]
# which evaluates to ["0", "{"] + ["}"] = ["0", "{", "}"]

Woohoo! 🥳

Turns out, slicing the list from the first element and attaching that first element to the end captures the list rotation logic of this question. Finally, our recursive call and blanks (c)-(e) look like:

...
return Link(beads[0], make_necklace(beads[1:] + [beads[0]], length - 1))
       #      (c)                            (d)                (e)

The easy way to figure out the base cases would be to consider the smallest instance of the problem or the inputs when our recursive calls would stop making sense. We can’t rely on the length of the beads list because it is now constant! (the recursive call just shifts the contents, but never decreases the size of the list). However, the answer to make_necklace(["anything", "here"], 0) must be an empty linked list, irrespective of the beads content. Therefore, the base case (blanks (a) and (b)) is conditioned on the length argument, which steadily decreases as recursion progresses:

if length == 0:
    return Link.empty

Full solution code for reference:

def make_necklace(beads, length):
    if length == 0:
        #  (a)
        return Link.empty
              #    (b)
    return Link(beads[0], make_necklace(beads[1:] + [beads[0]], length - 1))
            #   (c)                       (d)                       (e)

5. File Tree-tionaries

Question

We can represent files and folders on a computer with Trees:

Tree("C:", [Tree("Documents", [Tree("hw05.py")]), Tree("pwd.txt")])

We can also represent the same folder layout with dictionaries:

{"C:": {"Documents": {"hw05.py": "FILE"}, "pwd.txt": "FILE"}}

Notice that in this model, we still treat files as dictionary keys, but with the value "FILE".

Complete the implementation of filetree_to_dict below, which takes in a Tree t representing files and folders, and converts it to the dictionary representation.

def filetree_to_dict(t):
    """
    >>> filetree_to_dict(Tree("hw05.py"))
    {"hw05.py": "FILE"}
    >>> filetree = Tree("C:", [Tree("Documents", [Tree("hw05.py")]), Tree("pwd.txt")])
    >>> filetree_to_dict(filetree)
    {"C:": {"Documents": {"hw05.py": "FILE"}, "pwd.txt": "FILE"}}
    """
    res = {}
    if t.is_leaf():
        res[t.label] = _____________
                            (a)
    else:
        nested = {}
        for branch in t.branches:
            nested[branch.label] = _____________(branch)[_____________]
                                        (b)                   (c)
        _____________ = nested
             (d)
    return res

Fill in blank (a).

Fill in blank (b).

Fill in blank (c).

Fill in blank (d).

Walkthrough

To reiterate the prompt, we are given a tree that resembles the filesystem structure and we have to build out a dictionary with the same structure. Let’s make a few notes before we start:

  • The function starts by creating an empty dictionary res and ends by returning it. This means that every recursive call to function filetree_to_dict will create its own dictionary and return it as a result. The correct combination of these dictionaries should result in the final answer for the entire tree t.
  • Notice that node labels always directly correspond to the dictionary keys. There are only two possibilities for the dictionary values: another nested dictionary or a string "FILE". We use the latter only when we get to the files (bottom of the tree) and there are no folders to recurse further.

With these in mind, let’s start playing with the skeleton!

After declaring an empty dictionary res, we check if the node is a leaf. If that is the case, the skeleton asks us to assign some value (what goes to blank (a)) to the key t.label in the dictionary res. Looking at doctests, we see that for any leaf, we assign "FILE" string as a value (where the key is leaf’s label). Therefore, this line might look like:

if t.is_leaf():
    res[t.label] = "FILE"
    # blank (a) 

If node happened to not be a leaf, we enter the else case. This indicates that our tree has branches, which means that for the current tree t, the value of res[t.label] should be another dictionary that represents the structure of the branches of t. The newborn dictionary nested looks to be a perfect candidate and on blank (d), we are already trying to assign it to something:

...
else:
    nested = {}
    for branch in t.branches:
        nested[branch.label] = _____________(branch)[_____________]
                     #              (b)                   (c)
    res[t.label] = nested
        # (d)
return res

The final thing we have to do now is to populate the nested dictionary, which should happen in the for-loop with blanks (b) and (c). Let’s consider a doctest below:

>>> filetree = Tree("C:", [Tree("Documents", [Tree("hw05.py")]), Tree("pwd.txt")])
>>> filetree_to_dict(filetree)
{"C:": {"Documents": {"hw05.py": "FILE"}, "pwd.txt": "FILE"}}

When we call the function, we start from the root node with label "C:". It is not a leaf, so we go into the else case, create an empty dictionary and start going over the branches of t. In the for-loop, the branch will refer to one of the two subtrees of t. Let’s say it refers to one that starts from the node labeled "Documents". So we are going to execute the line:

nested["Documents"] = _____________(branch)[_____________]
	                #    (b)                     (c)
# 'branch' variable is referring to a subtree that starts with
# a node with label "Documents"
# Namely, branch variable refers to Tree("Documents", [Tree("hw05.py")])

The blank (b) is followed by (branch), which strongly looks like a function call. Probably this is the place for our recursion, so we put filetree_to_dict for blank (b). What is it going to return? If we assume it works correctly (leap-of-faith here), for input branch , it should return {"Documents": {"hw05.py": "FILE"}. Observe that we come up with this result based on the prompt description, not executing our solution (it is not complete).

So if we assume the expression filetree_to_dict(branch) evaluates to the above-mentioned dictionary, we need to extract {"hw05.py": "FILE"} from it, which corresponds to the key "Documents". To get the value, we can put branch.label into blank (c). To reiterate, a completed else-case (and the full solution) looks like the following:

def filetree_to_dict(t):
    res = {}
    if t.is_leaf():
        res[t.label] = "FILE"
                     #  (a)
    else:
        nested = {}
        for branch in t.branches:
            nested[branch.label] = filetree_to_dict(branch)[branch.label]
		                        # (b)                   # (c)
            res[t.label] = nested
                # (d)
    return res

6. Mapping Time and Space

Question

(a) The goal of the maplink_to_list function below is to map a linked list lnk into a Python list, applying a provided function f to each value in the list along the way. The function is fully written and passes all its doctests.

def maplink_to_list1(f, lnk):
    """Returns a Python list that contains f(x) for each x in Link LNK.
    >>> square = lambda x: x * x
    >>> maplink_to_list1(square, Link(3, Link(4, Link(5))))
    [9, 16, 25]
    """
    new_lst = []
    while lnk is not Link.empty:
        new_lst.append(f(lnk.first))
        lnk = lnk.rest
    return new_lst
  1. (1.0 pt) What is the order of growth of maplink_to_list1 in respect to the size of the input linked list lnk?

(b) The next function, maplink_to_list2, serves the same purpose but is implemented slightly differently. This alternative implementation also passes all the doctests.

def maplink_to_list2(f, lnk):
    """Returns a Python list that contains f(x) for each x in Link LNK.
    >>> square = lambda x: x * x
    >>> maplink_to_list2(square, Link(3, Link(4, Link(5))))
    [9, 16, 25]
    """
    def map_link(f, lnk):
        if lnk is Link.empty:
            return Link.empty
        return Link(f(lnk.first), map_link(f, lnk.rest))

    mapped_lnk = map_link(f, lnk)
    new_lst = []
    while mapped_lnk is not Link.empty:
        new_lst.append(mapped_lnk.first)
        mapped_lnk = mapped_lnk.rest
    return new_lst
  1. What is the order of growth of the alternative function, maplink_to_list2, in respect to the size of the input linked list lnk?

(c) Which of the functions requires more space to run?

Walkthrough

Part (a)

Recall that the order of growth (efficiency of the function) is the relationship of the size of the input to the “time” it takes to compute the answer. Basically, we have to figure out if increasing the size of the input forces our code to do more operations and if yes, by how much. When considering the number of operations, we often are counting the number of iterations of a loop (in an iterative approach) or the number of recursive function calls (in a recursive approach). Notably, the number of lines of code do not necessarily correspond to the efficiency of a function.

While executing maplink_to_list1, we have to see each element of the linked list lnk, so that we can modify and add it to the resulting list new_lst. If lnk had more elements, the number of iterations of the for-loop would increase proportionally (i.e. if lnk had 10 more elements, the for-loop would have to do 10 more iterations to see those elements, apply f to them and add the result to new_lst).
Hence, the order of growth of the function maplink_to_list1 is linear.

Part (b)

The function maplink_to_list2 achieves the same goal as maplink_to_list1, but has a different implementation. Namely, it firstly creates a new linked list mapped_lnk (using map_link function), where each element is the result of applying f to each element of the original linked list lnk. After building mapped_lnk, the function goes over it and copies its contents to new_lst.

We have two components that might depend on the size of the input linked list lnk: a call to the inner function map_link (because it takes lnk as an argument) and the while-loop that comes right after the call. Let’s evaluate the efficiency of each component:

First, if we look at the body of map_link, we see that it creates a linked list instance as its return value. In order to evaluate the rest of the linked list, it calls map_link again on the rest of the input. It means that for each element of the original lnk , we would have to make a new function call. In other words, the number of calls to map_link function is directly proportional to the number of elements in the lnk. This hints to us that the order of growth of map_link is linear (you could also reason about this from the fact that map_link has to “see” each element of the linked list in order to compute the modified linked list).

Now, to the while-loop. It actually looks very similar to the loop from the previous part, since all we do on each iteration is to add the element to new_lst and move the pointer to the next element of the linked list. Therefore, the number of iterations directly depend on the number of elements (you have to see all elements before you stumble upon Link.empty). This portion of the code has a linear order of growth.

When there are multiple components that affect the final efficiency of the function, we take the one that is the slowest. The reason for that is in the worst case, the faster component’s compute time becomes negligible comparing to the slower component’s one. However, in this case, both components have equal efficiency, so we can take either of them as our answer.

Hence, the order of growth of the function maplink_to_list2 is linear.

Part (c)

The space efficiency is concerned by the amount of storage the function requires to compute the answer. To be precise, it is the relationship of the size of the input to the “space” it takes to compute the answer. As the input gets bigger, the amount of things we store should also grow according to some relationship (linear, exponential etc.)

With that knowledge, let’s analyze both functions.

The first function stores the “mapped” elements of lnk into new_lst, which size is the same as number of elements in lnk. This is all the function needs to compute the final answer.

The second function firstly builds out a new linked list mapped_lnk, which size is equivalent to the size of the lnk. Then, it additionally creates new_lst, which also takes up the same amount of space. Moreover, the mapped_lnk is created recursively, so we have to store and keep track of all active environments (function frames) to create the linked list. All of that is required by the function to compute the final answer.

Clearly, the first function takes much less space than the second one.

7. Tulip Mania

Question

Implement flower_keeper, a function that mutates a tree t so that the only paths that remain are ones which end in a leaf node with a Tulip flower ('V').

For example, consider this tree where only one path ends in a flower. After calling flower_keeper, the tree has only three nodes left, the ones that lead to the flower.

https://i.imgur.com/dpgF8au.png

The shaded nodes in the diagram indicate paths that end in flowers.

For this tree where two paths end in flowers, the tree keeps both paths that lead to flowers.

https://i.imgur.com/f79bJtP.png

For a tree where none of the nodes are flowers, the function removes every branch except the root node.

https://i.imgur.com/exNmhN4.png

For a tree with only a single node that is a flower, the function does not remove anything.

https://i.imgur.com/IlIuRXB.png

Read through the docstring and doctests for additional guidance on completing the implementation of the function. The examples shown above are from the doctests.

def flower_keeper(t):
    """
    Mutates the tree T to keep only paths that end in flowers ('V').
    If a path consists entirely of stems ('|'), it must be pruned.
    If T has no paths that end in flowers, the root node is still kept.
    You can assume that a node with a flower will have no branches.

    >>> one_f = Tree('|', [Tree('|', [Tree('|'), Tree('|')]), Tree('|', [Tree('V'), Tree('|')])])
    >>> print(one_f)
    |
      |
        |
        |
      |
        V
        |
    >>> flower_keeper(one_f)
    >>> one_f
    Tree('|', [Tree('|', [Tree('V')])])
    >>> print(one_f)
    |
      |
        V
    >>> no_f = Tree('|', [Tree('|', [Tree('|'), Tree('|')]), Tree('|', [Tree('|'), Tree('|')])])
    >>> flower_keeper(no_f)
    >>> no_f
    Tree('|')
    >>> just_f = Tree('V')
    >>> flower_keeper(just_f)
    >>> just_f
    Tree('V')
    >>> two_f = Tree('|', [Tree('|', [Tree('V')]), Tree('|', [Tree('|'), Tree('V')])])
    >>> flower_keeper(two_f)
    >>> two_f
    Tree('|', [Tree('|', [Tree('V')]), Tree('|', [Tree('V')])])
    """
    for b in __________:
                 (a)
        __________
            (b)
    __________ = [__________ for b in __________ if __________]
        (c)           (d)                 (e)           (f)

Fill in blank (a).

Fill in blank (b).

Fill in blank (c).

Fill in blank (d).

Fill in blank (e).

Fill in blank (f).

Walkthrough

Observe that this is a mutation function, where we only have to prune the nodes, so it is very likely that our function will not return any explicit value (will return None). Also, it is guaranteed that flower nodes do not have any branches, which means only leaves will have the label "V".

Let’s try to reason from the leap-of-faith perspective: given a tree t, if we call flower_keeper on its branches b, what is supposed to happen? (if the function is working correctly)

  • Case 1: If the branch is a leaf with "V" as a label, we should not mutate it anyhow.
  • Case 2: if the branch is a leaf with "|" as a label, we also can’t mutate it because per spec: for a tree where none of the nodes are flowers, the function removes every branch except* the root node.* Leaf is also considered to be a tree (where the leaf itself is a root).
  • Case 3: if the branch is a subtree with its own branches, then our function should get rid of the paths that start from b and do not end with label "V". Since only leaf nodes can end with "V", we know that this subtree’s root b has a label "|".

Let’s move further with our leap-of-faith. Imagine that branches of t are mutated (we called flower_keeper on them and it worked correctly). It means that their subtrees are already taken care of (paths that do not end with "V" are pruned).

Now, for all the paths that start at node t, we have to make sure that we only keep ones that end with "V". This seems trivial because we already pruned branches of t, so by just adding an extra node on top of them, newly formed paths should only end with "V", right? For cases 1 & 3 mentioned above, it is indeed correct. However, Case 2 dealt with leaf nodes that have a “stem” label, which were not modified. By adding a root node t on top of them, we create a path that starts at t and ends with label "I"! We should filter out these branches by modifying t.branches to prune such paths.

Let’s reiterate our game plan:

1) Take a leap-of-faith and modify the branch subtrees of t (call flower_keeper on them)

2) For mutated branches, only keep those branches that either have "V" as a label (which means they are leaf nodes) or they are subtrees with branches (start at "|" but have their own branches, which means they correspond to Case 3).

Notice that we haven’t wrote a single line of code yet. Instead, we just wondered if our function would work correctly for subtrees of t, how could we use it to correctly modify t tree itself.

Skeleton fits great for our logic. With blanks (a) and (b), we make sure to mutate our branches first:

# step 1 of our plan
for b in t.branches: # (a)
    flower_keeper(b) # (b)

The last line filters our “non-flower” paths from the mutated branches:

# step 2 of our plan
t.branches = [b for b in t.branches if b.label == 'V' or not b.is_leaf()]
#   (c)      (d)            (e)                       (f)

Since this line comes after the for-loop, t.branches on blank (e) contain the modified branches.

Predicate on blank (f) only keeps branches with label "V" or branches that are not leaves (their label is "|" but they have branches). This concludes our solution.

8. CS61A Presents the Game of Hoop

When we introduced the Hog project to you, we designed the rules of the game in a way that would make it possible to implement without using more advanced concepts like OOP and iterators/generators. Now that we've learned these concepts, you can rewrite parts of the game and expand it! The new version, called Hoop, will support any number of players greater than or equal to 2.

Assume the following changes from Hog:

  • All rules have been taken out except for the Sow Sad rule, which is reiterated below.
  • Each player's strategy is a function strategy(own_score, other_scores) that takes in that player's score and a list of the other players' scores, and outputs the number of times that player wishes to roll the dice during their turn. Order doesn't matter for the list of other players' scores.
  • You don't have to keep track of the leader, and there is no commentary mechanism.

Sow Sad: If a player rolls any number of 1s in their turn, their score for that set of rolls is a 1 regardless of whatever else they rolled.

To start, take a look at the HoopPlayer class, which represents one player in our game:

class HoopPlayer:
    def __init__(self, strategy):
        """Initialize a player with STRATEGY, and a starting SCORE of 0. The
        STRATEGY should be a function that takes this player's score and a list
        of other players' scores.
        """
        self.strategy = strategy
        self.score = 0

Every game of Hoop will involve rolling a dice as well. Take a look at theHoopDice class, which represents a rollable dice that takes a set of values. You'll fill in the blanks in the first part of this question.

class HoopDice:
    def __init__(self, values):
        """Initialize a dice with possible values VALUES, and a starting INDEX
        of 0. The INDEX indicates which value from VALUES to return when the
        dice is rolled next.
        """
        self.values = values
        self.index = 0

    def roll(self):
        """Roll this dice. Advance the index to the next step before returning.
        """
        value = ______________
        ______________ = (______________) % ______________
        return value

Part (a)

Begin by making the HoopDice rollable. Fill in the skeleton for the HoopDice.roll method below. See above for the rest of HoopDice.

class HoopDice:
    ...
    def roll(self):
        """Roll this dice. Advance the index to the next step before
        returning.
        >>> five_six = HoopDice([5, 6])
        >>> five_six.roll()
        5
        >>> five_six.index
        1
        >>> five_six.roll()
        6
        >>> five_six.index
        0
        """
        value = ______________
                    (1a)
        ______________ = (______________) % ______________
            (1b)              (1c)              (1d)
        return value
  1. Fill in blank (1a)
  2. Fill in blank (1b)
  3. Fill in blank (1c)
  4. Fill in blank (1d)

Walkthrough

According to the description, this function must do three things:

1) roll the dice

2) advance the index (prepare for the future rolls)

3) return the outcome of the roll from the previous point

value looks to be the variable that captures the outcome and gets returned. From the doctests, we see that rolling the dice gives the elements from self.values in order and circles back to start (observe how self.index changes back to 0 in the doctests).

Therefore, it looks like blank (1a) could be a number from self.values at the current index. Namely, it should be self.values[self.index]. The first line and the final return value achieves the requirements 1 and 3 from above.

For advancing the index, we could do self.index = self.index + 1, so on the future roll, the roll outcome would be the next element of self.values. However, if we are already on the last element, incrementing the index would go beyond the bounds of the list. In that case, we have to get back to the index 0. The skeleton offers us the % operation for help.

In general, when we mod something by the number N, the result would always stay in range 0..N-1. Recall that in the past assignments, we’ve used % 10 on the number to get its rightmost digit. Using 10 ensured that we would only get numbers from 0-9.

To avoid the out of bounds error, we always have to stay in the valid index range - 0..len(self.values). Therefore, as long as we mod self.index + 1 by length of the list, we ensure that going beyond the length of the list will map back to the appropriate index from the beginning.

The complete solution looks like:

class HoopDice:
    ...
    def roll(self):
        value = self.values[self.index]
                    #  (1a)
        self.index = (self.index + 1) % len(self.values)
           # (1b)          (1c)              (1d)
        return value

Part (b)

For the rest of the question, assume that HoopDice.roll is implemented correctly, regardless of your answers above.

In the rest of the question, you will work on making the game itself. Take a look at the HoopGame class, which takes in the setup for your game and makes it playable. You'll fill in the blanks shortly.

class HoopGame:
    def __init__(self, players, dice, goal):
        """Initialize a game with a list of PLAYERS, which contains at least one
        HoopPlayer, a single HoopDice DICE, and a goal score of GOAL.
        """
        self.players = players
        self.dice = dice
        self.goal = goal

    def next_player(self):
        """Infinitely yields the next player in the game, in order."""
        yield from ______________
        yield from ______________

    def get_scores(self):
        """Collects and returns a list of the current scores for all players
        in the same order as the SELF.PLAYERS list.
        """
        # Implementation omitted. Assume this method works correctly.
    def get_scores_except(self, player):
        """Collects and returns a list of the current scores for all players
        except PLAYER.
        """
        return [______________ for pl in ______________ if ______________]
    def roll_dice(self, num_rolls):
        """Simulate rolling SELF.DICE exactly NUM_ROLLS > 0 times. Return sum of
        the outcomes unless any of the outcomes is 1. In that case, return 1.
        """
        outcomes = [______________ for x in ______________]
        ones = [______________ for outcome in outcomes]
        return 1 if ______________(ones) else ______________(outcomes)

    def play(self):
        """Play the game while no player has reached or exceeded the goal score.
        After the game ends, return all players' scores.
        """
        player_gen = self.next_player()
        while max(self.get_scores()) < self.goal:
            player = ______________(player_gen)
            other_scores = self.get_scores_except(player)
            num_rolls = ______________(player.score, other_scores)
            outcome = self.roll_dice(num_rolls)
            ______________ += outcome
        return self.get_scores()

To see what the expected behavior of each method is, take a look at its doctests. These will be provided for the relevant methods in each subpart for the rest of the question.

In all following subparts, assume that the following has already been run to set up the game:

>>> roll_once_strategy = lambda pl, ops: 1
>>> roll_twice_strategy = lambda pl, ops: 2
>>> always_5 = HoopDice([5])
>>> player1 = HoopPlayer(roll_twice_strategy)
>>> player2 = HoopPlayer(roll_once_strategy)
>>> player3 = HoopPlayer(lambda pl, ops: 6)
>>> game = HoopGame([player1, player2, player3], always_5, 55)
>>> # since we omit the implementation of HoopGame.get_scores, here's what it
>>> # should output:
>>> game.get_scores()
[0, 0, 0]

The first HoopGame method you'll implement is HoopGame.next_player, which gives the game a way to iterate over all of the players playing, in order to give them each a turn in order.

class HoopGame:
    ...
    def next_player(self):
        """Infinitely yields the next player in the game, in order.
        >>> player_gen = game.next_player()
        >>> next(player_gen) is player1
        True
        >>> next(player_gen) is player3
        False
        >>> next(player_gen) is player3
        True
        >>> next(player_gen) is player1
        True
        >>> next(player_gen) is player2
        True
        >>> new_player_gen = game.next_player()
        >>> next(new_player_gen) is player1
        True
        >>> next(player_gen) is player3
        True
        """
        yield from ______________
                        (2a)
        yield from ______________
                        (2b)
  1. Fill in blank (2a)
  2. Fill in blank (2b)

Walkthrough

The implementation of this method should ensure two functionalities:

1) yielding players of the instance

2) when there are no more players, start over from the first player and keep yielding. In other words, this generator should be infinite.

Recall that yield from <some generator> can always be rewritten as:

for elem in <some generator>:
    yield elem

In other words, yield from spits out the elements from the iterator/iterable until it is exhausted. Since we have to yield the players, it would make sense for the blank (2a) to be:

yield from self.players
            #   (2a)

We’ve fulfilled the first requirement (observe that it has to be self.players, not just players, since we are using the attribute of the instance).

How do we deal with the infiniteness property though? We need something like:

yield from self.players
yield from self.players
yield from self.players
yield from self.players
yield from self.players
...

but we have only one spot left for yield from. Do we have a generator method that yields players infinitely from the start (since after the first yield from , we have to start over)?

Yes! It is self.next_player()!

yield from self.next_player()
               (2b)

Again, we have to use self to call the method recursively, since that is how it preserves the “attachment” to its players attribute.

You might be wondering, haven’t we already “exhausted” self.players? How come that when we call self.next_player() again, we are able to start from scratch since it is the same players list object? Well, when yield from is used with lists (which is an iterable), it firstly needs to turn it into an iterator:

lst = [1, 2, 3, 4]
yield from lst 

# the line above translates into:
yield from iter(lst)

It means that every time we execute the yield from on a list, it creates a new iterator from its contents. This was reflected in one of the most common student solutions, though it was incorrect:

# incorrect but common solution
class HoopGame:
    ...
    def next_player(self):
        yield from self.players
                #    (2a)
        yield from self.players
                #    (2b)

Since every yield from line creates its own separate iterator, the implementation above yields the elements for two “rounds”. However, we need infinite number of “rounds”, not only two, which is handled by the recursive call. The final solution looks like:

class HoopGame:
    ...
    def next_player(self):
        yield from self.players
                 #   (2a)
        yield from self.next_player()
                 #   (2b)

Part (c)

The HoopGame.get_scores method is already fully implemented and provides a way to get a list of the scores of all players.

However, each player's strategy relies on the player's opponents' scores, so HoopGame also needs a method that gives the scores of all players except the current one. Fill in the skeleton for HoopGame.get_scores_except below. Keep in mind that multiple players may have the same score.

class HoopGame:
    ...
    def get_scores_except(self, player):
        """Collects and returns a list of the current scores for all players
        except PLAYER.
        >>> game.get_scores_except(player2)
        [0, 0]
        """
        return [______________ for pl in ______________ if ______________]
                    (3a)                     (3b)              (3c)

As a reminder, here's what the HoopPlayer class looks like:

class HoopPlayer:
    def __init__(self, strategy):
        """Initialize a player with STRATEGY, and a starting SCORE of 0. The
        STRATEGY should be a function that takes this player's score and a list
        of other players' scores.
        """
        self.strategy = strategy
        self.score = 0
  1. Fill in blank (3a)
  2. Fill in blank (3b)
  3. Fill in blank (3c)

Walkthrough

The method get_scores_except should return the list with scores of all players except the provided player argument. Let’s try to translate this statement into the list comprehension. We can focus on each component separately to fill in the blanks:

  • return the list ... of all players except ...

How can we get all of the players? Since get_scores_except is the method of HoopGame class, we have access to its attributes. self.players is exactly what we need and what we put for blank (3b):

return [______________ for pl in self.players if ______________]
      #      (3a)                    (3b)              (3c)
  • return the list with scores of all players except ...

The list should be populated with scores of the players. Since we are iterating over self.players with the for-loop, pl will refer to an instance of a player. To get their score, we can put pl.score into blank (3a):

return [pl.score for pl in self.players if ______________]
    #      (3a)                (3b)              (3c)
  • return the list ... except the provided player argument

We should consider all of the players, except the one provided as an argument. So as long as pl is not player, we should be fine. The complete solution:

class HoopGame:
    ...
    def get_scores_except(self, player):
        return [pl.score for pl in self.players if pl is not player]
            #   (3a)                (3b)              (3c)

# since we are comparing objects, we should use "is not" instead of using "!=" (though, in this case, both work)

Part (d)

Next, give HoopGame the ability to roll its dice some given number of times and return the total from rolling all those times. Recall that the dice in the game is just a single dice, represented by the HoopDice instance at self.dice. Fill in the skeleton for HoopGame.roll_dice below:

class HoopGame:
    ...
    def roll_dice(self, num_rolls):
        """Simulate rolling SELF.DICE exactly NUM_ROLLS > 0 times. Return sum of
        the outcomes unless any of the outcomes is 1. In that case, return 1.
        >>> game.roll_dice(4)
        20
        """
        outcomes = [______________ for x in ______________]
                        (4a)                    (4b)
        ones = [______________ for outcome in outcomes]
                    (4c)
        return 1 if ______________(ones) else ______________(outcomes)
                        (4d)                      (4e)
  1. Fill in blank (4a)
  2. Fill in blank (4b)
  3. Fill in blank (4c)
  4. Fill in blank (4d)
  5. Fill in blank (4e)

Walkthrough

Recall that self.dice is where we store the dice instance (self is referring to HoopGame class instance). In order to roll the dice once, we have to call self.dice.roll(), which returns the outcome of the roll. The question asks us to roll the dice num_rolls times and store the results in outcomes list:

outcomes = [self.dice.roll() for x in range(num_rolls)]
            #	(4a)                     (4b) 

# this list comprehension is equivalent to the following code:
outcomes_2 = []
for x in range(num_rolls):
    outcome = self.dice.roll()
    outcomes_2.append(outcome)

# notice that x variable is not used at all in the list comprehension
# the expression below would evaluate to the same list
[self.dice.roll() for _ in range(num_rolls)]

The initial intuition for the list ones might be that it needs to have all 1-s from the outcomes list. However, the skeleton does not provide any space for the filtering predicate (outcome == 1) after the for-loop. If we put the predicate as the element description into blank (4c), the list ones would be populated with True and False values (the result of evaluating outcome == 1 for each element of outcomes):

ones = [outcome == 1 for outcome in outcomes]
       #   (4c)

As long as we keep in mind that ones contains boolean values, we should be fine moving forward.

For the last two blanks, the function description actually provides us a huge hint: “Return sum of the outcomes unless any of the outcomes is 1”. It would make sense to return 1 if any(ones) is True value (there is at least one True in ones) and sum(outcomes) otherwise:

return 1 if any(ones) else sum(outcomes)
        #     (4d)            (4e)

Notice that using sum(ones) for blank (4d) also works, because for mathematical purposes, True is equal to 1 and False is equal to 0 -- this was an alternate solution. The complete solution looks like:

class HoopGame:
    ...
    def roll_dice(self, num_rolls):
        """Simulate rolling SELF.DICE exactly NUM_ROLLS > 0 times. Return sum of
        the outcomes unless any of the outcomes is 1. In that case, return 1.
        >>> game.roll_dice(4)
        20
        """
        outcomes = [self.dice.roll() for x in range(num_rolls)]
                     #   (4a)                       (4b)
        ones = [outcome == 1 for outcome in outcomes]
                 #   (4c)
        return 1 if any(ones) else sum(outcomes)
                 #     (4d)            (4e)

Part (e)

Finally, it's time to play the game! Recall that a player's strategy depends on two inputs: the player's own score, and a list of the scores of the player's opponents. Using existing attributes wherever possible, fill in the skeleton for HoopGame.play below:

class HoopGame:
    ...
    def play(self):
        """Play the game while no player has reached or exceeded the goal score.
        After the game ends, return all players' scores.
        >>> game.play()
        [20, 10, 60]
        """
        player_gen = self.next_player()
        while max(self.get_scores()) < self.goal:
            player = ______________(player_gen)
                        (5a)
            other_scores = self.get_scores_except(player)
            num_rolls = ______________(player.score, other_scores)
                            (5b)
            outcome = self.roll_dice(num_rolls)
            ______________ += outcome
                (5c)
        return self.get_scores()
  1. Fill in blank (5a)
  2. Fill in blank (5b)
  3. Fill in blank (5c)

Walkthrough

Before we enter the while-loop, the skeleton creates the generator player_gen using self.next_player(). From part (b), we know that this is an infinite generator and the only way to interact with it is to call next on the object (which yields the next player). This is also our answer for blank (5a):

...
player = next(player_gen)
        # (5a)
...

The code in blank (5b) should be a function (the blank is followed-up by parentheses) that helps us to figure out the number of rolls based on the player’s score and the list of opponents’ score. We haven’t implemented such function ourselves, but we know that each player has a strategy attribute, which is defined as “The STRATEGY should be a function that takes this player's score and a list of other players' scores”. Hence the blank (5b) looks like:

...
num_rolls = player.strategy(player.score, other_scores)
               #   (5b)
...

# notice that we can't do self.strategy, 
# since self here refers to the HoopGame instance

After computing the outcome, it should impact the current player’s score. This can be achieved by writing player.score into blank (5c). The complete solution looks like:

class HoopGame:
    ...
    def play(self):
        """Play the game while no player has reached or exceeded the goal score.
        After the game ends, return all players' scores.
        >>> game.play()
        [20, 10, 60]
        """
        player_gen = self.next_player()
        while max(self.get_scores()) < self.goal:
            player = next(player_gen)
                    # (5a)
            other_scores = self.get_scores_except(player)
            num_rolls = player.strategy(player.score, other_scores)
                         #   (5b)
            outcome = self.roll_dice(num_rolls)
            player.score += outcome
              #  (5c)
        return self.get_scores()

Changing the score on blank (5c) also ensures that the while-loop would terminate at some point, since by increasing the score of the current player, we move them closer to the goal.

Part (f)

Now that you've finished implementing the game, you'll implement a new kind of BrokenHoopDice. This dice extends HoopDice, but is broken! That is, it alternates between behaving like a normal dice and returning when_broken value between turns.

For this part of the question, you may not use = or lambda.

class BrokenHoopDice(HoopDice):
    def __init__(self, values, when_broken):
        ______________(values)
             (6a)
        self.when_broken = when_broken
        self.______________ = False
                  (6b)
    def roll(self):
        """
        >>> broken = BrokenHoopDice([5, 6, 7], 11)
        >>> broken.roll()
        5
        >>> [broken.roll() for _ in range(6)]
        [11, 6, 11, 7, 11, 5]
        """
        if self.is_broken:
            self.is_broken = not self.is_broken
            return ______________
                        (6c)
        else:
            self.is_broken = not self.is_broken
            return ______________()
                        (6d)
  1. Fill in blank (6a)
  2. Fill in blank (6b)
  3. Fill in blank (6c)
  4. Fill in blank (6d)

Walkthrough

Since BrokenHoopDice extends from the HoopDice, we can use the latter’s constructor to initialize the object. The blank (6a) would be super.__init__(values).

After initializing the object as HoopDice, we create two additional attributes, one of which is when_broken and we should figure out the name for the second. Looking at the roll method, we see it uses is_broken attribute, which seems to be the answer for blank (6b). It is set to be False since for the first roll, BrokenHoopDice should behave like a normal dice and turn into broken one for the next roll.

So far, we’ve got the following code:

class BrokenHoopDice(HoopDice):
    def __init__(self, values, when_broken):
        super().__init__(values)
        #  (6a) 
        self.when_broken = when_broken
        self.is_broken = False
        # (6b)
...

In the roll method, we see an if-condition that firstly flips the is_broken attribute and returns the appropriate roll result. We know that if the dice is broken, we return the value of when_broken and otherwise, we return the result of a normal HoopDice roll (observe that blank (6d) ends with function call parentheses). The complete solution looks like:

class BrokenHoopDice(HoopDice):
    def __init__(self, values, when_broken):
        super().__init__(values)
        # (6a)
        self.when_broken = when_broken
        self.is_broken = False
        #  (6b)
    def roll(self):
        """
        >>> broken = BrokenHoopDice([5, 6, 7], 11)
        >>> broken.roll()
        5
        >>> [broken.roll() for _ in range(6)]
        [11, 6, 11, 7, 11, 5]
        """
        if self.is_broken:
            self.is_broken = not self.is_broken
            return self.when_broken
                       # (6c)
        else:
            self.is_broken = not self.is_broken
            return super().roll()
                       # (6d)