CS Pathfinder Logo CS Pathfinder

Unit 5 · Advanced Topics

Encapsulation

Learn about encapsulation in Python — data hiding, access modifiers (public, protected, private), name mangling, getters and setters, the @property decorator, and real-world applications.

Table of Contents

What is Encapsulation?

University Definition

Encapsulation is one of the four pillars of OOP that bundles data (attributes) and methods that operate on that data into a single unit (class) and restricts direct access to some of the object's components. This protects the internal state of the object and prevents external code from putting it into an invalid or inconsistent state.

Encapsulation in Real Life:
============================

Car Example:
  - You use steering wheel (public interface)
  - You don't access engine internals directly
  - Engine is "encapsulated" inside the car

ATM Example:
  - You insert card and enter PIN (interface)
  - You don't access bank database directly
  - Internal workings are hidden

Why encapsulate?
  1. Data protection (prevent invalid states)
  2. Flexibility (change internal implementation)
  3. Debugging (errors traced to specific methods)
  4. Security (hide sensitive data)

Data Hiding

University Definition

Data hiding is the practice of restricting direct access to an object's internal data and requiring all interactions to go through well-defined methods (getters and setters). This ensures that data is accessed and modified only in controlled ways.

# WITHOUT encapsulation - data is exposed
class AccountBad:
    def __init__(self, balance):
        self.balance = balance  # Anyone can modify!

acc = AccountBad(1000)
acc.balance = -5000  # Invalid! No protection
print(acc.balance)   # -5000 (dangerous!)

# WITH encapsulation - data is protected
class AccountGood:
    def __init__(self, balance):
        self.__balance = balance  # Private!

    def get_balance(self):
        return self.__balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
        else:
            print("Invalid deposit amount!")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
        else:
            print("Invalid withdrawal!")

acc2 = AccountGood(1000)
acc2.deposit(500)
print(acc2.get_balance())  # 1500
acc2.withdraw(300)
print(acc2.get_balance())  # 1200
# acc2.__balance  # AttributeError - hidden!

Access Modifiers

class Employee:
    def __init__(self, name, salary, ssn):
        self.name = name          # PUBLIC - accessible anywhere
        self._department = "IT"   # PROTECTED - convention only
        self.__ssn = ssn          # PRIVATE - name mangled

    def display(self):
        print(f"Name: {self.name}")
        print(f"Department: {self._department}")
        print(f"SSN: {self.__ssn}")

emp = Employee("Rahul", 50000, "123-45-6789")

# PUBLIC - accessible everywhere
print(emp.name)            # Rahul
emp.name = "Priya"
print(emp.name)            # Priya

# PROTECTED - accessible but convention says don't
print(emp._department)     # IT (works but shouldn't access)
emp._department = "HR"     # Works but bad practice

# PRIVATE - not directly accessible
# print(emp.__ssn)         # AttributeError!
# emp.__ssn = "000"        # AttributeError!

# Accessing private via name mangling
print(emp._Employee__ssn)  # 123-45-6789 (works but bad!)
emp._Employee__ssn = "000" # Works but bad practice!
Modifier Syntax Inside Class Outside Class In Child Class
Publicself.nameYesYesYes
Protectedself._nameYesYes (by convention, don't)Yes
Privateself.__nameYesNo (name mangled)No (name mangled)

Name Mangling

University Definition

Name mangling is a Python mechanism where identifiers prefixed with double underscores (__name) are internally transformed to _ClassName__name. This prevents accidental access from outside the class and avoids name conflicts in inheritance.

class MyClass:
    def __init__(self):
        self.public_var = "public"
        self._protected_var = "protected"
        self.__private_var = "private"

obj = MyClass()

# Show name mangling
print(obj.public_var)          # public
print(obj._protected_var)      # protected
# print(obj.__private_var)     # AttributeError!

# Python transforms __private_var to _MyClass__private_var
print(obj._MyClass__private_var)  # private (name mangled)

# Check what attributes exist
print(dir(obj))  # Shows _MyClass__private_var

# Name mangling in inheritance
class Parent:
    def __init__(self):
        self.__secret = "parent secret"

class Child(Parent):
    def get_secret(self):
        # return self.__secret  # AttributeError!
        return self._Parent__secret  # Access via mangled name

child = Child()
print(child.get_secret())  # parent secret

# No conflict - child can have its own __secret
class Child2(Parent):
    def __init__(self):
        super().__init__()
        self.__secret = "child secret"  # Different from parent's!

c = Child2()
print(c._Child2__secret)    # child secret
print(c._Parent__secret)    # parent secret

Getters and Setters

class Student:
    def __init__(self, name, marks):
        self.__name = name
        self.__marks = marks

    # Getter method
    def get_name(self):
        return self.__name

    def get_marks(self):
        return self.__marks

    # Setter method with validation
    def set_name(self, name):
        if isinstance(name, str) and len(name) > 0:
            self.__name = name
        else:
            print("Invalid name!")

    def set_marks(self, marks):
        if 0 <= marks <= 100:
            self.__marks = marks
        else:
            print("Marks must be between 0 and 100!")

    def get_grade(self):
        if self.__marks >= 90: return "A+"
        elif self.__marks >= 80: return "A"
        elif self.__marks >= 70: return "B"
        elif self.__marks >= 60: return "C"
        else: return "F"

# Using getters and setters
s = Student("Rahul", 85)
print(f"Name: {s.get_name()}")       # Name: Rahul
print(f"Marks: {s.get_marks()}")     # Marks: 85
print(f"Grade: {s.get_grade()}")     # Grade: A

s.set_marks(92)
print(f"Updated marks: {s.get_marks()}")  # 92

s.set_marks(150)  # Marks must be between 0 and 100!
s.set_name("")    # Invalid name!
print(f"Name: {s.get_name()}")       # Still Rahul

@property Decorator

University Definition

The @property decorator allows you to define methods that can be accessed like attributes, providing a Pythonic way to implement getters and setters. It maintains the interface while adding validation internally.

class Temperature:
    def __init__(self, celsius=0):
        self.__celsius = celsius

    @property
    def celsius(self):
        """Getter - accessed like an attribute"""
        return self.__celsius

    @celsius.setter
    def celsius(self, value):
        """Setter - called when assigning"""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self.__celsius = value

    @property
    def fahrenheit(self):
        """Computed property (read-only)"""
        return (self.__celsius * 9/5) + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        """Allow setting in Fahrenheit"""
        self.__celsius = (value - 32) * 5/9

# Using property - looks like attribute access!
t = Temperature(25)
print(f"Celsius: {t.celsius}")        # 25
print(f"Fahrenheit: {t.fahrenheit}")   # 77.0

t.celsius = 100                       # Uses setter
print(f"Boiling point: {t.fahrenheit}")  # 212.0

t.fahrenheit = 32                     # Uses fahrenheit setter
print(f"Freezing: {t.celsius}")       # 0.0

# t.celsius = -300                    # ValueError!

class Circle:
    def __init__(self, radius):
        self.radius = radius  # Uses the setter!

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @property
    def circumference(self):
        return 2 * 3.14159 * self._radius

c = Circle(5)
print(f"Radius: {c.radius}")          # 5
print(f"Area: {c.area}")              # 78.53975
print(f"Circumference: {c.circumference}")  # 31.4159
c.radius = 10
print(f"New area: {c.area}")          # 314.159

Output:

Celsius: 25
Fahrenheit: 77.0
Boiling point: 212.0
Freezing: 0.0
Radius: 5
Area: 78.53975
Circumference: 31.4159
New area: 314.159

Real-World Example: Bank Account

class BankAccount:
    def __init__(self, owner, balance=0):
        self.__owner = owner
        self.__balance = balance
        self.__transactions = []

    @property
    def owner(self):
        return self.__owner

    @property
    def balance(self):
        return self.__balance

    def deposit(self, amount):
        if amount <= 0:
            print("Invalid deposit amount!")
            return
        self.__balance += amount
        self.__transactions.append(f"+{amount}")
        print(f"Deposited: {amount}. Balance: {self.__balance}")

    def withdraw(self, amount):
        if amount <= 0:
            print("Invalid withdrawal amount!")
            return
        if amount > self.__balance:
            print("Insufficient funds!")
            return
        self.__balance -= amount
        self.__transactions.append(f"-{amount}")
        print(f"Withdrawn: {amount}. Balance: {self.__balance}")

    def get_statement(self):
        print(f"\n{'='*40}")
        print(f"Account: {self.__owner}")
        print(f"Transactions: {', '.join(self.__transactions)}")
        print(f"Final Balance: {self.__balance}")
        print(f"{'='*40}")

# Usage
acc = BankAccount("Rahul", 10000)
print(f"Owner: {acc.owner}")     # Property access
print(f"Balance: {acc.balance}") # Property access

acc.deposit(5000)     # Deposited: 5000. Balance: 15000
acc.withdraw(3000)    # Withdrawn: 3000. Balance: 12000
acc.withdraw(20000)   # Insufficient funds!
acc.deposit(-100)      # Invalid deposit amount!

acc.get_statement()

# Cannot access private data directly
# acc.__balance = 9999999  # AttributeError!
# acc.__balance            # AttributeError!

Output:

Owner: Rahul
Balance: 10000
Deposited: 5000. Balance: 15000
Withdrawn: 3000. Balance: 12000
Insufficient funds!
Invalid deposit amount!

========================================
Account: Rahul
Transactions: +5000, -3000
Final Balance: 12000
========================================

Common Mistakes to Avoid

  • Using name mangling (_Class__var) to access private attributes — defeats the purpose.
  • Confusing protected (_var) with private (__var) — protected is convention only.
  • Making everything private — over-encapsulation reduces usability.
  • Not using @property when attribute access syntax is preferred.
  • Forgetting that encapsulation in Python is by convention, not enforced by the language.

Practice Questions

  1. Create a Person class with private age attribute and a setter that rejects negative ages.
  2. Implement a Wallet class with encapsulated balance, only allowing deposits and withdrawals through methods.
  3. Use the @property decorator to create a Rectangle class where area and perimeter are computed properties.
  4. Demonstrate name mangling by creating a parent class with a private variable and a child class that tries to access it.

Key Points

Encapsulation bundles data and methods, restricting direct access to internal state.

Public (var) — accessible everywhere; Protected (_var) — convention only; Private (__var) — name mangled.

Name mangling transforms __var to _Class__var to prevent accidental access.

Getters and setters provide controlled access to private attributes with validation.

The @property decorator provides a Pythonic way to implement getters/setters with attribute syntax.

Encapsulation protects data integrity and makes code more maintainable and secure.

Python Programming Handwritten Notes

Master Python Programming with Easy Handwritten Notes – Perfect for Interviews, Placements, GATE & Exams.