Lab 7 Solutions
Solution Files
Required Questions
Getting Started Videos
These videos may provide some helpful direction for tackling the coding problems on this assignment.
To see these videos, you should be logged into your berkeley.edu email.
Midsemester Survey
Q1: Mid-Semester Feedback
As part of this assignment, fill out the Mid-Semester Feedback form.
Confidentiality: Your responses to the survey are confidential, and only the instructors will be able to see this data unanonymized. More specifics on confidentiality can be found on the survey itself.
Once you finish the survey, you will be presented with a passphrase (if you miss
it, it should also be at the bottom of the confirmation email you receive). Put
this passphrase, as a string, on the line that says
passphrase = 'REPLACE_THIS_WITH_PASSPHRASE'
in the Python file for this assignment.
E.g. if the passphrase is abc
, then the line should be passphrase = 'abc'
.
Use Ok to test your code:
python3 ok -q midsem_survey
Inheritance
Consult the drop-down if you need a refresher on Inheritance. It's okay to skip directly to the questions and refer back here should you get stuck.
To avoid redefining attributes and methods for similar classes, we can write a
single base class from which more specialized classes inherit. For
example, we can write a class called Pet
and define Dog
as a subclass of
Pet
:
class Pet:
def __init__(self, name, owner):
self.is_alive = True # It's alive!!!
self.name = name
self.owner = owner
def eat(self, thing):
print(self.name + " ate a " + str(thing) + "!")
def talk(self):
print(self.name)
class Dog(Pet):
def talk(self):
super().talk()
print('This Dog says woof!')
Inheritance represents a hierarchical relationship between two or more
classes where one class is a more specific version of the other:
a dog is a pet
(We use is a to describe this sort of relationship in OOP languages,
and not to refer to the Python is
operator).
Since Dog
inherits from Pet
, the Dog
class will also inherit the
Pet
class's methods, so we don't have to redefine __init__
or eat
.
We do want each Dog
to talk
in a Dog
-specific way,
so we can override the talk
method.
We can use super()
to refer to the superclass of self
,
and access any superclass methods as if we were an instance of the superclass.
For example, super().talk()
in the Dog
class will call the talk
method from the Pet
class, but passing the Dog
instance as the self
.
Q2: WWPD: Inheritance ABCs
Important: For all WWPD questions, type
Function
if you believe the answer is<function...>
,Error
if it errors, andNothing
if nothing is displayed.Use Ok to test your knowledge with the following "What Would Python Display?" questions:
python3 ok -q inheritance-abc -u
>>> class A:
... x, y = 0, 0
... def __init__(self):
... return
>>> class B(A):
... def __init__(self):
... return
>>> class C(A):
... def __init__(self):
... return
>>> print(A.x, B.x, C.x)
____________
>>> B.x = 2
>>> print(A.x, B.x, C.x)
____________
>>> A.x += 1
>>> print(A.x, B.x, C.x)
____________
>>> obj = C()
>>> obj.y = 1
>>> C.y == obj.y
____________
>>> A.y = obj.y
>>> print(A.y, B.y, C.y, obj.y)
____________
Class Practice
Let's say we'd like to model a bank account that can handle interactions
such as depositing funds or gaining interest on current funds.
In the following questions, we will be building off of the Account
class.
Here's our current definition of the class:
class Account:
"""An account has a balance and a holder.
>>> a = Account('John')
>>> a.deposit(10)
10
>>> a.balance
10
>>> a.interest
0.02
>>> a.time_to_retire(10.25) # 10 -> 10.2 -> 10.404
2
>>> a.balance # Calling time_to_retire method should not change the balance
10
>>> a.time_to_retire(11) # 10 -> 10.2 -> ... -> 11.040808032
5
>>> a.time_to_retire(100)
117
"""
max_withdrawal = 10
interest = 0.02
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holder
def deposit(self, amount):
self.balance = self.balance + amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
return "Insufficient funds"
if amount > self.max_withdrawal:
return "Can't withdraw that amount"
self.balance = self.balance - amount
return self.balance
Q3: Retirement
Add a time_to_retire
method to the Account
class.
This method takes in an amount
and returns how many years the holder would
need to wait in order for the current balance
to grow to at least amount
,
assuming that the bank adds the interest (calculated as the current balance
multiplied by the interest
rate) to the balance
at the end of each year.
Note: Calling the
time_to_retire
method should not change the account balance.
def time_to_retire(self, amount):
"""Return the number of years until balance would grow to amount."""
assert self.balance > 0 and amount > 0 and self.interest > 0
future = self.balance
years = 0
while future < amount:
future += self.interest * future
years += 1
return years
Use Ok to test your code:
python3 ok -q Account
We take of our current balance, and simulate the growth from interest over many years. We stop once we hit the target value.
Note that the problem solving procedure does not differ very much from an non OOP problem. The main difference here is make sure that we do not change the account balance while in the process of calculating the future balance. Therefore, something along these lines is necessary:
future = self.balance
Video walkthrough:
Q4: FreeChecking
Implement the FreeChecking
class, which is like the Account
class
except that it charges a withdraw fee withdraw_fee
after
withdrawing free_withdrawals
number of times.
If a withdrawal is unsuccessful, it still counts towards the number of free
withdrawals remaining, but no fee for the withdrawal will be charged.
class FreeChecking(Account):
"""A bank account that charges for withdrawals, but the first two are free!
>>> ch = FreeChecking('Jack')
>>> ch.balance = 20
>>> ch.withdraw(100) # First one's free. Still counts as a free withdrawal even though it was unsuccessful
'Insufficient funds'
>>> ch.withdraw(3) # Second withdrawal is also free
17
>>> ch.balance
17
>>> ch.withdraw(3) # Ok, two free withdrawals is enough, as free_withdrawals is only 2
13
>>> ch.withdraw(3)
9
>>> ch2 = FreeChecking('John')
>>> ch2.balance = 10
>>> ch2.withdraw(3) # No fee
7
>>> ch.withdraw(3) # ch still charges a fee
5
>>> ch.withdraw(5) # Not enough to cover fee + withdraw
'Insufficient funds'
"""
withdraw_fee = 1
free_withdrawals = 2
def __init__(self, account_holder):
super().__init__(account_holder)
self.withdrawals = 0
def withdraw(self, amount):
self.withdrawals += 1
fee = 0
if self.withdrawals > self.free_withdrawals:
fee = self.withdraw_fee
return super().withdraw(amount + fee)
# Alternative solution where you don't need to include init.
# Check out the video solution for more.
def withdraw(self, amount):
self.free_withdrawals -= 1
if self.free_withdrawals >= 0:
return super().withdraw(amount)
return super().withdraw(amount + self.withdraw_fee)
Use Ok to test your code:
python3 ok -q FreeChecking
We can take advantage of inheritance to make sure we add just what we need
to withdraw
.
- For starters, a withdrawal with a fee is the same as the original
withdraw amount plus the amount from the fee. We can therefore represent
a
FreeChecking
withdraw as a "regular"Account
withdraw in this way. - On top of the note from before, we need to do a little bit of extra bookkeeping to make sure the first few withdrawals do not add the extra fee. We can either create a new instance attribute or modify an existing one.
Video walkthrough:
Object-Oriented Programming
Here's a refresher on Object-Oriented Programming. It's okay to skip directly to the questions and refer back here if you get stuck.
Object-oriented programming (OOP) uses objects and classes to organize programs. Here's an example of a class:
class Car:
max_tires = 4
def __init__(self, color):
self.tires = Car.max_tires
self.color = color
def drive(self):
if self.tires < Car.max_tires:
return self.color + ' car cannot drive!'
return self.color + ' car goes vroom!'
def pop_tire(self):
if self.tires > 0:
self.tires -= 1
Class: The type of an object. The Car
class (shown above) describes the characteristics of all Car
objects.
Object: A single instance of a class. In Python, a new object is created by calling a class.
>>> ferrari = Car('red')
Here, ferrari
is a name bound to a Car
object.
Class attribute: A variable that belongs to a class and is accessed via dot notation. The Car
class has a max_tires
attribute.
>>> Car.max_tires
4
Instance attribute: A variable that belongs to a particular object. Each Car
object has a tires
attribute and a color
attribute. Like class attributes, instance attributes are accessed via dot notation.
>>> ferrari.color
'red'
>>> ferrari.tires
4
>>> ferrari.color = 'green'
>>> ferrari.color
'green'
Method: A function that belongs to an object and is called via dot notation. By convention, the first parameter of a method is self
.
When one of an object's methods is called, the object is implicitly provided as the argument for self
. For example, the drive
method of the ferrari
object is called with empty parentheses because self
is implicitly bound to the ferrari
object.
>>> ferrari = Car('red')
>>> ferrari.drive()
'red car goes vroom!'
We can also call the original Car.drive
function. The original function does not belong to any particular Car
object, so we must provide an explicit argument for self
.
>>> ferrari = Car('red')
>>> Car.drive(ferrari)
'red car goes vroom!'
__init__: A special function that is called automatically when a new instance of a class is created.
Notice how the drive
method takes in self
as an argument, but it
looks like we didn't pass one in! This is because the dot notation
implicitly passes in ferrari
as self
for us. So in this example, self
is bound to the
object called ferrari
in the global frame.
To evaluate the expression Car('red')
, Python creates a new Car
object. Then, Python calls the __init__
function of the Car
class with self
bound to the new object and color
bound to 'red'
.
Q5: Bank Account
Extend the BankAccount
class to include a transactions
attribute. This attribute should be a list that keeps track of each transaction made on the account. Whenever the deposit
or withdraw
method is called, a new Transaction
instance should be created and added to the list, regardless of whether that action is successful.
The Transaction
class should have the following attributes:
before
: The account balance before the transaction.after
: The account balance after the transaction.id
: The transaction ID, which is the number of previous transactions (deposits or withdrawals) made on that account. Thisid
should be unique within the scope of each account, but not necessarily unique across all accounts.
In addition, the Transaction
class should have the following methods:
changed()
: ReturnsTrue
if the balance changed (i.e.,before
is different fromafter
), otherwise returnsFalse
.report()
: Returns a string describing the transaction. The string should start with the transaction ID and describe the change in balance. Take a look at the doctests for the expected output.
NOTE. The
BankAccount
class is the same as the originalAccount
class, meaning that it is exactly like theAccount
class seen under the Class Practice section excluding the changes made in Question #2 (Retirement) and #3 (FreeChecking). We just had to have a different class name for grading purposes.
class Transaction:
def __init__(self, id, before, after):
self.id = id
self.before = before
self.after = after
def changed(self):
"""Return whether the transaction resulted in a changed balance."""
return self.before != self.after
def report(self):
"""Return a string describing the transaction.
>>> Transaction(3, 20, 10).report()
'3: decreased 20->10'
>>> Transaction(4, 20, 50).report()
'4: increased 20->50'
>>> Transaction(5, 50, 50).report()
'5: no change'
"""
msg = 'no change'
if self.changed():
if self.after < self.before:
verb = 'decreased'
else:
verb = 'increased'
msg = verb + ' ' + str(self.before) + '->' + str(self.after) return str(self.id) + ': ' + msg
class BankAccount:
"""A bank account that tracks its transaction history.
>>> a = BankAccount('Eric')
>>> a.deposit(100) # Transaction 0 for a
100
>>> b = BankAccount('Erica')
>>> a.withdraw(30) # Transaction 1 for a
70
>>> a.deposit(10) # Transaction 2 for a
80
>>> b.deposit(50) # Transaction 0 for b
50
>>> b.withdraw(10) # Transaction 1 for b
40
>>> a.withdraw(100) # Transaction 3 for a
'Insufficient funds'
>>> len(a.transactions)
4
>>> len([t for t in a.transactions if t.changed()])
3
>>> for t in a.transactions:
... print(t.report())
0: increased 0->100
1: decreased 100->70
2: increased 70->80
3: no change
>>> b.withdraw(100) # Transaction 2 for b
'Insufficient funds'
>>> b.withdraw(30) # Transaction 3 for b
10
>>> for t in b.transactions:
... print(t.report())
0: increased 0->50
1: decreased 50->40
2: no change
3: decreased 40->10
"""
# *** YOU NEED TO MAKE CHANGES IN SEVERAL PLACES IN THIS CLASS ***
def next_id(self):
# There are many ways to implement this counter, such as using an instance
# attribute to track the next ID.
return len(self.transactions)
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holder
self.transactions = []
def deposit(self, amount):
"""Increase the account balance by amount, add the deposit
to the transaction history, and return the new balance.
"""
self.transactions.append(Transaction(self.next_id(), self.balance, self.balance + amount)) self.balance = self.balance + amount
return self.balance
def withdraw(self, amount):
"""Decrease the account balance by amount, add the withdraw
to the transaction history, and return the new balance.
"""
if amount > self.balance:
self.transactions.append(Transaction(self.next_id(), self.balance, self.balance)) return 'Insufficient funds'
self.transactions.append(Transaction(self.next_id(), self.balance, self.balance - amount)) self.balance = self.balance - amount
return self.balance
Use Ok to test your code:
python3 ok -q BankAccount
Q6: Email
An email system has three classes: Email
, Server
, and Client
. A Client
can
compose
an email, which it will send
to the Server
. The Server
then delivers it to the
inbox
of another Client
. To achieve this, a Server
has a dictionary called
clients
that maps the name of the Client
to the Client
instance.
Assume that a client never changes the server that it uses, and it only composes emails using that server.
Fill in the definitions below to finish the implementation! The Email
class
has been completed for you.
Important: Before you start, make sure you read the entire code snippet to understand the relationships between the classes, and pay attention to the parameter type of the methods. Think about what varible do you have access to in each method and how can you use that to access the other classes and their methods.
Note: The
sender
parameter from the__init__(self, msg, sender, recipient_name)
method in theClient
instance.Note: The
client
parameter from theregister_client(self, client)
method in theServer
class is aClient
instance.Note: The
send(self, email)
method in theServer
class is an
class Email:
"""An email has the following instance attributes:
msg (str): the contents of the message
sender (Client): the client that sent the email
recipient_name (str): the name of the recipient (another client)
"""
def __init__(self, msg, sender, recipient_name):
self.msg = msg
self.sender = sender
self.recipient_name = recipient_name
class Server:
"""Each Server has one instance attribute called clients that is a
dictionary from client names to client objects.
"""
def __init__(self):
self.clients = {}
def send(self, email):
"""Append the email to the inbox of the client it is addressed to.
email is an instance of the Email class.
"""
self.clients[email.recipient_name].inbox.append(email)
def register_client(self, client):
"""Add a client to the clients mapping (which is a
dictionary from client names to client instances).
client is an instance of the Client class.
"""
self.clients[client.name] = client
class Client:
"""A client has a server, a name (str), and an inbox (list).
>>> s = Server()
>>> a = Client(s, 'Alice')
>>> b = Client(s, 'Bob')
>>> a.compose('Hello, World!', 'Bob')
>>> b.inbox[0].msg
'Hello, World!'
>>> a.compose('CS 61A Rocks!', 'Bob')
>>> len(b.inbox)
2
>>> b.inbox[1].msg
'CS 61A Rocks!'
>>> b.inbox[1].sender.name
'Alice'
"""
def __init__(self, server, name):
self.inbox = []
self.server = server
self.name = name
server.register_client(self)
def compose(self, message, recipient_name):
"""Send an email with the given message to the recipient."""
email = Email(message, self, recipient_name) self.server.send(email)
Use Ok to test your code:
python3 ok -q Client
Check Your Score Locally
You can locally check your score on each question of this assignment by running
python3 ok --score
This does NOT submit the assignment! When you are satisfied with your score, submit the assignment to Gradescope to receive credit for it.
Submit Assignment
Submit this assignment by uploading any files you've edited to the appropriate Gradescope assignment. Lab 00 has detailed instructions.
In addition, all students who are not in the mega lab must submit the attendance form for credit. Ask your section TA for the link! Submit this form for each section, whether you attended lab or missed it for a good reason. The attendance form is not required for mega section students.
Optional Questions
Q7: Make Change
Implement make_change
, which takes a positive integer amount
and a
dictionary of coins
. The coins
dictionary keys are positive integer
denominations and its values are positive integer coin counts. For example,
{1: 4, 5: 2}
represents four pennies and two nickels. The make_change
function returns a list of coins that sum to amount
, where the count of
any denomination k
in the return value is at most coins[k]
.
If there are multiple ways to make change for amount
, prefer to use as many
of the smallest coins available and place the smallest coins first in the
returned list.
def make_change(amount, coins):
"""Return a list of coins that sum to amount, preferring the smallest coins
available and placing the smallest coins first in the returned list.
The coins argument is a dictionary with keys that are positive integer
denominations and values that are positive integer coin counts.
>>> make_change(2, {2: 1})
[2]
>>> make_change(2, {1: 2, 2: 1})
[1, 1]
>>> make_change(4, {1: 2, 2: 1})
[1, 1, 2]
>>> make_change(4, {2: 1}) == None
True
>>> coins = {2: 2, 3: 2, 4: 3, 5: 1}
>>> make_change(4, coins)
[2, 2]
>>> make_change(8, coins)
[2, 2, 4]
>>> make_change(25, coins)
[2, 3, 3, 4, 4, 4, 5]
>>> coins[8] = 1
>>> make_change(25, coins)
[2, 2, 4, 4, 5, 8]
"""
if not coins:
return None
smallest = min(coins)
rest = remove_one(coins, smallest)
if amount < smallest:
return None
elif amount == smallest:
return [smallest]
else:
result = make_change(amount-smallest, rest)
if result:
return [smallest] + result
else:
return make_change(amount, rest)
You should use the remove_one
function in your implementation:
def remove_one(coins, coin):
"""Remove one coin from a dictionary of coins. Return a new dictionary,
leaving the original dictionary coins unchanged.
>>> coins = {2: 5, 3: 2, 6: 1}
>>> remove_one(coins, 2) == {2: 4, 3: 2, 6: 1}
True
>>> remove_one(coins, 6) == {2: 5, 3: 2}
True
>>> coins == {2: 5, 3: 2, 6: 1} # Unchanged
True
"""
copy = dict(coins)
count = copy.pop(coin) - 1 # The coin denomination is removed
if count:
copy[coin] = count # The coin denomination is added back
return copy
Hint: Try using the smallest coin to make change. If it turns out that there is no way to make change using the smallest coin, then try making change without the smallest coin.
Hint: The simplest solution does not involve defining any local functions, but you can define additional functions if you wish.
Definitely try to solve this without reading the walkthrough, but if you're really stuck then read the walkthrough.
make_change(amount, coins)
should do the following:
- Check if
amount == smallest
, in which case return a one-element list containing justsmallest
. - Otherwise, call
make_change(amount-smallest, rest)
, which returns eitherNone
or a list of numbers. - If the call in Step 2 returned a list, then return a longer list that includes
smallest
at the front. - If the call in Step 2 returned
None
, then there's no way to use thesmallest
coin, so justmake_change(amount, rest)
Use Ok to test your code:
python3 ok -q make_change
Q8: Change Machine
Complete the change
method of the ChangeMachine
class. A ChangeMachine
instance holds some coins
, which are initially all pennies. The change
method takes a positive integer coin
, adds that coin to its coins
, and then
returns a list that sums to coin
. The machine prefers to return as many of
the smallest coins available, ordered from smallest to largest. The coins
returned by change
are removed from the machine's coins
.
class ChangeMachine:
"""A change machine holds a certain number of coins, initially all pennies.
The change method adds a single coin of some denomination X and returns a
list of coins that sums to X. The machine prefers to return the smallest
coins available. The total value in the machine never changes, and it can
always make change for any coin (perhaps by returning the coin passed in).
The coins attribute is a dictionary with keys that are positive integer
denominations and values that are positive integer coin counts.
>>> m = ChangeMachine(2)
>>> m.coins == {1: 2}
True
>>> m.change(2)
[1, 1]
>>> m.coins == {2: 1}
True
>>> m.change(2)
[2]
>>> m.coins == {2: 1}
True
>>> m.change(3)
[3]
>>> m.coins == {2: 1}
True
>>> m = ChangeMachine(10) # 10 pennies
>>> m.coins == {1: 10}
True
>>> m.change(5) # takes a nickel & returns 5 pennies
[1, 1, 1, 1, 1]
>>> m.coins == {1: 5, 5: 1} # 5 pennies & a nickel remain
True
>>> m.change(3)
[1, 1, 1]
>>> m.coins == {1: 2, 3: 1, 5: 1}
True
>>> m.change(2)
[1, 1]
>>> m.change(2) # not enough 1's remaining; return a 2
[2]
>>> m.coins == {2: 1, 3: 1, 5: 1}
True
>>> m.change(8) # cannot use the 2 to make 8, so use 3 & 5
[3, 5]
>>> m.coins == {2: 1, 8: 1}
True
>>> m.change(1) # return the penny passed in (it's the smallest)
[1]
>>> m.change(9) # return the 9 passed in (no change possible)
[9]
>>> m.coins == {2: 1, 8: 1}
True
>>> m.change(10)
[2, 8]
>>> m.coins == {10: 1}
True
>>> m = ChangeMachine(9)
>>> [m.change(k) for k in [2, 2, 3]]
[[1, 1], [1, 1], [1, 1, 1]]
>>> m.coins == {1: 2, 2: 2, 3: 1}
True
>>> m.change(5) # Prefers [1, 1, 3] to [1, 2, 2] (more pennies)
[1, 1, 3]
>>> m.change(7)
[2, 5]
>>> m.coins == {2: 1, 7: 1}
True
"""
def __init__(self, pennies):
self.coins = {1: pennies}
def change(self, coin):
"""Return change for coin, removing the result from self.coins."""
self.coins[coin] = 1 + self.coins.get(coin, 0) # Put the coin in the machine
result = make_change(coin, self.coins)
for c in result:
self.coins = remove_one(self.coins, c)
return result
Hint: Call the
make_change
function in order to compute the result ofchange
, but updateself.coins
before returning that result.
Definitely try to solve this without reading the walkthrough, but if you're really stuck then read the walkthrough.
change(self, coin)
should do the following:
- Add the
coin
to the machine. This way, you can just make change and you'll always get some result, although it might just be that coin back. The simplest way is toget
the count of thatcoin
(defaulting to 0) and add 1 to it:self.coins[coin] = 1 + self.coins.get(coin, 0)
- Call
make_change(coin, self.coins)
and assign the result to a name (such asresult
). You'll return this at the end. - Before returning, reduce the count of each coin you are returning. One way is to repeatedly call
remove_one(self.coins, c)
for each coinc
in the result of callingmake_change
in Step 2. - Return the result of calling
make_change
in Step 2.
Use Ok to test your code:
python3 ok -q ChangeMachine