A Reasonably Detailed Introduction to Classes In Python

Posted on 20-02-19 in Computing

In this article I will describe what classes are, why we use them and how to implement them in Python. Along the way we will cover some terminology generally required in the A level Computer Science Program.

What is a class?

Crudely put, a class is a collection of variables and functions stapled together into a single object. When coding, we sometimes find ourselves with several functions that are clearly related. Furthermore they end up having lots of arguments which are the same. For instance let's say we want to create a calculator for finding the area and perimeter of shapes.

In [1]:
def perimeter_rectangle(a,b):
    return 2 * (a + b)

def area_rectangle(a,b):
    return a*b

a = 5
b = 10
print(f"Rectangle with sides {a},{b} has area {area_rectangle(a,b)} and perimeter {perimeter_rectangle(a,b)}")
Rectangle with sides 5,10 has area 50 and perimeter 30

Here we have used the same arguments in both functions. More than that we've had to clunkily define the shape name inside the funtion name! We can do better than this by using a class.

In [2]:
class Rectangle:
    def __init__(self, a, b):
        self.a = a
        self.b = b
    
    def area(self):
        return self.a * self.b
    
    def perimeter(self):
        return 2 * (self.a + self.b)
    
rect = Rectangle(5, 10)
print(f"Rectangle with sides {rect.a},{rect.b} has area {rect.area()} and perimeter {rect.perimeter()}")
Rectangle with sides 5,10 has area 50 and perimeter 30

Here we've defined our first class. Let's dissect this and see what each bit is doing!

  1. init : This is our initialisation function, or method. In this we will pass any arguments that the class will need to work. In this case we've got a,b.
  2. self : Classes are descriptions of what the object will look like. In this case we've described every rectangle. To create a specific one, we'll need to instantiate one like we have on line 12. By using the keyword self we're saying use the methods and variables (known as attributes) of the object that exists.

So we can say a class is kindof a description of an object. These objects can have attributes, a property of the object and methods, actions the object can do.

The init method is the first thing that is executed when we create an instance of our class. For our purposes we'll refer to this is as the constructor[1].

Attributes can be designated as public or private. Public attributes can be accessed outside the class/object. Private attributes should not be accessed.

In some cases we only want a variable to be altered in very specific ways. For this we might have functions inside the class that set or get the True value of something.

In Python this is not a hardwired distinction, merely a polite request. For instance let's say we have a trigonometry function

[1]https://stackoverflow.com/questions/6578487/init-as-a-constructor

In [3]:
from math import pi, sin, cos, tan
from fractions import Fraction

class Trig:
    """
    Trigonometry Class based in Degrees
    In this we hide the actual complexity from the user
    """
    
    Degrees = pi / 180
    
    def set_angle(self, angle):
        """
        Converts from degrees to radians
        """
        return angle * Trig.Degrees
    
    def __init__(self, angle):
        self._angle = self.set_angle(angle)
        
    def get_angle(self):
        return self._angle / Trig.Degrees

    def sin(self):
        return sin(self._angle)
    
    def cos(self):
        return cos(self._angle)
    
    def tan(self):
        return tan(self._angle)
In [4]:
for angle in (0, 30, 45, 60, 90):
    tri = Trig(angle)
    print(f"For {tri.get_angle():.0f} Degrees sine is {tri.sin():.3f} and cosine is {tri.cos():.3f}")
For 0 Degrees sine is 0.000 and cosine is 1.000
For 30 Degrees sine is 0.500 and cosine is 0.866
For 45 Degrees sine is 0.707 and cosine is 0.707
For 60 Degrees sine is 0.866 and cosine is 0.500
For 90 Degrees sine is 1.000 and cosine is 0.000

For those who don't know, degrees are not the correct measure of angle! However given that this is a common measure it makes sense to hide this reality from the user. This class will return sine, cosine or the tangent given an angle in degrees!

The underscore on _angle is important as it tells the user to not edit this directly.

Let's consider another example

Representing Money as a class

In the case of money (pounds and pence for the UK where I'm writing this!) has a few requirements. Firstly we want to ensure a negative quantity of money isn't allowed. We also want to ensure fractions of a penny are not allowed either.

To do this we will create functions inside the class to set and get the balance. These getters and setters will allow us to prevent accidental entry of invalid values!

We will also use a more 'pythonic' way of creating these getters or setters this time.

In [5]:
class Money:
    """
    Money : Represent a bank balance
    """
    @property
    def balance(self):
        return self._balance / 100
    
    @balance.setter
    def balance(self, quantity):
        """
        Ensures Amount is always positive!
        Quantity is pence or pounds, pence
        """
        try:
            pounds, pence = quantity
        except TypeError:
            pence = quantity
            pounds = 0
        
        amount = pounds * 100 + pence
        if not float(amount).is_integer():
            raise ValueError("Fractional pence are not allowed!")
        
        if amount >= 0:
            self._balance = amount
        else:
            raise ValueError("Negative Quantities are not allowed!")

    def __init__(self, pounds=0, pence=0, symbol='£'):
        self.balance = (pounds, pence)
        self.symbol = symbol
        
    def __repr__(self):
        return f'{self.symbol}{self.balance:.02f}'
In [6]:
quantity = Money(1,22)
print(f"Your balance is {quantity}")
Your balance is £1.22

Internally the program stores money as an integer, namely pence only. In this case we have introducted the @property[1] object.

Using this makes the method behave like an attribute. Let us try entering bad inputs and see if it crashes like we would like!

[1]https://docs.python.org/3/library/functions.html#property

In [7]:
Money(1,22.5)  # £1.225??
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-7-e068ab470855> in <module>()
----> 1 Money(1,22.5)  # £1.225??

<ipython-input-5-ffe766ac7dc7> in __init__(self, pounds, pence, symbol)
     29 
     30     def __init__(self, pounds=0, pence=0, symbol='£'):
---> 31         self.balance = (pounds, pence)
     32         self.symbol = symbol
     33 

<ipython-input-5-ffe766ac7dc7> in balance(self, quantity)
     21         amount = pounds * 100 + pence
     22         if not float(amount).is_integer():
---> 23             raise ValueError("Fractional pence are not allowed!")
     24 
     25         if amount >= 0:

ValueError: Fractional pence are not allowed!
In [8]:
Money(1,-101)  # negative one pence!
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-8-87bb407d2122> in <module>()
----> 1 Money(1,-101)  # negative one pence!

<ipython-input-5-ffe766ac7dc7> in __init__(self, pounds, pence, symbol)
     29 
     30     def __init__(self, pounds=0, pence=0, symbol='£'):
---> 31         self.balance = (pounds, pence)
     32         self.symbol = symbol
     33 

<ipython-input-5-ffe766ac7dc7> in balance(self, quantity)
     26             self._balance = amount
     27         else:
---> 28             raise ValueError("Negative Quantities are not allowed!")
     29 
     30     def __init__(self, pounds=0, pence=0, symbol='£'):

ValueError: Negative Quantities are not allowed!

Before we move on, let us look at some other useful functionality we can add to this class.

Interaction of Objects

It can sometimes make sense for objects to interact. In the case of Money objects, we might want addition to merge the two with the balances being added together.

In the case of Python we will used magic methods[1]. There's a lot of very useful functionality here and I won't discuss it all, but definitely read the link below. Let's implement add and sub (addition and subtraction!)

[1]https://rszalski.github.io/magicmethods

In [9]:
class Money:
    """
    Money : Represent a bank balance
    """
  
    @property
    def balance(self):
        return self._balance / 100
    
    @balance.setter
    def balance(self, quantity):
        """
        Ensures Amount is always positive!
        Quantity is pence or pounds, pence
        """
        try:
            pounds, pence = quantity
        except TypeError:
            pence = quantity
            pounds = 0
        
        amount = pounds * 100 + pence
        if not float(amount).is_integer():
            raise ValueError("Fractional pence are not allowed!")
        
        if amount >= 0:
            self._balance = amount
        else:
            raise ValueError("Negative Quantities are not allowed!")

    def __init__(self, pounds=0, pence=0, symbol='£'):
        self.balance = (pounds, pence)
        self.symbol = symbol
        
    def __repr__(self):
        return f'{self.symbol}{self.balance:.02f}'
    
    def __add__(self, other):
        return Money(pence = self._balance + other._balance)
    
    def __sub__(self,other):
        return Money(pence = self._balance - other._balance)
In [10]:
new_balance = Money(1,22) + Money(2,22)
print(f"The combined balance is {new_balance}")
The combined balance is £3.44
In [11]:
new_balance = Money(1,22) - Money(0,25)
print(f"The combined balance is {new_balance}")
The combined balance is £0.97

Note that we still are not allowed negative amounts.

In [12]:
new_balance = Money(1,22) - Money(0,123)  # would be -1 pence!
print(f"The combined balance is {new_balance}")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-12-b8a2642576fe> in <module>()
----> 1 new_balance = Money(1,22) - Money(0,123)  # would be -1 pence!
      2 print(f"The combined balance is {new_balance}")

<ipython-input-9-d98da40c720a> in __sub__(self, other)
     40 
     41     def __sub__(self,other):
---> 42         return Money(pence = self._balance - other._balance)

<ipython-input-9-d98da40c720a> in __init__(self, pounds, pence, symbol)
     30 
     31     def __init__(self, pounds=0, pence=0, symbol='£'):
---> 32         self.balance = (pounds, pence)
     33         self.symbol = symbol
     34 

<ipython-input-9-d98da40c720a> in balance(self, quantity)
     27             self._balance = amount
     28         else:
---> 29             raise ValueError("Negative Quantities are not allowed!")
     30 
     31     def __init__(self, pounds=0, pence=0, symbol='£'):

ValueError: Negative Quantities are not allowed!

Going Further - Inheritance and Polymorphism

In the previous cases we have a single class that contains everything useful. There are cases where we might write multiple classes. These can fail for the same reason we originally wrote a single class - namely sharing stuff. To overcome this we might share by using what we call inheritance.

Inheritance - What is it?

We might have some methods or attributes that are common across several classes. In this case we can imagine a hierachy where we share the common bits. For example

In [13]:
class Rectangle:
    """
    2D Shape with dimensions length x width
    """
    def __init__(self, length, width):
        self.length = length
        self.width = width
        
    def area(self):
        return self.length * self.width

class Square(Rectangle):
    """
    2D Shape with dimensions side x side
    """
    def __init__(self, side):
        Rectangle.__init__(self, length=side, width=side)
        
    @property
    def side(self):
        return self.length
    
class SimpleTriangle:
    """
    2D Shape with base and height
    """
    def __init__(self, base, height):
        self.base = base
        self.height = height
        
    def area(self):
        return 0.5 * self.base * self.height
    
rt = Rectangle(10, 20)
print(f"Area of rectangle with sides {rt.length} and {rt.width} is {rt.area()}")
sq = Square(5)
print(f"Area of square with side length {sq.side} is {sq.area()}")
tri = SimpleTriangle(10, 5)
print(f"Area of triangle with base {tri.base} and {tri.height} is {tri.area()}")
Area of rectangle with sides 10 and 20 is 200
Area of square with side length 5 is 25
Area of triangle with base 10 and 5 is 25.0

Here we have inherited the Square class from Rectangle. The init function from Rectangle is called with its arguments. Note how we use @property here to define the attribute side.

Let's define another example, this time with multiple classes.

In [15]:
class House(Rectangle, SimpleTriangle):
    """
    Triangular roof with a rectangular base
    """
    
    def __init__(self, house_height, house_width, roof_height, units='m'):
        Rectangle.__init__(self, length=house_height, width=house_width)
        SimpleTriangle.__init__(self, base=house_width, height=roof_height)
        self.units = units
    
    def describe_house(self):
        return f"""House has:
        house width of   {self.base} {self.units}
        house height of  {self.length} {self.units}
        roof height of   {self.height} {self.units}
        Frontage area of {self.area()} {self.units}^2
        """
    
    def area(self):
        return SimpleTriangle.area(self) + Rectangle.area(self)
    
home = House(1, 2.5, 1.5)
print(home.describe_house())
House has:
        house width of   2.5 m
        house height of  1 m
        roof height of   1.5 m
        Frontage area of 4.375 m^2
        
In [16]:
class Cube(Square):
    """
    3D Shape with dimensions side x side x side
    """
    def __init__(self, side):
        super().__init__(side)
        
    def volume(self):
        return self.side ** 3
    
    def area(self):
        return super().area() * 6
    
class Cuboid(Rectangle):
    """
    3D Shape with dimensions length x width x height
    """
    def __init__(self, length, width, height):
        super().__init__(length, width)
        self.height = height
        
    def volume(self):
        return super().area() * self.height
    
    def area(self):
        return super().area() * 2 + (self.length * self.height) * 2 + (self.width * self.height) * 2
    
cb = Cube(5)
print(f"Volume of cube with side length {cb.side} is {cb.volume()} with surface area {cb.area()}")
cb2 = Cuboid(5, 10, 15)
print(f"Volume of cuboid with sides {cb2.length},{cb2.width},{cb2.height} is {cb2.volume()} with surface area {cb2.area()}")
Volume of cube with side length 5 is 125 with surface area 150
Volume of cuboid with sides 5,10,15 is 750 with surface area 550

Here we can see that Cube is inherited from Square which is inherited from Rectangle! I've also used super() rather than the class name. Whenever possible we should as this lets Python deal with inheritance better than we can. This will be especially true if we start changing things!


Polymorphism

A confusing word undoubtedly. When we inherit a class we are sometimes just using the functionality that is there. There is an alternative however. If we define a class that says all classes inherited from me must look like me we are creating a standard interface. This can be very handy if you know there's going to be a large number of subclasses.

From a maintainability standpoint, this also means that any new subclasses will fit into our existing code without too much annoyance.

In [17]:
from abc import ABC, abstractmethod

class Shape2D(ABC):
    
    @abstractmethod
    def area(self):
        pass
    
    @abstractmethod
    def perimeter(self):
        pass
    
    @abstractmethod
    def __repr__(self):
        return ''

    
class Square(Shape2D):
    """
    2D Shape with dimensions side x side
    """
            
    def __init__(self, side):
        self.side = side
        
    def area(self):
        return self.side ** 2
    
    def perimeter(self):
        return self.side * 4

    def __repr__(self):
        return 'Square'
    
from math import pi
      
class Circle(Shape2D):
    
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return pi * self.radius ** 2
    
    def perimeter(self):
        return 2 * pi * self.radius
    
    def __repr__(self):
        return 'Circle'
    
class SimpleTriangle(Shape2D):
    
    def __init__(self, base, height):
        self.base = base
        self.height = height
        
    def area(self):
        return 0.5 * self.base * self.height
    
    def hypotenuse(self):
        return (self.base ** 2 + self.height) ** 0.5
    
    def perimeter(self):
        return self.base + self.height + self.hypotenuse()
    
    def __repr__(self):
        return 'Simple Triangle'
In [18]:
shapes = (Square(5), Circle(10), SimpleTriangle(5, 10))

for shape in shapes:
    print(f"{shape} has area {shape.area():.1f} and perimeter {shape.perimeter():.1f}")
Square has area 25.0 and perimeter 20.0
Circle has area 314.2 and perimeter 62.8
Simple Triangle has area 25.0 and perimeter 20.9

Okay that's a lot of code! So in comparison to the previous case, all classes are now inherited from Shape2D. This is a special kind of class. We've mandated by using ABC (Abstract Base Class) that these methods must be defined in any inherited class, or it will not be allowed to exist! This means we can be certain that calling shape.area() will return a value. We can write functions that act on shapes in general, not just circles or squares.

What happens if we don't define all the methods?

In [19]:
class Rectangle(Shape2D):
            
    def __init__(self, length, width):
        self.length = length
        self.width = width
        
    def area(self):
        return self.length * self.width
       
    def __repr__(self):
        return 'Rectangle'
        
rect = Rectangle(2,4)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-19-3aaea73dc275> in <module>()
     11         return 'Rectangle'
     12 
---> 13 rect = Rectangle(2,4)

TypeError: Can't instantiate abstract class Rectangle with abstract methods perimeter

Here we've accidentally forgotten to write the perimeter function.


A final example, let us define 3D shapes.

In [20]:
class Shape3D(Shape2D):
    @abstractmethod
    def volume(self):
        pass
    
    def perimeter(self):  # doesn't make much sense
        return None
In [21]:
class Prism(Shape3D):
    """
    Prism class
    A passed 2D shape can be used to create a prism out of it!
    """
    
    def __init__(self, shape2d, depth, *args, **kwargs):
        self.depth = depth
        self.shape2d = shape2d
        super().__init__(*args, **kwargs)
        
    def area(self):
        cross_section = self.shape2d.area()
        return cross_section * 2 + self.shape2d.perimeter() * self.depth
    
    def volume(self):
        return self.depth * self.shape2d.area()
    
    def __repr__(self):
        return f'Prism made from {self.shape2d}'
    
class Sphere(Circle):
    
    def __init__(self, radius):
        super().__init__(radius)
        
    def volume(self):
        return 4/3 * pi * self.radius ** 3
    
    def area(self):
        return 4 * pi * self.radius ** 2
    
    def __repr__(self):
        return 'Sphere'
In [22]:
cylinder = Prism(Circle(5), depth = 10)
cuboid = Prism(Square(5), depth = 10)
triangular_prism = Prism(SimpleTriangle(2,3), depth = 10)
ball = Sphere(radius = 10)

for shape in (cylinder, cuboid, triangular_prism, ball):
    print(f"{shape} has volume {shape.volume():.1f} and surface area {shape.area():.1f}")
Prism made from Circle has volume 785.4 and surface area 471.2
Prism made from Square has volume 250.0 and surface area 250.0
Prism made from Simple Triangle has volume 30.0 and surface area 82.5
Sphere has volume 4188.8 and surface area 1256.6

Summary

In conclusion we should know the following definitions and their application in Python

  1. Class - A grouping of methods and attributes put together because it makes sense
  2. Object - An instance of a class. For example Rover is an object, but Dog is a class.
  3. Public Attributes - Variables that can be accessed directly from outside the class.
  4. Private Attributes - Variables that should not be accessed directly from outside the class.
  5. Getters/Setters - Methods for altering private attributes. Typically to prevent shenanigans.
  6. Inheritance - Incorporating a class into another class. Allows us to prevent violation of the DRY principle.
  7. Polymorphism - Define a standard interface for a super class that all sub classes should follow. Allows us to write functions that act on many different subclasses without worrying they won't work in some cases.