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.
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:
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!
Vendor
data classLet 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.
The name is fairly straightforward: we’ll use a
str
to represent it.
The menu has a few different options. For this section,
we’ll use a dict
that maps the names of food items
(str
s) to their price (float
s).
There are many ways to represent a vendor’s location. For
example, we could store its address, as a str
. Or we could
improve the precision of our data and store the latitude and longitude
(a tuple of float
s), which would be useful for displaying
restaurants on maps.
For now, we’ll store both address and latitude/longitude information for each vendor. It may be that both representations are useful, and should be stored by our application.
@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)
"""
str
name: str
address: dict[str, float]
menu: tuple[float, float] location:
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.
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
"""
Order
data classNow 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: Vendordict[str, int]
food_items:
start_time: datetime.datetime= None
courier: Optional[Courier] = None end_time: Optional[datetime.datetime]
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',
={'fries': 4.5}, location=(43.649, -79.397))
... menu>>> order = Order(customer=david, vendor=mcdonalds,
={'fries': 10},
... food_items=datetime.datetime(2020, 11, 5, 11, 30))
... start_time
>>> 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.
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
”.