# Mark Brown Tuition

Physics, Mathematics and Computer

Science Tuition & Resources

# 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.

```
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)}")
```

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.

```
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()}")
```

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

**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.**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

```
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)
```

```
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 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.

```
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}'
```

```
quantity = Money(1,22)
print(f"Your balance is {quantity}")
```

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

```
Money(1,22.5) # £1.225??
```

```
Money(1,-101) # negative one pence!
```

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!)

```
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)
```

```
new_balance = Money(1,22) + Money(2,22)
print(f"The combined balance is {new_balance}")
```

```
new_balance = Money(1,22) - Money(0,25)
print(f"The combined balance is {new_balance}")
```

Note that we still are not allowed negative amounts.

```
new_balance = Money(1,22) - Money(0,123) # would be -1 pence!
print(f"The combined balance is {new_balance}")
```

# 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

```
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()}")
```

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.

```
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())
```

```
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()}")
```

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.

```
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'
```

```
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}")
```

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?

```
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)
```

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

A final example, let us define 3D shapes.

```
class Shape3D(Shape2D):
@abstractmethod
def volume(self):
pass
def perimeter(self): # doesn't make much sense
return None
```

```
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'
```

```
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}")
```

## Summary¶

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

- Class - A grouping of methods and attributes put together because it makes sense
- Object - An instance of a class. For example Rover is an object, but Dog is a class.
- Public Attributes - Variables that can be accessed directly from outside the class.
- Private Attributes - Variables that
**should not**be accessed directly from outside the class. - Getters/Setters - Methods for altering private attributes. Typically to prevent shenanigans.
- Inheritance - Incorporating a class into another class. Allows us to prevent violation of the DRY principle.
- 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.