Skip to content
intermediate Phase 2 · Python OOP

Encapsulation & Properties

Use private attributes, getters/setters, and Python properties.

1h
0 problems
Topic Progress 0%

Naming Conventions

Python's Approach to Encapsulation

Python doesn't have true private attributes. Instead, it uses naming conventions:

class Person:
    def __init__(self, name, age, ssn):
        self.name = name          # Public
        self._age = age           # Protected (convention only)
        self.__ssn = ssn          # Name-mangled

p = Person('Alice', 30, '123-45-6789')

print(p.name)     # ✅ Alice
print(p._age)     # ✅ 30 (accessible, but convention says don't)
# print(p.__ssn)  # ❌ AttributeError
print(p._Person__ssn)  # ✅ 123-45-6789 (name-mangled)

Naming Conventions

Prefix Convention Example
name Public self.name
_name Protected (internal use) self._age
__name Name-mangled (prevents subclass override) self.__ssn
__name__ Dunder/magic methods self.__init__

When to Use Each

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner        # Public: anyone can access
        self._balance = balance   # Protected: internal use
        self.__pin = '1234'       # Private: sensitive data
    
    def deposit(self, amount):
        # Access protected attribute directly in class
        self._balance += amount
        return self._balance

account = BankAccount('Alice', 1000)
print(account.owner)      # ✅ Alice
print(account._balance)   # ✅ 1000 (works, but not recommended)
# print(account.__pin)    # ❌ AttributeError

Properties

Basic Property

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value < 0:
            raise ValueError('Radius cannot be negative')
        self._radius = value
    
    @property
    def area(self):
        """Calculate area (read-only)."""
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
print(c.radius)   # 5
c.radius = 10    # ✅ Setter called
print(c.area)    # 314.159...
# c.area = 100   # ❌ AttributeError (read-only)
# c.radius = -5  # ❌ ValueError: Radius cannot be negative

Property vs Getter/Setter

# ❌ Java-style getters/setters
class Person:
    def __init__(self, name):
        self._name = name
    
    def get_name(self):
        return self._name
    
    def set_name(self, name):
        self._name = name

person = Person('Alice')
name = person.get_name()  # Not Pythonic

# ✅ Python-style properties
class Person:
    def __init__(self, name):
        self._name = name
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        self._name = value

person = Person('Alice')
name = person.name  # Pythonic!

Computed Properties

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

t = Temperature(100)
print(t.fahrenheit)  # 212.0
t.fahrenheit = 32
print(t.celsius)     # 0.0

cached_property (Python 3.8+)

from functools import cached_property

class DataAnalyzer:
    def __init__(self, data):
        self.data = data
    
    @cached_property
    def statistics(self):
        # Computed once, then cached
        print('Computing statistics...')
        return {
            'mean': sum(self.data) / len(self.data),
            'min': min(self.data),
            'max': max(self.data)
        }

analyzer = DataAnalyzer([1, 2, 3, 4, 5])
print(analyzer.statistics)  # Computes and prints
print(analyzer.statistics)  # Returns cached (no 'Computing' print)

Slots & Immutable Objects

slots for Memory Optimization

class PointWithDict:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class PointWithSlots:
    __slots__ = ('x', 'y')
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

# PointWithDict uses ~150 bytes per instance
# PointWithSlots uses ~56 bytes per instance

import sys
p1 = PointWithDict(1, 2)
p2 = PointWithSlots(1, 2)

print(sys.getsizeof(p1.__dict__))  # 104 bytes (dict)
# print(sys.getsizeof(p2.__dict__))  # ❌ AttributeError (no dict)

Benefits of slots

class SlotClass:
    __slots__ = ('x', 'y')
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

# ✅ Memory savings
# ✅ Faster attribute access
# ✅ Prevents accidental attribute creation

s = SlotClass(1, 2)
# s.z = 3  # ❌ AttributeError: 'SlotClass' object has no attribute 'z'

Immutable Objects

class ImmutablePoint:
    __slots__ = ('_x', '_y')
    
    def __init__(self, x, y):
        object.__setattr__(self, '_x', x)
        object.__setattr__(self, '_y', y)
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    def __hash__(self):
        return hash((self._x, self._y))

p = ImmutablePoint(1, 2)
print(p.x, p.y)  # 1 2
# p.x = 3  # ❌ AttributeError (no setter)

# Works as dictionary key or in set
locations = {p: 'origin'}

dataclass with frozen=True

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
print(p)  # Point(x=1.0, y=2.0)
# p.x = 3.0  # ❌ FrozenInstanceError

# Can be used as dict key
locations = {p: 'origin'}