how to use __slots__ 

class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print(f'x: {p.x}')
print(f'y: {p.y}')

__slots__ is a special class variable in Python that restricts the attributes that can be assigned to an instance of a class.
__slots__ is an iterable(usually a tuple) that stores the names of the allowed attributes for a class. It specifies the list of attribute names that can be assigned to instances of the class.

If __slots__ is defined, an exception will be raised if  an attribute that is not in the list is assigned to an instance.

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.z = 5 #Raises an error because 'z' is not in __slots__

p = Point(2, 3)

In the above example, an AttributeError  exception is raised, because we tried to assign attribute z to an instance when "z" is not present in the __slots__ list. 

We also cannot add attributes to an instance dynamically.

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(2, 3)
p.z = 5 #Raises an exception

Why is __slots__ important?

By default, class instances have a dictionary called __dict__ that stores all the attributes, and their associated values. 

the __dict__ variable 

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

p = Point(3, 4)
print("__dict__: ", p.__dict__)

Using the dictionary, __dict__,  allows for dynamic creation of attributes at runtime, which can be convenient but can also lead to potential memory inefficiency.

The __slots__  variable allows for more efficient memory usage as it limits the attributes that can be assigned to an instance to only those listed in __slots__ declaration.

Using  __slots__ can offer considerable performance benefits because it eliminates the need for a dictionary to store attributes. When __slots__ is declared, the  attributes are stored in a fixed-size array on the object itself. This makes accessing and setting attributes much faster as the interpreter does not have to perform dictionary lookups.

class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print("__slots__: ", p.__slots__)
print("__dict__: ", p.__dict__) #The __dict__ is not used when __slots__ is declared.

__slots__ is particularly useful when we know exactly what attributes class instances should have, and we don't want to assign arbitrary attributes dynamically at runtime.

__slots__ with inheritance

When a class defines __slots__, it only applies to instances of that class and does not automatically apply to its child classes. A child class does not inherit the parent class's __slots__.

By default, a child class will still use the __dict__  dictionary for storing the attribute, in addition to parents __slots__, unless it also defines its own __slots__.

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.z = 10 #Raises an error because 'z' is not in __slots__

#inherits from Point
class Point3d(Point):
     def __init__(self, x, y, z):
         super().__init__(x, y)
         self.z = z #does not raise an error

p = Point3d(2, 3, 4)
print('__slots: ', p.__slots__) #parents __slots__
print("__dict__: ", p.__dict__) #child's __dict__

print("x: ", p.x)
print("y: ", p.y)
print("z: ", p.z)

A child class can also declare its own __slots__, without including those declared by its parent classes.

class Point:
    __slots__ = ["x", "y"]
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.z = 10 #Raises an error because 'z' is not in __slots__

#inherits from Point
class Point3d(Point):
     __slots__ = ("z", )
     def __init__(self, x, y, z):
         super().__init__(x, y)
         self.z = z #does not raise an error

p = Point3d(2, 3, 4)

print("x: ", p.x)
print("y: ", p.y)
print("z: ", p.z)