Introduction
In this lab, you will learn about a fundamental aspect of Python's object-oriented programming: attribute access. Python enables developers to customize how attributes are accessed, set, and managed in classes through special methods. This offers powerful ways to control object behavior.
In addition, you will learn how to customize attribute access in Python classes, understand the difference between delegation and inheritance, and practice implementing custom attribute management in Python objects.
Understanding __setattr__ for Attribute Control
In Python, there are special methods that let you customize how attributes of an object are accessed and modified. One such important method is __setattr__(). This method comes into play every time you try to assign a value to an attribute of an object. It gives you the ability to have fine - grained control over the attribute assignment process.
What is __setattr__?
The __setattr__(self, name, value) method acts as an interceptor for all attribute assignments. When you write a simple assignment statement like obj.attr = value, Python doesn't just directly assign the value. Instead, it internally calls obj.__setattr__("attr", value). This mechanism provides you with the power to decide what should happen during the attribute assignment.
Let's now see a practical example of how we can use __setattr__ to restrict which attributes can be set on a class.
Step 1: Create a new file
First, open a new file in the WebIDE. You can do this by clicking on the "File" menu and then selecting "New File". Name this file restricted_stock.py and save it in the /home/labex/project directory. This file will contain the class definition where we'll use __setattr__ to control attribute assignment.
Step 2: Add code to restricted_stock.py
Add the following code to the restricted_stock.py file. This code defines a RestrictedStock class.
class RestrictedStock:
def __init__(self, name, shares, price):
self.name = name
self.shares = shares
self.price = price
def __setattr__(self, name, value):
## Only allow specific attributes
if name not in {'name', 'shares', 'price'}:
raise AttributeError(f'Cannot set attribute {name}')
## If attribute is allowed, set it using the parent method
super().__setattr__(name, value)
In the __init__ method, we initialize the object with name, shares, and price attributes. The __setattr__ method checks if the attribute name being assigned is in the set of allowed attributes (name, shares, price). If it's not, it raises an AttributeError. If the attribute is allowed, it uses the parent class's __setattr__ method to actually set the attribute.
Step 3: Create a test file
Create a new file called test_restricted.py and add the following code to it. This code will test the functionality of the RestrictedStock class.
from restricted_stock import RestrictedStock
## Create a new stock
stock = RestrictedStock('GOOG', 100, 490.1)
## Test accessing existing attributes
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")
## Test modifying an existing attribute
print("\nChanging shares to 75...")
stock.shares = 75
print(f"New shares value: {stock.shares}")
## Test setting an invalid attribute
try:
print("\nTrying to set an invalid attribute 'share'...")
stock.share = 50
except AttributeError as e:
print(f"Error: {e}")
In this code, we first import the RestrictedStock class. Then we create an instance of the class. We test accessing existing attributes, modifying an existing attribute, and finally, we try to set an invalid attribute to see if the __setattr__ method works as expected.
Step 4: Run the test file
Open a terminal in the WebIDE and execute the following commands to run the test_restricted.py file:
cd /home/labex/project
python3 test_restricted.py
After running these commands, you should see output similar to this:
Name: GOOG
Shares: 100
Price: 490.1
Changing shares to 75...
New shares value: 75
Trying to set an invalid attribute 'share'...
Error: Cannot set attribute share
How It Works
The __setattr__ method in our RestrictedStock class works in the following steps:
- It first checks if the attribute name is in the allowed set (
name,shares,price). - If the attribute name is not in the allowed set, it raises an
AttributeError. This prevents the assignment of unwanted attributes. - If the attribute is allowed, it uses
super().__setattr__()to actually set the attribute. This ensures that the normal attribute assignment process takes place for the allowed attributes.
This method is more flexible than using __slots__, which we saw in previous examples. While __slots__ can optimize memory usage and restrict attributes, it has limitations when working with inheritance and may conflict with other Python features. Our __setattr__ approach gives us similar control over attribute assignment without some of those limitations.
Creating Read-Only Objects with Proxies
In this step, we're going to explore proxy classes, a very useful pattern in Python. Proxy classes let you take an existing object and change how it behaves without altering its original code. This is like putting a special wrapper around an object to add new features or restrictions.
What is a Proxy?
A proxy is an object that stands between you and another object. It has the same set of functions and properties as the original object, but it can do extra things. For example, it can control who can access the object, keep a record of actions (logging), or add other useful features.
Let's create a read - only proxy. This kind of proxy will stop you from changing the attributes of an object.
Step 1: Create the Read - Only Proxy Class
First, we need to create a Python file that defines our read - only proxy.
- Navigate to the
/home/labex/projectdirectory. - Create a new file named
readonly_proxy.pyin this directory. - Open the
readonly_proxy.pyfile and add the following code:
class ReadonlyProxy:
def __init__(self, obj):
## Store the wrapped object directly in __dict__ to avoid triggering __setattr__
self.__dict__['_obj'] = obj
def __getattr__(self, name):
## Forward attribute access to the wrapped object
return getattr(self._obj, name)
def __setattr__(self, name, value):
## Block all attribute assignments
raise AttributeError("Cannot modify a read-only object")
In this code, the ReadonlyProxy class is defined. The __init__ method stores the object we want to wrap. We use self.__dict__ to store it directly to avoid calling the __setattr__ method. The __getattr__ method is used when we try to access an attribute of the proxy. It simply passes the request to the wrapped object. The __setattr__ method is called when we try to change an attribute. It raises an error to prevent any changes.
Step 2: Create a Test File
Now, we'll create a test file to see how our read - only proxy works.
- Create a new file named
test_readonly.pyin the same/home/labex/projectdirectory. - Add the following code to the
test_readonly.pyfile:
from stock import Stock
from readonly_proxy import ReadonlyProxy
## Create a normal Stock object
stock = Stock('AAPL', 100, 150.75)
print("Original stock object:")
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")
print(f"Cost: {stock.cost}")
## Modify the original stock object
stock.shares = 200
print(f"\nAfter modification - Shares: {stock.shares}")
print(f"After modification - Cost: {stock.cost}")
## Create a read-only proxy around the stock
readonly_stock = ReadonlyProxy(stock)
print("\nRead-only proxy object:")
print(f"Name: {readonly_stock.name}")
print(f"Shares: {readonly_stock.shares}")
print(f"Price: {readonly_stock.price}")
print(f"Cost: {readonly_stock.cost}")
## Try to modify the read-only proxy
try:
print("\nAttempting to modify the read-only proxy...")
readonly_stock.shares = 300
except AttributeError as e:
print(f"Error: {e}")
## Show that the original object is unchanged
print(f"\nOriginal stock shares are still: {stock.shares}")
In this test code, we first create a normal Stock object and print its information. Then we modify one of its attributes and print the updated information. Next, we create a read - only proxy for the Stock object and print its information. Finally, we try to modify the read - only proxy and expect to get an error.
Step 3: Run the Test Script
After creating the proxy class and the test file, we need to run the test script to see the results.
- Open a terminal and navigate to the
/home/labex/projectdirectory using the following command:
cd /home/labex/project
- Run the test script using the following command:
python3 test_readonly.py
You should see output similar to:
Original stock object:
Name: AAPL
Shares: 100
Price: 150.75
Cost: 15075.0
After modification - Shares: 200
After modification - Cost: 30150.0
Read-only proxy object:
Name: AAPL
Shares: 200
Price: 150.75
Cost: 30150.0
Attempting to modify the read-only proxy...
Error: Cannot modify a read-only object
Original stock shares are still: 200
How the Proxy Works
The ReadonlyProxy class uses two special methods to achieve its read - only functionality:
__getattr__(self, name): This method is called when Python can't find an attribute in the normal way. In ourReadonlyProxyclass, we use thegetattr()function to pass the attribute access request to the wrapped object. So, when you try to access an attribute of the proxy, it will actually get the attribute from the wrapped object.__setattr__(self, name, value): This method is called when you try to assign a value to an attribute. In our implementation, we raise anAttributeErrorto stop any changes from being made to the proxy's attributes.In the
__init__method, we directly modifyself.__dict__to store the wrapped object. This is important because if we used the normal way to assign the object, it would call the__setattr__method, which would raise an error.
This proxy pattern allows us to add a read - only layer around any existing object without changing its original class. The proxy object acts just like the wrapped object, but it won't let you make any modifications.
Delegation as an Alternative to Inheritance
In object-oriented programming, reusing and extending code is a common task. There are two main ways to achieve this: inheritance and delegation.
Inheritance is a mechanism where a subclass inherits methods and attributes from a parent class. The subclass can choose to override some of these inherited methods to provide its own implementation.
Delegation, on the other hand, involves an object containing another object and forwarding specific method calls to it.
In this step, we will explore delegation as an alternative to inheritance. We'll implement a class that delegates some of its behavior to another object.
Setting Up a Delegation Example
First, we need to set up the base class that our delegating class will interact with.
- Create a new file called
base_class.pyin the/home/labex/projectdirectory. This file will define a class namedSpamwith three methods:method_a,method_b, andmethod_c. Each method prints a message and returns a result. Here is the code to put inbase_class.py:
class Spam:
def method_a(self):
print('Spam.method_a executed')
return "Result from Spam.method_a"
def method_b(self):
print('Spam.method_b executed')
return "Result from Spam.method_b"
def method_c(self):
print('Spam.method_c executed')
return "Result from Spam.method_c"
Next, we'll create the delegating class.
- Create a new file called
delegator.py. In this file, we'll define a class namedDelegatingSpamthat delegates some of its behavior to an instance of theSpamclass.
from base_class import Spam
class DelegatingSpam:
def __init__(self):
## Create an instance of Spam that we'll delegate to
self._spam = Spam()
def method_a(self):
## Override method_a but also call the original
print('DelegatingSpam.method_a executed')
result = self._spam.method_a()
return f"Modified {result}"
def method_c(self):
## Completely override method_c
print('DelegatingSpam.method_c executed')
return "Result from DelegatingSpam.method_c"
def __getattr__(self, name):
## For any other attribute/method, delegate to self._spam
print(f"Delegating {name} to the wrapped Spam object")
return getattr(self._spam, name)
In the __init__ method, we create an instance of the Spam class. The method_a method overrides the original method but also calls the Spam class's method_a. The method_c method completely overrides the original method. The __getattr__ method is a special method in Python that is called when an attribute or method that doesn't exist in the DelegatingSpam class is accessed. It then delegates the call to the Spam instance.
Now, let's create a test file to verify our implementation.
- Create a test file named
test_delegation.py. This file will create an instance of theDelegatingSpamclass and call its methods.
from delegator import DelegatingSpam
## Create a delegating object
spam = DelegatingSpam()
print("Calling method_a (overridden with delegation):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")
print("Calling method_b (not defined in DelegatingSpam, delegated):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")
print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")
## Try accessing a non-existent method
try:
print("Calling non-existent method_d:")
spam.method_d()
except AttributeError as e:
print(f"Error: {e}")
Finally, we'll run the test script.
- Run the test script using the following commands in the terminal:
cd /home/labex/project
python3 test_delegation.py
You should see output similar to the following:
Calling method_a (overridden with delegation):
DelegatingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a
Calling method_b (not defined in DelegatingSpam, delegated):
Delegating method_b to the wrapped Spam object
Spam.method_b executed
Result: Result from Spam.method_b
Calling method_c (completely overridden):
DelegatingSpam.method_c executed
Result: Result from DelegatingSpam.method_c
Calling non-existent method_d:
Delegating method_d to the wrapped Spam object
Error: 'Spam' object has no attribute 'method_d'
Delegation vs. Inheritance
Now, let's compare delegation with traditional inheritance.
- Create a file called
inheritance_example.py. In this file, we'll define a class namedInheritingSpamthat inherits from theSpamclass.
from base_class import Spam
class InheritingSpam(Spam):
def method_a(self):
## Override method_a but also call the parent method
print('InheritingSpam.method_a executed')
result = super().method_a()
return f"Modified {result}"
def method_c(self):
## Completely override method_c
print('InheritingSpam.method_c executed')
return "Result from InheritingSpam.method_c"
The InheritingSpam class overrides the method_a and method_c methods. In the method_a method, we use super() to call the parent class's method_a.
Next, we'll create a test file for the inheritance example.
- Create a test file named
test_inheritance.py. This file will create an instance of theInheritingSpamclass and call its methods.
from inheritance_example import InheritingSpam
## Create an inheriting object
spam = InheritingSpam()
print("Calling method_a (overridden with super call):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")
print("Calling method_b (inherited from parent):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")
print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")
## Try accessing a non-existent method
try:
print("Calling non-existent method_d:")
spam.method_d()
except AttributeError as e:
print(f"Error: {e}")
Finally, we'll run the inheritance test.
- Run the inheritance test using the following commands in the terminal:
cd /home/labex/project
python3 test_inheritance.py
You should see output similar to the following:
Calling method_a (overridden with super call):
InheritingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a
Calling method_b (inherited from parent):
Spam.method_b executed
Result: Result from Spam.method_b
Calling method_c (completely overridden):
InheritingSpam.method_c executed
Result: Result from InheritingSpam.method_c
Calling non-existent method_d:
Error: 'InheritingSpam' object has no attribute 'method_d'
Key Differences and Considerations
Let's look at the similarities and differences between delegation and inheritance.
Method Override: Both delegation and inheritance allow you to override methods, but the syntax is different.
- In delegation, you define your own method and decide whether to call the wrapped object's method.
- In inheritance, you define your own method and use
super()to call the parent's method.
Method Access:
- In delegation, undefined methods are forwarded via the
__getattr__method. - In inheritance, undefined methods are inherited automatically.
- In delegation, undefined methods are forwarded via the
Type Relationships:
- With delegation,
isinstance(delegating_spam, Spam)returnsFalsebecause theDelegatingSpamobject is not an instance of theSpamclass. - With inheritance,
isinstance(inheriting_spam, Spam)returnsTruebecause theInheritingSpamclass inherits from theSpamclass.
- With delegation,
Limitations: Delegation through
__getattr__doesn't work with special methods like__getitem__,__len__, etc. These methods would need to be explicitly defined in the delegating class.
Delegation is particularly useful in the following situations:
- You want to customize an object's behavior without affecting its hierarchy.
- You want to combine behaviors from multiple objects that don't share a common parent.
- You need more flexibility than inheritance provides.
Inheritance is generally preferred when:
- The "is-a" relationship is clear (e.g., a Car is a Vehicle).
- You need to maintain type compatibility across your code.
- Special methods need to be inherited.
Summary
In this lab, you have learned about powerful Python mechanisms for customizing attribute access and behavior. You explored how to use __setattr__ to control which attributes can be set on an object, enabling controlled access to object properties. Additionally, you implemented a read - only proxy to wrap existing objects, preventing modifications while preserving their functionality.
You also delved into the difference between delegation and inheritance for code reuse and customization. By using __getattr__, you learned to forward method calls to a wrapped object. These techniques offer flexible ways to control object behavior beyond standard inheritance, useful for creating controlled interfaces, implementing access restrictions, adding cross - cutting behaviors, and composing behavior from multiple sources. Understanding these patterns helps you write more maintainable and flexible Python code.