Python Lesson 15 of 71

OOP Part 1 — Classes, Objects, Attributes & Methods

You already know how to write a function that takes data and returns a result. That gets you a long way — most of the standard library is exactly that. But there’s a shape of problem where functions alone start to fray, and you can feel it happening.

It goes like this. You’re tracking bank accounts, so you keep a balance in a variable. Then you need the holder’s name, so that’s another variable. Then a transaction history — a third. Now you write deposit(balance, amount), and it has to return the new balance, and the caller has to remember to assign it. Then you need a second account, so now it’s two sets of three variables, or a dict of dicts, and every function starts with six lines of digging the right piece out. Somewhere in there you pass an account’s balance with a different account’s history, and nothing complains, because they’re just numbers and lists.

The problem isn’t any one of those steps. It’s that the data and the operations that belong to it have drifted apart, and nothing in the language is holding them together. A class holds them together.

This is lesson 1 of 4 on object-oriented programming, and it’s the foundation the other three stand on. We’re going to be unusually literal here: not “a class is a blueprint” hand-waving, but what actually happens, in order, when you type BankAccount("Vinod") — which function runs first, where your attributes physically live, and why self has to be there. Get this right and inheritance, encapsulation, and domain modelling are small additions. Get it fuzzy and they’re magic.

Everything below was run on Python 3.12. Type the snippets. Watching obj.__dict__ change on your own screen is what turns a rule into an instinct.


Why this matters

The honest case for OOP is narrower and more concrete than the way it’s usually sold. It is this: some data has behaviour that belongs to it, and nothing else.

A balance isn’t just a number. It’s a number with rules — it can’t go negative, deposits must be positive, and every change should be recorded. Those rules aren’t a property of integers; they’re a property of this balance in this account. If the rules live in free-floating functions, then nothing stops anyone from doing account_balance -= 5000 and skipping every check you wrote. The data has no way to defend itself, because the data doesn’t know the rules exist.

Bundling fixes that. When balance lives inside a BankAccount, and the only sane way to change it is account.withdraw(1000), the rule and the data occupy the same place. You get one home for “what is true about an account”, the same way a function gave you one home for “how do we compute an average” in Functions: Parameters, Return Values, Default & Keyword Arguments.

Here’s what that buys you, and what its absence costs:

Bundling state + behaviour gives you What that means in practice What it costs you to skip it
One home for the rules account.withdraw(x) is the only path in Every caller re-implements the “can’t go negative” check — badly, in two of them
Many independent copies 500 accounts, each with its own balance A dict-of-dicts you index by hand, and mismatched keys nobody catches
A vocabulary account.statement() reads like the domain format_statement(holder, balance, history) — three args that must agree
Invariants that hold Validation runs on every change, not when remembered State goes bad silently; you find out at month-end
A named type isinstance(x, BankAccount), real type hints Everything is dict[str, Any]; your editor can’t help you
Data that can’t be mismatched The balance and its history travel together Account A’s balance updated with account B’s history

And now the part most tutorials skip, because it undercuts the pitch.

The honest anti-case

Python is multi-paradigm, and a class is often the wrong tool. This isn’t a grudging footnote — it’s a real design skill, and reaching for class reflexively is one of the most reliable marks of someone who learned OOP somewhere else and brought the habits along. Java and C# require a class to hold code; Python doesn’t. A module is already a namespace. A function is already a unit of reuse. You do not need to wrap them in ceremony.

The test that actually works: does this thing have state that several operations need to share, and does that state change over time? If yes, a class is earning its keep. If no, you’re writing a function with extra steps.

Situation Reach for Why not a class
A calculation: input → output, no memory A function TaxCalculator().calculate(x) is calculate_tax(x) wearing a suit
A class with one method, called once A function If __init__ stores args and run() uses them, those are parameters
Bag of fields, no rules A dataclass or NamedTuple You want __init__/__repr__/__eq__ free — write them by hand only to learn
Data you parse from JSON and read A dict, or a dataclass A class adds nothing over data["name"] if there’s no behaviour
Grouping related functions A module utils.py is already a namespace; a class of @staticmethod is a worse module
Config / constants A module-level constant or dataclass Config with only class attributes is a dict you can’t iterate
Real state + rules + many instances A class This is the case classes are actually for

The TaxCalculator row is the classic tell, and it’s worth being blunt about. If you find yourself writing a class whose __init__ only stores its arguments and which has exactly one other method that uses them, you have written a function and given it a two-line preamble. calculate_tax(income, year) is better in every measurable way: fewer moving parts, no instance to construct, trivially testable, and the signature tells the whole truth.

Meanwhile the standard library is full of plain functions — len, sorted, sum, open — because that’s genuinely the right shape for them. Use a class when there’s state to protect. Otherwise write the function.

The mental model to carry through the whole lesson: a class is a factory that stamps out objects, and each object is a little bag of data that knows which functions apply to it. Everything else — self, __init__, attribute lookup — is mechanism serving that one idea.


Everything you have already used is an object

Before you write a class, notice that you’ve been using objects since your first line of Python. This isn’t a cute framing; it’s literally how the language is built, and it means class introduces far less new machinery than it appears to.

An integer has methods. A string has methods. Try it:

print((5).bit_length())        # => 3      — 5 is 0b101, so 3 bits
print((255).bit_length())      # => 8
print("kloudvin".upper())      # => KLOUDVIN
print((2.5).is_integer())      # => False
print((2.0).is_integer())      # => True
3
8
KLOUDVIN
False
True

Those parentheses around 5 aren’t decoration — they’re required, and the reason is a nice piece of trivia:

print(5.bit_length())
  File "demo.py", line 1
    print(5.bit_length())
           ^
SyntaxError: invalid decimal literal

Python’s tokeniser sees 5. and starts reading a float. Wrap it — (5).bit_length() — or just put it in a name first, which is what you’d do in real code anyway:

x = 5
print(x.bit_length())          # => 3      no parens needed once it has a name

Now the deeper point. type() tells you which class an object came from, and classes are themselves objects:

print(type(5))                 # => <class 'int'>
print(type("kloudvin"))        # => <class 'str'>
print(type(type(5)))           # => <class 'type'>
<class 'int'>
<class 'str'>
<class 'type'>

Read <class 'int'> literally: the number 5 is an instance of a class called int, and int is itself an instance of a class called type. The turtles do stop — type is an instance of itself — but the point is that when you write class BankAccount: you are not entering a special walled-off part of the language. You’re building the same kind of thing int and str already are.

Thing you’ve used Its class A method it has What that proves
5 int (5).bit_length()3 Even integers carry behaviour
"kloudvin" str .upper()KLOUDVIN Strings are objects; upper is a method
[3, 1, 2] list .sort() → mutates in place Methods can change the object
{"a": 1} dict .get("a")1 The dict you know is an instance
2.5 float .is_integer()False Floats too
print builtin_function_or_method print.__name__'print' Functions are objects
int type int("42")42 Classes are objects you can call

That last row is the hinge for everything below. int("42") is you calling a class, and getting an instance back. BankAccount("Vinod") is the exact same move. You already know how to instantiate — you’ve been doing it since list().


The class statement: a factory you write once

Here’s the smallest class that does anything:

class Server:
    """A single machine we care about."""

print(Server)                  # => <class '__main__.Server'>
print(type(Server))            # => <class 'type'>
print(Server.__name__)         # => Server
print(Server.__doc__)          # => A single machine we care about.
<class '__main__.Server'>
<class 'type'>
Server
A single machine we care about.

Server is now a name bound to a class object — a value, like any other. __main__ is just the module it was defined in (the script you ran).

The class body executes once, at definition time

This is the fact that dissolves the most confusion later, and almost nobody says it out loud: class is not a declaration. It’s a statement that runs. Python executes the indented body top to bottom, right where it appears, exactly once — then collects whatever names the body created and hands them to a new class object.

You can watch it:

print("--- before the class statement")

class Config:
    print("--- inside the class body: this runs NOW, once")
    ENV = "prod"
    print(f"--- ENV is already {ENV} in here")

print("--- after the class statement")
print(Config.ENV)

c1 = Config()
c2 = Config()
print("--- made two instances; the body did NOT run again")
--- before the class statement
--- inside the class body: this runs NOW, once
--- ENV is already prod in here
--- after the class statement
prod
--- made two instances; the body did NOT run again

Look at the order. The body’s prints land between “before” and “after” — during the class statement itself, before a single instance exists. And ENV is readable inside the body as a plain local, because at that moment it is one.

Two consequences worth banking now:

Part What it is Notes
class The keyword Executes the body, then builds a class object
Server Class name PascalCase by convention (PEP 8); functions stay snake_case
: Colon Required — forget it and you get SyntaxError: expected ':'
Indented body A block that runs Executes once, at definition time, in order
"""...""" Docstring First statement in the body → Server.__doc__, help(Server)
def ... inside A plain function Stored in Server.__dict__; becomes a method when accessed via an instance
X = 1 inside A class attribute One copy, shared by all instances
pass Placeholder For a body with nothing in it yet

What Server("web01") actually does

Calling a class calls the class object, and two dunder methods run in a fixed order. Most tutorials say “__init__ is the constructor” and move on. That’s wrong in a way that matters, so let’s watch the real sequence:

class Traced:
    def __new__(cls, *args, **kwargs):
        print("1. __new__  runs first  - it ALLOCATES a blank object")
        instance = super().__new__(cls)
        print(f"2. __new__  made a blank {type(instance).__name__}, __dict__ = {instance.__dict__}")
        return instance

    def __init__(self, name):
        print(f"3. __init__ runs second - self already EXISTS, __dict__ = {self.__dict__}")
        self.name = name
        print(f"4. __init__ filled it in,      __dict__ = {self.__dict__}")

t = Traced("web01")
print(f"5. Traced('web01') handed back the object: {t.name}")
1. __new__  runs first  - it ALLOCATES a blank object
2. __new__  made a blank Traced, __dict__ = {}
3. __init__ runs second - self already EXISTS, __dict__ = {}
4. __init__ filled it in,      __dict__ = {'name': 'web01'}
5. Traced('web01') handed back the object: web01

Read line 3 again: by the time __init__ runs, the object already exists. It’s blank — its __dict__ is empty — but it’s there, and it’s handed to __init__ as self. __init__ doesn’t build anything. It fills in an object somebody else built.

That’s why it’s an initialiser, not a constructor. __new__ is the closest thing Python has to a constructor: it’s the one that actually produces the object. You will almost never write __new__ — the default from object is what you want ~99% of the time, and it’s mostly seen in immutable subclasses and singletons. But knowing it’s there is what makes __init__'s signature make sense.

Step What runs Gets Returns Do you write it?
1 Server("web01") Calls the class object An instance No — it’s the ()
2 Server.__new__(Server, "web01") cls = the class A new blank instance Rarely — inherit object.__new__
3 Server.__init__(obj, "web01") self = that instance None, always Yes — this is your job
4 obj handed to the caller The initialised object No — automatic
__init__ is… __init__ is not
An initialiser — fills in an existing object A constructor — it doesn’t create anything
Handed self as its first argument Responsible for returning the instance
Required to return None Allowed to return self or any value
Optional — omit it and you get object.__init__ The only way to set attributes (you can set them any time)
Run once per instantiation Run when you access the object

That “required to return None” row is enforced, and the error is unusually blunt:

class Bad:
    def __init__(self):
        return 42

Bad()
Traceback (most recent call last):
  File "demo.py", line 5, in <module>
    Bad()
TypeError: __init__() should return None, not 'int'

If you’re coming from a language where the constructor returns the object, that’s the rule to unlearn. Your __init__ sets attributes and returns nothing.

Now the real thing:

class Server:
    def __init__(self, name, cpu):
        self.name = name           # store on THIS instance
        self.cpu = cpu

web = Server("web01", 4)
db = Server("db01", 16)

print(web.name, web.cpu)           # => web01 4
print(db.name, db.cpu)             # => db01 16
print(isinstance(web, Server))     # => True
print(web.__class__.__name__)      # => Server
web01 4
db01 16
True
Server

One class, one def, two completely independent objects. That’s the factory working.


self: the instance, handed over in the open

self is the single biggest stumbling block in this lesson, and it’s entirely because of how it’s usually explained. “self refers to the current instance” is true but useless — it makes self sound like a keyword the interpreter magically fills in.

Here’s the actual truth, and it’s much simpler: self is an ordinary parameter, and Python passes the instance to it as the first argument. That’s the whole mechanism.

The proof

obj.method() is sugar for Class.method(obj). Not “similar to” — the same call. Run this:

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

    def describe(self):
        return f"server {self.name}"

s = Server("web01")

print(s.describe())                    # the way you'll always write it
print(Server.describe(s))              # the way Python actually does it
print(s.describe() == Server.describe(s))
server web01
server web01
True

Server.describe(s) — reaching into the class, grabbing the plain function, and handing it the instance by hand — produces an identical result. It isn’t a trick or a debugging back door; it’s what the dot was doing for you all along.

You can go one level further and see the wiring:

print(type(s.describe))                        # => <class 'method'>
print(type(Server.describe))                   # => <class 'function'>
print(s.describe.__self__ is s)                # => True
print(s.describe.__func__ is Server.describe)  # => True
<class 'method'>
<class 'function'>
True
True

There it is, in the open. Server.describe is a plain function — that’s all a def in a class body ever creates. But s.describe is a bound method: a tiny object that packages the function together with the instance. It stores the instance in __self__ and the original function in __func__, and when you call it, it slots __self__ in as the first argument.

Expression Type What it holds Calling it
Server.describe function Just the function from the class body Server.describe(s) — pass the instance yourself
s.describe method Function + the instance s.describe() — instance passed for you
s.describe.__func__ function The underlying function — is Server.describe Same object, no binding
s.describe.__self__ Server The instance it’s bound to — is s
s.describe() Identical to Server.describe(s)

This is why self must appear in the signature: something is going to be passed into that first slot whether you declared a parameter for it or not.

Why forgetting self gives that error

Now the traceback makes sense before you even read it:

class Broken:
    def ping():                # no self!
        return "pong"

b = Broken()
print(b.ping())
Traceback (most recent call last):
  File "demo.py", line 6, in <module>
    print(b.ping())
          ^^^^^^^^
TypeError: Broken.ping() takes 0 positional arguments but 1 was given

Beginners read that and think Python is being absurd — “but I passed zero arguments!” You did. Python passed one: b. b.ping() becomes Broken.ping(b), ping declared no parameters, and one argument has nowhere to go. The error is describing the call Python actually made.

The clincher — and this genuinely helps it stick — is that the same broken class works fine if you call it off the class, where no instance gets passed:

print(Broken.ping())           # => pong    — works! It's just a function.
pong

Nothing is wrong with ping. It’s a perfectly good zero-argument function living in a class. It just can’t be called on an instance, because doing that supplies an argument it never asked for.

Version note: Python 3.9 words this as TypeError: ping() takes 0 positional arguments but 1 was given — no class name. The qualified Broken.ping() form arrived in 3.10 and is a real improvement when three classes have a ping.

self is a convention, not a keyword

Since self is just the first parameter, its name is up to you. Python doesn’t know the word:

class Weird:
    def __init__(potato, name):
        potato.name = name

    def describe(potato):
        return f"I am {potato.name}"

w = Weird("web01")
print(w.describe())            # => I am web01
I am web01

That runs. It’s also the last time you should ever do it. self is universal in Python — every linter, every tutorial, every colleague expects it, and pylint will flag potato immediately. The reason to know it’s possible is that it proves there’s no magic: no keyword, no interpreter hook, just a parameter that gets the instance because it’s first in line.


Attributes: where your data actually lives

Here’s the fact that makes Python objects click, and it’s less abstract than you’d expect: an instance is, essentially, a dictionary.

Not “like” a dictionary. It has one, it’s called __dict__, and you can read it:

class Server:
    def __init__(self, name, cpu):
        self.name = name
        self.cpu = cpu

    def describe(self):
        return f"{self.name} ({self.cpu} vCPU)"

web = Server("web01", 4)
db = Server("db01", 16)

print(web.__dict__)                    # => {'name': 'web01', 'cpu': 4}
print(db.__dict__)                     # => {'name': 'db01', 'cpu': 16}
print(web.__dict__ is db.__dict__)     # => False  — separate dicts
{'name': 'web01', 'cpu': 4}
{'name': 'db01', 'cpu': 16}
False

self.name = name is not a declaration being satisfied. It is, near enough, self.__dict__["name"] = name. That’s why two instances never interfere: each has its own dict, and web’s name key has nothing to do with db’s.

The class has a dict too, and it holds something completely different — the methods:

print(list(Server.__dict__))
print([k for k in Server.__dict__ if not k.startswith("__")])
print(type(Server.__dict__))
['__module__', '__init__', 'describe', '__dict__', '__weakref__', '__doc__']
['describe']
<class 'mappingproxy'>

Two things to notice. __init__ and describe are in the class dict, not the instance dict — which is exactly right: one copy of the code, shared by every instance, while the data is per-instance. And the class dict is a mappingproxy, a read-only view; you can’t do Server.__dict__["x"] = 1. Use Server.x = 1 instead.

Version note: the dunder keys vary by version. Python 3.13 adds __firstlineno__ and __static_attributes__ to that list. Filter to non-dunders (as in line 2) if you want output that’s stable across versions.

Attributes are not declared — they’re just keys

Because attributes are dict keys, you can add one whenever you like, to one object only:

web.region = "ap-south-1"              # brand new attribute, after the fact
print(web.__dict__)                    # => {'name': 'web01', 'cpu': 4, 'region': 'ap-south-1'}
print(db.__dict__)                     # => {'name': 'db01', 'cpu': 16}   — untouched
print(hasattr(db, "region"))           # => False
{'name': 'web01', 'cpu': 4, 'region': 'ap-south-1'}
{'name': 'db01', 'cpu': 16}
False

Python let you invent region on one Server and not the other, and never said a word.

Is that good? Honestly: mostly no. It’s occasionally handy for a quick script or a debugging tag, but as a habit it’s how you end up with objects of the same class that have different shapes, and AttributeError in production because one code path set an attribute and another didn’t. The discipline that costs nothing: set every attribute an object will ever have in __init__, even if the value is None. Then the shape is knowable by reading one method. (Part 3 covers __slots__, which lets you actually forbid the surprise attribute.)

Ask for something that was never set and you get the error you’ll see more than any other in OOP:

print(db.region)
Traceback (most recent call last):
  File "demo.py", line 6, in <module>
    print(db.region)
          ^^^^^^^^^
AttributeError: 'Server' object has no attribute 'region'

And when it’s a typo rather than a genuine absence, 3.12 is kind:

print(web.nane)
AttributeError: 'Server' object has no attribute 'nane'. Did you mean: 'name'?

Version note: the Did you mean: hint is 3.11+ and only fires when something is close enough — web.nmae (an m/a transposition) is too far and gets no suggestion. Python 3.9 never suggests. Don’t rely on it; read the name yourself.

The attribute API

Dot access is fine when you know the name while typing. When the name is computed at runtime — from a config file, a CSV header, user input — you need the function forms:

web = Server("web01", 4)

print(hasattr(web, "name"))            # => True
print(hasattr(web, "region"))          # => False
print(getattr(web, "name"))            # => web01
print(getattr(web, "region", "unset")) # => unset      — default, no crash

setattr(web, "region", "ap-south-1")
print(web.region)                      # => ap-south-1
delattr(web, "region")
print(hasattr(web, "region"))          # => False

field = "cpu"                          # the point: name known only at runtime
print(getattr(web, field))             # => 4
True
False
web01
unset
ap-south-1
False
4
Call Equivalent to Returns Use when
getattr(o, "x") o.x The value, or AttributeError Name is in a variable
getattr(o, "x", d) The value, or d if missing Absence is legal — no try/except needed
setattr(o, "x", v) o.x = v None Setting a computed name
hasattr(o, "x") True / False Checking existence (it’s a getattr in a try)
delattr(o, "x") del o.x None Removing an attribute
vars(o) o.__dict__ The instance dict Debugging: see all the data at once
dir(o) list[str] of every name found Exploring an unknown object in the REPL
o.__class__ type(o) The class object Getting back to the class from an instance

getattr(o, "x", default) deserves a callout: it is the clean way to handle “this attribute might not be there.” Reaching for try: o.x except AttributeError: when a three-argument getattr would do is a small tell that someone hasn’t met it yet.

Instance vs class attributes, and the lookup order

Assign a name in the class body and you get one value shared by everything:

class Server:
    kind = "compute"               # CLASS attribute — one copy, shared

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

web = Server("web01")
db = Server("db01")

print(web.kind, db.kind)           # => compute compute
print(web.__dict__)                # => {'name': 'web01'}   — no 'kind' here!
compute compute
{'name': 'web01'}

Stop on that second line. web.kind returned "compute" — but kind is not in web.__dict__. The instance genuinely does not have it. Python went and found it somewhere else.

That somewhere else is the class, and the rule is the mental model everything in the next three lessons builds on:

Reading obj.attr searches the instance __dict__ first, then the class __dict__, then each base class in turn. First hit wins. Nothing found → AttributeError.

Trace it for the three attributes above and the rule stops being abstract:

Reading 1· instance __dict__ 2· class __dict__ 3· bases (object) Result
web.name hit'web01' not reached not reached 'web01' — stops at step 1
web.kind miss hit'compute' not reached 'compute' — from the class
web.describe miss hit — a function not reached A bound method (see below)
web.__class__ miss miss hit <class 'Server'>
web.region miss miss miss AttributeError

The web.describe row is the one that quietly ties this lesson together: a method is found by the same lookup as any other attribute. The only extra step is that when the walk finds a function on the class, Python binds it to the instance on the way back — which is where self comes from.

Watch all of it happen — including what “first hit wins” really implies:

web.kind = "gpu"                   # writes to the INSTANCE, shadowing the class
print(web.kind, db.kind)           # => gpu compute
print(web.__dict__)                # => {'name': 'web01', 'kind': 'gpu'}
print(Server.kind)                 # => compute       — class untouched

del web.kind                       # remove the instance copy...
print(web.kind)                    # => compute       — the class shows through again
gpu compute
{'name': 'web01', 'kind': 'gpu'}
compute
compute

web.kind = "gpu" did not change the class attribute. It created a new key in web’s own dict, which now shadows the class one — db never noticed, and Server.kind is untouched. Delete the instance key and the class attribute is visible again, like a curtain being pulled back.

This is where the read/write asymmetry bites, because it’s the one part that isn’t symmetrical:

Operation Where it looks / lands Result
Read obj.x instance dict → class dict → bases (MRO) First hit wins; else AttributeError
Write obj.x = v Always the instance dict Never touches the class — creates/updates one key
Write Class.x = v The class dict Every instance without its own x sees the new value
Delete del obj.x Only the instance dict AttributeError if it was only ever on the class
Read obj.method Found on the class → bound to the instance You get a method, not the raw function

The trap this creates has burned everyone once:

class Playlist:
    tracks = []                    # BUG: class attribute, shared by ALL instances

a = Playlist()
b = Playlist()
a.tracks.append("Rain")            # MUTATES the shared list — no rebinding!
print(b.tracks)                    # => ['Rain']   ...on a different object
print(a.tracks is b.tracks)        # => True
['Rain']
True

Why didn’t shadowing save us? Because a.tracks.append(...) is a read followed by a mutation, not a write. It reads a.tracks, finds nothing on the instance, walks up to the class, gets the one shared list, and appends to it. There’s no assignment anywhere, so no instance key is ever created. If you’d written a.tracks = ["Rain"] you’d have shadowed it and b would be fine — which makes this delightfully inconsistent-looking until you know the rule.

The fix is the same as the mutable-default fix from the functions lesson: mutable state belongs in __init__, so each instance builds its own.

class Playlist:
    kind = "audio"                 # ✅ immutable, genuinely shared — fine on the class

    def __init__(self):
        self.tracks = []           # ✅ fresh list per instance
Put it on the class when Put it on the instance (__init__) when
It’s the same for every instance It differs per object
It’s immutable (str, int, tuple) It’s mutable (list, dict, set) — always
It’s a constant: MAX_RETRIES = 3 It’s state that changes over the object’s life
It’s a default several instances share It must be independent per object
It’s a method (a def) — methods belong on the class, always

Here’s the whole model in one picture. Read it left to right: the class statement runs once and builds a class object holding the methods, each call allocates an instance with its own __dict__ of data, reading an attribute walks instance-first-then-class, and finding a function on the class produces a bound method that passes the instance as self.

Python object model shown left to right: the class statement executing once at import to build one class object whose dict holds init, ping and kind; a Server call running new to allocate then init to fill; two instances web01 and db01 each with their own separate dict of data; attribute lookup walking the instance dict first then the class dict and raising AttributeError when nothing matches; and a bound method that stores the instance in self so that web01.ping is the identical call to Server.ping(web01)

The six badges are the exact spots beginners trip: the class body runs once (1); __init__ initialises an object __new__ already made (2); each instance owns its data dict (3); lookup walks instance → class → bases (4); a miss is AttributeError (5); and obj.ping() is Server.ping(obj) (6).


Methods, and a first __repr__

A method is a function stored on a class. That’s the entire definition — you already proved it with type(Server.describe)<class 'function'>. The only thing that makes it method-shaped is being reached through an instance, which binds self.

Plain function Method
Defined At module level In a class body
Stored in The module namespace Class.__dict__
First parameter Whatever you need self — the instance
Called describe(server) server.describe()
Access to instance data Only what you pass Everything on self
Type via the class function function — the same thing!
Type via an instance method (bound)
Namespaced No — describe competes globally Yes — Server.describe

Methods call other methods through self

Inside a method, other methods are not in scope as bare names. They live on the class, and you reach them the same way you reach data: through self.

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

    def slug(self):
        return self.name.lower().replace(" ", "-")

    def url(self):
        return f"https://{self.slug()}.internal"   # method → method, via self

site = Site("Web One")
print(site.slug())             # => web-one
print(site.url())              # => https://web-one.internal
web-one
https://web-one.internal

Drop the self. and you get a NameError, not an AttributeError — because a bare slug() sends Python looking through local, enclosing, global and builtin scopes (the LEGB chain from the functions lesson). The class isn’t in that chain at all:

    def url(self):
        return f"https://{slug()}.internal"        # BUG: bare name
Traceback (most recent call last):
  File "demo.py", line 9, in <module>
    Site("Web One").url()
  File "demo.py", line 7, in url
    return f"https://{slug()}.internal"
                      ^^^^
NameError: name 'slug' is not defined. Did you mean: 'self.slug'?

Python 3.12 diagnoses this one perfectly — Did you mean: 'self.slug'? is exactly the fix. (Python 3.9 just says NameError: name 'slug' is not defined and leaves you to it.) The takeaway: a class body is not a scope that methods can see. Everything goes through self.

The object that prints like garbage

Make a class without a __repr__ and printing it is useless:

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

b = Bare("web01")
print(b)
print([b])
<__main__.Bare object at 0x102642540>
[<__main__.Bare object at 0x102642540>]

That’s the default object.__repr__: the class, and a memory address that changes every run and tells you nothing. In a list of twenty, it’s actively hostile — you can’t tell them apart.

__repr__ fixes it in one method:

class Server:
    def __init__(self, name, cpu):
        self.name = name
        self.cpu = cpu

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

web = Server("web01", 4)
print(web)                             # => Server(name='web01', cpu=4)
print([web, Server("db01", 16)])       # => [Server(name='web01', cpu=4), Server(name='db01', cpu=16)]
print({"primary": web})                # => {'primary': Server(name='web01', cpu=4)}
Server(name='web01', cpu=4)
[Server(name='web01', cpu=4), Server(name='db01', cpu=16)]
{'primary': Server(name='web01', cpu=4)}

Note the !r inside the f-string — it applies repr() to each value, which is what puts the quotes around 'web01'. That’s not a cosmetic detail. The convention for __repr__ is: produce something that looks like the code which would rebuild the object. You can copy Server(name='web01', cpu=4) straight out of a log and paste it into a REPL. Without !r you’d get Server(name=web01, cpu=4), which you can’t.

__repr__ is the highest value-per-line method in Python. Three lines, and every debugging session, log line, and REPL poke for the life of that class gets better. Write it on every class you make.

__str__ vs __repr__

Two methods, two audiences: __repr__ is for developers (debugging, logs, the REPL); __str__ is for users (the message you’d show a human).

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

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

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

s = Server("web01")
print(s)                       # => server web01           print uses __str__
print(repr(s))                 # => Server(name='web01')
print(f"{s}")                  # => server web01           f-string default
print(f"{s!r}")                # => Server(name='web01')   !r forces __repr__
print([s])                     # => [Server(name='web01')] containers ALWAYS use __repr__
server web01
Server(name='web01')
server web01
Server(name='web01')
[Server(name='web01')]

That last line is the one people trip over. print([s]) shows the repr, even though print alone shows the str — because the list is formatting its own contents, and containers always use repr on their items. It’s why a __repr__-less object stays ugly inside a list no matter how nice its __str__ is.

Memorise this table instead of guessing:

What you write Calls Output above
print(s) __str__ server web01
str(s) __str__ server web01
f"{s}" __str__ server web01
repr(s) __repr__ Server(name='web01')
f"{s!r}" __repr__!r forces it Server(name='web01')
s in the REPL __repr__ Server(name='web01')
print([s]), print({s: 1}) __repr__containers always [Server(name='web01')]
print(s) with no __str__ __repr__ — the fallback Server(name='web01')
repr(s) with no __repr__ object.__repr__no fallback <__main__.Server object at 0x...>
__repr__ __str__
Audience Developers End users
Goal Unambiguous — ideally eval-able Readable
Called by repr(), the REPL, containers, f"{x!r}" str(), print(), f"{x}"
Fallback None — object.__repr__ gives <... at 0x...> Falls back to __repr__
Write it? Always Only if users see the object

The asymmetry in that fallback row is the practical rule: define __repr__ and you get a sensible str() for free; define only __str__ and repr() stays garbage. So if you write exactly one, write __repr__.

== compares identity by default

One more default worth meeting now, because it surprises everyone:

class Server:
    def __init__(self, name, cpu):
        self.name = name
        self.cpu = cpu

a = Server("web01", 4)
c = Server("web01", 4)

print(a == c)                  # => False   !!
print(a is c)                  # => False
print(a == a)                  # => True
print(a.__dict__ == c.__dict__)  # => True  — the DATA is identical
False
False
True
True

Two objects with byte-identical data are not equal. By default, == on a custom class falls back to is“are these the same object in memory?” — and they aren’t. That last line makes the point sharply: the data compares equal, the objects don’t.

Sometimes that’s what you want (two accounts with the same balance are still different accounts). Often it isn’t, and you fix it by writing __eq__ — which is Part 3’s territory, along with __hash__, why you can’t skip it if you want your objects in a set or as dict keys, and why @dataclass writes both for you. For now: know that == means is until you say otherwise.

Naming conventions you’ll see immediately

Name Means Enforced?
name Public — part of your API
_name “Internal, don’t touch” — a convention No. Nothing stops you
__name Name-mangled to _Class__name Partly — avoids subclass collisions, not access
__name__ A dunder: Python’s own protocol Don’t invent your own
Class PascalCase for class names Convention (PEP 8)
method snake_case for methods/attributes Convention (PEP 8)

The single leading underscore is the one you’ll use constantly — _validate in the next section says “this is plumbing, not for callers” without pretending to be private. Python has no private keyword and doesn’t want one; the convention is the mechanism. That whole discussion — properties, mangling, why “we’re all consenting adults here” — is Part 3.

Those __dunder__ names aren’t arbitrary either: they’re Python’s protocols. You don’t call them, you define them, and the language calls them for you when the matching syntax appears. Here are the ones this lesson touched, plus the two you’ll meet next:

Dunder Python calls it when You write it?
__init__ After C() builds the object — to initialise it Yes, nearly always
__new__ C() — to allocate the object Rarely (immutables, singletons)
__repr__ repr(), the REPL, containers, f"{x!r}" Yes, on every class
__str__ str(), print(), f"{x}" Only if users see it
__doc__ — it’s the docstring you wrote Via """..."""
__dict__ — it’s the attribute store itself No — read it to debug
__class__ — same as type(obj) No
__eq__ a == b Part 3 — default is identity
__hash__ hash(x), set/dict keys Part 3 — pairs with __eq__

The rule of thumb: define dunders, don’t call them. Write __repr__ and use repr(obj); write __init__ and use C(). Calling obj.__repr__() by hand works but marks you as fighting the language.


Worked example: a BankAccount that says no

Let’s put every piece together on something with real rules. The value of this example is that the class doesn’t just store a balance — it refuses to hold an invalid one.

Step 1 — validate in __init__. An object should never exist in a broken state, and __init__ is your one chance to guarantee that.

class BankAccount:
    """One customer's account. Amounts are whole rupees (int)."""

    MIN_BALANCE = 0                                  # class attr: shared, immutable

    def __init__(self, holder: str, opening_balance: int = 0) -> None:
        if not holder.strip():
            raise ValueError("holder must be a non-empty name")
        if opening_balance < 0:
            raise ValueError(f"opening_balance cannot be negative, got {opening_balance}")
        self.holder = holder.strip()                 # instance attrs: per object
        self.balance = opening_balance
        self.history: list[str] = []                 # fresh list per instance

Three decisions worth naming. Validation comes before any assignment, so a rejected account never half-exists. MIN_BALANCE is a class attribute because it’s shared and immutable — a genuine constant. And history is built in __init__, not defaulted in the signature and not put on the class, because it’s mutable — both of those routes give every account the same list.

Step 2 — __repr__ first, so debugging works from here on.

    def __repr__(self) -> str:
        return f"BankAccount(holder={self.holder!r}, balance={self.balance})"

Step 3 — a private helper the other methods share.

    def _validate(self, amount: int) -> None:
        if not isinstance(amount, int):
            raise TypeError(f"amount must be a whole number of rupees, got {type(amount).__name__}")
        if amount <= 0:
            raise ValueError(f"amount must be positive, got {amount}")

The leading underscore says “internal”. Both deposit and withdraw need identical checks, so the rule gets one home — DRY applies inside a class exactly as it does between functions.

Note the exception types are chosen, not random: TypeError when the argument is the wrong kind of thing; ValueError when it’s the right type but an unacceptable value. That distinction is a real convention and interviewers do ask.

Step 4 — the behaviour, using self to reach both data and helper.

    def deposit(self, amount: int) -> int:
        self._validate(amount)                       # method calling method
        self.balance += amount
        self.history.append(f"+{amount}")
        return self.balance

    def withdraw(self, amount: int) -> int:
        self._validate(amount)
        if self.balance - amount < self.MIN_BALANCE:
            raise ValueError(f"insufficient funds: balance {self.balance}, asked {amount}")
        self.balance -= amount
        self.history.append(f"-{amount}")
        return self.balance

    def statement(self) -> str:
        lines = [f"{self.holder}: {self.balance}"]
        lines.extend(f"   {entry}" for entry in self.history)
        return "\n".join(lines)

Notice self.MIN_BALANCE — read through the instance, and lookup finds it on the class. That’s deliberate: it means a future subclass (Part 2) can override MIN_BALANCE and withdraw will pick up the new value with no edit. Writing BankAccount.MIN_BALANCE would hard-code the base class’s value forever.

Step 5 — use it.

acct = BankAccount("Vinod H", 5000)
print(acct)
print(acct.deposit(2500))
print(acct.withdraw(1000))
print(acct.statement())

other = BankAccount("Asha R")
print(other)
print(other.history is acct.history)     # separate lists?
BankAccount(holder='Vinod H', balance=5000)
7500
6500
Vinod H: 6500
   +2500
   -1000
BankAccount(holder='Asha R', balance=0)
False

And the rules actually bite:

for bad in [lambda: acct.withdraw(999999),
            lambda: acct.deposit(-50),
            lambda: acct.deposit(3.5),
            lambda: BankAccount("   ")]:
    try:
        bad()
    except (ValueError, TypeError) as exc:
        print(f"{type(exc).__name__}: {exc}")
ValueError: insufficient funds: balance 6500, asked 999999
ValueError: amount must be positive, got -50
TypeError: amount must be a whole number of rupees, got float
ValueError: holder must be a non-empty name
Guard Raises Why that type
Empty holder ValueError Right type (str), unacceptable value
Negative opening_balance ValueError Right type (int), unacceptable value
deposit(3.5) TypeError Wrong kind of thing entirely
deposit(-50) / deposit(0) ValueError Right type, meaningless amount
withdraw(999999) ValueError Valid amount, invalid for this account’s state

The honest limitation, since we’re being straight about what OOP does and doesn’t give you: none of this makes balance safe. Anyone can still write acct.balance = -99999 and walk straight past every check, because balance is a plain public attribute and Python has no private. That’s not a bug in the design — it’s the thing Part 3 solves, with @property and the underscore convention. What you have now is a class that makes the right path easy and obvious, which is most of the benefit.

Two side notes on the money. Real financial code stores integer paise (or uses Decimal), never floats, because binary floats can’t represent 0.1 — 0.1 + 0.2 really does give 0.30000000000000004 (the numeric types lesson has the full story). And isinstance(amount, int) accepts True, since bool subclasses int; deposit(True) would add ₹1. Both are the kind of edge that separates a demo from production.

Where this goes next

Everything in the rest of this OOP series is an extension of the one rule you now own — lookup walks instance → class → bases. Nothing later replaces it:

Coming up Extends what you learned The connection
Part 2 — inheritance The bases step of the lookup walk class Savings(BankAccount) adds a link to the chain; C.__mro__ is the exact search order, and super() steps along it
Part 3 — encapsulation Making acct.balance = -99999 impossible @property intercepts the lookup itself; _name, name mangling, __slots__, and __eq__/__hash__
Part 4 — domain modelling Deciding which classes should exist Composition over inheritance, @dataclass, ABCs, and knowing when a function was the answer all along

So resist the urge to jump ahead. If obj.__dict__ vs C.__dict__ and obj.m() == C.m(obj) are reflexes, Part 2 is a short lesson. If they’re fuzzy, super() will look like magic — because you’ll be guessing at the chain instead of reading it.


Hands-on lab

You’ll build Playlist from nothing: instantiate several, prove the self binding by hand, inspect both dicts, add a __repr__, then deliberately break it two ways and fix it.

Everything is standard library — no pip install — but a virtual environment is the right habit:

mkdir playlist-lab && cd playlist-lab
python3 -m venv .venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
python3 --version               # want 3.12+ (Windows users: use `python`)

Step 1 — the class, with data and a __repr__. Create playlist.py:

"""playlist.py - one small class, built from scratch."""


class Playlist:
    """A named list of (title, seconds) tracks."""

    kind = "audio"                      # CLASS attribute: one copy, shared by all

    def __init__(self, name, owner="anon"):
        self.name = name                # INSTANCE attribute: one per object
        self.owner = owner
        self.tracks = []                # fresh list per instance - NOT a default arg

    def __repr__(self):
        return (f"Playlist(name={self.name!r}, owner={self.owner!r}, "
                f"tracks={len(self.tracks)})")

What just happened: kind sits on the class (shared, immutable). name, owner and tracks are set on self, so each playlist owns them. tracks is built inside __init__ on purpose — a tracks=[] default, or tracks = [] in the class body, would share one list across every playlist.

Step 2 — methods, including one that calls another. Append (keep the indentation — these are inside the class):

    def add(self, title, seconds):
        """Add one track. Returns self so calls can be chained."""
        self.tracks.append((title, seconds))
        return self

    def total_seconds(self):
        return sum(seconds for _title, seconds in self.tracks)

    def duration(self):
        total = self.total_seconds()    # a method calling a method, via self
        return f"{total // 60}m {total % 60}s"

    def longest(self):
        if not self.tracks:
            return None
        return max(self.tracks, key=lambda track: track[1])

What just happened: duration reaches total_seconds through self — bare total_seconds() would be a NameError. add returning self is the chaining trick you saw in Step 4’s .add(...).add(...).

Step 3 — the driver that proves the model. Append at module level (no indentation):

def main():
    chill = Playlist("Deep Focus", owner="vinod")
    gym = Playlist("Leg Day")

    chill.add("Rain on Glass", 245).add("Slow Static", 190)
    gym.add("Sprint", 132).add("Max Out", 208).add("Cooldown", 90)

    print("1. repr        :", chill)
    print("               :", gym)

    print("2. duration    :", chill.duration(), "|", gym.duration())
    print("3. longest     :", chill.longest(), "|", gym.longest())

    print("4. self proof  :", chill.duration() == Playlist.duration(chill))
    print("   bound to    :", chill.duration.__self__ is chill)
    print("   same func   :", chill.duration.__func__ is Playlist.duration)

    print("5. instance dict:", chill.__dict__)
    print("   own data?   :", chill.__dict__ is gym.__dict__)
    print("6. class dict  :", [k for k in Playlist.__dict__ if not k.startswith("__")])

    print("7. kind (class):", chill.kind, "|", gym.kind)
    print("   in instance?:", "kind" in chill.__dict__)
    chill.kind = "podcast"
    print("   after shadow:", chill.kind, "|", gym.kind, "| class:", Playlist.kind)
    del chill.kind
    print("   after del   :", chill.kind)

    print("8. no parens   :", chill.duration)
    print("9. getattr     :", getattr(chill, "owner"), "|", getattr(chill, "genre", "unset"))
    print("   hasattr     :", hasattr(chill, "genre"))


if __name__ == "__main__":
    main()

Step 4 — run it.

python3 playlist.py
1. repr        : Playlist(name='Deep Focus', owner='vinod', tracks=2)
               : Playlist(name='Leg Day', owner='anon', tracks=3)
2. duration    : 7m 15s | 7m 10s
3. longest     : ('Rain on Glass', 245) | ('Max Out', 208)
4. self proof  : True
   bound to    : True
   same func   : True
5. instance dict: {'name': 'Deep Focus', 'owner': 'vinod', 'tracks': [('Rain on Glass', 245), ('Slow Static', 190)]}
   own data?   : False
6. class dict  : ['kind', 'add', 'total_seconds', 'duration', 'longest']
7. kind (class): audio | audio
   in instance?: False
   after shadow: podcast | audio | class: audio
   after del   : audio
8. no parens   : <bound method Playlist.duration of Playlist(name='Deep Focus', owner='vinod', tracks=2)>
9. getattr     : vinod | unset
   hasattr     : False

That output is the entire lesson. Walk it:

Step 5 — break it on purpose: forget self. Add this method inside the class and call it from main():

    def shuffle():                      # BUG: no self
        return "shuffled"
    print("10. shuffle    :", chill.shuffle())
Traceback (most recent call last):
  File "playlist.py", line 73, in <module>
    main()
  File "playlist.py", line 69, in main
    print("10. shuffle    :", chill.shuffle())
                              ^^^^^^^^^^^^^^^
TypeError: Playlist.shuffle() takes 0 positional arguments but 1 was given

What just happened: chill.shuffle() became Playlist.shuffle(chill). You passed zero; Python passed one. Note the traceback has two frames — read it bottom-up: the error is on the chill.shuffle() line inside main, which was called from module level. The caret markers point at the exact failing call. Fix itdef shuffle(self): — and re-run. Then try Playlist.shuffle() on the broken version and watch it succeed: nothing is wrong with the function, only with calling it on an instance.

Step 6 — break it on purpose: AttributeError. Add to main():

    print("11. genre      :", chill.genre)
Traceback (most recent call last):
  File "playlist.py", line 70, in <module>
    main()
  File "playlist.py", line 66, in main
    print("11. genre      :", chill.genre)
                              ^^^^^^^^^^^
AttributeError: 'Playlist' object has no attribute 'genre'

What just happened: lookup checked chill.__dict__, then Playlist.__dict__, then object — no genre anywhere. Two fixes, and picking the right one is the skill: if every playlist should have a genre, set self.genre = genre in __init__ (the shape belongs in one place). If it’s genuinely optional, ask with a default: getattr(chill, "genre", "unset").

Now try a typo — chill.trakcs — and see 3.12 help:

AttributeError: 'Playlist' object has no attribute 'trakcs'. Did you mean: 'tracks'?

Step 7 — predict, then run. Each of these is a one-liner in main(). Guess first:

  1. print(Playlist.tracks) — does the class have tracks?
  2. Move tracks = [] from __init__ up into the class body, then run chill.add("X", 10) and print gym.tracks. What appears, and why didn’t shadowing save you?
  3. print(Playlist.kind, chill.kind) after Playlist.kind = "video". Which instances change?
  4. Delete __repr__ and re-run step 8. What does the bound method print now?
  5. print(vars(chill) == chill.__dict__) — same thing?

Common mistakes and troubleshooting

Symptom / traceback Cause Fix
TypeError: C.ping() takes 0 positional arguments but 1 was given Forgot self in the method signature def ping(self): — the instance is always passed as arg 1
TypeError: C.__init__() missing 1 required positional argument: 'name' Called C() without the args __init__ demands Pass them: C("web01"), or give the parameter a default
TypeError: C.__init__() takes 2 positional arguments but 3 were given Passed more args than __init__ accepts (self is the +1) Count self: def __init__(self, name) takes one arg from the caller
AttributeError: 'C' object has no attribute 'name' Never set, or set as name = name instead of self.name = name Assign through self; set every attribute in __init__
AttributeError: 'C' object has no attribute 'nane'. Did you mean: 'name'? Typo Read the suggestion — 3.12 usually nails it
NameError: name 'slug' is not defined. Did you mean: 'self.slug'? Called a sibling method as a bare name self.slug() — a class body is not a scope methods can see
Prints <bound method C.f of ...> Forgot the () — you printed the method object obj.f()
Prints <__main__.C object at 0x104f3d250> No __repr__ Add def __repr__(self): return f"C(x={self.x!r})"
TypeError: __init__() should return None, not 'int' returned a value from __init__ __init__ initialises and returns nothing — drop the return
All instances share one list/dict def __init__(self, t=[]) — default built once at def time t=None, then if t is None: t = []
All instances share one list/dict tracks = [] in the class body — one copy for the class Move it into __init__: self.tracks = []
a == b is False for identical data Default == on a class is identity (is) Write __eq__ (Part 3), or use @dataclass
TypeError: unhashable type: 'C' after writing __eq__ Defining __eq__ sets __hash__ = None Also define __hash__, or @dataclass(frozen=True) (Part 3)
AttributeError: 'mappingproxy' object does not support item assignment Tried C.__dict__["x"] = 1 C.x = 1
AttributeError: type object 'C' has no attribute 'name' Read an instance attribute off the class: C.name obj.name — instance data doesn’t exist until an instance does
TypeError: describe() missing 1 required positional argument: 'self' Called C.describe() off the class with no instance obj.describe(), or pass one: C.describe(obj)

Three deserve extra words, because they cost the most hours.

1. The off-by-one in every TypeError. self makes every arity error read one higher than you expect. def __init__(self, name) “takes 2 positional arguments” — you supply one, Python supplies self. So Server("web01", 4) reports “takes 2 positional arguments but 3 were given” and you count your two arguments and conclude Python can’t add. It can; it’s counting self. Whenever an arity error is off by exactly one, self is the missing one — either you forgot to declare it, or you forgot it’s already there.

2. Forgetting self. when assigning — the silent one. This is the nastiest bug in the lesson, because it doesn’t raise anything where the mistake is:

class Server:
    def __init__(self, name):
        name = name        # BUG: assigns a local parameter to itself. Stores nothing.

s = Server("web01")
print(s.__dict__)          # => {}          <- the object is EMPTY
s.name                     # AttributeError, one line later
{}

name = name is a perfectly legal statement — it rebinds a local to itself, does nothing, and vanishes when __init__ returns. Python has no idea you meant self.name. No error, no warning; the object is just born empty, and you find out somewhere else entirely when an AttributeError fires in a method that assumed the data was there. When an object mysteriously has no attributes, print obj.__dict__ immediately. An empty dict is this bug’s fingerprint.

3. Shared mutable state, by two different routes. Both of these give every instance the same list, and the second is the one unique to classes:

class A:
    def __init__(self, tracks=[]):     # route 1: mutable DEFAULT (evaluated once at def)
        self.tracks = tracks

class B:
    tracks = []                        # route 2: CLASS attribute (one copy on the class)

Route 1 you may know from the functions lesson: the [] is built once when def runs, so every call that omits the argument gets that same list. Route 2 is new and sneakier, because shadowing looks like it should protect you. It doesn’t, and the reason is precise: b.tracks.append(x) never assigns anything. It reads b.tracks — miss on the instance, hit on the class, get the shared list — and mutates it in place. No assignment, no instance key, everyone affected. Write b.tracks = [x] and you’d shadow it and be fine, which is why this bug looks inconsistent until you know the rule. Immutable constants on the class; every mutable in __init__.


Cheat-sheet

Syntax What it does
class C: Define a class — the body runs once, at definition time
class C: + """doc""" Docstring → C.__doc__, help(C)
obj = C() Instantiate: __new__ allocates, __init__ fills, object returned
def __init__(self, x): Initialiser — fills an object that already exists; returns None
def __new__(cls): The real constructor — allocates. You rarely write it
self.x = value Set an instance attribute (≈ self.__dict__["x"] = value)
x = value in class body Set a class attribute — one copy, shared by all instances
def m(self): A method — self is required, it’s just the first parameter
obj.m() Call it — sugar for C.m(obj)
C.m(obj) The identical call, written out longhand
obj.m A bound method object (no () = no call)
obj.m.__self__ The instance it’s bound to — is obj
obj.m.__func__ The underlying plain function — is C.m
def __repr__(self): Developer view — repr(), REPL, containers. Write it always
def __str__(self): User view — str(), print(), f"{x}". Falls back to __repr__
f"{self.x!r}" Apply repr() inside an f-string — how __repr__ gets its quotes
obj.__dict__ / vars(obj) The instance’s data dict — your debugging first move
C.__dict__ The class’s dict: methods + class attrs (a read-only mappingproxy)
getattr(obj, "x") obj.x when the name is in a variable
getattr(obj, "x", d) …with a default → no AttributeError
setattr(obj, "x", v) obj.x = v for a computed name
hasattr(obj, "x") Does it have it?
delattr(obj, "x") / del obj.x Remove an attribute
type(obj) / obj.__class__ The class an object came from
isinstance(obj, C) Is it a C? (Prefer over type(obj) == C)
C.__name__ The class’s name as a string
C.__mro__ Lookup order: (C, object) — the basis of Part 2
dir(obj) Every name reachable on it — REPL exploration
Lookup order obj.__dict__C.__dict__ → bases → AttributeError
Write rule obj.x = v always writes to the instance, never the class
_x “Internal” — convention only, nothing is enforced
__x__ Dunder — Python’s protocol names. Don’t invent your own

Interview and exam questions

Q: What’s the difference between a class and an object? A: A class is the factory (a template that says what attributes and methods instances will have); an object, or instance, is one thing the factory stamped out. Server is the class; Server("web01") returns an object. One class, many independent instances — each with its own __dict__ of data, all sharing the class’s methods. And the class is itself an object: type(Server) is <class 'type'>.

Q: Is __init__ a constructor? A: No — it’s an initialiser. By the time it runs, the object already exists: __new__ allocated it and passed it in as self. __init__'s only job is to fill in attributes on an object that’s already there, and it must return Nonereturn 42 raises TypeError: __init__() should return None, not 'int'. __new__ is the closest thing to a constructor, and you almost never write it.

Q: What is self, really? A: An ordinary parameter that receives the instance. obj.method() is sugar for Class.method(obj) — you can call it either way and prove they’re identical. The dot creates a bound method that stores the instance in __self__ and the function in __func__, then passes __self__ as the first argument. self isn’t a keyword; naming it potato works fine (and will get your PR rejected).

Q: Why do I get TypeError: C.ping() takes 0 positional arguments but 1 was given? A: You wrote def ping(): without self, then called obj.ping(). That becomes C.ping(obj) — Python passes the instance, and the function declared nowhere for it to go. Add self. Sanity check: C.ping() on the same broken class works, because calling through the class passes no instance.

Q: Where do instance attributes actually live? A: In the instance’s own __dict__. self.name = "web01" is essentially self.__dict__["name"] = "web01". Inspect it with obj.__dict__ or vars(obj). Methods live in C.__dict__ instead — one shared copy of the code, N copies of the data. Attributes aren’t declared; they’re just dict keys, which is why you can add one at runtime and why a typo raises AttributeError rather than being caught earlier.

Q: Explain the attribute lookup order. A: Reading obj.attr searches the instance __dict__ first, then the class __dict__, then each base class along the MRO (C.__mro__). First hit wins; nothing found raises AttributeError. Writing is not symmetric: obj.attr = v always writes to the instance dict, never the class. That asymmetry is what “shadowing” is — an instance attribute hides a class attribute of the same name without altering it.

Q: What’s the difference between a class attribute and an instance attribute? A: A class attribute is defined in the class body and there’s exactly one, shared by every instance. An instance attribute is set on self (normally in __init__) and each object has its own. Rule: immutable constants (MAX_RETRIES = 3) can live on the class; anything mutable must go in __init__, or every instance shares one list.

Q: This prints ['Rain']. Why — and why didn’t assigning to a protect b?

class Playlist:
    tracks = []

a, b = Playlist(), Playlist()
a.tracks.append("Rain")
print(b.tracks)

A: tracks is a class attribute — one list on the class. a.tracks.append("Rain") is a read plus a mutation, not a write: it looks up tracks, misses on the instance, finds the shared list on the class, and appends in place. No assignment happens, so no instance key is created and b sees the change. If you’d written a.tracks = ["Rain"] you would have shadowed it and b would print []. Fix: self.tracks = [] in __init__.

Q: __str__ vs __repr__ — which do you write? A: __repr__ is for developers (unambiguous, ideally looks like the code that rebuilds the object) and is used by repr(), the REPL, and containers — a list always prints its items’ reprs. __str__ is for end users and is used by print() and f"{x}". Key asymmetry: __str__ falls back to __repr__, but not vice versa. So if you write only one, write __repr__ — you get a decent str() free, and you never see <__main__.C object at 0x...> again.

Q: Why does a == b return False when both objects hold identical data? A: The default __eq__ inherited from object compares identity, so == behaves like is — two separately-created objects are never equal, even though a.__dict__ == b.__dict__ is True. To compare by value, implement __eq__ (and __hash__ alongside it, since defining __eq__ alone sets __hash__ = None and makes instances unhashable), or use @dataclass, which writes both.

Q: When should you not write a class? A: When there’s no state that several operations share and mutate. A class whose __init__ only stores its arguments and that has one other method using them is a function with extra steps — calculate_tax(income, year) beats TaxCalculator(income, year).calculate(). A bag of fields with no rules is a dataclass, a NamedTuple, or a dict. A group of related functions is a module, not a class of @staticmethods. Python is multi-paradigm; len and sorted are functions for good reason. Reach for a class when there’s real state to protect.

Q (coding): Write a Rectangle class with validation, a __repr__, an area(), and a method that uses another method. A:

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        if width <= 0 or height <= 0:
            raise ValueError(f"sides must be positive, got {width}x{height}")
        self.width = width
        self.height = height

    def __repr__(self) -> str:
        return f"Rectangle(width={self.width!r}, height={self.height!r})"

    def area(self) -> float:
        return self.width * self.height

    def is_square(self) -> bool:
        return self.width == self.height

    def describe(self) -> str:
        kind = "square" if self.is_square() else "rectangle"   # method → method
        return f"{kind} of area {self.area()}"                 # via self

r = Rectangle(3, 4)
print(r)                 # => Rectangle(width=3, height=4)
print(r.area())          # => 12
print(r.describe())      # => rectangle of area 12
print(Rectangle(5, 5).describe())   # => square of area 25

The graders are watching for four things: validation before assignment (so a bad object never exists), self. on every attribute, sibling methods reached via self. and not bare names, and a __repr__ using !r.


Key takeaways

pythonoopclassesobjectsselfattributesmethodsinitreprinstancesattribute-lookupdunder-methods
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments