7 | Object-Oriented Programming


In a previous Chapter, I defined programming as "storing data and doing stuff with the data". This definition is not the most technical and detailed definition you’ll read, but it’s still a very good one. You’ve learned about both parts of this definition. You can store data using various data types, and you can perform actions with the data, primarily by using functions. Object-oriented programming merges these two into a single item.

I’ll get back to this merger a bit later. This topic brings along with it some new terminology, too. You’ll soon read about classes, objects, instances, attributes, and a few more too. As I’ve done previously in this book, I’ll make sure to explain the concepts behind these terms as clearly as possible.

Indeed, you’ve already come across the term class. You’ve been using classes since your first program in Chapter 1. Have a look at the following variables:

>>> number = 10
>>> name = "Stephen"
>>> shopping_list = ["Coffee", "Bread", "Butter", "Tomatoes"]

>>> type(number)
<class 'int'>
>>> type(name)
<class 'str'>
>>> type(shopping_list)
<class 'list'>

When you use the type() built-in function to display an object’s data type, you’re told that this is of class 'int' or class 'str' or whatever the name of the data type may be.

If you reread the previous sentence, you’ll also notice the use of the word ‘object’. You can see you’ve also been using objects all the time! All will be clear by the end of this Chapter.

The Object-Oriented Programming Paradigm

First, let’s have a quick word about philosophy. Object-oriented programming is one of several programming paradigms or styles of programming. Whereas the tools you have learned about so far are unavoidable in most modern programming, object-oriented programming is a style of programming that you can use if you wish. However, there are some applications where it may be hard to avoid this paradigm.

If you search on the internet–a dangerous place to be most of the time–you’ll find a vast range of views about object-oriented programming, or OOP. Some love it. Others hate it. But if you don’t get dragged into these philosophical debates about programming, you’ll find that object-oriented programming is a great tool to have in your toolbox that you can choose to use on some occasions and avoid in other cases.

Python is inherently an object-oriented programming language. However, you’ll often see Python described as a multi-paradigm language. This means that it’s a language that supports many different programming styles, including object-oriented programming.

Meet The Market Seller

That’s enough about the theory of object-oriented programming for the time being. Let’s work our way towards what object-oriented programming is.

In this Chapter, you’ll use the example of a market seller who’s been learning Python and who decides to write code to help him deal with his daily operations of selling items at the market stall. You’ve already met the market seller in Chapter 5 when you read about errors and bugs.

He has four items on sale in this small stall. His first attempt at writing this code uses a number of lists:

items = ["Coffee", "Tea", "Chocolate", "Sandwich"]
cost_price = [1.1, 0.5, 0.9, 1.7]
selling_price = [2.5, 1.5, 1.75, 3.5]
stock = [30, 50, 35, 17]

His next section prompts the user to enter the item sold and the quantity:

items = ["Coffee", "Tea", "Chocolate", "Sandwich"]
cost_price = [1.1, 0.5, 0.9, 1.7]
selling_price = [2.5, 1.5, 1.75, 3.5]
stock = [30, 50, 35, 17]

# Input items and quantity sold
item_sold = input("Enter item sold: ").title()
quantity = int(input("Enter quantity sold: "))
item_index = items.index(item_sold)

In this code, you’ll note that you used the string method title() directly after the input() function. This is possible because input() returns a string. The title() method converts a string into title case. Try to run "this is a string".title() in the Console to see what’s the output.

You’ve already seen the need for changing the string returned from input() to a numeric format when you get the value for the quantity sold. You’ve used the built-in function int() with the input() function as its argument.

The list method index() returns the index that matches the item in the list. You can use this method to find the location of a specific item in the list.

The += and -= Operators

Let’s take a small detour before returning to the market seller’s first attempt at this code.

A common requirement in a program is to increment the value stored within a variable. For example, if you want to add to the score in a game, you can write:

>>> score = 0
>>> score = score + 1
>>> score
1

There’s a shortcut in Python for this operation which makes the code easier to write and more succinct:

>>> score = 0
>>> score += 1
>>> score
1

The += operator increments the value of the variable by the amount which follows the operator. The above code snippet is identical to the one before it.

You can also decrease the value in a similar manner:

>>> score = 0
>>> score -= 5
>>> score
-5

Other arithmetic operators work in the same way. You may want to experiment with *= and /= as well, for example.

Market Seller’s First Attempt

Let’s look at the next section in the market seller’s first attempt at writing the code:

items = ["Coffee", "Tea", "Chocolate", "Sandwich"]
cost_price = [1.1, 0.5, 0.9, 1.7]
selling_price = [2.5, 1.5, 1.75, 3.5]
stock = [30, 50, 35, 17]

daily_income = 0

# Input items and quantity sold
item_sold = input("Enter item sold: ").title()
quantity = int(input("Enter quantity sold: "))
item_index = items.index(item_sold)

# Work out required values
profit = quantity * (selling_price[item_index] - cost_price[item_index])
daily_income += selling_price[item_index] * quantity

# Update stock
stock[item_index] -= quantity
# TODO Add check to make sure stock does not go below zero

print(profit)
print(daily_income)
print(stock[item_index])

This works. The section headed with the comment ‘Work out required values’ works out the profit by subtracting the item’s cost price from its selling price and multiplying that by the quantity sold. The next line increases the income for the day using the increment operator += which takes the current value of daily_income and adds the income from this sale to it.

The following section, which has the heading ‘Update stock’, updates the number of items in stock by using the decrement operator. The market seller also added a comment to remind him to add a bit more code later to check that the stock doesn’t go below zero. Adding such a comment is a common technique to leave notes in your code for later on. Most IDEs also interpret the # TODO comments differently from other comments, and the IDE will show you a to-do list using these comments.

You can see the output from the test the market seller ran:

Enter item sold: chocolate
Enter quantity sold: 7
5.95
12.25
28

But the market seller stopped at this point as he realised that this might become quite cumbersome to deal with. Every operation will need to carefully reference the correct item in the correct list using the index. As the market seller adds more products and more information about each product, this method can quickly get out of hand.

Data that belong together

The code above stores the item’s name, cost price, selling price, and stock quantity in separate variables. You know that these separate storage boxes "belong together" and that each item in each list is related to the other items occupying the same position in the other lists.

However, the computer program doesn’t know that these data are related. Therefore, you need to ensure you make all of those links in the code. This style can make the code less readable and more prone to errors and bugs.

The Market Seller’s Second Attempt

The market seller noticed the problem with having many lists which store the various attributes linked to each product. So, he decided to refactor his code and use dictionaries instead. Refactoring is the process of changing the code to make it neater and better without changing the overall behaviour of the code. Here’s the market seller’s refactored second attempt:

# Create dictionary with item names as keys and a list of numbers as the values. The
# numbers in the lists refer to the cost price, selling price, and quantity in stock
# respectively
items = {
    "Coffee": [1.1, 2.5, 30],
    "Tea": [0.5, 1.5, 50],
    "Chocolate": [0.9, 1.75, 35],
    "Sandwich": [1.7, 3.5, 17],
}

daily_income = 0

# Input items and quantity sold
item_sold = input("Enter item sold: ").title()
quantity = int(input("Enter quantity sold: "))

# Work out required values
profit = quantity * (items[item_sold][1] - items[item_sold][0])
daily_income += items[item_sold][1] * quantity

# Update stock
items[item_sold][2] -= quantity
# TODO Add check to make sure stock does not go below zero

print(profit)
print(daily_income)
print(items[item_sold][2])

You’re now storing the data in a single dictionary instead of several lists. The keys in the dictionary are strings with the names of the items. The value for each key is a list. Each list contains the cost price, selling price, and the number of items in stock in that order.

You may have already spotted one drawback. You need to reference the data in the lists using the index. Look at the following line as an example:

profit = quantity * (items[item_sold][1] - items[item_sold][0])

You first need to use one of the keys to extract a value from the dictionary items. The variable item_sold is the string that contains the name of the item.

items[item_sold] refers to the value of the item_sold key, which is a list. You’re then indexing this to get one of the numerical values in the list. Therefore, items[item_sold][1] refers to the selling price of the item represented by the string item_sold. This makes the code harder to read and, for the same reason, more prone to errors.

Using a single data structure to store all the data has its advantages. Adding a new item or removing one that’s no longer sold is easier with the dictionary version than with the list approach. However, as the data structure becomes more complex, accessing items can also become trickier, leading to code that’s harder to write, read, and maintain.

Adding Functions To Perform The Tasks

Before looking at yet another way of writing this code, you can add some functions to the code you have so far:

# Create dictionary with item names as keys, and a list of numbers as value. The
# numbers in the lists refer to the cost price, selling price, and quantity in stock
# respectively
items = {
    "Coffee": [1.1, 2.5, 30],
    "Tea": [0.5, 1.5, 50],
    "Chocolate": [0.9, 1.75, 35],
    "Sandwich": [1.7, 3.5, 17],
}

daily_income = 0

def get_sale_info():
    item_sold = input("Enter item sold: ").title()
    quantity = int(input("Enter quantity sold: "))
    return item_sold, quantity

def get_profit(item_sold, quantity):
    return quantity * (items[item_sold][1] - items[item_sold][0])

def update_income(item_sold, quantity):
    return daily_income + items[item_sold][1] * quantity

def update_stock(item_sold, quantity):
    items[item_sold][2] -= quantity
    # TODO Add check to make sure stock does not go below zero

# Call functions
item_sold, quantity = get_sale_info()
profit = get_profit(item_sold, quantity)
daily_income = update_income(item_sold, quantity)
update_stock(item_sold, quantity)

print(profit)
print(daily_income)
print(items[item_sold][2])

This code now puts all the functionality of the code into standalone functions. You’ll recall the definition of programming I started the Chapter with. This code now clearly has the data stored in one variable and all the tasks that need to happen are defined as functions.

Let’s look at these functions a bit closer. The first one is get_sale_info(). This function asks the user to type in the product sold and how many of it were sold in a particular sale. The function returns a tuple with the values stored in item_sold and quantity. You’ll recall that the parentheses are not needed to create a tuple, so the variable names in the return statement are not enclosed in brackets.

The functions get_profit(), update_income(), and update_stock() all have two parameters. When you call these functions, the name of the item sold and the quantity sold need to be passed to the function.

Accessing global variables in functions

These three functions also use the data stored in the variable items. And update_income() also uses the data in daily_income. Functions have access to the global variables of the scope that’s calling them. This means that when Monty, the computer program, goes to a Function Room, he can still use any of the boxes which are on the White Room‘s shelves.

However, you can only ever use these functions with these variables. So, you may want to define the functions so that all the data needed is passed in as an argument and then returned when the function finishes its tasks. However, I won’t make this change to this code as instead, I’ll discuss the third option.

Changing the state of an object

You can notice another difference in the behaviour of these functions. The update_stock() function doesn’t return anything as its only job is to change a value directly within the dictionary items. Dictionaries are mutable data types, and therefore this is valid. You may hear this referred to as changing the state of the object items.

The other functions do not make any changes to existing variables directly. Instead, they return the new values, which you can then assign to variables when you call the functions, as you do in the section labelled ‘Call functions’ in the code.

In this Chapter and the later Chapter on Functional Programming, you’ll learn more about functions that change existing objects’ state and others that do not.

Object-Oriented Programming

We’ve gone a long way in this Chapter without having written any object-oriented code. Let’s redress this now that you’re familiar with the problems the market seller has encountered.

When thinking with an object-oriented programming mindset, the starting point is to think of the main objects relevant to the problem you’re solving. In a way, you need to think of the problem from a human being’s perspective first and design your solution accordingly.

Let’s see what this means from the market seller’s perspective. What are the objects that are relevant for the market seller? The objects that matter to him are the four products he sells. Although coffee, tea, chocolate, and sandwiches are different products with different prices and so on, they all share similar characteristics. They are all products that the market seller sells.

You can therefore start by creating a class of objects in Python that describes these products.

Creating A Class

To create a class in Python, you can use the class keyword. You can create a new Python file called market_stall.py where you’ll create your classes. If you choose a different file name, make sure you don’t use any spaces in the file name:

class Product:

The convention is to use a capital letter for the name of a class. More generally, you should use upper camel case, such as MyInterestingClass.

When you create a class, you’re creating your own data type, one that’s relevant for the application you’re writing. The class definition that you’ll be writing in the following sections acts as a template or a blueprint to create objects that belong to this class of objects.

You’ll see how to create an object of type Product soon, but first, we need to add a function definition within the class definition. A function that’s a member of a class is called a method. Methods are functions, and therefore they behave in the same way as functions:

class Product:
    def __init__(self):
        self.name = "Coffee"
        self.cost_price = 1.1
        self.selling_price = 2.5
        self.number_in_stock = 30

There’s a lot to understand in this code. I’ll explain all of the strange new additions in the following paragraphs.

The __init__() method

The __init__() method is a special function, and it’s often the first part of a class definition. This method tells the program how to initialise a Product when you create one. When you create an object of type Product, the initial state of this object will be determined based on what happens in the __init__() method. Hint: if you just start typing "init" in your IDE, the underscores and the parameter self, which you’ll learn about soon, will autocomplete.

Methods whose names start and end with double underscores have a special status and are often referred to as dunder methods. Dunder is a contraction of double underscore. Sometimes they’re also called magic methods. However, there’s nothing magical about them, so some prefer not to use that term.

You’ll have noticed another new term used in the __init__() method: self. I’ll discuss self shortly as you’ll see it a lot in class definitions.

Creating An Instance of A Class

When you defined the class in market_stall.py, you’ve created a template for making products, but you haven’t created any products yet. Let’s create an instance of the class Product in the Python Console instead of in the script market_stall.py.

When you start a Console session, you’re creating a new White Room, which is separate from the one created when you run the market_stall.py script. Therefore, the Console’s White Room doesn’t know about the class Product yet. However, you can import your script in the same way you’ve imported other modules previously:

>>> import market_stall
>>> market_stall
<module 'market_stall' from '/<path>/market_stall.py'>

When you import a module, you’re importing the contents of a Python script. This script could be one written by someone else or one you’ve written yourself. You can see that the name market_stall in the Console session refers to the module, and it references the file. As with any module you import, you can now access anything from within this module. In this case, you can access the class Product.

You can create an instance of the class Product as follows:

>>> market_stall.Product()
<market_stall.Product object at 0x7f9178d61b20>

You can create an instance of a class by using the class name followed by parentheses. The output doesn’t tell you much except that you’ve created an object of type Product which is a part of the market_stall module. The memory address is also displayed, but you won’t need this.

You can assign this instance of the class to a variable name:

>>> my_product = market_stall.Product()
>>> my_product.name
'Coffee'
>>> my_product.selling_price
2.5

The name my_product now refers to the object you have created. When an instance of the class is created, the __init__() method is called. This means that every Product will always have a variable called name attached to it, and another one called selling_price. You can see the values of these instance variables displayed above. You can even access the other instance variables you have defined in the __init__() method in the same way.

Making the class more flexible

The problem with the code you have is that each product you create can only be coffee with the same cost price, selling price, and quantity in stock. You’d like your class to be more general than this so that you can create other products as well.

You can go back to market_stall.py and make some changes:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

Like any other function, __init__() can have parameters defined in its signature. The mysterious self was already there, but you’ve now added more parameters.

The arguments passed to the __init__() method are then attached to the instance. In the next section, we’ll walk through what happens when you create an instance again, but this time you’ll have to pass arguments when you create the instance.

Creating a new file to test the class definition

Rather than carrying on in the Console, you can now create a second file. Let’s call this second script market_seller_testing.py. When dealing with object-oriented programming, it’s common practice to define the classes in one file, or module, and then use these classes in other files:

# market_seller_testing.py

from market_stall import Product

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

print(first_prod.name)
print(second_prod.name)

I’ve included the file name as a comment at the top of each code block since you’ll be working on two separate files from now on in this Chapter.

You’ve also used the import keyword differently from previous times. Instead of importing the whole module, you’re now only importing one object from within that module. In this case, you’re just bringing in the Product class and not the entire module. You can now use the class name Product without having to add market_stall and a dot before it.

You created two instances of the class Product. The arguments you use within the parentheses are passed to the class’s __init__() method. The objects first_prod and second_prod are both of type Product, and therefore they have been created using the same template. Both of them have instance variables called name, cost_price, selling_price, and number_in_stock. However, the values of these instance variables are different, as the output from the print() lines show:

Coffee
Chocolate

You can also confirm the data type of these two objects you have created:

# market_seller_testing.py

from market_stall import Product

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

print(type(first_prod))
print(type(second_prod))

The output now shows that both objects are of type Product:

<class 'market_stall.Product'>
<class 'market_stall.Product'>

When you create a class you’re creating a new data type, one that you have designed to suit your specific needs.

Viewing two scripts side-by-side

A small practical note: In most IDEs, you can split your window into multiple parts so that you can view two files side-by-side. Since you’re defining your class in one file and testing it in a different file, this is a perfect time to explore some options in your IDE.

If you’re using PyCharm, you can look in the Window menu and choose the Editor Tabs option. You’ll find the option to split your screen there. You can also make sure that any sidebars on the left-hand side are closed so that your screen is split between the two files.

Split screen setup in PyCharm IDE for object-oriented programming

If you’re using other IDEs, you’ll be able to find similar functionality, too.

Understanding self

In the definition of the __init__() method, you’ve used the keyword self several times. Let’s start by looking at its use inside the function definition first. Here are the four lines in this definition that all reference self:

# ...
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

The keyword self refers to the instance of the class that you’ll create when you use the class. You read earlier that the class definition doesn’t create any objects itself but serves as a blueprint for creating objects elsewhere in the code. The name self refers to the future name of the variable that the object will have.

For example, in market_seller_testing.py, you created two variables named first_prod and second_prod. These variables each store a different instance of the class Product. You can think of the term self in the class definition as a placeholder for these names.

Therefore, the above lines provide the instructions for the program to create four instance variables when it creates a new object. These lines are assignment statements, as shown by the = operator. The variables you’re creating are attached to the instance. The dot between self and the variable name shows this link.

An instance variable is a variable that is attached to an instance of a class. So every instance of the class you create will have its own instance variables name, cost_price, selling_price, and number_in_stock.

Creating the instance variables in the __init__() method

The __init__() method assigns values to the instance variables based on the arguments passed when creating the object. Recall that the values you place within parentheses when you create the instance of Product are passed to the __init__() method. The parameters name, cost_price, selling_price, and number_in_stock listed in the method’s signature store the values passed when creating the instance.

The parameter names and the instance variable names do not need to be the same name. Therefore, the following __init__() method is identical to the one you’ve written above:

# market_stall.py

class Product:
    def __init__(self, product_name, cost, sell, n_stock):
        self.name = product_name
        self.cost_price = cost
        self.selling_price = sell
        self.number_in_stock = n_stock

The parameter names are only used to move information from the method call to the instance variables. Recall that the program calls the __init__() method whenever you create an instance of the class.

self in the method signature

The other place you’ve used self is as the first parameter in the __init__() signature. You may have noticed that your IDE probably auto-filled this for you when autocompleting __init__(). If you’re not making the most of autocompletion in your IDE, then you should do so!

This parameter tells the function that its first argument is the object itself. Therefore, the method has access to the object it is acting on. You’ll see this used again in the following section when you create other methods for this class.

Adding Methods To The Class

So far, the Product class assigns instance variables to each instance of the class. Each product you’ll create using the class Product will have a ‘storage box’ attached to it to store the product’s name, another one to store the selling price, and so on.

However, you can add more attributes to the class, and these are not limited just to data that can be stored in each object. You can also give the instances of the class the ability to do things as well. And as you know, "doing things" is done by functions in Python:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock
        
    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity
        
    def change_cost_price(self, new_price):
        self.cost_price = new_price
        
    def change_selling_price(self, new_price):
        self.selling_price = new_price

In addition to the __init__() method, you’ve also defined three further methods. These functions are called methods as they belong to a class. Only objects of type Product will have access to these methods.

Using methods

Let’s see how you can use these methods by going back to market_seller_testing.py:

# market_seller_testing.py

from market_stall import Product

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

print(f"{first_prod.name} | Number in stock: {first_prod.number_in_stock}")
print(f"{second_prod.name} | Number in stock: {second_prod.number_in_stock}")

first_prod.decrease_stock(7)

print()
print(f"{first_prod.name} | Number in stock: {first_prod.number_in_stock}")
print(f"{second_prod.name} | Number in stock: {second_prod.number_in_stock}")

In this version of the script, you’re printing lines to show how many items there are in stock for each product you’ve created. You then call the decrease_stock() method for first_prod with the argument 7. When you print out the numbers in stock again, you’ll see that the number of items in stock for first_prod, which is coffee, has decreased by 7. However, the number of items in stock for second_prod, which is chocolate, remains unchanged since you didn’t call the method for this object:

Coffee | Number in stock: 30
Chocolate | Number in stock: 35

Coffee | Number in stock: 23
Chocolate | Number in stock: 35

This simple example shows one of the benefits of object-oriented programming. Once you’ve defined the methods in the class, it becomes straightforward to keep track of which data belong to which object. If you look back at the first and second attempts the market seller made at the beginning of this Chapter, where he used lists and dictionaries, you’ll recall that things weren’t so easy there.

Each object of a certain class has access to methods that will only act on that object. These methods will only change the data linked to that object.

Revisiting self

Each method you’ve created has self as the first parameter in the signature. You’ll always call a method preceded by the object’s name and a full stop, for example first_prod.decrease_stock(). The object itself is passed as the first unseen argument of the method decrease_stock(). Therefore the method call first_prod.decrease_stock(7) has two arguments and not just one. The first argument is first_prod, and the second is the integer 7. However, you don’t explicitly write the first argument as this is implied.

You can confirm this with the following experiment in the Console (note, you should close your previous Console and restart a new one since market_stall.py has changed):

>>> from market_stall import Product
>>> test = Product("Coffee", 1.1, 2.5, 30)
>>> test.decrease_stock(4, 5)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: decrease_stock() takes 2 positional arguments but 3 were given

You’ve tried calling decrease_stock() with the integers 4 and 5. This is incorrect as this method only needs one integer. Therefore, the program raises an error. However, look at the last line of the error message very carefully. The message tells you that decrease_stock() takes 2 positional arguments because the method has two parameters in its signature. These two parameters are self and quantity. The error message also says that you passed 3 arguments, even though you only put two numbers in the parentheses. The three arguments you passed are test, 4, and 5.

One more method

Let’s add one more method to this class. In market_seller_testing.py, you’ve printed out a formatted string to show the user the number of items in stock for each product. Unless you enjoy typing, you had to copy and paste that line and change the variable name.

However, this is something that an object of type Product should be able to do. You can do this by adding a method to the class whose job is to print out this formatted string:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

The method show_stock() doesn’t make any changes to the object. It merely prints out the formatted string. You can compare the print() line in this method to the ones you wrote in market_seller_testing.py earlier. In the method, you’ve now replaced the variable name with the keyword self, which is the placeholder for the name of the object.

You can now also update market_seller_testing.py to use this new method:

# market_seller_testing.py

from market_stall import Product

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

first_prod.show_stock()
second_prod.show_stock()

first_prod.decrease_stock(7)

print()
first_prod.show_stock()
second_prod.show_stock()

You should spend some time experimenting with the methods you’ve created, and you can even create one or two more!

Storing Data And Doing Stuff With Data

At the beginning of this Chapter, I described object-oriented programming as a way of merging the storage of data with doing stuff with data into one structure. There are two types of attributes that an object of a certain class can have:

  • instance variables such as self.name. These are also called data attributes
  • methods such as self.decrease_stock()

The instance variables, like all variables, store data. These are the boxes we use to store information in. The methods do stuff with the data, as all functions do. Therefore an object contains within it both the data and the functions to perform actions on the data.

In object-oriented programming, each object carries along with it all the data and the tools it needs. As you did with variable names and function names previously, it is best practice to name your instance variables using nouns and to start the name of your methods with a verb. And as always, descriptive names are better than obscure ones!

You’ve Already Used Many Classes

You’ve been using object-oriented programming for a very long time in your Python journey. Let’s look at an example:

>>> my_numbers = [5, 7, 3, 20, 1]
>>> type(my_numbers)
<class 'list'>
>>> my_numbers.append(235)
>>> my_numbers
[5, 7, 3, 20, 1, 235]

In the first line, you’ve created an instance of the class list. Since lists are one of the basic data types in Python, the way you create an instance looks different to how you’ve created an instance of Product, but the process behind the scenes is very similar. An object of type list has access to several methods, such as append(), which act on the object and make changes to the object.

When you define your own class, you’re creating your own special data type that you can use in the same way you can use other data types.

Type Hinting

I’ll make a slight detour in this section to briefly introduce a new topic. There is more information about this topic in one of the Snippets at the end of this Chapter.

In Python, you don’t need to declare what data type you will store in a variable. Python will decide what type of data you’re storing depending on what you write in the assignment. This statement may seem obvious. However, there are programming languages that require the programmer to state that a specific variable will be an integer, say, before storing an integer in it. In these languages, once you declare the type of a variable, you cannot store any other data type in that variable.

The same is valid for function definitions. Look at the following simple function:

>>> def add_integers(a, b):
...    return a + b

It’s clear from the function’s name and how you’ve written the function that your intention is that a and b are both numbers. You want a and b to be both of type int. However, your Python program doesn’t know that. Both of these function calls are valid:

>>> add_integers(5, 7)
12
>>> add_integers("Hello", "World")
'HelloWorld'

Clearly, in the second case, you didn’t add numbers, but Python knows how to ‘add’ two strings using the + operator, so it’s not bothered that the arguments are strings and not integers. Only you know that a and b were meant to be numbers.

A colleague you share this code with would also probably guess that a and b should be integers in this simple example. But this is not always the case.

You can add hints in your code to let colleagues know what data type you’re expecting the arguments to be:

>>> def add_integers(a: int, b: int):
...    return a + b

Note that this does not force you to use integers as arguments. These are merely hints to assist programmers who are using this function. The call add_integers("Hello", "World") will still work as it did earlier.

Other reasons to use type hinting

There are some other benefits of using type hinting other than helping other programmers who use your code. Many tools you use in programming will also understand these type hints and will warn you about transgressions. Have a look at the warning that PyCharm gives you when the you write the example above in a script:

Type hinting warnings in PyCharm IDE

You’ll still be able to run this code, but PyCharm has highlighted the string arguments in yellow, and when you hover on the yellow highlight, you’ll get a pop-up window telling you that an int is expected. Other IDEs will also offer similar functionality.

There’s another advantage of using type hinting. As your IDE is now aware of the data type you want your parameters to represent, you can now use autocompletion when you type the parameter name followed by a dot.

Adding Another Class to Help The Market Seller

The market seller has now learned the philosophy of object-oriented programming. Therefore, he asked himself the question: What are the objects that matter to me to run my market stall?

The items he sells are important, and the Product class creates a data type that allows him to create products. However, that’s not enough. The other object that matters to him is his till or cash register. The cash register is where he keeps his money and where he records his transactions. Let’s help him create a new class:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")
        
        
class CashRegister:
    def __init__(self):
        self.income = 0
        self.profit = 0
        self.cash_available = 100

You’ve created the CashRegister class with its __init__() method. When you create an instance of the class CashRegister, you’ll create three instance variables that all have a starting value. The market seller’s code will create a CashRegister instance every morning when he opens his stall and runs his program.

The instance variables income and profit have initial values of 0 as the market seller has not made any sales yet at the start of the day. He always starts the day with £100 in the cash register, so the cash_available instance variable is initialised with the value 100.

Registering a sale

Let’s look at what functions a cash register needs to perform. The main one is to register a sale whenever a customer comes along and buys an item.

You can create a method for the CashRegister class with the following signature:

def register_sale(self, item, quantity):

The method has three parameters:

  • self is the first parameter that represents the object the method is acting on
  • item identifies what product is purchased, whether it’s a coffee or a sandwich, say
  • quantity determines how many of the item were purchased in the transaction

Question: What data type would you choose for item?

You could make item a string, for example "Coffee". However, there’s a better option.

Product is a data type and objects of this type can therefore be used as an argument for a function or method. By making item an object of type Product, you’re making the most of all the data and functionality available in the Product class.

To make this clearer, you can use type hinting in this method definition:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")


class CashRegister:
    def __init__(self):
        self.income = 0
        self.profit = 0
        self.cash_available = 100

    def register_sale(self, item: Product, quantity: int):
        sale_amount = quantity * item.selling_price
        self.income += sale_amount
        self.cash_available += sale_amount
        self.profit += quantity * (item.selling_price - item.cost_price)

The method signature now uses type hinting showing that item should be of type Product and quantity should be an int. The method then works out the sale amount using quantity and the selling_price instance variable of the item. Incidentally, type hinting means that we can be lazy (read: efficient) when coding as the IDE will autocomplete item‘s attributes since the IDE is aware that this variable is of type Product.

The register_sale() method then updates the instance variables of the CashRegister object to increase the daily income and the cash available in the till. To work out the profit, you need to use both the item’s cost price and selling price to get the profit from the sale of that item.

Testing the method

You can check that this method works by using it in market_seller_testing.py:

# market_seller_testing.py

from market_stall import Product, CashRegister

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

till = CashRegister()

print(till.income)
print(till.profit)
print(till.cash_available)

till.register_sale(second_prod, 5)
print()

print(till.income)
print(till.profit)
print(till.cash_available)

You’re now creating an instance of the class CashRegister and showing the data attributes before and after you call register_sale(). This code gives the following output:

0
0
100

8.75
4.25
108.75

However, there’s a bit more you can do in this method.

Updating the Product when registering a sale

You’ve passed an object of type Product as an argument for the CashRegister method register_sale(). You can also update the number of items in stock of the product. If the market seller sold 5 chocolates in one transaction, then he has five fewer chocolates in stock.

Let’s add an extra line to the register_sale() method in the CashRegister class:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")


class CashRegister:
    def __init__(self):
        self.income = 0
        self.profit = 0
        self.cash_available = 100

    def register_sale(self, item: Product, quantity: int):
        sale_amount = quantity * item.selling_price
        self.income += sale_amount
        self.cash_available += sale_amount
        self.profit += quantity * (item.selling_price - item.cost_price)
        item.decrease_stock(quantity)

And you can make a few changes in market_seller_testing.py, too:

# market_seller_testing.py

from market_stall import Product, CashRegister

first_prod = Product("Coffee", 1.1, 2.5, 30)
second_prod = Product("Chocolate", 0.9, 1.75, 35)

till = CashRegister()

print(till.income)
print(till.profit)
print(till.cash_available)
second_prod.show_stock()

till.register_sale(second_prod, 5)
print()

print(till.income)
print(till.profit)
print(till.cash_available)
second_prod.show_stock()

This gives the following output:

0
0
100
Chocolate | Number in stock: 35

8.75
4.25
108.75
Chocolate | Number in stock: 30

You can see from the output that the program also decreased the number of chocolates in stock when you called till.register_sale().

Before finishing this Chapter, you should go back to the top and review the first and second attempts that the market seller made. These were the versions of the code that didn’t use the object-oriented programming approach. Look at the code you wrote earlier on and compare it with the OOP version. You’ll be able to appreciate that, once you’ve defined the classes, using object-oriented programming leads to neater and more readable code in some projects. This will made developing your code quicker and less likely to lead to errors and bugs that may be hard to find.

Conclusion

In this Chapter, you’ve learned about the object-oriented programming paradigm and the philosophy behind this topic. There are two reasons why you need to know the basics of object-oriented programming.

Firstly, you may want to write your own classes for specific projects in which the investment you put into writing the class in the first place pays off when you write your application. Any classes you define, you can reuse in other projects, too.

You also need to be familiar with classes and OOP because you’ll come across many classes as you use standard and third-party modules in Python. Even though someone else has already written these classes, understanding the concept of classes and OOP will help you understand and use the tools in these modules.

There’s a lot more to say about object-oriented programming. The aim of this Chapter is to provide an introduction to the basics. I’ll briefly discuss a couple of additional topics in the Snippets section at the end of this Chapter. However, a detailed study of OOP is beyond the scope of this book.

In this Chapter, you’ve learned:

  • What is the philosophy behind object-oriented programming
  • How to define a class
  • How to create an instance of a class
  • What attributes, instance variables, and methods are
  • How to define methods

You also learned about:

  • The increment and decrement operators += and -=
  • Type hinting

In the next Chapter, you’ll learn about using NumPy, which is one of the fundamental modules that you’ll use for quantitative applications.

Additional Reading


Snippets


1 | Dynamic Typing and Type Hinting

Python uses dynamic typing. What does this mean? Let’s look at the following assignments:

>>> my_var = 5
>>> my_var = "hello"

In the first line, the Python interpreter creates a label that points towards an integer. You didn’t have to let your program know that my_var should be an integer. When the interpreter executed the first line, it looked at the object on the right-hand side of the equals sign. It determined that this is an integer based on the fact that it’s a digit, without any quotation marks or brackets, and without a decimal point.

On the second line, the Python interpreter has no problems switching the data type that the variable my_var stores. The interpreter determines the data type of a variable when the line of code runs, and it can change later on in the same program.

This type of behaviour is not universal among programming languages. In statically-typed languages, the programmer needs to state what data type a variable will contain when the variable is first defined.

As with most things, there are advantages and disadvantages for both systems that I won’t get into. Dynamic typing certainly suits Python’s style of programming very well.

Duck typing

You’ll also hear the term duck typing used to refer to a related concept. The term comes from the phrase "if it walks like a duck and it quacks like a duck, then it is a duck". This idea points to the fact that in many instances, what matters is not what the actual data type is, but what properties the object has.

An example of this concept is indexing:

>>> name = "hello"
>>> numbers = [4, 5, 6, 5, 6]
>>> more_numbers = 23, 34, 45, 56, 3
>>> is_raining = True
>>> name[3]
'l'
>>> numbers[3]
5
>>> more_numbers[3]
56
>>> is_raining[3]
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'bool' object is not subscriptable

The same square brackets notation performs the same task on several data types. In the example above, lists, tuples and strings can all be indexed. Booleans cannot, though.

Type hinting

Python 3.5 introduced type hinting. Type hints do not change Python from a dynamically to a statically-typed language. Instead, as the name suggests, they serve only as hints. Have a look at the following example, which uses type hinting:

>>> my_list: list = [4, 5, 6, 7]
>>> my_list: list = "hello"
>>> my_list
'hello'

You’re adding a type hint when creating the variable my_list. However, in the second line you assign a string to this variable, and there are no complaints from Python’s interpreter.

You’ve already seen an example of type hinting when used with parameters in function definitions and how tools such as IDEs make use of type hints to assist you with your coding.

You may be working on projects as part of a team where type hinting is used as standard. Type hinting has been used more and more in recent years, especially in the context of production-level code.

For most other applications, it’s up to you on how and when to use type hinting. There are times when the extra information can make a significant contribution to making your code more readable. In other instances, you may use it to make the most of the IDEs functionality or other third-party tools that rely on type hinting for performing checks on your code.


2 | Dunder Methods

You’ve already come across one of the dunder methods you’ll see when you define a class in object-oriented programming. These methods whose names start and end with a double underscore have a special status, and they perform specific tasks.

Let’s look at a few more in this Snippet. You’ll use the Product class you defined earlier in the Chapter:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

Let’s experiment with this in a new script testing_dunder_methods.py:

# testing_dunder_methods.py

from market_stall import Product

a_product = Product("Coffee", 1.1, 2.5, 30)

print(a_product)

The output from this shows the following:

<market_stall.Product object at 0x7fec10a6e9d0>

This output is not very useful in most instances.

The __str__() method

You may want to customise what happens when you print an object of type Product. To do this, you need to define a dunder method:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

    def __str__(self):
        return f"{self.name} | Selling Price: £{self.selling_price}"

The __str__() dunder method has the self parameter and returns a string. If you rerun testing_dunder_methods.py now you’ll get a different output when you print the object:

Coffee | Selling Price: £2.5

You can also format the price a bit further:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

    def __str__(self):
        return f"{self.name} | Selling Price: £{self.selling_price:.2f}"

Following the selling_price instance variable in the curly brackets of the f-string, you’ve added a colon to format the output further. The code following the colon formats the float and displays it with two decimal places:

Coffee | Selling Price: £2.50

It’s up to you as the programmer to decide how you’d like the object to be displayed when you need to print it out.

Comparison operators

Let’s try the following operation on two objects of type Product:

# testing_dunder_methods.py

from market_stall import Product

a_product = Product("Coffee", 1.1, 2.5, 30)
another_product = Product("Chocolate",  0.9, 1.75, 35)

print(a_product > another_product)

You’re using one of the comparison operators to check whether one object is greater than the other. But what does this mean in the context of objects of type Product? Let’s see whether the Python interpreter can figure this out:

Traceback (most recent call last):
  File "<path>/testing_dunder_methods.py", line 8, in <module>
    print(a_product > another_product)
TypeError: '>' not supported between instances of 'Product' and 'Product'

No, it cannot. You can see the TypeError stating that the > operator is not supported between two objects of type Product. However, you can fix this with the __gt__() dunder method, which defines the behaviour for the greater than operator:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

    def __str__(self):
        return f"{self.name} | Selling Price: £{self.selling_price:.2f}"

    def __gt__(self, other):
        return self.selling_price > other.selling_price

The __gt__() dunder method has two parameters. You’ll see that the IDE autofills both of these. Since this operator compares two objects, there’s self and other to represent the two objects. The self parameter represents the object to the left of the > sign, and other represents the object on the right.

In this case, we’re assuming that in the context of objects of type Product, the result of the > operator should be determined based on the selling price of both products. The return statement in this dunder method should return a Boolean data type.

Here are some other related operators you can also define:

  • less than operator < using __lt__()
  • less than or equal operator <= using __le__()
  • greater than or equal operator >= using __ge__()
  • equality operator == using __eq__()

Arithmetic operators

Let’s finish with one last dunder method. What happens if you try to add two objects of type Product together:

# testing_dunder_methods.py

from market_stall import Product

a_product = Product("Coffee", 1.1, 2.5, 30)
another_product = Product("Chocolate",  0.9, 1.75, 35)

print(a_product + another_product)

You may have guessed it’s not obvious what adding two products means. The program raises another TypeError:

Traceback (most recent call last):
  File "<path>/testing_dunder_methods.py", line 8, in <module>
    print(a_product + another_product)
TypeError: unsupported operand type(s) for +: 'Product' and 'Product'

Another dunder method comes to the rescue. The __add__() dunder method defines how the + operator works for these objects:

# market_stall.py

class Product:
    def __init__(self, name, cost_price, selling_price, number_in_stock):
        self.name = name
        self.cost_price = cost_price
        self.selling_price = selling_price
        self.number_in_stock = number_in_stock

    def decrease_stock(self, quantity):
        self.number_in_stock -= quantity

    def change_cost_price(self, new_price):
        self.cost_price = new_price

    def change_selling_price(self, new_price):
        self.selling_price = new_price

    def show_stock(self):
        print(f"{self.name} | Number in stock: {self.number_in_stock}")

    def __str__(self):
        return f"{self.name} | Selling Price: £{self.selling_price:.2f}"

    def __gt__(self, other):
        return self.selling_price > other.selling_price

    def __add__(self, other):
        return self.selling_price + other.selling_price

Again, we’ve decided that the total selling price is what we’d like in this case. How you define these behaviours will depend on the class you’re defining and how you want objects of this class to behave. You can try out __sub__() and __mul__() too!

There are many other dunder methods that allow you to customise your class and how it behaves. For example, there is a dunder method to make a data type an iterable and another to make it indexable.


3 | Inheritance

In this Snippet you’ll look at a brief introduction to the concept of inheritance in object-oriented programming. When you define a class, you’re defining a template to create objects that have similar properties.

You may need objects of groups which are similar to each other but different enough that they cannot use exactly the same class.

Consider the market seller you met earlier in the Chapter. After using his code for a while, he noticed he has a problem. His code treats all sandwiches in the same way. However, he sells different types of sandwiches, and he wants to keep track of them separately.

He decides to write a new class called Sandwich. However, this class has a lot in common with Product. Therefore, he wants the new class to inherit its properties from the Product class. He’ll then make some additional changes.

Inheritance is a key area of object-oriented programming. To create a class that inherits from another class, you can add the parent class in the parentheses when you create the class:

class Sandwich(Product):

The child class Sandwich inherits from the parent class Product. The child class still needs an __init__() method:

# market_stall.py

# Definition of Product not shown
# ...

class Sandwich(Product):
    def __init__(self, filling, cost_price, selling_price, number_in_stock):
        super().__init__("Sandwich", cost_price, selling_price, number_in_stock)
        self.filling = filling

The __init__() method parameters are similar to those of the parent class. There is one difference. The second parameter represents the filling of the sandwich and not the name of the product. The name of the product must be "Sandwich" for all objects of this type.

The super() function

The first line of the __init__() method has a new function you’ve not seen so far. This is the super() function. This function allows you to access the properties of the parent class. You’re calling the __init__() method of the parent class in the first line of Sandwich‘s __init__() method. This means that when you initialise an object of type Sandwich, you first initialise the parent class and then go on with specific tasks for the child class.

The call to super().__init__() doesn’t use the filling parameter an object of type Product does not need this. The first argument in super().__init__() is the string "Sandwich", and this will be assigned to the instance variable name.

The last line then creates a new instance variable filling which is specific only to this child class.

Let’s try this out in a new script called testing_inheritance.py:

# testing_inheritance.py

from market_stall import Sandwich

type_1 = Sandwich("Cheese", 1.7, 3.5, 10)
type_2 = Sandwich("Ham", 1.9, 4, 10)
type_3 = Sandwich("Tuna", 1.8, 4, 10)

print(type_1.name)
print(type_2.name)
print(type_3.name)

print(type_1.filling)
print(type_2.filling)
print(type_3.filling)

You’re creating three instances of the class Sandwich with different arguments. An object of type Sandwich has all the properties of an object of type Product. You can see for the first three lines printed out that all objects have the same value for the instance variable name:

Sandwich
Sandwich
Sandwich
Cheese
Ham
Tuna

However, they all have different values for filling. Let’s see what happens when you print the object directly:

# testing_inheritance.py

from market_stall import Sandwich

type_1 = Sandwich("Cheese", 1.7, 3.5, 10)
type_2 = Sandwich("Ham", 1.9, 4, 10)
type_3 = Sandwich("Tuna", 1.8, 4, 10)

print(type_1)
print(type_2)
print(type_3)

This gives the output defined by the __str__() dunder method of the parent class Product:

Sandwich | Selling Price: £3.50
Sandwich | Selling Price: £4.00
Sandwich | Selling Price: £4.00

Overriding methods

However, you can define a __str__() dunder method specifically for the Sandwich class:

# market_stall.py

# Definition of Product not shown
# ...

class Sandwich(Product):
    def __init__(self, filling, cost_price, selling_price, number_in_stock):
        super().__init__("Sandwich", cost_price, selling_price, number_in_stock)
        self.filling = filling

    def __str__(self):
        return f"{self.filling} sandwich | Selling Price: £{self.selling_price:.2f}"

Sandwich‘s __str__() method overrides the same method in the parent class Product. The output from testing_inheritance.py now looks as follows:

Cheese sandwich | Selling Price: £3.50
Ham sandwich | Selling Price: £4.00
Tuna sandwich | Selling Price: £4.00

You should also override the show_stock() method similarly.

In the same way that you’ve added a data attribute to the child class, in this case filling, which doesn’t exist for the parent class, you can also create methods specifically for the child class.


Sign-Up For Updates

This site was launched in May 2021. I’ll be adding chapters regularly over the coming weeks and months.

Sign-up for updates and you’ll also join the Codetoday Forum where you can ask me questions as you go through this journey to learn Python coding.