Classes and objects

Basic definitions

To put it simply, in object oriented programming, the code is structured around objects.

An object is an instance of a class.

A class is a blueprint for creating objects, defined by its name in the namespace, its attributes and its methods.

In computing, a namespace is a set of signs (names) that are used to identify and refer to objects of various kinds. The names are saved as pointers somewhere in computer memory. A namespace ensures that all of a given set of objects have unique names so that they can be easily identified. To put it simply, a namespace is a mapping from names to objects.

A class in Python is defined with the keyword class following with the class name. Each class has a constructor, which is a method that is called when an object of the class is created. The constructor is called automatically when an object is created. The constructor is defined with an internal function __init__().

For example, lets create a class that creates an object of class Employee (I will explain every detail after the class initialization):

# Name of the class
class Employee: 
    # The constructor 
    def __init__(self, name, surname, position):
        """
        In order to create an object of the class Employee, we need to pass:
        name - name of the employee
        surname - surname of the employee
        position - position of the employee
        """
        self.name = name 
        self.surname = surname
        self.position = position

        # Calculating the name length of the employee in construction time 
        self.name_length = len(name)

    # Defining a method for the object 
    def get_full_name(self):
        """
        This method returns the full name of the employee
        """
        return f"{self.name} {self.surname}"

One might be wandering, what is the “self” argument in the init() function? The argument “self” is a reference to the object being created. The “self” argument is used to access the attributes and methods of the object.

When creating an object, we skip the argument “self” and pass the other arguments to the constructor.

# Two employees
Jane = Employee("Jane", "Doe", "Manager") 
John = Employee("John", "Doe", "Sales")

As you can see above, one blueprint (Employee class) was used to create two objects (Jane and John).

This is exactly as a recipe works: you can have a recipe (or blueprint) for a cake and with that recipe make hundreds of cakes.

One fact that should always be kept in one’s mind is that EVERYTHING in Python is an object. All the imported packages, all the defined variables even the functions are objects. This means that everything has a class with which the object was created, attributes and methods.

Python “magic” methods

Magic or dunder methods in Python are special methods that start and end with the double underscores __<name>__. Magic methods are not meant to be invoked directly by the user, but the invocation happens internally from the class on a certain action. For example, when you add two numbers using the + operator, internally, the add() method will be called:

a = 5
b = 4 

print(f"Addition results: {a + b}")
print(f"Addition using the magic method: {a.__add__(b)}")
Addition results: 9
Addition using the magic method: 9

Every class has a lot of magic methods. To list them out, use the dir() function and search for the __<name>__ pattern:

# All the magic methods of the class Employee
magic_methods = [x for x in dir(Employee) if x.startswith("__")]
print(magic_methods)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
# All the methods of the int class in Python
magic_methods = [x for x in dir(int) if x.startswith("__")]
print(magic_methods)
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__']

Constructors

A constructor is a special type of function of a class which initializes objects of a class. In Python, the constructor is automatically called when an object is beeing created.

The constructor is a magic function denoted as __init__().

# Equivalent statements
Bob = Employee("Bob", "Smith", "Sales")
Rob = Employee.__init__(Employee, "Rob", "Smith", "Sales")

Object attributes

An object attribute is a piece of data that is associated with an object. It cannot be called as a function. To access an object attribute, we use the dot operator (.).

print(f"Employee position of Jane: {Jane.position}")
print(f"Surname of John: {John.surname}")
print(f"Name length of John: {John.name_length}")
Employee position of Jane: Manager
Surname of John: Doe
Name length of John: 4

We can explicitly set any attribute to an object using the (.) operator as well.

John.salary = 1000 
print(f"John's salary: {John.salary}")

try:
    print(f"Jane's salary: {Jane.salary}")
except AttributeError as e:
    print(f"Jane does not have a salary yet!\nError: {e}")

Jane.salary = 2000
print(f"Jane's salary: {Jane.salary}")
John's salary: 1000
Jane does not have a salary yet!
Error: 'Employee' object has no attribute 'salary'
Jane's salary: 2000

The objects are completely independent from each other. If we define a new attribute to Jane, it will not affect John and vice versa.

Object methods

An object method is a callable function that is associated with an object. Each object method only uses the attributes of the object it is called on. For example, to get the full names of the employees, we can use the method get_full_name():

print(f"Jane's full name: {Jane.get_full_name()}")
print(f"John's full name: {John.get_full_name()}")
Jane's full name: Jane Doe
John's full name: John Doe

Object state

An object state is all the data that is stored in the object. The data includes attributes, methods and other data. The state of an object is dynamic because it can change over time.

For example, let’s create a new class Human:

class Human: 
    def __init__(self, name, surname, age):
        """
        Template for human object; Attributes:

        name - name of the human
        surname - surname of the human
        age - age of the person
        """
        self.name = name 
        self.surname = surname
        self.age = age

    def increase_age(self, amount):
        """
        Method to increase the age of the human by the given amount
        """
        self.age += amount
    
    def get_age(self):
        """
        Method to get the age of the human
        """
        return f"My name is {self.name} and my age is: {self.age}"
# Creating a 25 year old John Doe
John = Human("John", "Doe", 25) 

# Initial age value 
print(f"{John.get_age()}")
My name is John and my age is: 25

The initial state of the age of John Doe is 25. We can change that using the method increase_age():

# Increasting the age 
John.increase_age(1)

# What is the age now?
print(f"{John.get_age()}")
My name is John and my age is: 26

The internal state of the object has changed and it effected only John.

Class inheritance

Class inheritence in programming is a mechanism that allows one class to inherit the attributes and methods of another class.

In Python, the syntax is very simple:

class DerivedClass(BaseClass):
    ...
    ...

All the methods in the DerivedClass are inherited from the BaseClass.

For example, lets a new class called President that inherits from the Human class:

class President(Human):
    def __init__(self, name, surname, age, country, years_in_service):
        """
        Template for president object; Attributes:

        name - name of the president
        surname - surname of the president
        age - age of the president
        country - country of the president
        years_in_service - years of service of the president
        """
        super().__init__(name, surname, age)
        self.country = country
        self.years_in_service = years_in_service

    def introduce(self):
        """
        Method to introduce the president
        """
        return f"My name is {self.name} {self.surname} and I am a president of {self.country} serving for {self.years_in_service} years"

The constructor of President has a function called super() that calls the constructor of the base class and provides it with all the necessary arguments.

# Lets create the past president of USA
Donald = President("Donald", "Trump", 62, "USA", 8)

# Introduce yourself 
print(Donald.introduce())
My name is Donald Trump and I am a president of USA serving for 8 years

Encapsulation

Encapsulation is the packing of data and functions that work on that data within a single object. By doing so, you can hide the internal state of the object from the outside. This is done by defining the attributes of an object as either:

  • Public

  • Protected

  • Private

All the public variables in a class are accessible from outside the class and do not have any underscores infront of them <name>.

The members of a class that are declared protected are only accessible to a class derived from it and have 1 underscore in front of them _<name>.

All the private variables are not accessible from outside the class and have a double underscore infront of them __<name>.

# Lets create an example class of an animal 
class Animal:
    def __init__(self, name, species, age):
        """
        Template for animal object; Attributes:

        name - name of the animal
        species - species of the animal
        """
        self.name = name 
        self._species = species
        self.__age = age
    
    def increase_age(self, amount):
        """
        Method to increase the age of the animal by the given amount
        """
        self.__age += amount

    def print_info(self):
        """
        Get the animal information 
        """
        return f"My name is {self.name}, I am a {self._species} and my age is: {self.__age}"

# Lets create penguin 
penguin = Animal("Happy Feet", "Penguin", 1)

# Trying to access the private variable will result in an error
try:
    print(penguin.__age)
except AttributeError as e:
    print(f"Error: {e}")
Error: 'Animal' object has no attribute '__age'

The above error is a bit missleading, because the private variable is not accessible from outside the class but it DOES exist. Only methods of the same class can access it and modify it.

# Initial information
print(penguin.print_info())

# Adding one year to the age
penguin.increase_age(1) 

# New information
print(penguin.print_info())
My name is Happy Feet, I am a Penguin and my age is: 1
My name is Happy Feet, I am a Penguin and my age is: 2

Secondly, in Python, there is no existence of private instance variables that cannot be accessed except inside an object. We can freely access the private variables of an object (_species).

However, a convention is being followed by most Python coders that a name prefixed with an underscore should be treated as a non-public part of the API or any Python code, whether it is a function, a method, or a data member.

What happens to the inherited public, private and protected members of the base class? Lets extend the above class:

# Lets create a domesticated animal class 
class DomesticatedAnimal(Animal):
    def __init__(self, name, species, age, owner):
        """
        Template for domesticated animal object; Attributes:

        name - name of the animal
        species - species of the animal
        age - age of the animal
        owner - owner of the animal
        """
        super().__init__(name, species, age)
        self.owner = owner
    
    def increase_age(self, amount):
        return super().increase_age(amount)

    def print_info(self):
        """
        Prints all the information about the animal
        """
        return f"My name is {self.name}, I am a {self._species} and my age is: {self.__age} and I am owned by {self.owner}"

# Lets create an instancte of the class
domesticated_penguin = DomesticatedAnimal("Happy Feet", "Penguin", 1, "Old McDonald")

# Lets try increasing the age 
domesticated_penguin.increase_age(1)

# Lets try to access the private variable 
try:
    print(domesticated_penguin.print_info())
except AttributeError as e:
    print(f"Error: {e}")
Error: 'DomesticatedAnimal' object has no attribute '_DomesticatedAnimal__age'

Even though we inherited everything from the Animal class to the DomesticatedAnimal class, we still cannot access the private variable __age from the other class.

Altough we can augment the internal variable __age with a new method increase_age() which calls the base class method.

Summary

The most important thing from this chapter to remember is that everything in Python is an object. This means that:

  • The created object has a template class.

  • The template class has a constructor.

  • The object has attributes and methods which can be reached via the (.) operator.

  • You can chain multiple classes together using class inheritence.