Basics of object-oriented programming

Object-oriented programming is a way of programming in which you bundle related variables and functions into objects, and then manipulate and use these objects as a whole. We already saw many examples of objects on Python – e.g. lists, dictionaries, numpy arrays, dataframes – that have both variables and methods inside them. In this section we will learn how to create our own objects.

You can think of:

  • variables as properties / attributes of an object
  • functions as some operations / methods you can perform on this object

Let’s define out first class:

class Planet:
    # During initialization arguments will be in km and kg.
    # Internally we'll store all numbers in CGS units (cm and g).
    hostObject = "Sun"     # class attribute (the same value for every class instance)
    def __init__(self, radius, mass):   # "constructor" sets the initial state of a newly created object
        self.radius = radius*1e5   # instance attribute, convert km -> cm
        self.mass = mass*1.e3      # instance attribute, convert kg->g

Let’s define some instances of this class:

mercury = Planet(radius=2439.7, mass=3.285e23) # enter km and kg
venus = Planet(6051.8, 4.867e24)               # enter km and kg
venus.radius, venus.mass, venus.hostObject

Instances are guaranteed to have the attributes that we expect.

Topic 17.1

How can we define an instance without passing the values? E.g., I would like to say earth = Planet() and then pass the attribute values separately like this:

earth = Planet()
earth.radius = 6371e5   # these are dynamic variables that we can redefine
earth.mass = 5.972e27

Hint: make it so that Planet().radius returns “nan” – this can be set with float('nan').

 

Let’s add inside our class an instance method (with proper indentation):

    def density(self):   # it acts on the class instance
        return self.mass/(4./3.*math.pi*self.radius**3) # [g/cm^3]

Redefine the class, and now:

earth = Planet()
earth.radius = 6371*1e5      # here we need to convert manually
earth.mass = 5.972e24*1e3
import math
earth.density()    # 5.51 g/cm^3

Let’s add another method (remember the indentation!):

    def g(self):   # free fall acceleration
        return 6.67259e-8*self.mass/self.radius**2    # G in [cm^3/g/s^2]

and now

earth = Planet(6371,5.972e24)
mars = Planet(3389.5,6.39e23)
earth.g()              # 981.7 cm/s^2
mars.g() / earth.g()   # 0.378

Let’s add another method (remember the indentation!):

    def describe(self):
        print('density =', self.density(), 'g/cm^3')
        print('free fall =', self.g(), 'cm/s^2')

Redefine the class, and now:

jupyter = Planet(radius=69911, mass=1.898e27)
jupyter.describe()       # should print 1.32 g/cm^3 and 2591 cm/s^2
print(jupyter)           # says it is an object at this memory location (not very descriptive)

Let’s add our last method (remember the indentation!):

    def __str__(self):    # special method to redefine the output of print(self)
        return f"My radius is {self.radius/1e5}km and my mass is {self.mass/1e3}kg"

Redefine the class, and now:

jupyter = Planet(radius=69911, mass=1.898e27)
print(jupyter)        # prints the full sentence

Important: As with any complex object in Python, assigning an instance to a new variable will simply create a pointer, i.e. if you modify one in place, you’ll see the change through the other one too:

new = jupyter
jupyter.mass = -1
new.mass     # also -1

If you want a separate copy:

import copy
new = copy.deepcopy(jupyter)
jupyter.mass = -2
new.mass     # still -1

Inherit from parent classes

Let’s create a child class Moon that would inherit the attributes and methods of Planet class:

class Moon(Planet):    # it inherits all the attributes and methods of the parent process
    pass

phobos = Moon(radius=22.2, mass=1.08e16)
deimos = Moon(radius=12.6, mass=2.0e15)
phobos.g() / earth.g()        # 0.0001489
isinstance(phobos, Moon)         # True
isinstance(phobos, Planet)       # True - all objects of a child class are instances of the parent class
isinstance(jupyter, Planet)      # True
isinstance(jupyter, Moon)        # False
issubclass(Moon,Planet)      # True

Child classes can have their own attributes and methods that are distinct from (i.e. override) the parent class:

class Moon(Planet):
    hostObject = 'Mars'
    def g(self):
        return 'too small to compute accurately'
    
phobos = Moon(radius=22.2, mass=1.08e16)
deimos = Moon(radius=12.6, mass=2.0e15)
mars = Planet(3389.5,6.39e23)
phobos.hostObject, mars.hostObject     # ('Mars', 'Sun')
phobos.g(), mars.g()                   # ('too small to compute accurately', 371.1282569773226)

One thing to keep in mind about class inheritance is that changes to the parent class automatically propagate to child classes (when you follow the sequence of definitions), unless overridden in the child class:

class Parent:
	...
    def __str__(self):
        return "Changed in the parent class"
	...

class Moon(Planet):
    hostObject = 'Mars'
    def g(self):
        return 'too small to compute accurately'

deimos = Moon(radius=12.6, mass=2.0e15)
print(deimos)            # prints "Changed in the parent class"

You can access the parent class namespace from inside a method of a child class by using super():

class Moon(Planet):
    hostObject = 'Mars'
    def parentHost(self):
        return super().hostObject       # will return hostObject of the parent class

deimos = Moon(radius=12.6, mass=2.0e15)
deimos.hostObject, deimos.parentHost()     # ('Mars', 'Sun')

Generators

We already saw that in Python you can loop over a collection using for:

for i in 'weather':
    print(i)
for j in [5,6,7]:
    print(j)

Behind the scenes Python creates an iterator out of a collection. This iterator has a __next__() method, i.e. it does something like:

a = iter('weather')
a.__next__()    # 'w'
a.__next__()    # 'e'
a.__next__()    # 'a'

You can build your own iterator as if you were defining a function. Such function is called a generator in Python:

def cycle():
    yield 1
    yield 'hello'
    yield [1,2,3]

[i for i in cycle()]               # [1, 'hello', [1, 2, 3]]

def square(x):   # `x` is an input string in this generator
    for letter in x:
        yield int(letter)**2       # yields a sequence of numbers that you can cycle through

[i for i in square('12345')]       # [1, 4, 9, 16, 25]

a = square('12345')
[a.__next__() for i in range(3)]   # [1, 4, 9]