Skip to content
  • Home
  • How To
  • M0
  • Module 1 - Python
  • Agents
  • M2
  • M3
  • M4
  • M5
  • M6
  • Progress
  • Contact
  • Pricing
  • Sign in
  • Future
  1. Module 1M1
  2. ›
    S8Object-Oriented Programming
  3. ›
    Lesson 2 of 3Instance vs Class Data, Representation, and Encapsulation
  4. ›
    S8›Lesson 2 of 3
  1. Module 1
  2. ›
  3. Section 8
  4. ›
  5. Lesson 2

Instance vs Class Data, Representation, and Encapsulation

FREE·~1.5 h·2 labs·4 questions·1 assignment


Context

Data attached to one object belongs to that object alone, while data attached to the class is shared by everything made from it. An object can also define how it prints, one form aimed at someone debugging and one aimed at a reader.

The split matters as soon as more than one object exists. A count of how many records each loader has processed belongs to the individual loader, while a default encoding shared by all of them belongs to the class. Printing matters more than it sounds, because an object that prints as an unhelpful memory address turns every inspection into extra work, and a list of them tells you nothing about their contents.

Putting changeable data on the class is the failure that looks like corruption. One shared list means every object appends to the same one, so records loaded by one loader appear in another, and the bug reads as data contamination rather than as a misplaced attribute. Encapsulation is the discipline that prevents the milder version, outside code reaching in and setting a value the object was maintaining itself. The previous lesson created objects, this lesson decides what each one owns.


Goal

  • Distinguish an instance attribute (unique per object) from a class attribute (shared by all instances).
  • Predict and avoid the shared-mutable-class-attribute trap.
  • Give an object a useful __repr__ so it prints an informative summary instead of an opaque address.
  • Distinguish __repr__ from __str__ and explain which one print and the interactive prompt use.
  • Group related data and behavior behind a clean interface, using methods to protect an object's invariants.

Prerequisites

Optional, neither is required. The lesson stands on its own, they are here if you want the two kinds of attribute shown side by side first.

  • YouTube - "Python OOP Tutorial 2: Class Variables" by Corey Schafer. The instance-versus-class data distinction demonstrated live.
  • Official docs - The Python Tutorial, "Class and Instance Variables". The authoritative account of the two kinds, and of the shared-data caveat.

Table of Contents

  • Instance Attribute
  • Class Attribute
  • __repr__ / __str__
  • Encapsulation

Theory

Instance Attribute

What. An instance attribute is data that belongs to one specific object, stored on self, in a slot of its own on each instance. Why. It is the object's own memory, the thing that makes one Model differ from another, and almost all of an object's meaningful state lives here. When. You create an instance attribute every time you assign to self.something, which is normally inside __init__ so every instance starts with the same set of attributes.

You already met instance attributes in the previous lesson, this section sharpens the definition so it can be contrasted with its counterpart. The defining trait is ownership: an instance attribute is stored in the individual object, so two objects of the same class hold separate slots, each bound to its own value unless the same object is handed to both. Assigning self.weights = ... in __init__ carves out a slot on each instance as it is built, and changing one instance's slot never touches another's.

Because each instance owns its attributes, instance attributes are the right home for anything that varies from object to object, a model's learned weights, a dataset's row count, an account's balance. If two objects should be able to differ in some value, that value must be an instance attribute.

class Model:
    def __init__(self, name, learning_rate):
        # both are INSTANCE attributes, one slot per object
        self.name = name
        self.learning_rate = learning_rate

a = Model("linear", 0.01)
b = Model("tree", 0.10)
# each object owns its own values, fully independent
print(a.learning_rate, b.learning_rate)  # 0.01 0.1
a.learning_rate = 0.05                   # changes ONLY 'a'
print(a.learning_rate, b.learning_rate)  # 0.05 0.1

This independence is precisely what you want for per-object state, and it is the baseline against which class attributes are about to look very different.

DON'T:

  • DON'T put per-object data anywhere but on self, a value two instances should be able to differ on must be an instance attribute.
  • DON'T define some instances' attributes in __init__ and others in random methods, set the full attribute set in __init__ so every instance is shaped identically.

Class Attribute

What. A class attribute is data defined directly in the class body, shared by every instance of that class rather than copied per object. Why. Some values genuinely belong to the kind of thing, not to each individual, a shared default, a constant, a counter of how many instances exist, and duplicating them on every instance would be wasteful and error-prone. When. You use a class attribute for values common to all instances: shared defaults, class-wide constants, or bookkeeping about the class as a whole.

A class attribute is written in the class body, indented under class, but not inside any method and not on self. There is exactly one copy, owned by the class itself, and every instance sees it. When you read instance.attr, Python first looks on the instance, and if it finds nothing there, it falls through to the class. That fall-through is how all instances appear to "have" the shared value without any of them owning a copy of its own.

This single-copy sharing hides the module's classic beginner trap, which is worth slowing down for. The trap has two faces. First, reassigning through an instance, instance.attr = x, does not change the class attribute, it silently creates a new instance attribute that shadows the shared one for that object only. Second, and more dangerous, if the class attribute is a mutable object like a list, then mutating it through any instance mutates the one shared object, so the change leaks into every instance at once.

class Model:
    # CLASS attribute: one shared copy, defined in the class body
    framework = "numpy"

    def __init__(self, name):
        self.name = name         # instance attribute, per object

a = Model("linear")
b = Model("tree")
# both instances SEE the shared class attribute via fall-through
print(a.framework, b.framework)  # numpy numpy
# changing it ON THE CLASS updates the shared value for everyone
Model.framework = "torch"
print(a.framework, b.framework)  # torch torch

This is the intended, benign use of a class attribute, a value that genuinely belongs to the kind rather than to any one object.

class Model:
    framework = "numpy"  # shared class attribute

a = Model()
b = Model()
# THE TRAP, part one: assigning THROUGH an instance shadows, not shares
a.framework = "torch"    # creates an INSTANCE attribute on 'a' only
print(a.framework)       # torch  (a's new instance attribute)
print(b.framework)       # numpy  (still the untouched class attribute)
print(Model.framework)   # numpy  (the class attribute never changed)

A plain assignment through an instance, instance.attr = x, always writes an instance attribute and never the class attribute, no matter how much the line looks like an edit to the shared value, while += on a shared list does both, extending the class's list in place and then binding the instance's name to it. Assigning on the class, Model.framework = ..., is the only assignment that rebinds a shared default.

class Dataset:
    # DANGER: a MUTABLE class attribute shared by all instances
    tags = []  # one single list, shared by every Dataset

a = Dataset()
b = Dataset()
# mutating (not reassigning) the shared list leaks into EVERY instance
a.tags.append("clean")
print(b.tags)  # ['clean']  (b sees a's change, same shared list!)

This is the trap's dangerous face, and the difference from the block above is one word. That one reassigned, which shadows and protects the others. This one mutates, so there is no new instance attribute created to save anybody, and a.tags and b.tags are literally the same list. The fix is to create per-instance mutable data in __init__ with self.tags = [], so each object gets its own.

DON'T:

  • DON'T use a mutable object (list, dict, set) as a class attribute meant to hold per-instance data, every instance would share and mutate the same one, create it in __init__ instead.
  • DON'T change a shared default by assigning through an instance, instance.attr = x only shadows it, assign on the class (Class.attr = x) to change it for every instance that has not shadowed it.

__repr__ / __str__

What. __repr__ and __str__ are special methods that define how an object turns itself into text, __repr__ for an unambiguous developer-facing form, __str__ for a readable user-facing form. Why. By default an object prints as a useless <__main__.Model object at 0x...>, and a good __repr__ replaces that with a clear summary, which pays back constantly every time you debug or log. When. You write __repr__ on essentially every data-carrying class, and you add a separate __str__ only when the user-facing display should differ from the developer-facing one.

The default text form of an object is its type and memory address, which tells you almost nothing about its contents. Defining __repr__ lets the object describe itself. The convention for __repr__ is to return, where it can, a string that looks like the code you would write to recreate the object, such as Model(name='linear', lr=0.01), unambiguous, precise, aimed at a developer reading a log or a debugger. This is the one to define first and the one you will rely on most.

__str__ serves a different audience: the end user, who wants something friendly rather than precise. print(obj) and str(obj) use __str__ if it exists, and fall back to __repr__ if it does not, so defining __repr__ alone already gives print something sensible. The interactive prompt and containers (like a list of objects) always use __repr__. A common, sound strategy is to define a good __repr__ and skip __str__ entirely unless you have a real reason for two different displays.

class Model:
    def __init__(self, name, lr):
        self.name = name
        self.lr = lr

    # '__repr__' returns an unambiguous, recreate-it-style string
    def __repr__(self):
        # an f-string builds the developer-facing representation
        return f"Model(name={self.name!r}, lr={self.lr})"

m = Model("linear", 0.01)
print(m)        # Model(name='linear', lr=0.01)
print(repr(m))  # Model(name='linear', lr=0.01)
print([m, m])   # [Model(name='linear', lr=0.01), Model(name='linear', lr=0.01)]

{self.name!r} is the !r from 1.6.3, and here it is the fragment doing real work: it inserts the repr of the name rather than its plain text, which is what turns linear into 'linear', and quotes are the entire difference between a representation you could paste back into code and one you could not.

class Money:
    def __init__(self, cents):
        self.cents = cents

    # developer-facing: precise and unambiguous
    def __repr__(self):
        return f"Money(cents={self.cents})"

    # user-facing: friendly, used by print/str
    def __str__(self):
        return f"${self.cents / 100:.2f}"

price = Money(1599)
print(price)        # $15.99      (uses __str__)
print(repr(price))  # Money(cents=1599)  (uses __repr__)
print([price])      # [Money(cents=1599)]  (containers use __repr__)

Money defines both because its two audiences genuinely differ, a user wants $15.99 and a developer debugging wants the exact cents=1599. Note which one the list chose. A container never asks for __str__, so the friendly form is unreachable there no matter how carefully you wrote it.

DON'T:

  • DON'T leave a data class with the default object representation, the opaque <...object at 0x...> wastes your time on every debug session, define __repr__.
  • DON'T put a friendly, lossy display in __repr__, keep __repr__ precise and unambiguous, put the prettied form in __str__ if you need one.

Encapsulation

What. Encapsulation is bundling an object's data together with the methods that manage it, exposing a clean interface while keeping the internal details and invariants protected. Why. It lets the rest of your program use an object through a small, safe set of operations without depending on or corrupting its internals, so you can change the implementation later without breaking callers. When. You apply encapsulation whenever an object has rules about its own state, a balance that must not go negative, a buffer that must stay sorted, and you want those rules enforced in one place rather than trusted to every caller.

Encapsulation is the design idea the whole module has been circling. Instead of letting outside code reach in and set attributes arbitrarily, you provide methods that are the only sanctioned way to change the state, and those methods enforce the object's rules, its invariants. A bank account exposes deposit and withdraw rather than a raw, freely-writable balance, because those methods can refuse an overdraft. The data and the rules that keep it valid live together, which is exactly what a class is for.

Python does not enforce privacy the way some languages do, it relies on convention. A single leading underscore, like self._balance, is a clear signal that says "internal, do not touch from outside", Python will not stop you, but the underscore tells every reader the attribute is part of the implementation, not the public interface. Honoring that convention is what keeps the interface clean. The goal is not secrecy for its own sake, it is concentrating each rule in one method so the object cannot be left in an invalid state through its interface.

class Account:
    def __init__(self, owner):
        self.owner = owner  # public: fine to read
        self._balance = 0   # internal: leading underscore = "hands off"

    # the ONLY sanctioned way to add money
    def deposit(self, amount):
        # enforce the invariant: deposits must be positive
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount

    # the ONLY sanctioned way to remove money
    def withdraw(self, amount):
        # enforce the invariant: never overdraw
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance -= amount

    # a read-only view of the protected state
    def balance(self):
        return self._balance

acc = Account("goku")
acc.deposit(100)
acc.withdraw(30)
print(acc.balance())        # 70
# acc.withdraw(1000)  # would raise ValueError, the invariant holds

Each invariant is enforced in exactly one place, which is the whole of the technique. And because callers depend on the three methods rather than on _balance, you could later store the money as a list of transactions and not one of them would notice. That is encapsulation delivering its real payoff, a stable interface over an interior you are still free to change.

class RunningMean:
    def __init__(self):
        self._total = 0  # internal accumulators
        self._count = 0

    # the public operation, maintains both internals together
    def add(self, value):
        self._total += value
        self._count += 1

    # derived, read-only view, never lets the internals drift
    def value(self):
        return self._total / self._count if self._count else 0.0

    def __repr__(self):
        return f"RunningMean(value={self.value():.3f}, n={self._count})"

rm = RunningMean()
for x in [2, 4, 6]:
    rm.add(x)
print(rm)                # RunningMean(value=4.000, n=3)
print(rm.value())        # 4.0

Two internals updated together in one method cannot fall out of sync through the interface, and value() derives the mean on demand rather than storing a third number that could drift from the other two. This is the design instinct the ML libraries ahead are built on, an estimator's learned state is set by .fit() and used by .predict(), and what it keeps for itself carries the same leading underscore.

DON'T:

  • DON'T expose raw, freely-writable attributes for state that has rules, route every change through a method that enforces the invariant.
  • DON'T reach past the underscore convention and poke obj._internal from outside, the underscore is a contract, breaking it couples your code to details that may change.

Lab Exercises

Lab Exercise 1: A Shared Default, a Per-Instance Override, and the Leak Between Them

Define a class Layer with a class attribute activation = "relu" and an instance attribute units set in __init__. Create two layers and print that both see the default. Change the default on the class and show both reflect it. Then set activation on one instance and print all three values, the two instances and the class, and say in a comment why the other instance is unaffected.

Then write a class Dataset that wrongly declares tags = [] in the class body. Append through one instance and print the other's tags to show the leak. Rewrite the class with self.tags = [] in __init__, repeat the same two lines, and add a comment naming which of the two operations, assignment or mutation, produced each behavior.

Lab Exercise 2: An Object That Describes Itself, and One That Protects Itself

Define a class Tensor holding shape (a tuple) and dtype (a string) with a __repr__ returning something like Tensor(shape=(3, 4), dtype='float32'). Print one directly, print repr() of it, and print a list holding two of them.

Then define Buffer, which hides an internal list _items and exposes push(x), pop() (which raises on empty), and size(). Give it both a __repr__ showing the contents and a __str__ giving a friendly one-line summary.

Push two values, then print buf, repr(buf) and [buf] one after another, and add a comment stating which of the three used __str__ and which used __repr__. Finish by calling pop() on a fresh empty Buffer inside a try and printing the error.


Solution 1: A Shared Default, a Per-Instance Override, and the Leak Between Them
class Layer:
    activation = "relu"            # CLASS attribute, one shared copy

    def __init__(self, units):
        self.units = units         # INSTANCE attribute, one per object

a = Layer(64)
b = Layer(128)
print(a.activation, b.activation)  # relu relu

Layer.activation = "tanh"          # changed ON THE CLASS, so everyone sees it
print(a.activation, b.activation)  # tanh tanh

a.activation = "sigmoid"           # assigning THROUGH an instance
# sigmoid tanh tanh
print(a.activation, b.activation, Layer.activation)
# 'b' is unaffected because the assignment through 'a' did not touch the shared value
# at all. It created a NEW instance attribute on 'a' which now hides the
# class attribute for 'a' alone, and while it lasts, lookups stop there.
class Dataset:
    tags = []          # WRONG: one list, shared by every instance

x = Dataset()
y = Dataset()
x.tags.append("clean")
print(y.tags)          # ['clean']

class Dataset:         # FIXED: a fresh list per object
    def __init__(self):
        self.tags = []

x = Dataset()
y = Dataset()
x.tags.append("clean")
print(x.tags, y.tags)  # ['clean'] []
# Assignment shadows, mutating a shared object leaks. 'a.activation = ...'
# above was an assignment, so it created an attribute on 'a' and nothing
# leaked. '.append(...)' here is a mutation of the one shared list, so there
# was no new copy to save 'y', and it saw the change.

Those two blocks are the same mechanism producing opposite-looking results, which is exactly why the trap catches people. The rule that covers both: a name lookup falls through to the class, an assignment never does, and a mutation reaches whatever object the lookup found.

DON'T:

  • DON'T change a shared default through an instance, assigning on the class, Layer.activation = ..., is the only assignment that rebinds it, and even that leaves a, which has shadowed it, at "sigmoid".
  • DON'T store per-instance mutable data in the class body, and don't try to patch the leak with x.tags = [...] on one instance, the structural fix is creating the list in __init__.
Solution 2: An Object That Describes Itself, and One That Protects Itself
class Tensor:
    def __init__(self, shape, dtype):
        self.shape = shape
        self.dtype = dtype

    def __repr__(self):
        # '!r' adds the quotes
        return f"Tensor(shape={self.shape}, dtype={self.dtype!r})"

t = Tensor((3, 4), "float32")
print(t)        # Tensor(shape=(3, 4), dtype='float32')
print(repr(t))  # Tensor(shape=(3, 4), dtype='float32')
print([t, Tensor((2,), "int64")])
# [Tensor(shape=(3, 4), dtype='float32'), Tensor(shape=(2,), dtype='int64')]
class Buffer:
    def __init__(self):
        self._items = []         # internal, the underscore says 'hands off'

    def push(self, x):
        self._items.append(x)

    def pop(self):
        if not self._items:      # the invariant, enforced in ONE place
            raise IndexError("pop from empty buffer")
        return self._items.pop()

    def size(self):
        return len(self._items)  # derived on demand, never stored

    def __repr__(self):
        return f"Buffer({self._items!r})"

    def __str__(self):
        return f"buffer of {self.size()}"

buf = Buffer()
buf.push(10)
buf.push(20)

print(buf.size())                # 2
print(buf)                       # buffer of 2
print(repr(buf))                 # Buffer([10, 20])
print([buf])                     # [Buffer([10, 20])]
# 'print(buf)' took '__str__' because one exists. 'repr(buf)' took
# '__repr__' directly, and the LIST took '__repr__' too, because a
# container has no fallback and never asks for '__str__'.

print(buf.pop())                 # 20
print(buf.size())                # 1

try:
    Buffer().pop()               # a fresh, empty buffer
except IndexError as err:
    print("IndexError:", err)    # IndexError: pop from empty buffer

size() deriving the count on every call rather than keeping a self._size attribute is the encapsulation decision worth naming. A stored count is a second source of truth that push and pop must both remember to update, and the first one that forgets leaves the object lying about itself.

DON'T:

  • DON'T hand callers _items to append to directly, they would bypass push and any rule it ever grows.
  • DON'T expect a list of your objects to use __str__, containers always call __repr__, which is why __repr__ is the one to define first.

Theory Assessment

Q1: How Many Copies There Are, and Where Python Looks

Explain the difference between an instance attribute and a class attribute in terms of how many copies exist and who owns each. Then say what Python does when you read instance.attr and the instance has no attribute of that name, and what that lookup rule means for how every instance appears to "have" a class attribute.

Hint: One kind has a copy per object, the other has one copy in total. For the second half, the lookup checks the instance first and then somewhere else.

Q2: What an Object Prints, and Which Method Decides

Explain what an object prints by default with no __repr__ defined, and what a good __repr__ should return instead. Then state which method print(obj) uses, which method a list of objects uses, and what happens when only __repr__ is defined.

Hint: The default carries a type and an address and nothing about the contents. For the second half, one of the two methods has a fallback and containers do not.

Q3: Why the Balance Is Not a Public Attribute

Explain why exposing deposit and withdraw methods over a hidden _balance is better than letting callers assign to a public balance. Say where the rules end up living in each design, and what the method version lets you change later without breaking anyone.

Hint: Count how many places the "no overdraft" rule has to be written under each design.

Q4: Two Instances, One List, and Two Ways of Printing

Read the following without running it, then state every line of output in order.

class Run:
    tags = []
    status = "new"

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

    def __repr__(self):
        return f"Run({self.name!r}, tags={self.tags})"

    def __str__(self):
        return f"run {self.name}"

a = Run("a")
b = Run("b")

a.tags.append("clean")
a.status = "done"

print(a.status, b.status, Run.status)
print(b.tags)
print(a)
print([a, b])

Two lines change something through a and only one of the changes reaches b. Say which one leaked and which one did not, explain what makes them behave differently, and say why the third and fourth print calls produce text in two different formats.

Hint: One of the two lines is an assignment and the other is a method call on an object the assignment never replaced.


Answer 1:

An instance attribute has one independent slot per object, stored on the instance through self, so each object owns its own binding. A class attribute has a single copy defined in the class body, owned by the class and shared by every instance. Instance attributes are therefore the home for anything that varies per object, and class attributes for anything belonging to the kind as a whole, a shared default, a constant, a piece of class-wide bookkeeping.

When you read instance.attr and the instance has no attribute of that name, Python falls through and looks on the instance's class. That fall-through is exactly how every instance appears to "have" a class attribute without owning a copy, all of them are reading the one shared value. It also explains shadowing: once an instance has its own attribute of that name, the lookup finds it first and, while it exists, never falls through.

DON'T assume reading a class attribute through an instance copies it onto the instance, the read only follows the fall-through and leaves the instance as it was.

Answer 2:

With no __repr__, an object prints as its type and memory address, like <__main__.Model object at 0x...>, which says nothing about its contents. A good __repr__ returns an unambiguous string that ideally looks like the code that would recreate the object, such as Model(name='linear', lr=0.01), so logs, debuggers and the interactive prompt show real state instead of a pointer.

print(obj) uses __str__ when it exists and falls back to __repr__ when it does not. A list, and every other container, always uses __repr__ for its elements with no such flexibility. So defining __repr__ alone already gives sensible output from print, str, the prompt and containers alike, which is why it is the one to write first and often the only one worth writing.

DON'T put a prettied, lossy display in __repr__, keep it precise and put the friendly form in __str__ if the two audiences genuinely differ.

Answer 3:

Exposing deposit and withdraw over a hidden _balance puts the rules, deposits positive and no overdraft, inside those two methods, so each invariant is written once and can only be bypassed by reaching past the underscore. Letting callers assign to a public balance scatters that responsibility across every caller, and the object is only as valid as the least careful one of them, with no single place to fix.

What the method version buys later is freedom. Because callers depend on the interface rather than on the storage, you can change how the balance is held internally, to whole cents, to a running list of transactions, and every caller keeps working. That is the real payoff, and it is only available because nothing outside reached in.

DON'T expose freely writable state that has rules attached, an invariant enforced by convention is an invariant that will be broken.

Answer 4:

The output is four lines:

done new new
['clean']
run a
[Run('a', tags=['clean']), Run('b', tags=['clean'])]

a.status = "done" did not leak. It is an assignment through an instance, which never writes the class attribute, so it created a new instance attribute on a that shadows the shared one. b and Run both still read "new".

a.tags.append("clean") did leak. It is a mutation, not an assignment. The lookup a.tags found no instance attribute, fell through to the class, and returned the one shared list, and .append then changed that list in place. There was no new copy created to protect b, so b.tags is the same object and shows ['clean'].

The two lines look symmetrical and are not, which is the whole trap. Assignment replaces a binding and can only replace the one it is written on. Mutation changes an object, and every name pointing at that object sees it.

The third and fourth prints differ because they ask different questions. print(a) uses __str__ when one exists, so it gives run a. The list uses __repr__ for every element with no fallback, so it gives the precise form, and that form is where the leak becomes visible twice over, both runs report the same tags because there is only one list.

DON'T read a.attr = x and a.attr.append(x) as two versions of the same operation, the first can never reach the class and the second reaches it whenever a has no attr of its own.


Assignment

Assignment: A Self-Counting Experiment With an Alternative Constructor

Define a class Experiment that uses a class attribute _created = 0 as a shared counter, incremented in __init__ every time an instance is made, plus instance attributes name and seed. Give each instance a read-only id derived from the counter's value at the moment it was created, so every experiment permanently remembers its own creation order.

Add a class method from_label(label) that builds an Experiment whose name is the label and whose seed is the label's length, and a __repr__ showing the id, the name and the seed.

Create several experiments, some through __init__ and at least one through from_label, print each of them, and print the total number created.

Then show the mistake this design invites. Write a second version whose __init__ increments self._created instead of Experiment._created, build three instances from it, print each one's id and the class counter, and add a comment stating what every id came out as and why the class counter never moved.

Hints:

  • Increment the counter on the class as Experiment._created += 1, then copy the current value onto the instance so each object keeps its own id.
  • @classmethod def from_label(cls, label): receives the class as cls and returns cls(label, len(label)), funneling through the ordinary __init__ so the counter still moves.

Solution:
class Experiment:
    _created = 0                        # CLASS attribute, the shared creation counter

    def __init__(self, name, seed):
        Experiment._created += 1        # bump the SHARED counter, on the class
        self._id = Experiment._created  # snapshot it onto this instance
        self.name = name
        self.seed = seed

    def id(self):                       # read-only view of this instance's id
        return self._id

    @classmethod
    def total(cls):                     # read-only view of the shared counter
        return cls._created

    @classmethod
    def from_label(cls, label):         # 'cls' is the class, not an instance
        return cls(label, len(label))   # funnels through the normal __init__

    def __repr__(self):
        return f"Experiment(id={self._id}, name={self.name!r}, seed={self.seed})"

a = Experiment("baseline", 42)
b = Experiment.from_label("tuned")      # name='tuned', seed=5
c = Experiment("ablation", 7)

print(a)                                # Experiment(id=1, name='baseline', seed=42)
print(b)                                # Experiment(id=2, name='tuned', seed=5)
print(c)                                # Experiment(id=3, name='ablation', seed=7)
# total created: 3
print("total created:", Experiment.total())
# THE MISTAKE: incrementing through 'self' instead of on the class.
class Broken:
    _created = 0

    def __init__(self, name):
        self._created += 1     # reads the CLASS value, writes an INSTANCE one
        self._id = self._created
        self.name = name

    def id(self):
        return self._id

    @classmethod
    def total(cls):
        return cls._created

x = Broken("one")
y = Broken("two")
z = Broken("three")

print(x.id(), y.id(), z.id())  # 1 1 1
print(Broken.total())          # 0
# Every id is 1 and the class counter never moved. '+=' on 'self._created'
# reads through the fall-through, finds 0 on the class, adds one, and then
# ASSIGNS the result to the instance, which is the shadowing rule from
# earlier in this lesson doing exactly what it always does.

The broken version is worth more than the working one, because its failure is silent and its output is plausible. Nothing raises, every instance reports an id, and the ids are only wrong if you happen to build more than one and compare them. A counter that always reads 1 looks like a counter.

b getting id 2 is the detail that proves from_label is not a second construction path but the same one wearing a different name. cls(label, len(label)) calls __init__ exactly as Experiment(...) does, so the counter moves, the id is assigned, and every invariant the initializer maintains holds for both ways of building an object. An alternative constructor that skipped __init__ and set attributes itself would have to re-implement every one of those rules, and would drift from them the first time one changed.

DON'T write self._created += 1 to update a shared counter, the read falls through to the class and the write lands on the instance, so the counter never moves and every instance is stuck at the same value.


Additional Resources

Optional, and nothing later depends on either. Worth opening if you want the exact contracts, or the shortcut that writes this boilerplate for you.

  • Official docs - dataclasses, the @dataclass entry. The shortcut that generates __init__, __repr__ and __eq__ from declared fields.
  • Official docs - Python Language Reference, the object.__repr__ and object.__str__ entries. Two short entries stating precisely what each method is contracted to return.

Summary

This lesson was about a single question that quietly governs the quality of object-oriented code: where does each piece of state actually live, and who is allowed to change it. The instance-versus-class distinction is the first half of that answer. Most data belongs to the individual object and must live on self, while a smaller, carefully chosen set of values belongs to the kind itself and lives on the class. Getting that boundary wrong is not a stylistic slip, a mutable class attribute holding per-instance data is a real bug that leaks one object's changes into all the others, and recognizing that trap before it bites is part of what it means to think in objects.

Representation and encapsulation are the second half, and they are about kindness to the humans who read and use your objects, including your future self. A class that prints a clear summary of itself turns every debugging session from guesswork into reading, and the small discipline of writing a __repr__ pays back every single time something goes wrong. Encapsulation goes further, drawing a line between the interface an object offers and the internals it protects, so that the rules keeping an object valid live in one place and can be bypassed only by code that reaches past the underscore. The underscore is only a convention, but honoring it is how a codebase keeps its objects trustworthy.

Together these ideas turn a class from a mere bundle of fields into a genuine, well-behaved type, one that knows what belongs to it, presents itself clearly, and guards its own consistency. That is exactly the character of the objects waiting for you in the data and ML libraries ahead. When you fit an estimator that keeps its internals behind a leading underscore, when a model prints a tidy summary of how it was configured, you are seeing the principles of this lesson applied by people who took them seriously. You are no longer just using such objects, you understand why they were built the way they were, and the final piece, how these well-made types relate to one another, is where the next lesson takes you.


Cheat Sheet

Concept Where defined Copies Use for
Instance attribute self.x = ... in __init__ one per object per-object varying state
Class attribute in the class body one, shared shared defaults, constants, counters
Shadowing obj.x = ... makes a new instance attr (usually accidental)
Mutable-class-attr trap mutable in class body one shared object NEVER for per-instance data
__repr__ def __repr__(self): one, shared unambiguous dev-facing text
__str__ def __str__(self): one, shared friendly user-facing text
Encapsulation _internal + methods n/a protecting invariants
class Account:
    bank = "Local"              # class attribute (shared)
    def __init__(self, owner):
        self.owner = owner      # instance attribute (per object)
        self._balance = 0       # internal: underscore = hands off
    def deposit(self, amount):  # method enforces the invariant
        if amount <= 0: raise ValueError("must be positive")
        self._balance += amount
    def balance(self):          # read-only view of protected state
        return self._balance
    def __repr__(self):         # clear, unambiguous self-description
        return f"Account(owner={self.owner!r}, balance={self._balance})"

# TRAP: mutable class attribute is shared, use __init__
# class Bad: tags = []     # one shared list, mutation leaks everywhere
←Previous: Lesson 1
Next: Lesson 3→

Questions about this lesson: #module-1 on Discord

Found an error in this lesson? Report it

Your progress
Current streak
🔥 0
Lessons marked today
0 / 3
Longest streak
0

Sign in

The bootcamp

  • Curriculum
  • How To
  • Pricing
  • Changelog
  • Future

The community

  • Discord
  • Reviews
  • Contact

Follow

  • Instagram
  • X
  • TikTok
  • Reddit

Legal

  • Terms
  • Privacy
  • Refunds

Coming soon on November 16, 2026

Theme