A class bundles data with the operations on it. This post covers writing one — __init__,
what self really is, and the three dunder methods worth adding by hand.
The smallest useful class
class Account:
def __init__(self, number, balance):
self.number = number
self.balance = balance
def deposit(self, amount):
self.balance += amount
checking = Account("1001-0001", 1250.00)
checking.deposit(100)
print(checking.number) # Output: 1001-0001
print(checking.balance) # Output: 1350.0
class, a CapitalisedName, a colon, and indented methods. Calling
Account(...) creates an instance and runs __init__ on it.
There is no field declaration section. Attributes come into existence when you assign them, and
__init__ is where you do that — every attribute the object will ever have should be set
there, even if only to None, so a reader can see the shape in one place.
__init__ is not a constructor
The object already exists by the time __init__ runs. Its job is to
initialise, which is why it returns nothing:
class Account:
def __init__(self, balance=0.0):
print(f"initialising with {balance}")
self.balance = balance
a = Account(100)
print(a.balance)
# Output: initialising with 100
# Output: 100
Parameters work exactly as in any function — defaults, keywords, *args. The same
mutable-default rule applies too: def __init__(self, items=[]) shares one list between
every instance you create, which is the object-oriented version of the bug in
Functions.
self
self is the instance, passed as the first argument to every method. Python passes it
for you when you call through the instance:
class Account:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
self.balance += amount
a = Account(100)
a.deposit(50) # the normal way
Account.deposit(a, 50) # exactly what the line above does
print(a.balance) # Output: 200
Both lines do the same thing. a.deposit(50) is looked up on the class and called with
a as the first argument — that is the entire mechanism, and it is why self
must be written out in every method definition.
It is a convention, not a keyword: you could call it anything and everyone would hate you. Forgetting
it gives TypeError: deposit() takes 1 positional argument but 2 were given, which is worth
recognising because the number in the message is always one less than you expect.
Instance attributes versus class attributes
class Account:
bank_name = "Love Some Coding Bank" # shared by every instance
count = 0
def __init__(self, balance):
self.balance = balance # one per instance
Account.count += 1
a = Account(100)
b = Account(200)
print(a.bank_name) # Output: Love Some Coding Bank
print(Account.count) # Output: 2
print(a.balance, b.balance) # Output: 100 200
Anything assigned in the class body is shared; anything assigned to self belongs to the
instance. Use class attributes for constants and genuinely shared state, and be careful — a mutable
class attribute is shared, so appending to it from one instance changes it for all of them.
__repr__, the one to always write
Without it, printing an object tells you nothing useful:
class Bare:
def __init__(self, balance):
self.balance = balance
class Account:
def __init__(self, number, balance):
self.number = number
self.balance = balance
def __repr__(self):
return f"Account({self.number!r}, {self.balance})"
print(repr(Bare(100))[:14]) # Output: <__main__.Bare
print(repr(Account("1001", 1250))) # Output: Account('1001', 1250)
print([Account("1001", 1250)]) # Output: [Account('1001', 1250)]
The last line is the payoff. print() on a list shows each item's __repr__,
so without one a list of ten accounts is ten memory addresses. Every debugging session you ever have on
this class is improved by those two lines.
The convention is that __repr__ should look like the code that would recreate the
object. !r in the f-string applies repr() to the value, which is what puts the
quotes around the string.
__str__ and __eq__
class Account:
def __init__(self, number, balance):
self.number = number
self.balance = balance
def __repr__(self):
return f"Account({self.number!r}, {self.balance})"
def __str__(self):
return f"Account {self.number} — ${self.balance:,.2f}"
def __eq__(self, other):
if not isinstance(other, Account):
return NotImplemented
return self.number == other.number
print(str(Account("1001", 1250))) # Output: Account 1001 — $1,250.00
print(Account("1001", 1250) == Account("1001", 999)) # Output: True
__str__ is for people and __repr__ for developers; print()
uses __str__ and falls back to __repr__. Write __repr__ always
and __str__ only when the object has a natural display form.
Without __eq__, two objects are equal only if they are the same object, so two accounts
loaded from the same row would compare unequal. Returning NotImplemented for a foreign type
lets Python try the other operand's comparison rather than declaring them unequal.
Methods that do not need an instance
Two decorators cover the cases where a method belongs to the class rather than to one object:
class Account:
FEE = 2.50
def __init__(self, number, balance):
self.number = number
self.balance = balance
@staticmethod
def is_valid_number(number):
return len(number) == 9 and number[4] == "-"
@classmethod
def from_row(cls, row):
number, balance = row.split(",")
return cls(number, float(balance))
print(Account.is_valid_number("1001-0001")) # Output: True
print(Account.from_row("1001-0001,1250.00").balance) # Output: 1250.0
A @staticmethod takes no self — it is a plain function that lives in the
class because that is where you would look for it. A @classmethod receives the class as
cls, which is what lets it build and return an instance.
from_row is the pattern to remember: an alternative constructor. Python
has only one __init__ per class, so when you need a second way to build an object — from a
CSV row, from JSON, from a database record — a classmethod named from_something is the
idiom. Using cls rather than Account means it keeps working in a subclass.
Privacy is a convention
class Account:
def __init__(self, balance):
self._balance = balance # "internal, please do not touch"
a = Account(100)
print(a._balance) # Output: 100
A leading underscore means "this is an implementation detail". Nothing enforces it — the last line works fine. Python's position is that the convention is enough and a determined caller will get in anyway, so the language does not pretend otherwise.
Writing this much boilerplate for a class that mostly holds data is exactly what dataclasses exist to remove. First, OOP covers inheritance and what to do instead of it.