Private Attributes and Properties

PythonPythonIntermediate
Practice Now

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you'll learn how to encapsulate object internals using private attributes and implement property decorators to control attribute access. These techniques are essential for maintaining the integrity of your objects and ensuring proper data handling.

You'll also understand how to restrict attribute creation using __slots__. We'll modify the stock.py file throughout this lab to apply these concepts.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 81% completion rate. It has received a 96% positive review rate from learners.

Implementing Private Attributes

In Python, we use a naming convention to indicate that an attribute is intended for internal use within a class. We prefix these attributes with an underscore (_). This signals to other developers that these attributes are not part of the public API and should not be accessed directly from outside the class.

Let's look at the current Stock class in the stock.py file. It has a class variable named types.

class Stock:
    ## Class variable for type conversions
    types = (str, int, float)

    ## Rest of the class...

The types class variable is used internally to convert row data. To indicate that this is an implementation detail, we'll mark it as private.

Instructions:

  1. Open the stock.py file in the editor.

  2. Modify the types class variable by adding a leading underscore, changing it to _types.

    class Stock:
        ## Class variable for type conversions
        _types = (str, int, float)
    
        ## Rest of the class...
  3. Update the from_row method to use the renamed variable _types.

    @classmethod
    def from_row(cls, row):
        values = [func(val) for func, val in zip(cls._types, row)]
        return cls(*values)
  4. Save the stock.py file.

  5. Create a Python script named test_stock.py to test your changes. You can create the file in the editor using the following command:

    touch /home/labex/project/test_stock.py
  6. Add the following code to the test_stock.py file. This code creates instances of the Stock class and prints information about them.

    from stock import Stock
    
    ## Create a stock instance
    s = Stock('GOOG', 100, 490.10)
    print(f"Name: {s.name}, Shares: {s.shares}, Price: {s.price}")
    print(f"Cost: {s.cost()}")
    
    ## Create from row
    row = ['AAPL', '50', '142.5']
    apple = Stock.from_row(row)
    print(f"Name: {apple.name}, Shares: {apple.shares}, Price: {apple.price}")
    print(f"Cost: {apple.cost()}")
  7. Run the test script using the following command in the terminal:

    python /home/labex/project/test_stock.py

    You should see output similar to:

    Name: GOOG, Shares: 100, Price: 490.1
    Cost: 49010.0
    Name: AAPL, Shares: 50, Price: 142.5
    Cost: 7125.0

Converting Methods to Properties

Properties in Python allow you to access computed values like attributes. This eliminates the need for parentheses when calling a method, making your code cleaner and more consistent.

Currently, our Stock class has a cost() method that calculates the total cost of the shares.

def cost(self):
    return self.shares * self.price

To get the cost value, we have to call it with parentheses:

s = Stock('GOOG', 100, 490.10)
print(s.cost())  ## Calls the method

We can improve this by converting the cost() method to a property, allowing us to access the cost value without parentheses:

s = Stock('GOOG', 100, 490.10)
print(s.cost)  ## Accesses the property

Instructions:

  1. Open the stock.py file in the editor.

  2. Replace the cost() method with a property using the @property decorator:

    @property
    def cost(self):
        return self.shares * self.price
  3. Save the stock.py file.

  4. Create a new file named test_property.py in the editor:

    touch /home/labex/project/test_property.py
  5. Add the following code to the test_property.py file to create a Stock instance and access the cost property:

    from stock import Stock
    
    ## Create a stock instance
    s = Stock('GOOG', 100, 490.10)
    
    ## Access cost as a property (no parentheses)
    print(f"Stock: {s.name}")
    print(f"Shares: {s.shares}")
    print(f"Price: {s.price}")
    print(f"Cost: {s.cost}")  ## Using the property
  6. Run the test script:

    python /home/labex/project/test_property.py

    You should see output similar to:

    Stock: GOOG
    Shares: 100
    Price: 490.1
    Cost: 49010.0
✨ Check Solution and Practice

Implementing Property Validation

Properties also allow you to control how attribute values are retrieved, set, and deleted. This is useful for adding validation to your attributes, ensuring that the values meet specific criteria.

In our Stock class, we want to ensure that shares is a non-negative integer and price is a non-negative float. We'll use property decorators along with getters and setters to achieve this.

Instructions:

  1. Open the stock.py file in the editor.

  2. Add private attributes _shares and _price to the Stock class and modify the constructor to use them:

    def __init__(self, name, shares, price):
        self.name = name
        self._shares = shares  ## Using private attribute
        self._price = price    ## Using private attribute
  3. Define properties for shares and price with validation:

    @property
    def shares(self):
        return self._shares
    
    @shares.setter
    def shares(self, value):
        if not isinstance(value, int):
            raise TypeError("Expected integer")
        if value < 0:
            raise ValueError("shares must be >= 0")
        self._shares = value
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        if not isinstance(value, float):
            raise TypeError("Expected float")
        if value < 0:
            raise ValueError("price must be >= 0")
        self._price = value
  4. Update the constructor to use the property setters for validation:

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares  ## Using property setter
        self.price = price    ## Using property setter
  5. Save the stock.py file.

  6. Create a test script named test_validation.py:

    touch /home/labex/project/test_validation.py
  7. Add the following code to the test_validation.py file:

    from stock import Stock
    
    ## Create a valid stock instance
    s = Stock('GOOG', 100, 490.10)
    print(f"Initial: Name={s.name}, Shares={s.shares}, Price={s.price}, Cost={s.cost}")
    
    ## Test valid updates
    try:
        s.shares = 50  ## Valid update
        print(f"After setting shares=50: Shares={s.shares}, Cost={s.cost}")
    except Exception as e:
        print(f"Error setting shares=50: {e}")
    
    try:
        s.price = 123.45  ## Valid update
        print(f"After setting price=123.45: Price={s.price}, Cost={s.cost}")
    except Exception as e:
        print(f"Error setting price=123.45: {e}")
    
    ## Test invalid updates
    try:
        s.shares = "50"  ## Invalid type (string)
        print("This line should not execute")
    except Exception as e:
        print(f"Error setting shares='50': {e}")
    
    try:
        s.shares = -10  ## Invalid value (negative)
        print("This line should not execute")
    except Exception as e:
        print(f"Error setting shares=-10: {e}")
    
    try:
        s.price = "123.45"  ## Invalid type (string)
        print("This line should not execute")
    except Exception as e:
        print(f"Error setting price='123.45': {e}")
    
    try:
        s.price = -10.0  ## Invalid value (negative)
        print("This line should not execute")
    except Exception as e:
        print(f"Error setting price=-10.0: {e}")
  8. Run the test script:

    python /home/labex/project/test_validation.py

    You should see output showing successful valid updates and appropriate error messages for invalid updates.

    Initial: Name=GOOG, Shares=100, Price=490.1, Cost=49010.0
    After setting shares=50: Shares=50, Cost=24505.0
    After setting price=123.45: Price=123.45, Cost=6172.5
    Error setting shares='50': Expected integer
    Error setting shares=-10: shares must be >= 0
    Error setting price='123.45': Expected float
    Error setting price=-10.0: price must be >= 0
✨ Check Solution and Practice

Using __slots__ for Memory Optimization

The __slots__ attribute restricts the attributes a class can have. It prevents adding new attributes to instances and reduces memory usage.

In our Stock class, we'll use __slots__ to:

  1. Restrict attribute creation to only the attributes we've defined.
  2. Improve memory efficiency, especially when creating many instances.

Instructions:

  1. Open the stock.py file in the editor.

  2. Add a __slots__ class variable, listing all the private attribute names used by the class:

    class Stock:
        ## Class variable for type conversions
        _types = (str, int, float)
    
        ## Define slots to restrict attribute creation
        __slots__ = ('name', '_shares', '_price')
    
        ## Rest of the class...
  3. Save the file.

  4. Create a test script named test_slots.py:

    touch /home/labex/project/test_slots.py
  5. Add the following code to the test_slots.py file:

    from stock import Stock
    
    ## Create a stock instance
    s = Stock('GOOG', 100, 490.10)
    
    ## Access existing attributes
    print(f"Name: {s.name}")
    print(f"Shares: {s.shares}")
    print(f"Price: {s.price}")
    print(f"Cost: {s.cost}")
    
    ## Try to add a new attribute
    try:
        s.extra = "This will fail"
        print(f"Extra: {s.extra}")
    except AttributeError as e:
        print(f"Error: {e}")
  6. Run the test script:

    python /home/labex/project/test_slots.py

    You should see output showing that you can access the defined attributes, but attempting to add a new attribute raises an AttributeError.

    Name: GOOG
    Shares: 100
    Price: 490.1
    Cost: 49010.0
    Error: 'Stock' object has no attribute 'extra'
✨ Check Solution and Practice

Reconciling Type Validation with Class Variables

Currently, our Stock class uses both the _types class variable and property setters for type handling. To improve consistency and maintainability, we'll reconcile these mechanisms so that they use the same type information.

Instructions:

  1. Open the stock.py file in the editor.

  2. Modify the property setters to use the types defined in the _types class variable:

    @property
    def shares(self):
        return self._shares
    
    @shares.setter
    def shares(self, value):
        if not isinstance(value, self._types[1]):
            raise TypeError(f"Expected {self._types[1].__name__}")
        if value < 0:
            raise ValueError("shares must be >= 0")
        self._shares = value
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        if not isinstance(value, self._types[2]):
            raise TypeError(f"Expected {self._types[2].__name__}")
        if value < 0:
            raise ValueError("price must be >= 0")
        self._price = value
  3. Save the stock.py file.

  4. Create a test script named test_subclass.py:

    touch /home/labex/project/test_subclass.py
  5. Add the following code to the test_subclass.py file:

    from stock import Stock
    from decimal import Decimal
    
    ## Create a subclass with different types
    class DStock(Stock):
        _types = (str, int, Decimal)
    
    ## Test the base class
    s = Stock('GOOG', 100, 490.10)
    print(f"Stock: {s.name}, Shares: {s.shares}, Price: {s.price}, Cost: {s.cost}")
    
    ## Test valid update with float
    try:
        s.price = 500.25
        print(f"Updated Stock price: {s.price}, Cost: {s.cost}")
    except Exception as e:
        print(f"Error updating Stock price: {e}")
    
    ## Test the subclass with Decimal
    ds = DStock('AAPL', 50, Decimal('142.50'))
    print(f"DStock: {ds.name}, Shares: {ds.shares}, Price: {ds.price}, Cost: {ds.cost}")
    
    ## Test invalid update with float (should require Decimal)
    try:
        ds.price = 150.75
        print(f"Updated DStock price: {ds.price}")
    except Exception as e:
        print(f"Error updating DStock price: {e}")
    
    ## Test valid update with Decimal
    try:
        ds.price = Decimal('155.25')
        print(f"Updated DStock price: {ds.price}, Cost: {ds.cost}")
    except Exception as e:
        print(f"Error updating DStock price: {e}")
  6. Run the test script:

    python /home/labex/project/test_subclass.py

    You should see that the base Stock class accepts float values for the price, while the DStock subclass requires Decimal values.

    Stock: GOOG, Shares: 100, Price: 490.1, Cost: 49010.0
    Updated Stock price: 500.25, Cost: 50025.0
    DStock: AAPL, Shares: 50, Price: 142.50, Cost: 7125.00
    Error updating DStock price: Expected Decimal
    Updated DStock price: 155.25, Cost: 7762.50

Summary

In this lab, you've learned how to use private attributes, convert methods into properties, implement property validation, use __slots__ for memory optimization, and reconcile type validation with class variables. These techniques enhance the robustness, efficiency, and maintainability of your classes by enforcing encapsulation and providing clear interfaces.