Zero to iOS Hero 3: Learn by building and debugging
This is part 3 of the Zero to iOS Hero series.
Fast learning is not the same as fast typing. The useful loop is prediction, experiment, observation, and explanation. A compiler diagnostic, failed assertion, breakpoint, log line, or tiny test gives the observation. Your explanation turns it into knowledge you can reuse.
This post uses one arithmetic bug because the domain is obvious. The debugging method is the lesson.
The feedback loop
Use the same loop for language questions, interface bugs, state races, persistence failures, and device-only behavior:
State a prediction | vChange one variable | vBuild or run the smallest proof | vInspect the first mismatch | vExplain the cause | vKeep a test or checkpointThe loop gets weak when several variables change at once. A new framework, copied architecture, network call, database, and animation in one experiment produce a failure with too many possible owners.
Start with a passing anchor
The program calculates the integer average of three readings:
Execution sends this source to the project runner. It uses Swift 6.3.3 on Linux for standard-library code, not the Apple SDK, an iOS simulator, or a device.
Compiler diagnostics
(none)
Standard output
(no stdout)
Standard error
(no stderr)
Expected output:
Average: 4The browser runner proves only the Swift standard-library path on Linux. The same source can be compiled with debug information on macOS:
swiftc \ -swift-version 6 \ -warnings-as-errors \ -Onone \ -g \ DebuggingLoop.swift \ -o debugging-loop
./debugging-loop-Onone keeps the unoptimized development shape. -g emits debug information LLDB can use to map machine execution back to source and variables.
Form a prediction before breaking it
The inputs are [2, 4, 6].
total = 2 + 4 + 6 = 12divisor = 3average = 12 / 3 = 4The prediction is precise: total should be 12, divisor should be 3, and observed should be 4.
Now introduce one fault:
let divisor = values.count - 1Nothing else changes. The faulty program calculates 12 / 2, so the assertion should report an observed value of 6.
Assertions turn assumptions into failures
The program records its expected behavior:
assert(observed == expected, "Expected \(expected), got \(observed)")Without the assertion, the program prints a plausible integer and exits successfully. With the assertion, the process stops near the violated assumption.
Use assertion families deliberately:
assert: A development check for an internal assumption. Optimized production builds can remove it.precondition: A requirement that callers must satisfy. It remains active in ordinary optimized builds.- Throwing error: A recoverable condition the caller can handle.
- Test expectation: A durable behavior contract run by the test suite.
An empty readings array is a caller contract in this example, so precondition guards it. The expected average is a learning checkpoint, so assert exposes the deliberate bug.
Read the failure before opening a debugger
Run the broken executable once outside LLDB:
./average-readings-brokenThe process should exit unsuccessfully and report the assertion message:
Expected 4, got 6That message already narrows the search. The inputs were accepted and the function returned. The mismatch is in the calculation or expectation, not program launch.
The debugger is useful when the message does not reveal which intermediate value became wrong.
Compile the deliberate failing variant
The companion lab keeps broken and corrected sources separate so the failure is reproducible:
swiftc \ -swift-version 6 \ -warnings-as-errors \ -Onone \ -g \ AverageReadingsBroken.swift \ -o average-readings-brokenAn intentional runtime failure can still compile cleanly. Compilation proves syntax and type correctness. It does not prove behavior.
Stop in LLDB
Launch the executable under LLDB:
lldb ./average-readings-brokenSet a symbolic breakpoint on the function and run:
(lldb) breakpoint set --name average(lldb) runLLDB stops when execution enters average. Step over the precondition and assignments:
(lldb) next(lldb) next(lldb) nextInspect the current frame:
(lldb) frame variable values(lldb) frame variable total(lldb) frame variable divisorThe decisive mismatch is:
total = 12divisor = 2The expected divisor was 3. The debugger has moved the problem from “the average is wrong” to one expression: values.count - 1.
Continue and let the assertion record the consequence:
(lldb) continueWhat a breakpoint actually does
A breakpoint asks the debugger to interrupt execution at a source location, symbol, exception, or condition. While the process is stopped, LLDB can inspect the active stack frame, variables, memory, threads, and expressions.
Useful commands for the first week:
| Command | Question it answers |
|---|---|
breakpoint set --name average | Can I stop when this function begins? |
run | What happens from a fresh process? |
next | What changes after the next source line without entering called functions? |
step | What happens inside the function called by this line? |
finish | What happens when the current function returns? |
frame variable | What values exist in the current frame? |
bt | Which calls led to this point? |
continue | What happens after this breakpoint? |
Commands are observations, not repairs. Change the source only after you can name the bad value and the expression that produced it.
Fix one line
Restore the correct divisor:
let divisor = values.countCompile and run the corrected checkpoint:
swiftc \ -swift-version 6 \ -warnings-as-errors \ -Onone \ -g \ AverageReadingsFixed.swift \ -o average-readings-fixed
./average-readings-fixedExpected output:
Average: 4The fix is small because the experiment isolated one variable. A large rewrite would erase the evidence that explains why behavior changed.
Keep the failure as a test
An assertion inside an executable is useful for a small experiment. Product behavior belongs in a test target once it needs to survive refactoring.
The future test shape is:
import Testing
@Test("Average uses every reading")func averageUsesEveryReading() { #expect(average([2, 4, 6]) == 4)}The function is small enough that this test names its contract directly. Later lessons add boundary cases, error behavior, asynchronous work, deterministic dependencies, and UI journeys at their lowest useful testing distance.
Compiler diagnostics are part of the loop
Not every experiment reaches runtime. Remove the closing quote from a string or pass a String where an Int is required and the compiler stops earlier.
Treat a diagnostic as structured evidence:
- Read the file and line.
- Read the primary message before its notes.
- Identify the expected and actual types or syntax forms.
- Fix the earliest owned cause.
- Compile again before changing anything else.
Do not paste ten diagnostics into a search box and accept the first unrelated fix. Later errors often describe damage caused by the first one.
Logs answer timeline questions
Breakpoints pause a process and can change timing. Logs are better when the question is event order, repeated work, or a failure that is hard to stop near.
A small command-line experiment can start with print:
print("average input count: \(values.count)")Apple platform apps later use structured logging with privacy-aware fields and subsystem categories. The rule stays the same: log the smallest fact that distinguishes competing explanations. Do not dump private user content or credentials.
Remove noisy temporary output after the cause is understood. Keep durable operational logs only when they answer a real support or production question.
Documentation answers contract questions
Search documentation after naming the missing contract.
Weak question:
Why is Swift broken?Useful questions:
Does assert run in optimized Swift builds?What does LLDB frame variable inspect?Which Xcode scheme action enables the debugger?Is this API available on the deployment target?Use the symbol’s Quick Help, Apple Developer Documentation, the Swift book, Swift Evolution proposals, and LLDB documentation. Record the API version or availability when the answer can change with the toolchain.
Small experiments beat copied applications
Copying a finished application gives you many lines that once worked together. It does not show which line owns the behavior you care about.
A small experiment has a tighter contract:
- One question.
- One changed variable.
- One expected result.
- One observation surface.
- One retained test or note.
When the small proof works, move the lesson into the product. If the product then fails, the integration boundary becomes the next variable to inspect.
A practical debugging ladder
Use the least expensive observation that can answer the question:
Read the source and diagnostic | vCompile the smallest unit | vRun a focused test or assertion | vAdd one log or breakpoint | vInspect the integration boundary | vUse Simulator or physical-device evidenceDo not start with a physical-device mystery when a standalone function already returns the wrong value. Do not stop at a standalone proof when the claim depends on signing, sensors, background behavior, or a platform service.
Checkpoint
The complete learning record for this bug is short:
Prediction: total 12, divisor 3, result 4.Change: divisor became values.count - 1.Failure: assertion reported expected 4, got 6.Inspection: LLDB showed total 12 and divisor 2.Cause: the calculation dropped one element from the divisor only.Fix: divide by values.count.Proof: corrected executable printed Average: 4.That record is more useful than “changed code until it worked.” It preserves the recognition pattern for the next off-by-one bug.
The orientation arc is complete. The next lesson starts Swift from first principles with values, variables, concrete types, and inference.
Series navigation
- Previous: Part 2: Xcode, simulators, devices, and Git
- Next: Part 4: Values, variables, types, and inference
- Series index: Zero to iOS Hero
References
- Swift failure contracts: The Swift book’s assertions and preconditions explains development assertions, preconditions, and when execution stops.
- Apple’s LLDB model: Apple’s LLDB debugging guide and breakpoint chapter describe execution control, state inspection, source breakpoints, symbolic breakpoints, and Swift error breakpoints.
- Current LLDB command behavior: The LLDB project’s tutorial and variable formatting guide document
run, stepping, backtraces, frame selection, expressions, and variable inspection.
Related topics
- Test-driven development, turning a reproduced failure into a durable development loop.
- Unit tests, keeping pure behavior checks close to the function.
- Swift coding problems, small exercises suited to prediction-first debugging.