Object-Oriented Programming (OOP) in Python Explained

Object-oriented Python learned by building one small system, a shopping cart with discount rules: classes and instances, the shared-list bug, properties that protect invariants, special methods, abstract base classes, composition over inheritance, and dataclasses for plain data.

GloryTecks Team

Training & Placement Faculty

9 May 2026 7 min read

Last updated: 24 September 2026

Share:

Object-oriented programming (OOP) is a way of organising code so that data and the operations that belong to it live together. A shopping cart knows its items and how to total them; a discount rule knows how to compute a discount. Python supports OOP fully but never forces it, which makes the more useful question not "how do I write a class?" but "when does a class make my code better?". This guide answers both by building one small system: a cart with pluggable discount rules.

A first class: data plus behaviour

A class is a blueprint; each instance made from it has its own data. __init__ sets up a new instance, and self is the instance a method is working on (Python tutorial: classes).

python
from decimal import Decimal

class Cart:
    def __init__(self, customer: str):
        self.customer = customer
        self.items: list[tuple[str, Decimal, int]] = []

    def add(self, name: str, price: str, qty: int = 1) -> None:
        self.items.append((name, Decimal(price), qty))

    def subtotal(self) -> Decimal:
        return sum((price * qty for _, price, qty in self.items), Decimal("0"))

asha = Cart("Asha")
asha.add("Notebook", "120.00", 3)
asha.add("Pen", "25.50")
ravi = Cart("Ravi")
print(asha.subtotal(), ravi.subtotal(), len(asha.items), len(ravi.items))
text
385.50 0 2 0

Two carts, two independent lists. Prices are Decimal rather than float, because money needs exact decimal arithmetic. That already shows the point of a class: the list and the arithmetic that depends on it travel together, and there is no way to total the wrong list.

The bug everyone writes once: shared class attributes

Attributes assigned in the class body belong to the class, and every instance shares them. That is fine for constants and wrong for anything mutable:

python
class LeakyCart:
    items = []                      # one list, shared by every instance

    def add(self, name):
        self.items.append(name)

a, b = LeakyCart(), LeakyCart()
a.add("Notebook")
print(b.items)
text
['Notebook']

Cart b "contains" what was added to cart a. The Python tutorial warns about exactly this case. Create per-instance data in __init__, as Cart does.

Protecting invariants with properties

Some values must stay valid, for example a quantity that must be positive. A property looks like a plain attribute to callers but runs your code on every read or write (Python docs: property):

python
class LineItem:
    def __init__(self, name: str, price: str, qty: int = 1):
        self.name = name
        self.price = Decimal(price)
        self.qty = qty                  # goes through the setter below

    @property
    def qty(self) -> int:
        return self._qty

    @qty.setter
    def qty(self, value: int) -> None:
        if value < 1:
            raise ValueError(f"quantity must be at least 1, got {value}")
        self._qty = value

    @property
    def total(self) -> Decimal:
        return self.price * self.qty

    def __repr__(self) -> str:
        return f"LineItem({self.name!r}, {self.price}, qty={self.qty})"

item = LineItem("Notebook", "120.00", 3)
print(item, item.total)
try:
    item.qty = 0
except ValueError as exc:
    print(exc)
text
LineItem('Notebook', 120.00, qty=3) 360.00
quantity must be at least 1, got 0

No code anywhere can now create or leave a line item with a zero quantity. total is a read-only property: computed on demand, so it can never go stale. The leading underscore in _qty is Python's convention for "internal: do not touch from outside". Python does not enforce privacy; names starting with two underscores are merely renamed to make accidental clashes in subclasses unlikely.

__repr__ is one of the special methods that let your objects work with Python's built-in operations (Python reference: data model). Defining __len__, __iter__ or __eq__ makes len(cart), for item in cart and cart_a == cart_b work too.

Polymorphism: many discount rules, one interface

Discounts vary: a percentage off, a bulk discount, a coupon. The cart should not need an if for every kind. Define the interface once as an abstract base class, and let each rule implement it (Python docs: abc):

python
from abc import ABC, abstractmethod

class DiscountPolicy(ABC):
    @abstractmethod
    def discount(self, items: list[LineItem]) -> Decimal: ...

class NoDiscount(DiscountPolicy):
    def discount(self, items):
        return Decimal("0")

class PercentOff(DiscountPolicy):
    def __init__(self, percent: int):
        self.rate = Decimal(percent) / 100

    def discount(self, items):
        return sum((i.total for i in items), Decimal("0")) * self.rate

class BulkDiscount(DiscountPolicy):
    """10% off any line with at least `min_qty` units."""
    def __init__(self, min_qty: int):
        self.min_qty = min_qty

    def discount(self, items):
        return sum((i.total * Decimal("0.10") for i in items if i.qty >= self.min_qty), Decimal("0"))

try:
    DiscountPolicy()
except TypeError as exc:
    print(exc)
text
Can't instantiate abstract class DiscountPolicy with abstract method discount

The abstract class cannot be instantiated, and a subclass that forgets discount fails the same way, at creation rather than at checkout. Code that uses a policy only needs to know it has a discount method. That is polymorphism: one call, different behaviour depending on the object.

Composition: the cart has a policy

Now the cart holds a policy, instead of being a kind of discounted cart:

python
class ShoppingCart:
    def __init__(self, policy: DiscountPolicy | None = None):
        self.items: list[LineItem] = []
        self.policy = policy or NoDiscount()

    def add(self, item: LineItem) -> "ShoppingCart":
        self.items.append(item)
        return self

    def __len__(self) -> int:
        return len(self.items)

    def total(self) -> Decimal:
        gross = sum((i.total for i in self.items), Decimal("0"))
        return (gross - self.policy.discount(self.items)).quantize(Decimal("0.01"))

cart = ShoppingCart().add(LineItem("Notebook", "120.00", 5)).add(LineItem("Pen", "25.50", 2))
print(len(cart), cart.total())
for policy in (PercentOff(10), BulkDiscount(min_qty=5)):
    cart.policy = policy
    print(type(policy).__name__, cart.total())
text
2 651.00
PercentOff 585.90
BulkDiscount 591.00

Swapping the rule is one assignment, and adding a new rule never touches ShoppingCart. Had we used inheritance instead, with PercentOffCart(Cart) and BulkDiscountCart(Cart), every combination of cart feature and discount would need its own subclass. A useful test: use inheritance for an "is a" relationship with a shared interface (a PercentOff is a DiscountPolicy), and composition for "has a" (a cart has a policy). When unsure, prefer composition.

Dataclasses for plain data

Many classes mostly hold data. @dataclass writes __init__, __repr__ and __eq__ for you; frozen=True makes instances immutable, which suits values like a coupon (Python docs: dataclasses):

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Coupon:
    code: str
    percent: int

c = Coupon("WELCOME10", 10)
print(c, c == Coupon("WELCOME10", 10))
text
Coupon(code='WELCOME10', percent=10) True

Designing a class: three questions

Before writing a class, answer three questions in plain words.

  1. What does it know? The data each instance holds. For ShoppingCart: its line items and its discount policy.
  2. What can it do? The operations that need that data. Adding an item and computing the total both need the items; printing a marketing email does not, so it belongs elsewhere.
  3. What must always be true? The invariants the class protects. A line item's quantity is at least one; a total is never computed from a stale value. Every public method should leave those rules intact.

If the answers are short and specific, the class will be too. If question 2 grows a long list of loosely related abilities, the class is doing several jobs and should be split. And if question 1 has no answer, because there is no data to hold, you are looking at a function.

When not to write a class

A class earns its place when data and behaviour genuinely belong together, when several variants share an interface, or when an object must protect its own rules. A class with one method and no state is a function wearing a costume; write the function. And a module full of functions is a perfectly good design in Python. Python best practices covers more of these judgement calls.

What is self?

The instance the method was called on. cart.total() is shorthand for ShoppingCart.total(cart); Python passes the instance as the first argument automatically.

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

A class attribute is defined in the class body and shared by all instances; an instance attribute is set on self, usually in __init__, and belongs to one object.

When should I use @classmethod or @staticmethod?

A classmethod receives the class instead of an instance, which makes it the natural way to write alternative constructors, such as a LineItem.from_csv_row(row) that parses a row and returns a new instance. A staticmethod receives neither; it is just a function kept inside the class for organisation, and often it is clearer as a plain module-level function.

Does Python have private attributes?

Not enforced ones. A single leading underscore signals internal use by convention; a double underscore triggers name mangling, which avoids accidental clashes in subclasses but does not stop deliberate access.

Properties are one example of decorators, explained in Python decorators with real examples. Type hints like the ones above are covered in Python type hints. Classes, constructors, inheritance, polymorphism, encapsulation and abstraction make up the object-oriented programming module of the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course