The Story That Explains Abstraction
Grind the beans. Measure 18 grams. Tamp with 30 pounds of force. Regulate water to 93°C. Time the extraction to 25 seconds. Purge the group head afterwards.
All of that happens behind a single button. What you use is the interface — Espresso, Latte, Cappuccino, Off. What actually runs is the implementation — pumps, sensors, heating elements, timers.
Now imagine you buy a different machine — a drip coffee-maker, a French press, or a capsule machine. Same idea: "press this, get coffee." Every machine implements "brew" completely differently, but from your perspective they all offer the same simple action.
That is abstraction. Expose what a class does; hide how it does it. Every real-world coffee machine, payment method, database driver, and file format handler in software works this way.
Abstraction is the fourth pillar of OOP (alongside encapsulation, inheritance,
and polymorphism). It says: define a contract that describes what an object should do,
let each concrete class fill in the details. Python enforces this contract using
Abstract Base Classes (ABC) and the
@abstractmethod decorator.
What Abstraction Really Means — Interface vs Implementation
Every abstraction has two sides. The interface is the promise: "if you're a
Shape, you must be able to tell me your area." The implementation is how each
concrete class fulfils that promise — a circle uses π·r², a rectangle uses
w × h.
| Says what must be done |
| Declares method names and signatures |
| Does not say how |
| Cannot be instantiated directly |
| Written once — reused by every implementation |
| Says how it's done |
| Fills in every abstract method |
| Free to use any algorithm internally |
| Can be instantiated and used |
| Multiple concrete classes can implement the same interface |
Why It Matters
Without abstraction, callers have to know which specific class they're dealing with. With abstraction, callers depend on the interface only — every new implementation slots in without a single line of caller code changing.
# Without abstraction — the caller must handle every payment type explicitly
def process(payment_type, data, amount):
if payment_type == "credit":
# Do card-specific stuff
...
elif payment_type == "upi":
# Do UPI-specific stuff
...
elif payment_type == "crypto":
# Do crypto-specific stuff
...
# Adding a new payment method → edit this function → break existing callers
# WITH abstraction — the caller just talks to the interface
def process(payment: PaymentMethod, amount):
payment.authenticate()
payment.charge(amount)
# A new payment method? Just implement PaymentMethod. No changes here.
Introducing ABC and @abstractmethod
Python's abc module ("Abstract Base Classes") gives you two things:
ABC, a base class to inherit from, and
@abstractmethod, a decorator that marks a method as
must be overridden.
from abc import ABC, abstractmethod
class Shape(ABC): # inherit from ABC → this class is abstract
# ── Abstract methods: no body needed, just a signature ──
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
# Try to instantiate the abstract class directly → error
try:
s = Shape()
except TypeError as e:
print("Blocked:", e)
# Blocked: Can't instantiate abstract class Shape with abstract methods area, perimeter
Any class that inherits from ABC and has at least one
@abstractmethod is abstract. Abstract classes
cannot be instantiated until every abstract method has a concrete
implementation. Those two rules are the whole enforcement mechanism.
Animated Diagram — The Contract and Its Implementations
Watch how one abstract Shape contract sits at the top, declaring
area() and perimeter() as promises. Three concrete classes below —
Circle, Rectangle, Triangle — each fulfil the promise in
their own way. The interface is uniform; the implementations differ.
Same two method names — area() and perimeter() — in every subclass. Three completely different formulas underneath. That's abstraction.
Abstract Classes Cannot Be Instantiated
This is the guarantee that makes abstract classes useful. Python refuses to create an
instance of an abstract class until every abstract method has a concrete implementation.
Try, and you get a TypeError at construction time — before any of your code can run.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
# ── Attempt 1: instantiate the abstract class directly ─────
try:
s = Shape()
except TypeError as e:
print("Blocked:", e)
# ── Attempt 2: subclass but forget one abstract method ─────
class HalfCircle(Shape):
def area(self):
return 42
# perimeter() is still abstract — HalfCircle is still abstract too
try:
h = HalfCircle()
except TypeError as e:
print("Blocked:", e)
# ── Attempt 3: fully concrete — works ──────────────────────
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
sq = Square(5) # works — every abstract method is implemented
print(sq.area(), sq.perimeter()) # 25 20
Animated Diagram — Instantiation Attempts
Three creation attempts, three different outcomes. The abstract class itself is blocked. A half-implemented subclass is blocked. Only the fully-concrete subclass survives.
Every Shape(...) call passes through Python's ABC gate. If any abstract method is unimplemented, the object is never created.
Abstract + Concrete Methods Together
An abstract class isn't only abstract methods. It can also contain fully implemented methods that every subclass inherits for free. That's how you share common behaviour while still enforcing the contract.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
def __init__(self, holder):
self.holder = holder # concrete — shared setup
# ── ABSTRACT — every subclass must implement ──
@abstractmethod
def authenticate(self):
pass
@abstractmethod
def charge(self, amount):
pass
# ── CONCRETE — shared, but subclasses can still override ──
def log_transaction(self, action, amount):
print(f" [log] {self.holder}: {action} of ${amount}")
class CreditCard(PaymentMethod):
def __init__(self, holder, number):
super().__init__(holder) # reuse the base setup
self.number = number
def authenticate(self):
print(f"Verifying card ****{self.number[-4:]}")
return True
def charge(self, amount):
print(f"Charging ${amount} to card")
self.log_transaction("charge", amount) # inherited from base — no duplication
Mark methods abstract when every subclass genuinely differs — the algorithm itself changes per subclass. Leave methods concrete when they'd be copy-pasted identically into every subclass. That gives you enforcement where it matters and reuse where it's free.
Animated Diagram — Polymorphism Through Abstraction
This is where abstraction pays off. A single function process(payment, amount)
accepts any object that satisfies the PaymentMethod contract. Three
concrete objects flow through it. Each one dispatches to its own
charge() implementation — same interface, different behaviour, zero if/else.
The generic function calls charge() once. Each payment type routes to its own implementation. Adding a fourth method means writing a new class — nothing in process() changes.
Practical Example 1 — Payment Gateway With Multiple Methods
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
"""Abstract base for every payment method."""
def __init__(self, holder):
self.holder = holder
# ══ ABSTRACT — every payment method must implement ═══════
@abstractmethod
def authenticate(self) -> bool:
"""Verify the payer's identity."""
...
@abstractmethod
def charge(self, amount) -> None:
"""Deduct the amount from the payer."""
...
@abstractmethod
def refund(self, amount) -> None:
"""Return the amount to the payer."""
...
# ══ CONCRETE — inherited by every payment method ═════════
def log(self, action, amount):
print(f" [log] {self.holder}: {action} of {amount}")
class CreditCard(PaymentMethod):
def __init__(self, holder, card_number):
super().__init__(holder)
self.card_number = card_number
def authenticate(self):
print(f"Verifying card ****{self.card_number[-4:]}")
return True
def charge(self, amount):
print(f"Card charged ${amount}")
self.log("card charge", amount)
def refund(self, amount):
print(f"Card refunded ${amount}")
self.log("card refund", amount)
class UPIPayment(PaymentMethod):
def __init__(self, holder, upi_id):
super().__init__(holder)
self.upi_id = upi_id
def authenticate(self):
print(f"UPI PIN check for {self.upi_id}")
return True
def charge(self, amount):
print(f"Debiting ₹{amount} via UPI")
self.log("UPI charge", amount)
def refund(self, amount):
print(f"Refunding ₹{amount} to UPI")
self.log("UPI refund", amount)
class CryptoWallet(PaymentMethod):
def __init__(self, holder, wallet_address):
super().__init__(holder)
self.wallet_address = wallet_address
def authenticate(self):
print("Verifying wallet signature")
return True
def charge(self, amount):
print(f"Broadcasting {amount} to blockchain")
self.log("crypto charge", amount)
def refund(self, amount):
print(f"Broadcasting refund of {amount}")
self.log("crypto refund", amount)
# ══ Generic processor — does NOT know which method it holds ══
def process_purchase(payment: PaymentMethod, amount):
print(f"\n▶ Processing for {payment.holder}:")
if payment.authenticate():
payment.charge(amount)
# One list of different concrete types, one uniform loop
payments = [
CreditCard("Alice", "4111111111113456"),
UPIPayment("Bob", "bob@upi"),
CryptoWallet("Chandra", "0xabc123"),
]
for p in payments:
process_purchase(p, 500)
# Trying to instantiate the abstract base is blocked
try:
PaymentMethod("Anyone")
except TypeError as e:
print("\nBlocked:", e)
process_purchase has no if payment_type == anywhere. It receives
"some PaymentMethod" and trusts the interface. Adding ApplePay
next month means writing one new subclass and adding it to the list —
nothing else changes. That's abstraction earning its keep.
Practical Example 2 — Shape Hierarchy
from abc import ABC, abstractmethod
import math
class Shape(ABC):
@abstractmethod
def area(self):
...
@abstractmethod
def perimeter(self):
...
# Concrete — shared by every shape
def describe(self):
name = type(self).__name__
print(f"{name:10s} area={self.area():7.2f} perimeter={self.perimeter():7.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
class Triangle(Shape):
def __init__(self, a, b, c):
self.a, self.b, self.c = a, b, c
def area(self):
s = (self.a + self.b + self.c) / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def perimeter(self):
return self.a + self.b + self.c
shapes: list[Shape] = [
Circle(5),
Rectangle(4, 6),
Triangle(3, 4, 5),
]
for s in shapes:
s.describe() # inherited concrete method, uses each shape's own area/perimeter
Abstraction vs Encapsulation — Two Different Ideas
Beginners often confuse these two. They're both about "hiding," but they hide different things.
| Hides complexity: what the class does vs how |
Tool: ABC + @abstractmethod |
| Answers: "what interface must every X follow?" |
| Enforced at the class-design level |
Example: Shape declares area(); Circle implements it |
| Hides data: private state behind a public API |
Tool: _x, __x naming + @property |
| Answers: "who can read or change this attribute?" |
| Enforced at the attribute-access level |
Example: _balance can only be set via validated deposit() |
A well-designed class typically uses both. Abstraction defines the class's contract with the outside world (public methods every subclass must implement). Encapsulation hides its data (private attributes accessed only through those public methods). One controls the shape of your class; the other controls the doors and locks.
Common Mistakes (and Fixes)
| Mistake | What Goes Wrong | Fix |
|---|---|---|
Forgetting to inherit from ABC |
@abstractmethod is silently ignored — the class is instantiable |
Make sure the base class inherits from abc.ABC |
Forgetting the @abstractmethod decorator |
The method is just a regular method; subclasses aren't forced to override | Add @abstractmethod above every method that must be overridden |
| Trying to instantiate the abstract base | TypeError at construction — often surprising in test code |
Never call PaymentMethod(...); instantiate a concrete subclass |
| Missing one abstract method in a subclass | The subclass is still abstract; calling Subclass() raises TypeError |
Implement every abstract method — or leave the subclass abstract on purpose |
| Changing an abstract method's signature | Subclasses that used the old signature break; polymorphism collapses | Keep signatures stable. If new args are needed, give them defaults |
| Overusing abstract classes for one implementation | Adds ceremony without benefit — one child, one boilerplate parent | Only introduce an abstract base when you have (or genuinely expect) two+ implementations |
| Confusing abstract with "not implemented yet" | Callers assume the method exists; runtime NotImplementedError everywhere |
Abstract methods are a design signal ("subclass must fill this in"), not a TODO marker |
Quick Reference
| Task | Syntax | Notes |
|---|---|---|
| Import ABC support | from abc import ABC, abstractmethod | Standard library, no install |
| Declare abstract class | class Shape(ABC): | Inherits from ABC |
| Declare abstract method | @abstractmethod + def m(self): ... | Body can be pass or ... |
| Concrete method in ABC | Just define normally — no decorator | Inherited by every subclass |
| Implement in subclass | Override with the same signature | All abstract methods must be present |
| Instantiate concrete class | obj = ConcreteClass(...) | Works once every abstract method is implemented |
| Check if a class is abstract | ClassName.__abstractmethods__ | Frozenset of unimplemented names |
| Type-hint against interface | def f(p: PaymentMethod): | Callers can pass any subclass |
| Common pattern | Concrete method calls abstract methods | Template Method pattern |
Golden Rules
abc.ABC and decorate at
least one method with @abstractmethod. Both are required —
neither alone works.
TypeError. This is the enforcement mechanism, and it's the point.
log(),
describe(), or setup routine. Reuse everything you can; abstract only what
genuinely differs.
def process(p: PaymentMethod), not def process(p: CreditCard).
Any subclass slots in automatically — that's polymorphism through abstraction.
@abstractmethod. That single question sorts the two apart.