Lecture 17: Classes II

These lecture notes are partly adapted from https://www.python-course.eu/.

Data Hiding via Attribute Types

When we create instance variables of a class, we must decide what level of access "users" of the class should have over the data and procedural attributes. Some OOP languages enforce these distinctions, Python uses a special naming convention to "signal the attribute type."

  • Private (prefixed with __ or _): these attributes should only be used inside of the class definition. Among these, attributes with __ are strictly private and essentially invisible from outside. Attributes with _ can be used from outside but really should not be.
  • Public (no underscore prefix): these attributes can and should be freely used.
In [1]:
class TestingAttributes():
    __slots__ = ['__val', '_val', 'val']
    def __init__(self):
        self.__val = "I am strictly private."
        self._val = "I am private but accessible from outside."
        self.val = "I am public."
In [2]:
a = TestingAttributes()
In [3]:
a.__val
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-3-3e19e2bd1a2b> in <module>
----> 1 a.__val

AttributeError: 'TestingAttributes' object has no attribute '__val'
In [4]:
a._val
Out[4]:
'I am private but accessible from outside.'
In [5]:
a.val
Out[5]:
'I am public.'

If we print an object of our class coordinate, notice that the output is not very human readable. We can create user--defined way of a "pretty" object description using the special __str__ method.

Python calls the __str__ method when you print a class object. We can choose what it does! Suppose we want to it to print <3, 4> when called on an object with x-coordinate 3 and y-coordinate 4. Let us write a __str__ method to do this.

In [6]:
class A:
    """Test printing of objects."""
    pass
In [7]:
a = A()
In [8]:
print(a)
<__main__.A object at 0x10db77f90>
In [9]:
class A:
    """Test printing of objects."""
    def __str__(self):
        return 'print representation'
In [10]:
a = A()
In [11]:
print(a)
print representation
In [12]:
class Name:
    """Class to represent a person's name."""
    __slots__ = ['_f', '_m', '_l']
    
    def __init__(self, first, last, middle=''):
        self._f = first
        self._m = middle
        self._l = last
    
    def __str__(self):
        if len(self._m):
            return '{}. {}. {}'.format(self._f[0], self._m[0], self._l) 
        return '{}. {}'.format(self._f[0], self._l)
In [13]:
n1 = Name('Shikha', 'Singh')
n2 = Name('Iris', 'Howley', 'K.')
In [14]:
print(n1)
print(n2)
S. Singh
I. K. Howley

OOP Principle: Encapsulation

Encapsulation is the bundling of data with the methods that operate on that data. It is often accomplished by providing two kinds of procedural attributes:

  • methods for retrieving or accessing the values of attributes, called getter methods or accessor methods. Getter methods do not change the values of attributes, they just return the values, and
  • methods used for changing the values of attributes are called setter methods.
In [15]:
class Coordinate(object):
    """Represents the coordinates of a point."""
    __slots__ = ['_x', '_y']
    
    def __init__(self, x, y):
        self._x = x
        self._y = y
        
    def getX(self):
        return self._x
    
    def getY(self):
        return self._y
        
    def __str__(self):
        return '<{},{}>'.format(self._x, self._y)
In [16]:
p = Coordinate(4, 3)
q = Coordinate(0, 2)
In [17]:
print(p)
<4,3>
In [18]:
print(q)
<0,2>
In [19]:
p.getX()
Out[19]:
4
In [20]:
p.getY()
Out[20]:
3
In [21]:
p.getX() + q.getX()
Out[21]:
4
In [22]:
p.getY() + q.getY()
Out[22]:
5

Note. Explicilty calling getter methods as functions, especially if they are used a lot, can be cumbersome. It would be nice if we can write p.x + p.y instead of p.getX() + q.getX().

@property

Annotations @. Python provides a rich collection of syntactic notes that can change how code is interpreted, called annotations. These are typically prefixed with the at-sign (@).

  • Accessor methods do not change the state of the calling object and are used just to retrieve some information about the object
  • @property annotation (Treat a procedural attribute as a data attribute): If we’d like to treat an accessor method as-if it were a data attribute, we can use the @property annotation
In [23]:
class Coordinate(object):
    """Represents the coordinates of a point."""
    slots = ['_x', '_y']
    def __init__(self, x, y):
        self._x = x
        self._y = y
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
In [24]:
p = Coordinate(4, 3)
q = Coordinate(0, 2)
In [25]:
p.x # notice no parenthesis!
Out[25]:
4
In [26]:
p.y
Out[26]:
3
In [27]:
p.x + q.x, p.y + q.y
Out[27]:
(4, 5)

Example: Name Class

In the previous example, we used @property to access the data attributes of the Coordinate objects. This is not the only use of the annotation, we can use it for any accessor method that we want to invoke as if it was a data attribute.

In [28]:
class Name:
    """Class to represent a person's name."""
    __slots__ = ['_f', '_m', '_l']
    
    def __init__(self, first, last, middle=''):
        self._f = first
        self._m = middle
        self._l = last
    
    def initials(self):
        if len(self._m):
            return '{}. {}. {}.'.format(self._f[0], self._m[0], self._l[0]).upper()
        return '{}. {}.'.format(self._f[0], self._l[0]).upper()
In [29]:
me = Name('Shikha', 'Singh')
In [30]:
me.initials()
Out[30]:
'S. S.'
In [31]:
iris = Name('Iris', 'Howley', 'K')
In [32]:
iris.initials()
Out[32]:
'I. K. H.'
In [33]:
iris.initials
Out[33]:
<bound method Name.initials of <__main__.Name object at 0x10db4e870>>

Using @property. Let us annotate the initials class with @property to make treat initials as if it is a data attribute of Name class.

In [34]:
class Name:
    """Class to represent a person's name."""
    __slots__ = ['_f', '_m', '_l']
    
    def __init__(self, first, last, middle=''):
        self._f = first
        self._m = middle
        self._l = last
    
    @property
    def initials(self):
        if len(self._m):
            return '{}. {}. {}.'.format(self._f[0], self._m[0], self._l[0]).upper()
        else:
            return '{}. {}.'.format(self._f[0], self._l[0]).upper()
In [35]:
iris = Name('Iris', 'Howley', 'K')
In [36]:
iris.initials  # notice no parenthesis!
Out[36]:
'I. K. H.'

Building the Coordinate Class

Let us apply the concept we have learn to finish up our coordinate class.

We have already implemented:

  • x(self): returns the x coordinate of the calling instance (annotated with @)
  • y(self): returns the y coordinate of the calling instance (annotated with @)
  • __str__: print representation of the class

We will now implement:

  • _subX(self, other): subtracts the x coordinates of self and other and returns the answer
  • _subY(self, other): subtracts the y coordinate of self and other and returns the answer
  • dist(self, others): computes the Euclidean distance between coordinates self and other
  • radius(self): computes and returns the radius of the calling coordindate (annotated with @)

Distance between Two Points

Let us define a method distance for the Coordinate class which when called on an object of the class computes the Euclidean distance between that object and another point. Recall that the Eucliden distance between two points $p_1 = (x_1, y_1)$ and $p_2 = (x_2, y_2)$ is

$$ d(p_1, p_2) = \sqrt{((x_1-x_2)^2 + (y_1-y_2)^2}.$$
In [37]:
class Coordinate(object):
    """Represents the coordinates of a point."""
    __slots__ = ['_x', '_y']
    def __init__(self, x, y):
        self._x = x
        self._y = y
        
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    def _subX(self, other):
        """Subtracts the x coordinates of self 
        and other and returns the answer"""
        return self._x - other._x
    
    def _subY(self, other):
        """Subtracts the y coordinates of self 
        and other and returns the answer"""
        return self._y - other._y
    
    def dist(self, other):
        sqX = self._subX(other)**2
        sqY = self._subY(other)**2
        return round((sqX + sqY)**0.5, 2)
    
    @property
    def radius(self):
        """Returns the distance of the point from (0,0)"""
        origin = Coordinate(0,0)
        return self.dist(origin)
    
    def __str__(self):
        return '<{}, {}>'.format(self._x, self._y)
In [38]:
p1 = Coordinate(0,2)
In [39]:
p2 = Coordinate(3, 4)
In [40]:
p1.x
Out[40]:
0
In [41]:
p2.y
Out[41]:
4
In [42]:
p1.dist(p2)  # call distance on object pt, zero is argument
Out[42]:
3.61
In [43]:
round(13**0.5,2)
Out[43]:
3.61
In [44]:
p1.radius
Out[44]:
2.0
In [45]:
p2.radius
Out[45]:
5.0

Summary

Today we saw how Python supports data abstraction (separating the data and details of the implementation from the user) via :

  • Data hiding: via attribute naming conventions (private, public)
  • Encapsulation: bundling together of data and methods that provide an interface to the data
    • accessor methods that can be simply implemented using @property annotation