Encapsulation and Data Hiding - Properties, Name Mangling, and Descriptors
Reading time: ~32 minutes | Level: Intermediate → Engineering
Before reading further, predict every output:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def deposit(self, amount):
self.__balance += amount
account = BankAccount(1000)
account.deposit(500)
print(account.__balance) # ?
print(account._BankAccount__balance) # ?
Most developers expect:
account.__balance→1500account._BankAccount__balance→AttributeError
The actual results are the opposite:
account.__balance→AttributeError: 'BankAccount' object has no attribute '__balance'account._BankAccount__balance→1500
The double underscore does not make an attribute private in the C++/Java sense. It does something completely different - it mangles the name. The attribute __balance defined inside class BankAccount is stored as _BankAccount__balance. The original name __balance does not exist on the object.
This is not a bug. It is a feature. And understanding it precisely - along with @property and the descriptor protocol - is the foundation of every piece of Python code that manages state safely.
What You Will Learn
- The single underscore convention (
_name) - what it means and what it does not do - Double underscore name mangling (
__name) - the exact transformation Python applies and why @propertyfor controlled attribute access - reading attributes that behave like attributes but execute code@setterand@deleter- writing and deleting with validation- Validation patterns in property setters
__slots__revisited with inheritance implications- The descriptor protocol:
__get__,__set__,__delete__- what powers@property,@classmethod, and@staticmethod - Computed attributes and caching with descriptors
Prerequisites
- Lesson 01: Classes and Objects - class vs instance namespace, attribute resolution chain
- Lesson 02:
__init__and Object Construction - self, instance state - Lesson 03: Dunder Methods - protocol system
- Understanding Python's attribute lookup (MRO,
__dict__)
Part 1 - The Underscore Convention
Single Underscore - "Internal Use" Convention
A single leading underscore (_name) is a naming convention. It communicates to other developers: "this attribute or method is intended for internal use - I may change it without warning."
Python does not enforce this at all. The attribute is fully accessible:
class Connection:
def __init__(self, host, port):
self.host = host
self.port = port
self._socket = None # internal - implementation detail
self._retry_count = 0 # internal - subject to change
def _reconnect(self): # internal helper - not part of the public API
"""Implementation detail - callers should not call this directly."""
self._retry_count += 1
self._socket = self._open_socket()
def _open_socket(self):
pass # simplified
conn = Connection("db.example.com", 5432)
print(conn._retry_count) # 0 - accessible, just conventionally "internal"
conn._reconnect() # also accessible - the _ is a social contract, not a lock
The single underscore means: "if you access this, you are stepping outside the public API and accepting that it may break across versions." It is the Python equivalent of "friend" in C++ - a documented boundary, not a hard wall.
Where You Will See _ in the Wild
_data,_cache,_conn: internal implementation attributesfrom module import *skips names starting with_(another effect of the convention)_as a throwaway variable:for _ in range(5):orx, _ = func()
Part 2 - Double Underscore Name Mangling
What Mangling Does
When Python compiles a class body and encounters an identifier with two or more leading underscores and at most one trailing underscore, it rewrites the name by prepending _ClassName:
__name defined inside class Foo → stored as _Foo__name
__name defined inside class Bar → stored as _Bar__name
This happens at compile time, before the code runs:
class BankAccount:
def __init__(self, balance):
self.__balance = balance # stored as _BankAccount__balance
def get_balance(self):
return self.__balance # access is also mangled at compile time
# → self._BankAccount__balance
account = BankAccount(1000)
print(account.__dict__)
# {'_BankAccount__balance': 1000} - note the mangled key
print(account._BankAccount__balance) # 1500 - accessible via mangled name
# print(account.__balance) # AttributeError - no such attribute
Double-underscore name mangling does NOT make an attribute private. Anyone can access _BankAccount__balance directly - it is just a different name. The mangling is purely a collision-prevention mechanism for inheritance hierarchies, not an access control mechanism. If you need to inspect a mangled attribute from outside the class:
# Access the mangled name directly:
print(account._BankAccount__balance) # 1500 - works fine
# Or use vars() to see all attributes:
print(vars(account)) # {'_BankAccount__balance': 1000}
Python does not have true private attributes. The __name convention is a naming transform, not a security boundary.
Why Name Mangling Exists
Name mangling is not primarily for privacy. It exists to prevent accidental name clashes in inheritance hierarchies. Without mangling, a subclass that uses the same internal attribute name would silently overwrite the parent's attribute:
class Widget:
def __init__(self):
self.__state = "normal" # stored as _Widget__state
def get_state(self):
return self.__state # _Widget__state
class Button(Widget):
def __init__(self):
super().__init__()
self.__state = "clickable" # stored as _Button__state - different attribute!
def click(self):
self.__state = "clicked" # modifies _Button__state, NOT _Widget__state
b = Button()
print(b.__dict__)
# {'_Widget__state': 'normal', '_Button__state': 'clickable'}
# Both attributes coexist - no collision
print(b.get_state()) # 'normal' - Widget's __state is untouched
b.click()
print(b.__dict__)
# {'_Widget__state': 'normal', '_Button__state': 'clicked'}
If Widget had used self._state (single underscore), Button.__init__ setting self._state = "clickable" would have overwritten the parent's value - a silent, hard-to-debug collision.
When to Use Double Underscore
Use __name (double underscore) when:
- You are writing a class intended to be subclassed, and you have internal attributes that subclasses must not accidentally collide with
- You are implementing a framework or library where you control the base class and want to protect implementation details from being clobbered by subclass authors who do not know the internals
Do not use __name for regular "I want this private" use. The single underscore convention suffices for that - it communicates intent without the confusing mangling behavior.
# Correct use of __name: protecting framework internals
class BaseView:
def __init__(self):
self.__dispatch_table = {} # subclasses MUST NOT override this
self.__middleware = [] # same
def _setup(self): # intended override point - single underscore
pass
class MyView(BaseView):
def __init__(self):
super().__init__()
# self.__dispatch_table = {} # would NOT collide - stored as _MyView__dispatch_table
self._my_private_thing = 42 # normal internal attribute
Part 3 - @property for Controlled Access
The Problem Properties Solve
Plain attributes give callers direct read/write access with no ability to validate or compute. Adding validation later requires changing the API:
# Version 1 - plain attribute
class Circle:
def __init__(self, radius):
self.radius = radius # caller can set: c.radius = -10 - invalid!
# Version 2 - must change to method, breaking callers
class Circle:
def __init__(self, radius):
self._radius = radius
def get_radius(self):
return self._radius
def set_radius(self, value):
if value < 0:
raise ValueError("radius cannot be negative")
self._radius = value
# All callers must change: c.radius → c.get_radius()
# This is how Java does it. Python has a better way.
@property lets you start with a plain attribute, add validation later, and never break callers:
class Circle:
def __init__(self, radius: float):
self.radius = radius # goes through the setter immediately
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value < 0:
raise ValueError(f"radius must be non-negative, got {value}")
self._radius = value
@property
def area(self) -> float:
import math
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.53981...
print(c.circumference) # 31.41592...
c.radius = 10 # goes through setter - validated
print(c.area) # 314.1592...
try:
c.radius = -1 # ValueError: radius must be non-negative, got -1
except ValueError as e:
print(e)
The callers of Circle never change their code. c.radius still reads and writes as an attribute - the validation is invisible to them.
Use @property only when you actually need something beyond plain attribute storage: validation, type coercion, computed/derived values, or side effects on read/write. For attributes that are simply stored and retrieved with no logic, a plain attribute (self.name = name) is always preferable. Adding @property for no reason adds overhead, visual noise, and a _backing_attr name that serves no purpose.
The @property Mechanics
@property is actually a built-in class, not just a decorator. It works like this:
class Temperature:
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
self._celsius = value
# Equivalent without decorator syntax:
class Temperature:
def _get_celsius(self):
return self._celsius
def _set_celsius(self, value):
self._celsius = value
celsius = property(_get_celsius, _set_celsius)
property is a descriptor (covered in Part 5). When you write obj.celsius, Python finds celsius in the class dict, sees it is a descriptor (has __get__), and calls celsius.__get__(obj, type(obj)) instead of returning celsius directly.
Read-Only Properties
A property without a setter is read-only:
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
return self.width * self.height
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
@property
def is_square(self) -> bool:
return self.width == self.height
r = Rectangle(4, 6)
print(r.area) # 24.0
print(r.perimeter) # 20.0
print(r.is_square) # False
try:
r.area = 30 # AttributeError: can't set attribute
except AttributeError as e:
print(e)
Read-only properties are used for derived / computed values that depend on other attributes - you should not be able to set area directly; it is a function of width and height.
@deleter - Handling Attribute Deletion
class CachedData:
def __init__(self, source):
self._source = source
self._cache = None
@property
def data(self):
if self._cache is None:
print("Loading from source...")
self._cache = list(self._source) # simulate loading
return self._cache
@data.setter
def data(self, value):
self._cache = value
@data.deleter
def data(self):
print("Clearing cache...")
self._cache = None
source = range(5)
cd = CachedData(source)
print(cd.data) # Loading from source... [0, 1, 2, 3, 4]
print(cd.data) # [0, 1, 2, 3, 4] - from cache, no "Loading" message
del cd.data # Clearing cache...
print(cd.data) # Loading from source... [0, 1, 2, 3, 4] - reloaded
Part 4 - Validation Patterns in Property Setters
Type and Range Validation
from typing import Optional
class Employee:
def __init__(
self,
name: str,
salary: float,
department: str,
manager: Optional["Employee"] = None,
):
self.name = name # each goes through its setter
self.salary = salary
self.department = department
self.manager = manager
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("name must not be empty")
if len(value) > 100:
raise ValueError(f"name too long: {len(value)} chars (max 100)")
self._name = value
@property
def salary(self) -> float:
return self._salary
@salary.setter
def salary(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"salary must be numeric, got {type(value).__name__}")
value = float(value)
if value < 0:
raise ValueError(f"salary cannot be negative, got {value}")
if value > 10_000_000:
raise ValueError(f"salary exceeds maximum: {value}")
self._salary = value
@property
def department(self) -> str:
return self._department
@department.setter
def department(self, value: str) -> None:
VALID = {"Engineering", "Product", "Sales", "Finance", "HR", "Legal"}
if value not in VALID:
raise ValueError(f"Unknown department {value!r}. Valid: {VALID}")
self._department = value
@property
def manager(self) -> Optional["Employee"]:
return self._manager
@manager.setter
def manager(self, value: Optional["Employee"]) -> None:
if value is not None and not isinstance(value, Employee):
raise TypeError(f"manager must be an Employee, got {type(value).__name__}")
if value is self:
raise ValueError("An employee cannot be their own manager")
self._manager = value
def __repr__(self) -> str:
mgr = self._manager.name if self._manager else None
return f"Employee(name={self._name!r}, salary={self._salary}, dept={self._department!r}, manager={mgr!r})"
# All validation runs at construction time (through setters in __init__)
alice = Employee("Alice Chen", 120_000, "Engineering")
bob = Employee("Bob Smith", 95_000, "Engineering", manager=alice)
print(repr(alice))
# Employee(name='Alice Chen', salary=120000.0, dept='Engineering', manager=None)
try:
bad = Employee("", 50_000, "Engineering")
except ValueError as e:
print(e) # name must not be empty
try:
bad = Employee("Dave", -1, "Engineering")
except ValueError as e:
print(e) # salary cannot be negative, got -1.0
try:
alice.manager = alice # self-reference
except ValueError as e:
print(e) # An employee cannot be their own manager
Caching / Lazy Properties
For expensive computations, cache the result after the first access:
import hashlib
class Document:
def __init__(self, content: bytes):
self._content = content
self._sha256 = None # lazy - computed on first access
self._word_count = None # lazy
@property
def content(self) -> bytes:
return self._content
@property
def sha256(self) -> str:
if self._sha256 is None:
self._sha256 = hashlib.sha256(self._content).hexdigest()
return self._sha256
@property
def word_count(self) -> int:
if self._word_count is None:
text = self._content.decode("utf-8", errors="ignore")
self._word_count = len(text.split())
return self._word_count
doc = Document(b"Hello world this is a test document")
print(doc.sha256) # computed and cached
print(doc.sha256) # returned from cache
print(doc.word_count) # computed and cached
Python 3.8+ provides functools.cached_property for this pattern, which eliminates the need for a manual None guard:
from functools import cached_property
class Document:
def __init__(self, content: bytes):
self._content = content
@cached_property
def sha256(self) -> str:
return hashlib.sha256(self._content).hexdigest()
@cached_property
def word_count(self) -> int:
return len(self._content.decode("utf-8", errors="ignore").split())
doc = Document(b"Hello world")
print(doc.sha256) # computed once
print(doc.sha256) # from __dict__ - bypasses the property on second access
print(doc.__dict__) # {'sha256': '...', 'word_count': 2}
cached_property works by writing the computed value directly into instance.__dict__ on first access. Subsequent accesses find it in __dict__ before Python even checks the class - so the property is never called again. This requires that the instance has a __dict__ (no __slots__).
Part 5 - __slots__ with Inheritance
__slots__ was introduced in Lesson 01. Here is the full picture for inheritance:
class Base:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
class Child(Base):
__slots__ = ("z",) # only ADD new slots here - do not repeat parent slots
def __init__(self, x, y, z):
super().__init__(x, y)
self.z = z
c = Child(1, 2, 3)
print(c.x, c.y, c.z) # 1 2 3
# c.w = 4 # AttributeError - no __dict__, no slot for 'w'
If any class in the inheritance chain does not define __slots__, instances will have __dict__ (the memory savings are lost):
class NoSlots:
pass # has __dict__
class SlottedChild(NoSlots):
__slots__ = ("x",)
# SlottedChild instances still have __dict__ because NoSlots does
sc = SlottedChild()
sc.x = 1
sc.anything = "arbitrary" # works - __dict__ is available
print(sc.__dict__) # {'anything': 'arbitrary'}
@property works with __slots__ - the property lives in the class __dict__, not the instance. The slot stores only the underlying _value:
class PositiveFloat:
__slots__ = ("_value",)
def __init__(self, value: float):
self.value = value # goes through property setter
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float) -> None:
if v < 0:
raise ValueError(f"must be non-negative, got {v}")
self._value = float(v)
pf = PositiveFloat(3.14)
print(pf.value) # 3.14
# pf.other = 5 # AttributeError - slots enforced
Do not use __slots__ as a default optimisation for every class. It trades flexibility for memory savings and introduces significant constraints:
- Breaks
cached_property(which writes toinstance.__dict__) - Breaks mixins and multiple inheritance if parent classes do not also define
__slots__ - Prevents adding new attributes to instances at runtime
- Complicates pickling in some cases
Use __slots__ only when you have measured a memory problem with many instances (tens of thousands or more) of a specific class and profiling confirms that __dict__ overhead is the bottleneck.
Part 6 - The Descriptor Protocol
What Is a Descriptor?
A descriptor is any object that defines __get__, __set__, or __delete__. When a descriptor instance is stored as a class attribute, Python invokes these methods instead of returning the descriptor object directly.
class MyDescriptor:
def __get__(self, obj, objtype=None):
print(f"__get__ called: obj={obj!r}, objtype={objtype.__name__!r}")
return 42
def __set__(self, obj, value):
print(f"__set__ called: obj={obj!r}, value={value!r}")
def __delete__(self, obj):
print(f"__delete__ called: obj={obj!r}")
class MyClass:
attr = MyDescriptor() # descriptor stored as CLASS attribute
obj = MyClass()
x = obj.attr # __get__ called: obj=<MyClass...>, objtype='MyClass'
obj.attr = 10 # __set__ called: obj=<MyClass...>, value=10
del obj.attr # __delete__ called: obj=<MyClass...>
# Accessing via the class (not an instance) also calls __get__ with obj=None
y = MyClass.attr # __get__ called: obj=None, objtype='MyClass'
Data vs Non-Data Descriptors
There are two kinds of descriptors:
- Data descriptor: defines
__set__(and/or__delete__) - Non-data descriptor: defines only
__get__
The distinction controls how they interact with the instance __dict__:
# Data descriptor - __set__ defined
# Takes priority OVER instance __dict__
# attribute lookup: data_descriptor > instance.__dict__ > non-data_descriptor > class
# Non-data descriptor - only __get__
# Instance __dict__ takes priority OVER it
# attribute lookup: data_descriptor > instance.__dict__ > non-data_descriptor > class
property is a data descriptor (it defines __get__, __set__, and __delete__). That is why obj.x = value goes through the property setter even though Python would normally write to instance.__dict__.
Functions are non-data descriptors (they define only __get__). That is why you can shadow a method by setting an instance attribute with the same name.
@property Is Implemented as a Descriptor
You can see this directly:
class Circle:
@property
def radius(self):
return self._radius
print(type(Circle.__dict__["radius"])) # <class 'property'>
print(Circle.__dict__["radius"]) # <property object at 0x...>
print(hasattr(Circle.__dict__["radius"], "__get__")) # True
print(hasattr(Circle.__dict__["radius"], "__set__")) # True (returns AttributeError for read-only)
print(hasattr(Circle.__dict__["radius"], "__delete__")) # True
When you write c.radius:
- Python looks in
c.__dict__- not found - Python looks in
Circle.__dict__- finds thepropertyobject - Python sees that
propertyis a data descriptor (has__get__) - Python calls
Circle.__dict__["radius"].__get__(c, Circle) - That calls your getter function with
self=c
Writing Your Own Descriptor
Descriptors are the tool for reusable attribute behaviour - validation, logging, type coercion - that you want to apply across multiple attributes and multiple classes:
class TypedAttribute:
"""Descriptor that enforces a type for an attribute."""
def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
# Internal storage key - use the class-qualified name to avoid collisions
self._storage_name = f"_typed_{name}"
def __set_name__(self, owner, name):
# Called when the descriptor is assigned to a class attribute.
# owner = the class, name = the attribute name used in the class body.
self.name = name
self._storage_name = f"_typed_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self # class-level access returns the descriptor itself
return getattr(obj, self._storage_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name!r} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self._storage_name, value)
def __delete__(self, obj):
if hasattr(obj, self._storage_name):
delattr(obj, self._storage_name)
class Product:
name = TypedAttribute("name", str)
price = TypedAttribute("price", float)
quantity = TypedAttribute("quantity", int)
def __init__(self, name: str, price: float, quantity: int):
self.name = name # __set__ called - type checked
self.price = price # __set__ called - type checked
self.quantity = quantity # __set__ called - type checked
def __repr__(self):
return f"Product(name={self.name!r}, price={self.price}, quantity={self.quantity})"
p = Product("Widget", 9.99, 100)
print(p) # Product(name='Widget', price=9.99, quantity=100)
try:
p.price = "expensive" # TypeError: 'price' must be float, got str
except TypeError as e:
print(e)
try:
p.quantity = 50.5 # TypeError: 'quantity' must be int, got float
except TypeError as e:
print(e)
The same TypedAttribute descriptor can be reused across every class - one implementation, consistent behaviour everywhere. This is far more powerful than copy-pasting validation into each @property setter.
__set_name__ - Descriptor Naming (Python 3.6+)
In the example above, __set_name__ is called by the class machinery when the descriptor is assigned as a class attribute. It provides the actual attribute name so the descriptor does not need to be told separately:
class Validated:
"""Descriptor with automatic name detection."""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_" + name # store backing value here
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
self._validate(value)
setattr(obj, self.private_name, value)
def _validate(self, value):
pass # override in subclass
class PositiveNumber(Validated):
def _validate(self, value):
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.public_name!r} must be a positive number, got {value!r}")
class Temperature:
celsius = PositiveNumber() # __set_name__ → public='celsius', private='_celsius'
kelvin = PositiveNumber() # __set_name__ → public='kelvin', private='_kelvin'
def __init__(self, celsius):
self.celsius = celsius
t = Temperature(100)
print(t.celsius) # 100
try:
t.celsius = -10 # ValueError: 'celsius' must be a positive number, got -10
except ValueError as e:
print(e)
@classmethod and @staticmethod Are Descriptors
Once you understand the descriptor protocol, @classmethod and @staticmethod are demystified:
class Foo:
@classmethod
def bar(cls):
return cls
# classmethod is a descriptor
print(type(Foo.__dict__["bar"])) # <class 'classmethod'>
# When accessed via the class:
# Foo.bar calls bar.__get__(None, Foo) → returns a bound method with cls=Foo
# When accessed via an instance:
# obj.bar calls bar.__get__(obj, Foo) → still returns cls=Foo (not the instance)
class Foo:
@staticmethod
def baz():
return "static"
print(type(Foo.__dict__["baz"])) # <class 'staticmethod'>
# staticmethod.__get__ returns the underlying function unchanged -
# no binding to class or instance
This is why @classmethod always gets the class (not the instance) as its first argument - it is the descriptor's __get__ that wraps the function and passes cls.
Part 7 - Combining Techniques
A Full Example: A Typed, Validated Domain Object
from typing import Optional
import re
class StringField:
"""Descriptor: string with optional regex validation and max length."""
def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"
def __init__(self, max_length: int = 255, pattern: Optional[str] = None):
self.max_length = max_length
self._pattern = re.compile(pattern) if pattern else None
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._attr, None)
def __set__(self, obj, value: str):
if not isinstance(value, str):
raise TypeError(f"{self.name!r} must be str, got {type(value).__name__}")
value = value.strip()
if len(value) > self.max_length:
raise ValueError(f"{self.name!r} too long: {len(value)} > {self.max_length}")
if self._pattern and not self._pattern.fullmatch(value):
raise ValueError(f"{self.name!r} does not match required pattern")
setattr(obj, self._attr, value)
class PositiveFloat:
def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._attr, 0.0)
def __set__(self, obj, value):
value = float(value)
if value < 0:
raise ValueError(f"{self.name!r} must be non-negative, got {value}")
setattr(obj, self._attr, value)
class Product:
sku = StringField(max_length=20, pattern=r"[A-Z]{3}-\d{4}")
name = StringField(max_length=100)
price = PositiveFloat()
weight_kg = PositiveFloat()
def __init__(self, sku: str, name: str, price: float, weight_kg: float = 0.0):
self.sku = sku
self.name = name
self.price = price
self.weight_kg = weight_kg
@property
def price_formatted(self) -> str:
return f"${self.price:,.2f}"
def __repr__(self):
return (
f"Product(sku={self.sku!r}, name={self.name!r}, "
f"price={self.price}, weight_kg={self.weight_kg})"
)
p = Product("WGT-0042", "Deluxe Widget", 49.99, weight_kg=0.35)
print(repr(p))
# Product(sku='WGT-0042', name='Deluxe Widget', price=49.99, weight_kg=0.35)
print(p.price_formatted) # $49.99
try:
Product("invalid-sku", "Widget", 10) # ValueError - SKU pattern mismatch
except ValueError as e:
print(e)
try:
p.price = -5 # ValueError - negative price
except ValueError as e:
print(e)
Common Mistakes
Mistake 1 - Thinking __name Makes Attributes Private
# Misconception: __balance is inaccessible from outside
class Account:
def __init__(self, balance):
self.__balance = balance
a = Account(100)
# a.__balance → AttributeError (but this is NOT true privacy)
print(a._Account__balance) # 100 - the attribute is RIGHT THERE
Name mangling is name collision prevention for inheritance, not access control. Anyone can access the mangled name.
Mistake 2 - Property Getter Without Underlying Storage Key
# Wrong - infinite recursion
class Bad:
@property
def value(self):
return self.value # self.value calls __get__ again → RecursionError
# Right - use a different name for the backing storage
class Good:
@property
def value(self):
return self._value # _value is a plain instance attribute
@value.setter
def value(self, v):
self._value = v
Mistake 3 - Defining the Setter Without the Property
# Wrong - setter must be defined on an existing property object
class Bad:
@value.setter # NameError: name 'value' is not defined
def value(self, v):
self._value = v
# Right - define property first, then setter
class Good:
@property
def value(self):
return self._value
@value.setter # value is now defined as the property above
def value(self, v):
self._value = v
Mistake 4 - Descriptor Without __set_name__ Storing Wrong Name
# Wrong - hardcoding the attribute name in the descriptor
class Validated:
def __init__(self, name):
self._attr = f"_{name}" # must be told the name explicitly
class Product:
price = Validated("price") # you have to repeat "price" twice
# Right - use __set_name__
class Validated:
def __set_name__(self, owner, name):
self._attr = f"_{name}" # Python tells the descriptor its name
class Product:
price = Validated() # name is set automatically
Mistake 5 - Using cached_property with __slots__
# Wrong - cached_property writes to instance.__dict__, which doesn't exist with __slots__
class Bad:
__slots__ = ("_value",)
@cached_property
def computed(self): # will raise TypeError on first access
return expensive_computation()
# Right - use a manual cache slot, or remove __slots__
class Good:
__slots__ = ("_value", "_computed_cache")
@property
def computed(self):
if self._computed_cache is None:
self._computed_cache = expensive_computation()
return self._computed_cache
Engineering Checklist
Before moving to the next lesson, verify you can answer these without looking:
- What does a single underscore prefix (
_name) communicate, and what does Python enforce about it? - What transformation does Python apply to
__namedefined insideclass Foo? - Why does name mangling exist - what problem does it prevent?
- If
obj.attraccesses a property, at what level does thepropertyobject live - instance or class? - What makes a descriptor a "data descriptor" vs a "non-data descriptor", and why does it matter?
- In what order does Python look up
obj.x: class descriptors, instance__dict__, or something else? - What does
__set_name__receive as arguments, and when is it called? - Why does
@classmethodalways receivecls- which mechanism delivers it? - What happens when
cached_propertyis used on a class with__slots__?
Quick Reference
# Single underscore - convention only
self._internal = value
# Double underscore - name mangling
class Foo:
def __init__(self):
self.__secret = 42 # stored as _Foo__secret
# Access: foo._Foo__secret
# Property - controlled access
class MyClass:
@property
def value(self) -> int:
return self._value
@value.setter
def value(self, v: int) -> None:
if v < 0:
raise ValueError(f"must be non-negative, got {v}")
self._value = v
@value.deleter
def value(self) -> None:
del self._value
# Descriptor - reusable attribute behaviour
class TypeChecked:
def __set_name__(self, owner, name):
self.name = name
self._attr = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None: return self
return getattr(obj, self._attr, None)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
setattr(obj, self._attr, value)
class Config:
port = TypeChecked() # __set_name__ fires here
workers = TypeChecked()
Graded Practice
Level 1 - Predict the Output
Trace through the following code carefully. Predict each output before running.
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
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(100)
# Question 1:
print(t.celsius)
# Question 2:
print(t.fahrenheit)
# Question 3:
t.celsius = 0
print(t.fahrenheit)
# Question 4:
try:
t.celsius = -300
except ValueError as e:
print(e)
# Question 5:
try:
t.fahrenheit = 212
except AttributeError as e:
print(type(e).__name__)
Show Answer
# Question 1: print(t.celsius)
100
# The getter returns self._celsius which was set to 100 in __init__
# Question 2: print(t.fahrenheit)
212.0
# 100 * 9 / 5 + 32 = 180 + 32 = 212.0
# Question 3: t.celsius = 0; print(t.fahrenheit)
32.0
# Setter validates 0 >= -273.15 (passes), stores 0; 0 * 9/5 + 32 = 32.0
# Question 4: t.celsius = -300 → ValueError
Below absolute zero!
# -300 < -273.15 triggers the guard in the setter
# Question 5: t.fahrenheit = 212 → AttributeError
AttributeError
# fahrenheit is a read-only property (getter only, no setter)
# Python raises AttributeError: can't set attribute
Key concepts: the getter returns the backing _celsius attribute; the setter validates before storing; a property without a setter raises AttributeError on assignment; computed properties (fahrenheit) always derive from the stored value.
Level 2 - Debug Challenge
The following descriptor-based class has three bugs. Find and fix them all.
class RangedInt:
"""Descriptor that enforces an integer within [min_val, max_val]."""
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
# Bug 1: attribute name not set up
def __get__(self, obj, objtype=None):
return getattr(obj, self._attr) # Bug 2: what if obj is None?
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"must be int, got {type(value).__name__}")
if not (self.min_val <= value <= self.max_val):
raise ValueError(f"must be in [{self.min_val}, {self.max_val}], got {value}")
setattr(obj, self._attr, value) # Bug 3: _attr never set if __set_name__ missing
class Config:
workers = RangedInt(1, 32)
timeout = RangedInt(1, 300)
def __init__(self, workers, timeout):
self.workers = workers
self.timeout = timeout
c = Config(4, 30)
print(c.workers) # should print 4
print(Config.workers) # should return the descriptor, not raise
Show Answer
Bug 1 and Bug 3: RangedInt has no __set_name__ method, so self._attr is never set. Both __get__ and __set__ reference self._attr, which will raise AttributeError immediately.
Bug 2: __get__ does not handle obj is None (class-level access). When you access Config.workers, obj is None and getattr(None, self._attr) raises AttributeError rather than returning the descriptor itself.
Fixed version:
class RangedInt:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
self._attr = None # will be set by __set_name__
def __set_name__(self, owner, name): # Fix 1 and 3: add __set_name__
self._attr = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None: # Fix 2: handle class-level access
return self
return getattr(obj, self._attr)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"must be int, got {type(value).__name__}")
if not (self.min_val <= value <= self.max_val):
raise ValueError(f"must be in [{self.min_val}, {self.max_val}], got {value}")
setattr(obj, self._attr, value)
class Config:
workers = RangedInt(1, 32)
timeout = RangedInt(1, 300)
def __init__(self, workers, timeout):
self.workers = workers
self.timeout = timeout
c = Config(4, 30)
print(c.workers) # 4
print(Config.workers) # <__main__.RangedInt object at 0x...>
try:
c.workers = 0 # ValueError: must be in [1, 32], got 0
except ValueError as e:
print(e)
Level 3 - Design Challenge
Design a BankAccount class that:
- Has a
balanceproperty that is read-only from outside (no public setter) - Has
deposit(amount)andwithdraw(amount)methods that validate the amount (must be positive) and for withdrawals check sufficient funds - Has a
_transaction_historylist (single underscore - internal) that logs every transaction as a tuple(type, amount, new_balance) - Has a
transaction_countread-only property - Uses double underscore (
__owner) for an owner name that must not be overridden by subclasses - A
SavingsAccountsubclass adds aminimum_balanceconstraint - withdrawals that would go below the minimum are rejected
Show Answer
class BankAccount:
def __init__(self, owner: str, initial_balance: float = 0.0):
self.__owner = owner # double underscore: subclass collision prevention
self._balance = initial_balance # backing store for property
self._transaction_history = [] # single underscore: internal
@property
def owner(self) -> str:
return self.__owner
@property
def balance(self) -> float:
return self._balance
@property
def transaction_count(self) -> int:
return len(self._transaction_history)
def _record(self, txn_type: str, amount: float) -> None:
"""Internal: record a transaction."""
self._transaction_history.append((txn_type, amount, self._balance))
def deposit(self, amount: float) -> None:
if not isinstance(amount, (int, float)) or amount <= 0:
raise ValueError(f"deposit amount must be positive, got {amount!r}")
self._balance += amount
self._record("deposit", amount)
def withdraw(self, amount: float) -> None:
if not isinstance(amount, (int, float)) or amount <= 0:
raise ValueError(f"withdrawal amount must be positive, got {amount!r}")
if amount > self._balance:
raise ValueError(
f"insufficient funds: balance={self._balance:.2f}, requested={amount:.2f}"
)
self._balance -= amount
self._record("withdrawal", amount)
def __repr__(self) -> str:
return f"BankAccount(owner={self.__owner!r}, balance={self._balance:.2f})"
class SavingsAccount(BankAccount):
def __init__(self, owner: str, initial_balance: float = 0.0, minimum_balance: float = 100.0):
super().__init__(owner, initial_balance)
self._minimum_balance = minimum_balance
def withdraw(self, amount: float) -> None:
if self._balance - amount < self._minimum_balance:
raise ValueError(
f"withdrawal would breach minimum balance of {self._minimum_balance:.2f}"
)
super().withdraw(amount) # delegates to BankAccount.withdraw for validation
def __repr__(self) -> str:
return (
f"SavingsAccount(owner={self.owner!r}, balance={self.balance:.2f}, "
f"minimum={self._minimum_balance:.2f})"
)
# Demo
acc = BankAccount("Alice", 500.0)
acc.deposit(200)
acc.withdraw(100)
print(repr(acc)) # BankAccount(owner='Alice', balance=600.00)
print(acc.transaction_count) # 2
savings = SavingsAccount("Bob", 500.0, minimum_balance=100.0)
savings.deposit(100)
savings.withdraw(400) # balance = 200 - still above 100 minimum, allowed
print(repr(savings)) # SavingsAccount(owner='Bob', balance=200.00, minimum=100.00)
try:
savings.withdraw(150) # would leave 50, below minimum
except ValueError as e:
print(e) # withdrawal would breach minimum balance of 100.00
# Double underscore protects __owner across subclasses:
# savings.__owner → AttributeError
# savings._BankAccount__owner → 'Bob' (accessible via mangled name)
print(savings._BankAccount__owner) # Bob - demonstrating it's not truly private
Key Takeaways
- Single underscore (
_name) is a convention, not enforcement. It signals "internal use" and is respected byimport *, but nothing prevents access from outside. - Double underscore (
__name) is name mangling:__attrinclass Foois stored as_Foo__attr. It exists to prevent accidental attribute collisions in inheritance hierarchies - not to create private attributes. Anyone can still access_Foo__attrdirectly. @propertylets you intercept attribute reads and writes without changing the caller's API. Use it only when you need validation, type coercion, computed values, or side effects - not as a default for every attribute.- A property without a setter is read-only; attempting assignment raises
AttributeError. - The descriptor protocol (
__get__,__set__,__delete__) is the underlying mechanism for@property,@classmethod, and@staticmethod. Any class that defines these methods can be used as a class-level attribute and gains the same intercept power. - Data descriptors (define
__set__) take priority overinstance.__dict__. Non-data descriptors (define only__get__) are overridden byinstance.__dict__. This ordering explains why property setters work even when the instance dict is checked first. __set_name__(Python 3.6+) is called automatically when a descriptor is assigned to a class attribute, providing the attribute name without manual configuration.- Avoid
__slots__by default. It breakscached_property, complicates multiple inheritance, and removes the ability to add attributes dynamically. Use it only when profiling shows that__dict__memory overhead is a measurable problem.
What's Next
Lesson 06 covers inheritance at engineering depth - what inheritance actually does at the namespace level, single and multiple inheritance, the Method Resolution Order (MRO), cooperative super(), the fragile base class problem, isinstance and issubclass, and when inheritance is the right tool (true is-a relationships) versus when composition is better.
Inheritance is the most frequently misused feature in OOP. The MRO, which you have seen referenced in earlier lessons, is now covered in full - because without understanding it, multiple inheritance and mixin patterns will remain mysterious.
