Introduction to Classes in Python: Complete Object-Oriented Programming Fundamentals and Implementation

Introduction to classes in python

Introduction to classes in python

There was a stretch early on when I could write working Python scripts but genuinely didn’t understand why anyone would bother with classes when functions and dictionaries seemed to do the job fine. What changed my mind wasn’t a tutorial — it was writing the same validation and update logic across five different functions that all operated on the same “shape” of data, and realizing a class would have bundled all of that together naturally. This guide is the introduction I wish I’d had: what a class actually is in Python, how objects get created, and the fundamentals you need before moving on to inheritance, polymorphism, or more advanced patterns.

What Is a Class, Really?

A class is a blueprint for creating objects — it bundles data (attributes) and behavior (methods) together into a single reusable definition.

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

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


rex = Dog("Rex", "Labrador")
print(rex.bark())  # Rex says Woof!

Dog is the class — the blueprint. rex is an instance — a concrete object built from that blueprint. Every Dog instance follows the same structure (a name and a breed, and the ability to bark), but each instance holds its own independent data.

__init__: The Constructor

__init__ is a special method automatically called right after a new object is created, and it’s where you typically set up the instance’s initial state:

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

rex = Dog("Rex", "Labrador")
buddy = Dog("Buddy", "Poodle")

print(rex.name, rex.breed)     # Rex Labrador
print(buddy.name, buddy.breed) # Buddy Poodle

Technically, object creation is a two-step process: __new__ actually creates the raw object, and __init__ then initializes it. You almost never need to override __new__ for ordinary classes — object.__new__ handles allocation, and __init__ is where nearly all customization happens. __init__ doesn’t return the object; it returns None implicitly, and its job is purely to set attributes on self, the instance that’s already been created by the time __init__ runs.

self: Why It’s Everywhere

Every regular method takes self as its first parameter. self refers to the specific instance the method was called on, and it’s how a method knows which object’s data to read or modify:

class Counter:
    def __init__(self):
        self.count = 0

    def increment(self):
        self.count += 1

c1 = Counter()
c2 = Counter()
c1.increment()
c1.increment()
c2.increment()

print(c1.count)  # 2
print(c2.count)  # 1 — completely independent state

c1.increment() is really shorthand for Counter.increment(c1) — Python automatically passes the instance as the first argument when you call a method through an instance. self isn’t a keyword; it’s just a strong convention (you could technically name it anything, but never do — every Python programmer expects self).

Instance Attributes vs Class Attributes

Attributes defined inside __init__ (or any method) via self.x = ... are instance attributes — unique to each object. Attributes defined directly in the class body are class attributes — shared across all instances unless overridden.

class Dog:
    species = "Canis familiaris"  # class attribute, shared

    def __init__(self, name):
        self.name = name          # instance attribute, unique

d1 = Dog("Rex")
d2 = Dog("Buddy")

print(d1.species, d2.species)  # Canis familiaris Canis familiaris
print(d1.name, d2.name)        # Rex Buddy

Dog.species = "Updated species"
print(d1.species)  # Updated species — both instances see the class-level change

This is a useful distinction for things that are genuinely constant across every instance of a class (like species here), versus data that’s specific to each object.

Methods: Behavior Bound to Data

A method is just a function defined inside a class, operating on the instance’s data via self:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount

    def __str__(self):
        return f"{self.owner}'s account: ${self.balance}"


acc = BankAccount("Fatima", 100)
acc.deposit(50)
acc.withdraw(30)
print(acc)  # Fatima's account: $120

Bundling deposit, withdraw, and balance together means the balance can never be changed except through methods that (in a more thorough version) could enforce rules — this is the practical value of encapsulation: keeping related data and the logic that’s allowed to touch it in one place.

Dunder Methods: Customizing Built-in Behavior

Methods with double underscores on both sides (__init__, __str__, __repr__, __eq__, etc.) are Python’s hooks for making your objects work with built-in syntax and functions.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)


p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1)          # Point(1, 2)  -- uses __repr__
print(p1 == Point(1, 2))  # True  -- uses __eq__
print(p1 + p2)      # Point(4, 6) -- uses __add__

Without __repr__, print(p1) would show something unhelpful like <__main__.Point object at 0x...>. Without __eq__, p1 == Point(1, 2) would be False, because the default __eq__ compares object identity, not value equality. These hooks are how Python lets custom objects integrate naturally with +, ==, print(), len(), iteration, and more.

A Realistic Example: Modeling a Task

Here’s a slightly larger example that ties the fundamentals together:

class Task:
    def __init__(self, title, priority="medium"):
        self.title = title
        self.priority = priority
        self.done = False

    def complete(self):
        self.done = True

    def __repr__(self):
        status = "done" if self.done else "pending"
        return f"<Task '{self.title}' [{self.priority}] - {status}>"


class TaskList:
    def __init__(self):
        self.tasks = []

    def add(self, title, priority="medium"):
        self.tasks.append(Task(title, priority))

    def pending(self):
        return [t for t in self.tasks if not t.done]


tl = TaskList()
tl.add("Write article", priority="high")
tl.add("Review code")
tl.tasks[0].complete()

print(tl.pending())
# [<Task 'Review code' [medium] - pending>]

TaskList composes Task objects — a TaskList “has” Tasks rather than “is” a Task. This composition pattern, alongside inheritance, is one of the two fundamental ways classes relate to each other in object-oriented design.

Common Mistakes

FAQs

Q: Do I need a class for everything in Python? No — Python doesn’t force object-oriented design. Plain functions and dictionaries are often simpler and perfectly idiomatic for straightforward tasks. Classes earn their keep when you have data and behavior that naturally belong together and need multiple independent instances.

Q: What’s the difference between __str__ and __repr__? __str__ is meant to be a readable, user-facing description (used by print() and str()); __repr__ is meant to be an unambiguous, developer-facing representation (used by the interpreter, debuggers, and as a fallback if __str__ isn’t defined).

Q: Are Python classes expensive to create compared to dictionaries? Plain classes have some overhead compared to dictionaries (mainly due to the per-instance __dict__), but for the vast majority of applications this difference is irrelevant compared to the clarity and correctness benefits of proper structure. If memory truly matters at scale, __slots__ or dataclasses with slots can close much of that gap.

Summary

A class bundles data and behavior into a reusable blueprint; instances are the concrete objects built from that blueprint, each with independent state stored via self. __init__ sets up new objects, instance attributes hold per-object data while class attributes are shared, and dunder methods let your objects integrate naturally with Python’s built-in syntax. These fundamentals — self, __init__, instance vs class attributes, and basic dunder methods — are the foundation everything else in Python’s object model (inheritance, descriptors, static/class methods) builds on top of.

References

Exit mobile version