backend / python core / tricky questions / 23_empty_class_as_key.md

Empty Class as Dictionary Key in Python

3 interview angles 8 min read source

Empty Class as Dictionary Key in Python

Introduction

In Python, you can use almost any object as a dictionary key, including empty classes. This is because Python dictionaries use hashable objects as keys, and empty classes are hashable by default. Let’s explore this concept in detail.

Can an Empty Class be a Key in a Dictionary?

Yes, an empty class can be used as a key in a dictionary in Python.

Why This Works

  1. Hashability: Empty classes are hashable by default
  2. Object Identity: Each instance has a unique identity
  3. Immutability: Class instances are mutable, but their identity is immutable

Basic Examples

Simple Empty Class as Key

# Define an empty class
class EmptyClass:
    pass

# Create instances
obj1 = EmptyClass()
obj2 = EmptyClass()
obj3 = EmptyClass()

# Use as dictionary keys
my_dict = {
    obj1: "Value 1",
    obj2: "Value 2",
    obj3: "Value 3"
}

print(my_dict)  # {<__main__.EmptyClass object at 0x...>: 'Value 1', ...}
print(len(my_dict))  # 3

# Access values
print(my_dict[obj1])  # Value 1
print(my_dict[obj2])  # Value 2

Multiple Instances of Same Class

class Config:
    pass

# Create different configuration objects
db_config = Config()
api_config = Config()
cache_config = Config()

# Use as keys in a configuration dictionary
configs = {
    db_config: {"host": "localhost", "port": 5432},
    api_config: {"base_url": "https://api.example.com", "timeout": 30},
    cache_config: {"redis_host": "localhost", "redis_port": 6379}
}

# Access configurations
print(configs[db_config]["host"])  # localhost
print(configs[api_config]["base_url"])  # https://api.example.com

Advanced Examples

Empty Class with Custom Methods

class EventType:
    def __str__(self):
        return f"EventType({id(self)})"
    
    def __repr__(self):
        return self.__str__()

# Create event types
user_login = EventType()
user_logout = EventType()
data_export = EventType()

# Event handlers dictionary
event_handlers = {
    user_login: lambda: print("Handling user login"),
    user_logout: lambda: print("Handling user logout"),
    data_export: lambda: print("Handling data export")
}

# Trigger events
for event_type, handler in event_handlers.items():
    print(f"Triggering {event_type}: ", end="")
    handler()

Using Empty Classes for Type-Safe Constants

class Status:
    pass

class Priority:
    pass

# Define status constants
PENDING = Status()
APPROVED = Status()
REJECTED = Status()

# Define priority constants
LOW = Priority()
MEDIUM = Priority()
HIGH = Priority()

# Task management system
tasks = {
    (PENDING, HIGH): "Urgent tasks awaiting approval",
    (APPROVED, MEDIUM): "Approved tasks with medium priority",
    (REJECTED, LOW): "Rejected low-priority tasks"
}

# Access task descriptions
print(tasks[(PENDING, HIGH)])  # Urgent tasks awaiting approval

Empty Class with Custom Hash Method

class NamedObject:
    def __init__(self, name):
        self.name = name
    
    def __hash__(self):
        return hash(self.name)
    
    def __eq__(self, other):
        if not isinstance(other, NamedObject):
            return False
        return self.name == other.name
    
    def __str__(self):
        return f"NamedObject('{self.name}')"

# Create named objects
user1 = NamedObject("Alice")
user2 = NamedObject("Bob")
user3 = NamedObject("Alice")  # Same name as user1

# Use as dictionary keys
user_data = {
    user1: {"age": 25, "city": "New York"},
    user2: {"age": 30, "city": "Los Angeles"},
    user3: {"age": 28, "city": "Chicago"}  # This will overwrite user1's data
}

print(len(user_data))  # 2 (user3 overwrites user1 due to same hash)
print(user_data[user1])  # {'age': 28, 'city': 'Chicago'}
print(user_data[user3])  # {'age': 28, 'city': 'Chicago'}

Hashability Requirements

What Makes an Object Hashable?

# Hashable objects (can be dictionary keys)
class HashableClass:
    pass

# Unhashable objects (cannot be dictionary keys)
class UnhashableClass:
    def __hash__(self):
        return None  # Makes it unhashable

# Test hashability
obj1 = HashableClass()
obj2 = UnhashableClass()

print(hash(obj1))  # Some integer
print(hash(obj2))  # TypeError: unhashable type

# Dictionary test
try:
    d = {obj1: "works"}  # This works
    print("HashableClass works as key")
except TypeError as e:
    print(f"HashableClass failed: {e}")

try:
    d = {obj2: "fails"}  # This fails
    print("UnhashableClass works as key")
except TypeError as e:
    print(f"UnhashableClass failed: {e}")

Custom Hash Implementation

class CustomHashClass:
    def __init__(self, value):
        self.value = value
    
    def __hash__(self):
        return hash(self.value)
    
    def __eq__(self, other):
        if not isinstance(other, CustomHashClass):
            return False
        return self.value == other.value
    
    def __str__(self):
        return f"CustomHashClass({self.value})"

# Create instances
obj1 = CustomHashClass(42)
obj2 = CustomHashClass("hello")
obj3 = CustomHashClass(42)  # Same value as obj1

# Use in dictionary
my_dict = {
    obj1: "First object",
    obj2: "Second object",
    obj3: "Third object"  # Will overwrite obj1
}

print(len(my_dict))  # 2
print(my_dict[obj1])  # Third object
print(my_dict[obj3])  # Third object

Real-World Use Cases

1. Event System

class Event:
    pass

# Define events
USER_REGISTERED = Event()
USER_DELETED = Event()
PAYMENT_RECEIVED = Event()
ORDER_CANCELLED = Event()

# Event handlers
event_handlers = {
    USER_REGISTERED: [
        lambda user: print(f"Welcome email sent to {user}"),
        lambda user: print(f"Account created for {user}"),
        lambda user: print(f"Analytics event logged for {user}")
    ],
    USER_DELETED: [
        lambda user: print(f"Goodbye email sent to {user}"),
        lambda user: print(f"Data cleanup for {user}")
    ],
    PAYMENT_RECEIVED: [
        lambda amount: print(f"Payment of ${amount} processed"),
        lambda amount: print(f"Receipt sent for ${amount}")
    ]
}

# Trigger events
def trigger_event(event_type, *args):
    if event_type in event_handlers:
        for handler in event_handlers[event_type]:
            handler(*args)

# Usage
trigger_event(USER_REGISTERED, "alice@example.com")
trigger_event(PAYMENT_RECEIVED, 99.99)

2. Configuration Management

class ConfigSection:
    pass

# Define configuration sections
DATABASE = ConfigSection()
API = ConfigSection()
CACHE = ConfigSection()
LOGGING = ConfigSection()

# Configuration dictionary
config = {
    DATABASE: {
        "host": "localhost",
        "port": 5432,
        "name": "myapp",
        "user": "postgres"
    },
    API: {
        "base_url": "https://api.example.com",
        "timeout": 30,
        "retries": 3
    },
    CACHE: {
        "redis_host": "localhost",
        "redis_port": 6379,
        "ttl": 3600
    },
    LOGGING: {
        "level": "INFO",
        "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    }
}

# Access configuration
def get_config(section):
    return config.get(section, {})

db_config = get_config(DATABASE)
api_config = get_config(API)

print(f"Database host: {db_config['host']}")
print(f"API timeout: {api_config['timeout']}")

3. State Machine

class State:
    pass

# Define states
IDLE = State()
PROCESSING = State()
COMPLETED = State()
ERROR = State()

# State transitions
transitions = {
    (IDLE, "start"): PROCESSING,
    (PROCESSING, "complete"): COMPLETED,
    (PROCESSING, "error"): ERROR,
    (ERROR, "retry"): PROCESSING,
    (COMPLETED, "reset"): IDLE
}

class StateMachine:
    def __init__(self):
        self.current_state = IDLE
    
    def transition(self, event):
        key = (self.current_state, event)
        if key in transitions:
            self.current_state = transitions[key]
            return True
        return False
    
    def get_state(self):
        return self.current_state

# Usage
sm = StateMachine()
print(f"Initial state: {sm.get_state()}")  # IDLE

sm.transition("start")
print(f"After start: {sm.get_state()}")  # PROCESSING

sm.transition("complete")
print(f"After complete: {sm.get_state()}")  # COMPLETED

Performance Considerations

Memory Usage

import sys

class EmptyClass:
    pass

# Memory usage comparison
empty_class = EmptyClass()
string_key = "my_key"
integer_key = 42

print(f"Empty class size: {sys.getsizeof(empty_class)} bytes")
print(f"String key size: {sys.getsizeof(string_key)} bytes")
print(f"Integer key size: {sys.getsizeof(integer_key)} bytes")

# Dictionary with different key types
dict_with_classes = {EmptyClass(): "value" for _ in range(1000)}
dict_with_strings = {f"key_{i}": "value" for i in range(1000)}
dict_with_integers = {i: "value" for i in range(1000)}

print(f"Dict with classes: {sys.getsizeof(dict_with_classes)} bytes")
print(f"Dict with strings: {sys.getsizeof(dict_with_strings)} bytes")
print(f"Dict with integers: {sys.getsizeof(dict_with_integers)} bytes")

Hash Performance

import time

class PerformanceTest:
    pass

# Create test data
class_keys = [PerformanceTest() for _ in range(10000)]
string_keys = [f"key_{i}" for i in range(10000)]
int_keys = list(range(10000))

# Test hash performance
def test_hash_performance(keys):
    start_time = time.time()
    for key in keys:
        hash(key)
    end_time = time.time()
    return end_time - start_time

print(f"Class hash time: {test_hash_performance(class_keys):.6f} seconds")
print(f"String hash time: {test_hash_performance(string_keys):.6f} seconds")
print(f"Integer hash time: {test_hash_performance(int_keys):.6f} seconds")

Interview Questions and Answers

Q1: Can an empty class be used as a dictionary key in Python?

A: Yes, an empty class can be used as a dictionary key in Python. Empty classes are hashable by default, which means they can be used as dictionary keys. Each instance of the class will have a unique hash value based on its object identity.

Q2: What makes an object hashable in Python?

A: An object is hashable if it has a __hash__ method that returns a consistent integer value, and it has an __eq__ method for equality comparison. By default, user-defined classes are hashable unless they explicitly define __hash__ = None or override __hash__ to return None.

Q3: What happens if you use the same empty class instance as a key multiple times?

A: If you use the same instance multiple times as a key, it will refer to the same dictionary entry. However, if you create multiple instances of the same empty class, each will be a different key because they have different object identities.

Q4: Can you modify an empty class instance after using it as a dictionary key?

A: Yes, you can modify an empty class instance after using it as a dictionary key, but you should be careful. If you modify attributes that affect the object’s hash value or equality comparison, it could lead to unexpected behavior when accessing the dictionary.

Q5: What are the advantages of using empty classes as dictionary keys?

A: Advantages include:

  • Type safety and clear intent
  • Unique object identity for each instance
  • Ability to create meaningful constants
  • No risk of string key collisions
  • Better IDE support and refactoring

Q6: What are the disadvantages of using empty classes as dictionary keys?

A: Disadvantages include:

  • More verbose than string keys
  • Higher memory usage
  • Potential for memory leaks if instances are not properly managed
  • Less readable when debugging
  • Need to maintain references to key objects

Summary

Empty classes can indeed be used as dictionary keys in Python. They offer several advantages for certain use cases, particularly when you need:

  1. Type Safety: Clear distinction between different types of keys
  2. Unique Identity: Each instance is guaranteed to be unique
  3. Meaningful Constants: Self-documenting code with clear intent
  4. Event Systems: Natural fit for event-driven architectures
  5. Configuration Management: Organized configuration sections

However, they also come with trade-offs in terms of memory usage and complexity. The choice between empty classes and other key types (strings, integers, etc.) depends on the specific requirements of your application.

Key points to remember:

  • Empty classes are hashable by default
  • Each instance has a unique identity
  • They’re useful for creating type-safe constants
  • Consider memory usage for large-scale applications
  • Maintain references to key objects to avoid garbage collection issues

Interview angle

  • “Can an instance of a plain class be a dict key?” - yes. The default __hash__ is based on identity and the default __eq__ is identity comparison, so every instance is distinct and hashable.
  • “What breaks that?” - defining __eq__ without __hash__, which sets __hash__ to None and makes instances unhashable. Python does this deliberately: value equality with identity hashing would violate the contract.
  • “What’s the fix?” - define both consistently, or use a frozen=True dataclass which generates both from the fields.