lobee.ca · Documents
🐍 A Guide to CSC108 Python
CSC108 is the University of Toronto's introductory programming course, taught in Python. This page is my study companion for it: one place for what the course tends to look for in your code, from naming and docstrings to the small bugs that cost the most marks.
Every code example here is original and generic, written just for this page. None of it comes from course materials, assignments or tests.
Style basics
Style marks are some of the easiest to keep. Past offerings have generally expected the following, and most of it comes straight from PEP 8, Python's own style guide.
- Names in
snake_casethat say what they hold:total_price, nottporx2. - Indent with 4 spaces. Don't use tabs.
- Put spaces around operators and after commas:
total = price * count. - Keep lines under 80 characters.
- Give every function a docstring (see the recipe below).
- When a question gives you a constant (an
ALL_CAPSname), use it instead of retyping its value.
def average_speed(distance_km: float, hours: float) -> float:
"""Return the average speed in km/h of a trip that covered
distance_km kilometres in hours hours.
Precondition: hours > 0
>>> average_speed(120.0, 2.0)
60.0
>>> average_speed(5.0, 0.5)
10.0
"""
return distance_km / hours
def avg(d,h):
return d/h
Some offerings have checked style automatically with a tool such as PyTA (python_ta). Whether yours does, and how much style is worth, varies, so check your course's rules.
The Function Design Recipe
The design recipe is the course's step-by-step way to write a function. It front-loads the thinking, so the body is usually the easy part.
- 1. Examples. Write a couple of calls and what they should return. Pick examples that differ. For a function that returns a
bool, show oneTrueand oneFalse. - 2. Header. Name the function and its parameters clearly, with type annotations such as
list[str]ordict[str, int]. - 3. Description. Start with a verb ("Return …"), say what it returns rather than how, and mention every parameter by name.
- 4. Body. Write the code.
- 5. Test. Run your examples, then try edge cases: an empty list, an empty string, a single item.
Worked example: counting long words
Say you need a function that counts how many words in a list are at least a given length.
Step 1: examples. Before any code, decide what a few calls should give back:
>>> count_long_words(['owl', 'falcon', 'heron'], 5)
2
>>> count_long_words(['owl', 'emu'], 4)
0
Step 2: header. Choose clear names and annotate the types. The function takes a list of strings and an int, and returns an int:
def count_long_words(words: list[str], min_length: int) -> int:
Step 3: description. Add the docstring: the examples from step 1, under a description that starts with a verb and names both parameters.
def count_long_words(words: list[str], min_length: int) -> int:
"""Return the number of strings in words that have at least
min_length characters.
>>> count_long_words(['owl', 'falcon', 'heron'], 5)
2
>>> count_long_words(['owl', 'emu'], 4)
0
"""
Step 4: body. Now the code. An accumulator (count) starts at 0 and grows inside the loop:
def count_long_words(words: list[str], min_length: int) -> int:
"""Return the number of strings in words that have at least
min_length characters.
>>> count_long_words(['owl', 'falcon', 'heron'], 5)
2
>>> count_long_words(['owl', 'emu'], 4)
0
"""
count = 0
for word in words:
if len(word) >= min_length:
count = count + 1
return count
Step 5: test. Run the docstring examples (next sections show how), then poke at the edges:
>>> count_long_words([], 3)
0
>>> count_long_words(['kiwi'], 4)
1
Docstrings, type annotations and preconditions
- Common annotations:
int,float,str,bool,list[int],list[str],dict[str, int], andNonefor a function that returns nothing. - A function that returns a
boolis usually described as "Return True if and only if …". - Describe the result, not the steps. "Return the number of …" beats "Loop over the list and add 1 …".
def has_double_letter(word: str) -> bool:
"""Return True if and only if word has the same character twice
in a row.
>>> has_double_letter('coffee')
True
>>> has_double_letter('tea')
False
"""
for i in range(len(word) - 1):
if word[i] == word[i + 1]:
return True
return False
A precondition is something the caller promises, so the function doesn't have to check it. Write it in the docstring, often as a Python expression; a question may ask for one written like 0 <= i < len(s).
def swap_ends(text: str) -> str:
"""Return text with its first and last characters swapped.
Precondition: len(text) >= 2
>>> swap_ends('stop')
'ptos'
>>> swap_ends('ab')
'ba'
"""
return text[-1] + text[1:-1] + text[0]
Functions whose results aren't fixed, such as ones that use random, read input or only print, usually skip docstring examples.
Testing with doctest
Docstring examples double as tests. You can paste each call into your IDE's Python shell (the course has used Wing 101) and compare, or let Python check them all at once with the doctest module. Running doctest is a general good habit; it may not be something you're required to hand in.
if __name__ == '__main__':
import doctest
doctest.testmod()
No output means every example passed. To see each one run, add -v from a terminal:
python -m doctest -v word_tools.py
Trying calls by hand in the shell looks like this. Each line under a call is what Python printed back:
>>> has_double_letter('coffee')
True
>>> has_double_letter('tea')
False
>>> has_double_letter('')
False
- Examples must match the real output exactly:
'ptos'with quotes for a string,60.0rather than60for a float. - Use the function's real name in its examples.
- One example is rarely enough. Cover the different cases the description mentions.
- Other test tools such as
pytestorunittestmay or may not come up; check your course's rules.
Following question constraints
Questions often restrict how you may solve them: "use a for loop", "do not use any loops", "use the constant", "call your helper". Ignoring a restriction can cost the whole question even when the output is right. The same task can be correct one way and wrong another:
def total_letters(words: list[str]) -> int:
"""Return the total number of characters in the strings in words.
>>> total_letters(['sun', 'moon'])
7
"""
total = 0
for word in words:
total = total + len(word)
return total
def total_letters(words: list[str]) -> int:
"""Return the total number of characters in the strings in words.
>>> total_letters(['sun', 'moon'])
7
"""
return len(''.join(words))
When a question tells you to use a helper function, call it. Don't copy its body:
def is_vowel(char: str) -> bool:
"""Return True if and only if char is a lowercase vowel.
Precondition: len(char) == 1
>>> is_vowel('e')
True
>>> is_vowel('z')
False
"""
return char in 'aeiou'
def starts_with_vowel(word: str) -> bool:
"""Return True if and only if word starts with a lowercase vowel.
Precondition: word != ''
>>> starts_with_vowel('apple')
True
>>> starts_with_vowel('pear')
False
"""
return is_vowel(word[0])
def starts_with_vowel(word: str) -> bool:
return word[0] in 'aeiou'
- Paper tests have often come with a reference page of allowed built-ins and methods. Stick to what's on it.
- On paper, partial code can still earn marks, and code is checked against the docstring examples, so write what you can.
- Use parameters and return values, not global variables.
- Functions that return a value generally shouldn't
printor callinputunless the question asks. Some offerings have disallowed both in graded functions; check your course's rules. - Import only what a question needs. File questions commonly use
csvandTextIOfromtyping. - Whether
breakor particular built-ins are allowed varies. When in doubt, check.
Topic map: weeks 1 to 7
A rough order of topics. It can shift between terms, and this page only covers the first seven weeks.
- Week 1: types; arithmetic with
/,//,%and**; order of operations; variables and the memory model; built-in functions. - Week 2: defining functions, the design recipe, testing, string basics.
- Week 3:
bool,if/elif/else, short-circuit evaluation, choosing docstring examples, indexing and slicing. - Week 4: string methods,
forandwhileloops, accumulators. - Week 5: lists and list methods,
range, looping by item and by index, aliasing. - Week 6: parallel lists and nested lists.
- Week 7: reading files (
for line in f,readline,read,readlines,csv.reader) and dicts, including the dict accumulator.
Common pitfalls
The mistakes that come up again and again. In each pair the better version is on the left (on top on a phone).
print is not return
Printing shows a value in the shell, but the caller gets None. With the version on the right, double(4) + 1 crashes.
def double(n: int) -> int:
"""Return n multiplied by 2.
>>> double(4)
8
"""
return n * 2
def double(n: int) -> int:
print(n * 2)
List methods change the list and return None
sort, append, extend, insert, remove and reverse change the list in place and return None. (pop is the exception: it returns the item it removed.) Use sorted(levels) if you want a new list.
levels = [3, 1, 2]
levels.sort()
# levels is now [1, 2, 3]
levels = [3, 1, 2]
levels = levels.sort()
# levels is now None
Strings never change: keep the result
String methods return a new string. If you don't assign the result, it's lost.
greeting = ' hello '
greeting = greeting.strip()
# greeting is now 'hello'
greeting = ' hello '
greeting.strip()
# greeting is still ' hello '
Returning too early inside a loop
For an "every item" check, return False as soon as one item fails, and return True only after the loop. The version on the right only looks at the first item, and returns None for an empty list.
def all_positive(numbers: list[int]) -> bool:
"""Return True if and only if every number in numbers is
greater than 0.
>>> all_positive([3, 8, 1])
True
>>> all_positive([3, -8, 1])
False
"""
for number in numbers:
if number <= 0:
return False
return True
def all_positive(numbers: list[int]) -> bool:
for number in numbers:
if number <= 0:
return False
else:
return True
Changing the loop variable does not change the list
number = number * 2 only rebinds the loop variable. To change the list, assign through an index.
def double_all(numbers: list[int]) -> None:
"""Modify numbers so that each item is doubled.
>>> values = [1, 4]
>>> double_all(values)
>>> values
[2, 8]
"""
for i in range(len(numbers)):
numbers[i] = numbers[i] * 2
def double_all(numbers: list[int]) -> None:
for number in numbers:
number = number * 2
Put the guard first
and stops at the first False, so check that an index is valid before using it. The version on the right raises IndexError for char_is('loop', 9, 'o').
def char_is(text: str, i: int, char: str) -> bool:
"""Return True if and only if i is a valid index of text
and text[i] is char.
>>> char_is('loop', 2, 'o')
True
>>> char_is('loop', 9, 'o')
False
"""
return 0 <= i < len(text) and text[i] == char
def char_is(text: str, i: int, char: str) -> bool:
return text[i] == char and 0 <= i < len(text)
More to watch for
- Slices and
rangestop before the stop value:'python'[0:2]is'py'andrange(3)is 0, 1, 2. - Slicing past the end is safe (
'abc'[1:10]is'bc'), but indexing past it crashes ('abc'[10]). - A function with no
returnstatement returnsNone. b = amakes an alias, not a copy. Usea[:]to copy a list (next section).- Lines read from a file end in
'\n', so strip them. - Values from
csv.readerare strings. Convert withintorfloatbefore doing maths. inon a dict checks the keys only, never the values.
Memory model and aliasing
A variable holds the id of an object (its memory address), not the object itself. Assignment copies the id. Two names that hold the same id are aliases: change a mutable object such as a list through one name, and the other name sees it.
>>> a = [1, 2, 3]
>>> b = a
>>> b.append(4)
>>> a
[1, 2, 3, 4]
>>> c = a[:]
>>> c.append(5)
>>> a
[1, 2, 3, 4]
>>> a is b
True
>>> a is c
False
>>> a == c[:4]
True
is asks "same object?" (same id). == asks "equal values?". The same rule is why a function can change a list you pass to it, like double_all above: the parameter is an alias for the caller's list.
Strings, ints, floats and bools are immutable. Operations on them build new objects, so other names keep the old value:
>>> word = 'cat'
>>> same = word
>>> word = word + 's'
>>> word
'cats'
>>> same
'cat'
Files and dicts quick reference
Reading files
A file function is usually handed a file that is already open. It reads from it and leaves opening and closing to the caller.
for line in f:loops over the lines, each ending in'\n'.f.readline()reads one line;''means you've reached the end.f.read()returns the whole rest of the file as one string.f.readlines()returns the rest as a list of lines.csv.reader(f)gives each row as a list of strings (see the csv docs).
from typing import TextIO
def read_nonblank_lines(text_file: TextIO) -> list[str]:
"""Return the lines of text_file that are not blank, with
surrounding whitespace removed.
"""
lines = []
for line in text_file:
line = line.strip()
if line != '':
lines.append(line)
return lines
A CSV with a header row, and a dict accumulator that totals it:
species,count
heron,1
gull,4
heron,2
gull,3
import csv
from typing import TextIO
def tally_sightings(sightings_file: TextIO) -> dict[str, int]:
"""Return a dict that maps each species in sightings_file to the
total number seen. sightings_file is an open CSV file with a
header row, then rows of species,count.
"""
sightings_file.readline() # skip the header row
totals = {}
for row in csv.reader(sightings_file):
species = row[0]
count = int(row[1]) # CSV values are strings
if species in totals:
totals[species] = totals[species] + count
else:
totals[species] = count
return totals
>>> sightings_file = open('sightings.csv')
>>> tally_sightings(sightings_file)
{'heron': 3, 'gull': 7}
>>> sightings_file.close()
Dicts
>>> stock = {'apple': 4, 'pear': 2}
>>> stock['plum'] = 7
>>> stock['apple']
4
>>> 'pear' in stock
True
>>> 2 in stock
False
>>> len(stock)
3
>>> for fruit in stock:
... print(fruit, stock[fruit])
...
apple 4
pear 2
plum 7
d[key] = valueadds a key or replaces its value.d[key]on a missing key raisesKeyError.key in dchecks keys only. Loop withfor key in d:and look values up withd[key].- Dict accumulator: check
if key in d:and add to the value, otherwise start it withd[key] = …(as intally_sightings). - More in the Python tutorial's data structures chapter.
Closing note
That's the core of it: follow the recipe, let your examples drive the code, and read every question's restrictions twice. If you spot something wrong here, the official course materials and your instructors always have the final word.
References: PEP 8, the doctest docs, the csv docs and the Python tutorial's data structures chapter.