Python

Object Oriented Programming

Encapsulation & @property

account.balance = -500 # ✗ nothing stops this — balance is now nonsensical!

JrCodex·7 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 4 Time to complete: ~25 minutes


Table of Contents

  1. What is Encapsulation?
  2. Public, Protected & Private — Python's Conventions
  3. Name Mangling
  4. Why Hide Data At All?
  5. The @property Decorator
  6. Adding a Setter
  7. Read-Only Properties
  8. Summary & Next Steps

1. What is Encapsulation?

Encapsulation means bundling data and the methods that operate on it together, while restricting direct, uncontrolled access to that data from outside the class. The goal: an object protects its own internal consistency instead of trusting outside code to use it correctly.

class BankAccount:
    def __init__(self, balance):
        self.balance = balance
 
account = BankAccount(100)
account.balance = -500      # ✗ nothing stops this — balance is now nonsensical!

Encapsulation is about closing that gap — making sure a BankAccount's balance can never be set to something invalid, no matter what calling code tries to do.


2. Public, Protected & Private — Python's Conventions

Unlike Java or C++, Python has no true access modifiers (no private keyword that the interpreter enforces) — instead, it uses naming conventions that everyone agrees to respect.

class Example:
    def __init__(self):
        self.public_attr = "anyone can access this"
        self._protected_attr = "convention: internal use, but still accessible"
        self.__private_attr = "name-mangled, harder to access accidentally"
 
obj = Example()
print(obj.public_attr)          # ✓ fully intended for outside use
print(obj._protected_attr)        # ⚠️ works, but you're breaking convention by touching it
print(obj.__private_attr)          # AttributeError! (see Section 3 for why)
ConventionSyntaxMeaning
Publicself.nameFreely usable from outside the class
Protectedself._name"Internal use" — a signal to other developers, not enforced
Privateself.__nameName-mangled (Section 3) — strongly discourages outside access

Key mindset shift from other languages: Python trusts developers ("we're all consenting adults here") rather than locking things down at the language level. A leading underscore is a request for privacy, not a wall.


3. Name Mangling

A double-underscore prefix (__name, no trailing double-underscore) triggers name mangling — Python internally renames the attribute to _ClassName__name, making accidental access (and accidental collisions in subclasses) much less likely.

class Account:
    def __init__(self, balance):
        self.__balance = balance     # becomes _Account__balance internally
 
    def get_balance(self):
        return self.__balance          # works fine INSIDE the class
 
account = Account(100)
print(account.get_balance())        # 100 — accessing through a proper method works
print(account.__balance)               # AttributeError: 'Account' object has no attribute '__balance'
print(account._Account__balance)         # 100 — the REAL mangled name, still technically reachable
Name Mangling Mechanics
─────────────────────────────────────────
  self.__balance  (written inside class Account)
        ↓ Python automatically rewrites this to:
  self._Account__balance
─────────────────────────────────────────

Name mangling exists mainly to prevent accidental name clashes in inheritance (if a subclass happens to define its own __balance), not to make data truly unreadable — as shown above, it's still reachable if someone really wants to.


4. Why Hide Data At All?

The real value of encapsulation isn't secrecy — it's controlling how data changes, so invalid states become impossible:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance
 
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.__balance += amount
 
    def withdraw(self, amount):
        if amount > self.__balance:
            raise ValueError("Insufficient funds")
        self.__balance -= amount
 
    def get_balance(self):
        return self.__balance
 
account = BankAccount("Alice", 100)
account.deposit(50)                    # ✓ goes through validation
print(account.get_balance())              # 150
 
account.__balance = -99999               # this does NOT touch the real balance —
                                            # it creates a totally new public attribute!
print(account.get_balance())                # 150 — unaffected, the real data stayed protected

This "getter method" style (get_balance()) works, but it's verbose compared to what most Python code actually uses — Section 5 shows the more idiomatic approach.


5. The @property Decorator

@property lets you define a method that's accessed like a plain attribute (no parentheses), while still running real logic behind the scenes — giving you validation and control without an ugly get_x()/set_x() API.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance
 
    @property
    def balance(self):
        """This method runs whenever someone accesses .balance"""
        return self.__balance
 
account = BankAccount("Alice", 100)
print(account.balance)          # 100 — looks like a plain attribute, no ()!
                                    # but it's actually calling the balance() method behind the scenes
Without @property                    With @property
─────────────────────────────       ─────────────────────────────
  account.get_balance()                account.balance
     ↑ looks like a method call          ↑ looks like a plain attribute
     (works, but not idiomatic)          (idiomatic — same safety, cleaner API)
─────────────────────────────       ─────────────────────────────

6. Adding a Setter

A @property alone is read-only. Add a matching @balance.setter to allow controlled assignment:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance
 
    @property
    def balance(self):
        return self.__balance
 
    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self.__balance = value
 
account = BankAccount("Alice", 100)
print(account.balance)      # 100 — calls the getter
 
account.balance = 200         # calls the SETTER — looks like plain assignment!
print(account.balance)          # 200
 
account.balance = -50            # ValueError: Balance cannot be negative — validation still runs!

This is the real payoff: account.balance = 200 looks exactly like setting a plain public attribute, but it's secretly running validation logic every time — the calling code doesn't need to know or care.

# A more complete, realistic example
class Temperature:
    def __init__(self, celsius=0):
        self.celsius = celsius        # goes through the setter immediately, even in __init__
 
    @property
    def celsius(self):
        return self.__celsius
 
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is impossible")
        self.__celsius = value
 
    @property
    def fahrenheit(self):
        """A COMPUTED property — no matching setter, purely derived from celsius."""
        return self.__celsius * 9/5 + 32
 
temp = Temperature(25)
print(temp.celsius)          # 25
print(temp.fahrenheit)         # 77.0 — computed on the fly, always in sync with celsius
 
temp.celsius = 30
print(temp.fahrenheit)          # 86.0 — automatically updated, no separate field to keep in sync

7. Read-Only Properties

Simply omit the setter — any attempted assignment raises an AttributeError automatically:

class Circle:
    def __init__(self, radius):
        self.radius = radius
 
    @property
    def area(self):
        return 3.14159 * self.radius ** 2      # always derived, never stored directly
 
circle = Circle(5)
print(circle.area)      # 78.53975
 
circle.area = 100          # AttributeError: can't set attribute 'area'
                              # (no setter defined — Python enforces read-only automatically)

This is the idiomatic way to expose a computed value (like area, or fahrenheit above) that should never be set directly, only derived from other data.


8. Summary & Next Steps

Key Takeaways

  • Python has no enforced private keyword — it uses naming conventions: _protected (soft signal) and __private (name-mangled, harder to reach accidentally).
  • Encapsulation's real purpose is controlling how data changes, so an object can guarantee it never enters an invalid state.
  • @property turns a method into something accessed like a plain attribute (obj.value, no parentheses) — the idiomatic replacement for manual get_x() methods.
  • Add @x.setter to allow validated assignment through the same attribute-like syntax; omit it entirely to make a property read-only.

Concept Check

  1. What does Python actually do to self.__balance internally (name mangling), and why?
  2. Why is @property generally preferred over writing a get_balance() method?
  3. How do you make a property computed and read-only, like Circle.area?

Next Chapter

Chapter 6: Abstraction & Abstract Base Classes


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index