Monday 13 May 2024

Working with classes in Python

 Background

In the last few posts, we saw how Python works, what are closures, decorators, etc. In this post, I am going to explain how to write a class in Python and work with it. As you might already be aware though Python is used as a scripting language we can use it as an Object-oriented programming language.

Writing classes in Python

Let's start with a simple class and how to use it.

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

    def __str__(self) -> str:
        return f"Employee with name {self.name} and age {self.age}"

e = Employee("Aniket", 32)
print(e)


The above code prints: Employee with name Aniket and age 32

Let's try to understand what the above code does.

  • First, we have defined a simple class Employee using a keyword class
  • Then we have defined two magic methods (We will see what magic methods are later, for now just imagine these are special methods that are created for some specific intended functionality, wherever you see a method starting with double underscores you know it is a magic method)
    • __init__ : __init__() method in Python is used to initialize objects of a class. It is also called a constructor. 
    • __str__: The __str__() method returns a human-readable, or informal, string representation of an object. This method is called by the built-in print() , str() , and format() functions. If you don't define a __str__() method for a class, then the built-in object implementation calls the __repr__() method instead.
  • Notice how the constructor takes 3 arguments
    • self: Every method of a class that is an instance method (& not a class/static method - more on this later) will always have self as 1st argument. It is a reference to the instance created itself (e instance in the case of above).
    • name & age: These are 2 new parameters that init takes which means when we want to create an instance of Emploee we need to pass name and age. These are our class instance variables.
    • Also, notice how we have provided hints for the type of arguments (E.g., the name being of str type) and return type of method (-> None). These are optional but good to have. These are called annotations and are used only as hints (Nothing is actually enforced at run time)
  • Lastly, we have just created an instance of Employee and printed it. Notice unlike Java you don't need to use a new keyword here. Since we defined our own implementation of __str__ it printed the same when we printed the object (Else it would have printed something like <__main__.Employee object at 0x000001CB20653050>).

Class variables and private methods

  • Unlike Java, we do not define the type of variables (It's dynamic). 
    • If you have declared variables inside __init__ using self then those are instance variables (specific to the instance you created).
    • If you declare variables outside __init__ then those belong to the class (are called class variables) and can be accessed as ClassName.variable.
  • Similarly, there are no access modifiers like Java
    • If you want a method or variable to be private just start it with a single underscore. 
      • E.g., _MY_PRIVATE_ID = "ABC"
      • Please note this is more of a convention and nothing is enforced at run time (similar to the hints annotations I explained above). If you see such variables/methods then you should use caution before you use them as public variables/methods.



Let's look at an example to understand class variables and private methods.
class Employee:
    _BASE_INCOME = 10000

    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age
        self.income = None

    def _calculate_income(self) -> int:
        return Employee._BASE_INCOME * self.age

    def get_income(self) -> int:
        return self._calculate_income()


e = Employee("Aniket", 32)
print(Employee._BASE_INCOME)
print(e._BASE_INCOME)
print(e.get_income())
print(e._calculate_income())

Above prints:
10000
10000
320000
320000

A couple of points to note here

  • _BASE_INCOME since it is defined outside __init__ is a class variable. It can be accessed using class - Employee._BASE_INCOME. As you can see this starts with an underscore and is a private variable.
  • _calculate_income is a private method defined to calculate income based on _BASE_INCOME and age. There is a public method get_inome exposed that uses the private method to compute the actual income.
  • As you can see even though both variables and methods mentioned above are private you can access them. That is because as I mentioned before python does not enforce these, it's the developers' responsibility to use these with caution.

Now the natural question is how do we add validations to ensure that the instance variables are of the correct type. Let's see that next.


Using Properties

If you have worked on Java before you know that you can have getters and setter for instance variables. In Python, you can do the same using properties. See the below code for example:

class Employee:
    _MAX_AGE = 150

    def __init__(self, age: int) -> None:
        self._age = None
        self.age = age

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        if value > Employee._MAX_AGE:
            raise ValueError(f"Age cannot be more that {Employee._MAX_AGE}, Passed: {value}")
        self._age = value


e = Employee(200)


This prints:

Traceback (most recent call last):
...
  File "C:\Users\Computer\Documents\python\test.py", line 15, in age
    raise ValueError(f"Age cannot be more that {Employee._MAX_AGE}, Passed: {value}")
ValueError: Age cannot be more that 150, Passed: 200

Process finished with exit code 1

Notice
  • How we are using a corresponding private variable(_name) to track the actual value stored internally and using the name as an interface to be used publically.
  • When you try to create an Employee instance by passing age as 200 it actually calls the setter method which checks it is more than 200 and throws a ValueError.

Lastly, let's see a compact way to write the classes using dataclass decorator.

Using the dataclass decorator

You can use the dataclass decorator to write a class compactly. See the following example.

from dataclasses import dataclass, field


@dataclass
class Employee:
    name: str
    age: int
    BASE_INCOME: int = field(init=False, repr=False)


e = Employee("Aniket", 33)
print(e)

Above prints: Employee(name='Aniket', age=33)

Note here

  • Notice the import of dataclass
  • Once you decorate your class with @dataclass you get a few things for free
    • It automatically creates instance variables with variables defined with annotation (name and age in the above case)
    • You do not need to define an __init__ method for this.
    • If you do not provide annotation hints then it automatically considers it as class variables.
    • If you want to use hints annotations and still want it to be a class variable then you can initialize it with the field method by passing init and repr arguments as False. Eg., in the above case BASE_INCOME is not a class variable and will not be used in init or repr mafic methods.

Related Links

No comments:

Post a Comment

t> UA-39527780-1 back to top