Classes#

Classes are the fundamental concept for object oriented programming. A class defines a data type with both data and functions that can operate on the data. An object is an instance of a class. Each object will have its own namespace (separate from other instances of the class and other functions, etc. in your program).

We use the dot operator, ., to access members of the class (data or functions). We’ve already been doing this a lot, strings, ints, lists, … are all objects in python.

A simple class#

Here’s a class that holds some student info

class Student:
    def __init__(self, name, grade=None):
        self.name = name
        self.grade = grade
a = Student("Mike")
print(a.name)
print(a.grade)
Mike
None

Let’s create a bunch of them, stored in a list

students = []
students.append(Student("fry", "F-"))
students.append(Student("leela", "A"))
students.append(Student("zoidberg", "F"))
students.append(Student("hubert", "C+"))
students.append(Student("bender", "B"))
students.append(Student("calculon", "C"))
students.append(Student("amy", "A"))
students.append(Student("hermes", "A"))
students.append(Student("scruffy", "D"))
students.append(Student("flexo", "F"))
students.append(Student("morbo", "D"))
students.append(Student("hypnotoad", "A+"))
students.append(Student("zapp", "Q"))

Tip

The object self is the first argument in all of the methods of the class. This refers to the instance of the class we are working on itself. It is roughly equivalent to the *this pointer in C++ classes.

Exercise

Loop over the students in the students list and print out the name and grade of each student, one per line.

We can use list comprehensions with our list of objects. For example, let’s find all the students who have A’s

As = [q.name for q in students if q.grade.startswith("A")]
As
['leela', 'amy', 'hermes', 'hypnotoad']

Operators#

We can define operations like + and - that work on our objects. Here’s a simple example of currency—we keep track of the country and the amount

class Currency:
    """ a simple class to hold foreign currency """
    
    def __init__(self, amount, country="US"):
        self.amount = amount
        self.country = country
        
    def __add__(self, other):
        return Currency(self.amount + other.amount, country=self.country)

    def __sub__(self, other):
        return Currency(self.amount - other.amount, country=self.country)

    def __str__(self):
        return f"{self.amount} {self.country}"

We can now create some monetary amounts for different countries

d1 = Currency(10, "US")
d2 = Currency(15, "US")
print(d2 - d1)
5 US

Note

When we print our Currency object, python calls the __str__() method.

Exercise

As written, our Currency class has a bug—it does not check whether the amounts are in the same country before adding. Modify the __add__ method to first check if the countries are the same. If they are, return the new Currency object with the sum, otherwise, return None.

Mathematical vectors#

Here we write a class to represent 2-d vectors. Vectors have a direction and a magnitude. We can represent them as a pair of numbers, representing the x and y lengths. We’ll use a tuple internally for this

We want our class to do all the basic operations we do with vectors: add them, multiply by a scalar, cross product, dot product, return the magnitude, etc.

We’ll use the math module to provide some basic functions we might need (like sqrt)

This example will show us how to overload the standard operations in python. Here’s a list of the builtin methods:

https://docs.python.org/3/reference/datamodel.html

Tip

To make it really clear what’s being called when, I’ve added prints in each of the functions,

import math
class Vector:
    """ a general two-dimensional vector """
    
    def __init__(self, x, y):
        print("in __init__")
        self.x = x
        self.y = y
        
    def __str__(self):
        print("in __str__")        
        return f"({self.x} î + {self.y} ĵ)"
    
    def __repr__(self):
        print("in __repr__")        
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other):
        print("in __add__")        
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        else:
            # it doesn't make sense to add anything but two vectors
            raise NotImplementedError(f"unable to add a {type(other)} to a Vector")

    def __sub__(self, other):
        print("in __sub__")        
        if isinstance(other, Vector):
            return Vector(self.x - other.x, self.y - other.y)
        else:
            # it doesn't make sense to add anything but two vectors            
            raise NotImplementedError(f"unable to add a {type(other)} to a Vector")

    def __mul__(self, other):
        print("in __mul__")        
        if isinstance(other, int) or isinstance(other, float):
            # scalar multiplication changes the magnitude
            return Vector(other*self.x, other*self.y)
        else:
            raise NotImplementedError("unable to multiply two Vectors")

    def __matmul__(self, other):
        print("in __matmul__")
        # a dot product
        if isinstance(other, Vector):
            return self.x*other.x + self.y*other.y
        else:            
            raise NotImplementedError("matrix multiplication not defined")

    def __rmul__(self, other):
        print("in __rmul__")        
        return self.__mul__(other)

    def __truediv__(self, other):
        print("in __truediv__")        
        # we only know how to multiply by a scalar
        if isinstance(other, int) or isinstance(other, float):
            return Vector(self.x/other, self.y/other)

    def __abs__(self):
        print("in __abs__")        
        return math.sqrt(self.x**2 + self.y**2)

    def __neg__(self):
        print("in __neg__")        
        return Vector(-self.x, -self.y)

    def cross(self, other):
        # a vector cross product -- we return the magnitude, since it will
        # be in the z-direction, but we are only 2-d 
        return abs(self.x*other.y - self.y*other.x)

Here, both __str__ and __repr__ return something that is readable. The convection is what __str__ is human readable while __repr__ should be a form that can be used to recreate the object (e.g., via eval()). See:

http://stackoverflow.com/questions/1436703/difference-between-str-and-repr-in-python

v = Vector(1,2)
v
in __init__
in __repr__
Vector(1, 2)
print(v)
in __str__
(1 î + 2 ĵ)

Vectors have length, and we’ll use the abs() builtin to provide the magnitude. For a vector:

\[{\bf v} = \alpha \hat{\bf i} + \beta \hat{\bf j}\]

we have:

\[|{\bf v}| = \sqrt{\alpha^2 + \beta^2}\]
abs(v)
in __abs__
2.23606797749979

Let’s look at mathematical operations on vectors now. We want to be able to add and subtract two vectors as well as multiply and divide by a scalar.

u = Vector(3,5)
in __init__
w = u + v
print(w)
in __add__
in __init__
in __str__
(4 î + 7 ĵ)
u - v
in __sub__
in __init__
in __repr__
Vector(2, 3)

It doesn’t make sense to add a scalar to a vector, so we didn’t implement this – what happens?

u + 2.0
in __add__
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[15], line 1
----> 1 u + 2.0

Cell In[8], line 23, in Vector.__add__(self, other)
     20     return Vector(self.x + other.x, self.y + other.y)
     21 else:
     22     # it doesn't make sense to add anything but two vectors
---> 23     raise NotImplementedError(f"unable to add a {type(other)} to a Vector")

NotImplementedError: unable to add a <class 'float'> to a Vector

Now multiplication. It makes sense to multiply by a scalar, but there are multiple ways to define multiplication of two vectors.

Note that python provides both a __mul__ and a __rmul__ function to define what happens when we multiply a vector by a quantity and what happens when we multiply something else by a vector.

u*2.0
in __mul__
in __init__
in __repr__
Vector(6.0, 10.0)
2.0*u
in __rmul__
in __mul__
in __init__
in __repr__
Vector(6.0, 10.0)

and division: __truediv__ is the normal way of division /, while __floordiv__ is the more equivalent to C++/Fortran (truncated to integer), also enabled via //.

u/5.0
in __truediv__
in __init__
in __repr__
Vector(0.6, 1.0)
5.0/u
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 5.0/u

TypeError: unsupported operand type(s) for /: 'float' and 'Vector'

Python has the multiplication operator, @ – we’ll use this to implement a dot product between two vectors:

u @ v
in __matmul__
13

For a cross product, we don’t have an obvious operator, so we’ll use a function. For 2-d vectors, this will result in a scalar

u.cross(v)
1

Finally, negation is a separate operation:

-u
in __neg__
in __init__
in __repr__
Vector(-3, -5)

C++ version

Operator overloading is not unique to python. A C++ version of this vector class can be found here: zingale/computational_astrophysics