FreeCampus Python

Build Your First Useful Class

Refactor a working dictionary into a class, create independent instances, inspect instance and class state, and trace how Python binds self to method calls.
python-foundations object-oriented-python classes instances methods
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–6 hours
  • You will learn: Compare a dictionary-plus-functions design with a class, create independent instances, distinguish instance and class attributes, and trace where self comes from in a bound method call.
  • Practice in: Google Colab, JupyterLab, or a local Python interpreter

An expedition team records its trail name, distance, and visited checkpoints. A dictionary and functions can solve that task well. We will begin there—not because the code is wrong, but because a useful class should improve a real design rather than appear only to demonstrate syntax.

As you work, answer these questions:

1. Start with a dictionary that works

The first version uses a dictionary for state and functions for behavior:

def create_expedition(name):
    """Return a new expedition record."""
    return {
        "name": name,
        "distance_km": 0.0,
        "checkpoints": [],
    }


def record_distance(expedition, distance_km):
    """Add a non-negative distance to an expedition."""
    if distance_km < 0:
        raise ValueError("distance_km cannot be negative")
    expedition["distance_km"] += distance_km


def visit_checkpoint(expedition, checkpoint):
    """Record a non-empty checkpoint name."""
    cleaned = checkpoint.strip()
    if not cleaned:
        raise ValueError("checkpoint cannot be blank")
    expedition["checkpoints"].append(cleaned)

Create two expeditions and use the functions:

dawn = create_expedition("Dawn Trail")
dusk = create_expedition("Dusk Trail")

record_distance(dawn, 3.5)
visit_checkpoint(dawn, "River Gate")

print(dawn)
print(dusk)

The output shows that the dictionaries are independent:

{'name': 'Dawn Trail', 'distance_km': 3.5, 'checkpoints': ['River Gate']}
{'name': 'Dusk Trail', 'distance_km': 0.0, 'checkpoints': []}

This is valid procedural Python. Keep it when the data shape is small, the operations are few, and passing a record explicitly remains clear.

Now imagine ten functions spread across several modules. Every caller must know the exact key spelling. Any code can assign dawn["distance_km"] = -900. A checkpoint function could accidentally receive a customer dictionary with similar keys. The design lacks a clear owner, even though every individual line is legal.

NoteA class is not automatically shorter

The class version may contain more lines. Its value is a named kind of object, a clear call interface, and one place to keep related rules—not line-count reduction.

2. Move cohesive state and behavior together

Define a class with the class statement:

class ExpeditionTracker:
    """Track the progress of one expedition."""

    def __init__(self, name):
        self.name = name
        self.distance_km = 0.0
        self.checkpoints = []

    def record_distance(self, distance_km):
        if distance_km < 0:
            raise ValueError("distance_km cannot be negative")
        self.distance_km += distance_km

    def visit(self, checkpoint):
        cleaned = checkpoint.strip()
        if not cleaned:
            raise ValueError("checkpoint cannot be blank")
        self.checkpoints.append(cleaned)

Read the definition from the outside inward:

  1. class ExpeditionTracker: asks Python to execute a class body and bind the resulting class object to the name ExpeditionTracker.
  2. Each def creates a function in the class namespace.
  3. __init__ describes how to initialize one new instance.
  4. Assignments such as self.name = name store data on that instance.
  5. Later methods read or change the instance received as self.

Calling the class produces instances:

dawn = ExpeditionTracker("Dawn Trail")
dusk = ExpeditionTracker("Dusk Trail")

dawn.record_distance(3.5)
dawn.visit("River Gate")
dusk.record_distance(1.25)

print(dawn.name, dawn.distance_km, dawn.checkpoints)
print(dusk.name, dusk.distance_km, dusk.checkpoints)
Dawn Trail 3.5 ['River Gate']
Dusk Trail 1.25 []

dawn and dusk are both ExpeditionTracker instances, but they are not the same object. Each call created a separate instance and __init__ assigned a new checkpoint list to it.

print(dawn is dusk)
print(type(dawn).__name__)
print(isinstance(dawn, ExpeditionTracker))
False
ExpeditionTracker
True

type(dawn) identifies the class that created the instance. isinstance(dawn, ExpeditionTracker) asks whether dawn is an instance of that class or one of its subclasses. The is expression asks the stricter identity question from Unit 6.

Checkpoint: instances own independent state

3. See what initialization does—and does not do

It is common to say “__init__ creates the object,” but that shortcut causes confusion later. A more accurate beginner model is:

  1. calling ExpeditionTracker("Dawn Trail") starts Python’s instance construction machinery;
  2. Python obtains a new instance;
  3. Python passes that instance to __init__ as self along with the supplied arguments; and
  4. the class call returns the initialized instance.

__new__ participates in creating the instance, but customizing it is outside this foundation unit. The important correction is that __init__ initializes an instance and must return None.

This mistake is legal to write but fails when the class is called:

class BrokenTracker:
    def __init__(self, name):
        self.name = name
        return self


try:
    BrokenTracker("Dawn Trail")
except TypeError as error:
    print(type(error).__name__)
    print(error)

Python reports that __init__() should return None, not a BrokenTracker. Remove the return self. The class call already returns the instance.

Inspect the instance namespace

Most ordinary instances keep their directly assigned attributes in a dictionary-like namespace. vars lets you inspect it:

tracker = ExpeditionTracker("Cloud Path")
print(vars(tracker))

tracker.record_distance(2)
tracker.visit("Old Bridge")
print(vars(tracker))
{'name': 'Cloud Path', 'distance_km': 0.0, 'checkpoints': []}
{'name': 'Cloud Path', 'distance_km': 2.0, 'checkpoints': ['Old Bridge']}

vars(tracker) is powerful diagnostic evidence, but normal callers should use the object’s public interface. Reaching into vars to bypass methods would defeat the rules those methods own.

The class has a different namespace:

print("record_distance" in vars(ExpeditionTracker))
print("distance_km" in vars(ExpeditionTracker))
print("distance_km" in vars(tracker))

The method name is defined on the class. distance_km is assigned to this instance by __init__. That distinction prepares us to understand method binding.

Catch misspelled and surprise attributes

When lookup cannot find a requested name, Python raises AttributeError:

tracker = ExpeditionTracker("Cloud Path")

try:
    print(tracker.distnace_km)
except AttributeError as error:
    print(type(error).__name__)
    print(error)

Read the instance type and missing name in the message. Here, distnace_km is a typo; installing a package or recreating the notebook will not fix it. Compare the request with vars(tracker) and the class’s documented interface.

Ordinary Python instances also allow a caller to create a new attribute by assignment:

tracker.distnace_km = 900

print(tracker.distnace_km)
print(tracker.distance_km)
print(vars(tracker))

The typo now creates a second field while the real distance remains 0.0. That flexibility is useful in exploratory Python, but it means naming discipline and focused checks matter. Later tools can restrict attributes or catch names statically, but this foundation course first makes the runtime behavior visible.

Class bodies execute when Python reaches the class statement. Avoid placing input, network access, or demonstrations there. This small example prints while the class is being defined, before any instance exists:

class NoisyDefinition:
    print("defining NoisyDefinition")

    def __init__(self, value):
        self.value = value

Method bodies do not run during class definition; print is directly in the class body, so it does. Real class bodies normally contain method definitions, documented shared settings, and small declarative expressions—not program launch behavior.

4. Watch Python bind a method to one instance

Access the method through the class and through an instance:

tracker = ExpeditionTracker("Cloud Path")

print(ExpeditionTracker.record_distance)
print(tracker.record_distance)

The exact addresses vary, but the first representation identifies a function and the second identifies a bound method. The bound method remembers both the underlying function and the instance:

bound = tracker.record_distance

print(bound.__self__ is tracker)
print(bound.__func__ is ExpeditionTracker.record_distance)

Both results are True.

These two calls have the same effect:

tracker.record_distance(2.5)
ExpeditionTracker.record_distance(tracker, 1.5)

print(tracker.distance_km)
4.0

In tracker.record_distance(2.5), Python obtains a bound method and supplies tracker as the first argument. The explicit form calls the underlying function through the class and supplies the instance manually. Prefer the first form in ordinary code; the second form is useful evidence for understanding self.

self is not a keyword. It is the strong Python naming convention for the first parameter of an instance method. Calling it this_object would run, but would surprise every Python reader.

Diagnose a missing self

Consider this definition:

class Badge:
    def label(prefix):
        return f"{prefix}: explorer"

The syntax is valid. The problem appears at the call:

badge = Badge()

try:
    print(badge.label("Lead"))
except TypeError as error:
    print(type(error).__name__)
    print(error)

Python binds badge to the first parameter named prefix, then the explicit "Lead" becomes a second positional argument. The function only declares one, so Python reports that two were given.

Repair the signature:

class Badge:
    def label(self, prefix):
        return f"{prefix}: explorer"


print(Badge().label("Lead"))

The fix is not “add self because methods always need magic.” It is “declare a parameter to receive the instance Python binds to this method call.”

An instance method lives on the class. Accessing it through an instance creates a bound method for that access.

flowchart LR
  call[tracker record_distance 2] --> lookup[Find function on class]
  lookup --> bind[Bind tracker as self]
  bind --> run[Run function with tracker and 2]
  run --> state[Update tracker distance]

Checkpoint: trace self and initialization

5. Put shared settings on the class—not shared personal state

A class attribute is assigned in the class body rather than to self:

class ExpeditionTracker:
    distance_unit = "km"

    def __init__(self, name):
        self.name = name
        self.distance_km = 0.0
        self.checkpoints = []

    def record_distance(self, distance_km):
        if distance_km < 0:
            raise ValueError("distance_km cannot be negative")
        self.distance_km += distance_km

The unit label is intentionally shared:

dawn = ExpeditionTracker("Dawn")
dusk = ExpeditionTracker("Dusk")

print(dawn.distance_unit)
print(dusk.distance_unit)
print("distance_unit" in vars(dawn))
print("distance_unit" in vars(ExpeditionTracker))

Python does not find distance_unit directly on dawn, so it finds the name on the class. This common lookup model explains why both instances see "km".

Assigning through one instance creates or changes an instance attribute; it does not rewrite the class attribute:

dawn.distance_unit = "miles"

print(dawn.distance_unit)
print(dusk.distance_unit)
print(ExpeditionTracker.distance_unit)
print(vars(dawn))

dawn now has its own distance_unit that shadows the class value. dusk still finds "km" on the class. Deleting dawn.distance_unit would reveal the class value again.

This is a useful simplified model for normal attributes. Properties and other managed attributes add lookup behavior, and inheritance adds base classes. We will observe properties next rather than pretending this simplified order is a complete implementation algorithm.

Each instance owns its direct state and refers to the same class for methods and shared settings.

flowchart BT
  dawn["dawn namespace: name, distance, unit=miles"] --> cls["ExpeditionTracker class: methods, unit=km"]
  dusk["dusk namespace: name, distance"] --> cls

Repair the shared-list trap

Mutable class attributes often create an accidental connection:

class BrokenExpedition:
    checkpoints = []

    def __init__(self, name):
        self.name = name

    def visit(self, checkpoint):
        self.checkpoints.append(checkpoint)

Predict the second output before running it:

north = BrokenExpedition("North")
south = BrokenExpedition("South")

north.visit("Pine Gate")

print(north.checkpoints)
print(south.checkpoints)
print(north.checkpoints is south.checkpoints)

Both instances find the same list on the class, so both print ['Pine Gate'], and the identity check is True.

Repair the owner of the state:

class Expedition:
    def __init__(self, name):
        self.name = name
        self.checkpoints = []

    def visit(self, checkpoint):
        self.checkpoints.append(checkpoint)

Now each __init__ call creates a fresh list and assigns it to that instance. Use class attributes for genuinely shared facts or behavior—course-wide units, protocol versions, fixed limits—not for each object’s evolving collection.

6. Notice the difference between returning and changing

Methods are still functions. They can return values, cause side effects, or do both. Make that contract deliberate:

class DistanceLog:
    def __init__(self):
        self._entries = []

    def record(self, distance_km):
        """Store a distance and return the new total."""
        if distance_km < 0:
            raise ValueError("distance_km cannot be negative")
        self._entries.append(distance_km)
        return sum(self._entries)

    def total(self):
        """Return the total without changing the log."""
        return sum(self._entries)

record changes _entries and reports the new total. total only answers a question. A caller can verify both parts:

log = DistanceLog()

new_total = log.record(2.5)
same_total = log.total()

assert new_total == 2.5
assert same_total == 2.5

Do not return self by habit just to chain calls. Fluent chains can obscure when mutation happens, especially for beginners. Return the result the caller needs, or return None when the action itself is the complete contract.

7. Build a trail counter and explain why it is a class

Create TrailCounter with these names and contracts:

class TrailCounter:
    distance_unit = "km"

    def __init__(self, trail_name):
        """Start one named trail with zero distance and no checkpoints."""
        ...

    def add_distance(self, amount):
        """Add a non-negative amount and return the new total."""
        ...

    def reach(self, checkpoint):
        """Store a non-blank checkpoint and return its one-based number."""
        ...

    def summary(self):
        """Return `<trail>: <distance> km, <count> checkpoints`."""
        ...

Your implementation must satisfy:

ridge = TrailCounter("Ridge Run")
marsh = TrailCounter("Marsh Walk")

assert ridge.add_distance(2.5) == 2.5
assert ridge.add_distance(1.0) == 3.5
assert ridge.reach("Stone Arch") == 1
assert ridge.reach("North Lookout") == 2
assert ridge.summary() == "Ridge Run: 3.5 km, 2 checkpoints"

assert marsh.summary() == "Marsh Walk: 0.0 km, 0 checkpoints"
assert ridge.checkpoints is not marsh.checkpoints
assert TrailCounter.distance_unit == "km"

try:
    ridge.add_distance(-1)
except ValueError as error:
    assert str(error) == "amount cannot be negative"
else:
    raise AssertionError("negative distance should fail")

try:
    ridge.reach("   ")
except ValueError as error:
    assert str(error) == "checkpoint cannot be blank"
else:
    raise AssertionError("blank checkpoint should fail")

After it passes, write four sentences:

  1. Which attributes belong to each instance?
  2. Why is distance_unit a class attribute?
  3. What instance becomes self in ridge.reach("Stone Arch")?
  4. What does the class improve compared with a dictionary and functions?

Hint 1

In __init__, assign trail_name, distance_km, and a newly created checkpoints list to self. Do not place the list in the class body.

Hint 2

Validate before assigning or appending. reach can strip the supplied text, append the cleaned value, and return len(self.checkpoints).

Hint 3

summary only reads state. An f-string can use self.trail_name, self.distance_km, len(self.checkpoints), and self.distance_unit.

Compare a complete implementation after attempting the lab
class TrailCounter:
    distance_unit = "km"

    def __init__(self, trail_name):
        cleaned_name = trail_name.strip()
        if not cleaned_name:
            raise ValueError("trail_name cannot be blank")
        self.trail_name = cleaned_name
        self.distance_km = 0.0
        self.checkpoints = []

    def add_distance(self, amount):
        if amount < 0:
            raise ValueError("amount cannot be negative")
        self.distance_km += amount
        return self.distance_km

    def reach(self, checkpoint):
        cleaned = checkpoint.strip()
        if not cleaned:
            raise ValueError("checkpoint cannot be blank")
        self.checkpoints.append(cleaned)
        return len(self.checkpoints)

    def summary(self):
        return (
            f"{self.trail_name}: {self.distance_km} {self.distance_unit}, "
            f"{len(self.checkpoints)} checkpoints"
        )


ridge = TrailCounter("Ridge Run")
marsh = TrailCounter("Marsh Walk")

assert ridge.add_distance(2.5) == 2.5
assert ridge.add_distance(1.0) == 3.5
assert ridge.reach("Stone Arch") == 1
assert ridge.reach("North Lookout") == 2
assert ridge.summary() == "Ridge Run: 3.5 km, 2 checkpoints"
assert marsh.summary() == "Marsh Walk: 0.0 km, 0 checkpoints"
assert ridge.checkpoints is not marsh.checkpoints

The evolving fields belong to each instance. The immutable unit label is shared intentionally. ridge becomes self for its bound call. The class gives the state one named owner and makes valid operations discoverable at the call site.

Checkpoint: decide whether the class earns its place

8. Key points for your first useful class

  • Begin with the simplest working design. Introduce a class when named state ownership and related operations improve the program.
  • A class statement creates a class object. Calling the class returns an instance after initialization.
  • __init__ initializes an instance and returns None; it is not the method to return self from.
  • Attributes assigned to self belong directly to that instance. Two class calls can therefore produce independent state.
  • Functions defined on a class become bound methods when accessed through an instance. The bound instance is supplied as self.
  • Class attributes are useful for intentionally shared settings and methods. Mutable per-instance state belongs in initialization.
  • An instance attribute can shadow a class attribute. vars helps you inspect where ordinary state is stored.
  • A useful class makes responsibilities and valid operations easier to see. It does not earn its place merely by wrapping a dictionary.

References

Back to top