CSE 331: Whole program assertions
Course motto: Your job is to deliver code that you know works.
What does this program do?
The program below is just twenty-two lines long. Read it carefully before you read anything else. Then answer one question: what does it do?
Your answer should describe what a person who runs the program sees, not what each line does, as you would tell a friend who needed to run the program. Write your answer down. As we go through the notes for this lecture we are going to hold your "user manual" to a high standard you may find surprising. Since the code here is fixed, your description is "right" only if it is true on every run of the program. To figure that out, you have to have some way of coming to know the code will behave as the user manual describes (again, on every run of the program!).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
The comments marked (1) and (2) do nothing; they mark two places
we will come back to.
To run the program yourself, save it as Sum.java, then compile
and run it in a terminal:
javac Sum.java
java Sum
Type some numbers and then end the input, with Control-D on macOS and Linux or with Control-Z and Enter on Windows. One run looks like this:
Enter some numbers.
3 4 5
Got [3, 4, 5]
The sum of your list was: 12
Every run
Most readers answer with something like:
It reads a list of numbers from the user and prints them and their sum.
For the single run above, that answer is exactly right. Whether it is right about the program in general is a different question.
A run of a program is a single execution of it, from the first
line of main to the exit, on one particular input. The run above is
one example. If instead we run again and type 1 2 3, that would
give another. Typing nothing at all would give a third example run.
In general, a program that reads input has as many runs as there are things a user might type, which is to say infinitely many possible runs! Our "user manual" sentence is a claim about every possible run. A single run of the program can only confirm the claim for that run alone. On the other hand, a single run on which the "user manual" sentence is false refutes the claim. Such a run is a counterexample: it shows that the claim does not hold.
OK, so let's try a run that our "user manual" sentence did not have in mind. Try typing a word among the numbers:
Enter some numbers.
1 2 three 4
Got [1, 2]
The sum of your list was: 3
The user typed four things, and the program read two of them,
printed those two with their sum, and said nothing about the rest.
Our sentence never mentioned this possibility, and a user who typed
three by accident and got a sum of 3 would probably feel misled.
Next, try typing nothing and ending the input immediately. Now the
program prints Got [] and a sum of 0. Our "user manual" sentence is
true of this run, if we grant that an empty list has a sum of 0.
Finally, let's try typing two numbers that are each unremarkable on their own:
Enter some numbers.
2147483647 1
Got [2147483647, 1]
The sum of your list was: -2147483648
The two numbers add to 2147483648, but the program printed a negative
number! A Java int holds only the whole numbers from \(-2^{31}\) to
\(2^{31} - 1\), that is, from -2147483648 to 2147483647, and an
addition that passes the top wraps around to the bottom. Our sentence
promised the sum, and on this run the program did not deliver it.
What happened here happens for most of us on most programs. The "user manual" we write after a first reading is true of the runs we pictured while writing it and silent or wrong about the runs we did not. Unfortunately, the runs we did not imagine are where programs tend to fail once other people start using them. If your job is to deliver code that you know works, that habit is the first thing standing in your way: you cannot know that a program works until you can say, for every run, what working means.
The specification
The overflow run refutes our "user manual" sentence (i.e., it shows
that it is false, that it does not actually hold). We could change
the program, so that it reads long values (to avoid overflow on the
earlier example), or stops with a message at the first word, or
refuses input whose sum would overflow. None of those would make our
sentence true, though, and each would need a different one. So for
now, we keep the program and repair the "user manual" sentence, which
means we need to add what it left out.
The program asks for numbers. It reads whole numbers between \(-2^{31}\) and \(2^{31} - 1\) from the user, in order, until the input ends or the next thing typed is not such a number; anything from that point on is ignored. It prints the numbers it read, then their sum, wrapped around into that same range if the true sum falls outside it.
That is longer and uglier than the sentence we began with, but it has the virtue of being true on every run.1
Check it against the four runs above: it says what happens on the
word, on the empty input, and on the overflow, and a user who read it
first would be surprised by none of them. Even the range clause is
needed, because typing 2147483648 alone makes the program read
nothing at all, that number being too large for an int, and the
sentence says so. The clauses about the range are unpleasant, and
their unpleasantness is information: they tell you that this program
is probably not the one you want, and they point at what to change.
Definition: specification
A specification says what a piece of code must do on every run, as seen from outside it. It says nothing about how the code does it.
For a whole program, outside means the user, and the specification is a precise version of the user manual.
On every run is the part our first version got wrong and our second version gets right.
Because a specification makes a claim about all possible runs of the program, a single run where it is false refutes it. If there are infinitely many possible runs (the common case), no number of runs where the specification happens to be true can establish it.
As seen from outside is why the specification never mentions
list, sum, hasNextInt, or line 15: a user manual is written for
someone who will never see the code, and a good one lets that person
predict the program's behavior without it.
Must is there because a specification does not describe what the code happens to do; it states what the code is required to do, and the code either meets that requirement or fails it. When we repaired our sentence above we were describing, and the result was a specification this program meets. More often the order is reversed: someone writes the specification first, and the program has to live up to it. Homework 1 will ask you to work in both directions.
What is true at a point
The specification speaks from outside the program: it must be written in terms a user can check.
In order to know that a program meets its specification, we have to
look inside it, at the state of its variables at various points
during execution. OK, so now let's step inside the code, to the
comment marked (1) on line 13. The loop has just finished, nothing
has been printed since "Enter some numbers.", and the program is
about to print list. What do we know about list at that moment?
We know quite a lot, and we know it without running anything.
Whatever the user typed, the loop took numbers from the input one
at a time, in order, and stopped at the end of the input or at the
first thing that was not an int; so at line 13, list holds
exactly the numbers the user typed before that point, in the order
they typed them. That is true on the run with 3 4 5, on the run
with three in it, on the empty run, and on the overflow run, and
it would be true on any run we have not tried.
A fact like that differs in kind from the specification. The user
cannot see it, since nothing has been printed yet, and the user does
not know that a variable called list exists. It is a claim about
the inside of the program, at one particular point. We call such
a claim an assertion. This one is true at that point on every run.
Definition: assertion
An assertion is a true or false claim about the program's state (the values of variables and memory) at a particular point in the program.
An assertion holds if the claim is true at that point on every run of the program. One run where it is false, and the assertion does not hold.
We write an assertion in double braces at the point it describes, so that the claim sits beside the code it is about:
// (1)
{{ list holds, in order, every number the user typed before
the input ended or something that was not an int appeared }}
System.out.println("Got " + list);
If you have ever written a comment like // list is sorted here to
remind yourself of something you were sure of, you have written an
assertion. Three things it is not, though:
- It is not Java: the program never executes the braces, and Java's
own
assertstatement, which does execute when assertions are turned on, checks a condition on the one run that reaches it. - It is not a print statement, which would likewise show you only what particular runs do.
- It is not a test, which checks the runs you chose to try.
An assertion, like a specification, claims something about all the runs at once, the ones you tried and the ones you did not.
Sum with assertions
Point (2) on line 19 sits just after the second loop. The same kind
of reasoning applies there. sum started at zero on line 15, and the
loop added each element of list to sum exactly once. So at
line 19, sum is the sum of the elements of list, with the same
qualification the specification needed: if the true sum does not fit
in an int, then sum holds the wrapped-around value instead.
That is the claim most people write at (2). However, it is not the
only claim that line 20 depends on. The sum the user sees on line 20
only makes sense as the sum of the numbers they were shown on
line 14. So a second claim belongs at (2): list still holds
exactly what it held at (1). This second claim holds, because the
summing loop only reads list and never writes to it. It is the kind
of fact that often goes unstated, because it seems too obvious to
state. But a program that changed list while summing it could print
two lines that do not agree with each other, so the claim is worth
writing down.
Here is Sum with both points filled in:
while (in.hasNextInt()) {
list.add(in.nextInt());
}
// (1)
{{ list holds, in order, every number the user typed before
the input ended or something that was not an int appeared }}
System.out.println("Got " + list);
int sum = 0;
for (int i = 0; i < list.size(); i++) {
sum += list.get(i);
}
// (2)
{{ sum is the sum of the elements of list, wrapped into the
int range if the true sum falls outside it }}
{{ list still holds, in order, every number the user typed
before the input ended or something that was not an int
appeared }}
System.out.println("The sum of your list was: " + sum);
Now let's set the specification beside the assertions. The
specification makes two promises about what happens after the prompt:
the program prints the numbers it read, and then the program prints
their sum (wrapped if need be). Line 14 keeps the first promise
because of the assertion at (1): line 14 prints list, and list
holds the numbers read. Line 20 keeps the second promise because of
the two assertions at (2): line 20 prints sum, sum is the
wrapped sum of list, and list still holds the numbers read. In
other words, the specification says what the user will see, and the
assertions say why the code delivers it.
Reasoning between assertions
We have now made two arguments about Sum. The first argued from
what the input loop does to the claim at (1). The second argued
from the claim at (1), through the summing loop, to the two claims
at (2). Both arguments have the same shape: a claim before a piece
of code, the code itself, and a claim after the code. This shape is
the pattern of nearly every argument about a program that we will
make this quarter.
{{ P }}
some code
{{ Q }}
Here is the rule. Suppose P holds at its point. Suppose also that
running the code from any state in which P is true ends in a state
in which Q is true. Then Q holds at its point. This rule is
powerful because of what it leaves out. To establish Q, we needed
only P and the few lines of code between P and Q. We needed
nothing about how the program arrived at P, because whatever
happened earlier is summarized by P. That is why a large program
can be understood a few lines at a time.
Our two arguments above were made in prose, at the level of "the loop added each element once." That level of argument is fine for a loop whose behavior is obvious. Making the argument precise, so that it stands up for any loop, is the work of the lectures on reasoning later in the course.
The edge of the program
Everything a program knows about the world arrives through its input. At the edge of the program, where the input comes in, there is no one to make promises. The person at the keyboard may type a word where the program wanted a number, or nothing at all, or paste in a whole file. They did not read the code, and they owe the program nothing. Therefore, a specification for a whole program must not begin with "assuming the user types numbers." Instead, it has to say what the program does on every input: the inputs its author hoped for and the inputs its author did not. That is why our specification had to mention the word, the empty input, and the overflow.
Sum's answer to a word is to stop reading and print what it has so
far. Other answers would be reasonable too: Sum could complain
about the word, or skip the word and keep going. What is not
reasonable is having no answer at all. Here is what having no answer
looks like. Suppose line 10 used hasNext, which checks only that
more input is waiting, instead of hasNextInt, which checks that the
next thing is an int. Now suppose the user typed a word:
Enter some numbers.
3 4 hello
Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:977)
at java.base/java.util.Scanner.next(Scanner.java:1632)
at java.base/java.util.Scanner.nextInt(Scanner.java:2297)
at java.base/java.util.Scanner.nextInt(Scanner.java:2251)
at Sum.main(Sum.java:11)
A stack trace is a message from the language to the programmer. It
shows the exception, the method that threw it, and the line in our
code that led there. To someone who can read the code, a stack trace
is the start of a repair. To the person who typed hello, it is none
of that. The stack trace does not say what went wrong in terms they
understand. It does not say what to do next. And it ends the run
without the output they came for. The stack trace reached the user,
as stack traces usually do, because the author never decided what
should happen on a word. The specification promised an answer, and
the user got a message meant for someone else.
We hold your programs to a standard all quarter: every input a user can produce gets a response the specification describes, and a stack trace is never that response.
How do you come to know
This lecture began by asking what a program does. It arrived at two kinds of claim about every run of the program: a specification, which speaks from outside the program, and assertions, which speak from inside it. What this lecture has not yet said is how anyone comes to know that such a claim holds. That question, more than any definition, is what the rest of the course is about.
There are three ways to come to know, and we have already used all three.
- Testing: choosing runs on purpose (the ordinary runs, and the
runs a user manual might forget) and checking each run against
the claim. That is what we were doing when we ran
3 4 5, then typed a word, then nothing, then a number at the top of theintrange. A run tells you what happened on that run, and nothing about any other run. Testing is how our first sentence was found wanting. - Tools: letting another program check yours. The compiler did
that when we ran
javac: its type checker looked at every run without running any, but it checked only that each value has the right type. - Reasoning: arguing from the code itself that the claim is
true on every run at once. That is what we did at
(1)and(2), when we said what the loops must have left behind.
Testing can refute a claim about every run and never confirm it. A tool can confirm a claim about every run, but only the one claim it was built to check. Reasoning can confirm a claim about every run, if the argument is right. An argument can be wrong, and a test that refutes the claim refutes the argument too. The lectures ahead take up testing first and reasoning after.
The first reading asks what it means to know that code works, and it offers three answers that do not agree with each other. This lecture gives you the vocabulary to read those three answers with.
Finally, let's return to the course motto: your job is to deliver code that you know works. This lecture has given each of its words an exact meaning.
- Your job means that the compiler will not do this for you. Neither will a teammate or a tool that wrote some of the code. The claim is yours, and so is coming to know that it holds.
- Deliver means the code goes to people who will type things you did not imagine. Those people are owed what the specification promises, not what you hoped the code would do.
- Know means having reasons, from tests, from tools, and from arguments, that the code works.
- Works means the code meets its specification, on every run and not only on the runs you tried.
Exercises
Each answer is folded under its question. Write your own answer first.
1. Go back to the sentence you wrote before reading past the program.
- Test your sentence against the runs in this lecture. Where is it wrong, and where is it silent?
-
Predict what
Sumprints for each of these two inputs, then runSumto check.3 4 52.5 3 -
Repair your sentence until it is true of every run you have seen. Only then compare your sentence with ours.
Answer
The first input gives this run:
Enter some numbers.
3 4
5
Got [3, 4, 5]
The sum of your list was: 12
Sum keeps reading across lines until the input ends. So a sentence
that says Sum reads a line of numbers is wrong about this run. The
second input gives this run:
Enter some numbers.
2.5 3
Got []
The sum of your list was: 0
2.5 is not a whole number, so the loop stops before it ever reaches
the 3. A sentence that says numbers where it means whole
numbers is wrong about this run.
However you word it, a repaired sentence has to say three things: which things are read; what stops the reading, and what becomes of the rest of the input; and what the two lines after the prompt hold, the wrapped sum included. Our specification says all of that, and yours may say it better.
2. Here are four statements about Sum. Which of them are
specifications, and which of those does Sum meet? For each
specification that Sum does not meet, give a run that shows it
does not.
- A. It prints the numbers it read, and then their sum.
- B. It prints the numbers it read, and then a whole number.
- C. It prints every whole number the user typed.
- D. It keeps the numbers in a list and adds them up in a loop.
Answer
A, B, and C are specifications, and Sum meets only B.
- A fails on the overflow run, where the true sum is positive and
the number printed is not. A is true on every other run of
Sumin this lecture, so A is easy to believe. - B is true on every run, because line 14 always prints the
numbers read and line 20 always prints an
int. - C fails on the run with
threein it, since that run never prints the4. C also fails on typing2147483648alone, since that run never prints2147483648. - D is not a specification at all, even though D is true of the
code. D says how
Sumworks rather than what a user sees. That makes D useful to someone reading the code, and no use to someone who never will.
3. Compare statement B from exercise 2 with the specification we
wrote for Sum. Which of the two tells a user more, and does that
make the other one useless? Then ask the same two questions of these
two assertions, both of which hold at point (2):
{{ sum is the sum of the elements of list, wrapped into
the int range if the true sum falls outside it }}
{{ if list is empty, then sum is 0 }}
Answer
Our specification tells a user more. Any program that meets our
specification also meets B, since the sum such a program prints is a
whole number. The reverse is not true: a program that prints the
numbers it read and then 0, every time, meets B but not our
specification. That does not make B useless. Everything B says is
true, and a user can count on all of it. B just says too little to
tell a working program from a broken one. On the other hand, saying
more is not always better. Our specification rules out a program that
reads long values, and it rules out a program that reports an
overflow instead of wrapping. You might well prefer either of those
programs to Sum. A specification written before the program could
leave room for both by saying less about numbers and sums outside the
int range.
The two assertions compare the same way. Whenever the first assertion is true, so is the second, since the sum of no elements is 0. But the second assertion says nothing at all about a list with anything in it. The second assertion is enough to explain why the empty run prints a sum of 0. It is not enough for line 20 to keep the specification's promise on any other run. The next lecture gives this kind of comparison a name, and Homework 1 asks you to make such comparisons yourself.
4. Change line 15 of Sum from int sum = 0; to int sum = 1;.
- Which of the three assertions in Sum with assertions still hold, and which do not?
- Does the changed program still meet our specification? If not, give a run that shows it does not.
Answer
- The assertion at
(1)still holds, since the change comes after that point. The assertion at(2)aboutliststill holds too, since the summing loop still never writes tolist. The assertion at(2)aboutsumdoes not hold: on every run,sumnow ends up one more than that assertion says, wrapped if need be. -
The changed program does not meet our specification. The smallest run that shows this is the empty run:
Enter some numbers. Got [] The sum of your list was: 1The changed program does still meet statement B from exercise 2, since it prints the numbers read and then a whole number. B cannot tell this broken program from the working one.
5. The rule in Reasoning between assertions takes a claim
before some code to a claim after it. Use the rule on the two lines
between point (1) and the summing loop:
// (1)
{{ list holds, in order, every number the user typed before
the input ended or something that was not an int appeared }}
System.out.println("Got " + list);
{{ ? }}
int sum = 0;
{{ ? }}
- What holds after line 14, and why?
- What holds after line 15, and why?
Answer
- After line 14, the claim at
(1)still holds, word for word. Line 14 printslistbut changes no variable, and a claim about variables stays true across a line that changes none of them. What did change is what the user has seen: line 14 kept the specification's first promise. No assertion about variables records that. - After line 15, the claim at
(1)still holds, since line 15 does not touchlist. Beside it there is now a new claim,{{ sum is 0 }}, since line 15 just setsumto 0. Together these two claims make up the claim before the summing loop. The argument for(2)rested on both of them when it said the loop started from zero.
6. Here is Sum changed to read long values. The
highlighted lines are the four that changed. A long has 64
bits where an int has 32, so a long holds the whole numbers
from \(-2^{63}\) to \(2^{63} - 1\), that is, from
-9223372036854775808 to 9223372036854775807.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
- Predict which runs in this lecture now go differently, then run the program to check.
- Write the specification of this changed program.
Answer
- Two runs change. On
2147483647 1, the program now prints the true sum, 2147483648. Typing2147483648alone is now read as a number, so the program prints it in the list and again as the sum. The word, the empty input,3 4 5, and the two inputs from exercise 1 all go as before. -
A specification for the changed program:
The program asks for numbers. It reads whole numbers between \(-2^{63}\) and \(2^{63} - 1\) from the user, in order, until the input ends or the next thing typed is not such a number; anything from that point on is ignored. It prints the numbers it read, then their sum, wrapped around into that same range if the true sum falls outside it.
Notice that the range clause did not go away. It only moved. Typing
9223372036854775807 1prints -9223372036854775808, and typing9223372036854775808alone reads nothing, just as2147483647 1and2147483648did in theintversion. A bigger type moves the limit further out, but the specification still has to say where the limit is.
7. Here are two more statements, each offered as a specification
for the hasNext version of Sum from The edge of the program.
For each statement, what does it promise a user who types a word, and
does the hasNext version meet it? Which of the two statements, if
either, does this course accept?
- E. The user must type only whole numbers between \(-2^{31}\) and \(2^{31} - 1\). The program asks for them, reads them until the input ends, then prints them and their sum, wrapped around into that range if need be.
- F. The program asks for numbers and reads whole numbers between \(-2^{31}\) and \(2^{31} - 1\) until the input ends, then prints them and their sum, wrapped around into that range if need be. If anything else is typed before the input ends, it stops there with a stack trace.
Answer
- E promises that user nothing. Every promise E makes depends on
the user obeying E's rule, so on a run with a word the program may
do anything at all and still meet E. The
hasNextversion meets E, stack trace and all. - F promises a stack trace, and the
hasNextversion keeps that promise. On a word, a decimal, or a whole number outside the range, the run ends with a stack trace right after the prompt. Every other run goes as F says. - Neither. Both are specifications that the program meets, and both fail the standard in The edge of the program. E leaves inputs a user can produce with no response described. F describes a response meant for a programmer. Meeting a specification is only half the job. The other half is having a specification worth meeting. Homework 1 holds your programs to the same standard.
Further reading
- Specifications. Barbara Liskov and John Guttag, Program Development in Java (2001). Chapter 3 specifies procedures much as this course does, and Chapter 9 judges specifications by how restrictive, how general, and how clear they are.
- Assertions. Robert Floyd, "Assigning Meanings to Programs" (1967), and C. A. R. Hoare, "An Axiomatic Basis for Computer Programming" (Communications of the ACM, 1969), the classic papers on attaching claims to points in a program and reasoning from each to the next. The P, code, Q shape above is Hoare's.
- Testing. Brian Kernighan and Rob Pike, The Practice of Programming (1999). Chapter 6 recalls Dijkstra's point that testing can show the presence of bugs but never their absence, which this lecture made about runs. It then turns to the boundaries of a program's input, such as an empty input or a single item, where bugs cluster.
-
Every run on a computer with the memory to finish it, that is. Give
Summore numbers than Java has room to hold, and it stops with anOutOfMemoryErrorand a stack trace. Specifications, ours included, leave such runs out, as they leave out the power failing: they describe what a program does on a computer that has what it needs. ↩