Object-Oriented Python Done Right
You already know how to write functions and loops. The next jump in power is organizing related data and behavior together so your programs stay readable as they grow. That is what classes are for. In data and AI work, classes show up everywhere: a model wrapper, a dataset loader, a config object, an API client. This lesson teaches you to write classes that are clean, not clever.
This course is the layer above Python for AI & Data Science. We assume you already know variables, loops, functions, and the basic types. We will not re-teach those.
What You'll Learn
- When a class is the right tool (and when a function or dict is better)
- How
__init__, attributes, and methods fit together - The difference between instance state and class-level data
- How to keep classes small and focused
A Class Is Data Plus the Behavior That Acts on It
Imagine you are tracking experiments while tuning a model. You could juggle parallel lists, but that gets fragile fast. A class bundles the values and the operations into one named thing.
class Experiment:
def __init__(self, name, accuracy):
self.name = name
self.accuracy = accuracy
def passed(self, threshold=0.8):
return self.accuracy >= threshold
run = Experiment("baseline", 0.83)
print(run.name) # baseline
print(run.passed()) # True
__init__ runs when you create the object. self is the specific object being built, and self.name = name stores a value on that object. Methods like passed are just functions that receive self, so they can read and act on the object's own data.
What Happens, Step by Step
Experiment("baseline", 0.83) looks like a function call, and it almost is. Here is the exact sequence:
- Python creates a new, empty object in memory.
- It calls
__init__with that new object asself, plus your arguments:name="baseline"andaccuracy=0.83. - The two
self.lines attach those values to the object, so this object now carries its ownnameandaccuracy. - The finished object is handed back and stored in
run.
self is the part that confuses most people, so let's say it plainly: self is the object itself. When you write run.passed(), Python quietly rewrites it as Experiment.passed(run). That is why every method lists self as its first parameter even though you never pass it yourself: Python fills it in with the object before the dot. Inside passed, self.accuracy then means "the accuracy stored on this particular experiment", so run.passed() uses 0.83 while a different experiment would use its own value.
Instance State vs Class-Level Data
Anything you set with self. belongs to one object. Anything defined directly in the class body is shared by every instance. This trips up beginners constantly.
class Model:
provider = "generic" # shared by all instances
def __init__(self, name):
self.name = name # unique per instance
a = Model("classifier")
b = Model("regressor")
print(a.name, b.name) # classifier regressor
print(a.provider, b.provider) # generic generic
A common trap: never use a mutable default like a list at the class level expecting each object to get its own. The class body runs once, so that list is created once, and every instance sees the same one:
class Tracker:
history = [] # ONE list, created once, shared by all
def __init__(self, name):
self.name = name
a = Tracker("a")
b = Tracker("b")
a.history.append("first run")
print(b.history) # ['first run'] b sees a's data!
b never touched history, yet it shows a's entry, because both objects are reading the same shared list. The fix is one line: create the list inside __init__ with self.history = [], so every new object gets a fresh one. Numbers and strings at the class level are safe, because assigning a new value creates a per-instance attribute. Lists, dicts, and sets are where this bites.
When NOT to Use a Class
Classes are not free. If you only need to pass a few values around, a plain dictionary or a function is often clearer. Reach for a class when:
- The data and the behavior naturally belong together
- You will create many instances of the same shape
- You want to hide internal details behind clean method names
If a class has one method and no real state, it probably wants to be a function.
Pick the simplest tool that fits the job.
| Criteria | Plain function | Class |
|---|---|---|
| Best for | A single transformation on inputs | Data plus behavior, reused as many instances |
| Holds state | No (stateless) | Yes, on self |
| Signal it's the wrong choice | You keep passing the same 4 args everywhere | One method, no real state |
Plain function
- Best for
- A single transformation on inputs
- Holds state
- No (stateless)
- Signal it's the wrong choice
- You keep passing the same 4 args everywhere
Class
- Best for
- Data plus behavior, reused as many instances
- Holds state
- Yes, on self
- Signal it's the wrong choice
- One method, no real state
Inheritance: Use It Sparingly
Inheritance lets a class reuse another's behavior. It is useful, but beginners overuse it and build deep, tangled hierarchies. A good rule: prefer composition (one object holding another) over inheritance unless there is a true "is-a" relationship.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self): # override the parent method
return "Woof"
print(Dog("Rex").speak()) # Woof
How the override works: when you call Dog("Rex").speak(), Python looks for speak on Dog first and finds it, so the parent's version is never used. If Dog did not define speak, Python would walk up to Animal and use its version. That lookup order, child first and then parent, is all "override" means. Notice that Dog defines no __init__ at all, so creating a Dog falls back to Animal's __init__ automatically. That is why Dog("Rex") works.
Dog is genuinely an Animal, so inheritance fits. If you find yourself inheriting just to reuse one helper method, pass that helper in as an object instead.
Try It
Run this, then add a summary method that returns a readable one-line string for the experiment.
Key Takeaways
- A class bundles related data (
self.attributes) with the behavior that acts on it. __init__builds the object;selfrefers to that specific instance.- Class-body values are shared across all instances; never put mutable defaults there.
- Use a function or dict for simple cases; reach for a class when data and behavior belong together.
- Prefer composition over deep inheritance, and only inherit for true "is-a" relationships.

