Skip to content

8. Classes & Objects

The primary focus in this article is to provide common programming patterns related to class definitions. Some of the topics include:

  1. Making objects support common Python features.
  2. usage of special methods.
  3. Encapsulation techniques.
  4. Inheritence.
  5. Memory management.
  6. Useful Design Patterns.

8.1: str() & repr()

Problem

You want to change the output produced by printing or viewing instances to something more sensible.

Solution

To change the string representation of the class instances define __str__() & __repr__() methods as shown below.

1
2
3
4
5
6
7
8
class Pair:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return "({0.x!s}, {0.y!s})".format(self)
    def __repr__(self):
        return "Pair({0.x!r}, {0.y!r})".format(self)

🏆 The __repr__() method returns the code represntation of the instance, and is usually the code you would type to re-create the instance. The built-in repr() function returns this text and so does the interactive interpreter while inspecting values.

🏆 The __str__() method converts the instance to a string and is the output returned by print() or str() methods.

1
2
3
4
5
>>> p = Pair(2, 3)
>>> p
Pair(2, 3)              ## __repr__() output
>>> print(p)
>>> (2, 3)              ## __str__() output

🚨 The implementation of the above class also show how different string representations may be used during formatting. Specifically, the special !r formatting code indicates that the output of __repr__() should be used instead of __str__(), the default. An example below shows this behavior:

1
2
3
4
5
>>> p = Pair(3, 4)
>>> print("p is {0!r}").format(p)
p is Pair(3, 4)
>>> print("p is {0!s}").format(p)
p is (3, 4)
Discussion

🚨 It is standard practice to for the output of __repr__() to produce text such that eval(repr(x)) == x

If it is not prossible or desired then it is common to create a textual represntation enclosed in < & > instead as shown in below snippet.

1
2
3
>>> f = open("file.dat")
>>> f
<_io.TextIOWrapper name='file.dat' mode='r' encoding='utf-8'>

🚨 If not __str__() is provided then __repr__() is used as a fallback.

8.2: Customizing string formatting

Problem

You want an object to support customized formatting through the formaat() function and string method.

Solution

To customize string formatting, define a custom __format__() method in the class definition. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
## The format codes
##
_formats = {
    "ymd": "{d.year}-{d.month}-{d.day}",
    "mdy": "{d.month}/{d.day}/{d.year}",
    "dmy": "{d.day}/{d.month}/{d.year}"
}

## ----------------------------------------------- ##

class date:
    def __init__(self, day, month, year):
        self.day = day
        self.month = month
        self.year = year

    def __format__(self, code):
        if code == "":
            code = "ymd"
        fmt = _formats[code]
        return fmt.format(d=self)

Instances of Date now supports formatting options such as the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> d = Date(15, 11, 2021)
>>>
>>> format(d)
'2021-11-15'
>>>
>>> format(d, "mdy")
'11-15-2021'
>>>
>>> "The date is {:ymd}".format(d)
'The date is 2021-11-15'
>>>
>>> "The date is {:mdy}".format(d)
'The date is 11-15-2021'

The __format__() method provides HOOK into Python's string formatting functionality.

🏆 Its important to know that the interpretation of the format codes (e.g. _formats in above code) is entirely upto the class defintion and developer.

8.6: Creating managed attributes

Problem

You want to add extra processing (e.e. type checking, validation) to the getting and setting of the instance attributes

Solution

🚨 A simple way to customize access to an attribute is to define it as property.

For example, in below code, we define a property that adds simple type checking for its getter and setter methods.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Person:
    def __init__(self, first_name):
        self.first_name = first_name        ## NOTE: I'm defining "self.first_name" not "self._first_name"

    ## getter method
    @property
    def first_name(self):
        return self._first_name

    ## setter method
    @first_name.setter
    def first_name(self, value):
        if not isinstance(value, str):
            raise TypeError("Expected a string")
        self._first_name = value

    ## deleter method
    @first_name.deleter
    def first_name(self):
        raise AttributeError("Can't delete attribute")

8.9: Descriptor Class

Creating a new kind of class or instance attribute

You want to create a new kind of class with instance atrribute type with some extra functionality such as type checking

Solution

If you want to create an entirely new kind of instance attribute, define its functionality in the form of a descriptor class. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
## Descriptor attribute for an integer type-checked attribute
class Integer:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            return instance.__dict__[self.name]

    def __set__(self, instance, val):
        if not isinstance(val, int):
            raise TypeError("Expected to be an int")
        instance.__dict__[self.name] = val

    def __delete__(self, instance):
        del instance.__dict__[self.name]

🚨 A Descriptor is a class that implements three core attribute access operations (get, set, delete) via the special methods __get__(), __set__(), __delete__(). These methods work by receiving an instance as input. The underlying dictionary of the instance is then manipulated as appropriate.

🏆 To use a descriptor, instances of the descriptor are placed into the class defintion as class variables as shown below.

1
2
3
4
5
6
7
8
## Using a Descriptor in a class defintion

class Point:
    x = Integer("x")
    y = Integer("y")
    def __init__(self, x, y):
        self.x = x
        self.y = y
When we do this, all access to the descriptor attributes (x, y) are captured by __get__(), __set__(), __delete__() methods of the descriptor class. For example:

1
2
3
4
5
6
7
8
>>> p = Point(2, 3)
>>>
>>> p.x                     ## calls "Point.x.__get__(p, Point)"
2
>>> p.y = 5                 ## calls "Point.y.__set__(p, Point)"
>>> p.x = 2.5               ## calls "Point.x.__set__(p, Point)"
...
TypeError: Expected an int

🏆 As input, each method of the descriptor receives the instance being manipulated. To carry out the requested operation, the underlying instance dictionary (underlying __dict__() attribute) is manipulated. The self.name attribute of the descriptor holds the dictionary key being used to store the actual data in the instance dictionary.

Discussion

Descriptors provide the underlying logic for most of the Python's class features such as @classmethod, @staticmethod, @property and even __slots__ attribute.

By defining descriptors, you can capture the core instance oprtations (get, set, delete) at a very low level and completely customoze what they do. It is one of the most important tool used by writers of most advance librarues & frameeeeworks

🏆 One confusion with the descriptors is that they can only be defined at the class level and not at the per-instance basis. The following descriptor usage will not work.

1
2
3
4
5
6
7
8
## this descriptor USAGE will not work

class Point:
    def __init__(self, x, y):
        self.x = Integer("x")            ## NO! Must be a class variable
        self.y = Integer("y")            ## NO! Must be a class variable
        self.x = x
        self.y = y

8.13: Implementing a Data Model or Type System

Problem

You want to define various kinds of Data Structures, but want to enforce constraints on the values that are allowed to be assigned to certain constraints.

Solution

...