Foundations of Object-Oriented Programming

1

Foundations of Object-Oriented Programming

▶

Object-oriented programming, almost universally abbreviated as OOP, is a way of organizing and writing software that has become one of the dominant paradigms in professional engineering and scientific computing. To understand why it exists and why it matters, it helps to first appreciate the problem it solves. In procedural or script-based programming, code is arranged as a sequence of instructions operating on loose variables and data structures. As programs grow, this approach produces sprawling, tangled code where the relationship between a piece of data and the functions that work on it exists only in the programmer's mind. OOP addresses this by bundling data and the operations that act on that data together into a single, coherent unit called an object. This shift in perspective — from "what do I do next?" to "what kind of thing am I working with, and what can it do?" — is the intellectual core of object-oriented thinking.

In an engineering context, the benefits of this are immediately tangible. Consider a structural analysis program that must track dozens of beam elements, each with its own cross-sectional area, material properties, length, and loading conditions. Without OOP, you might maintain separate lists or dictionaries for each property, and every function that processes beams must carefully coordinate all of these. With OOP, each beam becomes a single object that carries all of its own data and knows how to perform its own calculations. Adding a new beam is a single, clean operation. Modifying how beams compute their deflection is a change in one well-defined place. The program becomes modular — built from discrete, self-contained components that can be understood, tested, and modified independently of one another.

OOP also promotes code reuse in a structured way. Once a class is written and tested, it can be used anywhere that kind of object is needed, both within the same project and in future projects. Shared structure and well-defined interfaces mean that one engineer can write a class and another can use it confidently, knowing exactly what data and operations it provides. Python is particularly well suited to this style of programming. It supports OOP natively, meaning classes and objects are first-class features of the language, not add-ons. At the same time, Python does not force you into OOP when simpler approaches suffice, making it practical for everything from quick engineering scripts to large, multi-module simulation packages. Libraries that engineers use every day — NumPy, SciPy, Pandas, Matplotlib — are all built extensively on OOP principles.

The fundamental building block of OOP is the class. A class is best understood as a blueprint or a template. It defines what data a certain kind of object will hold and what operations that object will be able to perform. The class itself is not a specific object; it is the description from which specific objects are created. A single class definition can be the source of many individual objects, just as a single engineering drawing can be the blueprint for many physical components.

An individual object created from a class is called an instance. Each instance is a unique, independent entity in memory that follows the structure defined by its class but holds its own specific data values. For example, you might have a class called Resistor that defines what data a resistor holds (resistance value, tolerance, power rating) and what it can do (calculate maximum current, check if it is within tolerance). You can then create many distinct Resistor instances, each representing a different physical component with its own values, but all sharing the same structure and behavior defined by the class.

Instantiation — the act of creating an instance from a class — is performed in Python by calling the class name as though it were a function. Python handles all of the underlying memory allocation and setup automatically. Here is a minimal illustration:

class Resistor:
    pass   # empty class for now

r1 = Resistor()   # create one instance
r2 = Resistor()   # create a second, independent instance

print(r1)   # <__main__.Resistor object at 0x...>
print(r2)   # <__main__.Resistor object at 0x...> (different address)

Even with an empty class body, Python creates two fully separate objects in memory. The class acts as the factory; calling it produces a fresh instance each time. In real engineering code the class would of course contain useful content, which brings us to the two central concepts that give classes their substance: attributes and methods.

Attributes are the data that an object carries. There are two varieties, and understanding the difference between them is important. Instance attributes are defined on each individual object and hold values specific to that object. If you have two beam instances, each one has its own length, its own area, its own material. Changing the length of one beam does not affect the other. Instance attributes are created by assigning to self.attribute_name inside the class, typically inside the constructor method. Class attributes, by contrast, are defined directly in the class body, outside of any method. They are shared by every instance of the class. A class attribute is useful for data that is genuinely common to all instances — for example, the gravitational acceleration constant used by every object in a structural analysis class, or a default material density used when none is specified.

class BeamElement:
    # Class attribute — shared by all instances
    default_material = "steel"

    def __init__(self, length, area):
        # Instance attributes — unique to each object
        self.length = length    # metres
        self.area = area        # square metres

b1 = BeamElement(3.0, 0.005)
b2 = BeamElement(5.5, 0.008)

print(b1.length)           # 3.0
print(b2.length)           # 5.5
print(b1.default_material) # steel  (from class)
print(b2.default_material) # steel  (same class attribute)

Attributes are accessed using dot notation: you write the object's name, a dot, and the attribute name. This same notation works whether you are reading an attribute value, setting it, or even adding a new attribute to a specific instance after creation (though it is generally good practice to declare all attributes in the constructor so that an object's structure is predictable and clearly documented in one place). In engineering applications, giving attributes meaningful, descriptive names — cross_sectional_area rather than a, yield_strength rather than ys — pays dividends in readability and reduces errors when code is revisited weeks or months later.

Methods are the behaviors of an object — functions that are defined inside the class body and that operate on the object's data. In Python, methods are defined using the standard def keyword, but they receive a special first parameter by convention named self. This parameter is not passed explicitly by the caller; Python passes it automatically and it refers to the specific instance on which the method is being called. Through self, a method can read and modify the instance's own attributes, effectively giving each object private access to its own data.

class BeamElement:
    default_material = "steel"
    elastic_modulus = 200e9    # Pa, for steel

    def __init__(self, length, area):
        self.length = length
        self.area = area
        self.axial_load = 0.0  # initialised to zero

    def apply_load(self, force):
        """Store an axial force on the beam."""
        self.axial_load = force

    def axial_stress(self):
        """Return the axial stress in Pascals."""
        if self.area == 0:
            raise ValueError("Cross-sectional area cannot be zero.")
        return self.axial_load / self.area

    def axial_strain(self):
        """Return the dimensionless axial strain."""
        return self.axial_stress() / BeamElement.elastic_modulus

b = BeamElement(length=2.0, area=0.004)
b.apply_load(80000)   # 80 kN
print(b.axial_stress())  # 20000000.0 Pa  (20 MPa)
print(b.axial_strain())  # 0.0001

Notice several things about this example. The method apply_load modifies the object's own axial_load attribute. The method axial_stress reads attributes and computes a result. The method axial_strain calls another method of the same object using self.axial_stress(), demonstrating how methods can build on one another. All of the calculation logic that belongs to a beam element lives inside the BeamElement class — it is encapsulated there. Anyone using this class does not need to remember the formula for axial stress; they simply call the method. This is one of the most practical advantages of OOP: the complexity is hidden behind a clean, understandable interface, and the names of the methods make the code nearly self-documenting.

The most important method in any class is the constructor, which in Python is the specially named method __init__. The double underscores on either side signal that this is a dunder method (short for "double underscore"), also called a magic method — a method that Python calls automatically at specific moments rather than one you call explicitly in your own code. The __init__ method is called automatically the instant a new instance is created. Any parameters you pass when you call the class name to create an instance are passed directly into __init__ (after self, which is always provided automatically).

The primary purpose of __init__ is to set up the instance in a valid, fully initialized state. By convention, every instance attribute the object will ever use should be declared inside __init__. This means that anyone reading the constructor can immediately see the complete structure of the object — all of its data fields, their names, and their initial values. Declaring attributes outside of __init__ (scattered through other methods) is legal in Python but strongly discouraged because it makes the object's structure opaque and unpredictable.

class FluidPipe:
    def __init__(self, diameter, length, roughness=0.000046):
        """
        diameter : float — internal diameter in metres
        length   : float — pipe length in metres
        roughness: float — absolute roughness in metres
                           (default is for commercial steel)
        """
        self.diameter = diameter
        self.length = length
        self.roughness = roughness
        self.flow_rate = 0.0    # m^3/s, starts at zero
        self.fluid = "water"    # default working fluid

pipe1 = FluidPipe(0.05, 10.0)           # uses default roughness
pipe2 = FluidPipe(0.10, 25.0, 0.0015)  # PVC pipe, smoother

print(pipe1.roughness)  # 4.6e-05
print(pipe2.roughness)  # 0.0015
print(pipe1.fluid)      # water

The FluidPipe example demonstrates two important features. First, all instance attributes — including ones that are not passed in as parameters, such as flow_rate and fluid — are declared inside __init__. A newly created FluidPipe object is always in a complete, usable state. Second, the roughness parameter has a default value. Default parameter values in __init__ are a powerful tool for making a class flexible: callers can supply the value when they know it, or omit it to get a sensible default. This is particularly useful in engineering code where a class might model a physical component that commonly has a standard specification but occasionally needs a custom one. Default values reduce the burden on the caller without sacrificing the ability to customize when needed.

Putting all of these ideas together, here is a more complete engineering example that shows a class with class attributes, instance attributes, a well-structured constructor with defaults, and several methods that encapsulate real calculations:

class CylindricalPressureVessel:
    """Models a thin-walled cylindrical pressure vessel."""

    # Class attribute: material property (MPa), aluminium alloy
    yield_strength = 270.0   # MPa

    def __init__(self, radius, wall_thickness, length, material="Al-6061"):
        self.radius = radius               # metres
        self.wall_thickness = wall_thickness  # metres
        self.length = length              # metres
        self.material = material
        self.internal_pressure = 0.0     # MPa

    def pressurise(self, pressure_mpa):
        """Set the internal pressure in MPa."""
        if pressure_mpa < 0:
            raise ValueError("Pressure cannot be negative.")
        self.internal_pressure = pressure_mpa

    def hoop_stress(self):
        """
        Calculate hoop (circumferential) stress using thin-wall formula:
        sigma_h = p * r / t
        Returns stress in MPa.
        """
        return (self.internal_pressure * self.radius) / self.wall_thickness

    def axial_stress(self):
        """
        Calculate axial (longitudinal) stress:
        sigma_a = p * r / (2 * t)
        Returns stress in MPa.
        """
        return (self.internal_pressure * self.radius) / (2 * self.wall_thickness)

    def safety_factor(self):
        """
        Return the safety factor based on hoop stress vs yield strength.
        """
        hs = self.hoop_stress()
        if hs == 0:
            return float('inf')
        return CylindricalPressureVessel.yield_strength / hs

    def is_safe(self, required_factor=2.0):
        """Return True if safety factor meets or exceeds the requirement."""
        return self.safety_factor() >= required_factor


# Create two vessels with different dimensions
vessel_a = CylindricalPressureVessel(radius=0.25, wall_thickness=0.005, length=1.0)
vessel_b = CylindricalPressureVessel(radius=0.25, wall_thickness=0.010, length=1.0)

vessel_a.pressurise(2.0)   # 2 MPa internal pressure
vessel_b.pressurise(2.0)

print(f"Vessel A hoop stress : {vessel_a.hoop_stress():.1f} MPa")  # 100.0 MPa
print(f"Vessel A safety factor: {vessel_a.safety_factor():.2f}")   # 2.70
print(f"Vessel A is safe (SF≥2): {vessel_a.is_safe()}")           # True

print(f"Vessel B hoop stress : {vessel_b.hoop_stress():.1f} MPa")  # 50.0 MPa
print(f"Vessel B safety factor: {vessel_b.safety_factor():.2f}")   # 5.40
print(f"Vessel B is safe (SF≥2): {vessel_b.is_safe()}")           # True

This example illustrates everything covered in this topic working together in a realistic scenario. The class attribute yield_strength is shared, reflecting a material property that belongs to the type of vessel rather than any individual one. Each instance has its own geometry and pressure state through instance attributes declared in __init__, which also provides a default for the material name. The methods encapsulate the standard thin-wall pressure vessel equations, and the meaningful names — hoop_stress, safety_factor, is_safe — make the code read almost like an engineering specification. Creating a second vessel with different wall thickness requires one line of code, and all its calculations automatically use its own dimensions. This is OOP delivering on its promise: organized, reusable, readable code that maps naturally onto the engineering concepts it models.

To summarize the core ideas: OOP structures programs around objects that combine data and behavior into coherent units, promoting modularity and reuse. A class is the blueprint defining structure and behavior; an instance is a specific object created from that blueprint, holding its own data. Instance attributes store object-specific data; class attributes hold data shared across all instances. Methods are functions defined inside the class that operate on the object's own data via self. The __init__ constructor is called automatically at instantiation and is the proper place to initialize all instance attributes, optionally with default parameter values for flexibility. Together, these building blocks provide the foundation for writing engineering software that is robust, maintainable, and straightforward to extend.

NotesThe pressure vessel example uses the thin-wall (membrane) stress approximation, valid when radius-to-thickness ratio is greater than approximately 10. This assumption should be made explicit if this material is used in a course where vessel design depth is expected. The default roughness value used for FluidPipe (4.6 × 10⁻⁵ m) corresponds to commercial steel pipe, which is a standard reference value from Moody chart tables.