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.
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.