Encapsulating data in Python

First, we will add attributes to a class in Python and then use prefixes to hide specific members of a class. We will use property getters and setters to control how we write and retrieve values to and from related attributes. We will use methods to add behaviors to classes, and we will create the mutable and immutable version of a 3D vector to understand the difference between an object that mutates state and an object that doesn't.

Adding attributes to a class

The TibetanSpaniel class is a blueprint for dogs that belong to the Tibetan Spaniel breed. This class should inherit from a Dog superclass, but we will forget about inheritance and other dog breeds for a while. We will use the TibetanSpaniel class to understand the difference between class attributes and instance attributes.

As happens with any other dog breed, Tibetan Spaniel dogs have some profile values. We will define the following class attributes to store the values that are shared by all the members of the Tibetan Spaniel breed. Note that the valid values for scores are from 0 to 10; 0 is the lowest skill and 10 the highest.

  • family: This is the family to which the dog breed belongs
  • area_of_origin: This is the are a of origin of the dog breed
  • learning_rate: This is the typical learning rate score for the members of this dog breed
  • obedience: This is the average obedience score for the members of this dog breed
  • problem_solving: This is the average problem solving score for the members of this dog breed

The following lines create a TibetanSpaniel class and declare the previously enumerated class attributes and a __init__ method within the body of a class:

class TibetanSpaniel:
  family = "Companion, herding"
  area_of_origin = "Tibet"
  learning_rate = 9
  obedience = 3
  problem_solving = 8

  def __init__(self, name, favorite_toy, watchdog_ability):
    self.name = name
    self.watchdog_ability = watchdog_ability
    self.favorite_toy = favorite_toy

The preceding code assigns a value to each class variable after the class header within the class body and without self. as its prefix. This code assigns a value outside of any method because there is no need to create any instance to access the attributes of a class.

Tip

It's common practice to place the class attributes at the top, right after the class header.

The following command prints the value of the previously declared family class attribute. Note that we didn't create any instance of the TibetanSpaniel class. Also, we specify an attribute after the class name and a dot:

print(TibetanSpaniel.family)

The following command creates an instance of the TibetanSpaniel class and then prints the value of the family class attribute. In this case, we will use an instance to access the class attribute:

brian = TibetanSpaniel("Brian", "Talking Minion", 4)
print(brian.family)

You can assign a new value to any class attribute. For example, the following command assigns 4 to the obedience class attribute:

TibetanSpaniel.obedience = 4

However, we must be very careful when we want to assign a new value to a class variable. We must address the class attribute through a class and not through an instance. If we assign a value to obedience through an instance named brian, Python will create a new instance attribute called obedience, instead of changing the value of the class attribute with the same name.

The following commands illustrate the problem. The first command creates a new instance attribute called obedience and sets its value to 8; therefore, brian.obedience will be 8. However, if we check the value of the TibetanSpaniel.obedience or type(brian).obedience class variable, the value continues to be 4:

brian.obedience = 8

print(brian.obedience)
print(type(brian).obedience)
print(TibetanSpaniel.obedience)

Hiding data in Python using prefixes

The previously declared TibetanSpaniel class exposes the instance and class attributes without any kind of restriction. Thus, we can access these attributes and change their values. Python uses a special naming convention for attributes to control their accessibility. So far, we have been using attribute names without any kind of prefix; therefore, we could access attribute names within a class definition and outside of a class definition. These kinds of attributes are known as public attributes and are exposed without any restriction.

In Python, we can mark an attribute as protected by prefixing the attribute name with a leading underscore (_). For example, if we want to convert the name attribute from a public to a protected attribute, we just need to change its name from name to _name.

Whenever we mark an attribute as protected, we are telling the users of the class that they shouldn't use these attributes outside of the class definition. Thus, only the code written within the class definition and within subclasses should access attributes marked as protected. We say should, because Python doesn't provide a real shield for the attributes marked as protected; the language just expects users to be honest and take into account the naming convention. The following command shows a new version of the __init__ method for the TibetanSpaniel class that declares three instance attributes as protected by adding a leading underscore (_) to names:

def __init__(self, name, favorite_toy, watchdog_ability):
    self._name = name
    self._watchdog_ability = watchdog_ability
    self._favorite_toy = favorite_toy

We can mark an attribute as private by prefixing the attribute name with two leading underscores (__). For example, if we want to convert the name attribute from a protected to a private attribute, we just need to change its name from _name to __name.

Whenever we mark an attribute as private, Python doesn't allow users to access the attribute outside of the class definition. The restriction also applies to subclasses; therefore, only the code written within a class can access attributes marked as private.

Python still provides access to these attributes outside of the class definition with a different name composed of a leading underscore (_), followed by the class name and the private attribute name. For example, if we use __name to mark name as a private attribute, it will be accessible with the TibetanSpaniel__name name. Obviously, the language expects users to be honest, take into account the naming convention, and avoid accessing the renamed private attribute. The following commands show a new version of the __init__ method for the TibetanSpaniel class that declares three instance attributes as private by adding two leading underscores (__) to names:

def __init__(self, name, favorite_toy, watchdog_ability):
    self.__name = name
    self.__watchdog_ability = watchdog_ability
    self.__favorite_toy = favorite_toy

Tip

The same naming convention applies to instance attributes, class attributes, instance methods, and class methods.

Using property getters and setters in Python

Python provides a simple mechanism to define properties and specify the getter and/or setter methods. We want to make the following changes to our TibetanSpaniel class:

  • Encapsulate the name attribute with a read-only name property
  • Encapsulate the __favorite_toy attribute with a favorite_toy property
  • Encapsulate the __watchdog_ability attribute with a watchdog_ability property and include the code in the setter method to assign 0 to the underlying attribute if the value specified is lower than 0 and 10 if the value specified is higher than 10
  • Define a protection_score read-only property with a getter method that calculates and returns a protection score based on the values of the __watchdog_ability private instance attribute, the learning_rate public class attribute, and the problem_solving public class attribute

We want a read-only name property; therefore, we just need to define a getter method that returns the value of the related __name private attribute. We just need to define a method named name and decorate it with @property. The following commands within the class body will do the job. Note that @property is included before the method's header:

@property
def name(self):
    return self.__name

After we add a getter method to define a read-only name property, we can create an instance of the edited class and try to change the value of the read-only property name, as shown in the following command:

merlin = TibetanSpaniel("Merlin", "Talking Smurf", 6)
merlin.name = "brian"

The Python console will display the following error because there is no setter method defined for the name property:

Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: can't set attribute

We want to encapsulate the __favorite_toy private attribute with the favorite_toy property; therefore, we have to define both getter and setter methods. The getter method returns the value of the related __favorite_toy private attribute. The setter method receives the new favorite toy value as an argument and assigns this value to the related __favorite_toy private attribute. We have to decorate the setter method with @favorite_toy.setter, that is, @, followed by the property name and .setter. The following commands within the class body will do the job:

@property
def favorite_toy(self):
    return self.__favorite_toy

@favorite_toy.setter
def favorite_toy(self, favorite_toy):
    self.__favorite_toy = favorite_toy

The setter method for the favorite_toy property is very simple. The watchdog_ability property requires a setter method with more code to transform values lower than 0 in 0 and values higher than 10 in 10. The following class body will do the job:

@property
def watchdog_ability(self):
    return self.__watchdog_ability

@watchdog_ability.setter
def watchdog_ability(self, watchdog_ability):
    if watchdog_ability < 0:
        self.__watchdog_ability = 0
    elif watchdog_ability > 10:
        self.__watchdog_ability = 10
    else:
        self.__watchdog_ability = watchdog_ability

After we add the watchdog_ability property, we will create an instance of the edited class and try to set different values to this property, as shown in the following code:

hugo = TibetanSpaniel("Hugo", "Tennis ball", 7)
hugo.watchdog_ability = -3
print(hugo.watchdog_ability)
hugo.watchdog_ability = 30
print(hugo.watchdog_ability)
hugo.watchdog_ability = 8
print(hugo.watchdog_ability)

In the preceding code, after we specified -3 as the desired value for the watchdog_ability property, we printed its actual value and the result was 0. After we specified 30, the actual printed value was 10. Finally, after we specified 8, the actual printed value was 8. The code in the setter method did its job. This is how we could control the values accepted for the underlying private instance attribute.

We want a read-only protection_score property. However, this time the getter method must calculate and return a protection score based on a private instance attribute and two public class attributes. Note that the code accesses the public class attributes through type(self), followed by the attribute name. It's a safe way to access class attributes because we can change the class name or work with inheritance without unexpected issues. The following commands in the class body will do the job:

@property
def protection_score(self):
    return math.floor((self.__watchdog_ability + type(self).learning_rate + type(self).problem_solving) / 3)

After we add the protection_score property, we will create an instance of the edited class and print the value of this read-only property:

cole = TibetanSpaniel("Cole", "Soccer ball", 4)
print(cole.protection_score)

Here is the complete code for the TibetanSpaniel class along with properties:

class TibetanSpaniel:
    family = "Companion, herding"
    area_of_origin = "Tibet"
    learning_rate = 9
    obedience = 3
    problem_solving = 8

    def __init__(self, name, favorite_toy, watchdog_ability):
        self.__name = name
        self.__watchdog_ability = watchdog_ability
        self.__favorite_toy = favorite_toy

    @property
    def name(self):
        return self.__name

    @property
    def favorite_toy(self):
        return self.__favorite_toy

    @favorite_toy.setter
    def favorite_toy(self, favorite_toy):
        self.__favorite_toy = favorite_toy

    @property
    def watchdog_ability(self):
        return self.__watchdog_ability

    @watchdog_ability.setter
    def watchdog_ability(self, watchdog_ability):
        if watchdog_ability < 0:
            self.__watchdog_ability = 0
        elif watchdog_ability > 10:
            self.__watchdog_ability = 10
        else:
            self.__watchdog_ability = watchdog_ability

    @property
    def protection_score(self):
        return math.floor((self.__watchdog_ability + type(self).learning_rate + type(self).problem_solving) / 3)

Using methods to add behaviors to classes in Python

So far, we have added instance methods to classes and used getter and setter methods combined with decorators to define properties. Now, we want to generate a class to represent the mutable version of a 3D vector.

We will use properties with simple getter and setter methods for x, y, and z. The sum public instance method receives the delta values for x, y, and z and mutates an object, that is, the setter method changes the values of x, y, and z. Here is the initial code of the MutableVector3D class:

class MutableVector3D:
    def __init__(self, x, y, z):
        self.__x = x
        self.__y = y
        self.__z = z

    def sum(self, delta_x, delta_y, delta_z):
        self.__x += delta_x
        self.__y += delta_y
        self.__z += delta_z

    @property
    def x(self):
        return self.__x

    @x.setter
    def x(self, x):
        self.__x = x

    @property
    def y(self):
        return self.__y

    @y.setter
    def y(self, y):
        self.__y = y

    @property
    def z(self):
        return self.__z

    @z.setter
    def z(self, z):
        self.__z = z

It's a very common requirement to generate a 3D vector with all the values initialized to 0, that is, x = 0, y = 0, and z = 0. A 3D vector with these values is known as an origin vector. We can add a class method to the MutableVector3D class named origin_vector to generate a new instance of the class initialized with all the values initialized to 0. It's necessary to add the @classmethod decorator before the class method header. Instead of receiving self as the first argument, a class method receives the current class; the parameter name is usually named cls. The following code defines the origin_vector class method:

@classmethod
def origin_vector(cls):
    return cls(0, 0, 0)

The preceding method returns a new instance of the current class (cls) with 0 as the initial value for the three elements. The class method receives cls as the only argument; therefore, it will be a parameterless method when we call it because Python includes a class as a parameter under the hood. The following command calls the origin_vector class method to generate a 3D vector, calls the sum method for the generated instance, and prints the values for the three elements:

mutableVector3D = MutableVector3D.origin_vector()
mutableVector3D.sum(5, 10, 15)
print(mutableVector3D.x, mutableVector3D.y, mutableVector3D.z)

Now, we want to generate a class to represent the immutable version of a 3D vector. In this case, we will use read-only properties for x, y, and z. The sum public instance method receives the delta values for x, y, and z and returns a new instance of the same class with the values of x, y, and z initialized with the results of the sum. Here is the code of the ImmutableVector3D class:

class ImmutableVector3D:
    def __init__(self, x, y, z):
        self.__x = x
        self.__y = y
        self.__z = z

    def sum(self, delta_x, delta_y, delta_z):
        return type(self)(self.__x + delta_x, self.__y + delta_y, self.__z + delta_z)

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    @property
    def z(self):
        return self.__z

    @classmethod
    def equal_elements_vector(cls, initial_value):
        return cls(initial_value, initial_value, initial_value)

    @classmethod
    def origin_vector(cls):
        return cls.equal_elements_vector(0)

Note that the sum method uses type(self) to generate and return a new instance of the current class. In this case, the origin_vector class method returns the results of calling the equal_elements_vector class method with 0 as an argument. Remember that the cls argument refers to the actual class. The equal_elements_vector class method receives an initial_value argument for all the elements of the 3D vector, creates an instance of the actual class, and initializes all the elements with the received unique value. The origin_vector class method demonstrates how we can call another class method in a class method.

The following command calls the origin_vector class method to generate a 3D vector, calls the sum method for the generated instance, and prints the values for the three elements of the new instance returned by the sum method:

vector0 = ImmutableVector3D.origin_vector()
vector1 = vector0.sum(5, 10, 15)
print(vector1.x, vector1.y, vector1.z)

Tip

As explained previously, we can change the values of the private attributes; therefore, the ImmutableVector3D class isn't 100 percent immutable. However, we are all adults and don't expect the users of a class with read-only properties to change the values of private attributes hidden under difficult to access names.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.119.172.243