Classes
Functions can be used for dictating some behavior. Lists, tuples, sets, dictionaries, and other data structures are useful for storing data. Classes are useful when you have data that should be coupled to specific behaviors.
Custom classes allow users of your class to be concerned with different matters from the implementers of your class.
Class Syntax
You can make classes in Python using a class statement. Everything inside a class must be indented.
>>> class AwesomeThing:
... def some_method(self):
... print("You just called a method!")
...
We just made a new class stored in the variable AwesomeThing which is of type type. That might seem a little odd:
>>> type(AwesomeThing)
<class 'type'>
But if we check the type of str and various other types, we’ll see that they look the same.
>>> type(str)
<class 'type'>
>>> type(int)
<class 'type'>
>>> type(dict)
<class 'type'>
So we have just created our very own “type”.
Classes are like blueprints or templates. They allow us to make a certain type of thing.
We’ve called our class AwesomeThing. We can create a variable of type AwesomeThing by using parentheses, just like we would call a function:
>>> some_class_instance = AwesomeThing()
This is often referred to as a constructor. The variable we have created is an instance of AwesomeThing. We can also use this syntax for creating instances of various other types of things we’ve learned about so far:
>>> str()
''
>>> int()
0
>>> list()
[]
>>> tuple()
()
>>> dict()
{}
>>> set()
set()
>>> AwesomeThing()
<AwesomeThing object at 0x...>
Functions inside a class are called methods. To call a method, we use a period to separate the class instance and the method name:
>>> some_class_instance.some_method()
You just called a method!
The period or dot . notation when used in this context is a separator that indicates that whatever follows it is part of (or related to) the item to the left of the period.
In this case it separates the class instance from the method we are calling.
This syntax tells Python that some_method is a method that belongs to the class of the some_class_instance object.
We’ve seen this notation before, used for a lot of other methods that are built-in to the data types we already know:
>>> numbers = []
>>> numbers.append(4)
>>> numbers
[4]
>>> "hello".upper()
'HELLO'
Initializing
So we haven’t seen what classes are actually good for yet. Let’s make a class that maintains some state for us.
Let’s say we’re making software for a bank and we need to keep track of how much money an account has.
>>> class BankAccount:
... def __init__(self):
... self.balance = 0
... def deposit(self, amount):
... self.balance += amount
... def withdraw(self, amount):
... self.balance -= amount
...
This __init__ method we’ve made is an initializer method. Python knows to call this method when it creates an instance of the class.
>>> trey_account = BankAccount()
>>> trey_account.balance
0
>>> trey_account.deposit(100)
>>> trey_account.balance
100
>>> trey_account.withdraw(40)
>>> trey_account.balance
60
In the __init__ method, we see the . notation again, this time used as a separator for instance attributes or variables, in this case the self.balance, making balance an attribute or variable that lives on each instance of BankAccount.
Notice that every method in our class starts with an argument called self. Our class instance will be passed in to this first argument. The argument name self is just a convention, but it’s a strong one.
Depending on your previous experience, you might feel more comfortable with a name that you are used to, and change self to this.
It’s kind of like “comfort food” for some programmers.
Fortunately, even though your co-workers might get annoyed, it all works exactly the same:
>>> class BankAccount:
... def __init__(this):
... this.balance = 0
... def deposit(this, amount):
... this.balance += amount
... def withdraw(this, amount):
... this.balance -= amount
...
>>> trey_account = BankAccount()
>>> trey_account.balance
0
>>> trey_account.deposit(100)
>>> trey_account.balance
100
>>> trey_account.withdraw(40)
>>> trey_account.balance
60
You can think of method calls as syntactic sugar. These two statements do exactly the same thing:
>>> trey_account.deposit(30)
>>> BankAccount.deposit(trey_account, 30)
We pretty much never call things that second way though.
Verbose Methods
Let’s add some print statements to make our code tell us what it’s doing while we use it.
First let’s move our code into a file so we don’t have to keep retyping it all in the shell every time. We’ll call our file bank_account.py.
class BankAccount:
def __init__(self):
self.balance = 0
print("Account opened.")
self.print_balance()
def deposit(self, amount):
self.balance += amount
print(f"${amount} deposited.")
self.print_balance()
def withdraw(self, amount):
self.balance -= amount
print(f"${amount} withdrawn.")
self.print_balance()
def print_balance(self):
print(f"Account balance is ${self.balance}.")
Let’s try using our new class:
>>> from bank_account import BankAccount
>>> account = BankAccount()
Account opened.
Account balance is $0.
>>> account.deposit(50)
$50 deposited.
Account balance is $50.
>>> account.deposit(75)
$75 deposited.
Account balance is $125.
>>> account.withdraw(30)
$30 withdrawn.
Account balance is $95.
Special Methods
Currently when we print an account out on the REPL, it doesn’t really do anything.
>>> account = BankAccount()
Account opened.
Account balance is $0.
>>> account
<BankAccount object at 0x...>
>>> print(account)
<BankAccount object at 0x...>
When we try to convert our account to a string, we’ll see the same thing:
>>> str(account)
'<BankAccount object at 0x...>'
Let’s customize this behavior by creating a __str__ method on our account object. __str__ is a special method recognized by Python and is used whenever Python needs a string representation of an object. Basically whenever str is called on an object (either directly or via print or str.format or something else), the __str__ method is called.
It’s as if the str built-in were implemented like this:
def str2(obj):
return obj.__str__()
>>> str2(4)
'4'
>>> x = 4
>>> x.__str__()
'4'
Python doesn’t know how to display our object because we defined it ourselves, so the default behavior is to display the basic object data. Let’s add our own __str__ method to override the default behavior of str:
class BankAccount:
def __init__(self):
self.balance = 0
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
def transfer(self, other_account, amount):
self.withdraw(amount)
other_account.deposit(amount)
def __str__(self):
return f"Account with balance of ${self.balance}"
Now when we try to use our account as a string, we’ll see something a little more useful:
>>> from bank_account import BankAccount
>>> account = BankAccount()
>>> str(account)
'Account with balance of $0'
>>> print(account)
Account with balance of $0
However, when we just output our account object at the REPL without converting it to a string, nothing has changed:
>>> account
<BankAccount object at 0x...>
We need to override the __repr__ method to customize this. This method should return a representation of our object that will be useful for developers.
Let’s make this method tell us how to create an account object equivalent to the current one. We don’t currently have a way for accounts to be opened with a balance. Let’s change that and then make a __repr__ method:
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
def transfer(self, other_account, amount):
self.withdraw(amount)
other_account.deposit(amount)
def __str__(self):
return f"Account with balance of ${self.balance}"
def __repr__(self):
return f"BankAccount(balance={self.balance})"
Now the developer-friendly representation of our account is more useful:
>>> from bank_account import BankAccount
>>> account = BankAccount()
>>> account.deposit(200)
>>> account
BankAccount(balance=200)
>>> new_account = BankAccount(balance=200)
>>> new_account
BankAccount(balance=200)
Typically the __repr__ method is used to make “developer-friendly” representations of a class, whereas the __str__ method makes “user-friendly” representations of the class info.
As we saw, if a class has a __str__ method, we still don’t see anything nice when we are using the REPL.
However, if a class has a __repr__ method, and no __str__ method, when we print it or use the str() function on an instance, Python will use the __repr__ method.
So it is always a good idea to make a __repr__ method - you can always add the __str__ method later.
The __init__, __str__, and __repr__ class methods are often called “special methods”, “magic methods”, or “dunder methods”. We will see more dunder methods in later parts of the class.
Class Exercises
Bank Account
This is the BankAccount exercise in classes.py.
To test it, run python test.py BankAccount from the exercises directory.
Make a BankAccount class that allows depositing and withdrawing money:
>>> from classes import BankAccount
>>> trey_account = BankAccount(20)
>>> trey_account.balance
20
>>> trey_account.deposit(100)
>>> trey_account.balance
120
>>> trey_account.withdraw(40)
>>> trey_account.balance
80
>>> trey_account
BankAccount(balance=80)
Your BankAccount class should also support transfers:
>>> mary_account = BankAccount(100)
>>> dana_account = BankAccount()
>>> mary_account.transfer(dana_account, 20)
>>> mary_account.balance
80
>>> dana_account.balance
20
Record Transactions
This is the BankAccount exercise in classes.py.
To test it, run python test.py BankAccount from the exercises directory.
Note
To test these changes, you need to modify the BankAccountTests class in classes_test.py to comment out the @unittest.skip lines with “Comment this line for transactions exercise.”.
Modify our class to record transactions for deposits and withdrawals.
A transactions attribute should be added to our object that maintains a list of all transactions in our account. Each transaction should be a tuple containing an action name (as a string), the amount the account changed, and the account balance after the change.
Example usage:
>>> from classes import BankAccount
>>> my_account = BankAccount(10)
>>> my_account.deposit(100)
>>> my_account.withdraw(40)
>>> my_account.deposit(95)
>>> my_account.transactions
[('OPEN', 10, 10), ('DEPOSIT', 100, 110), ('WITHDRAWAL', -40, 70), ('DEPOSIT', 95, 165)]
Month
This is the Month exercise in classes.py.
Edit the classes.py file in the exercises directory to implement this exercise.
To test it, run python test.py Month from the exercises directory.
Make a Month class that has year and month attributes.
This class should have two string representations and a first method that returns a datetime.date object representing the first day of the given month.
Example:
>>> from classes import Month
>>> python2_eol_month = Month(year=2020, month=1)
>>> python2_eol_month
Month(2020, 1)
>>> str(python2_eol_month)
'2020-01'
>>> print(python2_eol_month)
2020-01
>>> python2_eol_month.first()
datetime.date(2020, 1, 1)
Row
This is the Row exercise in classes.py.
Edit the classes.py file in the exercises directory to implement this exercise.
To test it, run python test.py Row from the exercises directory.
Make a Row class that accepts any keyword arguments given to it and stores these arguments as attributes.
>>> from classes import Row
>>> row = Row(a=1, b=2)
>>> row.a
1
>>> row.b
2
Ice Cream Flavor
This is the Flavor exercise in classes.py.
Edit the classes.py file in the exercises directory to implement this exercise.
To test it, run python test.py Flavor from the exercises directory.
Make a Flavor class that has name, ingredients, and has_dairy attributes.
The name attribute should be a required string.
The ingredients attribute should be an optional iterable.
The has_dairy attribute should be an optional boolean value (defaulting to True).
Instances of the Flavor class should also have a nice string representation.
It should work like this:
>>> from classes import Flavor
>>> vanilla = Flavor("vanilla")
>>> vanilla.name
'vanilla'
>>> vanilla.has_dairy
True
>>> vanilla.ingredients
[]
>>> vanilla
Flavor(name='vanilla', ingredients=[], has_dairy=True)
>>> mint_chip = Flavor(
... name="mint chip",
... ingredients=["sugar", "mint", "cashews", "coconut milk"],
... has_dairy=False,
... )
>>> mint_chip.ingredients
['sugar', 'mint', 'cashews', 'coconut milk']
>>> mint_chip.has_dairy
False
Ice Cream Size
This is the Size exercise in classes.py.
Edit the classes.py file in the exercises directory to implement this exercise.
To test it, run python test.py Size from the exercises directory.
Make a Size class that has quantity, unit, and price attributes.
Instances of the Size class should also have two string representations (as shown below).
It should work like this:
>>> from classes import Size
>>> one_quart = Size(quantity=1, unit="quart", price="$9")
>>> one_quart
Size(quantity=1, unit='quart', price='$9')
>>> print(one_quart)
1 quart
>>> two_scoops = Size(quantity=2, unit="scoop", price="$3")
>>> two_scoops
Size(quantity=2, unit='scoop', price='$3')
>>> print(two_scoops)
2 scoops
Ice Cream
This is the IceCream exercise in classes.py.
Edit the classes.py file in the exercises directory to implement this exercise.
To test it, run python test.py IceCream from the exercises directory.
Make an IceCream class that has flavor and size attributes.
It should work like this:
>>> from classes import IceCream
>>> one_quart = Size(quantity=1, unit="quart", price="$9")
>>> vanilla = Flavor("vanilla")
>>> quart_of_vanilla = IceCream(flavor=vanilla, size=one_quart)
>>> print(quart_of_vanilla)
1 quart of vanilla
>>> print(IceCream(flavor=vanilla, size=Size(quantity=2, unit="scoop", price="$3")))
2 scoops of vanilla
Email Server
This is the IMAPChecker exercise in classes.py.
The below code includes a get_connection function which returns a mail server object and three other functions that accept a mail server object as their first argument.
from email.parser import Parser
from imaplib import IMAP4_SSL
def get_connection(host, username, password):
"""Initialize IMAP server and login"""
server = IMAP4_SSL(host)
server.login(username, password)
server.select("inbox")
return server
def close_connection(server):
server.close()
server.logout()
def get_message_uids(server):
"""Return unique identifiers for each message"""
return server.uid("search", None, "ALL")[1][0].split()
def get_message(server, uid):
"""Get email message identified by given UID"""
result, data = server.uid("fetch", uid, "(RFC822)")
(_, message_text), _ = data
message = Parser().parsestr(message_text)
return message
Here’s an example usage of that code:
server = get_connection(host, username, password)
messages = [
message
for uid in get_message_uids(server)
]
close_connection(server)
I’d like you to refactor that code to use a class instead. It should work just like this:
server = IMAPChecker(host)
server.authenticate(username, password)
messages = [
message
for uid in server.get_message_uids()
]
server.quit()