2.6 - Errors

Nobody is perfect and errors will inevitably occur. There are a couple different ways errors can appear, and some ways to prevent them. Errors are a natural part of programming, and if you haven't experienced any, you will. But don't feel bad, making mistakes and correcting them is a big part of the learning process. Don't immediately give up and search the internet or ask an AI for help, you'd only be taking the learning experience away from yourself. When you identify and correct a mistake, you are much less likely to make it again.

Compile-Time Errors

Compile-time errors are the best. Why? Because it happened very early into development and can quickly be identified and fixed. There are several ways compile-time errors can occur, and all of them should contain an informative error message explaining what went wrong and where.

A syntax-error is an error in how the language is used. It could be a missing semicolon. It could be you used single-quotes instead of double-quotes in 'hello world'. Maybe you didn't use any quotes and tried to call System.out.println(hello world);. The compiler doesn't know what you're trying to do.

Another type of compile-time error is a missing symbol. Maybe you tried to call System.out.printLine(), but printLine() cannot be found. In this case, the compiler knows that you are trying to invoke a method, but it does not exist where you said it did. Similarly, you could be trying to use a variable that doesn't exist. For example, you tried to print total, but the variable was actually named sum.

One really important compile-time error is when you have a type-error. Since Java is a strongly typed language, all types need to be compatible. As you continue learning, this will make more sense. But if you tried to store "hello world" (a text String) in an int (a number), these would be incompatible types.

With a compile-time error, your program never even gets the chance to run. You must fix the compilation errors and try [to compile] again.

IDEs like IntelliJ can detect any compile-time errors before you try to run the program. There will be a strong visual cue, such as a red-squiggly line under the error. This allows you to quickly spot and correct errors. If I were to forget a semicolon, I can immediately notice the red-squiggly and add one. If it's something more than a missing semicolon, you might have to hover over it and see the error message tooltip. As you begin to identify and correct these errors, you will quickly reinforce your brain and know what error you made without looking at the message.

Runtime Errors

Runtime-errors are almost the worst. They are more generally called bugs. Unlike compile-time errors, there is nothing stopping you compiling and running a program that can produce a runtime error. You will only find it after the program runs into the error (hence, runtime). Examples include a missing file, a network connection issue, or the dreaded null-pointer exception (NPE). Unhandled errors will cause your program to immediately terminate - there is no recovering (unless is gets handled).

One example we can see right now would be with System.out.println(1 / 0);. This produces a division by zero error. For obvious errors like this, IntelliJ will give you warning about them (usually with a yellow-squiggly).

Other errors, such as a missing file, are likely enough that the programmer is forced to handle them in some way. Most runtime errors are like this, as the others are much more preventable (such as not dividing by zero, but you can't guarantee the internet is connected). We will learn more about runtime errors (Exceptions), and ways to handle them, in %chapter #%.

Logic Errors

Logic errors do not cause compilation or fatal runtime errors, but cause incorrect outputs. This is the category where most bugs live. They cause issues with the program, but it continues executing. Two of the most common logic errors are off-by-one errors.

Suppose you were supposed to sort usernames in alphabetical order (case-insensitive), but you accidentally sorted it with case-sensitivity. Everything compiled and executed fine on your first try: Bravo, Charlie, Delta. Then, the next day you load the results and see Bravo, Charlie, Delta, alpha. Now you notice that there is a bug using case-sensitivity. Some people may (jokingly) call this a feature, but in our example it is definitely a bug. And we didn't even know about it until the dataset changed and we happened to look at it again. How many other bugs are still hiding?

This is why I would consider logic errors to be the worst. At least with a runtime error you know something went wrong, and approximately where. But, with a logic error, you need to use your best judgement and intuition to find the root-cause. Sometimes you might get lucky and find it within a few minutes, but other times it might take hours or days. Sometimes finding the bug is the easy part, but fixing it is hard part.

An example program containing a logic error is given below. The intended output was 2 + 2 = 4, but instead the program prints 2 + 2 = 22. While we haven't formally covered String concatenation (+), this error is caused by an order of operations issue. One fix would be adding parentheses around (2 + 2).

public class LogicError {
    public static void main(String[] args) {
        /*
         * Expected output: 2 + 2 = 4
         * Actual   output: 2 + 2 = 22
         */
        System.out.println("2 + 2 = " + 2 + 2);
    }
}

Fortunately, not all hope is lost. Someday you will learn how to test your code (automatically). While creating tests does not guarantee that the code is error-free, it can significantly reduce the surface area for problems. In our initial example, maybe you (or a teammate) recognized the edge case of uppercase/lowercase usernames, and added specific testing for it. Testing is an important part of software development, and identifying edge cases is an important part of testing.

Reading Error Messages

Seeing a big red error message can be scary, but many times it tells you almost exactly what went wrong. Some of the error information is not very important at this point, but will become more relevant when we have larger programs with multiple classes and methods.

Compilation Error Message

When you have a compilation error, it tells you a lot of information such as the line number and column number. In the example where we try to run System.out.printLine("hello world");, we receive this compilation error message:

C:\Code\sandbox\src\Main.java:6:19
java: cannot find symbol
  symbol:   method printLine(java.lang.String)
  location: variable out of type java.io.PrintStream

From this error message we can see

As our earlier example showed, printLine() is not a method (specifically within type java.io.PrintStream, but we will ignore this for now), it should have been println(). IntelliJ should also jump to the file that caused the compilation error and put your caret (cursor) at the error position.

Runtime Error Message

Runtime error messages are an exception stack trace. This allows you to see where the unhandled error was thrown, and what methods led to it. For now, that will usually just be the main method. Later we will have multiple methods in the stack trace.

Using the example code System.out.println(1 / 0);, we can see this error message:

Exception in thread "main" java.lang.ArithmeticException: / by zero
  at Main.main(Main.java:6)

This says:

Right now, we really only care about the message / by zero and the position Main.main(Main.java:6). In IntelliJ, we can click the Main.java:6 to jump to line 6 of Main.java, where we see the printing of 1 / 0.

Debugging

Debugging is a process that allows you to step through the source code and view the state of the program changing at the same time. IDEs like IntelliJ and Eclipse support debugging out of the box. Editors like VSCode do not provide as rich of an experience and also need to rely on plugins/extensions.

Debug Mode (IntelliJ)

In IntelliJ, debugging can be launched by clicking the bug icon next to the top-right play button. Alternatively, debug mode can be launched by clicking the play button in the gutter and selecting Debug 'FILENAME.main()'. The following image illustrates their locations. Follow step A to directly launch a debug configuration (Note in the example, Current File is selected. You may need to change this). Steps 1 and 2 show an alternative route, which may need to be used once before the configuration in Step-A updates.

image info

When you run the following code in debug mode, other than some extra output such as:

Connected to the target VM, address: '127.0.0.1:60561', transport: 'socket'
<Your print statements>
Disconnected from the target VM, address: '127.0.0.1:60561', transport: 'socket'

This is IntelliJ connecting/disconnecting from the local (127.0.0.1) debugger. These lines can be ignored, and only show up in debug mode. You might be wondering what the point of this is, since nothing else seems different. The debugger doesn't change the execution of the program, but it can pause it. We just haven't told it where.

Breakpoints

In order to tell the debugger to pause, we need to set a breakpoint. To do this in IntelliJ (and most editors), click in the left gutter of the source code on the line you want to pause at (or on the line number). You can add as many breakpoints as you want. Click the gutter again to remove a breakpoint.

image info

Then the code where the breakpoint is set will appear highlighted (your editor colors may vary):

image info

Now when you click Debug, you should see something like this:
image info
or
image info

If you don't see either of those, you can restore the default layout by clicking these buttons:
image info

In this view, we can see the Threads & Variables and Console tabs. There are also a lot of control buttons:
image info

  1. Rerun: Stop and Restart the program
  2. Stop: Stop the program
  3. Resume: Resume execution
  4. Pause: Pause the program wherever it is, regardless of breakpoints.
  5. Step over: Execute the current line of code.
  6. Step into: Step into a method's code.
  7. Step out: Resume execution and pause at the caller's next line.
  8. View Breakpoints: Edit current breakpoint settings
  9. Mute Breakpoints: Ignore/disable all breakpoints

Buttons 6 and 7 are useful for debugging methods, which we haven't learned to write yet.

Using the breakpoint we set on our println, click button 5, step over. This will execute the line, printing "Hello world" to console. But we are still paused since we are now at the end of main. Stepping-over once more will finish executing the example program.

Practice Exercise

Consider our example program from the whitespace section:

public static void main(String[] args) {
    System.out.println("Hello World"); // Space
    System.out.println("Hello\tWorld"); // Tab
    System.out.println("Hello\nWorld"); // Newline
    System.out.println("Hello\rWorld"); // Carriage Return
    System.out.println("Hello\r\nWorld"); // CRLF (carriage-return line-feed)
}

Rather than commenting off different lines, insert a breakpoint on the first print, and step-over each line to see what it prints. Your debug breakpoint should look like this (you only need one):

image info
As you step over each line, view the output associated with that statement.

As another good exercise, step through this code and watch the output:

public static void main(String[] args) {
    System.out.print("Hello");
    System.out.print("\r");
    System.out.print("World");
}