Throwing Exceptions In C#: How And When

Posted by : on (Updated: )

Category : C#

Introduction

Exceptions are an essential part of programming, used to notify us when something goes wrong in our code. However, they can easily be misused, as they are not the only mechanism available for signaling issues. In fact, as their name suggests, exceptions should be reserved for handling exceptional cases, not minor problems that may affect our program.

In this post, I will provide an introduction to exceptions, explain how to use them, and discuss when it is appropriate to use them and when it is not. I will also explore alternative ways to handle potential issues in our code before resorting to exceptions as a last option.

What Are Exceptions

Exceptions are created using the throw keyword. In C#, an exception is a type that, when an instance of it is “thrown” using the throw keyword, will terminate the program unless it is “caught” by a try/catch/finally block. Exceptions are used to signal that something unexpected has occurred in the code—something the code where the exception was thrown cannot handle. If the calling code cannot resolve the issue using a try block, the program will enter an invalid state and terminate.

Exceptions can be thrown by your own code, by code you use (such as the Base Class Library, or BCL), or generated by the runtime.

All exceptions, in addition to properties like StackTrace and Message, have a property called InnerException, which can contain another exception. This is useful when rethrowing exceptions to include more generic types or when using custom exceptions that inherit from the base System.Exception class. It allows the more specific exception to encapsulate the generic exception when rethrowing.

How To Use The Try/Catch Statement

A try block must be followed by one or more catch blocks and, optionally, a finally block. The try block attempts to execute a piece of code that may throw an exception. If an exception is thrown, instead of crashing, the program will attempt to execute the code in one of the catch blocks, which may either try to resolve the error or rethrow the exception. Rethrowing an exception can be useful if the programmer wants to log the error before the program terminates or if they want to pass the responsibility of handling the error to the calling code.

The catch block specifies which type of exception can be caught. This can be the base System.Exception class or one of its derived types. Catching the generic exception type can be helpful when a uniform solution applies to all exceptions or if the goal is simply to log the exception before the program crashes. If access to the exception’s properties is not needed, a catch block can be written without any parameters. Additionally, exception filters can be applied in a catch block using the when keyword. Here’s a try/catch block example:

try
{
    throw new ArgumentOutOfRangeException();
}
catch(ArgumentOutOfRangeException)   
{
   Console.WriteLine("This block will execute because is more specific when an ArgumentOutOfRangeException is thrown.");
}
catch(Exception ex)
{
    Console.WriteLine($"This block, will execute for all other exceptions: An exception was thrown: {ex.Message}");
}

The finally block is a section of code that the runtime always attempts to execute. It will run after the successful execution of the try block or after an exception is caught and handled by the catch block. Since the runtime ensures the finally block is executed, it will run even if a statement in the try block alters the program’s flow, such as a return statement. For this reason, the finally block is ideal for performing clean-up tasks or releasing resources.

Unobservable Exceptions

When tasks are not awaited for their result, any exception they throw is “silently swallowed,” and we may never observe it. This can occur, for instance, when using the Task.WhenAny method, where one task completes before another throws an exception.

C# provides the TaskScheduler.UnobservedTaskException Event to handle such cases. Here’s an example:

If we have the following code:

int foo = await run();

async Task<int> run()
{
  return await Task.Run(() => {
  throw new Exception("Exception!");  
  return 42;
 });
}

It will throw normally, but the following will not and the exception will be swallowed:

Task<int> foo = run();

async Task<int> run()
{
  return await Task.Run(() => {
  throw new Exception("Exception!");  
  return 42;
 });
}

This happens, because here we never unwrap the task to get its result. We can get notified of the exception like this:

TaskScheduler.UnobservedTaskException += 
   (s,e) => Console.WriteLine("Unobserved Exception!");

Task<int> foo = run();

Thread.Sleep(100);
GC.Collect();
GC.WaitForPendingFinalizers();
Console.ReadLine();

async Task<int> run()
{
  return await Task.Run(() => {
  throw new Exception("Exception!");  
  return 42;
 });
}

The above uses the GC.Collect() and GC.WaitForPendingFinalizers() statements to collect the finished Task, as well the Thread.Sleep(100) to give time to the task to execute the exception. If you run the above in release mode (it won’t work in Debug mode) then the event will fire and you will see in the console: Unobserved Exception!

When To Use Exceptions

After learning how to use exceptions, let’s now discuss when to use them. Exceptions are often misused for two main reasons:

  • They may be used to control program flow instead of relying on language constructs designed specifically for that purpose. This not only degrades performance, as exceptions are costly, but also reduces code readability, testability, and ease of debugging.
  • They can serve as a way for developers to shift responsibility for handling edge cases. Rather than addressing these issues directly, a programmer may use exceptions to pass the burden onto consumers of the code.

Don’t Use Exceptions To Control Flow

Exceptions are meant to handle exceptional cases, not to replace guard clauses. If a situation can be prevented from reaching an invalid state, it should be. Exceptions are not a substitute for managing incorrect variable values or patching flawed algorithm implementations. Bugs that arise from our own code should be addressed and resolved where they occur, within the logic of the algorithm itself.

For instance, a division by zero should be prevented by ensuring it cannot happen in the first place, rather than letting it occur and catching it with a try/catch block. In such cases, the flow of the program should be controlled by an if statement, not by an exception.

Don’t Use Exceptions To Avoid Responsibility

The same principle applies to using exceptions as a way to avoid responsibility. We are accountable for the state of our code, the implementation of its algorithms, and fixing any potential bugs. It’s also our responsibility to handle any edge cases that may arise as our program transitions through different states.

Exceptions should not occur for things within our control, such as allowing a variable representing an enemy’s hit points to go negative, or attempting to add an element to a custom collection that has reached full capacity.

Exceptions should be reserved for situations where an undesired outcome is beyond our control—such as reading from a faulty drive, losing an internet connection, or lacking the necessary privileges to write a file. Even in those cases, exceptions should be a last resort. Just as we are responsible for providing users with a seamless experience, we are equally responsible for ensuring the code we write is reliable for other developers.

An exception is not a way to tell other developers that we don’t want to handle edge cases and are passing the responsibility on to them. Instead, it communicates that we don’t have a solution for an exceptional case. If other developers using our code can address that specific issue, they should implement a try/catch statement to manage it.

Four Ways To Handle Exceptional Situations

Even if we don’t have a solution for certain problematic situations, exceptions should always be the last resort. There are four strategies we can use to address potential bugs, and we should implement them in order of least to most intrusive, with exceptions being the final option.

The first step in addressing a bug caused by an external source that we aren’t responsible for is to determine whether we can safely ignore it and continue running our program. For example, imagine we’ve implemented an algorithm that reads a value representing a car’s speed from an external sensor one hundred times a second. Every second, this algorithm calculates the average speed and writes the result. If one of those readings is obviously erroneous—say, a reading equal to the speed of light—we shouldn’t throw an exception. Instead, we can simply ignore that outlier and calculate the average using the other ninety-nine valid readings. While we should log the issue for developers to review, and check other variables to ensure they fall within acceptable ranges, we can still provide a meaningful result without interrupting the user experience.

If ignoring the erroneous value isn’t feasible, the second approach is to replace it with a default value. For instance, if we have a file containing two types of values—an integer representing the length of a sentence and the characters that make up that sentence—and the length is negative, throwing an exception isn’t the best course of action. We should log the error for developers and potentially alert users, but substituting the negative length with zero—thereby omitting the sentence—is a more appropriate solution. Exceptions can crash our program if they go uncaught, which is far worse than simply being unable to load the data representing that sentence.

If we cannot substitute the erroneous value with a default, and our program requires specific data, the next step is to revert the program to a previous state and attempt the operation again. For example, if we expect input to be a valid email address, we should prompt the user for the address again instead of throwing an exception when the input is invalid. The same applies to fetching data from the internet; even if that data is crucial for our program, a failure to retrieve it should not result in an exception that the consumer of our code has to catch. Instead, we should attempt the operation a few more times.

Of course, if none of these strategies yield the desired result, then we have no choice but to throw an exception. However, exceptions should be our last resort. They should only be used for truly exceptional cases where all other solutions have failed. Finding solutions to problems within our code should be our responsibility, and we shouldn’t transfer that responsibility to other programmers. Any exception carries the risk of crashing our program if it is not caught by the user of our code.

Conclusion

Exceptions are a valuable mechanism for signaling issues that arise from exceptional situations. However, they should not be misused to control the flow of a program or as an excuse for programmers to transfer the responsibility of handling errors to the users of the code. We can use try/catch/finally blocks to manage these exceptional situations or, at the very least, log the problem before our program crashes.

Any potential errors in our code should be addressed in a manner that minimizes disruption to the user experience. Exceptions not only impact performance but can also be highly disruptive, as they carry the risk of crashing the program.

Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: