Python Classes Introduction

Posted by Daksh on Sunday, April 3, 2022

Object oriented programming in Python

Creating an empty class

Short Description:

  • A class is a blueprint that represents a type of object.
  • Even the simplest class has state and behavior.
  • Calling the class creates instances(objects) of that class.

Name convention for classes is to use CamelCase.

class MyClass:
    pass

# lets print the class
print(MyClass) # <class '__main__.MyClass'>
print(type(MyClass)) # <class 'type'>
print(MyClass.__name__) # MyClass
print(MyClass.__bases__) # (<class 'object'>,) -> root object of all classes in Python

# lets create an object of the class
# behavior of the class
print(MyClass()) # <__main__.MyClass object at 0x000001F5B1BN0GoC0>
obj1 = MyClass()
obj2 = MyClass()
print(obj1) # <__main__.MyClass object at 0x000001AVC1B5BLL3>
print(obj2) # <__main__.MyClass object at 0x000001F5B1A6QT8>
print(obj1 == obj2) # False
print(obj1 is obj2) # False

Creating a class with state

  • We can add state to the class by adding attributes to the class. This state can also be modified or even created outside the class.
  • The dunder __dict__ attribute is a dictionary that contains the class’s attributes. Class state is stored inside a MappingProxyType object which is a read-only proxy of the dictionary.
  • Class state is shared and accessible by all instances of that class.
  • There is something called as instance state, which will be discussed later in the same post.
class MyClass:
    # state
    attribute_1 = 10
    attribute_2 = 20

print(MyClass.__dict__) 
# {'__module__': '__main__', 'attribute_1': 10, 'attribute_2': 20, '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}

print(MyClass.__dict__['attribute_1']) # 10
print(MyClass.attribute_1) # 10
print(MyClass.attribute_2) # 20

obj_of_original_class = MyClass()
print(obj_of_original_class.attribute_1) # 10
print(obj_of_original_class.attribute_2) # 20
print(obj_of_original_class) # <__main__.MyClass object at 0x7f606eb21350>

# modifying the class attribute, after the class is created
MyClass.attribute_1 = 100
print(MyClass.attribute_1) # 100
print(obj_of_original_class.attribute_1) # 100

# creating new attribute, after the class is created
MyClass.attribute_3 = 300
print(MyClass.attribute_3) # 300
print(obj_of_original_class.attribute_3) # 300

# therefore, previously created objects will also be affected by the changes made to the class
# obj_of_original_class has modified attribute_1 and attribute_3, and yet it points to the same memory location
print(obj_of_original_class) # <__main__.MyClass object at 0x7f606eb21350>

Mutables as class attributes

  • If you assign a mutable object to a class attribute, then all instances of that class will share the same object.
  • This is not recommended, as it can lead to unexpected behavior.
  • When the mutable object is modified, all instances of that class will be affected, as they all share the same mutable object and it is binded to all instances of that class.
  • Mutables objects are objects that can be changed after they are created. Examples of mutable objects are lists, dictionaries, sets, etc.
class MyClass:
    # state
    mutable_attribute = [10, 20, 30]
    immutable_attribute = (10, 20, 30)


# mutable attributes
obj1 = MyClass()
print(obj1.mutable_attribute) # [10, 20, 30]
obj2 = MyClass()
obj2.mutable_attribute.append(40)
print(obj2.mutable_attribute) # [10, 20, 30, 40]
print(obj1.mutable_attribute) # [10, 20, 30, 40] OOPS!

# immutable attributes
print(obj1.immutable_attribute) # (10, 20, 30)
print(obj2.immutable_attribute) # (10, 20, 30)
obj2.immutable_attribute = (10, 20, 30, 40)
print(obj2.immutable_attribute) # (10, 20, 30, 40)
print(obj1.immutable_attribute) # (10, 20, 30)

Creating a class with Methods & Behavior

  • Instance methods are functions that are defined inside a class and can only be called from an instance of that class.
  • These functions always take the instance (self) as the first argument, and it is must for instance methods to have at least one argument which is the instance itself. Note that self is just a convention, you can use any other name for the first argument. self is just a convention and not a reserved keyword.
  • Instance methods are used to add behavior to the class.
  • Methods: when functions are defined within the body of a class, they become bound to instances of that class, and they are then called methods.
  • self represents the instance object bound to the method.
class MyClass:
    # state
    attribute_1 = 10
    attribute_2 = 20

    # behavior
    def method_1(self):
        # returns the object itself
        return self

    def method_2(self):
        return f"object is {self}"

obj = MyClass()

# method_1
print(obj) # <__main__.MyClass object at 0x7f6560b3cf90>
print(obj.method_1()) # <__main__.MyClass object at 0x7f6560b3cf90>
print(obj == obj.method_1()) # True
print(obj is obj.method_1()) # True

# self is the instance and not the class iteself
# therefore,
print(obj == MyClass) # False
print(obj.method_1() == MyClass) # False

# method_2
print(obj.method_2()) # object is <__main__.MyClass object at 0x7f6560b3cf90>

obj2 = MyClass()
print(obj2.method_2()) # object is <__main__.MyClass object at 0x7f6560b3cfd0>
print(obj2.method_1() == obj.method_1()) # False, different instances

# note that the instance methods are not bound to the class
print(MyClass.method_1) # <function MyClass.method_1 at 0x7f9c7fa2dda0>
print(type(MyClass.method_1)) # <class 'function'>
# but they are bound to the instance
print(obj.method_1) # <bound method MyClass.method_1 of <__main__.MyClass object at 0x7f6560b3cf90>>
print(type(obj.method_1)) # <class 'method'>

# if you invoke the method on the instance, you will get the ret
print(obj.method_1()) # <__main__.MyClass object at 0x7f6560b3cf90>
print(type(obj.method_1())) # <class '__main__.MyClass'>

# although an attribute can be accessed withouth the instance, it is not recommended
print(MyClass.attribute_1) # 10

# same thing is not possible with instance methods
# you will get an error
print(MyClass.method_2) # <function MyClass.method_2 at 0x7f9c7fa2dda0>
print(MyClass.method_2()) # TypeError: method_2() missing 1 required positional argument: 'self'

Methods without self argument. These methods can not be called from an instance of the class. Because when you call a method on an instance, the instance is always passed as the first argument to the method. So, if the method does not have the self argument, it will throw an error. However, these methods can be called from the class itself.

class MyClass:
    # state
    attribute_1 = 10
    attribute_2 = 20

    # behavior
    def method_1(self):
        # returns the object itself
        return self

    def method_2(self):
        return f"object is {self}"

    def method_3():
        return "method_3 without self argument"

obj = MyClass()
print(obj.method_1()) # <__main__.MyClass object at 0x7f6560b3cf90>
print(obj.method_2()) # object is <__main__.MyClass object at 0x7f6560b3cf90>   
print(obj.method_3()) # TypeError: method_3() takes 0 positional arguments but 1 was given
print(MyClass.method_3()) # method_3 without self argument

Class bound methods:

# we can bind the instance method to the class
# this is called a class method
# class methods are functions that are defined inside a class and can be called from the class itself.
# these functions always take the class (`cls`) as the **first** argument.
# class methods are bound to the class and not the instance

class MyClass2:
    # state
    attribute_1 = 10
    attribute_2 = 20

    # behavior
    def method_1(self):
        # returns the object itself
        return self

    def method_2(self):
        return f"object is {self}"

    @classmethod
    def class_method_1(cls):
        # returns the class itself
        return cls

print(MyClass2.method_1) # <function MyClass2.method_1 at 0x7f9c7fa2dda0>
print(MyClass2.class_method_1) # <bound method MyClass2.class_method_1 of <class '__main__.MyClass2'>>
print(MyClass2 == MyClass2.class_method_1) # False
print(MyClass2 is MyClass2.class_method_1) # False

# functions must be called to get the return value
print(MyClass2 == MyClass2.class_method_1()) # True
print(MyClass2 is MyClass2.class_method_1()) # True

Creating a class with Instance State or attributes

  • Instance state is the state that is unique to each instance of a class. They are basically an instance specific attributes.
  • They are created inside the __init__ method.
  • The __init__ method is called after an instance creation, but before the instance is returned to the caller. This gives us the opportunity to initialize the instance with some state.
  • The __init__ method is called the constructor of the class and it also has the self argument as the first & mandatory argument. It is bound to the instance and therefore, it is an instance method.
  • If all the values required to create an instance are not provided, then the __init__ method will raise an TypeError.

class MyClass:
    # class state
    class_attribute_1 = 10

    def __init__(self, attribute_1, attribute_2):
        # bounding the attributes to the instance, by using self.attribute_name
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2

    def method_1(self):
        return f"a1: {self.attribute_1}, a2: {self.attribute_2}"

    def method_2(self):
        return f"ca1: {self.class_attribute_1}, a1: {self.attribute_1}, a2: {self.attribute_2}"

obj_1 = MyClass() # TypeError: __init__() missing 2 required positional arguments: 'attribute_1' and 'attribute_2'

obj_1 = MyClass(1, 2)
print(obj_1.attribute_1) # 1
print(obj_1.attribute_2) # 2
print(obj_1.class_attribute_1) # 10
print(obj_1.method_1()) # a1: 1, a2: 2
print(obj_1.method_2()) # ca1: 10, a1: 1, a2: 2

obj_2 = MyClass(3, 4)
print(obj_2.attribute_1) # 3
print(obj_2.attribute_2) # 4
print(obj_2.class_attribute_1) # 10
print(obj_2.method_1()) # a1: 3, a2: 4
print(obj_2.method_2()) # ca1: 10, a1: 3, a2: 4

# modifying the instance attribute after the instance creation
obj_1.attribute_1 = 11
print(obj_1.method_1()) # a1: 11, a2: 2

Instance attribute with default values. Always provide the default values for the instance attributes as the last arguments in the __init__ method.

class MyClass:

    def __init__(self, attribute_1, attribute_2=20):
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2

    def method_1(self):
        return f"a1: {self.attribute_1}, a2: {self.attribute_2}"

# while creating the instance, if the value for the attribute_2 is not provided, then the default value will be used
obj_1 = MyClass(1)
print(obj_1.method_1()) # a1: 1, a2: 20

# to override the default value, we can provide the value while creating the instance
obj_2 = MyClass(1, 2)
print(obj_2.method_1()) # a1: 1, a2: 2

getattr() and setattr() built-in functions

  • getattr() is used to get the value of an attribute of an object. Syntax: getattr(obj, attribute_name, default_value). Example: getattr(obj, "attribute_1", None). Equivalent to obj.attribute_1 if the attribute exists, else None.
  • setattr() is used to set the value of an attribute of an object. Syntax: setattr(obj, attribute_name, value). Example: setattr(obj, "attribute_1", 10). Equivalent to obj.attribute_1 = 10 if the attribute exists, else it will raise an AttributeError.
  • Generally, we use these methods for modifying at large scale.
  • getattr() also does not raise an AttributeError if the attribute does not exist. It returns the default value provided as the third argument.
o1 = MyClass(1, 2)
o2 = MyClass(3, 4)
objs = [o1, o2]
attribs = ["attribute_1", "attribute_2"]
values = [10, 20]

for obj in objs:
    for attrib, value in zip(attribs, values):
        setattr(obj, attrib, value)

Class and Static Methods

  • Class methods are bound to the class and not the instance.
  • Static methods are not bound to the class or the instance.
  • Class methods are defined using the @classmethod decorator. It is recommended to use the cls argument instead of self for class methods.
  • Static methods are defined using the @staticmethod decorator. They are just like regular functions, but they are defined inside the namespace of the class, as they are somehow conceptually related to the class.
  • Regardless of the caller (instance or class), static methods run in the same way.
  • If an object is created inside a class method using the cls(argument_1, argument_2) syntax, then the class method can access the instance attributes of the object created inside the class method.
class MyClass:
    # class state
    class_attribute_1 = 10

    def __init__(self, attribute_1, attribute_2):
        # bounding the attributes to the instance, by using self.attribute_name
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2

    def method_1(self):
        return f"a1: {self.attribute_1}, a2: {self.attribute_2}"

    def method_2(self):
        return f"ca1: {self.class_attribute_1}, a1: {self.attribute_1}, a2: {self.attribute_2}"

    @classmethod
    def class_method_1(cls):
        return f"ca1: {cls.class_attribute_1}"

    @staticmethod
    def static_method_1():
        return "static method called"

    @classmethod
    def class_method_2(cls):
        obj = cls(1, 2)
        return obj.attribute_1

    @classmethod
    def class_method_3(cls):
        return cls.attribute_1

obj_1 = MyClass(1, 2)
print(obj_1.method_1()) # a1: 1, a2: 2
print(obj_1.method_2()) # ca1: 10, a1: 1, a2: 2
print(obj_1.class_method_1()) # ca1: 10
print(obj_1.static_method_1()) # static method called
print(obj_1.class_method_2()) # 1
print(MyClass.class_method_2()) # 1
print(obj_1.class_method_3()) # AttributeError: type object 'MyClass' has no attribute 'attribute_1'

Another way to define class methods and static methods is to use the classmethod() and staticmethod() built-in functions constructors.

class MyClass:
    # class state
    class_attribute_1 = 10

    def __init__(self, attribute_1, attribute_2):
        # bounding the attributes to the instance, by using self.attribute_name
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2

    def method_1(self):
        return f"a1: {self.attribute_1}, a2: {self.attribute_2}"

    def method_2(self):
        return f"ca1: {self.class_attribute_1}, a1: {self.attribute_1}, a2: {self.attribute_2}"

    class_method_1 = classmethod(lambda cls: "class_method_2 called")

    static_method_1 = staticmethod(lambda: "static_method_1 called")

    def class_method_2(cls):
        return "class_method_2 called"

    # classmethod() constructor
    class_method_2 = classmethod(class_method_2)

    # staticmethod() constructor
    def static_method_2():
        return "static_method_2 called"

    static_method_2 = staticmethod(static_method_2)

    # it is still an instance method
    def any_method(cls):
        # here self is cls
        return cls

Dunder Dict

Dunder Dict with Instance Attributes

  • The __dict__ attribute is a dictionary that contains the attributes of the object. It is in this dictionary where the attributes are binded to the object.
  • All instance attributes are stored in an instance-specific mapping object. This mapping object for instance is a python dictionary. Therefore, type(obj.__dict__) will return <class 'dict'>.
  • Each instance has a different __dict__ attribute in its own namespace.
  • Using obj.__dict__[attribute_name] is exactly the same as using obj.attribute_name.
  • The dunder __dict__ is in the namespace of the class. Therefore, it is itself an attribute of the class and not the object.
class MyClass:

    def __init__(self, attribute_1, attribute_2):
        # bounding the attributes to the instance, by using self.attribute_name
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2
    
    def method_1(self):
        return "method_1 called"

obj_1 = MyClass(1, 2)
print(obj_1.__dict__) # {'attribute_1': 1, 'attribute_2': 2}
print(obj_1.__dict__['attribute_1']) # 1
print(obj_1.attribute_1) # 1

# adding a new attribute to the object
obj_1.attribute_3 = 3
print(obj_1.__dict__) # {'attribute_1': 1, 'attribute_2': 2, 'attribute_3': 3}

obj_2 = MyClass(10, 20)
# obj2 has its own namespace, therefore no attribute_3
print(obj_2.__dict__) # {'attribute_1': 10, 'attribute_2': 20}

Dunder Dict with Class Attributes

  • Just like instances, classes also have their own namespace. It is accessed using the __dict__ attribute.
  • The __dict__ attribute when used with a class, returns a mapping proxy object that contains the class attributes, methods, instances, classmethods, staticmethods, and other dunders such as __dict__ , __doc__ , __weakref__ , __module__ , etc.
  • type(MyClass.__dict__) will return <class 'mappingproxy'>. Mapping proxy objects are read-only dictionaries like mapping objects. The lookup keys are always strings (for internal python optimizations or MRO - Method Resolution Order).
  • The __dict__ and __weakref__ are called discriptors. They are used to retrieve pointers from the instance memory layout.
  • When "__dict__" attribute is accessed, it is first checked inside instances, when not found, it is then checked in class namespace. The __get__() method of the __dict__ descriptor is called, which then returns the __dict__ pointer from the instance memory layout. And from this pointer, we get to the instances namespace. This namespace is a python dictionary, that contains only the instance attributes.
class MyClass:

    class_attribute_1 = 10

    def __init__(self, attribute_1, attribute_2):
        # bounding the attributes to the instance, by using self.attribute_name
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2
    
    def method_1(self):
        return "method_1 called"
    
    @classmethod
    def class_method_1(cls):
        return "class_method_1 called"
    
    @staticmethod
    def static_method_1():
        return "static_method_1 called"

print(MyClass.__dict__) 
# output
""" 
{'__module__': '__main__', # '__module__' retrieves the module name in which the class is defined
'class_attribute_1': 10, 
'__init__': <function MyClass.__init__ at 0x0000020B1B4B8D30>, 
'method_1': <function MyClass.method_1 at 0x0000020B1B4B8DC0>, 
'class_method_1': <classmethod object at 0x0000020B1B4B8F10>, 
'static_method_1': <staticmethod object at 0x0000020B1B4B8F40>, 
'__dict__': <attribute '__dict__' of 'MyClass' objects>, 
'__weakref__': <attribute '__weakref__' of 'MyClass' objects>, 
'__doc__': None} 
"""

print(type(MyClass.__dict__)) # <class 'mappingproxy'>

obj_1 = MyClass(1, 2)
print(obj_1.__dict__) # {'attribute_1': 1, 'attribute_2': 2}
print(type(obj_1.__dict__)) # <class 'dict'>

Docstring

  • The docstring is a string that is used to document the class, method, function, etc.
  • Comments are used for fellow developers to understand the code. Docstrings are used for the end users of the code. And unlike comments, docstrings are not ignored by the python interpreter. The python interpreter reads the docstrings and binds them in the __doc__ attribute.
  • The docstring is defined using triple quotes """ or '''.
  • A well written docstring is very important for the readability of the code and also for the documentation of the code.
  • You can then use packages like sphinx to generate documentation from the docstrings.
  • Accessing the docstring of a class, method, function, etc. is done either by using the __doc__ attribute or by using the help() function.
  • If the first thing in the class, method, function, etc. is a string, then that string will be considered as the docstring by python.
  • If you use help(), press q + Enter to exit the help screen.
  • There are different kind of docstrings conventions. One of them is reStructuredText (reST). It is a markup language that is used to write docstrings. It is used by sphinx to generate documentation from docstrings. Refer PEP 257 – Docstring Conventions. Google has its own docstring convention. Refer Google Python Style Guide. I prefer the Google docstring convention. NumPy has its own docstring convention. Refer NumPy Docstring Guide.
class MyClass:
    """This is the docstring of the class"""
    def __init__(self, attribute_1, attribute_2):
        """This is the docstring of the __init__ method"""
        self.attribute_1 = attribute_1
        self.attribute_2 = attribute_2
    
    def method_1(self):
        """This is the docstring of the method_1 method"""
        return "method_1 called"

print(MyClass.__doc__) # This is the docstring of the class
print(MyClass.__init__.__doc__) # This is the docstring of the __init__ method
print(MyClass.method_1.__doc__) # This is the docstring of the method_1 method