FREE COMPUTER SCIENCE STACK LEVEL · 2 OF 20

Programming & program design

How do you turn a problem into a correct, testable program?

The question this layer answers

How do you turn a problem into a correct, testable program?

Build programs you can reason about. This layer connects Functions, Recursion, Data abstraction, Mutation, Testing to the rest of the computing stack.

Build programs you can reason about

Learn to design data and functions together, trace execution, control mutation, and test contracts. Build small complete programs before studying how their implementations reach the machine.

How do you turn a problem into a correct, testable program?

This section is a sequence of worked studies. Each study asks you to reconstruct an example, solve a new problem, and check the assumptions behind your answer. Keep your derivations and code so you can revisit them without looking at the solution.

What you will work through

1. Values, control flow, and function contracts

Turn a specification into a small program whose behavior you can trace. Trace names, expressions, branches, and loop state. Write a function with an explicit input contract. Choose edge cases from the contract rather than guessing.

2. Recursion, stack frames, and termination

Follow recursive calls as separate computations with unfinished work. Trace separate local environments for recursive calls. Identify base cases and a decreasing measure. Compare recursion with an explicit work stack.

3. Mutation, aliasing, and ownership of data

Predict when a change is visible through more than one name. Distinguish rebinding from mutation. Trace shared references and shallow copies. Specify whether a function may mutate its inputs.

4. Abstract data types and representation invariants

Separate the promises of a data structure from the objects used to implement it. Specify operations independently of representation. State a representation invariant and abstraction function. Compare implementations using both semantics and cost.

5. Higher-order functions and composable behavior

Pass behavior as data and reason about closures without losing evaluation order. Write functions that accept or return functions. Trace a closure and its captured environment. Distinguish pure transformations from effects.

6. Objects, invariants, and substitutable behavior

Use objects to protect state and make interfaces predictable. Define an object invariant and preserve it across methods. Separate class-level behavior from per-instance state. Recognize when inheritance violates a caller contract.

7. Exceptions, files, and resource lifetimes

Handle expected failures while preserving useful errors and releasing resources. Distinguish failure categories and handle them at appropriate boundaries. Use context management to release resources. Separate parsing, validation, and side effects.

8. Debugging, tests, and falsifiable hypotheses

Use failures to locate causes and build evidence for a stated contract. Reduce a failure to a small reproducible case. Choose tests from boundaries and invariants. Separate a test oracle from the implementation being tested.

Before you begin

Arithmetic, comparisons, and the idea of an input and output. Each lesson names any additional prerequisites. Revisit the mathematics and programming sections whenever a calculation or execution trace becomes a barrier.

Evidence of understanding

Implement a small command-line program with a documented data model, explicit contracts, a test suite, and a failure-handling policy. Explain one defect you found by tracing state. A correct multiple-choice answer is only one kind of evidence. Preserve the intermediate steps, test an edge case, and explain why an attractive alternative fails.

Continue beyond the guided lessons

The primary readings provide fuller treatments and additional exercises. Work the section project after the guided problems, then return to a problem a few days later without notes. Use a failed attempt to identify the specific definition, assumption, or step that needs more practice.

Free Programming & program design lessons

Practice lab: Specify and test a bounded stack

About 35 minutes. Functions, lists, and exceptions.

A stack has capacity 2. push(x) adds x unless full; pop() removes and returns the most recent item unless empty. Failed operations leave the stack unchanged.

  1. Write a state table for push(A), push(B), push(C), pop(), pop(), pop().
  2. Implement the operations and test both failure cases.
  3. Explain how exposing the underlying list lets a caller violate capacity.
Hint

Record the list and return value after every operation.

Worked solution

States are [A], [A,B], [A,B], [A], [], []. The third push fails without mutation; the pops return B, A, then an empty-stack error. Keep the list private and expose only operations preserving length between zero and capacity. Test the state after an error, not only that an error occurred.

  • Demonstrate last-in-first-out order.
  • Preserve state after a rejected operation.
  • Test empty and full boundaries.

Change one assumption

How would the contract change if push silently discarded the oldest item?

Make program behavior explicit

These problems require a contract, executable reasoning, and a failing case. Run code locally after predicting the result. Preserve the counterexamples that expose a mistaken design.

1. Trace and property testing · allow 35 minutes

When two equivalent-looking pipelines disagree

Define encode(xs) to replace each maximal run of equal adjacent values with a pair (value,count). For [2,2,3,2,2], the answer is [(2,2),(3,1),(2,2)]. Write a one-pass implementation with no mutation of xs. State empty-input behavior and an invariant for the current run.

Propose three properties for testing it, including one that catches merging equal values across a different value. Then compare map(f,filter(p,xs)) with filter(p,map(f,xs)) for f(x)=x+1, p(x)=“x is even,” xs=[1,2,3]. Explain why a pipeline rewrite must specify which values its predicate sees.

Get a starting hint

Keep one unfinished run and a list of finished runs. For testing, write a decoder independently and check both reconstruction and maximality.

Compare your reasoning with the worked solution

Start with an empty output. For each x, append (x,1) if the output is empty or the last value differs; otherwise replace the final pair by (x,lastCount+1). Before each iteration, decoding the output yields exactly the processed prefix, each count is positive, and adjacent output values differ. This invariant establishes the postcondition at the end; the empty list encodes as [].

Useful properties are decode(encode(xs))=xs, the sum of counts equals len(xs), and adjacent encoded values differ. Reconstruction on [2,3,2] detects an implementation that incorrectly groups all equal values, while maximality detects [(2,1),(2,1)] for input [2,2]. Neither total counts nor membership alone is sufficient.

The first pipeline filters to [2], then maps to [3]. The second maps to [2,3,4], then filters to [2,4]. The expressions are not equivalent. A valid transformation can use a predicate composed with f when the original specification calls for testing transformed values; side effects, exceptions, and evaluation order also need explicit treatment.

Check your reasoning

  • State an invariant strong enough to prove reconstruction and maximal runs.
  • Include empty, singleton, alternating, and repeated-value tests.
  • Calculate both pipelines before proposing any rewrite.

3. Heap trace · allow 35 minutes

Explain a result that a shallow copy cannot fix

Predict this Python program without running it:

row = [0, 0]
a = [row, row]
b = a[:]
b[0][1] = 7
b.append([9, 9])

Draw the list objects and references after every assignment. Give the final values of row, a, and b. Implement a constructor for an independent 3-by-2 zero matrix. Explain whether [r[:] for r in a] is sufficient when cells themselves contain mutable lists. Specify the depth of independence your API promises.

Get a starting hint

Copying an outer list copies references to its elements. The two occurrences of row are two references to one object.

Compare your reasoning with the worked solution

row becomes [0,7]. Both elements of a refer to that same row, so a displays [[0,7],[0,7]]. b is a distinct outer list but its first two elements also refer to row; appending changes only b, which displays [[0,7],[0,7],[9,9]]. There are three list objects before the append: row, a, and b. The literal appended list creates a fourth.

Use [[0 for _ in range(2)] for _ in range(3)] to create three independent row lists. Integers are immutable, so sharing references to the integer 0 does not create a mutation problem. [[0,0]]*3 would reuse one row list.

Copying each row with r[:] is sufficient for independent row structure when cells are immutable. If a cell contains another mutable list, both copies still reference that nested list. A deep-copy policy, immutable cell values, or explicit ownership rule must be chosen. The correct answer depends on the declared data model; indiscriminate copying can also break intentional shared identity.

Check your reasoning

  • Track object identity separately from equal printed values.
  • Explain why append and nested assignment affect different objects.
  • State the promised depth of independence instead of calling a copy simply safe.

4. ADT design and counterexample · allow 45 minutes

Preserve an abstract queue across representation changes

Specify a FIFO queue with enqueue(x), dequeue(), and size(), including empty behavior. Implement it using two lists, incoming and outgoing: enqueue appends to incoming; dequeue pops outgoing, transferring all incoming elements by popping and appending when outgoing is empty.

Trace enqueue(A),enqueue(B),dequeue(),enqueue(C),dequeue(),dequeue(). State the abstract sequence represented by the two lists. Prove the transfer preserves that sequence. A proposed subclass silently drops the oldest element when full. Explain which base contract it violates, and design an explicit bounded-queue API instead.

Get a starting hint

The logical front is at the end of outgoing. Write the abstract queue as reversed(outgoing) followed by incoming.

Compare your reasoning with the worked solution

The queue contract is: enqueue(x) appends x to the abstract sequence; dequeue() removes and returns its first element, or raises an EmptyQueue error without mutation when the queue is empty; size() returns the number of stored elements without mutation. With two lists, size() is len(incoming)+len(outgoing).

The abstract sequence is reverse(outgoing)+incoming. After enqueuing A,B, incoming=[A,B],outgoing=[]. The first dequeue transfers to outgoing=[B,A], then returns A, leaving [B]. Enqueue C gives incoming=[C],outgoing=[B]. The next dequeue returns B without transferring; the last transfers and returns C.

When outgoing is empty, repeatedly popping incoming reverses its order in outgoing. Reversing outgoing in the abstraction function restores the original incoming order, so transfer changes representation without changing the abstract queue. Every element crosses lists at most once before removal, giving constant amortized operations even though one dequeue may perform a linear transfer.

If enqueue promises to retain every accepted element until dequeued, silently discarding the oldest violates that postcondition. A bounded API can return Full without mutation, block under a stated policy, or explicitly advertise drop-oldest semantics with a returned eviction. Each is a different contract. Inheritance does not by itself establish substitutability.

Check your reasoning

  • Give an abstraction function, not only a successful trace.
  • Separate amortized operation cost from the worst individual call.
  • Identify the exact postcondition broken by silent eviction.

5. Implementation and failure analysis · allow 45 minutes

Design failure behavior for a file transformation

A local command reads UTF-8 lines of nonnegative integers and writes their sum. The output must remain unchanged if any input line is malformed. Empty input is valid and produces 0. Whitespace-only lines are malformed. Design the stages and resource lifetimes.

Write tests for a malformed middle line, empty input, a very large integer, and an output-write failure. Explain why writing the running sum directly to the destination is unsafe. Distinguish an atomic rename within one filesystem from a guarantee that a power failure preserves the latest result. Do not claim crash durability without an explicit operating-system contract.

Get a starting hint

Validation and publication are separate stages. A temporary output can delay replacing the destination until the new result is complete.

Compare your reasoning with the worked solution

Open the input with a context manager, parse each stripped line with an explicit nonempty check, reject negative values, and accumulate using a type whose range satisfies the contract. Python integers support the very-large-integer case within resource limits. Only after successful parsing create a temporary file beside the destination, write the final value and close it, then replace the destination using a same-filesystem operation with documented semantics. On failure, preserve the original destination where the API permits and remove the temporary file.

Use a preexisting sentinel output in the malformed-middle-line test and assert it is unchanged. Empty input must produce 0. Inject a write exception instead of depending on a machine-specific permission accident. Assert resources close and temporary files are handled. Directly overwriting the destination before validation loses the old result even if parsing later fails.

Atomic replacement concerns which name-to-file state other observers can see. Durability across power loss additionally depends on flush operations, directory persistence, filesystem behavior, and hardware. A classroom implementation should state which guarantee it tests; successful close and rename alone are not a universal durability proof.

Check your reasoning

  • Keep malformed input from publishing a partial result.
  • Test failures deterministically and check cleanup.
  • Distinguish atomic visibility, durable storage, and arithmetic range.

Study the underlying methods

Section project: Build a file-backed study planner with a stable data contract

5 to 7 hours. Build a command-line study planner using Python’s standard library. Inputs are commands to add a task, list tasks, mark one complete, reopen it, and export a summary. Each task has a stable identifier, title, subject, optional due date in a declared format, and completion status. Store data as versioned JSON in a caller-specified local file. Outputs are predictable command responses and a summary grouped by subject. Separate parsing, domain operations, and persistence so the same domain functions can be tested without a terminal or filesystem. Reject malformed commands and invalid dates before mutating state. Write through a temporary file and replace the destination only after a complete valid serialization, while describing the limits of crash durability. Provide a documented policy for missing files, duplicate identifiers, unknown tasks, and corrupted storage. This project should demonstrate function contracts, collection choice, mutation discipline, resource cleanup, exceptions, and testing together. A graphical interface is optional; the core acceptance criteria concern correct behavior and recoverable user data. Treat due dates as calendar dates, not implied midnight timestamps, and state how undated tasks sort. Demonstrate that changing display order does not change task identity or completion state.

Milestones

  1. Write command syntax, task schema, storage version, and error policies.
  2. Implement pure or narrowly mutating domain operations and independent tests.
  3. Add file loading, validated serialization, and temporary-file replacement.
  4. Build the CLI adapter and a complete scripted study-planning session.
  5. Test invalid inputs and persistence failures, then write a short usage guide.

Acceptance criteria

  • Task identifiers remain stable through save, restart, completion, reopening, and export.
  • Completing a task twice has the documented idempotent result and does not create duplicate records.
  • An invalid date or unknown task identifier leaves both in-memory and persisted task state unchanged.
  • A malformed storage file produces a clear error and is not overwritten with an empty planner.
  • A simulated failure before file replacement leaves the previous valid destination readable.
  • Subject summaries count completed and total tasks consistently, including an empty planner and subjects containing only unfinished tasks.

Add a schema migration from an older storage version, preserving identifiers and producing a report of every transformed field.

Practise Programming & program design free →