In this post, we are going to see how we can implement classes if python didn’t come with a class construct, read till the end if you’d want to know why I put you made you go through all this pain fun😉

What the heck is a Class?

There are a lot of philosophical discussions about what classes are but I’m not concerned with that. In the simplest terms, a class is an entity that has data and functionality related to that data, these are called attributes and methods. If a language has data structures, and first-class functions (don’t fret if you don’t know what this is), we could make our own class system.

First-class functions.

If a language has support for first class functions that means you can:

  1. Pass functions as arguments to other function,
  2. Return functions from other functions.
  3. Store functions in data structures. (this is important!)

Passing functions as arguments

# classes.py
from operator import add, sub, mul

def do_math(a,b, operation):
    return operation(a,b)

run the file in interactive mode, here classes.py is the name of my file.

(this is how we’ll run all the examples in the post).

$ python -i classes.py

lets run a few examples. As you can see, we are passing the add, and sub functions as arguments.

>>> do_math(1,2, add)
3
>>> do_math(2,1, sub)
1

Returning functions from functions

def get_special_add():
    def add_three(a,b,c):
        return a+b+c
    return add_three

# in the interactive session

>>> special_add = get_special_add()
>>> special_add(1,2,3)
6

Storing functions in data structures

This is the most relevant to our use case so pay attention. Here we store add, sub, mul in a list.

# classes.py
def do_basic_ops_on_args(a,b):
    basic_ops = [add,sub, mul] # storing three functions in a list
    for op in basic_ops:
        print(op(a,b))


>>> do_basic_ops_on_args(4,2)
6
2
8

As you can see, python doesn’t differentiate between functions and data, which is important for what we are about to do next.

Our very own class system

Let’s look at a class defined in the traditional way called Animal. We’ll use this example to work on our own class system.

# classes.py

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

    def eat(self, food):
        print(f"eating {food}")

    def who_dis(self):
        print(f"this be {self.name} and they be {self.age} years old")
    


>>> animal = Animal("tom", 20)
>>> animal.name
"tom"
>>> animal.age
20
>>> animal.who_dis()
this be tom and they be 20 years old

Implementing attributes.

The building blocks of our class system will be dicts and functions. Let’s tackle the attributes of Animal. This is quite straightforward since getting attributes, and setting them are similar to key-value pairs in dicts. Let’s start by hardcoding the name and age of the animal. Let’s call our Animal python (cause why not?) and let it’s age be 27.

# hipster_classes.py

python = {"name":"python", "age":27}

# console
>>> python["name"]
'python'
>>> python["age"]
27

Implementing constructors.

But this bears little resemblance to Animal. And it would be a pain to define all instances of Animal in this fashion i.e. hardcode all the attributes. Constructors were created for this very reason! We can use functions as constructors.

# hipster_classes.py

# our very own constructor
def Animal(name, age):
    return {"name":name, "age":age}

>>> python = Animal("python", 27)
>>> python["name"] # similar to python.name
'python'
>>> python["age"]  # similar to python.age
27  
>>> python["age"] = python["age"]+1

# we can even change the attributes!
>>> python["age"] += 1
>>> python["age"]                   
28

I hope you’re starting to see the similarities with Classes. Our class constructor is indistinguishable from the normal python constructor. The only difference is how we access the attributes, we use the dictionary notation, instead of the dot notation.

Implementing methods.

Now for the tough part, implementing methods. We can use good-old functions for this. Let’s try implementing the eat method.

# hipster_classes.py

def eat(food):
    print(f"eating {food}")

def Animal(name, age):
    return {"name":name, "age":age, "eat": eat,}

>>> eat = python["eat"]
>>> eat("java")
eating java

# this is the immediate invocation syntax. and I will be using this from now on. 
# this is equivalent to the above example
>>> python["eat"]("java") 
eating java


# I'll just add some comments to Animal. this is still the same thing. it looks different that's all.

def Animal(name, age):
    return {
        # attributes
        "name":name, "age":age,
        # methods
        "eat": eat,
            }

so eat works. what about who_dis?

def eat(food):
    print(f"eating {food}")

def who_dis():
    print(f"this be {name} and they be {age} years old")

def Animal(name, age):
    return {
        # attributes
        "name":name, "age":age
        # methods
        "eat": eat,
        "who_dis":who_dis, # new
            }


>>> python["who_dis"]()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".\temp.py", line 17, in who_dis
    print(f"this be {name} and they be {age} years old")
NameError: name 'name' is not defined

What’s going on here? who_dis needs to access the attributes name and age of Animal.

for the we need a reference to an instance of Animal (here python). Lets redefine who_dis.

def who_dis(animal):
    print(f"this be { animal['name'] } and they be {animal['age']} years old")

>>> python["who_dis"](python)
this be python and they be 27 years old

Before we go further, I’d like to refactor this so it looks a little more familiar to you.

def Animal(name, age):
    self = {
        # attributes
        "name":name, "age":age,
        # methods
        "eat": eat,
        "who_dis": who_dis,
            }
    return self
 
def eat(self, food): # added self arg
    print(f"eating {food}")

def who_dis(self): # renamed animal to self
    print(f"this be {self['name']} and they be {self['age']} years old")

Making our class system more convenient to use.

At this point, we’re pretty much done with our class system. And this is starting to look very similar to classes defined the “old fashioned” way. But its very annoying to pass the instance the function when calling it, for e.g

>>> python["eat"](python, "java")
eating java

>>> python["who_dis"](python)
this be python and they be 27 years old

One way to solve this problem is using inner functions. We just move the eat and who_dis functions inside the Animal function. This way they have access to the all the attributes inside Animal, so then we don’t have to pass an instance everytime we call a method.

def Animal(name, age):
    def eat(food): # added self arg
        print(f"eating {food}")

    def who_dis(): # renamed animal to self
        print(f"this be {self['name']} and they be {self['age']} years old")

    self = {
        # attributes
        "name":name, "age":age,
        # methods
        "eat": eat,
        "who_dis": who_dis,
            }
    return self

# isn't this more convenient? and this looks similar to method invocations
>>> python["eat"]("java") # python.eat("java")
eating java
>>> python["who_dis"]()   # python.who_dis()
this be python and they be 27 years old

But what about inheritance?

A part of me wants to say that you don’t need inheritance, Golang doesn’t have it. But I know that’s a cop-out answer, and the solution is a little tricky. I’ll compromise, and we’ll implement single inheritance.

In our constructor let’s define a keyword argument called super and assign the superclass to it. We’ll create a child class Human that inherits from Animal.

# hipster_classes.py

def Human(name, age, super=Animal):
    super = Animal(name, age) # initialize Animal
    self = {**super} # copy the attributes and methods to child class's dict. Copying preserves elements in super.

    return self

>>> abhinav = Human("abhinav", 22)
>>> abhinav["eat"]("pizza")
eating pizza
>>> abhinav["who_dis"]()
this be abhinav and they be 22 years old

Adding child-class specific attributes.

Let’s add an attribute that’s specific to humans, like a job (I don’t see other animals working for cash). This is pretty straightforward and you can add the job attribute to the self dict.

# hipster_classes.py

def Human(name, age, job, super=Animal): # new arg in the constructor
    super = Animal(name, age)     # initialize Animal
    self = {**super,              # add the attributes and methods to child class's dict
            # child class specific attributes
            "job":job,
    } 

    return self

>>> abhinav = Human("abhinav", 22, "python programmer")
>>> abhinav["job"]
'python programmer'

Overriding superclass methods.

Since I’m an educated person, the grammatically incorrect sentence in who_dis bothers me, and people aren’t going to take me seriously, so I’d like to override that method. Since there can only be one key for each value, we could just add a new who_dis method in self and it will work as if the method was overridden.

# hipster_classes.py

def Human(name, age, job, super=Animal):
    def who_dis():
        print(f"I am {name}, I'm {age} years old and I work as a {job}")

    super = Animal(name, age)     # initialize Animal
    self = {**super,              # add the attributes and methods to child class's dict
            # child class specific attributes
            "job":job,
               
            #overriding who_dis method  
            "who_dis": who_dis, # new
    } 

    return self

>>> abhinav = Human("abhinav", 22, "python programmer")
>>> abhinav["who_dis"]()
I am abhinav, I'm 22 years old and I work as a python programmer

But how will I access the methods from the superclass?

This is as easy as calling super["attribute_name"] or super["method"](args) in a child class method. Here’s an example.

# hipster_classes.py

def Human(name, age, job, super=Animal):
    def who_dis():
        print(f"my ancestors would introduce themselves like this:")
        # call super method
        super['who_dis']()

        print(f"but I am evolved now, and I introduce myself like this.")
        print(f"I am {name}, I'm {age} years old and I work as a {job}")

    super = Animal(name, age)     # initialize Animal
    self = {**super,              # add the attributes and methods to child class's dict
            # child class specific attributes
            "job":job,
            #overriding who_dis method
            "who_dis": who_dis,
    } 

    return self

abhinav = Human("abhinav", 22, "python programmer")

>>> abhinav["who_dis"]()
my ancestors would introduce themselves like this:
this be abhinav and they be 22 years old
but I am evolved now, and I introduce myself like this.
I am abhinav, I'm 22 years old and I work as a python programmer

So why did I put you through all of that?

Because that is very close to how python’s class system works. Python classes use dicts underneath to provide their functionality. Here’s proof.

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

    def eat(self, food):
        print(f"eating {food}")

    def who_dis(self):
        print(f"this be {self.name} and they be {self.age} years old")

>>> dog = Animal("hachiko", 14)
>>> dog.__dict__
{'name': 'hachiko', 'age': 14} # attributes 

>>> dog.__class__.__dict__  
mappingproxy({'__module__': '__main__', '__init__': <function Animal.__init__ at 0x0000020690365670>, 
################# class methods ###########################
'eat': <function Animal.eat at 0x00000206904CD3A0>, 
'who_dis': <function Animal.who_dis at 0x00000206904CD430>, 
###########################################################
'__dict__': <attribute '__dict__' of 'Animal' objects>, '__weakref__': <attribute '__weakref__' of 'Animal' objects>, '__doc__': None})

>>> dog.__class__.__dict__["who_dis"](dog)
this be hachiko and they be 14 years old

As you can see attributes and methods are stored in two dictionaries.

Why two different dictionaries you may ask?

Let’s quickly revisit our two implementations. The first one did not have inner functions and the instance had to be explicitly passed to the functions when calling them, the second one had inner functions and we didn’t have to explicitly pass the instance as an argument.

There are upsides and downsides to each. The one with inner functions makes it convenient to call methods, but each object consumes a lot of memory, since each object has a copy of all its methods. The one without inner functions is memory efficient since the objects only store a reference to the methods but is tedious to use (method invocation).

The python class system is a best-of-both-worlds system, it combines the memory efficiency of our first implementation with the ease of use of our second implementation. Since all instances of a class share the same logic (methods) they are stored in the object.__class__.__dict__, which can be accessed by all instances of a class. Attributes are what differ between instances and are thus stored in object.__dict__ of each instance. Whenever a method is called on an object, the python interpreter will pass that object as the first argument to the method (it’s nice that way), along with any arguments you provide. Here’s what I mean.

>>> dog = Animal("hachiko", 14)    # the class Animal not our hipster implementation

>>> dog.who_dis()
this be hachiko and they be 14 years old

# callling the method from the dictionary. But you get a TypeError, since you haven't passed the instance to that method.
>>> dog.__class__.__dict__["who_dis"]() 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: who_dis() missing 1 required positional argument: 'self'

# callling the method from the dictionary but passing the dog instance to it. Works perfectly. 
>>> dog.__class__.__dict__["who_dis"](dog) # equivalent to dog.who_dis()
this be hachiko and they be 14 years old

Concluding thoughts.

I have just scratched the surface of how python classes work, there are so many more things to learn. I hope this post inspires you to learn more about that, or not, either way, you have some understanding of how classes work, and for day to day things, you won’t need an indepth understanding of python classes.

This technique can also be a way to simulate classes in functional languages (if you desire). But functional languages have their own way of dealing with state, and I’d say it’s more idiomatic to use that, rather than hacking your own system.