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

  1. Data descriptors (descriptors with __set__ from the class/parent chain)
  2. Instance `__dict__` (instance variables)
  3. 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

MethodWhen CalledPurpose
get(self, instance, owner)obj.attrRead attribute
set(self, instance, value)obj.attr = valueWrite attribute
delete(self, instance)del obj.attrDelete attribute

When to Use What?

SituationUseReason
Simple getter/setter@propertyConvenient and clean
Reuse across classesDescriptor classDRY principle
Type/range validationDescriptorReusable
Computed attributes@propertyAuto-calculation
Logging/cachingDescriptorTransparent additions

What We Learned

  1. Everything is an object: Classes, functions, methods are all objects
  2. Descriptors: Protocol for controlling attribute access
  3. Bound methods: Method binding implemented via descriptors
  4. 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