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

  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, then parent classes in MRO order.

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

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.