Debugging a program is one of the activities that the programmers often do in their work. When your program suddenly stops working or crashes, your job is to trace the root cause and fix the bug that caused these unexpected behaviors.

When I was in early of my professional career as an iOS developer, I didn’t know when I should start or how to trace the bugs. I wished that there was an excellent guide to debugging my code.

Over my career, I learned some techniques and tips about debugging skills.

Accept it’s probably your fault

In the past, I tended to deny my responsibility when I got an error report. I was like (now I sometimes still make this mistake) “oh, that is not caused by my code, my code is perfect”, “library X has a bug”, or “your device must be broken”. But in the end, the problem is often the code that I wrote a long time ago.

Experiment

The next thing I do after accepting probably it’s my fault is to understand the bug. I wonder these questions:

What’s happening?

What’s the expected result?

When does it happen?

When does it not happen?

What’s the difference in two scenarios?

After that, with my domain knowledge, I try to guess systemically at what could be breaking. I ask myself: “this variable is set to X where it should be Y”, “the exit condition may be wrong”. Then I start experimenting to check that guess.

Experiments could be changing or removing codes, trying new inputs, dynamically modifying the memory values with a debugger (LLDB), or adding some logs to get more information.

In reality, I often repeat these steps until I understand what’s going on.

Tip: My rule of thumb is that I only change one thing a time when experimenting to verify an assumption.

Check your assumptions

“Computers are good at following instructions, but not at reading your mind.” — Donald Knuth

I remembered someone told me: “The root cause of the problem is not the code. The code does exactly what you tell it to do. What causes the bug is your false assumptions”. This wisdom will stick with me for the rest of my life.

So you’d better make sure whether your assumptions are correct or not before moving to the next step.

Here is the list of some false assumptions:

The function does X.

The variable Y will be not be changed.

The code is not changed by someone else.

The documentation is correct.

These two blocks of code will run sequentially, not in parallel.

The behavior of a function is not changed when switching from a debugging build to a release build.

Add logs

What if, after these steps, you still have no idea what’s going on. This is a red flag that you don’t collect enough vital information. It’s time to add more detailed debugging logs.

In the past, my team got significant feedbacks every week from our users who told us that they had trouble when trying to upload images or videos using our iOS application. At this time, I was in charge of maintaining the corresponding code. While I was trying to figure out what was happening, I found that the existing log files were lacking information. So I put more detailed logs into our app to cover the possible scenarios I could find. After getting enough information, the case was solved, and the feedback has dropped by 80% (there are some cases caused by external factors such as bad network conditions).

Here is the list of the events we need to log:

The beginning of the flow.

The endpoint (success and failure) of the flow.

The critical turning points (if statement).

The early exit statement when a condition isn’t met.

Sometimes, I log events like this below example:

private func start (fileExists: Bool) { print( "START" ) if fileExists { print( "HERE-10" ) manager?.log(.downloadTask( "file already exists" , task: self )) if let fileInfo = try ? FileManager. default .attributesOfItem(atPath: cache.filePath(fileName: fileName) ! ), let length = fileInfo[.size] as ? Int64 { print( "HERE-30" ) progress.totalUnitCount = length } } else { executeControl() operationQueue.async { print( "HERE-40" ) self .didComplete(.local) } sessionTask?.resume() print( "HERE-50" ) } print( "END" ) }

It might look silly, but it works, especially when you debug multiple asynchronous tasks running in a concurrent queue. I know that I can use Xcode’s breakpoints to debug in runtime, but it’s less useful than just a plain log statement.

Fail fast

You need to be disciplined about immediately asserting the error or returning an error message instead of silently passing wrong data to another function. The danger of silently passing wrong data to another function is to cause a chain of unexpected behaviors that are impossible to predict. These unexpected behaviors could causes data loss and/or corruption which is worse than crashing!

if (SOMETHING_WRONG_HAPPENS) { ASSERT_OR_RETURN_AN_ERROR }

Debuggable code is code that is predictable, easy to modify and easy to explain. Undebuggable code could be code with hidden behavior, poor error handling. As the project grows in size, it is impossible to remember all the behaviors of your project. You must debug to understand the code you want to change, and it’s an exhausting process. Once you understand how the problem occurs, you might have to co-ordinate changes across several parts in order to fix the behavior.

Everyone agrees that it’s better to write a good code that’s easy to debug than writing unnecessary over-engineered clean code.

Good code isn’t necessarily a clean code. Clean code is more about how much pride the developer takes in the code, rather than how easy it has been to maintain or change. @dan_abramov wrote an interesting post about why we should write good code.

Good code is:

Code that doesn’t try to make an ugly problem look good or a boring problem looks interesting.

Code with apparent behavior helps developers predict the outcome easily after changing it.

Define specific and descriptive error message

Commonly, we define a generic error message used for all errors. A generic error message means the code like this:

{ ABC.download( "https://httpbin.org/image" , to: destination).response { response in if response.error == nil { success() } else { throw GenericError.noNetwork } } }

If you deal with errors like that, you will end up with confusion, not knowing what happens. It’s easier to debug with a specific and descriptive error message than a generic one.

Apple provides a clear and detail how to handle errors in Swift. Follow this guideline, you should define error codes like this:

enum DownloadError : Error { case invalidSession case invalidRequest }

That’s all

I hope these tips and techniques could help you to have a better experience in debugging a program.

If you disagree about anything I said or did I miss anything, please feel free to let me know.