Lecture 11: Objects and Classes

CSCI 1360: Foundations for Informatics and Analytics

Overview and Objectives

In this lecture, we'll delve into the realm of "object-oriented programming," or OOP. This is a programming paradigm in which concepts and actions are "packaged" using the abstraction of objects: modeling the system after real-world phenomena, both to aid our own understanding of the program and to enforce a good design paradigm. By the end of this lecture, you should be able to:

  • Understand the core concepts of encapsulation and abstraction that make object-oriented programming so powerful
  • Implement your own class hierarchy, using inheritance to avoid redundant code
  • Explain how Python's OOP mechanism differs from that in other languages, such as Java

Part 1: Object-oriented Programming

Poll: how many people have programmed in Java or C++?

How many have heard of object-oriented programming?

Up until now (and after this lecture), we've stuck mainly with procedural programming: focusing on the actions.

Object-oriented programming, by contrast, focuses on the objects.

Sort of a "verbs" (procedural programming) versus "nouns" (object-oriented programming) thing.

Objects versus Classes

Main idea: you design objects, usually modeled after real-world constructs, that interact with each other.

These designs are called classes. Think of them as a blueprint that detail out the various properties and capabilities of your object.

From the designs (classes), you can create an object by instantiating the class, or creating an instance of the class.

If the class is the blueprint, the object is the physical manifestation from the blueprint.

A car can have several properties--steering column, fuel injector, brake pads, airbags--but an instantiation of car would be a 2015 Honda Accord. Another instantiation would be a 2016 Tesla Model S. These are instances of a car.

In some [abstract] sense, these both derive from a common blueprint of a car, but their specific details differ. This is precisely how to think of the difference between classes and objects.

Part 2: Objects in Python

Every object in Python has certain things in common.

  • Methods: Remember when we covered the difference between functions and methods? This is where that difference comes into play. Methods are the way the object interacts with the outside world. They're functions, but they're attached directly to object instances.
  • Constructors: These are specialized methods that deal specifically with how an object instance is created. Every single object has a constructor, whether you explicitly write one or not.
  • Attributes: These are the physical properties of the object; maybe they change, maybe they don't. For a car, this could be color, make, model, or name. These are the things that distinguish one instance of the class from another.
  • Inheritance: This is where the power of object-oriented programming really comes into play. Quite often, our understanding of physical objects in the world is hierarchical: there are cars; then there are race cars, sedans, and SUVs; then there are gas-powered sedans, hybrid sedans, and electric sedans; then there are 2015 Honda Accords and 2016 Honda Accords. Wouldn't it be great if our class design reflected this hierarchy?

Defining Classes

Let's start with the first step of designing a class: its actual definition. We'll stick with the car example.


In [1]:
class Car():
    """ A simple representation of a car. """
    pass

To define a new class, you need a class keyword, followed by the name (in this case, Car). The parentheses are important, but for now we'll leave them empty. Like loops and functions and conditionals, everything that belongs to the class--variables, methods, etc--are indented underneath.

We can then instantiate this class using the following:


In [2]:
my_car = Car()
print(my_car)


<__main__.Car object at 0x106e41978>

Now my_car holds an instance of the Car class! It doesn't do much, but it's a valid object.

Constructors

The first step in making an interesting class is by creating a constructor. It's a special kind of function that provides a customized recipe for how an instance of that class is built.

It takes a special form, too:


In [5]:
class Car():
    
    def __init__(self):
        print("This is the constructor!")

In [6]:
my_car = Car()


This is the constructor!

Let's look at this method in more detail.


In [5]:
def __init__(self):
        pass
  • The def is normal: the Python keyword we use to identify a function definition.
  • __init__ is the name of our method. It's an interesting name for sure, and turns out this is a very specific name Python is looking for: whenever you instantiate an object, this is the method that's run. If you don't explicitly write a constructor, Python implicitly supplies a "default" one (where basically nothing really happens).
  • The method argument is strange; what is this mysterious self, and why--if an argument is required--didn't we supply one when we executed my_car = Car()?

A note on self

This is how the object refers to itself from inside the object. We'll see this in greater detail once we get to attributes.

Every method in a class must have self as the first argument. Even though you don't actually supply this argument yourself when you call the method, it still has to be in the function definition.

Otherwise, you'll get some weird error messages:

Attributes

Attributes are variables contained inside a class, and which take certain values when the class is instantiated.

The most common practice is to define these attributes within the constructor of the class.


In [2]:
class Car():
    
    def __init__(self, year, make, model):
        
        # All three of these are class attributes.
        self.year = year
        self.make = make
        self.model = model

In [4]:
my_car = Car(2015, "Honda", "Accord")  # Again, note that we don't specify something for "self" here.
print(my_car.year)


2015

These attributes are accessible from anywhere inside the class, but direct access to them from outside (as did in the print(my_car.year) statement) is heavily frowned upon.

Instead, good object-oriented design stipulates that these attributes be treated as private variables to the class.

To be modified or otherwise used, the classes should have public methods that expose very specific avenues for interaction with the class attributes.

This is the concept of encapsulation: restricting direct access to attributes, and instead encouraging the use of class methods to interact with the attributes in very specific ways.

Methods

Methods are functions attached to the class, but which are accessible from outside the class, and define the ways in which the instances of the class can interact with the outside world.

Whereas classes are usually nouns, the methods are typically the verbs. For example, what would a Car class do?


In [7]:
class Car():
    
    def __init__(self, year, make, model):
        self.year = year
        self.make = make
        self.model = model
        self.mileage = 0
        
    def drive(self, mileage = 0):
        if mileage == 0:
            print("Driving!")
        else:
            self.mileage += mileage
            print("Driven {} miles total.".format(self.mileage))

In [8]:
my_car = Car(2016, "Tesla", "Model S")
my_car.drive(100)
my_car.drive()
my_car.drive(50)


Driven 100 miles total.
Driving!
Driven 150 miles total.

Classes can have as many methods as you want, named whatever you'd like (though usually named so they reflect their purpose).

Methods are what are ultimately allowed to edit the class attributes (the self. variables), as per the concept of encapsulation. For example, the self.mileage attribute in the previous example that stores the total mileage driven by that instance.

Like the constructor, all the class methods must have self as the first argument in their headers, even though you don't explicitly supply it when you call the methods.

Inheritance

Inheritance is easily the most complicated aspect of object-oriented programming, but is most certainly where OOP derives its power for modular design.

When considering cars, certainly most are very similar and can be modeled effectively with one class, but eventually there are enough differences to necessitate the creation of a separate class. For example, a class for gas-powered cars and one for EVs.

But considering how much overlap they still share, it'd be highly redundant to make wholly separate classes for both.


In [ ]:
class GasCar():
    def __init__(self, make, model, year, tank_size):
        # Set up attributes.
        pass
    
    def drive(self, mileage = 0):
        # Driving functionality.
        pass

In [ ]:
class ElectricCar():
    def __init__(self, make, model, year, battery_cycles):
        # Set up attributes.
        pass
    
    def drive(self, mileage = 0):
        # Driving functionality, probably identical to GasCar.
        pass

Enter inheritance: the ability to create subclasses of existing classes that retain all the functionality of the parent, while requiring the implementation only of the things that differentiate the child from the parent.


In [12]:
class Car():  # Parent class.
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.mileage = 0
    
    def drive(self, mileage = 0):
        self.mileage += mileage
        print("Driven {} miles.".format(self.mileage))

In [15]:
class EV(Car):  # Child class--explicitly mentions "Car" as the parent!
    def __init__(self, make, model, year, charge_range):
        Car.__init__(self, make, model, year)
        self.charge_range = charge_range
        
    def charge_remaining(self):
        if self.mileage < self.charge_range:
            print("Still {} miles left.".format(self.charge_range - self.mileage))
        else:
            print("Battery depleted! Find a SuperCharger station.")

In [16]:
tesla = EV(2016, "Tesla", "Model S", 250)
tesla.drive(100)
tesla.charge_remaining()
tesla.drive(150)
tesla.charge_remaining()


Driven 100 miles.
Still 150 miles left.
Driven 250 miles.
Battery depleted! Find a SuperCharger station.

Hopefully you noticed--we could call tesla.drive() and it worked as it was defined in the parent Car class, without us having to write it again!

This is the power of inheritance: every child class inherits all the functionality of the parent class.

With ONE exception: if you override a parent attribute or method in the child class, then that takes precedence.


In [23]:
class Hybrid(Car):
    
    def drive(self, mileage, mpg):
        self.mileage += mileage
        print("Driven {} miles at {:.1f} MPG.".format(self.mileage, mpg))

In [24]:
hybrid = Hybrid(2015, "Toyota", "Prius")
hybrid.drive(100, 35.5)


Driven 100 miles at 35.5 MPG.

Using inheritance, you can build an entire hierarchy of classes and subclasses, inheriting functionality where needed and overriding it where necessary.

This illustrates the concept of polymorphism (meaning "many forms"): all cars are vehicles; therefore, any functions a vehicle has, a car will also have.

All transporters are vehicles--and also cars--and have all the associated functionality defined in those classes.

However, it does NOT work in reverse: not all vehicles are motorcycles! Thus, as you move down the hierarchy, the objects become more specialized.

Multiple Inheritance

Just a quick note on this, for all the Java converts--

Python does support multiple inheritance, meaning a child class can directly inherit from multiple parent classes.


In [27]:
class DerivedClassName(EV, Hybrid):
    pass

This can get very complicated (and is why Java restricts "multiple inheritance" to interfaces only) in terms of what method and attribute definitions takes precedence when found in multiple parent classes.

As such, we won't explore this very much if at all in this class.

Review Questions

Some questions to discuss and consider:

1: Buzzword bingo: define encapsulation, inheritance, polymorphism, instantiation, and the difference between objects and classes.

2: For those who are Java converts, you may recall public and private methods and variables. Python makes no such distinction; everything is intrinsically public. In this case, why still use methods to interact with classes, instead of directly accessing the class attributes?

3: What is the difference between method overriding and method overloading? Does Python support one, both, or neither?

4: Class variable scope exists when working with objects in Python. If I define a variable x outside a class, define x again inside the class method, and refer to x after the class definition, which x is accessed? If x is also an attribute of the class, how do you access it from outside the class? How can you access the x defined outside the class from inside a class method?

5: Design a class hierarchy for different kinds of drinks. Include as much detail as you can. Where are attributes and methods inherited, where are they overridden, and where are new attributes and methods defined?

Course Administrivia

How is A4 going?

Volunteers for tomorrow's flipped lecture?

Review session #2 on Thursday! Come with questions!

Additional Resources

  1. Matthes, Eric. Python Crash Course. 2016. ISBN-13: 978-1593276034
  2. Python Classes documentation: https://docs.python.org/3/tutorial/classes.html