ICS 33 Fall 2024
Exercise Set 1 Solutions
Problem 1
One possible solution using only techniques we've learned so far follows.
def only_truthy(**kwargs):
result = {}
for name, value in kwargs.items():
if value:
result[f'_{name}'] = value
return result
The way we'd approach this problem will evolve as we learn more Python as this quarter unfolds.
Problem 2
There are many examples throughout Python's standard library. One trick for finding them easily is to look at what's changed in Python's standard library since Python 3.8, since no function defined prior to that would have been able to use this feature. You can reasonably expect that Python's designers are unlikely to take a function that was previously designed without the use of positional-only parameters, then re-design it to begin requiring them, since that would cause code that used to work to suddenly stop working; this is done as rarely as possible in the evolution of a programming language, since it has such a negative impact on the community.
So, what benefit is it to have positional-only parameters in a function? There are (at least) two benefits.
In their simplest form, they don't allow us to do things we couldn't do without them; all they do is prevent us from doing things we could without them. For example, the function def subtract(n, m, /)
and the function def subtract(n, m)
can be called as subtract(18, 7)
either way. So, why would we want to prevent subtract(n = 18, m = 7)
from working? Generally, if the names of the parameters don't specify anything that isn't already obvious from the name and the order of the arguments to a function, using keyword arguments would only produce noise, with no other benefit to readability. Preventing someone from writing unnecessarily noisy code has the benefit that future readers of that code will be able to read it more easily. Preventing someone from altering the order of arguments whose order itself conveys meaning — subtraction is a good example of that, where we always write the order of the terms in a one way but not the other — can also be a plus.
There is an interesting wrinkle where the mechanics of an existing call to a function changes, depending on whether this feature has been used or avoided. Consider the function f(x = None, y = None, /, **kwargs)
instead.
/
in the function's signature, the call f(x = 8, y = 10, z = 12)
would cause kwargs
to be {'z': 12}
./
, though, that same call would have a new meaning and kwargs
would have the value {'x': 8, 'y': 10, 'z': 12}
instead.Whether we prefer one of those meanings or the other is a matter of the function's design — what problem is it intended to solve? — but that we have the ability to allow this behavior when we need it is part of what's so useful about positional-only parameters.
A more thorough explanation of the rationale and history of this particular language feature can be found in PEP 570, if you're interested in finding out more about it.
Problem 3
The mistake made in the original implementation boiled down to a common misunderstanding of the meaning of class attributes. When an object is missing an attribute that is defined in the object's class, the rules of attribute lookup in Python will ensure that the class attribute is found, even in the absence of the object attribute. That this is the behavior leads to the misguided use of a class attribute as a sort of default attribute for objects — "Here's what the value should be if I haven't set one in the object yet." And that's essentially what the design in this problem was attempting to do: defaulting the list of songs in each album to be empty automatically.
The problem with this technique is that it doesn't mesh well with other aspects of how Python works. In particular, the problem we have here is a combination of two things working poorly alongside each other.
self._songs
, which means that Album
objects never have their own _songs
attribute. Consequently, any time we access the value of self._songs
, we're accessing the same class attribute, no matter what Album
it is.self._songs.append(song)
in the add_song
method, we were appending an element to the existing list. Since that list was accidentally being shared amongst all Album
objects, adding a song to one album instead added that song to all albums.Our best bet for fixing this problem is to simply assign self._songs
to be an empty list in Album
's __init__
method, so that every Album
object has its own separate list of songs stored in an object attribute. At that point, there's little reason for the class attribute; it would best be removed.
Problem 4
A useful technique for testing these functions is to use contextlib.redirect_stdout
to temporarily redirect the standard output elsewhere, just long enough to run one test, then see what the redirected output looked like. By doing that with a context manager, we're sure that we're only having an impact on our one test, rather than a global impact that might cause other aspects of a large program to behave differently than we expect.
One example of such a test might look like this.
class TestPrinting(unittest.TestCase):
def test_printing_one_reversed_element_prints_only_the_one_element(self):
with contextlib.redirect_stdout(io.StringIO()) as output:
print_reversed_list(['Boo!'])
self.assertEqual('!ooB\n', output.getvalue())
The rest of a complete solution could be written as "more of the same" — perhaps using this same class for testing both functions — though you might also consider techniques to avoid duplication of test code, such as a helper method that does the redirection, calls the function, splits the output into lines, and returns a list of them, so that each individual test case would become shorter and simpler. (One of the recurring themes we'll see in this course is the simplifying of formulaic code, with many techniques offered throughout the course for doing so. Those techniques simplify testing, just as they simplify writing the programs we're testing.)
Problem 5
Here's how we could solve this problem, using only the techniques taught so far. The choices we have when designing solutions to many problems will increase as we learn more techniques; all things in time.
def should_raise(expected_exc_type):
return _ShouldRaiseContext(expected_exc_type)
class MissingExpectedError(Exception):
pass
class _ShouldRaiseContext:
def __init__(self, expected_exc_type):
self._expected_exc_type = expected_exc_type
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
if exc_type is self._expected_exc_type:
return True
else:
# Later this quarter, we'll learn how to outfit our exceptions
# with custom error messages.
raise MissingExpectedError
It's worth noting that _ShouldRaiseContext
is defined here as a protected attribute of the module, but that MissingExpectedError
is not. There's a method to that particular madness:
should_raise
function does not need to rely on any of the details of how context management has been implemented, other than the effect we want: a MissingExpectedError
exception being raised or not, depending on whether an exception of the expected type was raised while in the context.should_raise
function can expect a MissingExpectedError
exception to be raised, so there might be some benefit in being able to catch it and know what it is. (One reason is so that we could write automated tests of our should_raise
function.) So, ultimately, should_raise
and MissingExpectedError
are the "public surface area" of our module, while _ShouldRaiseContext
is an underlying implementation detail. We should endeavor to keep public parts of our modules separate from protected ones, as you've likely done in previous coursework, though we'll have to refine our sensibilities about where the line is drawn as we continue to learn new Python techniques.