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#
- Understanding Arbitrary Data Containers
- Python’s Empty Classes as Data Containers
- Python Functions as Data Containers
- Built-in Objects: Why They Resist
- The Technical Reason:
__dict__and Attribute Dictionaries - When to Use Which? (Best Practices)
- Conclusion
- 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__#
-
Memory Efficiency: Built-in types like
intorstrare 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. -
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.
-
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__. -
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 Type | Has __dict__? | Allows Arbitrary Attributes? |
|---|---|---|
| Empty user-defined class | Yes (default) | Yes |
| Function | Yes | Yes |
int, str, list (instances) | No | No |
User-defined class with __slots__ (no __dict__) | No | No |
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
dataclassesornamedtuplefor 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.