Only registred users can make comments

Object Oriented Programming in Python - Part 1/2

 


Object Oriented Programming in Python - Part 1/2

TL;DR 

The Object Oriented Programming in Python series is actually material for the upcoming course, but I've decided to release it as an article first.

That's the reason you may find it a little bit lengthy. Feel free to use it as a reference too if you already know some of the topics.
The upcoming course will be complementary material that will help you to master OOP even better. Stay tuned.

From Wikipedia:

Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which can contain data and code: data in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).

Many beginner and intermediate developers can find the OOP programming style pretty challenging.
This article series will try to change that. You will learn the fundamentals of OOP in Python.

We will develop a moto dealership application that uses OOP concepts like abstraction, encapsulation, inheritance, and polymorphism. 
This first article introduces you to the essentials of Object-Oriented Programming in Python.

In part 1 (this article) you will get introduced to Python objects and the concept of working with them. In addition, we will learn how to create classes, __init__() method (a constructor method),  how to define public, private, and protected arguments, and how to write instance methods, class methods, and static methods. 
We will also learn how to instantiate instances and how to use self and cls keywords.

In part one, you will learn the following:

In part two, we will continue learning the following:

  • Class Inheritance
  • Polymorphism
  • Encapsulation
  • Abstraction
  • Setter and Getter methods
  • Method resolution order
  • More magic methods

Abbreviation

  • OOP refers to Object-Oriented Programming
  • Magic method, special method, and dunder method mean the same and refer to the built-in Python methods that start and end with double underscores

Prerequisites

You should be familiar with:

  • basic data structures
  • variables
  • expressions
  • loops
  • functional programming

If you're coming from another language, you should not have difficulties following this Python OOP series. 

How To Learn

  • Read comments in the code snippet illustrations, a lot of details are written in the comments.
  • Follow along and try out the examples shown. Play around, practice and break things.
  • Create your own classes

Why Object-Oriented Programming?

Many developers ask why they should write their code using classes, instead of just sticking to functional programming, which is perfectly acceptable in Python.

Defining classes in an object-oriented programming (OOP) style is a valid question, especially since you can usually "get by" without writing your applications in OOP style in Python. However, you may eventually reach a point where keeping track of all dependencies, such as functions used in other functions, becomes difficult. This can lead to spaghetti code, where it becomes challenging to understand how all the pieces are related. Even your own code can be difficult to explain after some time, especially if you revisit it after a few months, which has happened to me many times in the past.

Let's take a quick look at the following illustration, my_app, paying attention to the dependencies:

which could easily become something like this:

spaghetti code

 

Even if we just have four functions in the my_app example above, it's already beginning to become pretty obvious that this approach will become challenging when trying to follow the dependency paths.

In contrast, there is not always a reason to create classes if your applications are simple.
Python is a powerful language, and some techies don't write applications in the OOP style, which I've especially noticed that among sys admins.

Why is that? Probably because some writes code in a procedural style to perform something pretty simple. And some developers use Python to write procedural scripts as you would do with Bash for instance.

That could be something like processing data via API calls, server administration, web scraping, extracting and wrangling data from Excel sheets and other data sources, etc. So for small utilities, there is no obvious reason to write classes.

Some people don't even find it natural to write Python code in OOP style which is perfectly fine.
There are many use cases and programming styles and OOP doesn't fit them all when it comes to how Python can be used.
In my personal opinion, that's the power of Python, it's easy to learn and you can apply it in multiple ways.

You should come to a point where you realize that your applications are becoming too complex, and hard to keep track of dependencies, and you want to keep a better structure in your code.

As a side note, one interesting thing is that in Python you use objects regardless if you define classes or not.
In Python, everything is an object and we will learn more about it as we go. It is simply abstracted away from us.

Benefits of OOP

A strong reason for writing classes is if you want to write reusable code that different applications and developers can share.
Also, you don't need to repeat yourself. You could define a class, and manage it as a library. You can create child classes, inherit attributes and methods from the parent class and customize it further.

Here are some good reasons for writing classes:

  • Reusable code that can be shared between different applications and developers.
  • Code can be easily broken down into separate pieces, with each class being used on its own or as part of something larger.
  • Multiple instances can be created based on the same class, allowing them to co-exist.
  • Classes can be seen as blueprints for objects.
  • Certain attributes can be protected, providing a more secure environment.
  • A good structure is maintained, avoiding spaghetti code.
  • Improved development skills.
  • Supports inheritance.
  • Bottom-up approach, in contrast to the top-down approach in procedural programming.

There are additional pros and cons, but let's keep it simple for now. The rest you will learn about in your journey of learning OOP in this series of articles.

Python Objects

What is an object?

Python is often thought of as not being an object-oriented language because you can write code without defining classes. However, everything in Python is actually an object that is built into the language. Variables and all data types (e.g., lists, dictionaries, sets, integers, floats, and strings) are objects.

In this series of articles on OOP in Python, we will define objects based on classes that we will create. Classes are blueprints for objects and define common attributes and methods for all objects of a specific class. We use classes to instantiate corresponding class instances. Within a class, we can write methods that act on the object's data, such as a method that calculates the price after tax for an object with a price attribute.

For example, consider the class "Product" in the diagram. It requires two instance attributes: name and price. We also have one method called "tax" that applies a tax rate to the price:


❗ A class, even though it serves as a blueprint for objects, is actually an instance of the type "metaclass". This article won't go into detail about metaclasses, as it is not a topic that is commonly needed to be understood by most developers. However, it's worth noting that even classes are objects.

From Wikipedia:

In object-oriented programming, a metaclass is a class whose instances are classes. Just as an ordinary class defines the behavior of certain objects, a metaclass defines the behavior of certain classes and their instances. Not all object-oriented programming languages support metaclasses.


The built-in functions type() and id() are useful for exploring the types and unique identities of objects in Python. The definitions for type() and id() can be found in the Python official documentation.

type() returns the type of a Python object, determining what kind of object it is.

The type of an object can be accessed through its __class__ attribute or by calling type(obj).

id() returns the "identity" of an object, represented as a unique and constant integer throughout the object's lifetime. Note that two objects with non-overlapping lifetimes may still have the same id() value.

The following example demonstrates how to create variables, assign values to them, and use type() and id() to examine the types and identities of the resulting objects:

product = 'Tesla' # variable
model = '3' # variable
price = 600 # variable

print(type(product))
print(type(model))
print(type(price))

print(id(product)) # a unique id for the product object
print(id(model)) # a unique id for the model object
print(id(price)) # a unique id for the price object

Output:

<class 'str'>
<class 'str'>
<class 'int'>

4449228656
4451902512
4452106416

  • str is a built-in string class
  • int is a built-in integer class
  • A number, e.g. 4449228656 is a unique ID for an object.

Another example, where we can check the type of built-in functions:

# Check object type
type(print)
<class 'builtin_function_or_method'>

#check object id
id(print())
4337219232

In Python, all objects get a unique ID. 

Also, the attributes and methods of a class are objects themselves:

class People:
    def __init__(self, gender) -> None:
        self.gender = gender
        
    def get_gender(self):
        return self.gender
    
human = People('woman')

print(human.get_gender())
print(type(human.get_gender))
print(type(human.gender))

Output:

woman
<class 'method'>
<class 'str'>
  • human.get_gender(): is a method belonging to a class.
  • human_1.gender: is str type belonging to a class, similar to a string variable that we have seen earlier 

In the following example, we will create a class called Item. We will also instantiate instances with the following attributes:

  • product
  • model
  • price
# This is the simplest form of a class,  we define classes like this.
class Vehicle:
    pass

# this is the way how we instantiate an instance of a class
item_1 = Vehicle()

# Let's assign some attributes to the instance
item_1.product = 'Tesla'
item_1.model = '3'
item_1.price = 600

print(type(Vehicle)) # class itself
print(type(item_1)) # the instance of the Item class
print(type(item_1.product)) # the attribute type of the instance
print(type(item_1.model)) # the attribute type of the instance
print(type(item_1.price)) # the attribute type of the instance

Output:

<class 'type'>
<class '__main__.Vehicle'>
<class 'str'>
<class 'str'>
<class 'int'>

Please pay attention to how we assigned attributes to our class instance, item_1; we've set some arbitrary attributes.
We could have given any attributes, even some that don't have any relevance to Vehicle class. like item_1.eye_color = 'blue' It doesn't make sense at all, but nothing stops us from doing it. 

There is of course a way to create a blueprint that requires relevant attributes. Later, we will learn how to define an __init__() method which does exactly that.

Since our current Vehicle class doesn't have any __init__() (constructor in other languages), we can, for now, assign whatever attribute we want and Python won't complain.


Example 1 - Create methods that act on instance data

In the following example, We're turning the first letter capitalized by using a built-in function capitalize:

my_name = 'tina'

print(my_name.capitalize())

Output:

Tina

We have used a built-in function  called capitalize()

Can we do this inside a class? Yes, in the example below, we will create a method called capitalize_name()


❗You may wonder, why do we call it a method and not a function. Great question! We will cover it in the next section. Keep up. 


Here comes an example where we're defining a People class. You probably still don't know some of the concepts in the code, but don't worry, just type it as-is for now:

class People:
    def __init__(self, gender, name) -> None:
        self.gender = gender
        self.name = name
        
    def get_gender(self):
        return self.gender
    
    def capitalize_name(self):
        return self.name.capitalize()    
    
# instantiating objects
human_1 = People('woman', 'tina')

print(human_1.capitalize_name()) # print the return of capitalize_name()
print(type(human_1.capitalize_name())) # print the type of return of capitalize_name()
print(type(human_1.capitalize_name)) # print the type of capitalize_name() method itself

Output:

Tina
<class 'str'>
<class 'method'

Our capitalize_name() works as expected and we're getting the first letter in upper case. 

First, we get the actual return of the capitalize_name()which is the name.

The return is a string type.

Also, we see that the capitalize_name() is a method and not a function since it's belonging to a Class. 

There are some concepts in the previous example that we haven't learned yet, such as __init__() and the self keyword. You may have noticed that we were able to call a method from our human_1 class instance.

Don't worry if that's something you don't understand, we will come to the details soon.


❗-> Return value annotation 

-> None is just a return value annotation and it's completely optional and if you'd removed it, nothing would change. It just indicates that the particular function or method is returning None.

 

Naming Convention

If you want to follow a good naming convention, the first letter in the class name is always defined with capital letter. 

examples:

  • class Item()
  • class People()
  • class Vehicle()

What is a method?

A method is a function inside a class. We use the term "method" to distinguish between functions and methods.

In contrast, by function, we mean a stand-alone function, which is not part of a Class

In the following example, we are writing a function called calc_sum() which sums up two numbers. 

We also have a method inside the People class, get_age_in_ten() which is an instance method, but it used the calc_sum() to provide us with the age of the human and what it will be in ten years. Very silly, but the example is showing that we can use a function inside our class. 

# a function that takes a list of numbers and returns the cumulative sum; that is, a new list where the ith element is the sum of the first i + 1 elements from the original list. For example, the cumulative sum of [1, 2, 3] is [1, 3, 6].
def calc_sum(a, b):
    return a + b

class People:
    # A method is a function that is defined inside a class
    def __init__(self, gender, name, age) -> None:
        self.gender = gender
        self.name = name
        self.age = age

    # A method      
    def get_gender(self):
        return self.gender

    # A method
    def capitalize_name(self):
        return self.name.capitalize()    

    # a silly method that provides the age in ten years. We use a function outside the class
    def get_age_in_ten(self):
        return calc_sum(self.age, 10)

# instantiating objects
human_1 = People('woman', 'tina', 33)

# age in 10 years
print(human_1.get_age_in_ten())


Output:

43

❗From Python official documentation:

 A method is a function that “belongs to” an object

(In Python, the term method is not unique to class instances: other object types can have methods as well. For example, list objects have methods called append, insert, remove, sort, and so on. However, in the following discussion, we’ll use the term method exclusively to mean methods of class instance objects, unless explicitly stated otherwise.)


What is a self keyword?

You can instantiate several instances of a class and assign different values to the attributes for each instance.

Let's illustrate this by enriching our People class.

The following items have been added to our class:

  • attribute: age
  • __str__() method that returns string representation while calling the instance. (Please note, __str__() method will be explained later)
'''
Self keyword that is part of what's usually refered to as a constructor. 
self is a keyword in Python that represents the object (instance) itself.
Constructor or init method is a method that is called when an object is created from a class and it allows 
the class to initialize the attributes of the class.
'''

class People:
    def __init__(self, gender, name, age:int) -> None:
        self.gender = gender
        self.name = name
        self.age = age
        
    def get_gender(self):
        return self.gender
    
    def capitalize_name(self):
        return self.name.capitalize()
    
    def __str__(self):
        return f"Name: {self.capitalize_name()} Gender: {self.gender} Age: {self.age}"
    
    
human_1 = People('woman', 'tina', 44)
print(human_1)

human_2 = People('man', 'alex', 34)
print(human_2)

human_3 = People('woman', 'aurora', 28)
print(human_3)

Output:

Name: Tina Gender: woman Age: 44
Name: Alex Gender: man Age: 34
Name: Aurora Gender: woman Age: 28

So even if we'd have written lowercase letters, this function accepts the instance itself and changes the first letter to an uppercase letter.

self is a keyword in Python that represents the object (instance) itself.

To be able to instantiate  a class instance, we need to pass it as an argument to our __init__() method (the constructor method)

The actual name "self" can be changed to anything, it's just a convention. The important thing to know is that the first argument represents the object itself.


Python's self keyword is similar to JavaScript's this keyword, which refers to an object.

From the official Python documentation:

Often, the first argument of a method is called self. This is nothing more than a convention: the name self has absolutely no special meaning to Python. Note, however, that by not following the convention your code may be less readable to other Python programmers, and it is also conceivable that a class browser program might be written that relies upon such a convention.


Pay attention to how we used self keyword which represents the actual instance as the first argument.

In this way, it's possible to get individual instance data when we call for an attribute or a method like capitalize_name(self) where we are passing the instance itself

The following lines print(human_1), print(human_2) and print(human_3)provided us with the following results:

Name: Tina Gender: woman Age: 44
Name: Alex Gender: man Age: 34
Name: Aurora Gender: woman Age: 28

The reason is, that the __init__() method accepts the instance as the first argument. The object and its attributes are kept in memory.

Next, it assigns a unique value to each instance attribute. This is recognizable by assigning the attributes and values, in this case self.gender, self.name and self.age

class People:
    def __init__(self, gender, name, age:int) -> None:
        self.gender = gender
        self.name = name
        self.age = age

And each method inside the class will take the instance as an argument. This means every unique instance will get its own data processed by the method.

What is done of course depends on what the method is performing. In our example, get_gender() method will return gender, capitalize_name() will capitalize the first letter in the name and __str__() will print a user-friendly output in string format:

    def get_gender(self):
        return self.gender
    
    def capitalize_name(self):
        return self.name.capitalize()
    
    def __str__(self):
        return f"Name: {self.capitalize_name()} Gender: {self.gender} Age: {self.age}"

All of these methods that we have written so far are called....try to guess....instance methods!

This is because the instance methods apply to the instances.


❗An instance method is a method that belongs to instances of a class. 

The instance of the class needs to be created before you can call an instance method.


Challenge id: 1739215 - Create A Class

You work for a construction company. You have been asked to create a class that will keep attributes common to all buildings.

Your job is to define an empty class that will serve the purpose. Based on the examples you have seen so far, how would you define the requested class?

❓Spend a few minutes on how you would define a class that is related to buildings and what attributes you would apply to the instances.

We will revisit this challenge later in the article.


A solution to 1739215

The solution to this challenge is pretty simple. We're defining a class Building and we require some attributes for each class instance that we'll create:

  • type of building
  • how many story building
  • does it have an elevator

We will also add a magic method called __str__()method that will provide a string representation of the instances.

Here is the solution with comments:

class Building():
    
    def __init__(self, building_type, storey: int, elevator: bool ) -> None:
        self.building_type = building_type # string attribute
        self.storey = storey # int attribute
        self.elevator = elevator # bool attribute
        
    # string representation of the object
    def __str__(self) -> str:
        return f'Type: {self.building_type}, storey: {self.storey}, Elevator: {self.elevator}'
    
    
building_1 = Building('apartment', 3, True)
building_2 = Building('office', 2, False)   # no elevator

print(building_1)
print(building_2)

Output:

Type: apartment, storey: 3, Elevator: True
Type: office, storey: 2, Elevator: False

❓Challenge id: 1335214  - A method that acts on instance data

Let's have a look at another example where self keyword is of high importance.

Use Case: we need to create two product instances based on the Product class. Since our products are sold nationwide, we need to apply the correct tax rate.

The apply_tax() method needs to apply a tax rate that we need to provide while creating the instance. We need to use self keyword so each instance can get its own copy of the tax rate

Requirements:

  • We need to define a class
  • each product (instance) must have an individual price
  • The final price shall be calculated based on the US State. Define at least two states with corresponding tax rates.
  • If no US State is provided, apply an 0% tax rate.

try to do the following before you check the solution below. Your solution does not have to match the solution in the course.

  • Define a class.
  • create a list of attributes that are needed. Provide some attributes needed in the __init__() constructor.
  • create a list of methods you think are needed.
  • Try to find out how you would create a condition to check the US states to apply the correct tax rate.

A solution to 1335214

First, we need to define a class. A suggestion is to call it Product

# empty class
class Product:
    pass

In this example, we will introduce a class attribute, tax_ratewhich is a dictionary keeping the US States and tax rates.

The tax_rates variable defined here is a class variable and not an instance variable. A good practice is to write class variables for the variables that are shared among all instances and where the value is not changing. In the example shown below, the state of California has a 7.25 % of the sales tax rate. That doesn't differ depending on the instance that you create. 

class Product:
    # class attribute
    tax_rates = {
        'California': 7.25,
        'Indiana': 7.00,
    }
    

and we need some attributes. We could keep it simple and have two required parameters: product and price.

The product variable will keep the name of the product.

class Product:
    # class attribute
    tax_rates = {
        'California': 7.25,
        'Indiana': 7.00,
    }
    
    def __init__(self, product, price) -> None:
        self.product = product
        self.price = price

And now, let's define a method that will apply a correct tax rate based on the US state.

The apply_tax() method will match the name of the US State passed as the argument, and if it matches, the correct rate will be applied.

If it can't be found, zero tax will be applied.

class Product:
    # class attribute
    tax_rates = {
        'California': 7.25,
        'Indiana': 7.00,
    }
    
    def __init__(self, product, price) -> None:
        self.product = product
        self.price = price

        
    # add Tax based on US state. If no state in list, apply 0%
    def apply_tax(self, state): 
        tax_rate = 0        
        for x in Product.tax_rates.keys():       
            if state == x:  
                tax_rate = Product.tax_rates[x]
        return round(self.price + (self.price * (tax_rate/100)), 2)
    

And finally, let's create three instances and pass the different US States to be able to see if we get different total prices due to tax rates applied:

class Product:
    # class attribute
    tax_rates = {
        'California': 7.25,
        'Indiana': 7.00,
    }

    def __init__(self, product, price) -> None:
        self.product = product
        self.price = price

    # add Tax based on US state's tax rate. If no state in list, apply 0%
    def apply_tax(self, state):
        tax_rate = 0
        for x in Product.tax_rates.keys():
            if state == x:
                tax_rate = Product.tax_rates[x]
        return round(self.price + (self.price * (tax_rate/100)), 2)


# # instantiate an instance
item_1 = Product('MacBook Pro', 1999)

item_2 = Product('Iphone 13', 1500)

item_3 = Product("Ipad Pro", 1000)

# # passes item_1 as the argument to the apply_tax method + state
print(item_1.apply_tax('California'))
print(item_2.apply_tax('Indiana'))
print(item_3.apply_tax('Mississippi'))

Output:

2143.93
1605.0
1000.0

Great, we got different total prices based on the US state passed. The last instance, item_3, didn't got any higher price since there is not tax defined for Mississippi state.

if you remove the keyword self, you would get the following:

TypeError: Product.increase_price() takes 0 positional arguments but 1 was given

In this section, you have learned why we need self keyword as the first argument.

__init__  (magic or dunder) method 

__init__() magic method is a constructor in Python, meaning the constructor gets called every time we instantiate an object.

We're defining the instance attributes inside the constructor. 

There are two types of constructors:

  • default constructor: Doesn't accept any argument except the instance itself
  • parameterized constructor: accepts the instance as a parameter as any other arguments the developer defines (as we have done in previous examples).

In the following use case, we'll assume that we're running a moto dealership and we need to keep an inventory of vehicles.

Default constructor example:

# Vehicle class
class Vehicle:
    def __init__(self) -> None:
        pass


item_1 = Vehicle() # instantiating item_1 product
item_2 = Vehicle() # instantiating item_2 product
item_3 = Vehicle() # instantiating item_3 product

item_1.name = 'Tesla' # attribute 
item_1.model = '3' # attribute 
item_1.price = 55000 # attribute 

item_2.name = 'BMW' # attribute 
item_2.model = '530' # attribute 
item_2.price = 45000 # attribute 


item_3.name = 'Volvo' # attribute 
item_3.model = 's90' # attribute 
item_3.price = 39000 # attribute 

# Printing out info of each item (instance)
print(f"Name: {item_1.name} Model: {item_1.model} Price: {item_1.price}")
print(f"Name: {item_2.name} Model: {item_2.model}  Price: {item_2.price}")
print(f"Name: {item_3.name} Model: {item_3.model}  Price: {item_3.price}")


 Output:

Name: Tesla Model: 3 Price: 55000
Name: BMW Model: 530  Price: 45000
Name: Volvo Model: s90  Price: 39000

We could define any attribute to our instances. 
Currently, the __init__() method in our class (constructor) does not define any required attribute(s).


All built-in functions/methods omitted with double underscores in Python are called magic method, special method or dunder method. 
We can use any of those terms interchangeably.


The following diagram illustrates three instances belonging to the Product class. 
The class itself has a constructor that requires four parameters:

  • self keyword: represents the instance itself which is sent as an argument
  • name: an attribute that defines the name of the product instance
  • model: an attribute that defines a model of the product instance
  • price: an attribute that defines the price of the product instance

 

To demonstrate this, let's write a Product class including a constructor, related to the previous diagram. 

To avoid defining the print statement with all the details every time, we can write a __str__() method that prints out details in a user-friendly format:


__str__() method in Python represents the class objects as a string. 


When we print out an instance, we will get a friendly return of the instance that we have defined in __str__() method

def __str__(self) -> str:
    return f"{self.name}, {self.model}, {self.price}" 

if we add the __str__() method to our class, we can just print the instances and we will get the expected results:

'''
    _str__ is a special method that is called when you try to print an object.
    str__() method in Python represents the class objects as a string.

'''


class Product:

    def __init__(self, name: str, model, price: float) -> None:
        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price

    def __str__(self) -> str:
        return f"{self.name}, {self.model}, {self.price}"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

print(item_1)
print(item_2)
print(item_3)

Output:

Tesla, 3, 55000
BMW, 530, 45000
Volvo, s90, 39000

To be able to assign a specific attribute value to each instance, we need to use the self keyword in each attribute:

self.name = name
self.model = model
self.price = price

You can also access the attributes outside the class, so if you would print the price of the item_1, it would work as well:

class Product:
    
    def __init__(self, name: str, model, price: float) -> None:        
        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        
    def __str__(self) -> str:
        return f"{self.name}, {self.model}, {self.price}" 


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

print('item_1 price is', item_1.price)

Output:

item_1 price is 55000

The self keyword in each variable in the constructor is a reference to the current instance of the class and is used to access variables and their values for each instance. This is the reason why can access each instance of the class individually. 

self.name = name
self.model = model
self.price = price

Each instance and its attributes become objects stored in memory:

print(id(item_1))
print(id(item_1.name))
print(id(item_1.model))
print(id(item_1.price))

Output:
4370776016
4370674160
4370674224
4369120112

What would happen if we try to create an instance without specifying the attributes defined in our __init__() method?
The answer is that we would get an error.

In the following example, another instance called  item_4 has been created:

class Product:
    def __init__(self, name, model, price:float) -> None:
        self.name = name
        self.model = model
        self.price = price
        
        print(f"Name: {self.name} Model: {self.model} Price: {self.price}")



item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

item_4 = Product()

When we run the code, the following error is shown:

    item_4 = Product()
TypeError: Product.__init__() missing 3 required positional arguments: 'name', 'model', and 'price'

Since we now have an __init__() method with defined parameters, name, model and price are required. We could think of it as a blueprint.

Set default argument to an instance

Let's say we want to set one more attribute called vehicle_type, but we want to have a default value if the user doesn't pass it as an argument.

The use case could be that in most cases, it's a car, but perhaps we also have other types of vehicles.


In the real world, we would probably create a child class for different product types, but that's something we will look into in part 2 of this series.


We could achieve this by the following: vehicle_type:str = 'car'

vehicle_type Implementation in our Product class:

class Product:
    def __init__(self, name, model, price:float, vehicle_type:str = 'car') -> None:
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        
        print(f"Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type}")

Now we also have an argument type that is a string type. We have declared the self.vehicle_type to get its value from the argument. 

The vehicle_typeis optional, we don't actually need to specify the argument while creating a new class instance, and in that case, the "car" will be assigned as the value by default:

class Product:
        def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        
        print(f"Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type}")



item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

Output:

Name: Tesla Model: 3 Price: 55000 Type: car
Name: BMW Model: 530 Price: 45000 Type: car
Name: Volvo Model: s90 Price: 39000 Type: car

❗We're using type hints to specify the data type (e.g. price: float, name: str). 

Name and vehicle_type is a string data type

Price is a float data type


Instance method

Instance methods are recognizable by usually having self as a parameter (you can name this first argument as you wish, but usually it's called self in Python it's a good convention). This means a class instance has to be instantiated before you can use an instance method just because it requires the instance as the first argument.

How does the instance method work?

You have already learned how the instance method gets implemented, but we will clarify it here.

We've created a method, apply_tax(self), but we never clarified what it's called.

The apply_tax() method is an instance method, it accepts an instance as the first argument, and this is something you could figure out by looking at the first argument called self. Instance methods are not defined with a decorator which is the case for class and static methods that we will learn about soon.

    # add Tax based on US state. If no state in list, apply 0%
    def apply_tax(self, state): 
        tax_rate = 0        
        for x in Product.tax_rates.keys():       
            if state == x:  
                tax_rate = Product.tax_rates[x]
        return round(self.price + (self.price * (tax_rate/100)), 2)

apply_tax() is an instance method since it accepts an instance as the first argument, here defined as  self keyword.


Example - Modify instance data by attribute values

In this example, we will detect instances that have a specific attribute value. 

The use case is the following: Tesla cars are only subjected to a 5% VAT rate since the government is subsidizing EVs.

We'll create a class called Product, as before. We have an instance method that we'll name  calculate_net_price()

If the name equals to Tesla, we will modify that instance VAT to 0.05, otherwise, it's 0.08.

We will also return the actual instance VAT in addition to the price, so we can see that the customers only pay a 5% VAT rate on Tesla cars, otherwise, it might not be fully obvious when we get a return. Both instances (Tesla 3 and BMW 530) that we will create have the same initial price before the VAT rate gets applied because we want to easily compare the difference.

Example:

class Product:

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance
    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return (self.vat * self.price + (self.price))

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 55000)

print(item_1)
print(item_2)

Output:

Make: Tesla, Model: 3, Price:57750.0, VAT: 5.0%
Make: BMW, Model: 530, Price:59400.0, VAT: 8.0%

Once again, we have learned the importance of self argument, how instance methods can apply certain things to individual instances.

Assert Statement

Assert is something that is covered in the following article: https://devoriales.com/post/104

Assert is great for sanity checks, troubleshooting and test cases and we will see how we can use it in the following use case.

The use case is, we don't want to have a negative price.

We could add the following line to ensure that we have a price that is greater than zero:

assert price > 0, 'The price has to be greater than zero';
 
Example - assert in Product class:
class Product:
    '''
    A Product class that has a name, model, price, and vehicle_type.
    Assert is a keyword that we can use to check for conditions. If the condition is not met, it will raise an AssertionError.
    '''

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance
    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 0)

print(item_1)

Output:

assert price > 0, 'The price has to be greater than zero'
AssertionError: The price has to be greater than zero

As we can see, we got an Assertion Error since we have intentionally set the price to zero for a Tesla car which doesn't make sense. 

Class Attributes

In the following example, we will learn how to add class attributes to our Product class.


The official statement for the class attributes is:

A class attribute is a Python variable that belongs to a class rather than a particular object. It is shared between all the objects of this class and it is defined outside the constructor function, __init__(self,...), of the class


Please note that the class attributes are also accessible via the instances too.

So let's say we have a sales drive during a weekend and every vehicle price is now 12 % off.

We could add a class attribute called sales_drive

Pay attention! we are requesting the sales_drive value both from the actual Product class and from item_1  instance

class Product:
    '''
    class attribute
    A Product class that has a name, model, price, and vehicle_type.
    Here we have added a class attribute that can be retrieved by the class itself, but also by the instances.
    '''
    # class attribute, discount rate
    sales_drive = 0.12

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance

    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 55000)

# retrieve the discount
print(Product.sales_drive)  # getting the discount rate from the Class
print(item_1.sales_drive)  # getting the discount rate from the Instance

Output:

0.12
0.12

Why is the class attribute accessible from both the class object and the instance?

Because Python initially searches for the objects on the instance level and if not found, it will continue to the class.

 

We can easily prove this by changing the class attribute on the instance level and there are several ways to do that.

In Python, there is an object called __dict__ that can list all attributes of an instance

With the following lines, we could get all the attributes of a certain class and instance:

print(Product.__dict__) # class level attribute

print(item_1.__dict__) # instance level attribute
 
Let's do that by adding this to our code:
class Product:
    '''
    class attribute
    A Product class that has a name, model, price, and vehicle_type.
    Here we have added a class attribute that can be retrieved by the class itself, but also by the instances.
    In Python, there is an object called __dict__ that can list all attributes of an instance and a class
    ex:
    # instance attributes
    print(item_1.__dict__)

    # class attributes
    print(Product.__dict__)
    '''
    # class attribute, discount rate
    sales_drive = 0.12

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance

    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

# instance attributes
print(item_1.__dict__)

# class attributes
print(Product.__dict__)

Output:

{'name': 'Tesla', 'model': '3', 'price': 55000, 'vehicle_type': 'car'}
{'__module__': '__main__', '__doc__': '\n    A Product class that has a name, model, price, and vehicle_type.\n    Here we have added a class attribute that can be retrieved by the class itself, but also by the instances.\n    ', 'sales_drive': 0.12, '__init__': <function Product.__init__ at 0x7fb30ac52290>, 'calculate_net_price': <function Product.calculate_net_price at 0x7fb30ac52320>, '__str__': <function Product.__str__ at 0x7fb30ac523b0>, '__dict__': <attribute '__dict__' of 'Product' objects>, '__weakref__': <attribute '__weakref__' of 'Product' objects>}

In the first dictionary, we don't see the sales_drive attribute since that is belonging to the class and not to the instance.

In the second dictionary, we do see the sales_drive since it's the class attribute

How to change a class attribute value

In this example, we will learn how to change a class attribute value. 

First, we'll add yet another instance method that calculates the price when applying the discount set in the sales_drive class attribute:

# instance method that calculates the discounted price
def apply_discount(self):
    return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)

We will now create some instances and apply the discount on one of the instances:

class Product:
    # class attribute, discount rate
    sales_drive = 0.12

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance
    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    # instance method that calculates the discounted price
    def apply_discount(self):
        return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

print(item_1.apply_discount())

Output:

50820.0

We could change the class attribute by directly calling it from the Product class itself. We will add some more lines to demonstrate this:

print(item_1.apply_discount())
Product.sales_drive = 0.20 
print(item_1.apply_discount())

Output:

50820.0
46200.0

What we have learned is how we were able to change a class attribute value and how this affected the instances. 

How to change a class attribute value from an instance (or not)

We could make a mistake and think that we could change a class attribute from an instance.

In our product class, we have the following  class attribute:

class Product:

    # class attribute, discount rate
    sales_drive = 0.12

The attribute can be changed in following ways:

item_1 = Product('Tesla', '3', 55000)

item_1.sales_drive = 0.40  
Product.sales_drive = 0.30 

print(item_1.sales_drive)
print(Product.sales_drive)

Now you may think that those two ways are equally the same, but they are not. 

In the following example we will perform the following:

  • Retrieve the sales_drive class attribute which should be the same as the default value in the code
  • Change the valueWe'll set  a new value for the class attribute: Product.sales_drive = 0.30
  • We'll check the sales_drive value from an instance: item_1.sales_drive which should have the same value as the one one we've set from the class itself
  • We'll change the value from an instance: item_1.sales_drive = 0.40
  • Checking the value from another instance item_2.sales_drive

Full code:

class Product:
    '''
    A Product class that has a name, model, price, and vehicle_type.
    Here we have added a class attribute that can be retrieved by the class itself, but also by the instances.
    In this example, we will change the class attribute.
    '''
    # class attribute, discount rate
    sales_drive = 0.12

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # calculate the net_price for each instance
    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    # instance method that calculates the discounted price
    def apply_discount(self):
        return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

print('Product class sales_drive:', Product.sales_drive  * 100,  '%')

Product.sales_drive = 0.30  # changing the class attribute for the class
print('Product class sales_drive ( a new value):', Product.sales_drive  * 100,  '%')
print('item_1 sales_drive: ', item_1.sales_drive * 100, '%')


item_1.sales_drive = 0.40  # changing the class attribute for the instance
print('item_1 sales_drive (after instance change): ', item_1.sales_drive * 100, '%') 


print('Product class sales_drive', Product.sales_drive  * 100,  '%') # class attribute
print('item_2 sales_drive: ', item_2.sales_drive * 100,  '%') # the class attribute is not changed for the instance

Output:

Product class sales_drive: 12.0 %
Product class sales_drive ( a new value): 30.0 %
item_1 sales_drive:  30.0 %
item_1 sales_drive (after instance change):  40.0 %
Product class sales_drive 30.0 %
item_2 sales_drive:  30.0 %

As expected, the values are different and the reason for that is, the item_1.sales_drive = 0.30 has become an instance variable and not the Class attribute.

We have NOT changed the class attribute from an instance.

List all instances created from a Class

In this section, we will explore several ways how to list all the instances that have been created from the class.

There are several ways, but we will start by writing our own solution before looking into existing built-in functions.

One way is to create a list and append each instance to that list at the time we're creating an instance.

We'll create an empty list as the class attribute like the following:

class_instances = []

Then we'll append  instances to it in our __init__() method.

Product.class_instances.append(self)
 
So the code would look like this:
class Product:
    '''
    devoriales.com
    name: List all instances created from a Class
    A Product class that has a name, model, price, and vehicle_type.
    Here we have added a class attribute that will keep the list of instances been created.
    '''
    # class attribute, discount rate
    sales_drive = 0.12
    # list of all instances been created
    class_instances = []

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

        # appending each instance to the class list
        Product.class_instances.append(self)

    # calculate the net_price for each instance

    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    # instance method that calculates the discounted price
    def apply_discount(self):
        return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

# print all instances created from the Class Product by using list comprehension
print([x.name for x in Product.class_instances]) # list comprehension to print the name of each instance

Output:

['Tesla', 'BMW', 'Volvo']

Magic Method - __repr__()to list all instances

 There is, fortunately, an easier way to print out the result of our class list. After all...this is Python. 

So let's introduce a new dunder method called __repr__() which as the name imposes, represents a string output of our instances.

This method will always return the string representation of the instances belonging to a class


 __repr__() function returns the object representation in string format.

It should return the string in a format that can be used to instantiate the object again.

The reason for mentioning "should" is because it's up to us to define the formatting. 

Python __repr__() function returns the object representation in string format. This method is called when repr() function is invoked on the object. If possible, the string returned should be a valid Python expression that can be used to reconstruct the object again.

What is the difference between __repr__() and __str__() functions?

While __str__() should be used to return a human friendly information to the user, the __repr__() function should return a representaion that can be used to reconstruct the object. 

Please note that if you have both functions defined inside your class, only __str__() function will be returned if you call for the instance like:

print(instance_1)

What you can do if you want to get both returned is to call the built-in functions direcrtly from the instance:

print(item_1.__str__())
print(item_1.__repr__())
 
Also, we can get the correct formatting if we now call for the items in the class_instances list, which is also a class attribute.

We will add the following method to our class

# special method used to represent a class’s objects as a string
    def __repr__(self) -> str:
        return f"Product(name={self.name}, model={self.model}, price={self.price})"

and now we could just print our list again, but we still need to call for the  class_instances list, created as a class attribute.

Full code:
class Product:
    '''
    devoriales.com
    name: List all instances created from a Class
    A Product class that has a name, model, price, and vehicle_type.
    __repr__  dunder method
    '''

    # class attribute, discount rate
    sales_drive = 0.12
    # list of all instances been created
    class_instances = []

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

        # appending each instance to the class list
        Product.class_instances.append(self)

    # calculate the net_price for each instance

    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    # instance method that calculates the discounted price
    def apply_discount(self):
        return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)

    # def __str__(self) -> str:
    #     return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"

    def __repr__(self) -> str:
        return f"Product(name={str(self.name)}, model={self.model}, price={self.price})"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)



print(item_1.__repr__()) # print the __repr__ dunder method
print(Product.class_instances) # print the list of all instances created from the Class Product

Output:

Product(name=Tesla, model=3, price=55000)
[Product(name=Tesla, model=3, price=55000), Product(name=BMW, model=530, price=45000), Product(name=Volvo, model=s90, price=39000)]

gc - Garbage Collector Module

There is a third way of getting the instances from a class and for that, we will use a module called gc which stands for the garbage collector.

The garbage collector (GC) provides automatic memory management. As we know, all objects that we define are kept in memory.

We can use a gc module that can provide us with the information about our objects (classes and instances)


gc module

From the official documentation:

With gc you can use the incoming and outgoing references between objects to find cycles in complex data structures

gc keeps track of all references between the objects.


The following code snippet shows how we can use the gc module to get information about the instances of our Product class.

To be able to use gc, we need to import the module. Example:

import gc

# with gc module, we can get all instances of a class
for inst in gc.get_objects(): # loops over all objects in garbage collector
    if isinstance(inst, Product): # condition to find instances of Product object
        print(inst) # print out the instance

Let's see this in action, we will get all the instances from the garbage collector: 

import gc  # garbage collector


class Product:
    '''
    devoriales.com
    path: part_1/representations/rep_2.py
    A Product class that has a name, model, price, and vehicle_type.
    In this example, we will use gc module to see how many instances are created, from the memory perspective.
    '''
    # class attribute, discount rate
    sales_drive = 0.12
    # list of all instances been created
    class_instances = []

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        assert price > 0, 'The price has to be greater than zero'
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

        # appending each instance to the class list
        Product.class_instances.append(self)

    # calculate the net_price for each instance

    def calculate_net_price(self):
        self.vat = 0.05 if self.name == 'Tesla' else 0.08  # if expression
        # returns net price and VAT individually
        return ((self.vat * self.price + (self.price)), self.vat*100)

    # instance method that calculates the discounted price
    def apply_discount(self):
        return self.calculate_net_price()[0] - (self.calculate_net_price()[0] * Product.sales_drive)

    def __str__(self) -> str:
        return f"Make: {self.name}, Model: {self.model}, Price:{self.calculate_net_price()}, VAT: {self.vat * 100 }%"

    def __repr__(self) -> str:
        return f"Product(name={self.name}, model={self.model}, price={self.price})"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

# gc is a module that provides access to the garbage collector for reference cycles.
for inst in gc.get_objects():  # loops over all objects in garbage collector
    if isinstance(inst, Product):  # condition to find instances of Product object
        print(inst.__repr__())  # print out the instance

Output:

Product(name=Tesla, model=3, price=55000)
Product(name=BMW, model=530, price=45000)
Product(name=Volvo, model=s90, price=39000)

Summary - how to get instances from a class 

As we could see, we were able to get all the instances from the Product class in several ways:

  • appending items to a class list, class_instances, and print it out as a regular list
  • __repr__() magic method returns the object representation in string format.
  • use the gc module to find all instances that belong to a specific class

Class Methods

In this section, we will learn what the class methods are and how we can use them.

Class methods does not accept self parameter as instance methods. Instead it takes cls parameter when we call it. 

We define a class method by using a decorator called @classmethod

The class object itself is passed as the parameter to the class method (keyword: cls), similar to the keyword self

A class method is bound to a class and not to the instance of the class. A class method can only access class variables.

Class methods can modify the class state by changing a value of a class variable that applies to all class objects using the same variable.

Class methods can't modify an object's instance state.

You can access class methods from instances as well, but it's not a common or recommended way of doing it

Class methods can also be used as alternative constructors, which means we can instantiate instances via class methods instead of __init__() method


❗the cls keyword refers to the class itself. When we use cls keyword, we can access class attributes only, not instance attributes. 


In the following example, we will create a class method called,  get_class_attributes() We will also print out the cls (the keyword that represents the class itself) and the class attribute, sales_drive

class Product:
    '''
    devoriales.com
    path: part_1/class_methods/code_2.py
    A Product class that has a name, model, price, and vehicle_type.
    We define a class method and list the class attributes
    class method can be used to create factory methods.
    '''
    # class attribute
    sales_drive = 0.12
    class_instances = []

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:

        # Validation
        assert price > 0, f'The price has to be greater than zero'

        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

        # appending each instance to the class list
        Product.class_instances.append(self)

        # calculate the net_price for each instance

    def calculate_net_price(self):
        if self.name == 'Tesla':
            self.sales_drive = 0.15
        return f"Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type} Discount%: {self.sales_drive *100}%"

    # class method that returns class attribute sales_drive
    @classmethod
    def get_class_attributes(cls):
        print(cls)  # prints the type
        return cls.sales_drive

     # special method used to represent a class’s objects
    def __repr__(self) -> str:
        return f"Item ('Name: {self.name} Model: {self.model} Price: {self.price}')"


# call class method
print(Product.get_class_attributes())

 Output:

<class '__main__.Product'>
0.12

We have received the object type, which is the Product class and the class attribute.

Class method - context manager to read a csv file

In the following example, we'll simulate a database by creating a CSV file with the following content. Create a csv file with the following content:

name,model,price
'Tesla','3',55000
'BMW','530',45000
'Volvo','s90',39000
'Peugeot','508',35000

Context managers allow us to allocate and release resources. The most common way of doing this is with the with statement.

In the next section, we will read the content from a CSV by using a with statement.

We will create a class method, read_from_csv() to instantiate the instances.

Class methods cannot be called from instances since class methods expect the actual class as the first argument, which we have learned earlier. Ref here 

We can access a class method by calling it from outside the class, e.g., Vehicle.read_from_csv(data.csv)

As stated earlier, the class method can change the class state, and we will see this in action soon.
We will now read the items in the CSV file and assign attributes to each instance:

    # class object will be passed as the argument by saying cls instead of passing the instance itself
    @classmethod
    def read_from_csv(cls):   
        with open('data.csv', 'r') as file:
            reader = csv.DictReader(file)
            items = list(reader)
        
        for item in items:      
            Product(
            name=item.get('name'),
            model=item.get('model'),
            price=int(item.get('price')),
        )

 Full code:

import csv
import gc


class Product:
    '''
    devoriales.com
    path: part_1/class_methods/code_03.py

    A Product class that has a name, model, price, and vehicle_type.
    In this example, we have a class method that is accepting the class as an argument.
    The class method will read data with a content manager and instantiate the instances with the data from
    a csv file that will send to the classmethod. This is called an alternative constructor.
    we will use gc.get_objects() to see the instances created
    '''
    # class attribute
    sales_drive = 0.12
    class_instances = []

    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:

        # Validation
        assert price > 0, f'The price has to be greater than zero'

        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

        # appending each instance to the class list
        Product.class_instances.append(self)

        # calculate the net_price for each instance

    def calculate_net_price(self):
        if self.name == 'Tesla':
            self.sales_drive = 0.15
        return f"Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type} Discount%: {self.sales_drive *100}%"

    # class object will be passed as the argument by keyword cls instead of passing the instance

    @classmethod
    def read_from_csv(cls, file_name: str):
        # read the content from csv files
        with open(file_name, 'r') as file:
            reader = csv.DictReader(file)  # DictReader returns a dictionary
            items = list(reader)  # § convert the reader object to a list

        # instantiate the instances with the data from the csv file
        print('-' * 50, '\ninstances:')  # print a separator5
        for item in items:
            # instantiate instances. This is also refered to alternative constructor:
            Product(
                name=item.get('name'),
                model=item.get('model'),
                price=float(item.get('price'))
            )

    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount

    # special method used to represent a class’s objects
    def __repr__(self) -> str:
        return f"Name: {self.name} Model: {self.model} Price: {self.calculate_net_price()} Type: {self.vehicle_type} Discount%: {self.sales_drive *100}%"


# Change a class state
Product.change_sales_state(0.25)

Product.read_from_csv('data/data.csv')  # read from csv file and instantiate the instances via the classmethod
print('-' * 50, '\nrepresentation:')  # print a separator

# print the instances
for item in gc.get_objects():
    if isinstance(item, Product):
        print(item)

Output:

instances:
-------------------------------------------------- 
representation:
Name: 'Tesla' Model: '3' Price: Name: 'Tesla' Model: '3' Price: 55000.0 Type: car Discount%: 25.0% Type: car Discount%: 25.0%
Name: 'BMW' Model: '530' Price: Name: 'BMW' Model: '530' Price: 45000.0 Type: car Discount%: 25.0% Type: car Discount%: 25.0%
Name: 'Volvo' Model: 's90' Price: Name: 'Volvo' Model: 's90' Price: 39000.0 Type: car Discount%: 25.0% Type: car Discount%: 25.0%

It worked as expected, the items in the CSV file have been used to feed data to the new instances.

In the following section, we will write some code to change an attribute of a class.

Class methods can modify the class state

In this section, you'll learn how to change a class state by actually changing our discount via a class variable called sales_drive

In our previous example, it's set to 0.12, but at one particular weekend, we'll go a little bit crazy and give 25% to all sales except for Tesla cars where we gonna keep the same discount of 15%. 

So let's write another class method that will change the class variable sales_drive.  We'll also change the representation (__repr__()) to calculate the net price.

The following code will accept the class itself as the argument + the discount that we want to provide to our customers.

    # change class attributes
    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount

    # special method used to represent a class’s objects
    def __repr__(self) -> str:
        return f"Product(name={self.name}, model={self.model}, price={self.calculate_net_price()})"

 Full code:

'''
devoriales.com
path: part_1/class_methods/code_04.py

A Product class that has a name, model, price, and vehicle_type.
In this example, we have a class method that is accepting the class as an argument.
we will change the class attribute sales_drive with the classmethod change_sales_state
we will also print the instances onece before and once after the class attribute is changed
'''

class Product:
    # class attribute
    sales_drive = 0.12
    class_instances = []
    
    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        
        # Validation
        assert price > 0, f'The price has to be greater than zero'
        
        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        
        Product.class_instances.append(self) # appending each instance to the class list    
        

        # calculate the net_price for each instance
    def calculate_net_price(self):
        if self.name == 'Tesla':        
            self.sales_drive = 0.15
        return f"Name: {self.name} Model: {self.model} Original Price: {self.price} Type: {self.vehicle_type} Discount%: {self.sales_drive *100}% Discounted Price: {self.price + (self.price * self.sales_drive)} "

    # change class attributes
    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount
    
    # special method used to represent a class’s objects as a string
    def __repr__(self) -> str:
        return f"Item ('Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type}')"


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

# print all instances created from the Class Product by using list comprehension
print(item_1.calculate_net_price())
print(item_2.calculate_net_price())
print(item_3.calculate_net_price())

# Change a class state
Product.change_sales_state(0.30)

# print all instances after the class state has been changed
print('-' * 50, '\nafter-class-state-change:')  # print a separator
print(item_1.calculate_net_price())
print(item_2.calculate_net_price())
print(item_3.calculate_net_price())

Output:

Name: Tesla Model: 3 Original Price: 55000 Type: car Discount%: 15.0% Discounted Price: 63250.0 
Name: BMW Model: 530 Original Price: 45000 Type: car Discount%: 12.0% Discounted Price: 50400.0 
Name: Volvo Model: s90 Original Price: 39000 Type: car Discount%: 12.0% Discounted Price: 43680.0 
-------------------------------------------------- 
after-class-state-change:
Name: Tesla Model: 3 Original Price: 55000 Type: car Discount%: 15.0% Discounted Price: 63250.0 
Name: BMW Model: 530 Original Price: 45000 Type: car Discount%: 30.0% Discounted Price: 58500.0 
Name: Volvo Model: s90 Original Price: 39000 Type: car Discount%: 30.0% Discounted Price: 50700.0 

Let's examine the output.

  • We call the def calculate_net_price(self) a method that applies a discount on the item_1 instance only. 
  • We changed the class variable by calling our class method Product.change_state(0.30)
  • We call the def calculate_net_price(self) again and we can see that method now changes the discount on item_2 and item_3 instances.

As mentioned in the summary at the beginning of this section, you can also call a class method from an instance as well and change the state, but my personal opinion is that it's contributing to the confusion. But let's explore just to prove the point.

In the previous example, we changed the sales_drive by calling the class itself:

Product.change_sales_state(0.30)

But we could do it from an instance too: 

item_1.change_sales_state(0.30)
 
Full code:
'''
devoriales.com
path: part_1/class_methods/code_05.py

A Product class that has a name, model, price, and vehicle_type.
In this example, we have a class method that is accepting the class as an argument.
We will change the class attribute sales_drive via an instance instead of the class
'''
class Product:
    # class attribute
    sales_drive = 0.12
    class_instances = []
    
    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        
        # Validation
        assert price > 0, f'The price has to be greater than zero'
        
        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        
        Product.class_instances.append(self) # appending each instance to the class list
              
        

        # calculate the net_price for each instance
    
    def calculate_net_price(self):
        if self.name == 'Tesla':        
            self.sales_drive = 0.15
        return f"Name: {self.name} Model: {self.model} Original Price: {self.price} Type: {self.vehicle_type} Discount%: {self.sales_drive *100}% Discounted Price: {self.price - (self.price * self.sales_drive)} "


    # change class attributes
    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount
    
    # special method used to represent a class’s objects as a string  
    def __repr__(self) -> str:
        return f"Item ('Name: {self.name} Model: {self.model} Price: {self.price} Type: {self.vehicle_type}')"



# Change a class state 
# Product.change_sales_state(0.30)


item_1 = Product('Tesla', '3', 55000)
item_2 = Product('BMW', '530', 45000)
item_3 = Product('Volvo', 's90', 39000)

# print all instances created from the Class Product by using list comprehension
print(item_1.calculate_net_price())
print(item_2.calculate_net_price())
print(item_3.calculate_net_price())

# Change the class attribute value from an instance
item_1.change_sales_state(0.30)

print('-' * 50, '\nafter-class-state-change-via-instance:')  # print a separator


# print all instances again created from the Class Product by using list comprehension
print(item_1.calculate_net_price())
print(item_2.calculate_net_price())
print(item_3.calculate_net_price())

Output:

Name: Tesla Model: 3 Original Price: 55000 Type: car Discount%: 15.0% Discounted Price: 46750.0 
Name: BMW Model: 530 Original Price: 45000 Type: car Discount%: 12.0% Discounted Price: 39600.0 
Name: Volvo Model: s90 Original Price: 39000 Type: car Discount%: 12.0% Discounted Price: 34320.0 
-------------------------------------------------- 
after-class-state-change-via-instance:
Name: Tesla Model: 3 Original Price: 55000 Type: car Discount%: 15.0% Discounted Price: 46750.0 
Name: BMW Model: 530 Original Price: 45000 Type: car Discount%: 30.0% Discounted Price: 31500.0 
Name: Volvo Model: s90 Original Price: 39000 Type: car Discount%: 30.0% Discounted Price: 27300.0 

The output is exactly the same as before. The confusing part is, that one may think that we have changed the discount of the item_1 instance, but we have actually called a class method using an instance, similar to when we did the same with the class, which is more concise. 

In summary, the following statements do the same thing, both change a class attribute's value:
 
Product.change_sales_state(0.30)

same as:

item_1.change_sales_state(0.30)

 

Alternative Constructors

We have learned how to create instances by using the magic method called __init__ method

example:

class Product:

    def __init__(self, name: str, model, price: float) -> None:
        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price

Once we have defined a class, we can instantiate the instances (create objects) of that class:

item_1 = Product('BMW', '530', 45000)
item_2 = Product('Volvo', 's90', 39000)

Some applications  need to support several ways to instantiate the instances.
It could be that the users need to  use a specific format while creating objects like comma separated data format, example: Tesla,5, 550000

Sometimes you also need to have multiple alternative constructors for different use cases. We have learned that we should use the __init__() magic method to initiate and construct our instances.


❗You need to provide all the attributes that are defined in the __init__() class method to be able to create a class object. 


In this section, we will learn how to use class methods as alternative constructor by:

  • writing a constructors using the built-in @classmethod decorator

Class method As An Alternative Constructor 

When we define a class instance, instead of passing the parameters to a constructor, we just send the parameters to a method defined as a class method.

That method accepts a keyword called clsand the whole class object gets sent to it. Along with the class we send in, we also pass the attributes as parameters. 
The class method will then pass the arguments to the constructor and instantiate the instance. I like to see it as a backdoor. 

The following diagram tries to show how this works:

 

  1. To define a class method:  you simply annotate it with a decorator called @classmethod
  2. Create an instance via an alternative constructor: but we call the class method instead of just passing the arguments to the constructor. We get our instance working in a similar way to when we pass the arguments to the constructor __init__() method directly. Since the class also involves the constructor method, we will get our instance instantiated. 
  3. The cls keyword: The class method accepts the class itself via the keyword clstogether with any other arguments and returns the class back
  4. Instantiate variables: The variables get instantiated inside the class. This happens by the class method returning the class with the variables (will be shown in the next example)
  5. Enforce standard: We force the user to use a method to get what they want, not passing the object instance directly.

In the following example, we have written a class method called alternative_constructor()which accepts the class itself (via cls keyword) and keyword argument (**kwargs), which means it will accept a dictionary that we can unpack to different variables that will become instance variables. 

# will initialize the instance attributes as an alternative constructor
@classmethod
def alternative_constructor(cls, **data):
    name, model, price = data['name'], data['model'], data['price'] # unpacking the kwargs - Keyword Arguments
    
    return cls(name, model, price)

if we add it to our Product class, it will look like the following: 

class Product:

    '''
    devoriales.com
    path: part_1/class_methods/code_06.py
    Alternative constructor to create an instance from a string
    '''

    # constructor , will initialize the instance attributes
    def __init__(self, name: str, model: str, price: float, vehicle_type: str = 'car') -> None:

        # Validation
        assert price > 0, 'The price has to be greater than zero'

        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type

    # will initialize the instance attributes as an alternative constructor
    @classmethod
    def alternative_constructor(cls, **data):
        # unpacking the kwargs - Keyword Arguments
        name, model, price = data['name'], data['model'], data['price']

        return cls(name, model, price)

    def __str__(self) -> str:
        return f"{self.name}, {self.model}, {self.price}"


item_1 = Product('Tesla', '3', 55000)  # instance of Product
# instance of Product via alternative constructor
item_2 = Product.alternative_constructor(name='Tesla', model='3', price=55000)

print(item_1)  # created by the constructor __init__ method of the class
print(item_2)  # created by the alternative constructor

Output:

Tesla, 3, 55000
Tesla, 3, 55000

The result is the same for both instances. We were able to instantiate both instances in different ways, the first one was instantiated via the __init__() method and the second instance was instantiated via the alternative constructor, a class method we called alternative_constructor() and both worked.

I think you're wondering why you would use a class method to instantiate instances instead of going via the constructor method and that's a good question.

This will come in the upcoming examples, but for now, just imagine if you would like to provide a way for the sales team to send in a list of products as a JSON and you also want to do other operations like applying the tax to each product and calculate the net price. You don't want that to happen in 2 steps like first instantiate and then perform the calculation. 

Challenges - alternative constructors

❓Challenge id: 1770706 - Create an instance from a comma-separated data format

template path: 

part_1/challenges/challenge_1770706.py

The company's sales force wants us to support the following format when instantiating new instances in our application

Tesla,3,55000

Spend 5 minutes on how you would solve this via an alternative constructor. Don't use google straight away, think.....and it's ok to fail....just 5 minutes and then go on:

Solution

solution path: 

part_1/challenges/solution_1770706.py

This solution could be pretty simple like the following. We will use the split built-in method to split the data. We could modify our previous class method a bit:

# will initialize the instance attributes as an alternative constructor
@classmethod
def alternative_constructor(cls, data):
    data = data.split(',') # splitting the string into a list       
    name, model, price = data[0], data[1], int(data[2]) #unpacking the list        
    return cls(name, model, price) # returning the class object that becomes an instance object

 The full code would look like the following:

'''
    devoriales.com
    path: part_1/challenges/solution_1770706.py
    
    solution to challenge 1770706
    The company's sales force wants us 
    to support the following format when 
    instantiating new instances in our application
'''


class Product:

    # constructor method
    '''
    write a constructor method that takes two arguments: product and price
    '''

    def __init__(self, name: str, model: str, price: int) -> None:
        self.name = name
        self.model = model
        self.price = price

    # class method to create an instance from a string - alternative constructor
    @classmethod
    def alternative_constructor(cls, data: str):
        name, model, price = data.split(',')
        return cls(name, model, int(price))

    # instance method __repr__ to print the instance
    def __repr__(self) -> str:
        return f"{self.name}, {self.model}, {self.price}"


item_1 = Product.alternative_constructor('Tesla,3,55000')

print(item_1)

Output:

Tesla, 3, 55000
  • We use an alternative constructor to instantiate our instance
  • The data is in a comma-separated format 
  • The alternative constructor splits and unpacks the data into variables
  • The alternative constructor method returns the class with the variables which become instantiated 
  • We get our instance correct
  • We use __repr__ method  to print the instance 

❓Challenge id: 1962319 - Create instances from a JSON object

template path: part_1/challenges/challenge_1962319.py

Our sales team provides us with a JSON object that contains all new products we need to add to our sales system.

What we need to do is to create an instance of each item in the JSON file provided to us. 

The sales team has provided a file, product.json, with some new products to be added to our application. 

Your task is to instantiate class instances based on the products provided in the product.json file.

product.json

 [
    {
        "name": "Opel",
        "model": "Astra",
        "price": 28000
    },
    {
        "name": "Peugeot",
        "model": "208",
        "price": 25000
    },
    {
        "name": "Mercedes",
        "model": "300e",
        "price": 69000
    },
    {
        "name": "Polestart",
        "model": "2",
        "price": 54000
    },
    {
        "name": "Tesla",
        "model": "3",
        "price": 54000
    }
    
]

Spend 15 minutes on some idea of how you would solve this challenge via an alternative constructor. This challenge may be a little bit challenging, but it's not too bad. Once again, try to think a bit, but don't give up. And remember, this can be solved in different ways.

A Solution to 1962319

We want to read all the data that exists in the provided file.  We need to import the json module to be able to manage JSON data.

import json

We will write a small code to open the product.json file and get the product information.  We will use with statement to handle our file to read the content:

with open('products.json') as prod_file:
    data = json.load(prod_file)

In our Product class, we'll write a simple @classmethod that will be used as an alternative constructor.

What this method will expect is data containing name, model, and price key-value pairs.


❗The alternative constructor needs the same attributes as specified in the __init__() constructor method.

if you're not familiar with unpacking the data, the line name, model, price = data['name'],  data['model'],  data['price']is unpacking the data to each variable (name, model, and price) from the item in the dictionary (JSON) we get from the file.


❗You can learn more about *args and **kwargs in Learn *args and **kwargs in Python


class method - unpacks the attribute required by the constructor:

    # to import the product items 
    @classmethod
    def import_objects(cls, data):
        name, model, price = data['name'],  data['model'],  data['price']  # unpacking the kwargs - Keyword Arguments    
        return cls(name, model, price) # returns unpacked variables that will be used to instantiate an instance

The class method above unpacks each item from the parameter we provide and assign it to the corresponding variable. 

The reason is, that the class method, in our case, returns back the whole class object. That's why class methods are sometimes referred to as alternative constructors.

To be able to keep track of the ordinals in the instance name (naming convention will be item_1, item_2, item_3 and so on), we will keep track of those in our class by increasing the number by 1 with the following expression Product.item += 1 each time we create an instance:

import json  # required to manage json data

class Product:
    # class attribute
    sales_drive = 0.12
    class_instances = []
    counter = 0
    
    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        
        # Validation
        assert price > 0, 'The price has to be greater than zero'

        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        self.item = Product.counter
        Product.counter += 1 # increment the item counter

The __repr__() magic method is returning back a representation for each instance. Note that there is a net price that calls an instance method, calucalte_net_price() that will calculate the end price to the customer. It applies the tax rate and withdraws the discount amount from the price. Tesla cars get more tax reduction, which is 15
% instead of only 12%:

# reporesentation of the instance
def __repr__(self) -> str:
    return f"Name: {self.name}, Model: '{self.model}', Price: {self.price}, Net Price {self.calculate_net_price()}"

We will add the with open statement to read the JSON data from the provided file. 

Pay attention that we are looping over the data and every time  we also check the current ordinal by calling the class attribute called item

prefix = str('item') # e.g. item
instance_name = f"{prefix}_{str(Product.counter)}" # e.g. item_1, item_2 and so on...

We will append all the results as dictionaries in a list:

# keep the instances in a list as dictionaries
my_products = []

The with open code block that will read the file and instantiate our instances on the fly.

    # keep the instances in a list as dictionaries
    my_products = []

    with open('products.json') as prod_file: # open the json file with the 
        data = json.load(prod_file)
        for x in enumerate(data):
            prefix = str('item')
            instance_name = f"{prefix}_{str(Product.counter)}"
            
            my_products.append(
                {
                'instance_name': instance_name,
                'id': str(id(data[x[0]])),
                'data': Product.import_objects(data[x[0]]),
                
                
                }
                )

Here is the full working code:

import json


class Product:
    # class attribute
    sales_drive = 0.12
    class_instances = []
    counter = 0
    
    def __init__(self, name: str, model, price: float, vehicle_type: str = 'car') -> None:
        
        # Validation
        assert price > 0, 'The price has to be greater than zero'

        # Assigning to the instance object via self
        self.name = name
        self.model = model
        self.price = price
        self.vehicle_type = vehicle_type
        self.item = Product.counter
        Product.counter += 1 # increment the item counter

              
        # print(id(self)) # check the id of the instance

    # instance method - calculate the net_price for each instance    
    def calculate_net_price(self):
        if self.name == 'Tesla':        
            self.sales_drive = 0.15
        return self.price - (self.price * self.sales_drive)

    # staticmethod - silly method that checks if a car is electric
    @staticmethod
    def is_ev(name:str):        
        return name.lower() in {'tesla', 'electric', 'ev'} # fast membership testing

    # classmethod - change class attribute
    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount
        
    # to import the product items 
    @classmethod
    def import_objects(cls, data):
        name, model, price = data['name'],  data['model'],  data['price']  # unpacking the kwargs - Keyword Arguments    
        return cls(name, model, price) # returns unpacked variables that will be used to instantiate an instance
    
    def __repr__(self) -> str:
        return f"Name: {self.name}, Model: '{self.model}', Price: {self.price}, Net Price {self.calculate_net_price()}"
    
    # def __str__(self) -> str:
    #     print('str')
    #     return f"Name: {self.name}, Model: '{self.model}', Price: {self.price}"
    


# this instance we're creating manually



def main():

    # keep the instances in a list as dictionaries
    my_products = []

    with open('products.json') as prod_file: # open the json file with the 
        data = json.load(prod_file)
        for x in enumerate(data):
            prefix = str('item')
            instance_name = f"{prefix}_{str(Product.counter)}"
            
            my_products.append(
                {
                'instance_name': instance_name,
                'id': str(id(data[x[0]])),
                'data': Product.import_objects(data[x[0]]),               
                }
                )

        
    # print out the instance net price
    for product in my_products:
        print(product)

if __name__ == '__main__':
    main()

 Output:

{'instance_name': 'item_0', 'id': '4472853568', 'data': Name: Opel, Model: 'Astra', Price: 28000, Net Price 24640.0}
{'instance_name': 'item_1', 'id': '4472855104', 'data': Name: Peugeot, Model: '208', Price: 25000, Net Price 22000.0}
{'instance_name': 'item_2', 'id': '4472854912', 'data': Name: Mercedes, Model: '300e', Price: 69000, Net Price 60720.0}
{'instance_name': 'item_3', 'id': '4472850880', 'data': Name: Polestart, Model: '2', Price: 54000, Net Price 47520.0}
{'instance_name': 'item_4', 'id': '4472863168', 'data': Name: Tesla, Model: '3', Price: 54000, Net Price 45900.0}

Pay attention to the price difference between item_3 and item_4. They have the same starting price, but the discount for Tesla cars are greater due to tax reduction of 15%.


❗we will use enumerate a built-in function.

enumerate(iterable, start=0)
  • Iterable: any object that supports iteration
  • Start: the index value from which the counter is to be started, by default it is 0

In essence, what this code does is the following:

  • reads the content of the product.json file and stores it in the data variable
  • Enumerates items in the data variable.
    • we need to take into account the number of already existing instances that we can get from one more thing that has been added to the class which is counter which helps us to count the number of existing instances
  • we check if the instance already exists, e.g. Does item_1 exist? If yes, we increase the suffix by 1 since we don't want to overwrite an existing instance.

Method class overloading with singledispatchmethod decorator

Assume that we want to validate data type of an argument, and based on the type, we want to dispatch an argument to the right method.

The following diagram is representing a class that is accepting different data types:

Imagine that we have a dispatcher that is looking at the argument and its type. Based on the type, the right implementation will take care of it.

Still, we will use the same method name so we don't call for different methods. A specific algorihm shall take care of this for us. This is called method overloading and for that, we will use singledispatchmethod decorator provided in the func tool module.


From official doc
❗The functools module is for higher-order functions: functions that act on or return other functions. In general, any callable object can be treated as a function for the purposes of this module.

Example 1 - Guess Game

In the following example, we have a guess game. Based on the data type of the argument, we should get the right data type. As shown in the diagram, we have a dipatch method that will accept any argument and dispatch it to the right method based on the data type. If no appropriate method is found, our dispatch method will throw a message telling us that it can't recognize the data type. 
 
Guess game:
from functools import singledispatchmethod

'''
devoriales.com
path: part_1/method_overload/method_overload_2.py
category: code
domain: python
subdomain: method overload
level: intermediate

description:
singledispatchmethod decorator example with a generic method that takes an argument and based on type of argument it will call the appropriate method.
'''

class GuessWhatType:

    # generic method that takes an argument of any type and based on the type of the argument it will call the appropriate method.
    @singledispatchmethod
    def guess(self, arg):
        print("I don't know what this is")

    # decorator for the guess method that takes an argument of type int
    @guess.register(int)
    def _(self, arg: int):
        return f'{arg} is an integer'

    # decorator for the guess method that takes an argument of type str
    @guess.register(str)
    def _(self, arg: str):
        return f'{arg} is a string'

    # decorator for the guess method that takes an argument of type list
    @guess.register(list)
    def _(self, arg: list):
        return f'{arg} is a list'

    # decorator for the guess method that takes an argument of type float
    @guess.register
    def _(self, arg: dict):
        return f'{arg} is a dict'


handler = GuessWhatType()

print(handler.guess(5))  # 5 is an integer

print(handler.guess('five')) # five is a string

print(handler.guess([10,20,30,40,50])) # [10, 20, 30, 40, 50] is a list

print(handler.guess({'a': 1, 'b': 2, 'c': 3})) # {'a': 1, 'b': 2, 'c': 3} is a dict

print(handler.guess(5.0)) # Can't guess the type of 5.0
  • @singledispatchmethod generic method that takes an argument of any type and based on the type of the argument it will call the appropriate method.
  • @guess.register(int) decorator for the guess method that takes an argument of type integer
  • @guess.register(str) decorator for the guess method that takes an argument of type string
  • @guess.register(list) decorator for the guess method that takes an argument of type list
Output:
 
5 is an integer
five is a string
[10, 20, 30, 40, 50] is a list
{'a': 1, 'b': 2, 'c': 3} is a dict
Can't guess the type of 5.0

Pretty cool, huh. We've written our own data validator. 

 Example 2 - Incident Handler for our auto moto shop

Business problem:

Our automotive business has expanded and started a repair shop for our customers. We want to be able to take care of the repairs, and based on the reported incidents, the right repair shall be performed.

Requirements:

  • Each incident type has its own class
  • Each incident shall have a unique ID for tracking purposes
  • The customer shall get a note when the incident has been registred
  • The customer shall get a notification when the repair is done

The code will implement the following:

  • class Incident(): a class that handles incidents, takes note of the incident type
  • class Engine(Incident): a class that handles Engine incidents
  • class Breaks(Incident): a class that handles Breaks incidents
  • class Suspension(Incident): a class that handles Breaks incidents
  • class IncidentHandler(): a class that handles dispatching of the incidents

 

from functools import singledispatchmethod
from random import randint
'''
devoriales.com
path: part_1/method_overload/method_overload_2.py
category: code
domain: python
subdomain: classes
level: intermediate

description:
A IncidentHandler class that has a handle method that takes an incident as an argument.
The handle method is decorated with the @singledispatchmethod decorator.
The handle method is a generic method that takes an incident as an argument.
The handle method is decorated with the @handle.register decorator.
Each incident has a unique method that is called when the handle method is called.

'''
# a class that handles incidents
class Incident:
    
    def __init__(self, incident_type: str, incident_description: str) -> None:
        self.incident_type = incident_type
        self.incident_description = incident_description
        self.incident_id = randint(1000, 9999)
        print(f"New incident created: {self.incident_type}, {self.incident_description}, {self.incident_id}")

    def __str__(self) -> str:
        return f"Type: {self.incident_type}, Description: {self.incident_description}, IncidentID: {self.incident_id}"

# a class that handles Engine incidents
class Engine(Incident):
    def __init__(self, incident_type: str, incident_description: str, engine_type: str) -> None:
        super().__init__(incident_type, incident_description)
        self.engine_type = engine_type

    def __repr__(self) -> str:
        return f"Engine(incident_type={self.incident_type}, incident_description={self.incident_description}, engine_type={self.engine_type})"

# a class that handles Breaks incidents
class Breaks(Incident):
    def __init__(self, incident_type: str, incident_description: str, breaks_type: str) -> None:
        super().__init__(incident_type, incident_description)
        self.breaks_type = breaks_type
    
    def __repr__(self) -> str:
        return f"Breaks(incident_type={self.incident_type}, incident_description={self.incident_description}, breaks_type={self.breaks_type})"



class Suspension(Incident):
    def __init__(self, incident_type: str, incident_description: str, suspension_type: str) -> None:
        super().__init__(incident_type, incident_description)
        self.suspension_type = suspension_type

    def __repr__(self) -> str:
        return f"Suspension(incident_type={self.incident_type}, incident_description={self.incident_description}, suspension_type={self.suspension_type})"

# a class that handles dispatching of the incidents
class IncidentHandler:
    # decorator that converts a function into a single-dispatch generic method.
    @singledispatchmethod
    def handle(self, incident):
        pass

    # decorator for the handle method that takes an argument of type Suspension
    @handle.register  # decorator for the handle method
    def _(self, incident: Suspension):
        print('Incident fixed for: ', incident)

    # decorator for the handle method that takes an argument of type Breaks
    @handle.register
    def _(self, flood: Breaks):
        print('Incident fixed for: ', incident)

    # decorator for the handle method that takes an argument of type Engine
    @handle.register
    def _(self, incident: Engine):
        print('Incident fixed for:', incident)


# create an instance of the IncidentHandler class
handler = IncidentHandler()

# incident of different types
incident_1 = Suspension('Suspension', 'Suspension is broken', 'Front Suspension')
incidents_2 = Breaks('Breaks', 'Breaks are broken', 'Front Breaks')
incident_3 = Engine('Engine', 'Engine is broken', 'V8 Engine')

# call the handle method with an incident
for incident in [incident_1, incidents_2, incident_3]:
    handler.handle(incident)

Output:

New incident created: Suspension, Suspension is broken, 8325
New incident created: Breaks, Breaks are broken, 4687
New incident created: Engine, Engine is broken, 4634
Incident fixed for:  Type: Suspension, Description: Suspension is broken, IncidentID: 8325
Incident fixed for:  Type: Breaks, Description: Breaks are broken, IncidentID: 4687
Incident fixed for: Type: Engine, Description: Engine is broken, IncidentID: 4634

The business requirements have been fulfilled by our code:

  • Each incident type is represented by own class
  • The dispatch method is dispatching each argument based on the data type. 
  • Each incident gets registred with correct class
  • Each incident gets its own unique ID

 

Now we have learned how we can use @singledispatchmethod from the functool module and dispatch theargument to the right method based on the data type.

We've looked at two different implementations:

  1. dispatch to a method inside a single class
  2. dispatch to other classes

Static Methods

Static methods are defined by a decorator @staticmethod

Static methods in a class are not related to an instance nor do they require to be called from an instance.

It should still make sense to have them in the class, but technically they are not dependent on the class. I see them as stand-alone methods.

The following static method is doing something really silly, it checks if a car is electric or not. For that, we have some keywords that should return True.
Anything else returns a False boolean value. 

We're using a data type of type set for fast membership testing, you can read more here

    # silly method that checks if a car is electric
    @staticmethod
    def is_ev(name:str):        
        return name.lower() in {'tesla', 'electric', 'ev'} # fast membership testing

Class method vs Static Method - When To Use

We will use the following code snippet to summarize the differences between class methods and static methods:

    # silly method that checks if a car is electric
    @staticmethod
    def is_ev(name:str):        
        return name.lower() in {'tesla', 'electric', 'ev'} # fast membership testing

    # change class attribute
    @classmethod
    def change_sales_state(cls, amount):
        cls.sales_drive = amount

    # to import our product items via json 
    @classmethod
    def import_objects(cls, data):
        name, model, price = data['name'],  data['model'],  data['price']        
        return cls(name, model, price)
  •  Static method is_ev() could have been placed outside the class ((as a standalone function) and would work just fine.
  •  Static methods do not require an instance to be instantiated. Still, it's good to keep them structured in a class if it makes sense.
  • A recommendation is to create static methods that are relevant to the class itself, like is_ev()
  • Class methods are used to change a class attribute as we do  change_sales_state() by changing the discount rate.
  • Class methods can be used as additional constructors, which we did with import_objects() method. With this method we instantiated instances and provided data from a csv file. 
  • Class and static methods can be called from the class itself, and from an instance even if the latter is more confusing and I don't recommend it.

Attribute Types

In Part 2, we will learn the OOP concept called Encapsulation where the protected and private attributes shine. As preparation for that, we will get introduced to three kinds of attributes:

  1. public attribute
  2. private attribute
  3. protected attribute

Public Attributes

In Python, all objects and attributes in a class are public by default. You can access both class and instance attributes outside the class. 

To show that, we can create a simple People class:

class People:

    def __init__(self, gender, name, age:int) -> None:
        self.gender = gender # public attribute 
        self.name = name # public attribute 
        self.age = age # public attribute 

Now, we're going to create an instance that we can call human_1

We could access any of the attributes by simply calling it:

human_1 = People('woman', 'tina', 44) # instantiate an object
print(human_1.gender) # access to an attribute

Output:

woman

This is a public attribute, and we have been working with those so far.

You can access all public attributes outside the class, via the class instance.

Private Attributes

If we want to make attributes invisible and not directly accessible from outside the class, we can set double underscore to the attribute()s. 

In the following example, we are defining self.__age as a private attribute. 
If we try to access the __age attribute, we'll get an AttributeError.

class People:
    def __init__(self, gender, name, age:int) -> None:
        self.gender = gender
        self.name = name
        self.__age = age # private attribute

human_1 = People('woman', 'tina', 44)

# printing age
print(human_1.__age)

Output:
AttributeError: 'People' object has no attribute '__age'

We are not able to get the __age attribute. There is a way of getting the age attribute with getter and this will be covered in Part 2.

But there is a so-called naming mangling of objects that have double underscores that will be callable with _object._class__attribute

So if change, which gave us an error,  print(human_1._People__age) we would get the result:

print(human_1._People__age)

Output:
44
 

Python doesn't have public OR private attributes in real terms. You can still access the data.

It's more of a naming convention of using single or double scores (protected or private) as the prefix in the attribute's names.

In the next article, we will learn how to use setter and getter methods to control access to private and protected attributes.


Protected Attributes

Protected attributes should only be used within the class and by the child classes, not outside those.

Python's way to make an instance attribute protected is to add a single underscore prefix to the attribute's name.

In the following example, we will create self._age a protected variable: 

class People:
    def __init__(self, gender, name, age:int) -> None:
        self.gender = gender
        self.name = name
        self._age = age # protected variable
        
    def __str__(self) -> str:
        return f'{self.name}, {self.gender}, {self._age}'

# instantiating an object
person_1 = People('male','Ben', 29 )

# printing age
print(person_1._age)

Output:

29

Not really what one would expect. Still, this is a convention and you should not break it. 

In this section, we have learned how to write public, private, and protected attributes in Python.

As you have noticed, those attributes are really not protected, but it's more about following a convention, you should not change private or protected attributes directly as we have done.

There are programming languages that provide protected attributes (access modifiers) for security reasons, but Python is not one of them.

The recommendation is to follow the convention.

In Part 2, we will learn how to write getter and setter methods to actually control the access to private and protected attribute values.
That is a recommended way and is pretty useful too. 

Summary

Great that you have come so far. We're halfway through and have learned:

  • What Python objects are. Everything is an object in Python, but we have learned how to define one ourselves via classes
  • We know what the __init__() magic method is, it's a constructor where we define an instance's attributes
  • What self keyword is and how to use it to define instance attributes
  • Class instances:
    • Instantiation of instances
    • How to assign attributes to instances
  • Instance methods:
    • self keyword and how to use them
    • how instance methods apply to class instances only
  • Class methods and how we can use those to:
    • change a class state/attributees
    • use class methods as alternative constructors
  • singledispatchmethod decorator from the functool module used for method classes overloading
  • Static methods and how those are used as stand-alone methods, like a function
  • Assert to verify the code
  • Kind of attributes:
    • public: by default, all instance attributes are public, directly accessible via instances
    • protected: double underscore as a prefix, not directly accessible via instances (which is not really true as we have learned). Only accessible inside the class and in the child classes.
    • private: prefixed with a single underscore. Shall not be directly changed from an instance. Only accessible inside the class.

The second part will be released soon

Keep up 🥁, see you in Part 2

About the Author

Aleksandro Matejic, Cloud Architect, began working in IT Industry over 20y ago as a technical consultant at Lindahl Rothoff in southern Sweden. Since then, he has worked in various companies and industries like Atea, Baxter, and IKEA having various architect roles. In his spare time, Aleksandro is developing and running devoriales.com, a blog and learning platform launched in 2022. In addition, he likes to read and write technical articles about software development and DevOps methods and tools. You can contact Aleksandro by paying a visit to his LinkedIn Profile.

Comments