Skip to content

Encapsulation

Encapsulation controls how an object's state can be accessed and modified, ensuring that all mutations go through well-defined interfaces. The goal is not merely to hide data, but to protect invariants --- making invalid states impossible. Encapsulation works alongside abstraction (defining stable interfaces), inheritance (sharing structure), and polymorphism (enabling flexibility).

Mental Model

Encapsulation is not about secrecy -- it is about control. A bank account does not hide its balance to be mysterious; it forces all changes to go through deposit() and withdraw() so it can enforce "balance must not go negative." The underscore prefix in Python is a social contract, not a padlock -- it signals "use the public interface, not this internal detail."


What is Encapsulation

Encapsulation is the mechanism of restricting access to certain details and controlling access to an object's data.

1. Bundling Data

python class Person: def __init__(self, name, age): self.__name = name # private self.__age = age # private

Data and methods are bundled into a single unit.

2. Controlled Access

```python def get_name(self): return self.__name

def set_age(self, age): if 0 < age < 120: self.__age = age ```

Access is controlled through public methods.

3. Information Hiding

Internal implementation details are hidden from the outside.


Access Levels

1. Public Attributes

python class Hello: def __init__(self): self.a = 1 # public

Accessible from anywhere.

2. Weak Private (_)

python class Hello: def __init__(self): self._b = 2 # weak private

Convention: internal use only, but still accessible.

3. Private (__)

python class Hello: def __init__(self): self.__c = 3 # private

Name mangling prevents direct external access.


Name Mangling (__)

1. Not Truly Private

Python has no true access control like Java or C++. Double-underscore attributes are name-mangled, not locked. self.__name becomes self._ClassName__name internally, which discourages accidental access but does not prevent it.

```python class Person: def init(self, name): self.__name = name

person = Person("John")

person.__name # AttributeError (mangled name)

print(person._Person__name) # "John" — still accessible ```

Practical Rule

In modern Python, prefer single underscore _attr as the default for internal attributes. It signals "internal use" clearly and avoids the complications of name mangling. Reserve double underscore __attr for the narrow case where you need to prevent name clashes in inheritance — for example, when a parent and child both need an attribute with the same name but independent storage. Most classes never need __.

2. Use Getter Methods

```python class Person: def init(self, name): self.__name = name

def get_name(self):
    return self.__name

person = Person("John") print(person.get_name()) # Works ```

3. Use Setter Methods

python def set_age(self, age): if 0 < age < 120: self.__age = age else: raise ValueError("Invalid age")

Setters enable validation logic.


Private Methods

Name mangling applies to methods exactly the same way as attributes --- __method becomes _ClassName__method.

1. Internal Logic

python class Hello: def __print_c(self): # name-mangled method print(self.__c)

Intended for internal use only, but not truly hidden.

2. Mangled, Not Inaccessible

python m = Hello() m.__print_c() # AttributeError (mangled name) m._Hello__print_c() # Works — same mangling as attributes

3. Expose via Public

```python def print_c(self): # public method self.__print_c()

m.print_c() # Works ```


Getters and Setters

Java-style get_x() / set_x() methods work in Python but are not idiomatic. Prefer @property (see the next section) for new code. The pattern below is shown for completeness and for reading legacy codebases.

1. Basic Pattern

```python class Person: def init(self, age): self.__age = age

def get_age(self):
    return self.__age

def set_age(self, age):
    self.__age = age

```

2. With Validation

python def set_age(self, age): if 0 < age < 120: self.__age = age else: raise ValueError("Invalid age")

3. Read-Only Attributes

```python def get_name(self): return self.__name

No setter - read-only

```


Idiomatic Python: @property

Java-style get_x() / set_x() methods work but are not idiomatic in Python. The Pythonic approach is to start with direct attribute access and add @property only when validation or computation is needed --- the external API stays the same either way.

1. Clean Syntax

```python class Temperature: def init(self, celsius): self._celsius = celsius

@property
def celsius(self):
    return self._celsius

@celsius.setter
def celsius(self, value):
    if value < -273.15:
        raise ValueError("Below absolute zero")
    self._celsius = value

```

2. Transparent to Callers

python t = Temperature(25) t.celsius = 100 # looks like direct access, but runs validation print(t.celsius) # 100

3. When to Use

  • Start with public attributes (self.x)
  • Add @property later if you need validation, computation, or read-only access
  • No need to pre-emptively wrap everything in getters/setters

Why Encapsulation

1. Data Protection

Prevents accidental modification of internal state.

2. Controlled Interface

Changes to internal implementation don't break external code.

3. Validation Logic

Setters can enforce business rules and constraints.


Inheritance and Name Mangling

1. Mangled Names Do Not Inherit Transparently

Name mangling is per-class: self.__width in Polygon becomes self._Polygon__width, which a subclass cannot access as self.__width (that would look for self._Rectangle__width).

```python class Polygon: def set_values(self, width, height): self.__width = width self.__height = height

class Rectangle(Polygon): def compute_area(self): return self.__width * self.__height # AttributeError! ```

For attributes that subclasses need, use single-underscore convention (self._width) instead.

2. Use Public or Protected Methods

```python class Polygon: def set_values(self, width, height): self.__width = width self.__height = height

def get_width(self):
    return self.__width

def get_height(self):
    return self.__height

class Rectangle(Polygon): def compute_area(self): return self.get_width() * self.get_height() ```


Key Takeaways

  • Encapsulation protects invariants and controls how state changes.
  • _ (single underscore) is the preferred convention for internal attributes.
  • __ (double underscore) triggers name mangling --- discourages access but does not enforce true privacy.
  • Use @property for idiomatic validation instead of Java-style getters/setters.
  • Python relies on convention and design, not language-enforced access control.

Runnable Example: encapsulation_examples.py

```python """ Example 01: Basic Encapsulation

Encapsulation is the concept of bundling data and methods that work on that data within a class, while controlling access to prevent misuse. """

BAD EXAMPLE - No Encapsulation

=============================================================================

Definitions

=============================================================================

class BankAccountBad: def init(self, owner, balance): self.owner = owner self.balance = balance # Anyone can modify this!

GOOD EXAMPLE - With Encapsulation

class BankAccountGood: def init(self, owner, balance): self.owner = owner self.__balance = balance # Private attribute (name mangling)

def deposit(self, amount):
    """Controlled way to add money"""
    if amount > 0:
        self.__balance += amount
        return True
    return False

def withdraw(self, amount):
    """Controlled way to remove money"""
    if 0 < amount <= self.__balance:
        self.__balance -= amount
        return True
    return False

def get_balance(self):
    """Controlled way to view balance"""
    return self.__balance

=============================================================================

Main

=============================================================================

if name == "main": # BAD EXAMPLE - No encapsulation (problem with no encapsulation) bad_account = BankAccountBad("John", 1000) print(f"Initial balance: ${bad_account.balance}")

# Direct access allows invalid operations
bad_account.balance = -5000  # Negative balance? No validation!
print(f"After direct modification: ${bad_account.balance}")  # This is bad!

print("\n" + "=" * 60)
print("ENCAPSULATION DEMONSTRATION")
print("=" * 60)

# GOOD EXAMPLE - Using encapsulated class
good_account = BankAccountGood("Jane", 1000)
print(f"\nInitial balance: ${good_account.get_balance()}")

# Must use methods to modify balance
good_account.deposit(500)
print(f"After deposit: ${good_account.get_balance()}")

good_account.withdraw(200)
print(f"After withdrawal: ${good_account.get_balance()}")

# Try invalid operations
print("\n--- Testing validation ---")
if not good_account.deposit(-100):
    print("❌ Cannot deposit negative amount")

if not good_account.withdraw(5000):
    print("❌ Cannot withdraw more than balance")

# Try to access private attribute directly
print("\n--- Testing encapsulation ---")
try:
    print(good_account.__balance)  # This will fail!
except AttributeError as e:
    print(f"❌ Cannot access private attribute: {e}")

# Python's name mangling allows this (but you shouldn't do it!)
print(f"\nName mangled attribute: {good_account._BankAccountGood__balance}")
print("⚠️  But you shouldn't access it this way!")

""" KEY TAKEAWAYS: 1. Encapsulation protects data from invalid modifications 2. Use private attributes (double underscore) for internal data 3. Provide public methods for controlled access 4. Validation happens in methods, not everywhere in your code 5. Name mangling makes attributes harder (but not impossible) to access 6. Encapsulation makes code safer and more maintainable """ ```


Exercises

Exercise 1. Create a class Password that stores a password in a private attribute __password. Provide a set_password(new_pw) method that only accepts passwords of at least 8 characters and a check_password(pw) method that returns True if the given password matches. Do not provide a getter that reveals the stored password.

Solution to Exercise 1

```python class Password: def init(self, password): self.__password = None self.set_password(password)

def set_password(self, new_pw):
    if len(new_pw) < 8:
        raise ValueError("Password must be at least 8 characters")
    self.__password = new_pw

def check_password(self, pw):
    return pw == self.__password

p = Password("secure123") print(p.check_password("secure123")) # True print(p.check_password("wrong")) # False

try: p.set_password("short") except ValueError as e: print(e) # Password must be at least 8 characters ```


Exercise 2. Predict the output of the following code. Explain how name mangling works.

```python class Secret: def init(self): self.__value = 42

s = Secret() print(hasattr(s, '__value')) print(hasattr(s, '_Secret__value')) print(s._Secret__value) ```

Solution to Exercise 2

The output is:

False True 42

Python's name mangling transforms any attribute starting with double underscores (e.g., __value) into _ClassName__value. Therefore s.__value does not exist as a direct attribute (so hasattr returns False), but s._Secret__value does exist. This mechanism discourages accidental access to private attributes but does not make them truly inaccessible.


Exercise 3. Write a class Temperature with a private attribute __celsius. Provide a set_celsius(value) method that rejects values below absolute zero (\(-273.15\)). Add a get_fahrenheit() method that computes and returns the equivalent Fahrenheit temperature using the formula \(F = C \times 9/5 + 32\).

Solution to Exercise 3

```python class Temperature: def init(self, celsius): self.__celsius = None self.set_celsius(celsius)

def set_celsius(self, value):
    if value < -273.15:
        raise ValueError("Temperature below absolute zero")
    self.__celsius = value

def get_fahrenheit(self):
    return self.__celsius * 9 / 5 + 32

t = Temperature(100) print(t.get_fahrenheit()) # 212.0

try: t.set_celsius(-300) except ValueError as e: print(e) # Temperature below absolute zero ```


Exercise 4. A developer writes the following class and claims it uses encapsulation. Identify the flaw and rewrite the class with proper encapsulation.

```python class Counter: def init(self): self.count = 0

def increment(self):
    self.count += 1

```

Solution to Exercise 4

The flaw is that self.count is a public attribute, so any external code can set it to an arbitrary value (e.g., c.count = -100), bypassing any validation. A properly encapsulated version uses a private attribute and controlled methods:

```python class Counter: def init(self): self.__count = 0

def increment(self):
    self.__count += 1

def get_count(self):
    return self.__count

c = Counter() c.increment() c.increment() print(c.get_count()) # 2

c.__count = -100 # AttributeError — cannot access directly

```


Exercise 5. Create a class ReadOnlyList that wraps a regular Python list in a private attribute. Expose get(index) and length() methods but do not provide any way to modify the list after construction. Demonstrate that external code cannot add or remove elements.

Solution to Exercise 5

```python class ReadOnlyList: def init(self, items): self.__items = list(items) # defensive copy

def get(self, index):
    return self.__items[index]

def length(self):
    return len(self.__items)

rol = ReadOnlyList([10, 20, 30]) print(rol.get(0)) # 10 print(rol.length()) # 3

No way to modify:

try: rol.__items.append(40) except AttributeError: print("Cannot access private attribute")

Even the returned values don't expose the internal list

```