One interesting benefit of taking the time to set up classes and objects for modeling is that instead of referring to an identifier (i.e. the station id), we refer to an object—a very important reason for why Python stores references to objects. And an object has attributes and methods that we can take advantage of.
How does this help us? Recall that we needed the distance of a trip. In order to do this, we need not only which stations are the endpoints, but also their location. So we need a few pieces of information and a relatively complicated formula. Using classes makes managing this much simpler.
class DivvyStation:
"""
A class representing a Divvy station.
Attributes:
station_id (int): id for station
name (str): station name
latitude (float): station latitude
longitude (float): station longitude
dpcapacity (int): number of docks
landmark (int): ??
online_date (str): date station was created
"""
def __init__(self, station_id, name, latitude, longitude,
dpcapacity, landmark, online_date)
self.station_id = station_id
self.name = name
self.latitude = latitude
self.longitude = longitude
self.dpcapacity = dpcapacity
self.landmark = landmark
self.online_date = online_date
def __repr__(self):
"""
Internal string representation
"""
return f"<{self.station_id}, name='{self.name}'>"
def __str__(self):
"""
Friendly string representation
"""
return f"Divvy Station #{self.station_id} {self.name}"
def distance_to(self, other):
"""
Computes the direct distance (drawing a line across the surface
of the earth) from this station to the given station.
Input:
other (DivvyStation): the destination Divvy station
Output: distance between the two stations (float)
"""
diff_latitude = math.radians(other.latitude - self.latitude)
diff_longitude = math.radians(other.longitude - self.longitude)
a = (math.sin(diff_latitude/2) * math.sin(diff_latitude/2) +
math.cos(math.radians(self.latitude)) *
math.cos(math.radians(other.latitude)) *
math.sin(diff_longitude/2) * math.sin(diff_longitude/2))
d = 2 * math.asin(math.sqrt(a))
return 6371000.0 * d
class DivvyTrip:
"""
A class representing a single Divvy trip.
Attributes:
trip_id (int): id of the trip
starttime (str): time at the start of the trip
stoptime(str): time at the end of the trip
bikeid (int): id of the bike
tripduration (int): duration of trip in seconds
from_station (DivvyStation): starting station
to_station (DivvyStation): destination station
usertype (str): one of "Customer", "Subscriber", "Dependent"
gender (Optional[str]): "M", "F", or None
birthyear (Optional[int]): a year or None
"""
def __init__(self, trip_id, starttime, stoptime, bikeid,
tripduration, from_station, to_station, usertype,
gender, birthyear):
self.trip_id = trip_id
self.starttime = starttime
self.stoptime = stoptime
self.bikeid = bikeid
self.tripduration = tripduration
self.from_station = from_station
self.to_station = to_station
self.usertype = usertype
self.gender = gender
self.birthyear = birthyear
def __repr__(self):
"""
Internal string representation
"""
return (f"<{self.trip_id}, "
f"from_station={self.from_station.station_id}, "
f"to_station={self.to_station.station_id}>")
def __str__(self):
"""
Friendly string representation
"""
return (f"Divvy Trip #{self.trip_id} "
f"from {self.from_station.name} "
f"to {self.to_station.name}")
def get_distance(self):
"""
Computes the distance from the origin station to the
destination station.
Output: distance between from_station and to_station (float)
"""
return self.from_station.distance_to(self.to_station)
Then we can compute our desired quantities:
total_distance = 0
for trip in trips:
total_distance = trip.get_distance()
total_duration = 0
for trip in trips:
total_duration = trip.tripduration
Notice that this now removes the need for us to
Instead, the classes and methods very naturally do that for us.
Generally, there are two kinds of errors that are "show-stoppers", in the sense that they will crash your program. These are
Syntax errors must be fixed before the program can run. However, run-time errors result in exceptions. Exceptions are called such because they arise in exceptional conditions. Here, we use exceptional in the literal sense—they are outside of ordinary operation, rather than exceptional as "really good". When an exception is raised, flow of control is interrupted and the exception needs to be handled.
We have seen a number of different kinds of exceptions.
NameError.
IndexError.
KeyError.
assert statement and it fails, we get an AssertionError.
These errors give us a clue into why our program might have failed. But at this point, you might notice something interesting about them: they are named in the same way that classes are. Yes, exceptions are a class in Python and there is an associated object with each of these exceptions. Everything truly is an object in the world of Python.
At first, exceptions seem insurmountable, but that's only because we don't know how to deal with them. But the fact that exceptions are objects tells us that there are ways that we are intended to work with them. In some sense, exceptions are actually a subset of errors that are recoverable. That is, even if an exception is encountered, there is a way for us to continue with the computation, because we're given this exception object. (Errors that crash your program without any exceptions are much more difficult to deal with.)
First, what does an exception do? When we run some code like
d = {}
d["a key that I think exists"]
print("This line will definitely run (not)")
our program encounters an error (a KeyError specifically) and ordinarily, our program stops and you either fix the problem or you go off to office hours for help.
But this doesn't have to be the case all the time. Up until now, you have generally encountered errors because your code was wrong. However, as you work with larger programs that interact with other programs and data, you will encounter errors that aren't your fault. This is good, because it's not your fault, but it's bad because you still have to deal with it.
But suppose that you could anticipate that there is some trouble code. What can you do about it? Well, we could try running it and seeing what happens. And if it works, then we're good. But if something does go wrong, we can try to handle the error somehow.
This is what a try statement does.
try:
<some code you think might give you a SomethingError>
except SomethingError:
<your code to deal with getting a SomethingError>
For example,
d = {}
try:
d["a key that I think exists"]
print("This line will definitely run (not)")
except KeyError:
print("You tried to access a key that doesn't exist in the dictionary")
print("This program continues running as normal.")
There are a few things to note here. It's worth thinking about the flow of the program here. Because exceptions signify exceptional events, when an exception is raised in the try block, control immediately stops and goes to the corresponding except block to handle the exception. Following that, the program continues after the try statement.
The fact that an exception is really an object is useful. This means that the exception object can contain information about the exception. We can use this information in the same way we would use any other object.
d = {}
try:
d["a key that I think exists"]
except KeyError e:
print("KeyError:", e)
try:
int("a number")
except ValueError as e:
print("Something went wrong:", e)
Each exception has its own information it provides.
Now, notice we explicitly specify which error we are going to try to handle. That way, if some other exception occurs, we don't erroneously handle it incorrectly. What if we do expect more than one kind of error?
There are two options. First, we can handle each of them, by adding an except block for each one under the same try. This is a common thing to do when, say, working with files—many things can go wrong, some of which may not be your fault.
try:
f = open("file.txt")
s = f.readline()
i = int(s.strip())
except OSError as e:
print("OSError:", e)
except ValueError:
print("Could not convert to integer")
except Exception as err:
print(f"Unexpected {err=}, {type(err)=}")
Here, we see Exception, which is a general exception type for all exceptions. In this case, the final block gets executed only if an exception is raised that is not one of the previous two. Generally, you do not want to rely on using only Exception, since you should try to deal with the exceptions you expect in appropriate ways.
If you would like to handle many different types of errors in the same way, you can combine them into one block.
try:
s = s + 23 + "forty"
except (TypeError, ValueError) as e:
print("Error:", type(e), e)
(As a side note, notice that this statement can cause multiple exceptions, but the one that actually gets raised is determined by the evaluation order of the expression)
Finally, in addition to try and except, we have the finally block. finally is an optional block that is run no matter what occurs in the previous blocks.
Why is finally necessary? Recall that exceptions modify the flow of control—any code in the try block that occurs after an exception is not run. This can be problematic, since there might be code that needs to be run regardless of the state of the program.
try:
f = open("missing-file.txt")
for row in f:
print(row)
except OSError as e:
print(e)
finally:
print("Finished attempting to read the file.")
Again, it's worth following the flow of control carefully here.
FileNotFoundError is raised.
try block and enters the except block, where the exception is handled.
except block is finished, the finally block is executed and after that is finished, control moves on to the next statement.
In the case that no exception is thrown, the try block will successfully complete and control moves on to the finally block. Again, finally will always be executed.
One thing that having these mechanisms may tempt you to do is something like the following:
try:
x = 765/0
except ZeroDivisionError:
pass
print("Mission accomplished👍")
This is what we call failing silently. You can think of it as the programming equivalent of sweeping something under the rug. Sure, it might "solve" the problem right now, but eventually, it will come back to haunt you. You will absolutely be penalized for handling exceptions in this way.
Something else you might be tempted to do is to just let your code encounter exceptions and deal with the consequences afterwards. This is not recommended. As we've seen, exceptions are exceptional conditions and because they are exceptional, handling them requires breaking out of the expected flow of control. It's this jumping around unexpectedly that makes understanding programs more difficult. If you are able to do avoid exceptions by doing some due diligence (like incorporating some simple checks beforehand), you should do so.
At some point, when you work on a program that is complicated enough, you may want to raise exceptions of your own. To do this, we use a raise statement. Simply raise the desired exception with an appropriate message.
raise ValueError("You gave a wrong value")
We can, of course, catch any such errors in the same way as usual.
try:
raise ValueError("You gave a wrong value")
except ValueError as e:
print(f"Caught our ValueError: {e}")
There are many built-in exceptions that one can use in Python. However, you may want to define your own. Since exceptions are classes, you simply need to define a certain kind of class.
class MyError(Exception):
pass
At the very least, this gives your exception a name. Then you can raise your custom exception with a raise statement:
raise MyError("Something went wrong here"):
Since these are classes, you can add attributes and methods for them, including dunder methods. Since exceptions are derived from the Exception class, they take a message in their constructor.