Chapter 5: Object-Oriented Programming
So far you have kept data in variables, lists, and dictionaries, and written separate functions to work on that data. Object-oriented programming lets you bundle the data and the functions that use it into a single unit called an object.
In this chapter you will learn:
- How to define a class and create objects from it
- How to make objects print in a readable way with
__str__and__repr__ - The difference between class attributes and instance attributes
- How inheritance and polymorphism let you reuse code and treat different objects alike
- How to protect an object’s data with encapsulation and
@property - How
@dataclasswrites the repetitive parts of a simple class for you
Introduction to object-oriented programming
Object-oriented programming (OOP) is a way of organizing programs around objects. An object holds some data (its attributes) and the functions that work on that data (its methods). Objects often represent real-world things, such as a person, a bank account, or a product in a shop.
You have already been using objects. Every string in Python is an object, and upper() is one of its methods:
name = 'alice'
print(name.upper()) # Output: ALICE
With OOP, you keep each piece of data together with the code that knows how to handle it, instead of passing the same data from function to function. Code built this way is more modular, and easier to reuse and maintain.
Classes and objects
A class is a template that describes what data and methods its objects will have. Objects created from a class are called instances of that class.
Here is a simple class:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
print(f'Hello, my name is {self.name} and I am {self.age} years old.')
This class describes a Person with a name and an age attribute, and a greet method that prints a greeting message.
To create an instance, you call the class as if it were a function. Python creates a new, empty object and then automatically calls the special __init__ method to set it up, passing along the arguments you gave:
person = Person('John', 30)
person.greet() # Output: Hello, my name is John and I am 30 years old.
The first parameter of every method, self, is the object the method is working on. When you write person.greet(), Python passes person as self for you, so self.name inside the method means person.name.
In this example, person is an instance of the Person class. You can read and change its attributes with a dot:
print(person.name) # Output: John
person.age = 31
person.greet() # Output: Hello, my name is John and I am 31 years old.
Objects can interact with each other through their methods. For example, a Dog object can call the greet method of a Person object that you pass to it:
class Dog:
def bark(self, person):
person.greet()
print('Woof woof!')
dog = Dog()
dog.bark(person)
# Output:
# Hello, my name is John and I am 31 years old.
# Woof woof!
Printing objects with __str__ and __repr__
If you print an object of the Person class above, Python shows something like <__main__.Person object at 0x10a3b2c50>: the class name and a memory address, which is not much help. You can tell Python how to show your objects by defining two special methods:
__str__returns a friendly description for people.print()andstr()use it.__repr__returns a precise description for programmers, ideally one that looks like the code that would create the object. Python uses it when it shows an object inside a list, and in the interactive shell.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f'{self.name}, {self.age} years old'
def __repr__(self):
return f'Person({self.name!r}, {self.age!r})'
person = Person('John', 30)
print(person) # Output: John, 30 years old
print(repr(person)) # Output: Person('John', 30)
print([person]) # Output: [Person('John', 30)]
The !r inside the f-string uses the repr() of the value, which puts quotes around strings. If you only write one of the two methods, write __repr__: when a class has no __str__, print() uses __repr__ instead.
Class attributes and instance attributes
Attributes you set on self inside __init__ are instance attributes: each object gets its own copy. Attributes you define directly in the class body are class attributes: they belong to the class and are shared by all of its instances.
class Dog:
species = 'Canis familiaris' # class attribute: shared by every Dog
count = 0 # class attribute: how many dogs have been created
def __init__(self, name):
self.name = name # instance attribute: one per Dog
Dog.count += 1
rex = Dog('Rex')
fido = Dog('Fido')
print(rex.name) # Output: Rex
print(fido.name) # Output: Fido
print(fido.species) # Output: Canis familiaris
print(Dog.count) # Output: 2
Notice that __init__ updates Dog.count, not self.count. Assigning to self.count would create a new instance attribute on that one dog and leave the shared counter unchanged.
Inheritance and polymorphism
Inheritance
Inheritance lets a new class take on the attributes and methods of an existing class. The new class (the subclass or child class) can add its own attributes and methods, or replace the ones it inherited, without you having to rewrite the code that stays the same.
Here are a parent class and a subclass that inherits from it:
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def make_sound(self):
print('Some generic animal sound')
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, species='Dog')
self.breed = breed
def make_sound(self):
print('Woof woof!')
Writing class Dog(Animal) makes Dog a subclass of Animal. Dog inherits everything Animal has, adds a breed attribute, and defines its own version of make_sound, which replaces (overrides) the one in Animal.
Inside Dog.__init__, the call super().__init__(name, species='Dog') runs the __init__ method of the parent class, so the code that sets name and species is written only once.
You create a Dog the same way as any other object, by calling the class:
dog = Dog('Fido', 'Labrador')
print(dog.name) # Output: Fido
print(dog.species) # Output: Dog
print(dog.breed) # Output: Labrador
dog.make_sound() # Output: Woof woof!
print(isinstance(dog, Animal)) # Output: True
isinstance confirms that a Dog is also an Animal.
Polymorphism
Polymorphism means “many forms”. In practice it means that different classes provide a method with the same name, so the rest of your code can call that method without caring which class it is dealing with.
For example:
class Animal:
def __init__(self, name):
self.name = name
def make_sound(self):
print('Some generic animal sound')
class Dog(Animal):
def make_sound(self):
print('Woof woof!')
class Cat(Animal):
def make_sound(self):
print('Meow meow!')
def make_sounds(animals):
for animal in animals:
animal.make_sound()
dog = Dog('Fido')
cat = Cat('Fluffy')
make_sounds([dog, cat, Animal('Mystery')])
# Output:
# Woof woof!
# Meow meow!
# Some generic animal sound
Dog and Cat don’t define __init__, so they use the one they inherit from Animal. Each class has its own make_sound, and the make_sounds function simply calls make_sound on every object in the list. Python looks up the method on each object’s own class, so the dog barks, the cat meows, and a plain Animal makes the generic sound. If you add a Cow class later, make_sounds works with it without any changes.
Python doesn’t even require the objects to share a parent class. Any object that has a make_sound method will work:
class Robot: # not an Animal at all
def make_sound(self):
print('Beep boop!')
make_sounds([dog, Robot()])
# Output:
# Woof woof!
# Beep boop!
This is called duck typing, from the saying “if it walks like a duck and quacks like a duck, it’s a duck”. Python only cares whether the object has the method you call, whatever class it comes from.
Encapsulation and data hiding
Encapsulation means bundling data together with the methods that work on it, and having the rest of the program use those methods instead of changing the data directly. The methods can then check that every change makes sense, for example that a bank account never goes below zero.
Data hiding is the related idea of keeping internal details out of reach of code outside the class. Unlike some languages, Python has no truly private attributes. Instead it uses naming conventions:
- A single leading underscore, as in
_balance, means “this is internal; please don’t use it from outside the class”. This is only a convention: Python does not stop anyone from using it, but other programmers will understand the warning. - A double leading underscore, as in
__balance, makes Python rename the attribute behind the scenes (this is called name mangling).
Here is a bank account that uses the single-underscore convention and only changes its balance through methods:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance # internal: use the methods below
def get_balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError('Deposit amount must be positive')
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError('Insufficient funds')
self._balance -= amount
account = BankAccount('Alice', 100)
account.deposit(50)
account.withdraw(30)
print(account.get_balance()) # Output: 120
try:
account.withdraw(500)
except ValueError as error:
print(error) # Output: Insufficient funds
print(account.get_balance()) # Output: 120
withdraw raises a ValueError when there is not enough money, instead of just printing a message. That way the code that called it finds out that something went wrong and can decide what to do, and the balance stays unchanged.
Name mangling, the double-underscore form, works like this:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
account = BankAccount(100)
try:
print(account.__balance)
except AttributeError as error:
print(error) # Output: 'BankAccount' object has no attribute '__balance'
print(account._BankAccount__balance) # Output: 100
Python stored the attribute as _BankAccount__balance. The main purpose of this is to stop a subclass from accidentally overwriting the attribute with one of its own that has the same name. It is not real protection, because the renamed attribute can still be reached. For everyday code, a single underscore is the usual choice.
Computed and validated attributes with @property
Calling get_balance() works, but Python has a neater way. The @property decorator turns a method into something you read like an attribute, without parentheses. You can also add a setter that runs whenever someone assigns a new value, which is a good place to validate it. (You will see how decorators work in Chapter 6.)
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # goes through the setter below
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError('Temperature below absolute zero')
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius) # Output: 25
print(t.fahrenheit) # Output: 77.0
t.celsius = 100
print(t.fahrenheit) # Output: 212.0
try:
t.celsius = -300
except ValueError as error:
print(error) # Output: Temperature below absolute zero
The real value is stored in _celsius. Code outside the class reads and assigns t.celsius as if it were a plain attribute, but every assignment is checked. fahrenheit is computed from _celsius each time you read it, and because it has no setter, it is read-only.
Data classes
Many classes exist mainly to hold a few pieces of data. For those, writing __init__ and __repr__ by hand gets repetitive. The @dataclass decorator from the dataclasses module writes them for you, based on a list of fields:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int = 0
def total(self):
return self.price * self.quantity
pen = Product('Pen', 1.5, 4)
print(pen) # Output: Product(name='Pen', price=1.5, quantity=4)
print(pen.total()) # Output: 6.0
notebook = Product('Notebook', 3.0)
print(notebook.quantity) # Output: 0
print(pen == Product('Pen', 1.5, 4)) # Output: True
Each line such as name: str declares a field. The part after the colon is a type hint: it tells readers (and tools) what kind of value the field should hold, but Python does not enforce it. A field with a value, like quantity: int = 0, has a default.
From these fields, @dataclass generates an __init__ that takes the fields as arguments, a readable __repr__, and an __eq__ method so that two products with the same data compare as equal. (Two objects of an ordinary class are only equal if they are the very same object.) You can still add your own methods, such as total.
Key takeaways
- A class is a template for objects. You create an object by calling the class, for example
Person('John', 30), and Python then calls__init__to set it up. - Define
__str__and__repr__so your objects print in a useful way. - Instance attributes (set on
self) belong to one object; class attributes (set in the class body) are shared by all instances. - Inheritance lets a subclass reuse and override a parent class’s code. Polymorphism and duck typing let the same code work with any object that has the right methods.
- Python has no truly private attributes:
_namemarks something as internal by convention, and__nametriggers name mangling. Use methods and@propertyto control how data changes. @dataclassgenerates__init__,__repr__, and__eq__for classes that mainly hold data.
Practice
Exercise 1: Rectangle
Write a Rectangle class that takes a width and a height. Give it an area() method, a perimeter() method, and a __str__ method so that print() shows something like Rectangle 3 x 4. Create a 3 by 4 rectangle and print it, its area, and its perimeter.
Solution
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def __str__(self):
return f'Rectangle {self.width} x {self.height}'
rect = Rectangle(3, 4)
print(rect)
print(rect.area())
print(rect.perimeter())
# Output:
# Rectangle 3 x 4
# 12
# 14
Exercise 2: Shapes and polymorphism
Create a Shape class with a name attribute and an area() method that returns 0. Then create two subclasses: Square (with a side) and Circle (with a radius), each with its own area(). Put one of each in a list and print the name and area of every shape, rounded to two decimal places. Use 3.14159 for pi.
Solution
class Shape:
def __init__(self, name):
self.name = name
def area(self):
return 0
class Square(Shape):
def __init__(self, side):
super().__init__('Square')
self.side = side
def area(self):
return self.side * self.side
class Circle(Shape):
def __init__(self, radius):
super().__init__('Circle')
self.radius = radius
def area(self):
return 3.14159 * self.radius * self.radius
shapes = [Square(4), Circle(2)]
for shape in shapes:
print(f'{shape.name}: {shape.area():.2f}')
# Output:
# Square: 16.00
# Circle: 12.57
Exercise 3: A validated age
Write a Person class with a name and an age. Make age a property whose setter raises a ValueError if the new age is negative. Create a person, change their age to a valid value, then try to set a negative age and print the error message.
Solution
class Person:
def __init__(self, name, age):
self.name = name
self.age = age # uses the setter, so it is validated too
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError('Age cannot be negative')
self._age = value
person = Person('Maya', 25)
person.age = 26
print(person.age)
try:
person.age = -5
except ValueError as error:
print(error)
print(person.age)
# Output:
# 26
# Age cannot be negative
# 26