The work of constructors is to set up the initial state of an object immediately after it has been created.

Consider the following example.

without a constructor 

#Define the class
class Point:
    x = 0
    y = 0

#Create an object of the class
p = Point()

print(p.x, p.y)

#Assign new values to the created object
p.x = 5
p.y = 7

print(p.x, p.y, sep = " ")

While the above example works, in most cases it is more natural and intuitive to pass values to the object when it is being created rather than when it is already created. This is where constructors comes in handy.

In Python, this is achieved by using the special dunder(double underscore) method,  __init__() . The __init__() method is defined just like any other instance methods, it takes the special argument self as its first and required argument. 

def __init__(self):
    #the body

Note: The name of the constructor method should strictly be __init__

#Define the class
class Point():

    #define the default constructor
    def __init__(self):
        self.name = "Point instance"

p = Point()
print(p.name)

If the __init__() method is defined without any additional parameters apart from self, as in above,  it  is referred to as  a default constructor. 

Parameterized Constructor

If the __init__() method takes other parameters alongside self , it is referred to as parameterized constructor.

def __init__(self, *others):
    #the body

In this case, the additional methods are passed when creating the object with the following syntax.

ClassName(*arguments)
#Define the class
class Point:

    #define the  parameterized constructor
    def __init__(self, x, y):
        self.x = x
        self.y = y

#create a point object and pass x and y arguments
p = Point(3, 5)
print(p.x, p.y)

In the above example, the constructor method takes two parameters alongside self i.e x and y. The  values of the additional parameters are passed as arguments when instantiating the instances. 

purposes of the __init__() method

To define initial instance variables.   

The most obvious purpose of constructors is to allow for user-defined initialization of the object’s instance variables. This makes it possible for us to pass initial values that are unique to that particular instance.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

p1 = Person('John Doe', 25)
p2 = Person('Janeb Smith', 28)

print(p1. name)
print(p2.name)

To establish object invariants

The second purpose is is to establish any invariants that must always be true for objects of this class. For example consider in the case of class -Point, we can have an invariant called coordinates which may be a tuple holding the values of x and y for each instance at any given time  

class Point:
   def __init__(self, x = 0, y = 0):
       self.x = 0
       self.y = 0
       self.coordinates = (self.x, self.y)

p = Point()

print(p.coordinates)

The constructor should not return any value

The __init__() method does not return any value other than the default return value None. This is because its purpose is to simply set up object's initial data. The method is not meant to be called directly from other points in the program.

TypeError will be raised if we try to make it return other value than the default, None.

class Demo:
   def __init__(self):
        return 1

a = Demo()

Where to place the constructor method

You can define the __init__ () method at literally any valid place inside the class. It is perfectly okay to put it, for example, as the last method in the class definition.

However, it is considered a good practice to define constructors as the first instance method.  This makes it easy to locate and identify the constructor.

#define the class
class Point:
    
    #Define the constructor as the first instance method
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def get_coordinates(self):
        return (self.x, self.y)
    def reset(self):
        self.x = 0
        self.y = 0

#Create an object of the class
p = Point(3, 2)

#Call instance methods
print(p.get_coordinates())
p.reset()

print(p.get_coordinates())

Only a single Constructor

Consider a scenario where you define more than one __init__() method for the same class.

class MyClass:

   #the fisrt constructor
   def __init__(self): 
      print("First constructor")
   #the second constructor
   def __init__(self):
      print("Second Constructor")

a = MyClass()

As you can see above, only the latest to be defined is called, the rest are ignored.

Note:  Python does not support multiple constructors.