Python Lecture 04 - Class Internals and Descriptors
Class Internals
In Python, "everything is an object." So what does the inside of a class look like?
__dict__: Attribute Storage
In Python, object attributes 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'}
What actually happens when accessing attributes:
# Code we 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"
Memory Structure:
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__: Class Reference
Every object knows which class it was created from.
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"
Attribute Lookup Order
Let's see how Python finds attributes.
class Animal:
species = "Unknown"
def speak(self):
return "..."
class Cat(Animal):
species = "Felis catus"
cat = Cat()
print(cat.species) # "Felis catus"
print(cat.speak()) # "..."
Attribute lookup process:
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
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 order is called MRO (Method Resolution Order).
# Check MRO
print(Cat.__mro__)
# (<class 'Cat'>, <class 'Animal'>, <class 'object'>)
Bound Methods
We've explored the internal structure of classes through __dict__ and __class__. Now let's see how methods work internally.
Two Faces of Methods
As it turns out, 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 perspective it's a function, but from the instance perspective it's a method.
What is a Bound Method?
A Bound Method is a method "bound" to a specific instance.
calc = Calculator(10)
# calc.add is bound to calc instance
print(calc.add)
# <bound method Calculator.add of <__main__.Calculator object at 0x...>>
# You can store the method in a variable
add_method = calc.add
add_method(5) # calc.value becomes 15
print(calc.value) # 15
The Secret of Bound Methods
Let's see how Python binds methods:
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
The secret of method calls:
# Code we 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
This is the true identity of the self parameter!
Inside Bound Methods
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: Callback Functions
Bound methods are frequently used as callback functions:
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)"
Descriptors
Now that we understand how bound methods work, let's look at the more fundamental mechanism behind them. Descriptors are lesser-known but one of Python's most powerful features.
What is a Descriptor?
A Descriptor is an object that controls attribute access.
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"
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 Descriptors vs Non-Data Descriptors
# 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)
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: Convenient Descriptors
@property Decorator
Creating descriptor classes every time is cumbersome. @property is an easier way to use descriptors:
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
The True Nature of property
@property is actually a descriptor!
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
A powerful feature 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
Methods Are Descriptors Too
Now that we've covered descriptors and properties, here's the final piece of the puzzle. The bound methods we learned about earlier can be fully explained 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!
Actual Python function objects have a __get__ method:
def my_function(self, x):
return x
# Functions are descriptors!
print(hasattr(my_function, '__get__')) # True
Summary
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 โ parent classes in MRO order.
Bound Methods
# Method call
obj.method(x)
# Actual internal operation
method = obj.__class__.method # Get function
method.__get__(obj, type(obj))(x) # Descriptor protocol
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?
| Situation | Use | Reason |
|---|---|---|
| Simple getter/setter | @property | Convenient and clean |
| Reuse across classes | Descriptor class | DRY principle |
| Type/range validation | Descriptor | Reusable |
| Computed attributes | @property | Auto-calculation |
| Logging/caching | Descriptor | Transparent additions |
What We Learned
- Everything is an object: Classes, functions, methods are all objects
- Descriptors: Protocol for controlling attribute access
- Bound methods: Method binding implemented via descriptors
- Properties: Convenient wrapper around descriptors
Why Does This Matter?
Understanding descriptors lets you:
- See how @property, @staticmethod, and @classmethod work internally
- Build reusable custom validation logic
- Recognize that Python's "magical" behaviors are built on a consistent protocol