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:
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:
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 fordog_speak, another forfish_speak). - You need to swap implementations without rewriting the callers (e.g., a
LocalImageStorevs. aCloudImageStore).
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
WeldInspectionobject carries the weld ID, the defect list, and the rule forpass/fail. Callers don’t touch the list directly; they calladd_defect(). - Inheritance —
UltrasonicInspectionandRadiographicInspectionboth areInspections; they share fields likeinspectorandtimestampbut 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.
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:
inspection = WeldInspection('W-001', 'Marco')
inspection.add_defect('porosity')
print(inspection.status()) # fail
print(inspection.weld_id) # W-001Notice 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.
class WeldInspection:
def __init__(self, weld_id, inspector):
self.weld_id = weld_id
self.inspector = inspector
self.defects = []When you write:
inspection = WeldInspection('W-001', 'Marco')Python:
- Creates a new empty
WeldInspectionobject. - Calls
__init__on it, passing the new object asselfand'W-001','Marco'as the remaining arguments. - 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.
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:
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 attributeThe 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).
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:
WeldInspection.is_valid_weld_id('W-001') # True
WeldInspection.is_valid_weld_id('hello') # FalseWhen 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.
class WeldInspection:
def __init__(self, weld_id):
self.weld_id = weld_id # public
w = WeldInspection('W-001')
print(w.weld_id) # totally fineProtected 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.
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 thisIf 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.
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 wrongThe 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:
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:
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:
'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 moduleWhen 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.
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 subclassOutput:
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, andFishdid not redefine__init__ordescribe()— they inherited them fromAnimal.- Each subclass overrides
speak()to give its own behavior. This is polymorphism at work: the loop callspet.speak()without caring which subclasspetactually 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.
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:
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:
[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 exposureA 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 callsuper().report()to extend rather than replace the parent’s version. - Polymorphism — the
forloop callsinspection.report()without ever checking which subclass it has. - Encapsulation — callers never reach into
self.defectsdirectly; they go throughadd_defect()andstatus().
Designing for OOP
Designing good classes is its own skill. A few starter principles to keep your designs healthy:
- Model real things. Class names should be nouns (
Inspection,Weld,DefectDetector). Methods should be verbs (add_defect,report,detect). - 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.
- 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. - Prefer composition to deep inheritance. Inheritance is great for true is-a relationships (
Dogis anAnimal). For has-a relationships, store the other object as an attribute instead. AWeldInspectionhas aDefectDetector; it isn’t one. - 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. - Use the privacy conventions. Mark internals with
_. Save__for the rare cases where you really need to avoid name clashes in subclasses. - If a method ignores
self, make it@staticmethod. It signals intent and lets callers use it without constructing an object. - 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
- In your own words, what is the difference between a class and an object?
- Name the four tenets of OOP and give a one-sentence definition of each.
- Give one example of a problem that is well-suited to OOP, and one that is better solved with a plain script of functions.
- What naming convention does PEP 8 recommend for class names? For methods?
- When does Python automatically call
__init__? - What does
selfrefer to inside a method? What happens if you forget theself.prefix when assigning to an attribute? - What does the
@staticmethoddecorator change about a method? When should you use it? - What is the convention for marking an attribute as protected in Python? As private? Does Python actually prevent access in either case?
- Explain how name mangling works for a
__secretattribute on a class namedFoo. - Write a class
Weldwith attributesweld_idandmaterial, plus a methoddescribe()that returns a sentence like'Weld W-001 (material: A36 steel)'. Then create twoWeldobjects and print their descriptions. - Extend the
Animalexample from this chapter by adding a new subclassCowwhosespeak()returns'<name> says: Moo.'. Create one and print bothdescribe()andspeak(). - Refactor the following procedural snippet into a class:
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'])- 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.