Encapsulation in Python
What is Encapsulation?
Encapsulation means:
(a) BUNDLING data (variables) and the methods that work on that data
inside one unit (the class), and
(b) HIDING the internal data so outside code cannot change it
directly. Outside code must go through methods we control.
Real-Life Analogy
An ATM machine. You cannot open the machine and take cash from the box yourself. You must use the screen and keypad (the "public methods"), which check your PIN and your balance first.
ACCESS LEVELS IN PYTHON (by naming convention)
----------------------------------------------
name -> PUBLIC : use it from anywhere
_name -> PROTECTED : "internal, please don't touch" (convention only)
__name -> PRIVATE : Python renames it (name mangling) so it is
hard to reach from outside the class
NOTE: Python has no true "private" keyword like Java. It trusts the programmer ("we are all consenting adults"). The underscores are signals plus a little name-mangling protection.
The Problem - No Encapsulation
Everything is public. Any code can set the balance to anything, even a negative number. There is no protection at all.
class UnsafeAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
acc = UnsafeAccount("Ravi", 5000)
acc.balance = -999999 # nobody stops this!
print(" Unsafe balance:", acc.balance, " <- invalid data got in")
Public Members
Normal names. Fine for data that is SAFE to read and change freely. Industry example: a blog post's title can be edited by anyone.
class BlogPost:
def __init__(self, title, author):
self.title = title # public
self.author = author # public
def publish(self): # public method
print(f" Published '{self.title}' by {self.author}")
post = BlogPost("Learning OOPS", "Sudheer")
post.title = "Learning Python OOPS" # allowed and fine
post.publish()
Protected Members (_single underscore)
One underscore means: "This is internal. Child classes may use it, but outside code should not." Python does NOT block access; it is a convention that every Python developer respects. Industry example: an internal retry counter in an API client.
class ApiClient:
def __init__(self, base_url):
self.base_url = base_url
self._retry_count = 3 # protected - internal setting
def fetch(self, endpoint):
print(f" GET {self.base_url}{endpoint} (max retries: {self._retry_count})")
class PaymentApiClient(ApiClient):
def __init__(self, base_url):
super().__init__(base_url)
self._retry_count = 5 # OK: a child class may use protected data
PaymentApiClient("https://api.shop.com").fetch("/payments")
client = ApiClient("https://api.shop.com")
print(" Outside access still works (but is bad practice):", client._retry_count)
Private Members (__double underscore) + Name Mangling
Two leading underscores make Python RENAME the attribute internally:
__balance becomes _BankAccount__balance
So obj.__balance from outside raises AttributeError. Industry example: a bank balance must only change via deposit/withdraw.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self.__balance = balance # private
def deposit(self, amount):
if amount <= 0:
print(" Deposit rejected: amount must be positive")
return
self.__balance += amount
print(f" Deposited Rs.{amount}. New balance: Rs.{self.__balance}")
def withdraw(self, amount):
if amount > self.__balance:
print(f" Withdraw rejected: insufficient funds (balance Rs.{self.__balance})")
return
self.__balance -= amount
print(f" Withdrew Rs.{amount}. New balance: Rs.{self.__balance}")
account = BankAccount("Priya", 10000)
account.deposit(2000)
account.deposit(-50)
account.withdraw(50000)
account.withdraw(3000)
try:
print(account.__balance) # cannot read it directly
except AttributeError as err:
print(" ERROR:", err)
# Teaching point: name mangling is protection, not a lock.
# The mangled name still exists, which shows how Python works internally.
print(" Mangled name exists:", account._BankAccount__balance, "(never do this in real code)")
# Another trap: assigning obj.__balance from OUTSIDE creates a NEW
# public attribute. It does NOT change the real private balance.
account.__balance = 1
print(" After 'account.__balance = 1' real balance is still:",
account._BankAccount__balance)
Getter and Setter Methods (Traditional Style)
A getter returns private data; a setter changes it AFTER validating. This style is common in Java, and you will see it in older Python code. Industry example: an employee's salary in a payroll system.
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = 0
self.set_salary(salary) # reuse the validation logic
def get_salary(self):
return self.__salary
def set_salary(self, value):
if value < 0:
raise ValueError("Salary cannot be negative")
self.__salary = value
emp = Employee("Anil", 60000)
print(" Salary:", emp.get_salary())
emp.set_salary(65000)
print(" After raise:", emp.get_salary())
try:
emp.set_salary(-100)
except ValueError as err:
print(" ERROR:", err)
@property - the Pythonic Way
@property lets us keep the simple syntax obj.salary and obj.salary = 5000 , while Python secretly calls our getter/setter. The user of the class does not even know validation is happening. Industry example: product stock in an inventory system.
class InventoryItem:
def __init__(self, name, stock):
self.name = name
self.stock = stock # this line already calls the setter below
@property
def stock(self): # GETTER -> obj.stock
return self.__stock
@stock.setter
def stock(self, value): # SETTER -> obj.stock = value
if not isinstance(value, int):
raise TypeError("Stock must be a whole number")
if value < 0:
raise ValueError("Stock cannot be negative")
self.__stock = value
item = InventoryItem("USB Cable", 50)
print(" Stock:", item.stock) # looks like a normal attribute
item.stock = 45 # goes through validation
print(" Updated stock:", item.stock)
for bad_value in (-5, 2.5):
try:
item.stock = bad_value
except (ValueError, TypeError) as err:
print(f" ERROR setting {bad_value!r}:", err)
Read-Only (Computed) Properties
A property WITHOUT a setter is read-only. It is often calculated from other data, so it can never be out of date. Industry example: an e-commerce order total, calculated from its lines.
class Order:
GST_RATE = 0.18
def __init__(self, order_id):
self.order_id = order_id
self.__lines = [] # private list of (name, price, qty)
def add_item(self, name, price, qty=1):
self.__lines.append((name, price, qty))
@property
def subtotal(self):
return sum(price * qty for _, price, qty in self.__lines)
@property
def tax(self):
return round(self.subtotal * Order.GST_RATE, 2)
@property
def total(self):
return self.subtotal + self.tax
order = Order("ORD-501")
order.add_item("Headphones", 1500)
order.add_item("Phone Case", 300, 2)
print(f" Subtotal: {order.subtotal} | GST: {order.tax} | Total: {order.total}")
try:
order.total = 10 # no setter -> cannot be changed
except AttributeError as err:
print(" ERROR:", err)
Property Deleter
@x.deleter runs when someone writes del obj.x Industry example: logging out clears a user's auth token safely.
class UserLogin:
def __init__(self, username, token):
self.username = username
self.__token = token
@property
def token(self):
if self.__token is None:
return "<logged out>"
return self.__token[:4] + "****" # never expose the full token
@token.deleter
def token(self):
print(f" Logging out {self.username}: token cleared")
self.__token = None
login = UserLogin("sudheer", "abcd1234secret")
print(" Token:", login.token)
del login.token
print(" Token:", login.token)
Private Helper Methods
Methods can be private too. These are internal steps that outside code should never call on their own. Industry example: sending an OTP. Generating and storing the OTP are internal steps; the public method is just send_otp().
import random
class OtpService:
def __init__(self):
self.__sent_otps = {}
def __generate_otp(self): # private method
return random.randint(100000, 999999)
def __mask_phone(self, phone): # private method
return "******" + phone[-4:]
def send_otp(self, phone): # public method (the only entry point)
otp = self.__generate_otp()
self.__sent_otps[phone] = otp
print(f" OTP sent to {self.__mask_phone(phone)}")
def verify(self, phone, otp):
return self.__sent_otps.get(phone) == otp
service = OtpService()
service.send_otp("9876543210")
print(" Verify wrong OTP:", service.verify("9876543210", 111111))
try:
service.__generate_otp()
except AttributeError:
print(" ERROR: __generate_otp() is private and cannot be called from outside")
Industry Example - Payment Card
This puts it all together:
* card number is private and only shown MASKED
* PIN is private and can never be read, only checked
* card is blocked after 3 wrong PIN attempts
* balance is read-only from outside and changes only via pay()
class PaymentCard:
MAX_ATTEMPTS = 3
def __init__(self, holder, card_number, pin, balance):
self.holder = holder # public
self.__card_number = card_number # private
self.__pin = pin # private
self.__balance = balance # private
self._failed_attempts = 0 # protected
self._blocked = False # protected
@property
def card_number(self):
return "XXXX-XXXX-XXXX-" + self.__card_number[-4:]
@property
def balance(self):
return self.__balance
def __check_pin(self, pin):
if self._blocked:
print(" Card is BLOCKED. Contact the bank.")
return False
if pin == self.__pin:
self._failed_attempts = 0
return True
self._failed_attempts += 1
left = PaymentCard.MAX_ATTEMPTS - self._failed_attempts
if left == 0:
self._blocked = True
print(" Wrong PIN. Card is now BLOCKED.")
else:
print(f" Wrong PIN. {left} attempt(s) left.")
return False
def pay(self, amount, pin):
if not self.__check_pin(pin):
return
if amount > self.__balance:
print(" Payment declined: insufficient balance")
return
self.__balance -= amount
print(f" Paid Rs.{amount} with {self.card_number}. Balance: Rs.{self.__balance}")
card = PaymentCard("Lakshmi", "4111222233334444", "4321", 20000)
print(" Card:", card.card_number)
card.pay(2500, "4321") # correct PIN
card.pay(100, "0000") # wrong 1
card.pay(100, "1111") # wrong 2
card.pay(100, "2222") # wrong 3 -> blocked
card.pay(100, "4321") # even the correct PIN fails now
print(" Balance (read-only):", card.balance)
Quick Revision
- Encapsulation = data + methods in one class + controlled access.
- name -> public, _name -> protected (convention), __name -> private (name mangled to _ClassName__name).
- Use
@propertyfor validation while keeping simple obj.attr syntax. - A property without a setter is read-only.
- Expose only what is needed. Hide the rest (PIN, tokens, balance logic).
Practice Questions
- Create a Student class with a private __marks and a property that
- Add a read-only property "grade" calculated from marks.
- Create a Password class that stores a password privately and has