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.
__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 set
s or dict
s 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.