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 withself.
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-instr()
function and by theprint()
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-inrepr()
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 |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:#
Create a class
Rectangle
that has attributes for width and height. Include methods to calculate the area and perimeter of the rectangle.
Click to reveal the solution!
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
# Usage:
my_rectangle = Rectangle(5, 10)
print("Area:", my_rectangle.area())
print("Perimeter:", my_rectangle.perimeter())
Create a class
BankAccount
that has attributes for account holder’s name and balance. Include methods to deposit, withdraw, and check the balance.
Click to reveal the solution!
class BankAccount:
def __init__(self, account_holder, initial_balance=0):
self.account_holder = account_holder
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
print(f"Deposited {amount}. New balance: {self.balance}")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds")
else:
self.balance -= amount
print(f"Withdrew {amount}. New balance: {self.balance}")
def check_balance(self):
print(f"Account holder: {self.account_holder}, Balance: {self.balance}")
# Usage:
account = BankAccount("Alice", 100)
account.deposit(50)
account.withdraw(30)
account.check_balance()
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.
Click to reveal the solution!
class Library:
def __init__(self, name):
self.name = name
self.books = []
def add_book(self, book):
self.books.append(book)
def remove_book(self, book):
if book in self.books:
self.books.remove(book)
def display_books(self):
print(f"Books in {self.name}:")
for book in self.books:
print(f" - {book}")
# Usage:
library = Library("City Library")
library.add_book("1984")
library.add_book("To Kill a Mockingbird")
library.display_books()
Exercises using inheritance:#
Create a base class
Animal
with attributes for name and age. Include a method to make a sound. Then, create subclassesDog
andCat
that inherit fromAnimal
and override the sound method to bark and meow, respectively.
Click to reveal the solution!
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def make_sound(self):
pass
class Dog(Animal):
def make_sound(self):
return "Bark!"
class Cat(Animal):
def make_sound(self):
return "Meow!"
# Usage:
dog = Dog("Buddy", 3)
cat = Cat("Whiskers", 2)
print(dog.make_sound())
print(cat.make_sound())
Create a base class
Vehicle
with attributes for make, model, and year. Include a method to display vehicle information. Then, create subclassesCar
andTruck
that inherit fromVehicle
and add specific attributes (e.g., number of doors forCar
, payload capacity forTruck
).
Click to reveal the solution!
class Vehicle:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def display_info(self):
print(f"Vehicle Information: {self.year} {self.make} {self.model}")
class Car(Vehicle):
def __init__(self, make, model, year, num_doors):
super().__init__(make, model, year)
self.num_doors = num_doors
def display_info(self):
super().display_info()
print(f"Number of doors: {self.num_doors}")
class Truck(Vehicle):
def __init__(self, make, model, year, payload_capacity):
super().__init__(make, model, year)
self.payload_capacity = payload_capacity
def display_info(self):
super().display_info()
print(f"Payload capacity: {self.payload_capacity} tons")
# Usage:
vehicle = Vehicle("Honda", "Civic", 2020)
vehicle.display_info()
car = Car("Toyota", "Camry", 2021, 4)
car.display_info()
truck = Truck("Ford", "F-150", 2019, 2)
truck.display_info()
Exercises with classes, physics:#
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.
Click to reveal the solution!
class Particle:
def __init__(self, mass, charge, position):
self.mass = mass
self.charge = charge
self.position = position # position is a tuple (x, y, z)
def kinetic_energy(self, velocity):
return 0.5 * self.mass * velocity**2
def potential_energy(self, height):
g = 9.81 # acceleration due to gravity in m/s^2
return self.mass * g * height
# Usage:
particle = Particle(1.0, 1.0, (0, 0, 0))
print("Kinetic Energy:", particle.kinetic_energy(10))
print("Potential Energy:", particle.potential_energy(5))
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.
Click to reveal the solution!
class ElectricField:
def __init__(self, field_strength, direction):
self.field_strength = field_strength # in N/C
self.direction = direction # direction is a tuple (x, y, z)
def force_on_particle(self, particle):
fx = self.field_strength * self.direction[0] * particle.charge
fy = self.field_strength * self.direction[1] * particle.charge
fz = self.field_strength * self.direction[2] * particle.charge
return (fx, fy, fz)
# Usage:
field = ElectricField(10, (1, 0, 0))
particle = Particle(1.0, 1.0, (0, 0, 0))
print("Force on Particle:", field.force_on_particle(particle))
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.
Click to reveal the solution!
class Wave:
def __init__(self, amplitude, frequency, wavelength):
self.amplitude = amplitude # in meters
self.frequency = frequency # in Hz
self.wavelength = wavelength # in meters
def wave_speed(self):
return self.frequency * self.wavelength
def energy(self):
return 0.5 * (self.amplitude ** 2) * (self.frequency ** 2)
# Usage:
wave = Wave(0.1, 5, 2)
print("Wave Speed:", wave.wave_speed())
print("Wave Energy:", wave.energy())
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 librarynumpy
for numerical operations (import numpy as np
,np.pi
for \(\pi\), andnp.exp()
to calculate the exponential function).
Click to reveal the solution!
class QuantumState:
def __init__(self, wavefunction, energy_level):
self.wavefunction = wavefunction # wavefunction is a callable function
self.energy_level = energy_level
def probability_density(self, position):
psi = self.wavefunction(position)
return abs(psi)**2
def expectation_value_position(self, position_range):
dx = position_range[1] - position_range[0]
integral = sum(self.probability_density(x) * x * dx for x in position_range)
return integral
# Usage:
import numpy as np
wavefunction = lambda x: (2 / np.pi)**0.25 * np.exp(-x**2) # Example wavefunction
quantum_state = QuantumState(wavefunction, 1)
print("Probability Density at x=0:", quantum_state.probability_density(0))
print("Expectation Value of Position:", quantum_state.expectation_value_position(np.linspace(-5, 5, 1000)))
Create a class
Circuit
that has attributes for resistance, capacitance, and inductance. Include methods to calculate the impedance and resonant frequency of the circuit.
Click to reveal the solution!
class Circuit:
def __init__(self, resistance, capacitance, inductance):
self.resistance = resistance # in ohms
self.capacitance = capacitance # in farads
self.inductance = inductance # in henrys
def impedance(self, frequency):
omega = 2 * 3.14159 * frequency
z_capacitive = 1 / (1j * omega * self.capacitance)
z_inductive = 1j * omega * self.inductance
return self.resistance + z_capacitive + z_inductive
def resonant_frequency(self):
return 1 / (2 * 3.14159 * (self.inductance * self.capacitance) ** 0.5)
# Usage:
circuit = Circuit(100, 1e-6, 1e-3)
print("Impedance at 1000 Hz:", circuit.impedance(1000))
print("Resonant Frequency:", circuit.resonant_frequency())
Exercises in classes and inheritance:#
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 subclassesElectron
andProton
that inherit fromQuantumParticle
and add specific attributes (e.g., atomic number forProton
).
Click to reveal the solution!
class QuantumParticle:
def __init__(self, mass, charge, spin):
self.mass = mass # in kg
self.charge = charge # in coulombs
self.spin = spin # in ħ units
def de_broglie_wavelength(self, momentum):
h = 6.62607015e-34 # Planck's constant in J·s
return h / momentum
def energy(self, frequency):
h = 6.62607015e-34 # Planck's constant in J·s
return h * frequency
class Electron(QuantumParticle):
def __init__(self):
super().__init__(mass=9.10938356e-31, charge=-1.602176634e-19, spin=0.5)
class Proton(QuantumParticle):
def __init__(self):
super().__init__(mass=1.6726219e-27, charge=1.602176634e-19, spin=0.5)
self.atomic_number = 1
# Usage:
electron = Electron()
proton = Proton()
print("Electron de Broglie wavelength:", electron.de_broglie_wavelength(1e-24))
print("Proton energy at 1e14 Hz:", proton.energy(1e14))
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 subclassesHarmonicOscillator
andParticleInBox
that inherit fromQuantumSystem
and implement specific Hamiltonians and wavefunctions.
Click to reveal the solution!
class QuantumSystem:
def __init__(self, hamiltonian, wavefunction):
self.hamiltonian = hamiltonian # Hamiltonian is a callable function
self.wavefunction = wavefunction # Wavefunction is a callable function
def time_evolution(self, time):
# Placeholder for time evolution calculation
pass
def expectation_value(self, observable, position_range):
dx = position_range[1] - position_range[0]
integral = sum(self.wavefunction(x).conjugate() * observable(x) * self.wavefunction(x) * dx for x in position_range)
return integral
class HarmonicOscillator(QuantumSystem):
def __init__(self, mass, frequency):
hamiltonian = lambda x: (1/2) * mass * (frequency**2) * (x**2) # Simplified Hamiltonian
wavefunction = lambda x: (mass * frequency / 3.14159)**0.25 * (2.71828)**(-mass * frequency * (x**2) / 2) # Ground state wavefunction
super().__init__(hamiltonian, wavefunction)
class ParticleInBox(QuantumSystem):
def __init__(self, box_length):
hamiltonian = lambda x: 0 if 0 < x < box_length else float('inf') # Infinite potential well
wavefunction = lambda x: (2 / box_length)**0.5 * (3.14159 / box_length)**0.5 * (3.14159 * x / box_length) # Ground state wavefunction
super().__init__(hamiltonian, wavefunction)
# Usage:
ho = HarmonicOscillator(mass=9.11e-31, frequency=1e14)
pib = ParticleInBox(box_length=1e-9)
print("Harmonic Oscillator expectation value:", ho.expectation_value(lambda x: x, np.linspace(-1e-9, 1e-9, 1000)))
print("Particle in Box expectation value:", pib.expectation_value(lambda x: x, np.linspace(0, 1e-9, 1000)))
Create a base class
QuantumField
with attributes for field strength and potential. Include methods to calculate the field equations and energy density. Then, create subclassesElectromagneticField
andScalarField
that inherit fromQuantumField
and implement specific field equations and potentials.
Click to reveal the solution!
class QuantumField:
def __init__(self, field_strength, potential):
self.field_strength = field_strength # in appropriate units
self.potential = potential # Potential is a callable function
def field_equations(self):
# Placeholder for field equations calculation
pass
def energy_density(self):
# Placeholder for energy density calculation
pass
class ElectromagneticField(QuantumField):
def __init__(self, field_strength, potential):
super().__init__(field_strength, potential)
class ScalarField(QuantumField):
def __init__(self, field_strength, potential):
super().__init__(field_strength, potential)
# Usage:
em_field = ElectromagneticField(field_strength=1.0, potential=lambda x: x**2)
scalar_field = ScalarField(field_strength=0.5, potential=lambda x: x**4)
print("Electromagnetic Field Strength:", em_field.field_strength)
print("Scalar Field Strength:", scalar_field.field_strength)