Python Lecture 04 - Class Internals and Descriptors
Deep dive into how Python classes work internally, uncovering the secrets of bound methods and descriptors
Where to go next
Class Internals
In Python, "everything is an object." So what does the inside of a class actually look like?
__dict__: Where Attributes Live
Object attributes in Python are stored as a dictionary.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Alice", 25)
# Check object's attributes directly
print(person.__dict__)
# {'name': 'Alice', 'age': 25}
# Add attribute
person.city = "Seoul"
print(person.__dict__)
# {'name': 'Alice', 'age': 25, 'city': 'Seoul'}
When you access an attribute, here's what's really happening:
# Code you write
print(person.name)
# What Python actually does
print(person.__dict__['name'])
Class Variables vs Instance Variables
class Dog:
# Class variable: shared by all dogs
species = "Canis familiaris"
legs = 4
def __init__(self, name):
# Instance variable: different for each dog
self.name = name
dog1 = Dog("Buddy")
dog2 = Dog("Max")
print(dog1.name) # "Buddy" (different for each)
print(dog2.name) # "Max"
print(dog1.legs) # 4 (same for all)
print(dog2.legs) # 4
# Changing class variable?
Dog.species = "Canis lupus"
print(dog1.species) # "Canis lupus" (all changed!)
print(dog2.species) # "Canis lupus"
Here's how it looks in memory:
graph BT
A["dog1
name: Buddy"] -->|__class__| C
B["dog2
name: Max"] -->|__class__| C
C["Dog class
species: Canis familiaris
legs: 4"]
style C fill:#e8f5e9
style A fill:#e3f2fd
style B fill:#e3f2fd
__class__: Knowing Your Origin
Every object knows which class created it.
print(dog1.__class__) # <class '__main__.Dog'>
print(dog1.__class__.__name__) # "Dog"
# Objects from same class
print(dog1.__class__ is dog2.__class__) # True
# Access class variables
print(dog1.__class__.species) # "Canis familiaris"
How Python Finds Attributes
When you access an attribute, Python searches in a specific order.
class Animal:
species = "Unknown"
def speak(self):
return "..."
class Cat(Animal):
species = "Felis catus"
cat = Cat()
print(cat.species) # "Felis catus"
print(cat.speak()) # "..."
For cat.species, Python first checks cat.__dict__. Not there. Then checks the Cat class. Found it.
flowchart TD
S1["cat.species access"] --> A1{"'species' in
cat.__dict__?"}
A1 -->|Not found| B1{"'species' in
Cat class?"}
B1 -->|Found!| C1["Return Cat.species
(Felis catus)"]
style S1 fill:#e3f2fd
style C1 fill:#e8f5e9
For cat.speak(), Python checks the instance, then Cat, then goes up to Animal where it finds the method.
flowchart TD
S2["cat.speak() call"] --> A2{"'speak' in
cat.__dict__?"}
A2 -->|Not found| B2{"'speak' in
Cat class?"}
B2 -->|Not found| C2{"'speak' in
Animal class?"}
C2 -->|Found!| D2["Call Animal.speak"]
style S2 fill:#e3f2fd
style D2 fill:#e8f5e9
This search order is called MRO (Method Resolution Order).
# Check MRO
print(Cat.__mro__)
# (<class 'Cat'>, <class 'Animal'>, <class 'object'>)
Bound Methods
We've looked at the internal structure through __dict__ and __class__. Now let's see how methods actually work.
Two Faces of a Method
Here's something surprising. Class methods have two forms.
class Calculator:
def __init__(self, value):
self.value = value
def add(self, n):
self.value += n
return self.value
calc = Calculator(10)
# Two faces of a method
print(type(Calculator.add)) # <class 'function'>
print(type(calc.add)) # <class 'method'>
From the class, it's a function. From the instance, it's a method. Same thing, two perspectives.
What is a Bound Method?
A Bound Method is a method "bound" to a specific instance.
calc = Calculator(10)
# calc.add is bound to the calc instance
print(calc.add)
# <bound method Calculator.add of <__main__.Calculator object at 0x...>>
# You can store it in a variable
add_method = calc.add
add_method(5) # calc.value becomes 15
print(calc.value) # 15
How Binding Actually Works
class MyClass:
def method(self, x):
return f"Value: {x}"
obj = MyClass()
# These two lines do exactly the same thing!
obj.method(42) # Normal way
MyClass.method(obj, 42) # Direct call
Here's the secret:
# Code you write
result = obj.method(42)
# What Python actually does:
method_func = MyClass.method # 1. Get the function
result = method_func(obj, 42) # 2. Pass obj as first argument
That's the true identity of self. Python automatically passes the instance as the first argument.
Inside a Bound Method
Let's go deeper.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
counter = Counter()
# Bound method details
bound = counter.increment
print(bound.__self__) # Points to counter object
print(bound.__func__) # Points to Counter.increment function
# Bound method works internally like:
# bound() โ bound.__func__(bound.__self__)
Real Example: Callbacks
Bound methods come in handy as callbacks.
class Button:
def __init__(self, label):
self.label = label
self.click_count = 0
def on_click(self):
self.click_count += 1
print(f"{self.label} button clicked ({self.click_count} times)")
# Create buttons
save_button = Button("Save")
cancel_button = Button("Cancel")
# Register methods as callbacks
callbacks = [save_button.on_click, cancel_button.on_click]
# Simulate button clicks
callbacks[0]() # "Save button clicked (1 times)"
callbacks[0]() # "Save button clicked (2 times)"
callbacks[1]() # "Cancel button clicked (1 times)"
Each bound method remembers which instance it belongs to. That's why callbacks just work.
Descriptors
Now that we understand bound methods, let's look at the mechanism behind them. Descriptors are lesser-known but incredibly powerful.
What is a Descriptor?
A Descriptor is an object that controls how attributes are accessed.
class Descriptor:
def __get__(self, obj, objtype=None):
print("__get__ called")
return obj._value
def __set__(self, obj, value):
print(f"__set__ called: {value}")
obj._value = value
def __delete__(self, obj):
print("__delete__ called")
del obj._value
class MyClass:
attr = Descriptor() # Descriptor as class variable
def __init__(self):
self._value = 0
obj = MyClass()
obj.attr = 10 # "__set__ called: 10"
print(obj.attr) # "__get__ called" โ 10
del obj.attr # "__delete__ called"
The Descriptor Protocol
Descriptors implement three special methods:
class Descriptor:
def __get__(self, instance, owner):
"""
instance: object accessing the attribute (obj in obj.attr)
owner: object's class (MyClass)
"""
pass
def __set__(self, instance, value):
"""
instance: object setting the attribute
value: value to set
"""
pass
def __delete__(self, instance):
"""
instance: object deleting the attribute
"""
pass
Data vs Non-Data Descriptors
This distinction matters for priority.
# Data descriptor: has __set__ or __delete__
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
# Non-data descriptor: only has __get__
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class MyClass:
data_desc = DataDescriptor()
non_data_desc = NonDataDescriptor()
obj = MyClass()
# Instance attribute vs descriptor priority
obj.__dict__['non_data_desc'] = "instance attr"
print(obj.non_data_desc) # "instance attr" (instance wins)
obj.__dict__['data_desc'] = "instance attr"
print(obj.data_desc) # "data descriptor" (data descriptor wins)
Data descriptors beat instance attributes. Non-data descriptors lose to them. This priority order is at the heart of Python's attribute system.
Real Example 1: Type Validation
class TypedProperty:
"""Descriptor that enforces type"""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be of type {self.expected_type.__name__}. "
f"Got {type(value).__name__}."
)
instance.__dict__[self.name] = value
class Person:
name = TypedProperty("name", str)
age = TypedProperty("age", int)
def __init__(self, name, age):
self.name = name
self.age = age
# Works normally
person = Person("Alice", 25)
print(person.name) # "Alice"
# Type error!
try:
person.age = "twenty-five"
except TypeError as e:
print(e) # "age must be of type int. Got str."
Real Example 2: Value Range Validation
class RangeValue:
"""Descriptor that limits value range"""
def __init__(self, name, min_value=None, max_value=None):
self.name = name
self.min_value = min_value
self.max_value = max_value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if self.min_value is not None and value < self.min_value:
raise ValueError(
f"{self.name} must be at least {self.min_value}"
)
if self.max_value is not None and value > self.max_value:
raise ValueError(
f"{self.name} must be at most {self.max_value}"
)
instance.__dict__[self.name] = value
class Game:
health = RangeValue("health", min_value=0, max_value=100)
level = RangeValue("level", min_value=1)
def __init__(self):
self.health = 100
self.level = 1
def take_damage(self, damage):
self.health -= damage
# Usage
game = Game()
print(game.health) # 100
game.take_damage(30)
print(game.health) # 70
try:
game.take_damage(80) # health would become -10
except ValueError as e:
print(e) # "health must be at least 0"
Real Example 3: Logging Descriptor
class LoggedProperty:
"""Descriptor that logs attribute access"""
def __init__(self, name):
self.name = name
self.internal_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
value = getattr(instance, self.internal_name, None)
print(f"[GET] {self.name} = {value}")
return value
def __set__(self, instance, value):
print(f"[SET] {self.name} = {value}")
setattr(instance, self.internal_name, value)
class Account:
balance = LoggedProperty("balance")
def __init__(self, initial_balance):
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
# Usage
account = Account(1000)
# [SET] balance = 1000
print(account.balance)
# [GET] balance = 1000
account.deposit(500)
# [GET] balance = 1000
# [SET] balance = 1500
Properties: The Easy Way
The @property Decorator
Writing descriptor classes every time is tedious. @property gives you the same power with less code.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Radius getter"""
return self._radius
@radius.setter
def radius(self, value):
"""Radius setter"""
if value < 0:
raise ValueError("Radius must be non-negative")
self._radius = value
@property
def area(self):
"""Area (read-only)"""
return 3.14159 * self._radius ** 2
@property
def diameter(self):
"""Diameter"""
return self._radius * 2
@diameter.setter
def diameter(self, value):
self._radius = value / 2
# Usage
circle = Circle(5)
print(circle.radius) # 5
print(circle.area) # 78.53975
circle.radius = 10
print(circle.area) # 314.159
# Setting diameter automatically calculates radius
circle.diameter = 20
print(circle.radius) # 10.0
Property IS a Descriptor
Here's the thing -- @property is just a descriptor under the hood.
class MyProperty:
"""Simple implementation of property"""
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def setter(self, fset):
return MyProperty(self.fget, fset)
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@MyProperty
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Cannot be lower than absolute zero")
self._celsius = value
# Test it works
temp = Temperature(25)
print(temp.celsius) # 25
Computed Attributes
One powerful use of properties is computed attributes.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""Area is computed"""
return self.width * self.height
@property
def perimeter(self):
"""Perimeter is computed"""
return 2 * (self.width + self.height)
rect = Rectangle(10, 20)
print(rect.area) # 200
print(rect.perimeter) # 60
# Changing width automatically changes area
rect.width = 15
print(rect.area) # 300
You access area like a regular attribute, but it's computed fresh every time. No need to remember to update it.
Methods Are Descriptors Too
Here's the final piece of the puzzle. Remember bound methods? They're explained entirely through descriptors.
Functions Are Descriptors
class Function:
"""Simple implementation of method"""
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
# Access from class: return just the function
return self.func
# Access from instance: create bound method
return lambda *args, **kwargs: self.func(instance, *args, **kwargs)
class MyClass:
@Function
def method(self, x):
return f"{self} value: {x}"
obj = MyClass()
# Access from class
print(type(MyClass.method)) # function
# Access from instance
print(type(obj.method)) # function (not method in simple impl)
print(obj.method(42)) # Works!
Real Python function objects have a __get__ method built in:
def my_function(self, x):
return x
# Functions are descriptors!
print(hasattr(my_function, '__get__')) # True
When you call obj.method(x), Python finds the function in the class, calls its __get__ to create a bound method, and then calls that. It's descriptors all the way down.
The Big Picture
Class Internals
graph TD
A["Instance
obj"]
B["obj.__dict__
Instance attributes"]
C["obj.__class__
Class reference"]
D["Class
MyClass"]
E["Class variables
Descriptors"]
A --> B
A --> C
C --> D
D --> E
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#e8f5e9
Attribute Lookup Priority
- Data descriptors (descriptors with
__set__from the class/parent chain) - Instance `__dict__` (instance variables)
- Non-data descriptors and regular class variables (descriptors with only
__get__, or non-descriptor variables from the class/parent chain)
At each step, Python searches the current class, then parent classes in MRO order.
Descriptor Protocol
| Method | When Called | Purpose |
|---|---|---|
get(self, instance, owner) | obj.attr | Read attribute |
set(self, instance, value) | obj.attr = value | Write attribute |
delete(self, instance) | del obj.attr | Delete attribute |
When to Use What
For simple getter/setter logic, use @property. It's clean and easy.
When you need the same validation across multiple classes, write a descriptor class. That's the DRY approach.
For computed attributes that should look like regular attributes, @property is perfect.
For cross-cutting concerns like logging or caching, descriptors let you add behavior transparently.
Everything in Python -- classes, functions, methods -- is an object. Descriptors are the protocol that controls attribute access. Bound methods are implemented through descriptors. And @property is just a convenient wrapper around the same mechanism.
Once you see this, things like @staticmethod and @classmethod stop being magic. They're all built on the same consistent protocol.