Object-Oriented Programming

Learning outcomes:

  • Understand the concept of "encapsulation" and "abstraction"
  • Understand why we use classes in object-oriented programming
  • Understand the Python syntax for initializing classes, calling methods, and using properties.
# 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

Dates

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.

  • Abstraction: Hiding these details is known as abstraction. When talking to a Date, all we should need to know is how to talk to it (i.e., what it can do, and what questions it can answer), rather than how it does it.
  • Encapsulation: All details relating to dates should be grouped together behind Date objects. This way, we can simply trust that the Date works correctly rather than fiddling around with many different pieces. This can be in terms of related functionality, or even keeping the pieces of a date together in a single place (i.e., the day, month, and year).

The Date class: constructors and properties

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.

  • Class: A class is a blueprint for how a certain group of things behave.
  • Object: An object is something that is been created out of one such blueprint.
  • Instance: An "instance of a class" is another way of describing an object of a particular class.

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:

  • We will create a Date class that is a blueprint for our Date objects.
  • classes 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.

Constructors

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.

  • This function has a special name, __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.
  • Inside the function, we then assign the components of the date to 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

Using constructors and properties

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))


2019/4/7

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)


2019/4/7

Exercise: Using the Date class and its properties

In the following code cells, implement the is_valid_date, increment_date, is_equal_date, and is_later_date functions.

is_equal_date

# 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'])


test_equal1 (__main__.IsEqualTest) ... ok
test_equal_neg1 (__main__.IsEqualTest) ... ok
test_equal_neg2 (__main__.IsEqualTest) ... ok
test_equal_neg3 (__main__.IsEqualTest) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

is_later_date

# 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))

is_valid_date

# 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))

increment_date

# 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))

The Date class: methods

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()

Aside: functions and methods, what's the difference?

  • A function is a way to express a series of operations in code. The operations can take some or no inputs, and can produce some or no outputs. Sometimes, functions can modify their inputs. In practice, a function is a helpful way to (1) organize code to make it easier to understand, and (2) allow us to easily reuse code in different parts of our program.
  • A method is a specific type of function that are used with classes. They are defined inside of 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: Defining comparators for the Date class

# 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)


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-18-ed9adf78a70e> in <module>
      1 assert Date(2019, 4, 15) < Date(2019, 4, 16)
----> 2 assert Date(2019, 4, 15) <= Date(2019, 4, 16)
      3 assert Date(2019, 4, 16) <= Date(2019, 4, 16)
      4 assert Date(2019, 4, 16) > Date(2019, 4, 15)
      5 assert Date(2019, 4, 16) >= Date(2019, 4, 15)

TypeError: '<=' not supported between instances of 'Date' and 'Date'

The DateTime class: inheritance

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.

Declaring an inheritance relationship

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.

Overriding superclass implementations of methods

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)