Skip to main content

Abstraction — Hiding Complexity

Editorial

Introduction

In the previous tutorial, we learned encapsulation — hiding data behind private fields and exposing controlled methods. Encapsulation answers the question: "How do I protect an object's internal state?"

Abstraction answers a different question: "How do I hide complexity so users of my class only see what they need?"

Think about driving a car. You interact with a simple interface: steering wheel, accelerator, brake. Behind that interface, there's a combustion engine, transmission system, electronic control unit, fuel injection system, and hundreds of sensors. But you don't need to know any of that to drive.

Abstraction applies the same principle to code:

  • Define what an object can do (the interface)
  • Hide how it does it (the implementation)

This is the foundation of building large systems. When a payment processing module exposes a process_payment(amount) method, the caller doesn't need to know whether it uses Stripe, PayPal, or bank transfers internally. That's abstraction at work — and it's what makes it possible to change the implementation later without breaking the rest of the system.

Abstraction vs Encapsulation — They're Not the Same

These two concepts are frequently confused. Here's the distinction:

AspectEncapsulationAbstraction
FocusProtecting dataHiding complexity
MechanismPrivate fields + getters/settersAbstract classes + interfaces
QuestionWho can access this?What does the user need to see?
AnalogyLocked vault inside a bankATM screen showing only deposit/withdraw
LevelImplementation detail (inside a class)Design decision (between classes)

Encapsulation: "My BankAccount.__balance is private, you access it through deposit() and withdraw()."

Abstraction: "You know every PaymentProcessor has a process_payment() method. You don't need to know if it's StripeProcessor or PayPalProcessor underneath."

They work together: encapsulation hides the data, abstraction hides the implementation. Both are essential for maintainable code.

Abstraction. Left: abstract contract - class Notification(ABC) can't be instantiated; declares @abstractmethod send() (subclasses must implement) plus concrete format_message() (shared). Right: hidden implementations - Email.send() via SMTP, SMS.send() via Twilio truncating at 160 chars, Push.send() via Firebase/APNs. Payoff: notify_user(n: Notification) just calls n.send() and works with any subtype, even a SlackNotification added later, without changing a line. Encapsulation hides data inside one class; abstraction hides implementation across a hierarchy.

Abstract Classes — Partial Blueprints

An abstract class is a class that:

  • Cannot be instantiated — you can't create an object from it directly
  • Defines abstract methods — methods that subclasses must implement
  • Can include concrete methods — shared logic that all subclasses inherit

Think of it as a template that says: "Every notification must have a send() method, but I'll leave the how to each specific type."

from abc import ABC, abstractmethod
from datetime import datetime


class Notification(ABC):
    """Abstract base class — defines WHAT every notification must do.
    
    Cannot be instantiated directly. Subclasses must implement send().
    """

    def __init__(self, recipient: str, message: str):
        self.recipient = recipient
        self.message = message
        self.created_at = datetime.now()

    @abstractmethod
    def send(self) -> bool:
        """Send the notification. Each subclass decides HOW."""
        pass

    # Concrete method — shared by ALL notification types
    def format_message(self) -> str:
        """Adds a timestamp to the message. All types use this."""
        timestamp = self.created_at.strftime("%Y-%m-%d %H:%M")
        return f"[{timestamp}] {self.message}"


class EmailNotification(Notification):
    """Sends notifications via email."""

    def __init__(self, recipient: str, message: str, subject: str):
        super().__init__(recipient, message)
        self.subject = subject

    def send(self) -> bool:
        formatted = self.format_message()  # Uses inherited concrete method
        print(f"📧 Email to {self.recipient}")
        print(f"   Subject: {self.subject}")
        print(f"   Body: {formatted}")
        # In real code: SMTP logic here
        return True


class SMSNotification(Notification):
    """Sends notifications via SMS."""

    def __init__(self, recipient: str, message: str, phone: str):
        super().__init__(recipient, message)
        self.phone = phone

    def send(self) -> bool:
        formatted = self.format_message()  # Uses inherited concrete method
        # SMS has character limit — truncate if needed
        if len(formatted) > 160:
            formatted = formatted[:157] + "..."
        print(f"📱 SMS to {self.phone}: {formatted}")
        # In real code: Twilio API call here
        return True


class PushNotification(Notification):
    """Sends notifications via push notification."""

    def __init__(self, recipient: str, message: str, device_token: str):
        super().__init__(recipient, message)
        self.device_token = device_token

    def send(self) -> bool:
        formatted = self.format_message()
        print(f"🔔 Push to device {self.device_token[:8]}...: {formatted}")
        # In real code: Firebase/APNs call here
        return True


# --- Usage: the caller doesn't need to know the type ---

def notify_user(notification: Notification) -> None:
    """Sends any notification. Doesn't care about the specific type."""
    success = notification.send()  # Abstraction: just call send()
    print(f"   Delivery: {'Success' if success else 'Failed'}")


# Different types, same interface
notify_user(EmailNotification("alice", "Your order shipped!", "Order Update"))
notify_user(SMSNotification("bob", "Your code: 123456", "+1-555-0100"))
notify_user(PushNotification("charlie", "New message!", "dk3j8f9a2b"))

# Can't instantiate the abstract class:
try:
    n = Notification("test", "hello")  # TypeError!
except TypeError as e:
    print(f"\nBlocked: {e}")
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public abstract class Notification {
    protected String recipient;
    protected String message;
    protected LocalDateTime createdAt;

    public Notification(String recipient, String message) {
        this.recipient = recipient;
        this.message = message;
        this.createdAt = LocalDateTime.now();
    }

    // Abstract method — subclasses MUST implement
    public abstract boolean send();

    // Concrete method — shared by ALL notification types
    public String formatMessage() {
        String timestamp = createdAt.format(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
        return String.format("[%s] %s", timestamp, message);
    }
}

// --- Concrete subclasses ---

class EmailNotification extends Notification {
    private String subject;

    public EmailNotification(String recipient, String message, String subject) {
        super(recipient, message);
        this.subject = subject;
    }

    @Override
    public boolean send() {
        String formatted = formatMessage();
        System.out.println("Email to " + recipient);
        System.out.println("   Subject: " + subject);
        System.out.println("   Body: " + formatted);
        return true;
    }
}

class SMSNotification extends Notification {
    private String phone;

    public SMSNotification(String recipient, String message, String phone) {
        super(recipient, message);
        this.phone = phone;
    }

    @Override
    public boolean send() {
        String formatted = formatMessage();
        if (formatted.length() > 160) {
            formatted = formatted.substring(0, 157) + "...";
        }
        System.out.println("SMS to " + phone + ": " + formatted);
        return true;
    }
}

// --- Usage ---
class NotificationDemo {
    // Accepts ANY Notification — doesn't care about the type
    static void notifyUser(Notification notification) {
        boolean success = notification.send();
        System.out.println("   Delivery: " + (success ? "Success" : "Failed"));
    }

    public static void main(String[] args) {
        notifyUser(new EmailNotification("alice", "Your order shipped!", "Order Update"));
        notifyUser(new SMSNotification("bob", "Your code: 123456", "+1-555-0100"));

        // Notification n = new Notification("test", "hello"); // COMPILE ERROR
    }
}

Notice the power of abstraction in the notify_user() function. It accepts any Notification and just calls send(). It doesn't know or care whether it's an email, SMS, or push notification. Tomorrow, you could add a SlackNotification and notify_user() would work with it immediately — without changing a single line.

Visualization

Send a message through the fixed notify_user(n) caller using different Notification types. The caller only calls n.send(); each type hides its own delivery (Email via SMTP, SMS via Twilio truncating at 160 chars, Push via Firebase). Instantiating the abstract base is blocked; add a Slack type at runtime and it works with zero caller changes.

Interfaces — Pure Contracts (Java)

Java has a second abstraction mechanism: interfaces. While abstract classes can have both abstract and concrete methods, a traditional interface is a pure contract — it only declares what methods must exist, with no implementation.

// Interface — pure contract, no implementation
public interface Searchable {
    List<String> search(String query);
    int countResults(String query);
}

// A class can implement multiple interfaces
public class ProductCatalog implements Searchable, Serializable {
    @Override
    public List<String> search(String query) {
        // Implementation specific to products
    }

    @Override
    public int countResults(String query) {
        // Implementation specific to products
    }
}

When to use which:

  • Abstract class — When subclasses share common state (fields) or common behavior (concrete methods). Example: all notifications share formatMessage().
  • Interface — When you want to define a capability that unrelated classes can implement. Example: both ProductCatalog and UserDirectory can be Searchable, even though they have nothing else in common.

In Python, abstract classes serve both purposes since Python supports multiple inheritance. We'll explore interfaces more deeply when we study the Interface Segregation Principle in Section 5 (SOLID Principles).

Common Mistakes

Mistake 1: Abstract Class with No Abstract Methods

❌ Wrong: Creating an abstract class that has only concrete methods. If everything is implemented, it should be a regular class — or you're using abstraction without any actual contract.

✅ Right: An abstract class should have at least one abstract method that forces subclasses to provide their own implementation.

Mistake 2: Leaking Implementation Details

❌ Wrong: An abstract method that exposes internal mechanics:

@abstractmethod
def send_via_smtp(self, smtp_server: str) -> bool: ...

This forces all subclasses to use SMTP — SMS and push notifications don't use SMTP!

✅ Right: Keep abstract methods implementation-agnostic:

@abstractmethod
def send(self) -> bool: ...

Mistake 3: Over-Abstracting

❌ Wrong: Creating abstract layers for things that will never have multiple implementations. If you'll only ever have one type of logger, making an AbstractLogger just adds indirection for no benefit.

✅ Right: Abstract when you have (or foresee) multiple implementations. Don't abstract speculatively — follow the rule: "Two is a pattern, one is an accident."

Key Takeaways

  • Abstraction hides implementation complexity behind simple interfaces — callers know what a class does, not how
  • Abstract classes define a contract: abstract methods that subclasses must implement, plus optional concrete methods with shared logic
  • Interfaces (Java) are pure contracts — no implementation, just method signatures. A class can implement multiple interfaces
  • Encapsulation hides data inside a class. Abstraction hides implementation across class hierarchies. They're complementary, not synonymous
  • Don't over-abstract. Only create abstract layers when multiple implementations exist or are clearly expected
  • The notify_user(Notification) pattern — accepting the abstract type instead of concrete types — is the foundation of flexible, extensible design
  • This concept becomes even more powerful with inheritance (next tutorial) and will be central to the SOLID principles in Section 5 and design patterns in Sections 9-12