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
Last updated: 24 September 2026
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).
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))385.50 0 2 0Two 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:
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)['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):
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)LineItem('Notebook', 120.00, qty=3) 360.00
quantity must be at least 1, got 0No 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):
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)Can't instantiate abstract class DiscountPolicy with abstract method discountThe 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:
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())2 651.00
PercentOff 585.90
BulkDiscount 591.00Swapping 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):
from dataclasses import dataclass
@dataclass(frozen=True)
class Coupon:
code: str
percent: int
c = Coupon("WELCOME10", 10)
print(c, c == Coupon("WELCOME10", 10))Coupon(code='WELCOME10', percent=10) TrueDesigning a class: three questions
Before writing a class, answer three questions in plain words.
- What does it know? The data each instance holds. For
ShoppingCart: its line items and its discount policy. - 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.
- 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?
What is the difference between a class attribute and an instance attribute?
When should I use @classmethod or @staticmethod?
Does Python have private attributes?
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
- Classes (Python tutorial) — Python documentation
- property() — Python documentation
- Data model: special method names — Python documentation
- abc — Abstract Base Classes — Python documentation
- dataclasses — Data Classes — Python documentation
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.
Python Mini Projects to Build in a Weekend
NextPandas Guide: DataFrames, Cleaning and Aggregation
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.