ENCM-100 · Lesson 9 of 12 · 28 min

Objects and classes

As programs grow, keeping related data and the functions that act on it together becomes essential. A pipe has a length, a diameter and a roughness, and a calculation of its head loss needs all three; a material has a modulus, a density and a strength; a beam has a span and a list of loads. Lessons 4 and 5 handled such things with functions and dictionaries. Object-oriented programming offers another way: define a class that bundles the data (attributes) and the operations (methods) of one kind of thing, then create as many objects of that class as needed. Almost every Python library you use, from NumPy arrays to pandas DataFrames to Matplotlib figures, is built this way. This lesson shows how to define classes, write methods, use dataclasses for data-heavy objects, extend classes by inheritance, and decide when classes help and when plain functions are better.

Objects you already use

Everything in Python is an object: a value with a type and with methods attached. A string has .upper() and .split(); a list has .append() and .sort(); a NumPy array has .shape, .mean() and .reshape(); a DataFrame has .groupby() and .plot(). The type of an object is its class, and type(x) tells you which. Writing your own classes lets you create new types suited to your problem, with the same convenient dot notation.

Defining a class

A class definition starts with class Name: and contains functions, called methods, that define what objects of the class can do. The special method __init__ runs when an object is created and stores its data as attributes on self, the name conventionally given to the object being worked on. Every method takes self as its first parameter, and Python fills it in automatically: steel.specific_stiffness() calls the method with self set to steel.

A Material class with attributes, two methods that compute material indices, and a __repr__ method that controls how an object prints.
class Material:
    """A structural material with the properties needed for simple design checks."""

    def __init__(self, name, E_GPa, density, yield_MPa):
        self.name = name
        self.E = E_GPa                 # modulus of elasticity, GPa
        self.rho = density             # Mg/m3
        self.fy = yield_MPa            # yield or design strength, MPa

    def specific_stiffness(self):
        return self.E / self.rho

    def beam_index(self):
        return self.E ** 0.5 / self.rho          # lightest stiff beam (ENGG-160)

    def __repr__(self):
        return f"Material({self.name}, E={self.E} GPa, rho={self.rho})"

steel = Material("Steel", 200, 7.85, 350)
alloy = Material("6061-T6", 69, 2.70, 240)
print(steel)
for m in (steel, alloy):
    print(f"{m.name:8s} E/rho = {m.specific_stiffness():5.1f}   E^0.5/rho = {m.beam_index():4.2f}")
alloy.fy = 276                         # attributes can be updated
print(alloy.name, "yield now", alloy.fy, "MPa")
Output
Material(Steel, E=200 GPa, rho=7.85)
Steel    E/rho =  25.5   E^0.5/rho = 1.80
6061-T6  E/rho =  25.6   E^0.5/rho = 3.08
6061-T6 yield now 276 MPa

Each call such as Material("Steel", 200, 7.85, 350) creates a new, independent object, an instance of the class, with its own attribute values. The methods specific_stiffness and beam_index compute the material indices of ENGG-160 from the object's own data, so the calculation and the data it needs travel together. The special method __repr__ returns the text shown when the object is printed; without it, Python prints an unhelpful description such as <__main__.Material object at 0x7f...>. Class names are conventionally written in CapitalizedWords, and attribute and method names in lower_case.

Why classes help

Classes earn their place in several situations:

  • Bundling data that belongs together. Passing a pipe's length, diameter and roughness to every function separately invites mistakes in argument order; passing one Pipe object does not.
  • Keeping calculations next to the data they use. A method such as pipe.head_loss(Q) cannot be called on the wrong kind of object, and anyone reading the class sees everything a pipe can do.
  • Modeling collections of similar things: a network of pipes, a set of loads on a beam, the equipment in a plant, the sensors in a building. Each is an object; a list holds them all.
  • Hiding detail behind a simple interface. Code that uses a class needs only its methods, not how they work, so the internals can be improved without changing the code that uses them.

Dataclasses

Many engineering classes are mainly containers for data with a few calculations attached. Python's dataclasses module removes the boilerplate: decorating a class with @dataclass and listing its fields with type hints automatically creates __init__, a useful __repr__ and an equality test. Fields can have default values, given after the field name.

A Pipe dataclass with a Darcy–Weisbach head-loss method using the Swamee–Jain friction factor, applied to a line of three pipes in series.
import math
from dataclasses import dataclass

@dataclass
class Pipe:
    length: float          # m
    diameter: float        # m, inside
    roughness: float = 0.0015e-3     # m, smooth plastic

    def area(self):
        return math.pi * self.diameter ** 2 / 4

    def head_loss(self, Q, nu=1.0e-6, g=9.81):
        """Darcy-Weisbach head loss (m) for flow Q (m3/s), Swamee-Jain friction factor."""
        V = Q / self.area()
        Re = V * self.diameter / nu
        f = 0.25 / math.log10(self.roughness / (3.7 * self.diameter) + 5.74 / Re ** 0.9) ** 2
        return f * self.length / self.diameter * V ** 2 / (2 * g)

line = [Pipe(120, 0.150), Pipe(80, 0.100), Pipe(35, 0.100, roughness=0.26e-3)]   # last: old cast iron
Q = 0.012                                                                         # m3/s
for p in line:
    print(p, f"-> {p.head_loss(Q):.2f} m")
total = sum(p.head_loss(Q) for p in line)
print(f"Total friction loss {total:.2f} m at {Q * 1000:.0f} L/s")
Output
Pipe(length=120, diameter=0.15, roughness=1.5e-06) -> 0.34 m
Pipe(length=80, diameter=0.1, roughness=1.5e-06) -> 1.57 m
Pipe(length=35, diameter=0.1, roughness=0.00026) -> 1.10 m
Total friction loss 3.00 m at 12 L/s

The Pipe class computes the friction head loss in each segment of a water line, using the Darcy–Weisbach equation with the Swamee–Jain explicit formula for the friction factor, which accounts for the pipe's relative roughness and the Reynolds number (Lesson 3). The line is simply a list of Pipe objects; the total loss is a one-line sum over them. Notice how the old cast-iron segment, only 35 m long, causes almost as much loss as the 80 m plastic one, because its rough, scaled interior raises the friction factor; replacing such sections is a common, cost-effective way to restore capacity in old water systems.

Worked problem

Extending the pipe model

The designer also needs minor losses from fittings (bends, valves), which add K × V²/(2g) per fitting. How should the Pipe class change?

Show solution
  1. Add a field fittings_K: float = 0.0, the sum of the loss coefficients of the fittings in that segment.
  2. Add a method minor_loss(self, Q) returning self.fittings_K × V²/(2g), with V computed from self.area().
  3. Add a method total_loss(self, Q) returning self.head_loss(Q) + self.minor_loss(Q).
  4. Existing code that creates Pipe objects without fittings still works, because the new field has a default.

Answer: Three small additions inside the class, and every pipe in every program gains the new capability. Code that only used head_loss is unaffected. This is the practical payoff of keeping data and methods together.

Inheritance and polymorphism

A class can be defined as a specialized version of another. The new class, a subclass, inherits all the attributes and methods of its base class and can add new ones or override existing ones. Inheritance suits families of things that share an interface but differ in detail, such as different kinds of loads, sensors or equipment.

A Load base class with PointLoad and UniformLoad subclasses, and a SimpleBeam class that finds reactions without knowing which kind of load it holds.
class Load:
    """Base class: every load can report its total force and its moment about x = 0."""
    def force(self):
        raise NotImplementedError
    def moment_about_origin(self):
        raise NotImplementedError

class PointLoad(Load):
    def __init__(self, P, x):
        self.P, self.x = P, x
    def force(self):
        return self.P
    def moment_about_origin(self):
        return self.P * self.x

class UniformLoad(Load):
    def __init__(self, w, x1, x2):
        self.w, self.x1, self.x2 = w, x1, x2
    def force(self):
        return self.w * (self.x2 - self.x1)
    def moment_about_origin(self):
        return self.force() * (self.x1 + self.x2) / 2     # resultant at the middle

class SimpleBeam:
    def __init__(self, span):
        self.span, self.loads = span, []
    def add(self, load):
        self.loads.append(load)
        return self                                       # allows chaining
    def reactions(self):
        total = sum(ld.force() for ld in self.loads)
        RB = sum(ld.moment_about_origin() for ld in self.loads) / self.span
        return total - RB, RB

# The overhanging beam of ENGG-130: supports at 0 and 6 m, load beyond B at 8 m
beam = SimpleBeam(6.0).add(UniformLoad(4.0, 0, 6)).add(PointLoad(10.0, 8.0))
RA, RB = beam.reactions()
print(f"RA = {RA:.2f} kN, RB = {RB:.2f} kN")
print([type(ld).__name__ for ld in beam.loads])
Output
RA = 8.67 kN, RB = 25.33 kN
['UniformLoad', 'PointLoad']

The SimpleBeam class asks each load only for its force and its moment about the origin; it neither knows nor cares whether the load is a point load or a uniform load. This is polymorphism: different classes responding to the same method call in their own way. Adding a TriangularLoad class later, with its own force and moment methods (Lesson 6 of ENGG-130 gives the centroid), would make it work with SimpleBeam immediately, without changing a line of the beam code. The base class Load raises NotImplementedError, documenting that every subclass must provide these methods. The result matches the reactions of ENGG-130's overhanging beam, 8.67 and 25.33 kN.

A class diagram. A box labeled Load with methods force and moment_about_origin has two arrows pointing to it from subclasses PointLoad, with attributes P and x, and UniformLoad, with attributes w, x1 and x2. A box labeled SimpleBeam, with attributes span and loads and a method reactions, is connected to Load by a line labeled "holds many".
Class diagram for the beam example: SimpleBeam holds many Load objects of either subclass.

Composition: objects made of objects

Inheritance expresses an "is a" relationship: a PointLoad is a Load. Composition expresses "has a": a SimpleBeam has loads; a pump station has pumps, pipes and a wet well; a building has floors, which have rooms. Composition is usually more flexible than deep inheritance hierarchies, and most engineering models are built mainly by composition, with inheritance used sparingly for genuinely interchangeable kinds of thing. A useful rule: if you find yourself building inheritance chains three or four levels deep, step back and consider composition.

Designing good classes

A few guidelines help:

  • One class, one responsibility. A class that reads files, does calculations and draws plots is three classes trying to be one.
  • Keep the data an object needs inside it, and validate it in __init__ (Lesson 6): a Pipe with a negative diameter should raise ValueError when created, not produce nonsense later.
  • Prefer methods that return results to methods that print them, so the results can be tested and reused.
  • Store values in consistent SI units inside objects and convert only for input and display, a habit that prevents the unit errors of ENGG-160's Mars Climate Orbiter example.
  • Write docstrings for the class and its methods (Lesson 4), and test them (Lesson 12).

When not to use classes

Classes are a tool, not a requirement. A short script that reads a file, computes a few results and plots them is usually clearer as a sequence of functions. A calculation with a handful of inputs and one output is a function, not a class. Python lets you mix styles freely: NumPy and pandas objects inside functions, small dataclasses to bundle related values, and full classes where a model of interacting things genuinely benefits. Start with functions; reach for classes when you notice the same group of values being passed around together, or the same kind of object appearing many times.

Worked problem

Function or class?

For each task, would you use a function or a class? (a) Convert a temperature from °F to °C. (b) Model each of 300 sensors in a building, each with a tag, location, calibration offset, range and history of readings, and methods to clean and summarize the readings. (c) Compute a beam's maximum moment from w and L.

Show solution
  1. (a) One input, one output, no state: a function.
  2. (b) Many similar objects, each with several pieces of data and shared behavior: a Sensor class, with a list or dictionary holding the 300 instances.
  3. (c) A formula: a function, perhaps in a module of beam formulas.

Answer: Functions for (a) and (c); a class for (b). The deciding questions are whether data and behavior belong together and whether there will be many objects of the same kind.

Common mistakes

  • MistakeForgetting self as the first parameter of a method.

    InsteadEvery instance method takes self first; Python passes the object automatically.

  • MistakeStoring attributes in local variables inside __init__.

    InsteadAssign them to self, as in self.length = length.

  • MistakeWriting a class where a function would do.

    InsteadUse classes when data and behavior belong together or many similar objects exist.

  • MistakeBuilding deep inheritance chains.

    InsteadPrefer composition; use inheritance for genuinely interchangeable kinds.

  • MistakeMixing units inside objects.

    InsteadStore SI internally and convert only for input and output.

Summary and key terms

  • Everything in Python is an object with a class (type), attributes and methods.
  • A class defines a new type; __init__ stores data on self; methods take self first.
  • __repr__ controls how an object prints.
  • @dataclass generates __init__, __repr__ and equality for data-centered classes.
  • Inheritance creates specialized subclasses; polymorphism lets different classes answer the same method call.
  • Composition builds objects from other objects and is often preferable to deep inheritance.
  • Use classes when they simplify; keep simple calculations as functions.
Class
A definition of a new type of object, with attributes and methods.
Object (instance)
A particular value created from a class.
Attribute
A piece of data stored on an object.
Method
A function defined in a class that acts on its objects.
self
The conventional name of the object a method is called on.
Constructor
The __init__ method, which sets up a new object.
Dataclass
A class whose boilerplate methods are generated from its field list.
Inheritance
Defining a class as a specialized version of another.
Polymorphism
Different classes responding to the same method call in their own ways.
Composition
Building objects that contain other objects.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What does type([1, 2]) return?

    Show answer

    list (the class of the object).

  2. Write the __init__ line that stores a parameter span as an attribute.

    Show answer

    self.span = span.

  3. What happens if a method is defined without self and then called on an object?

    Show answer

    A TypeError, because Python passes the object as an extra argument.

  4. Create a 6061-T6 material object with E = 69 GPa, ρ = 2.70 and f_y = 240 MPa.

    Show answer

    alloy = Material("6061-T6", 69, 2.70, 240).

  5. In the pipe example, which segment has the largest loss per meter?

    Show answer

    The old cast-iron 100 mm segment: 1.10/35 = 0.031 m/m versus 1.57/80 = 0.020.

  6. What does @dataclass generate automatically?

    Show answer

    __init__, __repr__ and __eq__, among others.

  7. Write the force method for a TriangularLoad rising from 0 to w over x1 to x2.

    Show answer

    return self.w * (self.x2 − self.x1) / 2.

  8. And its moment about the origin?

    Show answer

    self.force() * (self.x1 + 2 * (self.x2 − self.x1) / 3).

  9. Is "a pump station has pumps" inheritance or composition?

    Show answer

    Composition.

  10. Why should a Pipe constructor reject a negative diameter?

    Show answer

    So an invalid object can never exist and cause wrong results later.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz