How objects remember, how methods read and change what they hold, and how to design object behavior that is clear, safe, and reliable
Objects are not just containers for data. They are participants in a program — things that know their own condition and can take actions based on it.
Chapter 26 introduced the core ideas of object-oriented programming: classes as blueprints, objects as individual instances, the __init__ method for setup, attributes for stored values, and methods for behavior. You wrote your first classes and created your first objects.
This chapter goes deeper. The focus is on object state: the collection of attribute values that an object carries at any moment, and how methods read and change that state over time. You will learn how to write methods that inspect what an object knows, methods that update what an object holds, and how to design objects that behave consistently and clearly.
By the end of this chapter, you should be able to read and trace programs where multiple objects exist and interact, build objects that maintain state across many method calls, work with lists of objects, give objects useful string representations, and decide when a class-based design is genuinely clearer than a dictionary-based one.
This chapter is still introductory. It does not cover inheritance, class methods, static methods, or operator overloading. Those belong to a more advanced course. The goal here is to use classes comfortably for the kinds of programs that appear naturally in a first Python course.
By the end of this chapter, you should be able to:
__init__.__str__ method and explain what it does and why it is useful.__init__, methods for state changes.When you hold a library card, that card has a state. It might currently be active or expired. It might have two books checked out. It might have a fine of zero dollars or a fine of four dollars. The card's state changes over time as you borrow books, return them, and pay fines.
An object works the same way. Its state is the collection of values stored in its attributes at a given moment. State changes whenever a method modifies an attribute.
Here is a simple LibraryCard class:
class LibraryCard:
def __init__(self, owner_name):
self.owner = owner_name
self.books_checked_out = 0
self.fine_amount = 0.0
self.active = True
When you create a card:
card = LibraryCard("Priya Nair")
The object immediately has a state:
| Attribute | Value |
|---|---|
owner | "Priya Nair" |
books_checked_out | 0 |
fine_amount | 0.0 |
active | True |
This state is private to that object. If you create a second card:
card2 = LibraryCard("Omar Castillo")
That object has its own completely separate state. Changing card has no effect on card2.
State is what makes objects useful. A dictionary could hold the same four values. But a class lets you attach methods — behaviors — directly to the data, so the object knows how to manage itself.
Tracing the state of an object across a sequence of method calls is one of the most important skills in object-oriented programming. Before you can debug or extend a class, you must be able to predict what each method call does to the object's attributes. Tracing tables, introduced in the exercises of this chapter, make this concrete.
Instance attributes are the variables attached to a specific object. They are created using self.attribute_name inside the class. Each object gets its own copy.
You saw in Chapter 26 that __init__ creates instance attributes when the object is first built. But instance attributes can also be created or modified in any method — though it is best practice to create all of them in __init__ so you always know what attributes an object has.
Here is a fuller example:
class BankAccount:
def __init__(self, account_holder, starting_balance):
self.holder = account_holder
self.balance = starting_balance
self.transaction_count = 0
Three instance attributes: holder, balance, and transaction_count. Every BankAccount object will have all three.
account_a = BankAccount("Kenji Watanabe", 500.0)
account_b = BankAccount("Amara Diallo", 1200.0)
After these two lines, account_a.holder is "Kenji Watanabe" and account_a.balance is 500.0, while account_b.holder is "Amara Diallo" and account_b.balance is 1200.0. The attributes belong to each object individually.
You can read instance attributes directly:
print(account_a.balance) # 500.0 print(account_b.holder) # Amara Diallo
You can also set them directly from outside the class:
account_a.balance = 600.0
Setting attributes directly from outside the class works in Python, but doing it frequently is usually a sign of poor design. Methods give you a controlled, tested way to change state. Direct attribute assignment bypasses any logic the class is meant to enforce. Use methods when the attribute change has rules attached to it.
Some methods exist only to report on an object's current state. They read attributes and return or display information. They do not change anything.
class BankAccount:
def __init__(self, account_holder, starting_balance):
self.holder = account_holder
self.balance = starting_balance
self.transaction_count = 0
def get_balance(self):
return self.balance
def get_holder(self):
return self.holder
def is_overdrawn(self):
return self.balance < 0
def summary(self):
print(f"Account holder: {self.holder}")
print(f"Balance: ${self.balance:.2f}")
print(f"Transactions: {self.transaction_count}")
None of these methods change any attribute. They only report.
account = BankAccount("Kenji Watanabe", 500.0)
print(account.get_balance()) # 500.0
print(account.is_overdrawn()) # False
account.summary()
500.0 False Account holder: Kenji Watanabe Balance: $500.00 Transactions: 0
Methods that only read state are sometimes called accessor methods or getter methods in other programming languages. Python does not require those terms, but understanding the concept is useful: some methods observe, some methods change. Keeping them separate in your thinking makes classes easier to design and test.
Other methods modify attributes. These are the actions an object can perform. Each call moves the object to a new state.
class BankAccount:
def __init__(self, account_holder, starting_balance):
self.holder = account_holder
self.balance = starting_balance
self.transaction_count = 0
def deposit(self, amount):
self.balance = self.balance + amount
self.transaction_count = self.transaction_count + 1
def withdraw(self, amount):
self.balance = self.balance - amount
self.transaction_count = self.transaction_count + 1
def get_balance(self):
return self.balance
def summary(self):
print(f"Account holder: {self.holder}")
print(f"Balance: ${self.balance:.2f}")
print(f"Transactions: {self.transaction_count}")
Now trace this sequence:
account = BankAccount("Kenji Watanabe", 500.0)
account.deposit(200.0)
account.withdraw(75.0)
account.summary()
| Step | Action | balance | transaction_count |
|---|---|---|---|
| After __init__ | Created | 500.0 | 0 |
| After deposit(200.0) | +200 | 700.0 | 1 |
| After withdraw(75.0) | −75 | 625.0 | 2 |
Account holder: Kenji Watanabe Balance: $625.00 Transactions: 2
Notice how each method call moves the object to a new state. The object remembers every change because it stores the current values in its own attributes between calls. This is the essential power of objects. A plain function cannot remember results between calls unless you pass data back and forth explicitly. An object holds its own data and updates it with every action.
Methods can contain logic that checks whether an action should be allowed before carrying it out. This is one of the main reasons to use methods instead of setting attributes directly. A bank account probably should not allow withdrawals larger than the current balance:
def withdraw(self, amount):
if amount <= 0:
print("Withdrawal amount must be positive.")
elif amount > self.balance:
print("Insufficient funds.")
else:
self.balance = self.balance - amount
self.transaction_count = self.transaction_count + 1
Now the method enforces a rule. You cannot accidentally set the balance to a negative number by calling withdraw() incorrectly. The logic lives in one place, inside the class.
class BankAccount:
def __init__(self, account_holder, starting_balance):
self.holder = account_holder
self.balance = starting_balance
self.transaction_count = 0
def deposit(self, amount):
if amount <= 0:
print("Deposit amount must be positive.")
else:
self.balance = self.balance + amount
self.transaction_count = self.transaction_count + 1
def withdraw(self, amount):
if amount <= 0:
print("Withdrawal amount must be positive.")
elif amount > self.balance:
print("Insufficient funds.")
else:
self.balance = self.balance - amount
self.transaction_count = self.transaction_count + 1
def get_balance(self):
return self.balance
def summary(self):
print(f"Account holder: {self.holder}")
print(f"Balance: ${self.balance:.2f}")
print(f"Transactions: {self.transaction_count}")
account = BankAccount("Amara Diallo", 300.0)
account.deposit(-50)
account.withdraw(500.0)
account.withdraw(100.0)
account.summary()
Deposit amount must be positive. Insufficient funds. Account holder: Amara Diallo Balance: $200.00 Transactions: 1
When you write validation inside methods, you build an object that polices its own integrity. The failed deposit and failed withdrawal did not change the state. Only the valid withdrawal updated the balance and count. This is much safer than relying on the calling code to always pass correct values — which it won't, eventually.
Objects do not have to live in isolation. A method can receive another object as an argument, call methods on that object, or hold a reference to another object as an attribute.
Here is a Student class and a Classroom class that manages students:
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def is_passing(self):
return self.grade >= 60
def summary(self):
status = "passing" if self.is_passing() else "failing"
print(f"{self.name}: {self.grade} ({status})")
class Classroom:
def __init__(self, course_name):
self.course = course_name
self.students = []
def add_student(self, student):
self.students.append(student)
def count_students(self):
return len(self.students)
def class_average(self):
if len(self.students) == 0:
return 0.0
total = 0
for student in self.students:
total = total + student.grade
return total / len(self.students)
def print_roster(self):
print(f"--- {self.course} ---")
for student in self.students:
student.summary()
print(f"Class average: {self.class_average():.1f}")
s1 = Student("Fatima Al-Rashid", 88)
s2 = Student("Lucas Moreau", 54)
s3 = Student("Yuna Park", 73)
s4 = Student("Dev Sharma", 91)
room = Classroom("Introduction to Programming")
room.add_student(s1)
room.add_student(s2)
room.add_student(s3)
room.add_student(s4)
room.print_roster()
--- Introduction to Programming --- Fatima Al-Rashid: 88 (passing) Lucas Moreau: 54 (failing) Yuna Park: 73 (passing) Dev Sharma: 91 (passing) Class average: 76.5
Classroom does not copy student data. It stores references to the Student objects. The same student object lives in both the variable s1 and inside the classroom's list.class_average method reaches into each student object to read student.grade. One object's method is free to read the public attributes of another.print_roster method calls each student's summary() method. Classroom delegates the display work to Student. Neither class needs to know the full internal logic of the other.One of the most practical patterns you will use repeatedly is a list of objects. Many real programs manage collections of similar things: a list of students, a list of accounts, a list of products, a list of tasks.
class Task:
def __init__(self, description, priority):
self.description = description
self.priority = priority
self.completed = False
def complete(self):
self.completed = True
def display(self):
status = "Done" if self.completed else "Pending"
print(f"[{status}] ({self.priority}) {self.description}")
tasks = []
tasks.append(Task("Buy groceries", "low"))
tasks.append(Task("Finish lab report", "high"))
tasks.append(Task("Reply to emails", "medium"))
tasks.append(Task("Review Chapter 27 notes", "high"))
# Mark one task complete
tasks[1].complete()
# Display all tasks
for task in tasks:
task.display()
[Pending] (low) Buy groceries [Done] (high) Finish lab report [Pending] (medium) Reply to emails [Pending] (high) Review Chapter 27 notes
Working with lists of objects follows the same patterns as working with lists of integers or strings. You can iterate, filter, sort, and accumulate. The difference is that each element is an object with its own attributes and methods.
def find_high_priority(task_list):
result = []
for task in task_list:
if task.priority == "high":
result.append(task)
return result
def count_pending(task_list):
count = 0
for task in task_list:
if not task.completed:
count = count + 1
return count
urgent = find_high_priority(tasks)
print("High priority tasks:")
for task in urgent:
task.display()
print(f"\nPending tasks: {count_pending(tasks)}")
High priority tasks: [Done] (high) Finish lab report [Pending] (high) Review Chapter 27 notes Pending tasks: 3
When you print a plain object without any special setup, Python shows something like this:
<__main__.BankAccount object at 0x7f3c2a1b4d60>
This is not very helpful. It tells you the class name and a memory address, but nothing about the object's actual state. Python gives you a way to fix this: the __str__ method. When Python needs to convert an object to a string — for printing or for use in an f-string — it calls __str__ if you have defined one.
class BankAccount:
def __init__(self, account_holder, starting_balance):
self.holder = account_holder
self.balance = starting_balance
self.transaction_count = 0
def deposit(self, amount):
if amount > 0:
self.balance = self.balance + amount
self.transaction_count = self.transaction_count + 1
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance = self.balance - amount
self.transaction_count = self.transaction_count + 1
def __str__(self):
return f"BankAccount({self.holder}, ${self.balance:.2f})"
account = BankAccount("Kenji Watanabe", 500.0)
account.deposit(100.0)
print(account)
print(f"Account details: {account}")
BankAccount(Kenji Watanabe, $600.00) Account details: BankAccount(Kenji Watanabe, $600.00)
__str__ is one of Python's special methods, also called dunder methods because of the double underscores on each side of the name. You have already used __init__. __str__ is the most immediately useful beginner one after it.
When you have a list of objects, printing the list without __str__ gives you a list of memory addresses. With __str__, you get readable information:
accounts = [
BankAccount("Kenji Watanabe", 500.0),
BankAccount("Amara Diallo", 1200.0),
BankAccount("Priya Nair", 75.0),
]
for account in accounts:
print(account)
BankAccount(Kenji Watanabe, $500.00) BankAccount(Amara Diallo, $1200.00) BankAccount(Priya Nair, $75.00)
At this stage, you have two tools for grouping related data: dictionaries and objects. Understanding when to use each one will improve your program designs.
Consider storing information about a product. Here is the dictionary approach:
product = {
"name": "Spiral Notebook",
"price": 3.49,
"quantity": 50
}
print(product["name"])
product["quantity"] = product["quantity"] - 1
def is_low_stock(p):
return p["quantity"] < 10
print(is_low_stock(product))
Here is the class approach:
class Product:
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
def sell_one(self):
if self.quantity > 0:
self.quantity = self.quantity - 1
else:
print(f"Cannot sell {self.name}: out of stock.")
def restock(self, amount):
self.quantity = self.quantity + amount
def is_low_stock(self):
return self.quantity < 10
def __str__(self):
return f"Product({self.name}, ${self.price:.2f}, qty={self.quantity})"
product = Product("Spiral Notebook", 3.49, 50)
product.sell_one()
print(product)
print(product.is_low_stock())
Product(Spiral Notebook, $3.49, qty=49) False
account.deposit(100) instead of update_account(account, "deposit", 100).If you find yourself writing many functions that all take a dictionary as their first argument and do something to it, that is a sign a class might be cleaner. The dictionary plus its functions is already acting like an object — you just have not given it a class yet. Neither approach is always right. Real programs often use both. A class might hold a dictionary inside it as an attribute. A function might return a list of objects.
Writing a class is not only about knowing the syntax. It is about designing something that will be easy to use correctly and hard to use incorrectly. Here are guidelines for designing clear object behavior.
BankAccount should manage its own balance. It should not also manage customer addresses, send emails, or parse CSV files. Keep each class focused on one thing. This is the single responsibility principle applied to objects.__init__ and cannot see all the attributes the object will have, the class is harder to understand and debug. Every attribute the object needs should be visible in one place.# Confusing: attributes appear unpredictably
class Task:
def __init__(self, description):
self.description = description
def set_priority(self, p):
self.priority = p # appears here for the first time — hard to find
# Clear: all attributes declared upfront
class Task:
def __init__(self, description):
self.description = description
self.completed = False
self.priority = "medium"
Here is a class built from scratch, following all the principles from this chapter.
Goal: Represent a single item in a store inventory.
__str__class InventoryItem:
"""Represents a single product in a store inventory."""
def __init__(self, name, unit_price, quantity):
self.name = name
self.unit_price = unit_price
self.quantity = quantity
def restock(self, amount):
"""Add units to inventory."""
if amount <= 0:
print("Restock amount must be positive.")
else:
self.quantity = self.quantity + amount
print(f"Restocked {self.name}. New quantity: {self.quantity}")
def sell(self, units):
"""Remove units from inventory if available."""
if units <= 0:
print("Sale quantity must be positive.")
elif units > self.quantity:
print(f"Not enough {self.name} in stock. Available: {self.quantity}")
else:
self.quantity = self.quantity - units
total = units * self.unit_price
print(f"Sold {units} x {self.name} for ${total:.2f}.")
def apply_discount(self, percent):
"""Reduce unit price by a percentage (0 to 100)."""
if percent <= 0 or percent >= 100:
print("Discount must be between 0 and 100 percent.")
else:
reduction = self.unit_price * (percent / 100)
self.unit_price = self.unit_price - reduction
print(f"New price of {self.name}: ${self.unit_price:.2f}")
def is_low_stock(self, threshold=10):
"""Return True if quantity is below the threshold."""
return self.quantity < threshold
def total_value(self):
"""Return the total value of all units in stock."""
return self.quantity * self.unit_price
def __str__(self):
return (f"InventoryItem({self.name}, "
f"${self.unit_price:.2f}/unit, "
f"qty={self.quantity})")
item = InventoryItem("USB Cable", 8.99, 25)
print(item)
item.sell(5)
item.sell(30)
item.restock(20)
item.apply_discount(10)
print(item)
print(f"Total stock value: ${item.total_value():.2f}")
print(f"Low stock? {item.is_low_stock()}")
InventoryItem(USB Cable, $8.99/unit, qty=25) Sold 5 x USB Cable for $44.95. Not enough USB Cable in stock. Available: 20 Restocked USB Cable. New quantity: 40 New price of USB Cable: $8.09 InventoryItem(USB Cable, $8.09/unit, qty=40) Total stock value: $323.60 Low stock? False
| Step | Action | unit_price | quantity |
|---|---|---|---|
| After __init__ | Created | 8.99 | 25 |
| After sell(5) | −5 units sold | 8.99 | 20 |
| After failed sell(30) | No change — insufficient stock | 8.99 | 20 |
| After restock(20) | +20 units | 8.99 | 40 |
| After apply_discount(10) | Price reduced 10% | 8.09 | 40 |
Every state transition is predictable, because every method either validates before acting or reports the problem clearly. The failed sell(30) left the object completely unchanged. The caller knows what happened from the printed message. The object's integrity was never compromised.
__init__ so the object's structure is visible in one place. You can read and set attributes directly, but prefer methods when changes have rules attached.Classroom holding Student objects is a typical example. Each class cooperates without needing to know the full internal logic of the other.__str__ method defines what an object looks like when printed. Writing a good __str__ costs little effort and makes programs much easier to read, test, and debug, especially when working with lists of objects.__init__, use methods to change state, always write __str__, keep methods small and focused, and test each method independently before composing objects together.self.attribute_name inside the class, carrying a separate value for each instance.print() and f-strings.__init__ and __str__. Short for "double underscore."Read each code block carefully and answer the questions without running the code.
class Counter:
def __init__(self, label):
self.label = label
self.count = 0
def increment(self):
self.count = self.count + 1
def reset(self):
self.count = 0
def value(self):
return self.count
def __str__(self):
return f"{self.label}: {self.count}"
c = Counter("Clicks")
c.increment()
c.increment()
c.increment()
c.reset()
c.increment()
print(c)
Counter?c.count after the three increment() calls?reset() do to the state?print(c) display?class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def scale(self, factor):
self.width = self.width * factor
self.height = self.height * factor
def __str__(self):
return f"Rectangle({self.width} x {self.height})"
r = Rectangle(4, 6)
print(r.area())
r.scale(2)
print(r.perimeter())
print(r)
r.area() return before scale is called?r.scale(2), what are the new values of r.width and r.height?r.perimeter() return after scaling?print(r) display?class Jar:
def __init__(self, capacity):
self.capacity = capacity
self.contents = 0
def fill(self, amount):
space = self.capacity - self.contents
added = min(amount, space)
self.contents = self.contents + added
def empty(self):
self.contents = 0
def is_full(self):
return self.contents == self.capacity
def __str__(self):
return f"Jar({self.contents}/{self.capacity})"
j = Jar(10)
j.fill(7)
j.fill(5)
print(j)
print(j.is_full())
j.empty()
print(j)
j.fill(7), what is j.contents?j.fill(5) is called, only 3 units can actually be added. Explain why.print(j) display?print(j) display after j.empty()?Trace through the following code step by step. For each method call, write the values of all instance attributes after the call completes.
class ScoreTracker:
def __init__(self, player_name):
self.player = player_name
self.score = 0
self.high_score = 0
def add_points(self, points):
if points > 0:
self.score = self.score + points
if self.score > self.high_score:
self.high_score = self.score
def lose_points(self, points):
if points > 0:
self.score = self.score - points
if self.score < 0:
self.score = 0
def reset(self):
self.score = 0
def __str__(self):
return f"{self.player} | Score: {self.score} | Best: {self.high_score}"
tracker = ScoreTracker("Maya")
tracker.add_points(50)
tracker.add_points(30)
tracker.lose_points(20)
tracker.add_points(60)
tracker.lose_points(200)
tracker.add_points(15)
print(tracker)
Fill in this tracing table, then write the final output:
| After step | score | high_score |
|---|---|---|
| __init__ | ||
| add_points(50) | ||
| add_points(30) | ||
| lose_points(20) | ||
| add_points(60) | ||
| lose_points(200) | ||
| add_points(15) |
class LightSwitch:
def __init__(self):
self.on = False
self.times_switched = 0
def toggle(self):
self.on = not self.on
self.times_switched = self.times_switched + 1
def turn_on(self):
if not self.on:
self.toggle()
def turn_off(self):
if self.on:
self.toggle()
def __str__(self):
state = "ON" if self.on else "OFF"
return f"Switch is {state}, toggled {self.times_switched} times"
switch = LightSwitch()
switch.turn_on()
switch.turn_on()
switch.toggle()
switch.turn_off()
switch.toggle()
print(switch)
switch.on and switch.times_switched after each call.print(switch)?turn_on() not increment times_switched?class Wallet:
def __init__(self, owner):
self.owner = owner
self.cash = 0.0
def add_cash(self, amount):
self.cash = self.cash + amount
def spend(self, amount):
if amount > self.cash:
print("Not enough cash.")
else:
self.cash = self.cash - amount
def __str__(self):
return f"{self.owner}'s wallet: ${self.cash:.2f}"
w = Wallet("Jordan")
w.add_cash(20.0)
w.add_cash(15.0)
w.spend(8.50)
w.spend(30.0)
print(w)
w.cash after all calls complete?class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
self.tricks = []
def learn(self, trick):
self.tricks.append(trick)
def show_off(self):
if len(self.tricks) == 0:
print(f"{self.name} doesn't know any tricks yet.")
else:
print(f"{self.name} knows: {', '.join(self.tricks)}")
def __str__(self):
return f"Dog({self.name}, {self.breed})"
d1 = Dog("Rex", "Labrador")
d2 = Dog("Mochi", "Shiba Inu")
d1.learn("sit")
d1.learn("shake")
d2.learn("spin")
d1.show_off()
d2.show_off()
print(d1)
print(d2)
d1 and d2 share the same tricks list? Explain why or why not.class Temperature:
def __init__(celsius):
self.celsius = celsius
def to_fahrenheit():
return self.celsius * 9/5 + 32
def raise_by(self, degrees):
celsius = celsius + degrees
def __str__(self):
return f"{celsius}°C"
temp = Temperature(20)
temp.raise_by(5)
print(temp.to_fahrenheit())
print(temp)
Check every method signature. Check every use of self. Check every reference to an attribute — is it using self.attribute_name or just a plain name?
class SavingsGoal:
def __init__(self, goal_name, target_amount):
self.name = goal_name
self.target = target_amount
self.saved = 0.0
def deposit(self, amount):
self.saved = self.saved + amount
def is_reached(self):
return self.saved > self.target # Is this correct?
def remaining(self):
return self.target - self.saved
def __str__(self):
return (f"Goal: {self.name} | "
f"Saved: ${self.saved:.2f} / ${self.target:.2f}")
goal = SavingsGoal("New Laptop", 800.0)
goal.deposit(800.0)
print(goal.is_reached()) # Should print True
is_reached.is_reached.class GradeBook:
def __init__(self, student_name):
self.name = student_name
self.scores = []
self.total = 0
def add_score(self, score):
self.scores.append(score)
self.total = self.total + score
def average(self):
return self.total / len(self.scores)
def highest(self):
return max(self.scores)
def __str__(self):
return f"{self.name}: avg={self.average():.1f}"
gb = GradeBook("Yuna Park")
print(gb.average()) # This line will cause a problem
gb.average() is called on an empty gradebook? Name the error type.average() that handles the empty case gracefully.highest() have the same problem? Explain.Timer class with a name and an elapsed time starting at zero. Methods: tick(seconds) — add seconds (reject negatives); reset() — set elapsed back to zero; elapsed() — return current elapsed time; __str__ — display name and elapsed time. Test it by creating a timer, advancing it several times, displaying it, and resetting it.Playlist class with a name and a list of song titles. Methods: add_song(title); remove_song(title) — remove if found, print a message if not; song_count(); display() — print all songs numbered from 1; __str__ — show name and song count. Add four songs, remove one that exists and one that does not, then display the result.VendingMachine class that holds a list of items (each a dictionary with "name", "price", and "quantity"), and a total of money inserted. Methods: insert_money(amount); select_item(name) — dispense if available and funds are sufficient, otherwise print a clear message; display_items(); __str__ — show total money inserted.find_most_expensive(items) that takes a list of InventoryItem objects (from the worked example) and returns the one with the highest unit price. If the list is empty, return None. Test it with at least five items.In Chapter 18, you built a contact book using dictionaries. This project rebuilds it using a class-based design. The goal is not just to get the same result — it is to see how classes make the design clearer and safer.
A Contact should hold a name, a phone number, an email address, and a category (such as "friend", "work", or "family").
update_phone(new_number) and update_email(new_email) methods.matches(search_term) that returns True if the search term appears anywhere in the name, phone, or email (case-insensitive).__str__ method that displays all four fields neatly.A ContactBook should hold a name (for example, "Personal Contacts") and a list of Contact objects.
add_contact(contact), remove_contact(name), count(), and display_all().search(term) that returns a list of all contacts where matches(term) is True.list_by_category(category) that returns all contacts in that category.__str__ that shows the book name and contact count.ContactBook and add at least six contacts with varied names, numbers, emails, and categories.Contact class rather than a dictionary for each contact?Contact, in ContactBook, or in both? Why?__str__ make this program easier to test and debug compared to the dictionary version?These questions draw on skills from Chapters 11 through 26.
BankAccount objects, returning accounts whose balance is above a given amount. Explain how this pattern is identical to filtering a list of numbers.transfer(source, destination, amount) that takes two BankAccount objects and an amount, withdraws from the source, and deposits into the destination. What should the function do if the source does not have enough funds? Show the complete code and an example of using it.Task objects. Write a function that returns three values: the count of completed tasks, the count of pending tasks, and the number of high-priority pending tasks. Show how you would call it and print all three results.Student object has a grade attribute. You have a list of students. Without using any built-in sorting function, write a simple loop that finds the student with the highest grade and the student with the lowest grade. Print both students' names and grades.print(account) and print(account.get_balance()) when account is a BankAccount object. When would you use each one? Under what circumstances might print(account) fail to show useful information?__str__ method does and why it is useful.__init__, read-only methods, mutating methods with validation, and __str__.self is missing from method signatures or attribute accesses.__init__, not scattered through other methods.Chapter 28 expands from individual classes to complete small applications. You will plan a program from requirements, design its data structures and flow, combine functions and classes, add persistence by saving data to files, and think about testing the whole program — not just individual methods. The skills you practiced in Chapters 26 and 27 are the foundation. Chapter 28 shows how to assemble them into something a real user could open and run.