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


  1. Objects and Classes
  2. Constructor (__init__)
  3. Check 8.1
  4. Type Testing (type, isinstance)
  5. Generic Class Methods
    1. Equality Testing (__eq__)
    2. Check 8.2
    3. Converting to Strings (__str__ and __repr__)
    4. Using in Sets and Dictionaries (__hash__ and __eq__)
    5. Check 8.3
    6. Fraction Example
  6. Class-Level Features
    1. Class Attributes
    2. Static Methods
    3. Playing Card Demo
  7. Check 8.4
  8. Inheritance
    1. Specifying a Superclass
    2. Overriding methods
    3. isinstance vs type in inherited classes
    4. Monster Demo
  9. Check 8.5
  10. 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__)
    The constructor method __init__ is used to set up any newly-created objects. It takes "self" as input, along with any user-defined inputs needed to create the object. In the example below, we're using the inputs "color" and "isHappy" to initialize two properties of our object. If we wish, we can also set up other properties that aren't dependent on input, like self.shape below, which always starts as 'circle'.
    Note about "self": We don't actually provide "self" as an input when we create the object. The "self" variable is always automatically provided as the first input, and allows us to interact with the object itself. We'll see this more later; just take note of it for now!
    class A(object): def __init__(self, color, isHappy): self.color = color self.isHappy = isHappy self.shape = 'circle' def isBlue(self): return self.color == "blue" a1 = A('yellow', True) #Note that we don't provide self as input a2 = A('blue', False) print(a1.color, a1.isHappy, a1.shape, a1.isBlue()) print(a2.color, a2.isHappy, a1.shape, a2.isBlue())

  3. Check 8.1

  4. Type Testing (type, isinstance)
    We can check the type of objects we create, which can be handy if we have multiple classes.
    class A(object): pass a = A() print(type(a)) # A (technically, < class '__main__.A' >) print(type(a) == A) # True print(isinstance(a, A)) # True

  5. Generic Class Methods
    1. Equality Testing (__eq__)
      The problem:
      In the code below, we make two objects using identical expressions. Shouldn't they be equal...?
      class A(object): def __init__(self, x): self.x = x a1 = A(5) a2 = A(5) print(a1 == a2) # False!

      The partial solution: __eq__
      The __eq__ method tells Python how it should evalute the equality of two objects. Predictably, "self" is the object that the method belongs to, and "other" is the other variable we want to compare against. Note that it crashes if we compare it against an integer or other data types, though...
      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:
      Remember short-circuit evaluation? We can use it to check the type of a variable before we try to access a property that may or may not exist! This is more robust; you should include a type check like this when writing __eq__ methods. Note that the method must be called __eq__(self,other) or Python won't associate it with ==.
      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!)

      ...You might be wondering "if we compare two different objects of different classes, which __eq__ runs?" It shouldn't matter if you write both methods to return False if compared with a different data type, but if you're curious, try and figure it out through experimentation!

    2. Check 8.2

    3. Converting to Strings (__str__ and __repr__)
      The problem:
      Just like with ==, Python doesn't really know how we want it to print our objects...
      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__
      We can define __str__ and see that "a" is of class "A" but it still tells us stuff that doesn't mean much to us humans.
      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__
      The __repr__ method is an alternative we can use to make print display only what we specify.
      # 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)]

    4. Using in Sets and Dictionaries (__hash__ and __eq__)
      The problem:
      It can be handy to put objects in a set or as dictionary keys, but they're unhashable!
      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__
      We'll cover this in class so it's ok if this part is a little confusing! We can define the __hash__ method to tell Python what a particular object's hash value should be. Note that we call the hash function directly and return the result.
      Important: You must define __eq__ if you define __hash__ for a class! Also, we'll talk about this in class, but the properties you choose to hash on should be immutable types, should never change, and should be different for each instance.
      For instance, if we had a class called "dog" we might want to hash on the tuple (self.name, self.breed) since these shouldn't change for any particular dog. Conveniently, these are probably some of the properties you'll want to compare in your __eq__ method (though __eq__ can test the equivalence of mutable things too).
      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
      You can make a method called getHashables that packages the things you want to hash into a tuple. It's not required, but can be a little clearer to understand if you're hashing on many things.
      # 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!

    5. Check 8.3

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

  6. Class-Level Features
    1. Class Attributes
      Attributes are values specified in a class that are shared by all instances of that class! Note that we can access attributes from any object of that class, but changing those values anywhere changes them for every instance.
      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
      We can define methods in our class that can be called directly without necessarily making and/or referencing a specific object.
      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
      Let's check out what we've learned in another example!
      # 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)

  7. Check 8.4

  8. Inheritance
    We can define superclasses and subclasses that let us make different types of objects with some shared properties. Where might this be useful? Let's say we make a class called "pet" with some basic properties like name and age. Now we could make a subclass called "cat" that inherits the properties and methods of pet, but also adds some cat-specific properties like whiskerLength and pawSoftness, and methods like meow(self, volume) and scratch(self, furniture).
    Likewise, we could make pet subclasses "dog" and "axolotl" that also inherit from the pet class (so we don't have to define any shared properties and methods for each class) but have their own specific features too!
    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
      If we define a method in a subclass with the same name as a method in its superclass, that method will be overriden in the subclass. This is handy if we want to inherit most things from a superclass, but need a few things to work differently.
      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

  9. Check 8.5

  10. 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