What you'll be able to do

  • Model real-world entities with classes and objects
  • Use attributes, methods, and __init__ effectively
  • Apply inheritance and composition to reuse code
Competencies you'll build
  • Define a class with attributes and methods
  • Explain the difference between a class and an instance
  • Use inheritance to extend a base class

Key terms in this chapter

Chapter – Object-Oriented Programming

Supplementary chapter prepared for the BWXT Data Science Workforce Training Pilot. This material is original to the program and is not derived from Automate the Boring Stuff with Python; it is written in a similar tone for continuity with the other chapters.

About this chapter

So far, you’ve written programs as a top-to-bottom sequence of statements, and then learned to package repeated logic into functions. That style — sometimes called procedural or linear scripting — is great for short tools and one-off automations. But once your programs start juggling many related pieces of data (an X-ray image, the inspector who took it, the weld it belongs to, and the defects found in it), keeping it all in loose variables and dictionaries gets messy fast.

This chapter introduces object-oriented programming (OOP): a way of organizing code around objects that bundle related data (attributes) and behavior (methods) together. You’ll learn the four guiding ideas behind OOP, when it’s the right tool (and when it isn’t), how to define your own classes in Python, what self actually does, how @staticmethod differs from a normal method, the conventions Python uses for “privacy,” and finally how to use inheritance to share behavior across related classes — using both the classic Animal example and a manufacturing/welding example you’ll recognize from earlier modules.

What is Object-Oriented Programming?

A class is a blueprint. An object (also called an instance) is a concrete thing built from that blueprint. If WeldInspection is a class, then inspection_001 and inspection_002 are objects of that class — each with their own inspector, timestamp, and list of defects, but all sharing the same set of methods like mark_pass() or add_defect().

In procedural code, you might write:

python
weld_id = 'W-001'
inspector = 'Marco'
defects = ['porosity', 'undercut']
status = 'fail' if defects else 'pass'

In object-oriented code, you wrap that into one thing:

python
inspection = WeldInspection(weld_id='W-001', inspector='Marco')
inspection.add_defect('porosity')
inspection.add_defect('undercut')
print(inspection.status())  # 'fail'

The variables become attributes of the object, and the operations become methods on the object. The data and the code that operates on the data live together.

What problems does OOP solve?

Procedural code starts to creak when:

  • You have many functions that all take the same handful of arguments (e.g., every helper takes weld_id, inspector, defects, image_path, …).
  • Related data is scattered across several parallel lists or dictionaries that must stay in sync.
  • You find yourself copying functions to handle slightly different but related things (a function for cat_speak, another for dog_speak, another for fish_speak).
  • You need to swap implementations without rewriting the callers (e.g., a LocalImageStore vs. a CloudImageStore).

OOP addresses these by:

  • Bundling state and behavior so you pass one object instead of five arguments.
  • Reusing code across related types via inheritance.
  • Hiding internal details behind a stable interface (so you can change the inside without breaking the outside).
  • Substituting one implementation for another when both follow the same shape (polymorphism).

The four tenets of OOP, briefly

You’ll see these four words again and again. Here is the short version:

Tenet One-line meaning
Encapsulation Bundle data and the methods that act on it into one unit, and hide the internals.
Inheritance Define a general class once, then let specialized classes reuse and extend it.
Polymorphism Different objects can respond to the same method call in their own way.
Abstraction Expose what an object does, not how it does it; users work with a contract.

A welding-flavored example for each:

  • Encapsulation — A WeldInspection object carries the weld ID, the defect list, and the rule for pass/fail. Callers don’t touch the list directly; they call add_defect().
  • InheritanceUltrasonicInspection and RadiographicInspection both are Inspections; they share fields like inspector and timestamp but each adds its own probe-specific data.
  • Polymorphism — Calling report() on either inspection prints a sensible report. The caller doesn’t need to know which subclass it has.
  • Abstraction — A DefectDetector.detect(image) returns a list of defects. The caller doesn’t need to know whether that’s a YOLO model, a rules engine, or a human in a loop.

When to use OOP vs. linear / functional scripting

OOP is a tool, not a religion. Use the lightest thing that works.

Reach for OOP when:

  • You have multiple related pieces of state that travel together.
  • You will create many instances of the same kind of thing (many inspections, many welds, many sensors).
  • You expect variants of a concept that share most of their behavior.
  • You want to swap implementations behind a common interface (testing, mocking, plug-ins).

Stick with simple functions / scripts when:

  • The job is a one-shot transformation (read CSV → clean → write CSV).
  • There’s only one of the thing (a single config loader, a single utility).
  • The state is small and easy to pass as arguments.
  • You’re writing a quick automation that you’ll throw away.

A good rule of thumb: if you find yourself writing functions named weld_add_defect(weld, defect), weld_status(weld), weld_report(weld) that all take the same weld dict, you’re hand-rolling an object. Make it a class.

Writing a class

In Python, you create a class with the class keyword.

python
class WeldInspection:
    def __init__(self, weld_id, inspector):
        self.weld_id = weld_id
        self.inspector = inspector
        self.defects = []

    def add_defect(self, defect_name):
        self.defects.append(defect_name)

    def status(self):
        if self.defects:
            return 'fail'
        return 'pass'

Use it like this:

python
inspection = WeldInspection('W-001', 'Marco')
inspection.add_defect('porosity')
print(inspection.status())   # fail
print(inspection.weld_id)    # W-001

Notice three things: the class name uses CapWords, the constructor is named __init__, and every instance method takes self as its first parameter.

The Capital-name (CapWords / PascalCase) convention

Python’s style guide (PEP 8) says class names should use CapWords, also known as PascalCase. The first letter of each word is capitalized and there are no underscores between them.

Kind Convention Example
Class CapWords WeldInspection, DefectDetector
Function / method lower_snake_case add_defect, load_image
Variable lower_snake_case weld_id, defect_count
Constant UPPER_SNAKE_CASE MAX_DEFECTS, DEFAULT_THRESHOLD

Following this convention isn’t enforced by the interpreter, but it’s a powerful visual cue: when you see Thing(...) you instantly know it’s a class being instantiated, and when you see thing(...) you know it’s a function call.

Attributes and methods

  • An attribute is a piece of data that belongs to an object: inspection.weld_id, inspection.defects.
  • A method is a function that belongs to a class and operates on an instance: inspection.add_defect('porosity'), inspection.status().

Attributes typically come into existence inside __init__, but you can also set new ones later (inspection.notes = 'follow up tomorrow'). Methods are defined inside the class block using the familiar def statement.

__init__(): the constructor

__init__ (read aloud as “dunder init” — “dunder” for “double underscore”) is a special method Python calls automatically every time you create a new instance. Its job is to set up the object’s starting attributes.

python
class WeldInspection:
    def __init__(self, weld_id, inspector):
        self.weld_id = weld_id
        self.inspector = inspector
        self.defects = []

When you write:

python
inspection = WeldInspection('W-001', 'Marco')

Python:

  1. Creates a new empty WeldInspection object.
  2. Calls __init__ on it, passing the new object as self and 'W-001', 'Marco' as the remaining arguments.
  3. Returns the now-initialized object, which gets bound to inspection.

You don’t call __init__ yourself — calling the class itself (with parentheses) does it for you.

The self parameter

self is the current instance. It’s the first parameter of every regular method, and Python fills it in for you automatically when you call a method on an object.

python
inspection.add_defect('porosity')
# Python actually runs:
# WeldInspection.add_defect(inspection, 'porosity')

That’s why you write def add_defect(self, defect_name): with two parameters but call it with just one argument. The self slot is filled by whatever object is on the left of the dot.

Inside a method, you use self.something to read or write an attribute on this particular instance. Without self., you’d just be creating a local variable that disappears when the method returns:

python
class Counter:
    def __init__(self):
        self.count = 0

    def bump_wrong(self):
        count = self.count + 1   # local variable, thrown away

    def bump_right(self):
        self.count = self.count + 1   # actually updates the attribute

The name self is just a convention — Python doesn’t require it — but always use self. Every Python programmer reading your code expects it.

Static methods

Sometimes a function is logically related to a class but doesn’t need an instance to do its work. It doesn’t read or write any self.something — it just takes inputs and returns an output. That’s a static method.

You declare one with the @staticmethod decorator placed directly above the def line. A static method does not take self (or cls).

python
class WeldInspection:
    def __init__(self, weld_id, inspector):
        self.weld_id = weld_id
        self.inspector = inspector
        self.defects = []

    def add_defect(self, defect_name):
        self.defects.append(defect_name)

    @staticmethod
    def is_valid_weld_id(candidate):
        # Valid IDs look like "W-001", "W-042", etc.
        return (
            isinstance(candidate, str)
            and candidate.startswith('W-')
            and candidate[2:].isdigit()
        )

You can call a static method without creating an instance:

python
WeldInspection.is_valid_weld_id('W-001')   # True
WeldInspection.is_valid_weld_id('hello')   # False

When to make something static:

  • It’s a helper that belongs with the class conceptually (like “does this string look like one of our IDs?”).
  • It doesn’t need any per-instance data.
  • You want to call it without paying the cost of constructing an object.

If a method ignores self entirely, that’s a strong hint it should be @staticmethod.

Privacy: public, protected, and private

Some languages (Java, C++) enforce access control with keywords like public and private. Python takes a more relaxed stance — sometimes summarized as “we’re all consenting adults here” — but it has clear conventions that other Python programmers will recognize and respect.

Public attributes (no underscore)

Anything without a leading underscore is public. Use it freely from inside or outside the class.

python
class WeldInspection:
    def __init__(self, weld_id):
        self.weld_id = weld_id   # public

w = WeldInspection('W-001')
print(w.weld_id)   # totally fine

Protected attributes (single underscore _)

A single leading underscore is the convention for “protected” — meaning “this is an internal detail; please don’t touch it from outside the class (or its subclasses).” Python will not raise an error if you do; the underscore is purely a polite warning to other programmers.

python
class WeldInspection:
    def __init__(self, weld_id):
        self.weld_id = weld_id
        self._raw_image_buffer = None   # internal; don't poke at this

w = WeldInspection('W-001')
print(w._raw_image_buffer)   # works, but you shouldn't do this

If you see a _name in someone else’s class, treat it as off-limits. It might disappear or change shape in the next version without warning.

Private attributes (double underscore __)

Two leading underscores triggers a Python feature called name mangling. The interpreter quietly rewrites self.__secret inside the class to self._ClassName__secret. This makes accidental access from outside the class much harder.

python
class WeldInspection:
    def __init__(self, weld_id):
        self.weld_id = weld_id
        self.__internal_hash = self._compute_hash()

    def _compute_hash(self):
        return hash(self.weld_id)

w = WeldInspection('W-001')
print(w.weld_id)            # 'W-001'

# This raises AttributeError — there is no attribute literally
# named __internal_hash on the instance:
# print(w.__internal_hash)

# But name mangling means it really lives here:
print(w._WeldInspection__internal_hash)   # works, but very obviously wrong

The double-underscore prefix is not real privacy — a determined caller can still get in. Its purpose is to prevent accidental access and, importantly, to prevent accidental name collisions between a parent class and a subclass.

Style Convention Meaning Enforced?
Public name Free to use from anywhere n/a
Protected _name “Internal — please don’t touch from outside” No
Private __name Name-mangled to _ClassName__name No (just harder)
Dunder (special) __name__ Reserved for Python (e.g., __init__) n/a

Note: names with leading and trailing double underscores like __init__, __str__, __len__ are dunder methods reserved by Python itself. Don’t invent your own.

Accessing methods inside and outside the class

Outside the class — the way you’ve already seen — use dot notation:

python
inspection = WeldInspection('W-001', 'Marco')
inspection.add_defect('porosity')
print(inspection.status())

Inside the class — when one method needs to call another method on the same object — also use dot notation, but through self:

python
class WeldInspection:
    def __init__(self, weld_id, inspector):
        self.weld_id = weld_id
        self.inspector = inspector
        self.defects = []

    def add_defect(self, defect_name):
        self.defects.append(defect_name)

    def status(self):
        if self.defects:
            return 'fail'
        return 'pass'

    def report(self):
        # Calling another method on the same instance:
        return f'{self.weld_id} ({self.inspector}): {self.status().upper()}'

Here report() calls self.status() to reuse the pass/fail logic. Without the self. prefix, Python would look for a free-standing function named status and (most likely) fail.

Callback: methods you’ve already used

You’ve been using methods all along — you just didn’t call them that. Every one of these is a method on an object:

python
'Hello'.upper()             # str method
'  weld id  '.strip()        # str method
[1, 2, 3].append(4)          # list method
{'a': 1}.keys()              # dict method
my_file.read()               # file object method
random.randint(1, 6)         # function inside the random module

When you wrote 'Hello'.upper(), the string object 'Hello' was passed in as self to the upper method defined on the str class. Now you know how to write classes whose objects work the same way.

Inheritance: the Animal example

Inheritance lets one class build on another. The class you start from is the parent (or base / superclass); the new class is the child (or subclass). The child automatically gets the parent’s attributes and methods, and can add new ones or override existing ones.

The classic introductory example: an Animal superclass with Dog, Cat, and Fish subclasses.

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

    def describe(self):
        return f'{self.name} is {self.age} year(s) old.'

    def speak(self):
        # Generic default; subclasses should override.
        return f'{self.name} makes a generic animal noise.'


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


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


class Fish(Animal):
    def speak(self):
        return f'{self.name} says: ...(fish are quiet).'


pets = [Dog('Rex', 4), Cat('Whiskers', 7), Fish('Bubbles', 1)]
for pet in pets:
    print(pet.describe())   # inherited from Animal
    print(pet.speak())      # overridden in each subclass

Output:

text
Rex is 4 year(s) old.
Rex says: Woof!
Whiskers is 7 year(s) old.
Whiskers says: Meow.
Bubbles is 1 year(s) old.
Bubbles says: ...(fish are quiet).

Things to notice:

  • Dog, Cat, and Fish did not redefine __init__ or describe() — they inherited them from Animal.
  • Each subclass overrides speak() to give its own behavior. This is polymorphism at work: the loop calls pet.speak() without caring which subclass pet actually is.

A manufacturing version: inspections

Here’s the same idea applied to something closer to your day job. We have a generic Inspection and two specialized kinds.

python
from datetime import datetime


class Inspection:
    def __init__(self, weld_id, inspector):
        self.weld_id = weld_id
        self.inspector = inspector
        self.timestamp = datetime.now()
        self.defects = []

    def add_defect(self, defect_name):
        self.defects.append(defect_name)

    def status(self):
        return 'fail' if self.defects else 'pass'

    def report(self):
        # Default report; subclasses can extend it.
        return (
            f'[{self.timestamp:%Y-%m-%d %H:%M}] '
            f'Weld {self.weld_id} inspected by {self.inspector}: '
            f'{self.status().upper()}'
        )

    @staticmethod
    def is_valid_weld_id(candidate):
        return (
            isinstance(candidate, str)
            and candidate.startswith('W-')
            and candidate[2:].isdigit()
        )


class UltrasonicInspection(Inspection):
    def __init__(self, weld_id, inspector, probe_frequency_mhz):
        # Reuse the parent's setup, then add our own:
        super().__init__(weld_id, inspector)
        self.probe_frequency_mhz = probe_frequency_mhz

    def report(self):
        base = super().report()
        return f'{base} | Ultrasonic @ {self.probe_frequency_mhz} MHz'


class RadiographicInspection(Inspection):
    def __init__(self, weld_id, inspector, exposure_seconds):
        super().__init__(weld_id, inspector)
        self.exposure_seconds = exposure_seconds

    def report(self):
        base = super().report()
        return f'{base} | Radiographic, {self.exposure_seconds}s exposure'

Using them:

python
ut = UltrasonicInspection('W-001', 'Marco', probe_frequency_mhz=5)
ut.add_defect('porosity')

rt = RadiographicInspection('W-002', 'Aisha', exposure_seconds=30)

for inspection in [ut, rt]:
    print(inspection.report())

Output:

text
[2026-04-19 10:14] Weld W-001 inspected by Marco: FAIL | Ultrasonic @ 5 MHz
[2026-04-19 10:14] Weld W-002 inspected by Aisha: PASS | Radiographic, 30s exposure

A few things worth pointing out:

  • **class UltrasonicInspection(Inspection):** — the parent class goes in parentheses after the child class’s name.
  • **super().__init__(...)** — calls the parent’s __init__ so we don’t have to re-copy its setup code.
  • Method overriding — both subclasses override report() but call super().report() to extend rather than replace the parent’s version.
  • Polymorphism — the for loop calls inspection.report() without ever checking which subclass it has.
  • Encapsulation — callers never reach into self.defects directly; they go through add_defect() and status().

Designing for OOP

Designing good classes is its own skill. A few starter principles to keep your designs healthy:

  1. Model real things. Class names should be nouns (Inspection, Weld, DefectDetector). Methods should be verbs (add_defect, report, detect).
  2. One responsibility per class. If a class is doing two unrelated jobs (loading images and running a model and writing reports), it’s probably two or three classes.
  3. Keep __init__ small. Use it to assign attributes. Don’t do heavy I/O or model loading in there if you can avoid it; it makes the class hard to test.
  4. Prefer composition to deep inheritance. Inheritance is great for true is-a relationships (Dog is an Animal). For has-a relationships, store the other object as an attribute instead. A WeldInspection has a DefectDetector; it isn’t one.
  5. Program to the interface. Callers should rely on what methods exist (detector.detect(image)), not how they’re implemented. That way you can swap in a fake detector for tests without changing the caller.
  6. Use the privacy conventions. Mark internals with _. Save __ for the rare cases where you really need to avoid name clashes in subclasses.
  7. If a method ignores self, make it @staticmethod. It signals intent and lets callers use it without constructing an object.
  8. Don’t reach for OOP first. A 30-line script doesn’t need a class. Reach for OOP when state and behavior naturally cluster together — and when not clustering them is making your code worse.

A useful checklist when you’re about to write a new class: “What does this object know? What can it do? Who uses it, and what do they need to call?” If you can answer those three questions in a sentence each, you’re ready to write the class line.

Summary

Object-oriented programming organizes code around objects that bundle related data and behavior. You define the blueprint with a class, set up each new instance in __init__, and let methods operate on the current instance through self. The four guiding ideas — encapsulation, inheritance, polymorphism, and abstraction — show up again and again, even in small designs.

Python’s privacy story is a matter of convention rather than enforcement: a single underscore says “please don’t,” a double underscore makes accidental access harder via name mangling, and dunder names are reserved for Python itself. @staticmethod lets you attach helper functions to a class without dragging self along.

Inheritance lets a child class reuse a parent’s setup and override the parts it needs to specialize, and super() lets the child cooperate with — rather than copy — the parent. Whether your superclass is Animal or Inspection, the pattern is the same: capture what’s common in the parent, and let the children differ where it matters.

Topic Key ideas
Class vs. object A class is a blueprint; an object is a built instance
Naming Classes use CapWords; methods and attributes use lower_snake_case
__init__ and self __init__ runs on construction; self is the current instance
Static methods @staticmethod; no self; callable without an instance
Privacy name public, _name protected, __name name-mangled, __name__ reserved
Four tenets Encapsulation, inheritance, polymorphism, abstraction
Inheritance class Child(Parent):; use super().__init__(...); override methods as needed
When to use OOP Bundled state + behavior, multiple instances, variants, swappable implementations
Practice Questions

Practice Questions

  1. In your own words, what is the difference between a class and an object?
  2. Name the four tenets of OOP and give a one-sentence definition of each.
  3. Give one example of a problem that is well-suited to OOP, and one that is better solved with a plain script of functions.
  4. What naming convention does PEP 8 recommend for class names? For methods?
  5. When does Python automatically call __init__?
  6. What does self refer to inside a method? What happens if you forget the self. prefix when assigning to an attribute?
  7. What does the @staticmethod decorator change about a method? When should you use it?
  8. What is the convention for marking an attribute as protected in Python? As private? Does Python actually prevent access in either case?
  9. Explain how name mangling works for a __secret attribute on a class named Foo.
  10. Write a class Weld with attributes weld_id and material, plus a method describe() that returns a sentence like 'Weld W-001 (material: A36 steel)'. Then create two Weld objects and print their descriptions.
  11. Extend the Animal example from this chapter by adding a new subclass Cow whose speak() returns '<name> says: Moo.'. Create one and print both describe() and speak().
  12. Refactor the following procedural snippet into a class:
python
 def make_sensor(sensor_id, units):
     return {'id': sensor_id, 'units': units, 'readings': []}

 def add_reading(sensor, value):
     sensor['readings'].append(value)

 def average(sensor):
     if not sensor['readings']:
         return 0
     return sum(sensor['readings']) / len(sensor['readings'])
  1. Look back at code you’ve already written for this program. Find one place where several functions all take the same handful of arguments. Sketch (in plain English) the class you would create to replace them.

Check your understanding

Tier 2 depth · Applied coding

0 / 5 correct
  1. In `inspection = WeldInspection(weld_id='W-001', inspector='Marco')`, what is `inspection`?

  2. What does `self` refer to inside a method like `add_defect(self, ...)`?

  3. You're copying nearly identical functions cat_speak(), dog_speak(), fish_speak(). Which OOP idea removes that duplication?

  4. A method marked `@staticmethod` differs from a normal method because it:

  5. Procedural code starts to 'creak' and signals it's time for a class when:

Go deeper

More in Additional Resources →
← Deployment and Monitoring