0002.d Decomposition and functions
Programs that handle data, like the problems they solve, soon become too large to hold in mind all at once. This section works at two levels of granularity. At the level of the whole problem, decomposition divides the work into parts and offers two directions for doing so. At the level of a single part, the function is the unit that holds a piece of the work, so the section then asks how to use a function someone else wrote and how to write one well. These levels depend on each other, because a decomposition is only as useful as the boundaries between its parts.
Dividing the problem#
Decomposition means breaking a problem into smaller sub-problems that can be solved, tested, and understood separately.12 Its practical value shows up when something goes wrong. A student who writes a report program as one 200-line block has to search all 200 lines for a mistake, whereas one who wrote separate parts for reading, removing the lowest score, averaging, and sorting can check each part alone. A bug reported in an online store's discounts can be found and fixed in the discount function, with tax and payment left alone. Divide by sub-problem, not by length. Splitting a program into two parts of equal size, or labeling sections of one long block with comments, gives you nothing that can be tested on its own, and neither does a separate function for every individual assignment or case. A good first step for a tic-tac-toe program is a list of its sub-problems: showing the board, checking a move, and detecting a win.
You can work through a decomposition in either of two directions. Top-down design begins with the whole problem and divides it into parts, often as named placeholders whose details come later:
01 function main()
02 /* read scores */
03 /* compute average */
04 /* print report */
Bottom-up implementation builds and tests small functions and then combines them, as when someone unsure how to display a chess board starts with a function that checks whether a single move is legal.32 Working pieces can be verified early, though they still have to fit a larger design. The two approaches complement each other, and most projects use both, planning from the top and building from the bottom. What connects them is agreement on shared interfaces. A team whose members write functions in isolation can reach the end and find that one function's outputs don't match another's inputs, which an overall plan and agreed interfaces would have prevented.
The function as a unit#
Each part of a decomposition usually becomes a function. A function packages a task under a name, with inputs and an output, and this reduces duplicated code, so a correction made in one place applies wherever the function is called. If the same tax calculation appears in six places and the rate changes, a programmer who updates only five has introduced a bug. A function with price as a parameter removes that possibility in a way that a reminder in a comment can't. Moving code into functions doesn't necessarily make it run any faster, since the benefit is to the people who read and change it.4 It also isn't always worthwhile: wrapping one obvious line used in one place adds a level of indirection with no gain in reuse or clarity.
Many of the functions a program calls aren't written by its author at all. Third-party libraries save time and handle hard details, such as date parsing or payments, including edge cases others have already tested. In exchange, they add dependencies, meaning that your project now relies on outside code you don't control. Choose libraries that are well maintained, appropriately licensed, and limited to what you need. Recent releases and prompt responses to security reports are better evidence of maintenance than a long feature list or a polished website, and if you need one simple function from a very large library, a small package or a few lines of your own may be the lower risk. Authors can remove or change their libraries, and a program can stop building when that happens. You also still need testing, since using someone else's code doesn't remove your responsibility for whether your program works.
To use such a function, you read its signature and documentation. You need to know its purpose, its arguments and their types, and what it returns, and you don't need to know how it is implemented. The header gives the number, order, and types of the arguments. In this pseudocode the header doesn't state the return type, so the documentation supplies it, as in "round_to(float value, int digits) returns a float." From that line alone, you can see that round_to(2, 3.14159) has its arguments in the wrong order, and that assigning the result to an int variable is a type error. Documentation is also what tells you about behavior you can't infer from types. If it says a function returns a new array, you can rely on the original array being unchanged. And if the implementation changes but its signature and documented behavior don't, calling code still works, which is the idea at the center of the next section.
Reading a signature is the view of a function from outside. Writing a good one requires thinking about the inside, and two habits matter most. The first is one responsibility: a function should do a single job and have a clear name. Functions that do many things are harder to test and reuse, and a name with several "and"s, such as check_and_fix_and_print_grade, suggests splitting. If save_user validates an email address, writes a record, and sends a welcome message, a bug in validation breaks all three, while separate functions could be tested alone. A function that returns a value is easier to test than one that prints, because a test can compare the returned value directly. Hidden side effects are a further source of bugs: a function named get_total that also empties the shopping cart does something no caller would expect from its name.
The second habit is to pass values in through parameters and hand results back with return values, which makes a function independent of its surroundings. Compare two functions that do the same arithmetic:
01 int total = 10
02
03 function triple_total()
04 total = total * 3
05
06 function triple(int n)
07 return n * 3
triple_total() works on one particular global variable, so it can't be used for anything else, and it changes that variable for the rest of the program. triple() works on any int, and you can see everything it depends on in its header. Relying on global variables creates hidden dependencies, as when a test of an interest calculation passes only if the global rate happens to have been set first; passing rate as a parameter makes the dependency visible. A function that prints instead of returning leaves its caller with nothing to store, so int result = add(7, 8) needs return inside add(), and output won't do. Assigning to the caller's variable from inside the function doesn't work either. And because changing a parameter doesn't change the caller's variable, a caller has to store a returned value in order to keep it.
When a function has a clear job, a clear signature, and no hidden connections, you can use it knowing only what it promises. That ability has a name, and the next section takes it up.
- decomposition: breaking a problem into sub-problems that can be solved, tested, and understood separately
- top-down design: starting with the whole problem and dividing it into parts
- bottom-up implementation: building and testing small functions and then combining them
- third-party library: code written by others that a program uses
- dependency: outside code that a project relies on
- signature: a function's name and its parameters' types and order
- side effect: a change a function makes beyond returning a value, such as changing shared data or printing
- parameter: a named input to a function
- return value: the result a function hands back to its caller
Practice: practice questions for 0002.d
-
MWC1, Typeface: decomposition and debugging in a problem set. ↩
-
MWC1, Number Words: top-down planning, bottom-up implementation, and types. ↩ ↩2
-
MWC1, Scatter: top-down and bottom-up problem solving, conditionals, return values, minimums and maximums, and plots. ↩
-
MWC1, Names: variables, values, and functions with arguments. ↩