Classes

In programming, classes, or more general OOP (Object Oriented Programing) is a fundamental paradigm (next to others, e.g. functional programming). It is powerful and e.g. Java is fully based on it. Python is a multi-paradigm (and multi-everything anyway) language, meaning there are classes, functions etc.

However, classes play in Python an extremely important role - although one does not directly have to know about it - since actually every object in Python “comes from a class”. But we’re going ahead of things.

Let’s start with an example problem

We want to do some calculations with particles and their momenta, e.g. to calculate their invariant mass. (we focus on just one particle here, sure we could use lists but this means to keep track of which entry is which etc.)

[1]:
import numpy as np
[2]:
# particle 'pi1'
pi1_px = 10
pi1_py = 20
pi1_pz = 30
pi1_E = 100


def calc_mass_simple(px, py, pz, E):
    return np.sqrt(E ** 2 - (px ** 2 + py ** 2 + pz ** 2))
[3]:
calc_mass_simple(pi1_px, pi1_py, pi1_pz, pi1_E)
[3]:
92.73618495495704

Alright, but clearly cumbersome. Better: if we could stick it together. Let’s use a dict!

[4]:
pi1 = {'px': 10,
       'py': 20,
       'pz': 30,
       'E': 100}


def calc_mass(particle):
    momentum = particle['px'] ** 2 + particle['py'] ** 2 + particle['pz'] ** 2
    return np.sqrt(particle['E'] ** 2 - momentum)
[5]:
calc_mass(particle=pi1)
[5]:
92.73618495495704

That looks better! But now, calc_mass critically depends on the structure of pi1 if we e.g. want to create new particles. How can we “communicate” that well? (sure, docstrings, but is there a more “formal way”?)

Furthermore: calc_mass somehow “belongs” to pi1, we want to calculate the mass of it. We always use calc_mass together with a particle dict.

[6]:
# trial to connect together
pi1 = {'px': 10,
       'py': 20,
       'pz': 30,
       'E': 100,
       'mass': calc_mass}  # why not call it mass? it's the mass of the particle
[7]:
pi1['mass'](pi1)
[7]:
92.73618495495704

Cumbersome, but better, we get there! For the communication, what we want is a “template”/blueprint dict. So that if we want to create a new particle, we have to make sure to specify px, py, pz and E in the dict (so that it is valid). And then to also add the calc_mass function.

[8]:
def make_particle(px, py, pz, E):
    return {'px': px,
            'py': py,
            'pz': pz,
            'E': E,
            'mass': calc_mass}
[9]:
e1 = make_particle(20, 30, 20, E=41.234227)
e1['mass'](e1)
[9]:
0.5113475212892835

Ok, no we get picky: let’s split the above even more (just one last time)

[10]:
def make_particle():
    return {'px': None,
            'py': None,
            'pz': None,
            'E': None,
            'mass': calc_mass}


def initialize_particle(particle, px, py, pz, E):
    particle['px'] = px
    particle['py'] = py
    particle['pz'] = pz
    particle['E'] = E
    return particle


particle1 = initialize_particle(make_particle(), px=20, py=30, pz=20, E=50)
[11]:
# "magic line"
particle1['mass'](particle1)
[11]:
28.284271247461902
[ ]:

The call to calculate the mass is still not perfect: We want something that - “feeds itself to the function called”. - is created through a function (“constructor”) - has attributes (better then this [’…’] accesing would be with the dot)

…more like this: particle1.calc_mass()

Welcome to classes

A class is a blueprint of an object

[12]:
class SimpleParticle:
    # what we don't see: before the __init__, there is a (automatic) make_particle. Normally we don't need it
    # the initialiser, basically initialize_particle
    def __init__(self, px, py, pz, E):  # self is the instance, the future object.
        self.px = px
        self.py = py
        self.pz = pz
        self.E = E

    def calc_mass(self):
        # why not reuse the one from above?
        return calc_mass_simple(px=self.px, py=self.py, pz=self.pz, E=self.E)

Let’s use it!

[13]:
# where is __init__ called? (magic method again)
# answer: when calling the class
particle1 = SimpleParticle(20, 30, pz=40, E=80)  # NOT equivalent to Particle.__init__(), because
                                      # it calls a constructor before (make_particle)
[14]:
particle1.calc_mass()  # where did self go?
[14]:
59.16079783099616

In a class, self is given automatically as the first argument! Hereby, we solved our odd problem from above.

Furthermore, we can now access attributes instead of using the [...]

[15]:
particle1.pz
[15]:
40

That’s what we want!

Exercise: override the __add__ method to make two particle addable. Name it Particle Hint: you need to construct a new Particle

STOP SCROLLING, SOLUTION AHEAD!

STOP SCROLLING, SOLUTION AHEAD!

STOP SCROLLING, SOLUTION AHEAD!

STOP SCROLLING, SOLUTION AHEAD!

[16]:
class Particle:
    # what we don't see: before the __init__, there is a (automatic) make_particle. Normally we don't need it.

    # This is the initialiser, basically initialize_particle
    def __init__(self, px, py, pz, E):  # self is the instance, the future object.
        self.px = px
        self.py = py
        self.pz = pz
        self.E = E

    def calc_mass(self):
        # why not reuse the one from above?
        return calc_mass_simple(px=self.px, py=self.py, pz=self.pz, E=self.E)

    def __add__(self, other):
        new_px = self.px + other.px
        new_py = self.py + other.py
        new_pz = self.pz + other.pz
        new_E = self.E + other.E
        return Particle(new_px, new_py, new_pz, new_E)
[17]:
particle1 = Particle(10, 20, 30, 100)
particle2 = Particle(50, 10, 20, 200)

# test it here
new_particle = particle1 + particle2

Inheritance: a glance

Instead of completely rewriting Particle, we can also inherit the class from it. This means we “overtake” the parent class and add/replace certain fields.

[18]:
class VerboseParticle(Particle):  # This is inheritance

    def momentum_text(self):
        return f"px: {self.px}, py: {self.py}, pz: {self.pz}"
[19]:
# test it here again
particle1 = VerboseParticle(10, 10, 10, 50)
particle2 = VerboseParticle(10, 10, 10, 50)
new_particle = particle1 + particle2
[20]:
type(new_particle)
[20]:
__main__.Particle

We have one problem now: the particle is again a Particle, not a VerboseParticle. This is because we “hardcoded” the name into the __add__ method.

How to fix

Let’s first step back. We have seen quite a few things in this lecture. This was an introduction into classes in a minimal time. Classes are a powerful yet non-trivial concept that require to know a lot more than the simple behavior that we just looked at. There are many concepts - interfaces, multiple inheritance and MRO, inheritance vs composition, private vs public, getter and setter, stateful/stateless, classmethods and staticmethods, … - that we just did not cover here, as it takes a full fledged course on OOP to master these things.

The problem above should make one thing clear: it is a powerful, yet difficult tool to use and without the proper knowledge, things can go wrong in completely unexpected corners; that’s why good software practices are not just a nice-to-have but a mandatory asset to guarantee the best-possible (most bugfree) codebase.

How to actually fix it: instead of Particle, we can use the class dynamically itself. type comes in handy: this may has been encountered as a tool to return the type of an object. But this type is exactly the class we need!.

(Sidenote: be aware of isinstance vs type, use the former if not explicitly type has to be used.)

So we can replace the call in __add__ as follows. Instead of return Particle(new_px, new_py, new_pz, new_E) we have return type(self)(new_px, new_py, new_pz, new_E)

[21]:
class BetterParticle(Particle):
    def __init__(self, px, py, pz, E, superpower=42):
        super().__init__(px, py, pz, E)
        self.superpower = superpower