CMU 15-112: Fundamentals of Programming and Computer Science
Class Notes: Object-Oriented Programming (OOP)


  1. Objects and Classes
  2. Constructor (__init__)
  3. Type Testing (type, isinstance)
  4. Generic Class Methods
    1. Equality Testing (__eq__)
    2. Converting to Strings (__str__ and __repr__)
    3. Using in Sets and Dictionaries (__hash__ and __eq__)
    4. Fraction Example
  5. Class-Level Features
    1. Class Attributes
    2. Static Methods
    3. Playing Card Demo
  6. Inheritance
    1. Specifying a Superclass
    2. Overriding methods
    3. isinstance vs type in inherited classes
    4. Monster Demo
  7. Additional Reading

  1. Objects and Classes  
    In programming, an object is a data structure that has user-defined properties and methods associated with it. Its properties are features of the object; for example, a property of a Dog object might be its breed. These are implemented as variables. Its methods are things the object can do; for example, a method of a Dog might be a function called speak() which prints out "Bark!". These are implemented as functions.

    Objects are defined using a class, which can be thought of as a template for a generic object. Once the class has been written, individual objects can be created using this template. These individual objects are called instances.

    class Dog(object):
        # define properties and methods of a generic dog here
        pass
    
    fido = Dog() # fido is now a specific instance of the class

  2. Constructor (__init__)  
    class A(object):
        def __init__(self, color, isHappy):
            self.color = color
            self.isHappy = isHappy
    
        def isBlue(self):
            return self.color == "blue"
    
    a1 = A('yellow', True)
    a2 = A('blue', False)
    print(a1.color, a1.isHappy, a1.isBlue())
    print(a2.color, a2.isHappy, a2.isBlue())

  3. Type Testing (type, isinstance)  
    class A(object): pass
    a = A()
    print(type(a))           # A (technically, < class '__main__.A' >)
    print(type(a) == A)      # True
    print(isinstance(a, A))  # True

  4. Generic Class Methods
    1. Equality Testing (__eq__)  
      The problem:
      class A(object):
          def __init__(self, x):
              self.x = x
      a1 = A(5)
      a2 = A(5)
      print(a1 == a2)  # False!

      The partial solution: __eq__
      class A(object):
          def __init__(self, x):
              self.x = x
          def __eq__(self, other):
              return (self.x == other.x)
      a1 = A(5)
      a2 = A(5)
      print(a1 == a2)  # True
      print(a1 == 99)  # crash (darn!)

      A better solution:
      class A(object):
          def __init__(self, x):
              self.x = x
          def __eq__(self, other):
              return (isinstance(other, A) and (self.x == other.x))
      a1 = A(5)
      a2 = A(5)
      print(a1 == a2)  # True
      print(a1 == 99)  # False (huzzah!)

    2. Converting to Strings (__str__ and __repr__)  
      The problem:
      class A(object):
          def __init__(self, x):
              self.x = x
      a = A(5)
      print(a) # prints <__main__.A object at 0x102916128> (yuck!)

      The partial solution: __str__
      class A(object):
          def __init__(self, x):
              self.x = x
          def __str__(self):
              return "A(x=%d)" % self.x
      a = A(5)
      print(a) # prints A(x=5) (better)
      print([a]) # prints [<__main__.A object at 0x102136278>] (yuck!)

      The better solution: __repr__
      # Note: repr should be a computer-readable form so that
      # (eval(repr(obj)) == obj), but we are not using it that way.
      # So this is a simplified use of repr.
      
      class A(object):
          def __init__(self, x):
              self.x = x
          def __repr__(self):
              return "A(x=%d)" % self.x
      a = A(5)
      print(a) # prints A(x=5) (better)
      print([a]) # [A(x=5)]

    3. Using in Sets and Dictionaries (__hash__ and __eq__)  
      The problem:
      class A(object):
          def __init__(self, x):
              self.x = x
      
      s = set()
      s.add(A(5))
      print(A(5) in s) # False
      
      d = dict()
      d[A(5)] = 42
      print(d[A(5)]) # crashes

      The solution: __hash__ and __eq__
      class A(object):
          def __init__(self, x):
              self.x = x
          def __hash__(self):
              return hash(self.x)
          def __eq__(self, other):
              return (isinstance(other, A) and (self.x == other.x))
      
      s = set()
      s.add(A(5))
      print(A(5) in s) # True (whew!)
      
      d = dict()
      d[A(5)] = 42
      print(d[A(5)]) # works!

      A better (more generalizable) solution
      # Your getHashables method should return the values upon which
      # your hash method depends, that is, the values that your __eq__
      # method requires to test for equality.
      # CAVEAT: a proper hash function should only test values that will not change!
      
      class A(object):
          def __init__(self, x):
              self.x = x
          def getHashables(self):
              return (self.x, ) # return a tuple of hashables
          def __hash__(self):
              return hash(self.getHashables())
          def __eq__(self, other):
              return (isinstance(other, A) and (self.x == other.x))
      
      s = set()
      s.add(A(5))
      print(A(5) in s) # True (still works!)
      
      d = dict()
      d[A(5)] = 42
      print(d[A(5)]) # works!

    4. Fraction Example
      # Very simple, far-from-fully implemented Fraction class
      # to demonstrate the OOP ideas from above.
      # Note that Python actually has a full Fraction class that
      # you would use instead (from fractions import Fraction),
      # so this is purely for demonstrational purposes.
      
      def gcd(x, y):
          if (y == 0): return x
          else: return gcd(y, x%y)
      
      class Fraction(object):
          def __init__(self, num, den):
              # Partial implementation -- does not deal with 0 or negatives, etc
              g = gcd(num, den)
              self.num = num // g
              self.den = den // g
      
          def __repr__(self):
              return '%d/%d' % (self.num, self.den)
      
          def __eq__(self, other):
              return (isinstance(other, Fraction) and
                      ((self.num == other.num) and (self.den == other.den)))
      
          def times(self, other):
              if (isinstance(other, int)):
                  return Fraction(self.num * other, self.den)
              else:
                  return Fraction(self.num * other.num, self.den * other.den)
      
          def __hash__(self):
              return hash((self.num, self.den))
      
      def testFractionClass():
          print('Testing Fraction class...', end='')
          assert(str(Fraction(2, 3)) == '2/3')
          assert(str([Fraction(2, 3)]) == '[2/3]')
          assert(Fraction(2,3) == Fraction(2,3))
          assert(Fraction(2,3) != Fraction(2,5))
          assert(Fraction(2,3) != "Don't crash here!")
          assert(Fraction(2,3).times(Fraction(3,4)) == Fraction(1,2))
          assert(Fraction(2,3).times(5) == Fraction(10,3))
          s = set()
          assert(Fraction(1, 2) not in s)
          s.add(Fraction(1, 2))
          assert(Fraction(1, 2) in s)
          s.remove(Fraction(1, 2))
          assert(Fraction(1, 2) not in s)
          print('Passed.')
      
      if (__name__ == '__main__'):
          testFractionClass()

  5. Class-Level Features
    1. Class Attributes  
      class A(object):
          dirs = ["up", "down", "left", "right"]
      
      # typically access class attributes directly via the class (no instance!)
      print(A.dirs) # ['up', 'down', 'left', 'right']
      
      # can also access via an instance:
      a = A()
      print(a.dirs)
      
      # but there is only one shared value across all instances:
      a1 = A()
      a1.dirs.pop() # not a good idea
      a2 = A()
      print(a2.dirs) # ['up', 'down', 'left'] ('right' is gone from A.dirs)

    2. Static Methods  
      class A(object):
          @staticmethod
          def f(x):
              return 10*x
      
      print(A.f(42)) # 420 (called A.f without creating an instance of A)

    3. Playing Card Demo  
      # oopy-playing-cards-demo.py
      # Demos class attributes, static methods, repr, eq, hash
      
      import random
      
      class PlayingCard(object):
          numberNames = [None, "Ace", "2", "3", "4", "5", "6", "7",
                         "8", "9", "10", "Jack", "Queen", "King"]
          suitNames = ["Clubs", "Diamonds", "Hearts", "Spades"]
          CLUBS = 0
          DIAMONDS = 1
          HEARTS = 2
          SPADES = 3
      
          @staticmethod
          def getDeck(shuffled=True):
              deck = [ ]
              for number in range(1, 14):
                  for suit in range(4):
                      deck.append(PlayingCard(number, suit))
              if (shuffled):
                  random.shuffle(deck)
              return deck
      
          def __init__(self, number, suit):
              # number is 1 for Ace, 2...10,
              #           11 for Jack, 12 for Queen, 13 for King
              # suit is 0 for Clubs, 1 for Diamonds,
              #         2 for Hearts, 3 for Spades
              self.number = number
              self.suit = suit
      
          def __repr__(self):
              return ("<%s of %s>" %
                      (PlayingCard.numberNames[self.number],
                       PlayingCard.suitNames[self.suit]))
      
          def getHashables(self):
              return (self.number, self.suit) # return a tuple of hashables
      
          def __hash__(self):
              return hash(self.getHashables())
      
          def __eq__(self, other):
              return (isinstance(other, PlayingCard) and
                      (self.number == other.number) and
                      (self.suit == other.suit))
      
      # Show this code in action
      print("Demo of PlayingCard will keep creating new decks, and")
      print("drawing the first card, until we see the same card twice.")
      print()
      cardsSeen = set()
      diamondsCount = 0
      
      # Now keep drawing cards until we get a duplicate
      while True:
          deck = PlayingCard.getDeck()
          drawnCard = deck[0]
          if (drawnCard.suit == PlayingCard.DIAMONDS):
              diamondsCount += 1
          print("  drawnCard:", drawnCard)
          if (drawnCard in cardsSeen): break
          cardsSeen.add(drawnCard)
      
      # And then report how many cards we drew
      print("Total cards drawn:", 1+len(cardsSeen))
      print("Total diamonds drawn:", diamondsCount)

  6. Inheritance
    1. Specifying a Superclass  
      class A(object):
          def __init__(self, x):
              self.x = x
          def f(self):
              return 10*self.x
      
      class B(A):
          def g(self):
              return 1000*self.x
      
      print(A(5).f()) # 50
      print(B(7).g()) # 7000
      print(B(7).f()) # 70 (class B inherits the method f from class A)
      print(A(5).g()) # crashes (class A does not have a method g)

    2. Overriding methods  
      class A(object):
          def __init__(self, x):
              self.x = x
          def f(self):
              return 10*self.x
          def g(self):
              return 100*self.x
      
      class B(A):
          def __init__(self, x=42, y=99):
              super().__init__(x) # call overridden init!
              self.y = y
          def f(self):
              return 1000*self.x
          def g(self):
              return (super().g(), self.y)
      
      a = A(5)
      b = B(7)
      print(a.f()) # 50
      print(a.g()) # 500
      print(b.f()) # 7000
      print(b.g()) # (700, 99)

    3. isinstance vs type in inherited classes  
      class A(object): pass
      class B(A): pass
      a = A()
      b = B()
      print(type(a) == A) # True
      print(type(b) == A) # False
      print(type(a) == B) # False
      print(type(b) == B) # True
      print()
      print(isinstance(a, A)) # True
      print(isinstance(b, A)) # True (surprised?)
      print(isinstance(a, B)) # False
      print(isinstance(b, B)) # True

    4. Monster Demo
      # This is our base class
      class Monster(object):
          def __init__(self, strength, defense):
              self.strength = strength
              self.defense = defense
              self.health = 10
      
          def attack(self): # returns damage to be dealt
              if self.health > 0:
                  return self.strength
      
          def defend(self, damage): # does damage to self
              self.health -= damage
      
      # In this class, we'll partially overwrite the init method, and make a new, class-specific method
      class MagicMonster(Monster):
          def __init__(self, strength, defense):
              super().__init__(strength, defense) # most properties are the same
              self.health = 5 # but they start out weaker
      
          def heal(self): # only magic monsters can heal themselves!
              if 0 < self.health < 5:
                  self.health += 1
      
      # In this class, we'll overwrite a specific method
      class NecroMonster(Monster):
          def attack(self): # NecroMonsters can attack even when 'killed'
              return self.strength

  7. Additional Reading
    For more on these topics, and many additional OOP-related topics, check the following links:
         https://docs.python.org/3/tutorial/classes.html
         https://docs.python.org/3/reference/datamodel.html