ICS 33 Fall 2025
Exercise Set 5 Solutions
Problem 1
The minimum set of dunder methods we need in our MultipleSequence to meet the requirements are as follows.
__init__, because there's no automatic way to meet the initialization requirements.__len__, because objects are never sized without it.__getitem__, because we need indexing, and this is the only way to get it.__contains__, not because the in operator wouldn't have been supported without it, but because we had to meet an O(1) time requirement. The default behavior for the in operator in the presence of __len__ and __getitem__ is to iterate, which will take O(n) time.__repr__, because we needed to control the representation.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.
__eq__ method, but not the __ne__ method, since the presence of the former implies the behavior of the latter.Song is equivalent to an AlbumSong just because they have the same artist and title.
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.
super() allows the implementation of both initialization and equality to be shared by the Song and AlbumSong classes. That way, Song can do the "song part" of the job and AlbumSong can do the "album song part" of the job.Song's __eq__ method validates that the types of its arguments are identical, AlbumSong can rely on that to be sure that an AlbumSong will only be equivalent to an AlbumSong, so it wasn't necessary to re-establish that in AlbumSong's __eq__ method.
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.
del statement in a derived class definition could delete an attribute inherited from a base class, it would mean that the base class would have to be modified. Allowing the definition of a class Y to casually modify the behavior of the class X — affecting not only X and Y, but also any other classes derived from X! — is a dangerous default indeed.(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
__ror__ method would not be a necessity. Since the | operator can only be used to combine a set with another set or a dict with another dict, there's no scenario in which the reflected version of | would be needed. Why we need the reflected version of an operator is because we need to apply it to objects of different types, where the one on the left is missing an implementation of the operator, but the one on the right supports it.__ior__ method would be a necessity, not because the default behavior without it would produce an incorrect result, but because producing that result would require copying both sets or dicts in their entirety, whereas the __ior__ method could add keys (and, in the case of a dict, their associated values) into an existing set or dict, so that only the contents of the "right" collection would have to be copied into the "left" one.