Python Lesson 34 – Encapsulation | Dataplexa

Encapsulation

Encapsulation is one of the four pillars of object-oriented programming. At its core, it means bundling data and the methods that operate on that data together inside a class — and controlling how that data is accessed and modified from the outside. Done well, encapsulation protects your objects from invalid state, hides complexity, and gives you the freedom to change internal implementation without breaking anything that uses your class.

Python takes a pragmatic approach to encapsulation — it relies on conventions and tools rather than hard enforcement. This lesson covers everything from naming conventions to the @property decorator to truly private name mangling.

Why Encapsulation Matters

Without encapsulation, any code anywhere can reach into your object and set its data to anything — including nonsensical values that break your logic.

# No encapsulation — object state is unprotected

class BankAccount:
    def __init__(self, owner, balance):
        self.owner   = owner
        self.balance = balance

acc = BankAccount("Alice", 500.00)

# Nothing stops this — even though it makes no sense
acc.balance = -99999
print(acc.balance)   # -99999  ← invalid, but allowed
-99999

Encapsulation solves this by making the object responsible for its own state — external code can only interact through a controlled interface.

1. Public, Protected, and Private — Naming Conventions

Python signals intended visibility through naming conventions. There are no hard access keywords like private or protected — the conventions are respected by developers and enforced partially by the language itself.

  • Publicself.name — accessible from anywhere, no restriction
  • Protectedself._name — single underscore signals "internal use only — handle with care." Accessible from outside but considered off-limits by convention.
  • Privateself.__name — double underscore triggers name mangling. Python renames it to _ClassName__name, making accidental access from outside much harder.
# Public, protected, and private attributes

class Employee:
    def __init__(self, name, salary, ssn):
        self.name    = name        # public   — freely accessible
        self._salary = salary      # protected — internal use intended
        self.__ssn   = ssn         # private   — name-mangled

    def get_info(self):
        return f"{self.name} | Salary: ${self._salary:,.2f}"

    def verify_identity(self, ssn):
        return self.__ssn == ssn   # private used internally

emp = Employee("Alice", 85000, "123-45-6789")

print(emp.name)       # Alice      — fine, public
print(emp._salary)    # 85000      — works, but discouraged
# print(emp.__ssn)    # AttributeError — name mangled

# Still accessible via mangled name if you really need it
print(emp._Employee__ssn)   # 123-45-6789

print(emp.get_info())
print(emp.verify_identity("123-45-6789"))   # True
Alice
85000
123-45-6789
Alice | Salary: $85,000.00
True
  • Single underscore _ is a convention — Python does not restrict access
  • Double underscore __ triggers name mangling: __ssn becomes _Employee__ssn
  • Python's philosophy: "We're all consenting adults here" — trust the convention rather than fight it
  • Use __ only when you genuinely need to prevent subclass name collisions, not just as a habit

2. Getters and Setters

The traditional way to control attribute access is explicit getter and setter methods. Python supports this, though the @property approach covered next is generally preferred.

# Getters and setters — explicit method-based access control

class BankAccount:
    def __init__(self, owner, balance):
        self.owner    = owner
        self._balance = 0.0
        self.set_balance(balance)   # validation runs here

    def get_balance(self):
        return self._balance

    def set_balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError("Balance must be a number.")
        if amount < 0:
            raise ValueError(f"Balance cannot be negative, got {amount}.")
        self._balance = float(amount)

    def deposit(self, amount):
        self.set_balance(self._balance + amount)
        print(f"Deposited ${amount:.2f}. New balance: ${self._balance:.2f}")

acc = BankAccount("Alice", 500.00)
print(acc.get_balance())   # 500.0

acc.deposit(200)
acc.set_balance(750)
print(acc.get_balance())   # 750.0

try:
    acc.set_balance(-100)
except ValueError as e:
    print("Error:", e)
500.0
Deposited $200.00. New balance: $700.00
750.0
Error: Balance cannot be negative, got -100.
  • Getters and setters work perfectly but require callers to use acc.get_balance() instead of acc.balance
  • This is verbose — Python's @property decorator gives you the same control with cleaner syntax

3. The @property Decorator — Pythonic Encapsulation

@property lets you expose an attribute with clean read syntax while secretly running a method behind the scenes. Paired with a setter, it gives you full validation without the caller ever knowing.

Why it exists: you can start with a plain public attribute, then later add validation by converting it to a property — without changing any code that uses it. The public interface stays identical.

Real-world use: a Product class stores price as a plain number but validates it through a property setter — the code calling product.price = value never knows validation is happening.

# @property — clean encapsulation with validation

class BankAccount:
    def __init__(self, owner, balance):
        self.owner   = owner
        self.balance = balance      # calls the setter immediately

    @property
    def balance(self):
        return self._balance        # read from private storage

    @balance.setter
    def balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError("Balance must be a number.")
        if amount < 0:
            raise ValueError(f"Balance cannot be negative, got {amount}.")
        self._balance = float(amount)

    def deposit(self, amount):
        self.balance += amount      # setter validates automatically
        print(f"Balance: ${self.balance:.2f}")

acc = BankAccount("Alice", 500.00)
print(acc.balance)   # 500.0  — clean attribute access

acc.deposit(250)     # Balance: $750.00

try:
    acc.balance = -50
except ValueError as e:
    print("Error:", e)
500.0
Balance: $750.00
Error: Balance cannot be negative, got -50.
  • The caller uses acc.balance — exactly like a plain attribute — no getter/setter calls needed
  • Setting self.balance = balance in __init__ goes through the setter — validation runs even at construction
  • The private _balance is the real storage; balance is the controlled public interface
  • Read-only properties: define only the getter — any assignment raises AttributeError

4. Computed Properties

A property does not have to read from stored data. It can compute a value on the fly from other attributes — exposing something dynamic as a simple attribute.

# Computed properties — derived values exposed as attributes

class Order:
    def __init__(self, items):
        self.items = items   # list of (name, price, qty) tuples

    @property
    def subtotal(self):
        return sum(price * qty for _, price, qty in self.items)

    @property
    def tax(self):
        return round(self.subtotal * 0.08, 2)   # 8% tax

    @property
    def total(self):
        return round(self.subtotal + self.tax, 2)

order = Order([
    ("notebook",  4.99, 3),
    ("pen",       1.50, 10),
    ("desk",     89.99, 1),
])

print(f"Subtotal: ${order.subtotal:.2f}")
print(f"Tax:      ${order.tax:.2f}")
print(f"Total:    ${order.total:.2f}")
Subtotal: $119.96
Tax: $9.60
Total: $129.56
  • Computed properties stay in sync automatically — update items and every derived property reflects the change
  • No setter needed — these are naturally read-only
  • Cleaner than storing subtotal/tax/total separately and keeping them manually synchronised

5. A Complete Encapsulation Example

Putting it all together — a class that uses public, protected, and private attributes alongside properties to fully control its state.

# Complete encapsulation example — User class

class User:
    _user_count = 0   # protected class attribute

    def __init__(self, username, email, age):
        self.username = username   # goes through setter
        self.email    = email
        self.age      = age
        User._user_count += 1

    @property
    def username(self):
        return self._username

    @username.setter
    def username(self, value):
        if not value or not isinstance(value, str):
            raise ValueError("Username must be a non-empty string.")
        self._username = value.strip().lower()

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        if "@" not in value or "." not in value:
            raise ValueError(f"Invalid email: {value}")
        self._email = value.lower()

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or not (0 < value < 130):
            raise ValueError("Age must be an integer between 1 and 129.")
        self._age = value

    @classmethod
    def user_count(cls):
        return cls._user_count

    def __repr__(self):
        return f"User({self.username!r}, {self.email!r}, age={self.age})"

u1 = User("  Alice  ", "Alice@Example.COM", 30)
u2 = User("Bob", "bob@test.org", 25)

print(u1)
print(u2)
print("Total users:", User.user_count())

try:
    u1.age = 200
except ValueError as e:
    print("Error:", e)
User('alice', 'alice@example.com', age=30)
User('bob', 'bob@test.org', age=25)
Total users: 2
Error: Age must be an integer between 1 and 129.

Summary Table

Tool / Convention Syntax Purpose
Public attribute self.name Freely accessible from anywhere
Protected attribute self._name Internal use — accessible but discouraged externally
Private attribute self.__name Name-mangled — prevents accidental external access
Getter / Setter get_x() / set_x() Explicit controlled access — verbose but clear
@property @property def name(self): Read access with optional computation
@name.setter @name.setter def name(self, val): Write access with validation

Practice Questions

Practice 1. What does a single underscore prefix on an attribute signal in Python?



Practice 2. What does Python do internally when you define self.__ssn inside a class called Employee?



Practice 3. Why is @property preferred over explicit getter and setter methods in Python?



Practice 4. If you define a property getter but no setter, what happens when code tries to assign to it?



Practice 5. Why does a property setter use self._balance for storage instead of self.balance?



Quiz

Quiz 1. What is the main purpose of encapsulation?






Quiz 2. What is the mangled name of self.__balance defined inside a class called BankAccount?






Quiz 3. Which of the following correctly defines a read-only property called area?






Quiz 4. Why is it safe to refactor a plain public attribute into a @property without breaking existing callers?






Quiz 5. A computed property like total derived from subtotal and tax — does it need a setter?






Next up — Abstraction: hiding complexity behind clean interfaces using abstract classes and the abc module.