Object Composition, Reflection, and Magic Methods
Object Composition
Composition is a fundamental concept in object-oriented programming where one object contains a reference to another object as one of its attributes. This approach enables building complex objects by combining simpler ones.
When to use inheritance versus composition:
- Inheritance (is-a relationship): Use when one class is a specialized version of another. For example, a Dog is a Animal.
- Composition (has-a relationship): Use when one class has another as a component. For example, a Car has an Engine.
Basic Composition Example
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
class Car:
def __init__(self, model, engine):
self.model = model
self.engine = engine # Composition: Car has an Engine
vehicle = Car("Tesla", Engine(400))
print(vehicle.engine.horsepower) # Output: 400
Course Seletcion System
This example demonstrates practical composition usage in a course enrollment system:
class Person:
institution = "Tech University"
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender
class Administrator(Person):
def __init__(self, name, age, gender):
super().__init__(name, age, gender)
class Course:
def __init__(self, title, fee, duration):
self.title = title
self.fee = fee
self.duration = duration
python_course = Course("Python Programming", 15000, "6 months")
linux_course = Course("Linux Administration", 12000, "5 months")
class Student(Person):
def __init__(self, name, age, gender, enrolled_courses=None):
super().__init__(name, age, gender)
self.enrolled_courses = enrolled_courses or []
student = Student("Alice", 22, "female")
student.enrolled_courses.append(python_course)
student.enrolled_courses.append(linux_course)
for course in student.enrolled_courses:
print(f"{course.title}: ${course.fee}")
class Instructor(Person):
def __init__(self, name, age, gender, certification_level):
super().__init__(name, age, gender)
self.certification_level = certification_level
self.assigned_course = None
instructor = Instructor("Dr. Smith", 35, "male", 10)
instructor.assigned_course = linux_course
print(f"Instructor: {instructor.name}")
print(f"Course: {instructor.assigned_course.title}")
print(f"Duration: {instructor.assigned_course.duration}")
Reflection in Python
Reflection enables runtime inspection and modification of objects and classes. Python provides four built-in functions for reflection operations. These functions work on both classes and instances since everything in Python is an object.
Retrieving Attributes with getattr()
The getattr() function retrieves attributes by name. If the attribute doesn't exist, it returns a default value if provided.
class Student:
university = "MIT"
def __init__(self, name, age):
self.name = name
self.age = age
def display_info(self):
print(f"Student: {self.name}, Age: {self.age}")
def calculate_grade(self):
print("Calculating grade...")
student = Student("Bob", 21)
# Direct attribute access
print(student.name) # Bob
# Reflected attribute access
campus = getattr(student, "university", "Unknown")
print(campus) # MIT
# Non-existent attribute with default
result = getattr(student, "gpa", 0.0)
print(result) # 0.0
# Accessing methods dynamically
method = getattr(student, "display_info", None)
if method:
method()
Setting Attributes with setattr()
The setattr() function dynamically adds or modifies attributes on objects.
class Student:
university = "MIT"
def __init__(self, name, age):
self.name = name
self.age = age
student = Student("Charlie", 23)
setattr(student, "gpa", 3.8)
setattr(student, "major", "Computer Science")
print(student.__dict__)
# {'name': 'Charlie', 'age': 23, 'gpa': 3.8, 'major': 'Computer Science'}
Checking Attributes with hasattr()
The hasattr() function verifies whether an object possesses a specific attribute.
class Student:
university = "MIT"
def __init__(self, name, age):
self.name = name
self.age = age
def display_info(self):
print(f"Student: {self.name}")
student = Student("Diana", 20)
if hasattr(student, "display_info"):
getattr(student, "display_info")()
if hasattr(student, "gpa"):
print(f"GPA: {student.gpa}")
else:
print("No GPA attribute")
Deleting Attributes with delattr()
The delattr() function removes attributes from objects.
class Student:
university = "MIT"
def __init__(self, name, age, gpa):
self.name = name
self.age = age
self.gpa = gpa
student = Student("Eve", 22, 3.5)
print(student.__dict__) # All attributes present
delattr(student, "gpa")
print(student.__dict__) # gpa removed
Magic Methods
Magic methods (also known as dunder methods) are special methods that enable custom classes to integrate with Python's built-in operations and syntax.
String Representation: __str__ and __repr__
These methods define how objects are displayed as strings.
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
"""Called by str() and print() functions"""
print("__str__ invoked")
return f"Student: {self.name} (Age: {self.age})"
def __repr__(self):
"""Called by repr() function, useful for debugging"""
print("__repr__ invoked")
return f"Student('{self.name}', {self.age})"
student = Student("Frank", 25)
print(student) # Uses __str__
print(str(student)) # Uses __str__
# Explicit repr call
print(repr(student)) # Uses __repr__
Cleanup: __del__ Method
The __del__ method acts as a destructor, called when an object is garbage collected or when the program exits.
class DatabaseConnection:
def __init__(self, host, port):
self.host = host
self.port = port
self.connection = open("connection_log.txt", "a")
def query(self, sql):
print(f"Executing: {sql}")
def __del__(self):
"""Ensure resources are released when object is destroyed"""
print("Cleaning up database connection")
self.connection.close()
db = DatabaseConnection("localhost", 5432)
db.query("SELECT * FROM users")
Checking Class Hierarchy: issubclass()
The issubclass() functon determines if one class inherits from another.
class Animal:
pass
class Mammal(Animal):
pass
class Bird:
pass
print(issubclass(Mammal, Animal)) # True
print(issubclass(Bird, Animal)) # False
print(issubclass(Mammal, object)) # True
Documentation: __doc__ Attribute
Class docstrings are stored in the __doc__ attribute. Note that docstrings are not inherited by subclasses.
class Animal:
"""
Base class for all animals.
Provides common attributes like name and age.
"""
pass
class Dog(Animal):
"""Represents a canine companion."""
pass
print(Animal.__doc__)
# Base class for all animals.
# Provides common attributes like name and age.
print(Dog.__doc__)
# Represents a canine companion.
Context Managers: __enter__ and __exit__
These methods enable the with statement, providing resource management and exception handling.
class FileHandler:
def __init__(self, filename):
self.filename = filename
def __enter__(self):
"""Called when entering the with block"""
print("Setting up file resources")
return self
def __exit__(self, exception_type, exception_value, traceback):
"""Called when exiting the with block"""
print("Cleaning up file resources")
if exception_type:
print(f"Exception handled: {exception_type.__name__}")
return True # Suppress exceptions
# Successful execution
with FileHandler("data.txt") as handler:
print("Performing file operations")
print("File operations completed")
# Exception handling demonstration
with FileHandler("data.txt") as handler:
print("About to process data")
raise ValueError("Invalid data format")
print("This line never executes")
print("Program continues after exception")
Attribute Access: __getattr__, __setattr__, __delattr__
These methods intercept attribute operations using dot notation.
class DynamicObject:
class_variable = 1
def __init__(self, value):
self.value = value
def __getattr__(self, name):
"""Called when attribute doesn't exist"""
print(f"Attempted access to non-existent attribute: {name}")
return None
def __setattr__(self, name, value):
"""Called when setting any attribute"""
print(f"Setting attribute: {name} = {value}")
self.__dict__[name] = value
def __delattr__(self, name):
"""Called when deleting any attribute"""
print(f"Deleting attribute: {name}")
if name in self.__dict__:
del self.__dict__[name]
obj = DynamicObject(100)
print(f"obj.value: {obj.value}")
obj.new_property = 42
print(f"obj.new_property: {obj.new_property}")
nonexistent = obj.missing_attribute
print(f"Result: {nonexistent}")
del obj.new_property
Item Access: __getitem__, __setitem__, __delitem__
These methods enable bracket notation for attribute access, similar to dictionary syntax.
class Container:
def __init__(self, name):
self.name = name
def __getitem__(self, key):
"""Called when accessing obj[key]"""
print(f"Getting item: {key}")
return self.__dict__[key]
def __setitem__(self, key, value):
"""Called when setting obj[key] = value"""
print(f"Setting item: {key} = {value}")
self.__dict__[key] = value
def __delitem__(self, key):
"""Called when deleting obj[key]"""
print(f"Deleting item: {key}")
if key in self.__dict__:
del self.__dict__[key]
container = Container("MyContainer")
container["data"] = [1, 2, 3]
print(container.data)
del container["data"]
print(container.__dict__)
Callable Objects: __call__
The __call__ method enables instances to be called as functions.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
"""Makes the instance callable like a function"""
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
result1 = double(10) # Calls double.__call__(10)
result2 = triple(10) # Calls triple.__call__(10)
print(f"Doubled: {result1}") # 20
print(f"Tripled: {result2}") # 30
print(f"Is callable: {callable(double)}") # True