2. Material: Transformations and Crossroads¶
Signs of a Storm¶
In this material we're going to turn Python from a calculator into a typewriter. We'll also learn more advanced communication with the user. Our programs start to react to what the user does. The revolution of machines is just around the corner.
This material continues with essential programming concepts: conditional statements and strings, the latter of which was visited briefly earlier. In line with the typewriter theme we've also managed to fit in dictionaries. These tools could already be used for something epic like a text-based adventure game. We're not going to do that though because it would involve writing much more text than code. What we are going to do, is to go through some common scenarios encountered in programming.
Most real programs have some kind of a menu where the user can choose what to do with the program. Most programs don't do just one thing, they do a bunch of things related to each other. As an example image processing programs contain all sorts of tools and features that all share a common ground: they have something to do with manipulating digital images. Choices form the overarching theme for this material. On the one hand we're going to let the user know what options they have; on the other hand we need to provide the user with means to make a choice. We've ran out of hands but there's also the matter of how computers can be instructed to perform the selected task.
Trial and Error¶
Learning goals: This section covers handling of exceptions in code. You'll learn a new structure that is intended for this purpose.
At the end of the last material we threatened to let less perfect hands poke our beautiful programs. Earlier we just hand-waved over a rather important matter: what actually happens in the program if we try to convert something to a number when it doesn't look like one? Something like this can happen simply because a smart-ass user tries to input donkey as a number. Let's go through this using the ball calculator from exercise 1. You can find the code below.
Enter ball circumference: 23 cm
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
/media/sf_virtualshare/OA/code/H1/ball.py in <module>
16 return area, volume
17
---> 18 measurement = float(input("Enter ball circumference: "))
19 ball_area, ball_volume = calculate_ball_properties(measurement)
20 print("Volume:", round(ball_volume, 4))
ValueError: could not convert string to float: '23 cm'
The
string
that the user provided does not look like a number because it has a unit at the end. As much is told by the last line of ther error message
. Because error messages are roughly as terrifying as Necronomicon for mere mortals, it's usually best to protect them from seeing them and use friendly notifications instead. We'll focus on this sanity preservation next.The Philosophy of Exceptions¶
Because we cannot know what users try to do to our program with their
inputs
the program has to have some means of dealing with exceptions. Python, like most other programming languages, has a structure for dealing with this: we usually talk about a process called exception
handling.The basic idea is that we attempt to execute all code in a "business as usual" fashion but at the same time make some precautions at spots where we know things can go wrong. There's two sides to this: first of all we need to know what even are the potential issues; second, we need to instruct the program about how to deal with them.
When attempting to input letters into the program we're greeted with a harsh ValueError
message
. There's no magic way to find out all problem scenarios. You just need to try everything you can come up with and hope the users don't have more imagination than you. Good luck with that by the way.Usually various problem scenarios come up during a program's development, and often this leads to rewriting some parts of the code. It's not uncommon for new errors to be found once the program is completed either. Just look at how many games have bugs on release just because quality assurance didn't have the time and imagination to match that of thousands or millions of players - not to mention speedrunners who try to deliberately break games just to complete them faster.
With our program here we do already know one potential problem scenario: when the user
inputs
something that doesn't convert to a number. We can look at exception
handling through this example. One good way to deal with this is to "translate" the error message
to language that is easier for laymen to understand. E.g. the program could just say "Input must be a plain number." After this the program can just end and the user can run it again, and perhaps be a bit wiser next time. It's not ideal but we can't do better until later.Exceptional Structure¶
As we've learned, an
exception
can occur in the program when the user inputs a string
that the float function
cannot convert. We want to give the user a friendly notification by printing: "Input must be a plain number." Implementing this behavior is built around three keywords
: try, except, and else. Together these three form a new special structure. The last of the three is not a mandatory part of the structure but often good to have. This structure defines:- which lines we expect to produce the exception (try)
- which exceptions should be handled, and what to do when they occur (except)
- how to proceed when nothing goes wrong (else)
In our case the answers would be:
- the input line
- ValueError, inform the user about invalid input
- execute rest of the program like before
These three are placed inside the structure using indentations:
try:
measurement = float(input("Enter ball circumference: "))
except ValueError:
print("Input must be a plain number.")
else:
ball_area, ball_volume = calculate_ball_properties(measurement)
print("Volume:", round(ball_volume, 4))
print("Surface area:", round(ball_area, 4))
The first part of this structure is the try
branch
which is started with the try keyword
and a colon. The code line where we know the exception
occurs in is placed inside this block
, indented
.The next part is the except branch. In most cases except should be told which exception(s) it should handle. This is done by putting the exception's name after the keyword, before the colon at the end. Inside the branch, in its own block, is code that is executed when the exception occurs. In this case it's the line that prints the notification to the user.
The last part is the else
branch
which contains basically the rest of the code, indented
to its own block. The code in this block is only executed if the code inside the try branch goes through without issues. The animations below show how program execution proceeds through this try-except-else structure and after it.Not all
exceptions
are intended to be caught. Most importantly, exception handling should not cover problems arising from bad code - this problems should be fixed with better design. Exceptions that are often related to these kinds of problems include TypeError and NameError. NameError was encountered in the last material when we tried to access a function's
local name
from the main program
. It means we're trying to access a variable that has not been defined. This can be due to a typing error but can also result from poor branching: the variable is defined in a branch
that is not always entered. For instance if we tried to leave out the else part from the latest example:try:
measurement = float(input("Enter ball circumference: "))
except ValueError:
print("Input must be a plain number.")
ball_area, ball_volume = calculate_ball_properties(measurement)
print("Volume:", round(ball_volume, 4))
print("Surface area:", round(ball_area, 4))
If the user now gives an invalid
input
the notification does get printed. However, the program execution does not end, and instead continues to the first line outside the try structure. This particular line tries to retrieve the ball's properties using its measured circumference from the measurement variable
as an argument. Problem is, this variable was never defined and the result is NameError. The variable was not defined because the execution of the line inside the try statement stops immediately when the float function
causes an exception
. The real problem in this case is that all three last lines should be inside the else branch because they can only be executed if, and only if the measurement variable is successfully created.Fateful Choices¶
As we promised earlier, any program of significant size has some kind of a menu. To proceed we probably need to start making one. Let's dive into one of the most profound mysteries in the world: US customary units that are void of any discernible logic. Our goal is to make a program that converts these distance-between-a-happy-donkey's-ears-at-midsummer types of units into proper SI units.
In other words, we're making a program that can convert the following units:
- inch
- foot
- yard
- mile
- ounce
- pound
- cup
- pint
- quart
- gallon
- fahrenheit
This will be first somewhat complex program in this course. This means it's the first time we can really think about the best way to implement it. We can do the conversion calculations easy enough, but we also need to decide how the program is used.
Learning goals: In this section we're going to learn how a program's flow can be controlled based on user's inputs. Conditional structures will serve a central role in this endeavor. After this section you will know what they are and how they can be used for implementing menu structures. You will also learn a statement that does nothing, and how it's actually useful when drafting the program's function structure.
A World of Choices¶
An integral question when it comes to designing how to implement our program is: how is it going to know what the user wants? In this particular case we need a way to find out what unit the user wants to convert. At the moment we can only read text
inputs
from the user which leaves us with two options: ask the user which unit they want to convert, or ask them to include the unit with the value input and we interpret it from there (e.g. "5 oz"). The latter option would probably be nicer to use. However its implementation is going to need more than we're ready to chew in this material. Let's settle with the solution where the unit is selected with a separate input.Since there's a bunch of units there, we're also going to split them into categories: length, mass, volume and temperature. This results in a menu structure that has two levels: first the users selects a category, and then they select a unit within that category. Finally they enter the value they want to convert. To put it as a diagram, the program branches in the following way:
Because we only have one (but all the more confusing) temperature unit, the menu doesn't have a second level in that case. After doing this initial design it might be high time to learn how to actually make a program's execution branch. In programming, conditional structures serve this purpose.
Unconditionally Maybe¶
Conditional structure
is, as the name suggests, a structure made of conditions, and the fulfilment of each condition leads to something happening. Conditional statements
are largely formed like they would be in natural languages: "if deadline is at midnight, now is the time to code". Earlier we introduced the try structure that is in its own way a special case of a conditional structure: "if making coffee fails, make tea instead, otherwise drink coffee" where "making coffee" would be the try branch
, "make tea instead" would be the except branch, and "drink coffee" would be the else branch. In this structure the so-called conditions are exceptions
raised by Python whereas actual conditional statements usually assess the values
of variables
or statements
in one way or another. For instance we can check whether the user's input
is identical with the "length"
string
. As code:if choice == "length":
In this example choice is a variable that refers to the user's input. On this line the
condition
itself is choice == "length"
which is the statement that is being evaluated. ==
is an operator
that compares two values for equality. It returns a boolean value
which is a type that only has two literal values
: True and False.In programming,
if
is a keyword
that evaluates the truthfulness of its associated condition: if the condition is True (or equivalent) the code defined under the if branch
is executed; if the condition is False (or equivalent) that code is not executed. In either case, code execution eventually continues from the first line that is outside the conditional structure (i.e. after it).The following animations illustrate how this works. In the code example, negative numbers and zero for item counts are converted to one before adding to the catalog.
There's two important points in these animations: first, the condition in each
conditional statement
is always reduced to a single value for which truthfulness is evaluated; second, how program flow changes based on the condition
being True or False. The animations also show a new way to use the print function
which we'll revisit later. Also noteworthy is the use of a new operator
in the if statement: <
. Just like in math, this is the "less than" operator. The table below shows all operators from this category:== | a == b | a equals b |
!= | a != b | inequality |
< | a < b | a is less than b |
<= | a <= b | a is less than or equal to b |
> | a > b | a is more than b |
>= | a >= b | a is more than or equal to b |
Noteworthy properties:
In [1]: 1 == 1.0
Out[1]: True
In [2]: 1 == "1"
Out[2]: False
Whereas integers and
floating point numbers
can be equal to each other, strings
can never be equal to either of them. This is important to remember because comparing user inputs
in conditional statements
can produce confusing False results if they haven't been converted to numbers. And, with inequality, it becomes the other way around:In [1]: 1 != 1.0
Out[1]: False
In [2]: 1 != "1"
Out[2]: True
Comparisons between different types is not allowed with other
operators
:In [1]: "doggos" < 3
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-5-7396907f20c6> in <module>()
----> 1 "doggos" < 3
TypeError: unorderable types: str() < int()
What can be confusing however is that you can compare strings in this way:
In [1]: "donkey" > "walrus"
Out[1]: False
In [2]: "monkey" > "mongoose"
Out[2]: True
The "size" of strings in this case is based on comparing them via alphabetical order. One common error scenario that can result from this: you prompt the user for two numbers and compare them, but forget to
convert
them into numbers:In [1]: "31" > "4"
Out[1]: False
╯°□°)╯︵ ┻━┻
Trident¶
A single
conditional statement
by itself is not particularly powerful. Luckily conditional structures
can have multiple branches
. If we take a look at our program structure diagram we notice that on the outer level we need a structure that has four distinct choices. In order to implement these choices we also need to decide how the user can indicate their choice. At simplest the user can be prompted to type either "length", "volume", "mass" or "temperature" which at least should be very straightforward. Let's start with this solution.A good way to create a menu structure such as this is to use
functions
. Each part of the program gets its own function so that the main menu structure only calls
these functions. This way the menu code itself is very clear and compact. Likewise each part of the program is now in its own function which makes them easier to implement and maintain, compared to if they were part of the main program
where we place the conditional structure. Most importantly the conditional structure itself is easier to follow when there aren't too many lines of code inside each branch. Let's start drafting our program by defining a function for each of its main features, following the diagram:def length():
def mass():
def volume():
def temperature():
This time our
functions
don't have any parameters
. This is quite common in menu-like structures because there isn't anything relevant to pass on yet. This code isn't free of problems though. If you try to run it, Python reports an IndentationError. Whenever there's a colon that signifies a code block
, it must be followed with at least one indented
line. Still, it's occasionally good to be able to write function definitions without writing any of their content. Luckily Python has a useful feature that we can use: a command that does absolutely nothing, pass
. It's often used as a temporary replacement for function contents so that the code can be run even if all functions aren't ready yet.def length():
pass
def mass():
pass
def volume():
pass
def temperature():
pass
You can add new
branches
to conditional structures
with the elif
statement (abbreviation for "else if"). These statements must be placed on the same indentation
level as the if statement
that started the structure and as individuals they work just like it. However, only one branch in a single conditional structure can be selected. This means that the code block
inside a particular branch can only be reached if none of the preceding (i.e. above it) conditional statements has been fulfilled. Turned around this means that as soon as one condition
in the structure is fulfilled, the rest are not even assessed.number = int(input("Give an integer: "))
if number < 10:
print("Number is smaller than 10")
elif 10 <= number < 100:
print("Number is smaller than 100")
elif 100 <= number < 1000:
print("Number is smaller than 1000")
At a glance this looks logical. However, each elif in the code also contains the precedent that none of the conditions before it have been fulfilled. This means that the first comparison in both elif lines is in fact not needed (and therefore doesn't do anything). Basically:
- Upon reaching the first elif statementthe number is already known to be at least 10 because the earlierconditionwasn't true.
- Upon reaching the second elif statement we similarly know the number has to be at least 100 based on the previous condition.
This follows from the fact that we simply would not be evaluating the elif statements at all if the number had been smaller than 10, and we would not be evaluating the last one if the number had been smaller than 100. Using this information we can rewrite the structure:
number = int(input("Give an integer: "))
if number < 10:
print("Number is smaller than 10")
elif number < 100:
print("Number is smaller than 100")
elif number < 1000:
print("Number is smaller than 1000")
Now that we know what a
conditional structure
with multiple branches
looks like using elif statements
, we can draft the main menu code:choice = input("Make your choice: ")
if choice == "length":
length()
elif choice == "mass":
mass()
elif choice == "volume":
volume()
elif choice == "temperature":
temperature()
The structure of the menu is now very easy to understand. The conditional structure guides the program execution into
functions
that correspond to each choice available to the user. Because the functions have been defined (despite not doing anything) this program can already be ran. This allows us to implement the program in small parts but still be able to check it for errors frequently. The functions themselves could also contain something like this:def length():
print("Length was selected")
If we now run the program, we get this print as proof that the program did indeed proceed to the correct function when "length" was selected. This is a good way to
debug
errors from conditionals statements that have complex, error prone conditions
.But why do we need elif specifically? Isn't it enough to have multiple if statements? The important difference is that an if statement starts a new conditional structure whereas elif continues a structure. Only one
branch
is executed from each conditional structure and conditions are only checked until one of them is true. So when the user selects "length" the other conditions aren't even checked. But if we had implemented this with only if statements all conditions would always be checked because we would have four separate single branch structures instead of one four branch structure. In this particular example there is no difference because there is no overlap between the conditions (a string cannot be both "length" and "mass" at the same time). Conditions that do have overlap demonstrate the difference a bit better:User Experience is Unconditional¶
Now we have some code that implements a main menu for our program using a
conditional structure
. However, its usability is a bit trash. For starters the user has no idea what options they have. This is easy enough to fix by printing some instructions before input
prompt. Let's do this right away:def length():
pass
def mass():
pass
def volume():
pass
def temperature():
pass
print("This program converts US customary units to SI units")
print("Available features:")
print("length")
print("mass")
print("volume")
print("temperature")
print()
choice = input("Make your choice: ")
if choice == "length":
length()
elif choice == "mass":
mass()
elif choice == "volume":
volume()
elif choice == "temperature":
temperature()
The last print
call
that has no arguments
produces one empty line. Empty lines make the output a bit more readable. The same applies to code.What would happen if the user - through either mischief or accident - inputs something that is not covered by the
conditions
in our conditional structure
? At the moment the program just kicks the user back into the terminal
without explanation. At least the program should say "The selected feature is not available". This is something that's best done with the third component of conditional structures, else
. It covers all the situations that didn't fulfil the conditions for any of the other branches
, and it must always be last. It is not mandatory to have one - obviously, given that our code ran previously just fine without one. Let's put it to use:if choice == "length":
length()
elif choice == "mass":
mass()
elif choice == "volume":
volume()
elif choice == "temperature":
temperature()
else:
print("The selected feature is not available")
The else statement is the simplest part of the structure because it doesn't have a
condition
. It's simply the dumping ground where all the misfit cases fall into.Now the program is much friendlier. It gives some instructions and can even say something if things go wrong. Let's try it!
Methodical Characters¶
We ran into a problem: if the user gives a command that's technically correct but starts with a capital letter, the command is not recognized. We could just blame the user but that feels a bit unfair. Especially because we probably could accept typing variations pretty easily. The crux of the issue is that lower and upper case letters are not the same symbols:
In [1]: "A" == "a"
Out[1]: False
The most straightforward idea would be to add a condition that recognizes the "Length"
string
. But what if the user has caps lock on and they input
"LENGTH" or "lENGTH"? In the long term it's probably better for our sanity to do the comparison in a way that it's not case sensitive. A good way to go about this is to convert all letters in the user's input to lower case. This can be done with a method
.Learning goals: This section teaches you what methods are and what you can do with string methods.
Hey There, I'm a Method¶
Methods belong under the category of
functions
. The difference to functions as we've known them so far is that methods are attached to objects
- they belong to the object's attributes. In Python, object can refer to any value
. The word object is used here to signify something that has attributes
because it sounds more logical than saying that values have attributes. That sounds fancy, but what does it actually mean? Example time:number = round(number)
When functions are called we usually define what object they should handle - we give it as an
argument
. In this case the round function handles an object that's in the number variable
(i.e. the object is the variable's value). When a method is called it instead handles the object it is attached to. Shown below is a Python console
example of using the strip
method. You don't need to fully understand what's going on, but you can try it already!In [1]: creature = " donkey "
In [2]: creature.strip()
Out[2]: 'donkey'
Each
object's
set of methods
depends on its type
. For instance, all strings
have the same methods. The full list can be found from Python's documentation (surprise?). If you're working in the console you can also see the list of an object's attributes
(including its methods) with the dir
function
. You can do this by using any string as the dir function argument
, e.g empty string:In [1]: dir("")
Out[1]:
['__add__',
'__class__',
'__contains__',
'__delattr__',
'__dir__',
'__doc__',
'__eq__',
'__format__',
'__ge__',
'__getattribute__',
'__getitem__',
'__getnewargs__',
'__gt__',
'__hash__',
'__init__',
'__iter__',
'__le__',
'__len__',
'__lt__',
'__mod__',
'__mul__',
'__ne__',
'__new__',
'__reduce__',
'__reduce_ex__',
'__repr__',
'__rmod__',
'__rmul__',
'__setattr__',
'__sizeof__',
'__str__',
'__subclasshook__',
'capitalize',
'casefold',
'center',
'count',
'encode',
'endswith',
'expandtabs',
'find',
'format',
'format_map',
'index',
'isalnum',
'isalpha',
'isdecimal',
'isdigit',
'isidentifier',
'islower',
'isnumeric',
'isprintable',
'isspace',
'istitle',
'isupper',
'join',
'ljust',
'lower',
'lstrip',
'maketrans',
'partition',
'replace',
'rfind',
'rindex',
'rjust',
'rpartition',
'rsplit',
'rstrip',
'split',
'splitlines',
'startswith',
'strip',
'swapcase',
'title',
'translate',
'upper',
'zfill']
It returns a list of attributes. The ones in the beginning that are prefixed with underscores are intended only for the object's internal use and generally should not be touched. Just ignore them. The rest are all methods (in this case), and what we are interested in. Like functions they generally have more or less descriptive names. Let's take another method example that also completely coincidentally solves our current problem:
In [1]: word = "DoNkEy"
In [2]: word.lower()
Out[2]: 'donkey'
We could technically shorten this example to
"DoNkEy".lower()
, just to show you that you can attach method calls
to literals
as well. The period separates the object
from its method
. In other words the first part of the method call defines which object's method is being called, and the part after the period defines which method is called.One notable thing about this particular method call is that the parentheses are empty. This follows directly from the fact that the method operates on the object it belongs to. The information that would be inside the parentheses in a
function call
is now on the left side of the period instead. This does not mean there aren't methods that also have arguments
. After all, we do have functions with more than one argument as well.As our third example let's look at the
count
method
that can be used for counting how many times a shorter string
occurs inside another, longer string. Once again you can check what the method does from the documentation, or you can check it in the console
:In [1]: help("".count) Help on built-in function count: count(...) method of builtins.str instance S.count(sub[, start[, end]]) -> int Return the number of non-overlapping occurrences of substring sub in string S[start:end]. Optional arguments start and end are interpreted as in slice notation.
The line that shows how to use the method has an S before the period. This is where you put the longer string where you want to count occurrences of the shorter string from. Inside the parentheses you can see the method's three
arguments
first of which, sub, is mandatory while the other two, start and end, are optional (the square braces indicate this). Out of these, sub is the substring that we want to count. The optionals can be used to limit the search to only part of the S string.This method can be used for counting things like how many
comparison operators
are in a code file, or whether there's the correct number of elif statements in conditional structures
. This kind of code checking is called static checking where code is read as text instead of running it. That's just one use for scrutinizing strings
though. Reading and processing text on a computer is a major field in computer science, and you can start it with simple tricks like:text = input("Write a poem: ")
donkeys = text.count("donkey")
print("Your text contained", donkeys, "donkey(s))")
Method Chaining¶
String methods
can also be chained. This follows from the fact that most string methods return
a derived copy of the string
. When the line is executed, we can think of the method's return value replacing the method call
- just like we did with functions. There's also an important fact here: a string method never modifies the original string. The reason for this is that strings are an immutable
type
. The meaning of this will be clarified a bit later when we encounter the first type that is mutable. For now we have this animation to illustrate how strings methods, variables
and their values interact with each other.The same animation also shows that after the dust settles, there is a
string
in place of the method call
. One method that is commonly used in chaining is strip
. Let's consider a scenario where we want to add leading zeros to a number prompted from the user so that the number will always have 4 digits. The method that does this is zfill
. It takes the desired minimum length as its argument
. The code would look like this:number = input("Input number between 1 and 9999: ")
number = number.zfill(4)
print(number)
Normally this seems to work just fine:
Input number between 1 and 9999: 42 0042
However, what if the user accidentally puts some spaces before or after the number (or both, like in this example - you can highlight the text to see the trailing space)
Input number between 1 and 9999: 42 42
This is exactly the kind of a scenario where the strip method shines. Its default behavior is to remove all empty characters from the beginning and end of a
string
. The other empty characters besides spaces are newlines and tabs. It can also be given another set of characters to remove instead as an optional argument but stripping blanks is by far the most common use case. The method also has two relatives, lstrip and rstrip, that do the same thing but only from the beginning (lstrip, left) or end (rstrip, right). This method should be called before the zfill method, resulting in code like this:number = input("Input number between 1 and 9999: ")
number = number.strip().zfill(4)
print(number)
The execution of this line is explained in the following animation using " 42 " as a
value
for the number variable
.Now we can finally get back to the issue that sent us off on this wild
method
chase. The problem was that "length" and "Length" are entirely different things according to Python. We did already find a method to solve this issue: lower, which converts all letters of a string
to lower case. We also found a neat little method that strips all accidental blanks from an input. Luckily method calls can also be chained after any old function calls
as long as they return strings. Coincidentally input just happens to be that kind of a function. This allows us to use the following solution:choice = input("Make your choice: ").strip().lower()
Because we don't need the raw input elsewhere in the program, chaining calls like this is the best way to process it. This way we don't need to bother with the processing in multiple places and we also don't need to have a separate line for it. The
main program
as a whole becomes:print("This program converts US customary units to SI units")
print("Available features:")
print("length")
print("mass")
print("volume")
print("temperature")
print()
choice = input("Make your choice: ").strip().lower()
if choice == "length":
length()
elif choice == "mass":
mass()
elif choice == "volume":
volume()
elif choice == "temperature":
temperature()
else:
print("The selected feature is not available")
Operation Logic¶
If we're being completely real here, typing entire words in order to navigate a menu is pretty rude. In the modern world people mostly interact with menus using a mouse or touch screen but even back when we were programming next to bonfires in caves we had means to make menus a bit more painless. There's typically two ways to go about this. We can either enumerate the choices so that the user inputs a number to make a choice; or we can choose a letter to correspond with each choice (usually its initial letter if possible). Enumeration would result in an
interface
that looks like this:Available features: 1 - Length 2 - Mass 3 - Volume 4 - temperature Make your choice (1-4):
Whereas using single letters would give us something like:
Available features: (L)ength (M)ass (V)olume (T)emperature Make your choice:
The letters in parentheses indicate which character to input to choose a feature. This time we're going to go with the latter approach. This causes changes to how our instructions are printed and of course to the
conditional structure
that handles the choice making.We're actually going to be so understanding that the program doesn't only accept these single letter inputs but it will also accept full words.
Boolean operators
allow us to do this. They can be used to combine and negate existing conditions
. There's whole three of them: and
, not
, and or
. They are just like any other operators
(e.g. + or >=) but just happen to be words in Python. A lot of languages actually use symbols for these operators, mainly: &&, ! ,and ||, but the words in Python are a bit more descriptive. They are primarily used in conditional statements
when multiple conditions need to be combined, or one needs to be reversed.Let's start with the not operator which performs logical negation. One of the most common use cases for this is to check whether the user's
input
is entirely empty. An empty string
means a string that has no characters i.e. its length is zero. ""
and ''
are the only empty strings. For instance " "
is not empty because it contains a single space, and its length is one. You could test for an empty string like this:if choice == "":
but the preferred way is:
if not choice:
This line works the way it does because the
truthfulness
of an empty string is False. Negation with the not operator reverses this, return True if the condition itself is False, and False if the condition is True. A notable characteristic of the operator
is that it has only one operand
and it's always on its right side. For all intents and purposes it functions like a minus sign in front of a number.The other two
boolean operators
(and, or) are more commonly used because they can combine two conditions
. The result of the combination depends on the operator: and evaluates to True if and only if both operands are True; or evaluates to True if at least one of the its operands is True. For our current need, or does the job. We can test what it does outside conditional statements in the console:In [1]: choice = input("Make your choice: ").strip().lower()
Make your choice: L
In [2]: choice == "l" or choice == "length"
Out[2]: True
If we had used the and operator in the above example we would have created a condition that can never be True. The reason is simple: a
string
cannot have two different values ("l" and "length") at the same time. The and operator is more often used in cases where two variables are checked at the same time, or one variable is checked in two different ways. One such use case could be checking if a point is the origin:if x == 0 and y == 0:
Finally a word of warning about a common error:
if choice == "l" or "length":
When you read this aloud it might seem to make sense. However, if you read it like Python does, it turns out the operands of the or operator are actually
choice == "l"
and "length"
. As happened in the example when the user chose "m", the first operand is obviously False because "m" is not the same as "l". However, the second operand
- just the string
"length" by itself - is always True because it's not an empty string. The equality comparison operator
== only exists on the left side of the or operator. Once we reach the right side of the operator, the equality comparison no longer really exists. In other words this condition is always True regardless of what the user inputs
. It may feel stupid to write the whole comparison twice on the same line but we don't have a shorter way to express this - yet.As the result of this section, the
main program
has now taken this shape:print("This program converts US customary units to SI units")
print("Available features:")
print("(L)ength")
print("(M)ass")
print("(V)olume")
print("(T)emperature")
print()
choice = input("Make your choice: ").strip().lower()
if choice == "l" or choice == "length":
length()
elif choice == "m" or choice == "mass":
mass()
elif choice == "v" or choice == "volume":
volume()
elif choice == "t" or choice == "temperature":
temperature()
else:
print("The selected feature is not available")
Python's Style Academy¶
Let's go back to the program structure diagram:
We have a stub code for the the part circled with blue in the figure already. This
stub function
was used for showing that the menu implementation in the main program
worked as intended.def length():
print("Length was selected")
Our next goal is to turn this stub into a strong independent function. This function needs to prompt the user for two things: measurement unit and value. Way earlier we decided that the available units would be inch, foot, yard and mile. In addition to prompting these two things the function should use them to calculate the corresponding value in SI units. Inches and feet can be converted to centimeters, yards to meters and miles to kilometers. If we make decisions about the target unit like this without asking, it would also be polite to include it in the result. A use case could look like this:
Input unit: inch Input value: 12.4 12.40" is 31.50 cm
Following the solution model we learned before, this could be implemented with a
conditional structure
that has five branches
(4 for conditional
branches and 1 else for invalid unit choice), each containing their own calculation and output. This time there is little point in making separate functions
for each choice and instead their implementation should just be included inside the structure. Let's once again start with a stubbed version that doesn't have any additional fluff like instructions. The structure is already familiar from the main program
. Each conversion comes down to value * factor
, and the factors can be found from wikipediadef length():
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "inch":
print(value * 2.54)
elif unit == "foot":
print(value * 39.48)
elif unit == "yard":
print(value * 0.9144)
elif unit == "mile":
print(value * 1.609344)
else:
print("The selected unit is not supported")
With the functionality in place we get to this section's beef: formatting the results.
Learning goals: This section teaches you that some characters in strings can be problematic. Another important take away is how to produce beautiful output using the format method. Rounding gets revisited. After this section you'll be able to print all kinds of strings.
Escapism¶
We can do unit selection based on its abbreviation or symbol. In this case, in/", ft/', yd and mi. Let's start by plopping these in to the
conditional statements
we wrote just now. We'll use the or operator
for inches and feet so that both ways to choose them can be implemented.def length():
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == """:
print(value * 2.54)
elif unit == "ft" or unit == "'":
print(value * 39.48)
elif unit == "yd":
print(value * 0.9144)
elif unit == "mi":
print(value * 1.609344)
else:
print("The selected unit is not supported")
You should immediately notice that the syntax highlighting in this code looks a bit off. If one " character starts a string and a second one ends it, how should
"""
be interpreted? Trying to run this code gives an error message
File "/media/sf_virtualshare/OA/code/M2/converter_1.py", line 41 print("The selected feature is not available") ^ SyntaxError: EOF while scanning triple-quoted string literal
Triple-quoted string literal means a
string
that's been delimited with three quotation characters instead of single ones. So, a fact we've kept from you so far: """ also starts a string. Because there's no matching end delimiter for it in the file, Python encounters the end of the file before the end of the string, and ultimately throws a SyntaxError because of it. """ is actually a bit different from single quotes - strings delimited by them can go on for multiple lines:poem = """such string
many line"
wow"""
Well, that's cool? Doesn't solve the problem though. In general having the " character inside a string that's delimited with " characters is a bit problematic. We could sidestep the issue by delimiting with ' instead. This in turn would make ' characters inside the string problematic. Plus we would still run into problems with strings that contain - heavens forbid - both ' and ". Then what? Well, we're going to
escape
. That's what.Let's not get off our computers and skedaddle to the woods though. We're just going to use something called an escape character. In Python this character is backward slash, \. Using this character inside a string in front of another character changes the way the character is interpreted. For instance, an escaped ", i.e. \", is interpreted as the quote character instead of string delimiter. In code it looks like this:
In [1]: double_quote = "\""
In [2]: print(double_quote)
"
The character (or sometimes characters) after \ is interpreted differently. In other words it is escaped. Let's add this tech to our code:
def length():
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == "\"":
print(value * 2.54)
elif unit == "ft" or unit == "'":
print(value * 39.48)
elif unit == "yd":
print(value * 0.9144)
elif unit == "mi":
print(value * 1.609344)
else:
print("The selected unit is not supported")
Now we have a program that can already handle length conversions. It's still missing some of the prettifications that we planned to have though.
Beauty School¶
Instructional prints are easy enough to add to the beginning of the
function
since they are in no way different from what we did in the main program
. For results the plan was to make them print like this:12.40" is 31.50 cm
In an earlier example we saw a print
function call
that had more than one argument
and it looked like this:print("Added", name, "x", count)
It produced the following output when the variables had the values "donkey" and 1 respectively:
Added donkey x 1
The print function can accept an indefinite number of arguments, all of which will be printed on the same line, separated by spaces. As can be seen from the example each argument can be different: this one has both
literal values
and variables
- in fact the variables even have different types since one is a string
and one is an integer. It's therefore perfectly to valid to mix different types
in the arguments too. We can use this in our first attempt to print variable values inside an output line:print(value, "\"", "is", value * 2.54, "cm")
We can try it in the
Python console
:In [1]: value = 12.4
In [2]: print(value, "\"", "is", value * 2.54, "cm")
12.4 " is 31.496000000000002 cm
Close, but no bananas. The " character is separated from the value, the second decimal of 12.4 is missing (we wanted to show the 0) and the result hasn't been rounded. We can try to solve the most pressing issue - the lack of rounding - by dropping a round
function call
inside the print call.In [1]: value = 12.4
In [2]: print(value, "\"", "is", round(value * 2.54, 2), "cm")
12.4 " is 31.5 cm
Now the result is missing the desired second decimal as well. On top of that the code line starts to look really messy and it's hard to figure out what the output would look like. In general it's much preferable to use formatting tools for constructing any kinds of more complex
strings
. In Python this purpose is served by string formatting tools. When formatting strings, the literal string itself becomes sort of a template where various values can be inserted. A lot like elementary school assignments where you had to fill in the blanks:_____" is _____ cm
Well, we're not using underscores to mark the spots, but we do have another way to mark them. A simplest possible template for what we're trying to do is:
"{}\" is {} cm"
In this code the curly braces define spots where something else will be plopped into. This "plopping into" is enabled by turning the string into an
f string
, and placing the "something else" inside the curly braces. The example below uses a new variable result where we have assigned the result of the operation from the previous monster of a clause.f"{value}\" is {result} cm"
The same as a complete console example:
In [1]: value = 12.4
In [2]: result = round(value * 2.54, 2)
In [3]: print(f"{value}\" is {result} cm")
12.4" is 31.5 cm
This line has a much clearer structure: the shape of the output is easily readable from the structure of the template string. We also managed to attach the " firmly to the number, something the print call by itself didn't manage.
Let's also update our program to use
f strings
:def length():
print("Select unit of length from the options below using the abreviations")
print("Inch (in or \")")
print("Foot (ft or ')")
print("Yard (yd)")
print("Mile (mi)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == "\"":
si_value = round(value * 2.54, 2)
print(f"{value}\" is {si_value} cm")
elif unit == "ft" or unit == "'":
si_value = round(value * 39.48, 2)
print(f"{value}' is {si_value} cm")
elif unit == "yd":
si_value = round(value * 0.9144, 2)
print(f"{value} yd is {si_value} m")
elif unit == "mi":
si_value = round(value * 1.609344, 2)
print(f"{value} mi is {si_value} km")
else:
print("The selected unit is not supported")
Curly Styling¶
The power of placeholders doesn't end with specifying what values will be placed where in a
string
. They can also define additional formatting for the values. This material has two example cases that will both have some uses on a basic level course. One of these is adding leading zeros to integers and the other is defining how many decimals to display for floats
. Leading zeros are typically used for dates and times. A computer-readable timestamp usually looks like this: "2015-06-29 09:46:06"
. Adding the zeros is done with a placeholder that has additional formatting specification:In [1]: hours = 9
In [2]: f"{hours:02}"
Out[2]: '09'
The colon in the
placeholder
separates the formatting specification from the naming part. The zero following the colon indicates that we want to add leading zeros, whereas the 2 after that indicates that the result must be at least 2 characters long. In other words, zeros will be added if the value is shorter than 2 characters.Defining decimal precision is even more commonly done. This can be achieved with a pretty similar syntax:
In [1]: length = 4.451
In [2]: f"{length:.2f}"
Out[2]: '4.45'
This time the . character indicates that the number following it will be defining the desired decimal precision. The f at the end indicates that the value to be placed here must be treated as a float. If you don't have the f there this line can cause the following
exception
if the argument is an integer:ValueError: Precision not allowed in integer format specifier
Precision cannot be defined for integers because they don't have a decimal part. Defining precision means adding trailing zeros if necessary:
In [1]: length = 4
In [2]: f"{length:.2f}"
Out[2]: '4.00'
This also eliminates the need to use the round
function
because rounding is done by the formatting instead. This allows us to clean up our code lines a bit:si_value = value * 2.54
print(f"{value:.2f}\" is {si_value:.2f} cm")
With this the function becomes:
def length():
print("Select unit of length from the options below using the abreviations")
print("Inch (in or \")")
print("Foot (ft or ')")
print("Yard (yd)")
print("Mile (mi)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == "\"":
si_value = value * 2.54
print(f"{value:.2f}\" is {si_value:.2f} cm")
elif unit == "ft" or unit == "'":
si_value = value * 39.48
print(f"{value:.2f}' is {si_value:.2f} cm")
elif unit == "yd":
si_value = value * 0.9144
print(f"{value:.2f} yd is {si_value:.2f} m")
elif unit == "mi":
si_value = value * 1.609344
print(f"{value:.2f} mi is {si_value:.2f} km")
else:
print("The selected unit is not supported")
And this test run shows how pretty the results are:
This program converts US customary units to SI units Available features: (L)ength (M)ass (V)olume (T)emperature Make your choice: l Select unit of length from the options below using the abbreviations Inch (in or ") Foot (ft or ') Yard (yd) Mile (mi) Input source unit: mi Input value to convert: 65 65.00 mi is 104.61 km
Using
f string
for formatting has a wide variety of possibilities. This wealth of options can be admired at Python's documentation.Tying Strings Together¶
Here's the good news: of the remaining three
functions
we need to implement, two are pretty much identical to the length function. They just have different units and calculations. This means we can just write them out without further explanation. For mass we only have two units: ounce (oz) and pound (lb).def mass():
print("Select unit of mass from the options below using the abreviations")
print("Ounce (oz)")
print("Pound (lb)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "oz":
si_value = value * 28.349523125
print(f"{value:.2f} oz is {si_value:.2f} g")
elif unit == "lb":
si_value = value * 0.45359237
print(f"{value:.2f} lb is {si_value:.2f} kg")
else:
print("The selected unit is not supported")
And for volume:
def volume():
print("Select unit of mass from the options below using the abbreviations")
print("Cup (cp)")
print("Pint (pt)")
print("Quart (qt)")
print("Gallon (gal)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "cp":
si_value = value * 2.365882365
print(f"{value:.2f} cp is {si_value:.2f} dl")
elif unit == "pt":
si_value = value * 4.73176473
print(f"{value:.2f} pt is {si_value:.2f} dl")
elif unit == "yd":
si_value = value * 4.73176473
print(f"{value:.2f} qt is {si_value:.2f} l")
elif unit == "mi":
si_value = value * 3.785411784
print(f"{value:.2f} gal is {si_value:.2f} l")
else:
print("The selected unit is not supported")
For temperature we only have one unit to convert so we don't need to ask. This makes the function quite a bit shorter. The conversion from Fahrenheit to Celsius on the other hand is an artform in itself and cannot be done with a simple multiplication. The entire function looks like:
def temperature():
print("Temperature conversion from Fahrenheit to Celsius.")
fahrenheit = float(input("Input temperature: "))
celsius = (5 / 9) * (fahrenheit - 32)
print(f"{fahrenheit:.2f} °F is {celsius:.2f} °C")
We could have written the code inside each function to the
main program
as they are, in place of the function calls
. There would be no difference in how the program works. This would just add more indentation levels to the conditional structure
we have there. It sort of has the same amount of levels as is, we've just hidden that into the functions. If we got rid of all the functions, the main program would look like this:print("This program converts US customary units to SI units")
print("Available features:")
print("(L)ength")
print("(M)ass")
print("(V)olume")
print("(T)emperature")
print()
choice = input("Make your choice: ").strip().lower()
if choice == "l" or choice == "length":
print("Select unit of length from the options below using the abreviations")
print("Inch (in or \")")
print("Foot (ft or ')")
print("Yard (yd)")
print("Mile (mi)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == "\"":
si_value = value * 2.54
print(f"{value:.2f}\" is {si_value:.2f} cm")
elif unit == "ft" or unit == "'":
si_value = value * 39.48
print(f"{value:.2f}' is {si_value:.2f} cm")
elif unit == "yd":
si_value = value * 0.9144
print(f"{value:.2f} yd is {si_value:.2f} m")
elif unit == "mi":
si_value = value * 1.609344
print(f"{value:.2f} mi is {si_value:.2f} km")
else:
print("The selected unit is not supported")
elif choice == "m" or choice == "mass":
print("Select unit of mass from the options below using the abreviations")
print("Ounce (oz)")
print("Pound (lb)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "oz":
si_value = value * 28.349523125
print(f"{value:.2f} oz is {si_value:.2f} g")
elif unit == "lb":
si_value = value * 0.45359237
print(f"{value:.2f} lb is {si_value:.2f} kg")
else:
print("The selected unit is not supported")
elif choice == "v" or choice == "volume":
print("Select unit of mass from the options below using the abbreviations")
print("Cup (cp)")
print("Pint (pt)")
print("Quart (qt)")
print("Gallon (gal)")
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "cp":
si_value = value * 2.365882365
print(f"{value:.2f} cp is {si_value:.2f} dl")
elif unit == "pt":
si_value = value * 4.73176473
print(f"{value:.2f} pt is {si_value:.2f} dl")
elif unit == "yd":
si_value = value * 4.73176473
print(f"{value:.2f} qt is {si_value:.2f} l")
elif unit == "mi":
si_value = value * 3.785411784
print(f"{value:.2f} gal is {si_value:.2f} l")
else:
print("The selected unit is not supported")
elif choice == "t" or choice == "temperature":
print("Temperature conversion from Fahrenheit to Celsius.")
fahrenheit = float(input("Input temperature: "))
celsius = (5 / 9) * (fahrenheit - 32)
print(f"{fahrenheit:.2f} °F is {celsius:.2f} °C")
else:
print("The selected feature is not available")
This huge mess just makes it a lot harder to figure out how the program works. In particular the outer conditional structure becomes so long that the if that starts and the else that ends it don't fit on the same screen (or barely fit). If you compare this to the main program that uses function calls like we implemented it, it should be obvious how much clearer the entire thing is.
print("This program converts US customary units to SI units")
print("Available features:")
print("(L)ength")
print("(M)ass")
print("(V)olume")
print("(T)emperature")
print()
choice = input("Make your choice: ").strip().lower()
if choice == "l" or choice == "length":
length()
elif choice == "m" or choice == "mass":
mass()
elif choice == "v" or choice == "volume":
volume()
elif choice == "t" or choice == "temperature":
temperature()
else:
print("The selected feature is not available")
The entire program can be download below.
A Word on Dictionaries¶
If we look at the
conditional structures
of our unit converter we can discern a particular pattern: the structure is mostly used for choosing a conversion factor. Now, if there was another way to choose the factor, we could write the entire formula as value * conversion
. How would choosing the factor be implemented in this case? Well, one thing we could do is separate the factor selection from the printing by doing it beforehand like this:def length():
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
if unit == "in" or unit == "\"":
conversion = 2.54 / 100
elif unit == "ft" or unit == "'":
conversion = 30.48 / 100
elif unit == "yd":
conversion = 0.9144
elif unit == "mi":
conversion = 1.609344 * 1000
else:
print("The selected unit is not supported")
return
print(f"{value:.2f} {unit} is {value * conversion:.2f} m")
This by itself is not very different, and as a side effect we now have to convert everything to meters. The factors have been adjusted to reflect this. However, with a new useful type we can turn this into something much more elegant.
Learning goals: After this section you'll kow what dictionaries are in Python and a couple of different scenarios where they can be used to create elegant and dynamic code (i.e. code that's easy to adjust without making big changes). As another central topic we're looking into the difference between mutable and immutable objects and finally learn why it's important to understand variables as references to values.
Dictionary of Chaos¶
Dictionaries are
data structures
. Data structures in general are types
that contain other values
. Usually the values within a structure should be somehow related. Data structures also have their built-in means for placing values into them and retrieving the values. True to its name, dictionaries do this by word lookup. The words are called keys
and they can be any immutable types - but most commonly they are strings
.Just like words in real dictionaries are connected to explanations or translations, in Python dictionaries keys are connected to values. Whereas keys are usually strings, values can be anything - including other
data structures
! Likewise, the same key can only be present once in a dictionary but there are no such limitations for values. Lookup is one way: only keys can be used to look up values, not the other way around.A dictionary is marked with curly braces. Anything inside the braces is interpreted as the
dictionary's
definition. The syntax
requires that key-value pairs are separated from each other with commas, and keys are separated from values with colons. Because dictionary definitions tend to be rather wordy they are very commonly split into multiple lines. Typically splitting is done after the opening brace, and after each comma. The example below might hint at where we are going with dictionaries:length_factors = {
"in": 0.0254,
"ft": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
This example also shows a good convention of
indenting
the key-value pairs by one level. This indentation has no meaning for how the code is interpreted - it just makes the whole thing easier to read at a glance.Getting a Hold of the Keys¶
One selling point of
dictionaries
was getting values
from them by looking up with keys
. That's cool. How? There's two ways to do this: a common lookup syntax associated with data structures
and the dictionary get method
. The common lookup is made by adding a lookup value in square braces to the end of structure (or most commonly a variable
that contains the structure). For dictionaries the lookup value is a key. E.g. getting the conversion factor for yards from the dictionary we cooked up earlier:In [1]: length_factors = {
...: "in": 0.0254,
...: "ft": 0.3048,
...: "yd": 0.9144,
...: "mi": 1609.344
...: }
...:
In [2]: factor = length_factors["yd"]
In [3]: factor
Out[3]: 0.9144
A lookup from a dictionary
returns
the value corresponding to a key, which can then be assigned
to a variable. Instead of a variable, it can also be used inside a statement
just like a function call. So, how about replacing the conditional structure
we recently created with a key lookup...? A key can also be retrieved from a variable:In [4]: unit = "ft"
In [5]: print(length_factors[unit])
0.3048
Combining these new facts into a brand new length function:
def length():
length_factors = {
"in": 0.0254,
"ft": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
conversion = length_factors[unit]
print(f"{value:.2f} {unit} is {value * conversion:.2f} m")
As neat as this code looks it unfortunately has two (new) flaws: inch and foot cannot be looked up with their symbols; and unsupported user input is not dealt with in any way. The first one is quite easy to solve. As we stated earlier, the same value can be found with multiple keys, so we can just add the two:
length_factors = {
"in": 0.0254,
"\"": 0.0254,
"ft": 0.3048,
"'": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
This example also shows a bit of the dynamism we advertised earlier: we were able to add new features to the
function
just by editing this dictionary instead of having to alter the conditional structures
in the code (which we completely got rid of anyway).Now that we know that trying to look up with a key that doesn't exist in the dictionary results in an
exception
, the next step is to handle it. If the user's input
causes an exception we'll just print a friendly error message.def length():
length_factors = {
"in": 0.0254,
"\"": 0.0254,
"ft": 0.3048,
"'": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
try:
conversion = length_factors[unit]
except LastTasksAnswer:
print("The selected unit is not supported.")
else:
print(f"{value:.2f} {unit} is {value * conversion:.2f} m")
Invalid values are still left unhandled - we'll cover that one properly in the next material. Another thing: defining the
dictionary
inside the function is not smart because it's only used for reading values. If it's inside the function it gets redefined every time the function
is called. It would make more sense to define it in the beginning of the program as a constant
. In other words, like this (leaving the rest of program out for now).LENGTH_FACTORS = {
"in": 0.0254,
"\"": 0.0254,
"ft": 0.3048,
"'": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
def length():
unit = input("Input source unit: ")
value = float(input("Input value to convert: "))
try:
conversion = LENGTH_FACTORS[unit]
except LastTasksAnswer:
print("The selected unit is not supported.")
else:
print(f"{value:.2f} {unit} is {value * conversion:.2f} m")
The same can be done for the other functions. We should move on to modifying dictionaries though.
Wordsmithing Dictionaries¶
A common feature of
data structures
is that they can be modified. Dictionaries
support adding new key
-value
pairs during program execution. Values of keys can also be changed. In order to explore these features, let's create a new function. Imagine a program that processes measurement results from multiple sources. Each result is a dictionary with two key-value pairs: measurement unit and the measured value. E.g.:measurement = {
"unit": "in",
"value": 4.64
}
For further processing it would be better to have all measurements converted to the same unit. The dictionary we just created for the length
function
can be slightly modified to be used for this purpose. The goal is to make a function that converts one measurement to meters. The new function will have one parameter
. We no longer prompt the measurement and unit from the user because they are read from the dictionary instead, using the lookup we learned previously. Likewise the result will be calculated into a variable instead of printing it.def convert_to_meters(measurement):
value = measurement["value"]
unit = measurement["unit"]
meters = value * LENGTH_FACTORS[unit]
The only thing left to solve is saving the new values into the dictionary. The way to do it is actually astonishingly similar to what we did with lookups. We already know that the role of a
variable
changes if it's placed on the left side of an assignment operator
: it will be assigned to instead of using its value. The syntax of key lookup works exactly the same: if it's on the left side of an assignment operator, the value of the key will be replaced with whatever is on the right side:In [1]: measurement = {
...: "unit": "in",
...: "value": 4.64
...: }
...:
In [2]: measurement["unit"] = "m"
In [3]: measurement
Out[3]: {'unit': 'm', 'value': 4.64}
This looks rather simple, and isn't really that complicated, but there is one new concept involved:
dictionaries
are a mutable
type. What's noteworthy here is that new variables are not created at any point when the dictionary is modified - the modification is done directly to the same dictionary. Earlier in this material we said that strings
are an immutable
type and that all methods that appear to "change" the string actually return a modified copy. Nothing prevents us from using strings and replace to manage the measurements:In [1]: measurement = "12.984 in"
In [2]: measurement.replace("in", "m")
Out[2]: '12.984 m'
In [3]: measurement
Out[3]: '12.984 in'
In [4]: measurement = measurement.replace("in", "m")
In [5]: measurement
Out[5]: '12.984 m'
In this example we're first trying to "modify" the measurement but we soon notice that the effect of replace did not stick. Only the fourth line makes a change that actually sticks. However at this point it is no longer the same variable - it's a new one that has the same name. Dictionaries - like other mutable types - are the opposite: operations that modify them usually fiddle with the original directly. When messing around with dictionaries it's worth keeping in mind that they are basically collections of
references
to values. The values themselves are not really inside a dictionary as such - they reside in memory just like any old values. The fact that they correspond to dictionary keys
doesn't make them special in any way.If you stumble upon a need to actually have two identical
dictionaries
this can be achieved with the dictionary copy
method
. As the name suggests, it returns a copy of the dictionary. But that's not what we want right now. We specifically want a function that modifies the original measurement. We achieve it like this:def convert_to_meters(measurement):
value = measurement["value"]
unit = measurement["unit"]
meters = value * LENGTH_FACTORS[unit]
measurement["unit"] = "m"
measurement["value"] = meters
Is this missing something (other than exception handling)? This
function
doesn't have a return statement which seems a bit suspect... Except it isn't, this function is precisely what we want. When a mutable
value is modified the change is written directly into the original area in memory. This means that the change will be reflected to anywhere in code where this dictionary is accessed. We can prove this by implementing a simple main program
. Pay attention to how we print the measurement twice using the exact same line, and how the return value
of the function call
is not assigned to anything.LENGTH_FACTORS = {
"in": 0.0254,
"\"": 0.0254,
"ft": 0.3048,
"'": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
def convert_to_meters(measurement):
value = measurement["value"]
unit = measurement["unit"]
meters = value * LENGTH_FACTORS[unit]
measurement["unit"] = "m"
measurement["value"] = meters
measurement = {"unit": "in", "value": 4.64}
print(f"{measurement['value']:.3f} {measurement['unit']}")
print("is")
convert_to_meters(measurement)
print(f"{measurement['value']:.3f} {measurement['unit']}")
We can run this program and see that
4.640 in is 0.118 m
And what about the
exception
handling? It's still possible that the function receives a dictionary
that contains unsupported units (we're still naive enough to assume all values will be floats). Since we're studying here, let's do this handling in a way that teaches something new while we're at it. If we cannot convert a measurement we're going to tag a measurement with a new key-value pair: key "invalid" with a boolean value
- more specifically True if we were unable to convert the measurement. Adding a new key is done exactly the same way as modifying the value of an existing one - just like variables
once again. The exception handling itself is done by putting the lookup itself into try branch
, error handling in the except branch and dictionary update in the else branch:def convert_to_meters(measurement):
value = measurement["value"]
unit = measurement["unit"]
try:
meters = value * LENGTH_FACTORS[unit]
except KeyError:
measurement["invalid"] = True
else:
measurement["unit"] = "m"
measurement["value"] = meters
The actual new thing is that there's another way to retrieve
values
from a dictionary: the get
method
. In contrast to the lookup we learned earlier, this method doesn't cause an exception
when the key doesn't exist. Instead of causing a scene, the get method returns
a predetermined default value - None if not otherwise defined.In [1]: measurement_1 = {"unit": "m", "value": 1.0}
In [2]: measurement_2 = {"unit": "donkey", "value": 3.63, "invalid": True}
In [3]: measurement_1.get("invalid", False)
Out[3]: False
In [4]: measurement_2.get("invalid", False)
Out[4]: True
We can add handling of this into the main program for demonstration purpose:
LENGTH_FACTORS = {
"in": 0.0254,
"\"": 0.0254,
"ft": 0.3048,
"'": 0.3048,
"yd": 0.9144,
"mi": 1609.344
}
def convert_to_meters(measurement):
value = measurement["value"]
unit = measurement["unit"]
try:
meters = value * LENGTH_FACTORS[unit]
except KeyError:
measurement["invalid"] = True
else:
measurement["unit"] = "m"
measurement["value"] = meters
measurement = {"unit": "donkey", "value": 4.64}
print(f"{measurement['value']:.3f} {measurement['unit']}")
print("is")
convert_to_meters(measurement)
if not measurement.get("invalid", False):
print(f"{measurement['value']:.3f} {measurement['unit']}")
else:
print("Invalid unit")
Running results in
4.640 donkey is Invalid unit
This main program is a very artificial example. We just can't provide a more "real" use context without studying the next material first. Either way we've learned the basics of
dictionaries
and mutable
values here.Closing Words¶
In this material we've obtained three essential programming concepts: processing and producing text with
strings
; handling exceptions
with try-except structures; and most importantly how to control program flow using conditional structures
. Our programs transformed from doing simple calculations to programs where users can make real choices and get different results based on them. We also discovered that one big part of programming is transforming the so called engineer solutions to results that can be shown to anyone. Functional logic is just a part of programming - user experience is also important.We also took our first look at
data structures
with dictionaries
getting the honor of being the first one of them. Since dictionaries are also the first mutable
type in the material we learned some details about how that separates them from immutable
types like strings
.One noteworthy thing about this material is that in the latter example we routinely left user
inputs
unchecked to keep the code examples from running too rampant. Wherever we prompt numbers from the user we should always have a try-except structure to keep the program behaving. We will return to this topic in the next material and show how to do this without making the code harder to read. Another notable fault with the conversion program is that it has to be restarted after every conversion. This injustice will also be addressed in the future.Image Sources¶
- original license: CC-BY 2.0
- original license: CC-BY-NC 2.0 (caption added)
- original license: CC-BY 2.0 (caption added)
- original license: CC-BY 2.0 (caption added)
- original license: CC-BY 2.0
- original license: CC-BY 2.0 (caption added)
- original license: CC-BY 2.0 (caption added)
Give feedback on this content
Comments about this material