ICS 33 Fall 2024
Exercise Set 5 Solutions


Problem 1

The minimum set of dunder methods we need in our MultipleSequence to meet the requirements are as follows.

Notably, we didn't need to write an iterator, because the presence of __len__ and __getitem__ obviated it, and we didn't need to write a __bool__ method, because the presence of __len__ obviated it.


class MultipleSequence:
    def __init__(self, length, multiplier = 1, /):
        self._length = length
        self._multiplier = multiplier


    def __len__(self):
        return self._length


    def __getitem__(self, index):
        effective_index = int(index) if index >= 0 else self._length + int(index)

        if not self._is_valid_index(effective_index):
            raise IndexError('index out of range')

        return self._multiplier * effective_index


    def __contains__(self, value):
        return int(value) % self._multiplier == 0 and \
               self._is_valid_index(int(value) // self._multiplier)


    def __repr__(self):
        multiplier_repr = f', {self._multiplier}' if self._multiplier != 1 else ''
        return f'MultipleSequence({self._length}{multiplier_repr})'


    def _is_valid_index(self, index):
        return 0 <= index < self._length

Problem 2

There are a few intersecting requirements that we'll need to untangle to solve a problem like this one.

First of all, what is the minimal set of methods we'll need? If we need all six relational comparisons — ==, !=, <, <=, >, >= — we'll only need three methods to make that happen: __eq__, __lt__, and __le__. Each of these implies the behavior of its opposite, since the behavior is always symmetric (i.e., we only want the comparisons to work when the types of the operands are the same).

The asymptotic performance requirements boil down to "linear time and constant memory," so we'll need to use iteration and make decisions as we go. Without the ability to use the standard library, iter and next are our best bet for performing those iterations. As soon as we've determined an answer, we should return it; for example, when determining equality, we can conclude that the answer is False as soon as we find an inequivalent pair of elements, even if we haven't seen the remaining ones.

Avoiding comparing any elements at all requires playing some sort of trick, though there's at least one good trick to be played here. Other properties of the two sequences, when present, can be used to short-circuit our comparison. For example, if we knew self._values and other._values both had a length, we could cheaply compare them to determine inequality without comparing any of the elements. (If the lengths are the same, we still have to look at the elements, of course, but if they're different, we're done.)

You'll find a complete solution that takes these things into account at the link below.


Problem 3

A base class that meets the given requirements might be a Song class. There are a couple of things worth taking note of.


class Song:
    def __init__(self, artist, title):
        self._artist = artist
        self._title = title


    def artist(self):
        return self._artist


    def title(self):
        return self._title


    def __eq__(self, other):
        return type(self) is type(other) and \
               self._artist == other._artist and \
               self._title == other._title

A derived class that meets the given requirements might be an AlbumSong class, which is a song for which we've also specified how it fits into an album. There are a couple of techniques to consider here.


class AlbumSong(Song):
    def __init__(self, artist, title, album_name, track_number):
        super().__init__(artist, title)
        self._album_name = album_name
        self._track_number = track_number


    def album_name(self):
        return self._album_name


    def track_number(self):
        return self._track_number


    def __eq__(self, other):
        return super().__eq__(other) and \
               self._album_name == other._album_name and \
               self._track_number == other._track_number


Problem 4

There are obviously a lot of reasonable experiments that one might propose to explore these two uses of the del statement, but here's one such experiment for each scenario in this problem.

A del statement in a non-inheriting class


>>> class Thing:
...     del boo
...
    Traceback (most recent call last):
      ...
    NameError: name 'boo' is not defined. Did you mean: 'bool'?
             # Non-existent attributes cannot be deleted.
>>> class Thing:
...     def value(self):
...         return 0
...     del value
...
>>> Thing().value()
    Traceback (most recent call last):
      ...
    AttributeError: 'Thing' object has no attribute 'value'
             # Once deleted, attributes no longer exist.
>>> class Thing:
...     del value
...     def value(self):
...         return 0
...
    Traceback (most recent call last):
      ...
    NameError: name 'value' is not defined. Did you mean 'False'?
              # Order matters: Attributes can't be deleted before they're defined.
>>> class Thing:
...     def __init__(self, value):
...         self._value = 3
...     del _value
...
    Traceback (most recent call last):
      ...
    NameError: name '_value' is not defined
              # Deletion can affect class attributes, but not object attributes.

What we learned here is that a del statement in a class definition is a way to delete an attribute from the class being defined. Specifically, it deletes class attributes, rather than object attributes.

We could reasonably speculate about why that is, too: When a class is defined, there are no objects of that class yet. All we've created is a blueprint, but we haven't built anything from that blueprint. So, it makes sense that the only thing we can adjust in a class statement is the blueprint (i.e., the things that are true of the class as a whole), rather than aspects of the individual objects that don't yet exist and, hence, can't be modified.

A del statement in a derived class


>>> class Person:
...     def name(self):
...         return 'Boo'
...     def age(self):
...         return 13
...
>>> class AgelessPerson(Person):
...     del age
...
    Traceback (most recent call last):
      ...
    NameError: name 'age' is not defined
              # We can't delete attributes from base classes.
>>> class PersonWithId(Person):
...     def person_id(self):
...         return -1
...     del person_id
...
>>> PersonWithId().person_id()
    Traceback (most recent call last):
      ...
    AttributeError: 'PersonWithId' object has no attribute 'person_id'
              # Derived classes can delete their own attributes, like any other class.

Essentially, not much changes in the presence of explicitly-defined single inheritance. A del statement in a class definition that explicitly inherits from a base class other than object can affect attributes from within the class being defined, but not attributes inherited from its base classes.

Like most aspects of Python's design, this behavior is no accident. There are at least two reasons we might expect it to be this way.

(We might reasonably expect that everything we've said here is true in the presence of multiple inheritance, too, but it would require some additional experimentation to be sure.)


Problem 5