Effective Python: Classes
The Empty Class
You can create a class as follows and use it like a dictionary.
= type('typeName', (), {})
pseudo_class
= 10
pseudo_class.x = 'something else'
pseudo_class.y = range(10)
pseudo_class.x
print(pseudo_class, pseudo_class.x)
class B
derived from class A
To call the constructor of class A from the constructor of class B, you use the super()
function. Here’s how you can do it:
class A():
def __init__(self, x):
self.x = x
class B(A):
def __init__(self, x, y):
super().__init__(x) # This calls the constructor of class A with the argument x
self.y = y
The super().__init__(x)
call within B
’s constructor invokes the constructor of the superclass A
, passing x
as the argument. This way, A
’s constructor is responsible for handling the x
attribute, while B
’s constructor handles the additional attributes or operations specific to B
, such as setting the y
attribute in this case.
Super and subclasses
In the context of inheritance in object-oriented programming:
- A is referred to as the superclass (or base class, parent class), because it is the class from which another class (the subclass) inherits.
- B, which derives from A, is called the subclass (or derived class, child class) because it inherits from the superclass.
The subclass extends or modifies the behavior defined in the superclass. This mechanism allows for code reuse and polymorphism, making it easier to maintain and extend the functionality of your software.
It’s understandable that the terminology might feel counterintuitive at first! The terms “superclass” and “subclass” can indeed seem a bit abstract or even upside down, especially when you’re starting to grasp the concepts of inheritance and object-oriented programming. Here’s a way to think about it that might help:
Superclass (Base Class, Parent Class): Think of the superclass as the “foundation” or “base” upon which subclasses are built. It’s “super” in the sense that it provides the basic attributes and methods from which other classes can inherit. It’s a general, broader classification.
Subclass (Derived Class, Child Class): The subclass is considered “below” the superclass in the hierarchy, not in importance, but because it specializes or extends the superclass. It inherits from the superclass, adding more specific features or overriding existing ones to differentiate itself. It’s a more specific, detailed classification built on top of the superclass.
In a family tree analogy, the superclass is like a parent, while the subclass is like a child. The child inherits traits from the parent but can also have its unique characteristics or override inherited traits with new ones.
Despite the initial confusion, these terms are widely used in object-oriented programming to describe relationships between classes in a hierarchy, emphasizing the direction of inheritance from the more general to the more specific.
More about the `super
function
The super()
function in Python is used to give access to methods in a superclass from a subclass that inherits from it. It’s particularly powerful and somewhat complex when dealing with multiple inheritance, where a class is derived from more than one base class.
Basic Usage
In a single inheritance scenario, super()
is straightforward: it allows you to call methods of the superclass in your subclass. The common use case is to extend the functionality of the superclass method in the subclass by either calling the superclass method before executing additional logic in the subclass method or doing so afterward.
Multiple Inheritance
Python supports multiple inheritance, where a class can inherit from more than one class. This introduces complexity, especially in how super()
behaves. Python’s method resolution order (MRO) comes into play in these scenarios.
Method Resolution Order (MRO)
Python uses the C3 linearization algorithm to establish an MRO in multiple inheritance scenarios. The MRO determines the order in which base classes are searched when executing a method. You can view the MRO of a class using the .__mro__
attribute or the mro()
method.
Example with Multiple Inheritance
Consider the following classes:
class A:
def __init__(self):
print("A", end=" ")
super().__init__()
class B(A):
def __init__(self):
print("B", end=" ")
super().__init__()
class C(A):
def __init__(self):
print("C", end=" ")
super().__init__()
class D(B, C):
def __init__(self):
print("D", end=" ")
super().__init__()
In this case, D
is derived from both B
and C
. If you instantiate D
, the output will illustrate the MRO:
= D() d
Output might be: D B C A
This output is the result of the C3 linearization that Python uses to resolve the order in which methods should be called. You can check the MRO by:
print(D.mro())
Note it is a function of the class, not an instance.
super()
in Multiple Inheritance
When you call super()
in a class that’s part of a multiple inheritance hierarchy, Python follows the MRO to determine which superclass method to invoke. This ensures that each method in the hierarchy is called in a specific, predictable order, avoiding the problem of a method being called more than once.
super()
with Arguments
In Python 3, super()
called without arguments is equivalent to super(ThisClass, self)
, automatically passing the class and instance to super()
. In complex inheritance hierarchies, super()
works with the MRO to ensure the correct superclass method is called next.
Summary
super()
is essential for accessing superclass methods in a subclass.- In multiple inheritance scenarios, the MRO determines the order in which superclass methods are called.
- Python’s use of the C3 linearization algorithm ensures a consistent and predictable MRO.
- Understanding
super()
and MRO is crucial for correctly implementing and extending methods in complex inheritance hierarchies.
Dynamically extending
Here is a way to dynamically extend an object’s functionality in a flexible manner, without permanently adding more methods to the base class (A
) and while maintaining the ability to call A
’s methods directly from an instance of B
as if they were its own. This scenario calls for a design pattern that allows for such flexibility, typically achieved through composition or the use of a proxy pattern.
One approach is the Proxy Pattern, specifically a variation known as the Delegation Pattern. Instead of inheriting from class A
, class B
holds an instance of A
and delegates calls to it. You can automate delegation in Python using the __getattr__
method to dynamically forward method calls to the A
instance.
A delegation pattern is implemented like so:
class A:
def __init__(self, x):
self.x = x
def method_a(self):
print(f"Method in A, x = {self.x}")
class B:
def __init__(self, a_instance, y):
self._a_instance = a_instance
self.y = y
def __getattr__(self, name):
"""Delegate attribute access to the A instance if attribute not found in B."""
return getattr(self._a_instance, name)
# B's specific methods can be defined here
def method_b(self):
print(f"Method in B, y = {self.y}")
# Usage
= A("Hello from A")
a = B(a, "Hello from B")
b
# This will call A's method_a through B
b.method_a() # This will directly call B's method_b b.method_b()
In this implementation, B
does not inherit from A
. Instead, it “wraps” an instance of A
. When you try to access an attribute or method on an instance of B
that doesn’t exist in B
, Python’s __getattr__
method is called. Here, __getattr__
is used to delegate those calls to the wrapped A
instance. This allows you to call methods of A
directly on an instance of B
without the need for B
to explicitly define or know about those methods.
Key Points:
- Flexibility: This approach allows
B
to extendA
dynamically, without hardcoding method names or altering the structure ofA
. - Maintainability: It keeps
A
’s implementation clean and focused, only adding functionality inB
when necessary. - Transparency: To the user of class
B
, it appears as ifB
has all methods ofA
, providing a seamless interface.
Considerations:
- Performance: Using
__getattr__
introduces a slight overhead because of the dynamic method resolution. - Debugging: Debugging can be slightly more complex due to the indirection added by
__getattr__
. - Method Overriding: If
B
needs to override a method fromA
and also call the original method fromA
, you’ll have to handle that explicitly inB
.
This pattern provides a powerful way to dynamically extend objects in Python, accommodating scenarios where inheritance might be too rigid or when you want to avoid bloating base classes with methods that are only occasionally needed.
__getattribute__
vs __getattr__
The delegation pattern uses __getattr__
. In Python, both __getattr__
and __getattribute__
methods exist, and they have subtly different behaviors and use cases when it comes to attribute access in classes.
__getattribute__
- Always Called:
__getattribute__
is called for every attempt to access an attribute, regardless of whether the attribute exists or not. It’s a part of the lookup chain for any attribute access on an object. - Override with Caution: Because
__getattribute__
is called for every attribute access, overriding it requires careful handling to avoid infinite recursion. When you override__getattribute__
, you typically usesuper().__getattribute__(name)
within it to safely access attributes of the superclass without recursion. - Use Case: You might override
__getattribute__
if you need to intercept every attribute access, which could be useful for debugging, logging, or implementing proxies where you need to handle all attribute accesses dynamically.
Example:
class A:
def __init__(self):
self.x = 'X'
def __getattribute__(self, name):
print(f"Accessing {name}")
return super().__getattribute__(name)
= A()
a print(a.x) # This will print "Accessing x" followed by "X"
__getattr__
- Called as a Fallback:
__getattr__
is only called if the attribute was not found by the usual means. It acts as a fallback method for attribute access, which makes it useful for catching attempts to access missing attributes without affecting normal attribute access. - Use Case:
__getattr__
is suitable when you want to provide a default behavior for missing attributes, such as returning a default value, generating attributes on the fly, or forwarding attribute access to another object (as in delegation or proxy patterns).
Example:
class B:
def __getattr__(self, name):
return f"{name} does not exist"
= B()
b print(b.some_missing_attribute) # This will print "some_missing_attribute does not exist"
This explains why the pattern uses __getattr__
.
Key Differences
- Invocation:
__getattribute__
is called for every attribute access, making it very powerful but also risky if not handled correctly due to the potential for infinite recursion.__getattr__
is called only when an attribute is not found by the normal lookup process. - Purpose:
__getattribute__
can be used to intercept all attribute accesses, which is useful for low-level control or proxying.__getattr__
is more suited for providing fallback behavior for missing attributes or for cases where attributes are to be generated dynamically.
Choosing between __getattribute__
and __getattr__
depends on your specific needs for attribute access and interception, and understanding their differences is key to using them effectively.
Written with help from Chat GPT.