A class statement creates a class object — itself a value, like everything in Python — and binds it to a name. Inside the body, you define methods (functions that take self as the first argument) and class-level attributes. Calling the class (Circle(3)) creates an instance.
__init__ — the post-construction setup, not the constructor
__init__ isn't technically the constructor. The actual constructor is __new__, which creates the bare instance. __init__ is the setup hook that runs immediately after, populating the new instance's attributes. You'll override __new__ only in unusual cases (singletons, immutable subclasses); __init__ is what you write 99% of the time.
self — the instance, by convention
The first argument of every instance method is self. Python passes the instance automatically when you call obj.method(...). The name self isn't a keyword — you could call it anything — but every Python developer expects self, and any other name will get the next reader to stop and frown. Don't fight it.
Calling methods — bound vs unbound
When you access obj.method, Python returns a bound method — a callable that's already linked to the instance. obj.method(x) works because self is filled in for you. Calling Circle.area(c) directly also works — that's the unbound (more accurately: function-on-class) form, where you pass the instance explicitly. The bound form is what you write daily.
Principle: A class is a blueprint — instances are the things made from it. Class-level state lives on the class itself; instance-level state lives on each instance. Confusing these two creates the "why does my list keep growing across instances" class of bugs we'll cover in the next lesson.
self is not permission to turn every helper into object state. Keep the values whose invariants the instance must protect; leave pure transformations as functions or static helpers. An object's identity comes from rules it preserves over time, not from collecting fields.
Code
A minimal class·python
class Circle:
def __init__(self, radius):
self.radius = radius # attribute set on the instance
def area(self):
return 3.14159 * self.radius ** 2
c = Circle(5)
print(c.radius) # 5
print(c.area()) # 78.54
# self is the instance — Python fills it in for you
print(Circle.area(c)) # 78.54 (calling on the class, passing instance)
__init__ vs __new__·python
class Logger:
def __new__(cls, *args, **kwargs):
print("__new__: creating instance")
instance = super().__new__(cls) # actual construction
return instance
def __init__(self, name):
print("__init__: setting up")
self.name = name
l = Logger("main")
# __new__: creating instance
# __init__: setting up
# 99% of the time you only override __init__
Multiple instances — each with its own state·python
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
return self.balance
a = Account("alice")
b = Account("bob", 100)
a.deposit(50)
b.deposit(25)
print(a.owner, a.balance) # alice 50
print(b.owner, b.balance) # bob 125 — independent state
Bound methods — the magic explained·python
class Greeter:
def __init__(self, name):
self.name = name
def hi(self):
return f"hello {self.name}"
g = Greeter("pippa")
# obj.method is a BOUND method — it remembers the instance
bound = g.hi
print(bound) # <bound method Greeter.hi of <__main__.Greeter object at ...>>
print(bound()) # 'hello pippa'
# Plain Greeter.hi is just a function on the class
print(Greeter.hi(g)) # 'hello pippa' — pass instance explicitly
Define a class BankAccount with __init__(self, owner, opening_balance=0). Add three methods: deposit(amount), withdraw(amount) (raise ValueError if it would go below zero), and transfer_to(other_account, amount) that uses withdraw and deposit on the appropriate accounts. Test by creating two accounts, transferring between them, and printing each balance. Also try a transfer that would overdraw and confirm the exception fires.
Progress
Progress is local-only — sign in to sync across devices.