\( \newcommand{\NOT}{\neg} \newcommand{\AND}{\wedge} \newcommand{\OR}{\vee} \newcommand{\XOR}{\oplus} \newcommand{\IMP}{\Rightarrow} \newcommand{\IFF}{\Leftrightarrow} \newcommand{\TRUE}{\text{True}\xspace} \newcommand{\FALSE}{\text{False}\xspace} \newcommand{\IN}{\,{\in}\,} \newcommand{\NOTIN}{\,{\notin}\,} \newcommand{\TO}{\rightarrow} \newcommand{\DIV}{\mid} \newcommand{\NDIV}{\nmid} \newcommand{\MOD}[1]{\pmod{#1}} \newcommand{\MODS}[1]{\ (\text{mod}\ #1)} \newcommand{\N}{\mathbb N} \newcommand{\Z}{\mathbb Z} \newcommand{\Q}{\mathbb Q} \newcommand{\R}{\mathbb R} \newcommand{\C}{\mathbb C} \newcommand{\cA}{\mathcal A} \newcommand{\cB}{\mathcal B} \newcommand{\cC}{\mathcal C} \newcommand{\cD}{\mathcal D} \newcommand{\cE}{\mathcal E} \newcommand{\cF}{\mathcal F} \newcommand{\cG}{\mathcal G} \newcommand{\cH}{\mathcal H} \newcommand{\cI}{\mathcal I} \newcommand{\cJ}{\mathcal J} \newcommand{\cL}{\mathcal L} \newcommand{\cK}{\mathcal K} \newcommand{\cN}{\mathcal N} \newcommand{\cO}{\mathcal O} \newcommand{\cP}{\mathcal P} \newcommand{\cQ}{\mathcal Q} \newcommand{\cS}{\mathcal S} \newcommand{\cT}{\mathcal T} \newcommand{\cV}{\mathcal V} \newcommand{\cW}{\mathcal W} \newcommand{\cZ}{\mathcal Z} \newcommand{\emp}{\emptyset} \newcommand{\bs}{\backslash} \newcommand{\floor}[1]{\left \lfloor #1 \right \rfloor} \newcommand{\ceil}[1]{\left \lceil #1 \right \rceil} \newcommand{\abs}[1]{\left | #1 \right |} \newcommand{\xspace}{} \newcommand{\proofheader}[1]{\underline{\textbf{#1}}} \)

11.2 Object-Oriented Modelling

In the previous section, we said that a system is a collection of entities that interact with each other over time. In this section, we will explore what data should be a part of our problem domain—a food delivery system—and how that data might change over time. We’ll introduce an object-oriented approach to modelling this data in Python, using both data classes and general classes to represent different entities.

One thing to keep in mind as we proceed through this section (and the rest of the chapter) is that just like in the “real world”, the scope of our problem domain is not fixed and can change over time. We are interested in the minimum set of data needed for our system to be meaningful, keeping the scope small at first with the potential to expand over time. Throughout this section, we’ll point out places where we make simplifying assumptions that reduce the complexity of our system, which can serve as potential avenues for your own independent explorations after working through this chapter.

Entities in a food delivery system

A good first step in modelling our problem domain is to identify the relevant entities in the domain. Here is our initial description of SchoolEats from the previous section:

Seeing the proliferation of various food delivery apps, you have decided to create a food and grocery delivery app that focuses on students. Your app will allow student users to order groceries and meals from local grocery stores and restaurants. The deliveries will be made by couriers to deliver these groceries and meals—and you’ll need to pay the couriers, of course!

We use two strategies for picking out relevant entities from an English description like this one:

  1. Identify different roles that people/groups play in the domain. Each “role” is likely an entity: e.g., student user, courier, and grocery store/restaurant are three distinct roles in the system.
  2. Identify a bundle of data that makes sense as a logical unit. Each “bundle” is likely an entity: e.g., an order is a bundle of related information about a user’s food request.

In an object-oriented design, we typically create one class to represent each of type of entity. Should we make a data class or a general class for each one? There are no easy answers to this question, but a good strategy to use is to start with a data class, since data classes are easier to create, and turn it into a general class if we need a more complex design (e.g., to add methods, including the initializer, or mark attributes as private).

from dataclasses import dataclass


@dataclass
class Vendor:
    """A vendor that sells groceries or meals.

    This could be a grocery store or restaurant.
    """

@dataclass
class Customer:
    """A person who orders food."""

@dataclass
class Courier:
    """A person who delivers food orders from restaurants to customers."""

@dataclass
class Order:
    """A food order from a customer."""

Once we have identified the classes representing the entities in the system, we now dive into the details of the system to identify appropriate attributes for each of these data classes. We’ll discuss our process for two of these data classes in this section, and leave the other two to lecture this week.

Design decisions: even at the point of defining our data classes, we’ve made some (implicit) decisions in how we’re modelling our problem domain! We’ve created two separate data classes to represent Customer and Courier. But what if a student wants to use our app as both a customer and a courier? Would they need to “sign up” twice, or have two separate accounts?

To make things simple for this chapter, we’re going to assume our users are always a customer or courier, but not both at the same time. But we encourage you to think about how we might need to change our design to allow users to play both roles—or even to be a food vendor as well!

Designing the Vendor data class

Let us consider how we might design a food vendor data class. What would a vendor need to have stored as data? It is useful to envision how a user might interact with the app. A user might want to browse a list of vendor available, and so we need a way to identify each vendor: its name. After selecting a vendor, a user needs to see what food is available to order, so we need to store a food menu for each vendor. Finally, couriers need to know where restaurants are in order to pick up food orders, and so we need to store a location for each vendor.

Each of these three pieces of information—vendor name, food menu, and location—are appropriate attributes for the data class. Now we have to decide what data types to use to represent this data. You have much practice doing this, stretching back to all the way to the beginning of this course! Yet as we’ll see, there are design decisions to be made even when choosing individual attributes.

@dataclass
class Vendor:
    """A vendor that sells groceries or meals.

    This could be a grocery store or restaurant.

    Instance Attributes:
      - name: the name of the vendor
      - address: the address of the vendor
      - menu: the menu of the vendor with the name of the food item mapping to
              its price
      - location: the location of the vendor as (latitude, longitude)
    """
    name: str
    address: str
    menu: dict[str, float]
    location: tuple[float, float]

Note that the menu is a compound data type, and we chose to represent it using one of Python’s built-in data types (a dict). Another valid approach would have been to create a completely separate Menu data class. That is certainly a viable option, but we were wary of falling into the trap of creating too many classes in our simulation. Each new class we create introduces a little more complexity into our program, and for a relatively simple class for a menu, we did not think this additional complexity was worth it.

On the flip side, we could have used a dictionary to represent a food vendor instead of the Vendor data class. This would have reduced one area of complexity (the number of classes to keep track of), but introduced another (the “valid” keys of a dictionary used to represent a restaurant). There is always a trade-off in design, and when evaluating trade-offs one should always take into account cognitive load on the programmer.

Don’t forget about representation invariants!

Even though we’ve selected the instance attributes for our Vendor data class, we need consider what representation invariants we want to add. This is particularly important when modelling a problem domain in a program: we must write our representation invariants to rule out invalid states (e.g., invalid latitude/longitude values) while avoiding making assumptions on the entities in our system. Here are some representation invariants for Vendor:

@dataclass
class Vendor:
    """...

    Representation Invariants:
      - self.name != ''
      - self.address != ''
      - all(self.menu[item] >= 0 for item in self.menu)
      - -90.0 <= self.location[0] <= 90.0
      - -180.0 <= self.location[1] <= 180.0
    """

Designing the Order data class

Now let’s discuss a data class that’s a bit more abstract: a single order. An order must track the customer who placed the order, the vendor where the food is being ordered from, and the food items that are being ordered. We can also imagine that an order should have an associated courier who has been assigned to deliver the order. Finally, we’ll need to keep track of when the order was created, and when the order is completed.

There’s one subtlety with two of these attributes: the associated courier and the time when the order is completed might only be assigned values after the order has been created. So we use a default value None to assign to these two instance attributes when an Order is first created. We could implement this by converting the data class to a general class and writing our own __init__ method, but instead we’ll take advantage of a new feature with data classes: the ability to specify default values for an instance attribute after the type annotation.

from typing import Optional  # Needed for the type annotation
import datetime  # Needed for the start and end times of the order


@dataclass
class Order:
    """A food order from a customer.

    Instance Attributes:
      - customer: the customer who placed this order
      - vendor: the vendor that the order is placed for
      - food_items: a mapping from names of food to the quantity being ordered
      - start_time: the time the order was placed
      - courier: the courier assigned to this order (initially None)
      - end_time: the time the order was completed by the courier (initially None)

    Representation Invariants:
      - self.food_items != {}
      - all(self.food_items[item] >= 0 for item in self.food_items)
    """
    customer: Customer
    vendor: Vendor
    food_items: dict[str, int]
    start_time: datetime.datetime
    courier: Optional[Courier] = None
    end_time: Optional[datetime.datetime] = None

The line courier: Optional[Courier] = None is how we define an instance attribute Courier with a default value of None. The type annotation Optional[Courier] means that this attribute can either be None or a Courier instance. Similarly, the end_time attribute must be either None (its initial value) or a datetime.datetime value.

Here is how we could use this class (note that Customer is currently an empty data class, and so is instantiated simply as Customer()):

>>> david = Customer()
>>> mcdonalds = Vendor(name='McDonalds', address='160 Spadina Ave',
...                    menu={'fries': 4.5}, location=(43.649, -79.397))
>>> order = Order(customer=david, vendor=mcdonalds,
...               food_items={'fries': 10},
...               start_time=datetime.datetime(2020, 11, 5, 11, 30))

>>> order.courier is None  # Illustrating default values
True
>>> order.end_time is None
True

Design decisions: by associating a food order to a single Vendor, we’re making an assumption that a customer will order food from only one vendor. Or, if we want to allow a user to order food from more than one vendor, we’ll need to create separate Order objects for each vendor.

Class composition

Just as we saw earlier in the course that built-in collection types like lists can be nested within each other, classes can also be “nested” within each other through their instance attributes. Our above Order data class has attributes which are instances of other classes we have defined (Customer, Vendor, and Courier).

The relationship between Order and these other classes is called class composition, and is a fundamental to object-oriented design. When we create classes for a computational model, these classes don’t exist in isolation. They can interact with each other in several ways, one of which is composition. We use class composition to represent a “has a” relationship between two classes (we say that “an Order has a Customer”). This is in contrast to inheritance, which defines an “is a” relationships between two classes, e.g. “Stack1 is a Stack”.