Discussion 6: Object-Oriented Programming, Iterators and Generators
OOP
In a previous lecture, you were introduced to the programming paradigm known as Object-Oriented Programming (OOP). OOP allows us to treat data as objects - like we do in real life.
For example, consider the class Student
. Each of
you as individuals is an instance of this class.
So, a student Angela
would be an instance of the class
Student
.
Details that all CS 61A students have, such as name
, are called
instance variables. Every student has
these variables, but their values differ from student to student. A variable
that is shared among all instances of Student
is known as a
class variable.
An example would be the num_slip_days_allowed
attribute; the number of slip days that students can use during the semester is not a property of any given student but rather of all of them.
All students are able to do homework, attend lecture, and go to office hours.
When functions belong to a specific object, they are said to be
methods.
In this case, these actions would be bound methods of
Student
objects.
Here is a recap of what we discussed above:
- class: a template for creating objects
- instance: a single object created from a class
- instance variable: a data attribute of an object, specific to an instance
- class attribute: a data attribute of an object, shared by all instances of a class
- method: an action (function) that all instances of a class may perform
Questions
Q1: OOP WWPD - Student
Below we have defined the classesProfessor
and Student
, implementing some of what was described above.
Remember that we pass the self
argument implicitly to instance methods when using dot-notation.
class Student:
num_students = 0 # this is a class attribute
def __init__(self, name, staff):
self.name = name # this is an instance attribute
self.understanding = 0
Student.num_students += 1
print("There are now", Student.num_students, "students")
staff.add_student(self)
def visit_office_hours(self, staff):
staff.assist(self)
print("Thanks, " + staff.name)
class Professor:
def __init__(self, name):
self.name = name
self.students = {}
def add_student(self, student):
self.students[student.name] = student
def assist(self, student):
student.understanding += 1
What will the following lines output?
>>> callahan = Professor("Callahan")
>>> elle = Student("Elle", callahan)
>>> elle.visit_office_hours(callahan)
>>> elle.visit_office_hours(Professor("Paulette"))
>>> elle.understanding
>>> [name for name in callahan.students]
>>> x = Student("Vivian", Professor("Stromwell")).name
>>> x
>>> [name for name in callahan.students]
Q2: (Tutorial) Email
We would like to write three different classes (Server
, Client
,
and Email
) to simulate a system for sending and receiving email. Fill in the definitions below to finish
the implementation!
Important: We suggest that you approach this problem by first filling out the Email
class, then the register_client
method of Server
, the Client
class, and lastly the send
method of the Server
class.
Inheritance
Python classes can implement a useful abstraction technique known as
inheritance. To illustrate this concept, consider the following
Dog
and Cat
classes.
class Dog():
def __init__(self, name, owner):
self.is_alive = True
self.name = name
self.owner = owner
def eat(self, thing):
print(self.name + " ate a " + str(thing) + "!")
def talk(self):
print(self.name + " says woof!")
class Cat():
def __init__(self, name, owner, lives=9):
self.is_alive = True
self.name = name
self.owner = owner
self.lives = lives
def eat(self, thing):
print(self.name + " ate a " + str(thing) + "!")
def talk(self):
print(self.name + " says meow!")
Notice that because dogs and cats share a lot of similar qualities, there is a lot of repeated code! To avoid redefining attributes and methods for similar classes, we can write a single base class from which the similar classes inherit. For example, we can write a class called Pet and redefine Dog as a subclass of Pet:
class Pet():
def __init__(self, name, owner):
self.is_alive = True # It's alive!!!
self.name = name
self.owner = owner
def eat(self, thing):
print(self.name + " ate a " + str(thing) + "!")
def talk(self):
print(self.name)
class Dog(Pet):
def talk(self):
print(self.name + ' says woof!')
Inheritance represents a hierarchical relationship between two or more
classes where one class is a (no relation to the Python is
operator)
more specific version of the other, e.g.
a dog is a pet. Because Dog
inherits from Pet
, we
didn't have to redefine __init__
or eat
. However, since
we want Dog
to talk
in a way that is unique to dogs, we did
override the talk
method.
We can use the super()
function to refer to a class's superclass. For example, calling super()
with the class definition of Dog
allows us to refer to the Pet
class.
Here's an example of an alternate equivalent definition of Dog
that uses super()
to explicitly call the __init__
method of the parent class:
class Dog(Pet):
def __init__(self, name, owner):
super().__init__(name, owner)
# this is equivalent to calling Pet.__init__(self, name, owner)
def talk(self):
print(self.name + ' says woof!')
Keep in mind that creating the __init__
function shown above is actually not necessary, because creating a Dog
instance will automatically call the _init__
method of Pet
. Normally when defining an __init__
method in a subclass, we take some additional action to calling super().__init__
. For example, we could add a new instance variable like the following:
def __init__(self, name, owner, has_floppy_ears):
super().__init__(name, owner)
self.has_floppy_ears = has_floppy_ears
Questions
Q3: Cat
Below is a skeleton for the Cat
class, which inherits from
the Pet
class. To complete the implementation, override the
__init__
and talk
methods and add a new
lose_life
method. We have included the Pet
class as well for your convenience.
Run in 61A CodeHint: You can call the
__init__
method ofPet
to set a cat'sname
andowner
.
Q4: (Tutorial) NoisyCat
More cats! Fill in this implemention of a class calledNoisyCat
, which is just like a normal Cat
. However,
NoisyCat
talks a lot -- twice as much as a regular Cat
!
If you'd like to test your code, feel free to copy over your solution to the Cat
class above.
Run in 61A Code
Iterators
An iterable is a data type which contains
a collection of values which can be processed one by one
sequentially. Some examples of iterables we've seen include
lists, tuples, strings, and dictionaries. In general, any
object that can be iterated over in a for
loop
can be considered an iterable.
While an iterable contains values that can be iterated over,
we need another type of object called an iterator
to actually retrieve values contained in an iterable. Calling
the iter
function on an iterable will create an iterator
over that iterable. Each iterator keeps track of its position within
the iterable. Calling the next
function on an iterator will
give the current value in the iterable and move the iterator's position
to the next value.
In this way, the relationship between an iterable and an iterator is analogous to the relationship between a book and a bookmark - an iterable contains the data that is being iterated over, and an iterator keeps track of your position within that data.
Once an iterator has returned all the values in an iterable, subsequent
calls to next
on that iterable will result in a
StopIteration
exception. In order to be able to access the values
in the iterable a second time, you would have to create a second iterator. Check out the example below:
>>> a = [1, 2]
>>> a_iter = iter(a)
>>> next(a_iter)
1
>>> next(a_iter)
2
>>> next(a_iter)
StopIteration
Iterables can be used in for loops and as arguments to functions that require a
sequence (e.g. map
and zip
). For example:
>>> for n in range(2):
... print(n)
...
0
1
This works because the for loop implicitly creates an iterator using the
__iter__
method. Python then repeatedly calls next
repeatedly on the iterator, until it raises StopIteration
. In other
words, the loop above is (basically) equivalent to:
range_iterator = iter(range(2))
is_done = False
while not is_done:
try:
val = next(range_iterator)
print(val)
except StopIteration:
is_done = True
One important application of iterables
and iterators is the for
loop.
We've seen how we can use for
loops to iterate over iterables like lists and
dictionaries.
This only works because the for
loop implicitly creates an iterator
using the built-in iter
function.
Python then calls next
repeatedly on the iterator, until it raises
StopIteration
.
Most iterators are also iterables - that is, calling iter
on
them will return an iterator. This means that we can use them inside for
loops.
However, calling iter
on most iterators will not create a new iterator -
instead, it will simply return the same iterator.
We can also iterate over iterables in a list comprehension or pass in an iterable to
the built-in function list
in order to put the items of an iterable into
a list.
In addition to the sequences we've learned, Python has some built-in ways to create iterables and iterators. Here are a few useful ones:
range(start, end)
returns an iterable containing numbers from start to end-1. Ifstart
is not provided, it defaults to 0. Check out the docs for more details.map(f, iterable)
returns a new iterator containing the values resulting from applyingf
to each value initerable
. Check out the docs for more details and other uses ofmap
, such as passing in multiple iterables.filter(f, iterable)
returns a new iterator containing only the values initerable
for whichf(value)
returnsTrue
. Check out the docs for more details.
Questions
Q5: Iterators WWPD
What would Python display?
>>> s = [[1, 2]]
>>> i = iter(s)
>>> j = iter(next(i))
>>> next(j)
>>> s.append(3)
>>> next(i)
>>> next(j)
>>> next(i)
Generators
A generator function is a special kind of Python function that uses a yield statement instead of a return statement to report values. When a generator function is called, it returns a generator object, which is a type of iterator. Below, you can see a function that returns an iterator over the natural numbers.
>>> def gen_naturals():
... current = 0
... while True:
... yield current
... current += 1
>>> gen = gen_naturals()
>>> gen
<generator object gen at ...>
>>> next(gen)
0
>>> next(gen)
1
The yield
statement is similar to a return
statement.
However, while a return
statement closes the current frame after the
function exits, a yield
statement causes the frame to be saved until
the next time next
is called, which allows the generator to
automatically keep track of the iteration state.
Once next
is called again, execution resumes where it last
stopped and continues until the next yield
statement or the end of
the function. A generator function can have multiple yield
statements.
Including a yield
statement in a function automatically tells Python
that this function will create a generator. When we call the function, it
returns a generator object instead of executing the body. When the
generator's next
method is called, the body is executed until
the next yield
statement is executed.
When yield from
is called on an iterator, it will yield
every value from that iterator. It's similar to doing the following:
for x in an_iterator:
yield x
Questions
Q6: Filter-Iter
Implement a generator function calledfilter_iter(iterable, fn)
that only yields
elements of iterable
for which fn
returns True.
Run in 61A Code
Q7: (Tutorial) Merge
Write a generator functionmerge
that takes in two infinite generators a
and b
that are in increasing order without duplicates and returns a generator
that has all the elements of both generators, in increasing order, without duplicates.
Run in 61A Code