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.