Encapsulation
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
One line can break a perfectly good object: card.credit = -50. Encapsulation is the OOP habit of keeping an object's state behind a small, clear interface, which means the few attributes and methods that outside code is meant to use.
Why direct access can hurt
In Python, attributes are easy to reach through the dot. That is useful. It also means outside code can put an object into a state that makes no sense:
class LunchCard:
def __init__(self, owner, credit):
self.owner = owner
self.credit = credit
card = LunchCard("Mina", 30)
card.credit = -50
print(f"{card.owner}: {card.credit} credits")Mina: -50 credits
The class never got a chance to say, "No, lunch credit can't be negative." That check is validation, which means checking that a value is acceptable before storing it.
Think of encapsulation like a shop counter. Customers can ask the cashier to add credit or spend credit, but they don't reach straight into the cash drawer. The drawer is the object's inner state. The counter is the object's public interface.
A public attribute is a normal attribute meant for outside code to use. owner is a good public attribute if reading the owner's name is harmless. But changing credit has rules, so credit needs a controlled path.
Naming signals: public, _, and __
Python does not lock object attributes by default. Instead, Python programmers use naming conventions to show intent:
- Single-underscore attributes are names like
_creditthat mean "this is for this class's own code"; many developers call this protected, meaning outside code should leave it alone, but Python treats it as a convention. - Double-underscore attributes are names like
__card_codethat trigger name mangling, Python's rewrite of the outside name to include the class name; they are sometimes called private, meaning intended to be hidden from outside code, but they are not secret.
class LunchCard:
def __init__(self, owner, credit, card_code):
self.owner = owner
self._credit = credit
self.__card_code = card_code
def card_code_for_demo(self):
return self.__card_code
card = LunchCard("Mina", 30, "LC-42")
print(card.owner)
print(card._credit)
print(card.card_code_for_demo())
print(card._LunchCard__card_code)Mina30LC-42LC-42
owner is public. _credit is a strong signal: outside code should not touch this directly. __card_code is rewritten by Python, so the outside spelling becomes _LunchCard__card_code.
Getters and setters
A getter is a method whose job is to return a controlled value. A setter is a method whose job is to change a value after checking it. They let you keep the real attribute non-public while offering public methods that enforce rules.
class LunchCard:
def __init__(self, owner, credit):
self.owner = owner
self._credit = 0
self.set_credit(credit)
def get_credit(self):
return self._credit
def set_credit(self, amount):
if amount < 0:
raise ValueError("credit cannot be negative")
self._credit = amount
card = LunchCard("Mina", 30)
print(card.get_credit())
card.set_credit(45)
print(card.get_credit())
try:
card.set_credit(-10)
except ValueError as error:
print(f"Rejected: {error}")3045Rejected: credit cannot be negative
Now the class owns the rule. __init__ uses set_credit(credit) too, so the starting value and later values go through the same validation.
This pattern works. It is also a little noisy. Python gives you a smoother tool: properties.
Properties: methods that feel like attributes
A property is a method you access like an attribute. It keeps the clean outside style, card.credit, while letting the class run method code behind the scenes.
A decorator is syntax that adjusts a function; decorators get their full story in lesson 12.2. For now, read @property as "make this method readable like an attribute," and read @credit.setter as "run this method when someone assigns to that property."
class LunchCard:
def __init__(self, owner, credit):
self.owner = owner
self._credit = 0
self.credit = credit
@property
def credit(self):
return self._credit
@credit.setter
def credit(self, amount):
if amount < 0:
raise ValueError("credit cannot be negative")
self._credit = amount
card = LunchCard("Mina", 30)
print(card.credit)
card.credit = 45
print(card.credit)
try:
card.credit = -10
except ValueError as error:
print(f"Rejected: {error}")3045Rejected: credit cannot be negative
Outside the class, credit feels like a normal attribute. Inside the class, it is guarded by methods. That is the sweet spot: friendly code on the outside, rules enforced on the inside.
Try it - guard the lunch card
Run this, then change the starting credit and the spending amounts. The spend method and the credit setter both protect the same _credit value.
Computed read-only properties
A computed property is a property that calculates its value from other state instead of storing a separate attribute. A read-only property is a property with no setter, so outside code reads it but does not assign to it.
class LunchCard:
def __init__(self, owner, credit):
self.owner = owner
self._credit = 0
self.credit = credit
@property
def credit(self):
return self._credit
@credit.setter
def credit(self, amount):
if amount < 0:
raise ValueError("credit cannot be negative")
self._credit = amount
@property
def status(self):
if self.credit == 0:
return "empty"
if self.credit < 20:
return "low"
return "ready"
card = LunchCard("Noor", 12)
print(card.status)
card.credit = 35
print(card.status)lowready
There is no self.status = ... line. status is calculated each time you ask for it. That keeps duplicate state out of the object: the card stores credit, and status follows from credit.
Checkpoint
Answer all three to mark this lesson complete
You now know how to make objects that carry state and protect their own rules. Next, the course widens the model: classes start relating to other classes, and object-oriented design gets its first real power move.