Classes

Note

Be sure to activate your virtual environment in your command/terminal window before starting the Python REPL.

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()
<__main__.AwesomeThing object at 0x7f2222b9ea58>

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!

We’ve already seen this notation use for a lot of other methods:

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

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.

You can think of those method calls as syntactic sugar. These two statements are equivalent:

>>> 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("${} deposited.".format(amount))
        self.print_balance()

    def withdraw(self, amount):
        self.balance -= amount
        print("${} withdrawn.".format(amount))
        self.print_balance()

    def print_balance(self):
        print("Account balance is ${}.".format(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.

Working with Objects

Let’s make a transfer method that allows us to transfer money from one account to another.

class BankAccount:

    # ...

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

    # ...

Let’s try out our code.

Tip

We’ll need to restart our Python REPL and re-import this module to use this new method.

>>> from bank_account import BankAccount
>>> mary_account = BankAccount()
Account opened.
Account balance is $0.
>>> mary_account.deposit(100)
$100 deposited.
Account balance is $100.
>>> dana_account = BankAccount()
Account opened.
Account balance is $0.
>>> mary_account.transfer(dana_account, 20)
$20 withdrawn.
Account balance is $80.
$20 deposited.
Account balance is $20.
>>> mary_account.balance
80
>>> dana_account.balance
20

So this new method allows us to transfer money from our account to another account. Our BankAccount class knows how to do the transfer and we just ask it to do it. This kind of bundling of functionality is one of the most useful features of classes.

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
<bank_account.BankAccount object at 0x7f0a68564e10>
>>> print(account)
<bank_account.BankAccount object at 0x7f0a68564e10>

When we try to convert our account to a string, we’ll see the same thing:

>>> str(account)
'<bank_account.BankAccount object at 0x7f0a68564e10>'

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 __str__(self):
        return "Account with balance of ${}".format(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
<bank_account.BankAccount object at 0x7f2587ea06a0>

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
        print("Account opened.")
        self.print_balance()

    # ...

    def __repr__(self):
        return "BankAccount(balance={})".format(self.balance)

Now the developer-friendly representation of our account is more useful:

>>> from bank_account import BankAccount
>>> account = BankAccount()
Account opened.
Account balance is $0.
>>> account.deposit(200)
$200 deposited.
Account balance is $200.
>>> account
BankAccount(balance=200)
>>> new_account = BankAccount(balance=200)
Account opened.
Account balance is $200.
>>> new_account
BankAccount(balance=200)

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

Class Exercises

Record Transactions

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 bank_account import BankAccount
>>> my_account = BankAccount()
>>> my_account.deposit(100)
>>> my_account.withdraw(40)
>>> my_account.deposit(95)
>>> my_account.transactions
[('OPEN', 0, 0), ('DEPOSIT', 100, 100), ('WITHDRAWAL', -40, 60), ('DEPOSIT', 95, 155)]

Points

Create a Point class that has the following methods:

  • distance: accepts another Point as an argument and returns the distance between the two points (using the Pythagorean Theorem)
  • shift: accepts another Point an argument and returns a new point that is the sum of the two points
  • scale: accepts a number as an argument and returns a new point that is scaled by that number
>>> p1 = Point(1, 2, 3)
>>> p2 = Point(4, 5, 6)
>>> p2.shift(p1)
Point(5, 7, 9)
>>> p1.scale(2)
Point(2, 4, 6)
>>> p1.distance(p2)
5.196152422706632

Pythonic Points

Modify the Point class you implemented in the last exercise:

  • remove shift and add support for using the + operator between two points (hint: use __add__)
  • remove scale and add support for using the * operator between two points (hint: use __mul__)
>>> p1 = Point(1, 2, 3)
>>> p2 = Point(4, 5, 6)
>>> p1 + p2
Point(5, 7, 9)
>>> p1 * 2
Point(2, 4, 6)