19. Encapsulation#

In previous sections, we created a Point class based on Cartesian coordinates x and y. This class is designed to encapsulate the data and provide methods for interacting with it, thereby hiding the internal representation of the data from the outside world.

import math

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance(self, other):
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)

Encapsulation is a fundamental principle of object-oriented programming. It ensures that an object’s attributes are not accessed directly from outside the class. Instead, interaction with the object’s data is done through methods defined within the class. This practice protects the internal state of the object and provides a controlled way to access and modify it.

In the case of the Point class, the attributes x and y represent the state of the object, and the distance method is provided to interact with this state in a meaningful way. The power of encapsulation is evident when you look at how other classes, such as Polygon, Triangle, and Square, interact with Point objects. These classes never access the x and y attributes directly; they rely solely on the distance method to perform calculations like perimeters and areas. This approach allows for greater flexibility and maintainability of the code.

19.1. Changing the Representation#

Encapsulation also facilitates the process of modifying the internal representation of an object without impacting the rest of the source code. Suppose we decide to switch from Cartesian coordinates to polar coordinates for some reason. What effect will this have on our existing code?

Thanks to encapsulation, the impact is minimal. We only need to modify the Point class while ensuring that its interface (i.e., the methods and their expected inputs and outputs) remains unchanged. Below is an updated version of the Point class, now using polar coordinates.

class Point:
    def __init__(self, x, y):
        self.radius = math.sqrt(x*x + y*y)
        if self.radius > 0:
            self.theta = math.acos(x / self.radius)
            if y < 0:
                self.theta = -self.theta
        else:
            self.theta = 0
            
    def get_x(self):
        return self.radius * math.cos(self.theta)

    def get_y(self):
        return self.radius * math.sin(self.theta)
    
    def distance(self, other):
        return math.sqrt((self.get_x() - other.get_x()) ** 2 + (self.get_y() - other.get_y()) ** 2)

In this revised implementation, we have changed the internal representation of a Point from Cartesian coordinates to polar coordinates, using radius and theta (angle). Notice that the distance method is slightly modfied but still works as before, thanks to the get_x and get_y methods that convert the polar coordinates back to Cartesian coordinates for the purpose of distance calculation. The other classes that interact with Point do not need any modifications because they still interact with Point through its methods, without caring about the underlying data representation.

19.2. Enhancing the Constructor for Flexibility#

To further enhance flexibility and ensure backward compatibility, we could modify the constructor to accept either Cartesian or polar coordinates. This can be achieved by introducing an optional boolean parameter cartesian, which indicates the type of coordinates being provided:

class Point:
    def __init__(self, a, b, cartesian=True):
        if cartesian:
            self.radius = math.sqrt(a*a + b*b)
            if self.radius > 0:
                self.theta = math.acos(a / self.radius)
                if b < 0:
                    self.theta = -self.theta
            else:
                self.theta = 0
        else:
            self.radius = a
            self.theta = b

With this enhancement, the constructor can now accept either Cartesian coordinates (if cartesian=True) or polar coordinates (if cartesian=False). This ensures that the Point class remains versatile and adaptable to different use cases, without requiring changes to the code that relies on it.

19.3. Accessors and Decorators#

In Python, accessors are methods used to get or set the value of an attribute. Instead of directly accessing the attributes of a class, accessors allow you to control how these attributes are accessed or modified. This aligns with the principle of encapsulation, as it provides a controlled interface for interacting with an object’s state.

Decorators in Python, specifically the @property decorator, offer a powerful way to implement accessors without requiring explicit method calls in your code. They allow you to define methods that can be accessed like attributes while still providing the flexibility to control how these values are retrieved or set.

Let’s revisit the Point class. Instead of directly accessing x and y, we’ll use @property to create getter and setter methods that will allow us to manipulate the attributes in a controlled manner.

19.3.1. Getter Methods#

We can start by defining getter methods using the @property decorator. These methods will allow us to retrieve the values of x and y as if they were attributes, even though they might be derived from other internal representations (like polar coordinates).

class Point:
    def __init__(self, x, y):
        self.radius = math.sqrt(x*x + y*y)
        if self.radius > 0:
            self.theta = math.acos(x / self.radius)
            if y < 0:
                self.theta = -self.theta
        else:
            self.theta = 0

    @property
    def x(self):
        return self.radius * math.cos(self.theta)

    @property
    def y(self):
        return self.radius * math.sin(self.theta)
    
    def distance(self, other):
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)

In this example, the x and y properties are defined with the @property decorator. When you access point.x or point.y, Python automatically calls the corresponding getter method to retrieve the value. The rest of the code can interact with x and y as if they were simple attributes, but the actual values are calculated based on the internal polar coordinate representation.

19.3.2. Setter Methods#

Sometimes, you may want to allow modifications to the attributes. To do this, you can define setter methods using the @x.setter decorator. These methods will be invoked whenever an attempt is made to change the value of the attribute.

Let’s extend the Point class to include setters for x and y.

class Point:
    def __init__(self, x, y):
        self.radius = math.sqrt(x*x + y*y)
        if self.radius > 0:
            self.theta = math.acos(x / self.radius)
            if y < 0:
                self.theta = -self.theta
        else:
            self.theta = 0

    @property
    def x(self):
        return self.radius * math.cos(self.theta)

    @x.setter
    def x(self, value):
        y = self.y  # Calculate current y based on current radius and theta
        self.radius = math.sqrt(value*value + y*y)
        if self.radius > 0:
            self.theta = math.acos(value / self.radius)
            if y < 0:
                self.theta = -self.theta
        else:
            self.theta = 0

    @property
    def y(self):
        return self.radius * math.sin(self.theta)

    @y.setter
    def y(self, value):
        x = self.x  # Calculate current x based on current radius and theta
        self.radius = math.sqrt(x*x + value*value)
        if self.radius > 0:
            self.theta = math.acos(x / self.radius)
            if value < 0:
                self.theta = -self.theta
        else:
            self.theta = 0
    
    def distance(self, other):
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)

With this modification, you can now assign new values to point.x or point.y, and the corresponding setter method will adjust the internal radius and theta attributes accordingly.

19.3.3. Advantages of Using Accessors with Decorators#

Using accessors, we can enforce validation or transformations when getting or setting an attribute, ensuring that the object’s state remains consistent. We can change the internal representation of an attribute without altering the external interface.

Using accessors hides the complexity of the internal implementation. External code interacts with a clean and simple interface, making the source code easier to maintain.