Object-Oriented Programming Basics
Classes and objects, attributes and methods, __init__, and reusing behavior through inheritance.
Classes and objects
In Python, every value is an object: a number, a string, a list. Each object has a type that decides what data it holds and what you can do with it. Object-oriented programming (OOP) means defining your own types, so that data and the code that works on that data live together.
A class is the definition of such a type: a blueprint that describes which data each object has and which operations it supports. An instance is one concrete object created from a class. The words object and instance mean the same thing; "instance" stresses which class it came from.
class BankAccount:
pass
savings = BankAccount()
checking = BankAccount()
print(type(savings).__name__)
print(savings is checking)
BankAccount False
class BankAccount: defines the class; the body here is
empty, so it holds pass. Calling the class like a
function, BankAccount(), creates a new instance. The
two instances are separate objects. By convention, class names use
PascalCase.
__init__ and self
Most objects need data from the start: an account has an owner and a
balance. The special method
__init__ sets up a new
instance. Python calls it automatically right after creating the
object, passing along the arguments you gave to the class.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
account = BankAccount("Ada", 100)
print(account.owner)
print(account.balance)
empty = BankAccount("Grace")
print(empty.balance)
Ada 100 0
The first parameter of __init__, and of every other
method, is self — the
instance the method is working on. When you write
BankAccount("Ada", 100), Python creates the object and
calls __init__ with that object as self,
"Ada" as owner and 100 as
balance. You never pass self yourself.
__init__ is often called the constructor. Strictly, it
is an initializer: the object already exists when it runs,
and it must return None.
Attributes
An attribute is a name attached to
an object, reached with a dot: account.balance. There
are two kinds, and the difference matters.
Instance attributes
Attributes assigned on self, usually in
__init__, belong to one instance. Every account has its
own owner and balance, and changing one
account does not affect another.
Class attributes
Attributes assigned directly in the class body belong to the class and are shared by all its instances. They suit values that are the same for every object, such as a fixed interest rate.
class BankAccount:
interest_rate = 0.02 # class attribute: shared
def __init__(self, owner, balance=0):
self.owner = owner # instance attributes: per object
self.balance = balance
ada = BankAccount("Ada", 100)
grace = BankAccount("Grace", 250)
print(ada.interest_rate, grace.interest_rate)
BankAccount.interest_rate = 0.03
print(ada.interest_rate, grace.interest_rate)
ada.balance = 150
print(ada.balance, grace.balance)
0.02 0.02 0.03 0.03 150 250
When you read ada.interest_rate, Python looks on the
instance first and, if the name is not there, on the class. That is
why a change to BankAccount.interest_rate is visible
through every instance.
Methods
A method is a function defined inside
a class. You call it on an instance with a dot, and Python passes
that instance as self, so the method can read and
change the object's own data.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
account = BankAccount("Ada", 100)
account.deposit(50)
account.withdraw(30)
print(account.balance)
try:
account.withdraw(500)
except ValueError as error:
print("Refused:", error)
120 Refused: Insufficient funds
account.deposit(50) is shorthand for
BankAccount.deposit(account, 50). Putting the checks
inside the methods means every piece of code that changes a balance
goes through the same rules. The page on
error handling explains
raise and try.
Dunder methods
A dunder method ("double
underscore") is a method whose name starts and ends with two
underscores, such as __init__. You rarely call them
yourself: Python calls them for you when you use a built-in
operation on the object. Defining them lets your objects work with
print(), ==, len() and more.
__str__ and __repr__
By default, printing an object shows only its class and memory
address, such as
<__main__.Sensor object at 0x7f3a…>. Two methods
change that:
-
__str__returns a readable text for users. It is used byprint()andstr(). -
__repr__returns an unambiguous text for developers, ideally looking like the code that recreates the object. It is used byrepr(), in the REPL, and inside lists.
class Sensor:
def __init__(self, location, celsius):
self.location = location
self.celsius = celsius
def __str__(self):
return f"{self.location}: {self.celsius:.1f} °C"
def __repr__(self):
return f"Sensor({self.location!r}, {self.celsius!r})"
kitchen = Sensor("Kitchen", 21.456)
print(kitchen)
print(repr(kitchen))
print([kitchen])
Kitchen: 21.5 °C
Sensor('Kitchen', 21.456)
[Sensor('Kitchen', 21.456)]
If a class defines only __repr__, Python uses it for
str() as well, so __repr__ is the one to
write first.
__eq__, __len__ and others
Without __eq__, == between two of your
objects only checks whether they are the same object. Define it to
compare by value. __len__ makes len()
work.
class Playlist:
def __init__(self, name, songs):
self.name = name
self.songs = list(songs)
def __len__(self):
return len(self.songs)
def __eq__(self, other):
if not isinstance(other, Playlist):
return NotImplemented
return self.songs == other.songs
morning = Playlist("Morning", ["Sunrise", "Coffee"])
copy = Playlist("Copy", ["Sunrise", "Coffee"])
print(len(morning))
print(morning == copy)
print(morning is copy)
2 True False
Returning NotImplemented tells Python that this method
cannot compare with that type, so it falls back to other options
instead of guessing. Many more dunder methods exist:
__lt__ for <, __add__ for
+, __contains__ for in, and
so on.
Defining __eq__ makes instances unhashable unless you
also define __hash__, so they can no longer be set
items or dictionary keys:
class Coin:
def __init__(self, value):
self.value = value
def __eq__(self, other):
return isinstance(other, Coin) and self.value == other.value
print(Coin(2) == Coin(2))
print({Coin(2)})
True TypeError: unhashable type: 'Coin'
Encapsulation by convention
Encapsulation means keeping an object's internal
details behind its methods, so other code does not depend on them.
Many languages enforce this with private keywords.
Python does not: every attribute can be read and changed from
outside. Instead, a leading underscore marks a name as
internal, and other programmers are expected to leave it
alone.
class Thermostat:
def __init__(self, target):
self._target = target # internal: use the methods
def set_target(self, celsius):
if not 5 <= celsius <= 30:
raise ValueError("Target must be between 5 and 30 °C")
self._target = celsius
def target(self):
return self._target
living_room = Thermostat(20)
living_room.set_target(22)
print(living_room.target())
22
Two leading underscores (self.__pin) trigger
name mangling: Python renames the attribute to
_ClassName__pin to avoid clashes in subclasses. It does
not make the attribute secret, and a single underscore is enough in
almost all code.
Inheritance
Inheritance lets you define a new
class based on an existing one. The new class, the
subclass (or child class), inherits the methods and class
attributes of the parent class (superclass or base class),
and can add or change behavior. The parent's instance attributes are
set only if its __init__ runs, which is why a subclass
with its own __init__ calls
super().__init__(). You name the parent in parentheses.
class Shape:
def __init__(self, name):
self.name = name
def area(self):
return 0
def describe(self):
return f"{self.name} with area {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("Rectangle")
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
super().__init__("Circle")
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
for shape in [Rectangle(3, 4), Circle(1)]:
print(shape.describe())
Rectangle with area 12.00 Circle with area 3.14
Overriding methods
Rectangle and Circle define their own
area(), which overrides the parent's version.
describe() is written only once, in Shape,
but calls self.area() — and because
self is a Rectangle or a
Circle, Python uses the subclass's method. This is
called polymorphism: the same call does the right
thing for each type.
super()
super() gives access to the parent class's methods from
inside a subclass. Its most common use is in __init__:
super().__init__("Rectangle") lets
Shape set up its part of the object before the subclass
adds its own attributes. It is equally useful to extend, rather than
replace, a parent method:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def describe(self):
return f"{self.owner}: {self.balance:.2f}"
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, rate=0.02):
super().__init__(owner, balance)
self.rate = rate
def describe(self):
return super().describe() + f" (interest {self.rate:.0%})"
print(SavingsAccount("Ada", 1000).describe())
Ada: 1000.00 (interest 2%)
isinstance() and issubclass()
isinstance(obj, cls) checks whether an object is an
instance of a class or of one of its subclasses;
issubclass(a, b) checks the relationship between two
classes. Every class ultimately inherits from object.
class Shape:
pass
class Circle(Shape):
pass
wheel = Circle()
print(isinstance(wheel, Circle))
print(isinstance(wheel, Shape))
print(type(wheel) == Shape)
print(issubclass(Circle, Shape))
print(issubclass(Shape, Circle))
print(issubclass(Shape, object))
True True False True False True
Prefer isinstance() over comparing type():
it respects inheritance, so a subclass is still accepted wherever
its parent is.
Composition versus inheritance
Inheritance models an is-a relationship: a savings account is a bank account. Many relationships are has-a instead: a weather station has sensors. For those, use composition — store other objects as attributes — rather than inheriting.
class Sensor:
def __init__(self, location, celsius):
self.location = location
self.celsius = celsius
class WeatherStation:
def __init__(self, name):
self.name = name
self.sensors = [] # composition: a station has sensors
def add(self, sensor):
self.sensors.append(sensor)
def average(self):
return sum(s.celsius for s in self.sensors) / len(self.sensors)
station = WeatherStation("Rooftop")
station.add(Sensor("North", 18.0))
station.add(Sensor("South", 21.0))
print(station.name, station.average())
Rooftop 19.5
Dataclasses
Many classes mostly hold data, and writing __init__,
__repr__ and __eq__ for each of them is
repetitive. The @dataclass decorator from the standard
module dataclasses generates these methods from a list
of annotated fields.
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int = 1
def total(self):
return self.price * self.quantity
keyboard = Product("Keyboard", 49.99)
cables = Product("USB cable", 4.5, quantity=3)
print(keyboard)
print(cables.total())
print(keyboard == Product("Keyboard", 49.99))
Product(name='Keyboard', price=49.99, quantity=1) 13.5 True
The fields are written with
type hints
(name: str). Python does not check those types at run
time, but a dataclass needs them to know which fields exist. Fields
with a default value must come after fields without one. You can
still add ordinary methods, like total().