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.

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.

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:

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.