COMP 202 Foundations of Programming • McGill University, Montreal
Corrected exercises: types, conversion and conditions for COMP 202 at McGill
This is the corrected exercise set on types, conversion and conditions for COMP 202, Foundations of Programming, the first Python course taken at McGill University. It is the chapter every assignment of the term rests on, and the one where a bug is hardest to see: nothing here crashes in an interesting way, the program simply answers the wrong question. Part A covers the mechanics: the meaning an operator takes from its operands, the four gates between text and numbers, floating point arithmetic and the tolerance that has to replace the equality sign, boolean algebra with De Morgan and the precedence of not, and the cascade of elif whose order IS its logic. Part B works at midterm level: two broken fare programs, the leap year rule, five statements to correct, a dosage calculator and progressive tax brackets.
The thread running through the set: a value's TYPE decides what happens to it, and everything entering a program enters as text. The same characters, 42, are a number to be halved or two characters to be printed depending only on which side of the border they sit. That is why input has to be wrapped in int or float, why comparing a string to a number stops the program, and why a total held as a float and a total held as an int behave differently after ten operations.
The traps named explicitly in the solutions: int truncating toward zero while floor division goes toward minus infinity, int('12.0') raising ValueError where int(12.9) succeeds, banker's rounding on an exact half, comparing computed floats with the equality sign, a validation written with or that accepts every number, not binding more loosely than a comparison, a cascade of thresholds in increasing order, a run of separate ifs where elif was meant, an else that quietly accepts invalid data, and a tax bracket applied to a whole income instead of a slice.
10 corrected exercises • 100 points
• 150 minutes
Course recap
•The type belongs to the value, not to the variable: 4 + 3.0 is a float, '4' * 3 is a string, True + True is the integer 2.
•input always returns a str. Wrap it in int or float before any arithmetic or any numerical comparison.
•int applied to a float truncates toward zero: int(-12.9) is −12, while −12.9//1 is −13.
•int applied to a string accepts digits only: int('12.0') raises ValueError, int(float('12.0')) works.
•round uses banker's rounding on an exact half: round(2.5) is 2 and round(3.5) is 4.
•A decimal is exact in base two only when its fractional part is a sum of halves: 0.5, 0.25, 0.125 are exact, 0.1 and 0.2 are not.
•Compare computed floats with abs(a - b) < 1e-9, never with the equality sign.
•Precedence, from tight to loose: comparison, then not, then and, then or. So not a == b means not (a == b).
•De Morgan: not (A and B) is (not A) or (not B), and not (A or B) is (not A) and (not B).
•A cascade of elif runs at most ONE branch and each condition assumes all those above it failed. A run of separate if statements runs them all.
•A validation range needs and, never or: mark >= 0 or mark <= 100 is true for every number.
Part A: the mechanics (/50)
Exercise 1: Types: what an operator means depends on its operands
Python asks for no type declarations, so nothing in the source code announces that total is an integer or that name is a string. The type travels with the VALUE, and an operator looks at the values it is handed before deciding what to do. The plus sign is not one operation: it is addition between numbers and concatenation between strings, and it is nothing at all between a string and a number.
The table lists the combinations a COMP 202 assignment actually produces. Read it once now, and most of the TypeError messages of the term become predictable rather than mysterious.
a) Give the VALUE and the TYPE of each expression: 4 + 3.0, '4' + '3', '4' * 3, True + True, 10 / 5.
b) '4' + 3 raises a TypeError while '4' * 3 does not. State the one rule that explains both.
c) A variable holds 7.0 and another holds 7. Both print as a number and both compare equal. Name one thing that goes differently with the first one.
d) x = 5 on one line and x = 'five' three lines later is legal Python. Say what it costs, and what an examiner reads into it.
e) type(v) == float and isinstance(v, float) both ask about the type of v. Which one should a program use, and why does isinstance(True, int) answer True?
Show the solution
a) 4 + 3.0 is 7.0, a float: mixing an int with a float promotes the whole expression to float, because every int fits in a float but not the reverse. '4' + '3' is '43', a string, since between two strings the plus sign glues rather than adds. '4' * 3 is '444', a string: an int beside a string means repeat. True + True is 2, an int, because bool is a subclass of int with True worth 1. And 10 / 5 is 2.0, a float, not the int 2, since the single slash always returns a float even when the division comes out even.
b) The rule: an operator is defined for the PAIR of types, not for one of them. The star is defined between str and int as repetition, so '4' * 3 has a meaning. The plus sign is defined between str and str, and between int and int, but not between str and int, because there is no defensible answer: is '4' + 3 the string '43' or the number 7? Python refuses to guess and raises TypeError instead of picking one. That refusal is a feature, and it is why the error surfaces at the line that caused it.
c) Division by an integer that comes out even, a comparison against an exact value, and printing. 7.0 prints as 7.0 and not as 7, so a report line reads 7.0 marks out of 10. A float also carries the rounding of section 3: after a few operations, 7.0 can become 6.999999999999999 while 7 stays 7 forever. When a value counts things, keep it an int; when it measures something, a float is right.
d) It is legal and it is a bad idea. Any reader, and the student in three weeks, holds a mental picture of what x is; changing the type under that picture is how a program acquires a TypeError two hundred lines from the line that caused it. In COMP 202 it also costs marks under style: a name stands for one thing. Give the string a name of its own.
e) Use isinstance. It is the only one of the two that survives inheritance, and it is what the marker expects. isinstance(True, int) is True because bool really is a subclass of int in Python: True is 1 wearing a different name, which is why True + True is 2 and why sum of a list of booleans counts how many are true. The consequence to remember: a test written as isinstance(x, int) accepts a boolean, so when only a real integer will do, check the value as well.
Exercise 2: The border: input, int, float and str
Everything a program receives from the outside world arrives as text. The call input() returns a string even when the user types 42, a line read from a file is a string, and a command line argument is a string. Nothing on the left of that border can be added, compared numerically or averaged.
The figure draws the border and the four gates through it. Half of the runtime errors in a first assignment happen because a value was used on the wrong side of that line.
a) A program does age = input('Your age: ') and then tests if age >= 18. Describe exactly what happens when the user types 20, and give the one line fix.
b) Give the result, or name the error, for int('12'), int('12.0'), int(12.9), int(-12.9). Then explain why int(-12.9) is not the same as -12.9 // 1.
c) Give the value and type of float('1e3'), str(3.0), float(7), int(True).
d) round(2.5) is 2 and round(3.5) is 4. Explain the rule, and say what round(2.675, 2) returns and why it is not what a student expects.
e) The user is asked for two numbers on one line, like 3 4. Describe the three steps from that one string to two integers.
Show the solution
a) In Python 3 the comparison itself fails: TypeError, comparison not supported between instances of str and int. Nothing is compared, the program stops there. It is worth noticing that the value is perfectly correct, the string '20' holds exactly what the user typed; only its type is wrong. The fix is one call: age = int(input('Your age: ')). In Python 2 the same code compared a string with a number and quietly returned an answer, which is why old tutorials get this wrong.
b) int('12') is 12. int('12.0') raises ValueError: int applied to a STRING accepts only the digits of an integer, and the decimal point is not one of them; the fix is int(float('12.0')). int(12.9) is 12 and int(-12.9) is -12, because int applied to a FLOAT truncates toward zero, it does not round. Floor division goes the other way: -12.9 // 1 is -13.0, because it floors toward minus infinity. On positive numbers the two agree, which is exactly why the disagreement is discovered on the marked assignment rather than during testing.
c) float('1e3') is 1000.0, a float: the scientific notation of a string is understood. str(3.0) is '3.0', a string, and the zero survives, which is why a report built by concatenation shows 3.0 marks. float(7) is 7.0, a float. int(True) is 1, an int, since bool is a subclass of int.
d) Python uses banker's rounding: a value exactly halfway goes to the EVEN neighbour, so 2.5 gives 2 and 3.5 gives 4. The reason is statistical, always rounding halves up biases a long sum upward. round(2.675, 2) returns 2.67 and not 2.68, and this one has nothing to do with banker's rounding: the float nearest to 2.675 is very slightly BELOW it, so the value being rounded was never exactly halfway. When money must be rounded a specific way, round the number of cents as an integer instead.
e) One, read the line: line = input(). Two, cut it into pieces on the whitespace: parts = line.split(), which gives the list ['3', '4'], two strings. Three, convert each piece: a = int(parts[0]) and b = int(parts[1]), or in one line a, b = int(parts[0]), int(parts[1]). The step students skip is the second: split is what turns one string into several, and without it int('3 4') raises ValueError.
Exercise 3: Floats: why adding a tenth a hundred times does not give ten
A float is stored in base two, and a fraction that terminates in base ten need not terminate in base two. One tenth is to base two what one third is to base ten: an endless repeating expansion that has to be cut off. What Python stores for 0.1 is therefore not 0.1 but the nearest representable value, about 0.1000000000000000055.
The table shows which decimals survive the trip and which do not. The rule is short: a decimal is exact in base two only when its fractional part is a sum of halves, quarters, eighths and so on.
a) print(0.1 + 0.2) shows 0.30000000000000004. Explain it in two sentences using the table, and say whether the error is in the addition or in the two operands.
b) Write the test that decides whether two floats are close enough to count as equal, and justify the tolerance you choose.
c) A till adds 0.1 a hundred times in a for loop. The total prints as 9.99999999999998. What does the built in sum of a list of a hundred values 0.1 print, and what does the difference tell you?
d) 0.5, 0.25 and 0.75 come out exact. State what those three share, and give one more decimal with two digits that is exact.
e) A price must appear with exactly two decimals. Compare rounding the value with formatting the output, and say which one belongs in a program that also stores the price.
Show the solution
a) Neither operand is what it looks like: the nearest float to 0.1 is slightly above one tenth, and the nearest float to 0.2 is slightly above two tenths. The addition itself is performed exactly and then rounded to the nearest float, and the sum of the two small excesses lands on the float just above 0.3, which prints as 0.30000000000000004. The error was already in the source code, before any arithmetic ran, which is why writing the same computation more carefully does not fix it.
b) Never test two computed floats with the equality sign. Test abs(a - b) < tol. For quantities of ordinary size in a first course, tol = 1e-9 is the right default: it is far larger than the rounding of a few dozen operations, about 1e-16 each, and far smaller than any difference that matters in the problem. When the numbers themselves are very large or very small, compare relatively, abs(a - b) < tol * abs(b), because an absolute tolerance of 1e-9 is meaningless next to a value of 1e12.
c) The built in sum returns exactly 10.0, while the hand written loop returns 9.99999999999998. sum has kept a compensation term for the part it had to drop at each addition, and gives back the correctly rounded total; the naive loop drops that part a hundred times over and the losses add up. The lesson is not that one is magic: it is that the ORDER and the METHOD of accumulation change the answer in floating point, where they never do in mathematics. When a total matters, use sum, or accumulate in integer cents.
d) All three have a fractional part built out of a half and a quarter: 0.5 is one half, 0.25 is one quarter, 0.75 is a half plus a quarter. Any decimal whose fractional part is a whole number of some power of one half is exact, so 0.125, 0.375 and 0.625 are exact too. With two digits after the point, 0.25 and 0.75 are the only exact ones besides 0.5 and the whole numbers: 0.05, 0.1 and 0.2 are not.
e) Rounding CHANGES the stored value, formatting changes only what the reader sees. A program that keeps adding prices must not round them one by one, because each rounding introduces its own error and the total drifts away from the sum of the true values; it should carry the full value and format at the moment of printing, with an f-string of the shape f'{price:.2f}'. Rounding the value is right in exactly one situation: when the rounded number IS the quantity, for example a payment that will really be charged to the cent.
Exercise 4: Boolean expressions, precedence and De Morgan
A condition is an expression like any other: it has a value, and that value can be given a name, returned, or stored in a list. Reading a condition as a THING rather than as a piece of grammar is what makes the rest of the chapter easy.
The table gives the two basic columns. The two columns of De Morgan, the ones that let a condition be turned inside out, are what question a) asks you to add.
a) Copy the table and add the columns not (A and B) and (not A) or (not B). What law have you just verified, and what is its twin for not (A or B)?
b) How does Python parse not a == b? Give the two possible readings and say which one Python takes.
c) A mark must be between 0 and 100 inclusive. Write the test three ways: with and, with a chained comparison, and give a wrong version using or that is true for every number.
d) if flag == True: and if flag: both work when flag is a boolean. Give a value of flag for which they behave differently.
e) Rewrite not (age < 18 or has_permit == False) with no not anywhere and no comparison to True or False.
Show the solution
a) The two new columns are identical, line by line: False, True, True, True. That is De Morgan's first law, not (A and B) is the same as (not A) or (not B). The twin is not (A or B) equals (not A) and (not B). Both say the same thing in words: to deny that two things both hold, deny at least one; to deny that either holds, deny both. The practical use is that a negated condition can always be pushed inward until no not remains, which is how an unreadable guard becomes readable.
b) The two readings are (not a) == b and not (a == b). Python takes the SECOND, because comparison binds more tightly than not, which in turn binds more tightly than and, which binds more tightly than or. So not a == b tests whether a and b differ. The reading students expect, (not a) == b, needs its own parentheses. The safe habit is to parenthesise anything that mixes not with a comparison, since the cost of a redundant pair of brackets is zero and the cost of this one is a wrong answer that runs without error.
c) With and: mark >= 0 and mark <= 100. Chained: 0 <= mark <= 100, which Python evaluates as the same thing and which reads like the mathematics. The wrong version is mark >= 0 or mark <= 100, and it is true for EVERY number, since any number is either at least zero or at most one hundred, and often both. It is the single most common bug in a validation loop, and it is invisible in testing because the program never rejects anything.
d) Any truthy value that is not the boolean True. With flag = 1 the two agree, because 1 == True is True; but with flag = 'yes', with flag = [1, 2] or with flag = 2, the plain if flag: enters the branch while flag == True is False and skips it. Prefer if flag:, and reserve the explicit comparison for the case where only the boolean True will do. Comparing to False has the same problem, and if not flag: is the readable form.
e) Push the negation in with De Morgan, then simplify each piece: not (age < 18 or has_permit == False) becomes (not age < 18) and (not has_permit == False), that is age >= 18 and has_permit. Read aloud it says the person is at least eighteen and holds a permit, which is what the rule meant all along. The version with the negation on the outside said the same thing backwards, and that is exactly what makes it hard to check against a specification.
Exercise 5: The cascade: elif, and why its order is the logic
A chain of elif tests its conditions from top to bottom and stops at the first that holds. That single sentence contains the whole difficulty: each condition is read as if every condition above it had already failed, so a condition that is correct on its own can be unreachable where it stands.
The bands drawn below are the specification. The code given underneath claims to implement them.
Python
mark = 87
if mark >= 50:
grade = 'D'
elif mark >= 60:
grade = 'C'
elif mark >= 70:
grade = 'B'
elif mark >= 85:
grade = 'A'
print(grade)
a) What does the program print for mark = 87? For which marks does it print anything other than D?
b) Repair it in two different ways: one that changes only the order, one that keeps the order and changes the conditions.
c) The same four tests are rewritten with four separate if statements instead of one cascade. How many of them are evaluated for mark = 87, and what is printed?
d) The cascade has no else. What happens for mark = -3, and at which line does the program fail?
e) Rewrite the two nested conditionals below as a single condition, and say which version you would submit.
Show the solution
a) It prints D. Every mark of 50 or more satisfies the very first test, the cascade stops there, and the three branches below are dead code that no mark can ever reach. The only marks that do not print D are those under 50, and for them nothing is printed at all, which is question d). This is the failure mode to recognise on sight: a cascade of thresholds written in increasing order collapses onto its first branch.
b) First repair, reverse the order so the most demanding test comes first: mark >= 85 gives A, then mark >= 70 gives B, then mark >= 60 gives C, then mark >= 50 gives D, then else gives F. Each condition then means what it appears to mean, since everything above it has failed. Second repair, keep the order and close every band on both sides: 50 <= mark < 60 gives D, 60 <= mark < 70 gives C, 70 <= mark < 85 gives B, mark >= 85 gives A. It works, and it is what you write when the branches are not ordered along a line, but it states each boundary twice, so a change to one cutoff has to be made in two places.
c) All four are evaluated, because separate if statements do not know about each other. For mark = 87 every one of the four conditions is true, grade is assigned D, then C, then B, then A, and the program prints A. It is right here by accident: the last assignment wins, and the tests happen to be in increasing order. Change the order of the four statements and the answer changes. A cascade says at most one of these, a run of separate ifs says all of these in turn, and choosing the wrong one is a logic error the interpreter cannot see.
d) For mark = -3 no condition holds, no branch runs, and grade is never assigned. The failure is not in the cascade: it is at print(grade), with NameError, name grade is not defined. That distance between the cause and the crash is the reason to end every cascade with an else, even when you believe it cannot be reached. Assigning grade = 'F' before the cascade, or an else that raises a clear message, both remove the problem.
e) The nested pair if age >= 18: then inside it if has_ticket: becomes the single condition if age >= 18 and has_ticket:. Submit the single condition whenever the inner test does nothing else, since one line replaces two and there is no dangling middle case. Keep the nesting only when the outer test also has its own else, that is when being under eighteen calls for a message of its own; then flattening would force you to repeat the age test in the second branch.
Part B: problems and reasoning (/50)
Exercise 6: A fare program, read line by line
Two versions of the same fare program are given. Neither is correct. One fails with an error message, which is the good kind of bug; the other runs to completion and charges the wrong fare, which is the kind that reaches the marker.
The rule to implement: under 6 years old travels free, from 6 to under 18 pays 2.25 dollars, from 18 to under 65 pays 3.50 dollars, 65 and over pays 2.75 dollars.
Python
# version 1
age = input('Age: ')
if age < 6:
fare = 0.00
elif age < 18:
fare = 2.25
elif age < 65:
fare = 3.50
else:
fare = 2.75
print('Fare:', fare)
# version 2
age = int(input('Age: '))
if age < 6:
fare = 0.00
if age < 18:
fare = 2.25
if age < 65:
fare = 3.50
else:
fare = 2.75
print('Fare:', fare)
a) Version 1 is run and the user types 40. What is printed? Quote the reason, and give the corrected line.
b) Version 2 is run with 4, then with 40, then with 70. Give the three fares it prints.
c) Explain the fare version 2 charges a four year old, following the three if statements in order.
d) In version 2, which if does the else belong to, and what does that imply for a passenger aged 70?
e) Give the corrected program, and list the four ages you would test it with.
Show the solution
a) Nothing is printed. The comparison age < 6 raises TypeError, comparison not supported between instances of str and int, because input returned the string '40'. The program stops at that line, and the print is never reached. The corrected line is age = int(input('Age: ')), which is exactly what version 2 does. Note that the error names the line responsible, which is why an error message is worth more than a wrong number.
b) With 4 it prints 3.50. With 40 it prints 3.50. With 70 it prints 2.75. Only the last one is right, and the coincidence that two thirds of the ages give a plausible looking number is what lets this bug survive a quick test.
c) The three ifs are independent statements, so all three run. Age 4 satisfies the first, fare becomes 0.00. It also satisfies the second, since 4 is less than 18, and fare is overwritten with 2.25. It satisfies the third as well, since 4 is less than 65, and fare is overwritten again with 3.50. The last assignment that runs is the one that survives, and the child is charged the adult fare. Nothing in the code is wrong on its own line: the error is the missing word elif.
d) The else belongs to the THIRD if, the one testing age < 65, because an else attaches to the nearest if at the same indentation. So a passenger of 70 fails that third test, the else runs, and the fare is 2.75, correct by luck. If the third if had been an elif of the first, the whole meaning of that else would change. Attaching an else to the wrong if is an error the interpreter cannot report, since both readings are valid Python.
e) The corrected program reads age = int(input('Age: ')), then if age < 6: fare = 0.00, elif age < 18: fare = 2.25, elif age < 65: fare = 3.50, else: fare = 2.75, then print. The four ages to test are the BOUNDARIES, not four random numbers: 5 and 6 around the first cutoff, 17 and 18 around the second, 64 and 65 around the third, plus one obviously invalid value such as -1 to see what the program does with nonsense. Testing 4, 40 and 70 is what let both versions through in the first place.
Exercise 7: The leap year rule, and the price of testing in the wrong order
A year is a leap year when it is divisible by 4, except that a year divisible by 100 is not, except that a year divisible by 400 is. Three rules, each one cancelling the one before, which makes this the standard exercise on the ORDER of conditions. It is also a rule with real teeth: a well known spreadsheet still treats 1900 as a leap year for reasons of backwards compatibility.
The tools are the modulo operator, which returns the remainder, and the fact that a condition is an expression with a value of its own.
Python
if year % 4 == 0:
leap = True
elif year % 100 == 0:
leap = False
elif year % 400 == 0:
leap = True
else:
leap = False
a) State the rule as three sentences in decreasing order of priority, the way the code will read.
b) Write the whole rule as a single boolean expression assigned to a variable named leap.
c) Apply your expression to 1900, 2000, 2023 and 2024, showing the remainders you used.
d) A student writes the cascade below. Which of the four years above does it get wrong, and why is the mistake invisible on 2023 and 2024?
e) Which four years must a test suite contain, and what does each one protect against?
Show the solution
a) Highest priority first: if the year is divisible by 400 it is a leap year; otherwise if it is divisible by 100 it is not; otherwise if it is divisible by 4 it is; otherwise it is not. Written in that order each test means what it says, because everything above it has already failed. Written in the opposite order, as in the code, the first test swallows every case.
b) leap = (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0). Read it aloud: divisible by four but not by a hundred, or else divisible by four hundred. The parentheses are not decorative here, and even though and binds tighter than or and the expression would parse the same way without them, an examiner reads the intent from the brackets. The expression has a value, True or False, so it can be assigned, returned or printed directly, and writing if leap == True: after it adds nothing.
c) For 1900: 1900mod4=0, so the first half starts well, but 1900mod100=0, so the first bracket is False; and 1900mod400=300, so the second bracket is False too. Not a leap year. For 2000: 2000mod4=0 and 2000mod100=0, first bracket False; but 2000mod400=0, second bracket True. Leap year. For 2023: 2023mod4=3, both brackets False, not a leap year. For 2024: 2024mod4=0 and 2024mod100=24, first bracket True. Leap year.
d) It gets 1900 wrong, and it is the only one of the four it gets wrong. The year 1900 is divisible by 4, so the very first test succeeds, leap is set to True, and the two corrections below are never reached. It gets 2000 right by accident, for exactly the same reason: the first test succeeds and returns True, which happens to be the correct answer. On 2023 and 2024 the cascade cannot go wrong, because neither year is divisible by 100 and the corrections are irrelevant. In other words, three of the four test cases pass on a cascade whose whole structure is upside down.
e) Four years, one per branch of the rule. 2024, an ordinary leap year, protects the main case. 2023, an ordinary common year, protects the else. 1900, divisible by 100 but not by 400, protects the first correction and is the year that catches the cascade above. 2000, divisible by 400, protects the second correction. A test suite made of 2020, 2021, 2022 and 2023 exercises one single branch four times and proves nothing about the other three.
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 statement contradicted with a concrete pair of values is refuted; a statement contradicted with an opinion is not.
a) 'int(-7/2) and -7//2 are two ways of writing the same thing.'
b) 'If the user types 42, then input returns the number 42.'
c) 'The test 0.1 + 0.2 == 0.3 is True, since both sides are the same number.'
d) 'not a == b means the same as (not a) == b.'
e) 'Replacing every elif of a cascade by an if never changes what the program does.'
Show the solution
a) False. The expression -7/2 is -3.5, and int truncates toward zero, giving -3; while -7//2 floors toward minus infinity, giving -4. The two agree on positive operands, 7/2 gives 3 both ways, which is why the confusion survives. Correct version: int truncates toward zero, floor division rounds toward minus infinity, and they differ on every negative division that is not exact.
b) False. input always returns a str, whatever the user typed. The counterexample is one line: after n = input(), the expression n + 1 raises TypeError while n + '1' produces '421'. Correct version: input returns the characters typed, as a string, and a conversion such as int or float is required before any arithmetic.
c) False, the test is False. The two sides are not the same number: the nearest float to 0.1 and the nearest float to 0.2 add up to the float just above 0.3, and 0.1 + 0.2 - 0.3 is about 5.6e-17, not zero. Correct version: two computed floats are compared with a tolerance, abs(a - b) < 1e-9, never with the equality sign.
d) False. Comparison binds more tightly than not, so Python reads not (a == b), which is the same as a != b. Counterexample with a = 0 and b = False: not a == b is not (0 == False), that is not True, that is False; while (not a) == b is (not 0) == False, that is True == False, that is False as well. Take a = 1 and b = True instead: not a == b is not (1 == True), that is False, while (not a) == b is False == True, also False. The clean counterexample is a = 2, b = True: not a == b is not (2 == True) which is True, and (not a) == b is False == True which is False. Correct version: not applies to the whole comparison, and the other reading needs its own brackets.
e) False. It changes what runs, and often what is printed. With a cascade at most one branch runs; with separate ifs every condition is tested and every matching branch runs, so a later assignment overwrites an earlier one. The counterexample is exercise 6: the fare program charges a four year old the adult fare of 3.50 dollars once the elifs become ifs. Correct version: elif means at most one of these, a run of if means each of these in turn, and the two coincide only when the conditions are mutually exclusive.
Exercise 9: A dosage calculator, boundaries included
A paediatric syrup is prescribed by weight band, not by a formula: the pharmacist reads the band and gives the dose printed for it. The program must read a weight typed by a parent, refuse anything absurd, find the band and print the dose.
The bands are drawn below. Every boundary is closed on the LEFT, that is a weight of exactly 10 kilograms belongs to the second band, not to the first.
a) Write the cascade that turns a weight in kilograms into a dose in millilitres, with the boundaries as specified.
b) A weight of exactly 10.0 gives which dose under your cascade? Show the test that decides it.
c) The parent types 18,5 with a comma. What exactly happens, and what should the program do about it?
d) A weight is accepted only if it is strictly positive and below 200. Write the validation, and say why it must run BEFORE the cascade.
e) The weight is not typed but computed, as 35.0 minus a 0.0000001 rounding residue. Which band does the cascade choose, and what does that say about testing a boundary with floats?
Show the solution
a) if weight < 10: dose = 2.5, elif weight < 20: dose = 5.0, elif weight < 35: dose = 7.5, else: dose = 10.0. Written this way each cutoff appears exactly once and the bands cannot overlap or leave a gap, which is the reason to prefer a cascade of one sided tests over four two sided ones. The order matters as always: written from the largest cutoff down, with the same comparison signs, every weight would fall in the last band.
b) Exactly 10.0 gives 5.0 millilitres. The first test asks weight < 10, which is False for 10.0 since the comparison is strict, so the cascade moves on to weight < 20, which is True. That is precisely the specification, the boundary belongs to the band on its right. Writing weight <= 10 in the first test would give the child of ten kilograms the dose of the band below, and no test on 8 or 15 kilograms would ever reveal it. The boundary is the value to test.
c) float('18,5') raises ValueError: could not convert string to float. Python reads the decimal point, not the decimal comma, and in Montreal a parent writing in French will type the comma naturally. The program must not crash on it: either replace the comma before converting, with text.replace(',', '.'), or catch the ValueError and ask again. The choice belongs to the specification, but doing nothing means the program stops with a traceback in front of a user who did nothing unreasonable.
d) if weight <= 0 or weight >= 200: then print a message and stop; otherwise run the cascade. It must run first because the cascade has an else, and an else accepts everything that reached it: a weight of 900 or of -4 falls into the last band and the program prints a dose of 10 millilitres for a value that is not a weight at all. A cascade never validates, since its last branch is a catch all by design. Validation and classification are two different jobs and they belong in that order.
e) 34.9999999 is strictly less than 35, so the third test succeeds and the cascade returns 7.5 millilitres, the band below. That is the correct behaviour for the number it was given, and the wrong answer for the weight that was meant. A float that comes out of a computation should not be compared to a boundary as if it were exact: either round it to the precision the problem actually has, weight = round(weight, 1) for a scale that reads tenths of a kilogram, or compare with a tolerance. The general rule from exercise 3 applies here too, with the added twist that here the tolerance decides a dose.
Exercise 10: Progressive brackets: the classic bug is a whole salary
Income tax is computed by BRACKETS: the first slice of income is taxed at one rate, the next slice at a higher rate, and so on. The graph below draws the total tax against income for the rates used here, namely nothing on the first 15 thousand dollars, 15 percent from 15 to 50, 25 percent from 50 to 100, and 35 percent above 100.
Every quantity is in thousands of dollars so the numbers stay readable. The bug this exercise is built on is the one that appears in every discussion of tax on the internet: applying the rate of the top bracket to the whole income.
a) Compute the tax on an income of 50, then on an income of 100, showing each slice.
b) Write the cascade that computes the tax for any income, using the totals of question a) as the constants of the higher branches.
c) A first attempt writes if income > 50: tax = income * 0.25. Compute what it charges on an income of 60, compare with the correct figure, and say what the difference means for the taxpayer.
d) Check that your cascade agrees with itself at the boundary of 50, coming from below and from above. Why does that check matter more than testing a random income?
e) A raise takes someone from an income of 49 to an income of 51. Compute the extra tax, and answer the claim that a raise can leave you worse off.
Show the solution
a) On an income of 50: the first 15 are taxed at nothing, and the 35 that remain are taxed at 15 percent, so the tax is 35×0.15=5.25 thousand dollars. On an income of 100: the same 5.25 for everything up to 50, plus the 50 between 50 and 100 taxed at 25 percent, that is 12.5, for a total of 17.75 thousand dollars. Each slice is taxed at its own rate and the results are added; nothing is ever taxed twice.
b) if income <= 15: tax = 0, elif income <= 50: tax = (income - 15) * 0.15, elif income <= 100: tax = 5.25 + (income - 50) * 0.25, else: tax = 17.75 + (income - 100) * 0.35. Each branch charges the full tax of the brackets below, a constant computed once, plus the rate of the current bracket on the part of the income that lies INSIDE it, hence the subtraction. Writing income * 0.25 instead of (income - 50) * 0.25 is the whole bug of question c).
c) The wrong version charges 60×0.25=15 thousand dollars. The correct figure is 5.25+10×0.25=7.75 thousand. The taxpayer is charged nearly twice what is owed, and the error grows with the distance above the boundary, which means it is at its smallest exactly where a test would put it, just above 50. The two versions differ by the tax on the part of the income that belongs to the LOWER brackets, and it is always the same mistake: forgetting that a bracket taxes a slice, not a total.
d) From below, the branch for income <= 50 gives (50−15)×0.15=5.25. From above, the branch for income <= 100 at income 50 gives 5.25+0×0.25=5.25. The two agree, and that agreement is exactly what makes the tax continuous, which is what the graph shows as a change of slope with no jump. A boundary is where the two branches meet and where an off by one in a comparison sign hides; a random income of 63 exercises one branch and proves nothing about the join.
e) The tax at 49 is (49−15)×0.15=5.10; at 51 it is 5.25+1×0.25=5.50. The extra tax is 0.40 on an extra income of 2, so the taxpayer keeps 1.60 thousand dollars of the raise. The claim that a raise makes you poorer confuses the MARGINAL rate, which applies only to the slice above the boundary, with an average rate applied to everything. It would be true of the buggy program of question c), which is exactly why this bug is worth being able to name: at an income of 51 that program charges 12.75 instead of 5.50.