COMP 202 Foundations of Programming • McGill University, Montreal

Corrected exercises: functions, scope and program design, COMP 202 at McGill

This is the corrected exercise set on functions for COMP 202, Foundations of Programming, the first Python course taken at McGill University. It is the chapter where an assignment stops being judged on whether it runs and starts being judged on how it is built. Part A covers the mechanics: parameters against arguments, keyword calls and default values, the several shapes of return, the scope rules and the frames they create, the design choice between a pure function and one that mutates, and the docstring with its tests. Part B works at midterm level: decomposing a long script, a library of date functions, five statements to correct, a simulation, and functions passed as values.

The thread running through the set: a function is a contract in three clauses, what it takes, what it gives back, and what it changes outside itself. Every question here is one of those three seen from a different angle. A function that prints instead of returning has no second clause and cannot be tested; one that mutates its argument without saying so has an undocumented third clause and breaks on the second call; one that reaches for a global has a clause it never declared.

The traps named explicitly in the solutions: a mutable default argument shared between calls, a positional argument written after a keyword one, an assignment anywhere in a body making the name local everywhere in it, return ending the function so the lines below never run, a validation that answers on the first element, values = values.sort() storing None, a function tested through another one whose bug it never exercises, sorting with key=len(names) instead of key=len, and a simulation that doubles one die instead of throwing two.

10 corrected exercises • 100 points • 150 minutes

Course recap

  • A parameter is a name in the header, an argument is a value at the call. Parameters with defaults come last.
  • A default value is evaluated ONCE at definition time: never use a list as one, use None and build the list in the body.
  • return hands a value back AND ends the function. A function with no return returns None.
  • return a, b returns one tuple; the caller unpacks it with lo, hi = f(...).
  • Reading a global name inside a function works; assigning to it makes the name LOCAL for the whole body, which is what UnboundLocalError reports.
  • A frame is created on the call and destroyed on the return, which is why two functions may use the same local names.
  • Python convention: a call that mutates returns None. sorted builds, sort reorders and returns nothing.
  • Compute in one function, print in another. Only what is RETURNED can be tested.
  • Three tests earn most of the marks: an ordinary case, a boundary, and a case that used to fail.
  • Compare a returned float with a tolerance, never with the equality sign.
  • A function is a value: sorted(names, key=len) passes the function, key=len(names) passes a number and raises.

Part A: the mechanics (/50)

Exercise 1: The header is a contract: parameters, arguments and defaults

The first line of a function names everything the outside world is allowed to say to it. A parameter is a name written in the header; an argument is the value handed over at the call. The two words are used interchangeably in conversation and never in an examination answer.

The figure takes one header apart. Note the parameter with a default: it makes the call shorter for the common case without hiding the value, which is the whole point of a default.

def average(marks, weight=1.0):the name: a verb or a noun, never f1a parameter, a name that exists only insidea parameter with a default, the caller may omit it return sum(marks) / len(marks) * weight
  • a) Give three ways of calling average with a list of marks and a weight of 0.5, and one call that is refused.
  • b) Why must def f(a=1, b) be refused by Python? Give the rule and the corrected header.
  • c) A function is called with its two arguments in the wrong order, both being numbers. What does Python do, and what does that say about testing?
  • d) Give the exact behaviour when a function of two parameters is called with one argument, and when it is called with three.
  • e) A parameter is given the default [] so that the function can append to it. Explain what goes wrong on the SECOND call, and give the standard repair.
Show the solution

a) Positionally, average(marks, 0.5). By keyword, average(marks, weight=0.5), which is the readable one because the reader of the call sees what 0.5 means. Mixed the other way round, average(weight=0.5, marks=marks), which is legal since all the arguments are then named. The refused call is average(weight=0.5, marks), with a positional argument AFTER a keyword one: Python raises SyntaxError, positional argument follows keyword argument, because it can no longer tell which parameter the positional value belongs to.

b) A parameter with a default may not be followed by one without, because a call such as f(7) would be ambiguous: does the 7 go to a or to b? Python refuses at the definition, SyntaxError, non-default argument follows default argument, rather than at the call. The corrected header is def f(b, a=1), which reads as the required things first, the optional ones after, and that is also the order to keep when a function grows.

c) Nothing is reported. Both values fit, the function computes something, and a wrong number comes out. Python checks the NUMBER of arguments, never their meaning, and a type annotation would not help here either since both are numbers. That is the argument for keyword arguments at the call site, and for tests whose expected value would be different under a swap: testing average with two equal marks proves nothing about the order.

d) With one argument: TypeError, average() missing 1 required positional argument, raised at the call and naming the missing parameter. With three: TypeError, average() takes 2 positional arguments but 3 were given. Both are raised BEFORE the body runs, which is why an arity mistake is cheap to find and a meaning mistake is not. Note that a parameter with a default is not counted as missing, since the default supplies it.

e) The default value is evaluated ONCE, when the def line runs, not once per call. So a single list is created and shared by every call that omits the argument: the first call appends to it and returns one element, the second call finds the leftovers of the first and returns two. The standard repair is def f(items=None): then, as the first line of the body, if items is None: items = [], which creates a fresh list on every call. The rule to remember is short: a default must be immutable, so a number, a string, a tuple or None.

Exercise 2: return: one value, several, or none at all

return does two things at once, and forgetting the second is a classic: it hands a value back, and it ENDS the function on the spot. Everything written after it in the same branch is unreachable.

A function that returns nothing still returns something, namely None, and that None then travels into the rest of the program where it will fail far from the function that produced it.

  • a) Write a function that returns both the minimum and the maximum of a list. How does the caller receive the two values, and what type is really returned?
  • b) A function ends with print(total) instead of return total. The caller writes x = f(marks) and then x + 1. What exactly happens, and where does the error surface?
  • c) Two returns are written one after the other in the same branch. What does the second one do?
  • d) A validation function returns True inside a loop and False after it. Explain why putting the False inside the loop as an else is a bug, with a list that shows it.
  • e) Compare a function with a single exit at the end and one with early returns in guard clauses. Give the case where the second is clearly better.
Show the solution

a) return min(values), max(values), and the caller writes lo, hi = min_max(values). What is really returned is a single object, a TUPLE of two elements: the comma builds it and the assignment unpacks it. Two consequences: the caller may also write pair = min_max(values) and then read pair[0], and unpacking with the wrong number of names raises ValueError, not enough values to unpack, which is a clear message pointing at the right line.

b) The function prints the total, then falls off its end, which returns None. So x is None, and x + 1 raises TypeError, unsupported operand type(s) for +: NoneType and int. The error surfaces in the CALLER, one line after the call, while the mistake is inside the function, and that distance is what makes the None the most annoying bug of the chapter. print shows a value to a human, return hands it to the program, and a function that only prints cannot be reused or tested.

c) Nothing at all. The first return ends the function, so the second is unreachable code: it never runs, and Python does not warn about it. The same holds for any line written after a return in the same branch. It is worth spotting because it usually means the author intended two different branches and forgot the if.

d) With the False inside the loop as an else of the first test, the function answers on the FIRST element and never looks at the rest. On the list holding 2, 4 and 7, a function meant to answer are they all even returns True after seeing 2 alone, and on the list holding 3 and 4 it returns False after seeing 3, which is right by luck. The correct shape is: return False as soon as a counterexample is found, and return True AFTER the loop, once every element has been seen. It is the flag pattern of the loops chapter with returns instead of a flag.

e) Guard clauses win whenever the special cases are simple and the main body is not: if the list is empty, return None right away, then write the real computation with no nesting. The single exit version has to wrap the whole computation in an else and the indentation grows with every case. The single exit is worth keeping when the function must always do something before leaving, such as closing a file, though in Python the with statement handles that better.

Exercise 3: Scope: what a name means, and where

A function call creates a frame of its own. Every parameter and every name assigned in the body lives in that frame, is invisible from outside, and disappears when the function returns. That is not a restriction, it is the reason a function can be written without knowing what names the rest of the program uses.

One rule governs the whole chapter: READING a name that is not local looks outside, ASSIGNING a name makes it local for the whole function, from its first line to its last.

the global frametotal = 0 names = ['Ana']the frame of one call to average()marks, weight, and every name assigned in the bodycreated on the call, gone on the returnreading a global name worksassigning one makes a LOCAL name
Python
count = 0

def register(name):
    print('so far:', count)
    count = count + 1
    return count
  • a) A function reads a variable defined at the top of the file without declaring anything. Does it work? And if it assigns to that same name?
  • b) Explain the error raised by the function below, and say why the message mentions a line that comes BEFORE the assignment.
  • c) Give the two repairs for that function, and say which one you would submit.
  • d) A parameter is called total and a global variable is called total. Which one does the body see, and what is the risk?
  • e) A loop variable i is used inside a function and also at the top level of the file. Do they interfere? Justify with the frames.
Show the solution

a) Reading works: a name that is not local is looked up in the global frame, which is why a function can use a constant defined at the top of the file. Assigning does not: the assignment makes the name local for the whole function, and the global variable is left untouched. So a function can consult the outside and cannot modify it by assignment, which is exactly the separation that makes functions safe to reuse.

b) It raises UnboundLocalError, cannot access local variable count where it is not associated with a value, and the line it names is the print, not the assignment. Python decides at COMPILE time, once, that count is local to register, because the body assigns to it somewhere; from then on every mention of count in that function refers to the local one. When the print runs, the local count has not been given a value yet, hence the error on a line that only reads it. The rule to state in an answer: the whole function is scanned before it runs, so an assignment anywhere in the body makes the name local everywhere in the body.

c) First repair, declare the intention: global count as the first line of the body, which makes the assignment act on the global variable. Second repair, do not touch the global at all: take the current value as a parameter and return the new one, def register(name, count): ... return count + 1, and let the caller keep the counter. Submit the second. A global that several functions write to is the shortest path to a bug that cannot be reproduced, and in COMP 202 the use of global usually costs style marks; the parameter and return version is testable, which the other is not.

d) The body sees the PARAMETER, because a parameter is a local name and local names win over global ones. The risk is not technical, it is human: a reader who knows the global total will misread every line of the function, and a later edit that removes the parameter will silently start using the global instead of failing. Shadowing is legal, and it is worth avoiding in exactly the cases where it is most tempting.

e) They do not interfere. The i of the function lives in the frame created by the call and disappears when the call ends; the i of the module lives in the global frame. Even if the function is called from inside the module level loop, the two frames coexist and each name is resolved in its own. That independence is what allows a function to be written without asking what the caller has named its variables, and it is the same mechanism that makes recursion possible.

Exercise 4: Design: give back something new, or change what you were given

Two functions can compute the same thing and be used in opposite ways. sorted(values) returns a NEW sorted list and leaves the argument alone; values.sort() reorders the list itself and returns None. Python is consistent about this: a call that changes its argument returns nothing, precisely so that the mistake of using its result is loud.

Choosing between the two shapes is a design decision, and the docstring is where it is written down.

  • a) A function is to remove the negative marks from a list. Write both versions, and give the docstring line that distinguishes them.
  • b) What does values = values.sort() leave in values, and why does the same line with sorted work?
  • c) Which version should a function that reads a file and returns statistics be? Justify with testability.
  • d) A function is documented as returning a new list but is written to modify the argument as well. Give the failure this produces in a program that calls it twice on the same data.
  • e) State the convention Python follows for the return value of a method that mutates, and give two examples other than sort.
Show the solution

a) The pure version builds and returns: def without_negatives(marks): return [m for m in marks if m >= 0], documented as returns a NEW list, the argument is unchanged. The in place version changes the caller's list and returns nothing: def drop_negatives(marks): then walk a COPY, for m in marks[:], and remove what must go, documented as modifies marks in place and returns None. Two different names for two different contracts is not pedantry: a reader who sees the result of drop_negatives assigned to something knows at once that the call is wrong.

b) It leaves None in values, and the sorted list is lost for ever, because sort reorders the list and returns None as every mutating method does. The same line with sorted works because sorted builds a new list and returns it: values = sorted(values) rebinds the name onto the sorted copy. The rule to carry: if a call changes something, do not use its result; if a call returns something, do not expect it to change anything.

c) The pure version: it takes what it needs and returns a value, so a test can call it and compare the result against an expected one, with nothing to set up and nothing to clean afterwards. An in place function has to be tested by building the input, calling, and then INSPECTING the argument, which is longer to write and easy to get wrong. In a first course, prefer a function that returns; keep in place work for the rare case where copying a large structure would really cost something.

d) The second call sees data already changed by the first, so it returns something different from the first call on what the caller believes is the same input. That is the worst kind of bug: it depends on history, it disappears when the program is run one step at a time, and it usually gets blamed on the caller. A function that does both is also impossible to describe in one sentence, which is a reliable warning sign.

e) The convention: a method that mutates returns None. Beyond sort, the list methods append, extend, insert, remove, reverse and clear all return None, and so does the dictionary method update. Their pure counterparts are the ones that return a value: sorted, reversed, the concatenation with the plus sign, and a comprehension. Knowing which side a call sits on is the single fact that prevents the assignment of a None.

Exercise 5: The docstring and the three tests that go with it

A docstring is not a comment about the code, it is the contract read by whoever calls the function without opening it. The table lists what it must contain. If one line of it cannot be written, the function is doing more than one thing and should be cut in two.

Tests are the same contract written as code. Three of them are enough to earn most of the marks: one ordinary case, one edge case, and one case that used to be wrong.

what the docstring must stateexample for average()what it takes, and of what typemarks, a list of numberswhat it gives backtheir mean, a floatwhat it changes outsidenothingwhat it does at the edgesraises on an empty listone example callaverage([8, 6]) gives 7.0
  • a) Write the docstring of a function letter_grade(mark) that turns a mark out of 100 into a letter.
  • b) Give three test calls for it, and say what each one is protecting.
  • c) Write those tests as assert statements. What does a passing test print, and is that a problem?
  • d) A function returns a float. Why is assert average([1, 2]) == 1.5 acceptable while assert average([1, 2, 2]) == 1.6666666666666667 is not, and what should be written instead?
  • e) A function both computes and prints. Explain why it cannot be tested, and give the split.
Show the solution

a) Three lines are enough: takes a mark, a number between 0 and 100; returns the corresponding letter, a string among A, B, C, D and F; raises ValueError on a mark outside the range. Adding one example call, letter_grade(87) gives A, makes the contract concrete and doubles as a test. What must NOT be in it is a paraphrase of the code, since a comment that repeats the body is a comment that will contradict it after the next edit.

b) One ordinary case in the middle of a band, such as 74 giving B, which protects the general shape. One case exactly on a BOUNDARY, such as 85 giving A, which protects the comparison sign, and boundaries are where the marks are lost. One case outside the range, such as -3, which protects the guard and states that the function refuses nonsense rather than returning F. A fourth is worth having on the other end, 100, since a cascade sometimes forgets its top.

c) assert letter_grade(74) == 'B', then assert letter_grade(85) == 'A', then a test that the invalid mark raises, which in a first course is written by hand with a try and an else that fails. A passing assert prints NOTHING, and that is exactly what is wanted: silence means every contract still holds, and the day one breaks, the failing line is named. A test suite that prints reassuring messages is a test suite whose failures scroll past unread.

d) The first is acceptable because 1.5 is exactly representable in binary and the division of 3 by 2 is exact, so the comparison is safe. The second is not: it hard codes seventeen digits of a value that depends on the rounding of one particular division, and reading it teaches nothing. Write assert abs(average([1, 2, 2]) - 5 / 3) < 1e-9, which states the expected value as a computation and compares with a tolerance, exactly as the floats chapter requires.

e) A test can only inspect what a function RETURNS, and this one returns None while its real result goes to the screen. Nothing can be compared to anything, so the function can be run and never verified. The split is always the same: one function computes and returns, another one takes that value and prints it. The computing half is then testable, reusable and unchanged the day the output moves to a file or a web page.

Part B: problems and reasoning (/50)

Exercise 6: Cutting one long program into functions

A first assignment is often written as one long script: read the file, compute the averages, decide the letters, print the report. It works, it is impossible to test, and changing one line of the report means rereading everything.

The graph shows the same program cut into six functions. Each box does one job whose name fits on the box, and a box calls only what is under it.

main()read_marks()class_average()report_line()average()letter_grade()each box is one job, named
  • a) Give the header and the one line contract of read_marks and of report_line.
  • b) Why is average() called by two different functions rather than written twice?
  • c) main() is the only function that prints. State the rule that produces that shape, and the benefit for testing.
  • d) A seventh job appears, writing the report to a file. Where does it go in the graph, and which existing function must change?
  • e) One of the six functions is much harder to test than the others. Name it, say why, and give the change that fixes it.
Show the solution

a) def read_marks(filename): returns a list of records, each holding a name and its list of marks, read from the file whose name is given, and it changes nothing outside. def report_line(record): returns the one line string describing that record, without printing it. Both contracts fit in a sentence, which is the test that the cut is at the right place. A function whose contract needs the word and twice is usually two functions.

b) Because the same computation defined twice becomes two DIFFERENT computations at the first edit. The day the average must ignore the lowest mark, one of the two copies gets the change and the class average stops agreeing with the individual ones. Calling one function from two places also means it is tested once and correct in both, and the graph shows that shared dependency at a glance, which is one of the things a call graph is for.

c) The rule: computing and displaying are two jobs, and only the outermost function displays. Everything below it returns values. The benefit is immediate: five of the six functions can be called from a test file, compared against expected values, and the whole program can be reused with a different output, a file or a web page, by rewriting main alone. It also means a wrong number can be located by testing the layers one by one, from the bottom.

d) The new function goes beside report_line, called by main, taking the list of lines and a filename, returning nothing. Only main changes, since it is the only function that decides what is done with the report; the functions that build the lines do not know and must not know whether their output is printed, written or discarded. If any function other than main has to change, the cut was wrong.

e) read_marks, because testing it needs a FILE to exist. The others take values and return values, so a test is one line. The fix is to split it in two: one function that turns a list of already read lines into records, which is pure and testable with a hand written list, and a thin one that opens the file and hands its lines over. The rule generalises: push the contact with the outside world, files, input and print, into the thinnest possible layer at the edge of the program.

Exercise 7: A small library of date functions, built on each other

Three functions, each one calling the one before: is_leap(year), days_in_month(month, year) and day_of_year(day, month, year). It is the standard shape of a useful module, and the standard place to see why a bug in the bottom function is invisible until the top one is tested.

The rule for leap years was met in the conditions set: divisible by 4, except a century, unless divisible by 400.

  • a) Write is_leap as a single return statement.
  • b) Write days_in_month using a list of the twelve lengths, and say where is_leap is called.
  • c) Write day_of_year, and give its value for the 1st of March 2024 and for the 1st of March 2023.
  • d) days_in_month is called with month 13. What should happen, and where does the check belong?
  • e) A test suite calls only day_of_year. Which bug in is_leap would it fail to reveal, and which years must therefore be in the tests?
Show the solution

a) def is_leap(year): return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0). One expression, one return, no if and no assignment to a variable named leap: a function that answers a yes or no question returns the boolean directly, and writing if condition: return True else: return False is the redundancy an examiner circles first.

b) lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], then return lengths[month - 1] + (1 if month == 2 and is_leap(year) else 0), or the same thing with an explicit if. The subtraction of one on the index is the whole trap: months are numbered from 1 and lists from 0. is_leap is called only for February, which is the only month whose length depends on the year, and calling it for every month would be harmless but would say something false about the problem.

c) def day_of_year(day, month, year): total = day, then for m in range(1, month): total = total + days_in_month(m, year), then return total. For the 1st of March 2024: 31+29+1=6131 + 29 + 1 = 61. For 2023, which is not a leap year: 31+28+1=6031 + 28 + 1 = 60. The loop runs over the months BEFORE the given one, which is exactly range(1, month), and using range(1, month + 1) would add the current month twice, an off by one that a test on January would never catch since the range is then empty.

d) It should raise, because there is no thirteenth month and returning something would let the error travel. Written as a guard at the top of days_in_month, if month < 1 or month > 12: raise ValueError with a message naming the value received. The check belongs in the function that INDEXES the list, not in day_of_year, because it is the one that would fail obscurely: lengths[12] raises IndexError, a message about a list where the caller passed a month.

e) A test suite that only calls day_of_year on dates in January and February of ordinary years never exercises the century rule, so an is_leap that answers True for 1900 passes every test. The years that must appear are the four of the leap rule, and they must appear on a date AFTER February, since the bug is invisible before it: the 1st of March 1900, expected 60, the 1st of March 2000, expected 61, plus an ordinary year and an ordinary leap year. Testing a function through another one is legitimate, provided the test cases are chosen for the function underneath.

Exercise 8: Five statements to correct

Each of the five statements below is plausible, has been written by a student in an examination, and is false. For each one, say what is wrong, give the counterexample that settles it, and write the correct version.

The marks go to the counterexample: a function, a call and the value that actually comes back.

  • a) 'A function that ends with print(total) returns the total to its caller.'
  • b) 'A variable assigned inside a function is visible afterwards, since the function ran.'
  • c) 'def f(items=[]) is a good way of giving a function an empty list to work with.'
  • d) 'A function may be called before its def in the file, since Python reads the whole file first.'
  • e) 'return values.sort() returns the sorted list.'
Show the solution

a) False. print sends characters to the screen and the function returns None. The counterexample: with def total_of(marks): print(sum(marks)), the call x = total_of([1, 2]) prints 3 and leaves x as None, so x + 1 raises TypeError. Correct version: print shows a value to a human, return hands it to the program, and only the second one makes a function reusable.

b) False. The name is local to the frame created by the call and disappears when the function returns. The counterexample: def f(): result = 42, then f(), then print(result) raises NameError, name result is not defined. Correct version: what a function computes leaves it only through its return value, or through an object it was handed and mutated.

c) False, and it is the classic. The default is evaluated once at definition time, so every call that omits the argument shares the SAME list. The counterexample: with def add(x, items=[]): items.append(x), return items, the call add(1) gives one element and the very next add(2) gives two, [1, 2], on what looks like a fresh list. Correct version: def add(x, items=None): and if items is None: items = [] as the first line.

d) False as stated. Python executes the file top to bottom, and the def statement is what creates the function: a call written above it at module level raises NameError. The counterexample is two lines, f() then def f(): pass. What IS true, and is the source of the confusion, is that a function may CALL another one defined later in the file, because the body is not executed until the call. Correct version: a name must exist when the line that uses it RUNS, not when it is written.

e) False. sort reorders the list in place and returns None, so the function returns None and the caller receives nothing usable. The counterexample: def sorted_marks(values): return values.sort(), and sorted_marks([3, 1]) is None while the list itself has quietly been reordered, which is a second surprise. Correct version: return sorted(values) to give back a new sorted list, or call values.sort() and return nothing, but say which one in the docstring.

Exercise 9: Simulating two dice, one function at a time

A simulation is where functions pay off, because the same three lines are called ten thousand times and each one has a name. The module random supplies randint(a, b), which returns an integer between a and b INCLUSIVE, unlike range.

The bars give the exact distribution of the total of two dice over the 36 equally likely rolls. A simulation is judged against it: with enough rolls the counts must approach these proportions, and if they do not, the bug is in the program and not in the dice.

12233445566758493102111120246how many of the 36 rolls give each total
  • a) Write roll_two(), returning the total of two dice. Why is randint(1, 6) + randint(1, 6) not the same as randint(2, 12)?
  • b) Write simulate(n) returning the number of rolls that gave 7. What should it return for n equal to 36000, roughly?
  • c) A test must check simulate. Explain the difficulty, and give the two standard ways around it.
  • d) The function is written as randint(1, 6) * 2. Give the distribution it produces and the two symptoms that reveal it.
  • e) The average total over many rolls is compared to the expected value. Compute that expected value, and give the tolerance you would use for 10000 rolls.
Show the solution

a) def roll_two(): return random.randint(1, 6) + random.randint(1, 6). The two are not the same because randint(2, 12) makes every total equally likely, one chance in eleven each, while two dice make 7 six times more likely than 2: there are six ways to make 7 and only one to make 2, as the bars show. Both produce numbers between 2 and 12, which is exactly why the bug survives a quick look at the output.

b) def simulate(n): then count = 0, then for i in range(n): if roll_two() == 7: count = count + 1, then return count. The probability of a 7 is 6/36=1/66/36 = 1/6, so about 6000 out of 36000. The word about is not vagueness: the count varies from run to run by a few dozen, and a simulation that returned exactly 6000 every time would be the suspicious one.

c) The difficulty is that the result is different on every run, so no expected value can be asserted. The two standard ways around it: seed the generator, random.seed(42) before the run, which makes the sequence reproducible so an exact assertion becomes possible; or assert a RANGE rather than a value, for instance that the count lies within a few percent of one sixth of n, which tests the statistics rather than the sequence. The first proves the code did not change, the second proves it is right.

d) It produces only the six even totals 2, 4, 6, 8, 10 and 12, each equally likely. The two symptoms: no odd total ever appears, which the bars say should be more than half the rolls, and the shape is flat instead of triangular. The mistake is doubling ONE die instead of throwing two, and it is worth recognising because the same slip turns any independent repetition into a single event multiplied.

e) The expected total is 2×3,5=72 \times 3{,}5 = 7, since each die averages (1+2+3+4+5+6)/6=3,5(1+2+3+4+5+6)/6 = 3{,}5. For 10000 rolls the standard deviation of the mean is about 2,42/100002{,}42/\sqrt{10000}, that is 0,0240{,}024, so a tolerance of 0,10{,}1 around 7 is comfortable and still fails on the doubled die version of question d), whose mean is also 7 but whose variance is wrong. That last remark is worth keeping: testing the mean alone does not distinguish the two, which is why the test that matters here counts the odd totals.

Exercise 10: A function handed to another function

A function is a value like any other: it can be stored in a variable, put in a list, and passed as an argument. That is what the key parameter of sorted uses, and it is the shortest path from writing loops to describing what is wanted.

The distinction to keep in sight is between f, the function itself, and f(x), the result of calling it. Writing the brackets by accident hands over the result instead of the function, and the error message is usually about something else.

  • a) A list of names must be sorted by LENGTH. Give the call, and say why the argument is len and not len().
  • b) The same list must be sorted case insensitively. Give the key, and say what happens to the original list.
  • c) Write apply_to_all(values, f) returning the list of the results. Give the call that squares every number.
  • d) A student writes sorted(names, key=len(names)). Describe the error and the misunderstanding behind it.
  • e) A list of records, each a name and an average, must be sorted by average, highest first. Give the call.
Show the solution

a) sorted(names, key=len). The argument is the function ITSELF, because sorted needs something it can call once per element; len() would call it immediately, with no argument, which raises TypeError before sorted ever runs. The distinction is the whole exercise: a name without brackets is the function, with brackets it is the result of running it.

b) sorted(names, key=str.lower), or with a small function of your own that returns name.lower(). The original list is untouched, since sorted always builds a new one; the key is used only for COMPARING, so the strings that come back keep their original capitals. That is the useful part: sorting by a transformed value without altering the values.

c) def apply_to_all(values, f): return [f(v) for v in values]. The call that squares is apply_to_all(numbers, square) with a square function defined nearby, and the comprehension is what does the work in one line. Writing f(v) inside the comprehension is the moment the parameter stops being an inert name and becomes a call, and it is worth noticing that apply_to_all needs to know nothing at all about what f does.

d) key=len(names) evaluates len(names) first, which is a NUMBER, the length of the list, and hands that number to sorted as its key. The error is TypeError, int object is not callable, raised when sorted tries to call the key on the first element. The misunderstanding is treating key as the value to sort on rather than as the RULE for extracting it: sorted is not told the lengths, it is told how to obtain one.

e) sorted(records, key=lambda r: r[1], reverse=True), or with a named function get_average(r) returning r[1] if lambda has not been seen yet, which is the version to prefer in a first course since it can be tested on its own. reverse=True is what turns the ascending order into a descending one, and it is a far better answer than sorting and then reversing, which costs a second pass and reads as a repair.

See also

Stuck in COMP 202?

I tutor COMP 202 at McGill in English or in French, in person in Montreal or online. Get in touch for a first session.

Site by Studio Squalli