Problem overview
A music app keeps playlists: songs in the order they were added. The lesson builds it from two classes. A Song(title, seconds) holds one song's title and length. A Playlist() starts empty and keeps a list of Song objects. Its methods are:
add(song): adds a song at the end.remove(title): removes the first song with that title and returnsTrue. If no song has that title, it changes nothing and returnsFalse.total_seconds(): the sum of the songs' lengths, 0 for an empty playlist.duration(): the same total as"m:ss". The seconds have two digits and the minutes are never padded: 5 seconds are"0:05", 750 seconds are"12:30"and 3600 seconds are"60:00".
With songs of 65 and 200 seconds, the total is 265 and the duration is 4:25. After removing the 65-second song, the duration is 3:20.
Approach: one class holds data, the other holds the list
Song is a small class that only stores two values. Playlist owns a list of songs in self._songs. The underscore says that the list is private to the class by convention: only the playlist's own methods change it, so it cannot get out of step with what the methods report. Song's attributes stay public because Playlist reads each song's title and length.
Two of the methods have a catch. remove must take out only the first match, so it returns as soon as it finds one. duration must turn a number of seconds into minutes and seconds, using // for the whole minutes and % for the seconds left over.
Step by step
- Write
Song.__init__to storeself.titleandself.seconds. - Start the playlist with
self._songs = []in its__init__. - add: append the song to
self._songs. The playlist keeps the song object itself, with its title and length together. - remove: loop over the indexes with
range(len(self._songs)), because removing goes by position. Ifself._songs[i].title == title, callself._songs.pop(i)and returnTrue. Returning at once stops the loop, so only the first song with that title goes. - After the loop, return
False: no song matched and the list is unchanged. - total_seconds: start
totalat 0, add eachsong.secondsand return the total. - duration: call
self.total_seconds()so the adding stays in one place. Then takeminutes = total // 60andseconds = total % 60. - Pad the seconds. If
seconds < 10, return the minutes,":0"and the seconds, each number made a string withstr. Otherwise return the minutes,":"and the seconds.
The solution in Python
class Song:
def __init__(self, title, seconds):
self.title = title
self.seconds = seconds
class Playlist:
def __init__(self):
self._songs = []
def add(self, song):
self._songs.append(song)
def remove(self, title):
for i in range(len(self._songs)):
if self._songs[i].title == title:
self._songs.pop(i)
return True
return False
def total_seconds(self):
total = 0
for song in self._songs:
total += song.seconds
return total
def duration(self):
total = self.total_seconds()
minutes = total // 60
seconds = total % 60
if seconds < 10:
return str(minutes) + ":0" + str(seconds)
return str(minutes) + ":" + str(seconds)
Using it:
playlist = Playlist()
playlist.add(Song("Intro", 65))
playlist.add(Song("Sunrise", 200))
print(playlist.total_seconds(), playlist.duration())
print(playlist.remove("Intro"), playlist.duration())
265 4:25
True 3:20
Two songs can share a title. Here remove("Echo") takes only one of them each time:
playlist = Playlist()
playlist.add(Song("Echo", 30))
playlist.add(Song("Echo", 45))
playlist.add(Song("Rain", 5))
print(playlist.remove("Echo"), playlist.total_seconds())
print(playlist.remove("Echo"), playlist.remove("Echo"))
print(playlist.duration())
True 50
True False
0:05
The first call removes the 30-second song, leaving 45 + 5 = 50 seconds. The second removes the other Echo, and the third finds none, so it returns False. Only Rain is left: 5 seconds, written 0:05.
Why duration calls total_seconds
Both methods need the total length. If duration repeated the loop, a change to the way songs are counted would have to be made twice. Calling self.total_seconds() keeps that rule in one place, and duration is left with one job: formatting. Methods of a class can call each other through self in this way, just like they read its attributes.
The playlist also shows why a class holds a list of other objects. Playlist never copies a song's title or seconds into its own attributes. It keeps the Song objects and asks each one when it needs the data, so a song is always one object with both values together. It also means remove can compare titles with self._songs[i].title == title and, once it finds a match, take the whole song out in one step.
Formatting the duration
Some totals and their durations: 5 gives 0:05, 59 gives 0:59, 60 gives 1:00, 750 gives 12:30 and 3600 gives 60:00. The padding rule only needs a check for seconds below 10, because the other values already have two digits. The minutes stay as they are, so an hour shows as 60 minutes, not 1:00:00.
Edge cases and common mistakes
- Empty playlist.
total_seconds()is 0,duration()is"0:00"andremove("Rain")returnsFalse. - Titles are case-sensitive. After the playlist above,
remove("rain")returnsFalse, because"rain"and"Rain"are different titles. - Forgetting to return after
pop. Without thereturn True, the loop goes on and could remove a second song with the same title, or fail when the list is shorter than the range it started with. - Removing while looping over the songs. Deleting from a list you are iterating with
for song in self._songsskips elements. Loop over indexes and return right after the removal, as the solution does. - Adding a number to a string.
minutes + ":"fails with aTypeErrorwhenminutesis an integer. Usestr(minutes). - Unpadded seconds. Without the extra
0, 5 seconds would read0:5.
