Part 2: Classes#

2.1 Object-Oriented Programming (OOP) Concepts#

Classes and Objects in Python are at the core of Object-Oriented Programming (OOP). They provide a way of bundling data and functionality all together. OOP permits the grouping of related properties and behaviors into individual objects. You can think of objects as real-world entities that have attributes (properties) and behaviors (methods).

It is based on the concept of “objects”, which can contain data in the form of fields (often known as attributes or properties), and code in the form of procedures (often known as methods).

It also exists in other programming languages such as Java, C++, and Ruby, and it allows for:

  • Encapsulation: Bundling data and methods that operate on the data within one unit, e.g.: a class in Python.

  • Inheritance: Creating new classes based on previously existing classes, allowing for code reuse and the creation of a hierarchical relationship between classes.

2.1.1 Classes and instances#

Classes allow you to create user-defined data structures that encapsulate data and functionality together. By creating a new class, you create a new type of object, with functions (the same functions we saw in the previous section) which identify the behaviors and actions of the objects created from the class.

An instance is a specific object created from a particular class. Each class instance can have attributes attached to it for maintaining its state. When a class is defined, no memory is allocated until an instance of the class is created.

We can think of classes as categories of objects, and instances as the specific objects that belong to those categories. (For example, if we have a class called Dog, then an instance of that class could be a specific dog named “Buddy”. We will see more examples below.)

2.1.2 Defining A Class#

Classes create new types of objects, and these objects can have attributes (data) associated with them, as well as behaviors. The behaviors are implemented as functions.

Syntax:

class ClassName:
    def __init__(self, parameters):
        # Initialization code here
    
    def method_name(self, parameters):
        # Method code here

Here is how we define a class and methods in Python:

class MyClass:
    def __init__(self, name):
        self.name = name  # Attribute

    def hello_method(self):  # Method
        print(f"Hello, {self.name}!")

    # You can add more methods here
    def goodbye_method(self):
        print(f"Goodbye, {self.name}!")

In this example, MyClass is a class with an __init__ method (a special method called a constructor or magic method, which is called when an instance of the class is created) and a method called hello_method. The self parameter in the methods refers to the instance of the class itself.

Another example:

class Dog:
    def __init__(self, name, age):
        self.name = name  # Attribute1
        self.age = age    # Attribute2
    
    def bark(self):       # Method1
        print(f"{self.name} says Woof!")
    
    def get_age(self):     # Method2
        return self.age

In this example, we define a class named Dog with an __init__ method that initializes the name and age attributes, and two methods: bark, which prints a barking message, and get_age, which returns the age of the dog.

We can use dir() to obtain a list of all the attributes and methods of a class or an instance:

print(dir(MyClass))  # Lists attributes and methods of the MyClass class
print(dir(Dog))  # Lists attributes and methods of the Dog class

Note all the special methods (with double underscores) that are automatically created for classes. These methods are used internally by Python to implement various behaviors, such as object creation, representation, and comparison. We can override these methods to customize the behavior of our classes. (See section on Operator Overloading below.)

2.1.3 Creating Instances of a Class#

To create an instance of a class, you call the class as if it were a function, passing any arguments that the __init__ method requires. For example:

my_instance = MyClass("Alice")
my_instance.hello_method()  # Output: Hello, Alice!

We can create as many instances of a class as we want:

# First instance of Dog
dog1 = Dog("Buddy", 3)

# Second instance of Dog
dog2 = Dog("Max", 5)

# Calling methods on the instances
dog1.bark()  # Output: Buddy says Woof!

print(dog2.get_age())  # Output: 5

2.1.4 Attributes and Methods#

Attributes are variables that belong to an instance of a class. They are used to store data about the object. Methods are functions that belong to an instance of a class and can operate on the object’s attributes. You can access attributes and methods using the dot (.) notation:

print(dog1.name)  # Accessing attribute
dog1.bark()       # Calling method

Note the difference between calling attributes and methods. Attributes hold data related to the instance (dog1 and its name), and to access them we don’t use (). While methods perform actions or operations (dog1 barks - think of this of a mathematical operation or transformation), and when calling them we do use (). This is because methods are functions, and functions can also make use of other arguments as we saw in the previous section.

2.1.5 The __init__ Method#

The __init__ method is a special method in Python classes. It is called when an instance (object) of the class is created. The purpose of the __init__ method is to initialize the attributes of the new object. It is often referred to as the constructor of the class.

Here is an example of a class with an __init__ method:

class Person:
    def __init__(self, name, age):
        self.name = name  # Initialize the name attribute
        self.age = age    # Initialize the age attribute
    def greet(self):
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

When you create an instance of the Person class, the __init__ method is automatically called, and you can pass values for the name and age parameters:

person1 = Person("Alice", 30)
person1.greet()  # Output: Hello, my name is Alice and I am 30 years old.

2.1.6 Instance Variables and Class Variables#

We can find two types of variables in a class:

  • Instance Variables: These are variables that are unique to each instance of a class. They are defined within the __init__ method and are prefixed with self. to indicate that they belong to the instance. Each instance of the class has its own copy of these variables.

  • Class Variables: These are variables that are shared among all instances of a class. They are defined within the class but outside of any methods. Class variables are accessed using the class name or through an instance.

class Dog:
    species = "Canis familiaris"  # Class variable

    def __init__(self, name, age):
        self.name = name  # Instance variable
        self.age = age    # Instance variable

    def bark(self):
        print(f"{self.name} says Woof!")

    def get_age(self):
        return self.age # Instance method

# Creating instances of the Dog class
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

print(dog1.species)  # Accessing class variable via instance
print(Dog.species)   # Accessing class variable via class name
print(dog1.name)     # Accessing instance variable
print(dog2.name)     # Accessing instance variable

2.1.7 The self Parameter#

In Python, the self parameter in class methods refers to the instance of the class itself. It is used to access attributes and methods of the class within its own methods. When you define a method in a class, you must include self as the first parameter, even though you do not pass it explicitly when calling the method.

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

    def bark(self):
        print(f"{self.name} says Woof!")

    def get_age(self):
        return self.age

# Creating an instance of the Dog class
dog1 = Dog("Buddy", 3)

# Calling methods on the instance
dog1.bark()  # Output: Buddy says Woof!
print(dog1.get_age())  # Output: 3

2.1.8 The __str__ and __repr__ Methods#

The __str__ and __repr__ methods are special methods in Python that define how instances of a class are represented as strings. They are useful for debugging and logging purposes.

  • The __str__ method is intended to provide a “pretty” or user-friendly string representation of the object. It is called by the built-in str() function and by the print() function.

  • The __repr__ method is intended to provide a more detailed and unambiguous string representation of the object, which can be used to recreate the object. It is called by the built-in repr() function and is used in the interactive interpreter.

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

    def __str__(self):
        return f"{self.name} is {self.age} years old."

    def __repr__(self):
        return f"Dog(name={self.name!r}, age={self.age!r})" # !r calls repr() on the attribute  

# Creating an instance of the Dog class
dog1 = Dog("Buddy", 3)

print(dog1)          # Calls __str__: Output: Buddy is 3 years old
print(repr(dog1))    # Calls __repr__: Output: Dog(name='Buddy', age=3)

Example#

See the following example of a class called WASP, where you they initialize the class with a list of specific parameters. WASP program

2.2 Advanced OOP Concepts#

2.2.1 Inheritance#

Inheritance is a fundamental concept in object-oriented programming that allows a class (called the child or subclass) to inherit attributes and methods from another class (called the parent or superclass). This promotes code reuse and establishes a hierarchical relationship between classes.

You can inherit from a parent class by creating a new class and putting the parent class name in parentheses after the new class name. The child class will then inherit all the attributes and methods of the parent class.

Syntax:

class Parent:
    def __init__(self, name):
        self.name = name

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)
        self.age = age

Here, Child inherits data from Parent. The super() function is used to call the __init__ method of the parent class to initialize the inherited attributes. It allows you to initialize the attributes of the parent class, in addition to any attributes defined in the child class.

Example:

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

    def speak(self):
        raise NotImplementedError("Subclasses must implement this method.")

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"    

# Creating instances of the Dog and Cat classes
dog = Dog("Buddy")
cat = Cat("Whiskers")

print(dog.speak())  # Output: Buddy says Woof!
print(cat.speak())  # Output: Whiskers says Meow!

We can see that both Dog and Cat classes inherit from the Animal class. They both implement the speak method, which is defined as an abstract method in the Animal class.

2.2.2 Operator Overloading#

Operator overloading allows you to define custom behavior for standard operators (like +, -, *, etc.) when they are used with instances of your classes. This is done by defining special methods in your class that correspond to the operators you want to overload.

For example, to overload the + operator, you would define the __add__ method in your class. Here is an example of a simple Vector class that overloads the + operator to add two vectors together:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):             # Here `other` is another instance of Vector
        if isinstance(other, Vector):     # Check if `other` is an instance of Vector
            return Vector(self.x + other.x, self.y + other.y)
        return NotImplemented             # This will be returned if `other` is not a Vector instance

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

# Creating instances of the Vector class
v1 = Vector(2, 3)
v2 = Vector(5, 7)

v3 = v1 + v2     # This calls the __add__ method
print(v3)        # Output: Vector(7, 10)

In this example, we defined the __add__ method to specify how two Vector instances should be added together using the + operator. When we use v1 + v2, it calls the __add__ method, which returns a new Vector instance with the summed components.

The __add__ method corresponds to the operator +. But, Similary we have:

Operator

Operator Method

+

__add__(self, other)

-

__sub__(self, other)

*

__mul__(self, other)

/

__truediv__(self, other)

//

__floordiv__(self, other)

%

__mod__(self, other)

**

__pow__(self, other)

>>

__rshift__(self, other)

<<

__lshift__(self, other)

&

__and__(self, other)

|

__or__(self, other)

^

__xor__(self, other)

2.2.3 Some other useful methods: The __getitem__ and __setitem__ Methods#

The __getitem__ and __setitem__ methods are special methods in Python that allow you to define custom behavior for indexing and slicing operations on instances of your class. They enable your class to behave like a container (e.g., list, dictionary) that supports item access and assignment using square brackets ([]).

  • The __getitem__ method is called when you use the indexing operator to retrieve an item from an instance of your class.

  • The __setitem__ method is called when you use the indexing operator to set an item in an instance of your class.

class Vector:
    def __init__(self, x, y):   # Remember, self refers to the instance of the class, then x and y are parameters
        self.x = x
        self.y = y

    def __getitem__(self, index):  # index is the position of the item we want to access
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        else:
            raise IndexError("Index out of range") # Raise an error if the index is out of range

    def __setitem__(self, index, value):  # index is the position of the item we want to set, value is the new value we want to set
        if index == 0:
            self.x = value
        elif index == 1:
            self.y = value
        else:
            raise IndexError("Index out of range") # Again, raise an error if the index is out of range

    def __repr__(self):
        return f"Vector({self.x}, {self.y})" # This is just to have a nice printing of the object

In this example, we defined the __getitem__ and __setitem__ methods to allow indexing into a Vector instance. The __getitem__ method retrieves the x or y component based on the index, while the __setitem__ method allows you to set the x or y component.

v = Vector(10, 20)
print(v[0])  # Calls __getitem__: Output: 10
v[0] = 15    # Calls __setitem__
print(v[0])  # Output: 15   
print(v)     # Output: Vector(15, 20)

Exercises#

Exercises building classes:#

  1. Create a class Rectangle that has attributes for width and height. Include methods to calculate the area and perimeter of the rectangle.

  1. Create a class BankAccount that has attributes for account holder’s name and balance. Include methods to deposit, withdraw, and check the balance.

  1. Create a class Library that has attributes for name and a list of books (each book can be represented as a string). Include methods to add a book, remove a book, and display all books in the library.

Exercises using inheritance:#

  1. Create a base class Animal with attributes for name and age. Include a method to make a sound. Then, create subclasses Dog and Cat that inherit from Animal and override the sound method to bark and meow, respectively.

  1. Create a base class Vehicle with attributes for make, model, and year. Include a method to display vehicle information. Then, create subclasses Car and Truck that inherit from Vehicle and add specific attributes (e.g., number of doors for Car, payload capacity for Truck).

Exercises with classes, physics:#

  1. Create a class Particle that has attributes for mass, charge, and position (a tuple of x, y, z coordinates). Include methods to calculate the kinetic energy and potential energy of the particle in a gravitational field.

  1. Create a class ElectricField that has attributes for field strength and direction (a tuple of x, y, z components). Include methods to calculate the force on a charged particle placed in the field.

  1. Create a class Wave that has attributes for amplitude, frequency, and wavelength. Include methods to calculate the wave speed and the energy of the wave.

  1. Create a class QuantumState that has attributes for the wavefunction (a function of position) and energy level. Include methods to calculate the probability density and expectation value of position. Hint: you might need to use numerical integration for the expectation value. You can use the library numpy for numerical operations (import numpy as np, np.pi for \(\pi\), and np.exp() to calculate the exponential function).

  1. Create a class Circuit that has attributes for resistance, capacitance, and inductance. Include methods to calculate the impedance and resonant frequency of the circuit.

Exercises in classes and inheritance:#

  1. Create a base class QuantumParticle with attributes for mass, charge, and spin. Include methods to calculate the de Broglie wavelength and energy of the particle. Then, create subclasses Electron and Proton that inherit from QuantumParticle and add specific attributes (e.g., atomic number for Proton).

  1. Create a base class QuantumSystem with attributes for Hamiltonian and wavefunction. Include methods to calculate the time evolution of the system and expectation values of observables. Then, create subclasses HarmonicOscillator and ParticleInBox that inherit from QuantumSystem and implement specific Hamiltonians and wavefunctions.

  1. Create a base class QuantumField with attributes for field strength and potential. Include methods to calculate the field equations and energy density. Then, create subclasses ElectromagneticField and ScalarField that inherit from QuantumField and implement specific field equations and potentials.