OOP Basics in Python | Python Course | Dataplexa

OOP Basics in Python

Everything in Python is an object — integers, strings, lists, functions. But until now you have been using objects that Python created for you. Object-Oriented Programming (OOP) is the practice of designing your own objects: custom types that bundle data and the functions that operate on it into a single, reusable unit called a class.

OOP is not just a style preference — it is the dominant paradigm in professional Python. Web frameworks, data libraries, and virtually every large codebase you will encounter are built around classes. This lesson covers the complete foundation: classes, instances, attributes, methods, the special dunder methods, and encapsulation.

Classes and Instances

A class is a blueprint — it defines the structure and behaviour of a type of object. An instance is a specific object built from that blueprint. You can create as many independent instances from one class as you need.

class Dog:
    """A simple class representing a dog."""

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

    def bark(self):
        print("Woof!")

# Create two independent instances
rex  = Dog()
luna = Dog()

rex.bark()     # Woof!
luna.bark()    # Woof!

print(type(rex))              # <class '__main__.Dog'>
print(isinstance(rex, Dog))   # True
print(rex.species)            # Canis familiaris — shared class attribute
Woof! Woof! <class '__main__.Dog'> True Canis familiaris
  • Class names use PascalCase by convention — BankAccount, not bank_account.
  • Calling a class like a function (Dog()) creates a new instance.
  • self is the first parameter of every instance method — it refers to the specific instance the method is called on.
  • isinstance(obj, ClassName) checks whether an object is an instance of a class (also returns True for subclasses).

The __init__ Method — Initialising Instances

__init__ is the initialiser — Python calls it automatically every time you create a new instance. It is where you set up the object's initial state by assigning instance attributes.

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

    def __init__(self, name, breed, age):
        self.name  = name    # instance attributes — unique per object
        self.breed = breed
        self.age   = age

    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}.")

rex  = Dog("Rex",  "German Shepherd", 4)
luna = Dog("Luna", "Labrador",        2)

rex.describe()    # Rex is a 4-year-old German Shepherd.
luna.describe()   # Luna is a 2-year-old Labrador.

rex.birthday()    # Happy birthday Rex! Now 5.
rex.birthday()    # Happy birthday Rex! Now 6.
print(luna.age)   # 2 — luna.age is completely independent of rex.age
Rex is a 4-year-old German Shepherd. Luna is a 2-year-old Labrador. Happy birthday Rex! Now 5. Happy birthday Rex! Now 6. 2
  • Every attribute the instance should own must be assigned as self.attribute = value in __init__.
  • Attributes set in __init__ are available in every other method via self.
  • Each instance holds its own copy — changing rex.age never affects luna.age.

Instance Attributes vs Class Attributes

Attributes defined on self in __init__ belong to each instance individually. Attributes defined directly in the class body are class attributes — shared across all instances.

class BankAccount:
    bank_name     = "Dataplexa Bank"   # class attribute — shared
    interest_rate = 0.03               # class attribute
    _account_count = 0                 # class attribute used as counter

    def __init__(self, owner, balance=0.0):
        self.owner   = owner           # instance attribute
        self.balance = balance         # instance attribute
        BankAccount._account_count += 1

    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            print(f"{self.owner}: +${amount:.2f} → balance ${self.balance:.2f}")

    @classmethod
    def total_accounts(cls):
        return cls._account_count

acc1 = BankAccount("Alice", 500.00)
acc2 = BankAccount("Bob")

acc1.deposit(200)
print(acc2.balance)                       # 0.0  — independent
print(BankAccount.bank_name)              # Dataplexa Bank
print(BankAccount.total_accounts())       # 2
Alice: +$200.00 → balance $700.00 0.0 Dataplexa Bank 2
  • Class attributes are ideal for constants, shared configuration, and counters.
  • If you assign to self.bank_name on an instance, it creates a new instance attribute that shadows the class attribute for that instance only — the class attribute is unchanged.

Three Types of Methods

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    def describe(self):                         # instance method
        print(f"{self.celsius}°C / {self.to_fahrenheit():.1f}°F")

    def to_fahrenheit(self):
        return self.celsius * 9/5 + 32

    @classmethod
    def from_fahrenheit(cls, f):               # class method — alternative constructor
        return cls((f - 32) * 5/9)

    @classmethod
    def from_kelvin(cls, k):
        return cls(k - 273.15)

    @staticmethod
    def is_freezing(celsius):                  # static method — utility, no self/cls
        return celsius <= 0

t1 = Temperature(100)
t1.describe()                                  # 100°C / 212.0°F

t2 = Temperature.from_fahrenheit(32)
t2.describe()                                  # 0.0°C / 32.0°F

t3 = Temperature.from_kelvin(373.15)
t3.describe()                                  # 100.0°C / 212.0°F

print(Temperature.is_freezing(-5))             # True
print(Temperature.is_freezing(20))             # False
100°C / 212.0°F 0.0°C / 32.0°F 100.0°C / 212.0°F True False
  • Instance methods — take self, operate on instance data. Most common.
  • Class methods@classmethod, take cls. Used as alternative constructors or to work with class-level data.
  • Static methods@staticmethod. No self or cls. Pure utility functions namespaced inside the class.

The __str__ and __repr__ Methods

By default, printing an object shows something like <__main__.Dog object at 0x...>. Implementing __str__ and __repr__ gives your objects informative, readable string representations.

class Product:
    def __init__(self, name, price, stock=0):
        self.name  = name
        self.price = price
        self.stock = stock

    def __str__(self):
        return f"{self.name} — ${self.price:.2f}"   # human-friendly

    def __repr__(self):
        return f"Product(name={self.name!r}, price={self.price}, stock={self.stock})"

    def __len__(self):
        return self.stock    # len(product) returns stock count

    def __bool__(self):
        return self.stock > 0    # in stock = True

p1 = Product("Notebook", 4.99, 50)
p2 = Product("Desk",     89.99, 0)

print(p1)           # Notebook — $4.99        (calls __str__)
print(repr(p1))     # Product(name='Notebook', price=4.99, stock=50)
print(len(p1))      # 50
print(bool(p1))     # True   — in stock
print(bool(p2))     # False  — out of stock

items = [p1, p2]
print(items)        # list uses __repr__ for each item
Notebook — $4.99 Product(name='Notebook', price=4.99, stock=50) 50 True False [Product(name='Notebook', price=4.99, stock=50), Product(name='Desk', price=89.99, stock=0)]
  • If only __repr__ is defined, it is used as a fallback for both print() and repr().
  • Always implement at least __repr__ — it makes debugging dramatically easier.
  • __len__, __bool__, __eq__, __lt__ etc. are dunder methods — they let your objects work with Python's built-in operators and functions.

Dunder Methods — Making Objects Feel Native

class Vector:
    """2D vector that supports +, -, *, ==, and abs()."""
    def __init__(self, x, y):
        self.x = x
        self.y = y

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

    def __add__(self, other):          # v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):          # v1 - v2
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):         # v * 3
        return Vector(self.x * scalar, self.y * scalar)

    def __abs__(self):                 # abs(v) — magnitude
        return (self.x**2 + self.y**2) ** 0.5

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

v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)     # Vector(4, 6)
print(v2 - v1)     # Vector(2, 2)
print(v1 * 3)      # Vector(3, 6)
print(abs(v2))     # 5.0  — magnitude of (3,4) = 5
print(v1 == Vector(1, 2))   # True
Vector(4, 6) Vector(2, 2) Vector(3, 6) 5.0 True

Encapsulation — Public, Protected, Private

class BankAccount:
    def __init__(self, owner, balance):
        self.owner     = owner          # public — access freely
        self._log      = []             # protected — internal use, handle with care
        self.__balance = balance        # private — name-mangled

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            self._log.append(f"+${amount:.2f}")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            self._log.append(f"-${amount:.2f}")
        else:
            print("Insufficient funds or invalid amount")

    def get_balance(self):
        return self.__balance

    def statement(self):
        print(f"Account: {self.owner}")
        for entry in self._log:
            print(f"  {entry}")
        print(f"  Balance: ${self.__balance:.2f}")

acc = BankAccount("Alice", 200.00)
acc.deposit(100)
acc.withdraw(50)
acc.withdraw(500)     # Insufficient funds
acc.statement()

# __balance is name-mangled but still reachable if you know the name
print(acc._BankAccount__balance)   # 250.0
Insufficient funds or invalid amount Account: Alice +$100.00 -$50.00 Balance: $250.00 250.0
  • Single underscore _name — convention for "internal use". Python does not enforce it.
  • Double underscore __name — Python renames it to _ClassName__name (name mangling) making it harder to access accidentally from outside.
  • The Pythonic philosophy: trust naming conventions rather than enforcing hard barriers — "we're all adults here".

Quick Reference Table

ConceptWhat It IsKey Syntax
ClassBlueprint for creating objectsclass Name:
InstanceA specific object built from a classobj = ClassName()
__init__Initialises instance attributesdef __init__(self, ...):
Instance methodOperates on instance datadef method(self):
Class methodOperates on the class itself@classmethod def m(cls):
Static methodUtility function namespaced in class@staticmethod def m():
__str__Human-readable string (print)def __str__(self):
__repr__Developer-facing string (repr/REPL)def __repr__(self):
PublicAccessible anywhereself.name
ProtectedInternal use by conventionself._name
PrivateName-mangled, harder to access externallyself.__name

Practice

What naming convention do Python class names follow?



What special method does Python call automatically when a new instance is created?



What is the difference between a class attribute and an instance attribute?



Which special method is called when you use print() on an object?



What prefix signals that an attribute is intended for internal use only (protected)?



Which dunder method enables the + operator between two instances?



Quick Quiz

What does self refer to inside a method?





Which decorator is used to define a class method?





What happens when you define an attribute with a double underscore prefix like self.__balance?





If __str__ is not defined but __repr__ is, what does print(obj) use?





What is the main advantage of using a class method as an alternative constructor?





To support v1 + v2 between two custom objects, which method do you implement?





NEXT UP
Inheritance in Python
Learn to extend classes to build specialised types — sharing and overriding behaviour without rewriting shared code.