Last Updated on August 18, 2022 by Jay
There are several different programming paradigms and object oriented programming (OOP for short) is one of the most popular for the Python language. With a name that indicates this style of programming is oriented towards objects, it begs the question…
What is an object?
An object, as it is defined in object oriented programming, is also known as a “class”. Essentially a collection of related attributes, functions, and methods that we wish to combine into a single entity. This also is why we would want to create an object in the first place. Whenever we want to collect many attributes, functions, and methods under a single entity’s name because they are logically related in some way, an object is a great way to achieve that.
The classic definition of an object usually involves making reference to a blueprint. A blueprint is not the same thing as the object it describes how to build, but you could use a single blueprint to create many versions of the same object to the exact same specification. Similar to how functions are repeatable blocks of code, objects take this a step further. Functions have an input and an output, so the code stops repeating when we reach the output. However, an object stays active and ready to react to whatever situation we put it in until we explicitly delete it.
Please note that whenever we begin speaking of repeatable blocks of code, the line between ourselves and the “user” begins to blur. Both objects and functions are used to make programming easier, no matter who is using it. Throughout this article, I will refer to the “user” of our object as any programmer who wishes to instantiate an object we’ve created, but just know that often we are our own user.
What Is An Instance Of An Object?
An instance is a single implementation of an object. It is an actionable copy of the code we wrote in the object / class definition. For those of you coming to this with an understanding of databases already, you can imagine an object is a table, while an instance is a single row of that table, and in fact there are mature Python packages, like SQLAlchemy, that use this analogy as their starting point.
In the blueprint metaphor, an instance is the actual thing itself, what we have made once we’re done building this object according to its blueprint. We could imagine that we ourselves are an instance of the Person class here on earth. Our code can call upon, copy, and create as many instances of an object as we want and they will all behave in the way we dictate under the class definition / blueprint. The process of creating an instance of an object is called instantiation.
Object Oriented Programming Python Example
My favorite example of OOP, objects, instances and how to think about all this is an old videogame called Asteroids. We have a ship and an increasing number of asteroids to destroy as we progress. Think about how we might write this program from a DRY vs WET perspective.
The simplest way with the least amount of code to make that game would not be to write code for each individual asteroid, especially because we want each asteroid to behave in essentially the same way. Instead, we would create 2 main objects (blueprints), a ship object and an asteroid object. Then let the player’s progress determine the number of instances of the asteroid object on the screen. Each of those instances can keep track of its internal state for things like how fast it’s going and if it’s broken or not using code reproduced from the object definition (blueprint).
This Asteroids game makes a good exercise for practicing object oriented programming in Python.
Let’s see these concepts at work in some executable Python:
# use `class` for objects like you use `def` for functions.
# Pythonic convention uses TitleCase for object names.
class Greeting:
# class attributes don't change between instances and are declared
# at the leftmost scope of the class
phrase = "Hello, World"
# The __init__ method runs at the moment of instanciation.
# The first parameter here (named `self` by convetion) refers to an instance;
# it is self-referential to the instance.
def __init__(self, punctuation):
# adding an attribute to the instance
self.punctuation = punctuation
# now I can hang onto this datapoint outside the scope of this function
# the single underscore prefix is a convention indicating this is for internal use only
# and we will discuss more about this later
def _combine(self):
return Greeting.phrase + self.punctuation
# self is added as first parameter to most functions and methods
def speak(self):
print(self._combine())
my_punctuation = "!"
# here is instanciation actually happening
# notice I only pass 1 argument to __init__ and disregard `self`
g = Greeting(my_punctuation)
print("I can access class attributes:", g.phrase)
print("I can access instance attributes:", g.punctuation)
# calling a class function or method is very similar to an attribute, just add ()
g.speak()
# make as many instances as you want
g2 = Greeting("?")
g2.speak()
# now these instances of the Greeting object can be used in any way you want, and be
# passed around like normal variables
greetings = [g, g2]
OOP key concepts/building blocks
The following 5 topics are the key concepts to understand object oriented programming in Python or any language. Once we understand these topics, they become the building blocks we can combine and weave together to create amazing and simple-to-use objects. I’ve grouped these topics in a way that hopefully will make it easier to understand how they are related and the sometimes subtle differences between them.
Abstraction and Encapsulation
Both of these OOP building blocks are concerned with showing the user only what they need to see. By using hidden methods, functions, and attributes that the user does not need to concern themself with. In Python, the way to hide things from a user is using underscore prefixes, either _ or __ right after def. Notice how even the built-in __init__ has a double underscore prefix? A single underscore indicates it is partially hidden, while a double underscore indicates it is completely hidden. Technically nothing is truly possible to hide in Python, but that is the convention. Some IDEs will follow this convention by not allowing you to see or autocomplete anything prefixed with a single underscore. With a double underscore, users will need to go out of their way to indirectly access a property:
class Obj:
def __init__(self, var):
# 1 underscore prefix
self._var1 = var
# 2 underscore prefix
self.__var2 = var
# instantiate
o = Obj(5)
# prove we can directly access this attribute
print("* Attempt - Direct, Single Underscore")
try:
print(o._var1)
except:
print("Invalid operation")
# prove we cannot directly access this attribute
print("\n* Attempt - Direct, Double Underscore")
try:
print(o.__var2)
except:
print("Invalid operation")
# prove we can still indirectly access this attribute
# because nothing is truly hidden in python
print("\n* Attempt - Indirect, Double Underscore")
try:
print(o._Obj__var2)
except:
print("Invalid operation")
Abstraction In OOP
Abstraction answers the question of “what” to show to a user.
For example, it makes sense to include a drive() method for a car object. The aim here should be to provide helpful, useful, and obvious functions, methods, and attributes for the user to utilize. So we wouldn’t want to show the user all the details of the internal combustion engine. We wouldn’t want to make them consider how much each piston needs to rotate the crankshaft when it moves and pass all that information as parameters when they just want to drive. That level of detail is necessary for the object to work, but needs to be something internal for this object that only we as the developer of the object need to worry about.
In other words, the details need to be abstracted away. So the user can focus on what this car object does without getting into the complexity of how it works. Perhaps a simpler and better way for users to interact with the drive() method is to pass a single parameter called speed that indicates how fast they want to go. With that input, I’ll figure out the details internally. If something is too esoteric, consider making it internal and hidden from the user so as not to overwhelm them. In that way, we can abstract the details away and only show them what they need to see.
class Car:
# give users simple, easy-to-use, and expected ways to interact
# with the object
def drive(self, speed):
print("Driving...")
self._engine(speed)
# while hiding the more complex details of how it really works
def _engine(self, speed):
# a single underscore prefix indicates this method should
# only be used directly by users who know exactly what they're doing
if speed > 120:
raise SystemError
elif speed > 50:
print("Fast")
elif speed > 0:
print("Slow")
else:
# speed = negative number
raise SystemError
c = Car()
c.drive(55)
Encapsulation In OOP
Encapsulation answers the question of “how” to show the user your attributes, functions, and methods. The goal is to keep everything needed to operate your object “under the hood”. The main aspects of encapsulation are:
- Protecting the users of our code from making known mistakes when trying to use it
- Keeping all the pieces and parts the object needs to work in a single location
Encapsulation then heavily uses functions, methods, and attributes that are private to the object and can then only be accessed by the object itself internally. In that way, these internal details are encapsulated. This keeps users focused on the “correct” ways we want them to interact with the object and prevents them from making mistakes we’ve already thought through.
class Wheel:
def __init__(self, number):
self.number = number # instance attribute
# not being hidden with underscores indicates it's ok for users to edit
# directly from outside the object.
def spin(self):
print(f"Spinning wheel #{self.number}")
class Car:
wheels = [] # a class attribute is
# a lot more dangerous to be giving users the freedom to edit directly
# as these can affect ALL instances of this object
def drive(self):
print("Driving...")
for wheel in self.wheels:
wheel.spin()
class EncapsulatedCar:
def __init__(self, wheels):
self.__wheels = wheels # an instance attribute
# is any attribute connected to `self` such as inside the __init__ method.
# The 2 underscore prefix hides it from being directly utilized.
# the @property decorator allows us to make our own object properties
# which are essentially functions that don't need to be called; it's the difference
# between:
# car_instance.wheel_count()
# car_instance.wheel_count
@property
def wheel_count(self):
return len(self.__wheels)
# once an attribute is hidden, any interaction with it must be through
# additional functions, methods, attributes and properties
def add_wheels(self, wheels):
self.__wheels.extend(wheels)
def drive(self):
print("Driving...")
for wheel in self.__wheels:
wheel.spin()
# instantiate 4 wheels
w1 = Wheel(1)
w2 = Wheel(2)
w3 = Wheel(3)
w4 = Wheel(4)
# Instantiate 2 cars
c1 = Car()
c2 = Car()
# confirm neither car has wheels
assert len(c1.wheels) == 0
assert len(c2.wheels) == 0
# add wheels to class attribute of car 1
c1.wheels.extend([w1, w2])
# ERROR: notice effect on car 2?
c2.drive()
print("#" * 15)
# Instantiate 2 EncapsulatedCars
ec1 = EncapsulatedCar([])
ec2 = EncapsulatedCar([])
# confirm we cannot directly access the wheels
try:
print(len(ec1.__wheels))
except:
print("Invalid operation")
# confirm neither car has wheels
assert ec1.wheel_count == 0
assert ec2.wheel_count == 0
# add wheels to instance attribute of encapsulated car 1
ec1.add_wheels([w3, w4])
# notice NO effect on car 2
# because we used an instance attribute
ec2.drive()
Abstraction and Encapsulation work together to show the user a minimum framework to use our class successfully. We want to keep it simple and try not to overwhelm users. This also helps us write less buggy code because limiting how users can interact with your object limits the ways they can break it and the edge cases we need to consider.
Inheritance and Composition
These OOP building blocks relate to objects sharing the behaviors and abilities of other objects. They are used in cases where objects have a hierarchical relationship with one another.
Inheritance is used when our object “is” also another type of object, such as a Sedan is a type of Car. Functions, methods, and attributes are shared between objects by way of declaring that a new “Child” object is also a type of “Parent” object. It can use the Parent’s class definition or override something about the way the Parent works in this Child object alone.
class Car:
doors = None
# a static method is one that
# does not need a reference
# to self throughout its scope.
# declare it with the @staticmethod decorator
@staticmethod
def drive(speed):
print(f"Driving {speed} mph")
# pass a classname in parentheses
# to inherit all code from that class
# into this new one as well
class Sedan(Car):
# you can override the "default" behavior
# set by a Parent class in each Child
doors = 4
class Coupe(Car):
doors = 2
s = Sedan()
print(s.doors)
s.drive(5)
# Notice how the Sedan class
# did not directly implement the
# drive method; it inherited it
# by being a Child class of Car
c = Coupe()
print(c.doors)
c.drive(15)
Composition is for when our object “has” another object, such as a car has wheels. Functions, methods, and attributes are shared between objects by making entire objects become a simple attribute of another, larger object. In general, composition is more flexible and easier to refactor. So we should at least consider using composition instead of inheritance each time we are about to reach for inheritance. Imagine this difference as it is easier to take off a car’s wheels than to completely redefine a car as something that does not have wheels.
class Wheel:
def __init__(self, number):
self.number = number
def spin(self):
print(f"Spinning wheel #{self.number}")
class Car:
def __init__(self, wheels):
# we can store object instances or even a list of them
# as simple instance attributes of other classes
self.wheels = wheels
def wheel_count(self):
return len(self.wheels)
def add_wheel(self, wheel):
self.wheels.append(wheel)
def drive(self):
print("Driving...")
for wheel in self.wheels:
wheel.spin()
# calling the spin method on each instance
# think of how much code this saves us from writing
w1 = Wheel(1)
w2 = Wheel(2)
w3 = Wheel(3)
c = Car([w1, w2, w3])
print("This car has", c.wheel_count(), "wheels")
# Here I instantiate the 4th Wheel object inside the function call to add another Wheel.
# I will have no reference to it at this scope other than calling c.wheels[3]
c.add_wheel(Wheel(4))
print("This car has", c.wheel_count(), "wheels now")
print("This is wheel #", c.wheels[3].number)
c.drive()
Polymorphism
Polymorphism means giving our code the ability to have different flavors depending on the taste we’re looking for and the ingredients we provide. A perfect example of polymorphism is to think of how Python implemented the int and str objects to both utilize the + operator. When using + with integers, it adds them together, but if we use it with strings, it concatenates them together. Users can reach for the same operator whenever they want to combine things. However, the outcome will differ under different circumstances. So polymorphism allows the same object to react in different ways under different circumstances dynamically. This cuts down on the amount of code and variation the user needs to remember to utilize the codebase.
# same Wheel class we've been using
class Wheel:
def __init__(self, number):
self.number = number
def spin(self):
print(f"Spinning wheel #{self.number}")
# function that expects objects to have an interable attribute called "wheels"
# full of other objects with methods called "spin"
def roll(vehicle):
print("Driving...")
for wheel in vehicle.wheels:
wheel.spin()
class Car:
def __init__(self):
w1 = Wheel(1)
w2 = Wheel(2)
w3 = Wheel(3)
w4 = Wheel(4)
self.wheels = [w1, w2, w3, w4]
@property
def name(self):
return "This is a car"
class Tricycle:
def __init__(self):
w1 = Wheel(1)
w2 = Wheel(2)
w3 = Wheel(3)
self.wheels = [w1, w2, w3]
@property
def name(self):
return "This is a tricycle"
class Bicycle:
def __init__(self):
w1 = Wheel(1)
w2 = Wheel(2)
self.wheels = [w1, w2]
@property
def name(self):
return "This is a bicycle"
car = Car()
tricycle = Tricycle()
bicycle = Bicycle()
# Below is the beauty of polymorphism
# We can iterate across different objects, and as long as they all implement
# the same behavior, we can save users from having to write a lot of code.
# In this case they all need a property called "name" and to be
# able to be passed to the "roll" method.
for item in (car, tricycle, bicycle):
print(item.name)
roll(item)
Polymorphism brings up another way to think about the concept of abstraction within OOP, where similarities between objects can be implemented similarly. While we would never want to drive a tricycle anywhere that we need a car to travel, they both do have wheels and can both be utilized by the same roll function. Inside the roll function, I have abstracted both objects to be something I call a vehicle. I could have even gone so far as to create a Vehicle Parent class that they both inherit from. You can see how these different building blocks begin to merge and work together in new and interesting ways.
Object Oriented Programming In Python – Everything Is An Object
Cars, bicycles, wheels, greetings… everything can be an object in object-oriented programming. Suppose we can:
- Balance abstracting the way your objects work
- Encapsulating the internal details
- Inheriting behavior from similar objects
- Composing smaller objects into larger ones, and
- Morphing the implementations to make use of similar functionality across various objects.
In that case, you too can be an object-oriented programmer.
About the Author
Eric Smith is a classically trained poet and self-taught programmer, studying Computer Information Systems at Boston University – Metropolitan. When he’s not working as a Data Scientist at a Fortune 500 company. Eric enjoys spending time with his wife, their two children, and four dogs.
You can find him online at: