Why Do Python's Empty Classes and Functions Work as Arbitrary Data Containers, But Not Built-in Objects?

Python is celebrated for its flexibility and "everything is an object" philosophy. This means even classes, functions, and integers are objects with attributes and methods. But a curious observation often puzzles developers: empty classes and functions happily accept arbitrary attributes, while built-in objects like int, str, or list stubbornly resist. For example:

# Empty class: Works!  
class EmptyClass: pass  
obj = EmptyClass()  
obj.arbitrary_attr = "I work!"  
print(obj.arbitrary_attr)  # Output: "I work!"  
 
# Function: Works!  
def empty_func(): pass  
empty_func.data = [1, 2, 3]  
print(empty_func.data)  # Output: [1, 2, 3]  
 
# Built-in object: Fails!  
my_int = 42  
my_int.new_attr = "Oops"  # Raises AttributeError: 'int' object has no attribute 'new_attr'  

Why this discrepancy? In this blog, we’ll dive into Python’s data model to uncover the technical reasons behind this behavior, exploring concepts like __dict__, memory optimization, and object implementation.

Table of Contents#

  1. Understanding Arbitrary Data Containers
  2. Python’s Empty Classes as Data Containers
  3. Python Functions as Data Containers
  4. Built-in Objects: Why They Resist
  5. The Technical Reason: __dict__ and Attribute Dictionaries
  6. When to Use Which? (Best Practices)
  7. Conclusion
  8. References

1. Understanding Arbitrary Data Containers#

An "arbitrary data container" is an object that lets you dynamically add, modify, or delete attributes not predefined in its class. For example, if you create an empty class Person, you can later add name, age, or email attributes to its instances without modifying the class definition.

This flexibility is useful for quick prototyping, caching, or storing metadata. But why do some Python objects allow this while others don’t? The answer lies in how Python manages object attributes under the hood.

2. Python’s Empty Classes as Data Containers#

Empty classes (classes with no defined attributes or methods) are surprisingly powerful as data containers. Let’s break down why:

How Empty Classes Store Attributes#

By default, user-defined Python classes (including empty ones) assign each instance a special dictionary called __dict__. This dictionary stores all dynamic attributes of the instance. Here’s how it works:

class EmptyClass:  
    pass  # No attributes or methods defined  
 
# Create an instance  
obj = EmptyClass()  
 
# Add an arbitrary attribute  
obj.color = "blue"  
 
# The attribute is stored in obj.__dict__  
print(obj.__dict__)  # Output: {'color': 'blue'}  

The __dict__ attribute is automatically created for instances of user-defined classes, enabling dynamic attribute assignment. Even if the class is empty, Python assumes you might want to add attributes later and allocates space for __dict__.

Explicit Control with __slots__#

If you want to restrict dynamic attributes (e.g., for memory efficiency), you can define __slots__ in the class. __slots__ explicitly lists allowed attributes and disables __dict__ by default:

class RestrictedClass:  
    __slots__ = ("name",)  # Only "name" is allowed  
 
obj = RestrictedClass()  
obj.name = "Alice"  # Works  
 
# Trying to add an arbitrary attribute fails  
obj.age = 30  # Raises AttributeError: 'RestrictedClass' object has no attribute 'age'  
 
# No __dict__ exists!  
print(hasattr(obj, "__dict__"))  # Output: False  

This confirms that __dict__ is the key to dynamic attributes in user-defined classes. Empty classes work as containers because they include __dict__ by default.

3. Python Functions as Data Containers#

Functions in Python are also objects (instances of the function type), and they too come with a __dict__. This means you can treat functions as lightweight data containers:

Adding Attributes to Functions#

Consider a simple function. You can assign metadata, counters, or cached values directly to it:

def greet():  
    return "Hello!"  
 
# Add a "version" attribute  
greet.version = 1.0  
 
# Add a "usage_count" to track calls  
greet.usage_count = 0  
 
# Update the counter when the function is called  
def greet_with_counter():  
    greet_with_counter.usage_count += 1  
    return "Hello!"  
 
greet_with_counter.usage_count = 0  
greet_with_counter()  
greet_with_counter()  
print(greet_with_counter.usage_count)  # Output: 2  

Why Functions Allow __dict__#

Functions inherit from Python’s function type, which is implemented in Python (not low-level C). Unlike many built-ins, the function type includes __dict__ to support dynamic attributes. This design choice enables patterns like memoization (caching results) or attaching metadata without wrapping the function in a class.

4. Built-in Objects: Why They Resist#

Built-in objects like int, str, list, or dict behave differently. They do not allow arbitrary attributes. For example:

# Strings  
my_str = "hello"  
my_str.length = 5  # Raises AttributeError  
 
# Lists  
my_list = [1, 2, 3]  
my_list.owner = "Alice"  # Raises AttributeError  
 
# Integers  
my_int = 42  
my_int.is_lucky = True  # Raises AttributeError  

Key Reasons Built-ins Lack __dict__#

  1. Memory Efficiency: Built-in types like int or str are used everywhere in Python. If every integer had a __dict__, the memory overhead would be enormous. For example, a list of 1 million integers would require 1 million dictionaries—wasting gigabytes of memory.

  2. Fixed Structure: Built-ins have rigid, predefined structures. An integer’s value is stored in a fixed-size C-level variable, not a dynamic dictionary. Adding attributes would break this simplicity.

  3. Performance: Accessing attributes via __dict__ (a hash table) is slower than accessing fixed-size C struct fields. Built-ins prioritize speed, so they avoid __dict__.

  4. Immutability: Many built-ins (e.g., str, int) are immutable. Allowing dynamic attributes would conflict with immutability guarantees.

Exception: Some Built-ins Can Have __dict__#

A few built-in types (e.g., type instances like classes themselves) do allow __dict__. For example, you can add attributes to a class (which is a built-in type object):

class MyClass:  
    pass  
 
MyClass.new_attr = "I work!"  # Works because classes have __dict__  
print(MyClass.new_attr)  # Output: "I work!"  

But instances of most built-ins (like int or list) still lack __dict__.

5. The Technical Reason: __dict__ and Attribute Dictionaries#

The root cause of this behavior lies in Python’s data model, specifically the __dict__ attribute. Let’s formalize the rules:

Rule 1: User-Defined Objects (Classes/Functions) Get __dict__ by Default#

  • User-defined classes: Instances have __dict__ unless __slots__ is defined (and excludes __dict__).
  • Functions: All functions (user-defined) have __dict__ to support dynamic attributes.

Rule 2: Built-in Objects Usually Lack __dict__#

Most built-in types (implemented in C) omit __dict__ to save memory and boost performance. Their attribute access is handled directly via C structs, not Python dictionaries.

Rule 3: __slots__ Overrides __dict__#

For user-defined classes, __slots__ explicitly lists allowed attributes. If __slots__ is defined without "__dict__", the instance will not have a __dict__, blocking arbitrary attributes (as shown earlier with RestrictedClass).

Visual Summary#

Object TypeHas __dict__?Allows Arbitrary Attributes?
Empty user-defined classYes (default)Yes
FunctionYesYes
int, str, list (instances)NoNo
User-defined class with __slots__ (no __dict__)NoNo

6. When to Use Which? (Best Practices)#

Now that we understand the "why," let’s explore the "when"—practical scenarios for using empty classes/functions as containers, and when to avoid them.

Use Empty Classes When:#

  • Quick Prototyping: For simple data holders (e.g., grouping related variables):
    class Config:  
        pass  
     
    config = Config()  
    config.api_url = "https://api.example.com"  
    config.timeout = 30  
  • alternative dictionary: When attribute access (obj.attr) is more readable than dictionary lookups (dict["key"]).

Use Functions as Containers When:#

  • Metadata/Caching: Attaching metadata (e.g., func.author = "Alice") or caching results:
    def fibonacci(n):  
        if n in fibonacci.cache:  
            return fibonacci.cache[n]  
        result = n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)  
        fibonacci.cache[n] = result  
        return result  
     
    fibonacci.cache = {0: 0, 1: 1}  # Pre-seed cache  

Avoid When:#

  • Complex Data: Use dataclasses or namedtuple for structured data (better type hints, immutability, and readability).
  • Long-Term Maintainability: Dynamic attributes can make code harder to debug (no IDE autocompletion, hidden state).

7. Conclusion#

Python’s empty classes and functions act as arbitrary data containers because they include a __dict__ by default, allowing dynamic attribute storage. Built-in objects like int or list omit __dict__ to prioritize memory efficiency, performance, and fixed structure.

This design balances flexibility (for user code) and efficiency (for core built-ins). Understanding __dict__ and __slots__ helps you leverage Python’s dynamism while avoiding pitfalls like unexpected AttributeErrors.

8. References#