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.
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.
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.
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.
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.
public attributes, private attributes, protected attributes, name mangling, @property, getters and setters