08-defensive.md 18.9 KB
Newer Older
1
---
2
3
4
5
title: Defensive Programming
teaching: 30
exercises: 0
questions:
Greg Wilson's avatar
Greg Wilson committed
6
- "How can I make my programs more reliable?"
7
8
9
10
11
12
13
objectives:
- "Explain what an assertion is."
- "Add assertions that check the program's state is correct."
- "Correctly add precondition and postcondition assertions to functions."
- "Explain what test-driven development is, and use it when creating new functions."
- "Explain why variables should be initialized using actual data values rather than arbitrary constants."
keypoints:
Greg Wilson's avatar
Greg Wilson committed
14
15
16
17
18
- "Program defensively, i.e., assume that errors are going to arise, and write code to detect them when they do."
- "Put assertions in programs to check their state as they run, and to help readers understand how those programs are supposed to work."
- "Use preconditions to check that the inputs to a function are safe to use."
- "Use postconditions to check that the output from a function is safe to use."
- "Write tests before writing code in order to help determine exactly what that code is supposed to do."
19
---
Greg Wilson's avatar
Greg Wilson committed
20

21
Our previous lessons have introduced the basic tools of programming:
22
23
24
25
26
variables and lists,
file I/O,
loops,
conditionals,
and functions.
27
What they *haven't* done is show us how to tell
28
whether a program is getting the right answer,
29
30
31
32
33
34
and how to tell if it's *still* getting the right answer
as we make changes to it.

To achieve that,
we need to:

Azalee Bostroem's avatar
Azalee Bostroem committed
35
36
37
*   Write programs that check their own operation.
*   Write and run tests for widely-used functions.
*   Make sure we know what "correct" actually means.
38
39

The good news is,
40
41
doing these things will speed up our programming,
not slow it down.
42
As in real carpentry --- the kind done with lumber --- the time saved
43
by measuring carefully before cutting a piece of wood
44
is much greater than the time that measuring takes.
45

46
## Assertions
47

48
49
The first step toward getting the right answers from our programs
is to assume that mistakes *will* happen
50
and to guard against them.
51
52
This is called [defensive programming]({{ page.root }}/reference/#defensive-programming),
and the most common way to do it is to add [assertions]({{ page.root }}/reference/#assertion) to our code
53
54
55
so that it checks itself as it runs.
An assertion is simply a statement that something must be true at a certain point in a program.
When Python sees one,
Ashwin Srinath's avatar
Ashwin Srinath committed
56
it evaluates the assertion's condition.
57
If it's true,
58
Python does nothing,
59
but if it's false,
60
Python halts the program immediately
Azalee Bostroem's avatar
Azalee Bostroem committed
61
and prints the error message if one is provided.
62
For example,
63
this piece of code halts as soon as the loop encounters a value that isn't positive:
64

65
~~~
66
numbers = [1.5, 2.3, 0.7, -0.001, 4.4]
67
68
total = 0.0
for n in numbers:
Sung Bae's avatar
Sung Bae committed
69
    assert n > 0.0, 'Data should only contain positive values'
70
    total += n
71
print('total is:', total)
72
~~~
73
{: .language-python}
74
75

~~~
76
---------------------------------------------------------------------------
77
AssertionError                            Traceback (most recent call last)
78
<ipython-input-19-33d87ea29ae4> in <module>()
79
80
      2 total = 0.0
      3 for n in numbers:
Sung Bae's avatar
Sung Bae committed
81
----> 4     assert n > 0.0, 'Data should only contain positive values'
82
      5     total += n
83
      6 print('total is:', total)
84

85
86
AssertionError: Data should only contain positive values
~~~
87
{: .error}
88

89
Programs like the Firefox browser are full of assertions:
Geoff Cleary's avatar
Geoff Cleary committed
90
91
10-20% of the code they contain
are there to check that the other 80–90% are working correctly.
92
Broadly speaking,
93
94
assertions fall into three categories:

95
*   A [precondition]({{ page.root }}/reference/#precondition) is something that must be true at the start of a function in order for it to work correctly.
Greg Wilson's avatar
Greg Wilson committed
96

97
*   A [postcondition]({{ page.root }}/reference/#postcondition) is something that the function guarantees is true when it finishes.
Greg Wilson's avatar
Greg Wilson committed
98

99
*   An [invariant]({{ page.root }}/reference/#invariant) is something that is always true at a particular point inside a piece of code.
100
101

For example,
102
suppose we are representing rectangles using a [tuple]({{ page.root }}/reference/#tuple) of four coordinates `(x0, y0, x1, y1)`,
Azalee Bostroem's avatar
Azalee Bostroem committed
103
representing the lower left and upper right corners of the rectangle.
104
In order to do some calculations,
Azalee Bostroem's avatar
Azalee Bostroem committed
105
we need to normalize the rectangle so that the lower left corner is at the origin
106
and the longest side is 1.0 units long.
107
This function does that,
108
but checks that its input is correctly formatted and that its result makes sense:
109

110
~~~
111
def normalize_rectangle(rect):
112
    '''Normalizes a rectangle so that it is at the origin and 1.0 units long on its longest axis.
113
    Input should be of the format (x0, y0, x1, y1).
114
    (x0, y0) and (x1, y1) define the lower left and upper right corners of the rectangle, respectively.'''
115
    assert len(rect) == 4, 'Rectangles must contain 4 coordinates'
116
    x0, y0, x1, y1 = rect
117
118
    assert x0 < x1, 'Invalid X coordinates'
    assert y0 < y1, 'Invalid Y coordinates'
119

120
121
    dx = x1 - x0
    dy = y1 - y0
122
    if dx > dy:
123
124
125
126
127
128
        scaled = float(dx) / dy
        upper_x, upper_y = 1.0, scaled
    else:
        scaled = float(dx) / dy
        upper_x, upper_y = scaled, 1.0

129
130
    assert 0 < upper_x <= 1.0, 'Calculated upper X coordinate invalid'
    assert 0 < upper_y <= 1.0, 'Calculated upper Y coordinate invalid'
131

132
133
    return (0, 0, upper_x, upper_y)
~~~
134
{: .language-python}
135

136
The preconditions on lines 3, 5, and 6 catch invalid inputs:
137

138
~~~
139
print(normalize_rectangle( (0.0, 1.0, 2.0) )) # missing the fourth coordinate
140
~~~
141
{: .language-python}
142
143

~~~
144
---------------------------------------------------------------------------
145
AssertionError                            Traceback (most recent call last)
146
<ipython-input-21-3a97b1dcab70> in <module>()
147
----> 1 print(normalize_rectangle( (0.0, 1.0, 2.0) )) # missing the fourth coordinate
148

149
<ipython-input-20-408dc39f3915> in normalize_rectangle(rect)
150
      1 def normalize_rectangle(rect):
151
      2     '''Normalizes a rectangle so that it is at the origin and 1.0 units long on its longest axis.'''
152
----> 3     assert len(rect) == 4, 'Rectangles must contain 4 coordinates'
153
      4     x0, y0, x1, y1 = rect
154
      5     assert x0 < x1, 'Invalid X coordinates'
155

156
157
AssertionError: Rectangles must contain 4 coordinates
~~~
158
{: .error}
159

160
~~~
161
print(normalize_rectangle( (4.0, 2.0, 1.0, 5.0) )) # X axis inverted
162
~~~
163
{: .language-python}
164
165

~~~
166
---------------------------------------------------------------------------
167
AssertionError                            Traceback (most recent call last)
168
<ipython-input-22-f05ae7878a45> in <module>()
169
----> 1 print(normalize_rectangle( (4.0, 2.0, 1.0, 5.0) )) # X axis inverted
170

171
<ipython-input-20-408dc39f3915> in normalize_rectangle(rect)
172
      3     assert len(rect) == 4, 'Rectangles must contain 4 coordinates'
173
      4     x0, y0, x1, y1 = rect
174
175
----> 5     assert x0 < x1, 'Invalid X coordinates'
      6     assert y0 < y1, 'Invalid Y coordinates'
176
      7
177

178
179
AssertionError: Invalid X coordinates
~~~
180
{: .error}
181

182
The post-conditions on lines 17 and 18 help us catch bugs by telling us when our calculations cannot have been correct.
183
For example,
184
if we normalize a rectangle that is taller than it is wide everything seems OK:
185

186
~~~
187
print(normalize_rectangle( (0.0, 0.0, 1.0, 5.0) ))
188
~~~
189
{: .language-python}
190
191

~~~
192
193
(0, 0, 0.2, 1.0)
~~~
194
{: .output}
195

196
197
but if we normalize one that's wider than it is tall,
the assertion is triggered:
198

199
~~~
200
print(normalize_rectangle( (0.0, 0.0, 5.0, 1.0) ))
201
~~~
202
{: .language-python}
203
204

~~~
205
---------------------------------------------------------------------------
206
AssertionError                            Traceback (most recent call last)
207
<ipython-input-24-5f0ef7954aeb> in <module>()
208
----> 1 print(normalize_rectangle( (0.0, 0.0, 5.0, 1.0) ))
209

210
<ipython-input-20-408dc39f3915> in normalize_rectangle(rect)
211
     16
212
213
     17     assert 0 < upper_x <= 1.0, 'Calculated upper X coordinate invalid'
---> 18     assert 0 < upper_y <= 1.0, 'Calculated upper Y coordinate invalid'
214
     19
215
216
     20     return (0, 0, upper_x, upper_y)

217
218
AssertionError: Calculated upper Y coordinate invalid
~~~
219
{: .error}
220

221
Re-reading our function,
222
we realize that line 11 should divide `dy` by `dx` rather than `dx` by `dy`.
223
224
225
(You can display line numbers by typing Ctrl-M, then L.)
If we had left out the assertion at the end of the function,
we would have created and returned something that had the right shape as a valid answer,
226
but wasn't.
227
Detecting and debugging that would almost certainly have taken more time in the long run
228
229
230
than writing the assertion.

But assertions aren't just about catching errors:
231
232
233
they also help people understand programs.
Each assertion gives the person reading the program
a chance to check (consciously or otherwise)
234
235
236
that their understanding matches what the code is doing.

Most good programmers follow two rules when adding assertions to their code.
237
The first is, *fail early, fail often*.
238
The greater the distance between when and where an error occurs and when it's noticed,
239
the harder the error will be to debug,
240
241
so good code catches mistakes as early as possible.

242
The second rule is, *turn bugs into assertions or tests*.
Azalee Bostroem's avatar
Azalee Bostroem committed
243
244
Whenever you fix a bug, write an assertion that catches the mistake
should you make it again.
245
246
247
248
If you made a mistake in a piece of code,
the odds are good that you have made other mistakes nearby,
or will make the same mistake (or a related one)
the next time you change it.
249
Writing assertions to check that you haven't [regressed]({{ page.root }}/reference/#regression)
250
(i.e., haven't re-introduced an old problem)
251
252
253
can save a lot of time in the long run,
and helps to warn people who are reading the code
(including your future self)
254
255
that this bit is tricky.

256
## Test-Driven Development
257

258
An assertion checks that something is true at a particular point in the program.
259
260
The next step is to check the overall behavior of a piece of code,
i.e.,
261
to make sure that it produces the right output when it's given a particular input.
262
263
264
265
For example,
suppose we need to find where two or more time series overlap.
The range of each time series is represented as a pair of numbers,
which are the time the interval started and ended.
266
The output is the largest range that they all include:
267

268
![Overlapping Ranges](../fig/python-overlapping-ranges.svg)
269

270
271
Most novice programmers would solve this problem like this:

272
273
274
1.  Write a function `range_overlap`.
2.  Call it interactively on two or three different inputs.
3.  If it produces the wrong answer, fix the function and re-run that test.
275

276
This clearly works --- after all, thousands of scientists are doing it right now --- but
277
278
there's a better way:

279
280
281
1.  Write a short function for each test.
2.  Write a `range_overlap` function that should pass those tests.
3.  If `range_overlap` produces any wrong answers, fix it and re-run the test functions.
282
283

Writing the tests *before* writing the function they exercise
284
is called [test-driven development]({{ page.root }}/reference/#test-driven-development) (TDD).
285
286
Its advocates believe it produces better code faster because:

287
288
289
290
291
292
1.  If people write tests after writing the thing to be tested,
    they are subject to confirmation bias,
    i.e.,
    they subconsciously write tests to show that their code is correct,
    rather than to find errors.
2.  Writing tests helps programmers figure out what the function is actually supposed to do.
293
294

Here are three test functions for `range_overlap`:
295

296
~~~
297
assert range_overlap([ (0.0, 1.0) ]) == (0.0, 1.0)
298
assert range_overlap([ (2.0, 3.0), (2.0, 4.0) ]) == (2.0, 3.0)
299
300
assert range_overlap([ (0.0, 1.0), (0.0, 2.0), (-1.0, 1.0) ]) == (0.0, 1.0)
~~~
301
{: .language-python}
302
303

~~~
304
---------------------------------------------------------------------------
305
AssertionError                            Traceback (most recent call last)
306
<ipython-input-25-d8be150fbef6> in <module>()
Azalee Bostroem's avatar
Azalee Bostroem committed
307
308
----> 1 assert range_overlap([ (0.0, 1.0) ]) == (0.0, 1.0)
      2 assert range_overlap([ (2.0, 3.0), (2.0, 4.0) ]) == (2.0, 3.0)
309
310
      3 assert range_overlap([ (0.0, 1.0), (0.0, 2.0), (-1.0, 1.0) ]) == (0.0, 1.0)

311
AssertionError:
312
~~~
313
{: .error}
314

315
316
The error is actually reassuring:
we haven't written `range_overlap` yet,
317
318
so if the tests passed,
it would be a sign that someone else had
319
320
321
322
and that we were accidentally using their function.

And as a bonus of writing these tests,
we've implicitly defined what our input and output look like:
323
we expect a list of pairs as input,
324
325
326
327
328
and produce a single pair as output.

Something important is missing, though.
We don't have any tests for the case where the ranges don't overlap at all:

329
~~~
330
331
assert range_overlap([ (0.0, 1.0), (5.0, 6.0) ]) == ???
~~~
332
{: .language-python}
333
334

What should `range_overlap` do in this case:
335
fail with an error message,
336
produce a special value like `(0.0, 0.0)` to signal that there's no overlap,
337
338
339
or something else?
Any actual implementation of the function will do one of these things;
writing the tests first helps us figure out which is best
340
341
342
343
344
*before* we're emotionally invested in whatever we happened to write
before we realized there was an issue.

And what about this case?

345
~~~
346
347
assert range_overlap([ (0.0, 1.0), (1.0, 2.0) ]) == ???
~~~
348
{: .language-python}
349
350
351
352
353

Do two segments that touch at their endpoints overlap or not?
Mathematicians usually say "yes",
but engineers usually say "no".
The best answer is "whatever is most useful in the rest of our program",
354
but again,
355
356
357
358
any actual implementation of `range_overlap` is going to do *something*,
and whatever it is ought to be consistent with what it does when there's no overlap at all.

Since we're planning to use the range this function returns
359
as the X axis in a time series chart,
360
361
we decide that:

362
363
1.  every overlap has to have non-zero width, and
2.  we will return the special value `None` when there's no overlap.
364
365
366
367

`None` is built into Python,
and means "nothing here".
(Other languages often call the equivalent value `null` or `nil`).
368
With that decision made,
369
we can finish writing our last two tests:
370

371
~~~
372
373
374
assert range_overlap([ (0.0, 1.0), (5.0, 6.0) ]) == None
assert range_overlap([ (0.0, 1.0), (1.0, 2.0) ]) == None
~~~
375
{: .language-python}
376
377

~~~
378
---------------------------------------------------------------------------
379
AssertionError                            Traceback (most recent call last)
380
381
<ipython-input-26-d877ef460ba2> in <module>()
----> 1 assert range_overlap([ (0.0, 1.0), (5.0, 6.0) ]) == None
382
383
      2 assert range_overlap([ (0.0, 1.0), (1.0, 2.0) ]) == None

384
AssertionError:
385
~~~
386
{: .error}
387

388
389
390
Again,
we get an error because we haven't written our function,
but we're now ready to do so:
391

392
~~~
393
394
def range_overlap(ranges):
    '''Return common overlap among a set of [low, high] ranges.'''
395
396
397
398
399
    lowest = 0.0
    highest = 1.0
    for (low, high) in ranges:
        lowest = max(lowest, low)
        highest = min(highest, high)
400
401
    return (lowest, highest)
~~~
402
{: .language-python}
403

404
(Take a moment to think about why we use `max` to raise `lowest`
Azalee Bostroem's avatar
Azalee Bostroem committed
405
and `min` to lower `highest`).
406
407
We'd now like to re-run our tests,
but they're scattered across three different cells.
408
To make running them easier,
409
let's put them all in a function:
410

411
~~~
412
def test_range_overlap():
413
414
    assert range_overlap([ (0.0, 1.0), (5.0, 6.0) ]) == None
    assert range_overlap([ (0.0, 1.0), (1.0, 2.0) ]) == None
415
    assert range_overlap([ (0.0, 1.0) ]) == (0.0, 1.0)
416
    assert range_overlap([ (2.0, 3.0), (2.0, 4.0) ]) == (2.0, 3.0)
417
418
    assert range_overlap([ (0.0, 1.0), (0.0, 2.0), (-1.0, 1.0) ]) == (0.0, 1.0)
~~~
419
{: .language-python}
420

421
We can now test `range_overlap` with a single function call:
422

423
~~~
424
425
test_range_overlap()
~~~
426
{: .language-python}
427
428

~~~
429
---------------------------------------------------------------------------
430
AssertionError                            Traceback (most recent call last)
431
432
<ipython-input-29-cf9215c96457> in <module>()
----> 1 test_range_overlap()
433

434
<ipython-input-28-5d4cd6fd41d9> in test_range_overlap()
435
      1 def test_range_overlap():
436
----> 2     assert range_overlap([ (0.0, 1.0), (5.0, 6.0) ]) == None
437
438
439
440
      3     assert range_overlap([ (0.0, 1.0), (1.0, 2.0) ]) == None
      4     assert range_overlap([ (0.0, 1.0) ]) == (0.0, 1.0)
      5     assert range_overlap([ (2.0, 3.0), (2.0, 4.0) ]) == (2.0, 3.0)

441
AssertionError:
442
~~~
443
{: .error}
444

sclayton29's avatar
sclayton29 committed
445
446
447
The first test that was supposed to produce `None` fails,
so we know something is wrong with our function.
We *don't* know whether the other tests passed or failed
448
449
450
451
because Python halted the program as soon as it spotted the first error.
Still,
some information is better than none,
and if we trace the behavior of the function with that input,
452
we realize that we're initializing `lowest` and `highest` to 0.0 and 1.0 respectively,
453
454
regardless of the input values.
This violates another important rule of programming:
455
*always initialize from data*.
456

457
> ## Pre- and Post-Conditions
458
>
Greg Wilson's avatar
Greg Wilson committed
459
460
461
> Suppose you are writing a function called `average` that calculates the average of the numbers in a list.
> What pre-conditions and post-conditions would you write for it?
> Compare your answer to your neighbor's:
Valentina Staneva's avatar
Valentina Staneva committed
462
> can you think of a function that will pass your tests but not his/hers or vice versa?
Greg Wilson's avatar
Greg Wilson committed
463
464
465
466
>
> > ## Solution
> > ~~~
> > # a possible pre-condition:
467
> > assert len(input_list) > 0, 'List length must be non-zero'
Greg Wilson's avatar
Greg Wilson committed
468
> > # a possible post-condition:
469
> > assert numpy.min(input_list) <= average <= numpy.max(input_list), 'Average should be between min and max of input values (inclusive)'
Greg Wilson's avatar
Greg Wilson committed
470
> > ~~~
471
> > {: .language-python}
Greg Wilson's avatar
Greg Wilson committed
472
> {: .solution}
473
{: .challenge}
Greg Wilson's avatar
Greg Wilson committed
474

475
> ## Testing Assertions
Ashwin Srinath's avatar
Ashwin Srinath committed
476
>
477
478
> Given a sequence of a number of cars, the function `get_total_cars` returns
> the total number of cars.
Ashwin Srinath's avatar
Ashwin Srinath committed
479
>
480
> ~~~
481
> get_total_cars([1, 2, 3, 4])
Ashwin Srinath's avatar
Ashwin Srinath committed
482
> ~~~
483
> {: .language-python}
Ashwin Srinath's avatar
Ashwin Srinath committed
484
>
Greg Wilson's avatar
Greg Wilson committed
485
> ~~~
Andy's avatar
Andy committed
486
> 10
Ashwin Srinath's avatar
Ashwin Srinath committed
487
> ~~~
488
> {: .output}
489
>
490
> ~~~
491
> get_total_cars(['a', 'b', 'c'])
Ashwin Srinath's avatar
Ashwin Srinath committed
492
> ~~~
493
> {: .language-python}
Ashwin Srinath's avatar
Ashwin Srinath committed
494
>
Greg Wilson's avatar
Greg Wilson committed
495
> ~~~
Andy's avatar
Andy committed
496
> ValueError: invalid literal for int() with base 10: 'a'
Ashwin Srinath's avatar
Ashwin Srinath committed
497
> ~~~
498
> {: .output}
Ashwin Srinath's avatar
Ashwin Srinath committed
499
500
>
> Explain in words what the assertions in this function check,
Greg Wilson's avatar
Greg Wilson committed
501
502
> and for each one,
> give an example of input that will make that assertion fail.
503
>
504
> ~~~
Andy's avatar
Andy committed
505
> def get_total(values):
Greg Wilson's avatar
Greg Wilson committed
506
>     assert len(values) > 0
Andy's avatar
Andy committed
507
508
509
510
511
512
>     for element in values:
>     	assert int(element)
>     values = [int(element) for element in values]
>     total = sum(values)
>     assert total > 0
>     return total
Greg Wilson's avatar
Greg Wilson committed
513
> ~~~
514
> {: .language-python}
Greg Wilson's avatar
Greg Wilson committed
515
516
517
518
>
> > ## Solution
> > *   The first assertion checks that the input sequence `values` is not empty.
> >     An empty sequence such as `[]` will make it fail.
Andy's avatar
Andy committed
519
520
521
522
> > *   The second assertion checks that each value in the list can be turned into an integer.
> >     Input such as `[1, 2,'c', 3]` will make it fail.
> > *   The third assertion checks that the total of the list is greater than 0.
> >     Input such as `[-10, 2, 3]` will make it fail.
Greg Wilson's avatar
Greg Wilson committed
523
> {: .solution}
524
{: .challenge}
Greg Wilson's avatar
Greg Wilson committed
525

526
> ## Fixing and Testing
527
>
Greg Wilson's avatar
Greg Wilson committed
528
> Fix `range_overlap`. Re-run `test_range_overlap` after each change you make.
Greg Wilson's avatar
Greg Wilson committed
529
530
531
532
533
534
535
>
> > ## Solution
> > ~~~
> > import numpy
> >
> > def range_overlap(ranges):
> >     '''Return common overlap among a set of [low, high] ranges.'''
Nicola Soranzo's avatar
Nicola Soranzo committed
536
> >     if not ranges:
Nicola Soranzo's avatar
Nicola Soranzo committed
537
> >         # ranges is None or an empty list
Nicola Soranzo's avatar
Nicola Soranzo committed
538
> >         return None
Damien Irving's avatar
Damien Irving committed
539
540
> >     lowest, highest = ranges[0]
> >     for (low, high) in ranges[1:]:
Greg Wilson's avatar
Greg Wilson committed
541
542
> >         lowest = max(lowest, low)
> >         highest = min(highest, high)
Nicola Soranzo's avatar
Nicola Soranzo committed
543
> >     if lowest >= highest:  # no overlap
Greg Wilson's avatar
Greg Wilson committed
544
545
546
547
> >         return None
> >     else:
> >         return (lowest, highest)
> > ~~~
548
> > {: .language-python}
Greg Wilson's avatar
Greg Wilson committed
549
> {: .solution}
550
{: .challenge}
Maxim Belkin's avatar
Maxim Belkin committed
551
552

{% include links.md %}