Python - Encapsulation (Public, Private, Protected)

• Python uses naming conventions rather than strict access modifiers—single underscore (_) for protected, double underscore (__) for private, and no prefix for public attributes

Key Insights

• Python uses naming conventions rather than strict access modifiers—single underscore (_) for protected, double underscore (__) for private, and no prefix for public attributes • Name mangling with double underscores provides obfuscation, not true security, as attributes remain accessible through _ClassName__attribute syntax • Property decorators (@property) offer the most Pythonic way to implement encapsulation while maintaining clean syntax and backward compatibility

Understanding Python’s Encapsulation Model

Python takes a fundamentally different approach to encapsulation compared to languages like Java or C++. Instead of enforcing access control through language keywords, Python relies on naming conventions and a philosophy summarized as “we’re all consenting adults here.” This doesn’t mean encapsulation is absent—it’s just implemented through convention and social contract rather than compiler enforcement.

The three levels of access in Python are distinguished by naming patterns:

class BankAccount:
    def __init__(self, account_number, balance):
        self.account_number = account_number      # Public
        self._owner_id = None                     # Protected
        self.__pin = "1234"                       # Private

Public Attributes and Methods

Public members have no prefix and are meant to be accessed freely by any code. They form the official API of your class.

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price
        self.inventory_count = 0
    
    def restock(self, quantity):
        self.inventory_count += quantity
        return self.inventory_count
    
    def get_total_value(self):
        return self.price * self.inventory_count

# Usage
product = Product("Laptop", 999.99)
product.restock(50)
print(f"{product.name}: ${product.get_total_value()}")  # Laptop: $49999.50

Public attributes can be accessed and modified directly. This is intentional—Python developers prefer explicit interfaces over hidden complexity.

Protected Attributes (Single Underscore)

Protected attributes use a single leading underscore. This is a convention signaling “internal use—proceed with caution.” The interpreter doesn’t enforce any restrictions, but it’s a clear message to other developers.

class DatabaseConnection:
    def __init__(self, connection_string):
        self._connection_string = connection_string
        self._connection = None
        self._is_connected = False
    
    def connect(self):
        # Simulate connection
        self._connection = f"Connected to {self._connection_string}"
        self._is_connected = True
        self._log_connection()
    
    def _log_connection(self):
        """Protected method for internal logging"""
        print(f"Connection established: {self._connection_string}")
    
    def disconnect(self):
        if self._is_connected:
            self._connection = None
            self._is_connected = False

# Usage
db = DatabaseConnection("postgresql://localhost:5432/mydb")
db.connect()

# Technically accessible, but convention says don't do this
print(db._connection_string)  # Works, but violates convention

Protected members are particularly useful in inheritance scenarios where subclasses need access to internal state:

class SecureConnection(DatabaseConnection):
    def __init__(self, connection_string, ssl_cert):
        super().__init__(connection_string)
        self._ssl_cert = ssl_cert
    
    def connect(self):
        # Override and extend parent behavior
        self._validate_certificate()
        super().connect()
    
    def _validate_certificate(self):
        """Subclass can access parent's protected members"""
        print(f"Validating SSL for {self._connection_string}")

Private Attributes (Double Underscore)

Private attributes use a double leading underscore, triggering name mangling. Python transforms __attribute into _ClassName__attribute, making accidental access from outside the class less likely.

class CreditCard:
    def __init__(self, card_number, cvv):
        self.__card_number = card_number
        self.__cvv = cvv
        self.cardholder_name = ""
    
    def charge(self, amount):
        if self.__validate_card():
            return f"Charged ${amount} to card ending in {self.__get_last_four()}"
        return "Invalid card"
    
    def __validate_card(self):
        """Private method - name mangled"""
        return len(self.__card_number) == 16
    
    def __get_last_four(self):
        return self.__card_number[-4:]

# Usage
card = CreditCard("1234567890123456", "123")
print(card.charge(99.99))  # Works fine

# These raise AttributeError
# print(card.__card_number)
# card.__validate_card()

# But name mangling isn't true privacy
print(card._CreditCard__card_number)  # 1234567890123456 - still accessible!

Name mangling primarily prevents accidental attribute collisions in inheritance hierarchies:

class Parent:
    def __init__(self):
        self.__private = "parent"
    
    def get_private(self):
        return self.__private

class Child(Parent):
    def __init__(self):
        super().__init__()
        self.__private = "child"  # Different attribute due to name mangling
    
    def get_both(self):
        return f"Child: {self.__private}, Parent: {self.get_private()}"

child = Child()
print(child.get_both())  # Child: child, Parent: parent

Property Decorators: The Pythonic Approach

The most elegant encapsulation in Python uses property decorators, providing controlled access while maintaining attribute-like syntax.

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        """Getter for celsius"""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        """Setter with validation"""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        """Computed property"""
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9

# Usage
temp = Temperature(25)
print(temp.celsius)      # 25
print(temp.fahrenheit)   # 77.0

temp.fahrenheit = 32
print(temp.celsius)      # 0.0

# temp.celsius = -300  # Raises ValueError

Properties enable you to start with simple public attributes and add validation later without breaking existing code:

class User:
    def __init__(self, email):
        self._email = None
        self.email = email  # Uses setter for validation
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if '@' not in value:
            raise ValueError("Invalid email address")
        self._email = value.lower()
    
    @property
    def domain(self):
        """Read-only computed property"""
        return self._email.split('@')[1]

user = User("John.Doe@Example.COM")
print(user.email)   # john.doe@example.com
print(user.domain)  # example.com

# user.domain = "other.com"  # AttributeError: can't set attribute

Practical Encapsulation Patterns

Combine these techniques for robust class design:

class ShoppingCart:
    def __init__(self):
        self._items = []
        self.__discount_rate = 0.0
    
    def add_item(self, item, price, quantity=1):
        """Public interface"""
        self._items.append({
            'item': item,
            'price': price,
            'quantity': quantity
        })
    
    @property
    def subtotal(self):
        return sum(item['price'] * item['quantity'] for item in self._items)
    
    @property
    def total(self):
        return self.subtotal * (1 - self.__discount_rate)
    
    def apply_discount(self, code):
        """Public method using private validation"""
        if self.__validate_discount_code(code):
            self.__discount_rate = 0.1
            return True
        return False
    
    def __validate_discount_code(self, code):
        """Private validation logic"""
        return code == "SAVE10"

cart = ShoppingCart()
cart.add_item("Widget", 29.99, 2)
cart.add_item("Gadget", 49.99)
print(f"Subtotal: ${cart.subtotal:.2f}")  # Subtotal: $109.97

cart.apply_discount("SAVE10")
print(f"Total: ${cart.total:.2f}")  # Total: $98.97

Python’s encapsulation model prioritizes flexibility and clarity over rigid enforcement. Use public attributes for your API, protected for internal implementation details, private for avoiding name collisions, and properties for controlled access with validation. The goal isn’t to hide everything—it’s to communicate intent clearly to other developers.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.