CODE WITH MARTIN

12. Classes


Overview

Classes are a big topic, but we're going to take it very slow and easy. They are one of the hardest things for beginners to understand so please take it easy on yourself when going through this topic.

We first learned how statements directly do things like printing to the terminal, define variables, or execute functions. Then, we learned how we could define functions and group statements inside them to later execute those statements by executing the functions.

Functions give code a nice way of organizing things and you get to execute functions as many times as you want, from anywhere you want. Without them, you'd find yourself repeating groups of statements multiple times all over a project.

Classes are your next step in being able to organize both variables and functions. To understand how classes work, we need to take a side step to understanding a little bit of how programs work with your computers memory. Once you get this, classes are much easier to understand.

Memory

Your computers memory, RAM, is a temporary storage of data. It's temporary because when the power goes off, RAM loses all of its data. Your hard drive, or a USB stick, is permanent storage. All your files stored on these do not get lost when you turn off the computer.

Computers need RAM to work with data in a very fast way. Working with data on things like USB sticks or hard drives is very slow compared to RAM, but they have the advantage of being permanent storage. RAM's advantage is being super fast.

Memory, whether its in RAM or on a hard disk, is a simple single column table of data, like this:

AddressValue
10
20
30
40
50
60
70
80
......

The address counts up to millions depending how much memory there is. But the thing you can think about is that RAM, hard drives, all types of memory storage, have this table of data, where each row in the table has an address and a value. Each row in the table represents one byte of information.

When we write code and run it, the code is loaded into memory. The statements in a program do take up a small amount of memory but you don't ever have to worry about that. What we do sometimes have to think about though is how much memory we use when working with data and data is stored in variables.

Whenever we define variables in a program, we are asking our computer to reserve an area in RAM for us to be able to assign values to it. We take up some of the slots in that memory table. Our variables and data structures like lists and dictionaries all reserve an area in memory.

For example, here is a program that defines one integer variable:

my_integer = 10

When Python executes that statement, it will immediately go and reserve an area in RAM to store the number:

AddressValue
10
20
30
40
50
60
70
80
......

To store our number 10 from the code example above, Python actually reserves 28 bytes in memory. But the main point here is that memory is reserved, or for a better term, allocated, when we declare variables and each variable has an address somewhere in memory where its value is being held.

Now that we have a picture of how variables are allocated in memory, we can now get back to talking about how classes work.

Class Declaration

When we write a class declaration in Python, we're describing what it looks like. Nothing is executed and no variables are yet reserved in memory other than giving Python some knowledge about the class name and the functions it contains. Let's write code that describes a simple class:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0

Line 1 shows the use of the keyword 'class' followed by the class name 'Cube' and a colon ':' to finish the line. Classes can use any name they like as long as they don't clash with other defined names like our variables and other standard keywords.

Notice also how our class name starts with a capital letter. This is a naming standard in Python and most other languages have this standard too. When writing classes, names always start with a capital letter and also for multiple words in the name. For example, if we named the class 'EmployeeRecord', both words would start with a capital letter.

Line 2 is the start of a special function belonging to the class that we call the class constructor (notice how it's indented as the function belongs to the class). The class constructor is always named '__init__' which is two underscore characters before and after the word init). It is the first function that gets called when the class is instanced. We'll talk about what instancing classes means soon. All we're doing at the moment, is describing what the class named 'Cube' looks like to Python.

Functions inside classes reserve the first parameter as a variable that represents a pointer to the specific area in memory where the class lives after it's instanced. Again, we'll talk more about instancing in just a moment, so don't worry too much right now what this means.

This first parameter is usually named 'self' in Python classes, though the name really can be anything you want. It's best to stick with the standard practice of calling it self though.

You'll see on lines 3 to 5 how we're assigning to 3 variables using the 'self' variable. This is the specific part where we're adding variables to our class for us to work with later on. Remember, nothing happens in terms of memory being created yet because we've not yet 'instanced' the class.

Now that we've described or 'defined' a class, let's talk about instancing.

Class Instances

Once we have declared a class with a name, we're ready to start creating instances. What does this mean exactly? Let's see some code that creates an instance of our 'Cube' class:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
my_cube = Cube()

Line 7 here is where the magic happens. We're defining a new variable named 'my_cube' and assigning it to the value of the statement 'Cube()'. This 'Cube()' is what is telling Python that we want to create an instance of the class name 'Cube'. To create an instance, we always write the name of the class followed by empty brackets. It just looks like calling a function but with the name of a class.

The moment Python is instructed to create an instance, it looks at the information it has about the class, which is what we described earlier in the class declaration. Python goes ahead and starts to allocate an area in memory to hold the class. It then looks to see if we have the '__init__' function in the class, and calls that function. When that's done, our class instance is ready and the variable 'my_cube' is a reference to this block of memory that has been allocated.

The variable 'my_cube' is what we now call an object. It's an object because its a reference to an instance of a class. The object type is 'Cube'.

We can now start accessing and using the variables in the object by using the dot '.' operator with our variable. Like this:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
my_cube = Cube()
print(my_cube.width)
print(my_cube.height)

my_cube.length = 50
print(my_cube.length)

The output in the terminal will show how on lines 8 and 9 we print the values of the variables inside the object 'my_cube'. The dot operator '.' between the name of the variable 'my_cube' and the class variable name such as 'width' tells Python to fetch the value from memory where this instance has been allocated.

We can also assign to the object variables as seen on line 11, which we can see taking effect by printing the value on line 12.

A thing to note about declaring classes and the moment they are created is the variables in the object are initialized to the values we wrote in the __init__ function. You can change these values and see how the class gets instanced starting with those values.

Multiple Instances

Now we're going to see why we learned about memory and how classes come in handy by creating multiple instances of them. Observe the following code:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
first_cube = Cube()
second_cube = Cube()

first_cube.width = 10
second_cube.width = 20

print(first_cube.width)
print(second_cube.width)

Line 7 and 8 show how we are creating new instances of the Cube class and storing the objects in separate variables. Both of these lines are instructing Python to allocate two separate areas of memory for each of the variables.

To prove that these variables are in fact referencing two different areas of memory, you can see on lines 10 and 11 how we set the 'width' properties on each of the object variables. Then on lines 13 and 14, we print the two objects 'width' property. You will see how they both have different values - nice!

To convince you even more of this magic, you can ask Python to print an object variable, like this:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
first_cube = Cube()
second_cube = Cube()

print(first_cube)
print(second_cube)

When I run this code, I get the following terminal output:

<__main__.Cube object at 0x7f8d193c0ee0>
<__main__.Cube object at 0x7f8d193c0a60>

The output is telling us that from the print lines in our code, they are Cube objects at a specific memory address. That '0x7f8d193c0ee0' like number is the address in memory where our object was allocated and each of the two variables has a different address.

Objects In Lists

Objects are just another variable type in Python. Just like how we have seen integers, floats and strings being added to lists, we can also put objects in lists. They can be added to all of the list types such as tuple, list, set and dictionary.

Let's see how objects can be created and added to a list:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
cubes = []

for x in range(10):
    new_cube = Cube()
    new_cube.width = x * 10
    cubes.append(new_cube)
    
for cube in cubes:
    msg = "Cube Size: {} x {} x {}".format(
        cube.width, cube.height, cube.length)
    print(msg)

We create an empty list on line 7 in preparation to fill it with some Cube objects. Line 9 is a for loop for a total of 10 iterations and inside that loop on lines 10 to 12, we are creating an instance of the Cube class and appending that newly created object to our list variable 'cubes'. Finally, we create another loop on line 14 that iterates over every cube it can find in our list variable 'cubes' and prints the details of the cube instance in a message to the terminal using placeholders and the 'format' string function on lines 15 to 17.

You'll notice how each cube we created has a different width that we calculated on line 11 ('x * 10' means the value of x multiplied by 10). The output in the terminal shows how each and every cube instance we added to the list has a different width.

Class Functions

Let's start adding more functions to our class:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
    def display_size(self):
        msg = "Cube Size: {} x {} x {}".format(
            self.width, self.height, self.length)
        print(msg)
        
my_cube = Cube()

my_cube.width = 5
my_cube.height = 10
my_cube.length = 15

my_cube.display_size()

We've got quite a lot going on in this example, so let's start with the new function declaration on line 7. This function is indented under the class declaration on line 1 so we know this function belongs to the class. This function has 1 parameter named 'self' which lets us work with the class variables.

(Note: You don't need to specify the self parameter if you don't need it at all.)

When defining functions inside classes, the first parameter they have is special. It's special in that Python always sets it to be the object that is currently being used when someone executes the function.

That can be a bit confusing at first, so lets look down at line 18. On line 18, we're executing the function 'display_size' inside the class; notice how we're not specifying any arguments. It is being executed through the object variable 'my_cube' using the dot '.' operator.

When that function executes, the 'self' parameter is set to the same value as the 'my_cube' variable. It's pointing to the same memory location where the 'my_cube' object was created. So when we access the properties of the 'self' parameter on line 9, we're ensuring that the function is working with the values in the right area of memory that the 'my_cube' was created at.

This helps when you use the function inside the class with different instances. By making sure we access the class variables using the 'self' parameter when inside class functions, the function always ensures its working with variables in the right area of memory.

Here's an example of that by using the 'display_size' function on two separate objects:

class Cube:
    def __init__(self):
        self.width = 0
        self.height = 0
        self.length = 0
	
    def display_size(self):
        msg = "Cube Size: {} x {} x {}".format(
            self.width, self.height, self.length)
        print(msg)
        
first_cube = Cube()
second_cube = Cube()

first_cube.width = 5
first_cube.height = 10
first_cube.length = 15

second_cube.width = 50
second_cube.height = 75
second_cube.length = 150

first_cube.display_size()
second_cube.display_size()

Class functions mostly work with the data (the variables) in the class. Our 'display_size' function above for example helps us to display the size of the cube in the terminal. It's using all the variables we defined in the class constructor '__init__' function.

Class Constructor Parameters

When we create instances of classes, we can pass parameters to the special __init__ function. Here's an example:

class Cube:   
    def __init__(self, width, height, length):
        self.width = width
        self.height = height
        self.length = length
	
    def display_size(self):
        msg = "Cube Size: {} x {} x {}".format(
            self.width, self.height, self.length)
        print(msg)
        
my_cube = Cube(10, 50, 100)

my_cube.display_size()

You'll see on line 2 how we have added 3 more parameters after the 'self' parameter which we use to set the class variables on lines 3 to 5.

Line 12 shows where we now create the instance of the 'Cube' class but this time, we supply the constructor variables in between the round brackets; it looks just like calling a function with arguments. These 3 values are set on the '__init__' function parameters 'width', 'height' and 'length'.

By having this special constructor function, creating instances of a cube and setting the properties of it has now been compressed down to a single line. Creating the cube instance is a matter of passing the values for the object in the constructor instead of setting the properties of the instance individually as we saw in previous code examples.

Static Class Variables

One final mention of variables inside classes are those that are static. It's best to show you an example and explain how this behaves:

class Circle:
    PI = 314

    def __init__(self, radius):
        self.radius = radius
	
    def display_area(self):
        area = Circle.PI * self.radius * self.radius
        msg = "Circle Area: {}".format(area)
        print(msg)
        
my_circle = Circle(10)

my_circle.display_area()

On line 2, we define a new static variable named 'PI'. It represents the mathematical value of PI that helps us calculate the area of our circle in the 'display_area' function.

The reason this variable is static is because it doesn't exist inside class instances. It instead exists under the name of the class, meaning, to access it like we do on line 8, we write 'Circle.PI'. This is simply the name of the class and then the name of the static variable. We don't need to create any instances of a class to access a classes static variables. Static variables live on their own in their own area of memory and are not duplicated when you create instances of classes.

You might be thinking why would you use static variables. Considering the example above, the value of PI is 3.14. This value is constant, it doesn't change. We don't need to make it a variable inside a class instance because then every single instance of the class will create a new variable in memory holding the value 3.14. If you had 100 instances, that would be 100 variables in memory all holding the value 3.14. Sounds wasteful right? It is.

Instead, we can define this constant value as a static variable where it only exists once in memory stopping it from being duplicated with every instance of a class.

Just like on line 8 in the example above, static variable in classes can be accessed in your code without creating instances. Consider the following:

class Circle:
    PI = 314
        
print(Circle.PI)

We don't create an instance of the class at all, but we can still access that variable inside the class, because it's static.

Why Use Classes?

One of the main uses of classes is to represent real things that we think about in our software products. For example, if you were to write some code that deals with processing orders from a shopping basket, there might be multiple classes that represent the things that you're dealing with. For example, 'ShoppingBasket', 'Product', 'User', 'Offer', 'PaymentService' might be a few classes you would find in your code.

Perhaps you were writing a game that was dungeons and dragons like. Classes you might want to make are 'Player', 'Weapon', 'Item', 'Area', 'Monster', 'Loot'.

By having classes, you can place variables and functions related to those objects inside those classes. This keeps your code well organized, easy to read and maintain.

We'll go through an exercise of writing a business application that makes use of classes in an upcoming section.

What We Learned