Post

A Deep Dive into Python Class Properties

A Deep Dive into Python Class Properties

Introduction

Object-Oriented Programming (OOP) is a cornerstone of modern software development, and Python’s implementation of it is both powerful and elegant. At the heart of OOP are classes and objects, which encapsulate data (attributes) and behavior (methods). A key principle of encapsulation is controlling access to an object’s data, preventing direct, uncontrolled modification.

In many programming languages, this is achieved with private variables and explicit getter and setter methods. Python, however, offers a more “Pythonic” solution: properties. Properties allow you to expose what looks like a public attribute while retaining the control and logic of a method. This article explores the what, why, and how of Python properties, demonstrating their power in creating clean, robust, and maintainable code.

The Problem: Direct Attribute Access

Let’s start with a simple Employee class.

1
2
3
4
5
6
7
8
9
10
11
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def __str__(self):
        return f"{self.name}: ${self.salary:,.2f}"

emp = Employee("John Doe", 80000)
print(emp)
# Output: John Doe: $80,000.00

This works perfectly fine. However, there are no safeguards. What if someone accidentally sets an invalid salary?

1
2
3
emp.salary = -5000
print(emp)
# Output: John Doe: $-5,000.00

A negative salary doesn’t make sense. This direct, uncontrolled access to the salary attribute makes our object’s state unreliable. The traditional approach to solve this is to “hide” the attribute and create methods to manage it.

The Old Way: Private Attributes and Getter/Setter Methods

To enforce constraints, we can make the salary attribute “private” (by convention, prefixing it with an underscore) and introduce getter and setter methods.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self._salary = salary  # "Private" attribute

    def get_salary(self):
        return self._salary

    def set_salary(self, amount):
        if amount < 0:
            raise ValueError("Salary cannot be negative.")
        self._salary = amount

    def __str__(self):
        return f"{self.name}: ${self._salary:,.2f}"

emp = Employee("Jane Doe", 90000)

# Accessing the salary
current_salary = emp.get_salary()
print(f"Current Salary: ${current_salary}")
# Output: Current Salary: $90000

# Updating the salary
emp.set_salary(95000)
print(emp)
# Output: Jane Doe: $95,000.00

# Trying to set an invalid salary
try:
    emp.set_salary(-1000)
except ValueError as e:
    print(e)
# Output: Salary cannot be negative.

This works, but it has a major drawback. The interface of our class has changed. Anyone who was previously accessing emp.salary now has to change their code to emp.get_salary() and emp.set_salary(). This is not ideal, especially in large codebases. We’ve broken the public API of our class.

This is where properties come to the rescue.

The Pythonic Way: The @property Decorator

Properties allow us to solve this problem without changing the class’s public interface. We can use the @property decorator to turn a method into a “getter” for an attribute that has the same name as the method.

Let’s refactor our Employee class to use a property.

Step 1: The Getter

First, we define a “private” _salary attribute and a public-facing salary property.

1
2
3
4
5
6
7
8
9
10
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary  # This will now call the setter method!

    @property
    def salary(self):
        """The getter method for the salary."""
        print("Getting salary...")
        return self._salary

Here, the salary method is decorated with @property. This means that when we access emp.salary, Python will automatically call this method and return its result. Notice we haven’t defined a setter yet, so trying to assign a value will fail.

1
2
# emp = Employee("John Doe", 80000)
# AttributeError: can't set attribute

The __init__ method now fails because self.salary = salary is trying to set the attribute, and we haven’t defined how to do that yet.

Step 2: The Setter

To define a setter, we create another method with the same name (salary) and decorate it with @salary.setter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary  # The setter is called here

    @property
    def salary(self):
        """The getter method for the salary."""
        # print("Getting salary...")
        return self._salary

    @salary.setter
    def salary(self, amount):
        """The setter method for the salary."""
        # print("Setting salary...")
        if amount < 0:
            raise ValueError("Salary cannot be negative.")
        self._salary = amount

    def __str__(self):
        return f"{self.name}: ${self.salary:,.2f}"

# Now, creating an instance works
emp = Employee("Peter Pan", 75000)
print(emp)
# Output: Peter Pan: $75,000.00

# Accessing the attribute calls the getter
print(f"Salary: ${emp.salary}")
# Output: Salary: $75000

# Assigning to the attribute calls the setter
emp.salary = 80000
print(emp)
# Output: Peter Pan: $80,000.00

# The validation works as expected
try:
    emp.salary = -2000
except ValueError as e:
    print(e)
# Output: Salary cannot be negative.

We have successfully restored the original, clean interface (emp.salary) while adding the validation logic we needed. This is the power of properties. The user of the class doesn’t need to know about the internal _salary attribute or the validation logic; they just interact with a simple attribute.

Step 3: The Deleter

Properties can also have a “deleter” method, which is called when we use the del keyword on the attribute. This is useful for cleanup logic or for controlling whether an attribute can be deleted.

We define it using the @salary.deleter decorator.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, amount):
        if amount < 0:
            raise ValueError("Salary cannot be negative.")
        self._salary = amount

    @salary.deleter
    def salary(self):
        print("Deleting salary...")
        del self._salary

    def __str__(self):
        return f"{self.name}: ${self.salary:,.2f}"

emp = Employee("Mary Jane", 120000)
print(emp)
# Output: Mary Jane: $120,000.00

del emp.salary
# Output: Deleting salary...

try:
    print(emp.salary)
except AttributeError as e:
    print(e)
# Output: 'Employee' object has no attribute '_salary'

The deleter allows you to define custom behavior for attribute deletion, such as logging the action, updating other parts of the object, or preventing deletion altogether by raising an exception.

Read-Only Properties

What if you want to create an attribute that can be set at initialization but cannot be changed later? You can achieve this by simply omitting the setter method.

Let’s imagine an Employee class where the employee_id should be immutable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Employee:
    def __init__(self, name, employee_id):
        self.name = name
        self._employee_id = employee_id  # Set the internal attribute directly

    @property
    def employee_id(self):
        """A read-only property for the employee ID."""
        return self._employee_id

emp = Employee("Clark Kent", "EMP-007")

# You can read the ID
print(f"Employee ID: {emp.employee_id}")
# Output: Employee ID: EMP-007

# But you cannot change it
try:
    emp.employee_id = "EMP-008"
except AttributeError as e:
    print(e)
# Output: can't set attribute

This creates a clean, read-only public attribute, protecting the integrity of the employee_id after the object has been created.

Benefits of Using Properties

  1. Clean API: Properties maintain a simple, attribute-based public interface. Users of your class don’t need to call get_ and set_ methods, leading to cleaner, more readable code.
  2. Encapsulation and Validation: They provide a robust way to enforce business logic, validation, and constraints on your data without exposing the implementation details.
  3. Computed Attributes: A property’s getter can compute a value on the fly rather than just returning a stored attribute. For example, a Person class could have first_name and last_name attributes and a full_name property.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    class Person:
        def __init__(self, first_name, last_name):
            self.first_name = first_name
            self.last_name = last_name
    
        @property
        def full_name(self):
            return f"{self.first_name} {self.last_name}"
    
    person = Person("Tony", "Stark")
    print(person.full_name)  # Looks like an attribute, but is computed
    # Output: Tony Stark
    
  4. Maintainability and Refactoring: You can start with simple public attributes and later upgrade them to properties with getters and setters without changing the public API. This means you can add logic and validation as your program evolves without breaking existing code that uses your class.

Conclusion

Python properties are a powerful feature that embodies the language’s philosophy of simplicity and readability. They provide the perfect bridge between direct attribute access and the strict control of getter/setter methods. By using the @property decorator and its companions, @*.setter and @*.deleter, you can create classes that are both easy to use and robust in their design.

Embracing properties allows you to write more “Pythonic” code, offering a clean, attribute-style access pattern while hiding the complexity of validation, computation, or any other logic you need to associate with your data. It’s a fundamental tool for any developer looking to master Object-Oriented Programming in Python.

Suggested Reading

This post is licensed under CC BY 4.0 by the author.