Encapsulation & Abstraction

Difficulty: Intermediate

Encapsulation is the practice of bundling data and the methods that operate on that data within a class, and restricting direct access to some of the object's internals. In Python, encapsulation is achieved through naming conventions rather than strict access modifiers like those in Java or C++. There are three conventional access levels: public (no underscore prefix), protected (single underscore prefix), and private (double underscore prefix).

Public attributes have no underscore prefix and are freely accessible from anywhere. Protected attributes start with a single underscore, like _balance. This is a convention signaling that the attribute is intended for internal use by the class and its subclasses. Python does not enforce this restriction; you can still access _balance from outside the class, but doing so is considered bad practice.

Private attributes start with a double underscore, like __secret. Python applies name mangling to these: the attribute is internally renamed to _ClassName__secret. This makes accidental access from outside harder, though it is still possible if you know the mangled name. Name mangling exists primarily to prevent name collisions in subclasses rather than to provide security.

The @property decorator is Python's idiomatic way to implement getters and setters. Instead of writing explicit get_x() and set_x() methods, you define a property that looks like a regular attribute access to the caller but runs custom code behind the scenes. This gives you validation, computed values, or lazy loading while maintaining a clean interface. You define a getter with @property, a setter with @attribute_name.setter, and optionally a deleter with @attribute_name.deleter.

Abstraction is the complementary concept of hiding complex implementation details behind a simple interface. Users of a class interact with its public methods and properties without needing to understand the underlying logic. For example, a BankAccount class might expose deposit() and withdraw() methods while keeping the balance validation and transaction logging hidden. Python's abc module provides the ABC base class and @abstractmethod decorator for formal abstract classes that cannot be instantiated directly.

Code examples

Public, protected, and private attributes

class Account:
    def __init__(self, owner, balance):
        self.owner = owner          # public
        self._type = 'savings'      # protected (convention)
        self.__balance = balance    # private (name-mangled)

    def get_balance(self):
        return self.__balance

a = Account('Alice', 1000)
print(a.owner)
print(a._type)
print(a.get_balance())
print(a._Account__balance)  # name-mangled access

owner is public and freely accessible. _type is protected by convention. __balance is name-mangled to _Account__balance. You can still access it via the mangled name, but this is strongly discouraged.

@property for getters and setters

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('Temperature below absolute zero')
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius)
print(t.fahrenheit)
t.celsius = 100
print(t.celsius)
print(t.fahrenheit)

celsius uses @property as a getter and @celsius.setter for validation. fahrenheit is a read-only computed property with no setter. Access looks like a simple attribute (t.celsius) but runs the property methods behind the scenes.

Name mangling in inheritance

class Parent:
    def __init__(self):
        self.__secret = 'parent secret'
        self._protected = 'parent protected'

    def reveal(self):
        return self.__secret

class Child(Parent):
    def __init__(self):
        super().__init__()
        self.__secret = 'child secret'  # does NOT override parent's

    def child_reveal(self):
        return self.__secret

c = Child()
print(c.reveal())
print(c.child_reveal())
print(c._protected)

Parent's __secret becomes _Parent__secret and Child's __secret becomes _Child__secret. They are two separate attributes. This is exactly the name-collision prevention that name mangling is designed for. The protected _protected attribute is shared normally.

Encapsulation with validation

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = 0
        self.price = price  # triggers setter validation

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Price must be a number')
        if value < 0:
            raise ValueError('Price cannot be negative')
        self._price = round(value, 2)

    def display(self):
        return f'{self._name}: ${self._price}'

p = Product('Widget', 19.999)
print(p.display())
p.price = 24.50
print(p.display())
print(p.price)

The price setter validates the type and value before storing it. Even inside __init__, assigning self.price = price routes through the setter, ensuring the validation always runs. The name property has no setter, making it read-only after creation.

Key points

Concepts covered

public attributes, private attributes, protected attributes, name mangling, @property, getters and setters