Classes and Objects: From the Problem to a Complete Class

Lesson goals
  • Recognize the problems of procedural code as the system grows, and explain how OOP solves them
  • Define a Class and create several objects from it, and tell a Class apart from an Object
  • Write an __init__ that initializes a complete object and checks the values, and explain what self is
  • Add Methods — Getters and Setters — and a __str__ that displays the object
  • Tell apart an Instance Attribute and a Class Attribute, and use a shared counter
  • Turn a verbal description into a class structure — what is a Class, what is an Attribute and what is a Method
  • Build a complete Class that connects all the ideas — in a mini project

In this lesson

This lesson is built as one story, in the same order as the slides. We start from a small bank system written with dictionaries and functions, see where it breaks, and in every chapter add one tool that solves one problem:

  1. Chapter 1 — why we need OOP: the problems of procedural code
  2. Chapter 2 — Class and Object: a template and instances
  3. Chapter 3 — __init__ and self: an object that is born complete
  4. Chapter 4 — Methods: the object does not only know, it also does
  5. Chapter 5 — shared data: Class Attributes
  6. Chapter 6 — what becomes a Class: from a verbal requirement to classes
  7. Chapter 7 — back to the bank: everything together

At the end of the lesson — practice, and a mini project that connects all the chapters.

Chapter 1: Why We Need OOP — Procedural Code Versus Objects

What will we learn?

This whole part of the course is dedicated to OOP — Object-Oriented Programming. Before we write a single line of OOP code, we will answer the most important question: why?

In this chapter:

  • We will write a small system without OOP, in the way we already know.
  • We will see where it starts to become problematic.
  • We will understand the idea of OOP as the solution, still without syntax.

Why do we need this?

Everything we learned until now works. You can write any program with variables, dicts, lists and functions. This is called Procedural Programming: data on one side, functions that act on it on the other.

But when a system grows, to dozens of kinds of “things” and hundreds of functions, problems start. OOP is the way almost all large systems are built. And the tools you will use (FastAPI, Python libraries) are written in OOP too. To read them and use them, you need to understand it.

Explanation from the basics

The problem: a bank account system — without OOP

Let’s build a small system for managing bank accounts, with what we already know:

account1 = {"owner": "Dana", "balance": 1000}
account2 = {"owner": "Avi", "balance": 500}

def deposit(account, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    account["balance"] = account["balance"] + amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] = account["balance"] - amount

deposit(account1, 200)
withdraw(account2, 100)
print(account1, account2)
{'owner': 'Dana', 'balance': 1200} {'owner': 'Avi', 'balance': 400}

It works! So what is the problem? Let’s make the system bigger.

Problem 1: anyone can “touch” the data

account1["balance"] = -999999
account2["balnce"] = 100
  • The first line bypasses all the checks we wrote in withdraw. A negative balance, without any check.
  • The second line has a typo. A new key "balnce" was created, and there is no error at all (we saw this in the data structures and functions topic).

The rules (don’t withdraw more than there is, a positive amount) exist only if everyone remembers to use the functions. Nothing forces them.

Problem 2: the data and the actions are scattered

Every action on an account is a separate function that receives the dict: deposit(account, ...), withdraw(account, ...), transfer(...), monthly_fee(...), print_statement(...). In a real system, these are dozens of functions in several files. Whoever receives an account dict does not know which actions exist on it and where they are.

Problem 3: there is no defined “structure”

What does an account look like? What must be in it? Maybe in one place in the code someone created {"owner": ..., "balance": ...}, and in another place {"name": ..., "money": ...}. There is no one place that defines “this is a bank account, these are its fields, and these are its actions”.

Problem 4: similar but different kinds

A “savings account” was added: like a regular account, but with interest, and without the option to withdraw below 1,000. Now withdraw has to check: if account["type"] == "savings": ... elif .... And with every new kind of account, all the functions fill up with ifs.

The idea of OOP

OOP organizes the code around “things” (objects), and not around actions. Every “thing” contains together:

  • Its data: Attributes. For an account: the owner, the balance.
  • The actions you can do with it: Methods. For an account: deposit, withdraw.
PROCEDURAL                          OBJECT-ORIENTED
data:      account dict             ┌──────────────────────────┐
functions: deposit(account, x)      │ BankAccount              │
           withdraw(account, x)     │   data:    owner, balance│
           print_statement(account) │   actions: deposit()     │
                                    │            withdraw()    │
"functions act ON data"             │            statement()   │
                                    └──────────────────────────┘
                                    "the object knows its own data
                                     and its own actions"

And how does it solve the problems?

ProblemThe OOP solutionWe will learn it in…
Anyone touches the dataThe object protects its data, and changes go through its actions (Encapsulation)The next lesson
Data and actions are scatteredEverything is inside the Class. account. + Tab in VS Code shows everything you can doToday
No defined structureOne Class defines exactly which data every account hasToday
Similar kindsInheritance and Polymorphism: a savings account “inherits” from a regular account and changes only what is differentThe inheritance and polymorphism lessons

A template versus a specific thing

The most basic idea of OOP already exists in the real world:

Template (Class)Specific things created from it (Objects)
The building plan of an apartmentDana’s apartment on floor 3, Avi’s apartment on floor 5
The “student details” formThe form Dana filled in, the form Avi filled in
A template for producing cars on a production lineThe red car with license plate 12-345-67
The concept “bank account”Dana’s account with 1,200 NIS, Avi’s account with 400 NIS
  • All the apartments built from the same plan have the same structure (rooms, kitchen), but different values (wall color, who lives there).
  • A change to Dana’s apartment does not change Avi’s apartment.
  • The plan itself is not an apartment. You cannot live in a plan.
The central analogy for the whole OOP part

Class = a template for producing cars. It defines that every car has a color, a model and a license plate (Attributes), and that every car knows how to drive, brake and honk (Methods). Object = one specific car produced from the template: red, model 2024, plate 12-345-67. You can produce thousands of cars from it, each with its own color and number.

Syntax

We don’t write a class yet. In the next chapter we will learn the full syntax. But here is a “taste”, to see where we are going. The same bank system, in OOP:

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance = self.balance - amount

account1 = BankAccount("Dana", 1000)
account1.deposit(200)
print(account1.balance)

Don’t try to understand every word now (class, __init__, self), because each of them has a whole chapter later in the lesson. Just notice one thing: the data (owner, balance) and the actions (deposit, withdraw) are in the same place, and you call them through the object: account1.deposit(200).

First code example

You have been using objects all the time, without knowing it! In Python, every value is an object, with its own data and methods:

name = "dana cohen"
print(name.title())
print(name.upper())
print(type(name))

numbers = [3, 1, 2]
numbers.append(5)
numbers.sort()
print(numbers)
print(type(numbers))

Output:

Dana Cohen
DANA COHEN
<class 'str'>
[1, 2, 3, 5]
<class 'list'>

Breaking down the code line by line

CodeWhat it is in OOP
name = "dana cohen"An object of type str was created. Its data: the characters.
name.title()A call to the object’s method title. It “knows” how to work on its own text.
type(name) → <class 'str'>The object’s Class (template) is str.
numbers.append(5)A method of a list object that changes its own data.
<class 'list'>list is also a Class. Every list you created is an Object of the Class list.

Key point: str and list are Classes that Python wrote for us. In this part we will learn to write Classes of our own: BankAccount, Student, Product, which suit our problem.

Another example

Another procedural problem: a student system. Take the following code, and try to spot the problems yourself.

student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def add_grade(student, grade):
    student["grades"].append(grade)

def average(student):
    return sum(student["grades"]) / len(student["grades"])

add_grade(student1, 100)
student2["grades"].append(500)
print(average(student1))
print(average(student2))
print(average(student3))
91.66666666666667
285.0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

The problems:

  1. student3 was created with a typo ("nmae"). There is no place that defines what “must” be in a student.
  2. student2["grades"].append(500) bypassed add_grade. Even if add_grade checked that the grade is between 0 and 100, the check would not run. An average of 285!
  3. average crashes on a student without grades. And whoever uses a student dict needs to know that this function exists, and remember to handle this case.

In OOP, Student will be one Class with a defined structure (a name and grades, always), with a method add_grade that is the only way to add a grade (and checks it), and with a method average that knows how to handle an empty list. The rules sit next to the data they protect.

Summary

  • Procedural programming: data (dicts) and functions separately. It works, but as the system grows, the data is exposed, the actions are scattered, and there is no binding structure.
  • OOP: organize the code around things. Every object holds data (Attributes) and actions (Methods) together.
  • Class = a template (a building plan, a production template). Object = a specific thing created from it.
  • In Python every value is an object: str and list are Classes, and "hi".upper() is a call to a method.
  • OOP is a way of thinking: which things exist, what they know, and what they do. Not everything needs to be a Class.

Official documentation

Chapter 2: Class and Object — Defining a Template and Creating Objects from It

What will we learn?

  • What a Class is and what an Object is (also called an Instance).
  • What the word class means.
  • The syntax for defining a Class.
  • How to create an Object from a Class.
  • Why every Object is independent.

Why do we need this?

In the previous chapter we saw that the problem with “dict + functions” is that there is no defined structure and no one place that gathers the data and the actions. A Class is the tool that creates this structure. Everything else (__init__, self, methods) is built on it.

Explanation from the basics

What is a Class?

A Class is a template (Blueprint) that defines a new kind of thing: what information every thing of this kind will have, and what it will be able to do.

  • A Class does not contain the data of a specific thing. It is only the definition.
  • From one Class you can create as many things as you want.
  • When you define a Class, you are in fact creating a new type in Python, exactly like str or list.

What is an Object (Instance)?

An Object is a specific thing created from a Class. It has data of its own.

Instance is a synonym: “an Object of the Class Student” = “an Instance of Student”. The act of creating an Object from a Class is called Instantiation.

ClassObject / Instance
What it isA template, a definitionA specific thing
How manyOne for each kindAs many as you want
DataDefines which fields there will beHolds specific values
ExampleStudentDana, Avi, Noa
AnalogyA car production templateThe red car with plate 12-345-67

The word class

class is a reserved word (Keyword) in Python, like def, if and for. It tells Python: “here begins a definition of a new kind”. After it comes the Class’s name, and after that an indented body.

Names of Classes

According to PEP 8, Class names are written in PascalCase: every word starts with a capital letter, without underscores. Student, BankAccount, ShoppingCart. This is different from functions and variables (snake_case), and that way you immediately see if something is a Class.

Syntax

class ClassName:
    # class body - indented
    pass

my_object = ClassName()
PartExplanation
classA reserved word: “a Class definition begins”.
ClassNameThe Class’s name, in PascalCase.
:Required, like after def or if.
An indented bodyWhat the template includes (in the next chapters: __init__ and methods).
pass“Do nothing”. The body of a Class (or a function) cannot be empty, so pass is a placeholder.
ClassName()Creating an Object: you call the Class with brackets, like a function. You get a new Object.

First code example

week-3/first_class.py:

class Person:
    pass

dana = Person()
avi = Person()

print(dana)
print(avi)
print(type(dana))
print(dana is avi)
print(isinstance(dana, Person))

Output:

<__main__.Person object at 0x000001F4A3B2C1D0>
<__main__.Person object at 0x000001F4A3B2C2E0>
<class '__main__.Person'>
False
True

Breaking down the code line by line

LineExplanation
class Person:Defines a new kind named Person. No person has been created yet.
passThe template is empty for now.
dana = Person()Instantiation: a new Object of type Person was created, and the name dana points to it.
avi = Person()Another Object, completely separate.
print(dana)The Object has no “content” to show, so Python prints a default description: the type, and an address in memory (0x...). Your address will be different.
Two different addressesTwo separate objects in RAM.
type(dana)<class '__main__.Person'>. The object’s Class is Person, which was defined in the main file (__main__).
dana is aviFalse: not the same object (remember is!).
isinstance(dana, Person)True: “is dana an Instance of Person?”

Another example

Adding data to an object — and seeing why this is not the way. You can attach an Attribute to an object from outside, with a dot:

class Product:
    pass

laptop = Product()
laptop.name = "Laptop"
laptop.price = 3500

mouse = Product()
mouse.name = "Mouse"
mouse.price = 80

print(laptop.name, laptop.price)
print(mouse.name, mouse.price)

mouse.price = 70
print("After update:", mouse.price, "| laptop unchanged:", laptop.price)

Output:

Laptop 3500
Mouse 80
After update: 70 | laptop unchanged: 3500

Breakdown:

  • laptop.name = "Laptop" — creates an Attribute named name on the object laptop. The dot means “of”: laptop.name = “the name of the laptop”.
  • Every object has Attributes of its own. Changing mouse.price does not touch laptop.
  • This is already better than a dict: laptop.price is more readable than laptop["price"].

But there is a big problem here. What guarantees that every Product has a name and a price?

keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
AttributeError: 'Product' object has no attribute 'name'
  • A typo (nmae) created a different Attribute.
  • We forgot to define price, and there is no warning until someone tries to use it.
  • This is exactly the problem of the dict from the previous chapter! The empty Class still does not define a structure.

The solution: the Class itself should define which data every object must have, and fill it in at the moment of creation. That is the role of __init__, the topic of the next chapter.

Summary

  • Class = a template that defines a new kind. Object / Instance = a specific thing created from it.
  • Syntax: class Name: + an indented body (or pass). Names in PascalCase.
  • Creating an Object: obj = Name(), with brackets. Without brackets, it is the Class itself.
  • Every Object is independent: a is b → False. The Attributes of one do not affect the other.
  • type(obj) returns the Class. isinstance(obj, Name) checks membership.
  • Adding Attributes “from outside” works, but does not define a structure. That is why we need __init__.

Official documentation

Chapter 3: __init__ and self — an Object That Is Born Complete

What will we learn?

  • What __init__ is, the function that runs automatically when an object is created.
  • What a Constructor is.
  • What self is, the word that appears everywhere in OOP.
  • Instance Attributes: the data of every object.

Why do we need this?

In the previous chapter we added Attributes “from outside”: laptop.name = "Laptop". We saw that this is dangerous: you can forget a field, make a typo, and there is no binding structure. We even wrote a helper function make_book to guarantee that every book has a title and pages.

__init__ is exactly that make_book, only inside the Class. It guarantees that every object is created initialized: with all its fields, and with values, from the very first moment.

Explanation from the basics

__init__ — the initialization method

__init__ (pronounced “dunder init”, double underscore init) is a special function defined inside a Class. It:

  • Is called automatically the moment a new object is created: Student(...).
  • Initializes the object, that is, puts the initial data into it.
  • Returns nothing (return is not needed).

The name with two underscores on each side marks a special method of Python (called a Dunder Method or a Magic Method). Python looks for exactly this name, and calls it by itself. You never write obj.__init__() yourself.

Constructor

Constructor = the code that runs when a new object is built. In Python, __init__ plays this role, so it is usually called “the Constructor”. (To be technically precise: the building itself, allocating the object in memory, happens one step earlier, and __init__ “only” fills it in. For the purposes of the course, __init__ = Constructor.)

self — “this object”

self is the first parameter of every method in a Class, including __init__. It points to the specific object the method is working on right now.

Why is it needed? The Class is one template, but many objects are created from it. When the code in __init__ says “save the name”, it needs to know in which object to save it. self is the answer: “in the object being created now”.

self.name = name

Read it as: “in this object (self), create an Attribute named name, and put in it the value received in the parameter name”.

  • self.name — the object’s Attribute. It stays as long as the object exists.
  • name (alone) — the parameter of __init__: a local variable (Local, remember Scope!) that disappears at the end of __init__.

Where does self come from? Python passes it automatically! When you write Student("Dana", 24), you pass 2 arguments, but __init__ receives 3: Python adds the new object as self, at the start.

Analogy: a registration form

The Class is an empty form with fields: “Name: ____ Age: ____”. __init__ is the registration clerk who fills in a new form for every student who arrives. self is “the form on the desk right now”: the clerk writes “Name: Dana” on this form, and not on all the forms in the cabinet. Tomorrow Avi will arrive, and a different form will be filled in, but the clerk will again call it “the form on the desk” (self).

What happens on the line dana = Student("Dana", 24)

  1. Python creates a new empty object of type Student in memory.
  2. Python calls __init__ and passes it: self = the new object, name = "Dana", age = 24.
  3. __init__ runs self.name = name, meaning the name of the new object = "Dana". And the same for age.
  4. __init__ ends. The local parameters disappear, and the Attributes stay on the object.
  5. Python returns the object, and the name dana points to it.

Instance Attributes

Instance Attributes = data that belongs to a specific object. Every object has its own values. You define them in __init__ with self.something = ....

Syntax

class ClassName:
    def __init__(self, param1, param2):
        self.attribute1 = param1
        self.attribute2 = param2
        self.attribute3 = "default value"   # attributes do not have to come from parameters

obj = ClassName(arg1, arg2)    # Python calls __init__(obj, arg1, arg2) for you
obj.attribute1                 # read an instance attribute
  • __init__ is written inside the Class, indented.
  • self is always first in the parameter list.
  • In the call, you do not pass self.
  • You can give parameters default values, as in any function: def __init__(self, name, age=18):.

First code example

week-3/student.py:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.grades = []

dana = Student("Dana", 24)
avi = Student("Avi", 31)

print(dana.name, dana.age, dana.grades)
print(avi.name, avi.age, avi.grades)

dana.grades.append(95)
print("Dana's grades:", dana.grades)
print("Avi's grades:", avi.grades)

Output:

Dana 24 []
Avi 31 []
Dana's grades: [95]
Avi's grades: []

Breaking down the code line by line

LineExplanation
class Student:Defining the template.
def __init__(self, name, age):The Constructor. self = the object being created. name, age = what you pass at creation.
self.name = nameAn Attribute name on the object, with the value from the parameter.
self.age = ageThe same for the age.
self.grades = []An Attribute that does not come from a parameter: every student starts with an empty grade list of their own.
dana = Student("Dana", 24)Python: creates an object, calls __init__(new_obj, "Dana", 24), and returns it.
avi = Student("Avi", 31)A second object. __init__ runs again, with self = the second object.
dana.grades.append(95)Accessing Dana’s list and adding to it.
avi.grades stays []Every __init__ created a new list, so there is no sharing. (Compare with the bug of def f(cart=[]) in the functions lesson!)

Now the structure is guaranteed: you cannot create a Student without a name and an age, and every student always has grades.

Another example

A Product with default values, a calculated Attribute, and a first validation. week-3/product.py:

class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative, got " + str(price))
        self.name = name
        self.price = price
        self.quantity = quantity
        self.in_stock = quantity > 0

laptop = Product("Laptop", 3500, 4)
cable = Product("USB Cable", 25)

print(laptop.name, laptop.price, laptop.quantity, laptop.in_stock)
print(cable.name, cable.price, cable.quantity, cable.in_stock)

try:
    broken = Product("Ghost", -10)
except ValueError as error:
    print("Could not create product:", error)

Output:

Laptop 3500 4 True
USB Cable 25 0 False
Could not create product: price cannot be negative, got -10

Breakdown:

  • quantity=0 — a default value. Product("USB Cable", 25) gets a quantity of 0.
  • self.in_stock = quantity > 0 — an Attribute calculated from the data: True or False.
  • Validation in the Constructor: __init__ refuses to create a product with a negative price, and throws a ValueError (remember raise!). An invalid product cannot exist in the system. This is exactly “keeping the rules next to the data” that we talked about in the first chapter.
  • When __init__ throws an error, the object is not created, and broken does not get a value.

Person — a third example, for reinforcement

class Person:
    def __init__(self, first_name, last_name, city="Unknown"):
        self.first_name = first_name
        self.last_name = last_name
        self.city = city
        self.full_name = first_name + " " + last_name

noa = Person("Noa", "Levi", "Haifa")
tom = Person("Tom", "Cohen")
print(noa.full_name, "-", noa.city)
print(tom.full_name, "-", tom.city)
Noa Levi - Haifa
Tom Cohen - Unknown

Summary

  • __init__ = the Constructor: called automatically when an object is created, and initializes its data.
  • self = the specific object the method is working on right now. Always the first parameter, and Python passes it by itself.
  • self.x = x: self.x = an Attribute saved on the object. x = a local parameter that disappears.
  • obj = Class(a, b) → Python creates an object, calls __init__(obj, a, b), and returns the object.
  • In __init__ you can have: default values, calculated Attributes, and validation with raise, so that an invalid object is never created at all.

Official documentation

Chapter 4: Methods — the Object’s Actions

What will we learn?

  • What a Method is.
  • How to define it and how to call it.
  • Three kinds of methods by what they do: they calculate, they change state, they print.
  • A method that calls another method.
  • __str__: how an object “presents itself”.

Why do we need this?

Until now our objects had only data. But the idea of OOP is data + actions together. In the first chapter we saw that the rules (like “you cannot withdraw more than the balance”) should sit next to the data. Methods are the place where these rules live.

And there is an open problem from the previous chapter: the car’s description was calculated once in __init__, and became outdated when the color changed. A method calculates anew every time.

Explanation from the basics

What is a Method?

Method = a function defined inside a Class, which acts on a specific object of that Class.

Function (a regular function)Method
Where it is definedAt the top level of the fileInside a Class
How you call itfunction_name(args)obj.method_name(args)
What it works onOn what you pass to itOn its object (self), and on what you pass to it
The first parameterAnything you wantself

You have already used methods: "hi".upper(), numbers.append(5). Today we write our own.

self in methods

Exactly as in __init__: when you call dana.add_grade(90), Python automatically passes dana as self. Inside the method, self.grades = Dana’s grades. If you call avi.add_grade(80), exactly the same method runs, but self is Avi.

dana.add_grade(90)   →   Student.add_grade(dana, 90)
                                            ↑     ↑
                                          self  grade

The left line is what we write. The right line is what Python actually does.

Three kinds of methods (by what they do)

KindWhat it doesExample
GetterCalculates and returns a value from the data, without changing anythingaverage(), is_passing()
SetterChanges the state of the objectadd_grade(90), deposit(100)
DisplayPrints / returns text for displayprint_report(), __str__()

A useful distinction from Clean Code: a good method is usually either a Getter or a Setter, not both together (SRP!).

State

State = the values of all the object’s Attributes at a certain moment. An account with a balance of 1,000 is in a certain state. After deposit(200), it is in a new state. Setter methods are the orderly way to change state.

__str__ — what the object looks like in print

Remember that print(dana) printed <__main__.Student object at 0x...>? If you define in the Class a special method named __str__ (Dunder again!) that returns a string, print and str() will use it to display the object.

Syntax

class ClassName:
    def __init__(self, ...):
        self.data = ...

    def getter_method(self):             # returns something, changes nothing
        return ... self.data ...

    def setter_method(self, value):      # changes the object's state
        self.data = ...

    def uses_other_method(self):
        return self.getter_method() * 2  # call another method on the same object

    def __str__(self):                   # used by print() and str()
        return "readable text about " + str(self.data)

obj = ClassName(...)
obj.setter_method(5)           # no self in the call
result = obj.getter_method()
print(obj)                     # calls __str__ automatically
  • Every method is indented inside the Class, and self is first.
  • Between methods, put one empty line (PEP 8).
  • Inside a method, you access the data and other methods with self..

First code example

week-3/student_methods.py:

PASSING_GRADE = 60

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        if grade < 0 or grade > 100:
            raise ValueError("grade must be between 0 and 100, got " + str(grade))
        self.grades.append(grade)

    def average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self):
        return self.average() >= PASSING_GRADE

    def __str__(self):
        return self.name + " (average " + str(round(self.average(), 1)) + ")"

dana = Student("Dana")
dana.add_grade(90)
dana.add_grade(75)

avi = Student("Avi")
avi.add_grade(50)

print(dana)
print(avi)
print("Dana passing?", dana.is_passing())
print("Avi passing?", avi.is_passing())

Output:

Dana (average 82.5)
Avi (average 50.0)
Dana passing? True
Avi passing? False

Breaking down the code line by line

CodeKindExplanation
def add_grade(self, grade):SetterChanges the state: adds to self.grades. And first checks the rule (0–100).
def average(self):GetterCalculates from the object’s data and returns. Changes nothing. Handles an empty list (the bug from the first chapter!).
def is_passing(self):GetterCalls another method of the same object: self.average().
def __str__(self):DisplayReturns a string. print(dana) calls it automatically.
dana.add_grade(90)Python: Student.add_grade(dana, 90). self = dana.
print(avi)Python: print(avi.__str__()), that is, "Avi (average 50.0)".

The important point: the code that uses the Class (dana.add_grade(...)) does not need to know how the grades are stored (a list), and does not need to check the range itself. The Class is responsible for that.

Another example

A bank account — the problem from the first chapter, solved. week-3/bank_account.py:

class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive, got " + str(amount))
        self.balance = self.balance + amount
        self.transactions.append("deposit " + str(amount))

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive, got " + str(amount))
        if amount > self.balance:
            raise ValueError("insufficient funds: balance " + str(self.balance) + ", requested " + str(amount))
        self.balance = self.balance - amount
        self.transactions.append("withdraw " + str(amount))

    def transfer_to(self, other_account, amount):
        self.withdraw(amount)
        other_account.deposit(amount)

    def statement(self):
        print("--- Statement for", self.owner, "---")
        for transaction in self.transactions:
            print(" ", transaction)
        print("  Balance:", self.balance)

    def __str__(self):
        return "Account of " + self.owner + ": " + str(self.balance) + " NIS"

dana = BankAccount("Dana", 1000)
avi = BankAccount("Avi")

dana.deposit(200)
dana.transfer_to(avi, 300)

try:
    avi.withdraw(5000)
except ValueError as error:
    print("Rejected:", error)

print(dana)
print(avi)
dana.statement()

Output:

Rejected: insufficient funds: balance 300, requested 5000
Account of Dana: 900 NIS
Account of Avi: 300 NIS
--- Statement for Dana ---
  deposit 200
  withdraw 300
  Balance: 900

Breakdown:

  • transfer_to(self, other_account, amount) — a method that receives another object as a parameter. It does not change the balances directly. It uses self.withdraw and other_account.deposit, and that way all the checks run automatically. DRY!
  • If withdraw fails (not enough money), it throws an error before the deposit, so no “money is created out of thin air”.
  • self.transactions — an internal history that every action updates. Whoever uses the account does not need to remember to record a transaction.
  • statement() is a display method. __str__ gives a short description for print.
  • Compare with the procedural version from the first chapter: the same actions, but now there is one place that defines what an account is and what you are allowed to do with it.

Summary

  • Method = a function inside a Class. Define it with self first, and call obj.method(args) without self.
  • obj.method(x) is equivalent to Class.method(obj, x). That is how self “knows” which object to work on.
  • Inside a method, self.attribute and self.other_method().
  • A Getter returns and does not change. A Setter changes the State (and checks the rules).
  • __str__ returns a string, and print(obj) uses it.
  • Derived information is better calculated in a method, and not stored as an Attribute that may become outdated.

Official documentation

Chapter 5: Shared Data — Instance Attribute Versus Class Attribute

What will we learn?

  • Attribute versus Method: how to decide what each thing is.
  • Instance Attribute: data of each object separately (what we have done until now).
  • Class Attribute: data of the Class, shared by all the objects.
  • When to use each one, and which traps there are.

Why do we need this?

Suppose there is a Class Product, and all the products are subject to the same VAT: 17%. Where do you keep the VAT?

  • As an Instance Attribute (self.vat = 0.17)? Then there are a thousand copies of the same number, and if the VAT changes, you have to update a thousand objects.
  • As a global constant at the top of the file? Possible, but it is “far” from the Class it belongs to.

And what if you want to know how many products were created in total? No single product “knows” that. It is information about the whole kind. Class Attributes solve both cases.

Explanation from the basics

Attribute or Method?

AttributeMethod
What it isData, what the object knows / holdsAn action, what the object does / calculates
Part of speechA noun / an adjectiveA verb
Examplesname, price, balance, is_activedeposit(), add_grade(), total()
Accessobj.priceobj.total() with brackets

Questions that help decide:

  1. Is it data that is stored, or something that is calculated? Stored data → an Attribute. Calculated from other data → usually a Method (we saw it: the description that became outdated).
  2. Does it change the object? Every orderly change → a Method (a command).
  3. Are rules needed when it changes? If so, the change goes through a Method (and we will go deeper into this in the lesson on Encapsulation).

Instance Attribute

Defined with self.x = ..., usually in __init__. Every object has its own copy, with its own value.

Class Attribute

Defined directly in the body of the Class, outside any method, without self:

class Product:
    VAT_RATE = 0.17       # class attribute - one shared value
    count = 0             # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute - per object
  • There is only one copy, which belongs to the Class itself.
  • All the objects see it.
  • You access it through the Class (Product.VAT_RATE, the recommended way), or through an object (laptop.VAT_RATE, works for reading).
Analogy: a class at school

Instance Attributes = what every student keeps for themselves: a name, grades, a notebook. Class Attributes = what hangs on the classroom board and is shared by everyone: the homeroom teacher’s name, the room number, and how many students are in the class. When the teacher changes, you update the board once, and not in every student’s notebook.

How Python finds an Attribute

When you write obj.x, Python looks:

  1. First on the object (Instance Attributes).
  2. If not found, on the Class (Class Attributes and methods).
  3. Not found → AttributeError.

(Notice: this is similar to LEGB from the Scope lesson, a lookup in layers.) This is also where the trap in the common mistake comes from.

Syntax

class ClassName:
    SHARED_CONSTANT = 100        # class attribute (constant style: UPPER_CASE)
    counter = 0                  # class attribute (changes, shared)

    def __init__(self, value):
        self.value = value                     # instance attribute
        ClassName.counter = ClassName.counter + 1   # update the class attribute through the CLASS

    def uses_constant(self):
        return self.value * ClassName.SHARED_CONSTANT

ClassName.SHARED_CONSTANT        # read through the class
ClassName.counter

First code example

week-3/products_shared.py:

class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)

print(laptop.name, laptop.price_with_vat())
print(mouse.name, mouse.price_with_vat())
print("Products created:", Product.created_count)

Product.VAT_RATE = 0.18
print("After VAT change:", laptop.price_with_vat(), mouse.price_with_vat())

Output:

Laptop 1170.0
Mouse 58.5
Products created: 2
After VAT change: 1180.0 59.0

Breaking down the code line by line

CodeKindExplanation
VAT_RATE = 0.17Class AttributeOne value for all the products. Capital letters = “a constant” (a convention).
created_count = 0Class AttributeA shared counter. It starts at 0 when the Class is defined.
self.name, self.priceInstance AttributesDifferent in every product.
Product.created_count = Product.created_count + 1Every __init__ updates the Class’s counter, so it counts all the objects.
Product.VAT_RATE in the methodReading a Class Attribute.
Product.VAT_RATE = 0.18One change, and all the products are affected immediately, because they all read from the same place.

Another example

A class with a limit on places, and an automatic unique identifier. week-3/students_ids.py:

class Student:
    SCHOOL_NAME = "Kodkod 6"
    MAX_STUDENTS = 3
    next_id = 1
    all_students = []

    def __init__(self, name):
        if len(Student.all_students) >= Student.MAX_STUDENTS:
            raise ValueError("class is full - cannot add " + name)
        self.name = name
        self.student_id = Student.next_id
        Student.next_id = Student.next_id + 1
        Student.all_students.append(self)

    def __str__(self):
        return "#" + str(self.student_id) + " " + self.name + " (" + Student.SCHOOL_NAME + ")"

for name in ["Dana", "Avi", "Noa", "Tom"]:
    try:
        Student(name)
    except ValueError as error:
        print("Error:", error)

for student in Student.all_students:
    print(student)
print("Next id will be:", Student.next_id)

Output:

Error: class is full - cannot add Tom
#1 Dana (Kodkod 6)
#2 Avi (Kodkod 6)
#3 Noa (Kodkod 6)
Next id will be: 4

Breakdown:

  • next_id — a counter that produces a unique identifier for every student. Every object keeps its own number in the Instance Attribute student_id, and the shared counter moves forward.
  • all_students — a shared list in which the Class “registers” every object that was created. append(self) = “add this object to the list”.
  • MAX_STUDENTS — a rule that applies to the whole Class. The check runs before the ID is assigned, so Tom did not “burn” a number.
  • Student(name) in the loop without storing it in a variable: the object is not lost, because it is registered in all_students.
When yes and when no

Class Attributes are excellent for constants (VAT_RATE, MAX_STUDENTS) and for counters. A shared list of “all the objects” is a good tool for demonstration, but in real projects it is usually better to manage the collection in a separate Class (for example, School that holds a list of students). We will learn this in the OOP design lessons (Composition).

Summary

  • Attribute = what the object knows (a noun). Method = what it does (a verb, with brackets). Calculated information → a Method.
  • Instance Attribute (self.x in __init__) = every object has its own value.
  • Class Attribute (in the body of the Class, without self) = one shared copy. Suits constants and counters.
  • Python looks for obj.x first on the object, and then on the Class.
  • Change a Class Attribute only through the Class’s name. obj.X = ... creates a new Instance Attribute.
  • A list/dict “per object” → in __init__, not in the body of the Class.

Official documentation

Chapter 6: What Becomes a Class — From a Verbal Requirement to Classes

What will we learn?

Until now we told you which Classes to write. Now we move to the other side: you decide.

  • How to find Classes inside a verbal description of a system.
  • Four questions that decide: a Class, or not?
  • When something is an Attribute, and when it is a Class of its own.
  • Which Class each action belongs to.

Why do we need this?

The syllabus emphasizes: the goal is to understand OOP, and not only to know how to copy a Class. At work, nobody will tell you “write a Class named Order with these fields”. You will get a requirement: “we need a system in which customers order products…”, and you will have to turn it into a structure of Classes yourselves.

Good design at the start saves weeks of fixes. Bad design leads to one huge Class, or to dozens of empty Classes that do nothing.

Explanation from the basics

Tool 1: analyzing nouns and verbs

A simple and effective technique: read the requirement, and mark:

In the textA candidate for
Important nouns (“customer”, “order”, “product”)Classes or Attributes
Adjectives and properties (“price”, “date”, “active”)Attributes
Verbs (“orders”, “pays”, “cancels”)Methods
Rules (“it is not possible to…”, “at most…”)Validation / Invariants

This is a starting point, not an answer. Not every noun becomes a Class.

Tool 2: four questions — is it a Class?

#QuestionIf the answer is “yes”
1Does it have an identity and data of its own? (each one has different values)Leans towards a Class
2Does it have behavior or rules? (actions, validation)Leans strongly towards a Class
3Are there several instances of it? (many customers, many orders)Leans towards a Class
4Is it only a single value, without any behavior? (“color”, “name”)An Attribute, not a Class
Analogy

“Car” — it has its own license plate (identity), it drives and brakes (behavior), and there are many cars. ✓ A Class. “The car’s color” — it is only a value (“red”), with no actions of its own. An Attribute of the car. “Engine” — it depends! In a simple game, an Attribute (engine_size = 1.6). In a garage that repairs engines, a Class (it has a serial number, a service history and a condition). The same concept, a different decision according to what the system needs.

Tool 3: a simple Attribute or a Class of its own?

A decisive question: do you need to keep more than one piece of information about this thing, or apply rules to it?

ConceptA simple Attribute when…A Class of its own when…
AddressOne text is enough: "Herzl 10, Haifa"You need the city, the street and the zip code separately, and to filter by city, and to validate the zip code
PriceOne numberYou need a currency, conversions and rounding by currency
GradeA numberYou also need a date, the name of the exam, a weight and an appeal

Rule of thumb: start simple (an Attribute). When the value “grows” and gets more fields and rules, turn it into a Class.

Tool 4: which Class does the method belong to?

The golden rule: the method goes to the Class that holds the data it needs. (This is called “Information Expert”.)

  • “Calculate the order total” → needs the order’s items → Order.total().
  • “Is the product in stock?” → needs the product’s quantity → Product.is_in_stock().
  • “How much did the customer spend this year?” → needs the customer’s orders → Customer (if it holds its own orders), or a Class that manages all the orders.

A warning sign: a method in Class A that is mostly other.x, other.y, other.z → it probably belongs to the Class of other.

Syntax

There is no new syntax. The product of this stage is a design, which can be written as a code “skeleton”, with pass and docstrings:

class Order:
    """A customer's order: items, status and total."""

    def __init__(self, customer):
        self.customer = customer      # has-a: the order holds its customer
        self.items = []
        self.status = "new"

    def add_item(self, product, quantity):
        pass

    def total(self):
        pass

A skeleton like this lets you think about the structure before writing logic.

First code example

The requirement: “A system for a coffee shop. Customers order drinks from the menu. Every drink has a name, a size (small/large) and a price. A large drink costs 3 NIS more. An order can include several drinks, and it has a total. A customer can cancel an order, as long as it has not been prepared.”

Step A — marking:

  • Nouns: customer, drink, menu, order, name, size, price, total.
  • Verbs: order, include, cancel, prepared.
  • Rules: large = +3. Cancelling only before preparing.

Step B — the four questions:

CandidateIdentity/data?Behavior?Several instances?Decision
Drink✓ name, size, price✓ calculating the price by size✓Class Drink
Order✓ items, status✓ total, cancelling, preparing✓Class Order
Customer✓ nameA little✓Here: an Attribute (name) of Order. The requirement has no behavior for it.
MenuA list of drinksMaybe searchingOneHere: a simple dict. If rules are added, it will become a Class.
SizeA value (“small”/“large”)——An Attribute of Drink
PriceA number——An Attribute + a calculated method
TotalDerived——A Method Order.total()

Step C — methods (“who holds the data?”):

  • price() → Drink, because Drink knows the base price and the size.
  • total() → Order, because Order holds the drinks.
  • cancel(), mark_prepared() → Order, because Order holds the status.

Step D — code. week-3/coffee_shop.py:

LARGE_EXTRA = 3

class Drink:
    def __init__(self, name, base_price, size="small"):
        if size not in ("small", "large"):
            raise ValueError("size must be small or large, got " + size)
        self.name = name
        self.base_price = base_price
        self.size = size

    def price(self):
        if self.size == "large":
            return self.base_price + LARGE_EXTRA
        return self.base_price

class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.drinks = []
        self.status = "open"

    def add(self, drink):
        if self.status != "open":
            raise ValueError("cannot add drinks to a " + self.status + " order")
        self.drinks.append(drink)

    def total(self):
        return sum(drink.price() for drink in self.drinks)

    def mark_prepared(self):
        self.status = "prepared"

    def cancel(self):
        if self.status == "prepared":
            raise ValueError("cannot cancel - the order was already prepared")
        self.status = "cancelled"

MENU = {"espresso": 9, "latte": 14, "tea": 8}

order = Order("Dana")
order.add(Drink("latte", MENU["latte"], "large"))
order.add(Drink("espresso", MENU["espresso"]))
print(order.customer_name, "pays", order.total())

order.mark_prepared()
try:
    order.cancel()
except ValueError as error:
    print("Rejected:", error)

Output:

Dana pays 26
Rejected: cannot cancel - the order was already prepared

Breaking down the code line by line

Design decisionWhat it looks like in the code
Drink = a Classclass Drink with data (name, price, size) and behavior (price).
Size = an Attribute with a ruleself.size, with validation in __init__.
“Large = +3” belongs to the drinkDrink.price(), because the drink knows its own size.
Order = a Classclass Order with items and a status.
Customer = an Attribute (for now)self.customer_name, a string.
“Cancelling only before preparing”Validation in Order.cancel().
Menu = a dictMENU. It has no behavior, so there is no need for a Class.
Total = a methodtotal() calculates from drinks, and is not stored as data that may become outdated.

Another example

When the requirement changes — the customer becomes a Class. A requirement was added: “Customers have a club card: every purchase earns a point for every 10 NIS, and at 50 points you get a free drink.”

Now the customer has data (points), behavior (earning, redeeming) and rules (50 points). According to the four questions, it is already a Class:

POINTS_PER_SHEKEL = 0.1
FREE_DRINK_POINTS = 50

class Customer:
    def __init__(self, name):
        self.name = name
        self.points = 0

    def earn_points(self, amount_paid):
        self.points = self.points + int(amount_paid * POINTS_PER_SHEKEL)

    def has_free_drink(self):
        return self.points >= FREE_DRINK_POINTS

    def redeem_free_drink(self):
        if not self.has_free_drink():
            raise ValueError(self.name + " has only " + str(self.points) + " points")
        self.points = self.points - FREE_DRINK_POINTS

dana = Customer("Dana")
for payment in [120, 260, 140]:
    dana.earn_points(payment)
print(dana.name, "has", dana.points, "points. Free drink?", dana.has_free_drink())
dana.redeem_free_drink()
print("After redeeming:", dana.points)

Output:

Dana has 52 points. Free drink? True
After redeeming: 2

And in the structure: Order now holds self.customer (a Customer object) instead of customer_name, and when an order is paid, it calls self.customer.earn_points(self.total()). The same concept, “customer”, started as an Attribute and became a Class when the requirements grew. That is natural, and it is the right work process.

Summary

  • Nouns → candidates for Classes/Attributes. Verbs → Methods. Rules → Validation.
  • A Class when there is identity + data, behavior or rules, and several instances. A single value without behavior → an Attribute.
  • The same concept can be an Attribute in one system and a Class in another. Start simple, and upgrade when the requirements grow.
  • A Method belongs to the Class that holds the data it needs (Information Expert).
  • Sometimes an “action” (a loan, an order) is really a Class, when it has data and logic of its own.
  • Not a Class for every noun, and not a God Class that does everything.

Official documentation

Chapter 7: Back to the Bank — Everything Together

What will we learn?

We return to the bank system from the start of the lesson, and rebuild it — this time with everything we learned in the previous chapters.

Why do we need this?

Every chapter added one tool and solved one problem. Here you see how all of them work together in one Class — and that is exactly what you will do on your own in the mini project.

The bank — with everything we learned

class BankAccount:
    accounts_opened = 0                  # shared by all accounts

    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner               # per account
        self.balance = balance
        BankAccount.accounts_opened = BankAccount.accounts_opened + 1

    def deposit(self, amount):           # setter - changes the state
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def __str__(self):
        return self.owner + ": " + str(self.balance)

dana = BankAccount("Dana", 1000)
dana.deposit(200)
print(dana)                              # Dana: 1200
Part of the codeFrom which chapter
class BankAccount — one template for all the accountsChapter 2
__init__ with a check — every account is born complete and validChapter 3
deposit — a Setter that protects the rulesChapter 4
__str__ — what the account looks like in printChapter 4
accounts_opened — a counter shared by all the accountsChapter 5

The four problems from the first chapter were solved: you cannot create an account with a negative balance, the data and the actions sit together, the structure has one definition, and the Class itself enforces the rules.

Summary

  • A Class is a template, and an Object is an instance built from it — with data of its own.
  • __init__ runs at creation and guarantees that the object is born complete; self is the current object.
  • Methods are the actions: a Getter returns information, a Setter changes the state and checks the rules, and __str__ decides what print shows.
  • A Class Attribute is one piece of data shared by all the objects — like a counter.

Official documentation

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

What you practice on this page: why OOP is needed, creating a Class with __init__ and checking values, methods (Getter and Setter) and __str__, data shared by the whole Class, and design — what is a Class, what is an Attribute and what is a Method.

By the end of the page you will know how to:

  • Recognize when it is better to combine data and actions into one object
  • Write a complete Class: __init__, methods and __str__
  • Use a Class Attribute as a counter shared by all the objects
  • Turn a verbal description into a list of Classes, Attributes and Methods

Exercise 1: Spot the problems

What you do: read existing code and find problems in it — without writing code.

The code:

student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def average(student):
    return sum(student["grades"]) / len(student["grades"])

The task: find three problems that come from the data (the dictionaries) and the actions (the function) being separate. For each problem write one sentence: what the problem is, and on which line it appears.

What to submit: three problems, one sentence for each.

Show solution
  1. A typo creates a new key — student3 has "nmae" instead of "name". There is no defined structure that would catch this, and the problem will be discovered only when someone tries student3["name"].
  2. There are no rules — any code can put -50 or "abc" into grades, and nobody checks.
  3. The data and the actions are scattered — average is a separate function that receives a dictionary, and does not know whether it is a student at all. And with an empty list (student3) it will fail with a ZeroDivisionError.

In OOP, Student defines once which data it has and which actions it knows how to do — all together.

Exercise 2: A check at creation

What you build: a product in a shop — that cannot be created with a negative price.

What to write:

  1. A Class named Product.
  2. An __init__ that receives a name, a price and a quantity. If the quantity was not passed — it is 0.
  3. If the price is negative: throw a ValueError with the message that appears in the output, and save nothing.
  4. Otherwise: save the three pieces of data on the object, with self.

Test code:

pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)

The desired output:

Pen 5 0
error: price cannot be negative
Show solution
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.name = name
        self.price = price
        self.quantity = quantity


pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
Pen 5 0
error: price cannot be negative

The check in __init__ guarantees that there is never a Product object with an invalid price.

Exercise 3: Student with methods and str

What you build: a student who manages their own grades.

What to write:

  1. A Class named Student. The __init__ receives a name and creates an empty grade list.
  2. add_grade — receives a grade and adds it to the list. This is a Setter: it changes the state.
  3. average — returns the average, or 0 if there are no grades yet. This is a Getter: it only returns.
  4. __str__ — returns the name and the average, exactly as in the last line of the output.

Test code:

dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)

The desired output:

0
82.5
Dana (average 82.5)
Show solution
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

    def average(self):
        if len(self.grades) == 0:
            return 0
        return sum(self.grades) / len(self.grades)

    def __str__(self):
        return self.name + " (average " + str(self.average()) + ")"


dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
0
82.5
Dana (average 82.5)

add_grade is a Setter — it changes the state. average is a Getter — it returns information without changing anything. print calls __str__ automatically.

Exercise 4: A shared counter

What you build: a product that knows its price including VAT — and a Class that counts how many products were created.

What to write:

  1. A Class named Product, with an __init__ that receives a name and a price.
  2. Two Class Attributes — data that belongs to the whole Class, not to one object: VAT_RATE with the value 0.17, and created_count that starts at 0.
  3. Every time a product is created, increase created_count by 1.
  4. price_with_vat — returns the price including VAT.

Test code:

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())

The desired output:

2
1170.0
Show solution
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
2
1170.0
  • name and price belong to each product separately — Instance Attributes.
  • VAT_RATE and created_count are shared by all the products — Class Attributes.
  • The counter is updated through Product.created_count — not through self.

Exercise 5: Nouns and verbs

What you do: design only, without code — turn a verbal description into a list of Classes, Attributes and Methods.

The requirement:

“In a coffee shop, customers order drinks in different sizes. Every drink has a base price, and a large drink costs 3 NIS more. The customer earns points on every order, and can redeem them.”

The task: build a table with three columns: the candidate, what it is (a Class, an Attribute or a Method), and why.

Hint: nouns are candidates for Classes or Attributes. Verbs are candidates for Methods.

What to submit: a table with all the candidates from the requirement.

Show solution
CandidateWhat it isWhy
Customer (Customer)ClassA noun with data (name, points) and behavior (earns, redeems)
Drink (Drink)ClassA noun with data (base price, size) and a rule (large costs more)
Base price, sizeAttributes of DrinkSimple values that describe a drink
PointsAttribute of CustomerA simple number
Earns points, redeemsMethods of CustomerVerbs — actions on the customer’s data
Final priceMethod of DrinkCalculated from the price and the size

Mini project: A small library

Which system you build: a small system for a library. The library has books, and every book has several copies. Readers borrow copies and return them, and at every moment the system knows how many copies of each book are free — and how many books there are in the library in total.

What you will learn from the project:

  • Build a complete Class from scratch: __init__, methods and __str__
  • Check values already at creation, and throw an error when they are invalid
  • Tell apart a Setter that changes the state and a Getter that only returns information
  • Use a Class Attribute as a counter shared by all the objects

What to build — step by step:

Step 1 — the Class and the shared counter. Write a Class named Book, with a Class Attribute named total_books that starts at 0. It will count how many books were created.

Step 2 — creating a book. The __init__ receives a title, an author and a number of copies, and saves them. At the start all the copies are free — save them also in available. Every new book increases total_books by 1. If the number of copies is smaller than 1 — throw a ValueError, and do not create a book.

Step 3 — borrowing and returning (Setters).

  • borrow — takes away one free copy. If there are no free copies: ValueError.
  • return_book — returns one copy. If all the copies are already in the library: ValueError.

Step 4 — checking (Getter). is_available returns True if there is at least one free copy, otherwise False.

Step 5 — display. __str__ returns the title, the author and how many copies are free out of how many, as in the output.

The error messages — exactly like this:

copies must be at least 1
no copies available
all copies are already in

Test code:

books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)

The desired output:

Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2

By the end of the project you will know how to:

  • Translate a description of a “thing” in the world — a book — into a Class with data and actions
  • Keep the rules inside the object, so that it cannot reach an invalid state
  • Understand why the book Empty is not counted by the counter: the error was thrown before the increase

Bonus: add a Class named Library that holds a list of books, with add_book that adds a book, and available_books that returns only the books that can be borrowed right now.

Show solution
class Book:
    total_books = 0

    def __init__(self, title, author, copies):
        if copies < 1:
            raise ValueError("copies must be at least 1")
        self.title = title
        self.author = author
        self.copies = copies
        self.available = copies
        Book.total_books = Book.total_books + 1

    def borrow(self):
        if self.available == 0:
            raise ValueError("no copies available")
        self.available = self.available - 1

    def return_book(self):
        if self.available == self.copies:
            raise ValueError("all copies are already in")
        self.available = self.available + 1

    def is_available(self):
        return self.available > 0

    def __str__(self):
        return (self.title + " by " + self.author + " ("
                + str(self.available) + "/" + str(self.copies) + " available)")


books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
  • total_books is a Class Attribute — one counter for all the books. The book Empty was not created (the error was thrown before the increase), so the counter is 2.
  • borrow and return_book are Setters — they change the state, and check the rules before the change.
  • is_available is a Getter — it returns information without changing anything.

Bonus:

class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def available_books(self):
        result = []
        for book in self.books:
            if book.is_available():
                result.append(book)
        return result


library = Library()
for book in books:
    library.add_book(book)
for book in library.available_books():
    print(book)
Clean Code by Martin (2/3 available)