Python: Zero to Hero
Home/Object-Oriented Programming
Share

Chapter 17: Classes and Objects — Introduction to OOP

Everything you've built so far uses Python's built-in types — strings, lists, dictionaries, numbers. They're powerful, but they're generic. A dictionary can represent a bank account, but it can't enforce that the balance never goes negative. A list can hold students, but it can't know how to calculate their average grade.

Object-oriented programming (OOP) lets you create your own types. You define what data a type holds and what it can do — bundled together into one unit called a class. Once you have a class, you create objects (also called instances) from it, each with its own data but sharing the same behavior.

This is how almost all serious Python programs are structured. By the end of this chapter you'll understand what OOP is, why it matters, and how to write your own classes with data, methods, and useful string representations.

What is Object-Oriented Programming?

OOP is a way of organizing code around things rather than steps.

Instead of thinking "first do this, then do that," you think "what are the things in this program, and what can they do?"

A bank account is a thing. It has data (a balance, an owner name) and it can do things (deposit, withdraw, show its balance). In OOP you design a BankAccount class that captures both.

The three core ideas:

  1. Encapsulation — bundle data and the code that works on it together. The account knows its own balance and its own rules.
  2. Inheritance — build new types from existing ones. A SavingsAccount is a BankAccount with extra behavior.
  3. Polymorphism — different types respond to the same operation in their own way.

This chapter covers encapsulation. Chapters 18 and 19 cover inheritance and polymorphism.

In plain English: A class is a blueprint. An object is something built from that blueprint. The blueprint for a house describes how many rooms it has and what you can do in them. The actual house you live in is an object built from that blueprint.

Your First Class

Use the class keyword, then the class name (by convention: CapitalizedWords, also called PascalCase), then a colon.

class Dog:
    pass   # empty class for now

That's a valid class. Create an object from it:

my_dog = Dog()
print(type(my_dog))   # <class '__main__.Dog'>

Dog() calls the class like a function to create an instance. my_dog is now a Dog object.

__init__ — The Constructor

The __init__ method runs automatically every time you create a new object. It's where you set up the object's initial data.

class Dog:
    def __init__(self, name, breed, age):
        self.name  = name
        self.breed = breed
        self.age   = age

Now create some dogs:

dog1 = Dog("Rex",   "German Shepherd", 3)
dog2 = Dog("Bella", "Labrador",         5)
dog3 = Dog("Max",   "Poodle",           1)

Each object has its own data:

print(dog1.name)    # Rex
print(dog2.breed)   # Labrador
print(dog3.age)     # 1

dog1, dog2, and dog3 are three separate objects. They were all built from the same Dog blueprint, but each has its own name, breed, and age.

self — What It Means and Why It's There

Every method in a class receives the object itself as the first argument. By convention this is named self. Python passes it automatically — you never provide it when calling a method.

class Dog:
    def __init__(self, name, breed, age):
        self.name  = name    # self.name is an instance attribute
        self.breed = breed   # stored on THIS specific object
        self.age   = age

When you write self.name = name, you're saying: "Store the value of name on this specific instance of Dog." Every Dog object has its own self.name, its own self.breed, its own self.age.

Think of self as "me" — the object that the method belongs to.

In plain English: self is how a method says "I'm talking about this specific object, not some other one." When dog1.bark() runs, self is dog1. When dog2.bark() runs, self is dog2.

Instance Attributes vs Class Attributes

Instance attributes are set on self — each object has its own copy.

Class attributes are set directly on the class — shared by all instances.

class Dog:
    species = "Canis lupus familiaris"   # class attribute — shared by all dogs

    def __init__(self, name, breed, age):
        self.name  = name    # instance attribute — unique to each dog
        self.breed = breed
        self.age   = age

dog1 = Dog("Rex", "German Shepherd", 3)
dog2 = Dog("Bella", "Labrador", 5)

print(dog1.species)   # Canis lupus familiaris
print(dog2.species)   # Canis lupus familiaris  (same — shared)
print(Dog.species)    # Canis lupus familiaris  (access via class directly)

print(dog1.name)      # Rex
print(dog2.name)      # Bella  (different — each has its own)

If you set a class attribute via an instance (dog1.species = "something"), Python creates a new instance attribute that shadows the class attribute — the class attribute itself is unchanged.

Use class attributes for data that's the same for every instance. Use instance attributes for data that varies per instance.

Methods — What Objects Can Do

A method is a function defined inside a class. It always receives self as its first parameter.

class Dog:
    def __init__(self, name, breed, age):
        self.name  = name
        self.breed = breed
        self.age   = age

    def bark(self):
        print(f"{self.name} says: Woof!")

    def describe(self):
        print(f"{self.name} is a {self.age}-year-old {self.breed}.")

    def birthday(self):
        self.age += 1
        print(f"Happy birthday {self.name}! Now {self.age} years old.")

    def is_puppy(self):
        return self.age < 2

Call methods on objects:

dog1 = Dog("Rex", "German Shepherd", 3)
dog2 = Dog("Max", "Poodle", 1)

dog1.bark()         # Rex says: Woof!
dog1.describe()     # Rex is a 3-year-old German Shepherd.
dog1.birthday()     # Happy birthday Rex! Now 4 years old.

print(dog2.is_puppy())   # True  (age is 1, under 2)
print(dog1.is_puppy())   # False (age is now 4)

Methods can read instance attributes (self.name), modify them (self.age += 1), return values (return self.age < 2), and call other methods on self.

Your turn: Add a method human_years() to the Dog class that returns the dog's age multiplied by 7. Call it on two different dogs and print the result.

__str__ and __repr__ — Representing Objects as Text

When you print an object without defining __str__, Python shows something ugly:

dog = Dog("Rex", "German Shepherd", 3)
print(dog)   # <__main__.Dog object at 0x000001A2B3C4D5E6>

Define __str__ to control what print() and f-strings show:

class Dog:
    def __init__(self, name, breed, age):
        self.name  = name
        self.breed = breed
        self.age   = age

    def __str__(self):
        return f"Dog(name={self.name!r}, breed={self.breed!r}, age={self.age})"

Now:

dog = Dog("Rex", "German Shepherd", 3)
print(dog)         # Dog(name='Rex', breed='German Shepherd', age=3)
print(f"{dog}")    # Dog(name='Rex', breed='German Shepherd', age=3)

__str__ vs __repr__:

  • __str__ is for humans — the friendly, readable version. Used by print() and f-strings.
  • __repr__ is for developers — the technical version, ideally showing how to recreate the object. Used in the REPL and by repr().
def __repr__(self):
    return f"Dog({self.name!r}, {self.breed!r}, {self.age})"

The !r format spec inside f-strings calls repr() on the value — it adds quotes around strings, so the output is unambiguous.

If you only define one, define __repr__ — Python uses it as a fallback for __str__ too.

A Complete, Realistic Class

Let's build a BankAccount class that enforces real rules:

class BankAccount:
    """A simple bank account with deposit, withdrawal, and history."""

    interest_rate = 0.02   # class attribute — 2% for all accounts

    def __init__(self, owner, initial_balance=0.0):
        if initial_balance < 0:
            raise ValueError("Initial balance cannot be negative.")
        self.owner    = owner
        self._balance = float(initial_balance)   # _balance: "private by convention"
        self._history = []
        self._log(f"Account opened with ${initial_balance:.2f}")

    # ── Properties ──────────────────────────────────────
    @property
    def balance(self):
        """Read-only access to the balance."""
        return self._balance

    # ── Public methods ───────────────────────────────────
    def deposit(self, amount):
        """Deposit a positive amount."""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self._balance += amount
        self._log(f"Deposited  ${amount:.2f} -> balance ${self._balance:.2f}")

    def withdraw(self, amount):
        """Withdraw amount if sufficient funds exist."""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self._balance:
            raise ValueError(
                f"Insufficient funds. Balance: ${self._balance:.2f}, "
                f"Requested: ${amount:.2f}"
            )
        self._balance -= amount
        self._log(f"Withdrew   ${amount:.2f} -> balance ${self._balance:.2f}")

    def apply_interest(self):
        """Add interest to the account."""
        interest = self._balance * self.interest_rate
        self._balance += interest
        self._log(f"Interest   ${interest:.2f} -> balance ${self._balance:.2f}")

    def print_history(self):
        """Print full transaction history."""
        print(f"\nAccount history for {self.owner}:")
        for entry in self._history:
            print(f"  {entry}")

    # ── Dunder methods ───────────────────────────────────
    def __str__(self):
        return f"BankAccount({self.owner!r}, balance=${self._balance:.2f})"

    def __repr__(self):
        return f"BankAccount({self.owner!r}, {self._balance:.2f})"

    # ── Private helper ───────────────────────────────────
    def _log(self, message):
        """Record a transaction (private — not for external use)."""
        self._history.append(message)

Using it:

account = BankAccount("Alice", 500.00)
account.deposit(200.00)
account.withdraw(75.50)
account.apply_interest()
account.print_history()
print(account)
print(f"Balance: ${account.balance:.2f}")

Output:

Account history for Alice:
  Account opened with $500.00
  Deposited  $200.00 -> balance $700.00
  Withdrew   $75.50 -> balance $624.50
  Interest   $12.49 -> balance $636.99

BankAccount('Alice', balance=$636.99)
Balance: $636.99

Notice several things:

  • _balance and _history start with _ — the Python convention for "don't touch this from outside the class."
  • @property makes balance readable as account.balance without parentheses, but prevents direct assignment.
  • The class enforces its own rules — you can't deposit a negative amount or overdraw.
  • _log is a private helper — it's an implementation detail, not part of the public interface.

Your turn: Add a transfer(self, other_account, amount) method to BankAccount that withdraws from self and deposits into other_account. Handle the case where there are insufficient funds. Test it with two accounts.

Multiple Objects, Same Class

Every object created from a class is independent. Changes to one don't affect the others.

alice_account = BankAccount("Alice", 1000.00)
bob_account   = BankAccount("Bob",    500.00)

alice_account.deposit(250.00)
bob_account.withdraw(100.00)

print(alice_account)   # BankAccount('Alice', balance=$1250.00)
print(bob_account)     # BankAccount('Bob', balance=$400.00)

Alice's deposit didn't touch Bob's balance. They're completely separate objects sharing only the class definition (the blueprint) — not the data.

Objects in Collections

Objects work perfectly inside lists, dictionaries, and anywhere else you'd use a value:

accounts = [
    BankAccount("Alice",  1000.00),
    BankAccount("Bob",     500.00),
    BankAccount("Carlos", 2500.00),
    BankAccount("Diana",   750.00),
]

# Apply interest to all accounts
for acc in accounts:
    acc.apply_interest()

# Find the richest account
richest = max(accounts, key=lambda acc: acc.balance)
print(f"Richest: {richest.owner} with ${richest.balance:.2f}")

# Total deposits across all accounts
total = sum(acc.balance for acc in accounts)
print(f"Total:   ${total:.2f}")

Comparing Objects

By default, two objects are only equal if they're the same object in memory:

a = Dog("Rex", "German Shepherd", 3)
b = Dog("Rex", "German Shepherd", 3)

print(a == b)   # False — different objects, even with same data
print(a is b)   # False
print(a == a)   # True — same object

To make == compare by value, define __eq__:

class Dog:
    def __init__(self, name, breed, age):
        self.name  = name
        self.breed = breed
        self.age   = age

    def __eq__(self, other):
        if not isinstance(other, Dog):
            return NotImplemented
        return (self.name, self.breed, self.age) == (other.name, other.breed, other.age)

    def __repr__(self):
        return f"Dog({self.name!r}, {self.breed!r}, {self.age})"

Now:

a = Dog("Rex", "German Shepherd", 3)
b = Dog("Rex", "German Shepherd", 3)
c = Dog("Bella", "Labrador", 5)

print(a == b)   # True  — same data
print(a == c)   # False — different data

You'll learn all the comparison dunder methods (__lt__, __le__, __gt__, etc.) in Chapter 19.

Putting It All Together: A Student Grade Tracker

class Student:
    """Represents a student with a name and a list of grades."""

    def __init__(self, name, student_id):
        self.name       = name
        self.student_id = student_id
        self._grades    = []

    def add_grade(self, subject, score):
        if not 0 <= score <= 100:
            raise ValueError(f"Score must be between 0 and 100, got {score}.")
        self._grades.append({"subject": subject, "score": score})

    @property
    def average(self):
        if not self._grades:
            return 0.0
        return sum(g["score"] for g in self._grades) / len(self._grades)

    @property
    def highest(self):
        if not self._grades:
            return None
        return max(self._grades, key=lambda g: g["score"])

    @property
    def lowest(self):
        if not self._grades:
            return None
        return min(self._grades, key=lambda g: g["score"])

    @property
    def letter_grade(self):
        avg = self.average
        if avg >= 90: return "A"
        if avg >= 80: return "B"
        if avg >= 70: return "C"
        if avg >= 60: return "D"
        return "F"

    def report(self):
        print(f"\nStudent: {self.name} (ID: {self.student_id})")
        print(f"{'Subject':<20} {'Score':>5}")
        print("-" * 27)
        for g in self._grades:
            print(f"{g['subject']:<20} {g['score']:>5}")
        print("-" * 27)
        print(f"{'Average':<20} {self.average:>5.1f}  ({self.letter_grade})")
        if self.highest:
            print(f"Best:  {self.highest['subject']} ({self.highest['score']})")
            print(f"Worst: {self.lowest['subject']} ({self.lowest['score']})")

    def __str__(self):
        return f"{self.name} (avg: {self.average:.1f}, grade: {self.letter_grade})"

    def __repr__(self):
        return f"Student({self.name!r}, {self.student_id!r})"


# Create students
alice = Student("Alice Smith", "S001")
alice.add_grade("Math", 92)
alice.add_grade("Physics", 88)
alice.add_grade("Chemistry", 76)
alice.add_grade("English", 95)
alice.add_grade("History", 84)

bob = Student("Bob Jones", "S002")
bob.add_grade("Math", 65)
bob.add_grade("Physics", 72)
bob.add_grade("Chemistry", 58)
bob.add_grade("English", 80)
bob.add_grade("History", 61)

# Print reports
alice.report()
bob.report()

# Compare and sort
students = [alice, bob]
students.sort(key=lambda s: s.average, reverse=True)

print("\nRankings:")
for rank, student in enumerate(students, start=1):
    print(f"  {rank}. {student}")

Output:

Student: Alice Smith (ID: S001)
Subject              Score

Math                    92
Physics                 88
Chemistry               76
English                 95
History                 84

Average               87.0  (B)
Best:  English (95)
Worst: Chemistry (76)

Student: Bob Jones (ID: S002)
Subject              Score

Math                    65
Physics                 72
Chemistry               58
English                 80
History                 61

Average               67.2  (D)
Best:  English (80)
Worst: Chemistry (58)

Rankings:
  1. Alice Smith (avg: 87.0, grade: B)
  2. Bob Jones (avg: 67.2, grade: D)

What You Learned in This Chapter

  • A class is a blueprint. An object (instance) is built from that blueprint.
  • Define classes with class ClassName: — use PascalCase for names.
  • __init__ is the constructor — it runs automatically when an object is created.
  • self refers to the current instance — Python passes it automatically.
  • Instance attributes (self.x) are unique to each object. Class attributes are shared by all.
  • Methods are functions inside a class — always receive self as the first parameter.
  • __str__ controls print() output. __repr__ controls the developer representation.
  • _name (single underscore prefix) signals "private by convention" — don't use from outside.
  • @property makes a method behave like an attribute — readable without ().
  • __eq__ lets you define what == means for your objects.
  • Objects work inside lists, dicts, and any other collection.
  • Classes enforce their own rules through validation in methods.

What's Next?

You've built your first classes. The real power of OOP comes when you build classes from other classes.

In Chapter 18 you'll learn inheritance — how a SavingsAccount can be a BankAccount with extra behavior, how a GuideDog can be a Dog with extra capabilities, and how super() lets child classes build on their parent's work without repeating it. You'll also learn polymorphism — how different classes can respond to the same method call in their own way.

Your turn: Design a Rectangle class with width and height attributes. Add methods: area(), perimeter(), is_square(), and scale(factor) (which multiplies both dimensions by a factor). Add __str__ and __repr__. Then create a Square class (not using inheritance yet — just a class) that only takes one side argument and sets width = height = side. Compare a Rectangle(4, 4) and a Square(4) — do they have the same area and perimeter?

© 2026 Abhilash Sahoo. Python: Zero to Hero.