Module 4 ended with a clear diagnosis: at Papyrus, data lives in one dictionary (catalog) and behavior lives somewhere else (papyrus_utils.py). final_price() trusts you to pass it the right number; find_book() trusts the dictionary to have the expected shape. Nothing ties the two halves together. Object-oriented programming (OOP) solves exactly that: a class is a mold that packages data and behavior into a single named unit, and every object created from that mold carries its data with it and knows how to operate on it. In this lesson you will build Papyrus's Book class: each book will stop being a dictionary entry and become an object that calculates its own member price.

Contents

  1. From dictionaries to classes: why make the leap
  2. Defining a class with class and __init__
  3. self: the object talks about itself
  4. Instance attributes vs class attributes
  5. Methods: behavior that travels with the data
  6. Papyrus's complete Book class
  7. Several objects from the same mold
  8. Common mistakes and tips
  9. Exercises with solutions

From dictionaries to classes: why make the leap

Remember how the catalog ended up after the big refactor in 04-03:

catalog = {
    "The Odyssey": {"price": 12.50, "stock": 4},
    "Hamlet": {"price": 9.95, "stock": 6},
}

And how a price gets calculated, from papyrus_utils.py:

price = final_price(catalog["The Odyssey"]["price"], member=True)

It works, but look at the seams:

  • Nothing guarantees the structure: if someone writes {"price": "12,50"}, no part of the program complains until it blows up somewhere else.
  • The knowledge is scattered: to find out what you can do with a book you have to dig through papyrus_utils.py.
  • The function and the data don't know each other: final_price() accepts any number; it has no idea it works with books.

A class inverts the relationship: instead of functions that receive data, you have data that carries its functions built in.

Approach Data Behavior Who guarantees consistency?
Dict + functions (module 4) catalog["Hamlet"] papyrus_utils.final_price(...) Nobody: programmer discipline
Class (this module) hamlet.price hamlet.member_price() The class itself

Defining a class with class and __init__

The minimal syntax:

class Book:
    """A book in the Papyrus catalog."""

    def __init__(self, title, price, stock):
        self.title = title
        self.price = price
        self.stock = stock

Line-by-line breakdown:

  • class Book: — declares the class. By PEP 8 convention, class names use CapWords (Book, EBook), unlike functions and variables (final_price, total_stock).
  • The docstring works just like it did for functions in 03-01: it documents what the class is for.
  • __init__ is the initializer: a special method that Python runs automatically every time you create an object. It receives the initial data and stores it.
  • self.title = title — creates an instance attribute: a variable that belongs to that specific object, not to the class in general.

To create an object (an instance), you call the class as if it were a function:

odyssey = Book("The Odyssey", 12.50, 4)
hamlet = Book("Hamlet", 9.95, 6)

print(odyssey.title)   # The Odyssey
print(hamlet.price)    # 9.95
print(odyssey.stock)   # 4

Notice that you do not pass self when calling: you write Book("The Odyssey", 12.50, 4) with three arguments, and Python places the freshly created object into self for you.

self: the object talks about itself

self is the parameter that receives the object itself — the one currently being worked on. When Ana writes odyssey.member_price(), Python translates it internally into Book.member_price(odyssey): the object travels as the first argument.

class Book:
    def __init__(self, title, price, stock):
        self.title = title
        self.price = price
        self.stock = stock

    def in_stock(self):
        return self.stock > 0
  • Inside in_stock, self.stock means "the stock of this book".
  • If odyssey.stock is 4 and faust.stock is 0, odyssey.in_stock() returns True and faust.in_stock() returns False — same code, different data, because self points to a different object on each call.

self is not a reserved word: it is a universal convention. You could name it something else, but no Python programmer ever does. Always respect it.

Instance attributes vs class attributes

The VAT on books and the member discount are the same for every book at Papyrus. It makes no sense to store them in each object: they are class attributes, shared by all instances.

class Book:
    BOOK_VAT = 0.04          # class attribute: shared
    MEMBER_DISCOUNT = 0.05   # class attribute: shared

    def __init__(self, title, price, stock):
        self.title = title   # instance attributes: each object's own
        self.price = price
        self.stock = stock
Characteristic Instance attribute Class attribute
Defined in __init__, with self. Class body, without self
Belongs to Each object separately The class (everyone shares it)
Example at Papyrus title, price, stock BOOK_VAT, MEMBER_DISCOUNT
Varies between objects Yes (odyssey.stockhamlet.stock) No (same for all)
Access odyssey.price Book.BOOK_VAT or odyssey.BOOK_VAT
print(Book.BOOK_VAT)       # 0.04 — via the class
print(odyssey.BOOK_VAT)    # 0.04 — via the instance (inherited from the class)

When you access odyssey.BOOK_VAT, Python looks in the object first; if it doesn't find it there, it climbs up to the class. That's why it works from both sides. Notice how the constants that lived loose back in module 1 (STORE_NAME, BOOK_VAT) now find a natural home inside the class they conceptually belong to.

Methods: behavior that travels with the data

A method is a function defined inside a class. Papyrus's pricing formula (base × (1−discount) × (1+VAT), rounded to 2 decimal places) stops living in papyrus_utils.py and becomes knowledge the book itself carries:

def member_price(self):
    """Final price for members: 5% discount and 4% VAT."""
    gross = self.price * (1 - Book.MEMBER_DISCOUNT) * (1 + Book.BOOK_VAT)
    return round(gross, 2)

Compare the two calls:

# Module 4: an external function that receives the data
final_price(catalog["The Odyssey"]["price"], member=True)   # 12.35

# Module 5: the object works it out on its own
odyssey.member_price()                                      # 12.35

The result is identical (12.35 EUR), but the second style can't grab the wrong data: the method always operates on its own book's price.

Papyrus's complete Book class

class Book:
    """A book in the Papyrus catalog."""

    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def __init__(self, title, price, stock):
        self.title = title
        self.price = price
        self.stock = stock

    def member_price(self):
        """Final price for members, rounded to 2 decimal places."""
        gross = self.price * (1 - Book.MEMBER_DISCOUNT) * (1 + Book.BOOK_VAT)
        return round(gross, 2)

    def in_stock(self):
        """True if at least one copy is left."""
        return self.stock > 0

And in action, with the four canonical titles:

odyssey = Book("The Odyssey", 12.50, 4)
hamlet = Book("Hamlet", 9.95, 6)
quixote = Book("Don Quixote", 15.90, 8)
faust = Book("Faust", 21.00, 0)

for book in (odyssey, hamlet, quixote, faust):
    status = "available" if book.in_stock() else "OUT OF STOCK"
    print(f"{book.title:<12} member: {book.member_price():>6.2f} EUR  [{status}]")
The Odyssey  member:  12.35 EUR  [available]
Hamlet       member:   9.83 EUR  [available]
Don Quixote  member:  15.71 EUR  [available]
Faust        member:  20.75 EUR  [OUT OF STOCK]

The prices match exactly what Luis (member LUIS-001) has been paying since module 4: the logic is the same — it has just moved house.

Several objects from the same mold

The key distinction of the day: the class is the mold (written once); each instance is an independent object with its own values.

classDiagram
    class Book {
        +BOOK_VAT = 0.04
        +MEMBER_DISCOUNT = 0.05
        +title
        +price
        +stock
        +member_price()
        +in_stock()
    }
    Book <.. odyssey : instance
    Book <.. hamlet : instance
    Book <.. faust : instance
    class odyssey {
        title = "The Odyssey"
        price = 12.50
        stock = 4
    }
    class hamlet {
        title = "Hamlet"
        price = 9.95
        stock = 6
    }
    class faust {
        title = "Faust"
        price = 21.00
        stock = 0
    }

Every object has its own identity: modifying faust.stock doesn't affect odyssey.stock (none of the aliasing traps from 04-01, because they are distinct objects from the moment they are created). You can check the type of any object with type():

print(type(odyssey))           # <class '__main__.Book'>
print(type(odyssey) is Book)   # True

One detail you may already have spotted if you've tried print(odyssey):

print(odyssey)   # <__main__.Book object at 0x0000021F3A2B4E50>

It comes out "ugly": Python doesn't know how to display a Book as readable text, so it shows its memory address. There's an elegant fix, but it belongs to the magic methods of lesson 05-05. Until then, print specific attributes (book.title) whenever you need readable output.

Common Mistakes and Tips

  • Forgetting self in the method definition. def member_price(): without self raises TypeError: member_price() takes 0 positional arguments but 1 was given when called, because Python always passes the object as the first argument. Every instance method starts with self.
  • Forgetting self. when using an attribute. Inside a method, plain price is a nonexistent local variable (NameError); the object's attribute is self.price. Revisit variable scope from 03-01: the rules haven't changed.
  • Writing Book() with no arguments when __init__ requires them: TypeError: __init__() missing 3 required positional arguments. The parameters of __init__ work exactly like those of any function (03-02), including default values: def __init__(self, title, price, stock=0) would allow creating books with no initial stock.
  • Confusing the class with the instance. Book.member_price() fails because there's no object to operate on; odyssey.member_price() works. The class is the blueprint; the instance is the building.
  • Using a mutable class attribute as a per-instance value. reservations = [] in the class body would be shared by every book — the same trap as the mutable default argument in 03-02. Per-object lists and dictionaries are always created in __init__ with self.reservations = [].
  • Tip: start every class by answering two questions: what data does it describe? (attributes) and what does it know how to do with it? (methods). If a method never touches self, it should probably be a plain function outside the class.

Exercises

Exercise 1: the Member class

Papyrus manages members like Luis (LUIS-001) and Marta (MARTA-002). Create a Member class with instance attributes name and code, and a method greeting() that returns "Hello, Luis (member LUIS-001)". Create the members Luis, Marta (MARTA-002) and Pau (PAU-003) and print all three greetings with a loop.

Exercise 2: extending Book with sales

Add a method sell(units) to the Book class that subtracts units from the stock only if there is enough, returning True if the sale went through and False if it didn't. Test it: Julia wants 2 copies of "The Odyssey" (stock 4) and Omar wants 1 "Faust" (stock 0).

Exercise 3: instance counter with a class attribute

Add a class attribute total_books = 0 to Book that gets incremented by 1 inside __init__ every time a book is created (hint: Book.total_books += 1). Create the four catalog books and verify that Book.total_books equals 4.

Solutions

Solution 1:

class Member:
    """A member of the Papyrus readers' club."""

    def __init__(self, name, code):
        self.name = name
        self.code = code

    def greeting(self):
        return f"Hello, {self.name} (member {self.code})"

members = [Member("Luis", "LUIS-001"), Member("Marta", "MARTA-002"), Member("Pau", "PAU-003")]
for member in members:
    print(member.greeting())

Each object stores its own name and code; the greeting() method is the same for all three, but self makes each one speak about itself.

Solution 2:

class Book:
    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def __init__(self, title, price, stock):
        self.title = title
        self.price = price
        self.stock = stock

    def in_stock(self):
        return self.stock > 0

    def sell(self, units):
        """Deducts stock if there is enough; returns True/False."""
        if units <= self.stock:
            self.stock -= units
            return True
        return False

odyssey = Book("The Odyssey", 12.50, 4)
faust = Book("Faust", 21.00, 0)

print(odyssey.sell(2))   # True  → Julia takes 2
print(odyssey.stock)     # 2
print(faust.sell(1))     # False → Omar misses out on Faust (off to the reservation queue from 04-06)
print(faust.stock)       # 0

Notice how the check protects the data: the stock can never go negative as long as everyone uses sell(). Making sure nobody can bypass it is precisely the subject of encapsulation (05-04).

Solution 3:

class Book:
    total_books = 0   # class attribute: shared counter

    def __init__(self, title, price, stock):
        self.title = title
        self.price = price
        self.stock = stock
        Book.total_books += 1   # updated through the CLASS, not through self

odyssey = Book("The Odyssey", 12.50, 4)
hamlet = Book("Hamlet", 9.95, 6)
quixote = Book("Don Quixote", 15.90, 8)
faust = Book("Faust", 21.00, 0)

print(Book.total_books)   # 4

The counter lives in the class, which is why every instance shares it and every creation increments it. If you wrote self.total_books += 1, you'd create an instance attribute that shadows the class one — a classic mistake.

Conclusion

Today you joined together what module 4 left apart: the Book class packages the data (title, price, stock) with its behavior (member_price(), in_stock()) in a single unit. You've learned that class defines the mold, __init__ initializes each object, self lets each instance speak about itself, and that instance attributes (different per object) coexist with class attributes (BOOK_VAT, MEMBER_DISCOUNT, shared by all). Every Papyrus book now calculates its own member price: 12.35 EUR for "The Odyssey", 20.75 EUR for the sold-out "Faust". But Ana's shop doesn't sell only books: there are also magazines and, soon, ebooks with no physical stock. Will we have to copy and paste the Book class three times? Not at all: the next lesson introduces inheritance, the mechanism by which a Product class will share what's common while each kind of item adds only what makes it different.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved