Overview of Errors and Exceptions in C#

Encountering errors is a natural part of the software development process. They can stem from various sources, including minor oversights, logic issues, or unexpected user inputs. How we address these errors can have a significant impact on the performance and reliability of our software.

This article delves into the fundamental concepts of errors and exceptions in C#. We will examine the different types of errors and the tools that C# provides to handle them. The art of exception handling serves as a safeguard for our code, ensuring that it responds seamlessly to unforeseen issues and maintains its operational integrity.

What are Exceptions?

In the context of programming, the term exception refers to an anomalous or exceptional condition that can occur during the execution of a program. Typically, an exception disrupts the regular flow of program execution and triggers a predefined exception handler.

This concept is grounded in the idea that every procedure—or method in object-oriented programming—has a specific set of conditions, known as preconditions, under which it should terminate "normally." When these preconditions are violated, an exception-handling mechanism enables the procedure to raise an exception, signalling that the expected conditions have not been met.

For example, in a previous article about arrays, we briefly discussed the occurrence of an exception when a program attempted to access a memory location beyond the boundaries of an array. The code snippet below shows an example of instructions leading to such an exceptional condition.

int size = // provided as input: 5
int index = // an array element to access, calculated: 8

// declare an array of 'size' elements: first index 0, last index 'size'-1
double[] values = new double[size]; 

values[0] = -1.1; // value stored in position 0 (first)
values[size - 1] = -8.7; // value stored in last position
values[index] = 4.557; // an error will be generated
values[2] = 3.4; // value stored in position 3 (will not be executed)

In the example, we assume the size variable that defines the number of elements of the values array is provided as user input and contains 5. The index variable is assigned the value 8, calculated in the second instruction based on a given logic implemented in the code.

It is important to note that for an array of size elements, the index size - 1 always identifies the last element. Therefore, the instruction values[index] = 4.557 attempts to assign the literal 4.557 to an element of the values array that does not exist—the last element of the values array in this example is associated with the index 4 (and the code is attempting to access an element with the index 8).

Since there is no code to handle this exception in the example—the exception handler—the program execution will terminate abnormally, and the following message will appear on the Console:

Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.

The particular type of exception in this example suggests that something might be wrong with the logic that calculates (and assigns to the index variable) the array position to be accessed or with the array size provided as user input. As such, there might be a fundamental error with the logic of the application that the developer should fix instead of handling the exception using the try-catch construct discussed later in the article.

Error Codes versus Exceptions

Another error-handling approach could be considered in the case when the value assigned to the index variable is (for some reason) also provided as an external input to the program, as shown below.

int size = // provided as input: 5

// declare an array of 'size' elements: first index 0, last index 'size'-1
double[] values = new double[size];

int index = // an array element to access, provided as input: 8
bool result; // stores the result of the index boundary check
double value = 4.557;

// values.Length is equivalent to size        
if (index < 0 || index >= values.Length)
{
    result = false;
}
else
{
    result = true;
    values[index] = value;
}

if (result)
{
    Console.WriteLine($"Value {value} added at index {index}");
}
else 
{
    Console.WriteLine("Error: Index is out of array boundaries");
}

// do more things

The previous code snippet has been enhanced for increased robustness by verifying the validity of the index content provided as input and used to access an array element. This updated code validates both the array size and the index being accessed, effectively preventing the occurrence of the IndexOutOfRangeException.

As in the previous example, we assume that size = 5 and index = 8, but this time they have both been received as user input. Therefore, potential errors are now related to the validity of that input rather than the logic used to determine the array position to be accessed. Including the conditional check if (index < 0 || index >= values.Length) now ensures that any problematic assignment will not be executed, preventing the previous exception from happening.

Taking this one step further, the instructions for validating the content of the index and size variables could be encapsulated in a method called WriteAt(double[] a, double v, int i). This method can be invoked whenever there is a need to add a value v to an array a at a specific index position i. The method should also return a status code indicating the outcome of the operation, possibly a boolean, as shown in the program snippet. This status code can be checked by any other method calling WriteAt.

Error Codes

Having methods return a value to signal the success or failure of an operation is a commonly adopted approach for a program to identify different types of errors. However, this strategy places the responsibility on developers to be watchful if they want to catch all errors, as it requires checking the return value for every operation. While this approach is possible, it can make the code less transparent and more challenging to maintain because extensive error checking can overshadow the logical flow of what should happen when everything goes smoothly.

To tackle this concern, C# and many other high-level programming languages provide a prevalent error-handling mechanism centred on the concept of exception discussed earlier. This enables a degree of separation between error-handling logic and the code that tries to perform the task.

Exceptions

Instead of disregarding the possibility of an exception, as seen in the first code example, a programmer is encouraged to catch and handle exceptions, leveraging them to regain control over the program execution rather than allowing the program to terminate prematurely.

Not only do exceptions prevent the above concerns related to error code checking, but they also serve as a means to communicate issues with situations where using a return code would be impractical, e.g., operations that may not be possible or may fail due to the current state of the world.

For instance, if a program attempts to read data from a file, the (code returned by the) method File.Exists may be used to confirm the file's existence. However, even after verifying the file's existence, the same program may still encounter errors indicating unexpected issues that require handling in the code, e.g., that file may become corrupted, inaccessible, or may have been deleted in the meantime. Therefore, attempting to access the file may raise an exception, which must be handled accordingly to prevent the execution of a program from being abruptly terminated.

The remainder of the article will dive deep into the details of the exception-handling process.

Handling Exceptions

The first code snippet demonstrated the default outcome when a program fails to address exceptions: simply crashes. This serves as a reminder that exceptions cannot be casually ignored. When an exception is thrown, the Common Language Runtime (CLR) searches for code capable of handling the exception. The default exception-handling behaviour occurs only when no suitable handlers are found throughout the call stack. C#'s try and catch keywords are utilised to establish an exception handler.

try-catch Blocks

In C#, the try-catch block is the primary way to handle exceptions. Code that may throw exceptions can be enclosed within a try block, and the exceptions will be handled in one or more catch blocks. The code example below shows the basic structure of a try-catch block.

try
{
    // Code that might throw an exception
}
catch (ExceptionType ex)
{
    // Handle the exception
}

The try block of code is where an exception may occur. If an exception does occur while the program execution is in this block, the CLR will direct the control flow to the first appropriate catch block defined in the program.

A catch block specifies the type of exception it can handle within the parenthesis following the catch keyword. When an exception of the specified type is thrown, the corresponding catch block is executed. It is possible to have multiple catch blocks for different exception types to provide specific error handling for each case.

Exception Objects

The above code snippet also demonstrates another important concept related to exceptions. An identifier ex has been added after the type of exception (ExceptionType) handled by the catch.

Essentially, every exception is an object that a program can accessex is a reference type variable that references the generated exception object. As with every object, exceptions have methods and attributes—or properties in the C# specific jargon. Like the Length property of arrays, exceptions have a Message property that contains a string describing the reason for the exception and can be accessed when that exception is handled.

Exception Handling Example: Convert.ToInt32

In previous articles in this series, we demonstrated the use of Console.ReadLine() in combination with Convert.ToInt32() (or Convert.ToDouble()) to collect and store user input as int (or double) values. However, the examples assumed that the provided input was always valid, which is not necessarily the case.

To illustrate, consider the following code snippet:

Console.Write("Enter an integer: ");
string userInput = Console.ReadLine();
int number = Convert.ToInt32(userInput);

When a user enters a value like 56.7, which is a valid floating-point format but not suitable for an integer, the program will throw an exception and then suddenly terminate:

Unhandled exception. System.FormatException: The input string '56.7' was not in the correct format.

This FormatException exception does not indicate a flaw in the program's logic; it simply reflects the input provided. To handle such errors gracefully, a try-catch block should be used when calling Convert.ToInt32() (or similar conversion methods). The following code snippet shows how catching the generated exception can effectively handle the above error condition.

Console.Write("Enter an integer: ");
string userInput = Console.ReadLine();
try
{
    int number = Convert.ToInt32(userInput);
    Console.WriteLine("You entered: " + number);
}
catch (FormatException)
{
    Console.WriteLine("Format Exception. Please try again.");
}

The above error handling is limited to showing a message on the screen to inform the user about the format of the provided input not being valid. However, a more advanced exception-handling strategy could be based on implementing a loop whereby the user is asked to re-input the data until a valid value is provided. This is shown in the code snippet below. Moreover, inside the catch block, ex is now used to access and print on the screen the message explaining the reason for the exception based on the Message property of the generated exception.

bool isValidInput = false;
while (!isValidInput)
{
    Console.Write("Enter an integer: ");
    string userInput = Console.ReadLine();
    try
    {
        int number = Convert.ToInt32(userInput);
        Console.WriteLine("You entered: " + number);
        isValidInput = true;
    }
    catch (FormatException ex)
    {
        Console.WriteLine("Format Exception: " + ex.Message + " Please try again.");
    }
}

When executed, the above code commences with a user prompt for entering an integer within a loop that runs while the content of the isValidInput variable is false. After receiving the user's input, the code enters a try block where it attempts to convert the input to an integer using Convert.ToInt32.

However, if the input is a non-integer, a FormatException exception is triggered, directing the program to the catch block without executing the next two instructions within the try block. Inside the catch block, the program displays an error message derived from the exception.

Subsequently, the program gracefully loops back to the beginning of the user input prompt, allowing repeated attempts. Execution continues this way until a valid integer is provided and the content of the isValidInput variable is true. At this point, the program displays the successfully converted value and exits the loop.

Multiple Catch Blocks

Exception handling in C# (and other programming languages) can use multiple catch blocks within a try-catch construct to handle various types of exceptions effectively. Each catch block handles a specific category of exceptions, allowing for tailored responses based on the type of exception encountered.

When an exception arises within the try block, the runtime system evaluates each catch block in sequence. It checks whether the type of the thrown exception matches the type defined in a catch block. If a match is found, the corresponding catch block is executed, and the program continues from the end of the last catch block. If no matching catch block is identified, the exception is passed up the call stack to be handled by a higher-level error handler.

Considering the Convert.ToInt32 example discussed above, it is known that the conversion operation can lead to an overflow exception if the input value is too large (or too small). To address this specific scenario, an additional catch block for OverflowException can be added to the previous code:

bool isValidInput = false;
while (!isValidInput)
{
    Console.Write("Enter an integer: ");
    string userInput = Console.ReadLine();
    try
    {
        int number = Convert.ToInt32(userInput);
        Console.WriteLine("You entered: " + number);
        isValidInput = true;
    }
    catch (FormatException ex)
    {
        Console.WriteLine("Format Exception: " + ex.Message + " Please try again.");
    }
    catch (OverflowException ex)
    {
        Console.WriteLine("Overflow Exception: " + ex.Message + " Please try again.");
    }
   catch (Exception)
    {
        Console.WriteLine("An unexpected error occurred. Please try again.");
    }
}

The above code also shows the good practice of including a catch block for generic exceptions (often just referred to as Exception). This catch block can capture any unanticipated issues that may arise and provide a fallback error-handling mechanism.

In the new code example, the first catch block is dedicated to handling FormatException. If a FormatException is thrown during the Convert.ToInt32 operation, this catch block takes action, delivering a specific error message.

Following that, the second catch block addresses OverflowException. When the input value is too large (or too low) and triggers an overflow, this block provides a custom error message.

Lastly, the catch block for Exception acts as a catch-all for unexpected exceptions not explicitly handled by the preceding catch blocks. It offers a generic error message and facilitates a more composed response to unforeseen issues.

Finally Block

In addition to try and catch blocks, C# provides the finally block to ensure that certain code is executed regardless of whether an exception occurred. The finally, block is typically used to guarantee that required cleanup operations are performed even if an exception is thrown. The following code snippet extends the previous one by introducing a finally block at the end of the try-catch construct.

bool isValidInput = false;
while (!isValidInput)
{
    Console.Write("Enter an integer: ");
    string userInput = Console.ReadLine();
    try
    {
        int number = Convert.ToInt32(userInput);
        Console.WriteLine("You entered: " + number);
        isValidInput = true;
    }
    catch (FormatException ex)
    {
        Console.WriteLine("Format Exception: " + ex.Message + " Please try again.");
    }
    catch (OverflowException ex)
    {
        Console.WriteLine("Overflow Exception: " + ex.Message + " Please try again.");
    }
   catch (Exception)
    {
        Console.WriteLine("An unexpected error occurred. Please try again.");
    }
    finally
    {
        Console.WriteLine("This code always gets executed, regardless of exceptions.");
    }
}

As said, the finally block usually contains code guaranteed to be executed, whether an exception is thrown or not. In this case, a message is just displayed to illustrate that the block is executed unconditionally.

In reality, the finally block primarily ensures that essential cleanup operations, such as closing files or releasing resources, are performed to maintain the program's integrity. This block helps maintain a predictable and stable state of an application, even when exceptions occur during its execution.