class, __init__, self — the three keywords ai uses every time
self is just the instance
The thing that confuses every Python newcomer exactly once: every method
in a class takes self as its first argument, but you never pass it
yourself when you call the method.
def describe(self): # the method declares self as its first arg
return f"{self.name} ..."
rex.describe() # but you call it with zero arguments
That asymmetry — declared with one parameter, called with zero — is the piece that breaks the math model in your head. Once you see what's actually happening, it stops being weird.
What the dot is doing
rex.describe() is secretly Pet.describe(rex).
The dot operator does two things at once: it looks up the describe
method on the class, and it silently passes rex as the first argument.
You see one argument list at the call site (the ()), but Python
constructs two: the implicit self, and whatever else you wrote inside
the parentheses.
This is true for every method on every class:
rex.describe() # → Pet.describe(rex)
rex.feed("kibble") # → Pet.feed(rex, "kibble")
rex.greet(other_pet) # → Pet.greet(rex, other_pet)
In each one, the instance to the left of the dot becomes the self
parameter inside the method. The other arguments in the parentheses fill
in after.
Why two instances don't collide
This is what makes classes useful. Look at the editor on the right:
class Pet:
def __init__(self, name, kind):
self.name = name
self.kind = kind
def describe(self):
return f"{self.name} is a {self.kind}"
rex = Pet("rex", "dog")
whiskers = Pet("whiskers", "cat")
print(rex.describe())
print(whiskers.describe())
Run it. Output:
rex is a dog
whiskers is a cat
Same describe method. Same body of code. Two completely different
return values — because self.name and self.kind read from the
specific instance the method was called on, not from a global or from
the class itself.
In the first call, self is rex, so self.name is "rex" and
self.kind is "dog". In the second call, self is whiskers, so
self.name is "whiskers" and self.kind is "cat". The class
definition doesn't change between calls. Only self does.
Why __init__ matters
__init__ is just a method. The two underscores on each side mark it as
a dunder (double-underscore) method, which is Python's signal that
"this method is called automatically by some part of the language."
For __init__, the trigger is construction. The instant you write
Pet("rex", "dog"):
- Python creates a brand-new, empty
Petinstance. - Python calls
Pet.__init__(new_instance, "rex", "dog"). - Inside
__init__, the assignmentsself.name = nameandself.kind = kindattach the per-instance data. - Python returns the now-populated instance.
You never see steps 1, 2, or 4. From your seat, Pet("rex", "dog")
just produces a fully-set-up Pet. The constructor is the moment
where instance data gets attached.
Where AI specifically gets this wrong
Three patterns to flag in code Cursor writes:
-
Forgetting
selfon a method definition.def describe():(noself) inside a class. Callingrex.describe()then dies withTypeError: describe() takes 0 positional arguments but 1 was given. The "1" is the silently-passedrex. The fix isdef describe(self):. -
Forgetting
self.on the assignment.def __init__(self, name): name = namedoes nothing useful — it assigns the parameter to itself, never touching the instance. The fix isself.name = name. Reading the instance back later returns the class's default (orAttributeError) instead of the value the caller passed. -
Calling a method as
Pet.describe()without an instance. This raisesTypeError: describe() missing 1 required positional argument: 'self'. The fix is to call it on an instance —rex.describe()— so the dot suppliesself.
Run the editor. Two pets, two describe() calls, two different outputs.
The class definition doesn't change between calls — only self does.