OOP in Python

What is OOP?

OOP is a style of programming that uses Objects to represent concepts in code

Representing the world in code

How do we represent the world in Python code?

If we want to represent a salesperson, we could represent them as a list of data

james = ["James", 32, 1000]

We can extract data about our salesperson James

name = james[0]
age = james[1]
sales_budget = james[3]

How does that make you feel?

😭

What is an Object

An object typically has attributes and behaviour

Attributes is something we have

  • Age
  • Address
  • Height

Behaviour is something we do

  • Walk
  • Talk
  • Run

Other types of programming

  • Imperative
  • Functional

The Class == Object

Defining a class in Python

class SalesPerson:
    pass

james = SalesPerson()
  • james is an instance of SalesPerson
  • we have instantiated a new instance of SalesPerson

Attributes

Add attributes

We can initialize the class with some data

class SalesPerson:
    def __init__(self, name, age):
        self.name = name
        self.age = age

>>> james = SalesPerson(name="James", age=32)
>>> james.age
32

Aside: The mystical self

  • A class is a blueprint of something
  • A blueprint can create many instances with it’s own unique data
  • When we need to refer to an instance of a blueprint self is the placeholder we use
  • Python passes it as the first argument to any method defined on the class

Now we can make as many salespeople as we want

>>> mike = SalesPerson(name="Mike", age=40)
>>> mike.age
40

We can change attributes

We’re all consenting adults

Guido van Rossum

>>> james.name = "Tony"
>>> james.name
"Tony"

Two types of attributes

  • Classes have instance attributes, which we define in the __init__
  • Classes also have class attributes, which are shared between all instances

An example of class attributes

We have been using instance attributes - let’s try a class attribute

Let’s set Alm Brand be the default company to work for

class SalesPerson:
    company = "Alm Brand"

    def __init__(self, name)
        self.name = name

>>> james = SalesPerson("James")
>>> mike = SalesPerson("Mike")
>>> james.company
"Alm Brand"
>>> mike.company
"Alm Brand"
>>> james.company = "Tryg"
>>> mike.company
"Tryg"

Behaviour

  • Storing data is nice, and a common usecase
  • But we usually want to do something with data

Add behaviour

We can add behaviour to our class - these usually act on attributes

class SalesPerson:
    company = "Alm Brand"

    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"Hi, I'm {self.name} from {self.company}")

>>> james = SalesPerson("James")
>>> james.greet()
"Hi, I'm James from Alm Brand"

We have added the greet behaviour to our class - Our SalesPerson now knows how to greet someone

Exercise - 2 mins

Create an Organization class.

It should

  • have a num_employees attribute showing how many employees there are
  • have a method work which prints out that num_employees did some work

Solution

class Organization:
    def __init__(self, num_employees):
        self.num_employees = num_employees

    def work(self):
        print(f"{self.num_employees} did some work")

Composition vs Inheritance

OOP has two powerful concepts that allow objects to interact with each other:

Composition

Compose objects together

  • Objects can contain other objects and call their methods and attributes

Inheritance

  • Inherit from other objects

  • Objects can inherit from other objects and call its parents methods and attributes

  • Used to specialize an existing class - we usually call this subclassing

Inheritance

We can inherit from SalesPerson and create a customized version with more specific attributes and behaviour

class Phoner(SalesPerson):
    def call(self, telephone_number):
        ...

class TiedAgent(SalesPerson):
    def meet(self, address):
        ...

Inherited classes still have access to its parents methods

class Phoner(SalesPerson):
    def call(self, telephone_number):
        ...

>>> mike = Phoner("Mike")
>>> mike.greet()
"Hi, I'm Mike"

Note we didn’t define a greet method on our Phoner class

We can also overwrite methods from the parent

class Phoner(SalesPerson):
    def greet(self):
        print(f"Hi, I'm {self.name} and I'm a phoner")

>>> mike = Phoner("Mike")
>>> mike.greet()
"Hi, I'm Mike and I'm a phoner"

Or extend methods


class Phoner(SalesPerson):
    def __init__(self, name, phone_number):
        super().__init__(name)
        self.phone_number = phone_number

>>> jane = Phoner(name="Jane", phone_number=35477777)
>>> jane.phone_number
35477777

Super == ☝️

super is used when we want to call some method from the parent - usually when we override a method from the parent class

Exercise - 5 mins

Create two new classes inheriting from Organization:

  • CustomerServiceCenter
  • FranchiseOffice

They should:

  • accept a contact_method parameter in their __init__

  • override the work method to specify what work they did using the contact_method parameter

Solution

class Organization:
    def __init__(self,num_employees):
        self.num_employees = num_employees

class CustomerServiceCenter(Organization):
    def __init__(self, num_employees, contact_method):
        super().__init__(num_employees)
        self.contact_method = contact_method

    def work(self):
        print(f"{self.num_employees} contacted customers "
              f"via {self.contact_method}")
class FranchiseOffice(Organization):
    def __init__(self, num_employees, contact_method):
        super().__init__(num_employees)
        self.contact_method = contact_method

    def work(self):
        print(f"{self.num_employees} contacted customers "
              f"via {self.contact_method}")

Recap Inheritance

  • A class can inherit code from a parent class
  • Inheritance is used to specialize a class
  • super() calls a method from the parent
  • Think inheritance when you can describe the relationship as “is-a”

__dunder__ methods

  • Short for double underscore - also called magic methods
  • We override these methods to change our class behaviour
  • Examples include:
    • __init__
    • __repr__
    • __add__

__repr__

What happens if we want to print our SalesPerson?

class SalesPerson:
    def __init__(self, name):
        self.name = name

>>> james = SalesPerson("James")
>>> print(james)
<__main__.SalesPerson object at 0x7f479d47db50>

To make our SalesPerson presentable, we need to tell Python what to do when representing a Sales person

class SalesPerson:
    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"Hi, I'm {self.name}")

    def __repr__(self):
        return f"<SalesPerson {self.name}>"

>>> james = SalesPerson("James")
>>> print(james)
"<SalesPerson James>"

Exercise - 2 mins

Add a name attribute to the Organization class we made previously Add a __repr__ to the Organization class

It should

  • display the name of the organization and how many employees it has when printed

Solution

class Organization:
    def __init__(self, name, num_employees):
        self.name = name
        self.num_employees

    def work(self):
        print(f"{self.num_employees} did some work")

    def __repr__(self):
        return f"<{self.name}: {self.num_employees} employees>"

Everything is an object

Everything in python is an object under the hood.

Ever wonder why we can do this?

>>> "anders bogsnes".title()
"Anders Bogsnes"

We can do silly things

class MyInt(int):
    def add_one(self):
        return self + 1
>>> my_int = MyInt(2)
>>> my_int.add_one()
3

Composition

  • An object can contain other objects
  • We can compose components to add behaviour

Redefine Organization

We can say that an Organization is composed of salespeople

class Organization:
    def __init__(self, sales_people):
        self.sales_people = sale_people

    def work():
        print(f"{len(self.sales_people)} sales people "
               "did some work")

>>> james = SalesPerson("James")
>>> mike = SalesPerson("Mike")
>>> org = Organization(sales_people=[james, mike])
>>> org.work()
"2 sales people did some work"

We can do delegation

  • The organization doesn’t need to know anything about the SalesPerson class, only the interface.

We can add a sales_budget to each SalesPerson

class SalesPerson:
    def __init__(self, name, sales_budget):
        self.name = name
        self.sales_budget = sales_budget

    def greet(self):
        print("Hello, my name is {self.name}")

Now our organization can easily calculate its salesbudget dynamically

class Organization:
    ...
    def total_budget():
        return sum(sales_person.sales_budget
                   for sales_person
                   in self.sales_people)

We can rely on implementation

  • The SalesPerson class knows how to make an appropriate greeting for that instance
  • The Organization class doesn’t need to know how that is implemented
class Organization:
    ...
    def greet_all():
        for sales_person in sales_people:
            sales_person.greet()

Exercise - 5 mins

Create an Organization class composed of some number of SalesPerson

Criteria

  • SalesPerson should have a method work which prints the person’s name and how they’re doing work
  • The Organization should have a method work which delegates to SalesPerson's work method

Solution

class SalesPerson:
    def __init__(self, name, contact_method)
        self.name = name
        self.contact_method

    def work(self):
        print(f"{self.name} spent the day "
              f"{self.contact_method} customers")

>>> james = SalesPerson("James", "meeting")
>>> jane = SalesPerson("Jane", "calling")
class Organization:
    def __init__(self, sales_people):
        self.sales_people = sales_people

    def work(self):
        for sales_person in self.sales_people:
            sales_person.work()

>>> org = Organization([james, jane])
>>> org.work()
"James spent the day meeting customers"
"Jane spent the day calling customers"

Alternate composition

We could compose a Salesperson as well

class SalesPerson:
    def __init__(self, name, sales_method):
        self.name = name
        self.sales_method = sales_method

    def sell_to_customer(self, customer):
        self.sales_method.sell(customer)

Now we can make a PhoneSalesMethod and a MeetingSalesMethod class and pass to our salesperon

class MeetingSalesMethod:
    def sell(self, customer):
        self.go_to_meeting(customer)

>>> tied_agent = SalesPerson(name="Mike", sales_method=MeetingSalesMethod())

Remember, functions are objects too

We could also define a sales_function

class SalesPerson:
    def __init__(self, name, sales_method):
        self.name = name
        self.sales_method = sales_method

    def sell_to_customer(self, customer):
        self.sales_method(customer)
def phone_sales(customer):
    call(customer)

>>> tied_agent = SalesPerson("Mike", sales_method=phone_sales)

We are composing functionality together to define the behaviour of our SalesPerson

We see this pattern in Python all over

If I want to sort a list by a given value

to_sort = [(99, "a"), (98, "b"), (1, "z")]

# I want to sort by the second value of the tuple
>>> sorted(to_sort, key=lambda x: x[0])
[(1, 'z'), (98, 'b'), (99, 'a')]

If I want to support dumping numpy arrays to JSON

import json
import numpy as np


class CustomEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, np.int64):
            return int(o)
        return super().default(o)

>>> json.dumps(np.int64(20))
...
TypeError: Object of type int64 is not JSON serializable
>>> json.dumps(np.int64(20), cls=CustomEncoder)
'20'

Recap Composition

  • We can compose objects together to change how our program works
  • We can delegate to other objects so our class doesn’t need to know
  • Leads to less coupling - We can pass any object that has a work method to Organization

Properties

Access calculations as if it was an attribute

Let’s define a Person class with a first name and last name

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

We can now add a property to our person to get their full_name

class Person:
    ...
    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

We now have access to an attribute which does a calculation on the fly

>>> anders = Person(first_name="Anders", last_name="Bogsnes")
>>> print(anders.full_name) # No parentheses
"Anders Bogsnes"

Exercise - 2 mins

Change the implementation of Organization to use a property for num_employees

class Organization:
    def __init__(self, sales_people):
        self.sales_people = sales_people

Solution

class Organization:
    def __init__(self, sales_people):
        self.sales_people = sales_people

    @property
    def num_employees(self):
        return len(self.sales_people)

Advanced Bonus

Can also use a setter if we need to modify or validate data when setting an attribute.

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

    @full_name.setter
    def full_name(self, value):
        self.first_name, self.last_name = value.split(" ")

We can now change full_name and update first_name and last_name are updated automatically

>>> anders = Person("Anders", "Bogsnes")
>>> anders.full_name
"Anders Bogsnes"
>>> anders.full_name = "Thomas Bogsnes"
>>> anders.first_name
"Thomas"

Classmethods

Classmethods construct classes

Most often used as an alternate constructor for a class.

What if the data for our sales people was in a JSON file?

{
    "name": "John",
    "sales_budget": 2000
}

We could read the file first and then construct a SalesPerson instance


import json
import pathlib

data = json.loads(pathlib.Path("john.json").read_text())

john = SalesPerson(name=data["name"],
                   sales_budget=data["sales_budget"])

This works, but what if we need to add a new parameter, contact_method?

We would need to go through all our code and update every location we create a SalesPerson

😰

A better way is to add a classmethod constructor

class SalesPerson:
    ...

    @classmethod
    def from_json(cls, json_file):
        data = json.loads(pathlib.Path(json_file).read_text())
        return cls(name=data["name"],
                   age=data["age"])

We can then use it to create a SalesPerson from our json file

>>> john = SalesPerson.from_json("john.json")
>>> john.name
"John"

Now if we want to add our contact_method attribute, we just need to add it to the constructor!

😄

Exercise - 5 mins

Modify the Organization class to have an alternate constructor which accepts a JSON file containing a list of sales people

[
    {"name": "John", "sales_budget": 2000},
    {"name": "Mike", "sales_budget": 1000}
]

It should

  • have a classmethod from_json which will construct a list of SalesPeople and create an instance of Organization with that list

Solution

import json
import pathlib

class Organization:
    def __init__(self, sales_people):
        self.sales_people = sales_people

    @classmethod
    def from_json(cls, file_path):
        data = json.loads(pathlib.Path(file_path).read_text())
        return cls(sales_people=[
            SalesPerson(name=datum["name"],
                        sales_budget=["sales_budget"])
            for datum in data])

Staticmethods

  • Static methods are methods that don’t require any of the class attributes or methods
  • Formally, they don’t require self
  • Used to keep methods that make sense together instead of as a separate function
class Phoner:
    ...
    @staticmethod
    def lookup_phonenumber(phone_number):
        # Lookup who the phone number belongs to
        print(f"Phone number {phone_number} belongs to John")

>>> Phoner.lookup_phonenumber(35477777)
"Phone number 35477777 belongs to John"

>>> phoner = Phoner("Joe")
>>> phoner.lookup_phonenumber(35477777)
"Phone number 35477777 belongs to John"

It could easily be a function, but it’s grouped together with the Phoner, since it’s often used in that context

Abstract Base Classes (ABC)

  • How do we define the interface that all our inherited classes need to have?
  • How do we make sure that if someone else creates a new class, that it has all the behaviour we rely on?

We need all SalesPeople to have a work method so our Organization can call it

work is an interface - a contract of sorts

Any class that inherits from our AbstractSalesPerson will not work unless it implements a work method


import abc

class AbstractSalesPerson(abc.ABC):
    @abc.abstractmethod
    def work()
        pass
class LazySalesPerson(AbstractSalesPerson):
    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"Hi, I'm {self.name}")

    # Oops - I don't know how to work!

>>> mike = LazySalesPerson("Mike")
"TypeError: Can't instantiate abstract class LazySalesPerson with abstract methods work"

Let’s fix our LazySalesPerson

class LazySalesPerson(AbstractSalesPerson):
    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"Hi, I'm {self.name}")

    def work(self):
        print("I know how to work")

>>> mike = LazySalesPerson("Mike")
>>> mike.work()
"I know how to work"

Final assignment

We are going to simulate a month of sales using OOP

We want to simulate different strategies for allocating leads to Tied Agents or to Customer Service representatives.

Output

At the end of the month, we want to get a summary for that simulation:

  • How much did we sell for?
  • What did it cost to sell that amount?
  • How many leads were used?

Parameters

We want to set some parameters for the simulation:

  • How many leads are available?
  • How many Tied Agents are available?
  • How many Customer Service representatives are available?

Implementation

  • We need an Organization to contain the sales people
  • We need a TiedAgent class and a CustomerServiceRep class, both inheriting from a SalesPerson class
  • We need a Lead class

SalesPerson

  • Each salesperson has a hitrate assigned to them, depending on the type of salesperson
  • Each salesperson has a per-sales cost associated with them, depending on the type of salesperson
  • Each saleperson can consume some number of leads per day
  • Each salesperson keeps track of what leads it has used and which ones it converted

Leads

  • A lead has a potential sales sum
  • A lead can be “converted” into a sale
  • A lead has a lead cost associated with it

Organization

  • The Organization must implement the reporting functionality
  • The Organization must be able to run a day of the simulation
  • The Organization must be able to be constructed from a JSON list of scenarios

Additional Resources