Avoiding the Swamp

When faced with working programming assignments, many students, not having had the benefit of a lot of experience, resort to methods that look to them like shortcuts, but in reality lead into a swamp. They spend a staggeringly long time trying to slog through the swamp until they finally give up. Then, for the next assignment, many head out into the swamp again, with clean boots but no better a chance of success.

For those who want to avoid the swamp, here is how to do it. I hope to see the current group of students do much better than former students on this.

Solve the assignment

The programming assignments are designed to teach fundamental concepts.

If you don't bother to read the assignment and try to avoid precisely what the assignment is all about, you will receive little or no credit.

Follow the assignment.

Start early

If you start early, you will be willing to try the methods discussed below, and you will develop your software efficiently.

If you start late, you will decide that you do not have time to do those things, and that you have no choice but to try to run across the swamp, hoping not to get stuck in the mud. If the mud doesn't get you, the alligators will.

Design

Documentation is important

A certain faculty member who did not believe in documentation spent a long time writing a piece of software. Then he had to put it down for a few months over the summer. When he came back to it, he said that he could not understand any of it. He could not even reverse-engineer it. He had no choice but to throw it away.

Write contracts early

A common question I get asked is: "why doesn't my function work?"

Naturally, I ask what the function is intended to do. I can't help much without knowing that. The most frequent answer that I get is a description of the body of the function, line by line. But if that is what it is supposed to do, then it obviously works! The issue is, what do you want it to do?

Before you can write a definition of a function, you obviously need to know what the function is intended to do. If you know that, you should be able to write it down. So write it down.

Without contracts, you will find yourself spending time reverse-engineering what you wrote earlier. You will be surprised how much it helps to have clear, precise and correct contracts.

Use examples and pictures

Before you write any code, work out algorithms by trying them on examples. If your code uses data structures, draw pictures of those data structures. Trying to keep everything in your head is a huge mistake.

Walk through your algorithm on examples and see if it appears to work.

Use top-town design

When you are working out an algorithm, you often find yourself thinking "it would be nice if I had a function that did …" Top-down design tells you to work as if you have such a function.

When you are done, you will need to find or create the helper function(s). If you can find the helper function in a library, great. But if you can't, that is not a problem. Just write it!

The key points of top-down design are:

Put a harness on guessing

Guessing is an acceptable way to work on a problem as long as

  1. your guesses are intelligent, not wild, and
  2. you check whether your guesses are correct.

If you think of an idea for an algorithm that might work, check it out carefully. At a minimum, try it on some examples. Avoid confirmation bias; that is, don't coddle your algorithm by only looking at examples that are easy for it.

Coding

Develop incrementally: Successive refinement

Students typically imagine that the best way to solve a programming assignment is to write the entire thing and then to begin testing it. But doing that takes you into the heart of the swamp.

There will inevitably be many errors. It will be difficult to determine where in your program each error occurs. Sometimes, two errors work together to make it difficult to find either one.

To save yourself lots of time wandering around in the swamp, write your code in small increments. After each increment, test what you have, and don't move on until what you have works.

The most important thing about this successive refinement is that, when discover a mistake, it is usually in the part that you have just added. So you can localize your search for the error without much thought at all.

An increment should be something that is testable. Sometimes, that involves writing two or three functions. But keep increments as small as you reasonably can.

Testing

Hand simulate

When you have a tentative function definition, try running it by hand on a small example. Be critical. If it does not work, you want to find out.

A very common trap is to hand-simulate the code that you wish you had written rather that what you actually wrote. If you find yourself doing a hand simulation without consulting your code, you have certainly fallen into that trap.

Test thoroughly

Novices always test too little.

Experts know that it usually takes several tests to find mistakes. Production software undergoes thousands of tests.

You can't afford to do that level of testing, but you can certainly try 3 to 5 tests.

Always test boundary values. Those are inputs that have some extreme nature to them. For example, if the input is a nonnegative integer, be sure to test 0 (the smallest nonnegative integer).

Debugging

Diagnosis before cure

Your doctor always diagnoses your illness before trying to cure it. You are your software's doctor. If something is wrong with it, find out what before you try to fix the problem.

Especially when faced with a time crunch, students often start playing the lottery. Their reasoning goes like this: "I don't really want to understand what the problem is. I just want to fix it." So they try a random guess, changing something that might fix the problem.

Of course, all that does is move the software farther away from being right. It eats up time that you could have used to diagnose the problem, and you end up having destroyed anything that was correct. Find a way to keep yourself from doing that. It is a waste of time.

Turn on the lights

If you are having trouble diagnosing a problem, use traces. Make the program write out what is happening.

Never show raw data in a trace. Always say who's talking (the function where the trace is) and clearly label information that is shown. For example,

  printf("spotter: Trying to find %d\n", toFind);
Time spent making traces readable is well spent.

If there is important information, show it. A trace such as

  printf("I am here");
is worse than useless. (When I see that, I am reminded of a person I once knew who wrote "taken last thursday" on the backs of photographs.)

Use a debugger where appropriate

A debugger can be a useful tool in certain circumstances. It can quickly show you where any of the following are happening.

A debugger can also be employed for other errors, but be careful about that. It is tempting to single-step through the program. But the error can occur millions of steps in.