Learning outcomes:
# ASSIGNMENT METADATA
assignment_id: "oop"
In [1]:
# MASTER ONLY
from prog_edu_assistant_tools.magics import report, autotest
%load_ext prog_edu_assistant_tools.magics
In this assignment, we'll be exploring different ways to represent dates. Although we use dates in our daily lives, they are surprisingly complicated with a lot of edge-cases. For example, how do we decide what's a valid date? How do we figure out what date it is tomorrow? And how do we even deal with time zones?
Object-oriented programming provides us with a framework to avoid dealing with these fine details. Instead of thinking about individual steps (e.g., to determine tomorrow's date, we have to add a day to the month, or add a month to the year, or add a year!) we can think of a Date as something that can do things (e.g., "tell me your next date"), and expect that it will do these things correctly.
In the following section, we'll be using the terms "class", "object", and "instance" a lot. Let's clear up what each of these terms mean.
To make these concrete, we want to create a Date class. This is to say, we want to have a blueprint for how we talk about dates, e.g., how they're created, what information they store, and what information they can tell us. Afterwards, we want to use this blueprint to create Date objects, which we can later use in our programs.
The Python language supports object-oriented programming. We'll first be exploring the following concepts in Python:
class
that is a blueprint for our Date
objects.class
es can have properties, which are pieces of data related to the class. We'll be using properties to store the year, month, and day associated with the date.Below, we define the Date
class to represent a date. Notice that we define the __init__(...)
function inside the class. This is known as the constructor.
__init__(...)
, which stands for "initialize". This is a special function that Python understands as being used to create a Date
object. Notice that it takes four arguments, self
, and the components of a date.self
is a special argument that is used to refer to "ourself". This is because in our blueprint, we need a way to refer to the object we're creating and modifying itself.self.year
, self.month
, and self.day
. The dot operator, .
, can be thought of as something like a possessive (in English, this would be an 's). So, self.year
is like "self's year".
In [2]:
class Date(object):
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
To call the constructor, we call the class, e.g., Date(2019, 4, 7)
to create a date corresponding to 2019/4/7. Notice that we don't need to pass self
as an argument since Python does this for us. After creating our Date
, we can then access its properties using the dot operator.
In [3]:
d = Date(2019, 4, 7)
print('{}/{}/{}'.format(d.year, d.month, d.day))
In [4]:
class Date(object):
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# This is another special function that allows us to call print(...) on a
# Date object.
def __str__(self):
return '{}/{}/{}'.format(self.year, self.month, self.day)
In [5]:
d = Date(2019, 4, 7)
print(d)
In the following code cells, implement the is_valid_date
, increment_date
, is_equal_date
, and is_later_date
functions.
# EXERCISE METADATA
exercise_id: "is_equal_date"
This method should return True
if date
is equal to other_date
.
In [6]:
%%solution
def is_equal_date(date, other_date):
""" # BEGIN PROMPT
# Put your program here!
pass
""" # END PROMPT
# BEGIN SOLUTION
return (date.year == other_date.year and
date.month == other_date.month and
date.day == other_date.day)
# END SOLUTION
In [7]:
# TEST
assert is_equal_date(Date(2019, 4, 15), Date(2019, 4, 15))
assert not is_equal_date(Date(2019, 4, 15), Date(2019, 4, 16))
assert not is_equal_date(Date(2019, 4, 31), Date(2019, 5, 1))
In [8]:
# BEGIN UNITTEST
#import submission
import unittest
class Date(object):
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# This is another special function that allows us to call print(...) on a
# Date object.
def __str__(self):
return '{}/{}/{}'.format(self.year, self.month, self.day)
class IsEqualTest(unittest.TestCase):
def test_equal1(self):
self.assertTrue(submission.is_equal_date(Date(2019, 4, 15), Date(2019, 4, 15)))
def test_equal_neg1(self):
self.assertFalse(submission.is_equal_date(Date(2019, 4, 15), Date(2019, 4, 16)))
def test_equal_neg2(self):
self.assertFalse(submission.is_equal_date(Date(2019, 4, 15), Date(2019, 5, 15)))
def test_equal_neg3(self):
self.assertFalse(submission.is_equal_date(Date(2019, 4, 15), Date(2018, 4, 15)))
# END UNITTEST
from prog_edu_assistant_tools.magics import autotest, report
result, log = autotest(IsEqualTest)
print(log)
assert(result.results['IsEqualTest.test_equal1'])
assert(result.results['IsEqualTest.test_equal_neg1'])
assert(result.results['IsEqualTest.test_equal_neg2'])
assert(result.results['IsEqualTest.test_equal_neg3'])
# EXERCISE METADATA
exercise_id: "is_later_date"
This method should return True
if date
is later than other_date
.
In [9]:
%%solution
def is_later_date(date, other_date):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
if date.year != other_date.year:
return date.year > other_date.year
if date.month != other_date.month:
return date.month > other_date.month
return date.day > other_date.day
# END SOLUTION
In [10]:
assert is_later_date(Date(2019, 4, 15), Date(2019, 4, 14))
assert not is_later_date(Date(2019, 4, 15), Date(2019, 4, 15))
assert not is_later_date(Date(2019, 4, 15), Date(2019, 4, 16))
assert is_later_date(Date(2019, 4, 15), Date(2018, 4, 15))
assert is_later_date(Date(2019, 5, 15), Date(2018, 4, 15))
# EXERCISE METADATA
exercise_id: "is_valid_date"
This method should return True
if date
is a valid date, assuming that 0AD is the first supported year. As such, the method should check whether the year is valid; whether the month is between 1 and 12; and whether the day is valid given the month and year (e.g., taking into account leap years).
In [11]:
%%solution
# BEGIN SOLUTION
def is_leap_year(year):
if (year % 4) != 0:
return False
if (year % 100) != 0:
return True
return (year % 400) == 0
def days_in_month(month, year):
if month == 2:
return 29 if is_leap_year(year) else 28
elif month in [ 4, 6, 9, 11 ]:
return 30
else:
return 31
# END SOLUTION
def is_valid_date(date):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
if date.year < 0:
return False
if date.month < 1 or date.month > 12:
return False
if date.day < 1:
return False
return date.day <= days_in_month(date.month, date.year)
# END SOLUTION
In [12]:
assert is_valid_date(Date(2019, 4, 7))
assert not is_valid_date(Date(2019, -1, 7))
assert not is_valid_date(Date(2019, 4, -1))
assert not is_valid_date(Date(-1, 4, 7))
assert not is_valid_date(Date(2019, 4, 32))
assert not is_valid_date(Date(2019, 13, 7))
assert not is_valid_date(Date(2019, 4, 31))
# EXERCISE METADATA
exercise_id: "increment_date"
Increments date
by quantity
.
As an example, if we call:
d = Date(2019, 4, 5)
increment_date(d, 5)
d
should then be equal to (as defined by the earlier is_equal_date()
function) Date(2019, 4, 10)
.
This method should be able to handle large quantities. For example, if we call:
d = Date(2019, 4, 5)
increment_date(d, 30)
The resulting day should be equal to Date(2019, 5, 5)
.
In [13]:
%%solution
# BEGIN SOLUTION
def increment_date_by_day(date):
if is_valid_date(Date(date.year, date.month, date.day + 1)):
date.day += 1
return
if is_valid_date(Date(date.year, date.month + 1, 1)):
date.month += 1
date.day = 1
return
date.year += 1
date.month = 1
date.day = 1
return
# END SOLUTION
def increment_date(date, quantity):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
for _ in range(quantity):
increment_date_by_day(date)
# END SOLUTION
In [14]:
def increment_and_return_date(date, quantity):
increment_date(date, quantity)
return date
assert is_equal_date(increment_and_return_date(Date(2019, 4, 7), 2),
Date(2019, 4, 9))
assert is_equal_date(increment_and_return_date(Date(2019, 4, 7), 24),
Date(2019, 5, 1))
Previously, we defined many functions that all took a date
and either modified or told us something about date
. Methods flip this relationship by defining actions that can be performed by a class, rather than having actions performed on a class. Defining methods rather than functions allows us to encapsulate actions within a class and keep their implementations close to one another.
In the below example, we define is_valid
and increment
as methods. Notice that these can all directly refer to the Date
itself through the self
parameter. __init__(...)
is also a method, albeit a special one understood by Python.
In [15]:
class Date(object):
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# This is another special function that allows us to call print(...) on a
# Date object.
def __str__(self):
return '{}/{}/{}'.format(self.year, self.month, self.day)
def is_valid(self):
return is_valid_date(self)
def increment(self, quantity):
return increment_date(self, quantity)
To invoke a method, we again use the dot operator, but now call the method as we would other functions.
In [16]:
d = Date(2019, 12, 32)
assert Date(2019, 1, 1).is_valid()
class
definitions, and are different from regular functions in that they always have a self
argument. When you call a method on the class, Python automatically knows to pass the object as self
, so you only need to pass the remaining arguments.# EXERCISE METADATA
exercise_id: "date_comparators"
Another advantage of using methods is that we can have a consistent way to talk to objects. For example, we previously defined the is_equal_dates
and is_later_dates
functions, but how could we remember the specifics of these functions?
Instead, we can rely on common comparisons that we generally understand and agree upon, e.g., <, >, and ==. Objects allow us to share these common interfaces between objects by defining how these operators should work through special methods.
In the following exercise, define the __eq__(...)
through __ge__(...)
methods.
Hint: only two of the comparators are required to define all the other comparators.
In [17]:
%%solution
class Date(object):
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# This is another special function that allows us to call print(...) on a
# Date object.
def __str__(self):
return '{}/{}/{}'.format(self.year, self.month, self.day)
def is_valid(self):
return is_valid_date(self)
def increment(self, quantity):
return increment_date(self, quantity)
def __eq__(self, other):
return is_equal_date(self, other)
def __gt__(self, other):
return is_later_date(self, other)
def __ne__(self, other):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
return not self == other
# END SOLUTION
def __lt__(self, other):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
return not self == other and not self > other
# END SOLUTION
def __le__(self, other):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
return not self > other
# END SOLUTION
def __ge__(self, other):
""" # BEGIN PROMPT
# Put your solution here!
pass
""" # END PROMPT
# BEGIN SOLUTION
return self == other or self > other
# END SOLUTION
In [18]:
assert Date(2019, 4, 15) < Date(2019, 4, 16)
assert Date(2019, 4, 15) <= Date(2019, 4, 16)
assert Date(2019, 4, 16) <= Date(2019, 4, 16)
assert Date(2019, 4, 16) > Date(2019, 4, 15)
assert Date(2019, 4, 16) >= Date(2019, 4, 15)
assert Date(2019, 4, 16) >= Date(2019, 4, 16)
assert Date(2019, 4, 15) != Date(2019, 4, 16)
Another advantage of using classes is their potential for reuse. One pattern that's commonly used is the idea of inheritance, where one class is said to inherit from another. This means that one class "takes" all of the functionality of another, and extends it somehow. In this section, we explore the idea of adding the concept of time to our Date
class by defining a class called a DateTime
.
A class can be declared as a subclass of another in its class
declaration. We simply need to add the name of the class being subclassed from behind the name of our new class in parentheses. In the following example, use DateTime(Date)
to indicate that DateTime
is a subclass of Date
.
In [ ]:
class DateTime(Date):
pass
You may notice that this is the same as how we initially defined the Date
class! Python classes are typically subclasses of the generic object
class, which defines methods like __init__
, __str__
, and our comparators. These provide a nice, consistent interface that we're familiar and comfortable with whenever we use Python.
By default, a subclass will "take" all the implementations of its superclass. For example, we can create a new DateTime
and call the methods we previously implemented on Date
using the same syntax as before, and the superclass implementation will be called.
In [ ]:
# Calls the superclass constructor.
dt = DateTime(2019, 3, 5)
# Check if the date provided is valid.
print(dt.is_valid())
However, we want to add new functionality in our DateTime
class. This can be done through method overrides. This is a way of saying that instead of relying on the superclass implementation of a method, we want callers to use our own.
When you call a method on an object, Python attempts to find the most derived implementation of method, then calls it. For example, if we override is_valid()
on DateTime
to always return True
, then Python will call this version of the method instead of the one defined by Date
.
In [ ]:
class DateTime(Date):
def is_valid(self):
return True
dt = DateTime(2019, -1, 5)
# Calls our new implementation of `is_valid()`, which always returns True.
print(dt.is_valid())
Sometimes, in these methods, we would like to refer to the superclass implementation of a method. Python allows us to do this by calling super(DateTime, self)
, which returns the super "instance" of the class that backs our new subclass. One way that we can use this is in our constructor, where we want to set hour and minute, on top of the existing year, month, and day.
In [ ]:
class DateTime(Date):
def __init__(self, year, month, day, hour, minute):
super(DateTime, self).__init__(year, month, day)
self.hour = hour
self.minute = minute
def __str__(self):
return '{}/{}/{}, {}:{}'.format(self.year, self.month, self.day, self.hour,
self.minute)
dt = DateTime(2019, 5, 4, 13, 50)
print(dt)
Calling super()
is especially useful when we want to reuse a previous implementation. After all, since we put so much work into implementing our superclass, why should we throw all this work away! An example of this is how we might override the __eq__()
function to check that the minute and hour are equal, on top of all the other fields. Notice that we use the super()
implementation to do the work for all the other fields.
In [ ]:
class DateTime(Date):
def __init__(self, year, month, day, hour, minute):
super(DateTime, self).__init__(year, month, day)
self.hour = hour
self.minute = minute
def __str__(self):
return '{}/{}/{}, {}:{}'.format(self.year, self.month, self.day, self.hour,
self.minute)
def __eq__(self, other):
if not isinstance(other, DateTime):
return False
if self.minute != other.minute:
return False
if self.hour != other.hour:
return False
return super(DateTime, self).__eq__(other)
assert DateTime(2019, 5, 4, 13, 50) == DateTime(2019, 5, 4, 13, 50)
# Relies on our subclass implementation to identify as different.
assert DateTime(2019, 5, 4, 13, 50) != DateTime(2019, 5, 4, 13, 51)