Python Lesson 31 – OOP Basics | Dataplexa

OOP Basics

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 that data together 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, and the special methods that make your objects feel like native Python.

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.

Why it exists: without classes, you would represent related data as loose variables or disconnected dictionaries. A class groups the data and all the operations on it into one coherent, named unit — making code easier to reason about, test, and reuse.

Real-world use: a banking application defines a BankAccount class. Every customer account is a separate instance — same structure and behaviour, independent data.

# Defining a class and creating instances

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

    def bark(self):          # a method — a function that belongs to the class
        print("Woof!")

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

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

print(type(rex))    # 
print(isinstance(rex, Dog))   # True
Woof!
Woof!
<class '__main__.Dog'>
True
  • 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 method — it refers to the instance the method is called on
  • isinstance(obj, ClassName) checks whether an object is an instance of a class

The __init__ Method — Initialising Instances

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

Why it exists: every instance needs its own data. __init__ is the guaranteed place to set that data up — you know it always runs exactly once, right when the object is created.

# __init__ — setting up instance attributes

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

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

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

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.bark()        # Rex says: Woof!

# Access attributes directly
print(rex.name)   # Rex
print(luna.age)   # 2
Rex is a 4-year-old German Shepherd.
Luna is a 2-year-old Labrador.
Rex says: Woof!
Rex
2
  • __init__ is not called a constructor — Python's actual constructor is __new__. __init__ initialises the already-created instance.
  • Every attribute you want the instance to own must be assigned as self.attribute = value
  • Attributes set in __init__ are available in every other method via self
  • Each instance holds its own copy of the attributes — changing rex.age never affects luna.age

Instance Attributes vs Class Attributes

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

# Instance attributes vs class attributes

class BankAccount:
    bank_name = "Dataplexa Bank"   # class attribute — shared by all instances
    interest_rate = 0.03           # class attribute

    def __init__(self, owner, balance=0.0):
        self.owner   = owner       # instance attribute — unique per object
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        print(f"{self.owner} deposited ${amount:.2f}. Balance: ${self.balance:.2f}")

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

acc1.deposit(200)
print(acc2.balance)              # 0.0  — independent from acc1

# Class attribute accessible on both instance and class
print(acc1.bank_name)            # Dataplexa Bank
print(BankAccount.bank_name)     # Dataplexa Bank
print(BankAccount.interest_rate) # 0.03
Alice deposited $200.00. Balance: $700.00
0.0
Dataplexa Bank
Dataplexa Bank
0.03
  • Class attributes are defined at the class level, outside any method
  • All instances share the same class attribute — useful for constants and defaults
  • If you assign to self.bank_name on an instance, that creates a new instance attribute that shadows the class attribute for that instance only
  • Use class attributes for data that is truly shared — counters, configuration, constants

Methods — Instance, Class, and Static

Python classes support three types of methods, each serving a different purpose.

  • Instance methods — the most common. Take self as the first argument and operate on instance data.
  • Class methods — decorated with @classmethod. Take cls (the class itself) as the first argument. Used as alternative constructors or to work with class-level data.
  • Static methods — decorated with @staticmethod. No self or cls. Just a regular function namespaced inside the class.
# Three types of methods

class Temperature:
    unit = "Celsius"   # class attribute

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

    def describe(self):                    # instance method
        print(f"{self.value}° {Temperature.unit}")

    @classmethod
    def set_unit(cls, new_unit):           # class method
        cls.unit = new_unit

    @staticmethod
    def is_freezing(temp_c):              # static method — no self or cls
        return temp_c <= 0

t = Temperature(100)
t.describe()                    # 100° Celsius

Temperature.set_unit("Fahrenheit")
t.describe()                    # 100° Fahrenheit

print(Temperature.is_freezing(-5))   # True
print(Temperature.is_freezing(20))   # False
100° Celsius
100° Fahrenheit
True
False
  • Class methods are commonly used as alternative constructors — e.g. Temperature.from_fahrenheit(212)
  • Static methods are utility functions that logically belong with the class but do not need access to class or instance data
  • When in doubt, use an instance method — it is the most flexible

The __str__ and __repr__ Methods

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

  • __str__ — called by print() and str(). Should return a human-friendly description.
  • __repr__ — called in the REPL and by repr(). Should return an unambiguous developer-facing string, ideally one that could recreate the object.
# __str__ and __repr__

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

    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})"   # developer-friendly

p = Product("Notebook", 4.99)

print(p)        # calls __str__  → Notebook — $4.99
print(repr(p))  # calls __repr__ → Product(name='Notebook', price=4.99)

items = [Product("Pen", 1.50), Product("Desk", 89.99)]
print(items)    # list uses __repr__ for each item
Notebook — $4.99
Product(name='Notebook', price=4.99)
[Product(name='Pen', price=1.5), Product(name='Desk', price=89.99)]
  • If only one is defined, __repr__ is used as a fallback for both
  • Always implement at least __repr__ — it makes debugging dramatically easier
  • !r inside an f-string calls repr() on the value — useful for showing strings with their quotes

Encapsulation — Public, Protected, and Private

Python uses naming conventions to signal the intended visibility of attributes and methods. There are no hard access modifiers like in Java or C++, but the conventions are respected universally.

  • Publicself.name — accessible from anywhere
  • Protectedself._name — single underscore signals "internal use, handle with care"
  • Privateself.__name — double underscore triggers name mangling, making it harder (but not impossible) to access from outside
# Encapsulation conventions

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

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

    def get_balance(self):          # controlled public access
        return self.__balance

acc = BankAccount("Alice", 100.00)
acc.deposit(50)
print(acc.get_balance())   # 150.0
print(acc._log)            # ['Deposit: $50.00'] — accessible but discouraged
# print(acc.__balance)     # AttributeError — name mangled to _BankAccount__balance
print(acc._BankAccount__balance)   # 150.0 — still accessible if you know the mangled name
150.0
['Deposit: $50.00']
150.0
  • Single underscore _ is a convention — Python does not enforce it
  • Double underscore __ triggers name mangling: __balance becomes _BankAccount__balance
  • The Pythonic approach is to trust the convention rather than fight against it — "we're all adults here"

Summary Table

Concept What It Is Key Syntax
Class Blueprint for creating objects class Name:
Instance A specific object built from a class obj = ClassName()
__init__ Initialises instance attributes def __init__(self, ...):
Instance method Function that operates on instance data def method(self):
Class method Operates on the class itself @classmethod def m(cls):
Static method Utility function namespaced in class @staticmethod def m():
__str__ Human-readable string representation def __str__(self):
__repr__ Developer-facing string representation def __repr__(self):

Practice Questions

Practice 1. What naming convention do Python class names follow?



Practice 2. What is the name of the special method Python calls automatically when a new instance is created?



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



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



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



Quiz

Quiz 1. What does self refer to inside a method?






Quiz 2. Which decorator is used to define a class method?






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






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






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






Next up — Inheritance: extending classes to build specialised types without rewriting shared behaviour.