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.

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.

ConceptIn the libraryIn Python
ClassThe template “book” record (what data any book has)class Book:
Object / instanceA specific book, the Quijote on shelf B-12quijote = Book(...)
AttributeA piece of data of that book: its title, whether it is on loanquijote.title
MethodSomething the book can do: be lentquijote.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.

Class and objectsThe Book class as a mould on the left; on the right two objects with their own values, pointed to by the variables l1 and l2.One class, many objectsclass Book:def __init__(self, …) self.title self.author self.pages self.on_loanthe mould: says which attributeseach book will haveBook objecttitle'Don Quijote'author'Cervantes'pages1376on_loanFalseBook objecttitle'Rayuela'author'Cortázar'pages736on_loanFalsel1l2l1 is l2 -> False: each object has its own data
__init__ fills in each object's attributes: there is one mould, and the objects are independent.

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 methodWhat it changesExample
ModifierChanges the object’s attributesread(n), lend()
QueryReturns something computed without changing anythingremaining(), is_long()
Uses othersCombines 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.

Class and instance attributesThe Book class stores library, total and a list of tags; l1 has its own library attribute hiding the class one; l2 does not and looks it up in the class.Where does Python look for l1.library?Book classlibrary'EII Library'total2tags[ ] <- 1 listl1title'Rayuela'library'Humanities Library'l2title'Ficciones'l2 has no library:goes up to the classl1.library = … created a NEWattribute in l1: it hides the class oneLookup order: first the object, then the class. Assigning obj.x = … always writes to the object.But l1.tags.append(…) does not assign: it changes the single list, the class one, that everybody sees.
Reading looks in the object and then in the class; assigning always writes to the object.

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 writtenWhat it meansUsable from outside?
titlePublicYes, that’s normal
_shelf“Private by convention”: it’s internal, don’t touch itYes, but you shouldn’t
__isbnPython renames it (name mangling) to _Book__isbnWith __isbn, no (AttributeError); with the mangled name, yes
Name mangling and propertiesOn the left, the attributes written in the class; on the right their real names in vars: __isbn becomes _Book__isbn. Below, what happens when accessed from outside.What you write and what Python storesinside class Bookself.title = …self._shelf = …self.__isbn = …vars(l1)'title''_shelf''_Book__isbn'Python renames __isbn to _Book__isbnfrom outside you write…and…l1.__isbnAttributeErrorl1._Book__isbnworks (but don't)r.pagescalls the getter (@property)r.pages = 900calls the setter: validates
The double underscore is not real privacy: it is a renaming. The proper door is the property.

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
  • @property above def pages(self) turns that method into the getter: r.pages calls it without parentheses.
  • @pages.setter above another def pages(self, n) defines the setter: r.pages = 740 calls it and validates.
  • In __init__, self.pages = pages also goes through the setter. The real data lives in self.__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' gives AttributeError: … 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 methodClass method
Decoratornone@classmethod
First parameterself (the object)cls (the class)
ForWorking with the data of one objectShared data or creating objects
Called asl1.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
MethodFor whomUsed by…
__str__People: nice textprint(obj), str(obj), f'{obj}'
__repr__Programmers: exact text, ideally the code to recreate itrepr(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 = name goes through the setter, which strips spaces: ' Laura ' becomes 'Laura'.
  • MAX_LOANS = 3 is 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 ValueError and the loop catches it without stopping the program.
  • The loans property returns list(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 an append.
  • give_back uses list.remove, which already raises ValueError if 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:

  1. When class Book: runs, a class box appears in the global frame with total = 0 and both methods.
  2. In Book('Rayuela', 736) the __init__ frame opens: notice that self is an arrow to an empty object that gets filled with title and pages.
  3. When __init__ ends, its frame disappears (with the parameters title and pages), but the object stays, pointed to by r.
  4. After creating f, there are two different objects; total is in the class box, not in the objects, and it is 2.
  5. In r.is_long() and f.is_long(), look at which object self points to in each call.

Quick summary

I want to…I write…Detail
Define a classclass Book:PascalCase, with a docstring
Create an objectb = Book('Rayuela', 736)Calls __init__
Give initial attributesdef __init__(self, t): self.title = tWithout self., nothing is stored
A methoddef lend(self):self always first
Shared datatotal = 0 in the class bodyChanged with Book.total
Each object’s listsself.tags = [] in __init__Never in the class body
Hide an attributeself.__isbnStored as _Book__isbn
Read with control@propertyb.pages without parentheses
Change with control@pages.setterb.pages = 740 validates
Read-only@property without a setterAssigning 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:

  1. The class is the mould and each object carries its own data; self is “this object”, and without self. nothing is stored in it.
  2. 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__.
  3. Encapsulating means providing doors with control: @property to 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.

Next step: practicePython classes practice — three programs: an account that can’t be tampered with, the faculty library and a copy shop with VAT for all (and the −4000 € balance nobody stopped)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *