The Object Model

Why Object-Oriented Programming?

This chapter will give you an overall taste of Object-Oriented Programming. All these topics will be covered later in this book.

We'll start by introducing some terminology and then dive into some examples.

Terminology

The Object-Oriented Programming (OOP) paradigm was created to deal with the growing complexity of large software systems. Programmers discovered early on that large and complex programs became challenging to maintain. One small change at any point in the program would trigger a ripple effect of errors due to dependencies throughout the program.

Programmers needed a way to create containers for data that could be changed and manipulated without affecting the entire program. They needed a way to section off areas of code that performed specific procedures so that their programs could become the interaction of many small parts as opposed to one massive blob of dependency.

Enter OOP.

Classes form the basis of Python's type system. You can think of classes as blueprints, templates, or molds from which we create objects of a given type. A class, by default, can create many different objects of the same type.

Every class defines a type, and every type has a class. These two terms are so fundamentally similar that we often use them interchangeably.

Objects are the individual things created by classes. You can think of objects as the values for a given type. For instance, the Python str class lets us create string objects, so those strings are the values for the string type. Again, these two ideas are so similar that we can use them interchangeably. However, object is the preferred term in OOP.

Objects are also called instances or instance objects. When we create a new object from a class, we say that we created a new class instance. Once again, separating objects from instances is hard, so don't try. Use whichever term fits the situation best.

Everything we've used to represent data, from strings to integers to lists to dictionaries, are, in fact, objects. Even functions and classes themselves are objects. We'll dig deeper into this soon.

If you hang around Python people for a few days, you'll soon hear the phrase, "Everything in Python is an object!" That's not true, though; Not everything in Python is an object. However, anything that can be said to have a value is an object. That includes numbers, strings, lists, functions, modules, and even classes. However, a few things are not objects: statements, keywords, and variables are three that stand out.

We typically use nouns when discussing classes and objects. Classes and objects represent concrete (non-abstract) things. Many, but not all, methods are named with verbs; they represent actions and behaviors. The objects we create from classes are specific instances of the class nouns, and we manipulate them using the verbs provided by the method names.

Instantiation is the process of creating a new object. It's a fancy word for a fundamentally simple idea, but it makes you sound smart. Okay, that's not the real reason we use the term; instantiation is more specific than creation. However, in Python, where nearly everything is an object, there is little difference between the two.

Hiding data and functionality from the rest of the code base is known as encapsulation. It's a form of data protection; data can't be manipulated or changed without obvious intent. It defines the boundaries in your application and lets your code achieve new levels of complexity. Like many other OO languages, Python accomplishes this with objects and interfaces (i.e., methods) that interact with them.

It's worth noting that Python doesn't really hide anything. At best, it hides by convention. That is, it relies on naming conventions and the good sense of programmers that use our classes. We'll learn more about this later.

At Launch School, we'll usually pretend that hiding by convention in Python is the same as hiding for encapsulation purposes. We'll let you know when we step outside this box.

Another benefit of encapsulation is that it lets the programmer think about things with a new level of abstraction. Objects are represented as real-world nouns and can be given methods that describe the behavior provided for those objects.

Polymorphism is the ability for different data types to respond to the same interface. Suppose we have a function that invokes a move method on one of the function's arguments. We can pass the function any argument with a compatible move method. The object might represent a human, a cat, a jellyfish, or, conceivably, even a car or planet. The function is polymorphic.

Python has dozens of polymorphic functions and methods. For instance, the oft-used list function can take any iterable object as an argument: tuples, ranges, sets, dictionaries, and even other lists.

OOP gives us the flexibility to use pre-written code for new purposes.

The term "polymorphism" comes from the Greek words "poly" meaning "many", and "morph" meaning "form".

In inheritance, a class can acquire (inherit) all the behaviors and properties (we'll talk about properties shortly) of another class. This lets programmers define small, reusable classes and smaller, more specific classes for fine-grained, detailed behaviors.

In inheritance, the inheriting class is called a subclass of the inherited class. The inherited class is, in turn, called a superclass of the subclass.

The superclass/subclass relationship is akin to an ancestral relationship rather than a parent/child relationship. It's multigenerational. Thus, if class A subclasses B, class B subclasses C, and class C subclasses D, then B, C, and D are all superclasses of A. Likewise, A is a subclass of all 3 of the superclasses.

We'll learn more about these concepts in the following chapters.

Classes Define Objects

Python classes describe the characteristics of its objects. Classes provide a blueprint of the information its instances store and what those objects can do. To define a class, we use syntax similar to defining a function. We replace def with class and use the PascalCase naming convention to define the class:

class GoodDog:
    pass

sparky = GoodDog()

In that small example, we defined a GoodDog class and then created an object of the class by calling GoodDog().

The GoodDog class doesn't have any data or behaviors yet. However, we still need to include something in the class's block. The pass statement is perfect; it does nothing. However, as a side effect, it silences Python's complaints.

Our GoodDog object is now assigned to the sparky variable. We say that sparky references an object or instance of class GoodDog. We can also say that we've instantiated an instance of the GoodDog class.

One critical thing to observe is that we instantiate objects by calling a function with the same name as the class. It's as though GoodDog is both a class and a function. (It is not a dessert topping or a floor wax, though.) We call this function the class constructor.

Most objects have data. Collectively, the data inside an object defines its state. An object's state is given by its instance variables, which store the object's data. These variables can be initialized, accessed, replaced, or mutated through the class's instance methods and from outside the class (we'll see this later).

A class's instance methods are functions that operate on instances of the class. Instance methods are shared by all class instances. The instance methods are often called behaviors.

In Python, the terms instance variable, attribute, and property sometimes get used interchangeably. However, they are different and shouldn't be misused:

  • Instance variables are variables that are tied to an instance of a class.
  • Attributes include all instance variables and instance methods.
  • Properties are a special kind of method that enables syntax that makes the property look like an instance variable. You can use the property name like a variable name. Properties are usually associated with instance variables but can also be dynamically computed.

Let's define a class that has state and behaviors:

class GoodDog:

    def __init__(self, name):
        # self.name is an instance variable (state)
        self.name = name
        print(f'Constructor for {self.name}')

    # speak is an instance method (behavior)
    def speak(self):
        # We're using the self.name instance variable
        print(f'{self.name} says Woof!')

    # roll_over is an instance method (behavior)
    def roll_over(self):
        # We're using the self.name instance variable
        print(f'{self.name} is rolling over.')

sparky = GoodDog('Sparky') # Constructor for Sparky
sparky.speak()             # Sparky says Woof!
sparky.roll_over()         # Sparky is rolling over.

rover = GoodDog('Rover')   # Constructor for Rover
rover.speak()              # Rover says Woof!
rover.roll_over()          # Rover is rolling

Our updated class now has 3 methods:

  • __init__ is a magic method (also: dunder method) in Python. It's properly called the initializer method, the instance constructor, or the constructor. It initializes a new instance of an object.

    Magic methods are any methods whose name begins and ends with a double underscore. We'll cover them in more detail later.

  • The speak and roll_over methods tell a GoodDog instance to speak or roll over.

Note the distinction between class constructors and instance constructors. The class constructor, such as GoodDog(), orchestrates the instantiation of an instance object. It first calls the static method __new__ to create an instance object of the class. The uninitialized object is then passed to the __init__ instance constructor where it gets initialized. (Confusingly, __new__ is sometimes called a constructor, but we won't do that.)

For clarity going forward, we'll use the following terminology:

  • Constructor refers to the class constructor function, e.g., GoodDog(), list(), range(), etc.
  • __new__ for the __new__ method.
  • Initializer refers to the __init__ method.

We try to use this terminology consistently in this book and the remainder of the Python track courses.

The first parameter for all instance methods is conventionally named self. It represents the object that invoked the method. For instance, when we call sparky.speak on line 19, self refers to the sparky object. We use self.identifier inside the method's body to create and access instance variables (identifier is the name of the instance variable).

The above example shows several crucial aspects of classes and objects:

  • A class defines the behaviors for the instance objects of the class.
  • You can instantiate multiple instances of a class.
  • The instance objects are distinct from each other.
  • The instance objects share the same methods but have different states.

The following figure should help visualize these relationships:

Class Instance Diagram

Inheritance

As you can see, defining a class and creating a new class instance is straightforward. Sometimes, though, you might find yourself with several closely related classes whose instances have behaviors in common. Those behaviors can be identical across all or some of the classes, or they can be unique to a specific class. This is where inheritance comes in.

Consider these three classes:

class Dog:

    def __init__(self, name):
        self.name = name
        type_name = type(self).__name__
        print(f'I am {name}, a {type_name}.')

    def speak(self):
        print(f'{self.name} says Woof!')

    def roll_over(self):
        print(f'{self.name} is rolling over.')

class Cat:

    def __init__(self, name):
        self.name = name
        type_name = type(self).__name__
        print(f'I am {name}, a {type_name}.')

    def speak(self):
        print(f'{self.name} says Meow!')

class Parrot:

    def __init__(self, name):
        self.name = name
        type_name = type(self).__name__
        print(f'I am {name}, a {type_name}.')

    def speak(self):
        print(f'{self.name} wants a cracker!')

sparky = Dog('Sparky')
fluffy = Cat('Fluffy')
polly = Parrot('Polly')

sparky.roll_over()

for pet in [sparky, fluffy, polly]:
    pet.speak()
I am Sparky, a Dog.
I am Fluffy, a Cat.
I am Polly, a Parrot.
Sparky is rolling over.
Sparky says Woof!
Fluffy says Meow!
Polly wants a cracker!

We have three classes in the above code, with a single object for each class. There are several things you may observe if you study this code and the output:

  • All three classes have an identical constructor (__init__) method. That's a lot of duplicated code.
  • All three classes have a speak method, each of which differs from the others. Given that, we can call the speak method even if we don't know what kind of animal we have assigned to a variable. We can see this in action on the last 2 lines.
  • Only dogs have a roll_over method.
  • These poor critters are going to starve. They don't have an eat behavior.
  • We can use type(self).__name__ to programmatically determine the name of the class to which an object belongs.

Let's clean up this code. First, let's eliminate those duplicate constructors. We'll first create a Pet class that contains the constructor's code, then let the Dog, Cat, and Parrot classes inherit from Pet:

#highlight
class Pet:

    def __init__(self, name):
        self.name = name
        type_name = type(self).__name__
        print(f'I am {name}, a {type_name}.')
#endhighlight

#highlight
class Dog(Pet):

    # __init__ method removed
#endhighlight
    def speak(self):
        print(f'{self.name} says Woof!')

    def roll_over(self):
        print(f'{self.name} is rolling over.')

#highlight
class Cat(Pet):

    # __init__ method removed
#endhighlight
    def speak(self):
        print(f'{self.name} says Meow!')

#highlight
class Parrot(Pet):

    # __init__ method removed
#endhighlight
    def speak(self):
        print(f'{self.name} wants a cracker!')

sparky = Dog('Sparky')
fluffy = Cat('Fluffy')
polly = Parrot('Polly')

sparky.roll_over()

for pet in [sparky, fluffy, polly]:
    pet.speak()

The output is unchanged, but the code has some significant changes. Of course, we've created the Pet class and moved the __init__ out of the individual classes into the Pet class. We've also added (Pet) to each class name in the class statements: this tells Python you want to inherit from the Pet class. Pet is the superclass of the other three classes, and the other three classes are subclasses of Pet.

What does that mean for our program? That becomes apparent when you create an object from one of the subclasses. After allocating some memory for the instance, Python tries to call __init__ on the object's class. However, none of our subclasses has a __init__ method. Python persists! It looks to the superclass for a method with the same name. In this case, it finds __init__ in the Pet class and invokes it. Pet.__init__ creates and initializes a self.name instance variable in the new object and prints an informative message.

That informative message is identical to what you saw in the earlier version of this program. It includes the correct class name even though Python runs Pet.__init__. The self keyword makes that possible. We won't go into much detail right now, but self always references the object used to call the method.

Let's get our poor, starving pets fed. Suppose we want a simple eat method that looks like this:

def eat(self):
    print(f"{self.name}: Yum-yum-yum!")

Where do you think we should put that method? We could, of course, put a copy in each subclass. However, we can use inheritance by adding it to the Pet class:

class Pet:

    def __init__(self, name):
        self.name = name
        type_name = type(self).__name__
        print(f'I am {name}, a {type_name}.')

    #highlight
    def eat(self):
        print(f"{self.name}: Yum-yum-yum!")
    #endhighlight

# Code omitted for brevity

for pet in [sparky, fluffy, polly]:
    pet.speak()
    #highlight
    pet.eat()
    #endhighlight

Since none of our subclasses have an eat method, Python calls Pet.eat as it did with Pet.__init__. The program now outputs:

I am Sparky, a Dog.
I am Fluffy, a Cat.
I am Polly, a Parrot.
Sparky is rolling over.
Sparky says Woof!
Sparky: Yum-yum-yum!
Fluffy says Meow!
Fluffy: Yum-yum-yum!
Polly wants a cracker!
Polly: Yum-yum-yum!

Ah, the magic of inheritance! We'll see much more about inheritance later in this book and again in the Core Curriculum.

By the way; we can rewrite type(self).__name__ as self.__class__.__name__. type(self) and self.__class__ return the same values. While type(self) may be shorter, it's possible that self.__class__ may be clearer. Which you use is up to you. We'll use both in Core.

def __init__(self, name):
    self.name = name
    type_name = self.__class__.__name__
    print(f'I am {name}, a {type_name}.')

Summary

That was a high-speed trip through OOP in Python. We'll spend the remaining chapters diving into the details. Lest you think we're going for a refreshing swim, OOP can be likened more to a mud bath. It's good for you, but you're going to get dirty.

Exercises

  1. How do we create a class and an object in Python?

    Write a program that defines a class and creates two objects from that class. The class should have at least one instance variable that gets initialized by the initializer.

    What class you create doesn't matter, provided it satisfies the above requirements.

    Solution

    We first need to define a class. We'll create a Person class. It requires a __init__ method that initializes the Person's name.

    class Person:
    
        def __init__(self, name):
            self.name = name
    
    bob = Person('Bob')
    alice = Person('Alice')
    
  2. Given an instance of a Foo object, show two ways to print I am a Foo object without hardcoding the word Foo.

    Solution

    class Foo:
        pass
    
    foo = Foo()
    print(f'I am a {type(foo).__name__} object')
    print(f'I am a {foo.__class__.__name__} object')