Python classes — theory with the faculty library: objects, self, encapsulation, @property and magic methods (and the book that was created without a title)
My first FP2 class was a Book for the faculty library catalogue. I wrote it, created a book, the program said “Book created”… and on the next line it blew up saying my book had no title. I had just given it one:
class Book:
def __init__(self, title, author):
title = title # missing self.: this stores nothing in the object
author = author
quijote = Book('Don Quijote', 'Cervantes')
print('Book created')
print(quijote.title)
Output:
Book created
Traceback (most recent call last):
File "hook.py", line 9, in <module>
print(quijote.title)
^^^^^^^^^^^^^
AttributeError: 'Book' object has no attribute 'title'
The bug is five missing characters: self.. title = title assigns the parameter to itself, a local variable that disappears when __init__ ends, and the object stays empty. If you understand why this happens, you understand 80 % of classes in Python. Today we go for that 80 % and the rest.
Classes in Python are the basis of object-oriented programming (OOP), the paradigm that dominates FP2: a class bundles some data and the functions that work on it into a single piece. We cover it all with the faculty library as the thread: books, members and loans. You come from the exceptions topic, and you’ll need it: our classes will raise ValueError when someone tries to feed them bad data. After the theory you have the classes practice with three complete programs.
Table of Contents
What is a class and what is an object?
In FP1 you organised code with variables and functions. It works, but as soon as you have several “objects” of the same kind it gets messy. Here are two books without classes:
# Without classes: each book is three loose variables
title1, author1, on_loan1 = 'Don Quijote', 'Cervantes', False
title2, author2, on_loan2 = 'One Hundred Years of Solitude', 'García Márquez', False
def lend(on_loan):
return True # you must return it and remember to store it
on_loan1 = lend(on_loan1)
print(title1, '->', 'on loan' if on_loan1 else 'available')
print(title2, '->', 'on loan' if on_loan2 else 'available')
Output:
Don Quijote -> on loan One Hundred Years of Solitude -> available
Each book is three loose variables (title1, author1, on_loan1…) and the lend function can’t change anything on its own: it returns a value and you have to remember to store it in the right variable. With 300 books it would be impossible. Now the same with a class:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
self.on_loan = False # they all start available
def lend(self):
self.on_loan = True # changes ITS state, this book's
quijote = Book('Don Quijote', 'Cervantes')
solitude = Book('One Hundred Years of Solitude', 'García Márquez')
quijote.lend()
for book in [quijote, solitude]:
print(book.title, '->', 'on loan' if book.on_loan else 'available')
Output:
Don Quijote -> on loan One Hundred Years of Solitude -> available
The output is the same, but the code tells a different story. Each book is an object carrying its data (title, author, on_loan) and its actions (lend). quijote.lend() changes the state of that book and no other. A class is the mould; an object (or instance) is each concrete thing made with that mould.
| Concept | In the library | In Python |
|---|---|---|
| Class | The template “book” record (what data any book has) | class Book: |
| Object / instance | A specific book, the Quijote on shelf B-12 | quijote = Book(...) |
| Attribute | A piece of data of that book: its title, whether it is on loan | quijote.title |
| Method | Something the book can do: be lent | quijote.lend() |
Defining a class
How do you define a class in Python? With the word class, the name in PascalCase (each word starts with a capital: Book, BankAccount) and a colon. What goes inside, indented, is the class body:
class Book:
"""A book from the faculty library.""" # docstring: documents the class
item = Book() # instantiate: create an object of the class
print(type(item))
print(isinstance(item, Book))
print(Book.__doc__)
Output:
<class '__main__.Book'> True Un book de la library de la facultad.
The smallest possible class only has a docstring, the triple-quoted string that documents it (otherwise you’d need a pass). To create an object (instantiate the class), you call the class as if it were a function: Book(). type() confirms which class it belongs to and isinstance() asks whether an object is of a class, something you already used with exceptions.
The init method — the constructor
__init__ is the method Python runs automatically every time you create an object. Its job is to give it its initial attributes:
class Book:
def __init__(self, title, author, pages):
self.title = title # attribute = value of the parameter
self.author = author
self.pages = pages
self.on_loan = False # attribute that does not come from a parameter
l1 = Book('Don Quijote', 'Cervantes', 1376)
l2 = Book('Rayuela', 'Cortázar', 736)
print(l1.title, l1.pages)
print(l2.title, l2.pages)
print(vars(l1)) # l1's attributes, as a dictionary
print(l1 is l2) # two different objects
Output:
Don Quijote 1376
Rayuela 736
{'title': 'Don Quijote', 'author': 'Cervantes', 'pages': 1376, 'on loan': False}
False
Book('Don Quijote', 'Cervantes', 1376) does two things: it creates an empty object and calls __init__ with that object as self and the three values as title, author and pages. __init__ returns nothing (a return with a value gives TypeError); it only fills in. vars(l1) shows an object’s attributes as a dictionary, and l1 is l2 gives False: they are two different objects, each with its own data.
In the drawing there is one class and as many objects as you create. Changing l1.pages does not touch l2.
Parameter vs attribute — the most common confusion
This is the line that raises the most doubts in the topic:
self.title = title
title(on the right) is a parameter of__init__: a local variable that only exists while__init__runs.self.title(on the left) is an attribute of the object: it lives inside the object for as long as the object exists and can be used from any method.
The line copies the value from one to the other. They are usually named the same for clarity, but they don’t have to be. Look at what happens if you try to use the parameter from outside:
class Book:
def __init__(self, t, a):
self.title = t # the parameter is called t; the attribute, title
self.author = a
l1 = Book('Rayuela', 'Cortázar')
print(l1.title)
print(l1.t) # t only existed inside __init__
Output:
Rayuela
Traceback (most recent call last):
File "parameter.py", line 9, in <module>
print(l1.t) # t only existed inside __init__
^^^^
AttributeError: 'Book' object has no attribute 't'
t was the parameter: it died when __init__ ended. What remains in the object is title. Now go back to the hook at the start: without self., title = title copied the parameter into itself and the object received nothing. That’s why Python said there was no title attribute.
Forgetting self. in __init__. It doesn’t fail when you create the object (the line title = title is valid), but later, when you use the attribute: AttributeError: 'Book' object has no attribute 'title'. If you get that error for an attribute you “did assign”, look at __init__.
You can also create attributes that don’t come from any parameter, like self.on_loan = False: all books start available.
What is self?
self is the first parameter of every method and represents the specific object the method was called on. Python passes it for you: when you write l1.lend(), Python actually runs Book.lend(l1):
class Book:
def __init__(self, title):
self.title = title
self.on_loan = False
def lend(self):
self.on_loan = True
l1 = Book('Rayuela')
l1.lend() # Python turns it into Book.lend(l1)
print(l1.on_loan)
l2 = Book('Ficciones')
Book.lend(l2) # the same, written by hand
print(l2.on_loan)
Output:
True True
Both forms do the same; the first is the one always used. And if you forget to put self in the method definition, Python tells you with a message that is puzzling at first:
class Book:
def __init__(self, title):
self.title = title
def info(): # missing self
return 'a book'
l1 = Book('Rayuela')
print(l1.info())
Output:
Traceback (most recent call last):
File "no_self.py", line 10, in <module>
print(l1.info())
~~~~~~~^^
TypeError: Book.info() takes 0 positional arguments but 1 was given
“Takes 0 positional arguments but 1 was given”: you didn’t pass anything to info()… but Python did, the object l1. And since info has no parameter to receive it, it fails.
self is a convention, not a reserved word: Python would accept def lend(this):. But nobody does that, and anyone reading your code (including the examiner) expects self.
Instance methods
Methods are functions defined inside the class that work with the object’s attributes. They can read them, change them and call other methods of the same object with self.:
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
self.pages_read = 0
def read(self, n):
self.pages_read = min(self.pages, self.pages_read + n) # do not go past the end
def remaining(self):
return self.pages - self.pages_read
def is_long(self):
return self.pages > 500
def describe(self): # a method that uses other methods
kind = 'long' if self.is_long() else 'short'
return f'{self.title}: {kind} book, {self.remaining()} pages left'
r = Book('Rayuela', 736)
r.read(120)
r.read(80)
print(r.describe())
r.read(1000)
print(r.describe())
Output:
Rayuela: long book, 536 pages left Rayuela: long book, 0 pages left
Check: after reading 120 and 80 pages, pages_read is 200 and 736 − 200 = 536 remain. The second read(1000) would try to reach 1200, but min(self.pages, …) keeps it at 736, so 0 remain. describe doesn’t repeat any calculation: it calls self.is_long() and self.remaining(). If tomorrow you change what a “long” book is, you touch only one method.
| Kind of method | What it changes | Example |
|---|---|---|
| Modifier | Changes the object’s attributes | read(n), lend() |
| Query | Returns something computed without changing anything | remaining(), is_long() |
| Uses others | Combines methods of the same object with self. | describe() |
Class attributes vs instance attributes
So far, every attribute was an instance attribute: each object has its own. A class attribute is defined in the class body, outside the methods, and there is only one, shared by all objects:
class Book:
library = 'EII Library' # CLASS attribute: one for all
total = 0
def __init__(self, title):
self.title = title # INSTANCE attribute: one per object
Book.total += 1 # changed in the class, not in self
l1 = Book('Rayuela')
l2 = Book('Ficciones')
print(l1.library, '|', l2.library, '|', Book.total)
l1.library = 'Humanities Library' # creates a NEW attribute in l1!
print(l1.library, '|', l2.library, '|', Book.library)
print(vars(l1))
print(vars(l2))
Output:
EII Library | EII Library | 2
Humanities Library | EII Library | EII Library
{'title': 'Rayuela', 'library': 'Humanities Library'}
{'title': 'Ficciones'}
Here is the trap that costs most. The first line is as expected: both books “have” the class library and the counter is 2 (it is added in Book.total, not self.total). But l1.library = '…' does not change the class attribute: it creates a new attribute inside l1 with the same name, which hides the class one for l1 only. vars() makes it crystal clear: l1 now has its own library; l2 has none.
The rule is in the drawing: when reading obj.x, Python looks first in the object and, if it isn’t there, in the class. When assigning obj.x = …, it always writes to the object. That’s why to change a class attribute you write the class name: Book.total += 1.
And the sneakiest version, with a list:
class Book:
tags = [] # a single list... for all the books
def __init__(self, title):
self.title = title
l1 = Book('Python for Everybody')
l2 = Book('Rayuela')
l1.tags.append('programming') # the class list is modified
print(l2.title, l2.tags) # Rayuela 'is' a programming book
Output:
Rayuela ['programming']
l1.tags.append(…) is not an assignment: it creates nothing in l1. It reads tags (which it finds in the class) and modifies that list, the only one there is. Result: Rayuela is also “programming”. Each object’s lists and dictionaries always go in __init__ with self..
Putting a list or a dictionary as a class attribute thinking each object will get its own. They all share it. In the exercises you’ll see it with two students’ grades getting mixed up.
Private attributes — encapsulation
Encapsulation means hiding an object’s internal data and controlling how it is read and changed, so that the object never ends up in an impossible state (a book with −3 pages). Python has no private like Java; it has two conventions:
class Book:
def __init__(self, title, isbn):
self.title = title # public
self._shelf = 'B-12' # 'private by convention': one underscore
self.__isbn = isbn # double underscore: Python renames it
l1 = Book('Rayuela', '978-84-376-0494-7')
print(l1._shelf) # you can, but you shouldn't
print(vars(l1))
try:
print(l1.__isbn)
except AttributeError as err:
print('AttributeError:', err)
print(l1._Book__isbn) # the 'hidden' name is still there
Output:
B-12
{'title': 'Rayuela', '_shelf': 'B-12', '_Book__isbn': '978-84-376-0494-7'}
AttributeError: 'Book' object has no attribute '__isbn'
978-84-376-0494-7
| How it is written | What it means | Usable from outside? |
|---|---|---|
title | Public | Yes, that’s normal |
_shelf | “Private by convention”: it’s internal, don’t touch it | Yes, but you shouldn’t |
__isbn | Python renames it (name mangling) to _Book__isbn | With __isbn, no (AttributeError); with the mangled name, yes |
Name mangling is not real privacy: the data is still there under another name, as vars(l1) shows. Its point is that nobody changes it “by accident” from outside. To let people in with control, the class offers doors: getters and setters.
Getters and setters — controlling access
A getter is a method that returns the hidden attribute and a setter, one that changes it after checking that the value is valid:
class Book:
def __init__(self, title, pages):
self.title = title
self.__pages = 0
self.set_pages(pages) # validated on creation too
def get_pages(self):
return self.__pages
def set_pages(self, n):
if not isinstance(n, int) or n <= 0:
raise ValueError(f'invalid pages: {n!r}')
self.__pages = n
r = Book('Rayuela', 736)
print(r.get_pages())
try:
r.set_pages(-3)
except ValueError as err:
print('Rejected:', err)
print(r.get_pages()) # still 736: the object is not broken
Output:
736 Rejected: invalid pages: -3 736
The setter raises ValueError (from the previous topic) if the value isn’t a positive integer, and the object stays as it was: 736 pages. Notice that __init__ also uses set_pages: so a book can’t even be born with bad data.
@property — the Pythonic way
r.get_pages() and r.set_pages(740) work, but they are wordy. Python has @property, which turns those methods into something you use like a normal attribute, with the validation underneath:
class Book:
def __init__(self, title, pages, isbn):
self.title = title
self.pages = pages # uses the setter below
self.__isbn = isbn
@property
def pages(self): # getter: used as r.pages
return self.__pages
@pages.setter
def pages(self, n): # setter: used as r.pages = 900
if not isinstance(n, int) or n <= 0:
raise ValueError(f'invalid pages: {n!r}')
self.__pages = n
@property
def isbn(self): # no setter: read-only
return self.__isbn
r = Book('Rayuela', 736, '978-84-376-0494-7')
print(r.pages, r.isbn)
r.pages = 740 # looks like an assignment, but calls the setter
print(r.pages)
for attempt in ["r.pages = 'mil'", "r.isbn = '000'"]:
try:
exec(attempt)
except (ValueError, AttributeError) as err:
print(type(err).__name__, '->', err)
Output:
736 978-84-376-0494-7 740 ValueError -> invalid pages: 'mil' AttributeError -> property 'isbn' of 'Book' object has no setter
@propertyabovedef pages(self)turns that method into the getter:r.pagescalls it without parentheses.@pages.setterabove anotherdef pages(self, n)defines the setter:r.pages = 740calls it and validates.- In
__init__,self.pages = pagesalso goes through the setter. The real data lives inself.__pages, under a different name (if it had the same name, the setter would call itself endlessly: you’ll see it in the exercises). - A property without a setter is read-only:
r.isbn = '000'givesAttributeError: … has no setter.
Start with public attributes. If one day you need validation, replace them with an @property of the same name: code using the class (r.pages = 740) doesn’t need to change a single letter. That’s the big advantage over get_/set_.
Class methods — @classmethod
A class method belongs to the class, not to an object. It is marked with @classmethod and its first parameter is cls (the class itself) instead of self. It’s used for two things: working with class attributes and creating objects in another way (“factory methods”):
class Book:
total = 0
def __init__(self, title, author, pages):
self.title, self.author, self.pages = title, author, pages
Book.total += 1
@classmethod
def from_line(cls, line): # 'factory': another way to create books
title, author, pages = line.split(';')
return cls(title, author, int(pages)) # cls is Book
@classmethod
def how_many(cls):
return cls.total
catalogue = ['Rayuela;Cortázar;736', 'Ficciones;Borges;224']
books = [Book.from_line(l) for l in catalogue]
print(books[1].author, books[1].pages)
print(Book.how_many(), books[0].how_many()) # from the class or from an object
Output:
Borges 224 2 2
from_line receives a catalogue line ('Rayuela;Cortázar;736'), splits it and returns cls(title, author, int(pages)), which is the same as Book(...). It’s another door for creating books. how_many() reads the class attribute total, and can be called from the class or from an object: both give 2.
| Instance method | Class method | |
|---|---|---|
| Decorator | none | @classmethod |
| First parameter | self (the object) | cls (the class) |
| For | Working with the data of one object | Shared data or creating objects |
| Called as | l1.lend() | Book.how_many() |
The str and repr magic methods
Magic methods (or dunder, for double underscore) have names that start and end with __, and Python calls them on its own in specific situations. __init__ is already one. Without them, printing an object gives this:
class Book:
def __init__(self, title, author):
self.title, self.author = title, author
r = Book('Rayuela', 'Cortázar')
print(r) # no __str__ or __repr__
Output:
<__main__.Book object at 0x7f5b780c70e0>
The class name and a memory address (on your computer it will be different, and it changes on every run). Useless. We fix it with the two representation methods:
class Book:
def __init__(self, title, author):
self.title, self.author = title, author
def __str__(self): # for people: print(), str(), f-strings
return f'«{self.title}», de {self.author}'
def __repr__(self): # for programmers: repr(), lists, console
return f'Book({self.title!r}, {self.author!r})'
r = Book('Rayuela', 'Cortázar')
f = Book('Ficciones', 'Borges')
print(r)
print(f'Recommended: {r}')
print(repr(r))
print([r, f]) # a list shows each item's repr
print(r == Book('Rayuela', 'Cortázar')) # without __eq__: two different objects
Output:
«Rayuela», by Cortázar
Recommended: «Rayuela», by Cortázar
Book('Rayuela', 'Cortázar')
[Book('Rayuela', 'Cortázar'), Book('Ficciones', 'Borges')]
False
| Method | For whom | Used by… |
|---|---|---|
__str__ | People: nice text | print(obj), str(obj), f'{obj}' |
__repr__ | Programmers: exact text, ideally the code to recreate it | repr(obj), the console, and items inside lists and dictionaries |
The most surprising line is the list one: print([r, f]) uses each book’s __repr__, not __str__. If you only define __str__, a list of books still shows memory addresses. If you only define one, make it __repr__: Python also uses it when __str__ is missing.
The last line is a preview: r == Book('Rayuela', 'Cortázar') gives False even though the data is equal, because by default == checks whether they are the same object. To compare by content you need __eq__, which we’ll see with operator overloading.
Making __str__ print instead of return (print(...) inside __str__). It must return a str; if it returns None, print(obj) gives TypeError: __str__ returned non-string.
A complete class
We put it all together in a Member class: a class attribute for the loan rule, @property with validation, a read-only property, a private list returned as a copy, exceptions from the previous topic, a @classmethod and both magic methods:
class Member:
"""A library member: at most MAX_LOANS books at a time."""
MAX_LOANS = 3 # class attribute: the rule is the same for all
_total = 0
def __init__(self, name, card):
self.name = name # uses the setter (validates)
self.__card = card
self.__loans = []
Member._total += 1
@property
def name(self):
return self.__name
@name.setter
def name(self, value):
if not value.strip():
raise ValueError('the name cannot be empty')
self.__name = value.strip()
@property
def card(self): # read-only
return self.__card
@property
def loans(self):
return list(self.__loans) # a copy: the original cannot be touched from outside
def lend(self, title):
if len(self.__loans) >= Member.MAX_LOANS:
raise ValueError(f'{self.__name} already has {Member.MAX_LOANS} books')
self.__loans.append(title)
def give_back(self, title):
self.__loans.remove(title) # ValueError if it was not there
@classmethod
def total(cls):
return cls._total
def __str__(self):
return f'{self.__name} ({self.__card}): {len(self.__loans)} loans'
def __repr__(self):
return f'Member({self.__name!r}, {self.__card!r})'
laura = Member(' Laura ', 'EII-0042')
for t in ['Rayuela', 'Ficciones', 'Python for Everybody', 'The Aleph']:
try:
laura.lend(t)
print('Lent:', t)
except ValueError as err:
print('Not possible:', err)
copied = laura.loans
copied.clear() # empties the copy, not Laura's loans
print(laura)
laura.give_back('Rayuela')
print(laura, '|', repr(laura), '|', Member.total())
Output:
Lent: Rayuela
Lent: Ficciones
Lent: Python for Everybody
Not possible: Laura already has 3 books
Laura (EII-0042): 3 loans
Laura (EII-0042): 2 loans | Member('Laura', 'EII-0042') | 1
Go over the decisions:
self.name = namegoes through the setter, which strips spaces:' Laura 'becomes'Laura'.MAX_LOANS = 3is a class attribute: the rule is the same for every member (in this example; it is not the real ULPGC library rule).- The fourth loan raises
ValueErrorand the loop catches it without stopping the program. - The
loansproperty returnslist(self.__loans), a copy:copied.clear()empties the copy and Laura keeps her 3 books. If it returned the inner list, anyone could get round the limit with anappend. give_backuseslist.remove, which already raisesValueErrorif the title isn’t there: no need to check twice.
Watch it step by step in Python Tutor
Python Tutor draws classes and objects as boxes with arrows, just like the mould diagram. Choose Python 3, paste this program and step through with Next:
class Book:
total = 0
def __init__(self, title, pages):
self.title = title
self.pages = pages
Book.total += 1
def is_long(self):
return self.pages > 500
r = Book('Rayuela', 736)
f = Book('Ficciones', 224)
print(r.is_long(), f.is_long(), Book.total)
Output:
True False 2
What to watch:
- When
class Book:runs, a class box appears in the global frame withtotal = 0and both methods. - In
Book('Rayuela', 736)the__init__frame opens: notice thatselfis an arrow to an empty object that gets filled withtitleandpages. - When
__init__ends, its frame disappears (with the parameterstitleandpages), but the object stays, pointed to byr. - After creating
f, there are two different objects;totalis in the class box, not in the objects, and it is 2. - In
r.is_long()andf.is_long(), look at which objectselfpoints to in each call.
Quick summary
| I want to… | I write… | Detail |
|---|---|---|
| Define a class | class Book: | PascalCase, with a docstring |
| Create an object | b = Book('Rayuela', 736) | Calls __init__ |
| Give initial attributes | def __init__(self, t): self.title = t | Without self., nothing is stored |
| A method | def lend(self): | self always first |
| Shared data | total = 0 in the class body | Changed with Book.total |
| Each object’s lists | self.tags = [] in __init__ | Never in the class body |
| Hide an attribute | self.__isbn | Stored as _Book__isbn |
| Read with control | @property | b.pages without parentheses |
| Change with control | @pages.setter | b.pages = 740 validates |
| Read-only | @property without a setter | Assigning gives AttributeError |
| A class method | @classmethod def f(cls): | Factories and shared data |
Text for print | __str__ | Returns a str |
| Text for lists and console | __repr__ | Better if it recreates the object |
Three ideas to take away:
- The class is the mould and each object carries its own data;
selfis “this object”, and withoutself.nothing is stored in it. - Reading an attribute looks in the object and then in the class; assigning always writes to the object. Each object’s lists go in
__init__. - Encapsulating means providing doors with control:
@propertyto read and validate, no setter for read-only, and copies when you hand out something mutable.
Now, let’s code: in the Python classes practice we build a bank account that can’t be tampered with, a library with its books and a copy shop with shared VAT. If you study Java too, the same topic is in classes and objects in Java. And the next FP2 topic is Python inheritance, where you’ll finally understand what that (Exception) in custom exceptions meant.
Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)