# Classes and Objects: From the Problem to a Complete Class<no value>

## 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:

```python
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)
```

```text
{'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

```python
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 `if`s.

#### 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.

```text
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?

| Problem | The OOP solution | We will learn it in... |
|---|---|---|
| Anyone touches the data | The object **protects** its data, and changes go through its actions (**Encapsulation**) | The next lesson |
| Data and actions are scattered | Everything is **inside** the Class. `account.` + Tab in VS Code shows everything you can do | Today |
| No defined structure | **One Class** defines exactly which data every account has | Today |
| Similar kinds | **Inheritance** and **Polymorphism**: a savings account "inherits" from a regular account and changes only what is different | The 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 apartment | Dana's apartment on floor 3, Avi's apartment on floor 5 |
| The "student details" **form** | The form Dana filled in, the form Avi filled in |
| A **template** for producing cars on a production line | The 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.

{{< box "analogy" "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.
{{< /box >}}

### 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:

```python
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:

```python
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:**

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

### Breaking down the code line by line

| Code | What 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.

```python
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))
```

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

## 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**.

| | Class | Object / Instance |
|---|---|---|
| What it is | A template, a definition | A specific thing |
| How many | **One** for each kind | **As many** as you want |
| Data | Defines **which** fields there will be | Holds specific **values** |
| Example | `Student` | Dana, Avi, Noa |
| Analogy | A car production template | The 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

```python
class ClassName:
    # class body - indented
    pass

my_object = ClassName()
```

| Part | Explanation |
|---|---|
| `class` | A reserved word: "a Class definition begins". |
| `ClassName` | The Class's name, in PascalCase. |
| `:` | Required, like after `def` or `if`. |
| An indented body | What 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`:

```python
class Person:
    pass

dana = Person()
avi = Person()

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

**Output:**

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

### Breaking down the code line by line

| Line | Explanation |
|---|---|
| `class Person:` | Defines **a new kind** named `Person`. No person has been created yet. |
| `pass` | The 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 addresses | Two 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 avi` | `False`: 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:

```python
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:**

```text
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`?

```python
keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
```

```text
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__`.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [A First Look at Classes](https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes)
- [Class Objects](https://docs.python.org/3/tutorial/classes.html#class-objects)

<!-- docs:end -->

## 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", **d**ouble **under**score **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**".

```python
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.

{{< box "analogy" "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`).
{{< /box >}}

#### 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

```python
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`:

```python
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:**

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

### Breaking down the code line by line

| Line | Explanation |
|---|---|
| `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 = name` | An Attribute `name` **on the object**, with the value from the parameter. |
| `self.age = age` | The 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`:

```python
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:**

```text
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

```python
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)
```

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Class Objects — __init__](https://docs.python.org/3/tutorial/classes.html#class-objects)
- [object.__init__](https://docs.python.org/3/reference/datamodel.html#object.__init__)

<!-- docs:end -->

## 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 defined | At the top level of the file | **Inside** a Class |
| How you call it | `function_name(args)` | `obj.method_name(args)` |
| What it works on | On what you pass to it | On **its object** (`self`), and on what you pass to it |
| The first parameter | Anything you want | **`self`** |

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.

```text
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)

| Kind | What it does | Example |
|---|---|---|
| **Getter** | **Calculates and returns** a value from the data, without changing anything | `average()`, `is_passing()` |
| **Setter** | **Changes the state** of the object | `add_grade(90)`, `deposit(100)` |
| **Display** | Prints / returns text for display | `print_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

```python
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`:

```python
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:**

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

### Breaking down the code line by line

| Code | Kind | Explanation |
|---|---|---|
| `def add_grade(self, grade):` | **Setter** | Changes the state: adds to `self.grades`. And first **checks** the rule (0–100). |
| `def average(self):` | **Getter** | Calculates from **the object's** data and returns. Changes nothing. Handles an empty list (the bug from the first chapter!). |
| `def is_passing(self):` | **Getter** | Calls **another method** of the same object: `self.average()`. |
| `def __str__(self):` | **Display** | Returns 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`:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Method Objects](https://docs.python.org/3/tutorial/classes.html#method-objects)

<!-- docs:end -->

## 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?

| | Attribute | Method |
|---|---|---|
| What it is | **Data**, what the object **knows / holds** | **An action**, what the object **does / calculates** |
| Part of speech | A noun / an adjective | **A verb** |
| Examples | `name`, `price`, `balance`, `is_active` | `deposit()`, `add_grade()`, `total()` |
| Access | `obj.price` | `obj.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`**:

```python
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).

{{< box "analogy" "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.
{{< /box >}}

#### 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

```python
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`:

```python
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:**

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

### Breaking down the code line by line

| Code | Kind | Explanation |
|---|---|---|
| `VAT_RATE = 0.17` | **Class Attribute** | One value for all the products. Capital letters = "a constant" (a convention). |
| `created_count = 0` | **Class Attribute** | A shared counter. It starts at 0 when the Class is defined. |
| `self.name`, `self.price` | **Instance Attributes** | Different in every product. |
| `Product.created_count = Product.created_count + 1` | | Every `__init__` updates **the Class's** counter, so it counts all the objects. |
| `Product.VAT_RATE` in the method | | Reading a Class Attribute. |
| `Product.VAT_RATE = 0.18` | | **One** 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`:

```python
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:**

```text
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`.

{{< box "tip" "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).
{{< /box >}}

### 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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->

## 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 text | A 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?

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

{{< box "analogy" "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.**
{{< /box >}}

#### 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?**

| Concept | A simple Attribute when... | A Class of its own when... |
|---|---|---|
| Address | One 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 |
| Price | One number | You need a currency, conversions and rounding by currency |
| Grade | A number | You 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:

```python
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:**

| Candidate | Identity/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 | ✓ name | A little | ✓ | Here: an **Attribute** (name) of Order. The requirement has no behavior for it. |
| Menu | A list of drinks | Maybe searching | **One** | Here: a simple **dict**. If rules are added, it will become a Class. |
| Size | A value ("small"/"large") | — | — | An **Attribute** of Drink |
| Price | A number | — | — | An **Attribute** + a calculated method |
| Total | Derived | — | — | 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`:

```python
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:**

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

### Breaking down the code line by line

| Design decision | What it looks like in the code |
|---|---|
| Drink = a Class | `class Drink` with data (name, price, size) and behavior (`price`). |
| Size = an Attribute with a rule | `self.size`, with validation in `__init__`. |
| "Large = +3" belongs to the drink | `Drink.price()`, because the drink knows its own size. |
| Order = a Class | `class 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 dict | `MENU`. It has no behavior, so there is no need for a Class. |
| Total = a method | `total()` 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**:

```python
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:**

```text
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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Random Remarks](https://docs.python.org/3/tutorial/classes.html#random-remarks)

<!-- docs:end -->

## 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

```python
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 code | From which chapter |
|---|---|
| `class BankAccount` — one template for all the accounts | Chapter 2 |
| `__init__` with a check — every account is born complete and valid | Chapter 3 |
| `deposit` — a Setter that protects the rules | Chapter 4 |
| `__str__` — what the account looks like in print | Chapter 4 |
| `accounts_opened` — a counter shared by all the accounts | Chapter 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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Official documentation

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## 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:**

```text
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.

{{< 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.
{{< /solution >}}

### 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:**

```python
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:**

```text
Pen 5 0
error: price cannot be negative
```

{{< solution >}}
```python
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)
```

```text
Pen 5 0
error: price cannot be negative
```

The check in `__init__` guarantees that there is never a `Product` object with an invalid price.
{{< /solution >}}

### 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:**

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

**The desired output:**

```text
0
82.5
Dana (average 82.5)
```

{{< solution >}}
```python
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)
```

```text
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.
{{< /solution >}}

### 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:**

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

**The desired output:**

```text
2
1170.0
```

{{< solution >}}
```python
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())
```

```text
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`.
{{< /solution >}}

### 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.

{{< solution >}}
| Candidate | What it is | Why |
|---|---|---|
| Customer (`Customer`) | **Class** | A noun with data (name, points) and behavior (earns, redeems) |
| Drink (`Drink`) | **Class** | A noun with data (base price, size) and a rule (large costs more) |
| Base price, size | **Attributes** of `Drink` | Simple values that describe a drink |
| Points | **Attribute** of `Customer` | A simple number |
| Earns points, redeems | **Methods** of `Customer` | Verbs — actions on the customer's data |
| Final price | **Method** of `Drink` | Calculated from the price and the size |
{{< /solution >}}

## 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:**

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

**Test code:**

```python
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:**

```text
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.

{{< solution >}}
```python
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)
```

```text
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:**

```python
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)
```

```text
Clean Code by Martin (2/3 available)
```
{{< /solution >}}

<!-- exercises:end -->
