Skip to content

Attribute Lookup

When you access an attribute on a Python object, the interpreter does not simply retrieve a stored value. Instead, it follows a well-defined search chain that moves from the instance to its class and then up through the class hierarchy. Understanding this lookup mechanism is essential for working with inheritance, class design, and advanced features like descriptors.

Lookup Order

1. Instance, Class, Parent

When you write obj.attr, Python searches in a specific order: it checks the instance's own namespace first, then the class that created the instance, and finally any parent classes following the method resolution order (MRO). The first match wins.

class Parent:
    x = "parent"

class Child(Parent):
    y = "child"

obj = Child()
obj.z = "instance"

print(obj.z)  # instance (found in instance)
print(obj.y)  # child (found in class)
print(obj.x)  # parent (found in parent)

In this example, obj.z is found directly on the instance. The name obj.y is not on the instance, so Python checks Child and finds it there. Finally, obj.x is not on the instance or on Child, so Python continues up to Parent where it finds the match.

The dict Dictionary

Every object and class in Python maintains a __dict__ dictionary that stores its attributes. This dictionary is the underlying data structure that powers the attribute lookup chain described above.

1. Instance Dict

Attributes set through self.x = value inside a method (or directly on the instance) are stored in the instance's __dict__. This is the first place Python looks during attribute resolution.

class MyClass:
    def __init__(self, x):
        self.x = x

obj = MyClass(10)
print(obj.__dict__)  # {'x': 10}

2. Class Dict

Class-level attributes, methods, and other definitions live in the class's __dict__. Python searches here after checking the instance namespace.

print(MyClass.__dict__)
# Contains methods and class attributes

Summary

  • Python resolves attribute access by searching the instance namespace first, then the class, and finally parent classes in MRO order.
  • The __dict__ dictionary on each object and class is the storage mechanism behind this lookup chain.
  • This dynamic lookup process runs every time you access an attribute, which gives Python its flexibility for runtime modifications.

Runnable Example: properties_tutorial.py

"""
05: Properties and Decorators (@property)

Properties provide a way to customize access to instance attributes.
They allow you to use getter/setter methods while maintaining simple attribute syntax.
"""

# ============================================================================
# Example 1: Problem without properties
class TemperatureBasic:
    def __init__(self, celsius):
        self.celsius = celsius

if __name__ == "__main__":

    temp = TemperatureBasic(25)
    print("WITHOUT PROPERTIES:")
    print(f"Temperature: {temp.celsius}°C")

    # Problem: No validation
    temp.celsius = -500  # Unrealistic temperature!
    print(f"After invalid assignment: {temp.celsius}°C (This shouldn't be allowed!)")


    # ============================================================================
    # Example 2: Using properties with @property decorator
    class Temperature:
        def __init__(self, celsius):
            self._celsius = celsius  # Protected attribute

        @property
        def celsius(self):
            """Getter for celsius"""
            return self._celsius

        @celsius.setter
        def celsius(self, value):
            """Setter with validation"""
            if value < -273.15:  # Absolute zero
                raise ValueError("Temperature cannot be below absolute zero (-273.15°C)")
            self._celsius = value

        @property
        def fahrenheit(self):
            """Calculated property"""
            return (self._celsius * 9/5) + 32

        @fahrenheit.setter
        def fahrenheit(self, value):
            """Set temperature using Fahrenheit"""
            self.celsius = (value - 32) * 5/9

        @property
        def kelvin(self):
            """Another calculated property"""
            return self._celsius + 273.15

    print("\n" + "="*50)
    print("WITH PROPERTIES:")
    temp = Temperature(25)
    print(f"Temperature: {temp.celsius}°C")
    print(f"In Fahrenheit: {temp.fahrenheit}°F")
    print(f"In Kelvin: {temp.kelvin}K")

    # Now validation happens automatically
    try:
        temp.celsius = -500
    except ValueError as e:
        print(f"Validation works! Error: {e}")

    # Can set using different units
    temp.fahrenheit = 86
    print(f"\nSet to 86°F = {temp.celsius}°C")


    # ============================================================================
    # Example 3: Read-only properties
    class Circle:
        def __init__(self, radius):
            self._radius = radius

        @property
        def radius(self):
            return self._radius

        @radius.setter
        def radius(self, value):
            if value < 0:
                raise ValueError("Radius cannot be negative")
            self._radius = value

        @property
        def diameter(self):
            """Read-only property (no setter)"""
            return self._radius * 2

        @property
        def area(self):
            """Read-only calculated property"""
            return 3.14159 * self._radius ** 2

        @property
        def circumference(self):
            """Read-only calculated property"""
            return 2 * 3.14159 * self._radius

    print("\n" + "="*50)
    print("READ-ONLY PROPERTIES:")
    circle = Circle(5)
    print(f"Radius: {circle.radius}")
    print(f"Diameter: {circle.diameter}")
    print(f"Area: {circle.area:.2f}")
    print(f"Circumference: {circle.circumference:.2f}")

    # Can change radius
    circle.radius = 10
    print(f"\nAfter changing radius to 10:")
    print(f"Diameter: {circle.diameter}")
    print(f"Area: {circle.area:.2f}")

    # Cannot change diameter directly (no setter)
    try:
        circle.diameter = 50
    except AttributeError as e:
        print(f"\nCannot set diameter: {e}")


    # ============================================================================
    # Example 4: Properties with validation logic
    class Person:
        def __init__(self, name, age):
            self._name = name
            self._age = age
            self._email = None

        @property
        def name(self):
            return self._name

        @name.setter
        def name(self, value):
            if not value or not value.strip():
                raise ValueError("Name cannot be empty")
            self._name = value.strip()

        @property
        def age(self):
            return self._age

        @age.setter
        def age(self, value):
            if not isinstance(value, int):
                raise TypeError("Age must be an integer")
            if value < 0 or value > 150:
                raise ValueError("Age must be between 0 and 150")
            self._age = value

        @property
        def email(self):
            return self._email

        @email.setter
        def email(self, value):
            if value and '@' not in value:
                raise ValueError("Invalid email format")
            self._email = value

        @property
        def is_adult(self):
            """Computed property"""
            return self._age >= 18

    print("\n" + "="*50)
    print("VALIDATION WITH PROPERTIES:")
    person = Person("Alice", 25)
    print(f"{person.name} is {person.age} years old")
    print(f"Is adult: {person.is_adult}")

    # Validation in action
    try:
        person.age = -5
    except ValueError as e:
        print(f"Validation error: {e}")

    try:
        person.email = "invalid-email"
    except ValueError as e:
        print(f"Email validation error: {e}")

    person.email = "alice@example.com"
    print(f"Valid email set: {person.email}")


    # ============================================================================
    # Example 5: Properties with lazy loading
    class DataProcessor:
        def __init__(self, filename):
            self._filename = filename
            self._data = None  # Not loaded yet
            self._processed = None

        @property
        def data(self):
            """Lazy loading - only load when accessed"""
            if self._data is None:
                print(f"Loading data from {self._filename}...")
                # Simulate loading data
                self._data = [1, 2, 3, 4, 5]
            return self._data

        @property
        def processed_data(self):
            """Process data only when needed"""
            if self._processed is None:
                print("Processing data...")
                self._processed = [x * 2 for x in self.data]
            return self._processed

    print("\n" + "="*50)
    print("LAZY LOADING WITH PROPERTIES:")
    processor = DataProcessor("data.txt")
    print("DataProcessor created (data not loaded yet)")
    print(f"Accessing data: {processor.data}")  # Loads here
    print(f"Accessing again: {processor.data}")  # Uses cached version
    print(f"Processed: {processor.processed_data}")


    # ============================================================================
    # Example 6: Using properties for data transformation
    class Product:
        def __init__(self, name, price):
            self._name = name
            self._price = price
            self._discount = 0

        @property
        def price(self):
            return self._price

        @price.setter
        def price(self, value):
            if value < 0:
                raise ValueError("Price cannot be negative")
            self._price = value

        @property
        def discount(self):
            return self._discount

        @discount.setter
        def discount(self, value):
            if not 0 <= value <= 100:
                raise ValueError("Discount must be between 0 and 100")
            self._discount = value

        @property
        def final_price(self):
            """Calculated property with discount"""
            return self._price * (1 - self._discount / 100)

        @property
        def savings(self):
            """How much money is saved"""
            return self._price - self.final_price

    print("\n" + "="*50)
    print("DATA TRANSFORMATION:")
    product = Product("Laptop", 1000)
    print(f"Original price: ${product.price}")
    product.discount = 20
    print(f"With 20% discount: ${product.final_price:.2f}")
    print(f"You save: ${product.savings:.2f}")


    # ============================================================================
    # Example 7: Properties vs regular methods comparison
    class Rectangle:
        def __init__(self, length, width):
            self._length = length
            self._width = width

        # Using property
        @property
        def area(self):
            return self._length * self._width

        # Using method
        def calculate_area(self):
            return self._length * self._width

    print("\n" + "="*50)
    print("PROPERTY VS METHOD:")
    rect = Rectangle(5, 3)

    # Property: accessed like an attribute
    print(f"Using property: {rect.area}")

    # Method: needs parentheses
    print(f"Using method: {rect.calculate_area()}")

    # Property is more intuitive for calculated values


    # Key Takeaways:
    # 1. @property makes methods accessible like attributes
    # 2. Provides encapsulation - hide implementation details
    # 3. Allows validation when setting values
    # 4. Can create read-only properties (no setter)
    # 5. Good for computed/derived values
    # 6. Maintains clean syntax while adding functionality
    # 7. Can implement lazy loading
    # 8. Use properties when value needs computation or validation
    # 9. Use regular methods when action/operation is being performed