8. Basics of OOP
Learning Objectives
- Define object-oriented programming and explain how it differs from writing plain sequential code
- Distinguish a class from an object and create both correctly in Python
- Apply encapsulation to protect an object's internal state from unintended modification
- Use abstraction to expose only necessary functionality through abstract base classes
- Implement inheritance to reuse and extend behavior across related classes
- Explain polymorphism and demonstrate it with a real example involving multiple subclasses
Quick Answer
Object-oriented programming (OOP) is a way of organizing code around objects — self-contained units that bundle data (attributes) and behavior (methods) together, instead of scattering related data and the functions that operate on it across a program. A class is the blueprint that defines what attributes and methods its objects will have; an object is a specific instance created from that blueprint. OOP rests on four pillars: encapsulation (protecting internal state), abstraction (hiding unnecessary detail), inheritance (reusing behavior across related classes), and polymorphism (letting different classes respond to the same method call in their own way). Together these ideas make large codebases easier to model, extend, and maintain, which is why most modern software — from mobile apps to game engines — is built using OOP.
Classes and Objects
A class is a blueprint; an object is a concrete thing built from that blueprint. Every object created from the same class shares the same structure but can hold different data.
class Car:
def __init__(self, brand, model, year):
self.brand = brand
self.model = model
self.year = year
def start_engine(self):
print(f"The {self.year} {self.brand} {self.model} engine is now running.")
my_car = Car("Toyota", "Camry", 2020)
your_car = Car("Honda", "Civic", 2023)
my_car.start_engine() # The 2020 Toyota Camry engine is now running.
your_car.start_engine() # The 2023 Honda Civic engine is now running.
Car is the class — it exists once in the code. my_car and your_car are two separate objects (instances), each with its own brand, model, and year. The __init__ method is the constructor: Python calls it automatically whenever a new object is created, to set up that object's initial state.
Why It Matters
Before OOP, a program modeling many cars would typically track each attribute in separate parallel lists (brands = [...], models = [...], years = [...]) and hope the indices always stayed aligned — a fragile design that gets worse as more attributes are added. A class bundles everything about one car into a single, self-consistent unit, so adding a new car is just creating a new object, and adding a new attribute means changing one class definition instead of every list in the program.
A common misunderstanding: students think the class is the data. It isn't — the class is only the template. No car actually exists in memory until you call Car(...) to create an object from it.
Encapsulation
Encapsulation bundles data and the methods that operate on it inside a class, while restricting direct outside access to sensitive internal details.
class BankAccount:
def __init__(self, balance):
self.__balance = balance # double underscore signals "private" by convention
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
def get_balance(self):
return self.__balance
account = BankAccount(1000)
account.deposit(500)
print(account.get_balance()) # 1500
# account.__balance would raise an AttributeError from outside the class
__balance can't be modified directly from outside the class (Python "name-mangles" it internally to discourage — though not fully prevent — outside access). Instead, code must go through deposit(), which validates the amount before changing the balance. This is exactly why encapsulation matters: it prevents a careless line like account.balance = -1000000 from silently corrupting the object's state.
Why It Matters
Without encapsulation, any part of a large codebase could reach in and set a bank account's balance directly, bypassing validation. Bugs introduced this way are notoriously hard to trace because the invalid state could have been set from anywhere in the program. Encapsulation forces all state changes through a small, controlled set of methods.
Abstraction
Abstraction hides how something is done and exposes only what it does. In Python, this is often expressed with abstract base classes that force subclasses to implement specific methods.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
shapes = [Rectangle(5, 3), Circle(4)]
for shape in shapes:
print(shape.area()) # 15, 50.26544
Shape guarantees that every subclass has an area() method, without dictating how that area is calculated. Code that uses a Shape doesn't need to know whether it's a Rectangle or a Circle — it just calls .area() and trusts the abstraction. Trying to instantiate Shape() directly raises a TypeError, because an abstract class only exists to be extended, not used on its own.
Why It Matters
Abstraction lets you work at a higher level without tracking every implementation detail. A developer calling shape.area() doesn't need to remember the area formula for every possible shape — they trust that whichever subclass they're holding implements it correctly.
Inheritance
Inheritance lets a new class (the child or subclass) reuse and extend the attributes and methods of an existing class (the parent or superclass).
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} is making a noise.")
class Dog(Animal):
def speak(self):
print(f"{self.name} is barking.") # overrides the parent's version
buddy = Dog("Buddy")
buddy.speak() # Buddy is barking.
Dog inherits __init__ from Animal for free — it doesn't need to redefine how name is stored. It only overrides speak() to provide dog-specific behavior. If Dog didn't override speak(), calling buddy.speak() would fall back to Animal's version and print "Buddy is making a noise."
Why It Matters
Inheritance avoids duplicating code across related classes. If ten different animal subclasses all needed a name attribute, defining it once in Animal and inheriting it everywhere means a future change (say, adding validation to ensure name isn't empty) only needs to happen in one place.
Polymorphism
Polymorphism means objects of different classes can be used interchangeably through a shared interface, with each class responding to the same method call in its own way.
class Cat(Animal):
def speak(self):
print(f"{self.name} is meowing.")
animals = [Dog("Rex"), Cat("Whiskers"), Animal("Generic Creature")]
for animal in animals:
animal.speak()
# Rex is barking.
# Whiskers is meowing.
# Generic Creature is making a noise.
The loop doesn't check what type each animal is before calling speak() — it trusts that every object in the list, regardless of its specific class, understands what .speak() means for itself. This is polymorphism in action: one interface (speak()), many implementations.
Why It Matters
Polymorphism is what makes code extensible. Adding a new animal type (say, Bird) that also overrides speak() requires zero changes to the loop above — it will automatically call Bird's version. Without polymorphism, you'd need an if/elif chain checking every possible type, which grows more fragile every time a new type is added.
Key Terms
| Term | Definition | Related Concept |
|---|---|---|
| Class | A blueprint that defines the attributes and methods objects created from it will have | Object |
| Object | A specific instance created from a class, with its own data | Class |
Constructor (__init__) | The method automatically called when a new object is created, to set up its initial state | Class, Object |
| Encapsulation | Bundling data and methods together while restricting direct outside access to internal state | Private Attribute |
| Abstraction | Hiding implementation detail and exposing only the necessary functionality | Abstract Base Class |
| Inheritance | A mechanism where a subclass reuses and extends the attributes/methods of a parent class | Superclass, Subclass |
| Polymorphism | The ability of different classes to respond to the same method call in their own way | Method Overriding |
| Method Overriding | Redefining a parent class's method in a subclass to change its behavior | Inheritance, Polymorphism |
Common Mistakes
Misconception: A class and an object are the same thing, and the terms can be used interchangeably.
Why it's wrong: A class is only a blueprint — it defines what attributes and methods will exist, but no actual data is stored until an object is created from it. Code can define a Car class once and never create a single Car object, in which case no car "exists" in memory at all.
Correct understanding: A class is a template; an object is a specific instance built from that template, holding its own independent data. You can create many objects from one class.
Misconception: Making an attribute private with a double underscore (__balance) makes it truly impossible to access from outside the class.
Why it's wrong: Python's "privacy" is a convention enforced through name-mangling (__balance becomes _BankAccount__balance internally), not a hard security boundary. It can still be accessed with the mangled name if someone really wants to.
Correct understanding: Double-underscore attributes discourage accidental external modification and signal intent clearly, but true access control in Python relies on discipline and clear interfaces (like deposit() and get_balance()), not unbreakable enforcement.
Misconception: Inheritance should be used whenever two classes share any attribute or method, to avoid "duplicating" code.
Why it's wrong: Inheritance implies an "is-a" relationship (a Dog is an Animal). Using it just to reuse code between unrelated classes (e.g., making Car inherit from Employee because both happen to have a name field) creates a confusing, incorrect class hierarchy.
Correct understanding: Use inheritance only when the relationship is genuinely hierarchical. For code reuse between unrelated classes, prefer composition (one class holding an instance of another) instead.
Comparison and Connections
| Pillar | What It Controls | Real-World Analogy |
|---|---|---|
| Encapsulation | Who can directly access/modify an object's internal data | A car's engine is sealed under the hood; you use the pedals, not the pistons directly |
| Abstraction | How much implementation detail is exposed to the user of a class | Driving a car only requires knowing the steering wheel and pedals, not combustion mechanics |
| Inheritance | How a class reuses and extends another class's structure | A "sedan" is a specific kind of "car," inheriting general car features |
| Polymorphism | How different classes respond differently to the same call | Turning any vehicle's key starts it, but what "starting" does differs by engine type |
| Concept | Inheritance | Composition |
|---|---|---|
| Relationship implied | "is-a" (Dog is an Animal) | "has-a" (Car has an Engine) |
| Coupling | Tighter — subclass depends on parent's internal structure | Looser — object just uses another object's public interface |
| When to prefer | Genuine hierarchical relationship exists | Reusing behavior between otherwise unrelated classes |
Practice Questions
Recall
-
What is the difference between a class and an object? Look for: a class is a blueprint defining attributes/methods; an object is a specific instance created from that blueprint, holding its own data.
-
Name the four pillars of OOP. Look for: encapsulation, abstraction, inheritance, polymorphism.
Understanding
-
Explain why encapsulating a bank account's balance behind a
deposit()method is safer than allowing direct access to the attribute. Look for: direct access allows any code to set the balance to an invalid value (like a negative number) without validation; going throughdeposit()ensures every change is checked first, preventing corrupted state. -
Why does an abstract class like
Shaperaise an error if you try to instantiate it directly? Look for: an abstract class defines methods (likearea()) that its subclasses must implement, but has no concrete implementation itself; instantiating it directly would create an object with missing/undefined behavior, so Python blocks it.
Application
-
Write a
Birdclass that inherits fromAnimal(with aspeak()method) and overridesspeak()to print that the bird is chirping. Look for:class Bird(Animal): def speak(self): print(f"{self.name} is chirping.")— correctly inheriting__init__and overriding onlyspeak(). -
Given the
shapeslist from the abstraction example, write code to print the total area of all shapes in the list. Look for:total = sum(shape.area() for shape in shapes)or an equivalent loop that calls.area()on each shape and accumulates the sum, relying on polymorphism rather than checking each shape's type.
Analysis
-
A student adds a
Fishclass that inherits fromAnimalbut does not overridespeak(). What happens whenfish.speak()is called, and is this a bug? Look for: it callsAnimal'sspeak()and prints "Fish is making a noise" (using whatever name was given); this is not necessarily a bug — it's simply inherited default behavior, though it may not describe fish accurately, which the student should fix if a more specific behavior (e.g., "gliding silently") is needed. -
Compare using inheritance versus composition for a
Carclass that needs anEngine. Which is more appropriate and why? Look for: aCar"has-a"Engine, not "is-a"Engine", so composition (storing anEngineobject as an attribute ofCar) is more appropriate than makingCarinherit fromEngine`; inheritance would incorrectly imply a hierarchical is-a relationship that doesn't exist.
FAQ
Q: Why does Python allow multiple inheritance when many languages (like Java) don't? Python allows a class to inherit from more than one parent class simultaneously, offering flexibility but introducing complexity like the "diamond problem" (ambiguity when two parents define the same method). Python resolves this with a deterministic Method Resolution Order (MRO), but many languages avoid the complexity entirely by only allowing single inheritance and using interfaces instead.
Q: Is polymorphism only about overriding methods in subclasses?
That's the most common form (called "runtime" or "subtype" polymorphism), but polymorphism more broadly means the same operation behaving differently depending on the type it's applied to. Python's + operator is also polymorphic — it adds numbers but concatenates strings, based on the operand types.
Q: Do I always need to use all four pillars of OOP in every program? No. Not every class needs a strict inheritance hierarchy, and not every attribute needs to be private. Use encapsulation when internal state needs protecting, inheritance when a genuine hierarchical relationship exists, and abstraction when you want to enforce a consistent interface across implementations — apply each where it solves a real problem, not by default.
Q: What's the difference between an abstract class and a regular class with just some unimplemented methods?
An abstract class (using Python's ABC and @abstractmethod) actively prevents instantiation until all abstract methods are implemented by a subclass — Python enforces this at object-creation time. A regular class with a method that just does nothing (pass) can still be instantiated directly, silently allowing incomplete or incorrect objects to exist.
Q: Why is composition often recommended over inheritance? Composition creates looser coupling — a class using another class's public interface doesn't break if that other class's internals change. Deep inheritance hierarchies, by contrast, can become fragile: a change to a base class can unexpectedly ripple through many subclasses that depend on its internal structure.
Quick Revision
- A class is a blueprint; an object is a specific instance created from that blueprint with its own data.
__init__is the constructor — it runs automatically when a new object is created to set up initial state.- Encapsulation bundles data and methods together and restricts direct external access, forcing changes through controlled methods.
- Python's double-underscore "privacy" is a naming convention (name-mangling), not a hard security boundary.
- Abstraction hides implementation detail and exposes only necessary functionality, often via abstract base classes (
ABC,@abstractmethod). - An abstract class cannot be instantiated directly — only concrete subclasses that implement its abstract methods can be.
- Inheritance lets a subclass reuse a parent class's attributes/methods and override specific ones ("is-a" relationship).
- Polymorphism lets different classes respond to the same method call in their own way, enabling code that works across many types without type-checking.
- Use inheritance only for genuine hierarchical relationships; prefer composition ("has-a") for reusing behavior between otherwise unrelated classes.
- The four pillars — encapsulation, abstraction, inheritance, polymorphism — work together to make large codebases modular, extensible, and maintainable.
Related Topics
Prerequisites: Functions and Recursion, Debugging and Testing, Variables and Data Types
Related Topics: Data Structures and Algorithms (many are implemented as classes), Design Patterns, Software Engineering principles
Next Topics: Database Management Systems (modeling entities as objects), Data Structures and Algorithms, Compiler Design